From 1725ebed010840596275ab2171e74a2a1b804f9d Mon Sep 17 00:00:00 2001
From: devnull03 <56480041+devnull03@users.noreply.github.com>
Date: Mon, 28 Sep 2026 18:25:47 +0000
Subject: [PATCH 01/31] feat(workspace): add a pop-out viewer window that
follows the selection
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
assets/icons/app-window.svg | 1 +
assets/icons/file-x.svg | 1 +
assets/icons/pin-filled.svg | 1 +
assets/icons/pin.svg | 1 +
crates/app/src/assets.rs | 21 +-
crates/app/src/main.rs | 2 +
crates/settings/src/lib.rs | 5 +-
crates/workspace/src/lib.rs | 2 +
crates/workspace/src/panels/details.rs | 54 +-
crates/workspace/src/pop_out.rs | 1151 ++++++++++++++++++++++++
crates/workspace/src/viewer/find.rs | 10 +-
crates/workspace/src/viewer/mod.rs | 158 +++-
12 files changed, 1347 insertions(+), 60 deletions(-)
create mode 100644 assets/icons/app-window.svg
create mode 100644 assets/icons/file-x.svg
create mode 100644 assets/icons/pin-filled.svg
create mode 100644 assets/icons/pin.svg
create mode 100644 crates/workspace/src/pop_out.rs
diff --git a/assets/icons/app-window.svg b/assets/icons/app-window.svg
new file mode 100644
index 00000000..749559ab
--- /dev/null
+++ b/assets/icons/app-window.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/file-x.svg b/assets/icons/file-x.svg
new file mode 100644
index 00000000..d5ae477e
--- /dev/null
+++ b/assets/icons/file-x.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/pin-filled.svg b/assets/icons/pin-filled.svg
new file mode 100644
index 00000000..3623c7fb
--- /dev/null
+++ b/assets/icons/pin-filled.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/pin.svg b/assets/icons/pin.svg
new file mode 100644
index 00000000..3b5b2732
--- /dev/null
+++ b/assets/icons/pin.svg
@@ -0,0 +1 @@
+
diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs
index dd0d257e..67614d35 100644
--- a/crates/app/src/assets.rs
+++ b/crates/app/src/assets.rs
@@ -1,6 +1,7 @@
//! Our own icons in front of `gpui_component_assets`, which is otherwise the only asset source.
//! The bundled icon set has no filled panel glyphs (the title bar's "this dock is open" state) and
-//! no picture glyph (visual search), so those SVGs are ours, copied from Lucide.
+//! no picture glyph (visual search), and none of the pop-out viewer's window, pin or missing-file
+//! glyphs, so those SVGs are ours, copied from Lucide.
use std::borrow::Cow;
@@ -8,7 +9,7 @@ use gpui::{AssetSource, Result, SharedString};
pub struct Assets;
-const OWN: [(&str, &str); 5] = [
+const OWN: [(&str, &str); 9] = [
(
"icons/history.svg",
include_str!("../../../assets/icons/history.svg"),
@@ -29,6 +30,22 @@ const OWN: [(&str, &str); 5] = [
"icons/image.svg",
include_str!("../../../assets/icons/image.svg"),
),
+ (
+ "icons/app-window.svg",
+ include_str!("../../../assets/icons/app-window.svg"),
+ ),
+ (
+ "icons/pin.svg",
+ include_str!("../../../assets/icons/pin.svg"),
+ ),
+ (
+ "icons/pin-filled.svg",
+ include_str!("../../../assets/icons/pin-filled.svg"),
+ ),
+ (
+ "icons/file-x.svg",
+ include_str!("../../../assets/icons/file-x.svg"),
+ ),
];
impl AssetSource for Assets {
diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs
index b2241bdd..20018455 100644
--- a/crates/app/src/main.rs
+++ b/crates/app/src/main.rs
@@ -221,6 +221,8 @@ impl App {
return false;
}
flush_all_state(cx);
+ // Its table goes with this window, and on Windows and Linux an open window keeps the app running.
+ workspace::close_pop_out(cx);
true
});
diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs
index d6d19bae..e47eb3b5 100644
--- a/crates/settings/src/lib.rs
+++ b/crates/settings/src/lib.rs
@@ -35,6 +35,9 @@ use crate::path_picker::PathPickerApp;
/// `AppSettings` value key for the Settings window's last size (a JSON [`MainWindowBounds`]).
pub const SETTINGS_WINDOW_BOUNDS_KEY: &str = "settings_window_bounds";
+/// The same for the pop-out viewer, whose display is the point of it: it reopens on the monitor
+/// the archivist moved it to.
+pub const POP_OUT_WINDOW_BOUNDS_KEY: &str = "pop_out_window_bounds";
/// Setting key for autosave behavior: `"timed"` (buffered, the default), `"immediate"`,
/// or `"off"`. Read by the table crate to decide when a committed cell edit reaches disk.
@@ -539,7 +542,7 @@ impl AppSettings {
}
pub fn set_text(key: &'static str, val: SharedString, cx: &mut App) {
- if key != SETTINGS_WINDOW_BOUNDS_KEY {
+ if key != SETTINGS_WINDOW_BOUNDS_KEY && key != POP_OUT_WINDOW_BOUNDS_KEY {
log::debug!("settings: app text changed key={key}");
}
Self::update(cx, |s| {
diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs
index 199d4402..f8a8a1b5 100644
--- a/crates/workspace/src/lib.rs
+++ b/crates/workspace/src/lib.rs
@@ -6,10 +6,12 @@ mod dock_button;
pub mod extension;
mod panel_registry;
mod panels;
+mod pop_out;
mod skin;
mod viewer;
mod views;
+pub use pop_out::{close as close_pop_out, open as open_pop_out};
pub use viewer::{CloseViewerLayer, Scope as ViewerScope, VIEWER_CONTEXT, open_viewer};
pub use dock_button::DockToggleButton;
diff --git a/crates/workspace/src/panels/details.rs b/crates/workspace/src/panels/details.rs
index 05d57437..58e3121c 100644
--- a/crates/workspace/src/panels/details.rs
+++ b/crates/workspace/src/panels/details.rs
@@ -7,7 +7,7 @@ use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::*;
use gpui_component::{
- ActiveTheme, IconName, Sizable, StyledExt as _,
+ ActiveTheme, Icon, IconName, Selectable as _, Sizable, StyledExt as _,
button::{Button, ButtonVariants},
dock::{BasePanel, DockPlacement, Panel, PanelEvent},
h_flex,
@@ -146,6 +146,12 @@ pub struct DetailsPanel {
/// What `transport` and `caption` were built from. `retarget` runs on every table change, so
/// without it a keystroke in the grid would re-stat the file.
file: Option,
+ /// The rows described instead of the grid's selection, which makes this the pop-out's sidebar:
+ /// that window can be pinned to an item the grid has moved on from, and its stage already
+ /// shows the file, so there is no image pane here.
+ rows: Option>,
+ /// Repaints the Pop out button when that window opens or closes.
+ _pop_out_sub: Subscription,
}
impl DetailsPanel {
@@ -202,6 +208,9 @@ impl DetailsPanel {
caption: None,
_caption_task: None,
file: None,
+ rows: None,
+ _pop_out_sub: cx
+ .observe_global::(|_this: &mut Self, cx| cx.notify()),
};
this.bind(cx);
this
@@ -235,7 +244,10 @@ impl DetailsPanel {
/// The selected items as source rows in view order — what the whole panel is about, and the
/// same list the grid, the gallery and the status bar count.
- fn picked(&self, cx: &App) -> Vec {
+ pub(crate) fn picked(&self, cx: &App) -> Vec {
+ if let Some(rows) = &self.rows {
+ return rows.clone();
+ }
self.state
.as_ref()
.and_then(|w| w.upgrade())
@@ -259,6 +271,11 @@ impl DetailsPanel {
let picked = self.picked(cx);
let front = self.front(&picked);
self.load_row_history(front, cx);
+ // The pop-out's stage has the file, its caption and its transport.
+ if self.rows.is_some() {
+ self.transport = None;
+ return;
+ }
let path = front.and_then(|row| {
let state = self.state.as_ref()?.upgrade()?;
let delegate = state.read(cx).delegate();
@@ -288,6 +305,16 @@ impl DetailsPanel {
self.transport = path.and_then(|path| Transport::new(path, cx));
}
+ /// Describe `rows` from now on, rather than the grid's selection.
+ pub(crate) fn show_rows(&mut self, rows: Vec, cx: &mut Context) {
+ if self.rows.as_ref() == Some(&rows) {
+ return;
+ }
+ self.rows = Some(rows);
+ self.retarget(cx);
+ cx.notify();
+ }
+
/// Re-read the front item's history, off the UI thread, when the item or the project file has
/// changed since. A new item shows its unsaved changes at once and its saved ones on arrival.
fn load_row_history(&mut self, front: Option, cx: &mut Context) {
@@ -1019,6 +1046,21 @@ fn render_image_frame(
}),
)
})
+ // Beside fullscreen, since both open a bigger view — but for any file,
+ // because the pop-out also says what it cannot show. One per project.
+ .child({
+ let open = crate::pop_out::is_open(cx);
+ Button::new("pop-out")
+ .icon(Icon::empty().path("icons/app-window.svg"))
+ .ghost()
+ .small()
+ .selected(open)
+ .tooltip(match open {
+ true => "Show pop-out window",
+ false => "Open in new window",
+ })
+ .on_click(|_, _, cx| crate::pop_out::open(cx))
+ })
.child(action(
"open-image",
IconName::ExternalLink,
@@ -1185,6 +1227,10 @@ impl Render for DetailsPanel {
== crate::ViewMode::Gallery;
let Some((fields, image_path, lost)) = selection.filter(|(f, _, _)| !f.is_empty()) else {
+ // The pop-out's stage already says so, in a place a collapsed sidebar cannot hide.
+ if self.rows.is_some() {
+ return div().into_any_element();
+ }
// Says what this panel is for and how to fill it, rather than only reporting that it
// is empty — the multi-select gesture is the one thing here nobody discovers by luck.
return div()
@@ -1568,7 +1614,7 @@ impl Render for DetailsPanel {
// region padding itself: the split below sizes its panes against whatever height it is
// handed, so a panel that grew 29px when the bottom dock closed re-scaled the image
// pane under the pointer. Paid here, the split's height never changes.
- .pb(crop)
+ .when(self.rows.is_none(), |panel| panel.pb(crop))
.key_context(DETAILS_META.name)
.track_focus(&self.focus_handle)
.id("details-panel")
@@ -1601,7 +1647,7 @@ impl Render for DetailsPanel {
})
// Dropped entirely in the gallery: the cards are already showing this photo,
// so the pane is just less room for the fields. It comes back with the grid.
- .when(!gallery, |split| {
+ .when(!gallery && self.rows.is_none(), |split| {
split.child(
resizable_panel()
.size(px(image_height))
diff --git a/crates/workspace/src/pop_out.rs b/crates/workspace/src/pop_out.rs
new file mode 100644
index 00000000..dac92476
--- /dev/null
+++ b/crates/workspace/src/pop_out.rs
@@ -0,0 +1,1151 @@
+//! The pop-out viewer: a second window that shows the selected row's file at full size beside its
+//! fields. It follows the grid's selection, so on two monitors the table sits on one screen and the
+//! scan fills the other — unless it is pinned, when it holds its item while the grid moves on.
+//!
+//! The stage is the full-screen viewer itself, mounted in [`Scope::PopOut`], and the sidebar is a
+//! [`DetailsPanel`] describing whatever this window shows. Neither knows about the other: this
+//! window decides which rows they are both about.
+
+use std::ops::Range;
+use std::path::PathBuf;
+
+use gpui::prelude::FluentBuilder as _;
+use gpui::*;
+use gpui_component::{
+ ActiveTheme, Disableable as _, Icon, IconName, Root, Selectable as _, Sizable, StyledExt as _,
+ TitleBar,
+ button::{Button, ButtonVariants},
+ h_flex,
+ resizable::{ResizableState, h_resizable, resizable_panel},
+ table::TableState,
+ v_flex,
+};
+use settings::project::{CurrentProject, RowId};
+use settings::{AppSettings, MainWindowBounds, POP_OUT_WINDOW_BOUNDS_KEY};
+use table::{QrateTableDelegate, Selection, TableChanged, TablePanelHandle, TableStateHandle};
+
+use crate::panels::DetailsPanel;
+use crate::viewer::{self, Scope, Viewer};
+
+/// How wide the sidebar opens, and how far it may be dragged either way — the same range as the
+/// viewer's find panel, which it replaces for a document.
+const SIDEBAR: Pixels = px(320.);
+const SIDEBAR_RANGE: Range = px(240.)..px(720.);
+/// The smallest window that still fits a readable page beside the sidebar.
+const MIN_SIZE: Size = Size {
+ width: px(800.),
+ height: px(600.),
+};
+const DEFAULT_SIZE: Size = Size {
+ width: px(1120.),
+ height: px(700.),
+};
+/// Height of the sidebar's header strip, the Details title or the Details | Find tabs.
+const HEADER_H: Pixels = px(30.);
+/// The stage's own text colours. Not theme colours, for the reason the backdrop is not one.
+const STAGE_FG: u32 = 0xe8e8e8;
+const STAGE_MUTED: u32 = 0xa3a3a3;
+
+/// The open pop-out window. There is one per project, and one project open at a time.
+#[derive(Default)]
+pub(crate) struct PopOutWindow(Option);
+
+impl Global for PopOutWindow {}
+
+pub fn is_open(cx: &App) -> bool {
+ cx.try_global::()
+ .is_some_and(|window| window.0.is_some())
+}
+
+/// Open the pop-out window, or bring the open one forward.
+pub fn open(cx: &mut App) {
+ if let Some(handle) = cx.try_global::().and_then(|window| window.0)
+ && handle
+ .update(cx, |_, window, _| window.activate_window())
+ .is_ok()
+ {
+ return;
+ }
+ // Size and display only, like the other windows: the display is the point of this one.
+ let saved = AppSettings::get(cx)
+ .values
+ .get(POP_OUT_WINDOW_BOUNDS_KEY)
+ .map(|value| value.text())
+ .and_then(|raw| serde_json::from_str::(&raw).ok());
+ let display = saved.as_ref().and_then(|b| b.display_id).and_then(|raw| {
+ cx.displays()
+ .into_iter()
+ .find(|display| u64::from(display.id()) == raw)
+ .map(|display| display.id())
+ });
+ let win_size = saved
+ .filter(|b| {
+ b.width.is_finite()
+ && b.height.is_finite()
+ && px(b.width) >= MIN_SIZE.width
+ && px(b.height) >= MIN_SIZE.height
+ })
+ .map_or(DEFAULT_SIZE, |b| size(px(b.width), px(b.height)));
+ let options = WindowOptions {
+ window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
+ display, win_size, cx,
+ ))),
+ display_id: display,
+ window_min_size: Some(MIN_SIZE),
+ ..TitleBar::window_options()
+ };
+ match cx.open_window(options, |window, cx| {
+ let view = cx.new(|cx| PopOut::new(window, cx));
+ cx.new(|cx| Root::new(view, window, cx))
+ }) {
+ Ok(handle) => cx.set_global(PopOutWindow(Some(handle.into()))),
+ Err(err) => log::error!("couldn't open the pop-out viewer window: {err:#}"),
+ }
+}
+
+/// Close the pop-out window, if one is open. For the main window closing: the table this one
+/// follows goes with it.
+pub fn close(cx: &mut App) {
+ let Some(handle) = cx.try_global::().and_then(|window| window.0) else {
+ return;
+ };
+ cx.set_global(PopOutWindow::default());
+ handle
+ .update(cx, |_, window, _| window.remove_window())
+ .ok();
+}
+
+pub struct PopOut {
+ focus_handle: FocusHandle,
+ /// The project this window was opened for. It closes when another one is opened.
+ project: Option,
+ state: Option>>,
+ /// The items held while pinned, by id rather than position, so rows added or removed above
+ /// them cannot move the pin onto a neighbour. `None` while following the grid.
+ pinned: Option>,
+ /// What this window shows — the pinned items or the grid's selection — as source rows.
+ rows: Vec,
+ /// Which of several items the stage shows. Only the stage steps: the sidebar's shared fields
+ /// and the grid's selection both keep all of them.
+ stack: usize,
+ /// The stack's step arrows only exist under the pointer, so they never cover the page at rest.
+ stack_hover: bool,
+ /// The front item's file, when there is one to draw.
+ viewer: Option>,
+ details: Entity,
+ sidebar: bool,
+ split: Entity,
+ _table_sub: Option,
+ /// Repaints when the viewer's pages, controls or find tab change under it.
+ _viewer_sub: Option,
+ _subs: Vec,
+}
+
+impl PopOut {
+ fn new(window: &mut Window, cx: &mut Context) -> Self {
+ let details = cx.new(|cx| {
+ let mut details = DetailsPanel::new(window, cx);
+ details.show_rows(Vec::new(), cx);
+ details
+ });
+ let me = window.window_handle();
+ cx.on_release(move |this: &mut Self, cx| {
+ // A recording playing here would otherwise go on with nothing on screen to stop it.
+ if this
+ .viewer
+ .as_ref()
+ .is_some_and(|viewer| viewer.read(cx).transport.is_some())
+ {
+ preview::playback::stop(cx);
+ }
+ // Only this window's own entry: a new pop-out may already have replaced it.
+ if cx
+ .try_global::()
+ .is_some_and(|window| window.0 == Some(me))
+ {
+ cx.set_global(PopOutWindow::default());
+ }
+ })
+ .detach();
+
+ let _subs = vec![
+ cx.observe_global_in::(window, |this, window, cx| {
+ this.bind(window, cx)
+ }),
+ cx.observe_global_in::(window, |this, window, cx| {
+ if cx.try_global::().map(|p| &p.file) != this.project.as_ref() {
+ window.remove_window();
+ }
+ }),
+ cx.observe_global::(|_, cx| cx.notify()),
+ // The stage offers to install what a PDF or a video needs; once it lands, draw it.
+ cx.observe_global_in::(window, |this, window, cx| {
+ let installed = this.viewer.as_ref().is_some_and(|viewer| {
+ let viewer = viewer.read(cx);
+ viewer.needs.is_some() && preview::missing(&viewer.path).is_none()
+ });
+ if installed {
+ this.viewer = None;
+ this.sync(window, cx);
+ }
+ }),
+ cx.observe_window_bounds(window, |_, window, cx| {
+ let bounds = MainWindowBounds::capture_from_window(window, cx);
+ if let Ok(json) = serde_json::to_string(&bounds) {
+ AppSettings::set_text(POP_OUT_WINDOW_BOUNDS_KEY, json.into(), cx);
+ }
+ }),
+ ];
+
+ let focus_handle = cx.focus_handle();
+ focus_handle.focus(window, cx);
+ let mut this = Self {
+ focus_handle,
+ project: cx.try_global::().map(|p| p.file.clone()),
+ state: None,
+ pinned: None,
+ rows: Vec::new(),
+ stack: 0,
+ stack_hover: false,
+ viewer: None,
+ details,
+ sidebar: true,
+ split: cx.new(|_| ResizableState::default()),
+ _table_sub: None,
+ _viewer_sub: None,
+ _subs,
+ };
+ this.bind(window, cx);
+ this
+ }
+
+ fn bind(&mut self, window: &mut Window, cx: &mut Context) {
+ self.state = cx.try_global::().map(|h| h.0.clone());
+ self._table_sub = self.state.as_ref().and_then(|w| w.upgrade()).map(|table| {
+ cx.subscribe_in(&table, window, |this, _, _: &TableChanged, window, cx| {
+ this.sync(window, cx)
+ })
+ });
+ self.sync(window, cx);
+ }
+
+ fn table(&self) -> Option>> {
+ self.state.as_ref().and_then(|w| w.upgrade())
+ }
+
+ /// The item the stage shows: the stack's front card, clamped so a smaller selection never
+ /// leaves it pointing past the end.
+ fn front(&self) -> Option {
+ self.rows
+ .get(self.stack.min(self.rows.len().checked_sub(1)?))
+ .copied()
+ }
+
+ /// Bring the rows, the stage and the sidebar up to date with the grid.
+ fn sync(&mut self, window: &mut Window, cx: &mut Context) {
+ let rows = self.table().map_or_else(Vec::new, |state| {
+ let delegate = state.read(cx).delegate();
+ match &self.pinned {
+ Some(ids) => ids
+ .iter()
+ .filter_map(|id| delegate.row_ids().iter().position(|row| row == id))
+ .collect(),
+ None => delegate.selected_source_rows(),
+ }
+ });
+ // Every pinned item deleted: there is nothing left to hold, so follow again.
+ if self.pinned.is_some() && rows.is_empty() {
+ self.pinned = None;
+ return self.sync(window, cx);
+ }
+ if rows != self.rows {
+ self.stack = 0;
+ self.rows = rows.clone();
+ }
+ self.details
+ .update(cx, |details, cx| details.show_rows(rows, cx));
+
+ let file = self
+ .front()
+ .zip(self.table())
+ .and_then(|(row, state)| viewer::previewable(state.read(cx).delegate(), row));
+ if self.viewer.as_ref().map(|viewer| &viewer.read(cx).path) != file.as_ref() {
+ self.viewer = file.map(|file| viewer::build(file, Scope::PopOut, window, cx));
+ self._viewer_sub = self
+ .viewer
+ .as_ref()
+ .map(|viewer| cx.observe(viewer, |_, _, cx| cx.notify()));
+ }
+ let (file, rest) = self.title(cx);
+ window.set_window_title(&format!("{file}{rest}"));
+ cx.notify();
+ }
+
+ /// The row ids of `rows`, which is what a pin holds on to.
+ fn ids(&self, rows: &[usize], cx: &App) -> Vec {
+ self.table().map_or_else(Vec::new, |state| {
+ let ids = state.read(cx).delegate().row_ids();
+ rows.iter()
+ .filter_map(|&row| ids.get(row).copied())
+ .collect()
+ })
+ }
+
+ /// Step to the neighbouring row with a file to show. Following, that moves the grid, and this
+ /// window comes along; pinned, it moves only this window.
+ fn step(&mut self, delta: isize, window: &mut Window, cx: &mut Context) {
+ if self.pinned.is_none() {
+ viewer::step_row(delta, cx);
+ return;
+ }
+ let Some(state) = self.table() else {
+ return;
+ };
+ let target = {
+ let delegate = state.read(cx).delegate();
+ let visible = delegate.visible();
+ self.front()
+ .and_then(|row| delegate.view_row(row))
+ .and_then(|from| {
+ viewer::next_row(from, delta, visible.len(), |view| {
+ viewer::previewable(delegate, visible[view]).is_some()
+ })
+ })
+ .and_then(|view| delegate.row_ids().get(visible[view]).copied())
+ };
+ if let Some(id) = target {
+ self.pinned = Some(vec![id]);
+ self.sync(window, cx);
+ }
+ }
+
+ /// Walk the stack of selected items, wrapping at both ends like the Details preview does.
+ fn step_stack(&mut self, forward: bool, window: &mut Window, cx: &mut Context) {
+ let count = self.rows.len();
+ if count < 2 {
+ return;
+ }
+ let at = self.stack.min(count - 1);
+ self.stack = match forward {
+ true => (at + 1) % count,
+ false => (at + count - 1) % count,
+ };
+ self.sync(window, cx);
+ }
+
+ fn toggle_pin(&mut self, window: &mut Window, cx: &mut Context) {
+ self.pinned = match self.pinned {
+ Some(_) => None,
+ None => Some(self.ids(&self.rows, cx)),
+ };
+ self.sync(window, cx);
+ }
+
+ /// Stay pinned, but on whatever the grid has selected now.
+ fn jump(&mut self, window: &mut Window, cx: &mut Context) {
+ let Some(state) = self.table() else {
+ return;
+ };
+ let rows = state.read(cx).delegate().selected_source_rows();
+ self.pinned = Some(self.ids(&rows, cx));
+ self.sync(window, cx);
+ }
+
+ /// Switch the sidebar to Find, opening it if it was collapsed — only a document has text.
+ fn open_find(&mut self, window: &mut Window, cx: &mut Context) {
+ let Some(viewer) = self.viewer.clone().filter(|v| v.read(cx).document) else {
+ return;
+ };
+ self.sidebar = true;
+ viewer.update(cx, |viewer, cx| viewer.open_find(window, cx));
+ cx.notify();
+ }
+
+ /// Back to the Details tab — what Escape means here, and all it means.
+ fn show_details(&mut self, window: &mut Window, cx: &mut Context) {
+ if let Some(viewer) = self.viewer.clone().filter(|v| v.read(cx).find_open) {
+ viewer.update(cx, |viewer, cx| viewer.close_find(window, cx));
+ }
+ }
+
+ fn key_down(&mut self, ev: &KeyDownEvent, window: &mut Window, cx: &mut Context) {
+ let keys = &ev.keystroke;
+ // Arrows are a text box's caret wherever one has focus; they mean rows only on the page
+ // or the bare window.
+ let reading = self.focus_handle.is_focused(window)
+ || self
+ .viewer
+ .as_ref()
+ .is_some_and(|viewer| viewer.read(cx).focus_handle.is_focused(window));
+ match keys.key.as_str() {
+ "f" if keys.modifiers.secondary() => self.open_find(window, cx),
+ "up" | "down" if reading && !keys.modifiers.alt => {
+ self.step(if keys.key == "up" { -1 } else { 1 }, window, cx)
+ }
+ "left" | "right" if reading && keys.modifiers.alt => {
+ self.step_stack(keys.key == "right", window, cx)
+ }
+ _ => {}
+ }
+ }
+
+ /// The window's title, as the file part and the rest, which the title bar mutes.
+ fn title(&self, cx: &App) -> (String, String) {
+ let project = cx
+ .try_global::()
+ .map(|p| p.display_name())
+ .unwrap_or_default();
+ let file = match (self.rows.len(), self.front().zip(self.table())) {
+ (0, _) | (_, None) => String::new(),
+ (1, Some((row, state))) => {
+ let delegate = state.read(cx).delegate();
+ delegate
+ .row_image(row)
+ .and_then(|path| path.file_name())
+ .map(|name| name.to_string_lossy().into_owned())
+ .or_else(|| {
+ table::file_links::missing_file(delegate, row, cx)
+ .map(|(_, name)| name.to_string())
+ })
+ .unwrap_or_default()
+ }
+ (count, _) => format!("{count} items"),
+ };
+ let rest = match file.is_empty() {
+ true => format!("{project} — qrate"),
+ false => format!(" — {project} — qrate"),
+ };
+ (file, rest)
+ }
+
+ /// Where this window is in the grid's order, and — while pinned somewhere else — which row
+ /// the grid is on.
+ fn readouts(&self, cx: &App) -> (String, Option) {
+ let Some(state) = self.table() else {
+ return ("No selection".into(), None);
+ };
+ let delegate = state.read(cx).delegate();
+ let total = delegate.visible().len();
+ let views: Vec = self
+ .rows
+ .iter()
+ .filter_map(|&row| delegate.view_row(row))
+ .collect();
+ let readout = match (self.rows.len(), views.iter().min(), views.iter().max()) {
+ (0, ..) => "No selection".to_string(),
+ (1, Some(view), _) => format!("Row {} of {total}", view + 1),
+ (1, None, _) => "Row hidden by the filter".to_string(),
+ (count, Some(low), Some(high)) => {
+ format!("Rows {}–{} · {count} selected", low + 1, high + 1)
+ }
+ (count, ..) => format!("{count} selected"),
+ };
+ let table_row = self
+ .pinned
+ .as_ref()
+ .filter(|_| delegate.selected_source_rows() != self.rows)
+ .and_then(|_| match delegate.selection() {
+ Some(Selection::Cell { row, .. } | Selection::Row(row)) => delegate.view_row(row),
+ _ => None,
+ })
+ .map(|view| view + 1);
+ (readout, table_row)
+ }
+
+ fn title_bar(&self, cx: &mut Context) -> AnyElement {
+ let pinned = self.pinned.is_some();
+ let (readout, table_row) = self.readouts(cx);
+ let (file, rest) = self.title(cx);
+ let dirty = settings::dirty::Dirty::has(settings::dirty::PROJECT_DATA, cx);
+ let theme = cx.theme();
+ let (fg, muted, border, chip, link) = (
+ theme.foreground,
+ theme.muted_foreground,
+ theme.border,
+ theme.muted,
+ theme.primary,
+ );
+
+ TitleBar::new()
+ .text_xs()
+ .text_color(fg)
+ .child(
+ h_flex().flex_1().min_w_0().child(
+ // Occluded: the title bar is a drag region, which Windows never delivers a
+ // click from.
+ h_flex()
+ .min_w_0()
+ .gap_1()
+ .occlude()
+ .child(
+ Button::new("pop-out-follow")
+ .icon(Icon::empty().path(match pinned {
+ true => "icons/pin-filled.svg",
+ false => "icons/pin.svg",
+ }))
+ .label(match pinned {
+ true => "Pinned",
+ false => "Following",
+ })
+ .ghost()
+ .xsmall()
+ .selected(pinned)
+ .disabled(!pinned && self.rows.is_empty())
+ .tooltip(match pinned {
+ true => "Follow the table's selection again",
+ false => "Keep this item here while the table moves on",
+ })
+ .on_click(
+ cx.listener(|this, _, window, cx| this.toggle_pin(window, cx)),
+ ),
+ )
+ .child(div().flex_none().w_px().h(px(14.)).mx_0p5().bg(border))
+ .child(
+ Button::new("pop-out-previous-row")
+ .icon(IconName::ChevronUp)
+ .ghost()
+ .xsmall()
+ .tooltip("Previous row (↑)")
+ .on_click(
+ cx.listener(|this, _, window, cx| this.step(-1, window, cx)),
+ ),
+ )
+ .child(
+ Button::new("pop-out-next-row")
+ .icon(IconName::ChevronDown)
+ .ghost()
+ .xsmall()
+ .tooltip("Next row (↓)")
+ .on_click(
+ cx.listener(|this, _, window, cx| this.step(1, window, cx)),
+ ),
+ )
+ .child(div().min_w_0().truncate().text_color(muted).child(readout))
+ .when_some(table_row, |bar, row| {
+ bar.child(
+ h_flex()
+ .flex_none()
+ .items_center()
+ .gap_1p5()
+ .h(px(20.))
+ .px_2()
+ .ml_1()
+ .rounded_full()
+ .bg(chip)
+ .whitespace_nowrap()
+ .child(format!("Table is on row {row}"))
+ .child(
+ div()
+ .id("pop-out-jump")
+ .text_color(link)
+ .cursor_pointer()
+ .child("Jump")
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.jump(window, cx)
+ })),
+ ),
+ )
+ }),
+ ),
+ )
+ // The file name gives way before the row controls do.
+ .child(
+ h_flex()
+ .min_w_0()
+ .max_w(relative(0.42))
+ .gap_1p5()
+ .items_center()
+ .when(dirty, |title| {
+ title.child(div().flex_none().size(px(6.)).rounded_full().bg(fg))
+ })
+ .child(div().min_w_0().truncate().child(file))
+ .child(div().min_w_0().truncate().text_color(muted).child(rest)),
+ )
+ .child(
+ h_flex().flex_1().justify_end().pr_2().child(
+ div().occlude().child(
+ Button::new("pop-out-sidebar")
+ .icon(Icon::empty().path(match self.sidebar {
+ true => "icons/panel-right-filled.svg",
+ false => "icons/panel-right.svg",
+ }))
+ .ghost()
+ .small()
+ .tooltip(match self.sidebar {
+ true => "Hide details",
+ false => "Show details",
+ })
+ .on_click(cx.listener(|this, _, _, cx| {
+ this.sidebar = !this.sidebar;
+ cx.notify();
+ })),
+ ),
+ ),
+ )
+ .into_any_element()
+ }
+
+ /// The file, or what the stage says in its place, over the viewer's own backdrop.
+ fn stage(&self, cx: &mut Context) -> AnyElement {
+ let (fg, muted) = (rgb(STAGE_FG), rgb(STAGE_MUTED));
+ let front = self.front().zip(self.table()).map(|(row, state)| {
+ let delegate = state.read(cx).delegate();
+ (
+ row,
+ delegate.row_image(row).map(|path| path.to_path_buf()),
+ table::file_links::missing_file(delegate, row, cx).map(|(_, name)| name),
+ )
+ });
+
+ // What to say when there is nothing the viewer can draw. The one useful action goes with
+ // it, where there is one.
+ let message = self.viewer.is_none().then(|| match front {
+ None => v_flex()
+ .items_center()
+ .gap_1p5()
+ .max_w(px(360.))
+ .p_6()
+ .text_center()
+ .child(
+ Icon::new(IconName::LayoutDashboard)
+ .size_8()
+ .text_color(muted),
+ )
+ .child(div().mt_1().text_sm().text_color(fg).child("Nothing selected"))
+ .child(
+ div()
+ .text_size(px(13.))
+ .text_color(muted)
+ .child("Select a row in the table to see its file here. This window follows the selection."),
+ ),
+ Some((row, None, Some(name))) => v_flex()
+ .items_center()
+ .gap_2()
+ .max_w(px(420.))
+ .p_6()
+ .text_center()
+ .child(
+ Icon::empty()
+ .path("icons/file-x.svg")
+ .size_12()
+ .text_color(cx.theme().warning),
+ )
+ .child(div().text_sm().text_color(fg).child("File not found"))
+ .child(
+ div()
+ .text_xs()
+ .font_family(cx.theme().mono_font_family.clone())
+ .text_color(muted)
+ .child(name),
+ )
+ .child(
+ Button::new("pop-out-locate-file")
+ .small()
+ .mt_1()
+ .label("Locate file…")
+ .on_click(move |_, window, cx| {
+ if let Some(table) = cx
+ .try_global::()
+ .and_then(|handle| handle.0.upgrade())
+ {
+ table.update(cx, |table, cx| table.locate_file(row, window, cx));
+ }
+ }),
+ ),
+ Some((_, file, _)) => {
+ let tag = file
+ .as_deref()
+ .and_then(|path| path.extension())
+ .map(|ext| ext.to_string_lossy().to_uppercase());
+ v_flex()
+ .items_center()
+ .gap_2p5()
+ .p_6()
+ .text_center()
+ .child(
+ div()
+ .relative()
+ .size_16()
+ .text_color(muted)
+ .child(Icon::new(IconName::File).size_16())
+ .children(tag.clone().map(|tag| {
+ div()
+ .absolute()
+ .left_0()
+ .right_0()
+ .bottom(px(14.))
+ .text_center()
+ .text_size(px(11.))
+ .font_semibold()
+ .child(tag)
+ })),
+ )
+ .child(div().text_size(px(13.)).text_color(muted).child(
+ match (&file, &tag) {
+ (Some(_), Some(tag)) => format!("No preview for {tag} files"),
+ (Some(_), None) => "No preview for this file".to_string(),
+ (None, _) => "No file is linked to this item".to_string(),
+ },
+ ))
+ .children(file.map(|path| {
+ Button::new("pop-out-open-default")
+ .small()
+ .icon(IconName::ExternalLink)
+ .label("Open in the default app")
+ .on_click(move |_, _, _| {
+ if let Err(err) = settings::os_open::open_in_default_app(&path) {
+ log::error!("could not open {}: {err}", path.display());
+ }
+ })
+ }))
+ }
+ });
+
+ let count = self.rows.len();
+ let at = self.stack.min(count.saturating_sub(1));
+ // Above the viewer's own pill when it has one, so a stack of PDFs keeps its page controls.
+ let lift = match self
+ .viewer
+ .as_ref()
+ .is_some_and(|viewer| viewer.read(cx).has_controls())
+ {
+ true => px(64.),
+ false => px(16.),
+ };
+ let theme = cx.theme();
+ let (background, popover, border, radius) =
+ (theme.background, theme.popover, theme.border, theme.radius);
+
+ div()
+ .id("pop-out-stage")
+ .relative()
+ .size_full()
+ .overflow_hidden()
+ .bg(background)
+ .on_hover(cx.listener(|this, over: &bool, _, cx| {
+ this.stack_hover = *over;
+ cx.notify();
+ }))
+ // Back from the sidebar, a click on the page gives the arrows and zoom keys back to it.
+ .on_mouse_down(
+ MouseButton::Left,
+ cx.listener(|this, _, window, cx| {
+ let focus = match &this.viewer {
+ Some(viewer) => viewer.read(cx).focus_handle.clone(),
+ None => this.focus_handle.clone(),
+ };
+ focus.focus(window, cx);
+ }),
+ )
+ .child(
+ // The viewer's backdrop, painted here so every state of the stage shares it.
+ div()
+ .absolute()
+ .top_0()
+ .left_0()
+ .size_full()
+ .bg(black().opacity(0.85)),
+ )
+ .children(self.viewer.clone())
+ .children(message.map(|message| {
+ div()
+ .absolute()
+ .top_0()
+ .left_0()
+ .size_full()
+ .flex()
+ .items_center()
+ .justify_center()
+ .child(message)
+ }))
+ .when(count > 1, |stage| {
+ stage
+ .when(self.stack_hover, |stage| {
+ stage.children([true, false].map(|left| {
+ div()
+ .absolute()
+ .top_0()
+ .bottom_0()
+ .map(|side| match left {
+ true => side.left_4(),
+ false => side.right_4(),
+ })
+ .flex()
+ .items_center()
+ .child(
+ div()
+ .rounded_full()
+ .bg(popover.opacity(0.8))
+ .border_1()
+ .border_color(border)
+ .occlude()
+ .child(
+ Button::new(match left {
+ true => "pop-out-stack-previous",
+ false => "pop-out-stack-next",
+ })
+ .icon(match left {
+ true => IconName::ChevronLeft,
+ false => IconName::ChevronRight,
+ })
+ .ghost()
+ .rounded_full()
+ .tooltip(match left {
+ true => "Previous selected item (Alt+←)",
+ false => "Next selected item (Alt+→)",
+ })
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.step_stack(!left, window, cx)
+ })),
+ ),
+ )
+ }))
+ })
+ .child(
+ div()
+ .absolute()
+ .left_0()
+ .right_0()
+ .bottom(lift)
+ .flex()
+ .justify_center()
+ .child(
+ h_flex()
+ .items_center()
+ .gap_1()
+ .p_1()
+ .rounded(radius * 2.)
+ .bg(popover)
+ .border_1()
+ .border_color(border)
+ .shadow_lg()
+ .occlude()
+ .text_color(cx.theme().foreground)
+ .text_size(px(13.))
+ .child(
+ Button::new("pop-out-stack-back")
+ .icon(IconName::ChevronLeft)
+ .ghost()
+ .small()
+ .tooltip("Previous selected item (Alt+←)")
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.step_stack(false, window, cx)
+ })),
+ )
+ .child(
+ div().px_1().child(format!("Item {} of {count}", at + 1)),
+ )
+ // Past a dozen the dots stop telling anyone anything.
+ .when(count <= 12, |pill| {
+ pill.child(h_flex().gap_1().px_1().children(
+ (0..count).map(|ix| {
+ div().size(px(6.)).rounded_full().bg(
+ match ix == at {
+ true => cx.theme().primary,
+ false => cx.theme().muted,
+ },
+ )
+ }),
+ ))
+ })
+ .child(
+ Button::new("pop-out-stack-forward")
+ .icon(IconName::ChevronRight)
+ .ghost()
+ .small()
+ .tooltip("Next selected item (Alt+→)")
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.step_stack(true, window, cx)
+ })),
+ ),
+ ),
+ )
+ })
+ .into_any_element()
+ }
+
+ /// Details, or — for a document — Details and Find as two tabs. A tab rather than a third
+ /// column, so the window still works at its smallest.
+ fn sidebar(&self, width: Pixels, cx: &mut Context) -> AnyElement {
+ let document = self
+ .viewer
+ .clone()
+ .filter(|viewer| viewer.read(cx).document);
+ let finding = document
+ .as_ref()
+ .is_some_and(|viewer| viewer.read(cx).find_open);
+ let theme = cx.theme();
+ let (fg, muted, border, primary, strip, background) = (
+ theme.foreground,
+ theme.muted_foreground,
+ theme.border,
+ theme.primary,
+ theme.tab_bar,
+ theme.background,
+ );
+ let tab = |id: &'static str, label: &'static str, on: bool| {
+ div()
+ .id(id)
+ .flex()
+ .items_center()
+ .px_2()
+ .cursor_pointer()
+ .text_color(if on { fg } else { muted })
+ .when(on, |tab| tab.border_b_2().border_color(primary))
+ .child(label)
+ };
+ let header =
+ h_flex()
+ .flex_none()
+ .h(HEADER_H)
+ .bg(strip)
+ .border_b_1()
+ .border_color(border)
+ .text_size(px(13.))
+ .map(|header| match document.is_some() {
+ false => header.items_center().px_3().child("Details"),
+ true => header
+ .items_stretch()
+ .gap_0p5()
+ .px_2()
+ .child(tab("pop-out-tab-details", "Details", !finding).on_click(
+ cx.listener(|this, _, window, cx| this.show_details(window, cx)),
+ ))
+ .child(tab("pop-out-tab-find", "Find", finding).on_click(
+ cx.listener(|this, _, window, cx| this.open_find(window, cx)),
+ )),
+ });
+ let body = match document.filter(|_| finding) {
+ Some(viewer) => viewer.update(cx, |viewer, cx| {
+ viewer::find::panel(&viewer.find, width, true, cx)
+ }),
+ None => self.details.clone().into_any_element(),
+ };
+ v_flex()
+ .size_full()
+ .bg(background)
+ .border_l_1()
+ .border_color(border)
+ .child(header)
+ .child(div().flex_1().min_h_0().child(body))
+ .into_any_element()
+ }
+}
+
+impl Render for PopOut {
+ fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let dialog_layer = Root::render_dialog_layer(window, cx);
+ // The live width, so the find rows re-trim as the sidebar is dragged.
+ let width = self
+ .split
+ .read(cx)
+ .sizes()
+ .get(1)
+ .copied()
+ .unwrap_or(SIDEBAR);
+
+ v_flex()
+ .size_full()
+ .bg(cx.theme().background)
+ .text_color(cx.theme().foreground)
+ .key_context("PopOut")
+ .track_focus(&self.focus_handle)
+ .id("pop-out")
+ .role(Role::Group)
+ .aria_label("Pop-out viewer")
+ // Escape from Details would deselect the grid's rows; here it only ever means "back
+ // to Details", and past that, nothing.
+ .on_action(
+ cx.listener(|this, _: &table::Deselect, window, cx| this.show_details(window, cx)),
+ )
+ .on_action(
+ cx.listener(|this, _: &gpui_component::input::Escape, window, cx| {
+ this.show_details(window, cx)
+ }),
+ )
+ .on_key_down(cx.listener(Self::key_down))
+ .child(self.title_bar(cx))
+ .child(
+ div().flex_1().min_h_0().child(
+ h_resizable("pop-out-split")
+ .with_state(&self.split)
+ .child(resizable_panel().child(self.stage(cx)))
+ // `visible` rather than adding and removing the panel, so a collapse keeps
+ // the width the sidebar was dragged to.
+ .child(
+ resizable_panel()
+ .size(SIDEBAR)
+ .size_range(SIDEBAR_RANGE)
+ .visible(self.sidebar)
+ .child(self.sidebar(width, cx)),
+ ),
+ ),
+ )
+ .children(dialog_layer)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ // Never `use super::*` here — the parent's `use gpui::*` would shadow `#[test]`.
+ use gpui::{Entity, TestAppContext, VisualTestContext};
+ use gpui_component::table::TableState;
+ use table::{QrateTableDelegate, TableChanged};
+
+ use super::PopOut;
+
+ /// Three rows in a real grid, and the pop-out window watching it.
+ fn window_over_a_table(
+ cx: &mut TestAppContext,
+ ) -> (
+ Entity,
+ Entity>,
+ &mut VisualTestContext,
+ ) {
+ cx.update(|cx| {
+ gpui_component::init(cx);
+ let mut app = settings::AppSettings::default();
+ app.values.insert(
+ settings::AUTOSAVE_KEY.into(),
+ settings::Val::Text("off".into()),
+ );
+ cx.set_global(app);
+ cx.set_global(settings::SettingsPersistence::default());
+ cx.set_global(settings::project::CurrentProject {
+ file: std::env::temp_dir().join("qrate-pop-out.qrate"),
+ data: settings::project::ProjectData {
+ name: "Aderman Collection".into(),
+ columns: Vec::new(),
+ headers: vec!["Identifier".into(), "Title".into()],
+ rows: vec![
+ vec!["ADR-0042".into(), "Beacon Hill Park".into()],
+ vec!["ADR-0043".into(), "Sawmill crew".into()],
+ vec!["ADR-0044".into(), "Saanich mill".into()],
+ ],
+ row_ids: vec![11, 12, 13],
+ values: Default::default(),
+ },
+ });
+ });
+ cx.add_window_view(table::TablePanel::new);
+ let state = cx.update(|cx| {
+ cx.global::()
+ .0
+ .upgrade()
+ .expect("the table panel publishes its state handle")
+ });
+ let (pop_out, cx) = cx.add_window_view(PopOut::new);
+ (pop_out, state, cx)
+ }
+
+ fn select(
+ state: &Entity>,
+ rows: &[usize],
+ cx: &mut VisualTestContext,
+ ) {
+ state.update(cx, |state, cx| {
+ state.delegate_mut().select_only_row(rows[0]);
+ for &row in &rows[1..] {
+ state.delegate_mut().toggle_row(row);
+ }
+ cx.emit(TableChanged);
+ });
+ cx.run_until_parked();
+ }
+
+ /// The window's reason to exist: it follows the grid until pinned, then holds its item while
+ /// the grid moves on — and says where the grid went, so Jump can catch up.
+ #[gpui::test]
+ fn a_pinned_window_holds_its_item_while_the_table_moves_on(cx: &mut TestAppContext) {
+ let (pop_out, state, cx) = window_over_a_table(cx);
+
+ select(&state, &[0], cx);
+ pop_out.read_with(cx, |pop_out, _| assert_eq!(pop_out.rows, [0]));
+
+ pop_out.update_in(cx, |pop_out, window, cx| pop_out.toggle_pin(window, cx));
+ select(&state, &[1], cx);
+ pop_out.read_with(cx, |pop_out, cx| {
+ assert_eq!(pop_out.rows, [0], "pinned, the grid moving is not ours");
+ assert_eq!(
+ pop_out.readouts(cx),
+ ("Row 1 of 3".to_string(), Some(2)),
+ "and the chip says where the grid is"
+ );
+ });
+
+ pop_out.update_in(cx, |pop_out, window, cx| pop_out.jump(window, cx));
+ pop_out.read_with(cx, |pop_out, cx| {
+ assert_eq!(pop_out.rows, [1], "Jump moves this window to the grid");
+ assert!(pop_out.pinned.is_some(), "and leaves it pinned there");
+ assert_eq!(
+ pop_out.readouts(cx).1,
+ None,
+ "so there is nowhere left to jump"
+ );
+ });
+
+ pop_out.update_in(cx, |pop_out, window, cx| pop_out.toggle_pin(window, cx));
+ select(&state, &[2], cx);
+ pop_out.read_with(cx, |pop_out, _| {
+ assert_eq!(pop_out.rows, [2], "unpinned, it follows again")
+ });
+ }
+
+ /// A pin is held by row id, so the item stays put when the grid's selection changes — and the
+ /// sidebar describes the pinned item, not the grid's.
+ #[gpui::test]
+ fn the_sidebar_describes_what_the_window_shows(cx: &mut TestAppContext) {
+ let (pop_out, state, cx) = window_over_a_table(cx);
+
+ select(&state, &[1], cx);
+ pop_out.update_in(cx, |pop_out, window, cx| pop_out.toggle_pin(window, cx));
+ pop_out.read_with(cx, |pop_out, _| {
+ assert_eq!(pop_out.pinned.as_deref(), Some(&[12][..]))
+ });
+ select(&state, &[0, 2], cx);
+
+ let details = pop_out.read_with(cx, |pop_out, _| pop_out.details.clone());
+ details.read_with(cx, |details, cx| assert_eq!(details.picked(cx), [1]));
+ }
+
+ /// Several rows: the stage steps through them, wrapping, while the sidebar keeps all of them.
+ #[gpui::test]
+ fn the_stack_steps_through_the_selection_and_wraps(cx: &mut TestAppContext) {
+ let (pop_out, state, cx) = window_over_a_table(cx);
+ select(&state, &[0, 2], cx);
+
+ pop_out.update_in(cx, |pop_out, window, cx| {
+ assert_eq!(pop_out.front(), Some(0));
+ pop_out.step_stack(true, window, cx);
+ assert_eq!(pop_out.front(), Some(2));
+ pop_out.step_stack(true, window, cx);
+ assert_eq!(pop_out.front(), Some(0), "wraps past the end");
+ pop_out.step_stack(false, window, cx);
+ assert_eq!(pop_out.front(), Some(2), "and past the start");
+ assert_eq!(pop_out.rows, [0, 2], "without touching the selection");
+ let (readout, _) = pop_out.readouts(cx);
+ assert_eq!(readout, "Rows 1–3 · 2 selected");
+ assert_eq!(pop_out.title(cx).0, "2 items");
+ });
+
+ // A new selection starts at its first item rather than wherever the last one was left.
+ select(&state, &[1, 2], cx);
+ pop_out.read_with(cx, |pop_out, _| assert_eq!(pop_out.front(), Some(1)));
+ }
+
+ /// Nothing selected is a state of the stage, not an empty window.
+ #[gpui::test]
+ fn with_nothing_selected_the_window_says_so(cx: &mut TestAppContext) {
+ let (pop_out, _state, cx) = window_over_a_table(cx);
+ cx.update(|window, cx| window.draw(cx).clear(cx));
+ pop_out.read_with(cx, |pop_out, cx| {
+ assert!(pop_out.rows.is_empty());
+ assert!(pop_out.viewer.is_none());
+ assert_eq!(pop_out.readouts(cx).0, "No selection");
+ assert_eq!(
+ pop_out.title(cx),
+ (String::new(), "Aderman Collection — qrate".to_string())
+ );
+ });
+ }
+}
diff --git a/crates/workspace/src/viewer/find.rs b/crates/workspace/src/viewer/find.rs
index b1cf3d41..c40c3b33 100644
--- a/crates/workspace/src/viewer/find.rs
+++ b/crates/workspace/src/viewer/find.rs
@@ -82,7 +82,9 @@ impl Find {
///
/// One row per hit rather than a box holding the whole document — a reader wants to see *where* a
/// word turns up, and a wall of extracted text answers a question nobody asked.
-pub fn panel(find: &Find, width: Pixels, cx: &mut Context) -> AnyElement {
+///
+/// `docked` is the pop-out's sidebar tab, which has its own edge and no controls above it.
+pub fn panel(find: &Find, width: Pixels, docked: bool, cx: &mut Context) -> AnyElement {
let empty = find.hits.is_empty();
let rows: Vec<_> = find
.hits
@@ -96,11 +98,11 @@ pub fn panel(find: &Find, width: Pixels, cx: &mut Context) -> AnyElement
.gap_2()
.p_2()
// Clears the control cluster pinned to the overlay's top-right corner.
- .pt_12()
+ .when(!docked, |panel| {
+ panel.pt_12().border_l_1().border_color(cx.theme().border)
+ })
.occlude()
.bg(cx.theme().background)
- .border_l_1()
- .border_color(cx.theme().border)
.child(
h_flex()
.gap_1()
diff --git a/crates/workspace/src/viewer/mod.rs b/crates/workspace/src/viewer/mod.rs
index 3e9f9fa5..829f41c7 100644
--- a/crates/workspace/src/viewer/mod.rs
+++ b/crates/workspace/src/viewer/mod.rs
@@ -10,7 +10,7 @@
//! The two neighbours hold the parts with rules in them: [`find`] owns the search state, and
//! [`highlight`] turns a hit's position on the page into a position on the screen.
-mod find;
+pub(crate) mod find;
mod highlight;
pub(crate) mod transport;
@@ -51,6 +51,9 @@ pub enum Scope {
Workspace,
/// Over the centre panel only, leaving the docked panels visible.
Centre,
+ /// The pop-out window's stage. Never the global viewer: that window owns its viewer, and
+ /// follows the selection itself so that it can stop following while pinned.
+ PopOut,
}
/// The currently-open viewer and the focus to restore. Both mount slots observe this.
@@ -71,24 +74,42 @@ pub fn viewer_in(scope: Scope, cx: &App) -> Option> {
/// Opens `path` in the shared viewer overlay, replacing any viewer already open.
pub fn open_viewer(path: PathBuf, scope: Scope, window: &mut Window, cx: &mut App) {
- let document = preview::has_text(&path);
- let video = preview::has_video(&path);
- let details = preview::describe(&path);
- let probe_path = path.clone();
let return_focus = cx
.try_global::()
.and_then(|active| active.return_focus.clone())
.or_else(|| window.focused(cx));
+ let viewer = build(path, scope, window, cx);
+ cx.set_global(ActiveViewer {
+ viewer: Some(viewer),
+ return_focus,
+ });
+}
+
+/// A viewer for `path`, not yet mounted anywhere.
+pub(crate) fn build(
+ path: PathBuf,
+ scope: Scope,
+ window: &mut Window,
+ cx: &mut App,
+) -> Entity {
+ let document = preview::has_text(&path);
+ let video = preview::has_video(&path);
+ let details = preview::describe(&path);
+ let probe_path = path.clone();
let table = cx
.try_global::()
- .and_then(|handle| handle.0.upgrade());
+ .and_then(|handle| handle.0.upgrade())
+ .filter(|_| scope != Scope::PopOut);
let needs = preview::missing(&path);
let viewer = cx.new(|cx| Viewer {
needs,
_components: cx.observe_global_in::(
window,
|this: &mut Viewer, window, cx| {
- if this.needs.is_some() && preview::missing(&this.path).is_none() {
+ if this.scope != Scope::PopOut
+ && this.needs.is_some()
+ && preview::missing(&this.path).is_none()
+ {
open_viewer(this.path.clone(), this.scope, window, cx);
}
cx.notify();
@@ -149,16 +170,13 @@ pub fn open_viewer(path: PathBuf, scope: Scope, window: &mut Window, cx: &mut Ap
});
})
.detach();
- cx.set_global(ActiveViewer {
- viewer: Some(viewer),
- return_focus,
- });
+ viewer
}
/// Select the next row, by `delta`, in the view's order that has something to preview. During a
/// search the view is its hits, so this steps through the results. The open viewer follows the
/// selection to that row's file.
-fn step_row(delta: isize, cx: &mut App) {
+pub(crate) fn step_row(delta: isize, cx: &mut App) {
let Some(state) = cx
.try_global::()
.and_then(|handle| handle.0.upgrade())
@@ -189,7 +207,7 @@ fn step_row(delta: isize, cx: &mut App) {
}
/// The file `row` links to, if the viewer can show it.
-fn previewable(delegate: &table::QrateTableDelegate, row: usize) -> Option {
+pub(crate) fn previewable(delegate: &table::QrateTableDelegate, row: usize) -> Option {
delegate
.row_image(row)
.filter(|file| preview::can_preview(file))
@@ -197,7 +215,7 @@ fn previewable(delegate: &table::QrateTableDelegate, row: usize) -> Option,
scope: Scope,
@@ -240,12 +258,12 @@ pub struct Viewer {
pages: usize,
/// Whether this file is a document at all, which is a different question from whether it has
/// more than one page — a one-page PDF is still a document, and still says "1 / 1".
- document: bool,
+ pub(crate) document: bool,
/// Known from the extension immediately, before the duration probe finishes.
video: bool,
/// The playback transport, present exactly when the file is a recording. Gated on the format
/// for the same reason `document` is: a silent tape is still audio and still gets a transport.
- transport: Option,
+ pub(crate) transport: Option,
/// The scrubber, present exactly when the file is a video ffmpeg could measure.
///
scrubber: Option>,
@@ -258,18 +276,18 @@ pub struct Viewer {
offset: Point,
/// Last pointer position while dragging; `None` when not panning.
drag_from: Option>,
- focus_handle: FocusHandle,
+ pub(crate) focus_handle: FocusHandle,
/// Grabs focus on first render so Escape reaches [`Self`]; set once so we don't re-focus.
focused: bool,
- find: Find,
- /// Whether the find panel is showing.
- find_open: bool,
+ pub(crate) find: Find,
+ /// Whether the find panel is showing — in the pop-out, whether its sidebar is on Find.
+ pub(crate) find_open: bool,
/// The split between the page and the find panel, owned by `gpui_component`'s resizable — it
/// carries the drag handle, the sizing and the propagation rules, none of which are ours to
/// reinvent.
split: Entity,
/// The optional part this file needs and does not have, which the viewer offers to install.
- needs: Option,
+ pub(crate) needs: Option,
/// Opens the file again once that part is installed, so its pages and timeline are read.
_components: Subscription,
/// Swaps in the selected row's file when the selection moves, from the find bar, the arrows or
@@ -341,8 +359,20 @@ impl Viewer {
self.offset = Point::default();
}
+ /// Whether the bottom pill has anything to hold: page controls, a transport or a scrubber.
+ pub(crate) fn has_controls(&self) -> bool {
+ self.document || self.pages > 1 || self.transport.is_some() || self.scrubber.is_some()
+ }
+
+ /// Put the find panel away and hand the keys back to the page.
+ pub(crate) fn close_find(&mut self, window: &mut Window, cx: &mut Context) {
+ self.find_open = false;
+ window.focus(&self.focus_handle, cx);
+ cx.notify();
+ }
+
/// Show the find panel, building its query box the first time.
- fn open_find(&mut self, window: &mut Window, cx: &mut Context) {
+ pub(crate) fn open_find(&mut self, window: &mut Window, cx: &mut Context) {
self.find_open = true;
// Asked once, on first open: it opens the document, and the answer cannot change while
@@ -485,6 +515,7 @@ impl Render for Viewer {
let banner = self
.needs
.and_then(|id| crate::component_banner::banner(id, cx));
+ let popped = self.scope == Scope::PopOut;
div()
.track_focus(&self.focus_handle)
@@ -493,12 +524,11 @@ impl Render for Viewer {
.role(Role::Group)
.aria_label("File viewer")
// The find panel is the inner layer, so Escape dismisses it before the viewer.
+ // The pop-out has no overlay to close: its window is what the viewer is.
.on_action(cx.listener(|this, _: &CloseViewerLayer, window, cx| {
if this.find_open {
- this.find_open = false;
- window.focus(&this.focus_handle, cx);
- cx.notify();
- } else {
+ this.close_find(window, cx);
+ } else if this.scope != Scope::PopOut {
close_viewer(window, cx);
}
}))
@@ -516,23 +546,28 @@ impl Render for Viewer {
.occlude()
// Dim what's behind so the file reads as the focus. Not a theme colour: a light
// theme's background is white, which hides nothing and lights the room around a photo.
- .bg(black().opacity(0.85))
+ // The pop-out's stage paints the same backdrop, whatever it is showing.
+ .when(!popped, |viewer| viewer.bg(black().opacity(0.85)))
.on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| {
// Paging keys are only ours while the viewer itself holds focus: with the query
// box focused, left/right belong to its caret.
let reading = this.focus_handle.is_focused(window);
+ // Alt+←/→ steps the pop-out's stack of selected items, not the pages.
+ let paging = reading && this.scrubber.is_none() && !ev.keystroke.modifiers.alt;
+ // The pop-out's window handles Find and the rows, since both reach past the page.
+ let overlay = this.scope != Scope::PopOut;
match ev.keystroke.key.as_str() {
- "f" if ev.keystroke.modifiers.secondary() && this.document => {
+ "f" if overlay && ev.keystroke.modifiers.secondary() && this.document => {
this.open_find(window, cx);
}
// The keys anyone reading a document reaches for first. Harmless on a photo,
// where there is only ever one page to move between — but kept off a video,
// whose position is the scrubber's, and whose thumb would be left behind.
- "left" | "pageup" if reading && this.scrubber.is_none() => {
+ "left" | "pageup" if paging => {
this.turn_page(-1);
cx.notify();
}
- "right" | "pagedown" if reading && this.scrubber.is_none() => {
+ "right" | "pagedown" if paging => {
this.turn_page(1);
cx.notify();
}
@@ -552,7 +587,7 @@ impl Render for Viewer {
this.set_zoom(1.0);
cx.notify();
}
- "up" | "down" if reading => {
+ "up" | "down" if reading && overlay => {
step_row(if ev.keystroke.key == "up" { -1 } else { 1 }, cx);
}
_ => {}
@@ -638,8 +673,8 @@ impl Render for Viewer {
resizable_panel()
.size(PANEL)
.size_range(PANEL_RANGE)
- .visible(self.find_open)
- .child(find::panel(&self.find, panel_width, cx)),
+ .visible(self.find_open && !popped)
+ .child(find::panel(&self.find, panel_width, false, cx)),
),
)
.child(
@@ -673,10 +708,7 @@ impl Render for Viewer {
}))
// Bottom pill: page controls (even for 1 page, or a TIFF stack), transport or scrubber.
.when(
- self.document
- || self.pages > 1
- || self.transport.is_some()
- || self.scrubber.is_some(),
+ self.has_controls(),
|viewer| {
viewer.child(
div()
@@ -790,8 +822,8 @@ impl Render for Viewer {
},
)
// Rows, not pages: the neighbouring files in the view's order, which during a search are
- // the neighbouring results.
- .child(
+ // the neighbouring results. The pop-out keeps these in its title bar instead.
+ .children((!popped).then(|| {
div()
.absolute()
.bottom_4()
@@ -816,9 +848,10 @@ impl Render for Viewer {
.small()
.tooltip("Next row (↓)")
.on_click(|_, _, cx| step_row(1, cx)),
- ),
- )
- .child(
+ )
+ }))
+ // Zoom has its keys and the wheel there; find is a sidebar tab, and closing is the window's.
+ .children((!popped).then(|| {
div()
.absolute()
.top_4()
@@ -844,9 +877,7 @@ impl Render for Viewer {
})
.on_click(cx.listener(|this, _, window, cx| {
if this.find_open {
- this.find_open = false;
- window.focus(&this.focus_handle, cx);
- cx.notify();
+ this.close_find(window, cx);
} else {
this.open_find(window, cx);
}
@@ -882,8 +913,8 @@ impl Render for Viewer {
.small()
.tooltip("Close (Esc)")
.on_click(cx.listener(|_, _, window, cx| close_viewer(window, cx))),
- ),
- )
+ )
+ }))
}
}
@@ -895,7 +926,7 @@ mod tests {
VisualTestContext, Window, div,
};
- use crate::viewer::{Scope, close_viewer, next_row, open_viewer, viewer_in};
+ use crate::viewer::{Scope, build, close_viewer, next_row, open_viewer, viewer_in};
#[test]
fn stepping_rows_skips_what_cannot_be_previewed_and_stops_at_the_ends() {
@@ -943,6 +974,35 @@ mod tests {
});
}
+ /// The pop-out owns its viewer. Building one must not take over the overlay's slot, and
+ /// closing the overlay must leave it alone.
+ #[gpui::test]
+ fn a_pop_out_viewer_and_the_overlay_do_not_touch_each_other(cx: &mut TestAppContext) {
+ let cx = with_window(cx);
+ cx.update(|window, cx| {
+ open_viewer(
+ "/nonexistent/overlay.jpg".into(),
+ Scope::Workspace,
+ window,
+ cx,
+ );
+ let popped = build("/nonexistent/popped.jpg".into(), Scope::PopOut, window, cx);
+ let overlay = viewer_in(Scope::Workspace, cx).expect("the overlay is still open");
+ assert_ne!(overlay.entity_id(), popped.entity_id());
+ assert!(
+ viewer_in(Scope::PopOut, cx).is_none(),
+ "never a global viewer"
+ );
+
+ close_viewer(window, cx);
+ assert!(viewer_in(Scope::Workspace, cx).is_none());
+ assert_eq!(
+ popped.read(cx).path,
+ std::path::PathBuf::from("/nonexistent/popped.jpg")
+ );
+ });
+ }
+
#[gpui::test]
fn closing_the_viewer_restores_its_callers_focus(cx: &mut TestAppContext) {
let cx = with_window(cx);
From 2c3b3ff95178d70933ca93073344cc29fbdaae2b Mon Sep 17 00:00:00 2001
From: devnull03 <56480041+devnull03@users.noreply.github.com>
Date: Mon, 28 Sep 2026 18:58:16 +0000
Subject: [PATCH 02/31] fix(workspace): pop-out playback, per-project placement
and pin cost
- 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.
---
crates/settings/src/lib.rs | 5 +-
crates/workspace/src/pop_out.rs | 187 +++++++++++++++++++++++++-------
2 files changed, 146 insertions(+), 46 deletions(-)
diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs
index e47eb3b5..d6d19bae 100644
--- a/crates/settings/src/lib.rs
+++ b/crates/settings/src/lib.rs
@@ -35,9 +35,6 @@ use crate::path_picker::PathPickerApp;
/// `AppSettings` value key for the Settings window's last size (a JSON [`MainWindowBounds`]).
pub const SETTINGS_WINDOW_BOUNDS_KEY: &str = "settings_window_bounds";
-/// The same for the pop-out viewer, whose display is the point of it: it reopens on the monitor
-/// the archivist moved it to.
-pub const POP_OUT_WINDOW_BOUNDS_KEY: &str = "pop_out_window_bounds";
/// Setting key for autosave behavior: `"timed"` (buffered, the default), `"immediate"`,
/// or `"off"`. Read by the table crate to decide when a committed cell edit reaches disk.
@@ -542,7 +539,7 @@ impl AppSettings {
}
pub fn set_text(key: &'static str, val: SharedString, cx: &mut App) {
- if key != SETTINGS_WINDOW_BOUNDS_KEY && key != POP_OUT_WINDOW_BOUNDS_KEY {
+ if key != SETTINGS_WINDOW_BOUNDS_KEY {
log::debug!("settings: app text changed key={key}");
}
Self::update(cx, |s| {
diff --git a/crates/workspace/src/pop_out.rs b/crates/workspace/src/pop_out.rs
index dac92476..ccde176e 100644
--- a/crates/workspace/src/pop_out.rs
+++ b/crates/workspace/src/pop_out.rs
@@ -20,8 +20,8 @@ use gpui_component::{
table::TableState,
v_flex,
};
+use settings::MainWindowBounds;
use settings::project::{CurrentProject, RowId};
-use settings::{AppSettings, MainWindowBounds, POP_OUT_WINDOW_BOUNDS_KEY};
use table::{QrateTableDelegate, Selection, TableChanged, TablePanelHandle, TableStateHandle};
use crate::panels::DetailsPanel;
@@ -36,16 +36,24 @@ const MIN_SIZE: Size = Size {
width: px(800.),
height: px(600.),
};
-const DEFAULT_SIZE: Size = Size {
- width: px(1120.),
- height: px(700.),
-};
/// Height of the sidebar's header strip, the Details title or the Details | Find tabs.
const HEADER_H: Pixels = px(30.);
/// The stage's own text colours. Not theme colours, for the reason the backdrop is not one.
const STAGE_FG: u32 = 0xe8e8e8;
const STAGE_MUTED: u32 = 0xa3a3a3;
+/// `.qrate` setting key for the window's last size and display, as a JSON [`MainWindowBounds`] —
+/// per project, the same way the main window keeps its own.
+const BOUNDS_KEY: &str = "pop_out_window_bounds";
+
+/// Silence `viewer`'s recording as it goes — only its own: the player is shared by the whole app,
+/// and may be playing something the main window started.
+fn stop_playing(viewer: &Entity, cx: &mut App) {
+ if preview::playback::playing(cx) == Some(viewer.read(cx).path.as_path()) {
+ preview::playback::stop(cx);
+ }
+}
+
/// The open pop-out window. There is one per project, and one project open at a time.
#[derive(Default)]
pub(crate) struct PopOutWindow(Option);
@@ -66,30 +74,14 @@ pub fn open(cx: &mut App) {
{
return;
}
- // Size and display only, like the other windows: the display is the point of this one.
- let saved = AppSettings::get(cx)
- .values
- .get(POP_OUT_WINDOW_BOUNDS_KEY)
- .map(|value| value.text())
+ let saved = cx
+ .try_global::()
+ .and_then(|p| settings::project::read_setting(&p.file, BOUNDS_KEY).ok())
+ .flatten()
.and_then(|raw| serde_json::from_str::(&raw).ok());
- let display = saved.as_ref().and_then(|b| b.display_id).and_then(|raw| {
- cx.displays()
- .into_iter()
- .find(|display| u64::from(display.id()) == raw)
- .map(|display| display.id())
- });
- let win_size = saved
- .filter(|b| {
- b.width.is_finite()
- && b.height.is_finite()
- && px(b.width) >= MIN_SIZE.width
- && px(b.height) >= MIN_SIZE.height
- })
- .map_or(DEFAULT_SIZE, |b| size(px(b.width), px(b.height)));
+ let (bounds, display) = MainWindowBounds::startup_placement(saved.as_ref(), cx);
let options = WindowOptions {
- window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
- display, win_size, cx,
- ))),
+ window_bounds: Some(WindowBounds::Windowed(bounds)),
display_id: display,
window_min_size: Some(MIN_SIZE),
..TitleBar::window_options()
@@ -151,12 +143,8 @@ impl PopOut {
let me = window.window_handle();
cx.on_release(move |this: &mut Self, cx| {
// A recording playing here would otherwise go on with nothing on screen to stop it.
- if this
- .viewer
- .as_ref()
- .is_some_and(|viewer| viewer.read(cx).transport.is_some())
- {
- preview::playback::stop(cx);
+ if let Some(viewer) = &this.viewer {
+ stop_playing(viewer, cx);
}
// Only this window's own entry: a new pop-out may already have replaced it.
if cx
@@ -189,10 +177,14 @@ impl PopOut {
this.sync(window, cx);
}
}),
- cx.observe_window_bounds(window, |_, window, cx| {
+ // Debounced by the writer: this fires on every pixel of a drag.
+ cx.observe_window_bounds(window, |this, window, cx| {
+ let Some(file) = &this.project else {
+ return;
+ };
let bounds = MainWindowBounds::capture_from_window(window, cx);
if let Ok(json) = serde_json::to_string(&bounds) {
- AppSettings::set_text(POP_OUT_WINDOW_BOUNDS_KEY, json.into(), cx);
+ settings::project::queue_write(file, BOUNDS_KEY, &json, cx);
}
}),
];
@@ -245,11 +237,25 @@ impl PopOut {
fn sync(&mut self, window: &mut Window, cx: &mut Context) {
let rows = self.table().map_or_else(Vec::new, |state| {
let delegate = state.read(cx).delegate();
+ let all = delegate.row_ids();
match &self.pinned {
- Some(ids) => ids
- .iter()
- .filter_map(|id| delegate.row_ids().iter().position(|row| row == id))
- .collect(),
+ // Where the pin was last found, while every row is still there: a cell edit is the
+ // common change, and it moves nothing.
+ Some(ids)
+ if ids.len() == self.rows.len()
+ && ids
+ .iter()
+ .zip(&self.rows)
+ .all(|(id, &row)| all.get(row) == Some(id)) =>
+ {
+ self.rows.clone()
+ }
+ // Rows were added, removed or moved: one pass to find the pin again.
+ Some(ids) => {
+ let at: std::collections::HashMap<_, _> =
+ all.iter().enumerate().map(|(row, id)| (*id, row)).collect();
+ ids.iter().filter_map(|id| at.get(id).copied()).collect()
+ }
None => delegate.selected_source_rows(),
}
});
@@ -270,6 +276,9 @@ impl PopOut {
.zip(self.table())
.and_then(|(row, state)| viewer::previewable(state.read(cx).delegate(), row));
if self.viewer.as_ref().map(|viewer| &viewer.read(cx).path) != file.as_ref() {
+ if let Some(leaving) = &self.viewer {
+ stop_playing(leaving, cx);
+ }
self.viewer = file.map(|file| viewer::build(file, Scope::PopOut, window, cx));
self._viewer_sub = self
.viewer
@@ -443,11 +452,13 @@ impl PopOut {
let table_row = self
.pinned
.as_ref()
- .filter(|_| delegate.selected_source_rows() != self.rows)
.and_then(|_| match delegate.selection() {
- Some(Selection::Cell { row, .. } | Selection::Row(row)) => delegate.view_row(row),
+ Some(Selection::Cell { row, .. } | Selection::Row(row)) => Some(row),
_ => None,
})
+ // Only the grid's cursor, not its whole selection: this runs on every repaint.
+ .filter(|row| !self.rows.contains(row))
+ .and_then(|row| delegate.view_row(row))
.map(|view| view + 1);
(readout, table_row)
}
@@ -992,7 +1003,7 @@ mod tests {
use gpui_component::table::TableState;
use table::{QrateTableDelegate, TableChanged};
- use super::PopOut;
+ use super::{PopOut, stop_playing};
/// Three rows in a real grid, and the pop-out window watching it.
fn window_over_a_table(
@@ -1108,6 +1119,37 @@ mod tests {
details.read_with(cx, |details, cx| assert_eq!(details.picked(cx), [1]));
}
+ /// A pin is an item, not a position: a row added above it leaves the window on the same item.
+ #[gpui::test]
+ fn a_pin_stays_on_its_item_when_rows_are_added_above_it(cx: &mut TestAppContext) {
+ let (pop_out, state, cx) = window_over_a_table(cx);
+ select(&state, &[1], cx);
+ pop_out.update_in(cx, |pop_out, window, cx| pop_out.toggle_pin(window, cx));
+
+ state.update(cx, |state, cx| {
+ state.delegate_mut().set_data(
+ &["Identifier".into(), "Title".into()],
+ &[10, 11, 12, 13],
+ &[
+ vec!["ADR-0041".into(), "Added above".into()],
+ vec!["ADR-0042".into(), "Beacon Hill Park".into()],
+ vec!["ADR-0043".into(), "Sawmill crew".into()],
+ vec!["ADR-0044".into(), "Saanich mill".into()],
+ ],
+ );
+ cx.emit(TableChanged);
+ });
+ cx.run_until_parked();
+
+ pop_out.read_with(cx, |pop_out, _| {
+ assert_eq!(
+ pop_out.rows,
+ [2],
+ "row id 12 moved down one, and the pin with it"
+ )
+ });
+ }
+
/// Several rows: the stage steps through them, wrapping, while the sidebar keeps all of them.
#[gpui::test]
fn the_stack_steps_through_the_selection_and_wraps(cx: &mut TestAppContext) {
@@ -1133,6 +1175,67 @@ mod tests {
pop_out.read_with(cx, |pop_out, _| assert_eq!(pop_out.front(), Some(1)));
}
+ struct Blank;
+
+ impl gpui::Render for Blank {
+ fn render(
+ &mut self,
+ _: &mut gpui::Window,
+ _: &mut gpui::Context,
+ ) -> impl gpui::IntoElement {
+ gpui::div()
+ }
+ }
+
+ /// Replacing or closing the pop-out's viewer stops only its own recording. The player is the
+ /// app's, and what it is playing may be the main window's.
+ ///
+ /// Like the viewer's playback test, this is the real path on a machine with an output device
+ /// and holds trivially on one without.
+ #[gpui::test]
+ fn leaving_a_file_stops_only_its_own_recording(cx: &mut TestAppContext) {
+ let data = 8000usize * 2;
+ let mut wav = Vec::new();
+ wav.extend(b"RIFF");
+ wav.extend((36 + data as u32).to_le_bytes());
+ wav.extend(b"WAVEfmt ");
+ wav.extend(16u32.to_le_bytes());
+ wav.extend(1u16.to_le_bytes());
+ wav.extend(1u16.to_le_bytes());
+ wav.extend(8000u32.to_le_bytes());
+ wav.extend(16000u32.to_le_bytes());
+ wav.extend(2u16.to_le_bytes());
+ wav.extend(16u16.to_le_bytes());
+ wav.extend(b"data");
+ wav.extend((data as u32).to_le_bytes());
+ wav.extend(std::iter::repeat_n(0u8, data));
+ let main = std::env::temp_dir().join("qrate-pop-out-main.wav");
+ let shown = std::env::temp_dir().join("qrate-pop-out-shown.wav");
+ std::fs::write(&main, &wav).unwrap();
+ std::fs::write(&shown, &wav).unwrap();
+
+ let (_, cx) = cx.add_window_view(|_, _| Blank);
+ cx.update(|window, cx| {
+ let popped =
+ crate::viewer::build(shown.clone(), crate::viewer::Scope::PopOut, window, cx);
+ preview::playback::play(&main, cx);
+ let before = preview::playback::playing(cx).map(|path| path.to_path_buf());
+ stop_playing(&popped, cx);
+ assert_eq!(
+ preview::playback::playing(cx).map(|path| path.to_path_buf()),
+ before,
+ "the main window's recording plays on"
+ );
+
+ preview::playback::play(&shown, cx);
+ stop_playing(&popped, cx);
+ assert!(preview::playback::playing(cx).is_none(), "its own stops");
+ });
+
+ let _ = std::fs::remove_file(&main);
+ let _ = std::fs::remove_file(&shown);
+ }
+
/// Nothing selected is a state of the stage, not an empty window.
#[gpui::test]
fn with_nothing_selected_the_window_says_so(cx: &mut TestAppContext) {
From 4c3072e67385e673e5e5a2b07d4ab664f733b6bc Mon Sep 17 00:00:00 2001
From: devnull03 <56480041+devnull03@users.noreply.github.com>
Date: Mon, 28 Sep 2026 18:58:16 +0000
Subject: [PATCH 03/31] fix(workspace): settle viewer probes and searches; aim
Details edits 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.
---
crates/workspace/src/panels/details.rs | 98 ++++++++++++++++++++++----
crates/workspace/src/viewer/find.rs | 2 +
crates/workspace/src/viewer/mod.rs | 45 ++++++++----
3 files changed, 116 insertions(+), 29 deletions(-)
diff --git a/crates/workspace/src/panels/details.rs b/crates/workspace/src/panels/details.rs
index 58e3121c..fe334ec8 100644
--- a/crates/workspace/src/panels/details.rs
+++ b/crates/workspace/src/panels/details.rs
@@ -100,10 +100,11 @@ pub struct DetailsPanel {
/// The field editor, shared across whichever field is open — the same one-per-panel
/// arrangement the grid uses for its cell editor.
editor: Entity,
- /// `(source_rows, data_col)` of the field being edited, in the grid's own coordinates so a
- /// filter change between opening and committing can't redirect the write. Several rows when
- /// the field belongs to a bundle: one edit box writing the same value down the selection.
- editing: Option<(Vec, usize, SharedString)>,
+ /// `(row ids, data_col, header)` of the field being edited. Ids rather than source rows, so a
+ /// row added or removed before the commit — from either window — can't redirect the write.
+ /// Several rows when the field belongs to a bundle: one edit box writing the same value down
+ /// the selection.
+ editing: Option<(Vec, usize, SharedString)>,
/// Which of the selected items the preview stack is showing, and whether the pointer is over
/// it — the step arrows only exist while it is, so they never cover the photo at rest.
stack: usize,
@@ -381,14 +382,19 @@ impl DetailsPanel {
) {
// Whatever was open loses focus rather than being silently dropped.
self.commit(cx);
- let rows = self.picked(cx);
+ let picked = self.picked(cx);
let located = self
.state
.as_ref()
.and_then(|w| w.upgrade())
- .and_then(|s| s.read(cx).delegate().data_col(header))
- .filter(|_| !rows.is_empty());
- let Some(col) = located else {
+ .filter(|_| !picked.is_empty())
+ .and_then(|s| {
+ let delegate = s.read(cx).delegate();
+ let ids = delegate.row_ids();
+ let rows = picked.iter().filter_map(|&row| ids.get(row).copied());
+ Some((rows.collect::>(), delegate.data_col(header)?))
+ });
+ let Some((rows, col)) = located else {
log::warn!("details: no column named {header} to edit");
return;
};
@@ -830,15 +836,38 @@ impl DetailsPanel {
/// validation and undo stay single-sourced. Clearing `editing` first keeps the `TableChanged`
/// this provokes from re-entering as a second commit.
fn commit(&mut self, cx: &mut Context) {
- let Some((rows, col, _)) = self.editing.take() else {
+ let Some((ids, _, header)) = self.editing.take() else {
return;
};
let value = self.editor.read(cx).value().clone();
+ // Resolved now, not when the editor opened: rows and columns may have moved since.
+ let Some(cells) = self
+ .state
+ .as_ref()
+ .and_then(|w| w.upgrade())
+ .and_then(|state| {
+ let delegate = state.read(cx).delegate();
+ let col = delegate.data_col(&header)?;
+ let at: std::collections::HashMap<_, _> = delegate
+ .row_ids()
+ .iter()
+ .enumerate()
+ .map(|(row, id)| (*id, row))
+ .collect();
+ let rows = ids.iter().filter_map(|id| at.get(id).copied());
+ Some(
+ rows.map(|row| (row, col, value.clone()))
+ .collect::>(),
+ )
+ })
+ else {
+ log::warn!("details: the field being edited is gone, so the edit was dropped");
+ return;
+ };
// One batch, so setting a field across a bundle is a single undo step — and `apply_edit`
// drops the rows whose text this didn't change, so committing an untouched shared field
// costs nothing.
- let cells = rows.into_iter().map(|row| (row, col, value.clone()));
- table::write_cells(cells.collect(), settings::history::Origin::Details, cx);
+ table::write_cells(cells, settings::history::Origin::Details, cx);
cx.notify();
}
@@ -2034,7 +2063,7 @@ mod tests {
panel.edit_field(&"Title".into(), &"".into(), window, cx);
assert_eq!(
panel.editing,
- Some((vec![0, 2], 1, "Title".into())),
+ Some((vec![1, 3], 1, "Title".into())),
"the write is aimed at both selected items"
);
panel
@@ -2057,6 +2086,47 @@ mod tests {
assert_eq!(titles(cx), vec!["one", "two", "three"]);
}
+ /// A row added above the item while its field is open must not redirect the write onto
+ /// whichever row slid into the old position — the other window can do this mid-edit.
+ #[gpui::test]
+ fn an_edit_lands_on_its_item_after_a_row_is_added_above_it(cx: &mut TestAppContext) {
+ project_with_table(cx);
+ let state = cx.update(|cx| {
+ cx.try_global::()
+ .and_then(|h| h.0.upgrade())
+ .expect("the table panel publishes its state handle")
+ });
+ let (panel, cx) = cx.add_window_view(DetailsPanel::new);
+ state.update(cx, |state, cx| state.set_selected_cell(1, 2, cx));
+
+ panel.update_in(cx, |panel, window, cx| {
+ panel.edit_field(&"Title".into(), &"two".into(), window, cx);
+ panel.editor.update(cx, |editor, cx| {
+ editor.set_value("two, revised", window, cx)
+ });
+ });
+ state.update(cx, |state, _| {
+ state.delegate_mut().set_data(
+ &["Medium".into(), "Title".into()],
+ &[9, 1, 2, 3],
+ &[
+ vec!["Photo".into(), "new".into()],
+ vec!["Film".into(), "one".into()],
+ vec!["Video".into(), "two".into()],
+ vec!["Film".into(), "three".into()],
+ ],
+ )
+ });
+ panel.update(cx, |panel, cx| panel.commit(cx));
+
+ let titles = state.read_with(cx, |s, _| {
+ (0..4)
+ .map(|row| s.delegate().cell(row, 1).cloned().unwrap_or_default())
+ .collect::>()
+ });
+ assert_eq!(titles, vec!["new", "one", "two, revised", "three"]);
+ }
+
/// The DoD: a field edited in the panel lands in the grid, and undo — the grid's own history,
/// which the panel must not have bypassed — puts it back. Also pins the name→column lookup:
/// each field must write its own column, not the one at its position in the list.
@@ -2076,7 +2146,7 @@ mod tests {
panel.edit_field(&"Title".into(), &"two".into(), window, cx);
assert_eq!(
panel.editing,
- Some((vec![1], 1, "Title".into())),
+ Some((vec![2], 1, "Title".into())),
"Title is data column 1"
);
panel.editor.update(cx, |editor, cx| {
@@ -2087,7 +2157,7 @@ mod tests {
panel.edit_field(&"Medium".into(), &"Video".into(), window, cx);
assert_eq!(
panel.editing,
- Some((vec![1], 0, "Medium".into())),
+ Some((vec![2], 0, "Medium".into())),
"Medium is data column 0"
);
panel.editing = None;
diff --git a/crates/workspace/src/viewer/find.rs b/crates/workspace/src/viewer/find.rs
index c40c3b33..a0841bc3 100644
--- a/crates/workspace/src/viewer/find.rs
+++ b/crates/workspace/src/viewer/find.rs
@@ -34,6 +34,8 @@ pub struct Find {
/// Whether the document has any text to search. `None` until checked. A scan that was never
/// OCR'd finds nothing for every query, and "No matches" would blame the query for it.
pub layered: Option,
+ /// The search for `query`. Replaced by the next one, which drops it if it has not begun.
+ pub task: Option>,
}
impl Find {
diff --git a/crates/workspace/src/viewer/mod.rs b/crates/workspace/src/viewer/mod.rs
index 829f41c7..c3bb5cdf 100644
--- a/crates/workspace/src/viewer/mod.rs
+++ b/crates/workspace/src/viewer/mod.rs
@@ -42,6 +42,12 @@ pub const VIEWER_CONTEXT: &str = "Viewer";
const PANEL: Pixels = px(384.);
const PANEL_RANGE: std::ops::Range = px(240.)..px(720.);
+/// How long a file has to stay open before its pages or duration are probed, and a query has to
+/// stay typed before it is searched. Stepping through videos or typing a word then starts one
+/// ffmpeg or one PDFium search, not one per file or per keystroke — neither can be stopped once
+/// running.
+const SETTLE: Duration = Duration::from_millis(150);
+
/// Which slot mounts the viewer. Two, because they answer different asks: the Details panel's
/// button means "show me this as big as the window allows", while a gallery card means "show me
/// this instead of the thumbnails" — the side panels stay readable beside it.
@@ -151,25 +157,29 @@ pub(crate) fn build(
find: Find::default(),
find_open: false,
split: cx.new(|_| ResizableState::default()),
- });
- let probe = cx.background_executor().spawn(async move {
- let seconds = preview::video_duration(&probe_path);
- let pages = seconds.map_or_else(
- || preview::page_count(&probe_path),
- |seconds| seconds.max(1) as usize,
- );
- (seconds, pages)
+ _probe: None,
});
let weak = viewer.downgrade();
- cx.spawn(async move |cx| {
- let (seconds, pages) = probe.await;
+ let probe = cx.spawn(async move |cx| {
+ cx.background_executor().timer(SETTLE).await;
+ let (seconds, pages) = cx
+ .background_executor()
+ .spawn(async move {
+ let seconds = preview::video_duration(&probe_path);
+ let pages = seconds.map_or_else(
+ || preview::page_count(&probe_path),
+ |seconds| seconds.max(1) as usize,
+ );
+ (seconds, pages)
+ })
+ .await;
cx.update(|cx| {
if let Some(viewer) = weak.upgrade() {
viewer.update(cx, |viewer, cx| viewer.install_timeline(seconds, pages, cx));
}
});
- })
- .detach();
+ });
+ viewer.update(cx, |viewer, _| viewer._probe = Some(probe));
viewer
}
@@ -293,6 +303,9 @@ pub struct Viewer {
/// Swaps in the selected row's file when the selection moves, from the find bar, the arrows or
/// anywhere else.
_follow: Option,
+ /// Reads the page count or duration. Held so that a viewer replaced before [`SETTLE`] is up
+ /// never starts it.
+ _probe: Option>,
}
impl Viewer {
@@ -442,7 +455,9 @@ impl Viewer {
self.find.searching = true;
let path = self.path.clone();
- cx.spawn(async move |this, cx| {
+ // Replacing the task drops the previous query's search if it has not started yet.
+ self.find.task = Some(cx.spawn(async move |this, cx| {
+ cx.background_executor().timer(SETTLE).await;
let hits = cx
.background_executor()
.spawn({
@@ -463,8 +478,7 @@ impl Viewer {
cx.notify();
})
.ok();
- })
- .detach();
+ }));
cx.notify();
}
@@ -1208,6 +1222,7 @@ mod tests {
open_viewer(path.clone(), Scope::Workspace, window, cx);
viewer_in(Scope::Workspace, cx).expect("just opened")
});
+ cx.executor().advance_clock(super::SETTLE);
cx.run_until_parked();
let scrubber = cx.update(|_, cx| {
let scrubber = viewer
From 52b215647cb0b7d166a92d17e8beb2d105ab266e Mon Sep 17 00:00:00 2001
From: devnull03 <56480041+devnull03@users.noreply.github.com>
Date: Mon, 28 Sep 2026 21:03:12 +0000
Subject: [PATCH 04/31] fix(workspace): pop-out playback ownership, fresh
bounds and pin pruning
- 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.
---
crates/preview/src/playback.rs | 19 ++++-
crates/workspace/src/pop_out.rs | 102 +++++++++++++++++++----
crates/workspace/src/viewer/mod.rs | 3 +-
crates/workspace/src/viewer/transport.rs | 2 +-
4 files changed, 103 insertions(+), 23 deletions(-)
diff --git a/crates/preview/src/playback.rs b/crates/preview/src/playback.rs
index 91612d28..3acecf90 100644
--- a/crates/preview/src/playback.rs
+++ b/crates/preview/src/playback.rs
@@ -14,7 +14,7 @@ use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::time::Duration;
-use gpui::{App, Global};
+use gpui::{App, EntityId, Global};
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player};
pub use crate::audio::duration;
@@ -26,6 +26,9 @@ struct Playback {
/// What was last handed to the player. There is one device and one recording, but more than
/// one transport can be on screen — each has to know whether the position is even its own.
playing: Option,
+ /// The view whose transport started it. Two windows can show the same recording, and closing
+ /// one must not silence the other's.
+ owner: Option,
}
impl Global for Playback {}
@@ -36,9 +39,9 @@ fn player(cx: &App) -> Option<&Player> {
Some(&cx.try_global::()?.player)
}
-/// Start `path` from the beginning, replacing whatever was playing. Opens the output device on
-/// first use, and stays quiet on a machine that has none.
-pub fn play(path: &Path, cx: &mut App) {
+/// Start `path` from the beginning for `owner`, replacing whatever was playing. Opens the output
+/// device on first use, and stays quiet on a machine that has none.
+pub fn play(path: &Path, owner: EntityId, cx: &mut App) {
let opened = File::open(path)
.map_err(|err| err.to_string())
.and_then(|file| Decoder::new(BufReader::new(file)).map_err(|err| err.to_string()));
@@ -58,11 +61,13 @@ pub fn play(path: &Path, cx: &mut App) {
_device: device,
player,
playing: None,
+ owner: None,
});
}
let playback = cx.global_mut::();
playback.playing = Some(path.to_path_buf());
+ playback.owner = Some(owner);
playback.player.clear();
playback.player.append(source);
playback.player.play();
@@ -74,6 +79,11 @@ pub fn playing(cx: &App) -> Option<&Path> {
cx.try_global::()?.playing.as_deref()
}
+/// The view that started what is loaded.
+pub fn owner(cx: &App) -> Option {
+ cx.try_global::()?.owner
+}
+
/// Pause if playing, resume if paused. Does nothing before anything is loaded.
pub fn toggle(cx: &App) {
let Some(player) = player(cx) else {
@@ -108,6 +118,7 @@ pub fn stop(cx: &mut App) {
let playback = cx.global_mut::();
playback.player.clear();
playback.playing = None;
+ playback.owner = None;
}
}
diff --git a/crates/workspace/src/pop_out.rs b/crates/workspace/src/pop_out.rs
index ccde176e..f0dcb5f0 100644
--- a/crates/workspace/src/pop_out.rs
+++ b/crates/workspace/src/pop_out.rs
@@ -36,6 +36,10 @@ const MIN_SIZE: Size = Size {
width: px(800.),
height: px(600.),
};
+const DEFAULT_SIZE: Size = Size {
+ width: px(1120.),
+ height: px(700.),
+};
/// Height of the sidebar's header strip, the Details title or the Details | Find tabs.
const HEADER_H: Pixels = px(30.);
/// The stage's own text colours. Not theme colours, for the reason the backdrop is not one.
@@ -46,14 +50,21 @@ const STAGE_MUTED: u32 = 0xa3a3a3;
/// per project, the same way the main window keeps its own.
const BOUNDS_KEY: &str = "pop_out_window_bounds";
-/// Silence `viewer`'s recording as it goes — only its own: the player is shared by the whole app,
-/// and may be playing something the main window started.
+/// Silence `viewer`'s recording as it goes — only if it started it: the player is shared by the
+/// whole app, and the main window may be playing the same file.
fn stop_playing(viewer: &Entity, cx: &mut App) {
- if preview::playback::playing(cx) == Some(viewer.read(cx).path.as_path()) {
+ if preview::playback::owner(cx) == Some(viewer.entity_id()) {
preview::playback::stop(cx);
}
}
+/// The bounds last seen, for the project they belong to. The `.qrate` write is debounced, so a
+/// window closed and reopened inside that interval would otherwise read the size it had before.
+#[derive(Default)]
+struct LastBounds(Option<(PathBuf, MainWindowBounds)>);
+
+impl Global for LastBounds {}
+
/// The open pop-out window. There is one per project, and one project open at a time.
#[derive(Default)]
pub(crate) struct PopOutWindow(Option);
@@ -74,12 +85,27 @@ pub fn open(cx: &mut App) {
{
return;
}
- let saved = cx
- .try_global::()
- .and_then(|p| settings::project::read_setting(&p.file, BOUNDS_KEY).ok())
- .flatten()
- .and_then(|raw| serde_json::from_str::(&raw).ok());
- let (bounds, display) = MainWindowBounds::startup_placement(saved.as_ref(), cx);
+ let file = cx.try_global::().map(|p| p.file.clone());
+ let saved = file.as_ref().and_then(|file| {
+ let remembered = cx
+ .try_global::()
+ .and_then(|last| last.0.as_ref())
+ .filter(|(of, _)| of == file)
+ .map(|(_, bounds)| bounds.clone());
+ remembered.or_else(|| {
+ settings::project::read_setting(file, BOUNDS_KEY)
+ .ok()
+ .flatten()
+ .and_then(|raw| serde_json::from_str::(&raw).ok())
+ })
+ });
+ // The shared placement's fallback is the main window's portrait default, narrower than this
+ // window's minimum; nothing saved means this window's own default.
+ let (bounds, display) = match saved {
+ Some(saved) => MainWindowBounds::startup_placement(Some(&saved), cx),
+ None => (Bounds::centered(None, DEFAULT_SIZE, cx), None),
+ };
+ let bounds = Bounds::centered(display, bounds.size.max(&MIN_SIZE), cx);
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
display_id: display,
@@ -186,6 +212,7 @@ impl PopOut {
if let Ok(json) = serde_json::to_string(&bounds) {
settings::project::queue_write(file, BOUNDS_KEY, &json, cx);
}
+ cx.set_global(LastBounds(Some((file.clone(), bounds))));
}),
];
@@ -235,6 +262,7 @@ impl PopOut {
/// Bring the rows, the stage and the sidebar up to date with the grid.
fn sync(&mut self, window: &mut Window, cx: &mut Context) {
+ let mut kept = None;
let rows = self.table().map_or_else(Vec::new, |state| {
let delegate = state.read(cx).delegate();
let all = delegate.row_ids();
@@ -250,15 +278,24 @@ impl PopOut {
{
self.rows.clone()
}
- // Rows were added, removed or moved: one pass to find the pin again.
+ // Rows were added, removed or moved: one pass to find the pin again, dropping the
+ // items that are gone so the pass above matches again from the next change.
Some(ids) => {
let at: std::collections::HashMap<_, _> =
all.iter().enumerate().map(|(row, id)| (*id, row)).collect();
- ids.iter().filter_map(|id| at.get(id).copied()).collect()
+ let (ids, rows): (Vec<_>, Vec<_>) = ids
+ .iter()
+ .filter_map(|id| Some((*id, *at.get(id)?)))
+ .unzip();
+ kept = Some(ids);
+ rows
}
None => delegate.selected_source_rows(),
}
});
+ if kept.is_some() {
+ self.pinned = kept;
+ }
// Every pinned item deleted: there is nothing left to hold, so follow again.
if self.pinned.is_some() && rows.is_empty() {
self.pinned = None;
@@ -1150,6 +1187,37 @@ mod tests {
});
}
+ /// An item deleted out of a pin of several leaves the pin holding only what is left, so the
+ /// next cell edit takes the cheap path rather than re-finding every row.
+ #[gpui::test]
+ fn a_deleted_item_leaves_the_pin(cx: &mut TestAppContext) {
+ let (pop_out, state, cx) = window_over_a_table(cx);
+ select(&state, &[0, 2], cx);
+ pop_out.update_in(cx, |pop_out, window, cx| pop_out.toggle_pin(window, cx));
+
+ state.update(cx, |state, cx| {
+ state.delegate_mut().set_data(
+ &["Identifier".into(), "Title".into()],
+ &[12, 13],
+ &[
+ vec!["ADR-0043".into(), "Sawmill crew".into()],
+ vec!["ADR-0044".into(), "Saanich mill".into()],
+ ],
+ );
+ cx.emit(TableChanged);
+ });
+ cx.run_until_parked();
+
+ pop_out.read_with(cx, |pop_out, _| {
+ assert_eq!(
+ pop_out.pinned.as_deref(),
+ Some(&[13][..]),
+ "11 is gone from the pin"
+ );
+ assert_eq!(pop_out.rows, [1]);
+ });
+ }
+
/// Several rows: the stage steps through them, wrapping, while the sidebar keeps all of them.
#[gpui::test]
fn the_stack_steps_through_the_selection_and_wraps(cx: &mut TestAppContext) {
@@ -1209,30 +1277,30 @@ mod tests {
wav.extend(b"data");
wav.extend((data as u32).to_le_bytes());
wav.extend(std::iter::repeat_n(0u8, data));
- let main = std::env::temp_dir().join("qrate-pop-out-main.wav");
let shown = std::env::temp_dir().join("qrate-pop-out-shown.wav");
- std::fs::write(&main, &wav).unwrap();
std::fs::write(&shown, &wav).unwrap();
let (_, cx) = cx.add_window_view(|_, _| Blank);
cx.update(|window, cx| {
let popped =
crate::viewer::build(shown.clone(), crate::viewer::Scope::PopOut, window, cx);
- preview::playback::play(&main, cx);
+ // The same recording, open in the main window too.
+ let main =
+ crate::viewer::build(shown.clone(), crate::viewer::Scope::Workspace, window, cx);
+ preview::playback::play(&shown, main.entity_id(), cx);
let before = preview::playback::playing(cx).map(|path| path.to_path_buf());
stop_playing(&popped, cx);
assert_eq!(
preview::playback::playing(cx).map(|path| path.to_path_buf()),
before,
- "the main window's recording plays on"
+ "the main window's playback of the same file plays on"
);
- preview::playback::play(&shown, cx);
+ preview::playback::play(&shown, popped.entity_id(), cx);
stop_playing(&popped, cx);
assert!(preview::playback::playing(cx).is_none(), "its own stops");
});
- let _ = std::fs::remove_file(&main);
let _ = std::fs::remove_file(&shown);
}
diff --git a/crates/workspace/src/viewer/mod.rs b/crates/workspace/src/viewer/mod.rs
index ea9eb68c..127225b7 100644
--- a/crates/workspace/src/viewer/mod.rs
+++ b/crates/workspace/src/viewer/mod.rs
@@ -1228,7 +1228,8 @@ mod tests {
cx.update(|window, cx| {
open_viewer(path.clone(), Scope::Workspace, window, cx);
- preview::playback::play(&path, cx);
+ let viewer = viewer_in(Scope::Workspace, cx).expect("just opened");
+ preview::playback::play(&path, viewer.entity_id(), cx);
close_viewer(window, cx);
assert!(
!preview::playback::position(cx).is_some_and(|(_, playing)| playing),
diff --git a/crates/workspace/src/viewer/transport.rs b/crates/workspace/src/viewer/transport.rs
index 9b15d54e..8f02e463 100644
--- a/crates/workspace/src/viewer/transport.rs
+++ b/crates/workspace/src/viewer/transport.rs
@@ -154,7 +154,7 @@ pub fn toggle(this: &mut V, window: &mut Window, cx: &mut Context) {
if transport.is_current(cx) {
preview::playback::toggle(cx);
} else {
- preview::playback::play(&path, cx);
+ preview::playback::play(&path, cx.entity_id(), cx);
}
if needs_tick {
From ac1770ee6027feb66f1d4d573ca3438a93909460 Mon Sep 17 00:00:00 2001
From: devnull03 <56480041+devnull03@users.noreply.github.com>
Date: Mon, 28 Sep 2026 14:50:39 -0700
Subject: [PATCH 05/31] feat(workspace): animate GIFs in the viewer, rotate,
and actual-size 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
---
crates/preview/src/cache.rs | 5 +
crates/preview/src/lib.rs | 168 +++++++++++++++++++------
crates/workspace/src/viewer/mod.rs | 192 ++++++++++++++++++++++++++++-
3 files changed, 325 insertions(+), 40 deletions(-)
diff --git a/crates/preview/src/cache.rs b/crates/preview/src/cache.rs
index 2ac9ba0e..4b083b55 100644
--- a/crates/preview/src/cache.rs
+++ b/crates/preview/src/cache.rs
@@ -52,6 +52,10 @@ pub fn dir() -> Option {
.clone()
}
+/// Bumped when a decode changes what an unchanged file looks like, so every old entry misses.
+/// 1: raster thumbnails turned upright by their EXIF orientation.
+const FORMAT: u32 = 1;
+
/// Identity of one cached rendering. The file's length and mtime are in the hash, so editing or
/// replacing a source file misses rather than serving the old picture — which is why nothing here
/// needs an invalidation pass.
@@ -61,6 +65,7 @@ pub fn dir() -> Option {
pub fn key(path: &Path, max_edge: u32, page: usize) -> Option {
let meta = fs::metadata(path).ok()?;
let mut hasher = DefaultHasher::new();
+ FORMAT.hash(&mut hasher);
path.hash(&mut hasher);
meta.len().hash(&mut hasher);
meta.modified().ok()?.hash(&mut hasher);
diff --git a/crates/preview/src/lib.rs b/crates/preview/src/lib.rs
index 608e13fa..63b4d79b 100644
--- a/crates/preview/src/lib.rs
+++ b/crates/preview/src/lib.rs
@@ -135,8 +135,9 @@ pub fn placeholder_icon(path: Option<&Path>) -> IconName {
pub struct Preview;
/// File, size cap, where in it (the page for a document, whole seconds in for a video, zero for
-/// everything else, which has only one thing to show), and the [`generation`] it was drawn at.
-type Key = (PathBuf, u32, usize, u64);
+/// everything else, which has only one thing to show), the [`generation`] it was drawn at, and how
+/// many quarter turns clockwise the viewer has rotated it.
+type Key = (PathBuf, u32, usize, u64, u8);
/// For the files PDFium and ffmpeg draw, the component generation, so installing either gives
/// them new keys and a card that fell back to an icon is drawn again. Zero for everything else,
@@ -154,13 +155,13 @@ impl Asset for Preview {
type Output = Option>;
fn load(
- (path, max_edge, page, _): Self::Source,
+ (path, max_edge, page, _, turns): Self::Source,
cx: &mut App,
) -> impl Future