diff --git a/.gitignore b/.gitignore index b537580..77cdf31 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ repo/ .claude/* !.claude/CLAUDE.md +# Local working notes (not for the repo) +/TODO.local.md + diff --git a/Cargo.lock b/Cargo.lock index 2641aa2..a1327a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arrayref" diff --git a/crates/quickview-ui/src/widgets/image_overlay.rs b/crates/quickview-ui/src/widgets/image_overlay.rs index 068c397..8334a53 100644 --- a/crates/quickview-ui/src/widgets/image_overlay.rs +++ b/crates/quickview-ui/src/widgets/image_overlay.rs @@ -60,6 +60,10 @@ impl ImageOverlayWidget { self.canvas.set_texture(texture); } + pub fn clear_texture(&self) { + self.canvas.clear_texture(); + } + pub fn set_ocr_result(&self, result: Option) { self.canvas.set_ocr_result(result); } @@ -77,6 +81,14 @@ impl ImageOverlayWidget { self.canvas.selected_text() } + pub fn copy_selection_to_clipboard(&self) { + self.canvas.copy_selection_to_clipboard(); + } + + pub fn open_context_menu(&self) { + self.canvas.open_context_menu(); + } + pub fn zoom_by(&self, factor: f64) { self.canvas.zoom_by(factor); } @@ -100,6 +112,7 @@ mod imp { #[derive(Default)] pub struct ZoomableCanvas { pub(super) state: RefCell, + pub(super) context_menu: RefCell>, } #[glib::object_subclass] @@ -107,6 +120,15 @@ mod imp { const NAME: &'static str = "QuickViewZoomableCanvas"; type Type = super::ZoomableCanvas; type ParentType = gtk::Widget; + + fn class_init(klass: &mut Self::Class) { + klass.install_action("canvas.copy", None, |canvas, _, _| { + canvas.copy_selection_to_clipboard(); + }); + klass.install_action("canvas.copy-all", None, |canvas, _, _| { + canvas.copy_all_text_to_clipboard(); + }); + } } impl ObjectImpl for ZoomableCanvas { @@ -116,10 +138,28 @@ mod imp { let obj = self.obj(); obj.set_focusable(true); obj.setup_controllers(); + obj.update_copy_actions(); + } + + fn dispose(&self) { + if let Some(menu) = self.context_menu.take() { + menu.unparent(); + } } } impl WidgetImpl for ZoomableCanvas { + fn size_allocate(&self, width: i32, height: i32, baseline: i32) { + self.parent_size_allocate(width, height, baseline); + + // Custom widgets must present popover children during allocation; + // without this the context menu can end up unallocated or stuck at + // a stale position after resizes. + if let Some(popover) = self.context_menu.borrow().as_ref() { + popover.present(); + } + } + fn measure(&self, orientation: gtk::Orientation, for_size: i32) -> (i32, i32, i32, i32) { let state = self.state.borrow(); let natural = natural_size_for_measure( @@ -216,6 +256,23 @@ mod imp { pub(super) pinch_anchor_widget: Point, } + impl CanvasState { + /// Reset OCR, selection, zoom/center, and gesture state. + /// + /// Shared by `set_texture` / `clear_texture`; callers set the + /// texture, dimensions, and center themselves. + pub(super) fn reset_view_state(&mut self) { + self.ocr = None; + self.ocr_index = None; + self.selected_indices.clear(); + self.zoom_factor = MIN_ZOOM_FACTOR; + self.center_img = Point::default(); + self.selecting = false; + self.panning = false; + self.pinch_active = false; + } + } + impl Default for CanvasState { fn default() -> Self { Self { @@ -262,23 +319,30 @@ impl ZoomableCanvas { pub fn set_texture(&self, texture: gtk::gdk::Texture) { let mut state = self.imp().state.borrow_mut(); + state.reset_view_state(); state.texture = Some(texture.clone()); state.image_width = texture.width() as f64; state.image_height = texture.height() as f64; - state.ocr = None; - state.ocr_index = None; - state.zoom_factor = MIN_ZOOM_FACTOR; state.center_img = Point { x: state.image_width * 0.5, y: state.image_height * 0.5, }; - state.selecting = false; - state.panning = false; - state.pinch_active = false; - state.selected_indices.clear(); drop(state); self.queue_draw(); self.update_cursor(); + self.update_copy_actions(); + } + + pub fn clear_texture(&self) { + let mut state = self.imp().state.borrow_mut(); + state.reset_view_state(); + state.texture = None; + state.image_width = 0.0; + state.image_height = 0.0; + drop(state); + self.queue_draw(); + self.update_cursor(); + self.update_copy_actions(); } pub fn set_ocr_result(&self, result: Option) { @@ -292,6 +356,7 @@ impl ZoomableCanvas { state.selecting = false; drop(state); self.queue_draw(); + self.update_copy_actions(); } pub fn clear_selection(&self) { @@ -300,6 +365,7 @@ impl ZoomableCanvas { state.selected_indices.clear(); drop(state); self.queue_draw(); + self.update_copy_actions(); } pub fn selected_text(&self) -> String { @@ -316,6 +382,28 @@ impl ZoomableCanvas { select::selected_text(words) } + pub fn copy_selection_to_clipboard(&self) { + let text = self.selected_text(); + if text.trim().is_empty() { + return; + } + self.clipboard().set_text(&text); + } + + pub fn copy_all_text_to_clipboard(&self) { + let text = { + let state = self.imp().state.borrow(); + match &state.ocr { + Some(ocr) => select::selected_text(ocr.words.iter().collect()), + None => String::new(), + } + }; + if text.trim().is_empty() { + return; + } + self.clipboard().set_text(&text); + } + pub fn zoom_by(&self, factor: f64) { if factor <= 0.0 { return; @@ -414,6 +502,17 @@ impl ZoomableCanvas { } self.add_controller(pinch); + let right_click = gtk::GestureClick::new(); + right_click.set_button(gtk::gdk::BUTTON_SECONDARY); + { + let canvas = self.clone(); + right_click.connect_pressed(move |gesture, _n_press, x, y| { + gesture.set_state(gtk::EventSequenceState::Claimed); + canvas.show_context_menu(x, y); + }); + } + self.add_controller(right_click); + let drag = gtk::GestureDrag::new(); drag.set_button(0); { @@ -477,6 +576,7 @@ impl ZoomableCanvas { drop(state); self.update_cursor(); self.queue_draw(); + self.update_copy_actions(); } fn on_drag_update(&self, dx: f64, dy: f64) { @@ -547,6 +647,7 @@ impl ZoomableCanvas { drop(state); if needs_redraw { self.queue_draw(); + self.update_copy_actions(); } } @@ -557,6 +658,7 @@ impl ZoomableCanvas { drop(state); self.update_cursor(); self.queue_draw(); + self.update_copy_actions(); } fn on_pinch_begin(&self, gesture: >k::GestureZoom) { @@ -732,6 +834,51 @@ impl ZoomableCanvas { self.update_cursor(); } + /// Open the context menu from the keyboard (Menu key / Shift+F10), + /// anchored at the pointer if known, otherwise the widget center. + pub fn open_context_menu(&self) { + let anchor = self.last_cursor_widget(); + self.show_context_menu(anchor.x, anchor.y); + } + + fn show_context_menu(&self, x: f64, y: f64) { + let imp = self.imp(); + if imp.context_menu.borrow().is_none() { + let model = gtk::gio::Menu::new(); + model.append(Some("Copy"), Some("canvas.copy")); + model.append(Some("Copy All Text"), Some("canvas.copy-all")); + + let popover = gtk::PopoverMenu::from_model(Some(&model)); + popover.set_parent(self); + popover.set_has_arrow(false); + popover.set_halign(gtk::Align::Start); + *imp.context_menu.borrow_mut() = Some(popover); + } + + let popover = imp + .context_menu + .borrow() + .as_ref() + .expect("context menu just created") + .clone(); + popover.set_pointing_to(Some(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); + popover.popup(); + } + + fn update_copy_actions(&self) { + let state = self.imp().state.borrow(); + let has_selection = !state.selected_indices.is_empty(); + let has_words = state + .ocr + .as_ref() + .map(|ocr| !ocr.words.is_empty()) + .unwrap_or(false); + drop(state); + + self.action_set_enabled("canvas.copy", has_selection); + self.action_set_enabled("canvas.copy-all", has_words); + } + fn widget_center(&self) -> Point { Point { x: (self.width() as f64) * 0.5, diff --git a/crates/quickview-ui/src/windows/full_viewer.rs b/crates/quickview-ui/src/windows/full_viewer.rs index 7e8fece..54dc136 100644 --- a/crates/quickview-ui/src/windows/full_viewer.rs +++ b/crates/quickview-ui/src/windows/full_viewer.rs @@ -3,7 +3,10 @@ use gtk4 as gtk; use adw::prelude::*; use gtk::prelude::WidgetExt; -use crate::{windows::shared::ViewerController, LaunchOptions}; +use crate::{ + windows::shared::{FileInfo, ViewerController}, + LaunchOptions, +}; pub fn present(app: &adw::Application, opts: &LaunchOptions) { let window = adw::ApplicationWindow::builder() @@ -13,11 +16,20 @@ pub fn present(app: &adw::Application, opts: &LaunchOptions) { .default_height(800) .build(); + let title = adw::WindowTitle::new("QuickView", ""); let header = adw::HeaderBar::new(); - header.set_title_widget(Some(>k::Label::new(Some("QuickView")))); + header.set_title_widget(Some(&title)); let viewer = ViewerController::new(opts.file.clone(), opts.ocr_lang.clone()); + { + let title = title.clone(); + viewer.connect_file_loaded(move |info| { + title.set_title(&info.name); + title.set_subtitle(&format_subtitle(info)); + }); + } + let toolbar_view = adw::ToolbarView::new(); toolbar_view.add_top_bar(&header); toolbar_view.set_content(Some(&viewer.widget())); @@ -28,14 +40,18 @@ pub fn present(app: &adw::Application, opts: &LaunchOptions) { { let viewer = viewer.clone(); let overlay = viewer.overlay(); - let window_clone = window.clone(); let controller = gtk::EventControllerKey::new(); controller.connect_key_pressed(move |_, key, _, state| { let is_ctrl = state.contains(gtk::gdk::ModifierType::CONTROL_MASK); if is_ctrl && key == gtk::gdk::Key::c { - let display = WidgetExt::display(&window_clone); - viewer.copy_selection_to_clipboard(&display); + viewer.copy_selection_to_clipboard(); + return glib::Propagation::Stop; + } + + let is_shift = state.contains(gtk::gdk::ModifierType::SHIFT_MASK); + if key == gtk::gdk::Key::Menu || (is_shift && key == gtk::gdk::Key::F10) { + overlay.open_context_menu(); return glib::Propagation::Stop; } @@ -71,3 +87,65 @@ pub fn present(app: &adw::Application, opts: &LaunchOptions) { window.present(); } + +/// Subtitle string for the headerbar: `1920×1080 · 2.4 MB`. +fn format_subtitle(info: &FileInfo) -> String { + if info.load_failed { + return "Could not load image".to_string(); + } + + let dims = format!("{}×{}", info.width, info.height); + match info.size_bytes { + Some(bytes) => format!("{dims} · {}", glib::format_size(bytes)), + None => dims, + } +} + +#[cfg(test)] +mod tests { + use super::{format_subtitle, FileInfo}; + + fn info(width: i32, height: i32, size_bytes: Option, load_failed: bool) -> FileInfo { + FileInfo { + name: "test.png".to_string(), + width, + height, + size_bytes, + load_failed, + } + } + + #[test] + fn subtitle_shows_dimensions_and_size() { + let subtitle = format_subtitle(&info(1920, 1080, Some(2_400_000), false)); + let expected_size = glib::format_size(2_400_000); + assert_eq!(subtitle, format!("1920×1080 · {expected_size}")); + } + + #[test] + fn subtitle_omits_size_when_metadata_unavailable() { + assert_eq!(format_subtitle(&info(800, 600, None, false)), "800×600"); + } + + #[test] + fn subtitle_handles_zero_bytes() { + let subtitle = format_subtitle(&info(1, 1, Some(0), false)); + let expected_size = glib::format_size(0); + assert_eq!(subtitle, format!("1×1 · {expected_size}")); + } + + #[test] + fn subtitle_handles_large_files() { + let subtitle = format_subtitle(&info(12000, 8000, Some(3_500_000_000), false)); + let expected_size = glib::format_size(3_500_000_000); + assert_eq!(subtitle, format!("12000×8000 · {expected_size}")); + } + + #[test] + fn subtitle_reports_load_failure() { + assert_eq!( + format_subtitle(&info(0, 0, Some(123), true)), + "Could not load image" + ); + } +} diff --git a/crates/quickview-ui/src/windows/quick_preview.rs b/crates/quickview-ui/src/windows/quick_preview.rs index 31723ba..7058d22 100644 --- a/crates/quickview-ui/src/windows/quick_preview.rs +++ b/crates/quickview-ui/src/windows/quick_preview.rs @@ -40,8 +40,13 @@ pub fn present(app: &adw::Application, opts: &LaunchOptions) { controller.connect_key_pressed(move |_, key, _, state| { let is_ctrl = state.contains(gtk::gdk::ModifierType::CONTROL_MASK); if is_ctrl && key == gtk::gdk::Key::c { - let display = WidgetExt::display(&window_clone); - viewer.copy_selection_to_clipboard(&display); + viewer.copy_selection_to_clipboard(); + return glib::Propagation::Stop; + } + + let is_shift = state.contains(gtk::gdk::ModifierType::SHIFT_MASK); + if key == gtk::gdk::Key::Menu || (is_shift && key == gtk::gdk::Key::F10) { + overlay.open_context_menu(); return glib::Propagation::Stop; } diff --git a/crates/quickview-ui/src/windows/shared.rs b/crates/quickview-ui/src/windows/shared.rs index ca014e9..c9f1a2b 100644 --- a/crates/quickview-ui/src/windows/shared.rs +++ b/crates/quickview-ui/src/windows/shared.rs @@ -14,6 +14,23 @@ use quickview_core::{ use crate::widgets::image_overlay::ImageOverlayWidget; +/// Basic metadata about the currently displayed file, for the info display. +#[derive(Debug, Clone)] +pub struct FileInfo { + /// File name without the directory. + pub name: String, + /// Texture width in pixels (0 when `load_failed`). + pub width: i32, + /// Texture height in pixels (0 when `load_failed`). + pub height: i32, + /// File size in bytes; `None` if metadata could not be read. + pub size_bytes: Option, + /// True when the image could not be decoded. + pub load_failed: bool, +} + +type FileLoadedCallback = Box; + #[derive(Clone)] pub struct ViewerController { overlay: ImageOverlayWidget, @@ -26,6 +43,9 @@ pub struct ViewerController { // Monotonic id to ignore late OCR results. ocr_job_id: Rc>, + + on_file_loaded: Rc>>, + last_file_info: Rc>>, } impl ViewerController { @@ -42,6 +62,8 @@ impl ViewerController { dir_index: Rc::new(Cell::new(dir_index)), ocr_lang: Rc::new(RefCell::new(ocr_lang)), ocr_job_id: Rc::new(Cell::new(0)), + on_file_loaded: Rc::new(RefCell::new(None)), + last_file_info: Rc::new(RefCell::new(None)), }; this.load_file(&initial_file); @@ -61,20 +83,63 @@ impl ViewerController { self.current_file.borrow().clone() } + /// Register a callback fired whenever a file finishes loading (or fails). + /// + /// If a file has already been loaded, the callback is invoked immediately + /// with its info so late registration (e.g. after `new()`) misses nothing. + pub fn connect_file_loaded(&self, f: impl Fn(&FileInfo) + 'static) { + if let Some(info) = self.last_file_info.borrow().as_ref() { + f(info); + } + *self.on_file_loaded.borrow_mut() = Some(Box::new(f)); + } + + fn emit_file_loaded(&self, info: FileInfo) { + if let Some(cb) = self.on_file_loaded.borrow().as_ref() { + cb(&info); + } + *self.last_file_info.borrow_mut() = Some(info); + } + pub fn load_file(&self, path: &Path) { *self.current_file.borrow_mut() = path.to_path_buf(); + let name = path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + let size_bytes = std::fs::metadata(path).ok().map(|m| m.len()); + // Load image synchronously for now. let file = gtk::gio::File::for_path(path); match gtk::gdk::Texture::from_file(&file) { Ok(texture) => { + let info = FileInfo { + name, + width: texture.width(), + height: texture.height(), + size_bytes, + load_failed: false, + }; self.overlay.set_texture(texture); + self.emit_file_loaded(info); } Err(err) => { tracing::error!("Failed to load image: {err}"); - // Clear OCR state. - self.overlay.set_ocr_result(None); + // Invalidate any in-flight OCR job from the previous image so + // a late result can't repopulate the cleared canvas. + self.ocr_job_id.set(self.ocr_job_id.get().wrapping_add(1)); + // Clear the stale image (and its OCR state) so the canvas + // matches the load_failed info shown in the headerbar. + self.overlay.clear_texture(); self.overlay.set_ocr_busy(false); + self.emit_file_loaded(FileInfo { + name, + width: 0, + height: 0, + size_bytes, + load_failed: true, + }); return; } } @@ -111,12 +176,8 @@ impl ViewerController { self.load_file(&p); } - pub fn copy_selection_to_clipboard(&self, display: >k::gdk::Display) { - let text = self.overlay.selected_text(); - if text.trim().is_empty() { - return; - } - display.clipboard().set_text(&text); + pub fn copy_selection_to_clipboard(&self) { + self.overlay.copy_selection_to_clipboard(); } fn start_ocr(&self, path: PathBuf) { diff --git a/docs/PHASED_PLAN.md b/docs/PHASED_PLAN.md index e8f2d7a..de8cd0c 100644 --- a/docs/PHASED_PLAN.md +++ b/docs/PHASED_PLAN.md @@ -1,7 +1,7 @@ # QuickView Phased Implementation Plan -**Document version:** 0.1 (planning) -**Last updated:** 2026-03-02 +**Document version:** 0.2 (planning) +**Last updated:** 2026-07-05 This plan is structured as incremental phases that each deliver a usable artifact. If priorities change, you can reshuffle phases, but try to keep the “render first, OCR later” principle intact. @@ -45,7 +45,7 @@ If priorities change, you can reshuffle phases, but try to keep the “render fi --- -## Phase 2 — Directory navigation + info panel (partially done) +## Phase 2 — Directory navigation + info panel ✅ **Core tasks** - Identify “image set” as all supported images in the same directory ✅ @@ -53,13 +53,15 @@ If priorities change, you can reshuffle phases, but try to keep the “render fi - Add navigation: - Left/Right arrows to prev/next ✅ - Add info panel: - - filename - - dimensions - - file size + - filename ✅ + - dimensions ✅ + - file size ✅ + - (implemented as headerbar title/subtitle via `adw::WindowTitle` rather than + a separate panel — most idiomatic libadwaita for three fields) **Definition of done** - Prev/next navigation is correct and stable ✅ -- Info updates immediately when switching images +- Info updates immediately when switching images ✅ --- @@ -98,7 +100,7 @@ If priorities change, you can reshuffle phases, but try to keep the “render fi --- -## Phase 5 — OCR overlay + text selection UX (partially done) +## Phase 5 — OCR overlay + text selection UX ✅ **Core tasks** - Render OCR overlay (invisible by default or lightly highlighted on hover) ✅ @@ -107,7 +109,7 @@ If priorities change, you can reshuffle phases, but try to keep the “render fi - highlight matched words ✅ - Implement copy: - Ctrl+C copies selected text ✅ - - context menu action “Copy” + - context menu action “Copy” ✅ (plus “Copy All Text” for the whole OCR result) **Definition of done** - User can reliably select and copy text from an image ✅ @@ -115,34 +117,56 @@ If priorities change, you can reshuffle phases, but try to keep the “render fi --- -## Phase 6 — Integration polish (not started) +## Phase 6 — Integration polish (partially done) **Core tasks** - `.desktop` integration: - - default open handler for images - - optional action for quick preview mode + - default open handler for images ✅ (`assets/desktop/`, installed by PKGBUILD/Flatpak) + - optional action for quick preview mode ✅ (`QuickPreview` desktop action) - Compositor keybind recipes (niri, Hyprland, Sway) documented + (`templates/keybind-examples.md` exists but is generic — add per-compositor snippets) +- 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 - Optional GNOME Sushi-compatible DBus interface (advanced / optional) **Definition of done** - App can be set as default image viewer in common file managers - Quick Preview can be triggered by a keybind in at least one compositor +- Pressing the keybind while a preview is already open closes it (toggle) --- ## Phase 7 — Hardening + performance (not started) **Core tasks** -- Add cache (in-memory first) +- Async + sandboxed image loading — implements ADR-0004, fixes NFR-001/NFR-002. + Do this first: the cache DoD below is unverifiable while decode still blocks + the main thread. + - replace the synchronous `Texture::from_file` in `load_file` with glycin's + async loader where available (behind a cargo feature), falling back to GDK + decoding on a background thread via the existing async-channel pattern + - show the previous image or a placeholder until decode completes + - stale-result guard (monotonic job ID, same pattern as OCR) so fast + arrow-key navigation cannot race decodes +- Add cache (in-memory first; `cache.rs` on-disk key derivation exists but is unwired) - Add basic benchmarking hooks (decode + OCR timing) - Improve OCR accuracy options: - - language selection + - language selection via config file / env var (CLI `--lang` already exists) - selectable `tessdata_fast` vs `tessdata_best` - Add guardrails: - maximum image dimensions for OCR (downscale) - memory usage limits (where feasible) **Definition of done** +- Opening a large image does not freeze or hitch the UI - Re-opening the same image is faster due to caching - OCR is stable across a variety of real-world screenshots