Skip to content

feat(workspace): pop-out viewer window and long-document navigation - #142

Merged
devnull03 merged 32 commits into
mainfrom
feat/pop-out-viewer
Sep 29, 2026
Merged

devnull03 merged 32 commits into
mainfrom
feat/pop-out-viewer

Conversation

@devnull03

@devnull03 devnull03 commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Closes #141 (Notion ID: 3e921d32-b13b-8129-9f75-ef3977842a5d)
Closes #144 (Notion ID: 3e921d32-b13b-81e2-ae04-d612d56cac51)

Implements the Pop-out Viewer design handoff. A Pop out button in the Details image pane opens a second window. The window shows the selected row's file at full size, with its fields beside it. It follows the table's selection, so the table can sit on one monitor and the scan on the other.

  • Button: second in the image pane's action chip, after Maximize. It's shown for any linked file. While the window is open, its icon turns the primary colour, like the status bar's lit icons, and clicking it brings the window forward. Tooltips are "Open in new window" and "Show pop-out window".
  • Title bar: has a Following/Pinned toggle, ▲▼ row stepping and "Row 42 of 318" (or "Rows 42–44 · 3 selected"). While pinned, it also shows a "Table is on row N · Jump" chip. A pin holds row ids, so adding or removing rows doesn't move it onto a neighbour. The centre shows the file name, then the project, with the unsaved dot. On the right is the sidebar toggle.
  • Stage: the existing viewer, in a new Scope::PopOut, on the same black-85% backdrop. Zoom, pan, paging, the video scrubber, the audio transport and the install banner work as they do in the full-screen viewer. The stage also has its own states:
    • Nothing selected.
    • File not found, with Locate file….
    • No preview for EXT files, with Open in the default app.
    • Several rows selected: a stack with hover ◀▶, an "Item 2 of 3" pill and Alt+←/→.
  • Sidebar: a DetailsPanel without its image pane. It's 320px by default, can be dragged from 240 to 720px, and can be collapsed. Documents get a Details | Find tab strip, and Ctrl+F switches to Find. Esc goes back to Details and does nothing further, so it doesn't deselect the grid from this window.
  • Lifecycle:
    • There's one window per project.
    • It closes when the project changes or the main window closes.
    • It re-binds when the table is rebuilt.
    • It reopens at its saved size on its saved display. This is saved per project with MainWindowBounds, the same way the main window saves its own.

Long-document navigation (#144)

These work in the full-screen viewer and in the pop-out, which share the page pill and the stage.

  • Go to page: the "Page N of M" readout is a box. Enter jumps to the typed page, clamped to the document, and Escape puts the current page back.
  • Page strip: a column of page thumbnails on the left of the stage, for PDFs and TIFF stacks. It's a uniform_list, so only the pages on screen are rendered, through the same cached thumbnail path as the gallery. The current page is outlined and kept in view, and a click goes to it. It starts closed, and a button in the page pill opens it. It's in the stage rather than a pop-out sidebar tab, so both views get the same strip.
  • Fit to width: a button in the page pill, and W. It zooms the page to span the frame and starts at its top. The page's shape comes from the decoded picture rather than from PDFium, so it works for PDF and TIFF pages alike, and 1 (actual size) now works on documents too.
  • Known limit: strip thumbnails share PDFium's lock with the page itself, so a jump can wait behind a screenful of thumbnails.

Viewer and preview fixes

These affect both the pop-out stage and the full-screen overlay.

  • GIFs animate in the full-screen viewer. They never did before: gpui only keeps an image's frame clock when the element has an id, and ours had none. Only the viewer animates. The Details pane and gallery cards now get the GIF's first frame through the cached thumbnail path, which also means large GIFs in the gallery no longer use unbounded memory.
  • Thumbnails respect EXIF orientation. A phone photo was sideways in Details and the gallery, but upright in the viewer, because gpui already applied the orientation tag. The thumbnail cache key has a version salt, so thumbnails already cached sideways are rebuilt.
  • Thumbnail corners: thumbnail images round their own corners. overflow_hidden clips to a rectangle in gpui, so a cover image's corners showed past a rounded border (the launcher's recent projects).
  • Details caption shows the type and size only, e.g. JPG · 725.3 KB, and truncates rather than running under the action buttons in a narrow pane.
  • Rotate: a toolbar button, R to turn clockwise and Shift+R to turn back. It only changes the view and resets per file. gpui can't rotate an image sprite, so a turn decodes the file again; the last finished turn stays on screen until the new one is ready, so the stage never goes blank. A rotated GIF stops animating, and a rotated page shows no search highlights.
  • Zoom readout: shows the zoom as a percentage of actual size. Clicking it switches between fit and 1:1, and 1 jumps to 1:1. The zoom limit rises above 8× when 1:1 needs it.
  • Double-click zooms to actual size at the cursor, and double-clicking again returns to fit.
  • Panning stops where the picture's edge meets the frame's. Zoomed out, the picture can still be dragged around inside the frame. A drag that ends outside the viewer no longer stays stuck.
  • Cursors: an open hand when the image can be panned, a closed hand while dragging. gpui has no grab cursors on Windows, so the pointing hand stands in there.
  • Page pill uses the same translucent pill and small ghost buttons as the viewer's toolbar, instead of an opaque, bordered one.
  • Logging: turns and preview decodes, with their timings, are logged at debug level.

