Skip to content

feat(ui): image viewer with zoom, pan and gesture support - #597

Merged
Tryanks merged 1 commit into
mainfrom
feat/image-viewer
Oct 6, 2026
Merged

Tryanks merged 1 commit into
mainfrom
feat/image-viewer

Conversation

@Tryanks

@Tryanks Tryanks commented Oct 6, 2026

Copy link
Copy Markdown
Owner

Summary

The image lightbox was a 1200px dialog card with a static img inside. It is now an ImageViewer entity on a host-filling dialog that zooms and pans, replacing attachments::open_image_lightbox for the composer strip, sent-message thumbnails, Markdown images and image-link badges.

 Overlays (dialog stack)
   Dialog
-    card: title + img().max_w_full().max_h(…)
+    fill(): absolute inset_0, surface brings its own chrome
+      <ImageViewer> (crates/ui/src/image_viewer.rs)
+        stage  — img placed at fit × zoom + offset, clamped to the stage
+        toolbar — title · zoom out · % · zoom in · fit ⇄ actual size · close

Gestures, all resolved against the last drawn frame:

wheel (lines)            zoom about the pointer, one notch = ×1.25
wheel (pixels / touch)   pan; with ctrl/⌘ held: zoom (browser convention)
pinch (trackpad, DM, Wayland/X11, native mobile) zoom about the pinch centre
drag / touch pan         pan while the image exceeds the stage
double-click / tap       fit ⇄ actual size (or 2× fit when that is nearer)
= + ⌘= / - ⌘- / 0 ⌘0 / 1 ⌘1   zoom in / out / fit / actual size (glide 200 ms)
click beside the image, Esc, ×   close

Placement is clamped so the image is centred on any axis where it is smaller than the stage and never leaves a gap where it is larger; small images are not enlarged at fit, and 1:1 is always reachable. Keys, buttons and double-click glide via gpui_base::motion::transition; pointer gestures track directly.

Survey before building: neither gpui-base 0.7 nor gpui-kit ships an image viewer, lightbox or zoom/pan primitive (only Carousel and Attachment thumbnails), so this composes gpui's PinchEvent/ScrollWheelEvent/click-count with gpui-base Dialog and transition.

Also in this PR:

  • Dialog::fill() on Tcode's dialog wrapper, the one addition needed to host a full-window surface on the existing stack.
  • window_caption::traffic_light_inset(window) is now the owner of the macOS traffic-light inset; the shell's nav bar uses it instead of its private constant, and the viewer toolbar uses it too.
  • Four Lucide icons (zoom-in, zoom-out, maximize-2, minimize-2) bundled in assets/icons, since the bundled component icon set does not contain them (they rendered blank on first run).

Evidence

  • Before: fixed-size card, no zoom or pan; a large screenshot was only ever shown shrunk.
    After: launched target/debug/tcode on a throwaway TCODE_DATA_DIR, pasted a 2400×1600 PNG into the composer and opened it from the thumbnail. Verified: fit at 49%, toolbar clear of the traffic lights, = key → 61% and fit button appears, drag pans the enlarged image, window at 480px wide re-fits to 27% with the compact toolbar, dark mode renders the dark scrim and opaque toolbar. Double-click could not be driven by the automation tool (it posts two single clicks); that path is covered by the headless test.

  • Headless UI tests in image_viewer::tests drive the window the way the app does (simulate_event for wheel and pinch, mouse down/move/up, keystrokes, clock advance for the glide):

pointer_gestures_zoom_about_the_pointer_pan_within_the_stage_and_close
  fit 400×200 → wheel notch at centre → 500×250 centred
  pinch ×2 at the corner → 1000×500, held flush to the left edge
  drag 100px → origin -100 ; trackpad pan -50 → origin -150
  click beside the image → viewer gone
keys_glide_between_fit_and_actual_size_and_escape_closes
  "1" → still fitted on the first frame, actual size after 200 ms
  "0" → fitted again ; escape → viewer gone
  • Checks run locally on this branch: cargo fmt --all --check, cargo clippy --workspace --all-targets --locked -- -D warnings, cargo nextest run --workspace --locked (892 passed), cargo machete. Not run here: Windows, Linux, iOS, Android and Web builds (CI).

Merge Danger

Door: two-way

Revert restores the previous card; no persisted state or protocol is touched.

Blast Radius: presentation

Every image-open path in the UI now lands in the viewer. The dialog wrapper gains an opt-in fill() and existing dialogs are laid out exactly as before. The shell nav bar's traffic-light inset is computed by the shared helper with the same values. Pinch on iOS/Android depends on the platform declaring a native pinch recogniser; gpui's portable recogniser does not implement pinch yet, so touch there gets tap, double-tap and pan.

Replace the fixed-card image lightbox with an ImageViewer entity on a
host-filling dialog. Wheel and pinch zoom about the pointer, drag and
touch pan, double-click toggles fit and actual size, keys and toolbar
glide between placements, and a click beside the image, Escape or the
close button dismiss it. Placement is clamped to the stage and small
images are not enlarged at fit.

Dialog gains an opt-in fill() mode; the macOS traffic-light inset moves
to window_caption as its owner and the shell nav bar uses it; the four
Lucide icons the toolbar needs are bundled.
@Tryanks
Tryanks merged commit 8d64eea into main Oct 6, 2026
7 checks passed
@Tryanks
Tryanks deleted the feat/image-viewer branch October 6, 2026 08:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant