-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Quick Preview click-outside-to-close and single-instance toggle #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| 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); | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an existing Quick Preview is reused for a different file, this path only calls
load_file, butViewerControllerkeeps theocr_langcaptured by the first preview andstart_ocrreads that stored value. A later invocation such asquickview --quick-preview --lang deu other.pngwhile an English preview is open will still OCRother.pngwith English, so the documented CLI language override is ignored for all replacement previews until the window is closed.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
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_filewhilestart_ocrreads theocr_langcaptured at controller construction, so a forwarded--langwas ignored until the preview closed. Fixed in f4716da:ViewerController::set_ocr_langis now applied beforeload_filein the reuse branch (in-flight jobs keep the language they started with, consistent with the existing job-id supersession).