Status bar and cache

  • Loading previews: while thumbnails decode, the right of the status bar shows a spinner and how many are in flight. It's gone, divider included, when nothing is decoding.
  • Clear cache in Settings now also drops the decoded thumbnails in memory, so they are drawn again from the files straight away.

Differences from the tasks

  • Pin: added, because the design asks for it. The task listed it as out of scope.
  • Viewer rebuilds instead of Viewer::load: the viewer is rebuilt on each file change, the same way the overlay already works. open_viewer is split into build and mounting, rather than getting a Viewer::load.
  • Window position: only size and display are restored. The shared MainWindowBounds mechanism doesn't save the position.
  • Design guesses kept: Alt+←/→ for the stack. The design's placeholder banner copy is replaced by the existing component_banner.
  • Stack cards: the stage doesn't draw the offset "ghost" cards behind the front file.
  • ASNT-110: Viewer: long-document navigation (page jump, page strip, fit width) #144 page size: read from the decoded page rather than from PDFium, as above.

Verification

  • Ran ./scripts/ci.sh (fmt, clippy with -D warnings -A dead_code, and cargo test --workspace) on Windows at b955d06. All three passed.
  • New tests cover:
    • Pop-out: pinned vs following, Jump, the sidebar showing the pinned item, stack wrapping, the nothing-selected state, and the pop-out viewer staying separate from the overlay.
    • Viewer: EXIF orientation with the viewer's rotation on top, GIF routing (only the viewer gets the original file), pan limits zoomed in and out, the actual size after a rotation, page-number parsing and clamping, fit to width, and which files get a page strip.
  • Not yet run in the real app on a desktop. That should happen before merging: a GIF animating, the cursors, a turn on a large scan, and a long PDF in both the overlay and the pop-out.

🤖 Generated with Claude Code

A Pop out button in the Details image pane opens a second window: the
full viewer on a dark stage with a Details sidebar beside it, following
the table's selection so the table can sit on one monitor and the scan
on the other.

- Title bar: Following/Pinned toggle, row stepping, "Row 42 of 318", and
  a "Table is on row N · Jump" chip while pinned. Pins hold row ids.
- Sidebar: DetailsPanel without its image pane, resizable 240-720px and
  collapsible; documents get a Details | Find tab strip (Ctrl+F).
- Stage: the viewer in a new Scope::PopOut, plus nothing-selected,
  missing-file and no-preview states, and a stack with Alt+Left/Right
  for several selected rows.
- One window per project; closes with the project or the main window,
  and reopens at its saved size on its saved display.
