Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
158 changes: 158 additions & 0 deletions crates/quickview-ui/src/ipc.rs
Original file line number Diff line number Diff line change
@@ -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=<quick-preview|full-viewer> --lang=<lang> --file=<abs path>
//! ```
//!
//! 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<String>` 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<String> {
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<LaunchOptions> {
let mut mode = None;
let mut lang = None;
let mut file: Option<PathBuf> = 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<OsString> {
parts.iter().map(OsString::from).collect()
}

fn round_trip(original: &LaunchOptions) -> LaunchOptions {
let argv: Vec<OsString> = 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());
}
}
114 changes: 101 additions & 13 deletions crates/quickview-ui/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<Option<PreviewHandle>>,
}

struct PreviewHandle {
window: glib::WeakRef<gtk::ApplicationWindow>,
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<i32> {
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::<glib::GString>(&[]);
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<AppState>, 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply each forwarded OCR language before reusing preview

When an existing Quick Preview is reused for a different file, this path only calls load_file, but ViewerController keeps the ocr_lang captured by the first preview and start_ocr reads that stored value. A later invocation such as quickview --quick-preview --lang deu other.png while an English preview is open will still OCR other.png with English, so the documented CLI language override is ignored for all replacement previews until the window is closed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid — verified: the replace path only called load_file while start_ocr reads the ocr_lang captured at controller construction, so a forwarded --lang was ignored until the preview closed. Fixed in f4716da: ViewerController::set_ocr_lang is now applied before load_file in the reuse branch (in-flight jobs keep the language they started with, consistent with the existing job-id supersession).

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);
}
}
}
2 changes: 1 addition & 1 deletion crates/quickview-ui/src/windows/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
pub mod full_viewer;
pub mod quick_preview;

mod shared;
pub(crate) mod shared;
Loading
Loading