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/move-horizontal.svg b/assets/icons/move-horizontal.svg new file mode 100644 index 00000000..87176ae5 --- /dev/null +++ b/assets/icons/move-horizontal.svg @@ -0,0 +1 @@ + \ No newline at end of file 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/app_settings/mod.rs b/crates/app/src/app_settings/mod.rs index ba531aeb..e953b936 100644 --- a/crates/app/src/app_settings/mod.rs +++ b/crates/app/src/app_settings/mod.rs @@ -791,7 +791,7 @@ fn previews_group(cx: &App) -> SettingGroup { }; cx.update(|cx| { cx.set_global(CacheCleared(outcome.into())); - cx.refresh_windows(); + preview::forget_all(cx); }); }) .detach(); diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index dd0d257e..ef91e7b6 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, nor the viewer's fit-to-width glyph, 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); 10] = [ ( "icons/history.svg", include_str!("../../../assets/icons/history.svg"), @@ -29,6 +30,26 @@ 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"), + ), + ( + "icons/move-horizontal.svg", + include_str!("../../../assets/icons/move-horizontal.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/app/src/status_items/mod.rs b/crates/app/src/status_items/mod.rs index 9826330e..711a9f2f 100644 --- a/crates/app/src/status_items/mod.rs +++ b/crates/app/src/status_items/mod.rs @@ -3,6 +3,7 @@ pub mod markup; mod new_files; mod panel_buttons; mod plugin_bar; +mod previews_busy; use cell_location::CellLocation; use gpui::*; @@ -11,6 +12,7 @@ use new_files::NewFilesButton; use panel_buttons::PanelButtons; use plugin_api::{Bar, BarContributions, Side}; pub use plugin_bar::PluginBar; +use previews_busy::PreviewsBusy; use window_wrapper::{BarRegistry, status_bar::StatusBarRegistry}; use workspace::{BarSide, DockToggleButton, PANELS}; @@ -64,6 +66,13 @@ pub fn build_status_bar_registry(cx: &mut App, dock: WeakEntity) -> St !BarContributions::at(Bar::Status, Side::Right, cx).is_empty() }); + let previews_busy = cx.new(PreviewsBusy::new); + registry + .items_mut() + .add_right_if(previews_busy.clone(), move |cx| { + previews_busy.read(cx).shown > 0 + }); + // Text readout of the table's selected cell. let cell_location = cx.new(CellLocation::new); registry.items_mut().add_right(cell_location); diff --git a/crates/app/src/status_items/new_files.rs b/crates/app/src/status_items/new_files.rs index 7b361a5b..55f3bca0 100644 --- a/crates/app/src/status_items/new_files.rs +++ b/crates/app/src/status_items/new_files.rs @@ -40,12 +40,7 @@ impl Render for NewFilesButton { .label(format!("New files ({count})")) .tooltip("Files in the files folder that no row links to") .on_click(|_, window, cx| { - if let Some(table) = cx - .try_global::() - .and_then(|handle| handle.0.upgrade()) - { - table.update(cx, |table, cx| table.import_new_files(window, cx)); - } + TablePanelHandle::update(cx, |table, cx| table.import_new_files(window, cx)); }) .into_any_element() } diff --git a/crates/app/src/status_items/previews_busy.rs b/crates/app/src/status_items/previews_busy.rs new file mode 100644 index 00000000..1509db50 --- /dev/null +++ b/crates/app/src/status_items/previews_busy.rs @@ -0,0 +1,51 @@ +//! Right-side status-bar readout: a spinner and a count while previews are decoding, so a gallery +//! filling in reads as work under way rather than a slow app. + +use std::time::Duration; + +use gpui::*; +use gpui_component::{Sizable as _, h_flex, spinner::Spinner}; + +pub struct PreviewsBusy { + pub shown: usize, + _poll: Task<()>, +} + +impl PreviewsBusy { + /// ponytail: polls, since decodes finish on a background thread that cannot notify a view. + /// Four cheap loads a second, and a re-render only when the count moves. + pub fn new(cx: &mut Context) -> Self { + let _poll = cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(250)) + .await; + let now = preview::decoding(); + let alive = this.update(cx, |this, cx| { + if this.shown == now { + return; + } + // Appearing or leaving changes the bar's dividers, which only the bar redraws. + if (this.shown == 0) != (now == 0) { + cx.refresh_windows(); + } + this.shown = now; + cx.notify(); + }); + if alive.is_err() { + break; + } + } + }); + Self { shown: 0, _poll } + } +} + +impl Render for PreviewsBusy { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + h_flex() + .gap_1() + .child(Spinner::new().xsmall()) + .child(format!("Loading previews ({})", self.shown)) + } +} diff --git a/crates/onboarding/src/lib.rs b/crates/onboarding/src/lib.rs index 4d8679e7..fba0df83 100644 --- a/crates/onboarding/src/lib.rs +++ b/crates/onboarding/src/lib.rs @@ -706,18 +706,13 @@ fn show_me(guide: &Entity, task: Task, window: &mut Window, cx: &mut App) } Task::AddFilesFolder => { let blank = guide.read(cx).kind == GuideKind::Blank; - if let Some(table) = cx - .try_global::() - .and_then(|handle| handle.0.upgrade()) - { - table.update(cx, |table, cx| { - if blank { - table.choose_import_paths(window, cx) - } else { - table.choose_files_root(window, cx) - } - }); - } + table::TablePanelHandle::update(cx, |table, cx| { + if blank { + table.choose_import_paths(window, cx) + } else { + table.choose_files_root(window, cx) + } + }); } Task::OpenRow | Task::SelectRow => { ensure_visible(guide, DETAILS_META.name, window, cx); diff --git a/crates/preview/Cargo.toml b/crates/preview/Cargo.toml index f558e627..db810cd4 100644 --- a/crates/preview/Cargo.toml +++ b/crates/preview/Cargo.toml @@ -26,6 +26,10 @@ pdfium-render = { version = "0.9.3", default-features = false, features = ["pdfi # ship two whole symphonia trees in the binary. Move both together or neither. symphonia = { version = "0.5.5", default-features = false, features = ["mp3", "aac", "alac", "isomp4", "ogg", "vorbis", "flac", "wav", "pcm"] } +[features] +# Test fixtures other crates' tests use, such as `playback::silent_wav`. +test-support = [] + [dev-dependencies] # `#[gpui::test]`, TestAppContext, and run_test are all behind this feature. gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/preview/src/audio.rs b/crates/preview/src/audio.rs index f9cd7183..7e1c391f 100644 --- a/crates/preview/src/audio.rs +++ b/crates/preview/src/audio.rs @@ -132,22 +132,7 @@ mod tests { /// rather than erroring in a way that would look the same as an unreadable file. #[test] fn a_valid_recording_without_artwork_is_not_an_error() { - // 44-byte canonical WAV header describing one sample of silence. - let mut wav = Vec::new(); - wav.extend(b"RIFF"); - wav.extend(36u32.to_le_bytes()); - wav.extend(b"WAVEfmt "); - wav.extend(16u32.to_le_bytes()); - wav.extend(1u16.to_le_bytes()); // PCM - wav.extend(1u16.to_le_bytes()); // mono - 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(2u32.to_le_bytes()); - wav.extend(0u16.to_le_bytes()); - + let wav = crate::playback::silent_wav(1); let path = std::env::temp_dir().join("qrate-audio-silent.wav"); std::fs::write(&path, &wav).unwrap(); assert!(audio::cover(&path).is_none(), "no artwork, but no panic"); 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..b951a442 100644 --- a/crates/preview/src/lib.rs +++ b/crates/preview/src/lib.rs @@ -19,6 +19,7 @@ pub mod playback; use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use components::ComponentId; @@ -52,6 +53,10 @@ pub const FULL: u32 = 0; /// natural pixel size, so "no cap" still needs a number; this is generous enough to zoom into. const FULL_FALLBACK: u32 = 2048; +/// The most a turned [`FULL`] picture keeps. gpui draws the upright one from the file; a turn is +/// ours to hold, and a 60 MP scan held whole would outgrow [`BUDGET`] on its own. +const TURNED: u32 = 4096; + /// How much decoded image data may stay resident. gpui's asset cache never evicts on its own, so /// without a ceiling a scroll through a large collection retains every thumbnail it passes. /// @@ -135,8 +140,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,18 +160,36 @@ 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 + Send + 'static { let executor = cx.background_executor().clone(); + DECODING.fetch_add(1, Ordering::Relaxed); async move { executor - .spawn(async move { render(&path, max_edge, page) }) + .spawn(async move { + let started = std::time::Instant::now(); + let image = render(&path, max_edge, page, turns); + DECODING.fetch_sub(1, Ordering::Relaxed); + log::debug!( + "preview: {} page {page} turned {turns} at {max_edge}px took {:?}", + path.display(), + started.elapsed() + ); + image + }) .await } } } +static DECODING: AtomicUsize = AtomicUsize::new(0); + +/// How many previews are being decoded right now, for a busy readout. +pub fn decoding() -> usize { + DECODING.load(Ordering::Relaxed) +} + /// Page counts learned by whoever last opened each file: the thumbnail loader, from the disk cache /// or a fresh count, and the viewer. Read by the gallery per card per frame, so it touches no disk. /// @@ -291,6 +315,33 @@ pub fn describe(path: &Path) -> Option { (!parts.is_empty()).then(|| parts.join(" · ")) } +/// Width and height in pixels, upright, read from the header alone. `None` for anything that is not +/// one of the raster formats `image` reads, which have no pixel size of their own to report. +pub fn dimensions(path: &Path) -> Option<(u32, u32)> { + use image::ImageDecoder as _; + use image::metadata::Orientation::{Rotate90, Rotate90FlipH, Rotate270, Rotate270FlipH}; + + if !extension(path).is_some_and(|extension| is_raster(&extension)) { + return None; + } + let mut decoder = image::ImageReader::open(path) + .ok()? + .with_guessed_format() + .ok()? + .into_decoder() + .ok()?; + let (width, height) = decoder.dimensions(); + let sideways = matches!( + decoder.orientation(), + Ok(Rotate90 | Rotate270 | Rotate90FlipH | Rotate270FlipH) + ); + Some(if sideways { + (height, width) + } else { + (width, height) + }) +} + /// `2.4 MB`. Powers of 1024 with the unit names every file manager on the three platforms shows, /// and whole bytes below a kilobyte — "0.3 KB" reads as a rounding of something, not as a stub. pub fn file_size(bytes: u64) -> String { @@ -411,10 +462,23 @@ pub fn thumbnail_png(path: &Path, page: usize) -> Option> { Some(encoded.into_inner()) } -/// Decode `path`, shrink it to fit `max_edge`, and hand back something gpui can draw. Runs on a -/// background thread; `None` for anything that won't decode, which the caller turns into the icon. -fn render(path: &Path, max_edge: u32, page: usize) -> Option> { - let mut bgra = thumbnail_pixels(path, max_edge, page)?; +/// Decode `path`, shrink it to fit `max_edge`, turn it `turns` quarter turns clockwise, and hand +/// back something gpui can draw. Runs on a background thread; `None` for anything that won't +/// decode, which the caller turns into the icon. +fn render(path: &Path, max_edge: u32, page: usize, turns: u8) -> Option> { + let mut upright = thumbnail_pixels(path, max_edge, page)?; + if !turns.is_multiple_of(4) + && max_edge == FULL + && upright.width().max(upright.height()) > TURNED + { + upright = downscale(upright.into(), TURNED); + } + let mut bgra = match turns % 4 { + 1 => image::imageops::rotate90(&upright), + 2 => image::imageops::rotate180(&upright), + 3 => image::imageops::rotate270(&upright), + _ => upright, + }; // `RenderImage` is documented as BGRA and gpui only swaps inside its own decode path, so an // image built by hand has to arrive already swapped or every preview draws blue-for-red. for px in bgra.pixels_mut() { @@ -520,13 +584,24 @@ fn decode(path: &Path, max_edge: u32, page: usize) -> Option Option { + use image::ImageDecoder as _; + image::ImageReader::open(path) .ok()? .with_guessed_format() .ok()? - .decode() + .into_decoder() + .and_then(|mut decoder| { + let orientation = decoder + .orientation() + .unwrap_or(image::metadata::Orientation::NoTransforms); + let mut image = image::DynamicImage::from_decoder(decoder)?; + image.apply_orientation(orientation); + Ok(image) + }) .map_err(|err| log::warn!("could not decode {}: {err}", path.display())) .ok() } @@ -567,8 +642,8 @@ struct Live { impl Global for Live {} fn cost(image: &RenderImage) -> usize { - // ponytail: frame 0 only, so an animated GIF is undercounted. Costs accuracy on a format the - // budget already tolerates; revisit if animations become common in collections. + // Frame 0 is the whole cost: the ladder builds one frame, and the animated original goes + // through gpui's loader instead. image.as_bytes(0).map_or(0, <[u8]>::len) } @@ -665,7 +740,7 @@ pub fn forget(path: &Path, cx: &mut App) { } } let at = extension(path).map_or(0, |extension| generation(&extension)); - let unheld = [CARD, PANE, FULL].map(|edge| (path.to_path_buf(), edge, 0, at)); + let unheld = [CARD, PANE, FULL].map(|edge| (path.to_path_buf(), edge, 0, at, 0)); for key in held.iter().chain(&unheld) { cx.remove_asset::(key); } @@ -675,6 +750,20 @@ pub fn forget(path: &Path, cx: &mut App) { cx.refresh_windows(); } +/// Drop every decoded picture and page count, so a cleared disk cache is drawn again from the +/// files rather than from memory. Call it outside a frame, as [`release`] explains. +pub fn forget_all(cx: &mut App) { + if let Ok(mut known) = PAGES.lock() { + known.clear(); + } + let live = std::mem::take(cx.default_global::()); + for (key, image) in live.entries.into_values() { + cx.remove_asset::(&key); + cx.drop_image(image, None); + } + cx.refresh_windows(); +} + /// The file's contents fit to whatever box the caller gives it, or a type icon when there is /// nothing to draw — no path, an undecodable one, or a decode that fails at paint time. `fit` /// lets a small icon fill its frame while gallery and details views show the entire file. @@ -719,8 +808,9 @@ pub fn thumb(path: Option<&Path>, max_edge: u32, fit: ObjectFit, cx: &App) -> An // For contain, keep the image's intrinsic ratio under `max_w/h_full` so it can // letterbox. Cover gives the image the frame's full size so GPUI crops it. Some(path) => { - let image = img(source(path, max_edge, 0)) + let image = img(source(path, max_edge, 0, 0)) .object_fit(fit) + .rounded(cx.theme().radius) .with_fallback(placeholder); frame.child(if cover { image.size_full() @@ -739,13 +829,15 @@ pub fn thumb(path: Option<&Path>, max_edge: u32, fit: ObjectFit, cx: &App) -> An /// /// gpui's own loader gets the file whenever it can read it and nothing has to be shrunk, so what /// the fullscreen viewer draws is the original: animation intact, no round trip through our decode -/// and BGRA swap, no second interpretation of a file gpui already understands. +/// and BGRA swap, no second interpretation of a file gpui already understands. It is also the only +/// place a GIF moves: a card or the details pane gets its first frame, shrunk and cached. /// /// - **SVG at every size.** It is the one format [`can_preview`] accepts that the `image` crate /// cannot decode — gpui rasterises it through the `resvg` it already vendors — and vector files /// are small enough that neither the downscale nor the disk cache would earn its keep. -/// - **Raster at [`FULL`] only**, i.e. the viewer. A card or a details pane wants the capped, -/// cached copy; there is nothing to cap here. +/// - **Raster at [`FULL`] only**, i.e. the viewer, while it is upright. A card or a details pane +/// wants the capped, cached copy; there is nothing to cap here. A turned image has to be turned +/// by us, so a rotated GIF stands still. /// /// Everything else goes through [`Preview`] as a custom source — the formats gpui cannot read at /// all (PDF, RAW, video, audio artwork, whatever only the OS can thumbnail), and every capped @@ -757,18 +849,21 @@ pub fn thumb(path: Option<&Path>, max_edge: u32, fit: ObjectFit, cx: &App) -> An /// so a session spent opening one large scan after another keeps every one of them. Acceptable /// while the viewer shows one at a time; give the viewer an explicit `drop_image` on close if it /// ever shows up in a memory profile. -pub fn source(path: &Path, max_edge: u32, page: usize) -> ImageSource { +pub fn source(path: &Path, max_edge: u32, page: usize, turns: u8) -> ImageSource { let extension = extension(path).unwrap_or_default(); - // ponytail: a GIF goes to gpui whole at every size, because only gpui's own decode keeps the - // frames that animate it. It skips the thumbnail cache and the memory budget, so a gallery of - // large GIFs holds them all; downscale every frame here if that shows up. - if extension == "svg" - || extension == "gif" - || (max_edge == FULL && page == 0 && is_raster(&extension)) + let turns = turns % 4; + if turns == 0 + && (extension == "svg" || (max_edge == FULL && page == 0 && is_raster(&extension))) { return ImageSource::Resource(path.to_path_buf().into()); } - let key = (path.to_path_buf(), max_edge, page, generation(&extension)); + let key = ( + path.to_path_buf(), + max_edge, + page, + generation(&extension), + turns, + ); ImageSource::Custom(Arc::new(move |window: &mut Window, cx: &mut App| { // `None` while the decode is still running, which leaves the frame empty rather than // flashing the icon; gpui re-renders the view when the task lands. @@ -786,6 +881,25 @@ pub fn source(path: &Path, max_edge: u32, page: usize) -> ImageSource { })) } +/// `None` while `source` decodes, starting it if it has not started; gpui re-renders the asking +/// view when it lands. Once done, the pixel size of what was decoded, if we decoded it. +/// +/// A path handed to gpui counts as done with no size: it is the file itself, and [`dimensions`] +/// already answers for it. +pub fn decoded( + source: &ImageSource, + window: &mut Window, + cx: &mut App, +) -> Option> { + match source { + ImageSource::Custom(load) => Some(load(window, cx)?.ok().map(|image| { + let size = image.size(0); + (size.width.0 as u32, size.height.0 as u32) + })), + _ => Some(None), + } +} + #[cfg(test)] mod tests { // No `use super::*`: chain-globbing `gpui::*` shadows the built-in `#[test]` and recurses (see CLAUDE.md). @@ -866,11 +980,11 @@ mod tests { fn the_viewer_gets_the_original_file_and_everything_else_gets_the_ladder() { use gpui::ImageSource; - use crate::{CARD, FULL, source}; + use crate::{CARD, FULL, PANE, source}; let native = |p: &str, max_edge, page| { matches!( - source(Path::new(p), max_edge, page), + source(Path::new(p), max_edge, page, 0), ImageSource::Resource(_) ) }; @@ -884,9 +998,15 @@ mod tests { assert!(native("/f/logo.svg", CARD, 0)); assert!(native("/f/logo.svg", FULL, 0)); + // Only the viewer animates; the Details pane and a card get the cached first frame. + assert!(!native("/f/anim.gif", PANE, 0)); + assert!(!native("/f/anim.gif", CARD, 0)); assert!( - native("/f/anim.gif", CARD, 0), - "the Details pane animates too" + !matches!( + source(Path::new("/f/anim.gif"), FULL, 0, 1), + ImageSource::Resource(_) + ), + "a turned image is turned by the ladder" ); // Capped sizes stay on the ladder — a card wants the shrunk, disk-cached copy. assert!(!native("/f/scan.jpg", CARD, 0)); @@ -1128,7 +1248,7 @@ mod tests { .save(&red) .unwrap(); - let rendered = crate::render(&red, crate::CARD, 0).expect("a 4x4 png decodes"); + let rendered = crate::render(&red, crate::CARD, 0, 0).expect("a 4x4 png decodes"); let bytes = rendered.as_bytes(0).expect("one frame"); assert_eq!( &bytes[..4], @@ -1147,7 +1267,7 @@ mod tests { image::RgbaImage::from_pixel(900, 300, image::Rgba([1, 2, 3, 255])) .save(&big) .unwrap(); - let rendered = crate::render(&big, 256, 0).expect("decodes"); + let rendered = crate::render(&big, 256, 0, 0).expect("decodes"); let size = rendered.size(0); assert_eq!(i32::from(size.width), 256, "longest edge is capped"); assert_eq!(i32::from(size.height), 85, "aspect ratio preserved"); @@ -1156,13 +1276,27 @@ mod tests { image::RgbaImage::from_pixel(40, 20, image::Rgba([1, 2, 3, 255])) .save(&small) .unwrap(); - let rendered = crate::render(&small, 512, 0).expect("decodes"); + let rendered = crate::render(&small, 512, 0, 0).expect("decodes"); assert_eq!(i32::from(rendered.size(0).width), 40, "never enlarged"); let _ = std::fs::remove_file(&big); let _ = std::fs::remove_file(&small); } + /// A turned full-size picture is ours to hold, so it is capped; an upright one is not. + #[test] + fn a_turned_full_size_picture_is_capped() { + let wide = std::env::temp_dir().join("qrate-turned-cap-probe.png"); + image::RgbaImage::from_pixel(5000, 10, image::Rgba([1, 2, 3, 255])) + .save(&wide) + .unwrap(); + let turned = crate::render(&wide, crate::FULL, 0, 1).expect("decodes"); + assert_eq!(i32::from(turned.size(0).height), crate::TURNED as i32); + let upright = crate::render(&wide, crate::FULL, 0, 0).expect("decodes"); + assert_eq!(i32::from(upright.size(0).width), 5000); + let _ = std::fs::remove_file(&wide); + } + /// A second look at the same file must come off disk rather than decoding again — the reason /// the cache exists. Asserted through the artefact, since the decode itself is not observable. #[test] @@ -1175,7 +1309,7 @@ mod tests { let entry = crate::cache::dir().expect("cache dir").join(&key); let _ = std::fs::remove_file(&entry); - crate::render(&path, 128, 0).expect("decodes"); + crate::render(&path, 128, 0, 0).expect("decodes"); assert!(entry.is_file(), "the first decode leaves an entry behind"); assert!(crate::cache::read(&key).is_some(), "and it reads back"); @@ -1212,7 +1346,7 @@ mod tests { cx.update(|window, cx| { for n in 0..8 { - let key = (PathBuf::from(format!("/f/{n}.png")), crate::CARD, 0, 0); + let key = (PathBuf::from(format!("/f/{n}.png")), crate::CARD, 0, 0, 0); crate::retain(&key, &image(), budget, window, cx); } assert_eq!( @@ -1261,12 +1395,48 @@ mod tests { }); } + /// A phone photo stored sideways with an EXIF turn has to come out upright in every thumbnail, + /// as it does in the viewer, where gpui applies the tag itself. The viewer's own rotation is + /// applied on top of that, never instead of it. + #[test] + fn exif_orientation_and_the_viewers_turn_both_reach_the_pixels() { + use image::ImageEncoder as _; + + let path = std::env::temp_dir().join("qrate-exif-orientation-probe.png"); + // A little-endian TIFF header with one IFD entry: Orientation (0x0112) = 6, "rotate 90°". + let exif = vec![ + 0x49, 0x49, 0x2A, 0, 8, 0, 0, 0, 1, 0, 0x12, 0x01, 3, 0, 1, 0, 0, 0, 6, 0, 0, 0, 0, 0, + 0, 0, + ]; + let mut encoder = + image::codecs::png::PngEncoder::new(std::fs::File::create(&path).unwrap()); + encoder.set_exif_metadata(exif).unwrap(); + encoder + .write_image(&[0u8; 4 * 2 * 4], 4, 2, image::ExtendedColorType::Rgba8) + .unwrap(); + + assert_eq!(crate::dimensions(&path), Some((2, 4)), "reported upright"); + let upright = crate::render(&path, crate::CARD, 0, 0).expect("decodes"); + assert_eq!(i32::from(upright.size(0).width), 2, "drawn upright"); + let turned = crate::render(&path, crate::CARD, 0, 1).expect("decodes"); + assert_eq!( + i32::from(turned.size(0).width), + 4, + "then turned by the viewer" + ); + + if let Some(key) = crate::cache::key(&path, crate::CARD, 0) { + let _ = std::fs::remove_file(crate::cache::dir().unwrap().join(key)); + } + let _ = std::fs::remove_file(&path); + } + #[test] fn undecodable_files_render_nothing_rather_than_panicking() { let junk = std::env::temp_dir().join("qrate-junk-probe.jpg"); std::fs::write(&junk, b"this is not an image").unwrap(); - assert!(crate::render(&junk, crate::CARD, 0).is_none()); - assert!(crate::render(Path::new("/nonexistent/x.png"), crate::CARD, 0).is_none()); + assert!(crate::render(&junk, crate::CARD, 0, 0).is_none()); + assert!(crate::render(Path::new("/nonexistent/x.png"), crate::CARD, 0, 0).is_none()); let _ = std::fs::remove_file(&junk); } } diff --git a/crates/preview/src/playback.rs b/crates/preview/src/playback.rs index 91612d28..5b2c07f5 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 { @@ -101,16 +111,40 @@ pub fn position(cx: &App) -> Option<(Duration, bool)> { Some((player.get_pos(), !player.is_paused() && !player.empty())) } -/// Silence. The viewer calls this as it closes — without it the recording plays on over an empty -/// screen, with nothing left on the page to stop it. -pub fn stop(cx: &mut App) { - if cx.has_global::() { +/// Silence, if `owner` started what is playing. A view calls this as it closes or moves on — +/// without it the recording plays on over an empty screen — and must not silence another +/// window's recording on the way out. +pub fn stop(owner: EntityId, cx: &mut App) { + if self::owner(cx) == Some(owner) { let playback = cx.global_mut::(); playback.player.clear(); playback.playing = None; + playback.owner = None; } } +/// A real WAV of `samples` of 8 kHz 16-bit mono silence — a canonical 44-byte header and the +/// zeros — for tests that need a recording on disk. +#[cfg(any(test, feature = "test-support"))] +pub fn silent_wav(samples: usize) -> Vec { + let data = (samples * 2) as u32; + let mut wav = Vec::new(); + wav.extend(b"RIFF"); + wav.extend((36 + data).to_le_bytes()); + wav.extend(b"WAVEfmt "); + wav.extend(16u32.to_le_bytes()); + wav.extend(1u16.to_le_bytes()); // PCM + wav.extend(1u16.to_le_bytes()); // mono + 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.to_le_bytes()); + wav.extend(std::iter::repeat_n(0u8, data as usize)); + wav +} + #[cfg(test)] mod tests { // Never `use super::*` here — a chained `gpui::*` glob would shadow `#[test]`. @@ -120,23 +154,8 @@ mod tests { /// is every CI runner. Nothing here opens an output device. #[test] fn a_recordings_length_is_read_without_playing_it() { - // 44-byte canonical WAV header, then one second of 8 kHz 16-bit mono silence. - let samples = 8000usize; - let data = samples * 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()); // PCM - wav.extend(1u16.to_le_bytes()); // mono - 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)); + // One second of 8 kHz silence. + let wav = crate::playback::silent_wav(8000); let path = std::env::temp_dir().join("qrate-playback-duration.wav"); std::fs::write(&path, &wav).unwrap(); diff --git a/crates/project-wizard/src/launcher.rs b/crates/project-wizard/src/launcher.rs index 67b15ca9..56dc21db 100644 --- a/crates/project-wizard/src/launcher.rs +++ b/crates/project-wizard/src/launcher.rs @@ -652,7 +652,7 @@ fn project_thumbnail(image: Thumbnail<'_>, size: Pixels, cx: &App) -> AnyElement let frame = div() .size(size) .flex_none() - .rounded_md() + .rounded(cx.theme().radius) .border_1() .border_color(cx.theme().border) .bg(cx.theme().tiles) @@ -665,7 +665,8 @@ fn project_thumbnail(image: Thumbnail<'_>, size: Pixels, cx: &App) -> AnyElement example::THUMBNAIL.to_vec(), ))) .size_full() - .object_fit(ObjectFit::Cover), + .object_fit(ObjectFit::Cover) + .rounded(cx.theme().radius), ) .into_any_element(), Thumbnail::Recent(Some(path)) => frame diff --git a/crates/table/src/cell.rs b/crates/table/src/cell.rs index 920ef41d..cd459002 100644 --- a/crates/table/src/cell.rs +++ b/crates/table/src/cell.rs @@ -128,14 +128,9 @@ pub(crate) fn render_cell( return; }; window.defer(cx, move |window, cx| { - if let Some(panel) = cx - .try_global::() - .and_then(|handle| handle.0.upgrade()) - { - panel.update(cx, |panel, cx| { - panel.link_dropped_file(row_ix, col_ix, path, window, cx) - }); - } + crate::TablePanelHandle::update(cx, |panel, cx| { + panel.link_dropped_file(row_ix, col_ix, path, window, cx) + }); }); }) }) diff --git a/crates/table/src/delegate.rs b/crates/table/src/delegate.rs index 9cb6cde2..dad47873 100644 --- a/crates/table/src/delegate.rs +++ b/crates/table/src/delegate.rs @@ -1825,10 +1825,24 @@ impl QrateTableDelegate { ); } - pub(crate) fn row_id(&self, source: usize) -> Option { + pub fn row_id(&self, source: usize) -> Option { self.row_ids.get(source).copied() } + /// The source row `id` sits at now. A scan; for a batch of ids, [`Self::row_positions`]. + pub fn row_of(&self, id: settings::project::RowId) -> Option { + self.row_ids.iter().position(|row| *row == id) + } + + /// Where every row id sits now, for finding a batch of ids again after rows moved. + pub fn row_positions(&self) -> std::collections::HashMap { + self.row_ids + .iter() + .enumerate() + .map(|(row, id)| (*id, row)) + .collect() + } + pub fn row_ids(&self) -> &[settings::project::RowId] { &self.row_ids } @@ -2058,6 +2072,14 @@ impl QrateTableDelegate { self.selection } + /// The source row the cursor is on: a cell's row or a whole row, and nothing for a column. + pub fn cursor_row(&self) -> Option { + match self.selection? { + Selection::Cell { row, .. } | Selection::Row(row) => Some(row), + Selection::Column(_) => None, + } + } + /// Every selected item as source rows, in view order: the ⌘-clicked set unioned with whatever /// row the cursor is on. This is what Details, the gallery, the selection menu and the status /// bar all count — one answer to "what is selected", so none of them can disagree. @@ -2065,10 +2087,7 @@ impl QrateTableDelegate { /// Filtered-away rows stay in the set but drop out here, so an action reaches what the /// archivist can actually see and clearing the filter brings the rest back. pub fn selected_source_rows(&self) -> Vec { - let cursor = match self.selection { - Some(Selection::Cell { row, .. } | Selection::Row(row)) => Some(row), - Some(Selection::Column(_)) | None => None, - }; + let cursor = self.cursor_row(); self.visible_rows .iter() .copied() diff --git a/crates/table/src/lib.rs b/crates/table/src/lib.rs index 95b81816..b1e1e841 100644 --- a/crates/table/src/lib.rs +++ b/crates/table/src/lib.rs @@ -44,6 +44,18 @@ pub use visual::remove_model as remove_visual_model; pub struct TablePanelHandle(pub WeakEntity); impl Global for TablePanelHandle {} +impl TablePanelHandle { + /// Run `run` on the centre table, when one is open. + pub fn update(cx: &mut App, run: impl FnOnce(&mut TablePanel, &mut gpui::Context)) { + if let Some(panel) = cx + .try_global::() + .and_then(|handle| handle.0.upgrade()) + { + panel.update(cx, run); + } + } +} + /// Settings key (in either scope) for the alternating-row-stripe toggle. pub const TABLE_STRIPES_KEY: &str = "table_stripes"; @@ -401,12 +413,7 @@ pub(crate) fn file_rows( /// The rows `changes` put new text in or back into, as they now sit; `None` when a column moved, /// which can change what every row resolves to. fn changed_rows(delegate: &QrateTableDelegate, changes: &[Change]) -> Option> { - let position: std::collections::HashMap<_, _> = delegate - .row_ids() - .iter() - .enumerate() - .map(|(at, id)| (*id, at)) - .collect(); + let position = delegate.row_positions(); changes .iter() .filter_map(|change| match change { @@ -533,10 +540,7 @@ pub fn restore_to(to: EntryId, cx: &mut App) { else { continue; }; - let position = row.and_then(|id| { - let delegate = state.read(cx).delegate(); - delegate.row_ids().iter().position(|r| *r == id) - }); + let position = row.and_then(|id| state.read(cx).delegate().row_of(id)); if row.is_some() && position.is_none() { continue; } @@ -571,11 +575,7 @@ pub fn restore_value( }; let target = { let delegate = state.read(cx).delegate(); - delegate - .row_ids() - .iter() - .position(|id| *id == row) - .zip(delegate.data_col(column)) + delegate.row_of(row).zip(delegate.data_col(column)) }; if let Some((row, col)) = target { write_cell(row, col, text, Origin::Restore(from), cx); diff --git a/crates/table/src/panel.rs b/crates/table/src/panel.rs index dccc63d4..177e4b34 100644 --- a/crates/table/src/panel.rs +++ b/crates/table/src/panel.rs @@ -1882,14 +1882,6 @@ impl TablePanel { /// App-level handlers for the grid's menu commands, so the menu bar reaches them wherever focus /// sits rather than only while the grid holds it. Undo, Redo and Deselect are the app's own. pub fn register_global_actions(cx: &mut App) { - fn on_panel(cx: &mut App, run: impl FnOnce(&mut TablePanel, &mut Context)) { - if let Some(panel) = cx - .try_global::() - .and_then(|handle| handle.0.upgrade()) - { - panel.update(cx, run); - } - } // A global handler runs while the dispatching window is taken out; wait for it to come back. fn in_window( cx: &mut App, @@ -1915,12 +1907,12 @@ pub fn register_global_actions(cx: &mut App) { } cx.on_action(|_: &InsertRowAbove, cx| { - on_panel(cx, |this, cx| { + crate::TablePanelHandle::update(cx, |this, cx| { this.structural(|rows, _| crate::Structural::InsertRow { at: rows[0] }, cx) }) }); cx.on_action(|_: &InsertRowBelow, cx| { - on_panel(cx, |this, cx| { + crate::TablePanelHandle::update(cx, |this, cx| { this.structural( |rows, _| crate::Structural::InsertRow { at: rows[rows.len() - 1] + 1, @@ -1930,7 +1922,7 @@ pub fn register_global_actions(cx: &mut App) { }) }); cx.on_action(|_: &DuplicateRow, cx| { - on_panel(cx, |this, cx| { + crate::TablePanelHandle::update(cx, |this, cx| { this.structural( |rows, _| crate::Structural::DuplicateRow { row: rows[0] }, cx, @@ -1938,45 +1930,47 @@ pub fn register_global_actions(cx: &mut App) { }) }); cx.on_action(|_: &DeleteRow, cx| { - on_panel(cx, |this, cx| { + crate::TablePanelHandle::update(cx, |this, cx| { this.structural(|rows, _| crate::Structural::DeleteRows(rows.to_vec()), cx) }) }); cx.on_action(|_: &InsertColumnLeft, cx| { - on_panel(cx, |this, cx| { + crate::TablePanelHandle::update(cx, |this, cx| { this.structural(|_, col| crate::Structural::InsertColumn { at: col }, cx) }) }); cx.on_action(|_: &InsertColumnRight, cx| { - on_panel(cx, |this, cx| { + crate::TablePanelHandle::update(cx, |this, cx| { this.structural(|_, col| crate::Structural::InsertColumn { at: col + 1 }, cx) }) }); cx.on_action(|_: &DeleteColumn, cx| { - on_panel(cx, |this, cx| { + crate::TablePanelHandle::update(cx, |this, cx| { this.structural(|_, col| crate::Structural::DeleteColumn { col }, cx) }) }); cx.on_action(|_: &IndentRow, cx| { - on_panel(cx, |this, cx| arrange(this, crate::Arrangement::Indent, cx)) + crate::TablePanelHandle::update(cx, |this, cx| { + arrange(this, crate::Arrangement::Indent, cx) + }) }); cx.on_action(|_: &OutdentRow, cx| { - on_panel(cx, |this, cx| { + crate::TablePanelHandle::update(cx, |this, cx| { arrange(this, crate::Arrangement::Outdent, cx) }) }); cx.on_action(|_: &DeleteSubtree, cx| { - on_panel(cx, |this, cx| { + crate::TablePanelHandle::update(cx, |this, cx| { arrange(this, crate::Arrangement::DeleteSubtree, cx) }) }); cx.on_action(|_: &UnfreezeColumns, cx| { - on_panel(cx, |this, cx| { + crate::TablePanelHandle::update(cx, |this, cx| { crate::set_frozen_columns(&this.state.clone(), 0, cx) }) }); cx.on_action(|_: &ExpandAll, cx| { - on_panel(cx, |this, cx| { + crate::TablePanelHandle::update(cx, |this, cx| { let expanded = this.state.update(cx, |state, cx| { state.delegate_mut().expand_all(); let expanded = state.delegate().expanded_rows(); @@ -1988,7 +1982,7 @@ pub fn register_global_actions(cx: &mut App) { }) }); cx.on_action(|_: &CollapseAll, cx| { - on_panel(cx, |this, cx| { + crate::TablePanelHandle::update(cx, |this, cx| { this.state.update(cx, |state, cx| { state.delegate_mut().collapse_all(); state.refresh(cx); @@ -2265,7 +2259,13 @@ mod tests { fn project_with_notes(cx: &mut TestAppContext) { cx.update(|cx| { gpui_component::init(cx); - cx.set_global(settings::AppSettings::default()); + // Autosave off, so no test here writes the temp project file. + 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::project::CurrentProject { file: std::env::temp_dir().join("qrate-note-cancel.qrate"), data: settings::project::ProjectData { @@ -2287,10 +2287,6 @@ mod tests { use gpui::BorrowAppContext as _; cx.update_global::(|project, _| { project.data.rows = vec![vec!["Agnès Varda".into()], vec!["Varda, Agnès".into()]]; - project.data.values.insert( - settings::AUTOSAVE_KEY.into(), - settings::Val::Text("off".into()), - ); project.data.values.insert( settings::columns::COLUMN_SETTINGS_KEY.into(), settings::Val::Text(r#"{"Title":{"variant_review":true}}"#.into()), @@ -2400,15 +2396,6 @@ mod tests { #[gpui::test] fn grouped_fixes_write_every_cell_in_one_undo_step(cx: &mut TestAppContext) { project_with_notes(cx); - cx.update(|cx| { - use gpui::BorrowAppContext as _; - cx.update_global::(|project, _| { - project.data.values.insert( - settings::AUTOSAVE_KEY.into(), - settings::Val::Text("off".into()), - ); - }); - }); let (panel, cx) = cx.add_window_view(super::TablePanel::new); panel.update(cx, |panel, cx| { crate::set_cell_texts( @@ -2530,30 +2517,17 @@ mod tests { } /// What Backspace and Delete do to the selection, and the promise that it is one undo step. - /// Autosave off so the temp project file is never written. #[gpui::test] fn clearing_the_selection_blanks_it_and_undoes_as_one_step(cx: &mut TestAppContext) { + project_with_notes(cx); 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::project::CurrentProject { - file: std::env::temp_dir().join("qrate-clear-range.qrate"), - data: settings::project::ProjectData { - name: "T".into(), - columns: Vec::new(), - headers: vec!["Medium".into(), "Title".into()], - rows: vec![ - vec!["Film".into(), "one".into()], - vec!["Video".into(), "two".into()], - ], - row_ids: vec![1, 2], - values: Default::default(), - }, + use gpui::BorrowAppContext as _; + cx.update_global::(|project, _| { + project.data.headers = vec!["Medium".into(), "Title".into()]; + project.data.rows = vec![ + vec!["Film".into(), "one".into()], + vec!["Video".into(), "two".into()], + ]; }); }); let (panel, cx) = cx.add_window_view(super::TablePanel::new); diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 2cdb7770..7caece29 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -21,3 +21,4 @@ components = { path = "../components" } [dev-dependencies] # `#[gpui::test]`, TestAppContext, and run_test are all behind this feature. gpui = { workspace = true, features = ["test-support"] } +preview = { path = "../preview", features = ["test-support"] } diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 199d4402..fde23c5d 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; @@ -932,3 +934,41 @@ mod tests { assert_eq!(children[0]["panel_name"], "ViewsPanel"); } } + +/// What the panel and pop-out tests share: a real grid over a project, as the app builds one. +#[cfg(test)] +pub(crate) mod test_support { + use gpui::{Entity, TestAppContext}; + use gpui_component::table::TableState; + use table::QrateTableDelegate; + + /// A table panel in its own window over `data`, saved to `file` in the temp directory. Autosave + /// is off, so a committed edit never writes that file. + pub(crate) fn open_table( + cx: &mut TestAppContext, + file: &str, + data: settings::project::ProjectData, + ) -> Entity> { + 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(file), + data, + }); + }); + cx.add_window_view(table::TablePanel::new); + cx.update(|cx| { + cx.global::() + .0 + .upgrade() + .expect("the table panel publishes its state handle") + }) + } +} diff --git a/crates/workspace/src/panels/details.rs b/crates/workspace/src/panels/details.rs index ea45fbad..fb529e55 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, Sizable, StyledExt as _, button::{Button, ButtonVariants}, dock::{BasePanel, DockPlacement, Panel, PanelEvent}, h_flex, @@ -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, @@ -146,6 +147,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 +209,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 +245,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()) @@ -243,22 +256,19 @@ impl DetailsPanel { .unwrap_or_default() } - /// The item the preview is showing: the stack's front card. Clamped rather than remembered, so - /// stepping to the fifth of five and then selecting two doesn't leave the preview blank. - fn front(&self, picked: &[usize]) -> Option { - picked - .get(self.stack.min(picked.len().checked_sub(1)?)) - .copied() - } - /// Point the transport at whatever is selected now. A no-op while the selection stays on the /// same file — this runs on every table change, and rebuilding would re-probe the file and /// throw away the position on every keystroke in the grid. fn retarget(&mut self, cx: &mut Context) { self.fields = None; let picked = self.picked(cx); - let front = self.front(&picked); + let front = stack_front(&picked, self.stack); 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(); @@ -283,17 +293,27 @@ impl DetailsPanel { // Whatever was playing belonged to the row being left. Leaving it running would narrate // one item while the panel details another. if self.transport.is_some() { - preview::playback::stop(cx); + preview::playback::stop(cx.entity_id(), cx); } 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) { let row = front.and_then(|row| { let state = self.state.as_ref()?.upgrade()?; - state.read(cx).delegate().row_ids().get(row).copied() + state.read(cx).delegate().row_id(row) }); let file = cx .try_global::() @@ -354,14 +374,18 @@ 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 rows = picked.iter().filter_map(|&row| delegate.row_id(row)); + Some((rows.collect::>(), delegate.data_col(header)?)) + }); + let Some((rows, col)) = located else { log::warn!("details: no column named {header} to edit"); return; }; @@ -789,12 +813,7 @@ impl DetailsPanel { /// Move the preview stack one item along, wrapping at both ends so a bundle can be walked in /// either direction without hunting for the end of it. fn step_stack(&mut self, forward: bool, cx: &mut Context) { - let count = self.picked(cx).len().max(1); - let at = self.stack.min(count - 1); - self.stack = match forward { - true => (at + 1) % count, - false => (at + count - 1) % count, - }; + self.stack = stack_step(self.stack, self.picked(cx).len(), forward); self.retarget(cx); cx.notify(); } @@ -803,15 +822,33 @@ 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 = delegate.row_positions(); + 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(); } @@ -1019,6 +1056,24 @@ 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") + .when(open, |icon| icon.text_color(cx.theme().primary)), + ) + .ghost() + .small() + .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, @@ -1044,12 +1099,17 @@ fn render_image_frame( .absolute() .top_1() .left_1() + // The action chip opposite takes the rest; a narrow pane truncates rather than overlaps. + .max_w(relative(0.4)) .px_1p5() .py_0p5() .rounded(cx.theme().radius) .bg(cx.theme().background) .text_xs() .text_color(cx.theme().foreground) + .whitespace_nowrap() + .overflow_hidden() + .text_ellipsis() .child(caption) })) // Along the bottom of the frame, over the cover art rather than beside it: the pane is a @@ -1073,6 +1133,22 @@ fn render_image_frame( /// One of the preview stack's step arrows, pinned to the edge its chevron points at and centred /// down the card. Full-height flex rather than a top offset: the pane is a height the user drags, /// so there is no fixed centre to hardcode. +/// The item a stack of selected `rows` shows: the front card. Clamped rather than remembered, so +/// stepping to the fifth of five and then selecting two doesn't leave the preview blank. +pub(crate) fn stack_front(rows: &[usize], stack: usize) -> Option { + rows.get(stack.min(rows.len().checked_sub(1)?)).copied() +} + +/// The front card after one step through `count` cards, wrapping at either end. +pub(crate) fn stack_step(stack: usize, count: usize, forward: bool) -> usize { + let count = count.max(1); + let at = stack.min(count - 1); + match forward { + true => (at + 1) % count, + false => (at + count - 1) % count, + } +} + fn step( id: &'static str, left: bool, @@ -1151,7 +1227,7 @@ fn shared_fields(delegate: &QrateTableDelegate, picked: &[usize]) -> Vec) -> impl IntoElement { let picked = self.picked(cx); - let front = self.front(&picked); + let front = stack_front(&picked, self.stack); let count = picked.len(); let selection = self.state.as_ref().and_then(|w| w.upgrade()).map(|s| { let delegate = s.read(cx).delegate(); @@ -1185,6 +1261,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() @@ -1193,14 +1273,9 @@ impl Render for DetailsPanel { style.bg(cx.theme().secondary_hover) }) .on_drop(|paths: &ExternalPaths, window, cx| { - if let Some(table) = cx - .try_global::() - .and_then(|handle| handle.0.upgrade()) - { - table.update(cx, |table, cx| { - table.import_external_paths(paths.paths().to_vec(), window, cx) - }); - } + TablePanelHandle::update(cx, |table, cx| { + table.import_external_paths(paths.paths().to_vec(), window, cx) + }); }) .flex() .flex_col() @@ -1416,14 +1491,9 @@ impl Render for DetailsPanel { .small() .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) - }); - } + TablePanelHandle::update(cx, |table, cx| { + table.locate_file(row, window, cx) + }); }), ), ) @@ -1555,20 +1625,15 @@ impl Render for DetailsPanel { .size_full() .drag_over::(|style, _, _, cx| style.bg(cx.theme().secondary_hover)) .on_drop(|paths: &ExternalPaths, window, cx| { - if let Some(table) = cx - .try_global::() - .and_then(|handle| handle.0.upgrade()) - { - table.update(cx, |table, cx| { - table.import_external_paths(paths.paths().to_vec(), window, cx) - }); - } + TablePanelHandle::update(cx, |table, cx| { + table.import_external_paths(paths.paths().to_vec(), window, cx) + }); }) // The whole panel gives the bottom-strip crop back at once, rather than each scrolling // 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") @@ -1600,9 +1665,10 @@ impl Render for DetailsPanel { } }) // Hidden in the gallery, not dropped: the split sizes its panels by index. + // Never shown in the pop-out's sidebar, whose stage is the file. .child( resizable_panel() - .visible(!gallery) + .visible(!gallery && self.rows.is_none()) .size(px(image_height)) .size_range(px(80.)..px(600.)) .flex_none() @@ -1732,6 +1798,28 @@ mod tests { use super::{DetailsPanel, render_image_frame}; + /// The stack wraps at both ends, and a remembered position past a smaller selection lands + /// on its last card rather than on nothing. + #[test] + fn the_stack_wraps_and_clamps_to_the_selection() { + use super::{stack_front, stack_step}; + + assert_eq!( + stack_step(2, 3, true), + 0, + "past the last wraps to the first" + ); + assert_eq!( + stack_step(0, 3, false), + 2, + "before the first wraps to the last" + ); + assert_eq!(stack_step(4, 2, true), 0, "a stale position clamps first"); + assert_eq!(stack_step(0, 0, true), 0, "nothing selected goes nowhere"); + assert_eq!(stack_front(&[7, 8], 5), Some(8)); + assert_eq!(stack_front(&[], 0), None); + } + /// Wraps `render_image_frame` in a root `Render` view so a test can actually draw it — /// `Img`'s real load/fallback logic runs during layout/paint, not at element construction, /// so building the element tree alone (without a window draw) wouldn't exercise it. @@ -1800,36 +1888,26 @@ mod tests { cx.add_window_view(DetailsPanel::new); } - /// A real table behind the panel, with autosave off so a committed edit doesn't write the - /// temp project file. Same shape as `table::delegate`'s own fixture. + /// A real table behind the panel: three rows, two of which share a Medium. fn project_with_table(cx: &mut TestAppContext) { - 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::project::CurrentProject { - file: std::env::temp_dir().join("qrate-details-edit.qrate"), - data: settings::project::ProjectData { - name: "T".into(), - columns: Vec::new(), - headers: vec!["Medium".into(), "Title".into()], - rows: vec![ - vec!["Film".into(), "one".into()], - vec!["Video".into(), "two".into()], - // Shares a Medium with row 0 but not a Title, so a selection of the two - // has one agreed field and one mixed. - vec!["Film".into(), "three".into()], - ], - row_ids: vec![1, 2, 3], - values: Default::default(), - }, - }); - }); - cx.add_window_view(table::TablePanel::new); + crate::test_support::open_table( + cx, + "qrate-details-edit.qrate", + settings::project::ProjectData { + name: "T".into(), + columns: Vec::new(), + headers: vec!["Medium".into(), "Title".into()], + rows: vec![ + vec!["Film".into(), "one".into()], + vec!["Video".into(), "two".into()], + // Shares a Medium with row 0 but not a Title, so a selection of the two has + // one agreed field and one mixed. + vec!["Film".into(), "three".into()], + ], + row_ids: vec![1, 2, 3], + values: Default::default(), + }, + ); } #[gpui::test] @@ -1976,7 +2054,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 @@ -1999,6 +2077,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. @@ -2018,7 +2137,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| { @@ -2029,7 +2148,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/panels/history.rs b/crates/workspace/src/panels/history.rs index b7271bbf..45271c3b 100644 --- a/crates/workspace/src/panels/history.rs +++ b/crates/workspace/src/panels/history.rs @@ -1055,12 +1055,7 @@ impl HistoryPanel { .map(|state| { let delegate = state.read(cx).delegate(); ( - delegate - .row_ids() - .iter() - .enumerate() - .map(|(p, id)| (*id, p)) - .collect(), + delegate.row_positions(), delegate .unsaved_history() .iter() diff --git a/crates/workspace/src/pop_out.rs b/crates/workspace/src/pop_out.rs new file mode 100644 index 00000000..9ab551b8 --- /dev/null +++ b/crates/workspace/src/pop_out.rs @@ -0,0 +1,1293 @@ +//! 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::MainWindowBounds; +use settings::project::{CurrentProject, RowId}; +use table::{QrateTableDelegate, 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; + +/// `.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"; + +/// 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); + +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; + } + 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, + 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, + /// What the OS title bar was last told, so a table edit does not set it again unchanged. + window_title: String, + _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 let Some(viewer) = &this.viewer { + preview::playback::stop(viewer.entity_id(), 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); + } + }), + // 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) { + settings::project::queue_write(file, BOUNDS_KEY, &json, cx); + } + cx.set_global(LastBounds(Some((file.clone(), bounds)))); + }), + ]; + + 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, + window_title: String::new(), + _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 { + crate::panels::details::stack_front(&self.rows, self.stack) + } + + /// 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(); + match &self.pinned { + // 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)| delegate.row_id(row) == Some(*id)) => + { + self.rows.clone() + } + // 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 = delegate.row_positions(); + 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; + 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() { + if let Some(leaving) = &self.viewer { + preview::playback::stop(leaving.entity_id(), cx); + } + self.viewer = file.map(|file| viewer::build(file, Scope::PopOut, window, cx)); + self._viewer_sub = self.viewer.as_ref().map(|viewer| { + // The viewer repaints itself on every pan and zoom; this window only draws + // its controls, tabs and find results. + let mut seen = None; + cx.observe(viewer, move |_, viewer, cx| { + let viewer = viewer.read(cx); + let now = (viewer.has_controls(), viewer.document, viewer.find_open); + if viewer.find_open || seen != Some(now) { + seen = Some(now); + cx.notify(); + } + }) + }); + } + let (file, rest) = self.title(cx); + let title = format!("{file}{rest}"); + if title != self.window_title { + window.set_window_title(&title); + self.window_title = title; + } + 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 delegate = state.read(cx).delegate(); + rows.iter() + .filter_map(|&row| delegate.row_id(row)) + .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(); + self.front() + .and_then(|row| viewer::next_previewable(delegate, row, delta)) + .and_then(|view| delegate.row_id(delegate.visible()[view])) + }; + 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) { + if self.rows.len() < 2 { + return; + } + self.stack = crate::panels::details::stack_step(self.stack, self.rows.len(), forward); + 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() + .and_then(|_| delegate.cursor_row()) + // 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) + } + + 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)); + // Only the messages read it, and they show only when there is no viewer. + let front = self + .front() + .filter(|_| self.viewer.is_none()) + .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| { + TablePanelHandle::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, + ) { + let state = crate::test_support::open_table( + cx, + "qrate-pop-out.qrate", + 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(), + }, + ); + 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])); + } + + /// 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" + ) + }); + } + + /// 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) { + 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))); + } + + 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 wav = preview::playback::silent_wav(8000); + let shown = std::env::temp_dir().join("qrate-pop-out-shown.wav"); + 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); + // 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()); + preview::playback::stop(popped.entity_id(), cx); + assert_eq!( + preview::playback::playing(cx).map(|path| path.to_path_buf()), + before, + "the main window's playback of the same file plays on" + ); + + preview::playback::play(&shown, popped.entity_id(), cx); + let before = preview::playback::playing(cx).map(|path| path.to_path_buf()); + crate::viewer::open_viewer(shown.clone(), crate::viewer::Scope::Workspace, window, cx); + crate::viewer::close_viewer(window, cx); + assert_eq!( + preview::playback::playing(cx).map(|path| path.to_path_buf()), + before, + "closing the main window's viewer leaves the pop-out playing" + ); + + preview::playback::stop(popped.entity_id(), cx); + assert!(preview::playback::playing(cx).is_none(), "its own stops"); + }); + + 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) { + 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..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 { @@ -82,7 +84,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 +100,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 7c83609b..fac89225 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; @@ -22,9 +22,10 @@ use std::time::Duration; use gpui::prelude::FluentBuilder as _; use gpui::*; use gpui_component::{ - ActiveTheme, Disableable as _, IconName, Selectable as _, Sizable, StyledExt as _, + ActiveTheme, Disableable as _, Icon, IconName, Selectable as _, Sizable, button::{Button, ButtonVariants}, - input::{InputEvent, InputState}, + h_flex, + input::{Input, InputEvent, InputState}, resizable::{ResizableState, h_resizable, resizable_panel}, slider::{Slider, SliderEvent, SliderState, SliderValue}, }; @@ -44,6 +45,18 @@ pub const VIEWER_CONTEXT: &str = "Viewer"; const PANEL: Pixels = px(384.); const PANEL_RANGE: std::ops::Range = px(240.)..px(720.); +/// The page strip's width, and the height of one page in it: a thumbnail and its number. +const STRIP: Pixels = px(128.); +const STRIP_ROW: Pixels = px(168.); +/// The most a page thumbnail may take inside a row, after its padding and number. +const STRIP_THUMB: Size = size(px(112.), px(128.)); + +/// 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. @@ -53,6 +66,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. @@ -73,24 +89,44 @@ 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)); + stop_active(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 pixels = preview::dimensions(&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(); @@ -102,12 +138,9 @@ pub fn open_viewer(path: PathBuf, scope: Scope, window: &mut Window, cx: &mut Ap window, |this: &mut Viewer, table, _: &table::TableChanged, window, cx| { let delegate = table.read(cx).delegate(); - let file = match delegate.selection() { - Some(table::Selection::Cell { row, .. } | table::Selection::Row(row)) => { - previewable(delegate, row) - } - _ => None, - }; + let file = delegate + .cursor_row() + .and_then(|row| previewable(delegate, row)); if let Some(file) = file.filter(|file| *file != this.path) { open_viewer(file, this.scope, window, cx); } @@ -127,41 +160,67 @@ pub fn open_viewer(path: PathBuf, scope: Scope, window: &mut Window, cx: &mut Ap zoom: 1.0, offset: Point::default(), drag_from: None, + turns: 0, + shown: 0, + pixels, + header: pixels, + scale: 1.0, frame: Rc::default(), focus_handle: cx.focus_handle(), focused: false, find: Find::default(), find_open: false, + page_input: None, + strip_open: false, + strip: UniformListScrollHandle::new(), 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(); - cx.set_global(ActiveViewer { - viewer: Some(viewer), - return_focus, }); + viewer.update(cx, |viewer, _| viewer._probe = Some(probe)); + viewer +} + +/// The size a page thumbnail takes in the strip: its own shape, as large as fits the row. A page +/// that has not decoded yet is drawn portrait, the shape most documents are. +fn strip_frame(pixels: Option<(u32, u32)>) -> Size { + let (width, height) = pixels + .filter(|(width, height)| *width > 0 && *height > 0) + .map_or((3.0, 4.0), |(width, height)| (width as f32, height as f32)); + let scale = (f32::from(STRIP_THUMB.width) / width).min(f32::from(STRIP_THUMB.height) / height); + size(px(width * scale), px(height * scale)) +} + +/// The page a typed number lands on: 1-based as the reader types it, clamped to the document. +/// `None` for anything that is not a number, which leaves the page where it is. +fn typed_page(text: &str, pages: usize) -> Option { + let number: usize = text.trim().parse().ok()?; + Some(number.clamp(1, pages.max(1)) - 1) } /// 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()) @@ -170,18 +229,9 @@ fn step_row(delta: isize, cx: &mut App) { }; let target = { let delegate = state.read(cx).delegate(); - let visible = delegate.visible(); - let from = match delegate.selection() { - Some(table::Selection::Cell { row, .. } | table::Selection::Row(row)) => { - delegate.view_row(row) - } - _ => None, - }; - from.and_then(|from| { - next_row(from, delta, visible.len(), |view| { - previewable(delegate, visible[view]).is_some() - }) - }) + delegate + .cursor_row() + .and_then(|row| next_previewable(delegate, row, delta)) }; if let Some(view) = target { state.update(cx, |state, cx| { @@ -191,8 +241,22 @@ fn step_row(delta: isize, cx: &mut App) { } } +/// The view index of the nearest row past source `row`, by `delta`, whose file the viewer can +/// show. `None` at either end, or when `row` is filtered out of the view. +pub(crate) fn next_previewable( + delegate: &table::QrateTableDelegate, + row: usize, + delta: isize, +) -> Option { + let visible = delegate.visible(); + let from = delegate.view_row(row)?; + next_row(from, delta, visible.len(), |view| { + previewable(delegate, visible[view]).is_some() + }) +} + /// 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)) @@ -219,9 +283,19 @@ fn next_row( } } +/// Stop the recording the open viewer started, as it is closed or replaced by the next row's. +fn stop_active(cx: &mut App) { + if let Some(viewer) = cx + .try_global::() + .and_then(|active| active.viewer.clone()) + { + preview::playback::stop(viewer.entity_id(), cx); + } +} + pub fn close_viewer(window: &mut Window, cx: &mut App) { // Without this the recording plays on over an empty screen, with nothing left to stop it. - preview::playback::stop(cx); + stop_active(cx); let return_focus = cx .try_global::() .and_then(|active| active.return_focus.clone()); @@ -232,7 +306,7 @@ pub fn close_viewer(window: &mut Window, cx: &mut App) { } pub struct Viewer { - path: PathBuf, + pub(crate) path: PathBuf, /// File type and size, read once when the viewer opens rather than statting on every repaint. details: Option, scope: Scope, @@ -243,12 +317,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>, @@ -261,26 +335,45 @@ pub struct Viewer { offset: Point, /// Last pointer position while dragging; `None` when not panning. drag_from: Option>, + /// Quarter turns clockwise, for a scan that was fed in sideways. A view, never saved. + turns: u8, + /// The turn on screen: the last one decoded, kept up while `turns` decodes so a turn never + /// blanks the stage. + shown: u8, + /// Upright pixel size of what is on screen — what "actual size" is measured against. + pixels: Option<(u32, u32)>, + /// The file header's size, for the picture gpui draws from the file itself and never reports. + header: Option<(u32, u32)>, + /// The window's scale factor at the last render, so 1:1 means one image pixel per device pixel. + scale: f32, /// Window-space rect of the content box, from `canvas` prepaint — where scroll-zoom's anchor /// is measured from. frame: Rc>>, - 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 go-to-page box in the bottom pill, built the first time the pill draws. + page_input: Option>, + /// Whether the page strip is showing, for a file with pages to list. + strip_open: bool, + strip: UniformListScrollHandle, /// 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 /// 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 { @@ -315,17 +408,93 @@ impl Viewer { cx.notify(); } - /// Clamp zoom to [0.1, 8] — below 1 zooms out past the initial fit — keeping the point at - /// `anchor` (relative to the frame's centre) still, and recenter once the image is no bigger - /// than its frame, where there's nothing to pan to. + /// Clamp zoom to [0.1, 8], widened to reach actual size either way — below 1 zooms out past + /// the initial fit — keeping the point at `anchor` (relative to the frame's centre) still. The + /// pan stays where the reader put it, inside the frame; only [`Self::fit_view`] recentres. fn set_zoom(&mut self, zoom: f32, anchor: Point) { - let zoom = zoom.clamp(0.1, 8.0); + let actual = self.actual_size(); + let most = actual.map_or(8.0, |actual| actual.max(8.0)); + let least = actual.map_or(0.1, |actual| actual.min(0.1)); + let zoom = zoom.clamp(least, most); let scale = zoom / self.zoom; self.offset = anchor - (anchor - self.offset) * scale; self.zoom = zoom; - if self.zoom <= 1.0 { - self.offset = Point::default(); + self.clamp_pan(); + } + + /// Back to fit, centred: the one view the pointer cannot land on exactly. + fn fit_view(&mut self) { + self.zoom = 1.0; + self.offset = Point::default(); + } + + /// The picture on screen's pixel size as turned, and the scale that fits it to the frame. `None` + /// where the file has no pixel size of its own, or before the frame has been laid out. + fn fit(&self) -> Option<(Size, f32)> { + let (width, height) = self.pixels?; + let image = match self.shown % 2 { + 0 => size(width as f32, height as f32), + _ => size(height as f32, width as f32), + }; + let frame = self.frame.get().size; + let fit = + (f32::from(frame.width) / image.width).min(f32::from(frame.height) / image.height); + (fit > 0.0).then_some((image, fit)) + } + + /// The zoom at which one pixel of the image is one pixel of the screen. + fn actual_size(&self) -> Option { + self.fit().map(|(_, fit)| 1.0 / (fit * self.scale)) + } + + /// The most the picture may be panned each way: to where its edge meets the frame's, from + /// outside when it overhangs and from inside when it is smaller. + fn slack(&self) -> Point { + let frame = self.frame.get().size; + let shown = self.fit().map_or(frame, |(image, fit)| { + size(px(image.width * fit), px(image.height * fit)) + }); + point( + ((shown.width * self.zoom - frame.width) / 2.).abs(), + ((shown.height * self.zoom - frame.height) / 2.).abs(), + ) + } + + /// Keep the picture's edges against the frame's, so a drag cannot lose it off screen. Left + /// alone before the frame has a size, which is only ever the case before the first paint. + fn clamp_pan(&mut self) { + if self.frame.get().size.width <= px(0.) { + return; } + let slack = self.slack(); + self.offset.x = self.offset.x.clamp(-slack.x, slack.x); + self.offset.y = self.offset.y.clamp(-slack.y, slack.y); + } + + /// A quarter turn clockwise, or back with `-1`. Starts from fit: the old zoom and pan were aimed + /// at a picture of a different shape. + fn rotate(&mut self, delta: i8) { + self.turns = (self.turns as i8 + delta).rem_euclid(4) as u8; + self.fit_view(); + log::debug!( + "viewer: {} turned to {}°", + self.path.display(), + self.turns as u16 * 90 + ); + } + + /// Fit when zoomed, actual size when fitted; 2× for a file with no pixel size of its own, or + /// whose actual size is the fit. + fn toggle_zoom(&mut self, anchor: Point) { + if (self.zoom - 1.0).abs() > 0.01 { + self.fit_view(); + return; + } + let target = self + .actual_size() + .filter(|actual| (actual - 1.0).abs() > 0.05) + .unwrap_or(2.0); + self.set_zoom(target, anchor); } /// Move `delta` pages, stopping at either end rather than wrapping — a document has a first @@ -347,12 +516,76 @@ impl Viewer { /// lands on a page directly rather than by stepping to it, and must reset the same things. fn show_page(&mut self, page: usize) { self.page = page; - self.zoom = 1.0; - self.offset = Point::default(); + self.fit_view(); + // A new page has no old turn decoded to keep up. + self.shown = self.turns; + self.strip.scroll_to_item(page, ScrollStrategy::Nearest); + } + + /// Zoom until the page spans the frame's width, starting at its top. A page already that wide + /// at fit stays at fit. + fn fit_width(&mut self) { + let Some((image, fit)) = self.fit() else { + return; + }; + let zoom = f32::from(self.frame.get().size.width) / (image.width * fit); + self.set_zoom(zoom, Point::default()); + if self.zoom > 1.0 { + self.offset.y = self.slack().y; + } + } + + /// Whether the file has pages to list: a document or image stack, not a video's seconds. + fn paged(&self) -> bool { + self.pages > 1 && self.scrubber.is_none() && !self.video + } + + /// The page box, built on first use. It shows the current page whenever it is not being typed + /// in, and Enter goes to what was typed. + fn page_input(&mut self, window: &mut Window, cx: &mut Context) -> Entity { + let input = match self.page_input.clone() { + Some(input) => input, + None => { + let input = cx.new(|cx| InputState::new(window, cx)); + cx.subscribe_in( + &input, + window, + |this, input, event: &InputEvent, window, cx| { + if let InputEvent::PressEnter { .. } = event { + if let Some(page) = typed_page(&input.read(cx).value(), this.pages) { + this.show_page(page); + } + window.focus(&this.focus_handle, cx); + cx.notify(); + } + }, + ) + .detach(); + self.page_input = Some(input.clone()); + input + } + }; + let current = (self.page + 1).to_string(); + if !input.focus_handle(cx).is_focused(window) && input.read(cx).value() != current { + input.update(cx, |input, cx| input.set_value(current, window, cx)); + } + input + } + + /// 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 @@ -422,7 +655,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({ @@ -443,8 +678,7 @@ impl Viewer { cx.notify(); }) .ok(); - }) - .detach(); + })); cx.notify(); } @@ -468,6 +702,7 @@ impl Render for Viewer { window.focus(&self.focus_handle, cx); self.focused = true; } + self.scale = window.scale_factor(); let (zoom, offset, page, pages) = (self.zoom, self.offset, self.page, self.pages); let name: SharedString = self .path @@ -488,13 +723,129 @@ impl Render for Viewer { }; let pill = cx.theme().background.opacity(0.8); let accent = cx.theme().primary.opacity(0.55); - let marks: Vec = self.find.on_page(page).cloned().collect(); + // A hit's box is measured on the upright page, so a turned page shows none. + let marks: Vec = match self.turns { + 0 => self.find.on_page(page).cloned().collect(), + _ => Vec::new(), + }; + let wanted = preview::source(&self.path, cap, page, self.turns); + if let Some(size) = preview::decoded(&wanted, window, cx) { + self.shown = self.turns; + // A PDF or TIFF page has no header size, and pages differ; what was drawn does. + self.pixels = match size { + Some((width, height)) => Some(match self.turns % 2 { + 0 => (width, height), + _ => (height, width), + }), + None => self.header, + }; + } + let picture = match self.shown == self.turns { + true => wanted, + false => preview::source(&self.path, cap, page, self.shown), + }; + // gpui on Windows has no grab cursors and falls back to the arrow; the hand is its nearest. + let cursor = match (self.drag_from.is_some(), self.slack() != Point::default()) { + (false, false) => CursorStyle::Arrow, + _ if cfg!(windows) => CursorStyle::PointingHand, + (true, _) => CursorStyle::ClosedHand, + (false, true) => CursorStyle::OpenHand, + }; + let readout = match self.actual_size() { + Some(actual) => format!("{:.0}%", zoom / actual * 100.), + None => format!("{:.0}%", zoom * 100.), + }; // The panel's *live* width, straight off the resizable's state, so the rows re-trim as it // is dragged. Empty until the group has laid out once. let panel_width = self.split.read(cx).sizes().get(1).copied().unwrap_or(PANEL); let banner = self .needs .and_then(|id| crate::component_banner::banner(id, cx)); + let popped = self.scope == Scope::PopOut; + // Only the page pill has one, so a photo, recording or video never builds it. + let page_input = + (self.has_controls() && self.transport.is_none() && self.scrubber.is_none()) + .then(|| self.page_input(window, cx)); + let paged = self.paged(); + let strip_width = match paged && self.strip_open { + true => STRIP, + false => px(0.), + }; + // Only the rows on screen are built, so a 300-page scan asks for a handful of thumbnails. + // ponytail: they share PDFium's one lock with the page itself, so a jump can wait behind a + // screenful of thumbnails; render the page first if that shows up. + let strip = (strip_width > px(0.)).then(|| { + let (primary, muted, radius, tile) = ( + cx.theme().primary, + cx.theme().muted_foreground, + cx.theme().radius, + cx.theme().muted, + ); + div() + .w(STRIP) + .h_full() + .flex_none() + .bg(pill) + .occlude() + .child( + uniform_list( + "viewer-pages", + pages, + cx.processor(move |this, range: std::ops::Range, window, cx| { + range + .map(|index| { + let on = index == this.page; + div() + .id(("viewer-page", index)) + .h(STRIP_ROW) + .p_2() + .flex() + .flex_col() + .items_center() + .gap_1() + .cursor_pointer() + .child({ + let source = preview::source( + &this.path, + preview::CARD, + index, + 0, + ); + let shape = + preview::decoded(&source, window, cx).flatten(); + let frame = strip_frame(shape); + div() + .w(frame.width) + .h(frame.height) + .flex_none() + .rounded(radius) + .border_2() + .border_color(match on { + true => primary, + false => transparent_black(), + }) + .overflow_hidden() + .bg(tile) + .child(img(source).size_full().rounded(radius)) + }) + .child( + div() + .text_xs() + .when(!on, |label| label.text_color(muted)) + .child((index + 1).to_string()), + ) + .on_click(cx.listener(move |this, _, _, cx| { + this.show_page(index); + cx.notify(); + })) + }) + .collect() + }), + ) + .track_scroll(&self.strip) + .size_full(), + ) + }); div() .track_focus(&self.focus_handle) @@ -503,12 +854,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); } })) @@ -526,23 +876,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(); } @@ -559,16 +914,32 @@ impl Render for Viewer { } // Back to fit, the one zoom the pointer cannot land on exactly. "0" if reading => { - this.set_zoom(1.0, Point::default()); + this.fit_view(); cx.notify(); } - "up" | "down" if reading => { + "1" if reading => { + if let Some(actual) = this.actual_size() { + this.set_zoom(actual, Point::default()); + cx.notify(); + } + } + "w" if reading => { + this.fit_width(); + cx.notify(); + } + "r" if reading => { + this.rotate(if ev.keystroke.modifiers.shift { -1 } else { 1 }); + cx.notify(); + } + "up" | "down" if reading && overlay => { step_row(if ev.keystroke.key == "up" { -1 } else { 1 }, cx); } _ => {} } })) .child( + h_flex().size_full().children(strip).child( + div().flex_1().min_w_0().h_full().child( h_resizable("viewer-split") .with_state(&self.split) .child( @@ -590,9 +961,14 @@ impl Render for Viewer { cx.notify(); }, )) + .cursor(cursor) .on_mouse_down( MouseButton::Left, cx.listener(|this, ev: &MouseDownEvent, _, cx| { + if ev.click_count == 2 { + let anchor = ev.position - this.frame.get().center(); + this.toggle_zoom(anchor); + } this.drag_from = Some(ev.position); cx.notify(); }), @@ -603,6 +979,7 @@ impl Render for Viewer { }; this.offset.x += ev.position.x - last.x; this.offset.y += ev.position.y - last.y; + this.clamp_pan(); this.drag_from = Some(ev.position); cx.notify(); })) @@ -613,6 +990,14 @@ impl Render for Viewer { cx.notify(); }), ) + // Released over a panel or outside the window, the drag still ends. + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|this, _: &MouseUpEvent, _, cx| { + this.drag_from = None; + cx.notify(); + }), + ) .child( // The positioned content box. The overlay measures *this* // element, so the highlight maths never has to know about the @@ -633,7 +1018,9 @@ impl Render for Viewer { }) .children((!bare).then(|| { // `flex_shrink_0` keeps `relative(zoom)` past 1. - img(preview::source(&self.path, cap, page)) + // The id is what lets gpui keep a GIF's frame clock. + img(picture) + .id("viewer-image") .flex_shrink_0() .relative() .w(relative(zoom)) @@ -657,15 +1044,16 @@ 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( div() .absolute() .top_4() - .left_4() + .left(px(16.) + strip_width) .px_2() .py_1() .rounded(cx.theme().radius) @@ -692,10 +1080,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() @@ -711,20 +1096,15 @@ impl Render for Viewer { false => slot.bottom_4(), }) .child( - // Loud on purpose. These are the only controls a reader reaches for - // constantly, and over a dimmed page a translucent pill of small - // ghost buttons reads as decoration. + // Same pill as the toolbar and row stepper. div() .flex() .items_center() - .gap_2() - .px_2() - .py_1() + .gap_1() + .p_1() .rounded(cx.theme().radius) - .bg(cx.theme().background) - .border_1() - .border_color(cx.theme().border) - .shadow_lg() + .bg(pill) + .text_sm() .occlude() .map(|pill| match (&self.transport, &self.scrubber) { (Some(transport), _) => { @@ -732,7 +1112,7 @@ impl Render for Viewer { } (_, Some(scrubber)) => pill .child( - div().px_1().font_semibold().child(transport::clock( + div().px_1().child(transport::clock( Duration::from_secs(self.scrub as u64), )), ) @@ -754,7 +1134,8 @@ impl Render for Viewer { .child( Button::new("play-in-default-app") .icon(IconName::ExternalLink) - .outline() + .ghost() + .small() .tooltip("Play in the default app") .on_click({ let path = self.path.clone(); @@ -773,10 +1154,34 @@ impl Render for Viewer { }), ), _ => pill + .when(paged, |pill| { + pill.child( + Button::new("toggle-pages") + .icon(Icon::new(IconName::PanelLeft).when( + self.strip_open, + |icon| icon.text_color(cx.theme().primary), + )) + .ghost() + .small() + .tooltip(match self.strip_open { + true => "Hide pages", + false => "Show pages", + }) + .on_click(cx.listener(|this, _, _, cx| { + this.strip_open = !this.strip_open; + this.strip.scroll_to_item( + this.page, + ScrollStrategy::Center, + ); + cx.notify(); + })), + ) + }) .child( Button::new("previous-page") .icon(IconName::ChevronLeft) - .outline() + .ghost() + .small() .disabled(page == 0) .tooltip("Previous page") .on_click(cx.listener(|this, _, _, cx| { @@ -786,22 +1191,44 @@ impl Render for Viewer { ) // Numbered from one: the page count a reader sees has to // match the one printed on the document. + .child(div().pl_1().child("Page")) .child( div() - .px_1() - .font_semibold() - .child(format!("Page {} of {pages}", page + 1)), + .w(px(52.)) + .on_action(cx.listener( + |this, _: &gpui_component::input::Escape, window, cx| { + window.focus(&this.focus_handle, cx); + cx.notify(); + }, + )) + .children(page_input.map(|input| Input::new(&input).small())), ) + .child(div().pr_1().child(format!("of {pages}"))) .child( Button::new("next-page") .icon(IconName::ChevronRight) - .outline() + .ghost() + .small() .disabled(page + 1 >= pages) .tooltip("Next page") .on_click(cx.listener(|this, _, _, cx| { this.turn_page(1); cx.notify(); })), + ) + .child( + Button::new("fit-width") + .icon( + Icon::empty() + .path("icons/move-horizontal.svg"), + ) + .ghost() + .small() + .tooltip("Fit to width (W)") + .on_click(cx.listener(|this, _, _, cx| { + this.fit_width(); + cx.notify(); + })), ), }), ), @@ -809,8 +1236,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() @@ -835,9 +1262,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() @@ -863,15 +1291,26 @@ 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); } })), ) }) + .when(!bare, |group| { + group.child( + Button::new("rotate") + .icon(IconName::RotateCw) + .ghost() + .small() + .tooltip("Rotate (R, Shift+R back)") + .on_click(cx.listener(|this, _, _, cx| { + this.rotate(1); + cx.notify(); + })), + ) + }) .child( Button::new("zoom-out") .icon(IconName::Minus) @@ -883,6 +1322,21 @@ impl Render for Viewer { cx.notify(); })), ) + // Percent of actual size where the file has one, else of the fit. + .child( + Button::new("zoom-readout") + .label(readout) + .ghost() + .small() + .tooltip(match self.actual_size() { + Some(_) => "Toggle fit (0) and actual size (1)", + None => "Toggle fit (0) and 2×", + }) + .on_click(cx.listener(|this, _, _, cx| { + this.toggle_zoom(Point::default()); + cx.notify(); + })), + ) .child( Button::new("zoom-in") .icon(IconName::Plus) @@ -901,8 +1355,8 @@ impl Render for Viewer { .small() .tooltip("Close (Esc)") .on_click(cx.listener(|_, _, window, cx| close_viewer(window, cx))), - ), - ) + ) + })) } } @@ -914,7 +1368,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() { @@ -962,6 +1416,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); @@ -1041,6 +1524,182 @@ mod tests { }); } + /// A drag cannot lose the picture off screen, "actual size" follows the turn, and a turn starts + /// again from fit. + #[gpui::test] + fn panning_stops_at_the_edge_and_a_turn_swaps_the_actual_size(cx: &mut TestAppContext) { + let cx = with_window(cx); + let path = std::path::PathBuf::from("/nonexistent/qrate-pan-test.png"); + cx.update(|window, cx| { + open_viewer(path, Scope::Workspace, window, cx); + let viewer = viewer_in(Scope::Workspace, cx).expect("just opened"); + viewer.update(cx, |viewer, _| { + // A 2000×1000 picture in a 1000×500 frame: fitted at half scale. + viewer.pixels = Some((2000, 1000)); + viewer.frame.set(gpui::Bounds::new( + gpui::Point::default(), + gpui::size(gpui::px(1000.), gpui::px(500.)), + )); + assert_eq!(viewer.actual_size(), Some(2.0)); + + viewer.set_zoom(2.0, gpui::Point::default()); + viewer.offset = gpui::point(gpui::px(5000.), gpui::px(-5000.)); + viewer.clamp_pan(); + assert_eq!( + viewer.offset, + gpui::point(gpui::px(500.), gpui::px(-250.)), + "no further than the overhang on either side" + ); + + viewer.set_zoom(0.5, gpui::Point::default()); + viewer.offset = gpui::point(gpui::px(5000.), gpui::px(5000.)); + viewer.clamp_pan(); + assert_eq!( + viewer.offset, + gpui::point(gpui::px(250.), gpui::px(125.)), + "zoomed out, it still drags, as far as the frame's edge" + ); + viewer.set_zoom(0.6, gpui::Point::default()); + assert_ne!( + viewer.offset, + gpui::Point::default(), + "zooming keeps the pan" + ); + viewer.fit_view(); + assert_eq!(viewer.offset, gpui::Point::default(), "fit recentres"); + + viewer.rotate(1); + assert_eq!(viewer.zoom, 1.0); + assert_eq!(viewer.offset, gpui::Point::default()); + assert_eq!( + viewer.actual_size(), + Some(2.0), + "measured on the upright picture still on screen" + ); + // The turn lands: now 1000×2000 in the same frame, fitted at a quarter. + viewer.shown = viewer.turns; + assert_eq!(viewer.actual_size(), Some(4.0)); + viewer.rotate(-2); + assert_eq!(viewer.turns, 3, "a turn back from upright wraps"); + }); + close_viewer(window, cx); + }); + } + + /// Fit to width fills the frame's width from the top of a tall page, and leaves a page that + /// is already as wide as its frame at fit. + #[gpui::test] + fn fit_width_fills_the_width_from_the_top(cx: &mut TestAppContext) { + let cx = with_window(cx); + let path = std::path::PathBuf::from("/nonexistent/qrate-fit-width.pdf"); + cx.update(|window, cx| { + open_viewer(path, Scope::Workspace, window, cx); + let viewer = viewer_in(Scope::Workspace, cx).expect("just opened"); + viewer.update(cx, |viewer, _| { + viewer.frame.set(gpui::Bounds::new( + gpui::Point::default(), + gpui::size(gpui::px(1000.), gpui::px(500.)), + )); + + // Portrait, fitted at a quarter: four times over to span the width. + viewer.pixels = Some((1000, 2000)); + viewer.fit_width(); + assert_eq!(viewer.zoom, 4.0); + assert_eq!( + viewer.offset, + gpui::point(gpui::px(0.), gpui::px(750.)), + "the page's top edge on the frame's" + ); + + // Landscape already spans the width at fit. + viewer.show_page(0); + viewer.pixels = Some((2000, 1000)); + viewer.fit_width(); + assert_eq!(viewer.zoom, 1.0); + assert_eq!(viewer.offset, gpui::Point::default()); + }); + close_viewer(window, cx); + }); + } + + /// Actual size is reachable however small the file: an icon fitted far past 10× still gets + /// down to one pixel per pixel. + #[gpui::test] + fn actual_size_is_reachable_for_a_tiny_image(cx: &mut TestAppContext) { + let cx = with_window(cx); + let path = std::path::PathBuf::from("/nonexistent/qrate-tiny.png"); + cx.update(|window, cx| { + open_viewer(path, Scope::Workspace, window, cx); + let viewer = viewer_in(Scope::Workspace, cx).expect("just opened"); + viewer.update(cx, |viewer, _| { + viewer.pixels = Some((32, 32)); + viewer.frame.set(gpui::Bounds::new( + gpui::Point::default(), + gpui::size(gpui::px(1000.), gpui::px(500.)), + )); + let actual = viewer.actual_size().expect("has a size"); + assert!(actual < 0.1); + viewer.set_zoom(actual, gpui::Point::default()); + assert_eq!(viewer.zoom, actual); + }); + close_viewer(window, cx); + }); + } + + /// A page in the strip keeps its own shape and never leaves the row's box. + #[test] + fn a_strip_thumbnail_keeps_the_pages_shape_inside_the_row() { + use super::{STRIP_THUMB, strip_frame}; + use gpui::{px, size}; + + assert_eq!( + strip_frame(Some((1000, 2000))), + size(px(64.), px(128.)), + "tall" + ); + assert_eq!( + strip_frame(Some((2000, 1000))), + size(px(112.), px(56.)), + "wide" + ); + let pending = strip_frame(None); + assert!(pending.height > pending.width, "portrait until it decodes"); + assert!(pending.width <= STRIP_THUMB.width && pending.height <= STRIP_THUMB.height); + assert_eq!( + strip_frame(Some((0, 10))), + pending, + "a degenerate size is not divided by" + ); + } + + /// A typed page is 1-based, lands inside the document however far off it is, and anything + /// that is not a number leaves the page alone. + #[test] + fn a_typed_page_is_one_based_and_clamped() { + use super::typed_page; + + assert_eq!(typed_page("1", 300), Some(0)); + assert_eq!(typed_page(" 42 ", 300), Some(41)); + assert_eq!( + typed_page("999", 300), + Some(299), + "past the end is the last page" + ); + assert_eq!( + typed_page("0", 300), + Some(0), + "before the start is the first page" + ); + assert_eq!(typed_page("", 300), None); + assert_eq!(typed_page("x", 300), None); + assert_eq!(typed_page("-3", 300), None); + assert_eq!( + typed_page("5", 0), + Some(0), + "an uncounted file still has page one" + ); + } + /// Paging has to stop at both ends. Wrapping past the last page loses the reader's place, and /// an underflow on page zero would panic on a `usize` subtraction. #[gpui::test] @@ -1053,7 +1712,9 @@ mod tests { viewer.update(cx, |viewer, _| { // A missing file reports one page, so give it a document to page through. + assert!(!viewer.paged(), "one page has nothing to list"); viewer.pages = 3; + assert!(viewer.paged(), "a document with pages gets the strip"); viewer.turn_page(-1); assert_eq!(viewer.page, 0, "cannot go back from the first page"); @@ -1119,6 +1780,14 @@ mod tests { let document = viewer_in(Scope::Workspace, cx).expect("just opened"); document.update(cx, |viewer, _| assert!(viewer.transport.is_none())); + // A video's positions are seconds, which are the scrubber's and not a page strip's. + open_viewer("/nonexistent/clip.mp4".into(), Scope::Workspace, window, cx); + let video = viewer_in(Scope::Workspace, cx).expect("just opened"); + video.update(cx, |viewer, _| { + viewer.pages = 6; + assert!(!viewer.paged()); + }); + close_viewer(window, cx); }); } @@ -1132,29 +1801,15 @@ mod tests { #[gpui::test] fn closing_the_viewer_leaves_nothing_playing(cx: &mut TestAppContext) { let cx = with_window(cx); - // 44-byte canonical WAV header, then a second of 8 kHz 16-bit mono silence. - 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()); // PCM - wav.extend(1u16.to_le_bytes()); // mono - 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 wav = preview::playback::silent_wav(8000); let path = std::env::temp_dir().join("qrate-viewer-close-stops.wav"); std::fs::write(&path, &wav).unwrap(); 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), @@ -1191,6 +1846,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 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 { diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index 0f871e50..0ea9db36 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -237,12 +237,7 @@ impl ViewsPanel { .and_then(WeakEntity::upgrade) .and_then(|state| { let delegate = state.read(cx).delegate(); - match delegate.selection()? { - table::Selection::Cell { row, .. } | table::Selection::Row(row) => { - delegate.view_row(row) - } - table::Selection::Column(_) => None, - } + delegate.view_row(delegate.cursor_row()?) }); if cursor == self.gallery_followed { return;