gnome-nifti-preview

Architecture

Nautilus ──Space──▶ GNOME Sushi (gjs) ──loads──▶ viewer plugin (GJS)
   (D-Bus ShowFile)                                 │  legacy/niftiPreview.js   Sushi 46–50, GTK 3
                                                    │  v1/niftiPreview.js       Sushi 51+,  GTK 4
                                                    │  common/niftiPreview/     shared client + view state
                                                    ▼  Gio.Subprocess, stdin/stdout, JSON lines + raw pixels
                                       nifti-preview serve   (Rust, one process per preview)

a terminal ◀──escape sequences──▶ nitty   (Rust, backend/terminal/: the same library, linked in)

Why a Sushi plugin

Space in Nautilus always calls the D-Bus service org.gnome.NautilusPreviewer, which is GNOME Sushi. A standalone application could only be reached through “Open With”; replacing the service would mean reimplementing every other preview. Sushi loads user viewers from ~/.local/share/sushi/viewers/ (versions 46–50) and ~/.local/share/sushi/plugins-1/ (51+), so the plugin needs no patching.

Two facts about Sushi shaped the design:

Components

Backend (backend/, Rust, binary nifti-preview)

backend/ is also a Cargo workspace whose default member is this crate: cargo build there builds nifti-preview only, and the terminal viewer (backend/terminal/) shares the lock file and the release profile.

Viewers (sushi/, GJS)

MIME (mime/gnome-nifti-preview.xml) — image/x-nifti (*.nii, alias image/x.nifti as used by libmagic) and image/x-compressed-nifti (*.nii.gz, sub-class of application/gzip, like application/x-compressed-tar). The longest glob wins, so other .gz files keep their type. The sub-class is needed: from a per-user definition, *.gz and *.nii.gz both match and GLib breaks the tie with the content, a gzip stream. It also makes NIfTI files inherit the applications of gzip archives (Files would extract them), so when no NIfTI viewer is the default, install.sh hides those for image/x-compressed-nifti ([Removed Associations] in mimeapps.list; uninstall.sh restores them). That is not enough on GNOME: gnome-mimeapps.list makes Files the default application of application/gzip, and a default inherited this way is not removed by [Removed Associations]. So when it finds no viewer, the installer also writes a hidden launcher, gnome-nifti-preview-no-viewer.desktop (“a NIfTI viewer…”, running noViewer.js), and makes it the default: the preview’s button reads “Open With a NIfTI viewer…” and explains what to install. A viewer found by a later install replaces it.

Reference (reference/, Python + nibabel) — the first implementation of the backend, kept as a test oracle: CI checks that the Rust backend reproduces it (metadata, affine, plans, statistics, pixels). It also generates every test fixture. It is not installed for users.

Terminal viewer (nitty)

backend/terminal/ builds nitty, which links the library: every format, orientation, window, palette and label colour comes from it; nitty only puts its RGB8 slices on a terminal and handles the interaction. It depends on libc and serde_json, both already in the lock file (memmap2, the library): no TUI crate, no terminfo.

Data flow of one preview

  1. Sushi constructs the viewer; the window appears immediately with a spinner.
  2. The client spawns nifti-preview serve and writes, without waiting: open, load, then one slice per view once open has answered.
  3. open parses the header only (≈0.1 ms) → view geometry and edge labels.
  4. load reads volume 0 (read, mmap, or inflate; all the data for spectroscopy) and computes the window and the label detection (on a 1 M-voxel strided sample, three threads). The metadata panel is filled once, now, complete; the slices come right after. For a 4D image the client then asks for the time course of the cursor voxel, and set_volume when the slider moves (asked again while a volume is being decompressed, see PROTOCOL.md).
  5. Each slice is rendered at native resolution (≈1 ms) and sent as raw RGB; the viewer wraps it in a pixbuf/texture without copying it again and scales it with the physical voxel aspect ratio.
  6. Scrolling and clicking move the cursor; only the latest wanted slice of each view is requested. The value under the cursor is fetched the same way.
  7. Closing the preview sends close, closes stdin, and kills the process if it is still alive 1.5 s later.

Installation layout

~/.local/share/gnome-nifti-preview/bin/nifti-preview
~/.local/share/gnome-nifti-preview/gjs/niftiPreview/{backendClient,viewState,settings,spectrumPlot,noViewer}.js
~/.local/share/applications/gnome-nifti-preview-no-viewer.desktop   (only without a viewer)
~/.local/share/gnome-nifti-preview/{manifest.txt,state}
~/.local/share/sushi/viewers/niftiPreview.js        (Sushi 46–50)
~/.local/share/sushi/plugins-1/niftiPreview.js      (Sushi 51+)
~/.local/share/mime/packages/gnome-nifti-preview.xml
~/.local/share/thumbnailers/gnome-nifti-preview.thumbnailer   (thumbnails, when enabled)
/usr/local/bin/gnome-nifti-preview-thumbnailer                 (the backend; only with --thumbnails or a yes)

The terminal viewer is installed on its own, by install.sh --terminal or get.sh --terminal: one file, $XDG_BIN_HOME/nitty or ~/.local/bin/nitty (/usr/local/bin/nitty as root), static on the release archive. Neither installation reads or removes the other’s files.

Thumbnails. GNOME Files (gnome-desktop’s thumbnail factory) runs the program of a .thumbnailer entry for each file in a bubblewrap sandbox that mounts /usr read-only, a private /tmp with the file (named gnome-desktop-file-to-thumbnail.<extension>, so the format is recognised from the content) and the output, and nothing of the home folder. Hence the copy of the backend in /usr/local/bin, and the options written into the entry’s Exec line (thumbnailer.js, from the settings): nifti-preview thumbnail draws the centre slice of one view of volume 0 (or the spectrum of a single-voxel NIfTI-MRS file) at its physical aspect ratio, scaled to the requested size (average when shrinking, bilinear when enlarging, nearest voxel for segmentations), and writes a PNG.

Both viewers are always installed, so an Ubuntu upgrade from Sushi 50 to 51 keeps working. install.sh writes every file atomically, removes files that an older version installed and this one does not ship, and records whether it created the user MIME database so that uninstall.sh restores the exact previous state.

Decisions