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..00cd61b 100644 --- a/crates/quickview-ui/src/lib.rs +++ b/crates/quickview-ui/src/lib.rs @@ -1,48 +1,136 @@ //! 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, with + // the language this invocation asked for. + controller.set_ocr_lang(opts.ocr_lang.clone()); + 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..88c3b83 100644 --- a/crates/quickview-ui/src/windows/shared.rs +++ b/crates/quickview-ui/src/windows/shared.rs @@ -80,11 +80,18 @@ impl ViewerController { self.overlay.clone() } - #[allow(dead_code)] pub fn current_file(&self) -> PathBuf { 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 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**