From b25f0ceb5c1274c16fbbc65e119e15dfcece1649 Mon Sep 17 00:00:00 2001 From: Babken Egoian <101829110+green2grey@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:28:09 -0700 Subject: [PATCH 1/4] feat: show file info in headerbar and add right-click copy menu Closes Phase 2 (info) and Phase 5 (context menu): - ViewerController exposes connect_file_loaded; load_file emits FileInfo (name, dimensions, size, load_failed) on success and decode failure, replaying the last info on late registration so the initial file shows. - Full Viewer headerbar uses adw::WindowTitle: filename as title, "1920x1080 - 2.4 MB" subtitle via glib::format_size, or "Could not load image" on failure. format_subtitle unit-tested. - ZoomableCanvas installs canvas.copy / canvas.copy-all widget actions; right-click opens a PopoverMenu at the cursor. Copy All Text joins all OCR words in reading order. Enablement tracks selection, OCR arrival, and image switches; popover unparented in dispose. - Clipboard access moved into the widget so Ctrl+C and the menu share one code path; both windows get the menu via the shared canvas. Also: PHASED_PLAN v0.2 (Phase 2/5 done, Phase 6 status corrected, FR-002 and ADR-0004 async/sandboxed-loading tasks tracked) and .gitignore rule for local working notes. --- .gitignore | 3 + .../quickview-ui/src/widgets/image_overlay.rs | 97 +++++++++++++++++++ .../quickview-ui/src/windows/full_viewer.rs | 82 +++++++++++++++- .../quickview-ui/src/windows/quick_preview.rs | 3 +- crates/quickview-ui/src/windows/shared.rs | 69 +++++++++++-- docs/PHASED_PLAN.md | 52 +++++++--- 6 files changed, 279 insertions(+), 27 deletions(-) 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/crates/quickview-ui/src/widgets/image_overlay.rs b/crates/quickview-ui/src/widgets/image_overlay.rs index 068c397..9db4a40 100644 --- a/crates/quickview-ui/src/widgets/image_overlay.rs +++ b/crates/quickview-ui/src/widgets/image_overlay.rs @@ -77,6 +77,10 @@ impl ImageOverlayWidget { self.canvas.selected_text() } + pub fn copy_selection_to_clipboard(&self) { + self.canvas.copy_selection_to_clipboard(); + } + pub fn zoom_by(&self, factor: f64) { self.canvas.zoom_by(factor); } @@ -100,6 +104,7 @@ mod imp { #[derive(Default)] pub struct ZoomableCanvas { pub(super) state: RefCell, + pub(super) context_menu: RefCell>, } #[glib::object_subclass] @@ -107,6 +112,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,6 +130,13 @@ 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(); + } } } @@ -279,6 +300,7 @@ impl ZoomableCanvas { drop(state); self.queue_draw(); self.update_cursor(); + self.update_copy_actions(); } pub fn set_ocr_result(&self, result: Option) { @@ -292,6 +314,7 @@ impl ZoomableCanvas { state.selecting = false; drop(state); self.queue_draw(); + self.update_copy_actions(); } pub fn clear_selection(&self) { @@ -300,6 +323,7 @@ impl ZoomableCanvas { state.selected_indices.clear(); drop(state); self.queue_draw(); + self.update_copy_actions(); } pub fn selected_text(&self) -> String { @@ -316,6 +340,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 +460,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 +534,7 @@ impl ZoomableCanvas { drop(state); self.update_cursor(); self.queue_draw(); + self.update_copy_actions(); } fn on_drag_update(&self, dx: f64, dy: f64) { @@ -557,6 +615,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 +791,44 @@ impl ZoomableCanvas { self.update_cursor(); } + 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..6150b7e 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,12 @@ 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; } @@ -71,3 +81,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..6b7026b 100644 --- a/crates/quickview-ui/src/windows/quick_preview.rs +++ b/crates/quickview-ui/src/windows/quick_preview.rs @@ -40,8 +40,7 @@ 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; } diff --git a/crates/quickview-ui/src/windows/shared.rs b/crates/quickview-ui/src/windows/shared.rs index ca014e9..739bf44 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,59 @@ 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); self.overlay.set_ocr_busy(false); + self.emit_file_loaded(FileInfo { + name, + width: 0, + height: 0, + size_bytes, + load_failed: true, + }); return; } } @@ -111,12 +172,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 From 32c0deae0a6cdc6fe02930e1ca3fefca0ccd1c0c Mon Sep 17 00:00:00 2001 From: Babken Egoian <101829110+green2grey@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:40:16 -0700 Subject: [PATCH 2/4] chore: bump anyhow to 1.0.103 for RUSTSEC advisory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo deny's advisories check fails on anyhow 1.0.102 (unsound downcast_mut, dtolnay/anyhow#451; fixed in 1.0.103). Unrelated to this branch's changes — any branch would fail CI until the lockfile moves. --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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" From d46b4b570a57cd1621adbfc072445465fdd73b61 Mon Sep 17 00:00:00 2001 From: Babken Egoian <101829110+green2grey@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:50:07 -0700 Subject: [PATCH 3/4] fix: clear canvas on decode failure and present popover on allocate Addresses PR #2 review findings: - clear_texture() resets the canvas (texture, dims, OCR, selection, view) when a decode fails, so the display matches the load_failed headerbar info instead of showing the previous image (CodeRabbit). - size_allocate now presents the context-menu popover: custom widgets without a layout manager must call gtk_popover_present() during allocation or the popover can be unallocated/mispositioned after window resizes (Codex). --- .../quickview-ui/src/widgets/image_overlay.rs | 34 +++++++++++++++++++ crates/quickview-ui/src/windows/shared.rs | 5 +-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/quickview-ui/src/widgets/image_overlay.rs b/crates/quickview-ui/src/widgets/image_overlay.rs index 9db4a40..10dd15d 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); } @@ -141,6 +145,17 @@ mod imp { } 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( @@ -303,6 +318,25 @@ impl ZoomableCanvas { self.update_copy_actions(); } + pub fn clear_texture(&self) { + let mut state = self.imp().state.borrow_mut(); + state.texture = None; + state.image_width = 0.0; + state.image_height = 0.0; + state.ocr = None; + state.ocr_index = None; + state.zoom_factor = MIN_ZOOM_FACTOR; + state.center_img = Point::default(); + 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 set_ocr_result(&self, result: Option) { let mut state = self.imp().state.borrow_mut(); state.ocr = result; diff --git a/crates/quickview-ui/src/windows/shared.rs b/crates/quickview-ui/src/windows/shared.rs index 739bf44..6e55773 100644 --- a/crates/quickview-ui/src/windows/shared.rs +++ b/crates/quickview-ui/src/windows/shared.rs @@ -126,8 +126,9 @@ impl ViewerController { } Err(err) => { tracing::error!("Failed to load image: {err}"); - // Clear OCR state. - self.overlay.set_ocr_result(None); + // 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, From 544231fa713668da0edbf967fccd736bb3a0b398 Mon Sep 17 00:00:00 2001 From: Babken Egoian <101829110+green2grey@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:04:19 -0700 Subject: [PATCH 4/4] fix: address second-round review findings - Bump ocr_job_id in the decode-failure path so an in-flight OCR job from the previous image can't repopulate the cleared canvas (Codex). - Refresh copy-action enablement during drag updates, not just at drag boundaries (CodeRabbit). - Add keyboard access to the context menu: Menu key and Shift+F10 open it at the pointer (or widget center), per NFR-005 (CodeRabbit). - Extract CanvasState::reset_view_state so set_texture/clear_texture share one reset path instead of duplicating it (CodeRabbit). --- .../quickview-ui/src/widgets/image_overlay.rs | 46 +++++++++++++------ .../quickview-ui/src/windows/full_viewer.rs | 6 +++ .../quickview-ui/src/windows/quick_preview.rs | 6 +++ crates/quickview-ui/src/windows/shared.rs | 3 ++ 4 files changed, 46 insertions(+), 15 deletions(-) diff --git a/crates/quickview-ui/src/widgets/image_overlay.rs b/crates/quickview-ui/src/widgets/image_overlay.rs index 10dd15d..8334a53 100644 --- a/crates/quickview-ui/src/widgets/image_overlay.rs +++ b/crates/quickview-ui/src/widgets/image_overlay.rs @@ -85,6 +85,10 @@ impl ImageOverlayWidget { 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); } @@ -252,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 { @@ -298,20 +319,14 @@ 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(); @@ -320,17 +335,10 @@ impl ZoomableCanvas { 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; - state.ocr = None; - state.ocr_index = None; - state.zoom_factor = MIN_ZOOM_FACTOR; - state.center_img = Point::default(); - state.selecting = false; - state.panning = false; - state.pinch_active = false; - state.selected_indices.clear(); drop(state); self.queue_draw(); self.update_cursor(); @@ -639,6 +647,7 @@ impl ZoomableCanvas { drop(state); if needs_redraw { self.queue_draw(); + self.update_copy_actions(); } } @@ -825,6 +834,13 @@ 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() { diff --git a/crates/quickview-ui/src/windows/full_viewer.rs b/crates/quickview-ui/src/windows/full_viewer.rs index 6150b7e..54dc136 100644 --- a/crates/quickview-ui/src/windows/full_viewer.rs +++ b/crates/quickview-ui/src/windows/full_viewer.rs @@ -49,6 +49,12 @@ pub fn present(app: &adw::Application, opts: &LaunchOptions) { 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; + } + if key == gtk::gdk::Key::plus || key == gtk::gdk::Key::equal || key == gtk::gdk::Key::KP_Add diff --git a/crates/quickview-ui/src/windows/quick_preview.rs b/crates/quickview-ui/src/windows/quick_preview.rs index 6b7026b..7058d22 100644 --- a/crates/quickview-ui/src/windows/quick_preview.rs +++ b/crates/quickview-ui/src/windows/quick_preview.rs @@ -44,6 +44,12 @@ pub fn present(app: &adw::Application, opts: &LaunchOptions) { 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; + } + if key == gtk::gdk::Key::plus || key == gtk::gdk::Key::equal || key == gtk::gdk::Key::KP_Add diff --git a/crates/quickview-ui/src/windows/shared.rs b/crates/quickview-ui/src/windows/shared.rs index 6e55773..c9f1a2b 100644 --- a/crates/quickview-ui/src/windows/shared.rs +++ b/crates/quickview-ui/src/windows/shared.rs @@ -126,6 +126,9 @@ impl ViewerController { } Err(err) => { tracing::error!("Failed to load image: {err}"); + // 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();