- Stop playback when the pop-out leaves or closes a file, and only when
  the shared player is playing that file, so the main window's
  recording is never silenced.
- Keep the window's bounds per project through MainWindowBounds, the
  same mechanism and `.qrate` key pattern as the main window, instead of
  a new app-wide setting.
- Re-find a pin only when rows were added, removed or moved (one id map
  per structural change), and compare the grid's cursor rather than its
  whole selection when drawing the "Table is on row N" chip.
…by id

- The viewer waits briefly before probing pages or duration, and holds
  the task, so stepping quickly through files starts one ffmpeg rather
  than one per file.
- Find waits for typing to settle and replaces the previous search
  task, so a word typed quickly runs one PDFium search.
- The Details editor captures row ids and resolves them, and its column,
  at commit, so a row added or removed mid-edit cannot redirect the
  write.
@devnull03
devnull03 marked this pull request as ready for review September 28, 2026 19:09
# Conflicts:
#	crates/workspace/src/panels/details.rs
#	crates/workspace/src/viewer/mod.rs
- Playback records the view whose transport started it; the pop-out
  stops only playback its own viewer started, so the main window
  playing the same file keeps going.
- Keep the latest pop-out bounds in memory per project and prefer them
  over the debounced .qrate write, so a quick reopen gets the current
  size and display.
- Drop deleted items from a pin when it is re-found, so later cell
  edits take the cheap path instead of rebuilding the id map each time.
- Open at 1120x700 when nothing is saved and never below the 800x600
  minimum, rather than MainWindowBounds' 600x800 main-window fallback.
…zoom

The viewer's image had no element id, so gpui kept no frame clock and a GIF
stood on its first frame. Only the fullscreen viewer animates now; the Details
pane and gallery get the first frame through the cached ladder.

- Thumbnails honour EXIF orientation, as gpui already did for the viewer; the
  thumbnail cache key is salted so sideways entries are rebuilt
- Captions carry pixel dimensions, read from the header
- Viewer: quarter-turn rotation (R / Shift+R), a zoom readout that toggles fit
  and 1:1, double-click to zoom, pan clamped to the image, and open/closed
  hand cursors while it can be panned
overflow_hidden clips to a rectangle in gpui, so a Cover image's square
corners showed past the launcher's rounded border.
…ption

The pop-out button now lights its glyph like the status bar instead of
filling a box. The caption chip caps its width and ellipsizes, so a
narrow pane cannot run it under the action buttons.
…omed out

A turn re-decodes the file, since gpui cannot rotate an image sprite, so
the stage went blank until it landed. The viewer now draws the last
finished turn until the new one is ready.

Panning is allowed again below fit, bounded by the frame. Windows has no
grab cursors in gpui, so the hand stands in there. Turns and preview
decodes are logged at debug level with their timings. The details
caption drops the pixel dimensions and shows type and size only.
…ring the cache

Clear cache only deleted the files on disk, so every thumbnail already
decoded stayed in memory and nothing changed on screen. It now drops the
decoded pictures too, and they are drawn again from the files.

While previews decode, the right of the status bar shows a spinner and
how many are in flight, so a gallery filling in reads as work under way.
It rendered empty but still counted as occupied, so the bar kept a
divider and a gap for it.
It was the one opaque, bordered, shadowed pill with outline buttons and
bold text. It now uses the translucent pill and small ghost buttons the
toolbar and row stepper use. The video scrubber shares it.
The page readout in the viewer's pill is now a box: Enter jumps to the
typed page, clamped to the document, and Escape puts the current page
back. Works in the overlay and the pop-out, which share the pill.

Part of #144.
A column of page thumbnails on the left of the viewer's stage, for PDFs
and TIFF stacks. It is a virtualised list, so only the pages on screen
are rendered, through the same cached thumbnail path as the gallery. The
current page is outlined and kept in view, and a click goes to it. The
page pill has a button to hide it.

It lives in the stage rather than the pop-out's sidebar, so the overlay
and the pop-out get the same strip.

