From 2e4f61ad747560025937057b6d50be1563ce00ab Mon Sep 17 00:00:00 2001 From: Babken Egoian <101829110+green2grey@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:06:06 -0700 Subject: [PATCH 1/2] feat: Quick Preview click-outside-to-close and single-instance toggle Click-outside (FR-002): - Layer-shell path anchors the surface to all four edges; a transparent backdrop Box (gtk::Overlay main child) closes the window on any click, while the centered 900x700 panel holds the viewer as an overlay sibling, so clicks on the image never reach the backdrop gesture and drag-select / middle-drag pan keep working. - Window CSS makes the anchored surface transparent; the panel restores an opaque background with rounded corners. - Fallback path (no layer shell) closes on focus loss with a was-active latch instead of the originally sketched GestureClick, which would also have fired for clicks inside the preview (PHASED_PLAN amended). Single-instance toggle (FR-002): - GApplication now runs with HANDLES_COMMAND_LINE; the invoking process resolves everything (clap, stdin, canonicalization) and forwards a canonical --key=value argv (new ipc module). Single-token framing is deliberate: GLib's local GOptionContext strips a bare -- separator even in pass-through mode. - Dispatch in the primary: same-file preview invocation toggles the preview closed, different-file replaces its content, full-viewer invocations open a new window (closing any preview first, since the anchored keyboard-exclusive overlay would block it). - This retires the run_with_args(&[]) workaround: GLib only ever sees the sanitized argv we build. --- .claude/CLAUDE.md | 9 +- crates/quickview-ui/src/ipc.rs | 158 ++++++++++++++++++ crates/quickview-ui/src/lib.rs | 112 +++++++++++-- crates/quickview-ui/src/windows/mod.rs | 2 +- .../quickview-ui/src/windows/quick_preview.rs | 108 +++++++++++- crates/quickview-ui/src/windows/shared.rs | 1 - docs/PHASED_PLAN.md | 19 ++- 7 files changed, 377 insertions(+), 32 deletions(-) create mode 100644 crates/quickview-ui/src/ipc.rs diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 601ecb8..627fd0a 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -47,7 +47,13 @@ The scaffold is functional with image display, async OCR pipeline, drag-select o - Async, sandboxed image loading (glycin loader process when installed, runtime-probed once per session; in-process GDK decoding on a worker thread otherwise — fallback is session-wide only, never per-file) -- Quick Preview window (borderless, layer-shell, Space/Esc dismiss) +- Quick Preview window (borderless, layer-shell, Space/Esc dismiss, + click-outside-to-close via transparent backdrop; focus-loss close on the + no-layer-shell fallback path) +- Single-instance app (`HANDLES_COMMAND_LINE` + canonical synthetic argv): + repeating a `--quick-preview` invocation toggles the preview closed, a + different file replaces its content, full-viewer invocations open new + windows in the primary instance - Full Viewer window (headerbar, arrow key navigation) - File info in the headerbar (filename, dimensions, file size) - Async OCR (Tesseract TSV → word bounding boxes) @@ -59,7 +65,6 @@ The scaffold is functional with image display, async OCR pipeline, drag-select o - Zoom & pan (Ctrl+scroll, pinch, +/- keys, middle-drag pan) via custom `ZoomableCanvas` widget ### What's not implemented yet: -- Quick Preview click-outside-to-close and single-instance toggle - Performance benchmarks ## Development diff --git a/crates/quickview-ui/src/ipc.rs b/crates/quickview-ui/src/ipc.rs new file mode 100644 index 0000000..02fde29 --- /dev/null +++ b/crates/quickview-ui/src/ipc.rs @@ -0,0 +1,158 @@ +//! Canonical argv codec for single-instance forwarding. +//! +//! With `HANDLES_COMMAND_LINE`, a second invocation's argv is delivered to +//! the primary instance's `command-line` handler. The real CLI (clap, stdin +//! path resolution, canonicalization) runs in the invoking process; what +//! crosses the process boundary is only this fixed, sanitized form: +//! +//! ```text +//! quickview --mode= --lang= --file= +//! ``` +//! +//! Every value is glued to its key in a single `--key=value` token: GLib's +//! local `GOptionContext` pass still runs on the remote side even in +//! pass-through mode, and it strips a bare `--` separator (observed +//! empirically), while unknown `--key=value` tokens travel untouched. The +//! `--file=` framing also keeps file names starting with `-` safe without a +//! separator. Encoding is UTF-8 `String` (gio's `run_with_args` accepts +//! nothing wider); clap's `Option` file argument already rejects +//! non-UTF-8 paths at the outer CLI today, so this codec is not the limiting +//! factor. Decoding takes `OsString` because that is what +//! `ApplicationCommandLine::arguments` hands back. + +use std::ffi::OsString; +use std::path::PathBuf; + +use anyhow::{anyhow, bail, Result}; + +use crate::{LaunchOptions, Mode}; + +const MODE_QUICK_PREVIEW: &str = "quick-preview"; +const MODE_FULL_VIEWER: &str = "full-viewer"; + +pub(crate) fn to_argv(opts: &LaunchOptions) -> Vec { + let mode = match opts.mode { + Mode::QuickPreview => MODE_QUICK_PREVIEW, + Mode::FullViewer => MODE_FULL_VIEWER, + }; + vec![ + "quickview".to_owned(), + format!("--mode={mode}"), + format!("--lang={}", opts.ocr_lang), + format!("--file={}", opts.file.to_string_lossy()), + ] +} + +pub(crate) fn from_argv(argv: &[OsString]) -> Result { + let mut mode = None; + let mut lang = None; + let mut file: Option = None; + + for arg in argv.iter().skip(1) { + // skip program name + let arg = arg + .to_str() + .ok_or_else(|| anyhow!("argument {arg:?} is not UTF-8"))?; + if let Some(value) = arg.strip_prefix("--mode=") { + mode = Some(match value { + MODE_QUICK_PREVIEW => Mode::QuickPreview, + MODE_FULL_VIEWER => Mode::FullViewer, + _ => bail!("unknown mode {value:?}"), + }); + } else if let Some(value) = arg.strip_prefix("--lang=") { + lang = Some(value.to_owned()); + } else if let Some(value) = arg.strip_prefix("--file=") { + file = Some(PathBuf::from(value)); + } else { + bail!("unexpected argument {arg:?}"); + } + } + + Ok(LaunchOptions { + mode: mode.ok_or_else(|| anyhow!("missing --mode"))?, + ocr_lang: lang.ok_or_else(|| anyhow!("missing --lang"))?, + file: file.ok_or_else(|| anyhow!("missing --file"))?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn opts(mode: Mode, lang: &str, file: &str) -> LaunchOptions { + LaunchOptions { + mode, + file: PathBuf::from(file), + ocr_lang: lang.to_owned(), + } + } + + fn os_argv(parts: &[&str]) -> Vec { + parts.iter().map(OsString::from).collect() + } + + fn round_trip(original: &LaunchOptions) -> LaunchOptions { + let argv: Vec = to_argv(original).into_iter().map(OsString::from).collect(); + from_argv(&argv).unwrap() + } + + #[test] + fn round_trips_both_modes() { + for mode in [Mode::QuickPreview, Mode::FullViewer] { + let original = opts(mode, "eng", "/tmp/a.png"); + assert_eq!(round_trip(&original), original); + } + } + + #[test] + fn round_trips_lang_and_awkward_paths() { + for file in [ + "/tmp/with spaces/shot 1.png", + "/tmp/-starts-with-dash.png", + "/tmp/has=equals.png", + ] { + let original = opts(Mode::QuickPreview, "deu", file); + assert_eq!(round_trip(&original), original); + } + } + + #[test] + fn rejects_missing_pieces() { + assert!(from_argv(&os_argv(&["quickview"])).is_err()); + assert!(from_argv(&os_argv(&["quickview", "--mode=quick-preview"])).is_err()); + assert!(from_argv(&os_argv(&[ + "quickview", + "--mode=quick-preview", + "--file=/a" + ])) + .is_err()); + assert!(from_argv(&os_argv(&["quickview", "--lang=eng", "--file=/a"])).is_err()); + assert!(from_argv(&os_argv(&[ + "quickview", + "--mode=quick-preview", + "--lang=eng" + ])) + .is_err()); + } + + #[test] + fn rejects_garbage() { + assert!(from_argv(&os_argv(&["quickview", "--bogus=x"])).is_err()); + assert!(from_argv(&os_argv(&[ + "quickview", + "--mode=sideways", + "--lang=eng", + "--file=/a" + ])) + .is_err()); + // A stray positional argument (e.g. a path that lost its --file= + // framing) must not be silently accepted. + assert!(from_argv(&os_argv(&[ + "quickview", + "--mode=full-viewer", + "--lang=eng", + "/a" + ])) + .is_err()); + } +} diff --git a/crates/quickview-ui/src/lib.rs b/crates/quickview-ui/src/lib.rs index 91f052d..ab0d0ef 100644 --- a/crates/quickview-ui/src/lib.rs +++ b/crates/quickview-ui/src/lib.rs @@ -1,48 +1,134 @@ //! GTK4/libadwaita UI for QuickView. -use std::path::PathBuf; +use std::{cell::RefCell, path::PathBuf, rc::Rc}; use anyhow::Result; use adw::prelude::*; +use gtk4 as gtk; + +use gtk::gio; mod decode; +mod ipc; pub mod widgets; pub mod windows; +use windows::shared::ViewerController; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { QuickPreview, FullViewer, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct LaunchOptions { pub mode: Mode, pub file: PathBuf, pub ocr_lang: String, } +/// Windows the primary instance manages across invocations. +/// +/// Only the Quick Preview is tracked: it is single-instance (a repeat +/// invocation toggles it closed, FR-002), while full-viewer invocations +/// always open another independent window. +#[derive(Default)] +struct AppState { + preview: RefCell>, +} + +struct PreviewHandle { + window: glib::WeakRef, + controller: ViewerController, +} + /// Run the GTK application. /// -/// Notes: -/// - We intentionally call `run_with_args(&[])` so GLib/GTK does not reject our custom CLI flags. +/// The application is registered with `HANDLES_COMMAND_LINE`, so a second +/// invocation forwards its (pre-resolved) options to the primary instance +/// over the session bus instead of spawning another window stack. clap +/// parsing, stdin reading, and path canonicalization all happen in the +/// invoking process before this point; only the canonical argv built by +/// [`ipc::to_argv`] ever reaches GLib, so GLib never sees the real CLI flags. pub fn run(opts: LaunchOptions) -> Result { let app = adw::Application::builder() .application_id("com.example.QuickView") + .flags(gio::ApplicationFlags::HANDLES_COMMAND_LINE) .build(); - let opts_clone = opts.clone(); - app.connect_activate(move |app| match opts_clone.mode { - Mode::QuickPreview => { - windows::quick_preview::present(app, &opts_clone); - } - Mode::FullViewer => { - windows::full_viewer::present(app, &opts_clone); + let state = Rc::new(AppState::default()); + app.connect_command_line(move |app, cmdline| { + // Runs in the primary instance for every invocation (including its + // own first one): one uniform dispatch path. + tracing::debug!("command-line argv: {:?}", cmdline.arguments()); + match ipc::from_argv(&cmdline.arguments()) { + Ok(opts) => { + dispatch(app, &state, &opts); + glib::ExitCode::SUCCESS + } + Err(err) => { + // Only reachable through an ipc codec bug or a hand-crafted + // DBus call; the codec builds every real argv itself. + tracing::error!("rejected invocation argv: {err:#}"); + glib::ExitCode::from(2) + } } }); - // Important: don't pass our CLI args to GTK. - let code = app.run_with_args::(&[]); + let code = app.run_with_args(&ipc::to_argv(&opts)); Ok(code.into()) } + +/// Route one invocation's options to the right window action. +fn dispatch(app: &adw::Application, state: &Rc, opts: &LaunchOptions) { + match opts.mode { + Mode::QuickPreview => { + // Clone the live handle out and drop the borrow before closing: + // `close()` re-enters `close_request`, which mutates the state. + let existing = state + .preview + .borrow() + .as_ref() + .and_then(|h| h.window.upgrade().map(|w| (w, h.controller.clone()))); + + if let Some((window, controller)) = existing { + if controller.current_file() == opts.file { + // Same file again: the launch keybind acts as a toggle. + window.close(); + } else { + // Explicit request for a different file: show it. + controller.load_file(&opts.file); + window.present(); + } + return; + } + + let (window, controller) = windows::quick_preview::present(app, opts); + let handle = PreviewHandle { + window: glib::WeakRef::new(), + controller, + }; + handle.window.set(Some(&window)); + *state.preview.borrow_mut() = Some(handle); + + let state = Rc::downgrade(state); + window.connect_close_request(move |_| { + if let Some(state) = state.upgrade() { + *state.preview.borrow_mut() = None; + } + glib::Propagation::Proceed + }); + } + Mode::FullViewer => { + // The anchored, keyboard-exclusive preview overlay would sit on + // top of (and block) the new viewer; close it first. + let preview = state.preview.borrow_mut().take(); + if let Some(window) = preview.and_then(|h| h.window.upgrade()) { + window.close(); + } + windows::full_viewer::present(app, opts); + } + } +} diff --git a/crates/quickview-ui/src/windows/mod.rs b/crates/quickview-ui/src/windows/mod.rs index 9664ee7..dd5634f 100644 --- a/crates/quickview-ui/src/windows/mod.rs +++ b/crates/quickview-ui/src/windows/mod.rs @@ -1,4 +1,4 @@ pub mod full_viewer; pub mod quick_preview; -mod shared; +pub(crate) mod shared; diff --git a/crates/quickview-ui/src/windows/quick_preview.rs b/crates/quickview-ui/src/windows/quick_preview.rs index 7058d22..548da07 100644 --- a/crates/quickview-ui/src/windows/quick_preview.rs +++ b/crates/quickview-ui/src/windows/quick_preview.rs @@ -1,35 +1,97 @@ +use std::{cell::Cell, rc::Rc}; + use gtk4 as gtk; use adw::prelude::*; use gtk::prelude::WidgetExt; -use gtk4_layer_shell::{KeyboardMode, Layer, LayerShell}; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use crate::{windows::shared::ViewerController, LaunchOptions}; -pub fn present(app: &adw::Application, opts: &LaunchOptions) { +/// Size of the centered preview panel on the layer-shell path, and of the +/// whole window on the fallback path. +const PANEL_WIDTH: i32 = 900; +const PANEL_HEIGHT: i32 = 700; + +pub fn present( + app: &adw::Application, + opts: &LaunchOptions, +) -> (gtk::ApplicationWindow, ViewerController) { let window = gtk::ApplicationWindow::builder() .application(app) .title("QuickView") .decorated(false) .resizable(true) - .default_width(900) - .default_height(700) + .default_width(PANEL_WIDTH) + .default_height(PANEL_HEIGHT) .build(); + let viewer = ViewerController::new(opts.file.clone(), opts.ocr_lang.clone()); + // Layer shell if supported. if gtk4_layer_shell::is_supported() { window.init_layer_shell(); window.set_layer(Layer::Overlay); window.set_keyboard_mode(KeyboardMode::Exclusive); window.set_namespace(Some("quickview")); - // Not anchored: centered by compositor. + // Anchored to all edges: the surface covers the output so clicks + // outside the centered panel land on our transparent backdrop and + // dismiss the preview (FR-002). While the preview is open it therefore + // owns all pointer input on this output — intended Quick Look-style + // modality. // Not exclusive: do not reserve screen space. window.set_exclusive_zone(0); - } + for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] { + window.set_anchor(edge, true); + } - let viewer = ViewerController::new(opts.file.clone(), opts.ocr_lang.clone()); - window.set_child(Some(&viewer.widget())); + ensure_css_installed(); + window.add_css_class("qv-preview-surface"); + + // The panel is a *sibling* of the backdrop inside a gtk::Overlay, so + // clicks on the image never reach the backdrop gesture — drag-select + // and middle-drag pan keep working. Do not attach the close gesture + // to the window itself. + let backdrop = gtk::Box::new(gtk::Orientation::Vertical, 0); + backdrop.set_hexpand(true); + backdrop.set_vexpand(true); + backdrop.add_css_class("qv-backdrop"); + { + let window = window.clone(); + let click = gtk::GestureClick::new(); + click.set_button(0); // any button + click.connect_pressed(move |_, _, _, _| window.close()); + backdrop.add_controller(click); + } + + let panel = gtk::Box::new(gtk::Orientation::Vertical, 0); + panel.set_halign(gtk::Align::Center); + panel.set_valign(gtk::Align::Center); + panel.set_size_request(PANEL_WIDTH, PANEL_HEIGHT); + panel.add_css_class("qv-preview-panel"); + panel.append(&viewer.widget()); + + let overlay = gtk::Overlay::new(); + overlay.set_child(Some(&backdrop)); + overlay.add_overlay(&panel); + window.set_child(Some(&overlay)); + } else { + window.set_child(Some(&viewer.widget())); + + // No layer shell: there is nothing "outside" the window for us to + // observe clicks on under Wayland, so dismiss on focus loss instead. + // The latch only closes on a true->false transition, guarding against + // compositors that map the window unfocused. + let was_active = Rc::new(Cell::new(false)); + window.connect_is_active_notify(move |window| { + if window.is_active() { + was_active.set(true); + } else if was_active.get() { + window.close(); + } + }); + } // Key handling: Esc/Space closes, Ctrl+C copies. { @@ -77,4 +139,34 @@ pub fn present(app: &adw::Application, opts: &LaunchOptions) { } window.present(); + (window, viewer) +} + +/// Install the preview surface CSS once per (main-thread) session. +/// +/// The window itself must be transparent so the all-edges layer surface does +/// not paint the theme background across the whole output; the panel then +/// restores an opaque background behind the image. +fn ensure_css_installed() { + thread_local! { + static INSTALLED: Cell = const { Cell::new(false) }; + } + if INSTALLED.get() { + return; + } + let Some(display) = gtk::gdk::Display::default() else { + return; + }; + + let provider = gtk::CssProvider::new(); + provider.load_from_data( + "window.qv-preview-surface { background: transparent; }\n\ + .qv-preview-panel { background-color: @window_bg_color; border-radius: 12px; }", + ); + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + INSTALLED.set(true); } diff --git a/crates/quickview-ui/src/windows/shared.rs b/crates/quickview-ui/src/windows/shared.rs index ce62ab5..cdcff80 100644 --- a/crates/quickview-ui/src/windows/shared.rs +++ b/crates/quickview-ui/src/windows/shared.rs @@ -80,7 +80,6 @@ impl ViewerController { self.overlay.clone() } - #[allow(dead_code)] pub fn current_file(&self) -> PathBuf { self.current_file.borrow().clone() } diff --git a/docs/PHASED_PLAN.md b/docs/PHASED_PLAN.md index 1129a67..54309b4 100644 --- a/docs/PHASED_PLAN.md +++ b/docs/PHASED_PLAN.md @@ -128,13 +128,18 @@ If priorities change, you can reshuffle phases, but try to keep the “render fi - Rename placeholder app ID `com.example.QuickView` before wider distribution (touches app ID in `quickview-ui`, `.desktop`, metainfo, icon filename, Flatpak manifest, PKGBUILD) - Quick Preview dismissal completeness (FR-002): - - click outside closes — layer-shell path: anchor surface to all edges with a - transparent backdrop around the image; fallback path: `GestureClick` on the - window plus focus-loss handling - - single-instance toggle — `GApplication` uniqueness with `HANDLES_COMMAND_LINE` - (or `open`) so a second `--quick-preview` invocation reaches the running - instance and toggles/replaces the window instead of spawning a new process; - requires revisiting the current `run_with_args(&[])` workaround + - click outside closes ✅ — layer-shell path: surface anchored to all edges, + transparent backdrop (`gtk::Overlay` sibling of the centered panel) closes + on click; fallback path closes on focus loss only (a `GestureClick` on the + window, as originally sketched, would also fire for clicks *inside* the + preview and break drag-select, FR-005) + - single-instance toggle ✅ — `GApplication` uniqueness with + `HANDLES_COMMAND_LINE`; the invoking process resolves everything (clap, + stdin, canonicalization) and forwards a canonical synthetic argv, so a + second `--quick-preview` invocation toggles the open preview (same file) + or replaces its content (different file). This retired the + `run_with_args(&[])` workaround: GLib now receives a sanitized argv we + build ourselves. - Optional GNOME Sushi-compatible DBus interface (advanced / optional) **Definition of done** From f4716da9bcd47703b02db4f5a1f9b5cc102b7a8d Mon Sep 17 00:00:00 2001 From: Babken Egoian <101829110+green2grey@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:12:40 -0700 Subject: [PATCH 2/2] fix: apply forwarded OCR language when reusing the preview window A different-file invocation reused the ViewerController's original ocr_lang, silently ignoring the new invocation's --lang. Found by Codex review on PR #5. --- crates/quickview-ui/src/lib.rs | 4 +++- crates/quickview-ui/src/windows/shared.rs | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/quickview-ui/src/lib.rs b/crates/quickview-ui/src/lib.rs index ab0d0ef..00cd61b 100644 --- a/crates/quickview-ui/src/lib.rs +++ b/crates/quickview-ui/src/lib.rs @@ -98,7 +98,9 @@ fn dispatch(app: &adw::Application, state: &Rc, opts: &LaunchOptions) // Same file again: the launch keybind acts as a toggle. window.close(); } else { - // Explicit request for a different file: show it. + // Explicit request for a different file: show it, with + // the language this invocation asked for. + controller.set_ocr_lang(opts.ocr_lang.clone()); controller.load_file(&opts.file); window.present(); } diff --git a/crates/quickview-ui/src/windows/shared.rs b/crates/quickview-ui/src/windows/shared.rs index cdcff80..88c3b83 100644 --- a/crates/quickview-ui/src/windows/shared.rs +++ b/crates/quickview-ui/src/windows/shared.rs @@ -84,6 +84,14 @@ impl ViewerController { self.current_file.borrow().clone() } + /// Change the OCR language for subsequent loads. + /// + /// Takes effect on the next `load_file` (jobs already in flight keep the + /// language they started with). + pub fn set_ocr_lang(&self, lang: String) { + *self.ocr_lang.borrow_mut() = lang; + } + /// Register a callback fired whenever a file finishes loading (or fails). /// /// If a file has already been loaded, the callback is invoked immediately