Part of #144.
@devnull03 devnull03 linked an issue Sep 29, 2026 that may be closed by this pull request
5 tasks
A button in the page pill, and W, zoom a page until it spans the frame
and start at its top. The page's shape comes from the picture that was
decoded, so a PDF or TIFF page works without asking PDFium for a size,
and actual size (1) now works on documents too.

Part of #144.
@devnull03 devnull03 changed the title feat(workspace): pop-out viewer window synced to the selection feat(workspace): pop-out viewer window and long-document navigation Sep 29, 2026
The page pill's button opens it.
The outline and rounding were on a fixed box around the thumbnail, so
every page showed as the same shape. They are on the picture now, which
keeps its own aspect ratio inside the row.
Capping the picture with max_w_full/max_h_full inside a flexed row left
it at its intrinsic 512px: it spilled past the strip's edge and the
outlined page came up empty. Each thumbnail is now given an explicit
size, its decoded shape fitted to the row, and a portrait placeholder
until it lands.
Page one of a TIFF stack is handed to gpui, which reports no size, so
paging back to it kept the previous page's shape for fit, pan and fit
to width. The viewer now falls back to the header's size for it.
A turn is decoded by us rather than gpui and held under the preview
budget; a 60 MP scan held whole was larger than the budget on its own,
so it and the gallery evicted each other. The shrink happens after the
decode, so nothing extra is written to the disk cache.
It re-rendered its title bar, stage and sidebar each time the viewer
notified, which is every mouse move of a drag. It now redraws only when
the pill, the document tabs or the find results change. The stage skips
the missing-file lookup while a file is showing, and the OS title is set
only when it changes.
The floor was a flat 10%, so an icon fitted far past 10x could not get
down to one pixel per pixel and 1 showed 312%. The floor now widens to
the actual size, as the ceiling already did.
Every viewer built the input and its subscription on first render,
including photos, recordings and each row stepped through in the
pop-out.
While a turn decoded, fit and pan limits already used the new shape
though the old picture was still shown. They now follow the turn on
screen. A page change drops the old turn too, since the new page has
none decoded and the stage would otherwise wait on it blank.
Zooming at or below fit snapped the picture back to the centre, so a
picture dragged inside its frame jumped on the next wheel tick. Zoom now
keeps the pan, clamped to the frame. Fit (0, or the readout) still
recentres, through one fit_view shared with page changes and turns.
Closing the full-screen viewer, or moving the Details selection, stopped
whatever was playing, including a pinned pop-out's recording. The owner
check the pop-out had is now playback::stop itself, so every caller
gets it; the viewer also stops its own recording when the next row's
replaces it.
The Cell-or-Row match was written out in five places across the grid,
the viewer, the pop-out and the view switcher. It is now
QrateTableDelegate::cursor_row.
The viewer's row arrows and the pinned pop-out's each looked up the view
row, stepped and tested for a previewable file. Both now call
viewer::next_previewable.
The pop-out's stack copied Details' clamp-to-last and wrap-around maths
verbatim. Both now use details::stack_front and stack_step.
The id-to-row map was built by hand in four places and scanned for in
two more, and rows were turned into ids through row_ids().get() in
three. The delegate now has row_positions, row_of and a public row_id.
The look-up, upgrade and update was written out at eight sites across
five crates, and the grid's menu handlers kept a private copy. They all
use one public TablePanelHandle::update now.
The silent-WAV header was built by hand in four tests across two
crates; it is preview::playback::silent_wav now, behind a test-support
feature workspace's tests turn on. The Details and pop-out tests share
test_support::open_table, and the table panel's tests start from one
project with autosave already off.
@devnull03
devnull03 merged commit 5ea08d2 into main Sep 29, 2026
3 checks passed
@devnull03
devnull03 deleted the feat/pop-out-viewer branch September 29, 2026 21:50
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.

ASNT-110: Viewer: long-document navigation (page jump, page strip, fit width) ASNT-109: Pop-out viewer window synced to the selection

1 participant