//! Quick capture: a system-wide hotkey that opens a small window to type into. //! //! The point is capture WITHOUT the app. Bringing the whole board forward to write //! one line is the friction this removes, so the shortcut opens a small window of //! its own rather than focusing `main` — and that window closes itself the moment //! the note is saved. //! //! ## Why the shortcut is configurable, and why it starts unset //! //! A global shortcut is the one setting in this app that can collide with software //! it knows nothing about. Whatever default is picked is a key combination taken //! away from something on somebody's machine, silently, at install time. So there //! is no default: the feature is off until someone chooses a combination, and //! choosing one is how it turns on. //! //! The suggestion the settings screen offers (`CommandOrControl+Shift+N`) lives in //! the frontend, not here. It is a UI affordance — a starting point put in front of //! someone — and this side accepts any combination the OS will take, so a constant //! here would be a second copy of a string only the UI ever reads. //! //! ## Failure has to be visible //! //! Registering can fail — the combination may already be held by the window //! manager or another app, and on Wayland a compositor may refuse global grabs //! outright. A hotkey that quietly does nothing is worse than one that was never //! offered, because there is nothing to look at and nothing to fix. So the stored //! shortcut and the LIVE registration are reported separately: see //! [`CaptureShortcut`]. use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; use inkwell_core::local::{store, Db}; const SHORTCUT_PREF: &str = "capture_shortcut"; /// The window the hotkey opens. Also the label the capability file grants to. pub const CAPTURE_WINDOW: &str = "capture"; /// Emitted to the main window after a capture is saved, so the board reloads. /// /// The two windows hold separate copies of the frontend and therefore separate /// Pinia stores; nothing in the capture window's store can reach the board's. The /// note is already in SQLite by the time this fires — this only says "look again". pub const CAPTURED_EVENT: &str = "inkwell://captured"; /// The stored shortcut and whether it is actually live. /// /// Two fields rather than one because they genuinely disagree: a combination can /// be saved and refuse to register, and the person needs to be told which of those /// they are looking at. `registered: false` with a non-empty `shortcut` is the /// "something else already has this" case. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CaptureShortcut { /// The stored combination, or empty when quick capture is off. pub shortcut: String, /// Whether the OS accepted it. Always false when `shortcut` is empty. pub registered: bool, } fn stored(db: &Db) -> Result { let conn = db.conn()?; Ok(store::pref(&conn, SHORTCUT_PREF) .map_err(|e| e.to_string())? .unwrap_or_default()) } /// Open (or focus) the capture window. /// /// Reused rather than recreated: holding one window and showing it is what makes /// the second press feel instant, and it means a half-typed capture survives the /// window being dismissed and reopened. /// /// `always_on_top` and `center` because this is summoned over whatever you were /// doing — a capture window that opens behind the app you called it from has /// failed at the only thing it does. fn open_capture_window(app: &AppHandle) { if let Some(window) = app.get_webview_window(CAPTURE_WINDOW) { let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); return; } // `index.html?capture=1` rather than a `/capture` path: the bundled assets are // served as files, so a path with no file behind it is a 404 in the production // build even though it routes fine under the dev server. A query string is // carried through untouched and the router reads it on boot. let built = WebviewWindowBuilder::new( app, CAPTURE_WINDOW, WebviewUrl::App("index.html?capture=1".into()), ) .title("Quick capture") .inner_size(520.0, 220.0) .min_inner_size(360.0, 160.0) .resizable(true) .always_on_top(true) .center() .skip_taskbar(true) .build(); match built { Ok(window) => { let _ = window.set_focus(); } // Never a panic and never fatal: failing to open a capture window must not // take down an app whose board is working fine. Err(e) => log::error!("could not open the capture window: {e}"), } } /// Register `shortcut`, replacing whatever was live. /// /// Unregisters everything first rather than tracking the previous binding: this /// app owns exactly one global shortcut, so "all of ours" and "the old one" are /// the same set, and keeping a copy of it is one more thing to get out of step. fn register(app: &AppHandle, shortcut: &str) -> Result<(), String> { let manager = app.global_shortcut(); let _ = manager.unregister_all(); if shortcut.is_empty() { return Ok(()); } let parsed: Shortcut = shortcut .parse() .map_err(|_| format!("'{shortcut}' is not a shortcut this system understands."))?; manager .on_shortcut(parsed, |app, _shortcut, event| { // Pressed only. Without this the window is opened on the press AND on // the release, and the second one lands on the window the first opened. if event.state == ShortcutState::Pressed { open_capture_window(app); } }) .map_err(|e| format!("Something else on this system is already using it ({e}).")) } /// Restore the stored shortcut at startup. /// /// Best-effort by construction: a combination that worked when it was chosen can /// be taken by something installed later, and the app must still open. The failure /// is logged and the UI will show it as not registered when the settings screen is /// next opened. pub fn restore(app: &AppHandle, db: &Db) { let shortcut = match stored(db) { Ok(s) if !s.is_empty() => s, Ok(_) => return, Err(e) => { log::warn!("could not read the capture shortcut: {e}"); return; } }; match register(app, &shortcut) { Ok(()) => log::info!("quick capture is on: {shortcut}"), Err(e) => log::warn!("quick capture shortcut '{shortcut}' did not register: {e}"), } } #[tauri::command] pub fn capture_shortcut_get(app: AppHandle, db: State<'_, Db>) -> Result { let shortcut = stored(&db)?; // Asked of the manager rather than remembered from startup: the answer can // have changed since, and a settings screen that reports a stale success is // the exact thing this pair of fields exists to prevent. let registered = !shortcut.is_empty() && shortcut .parse::() .map(|s| app.global_shortcut().is_registered(s)) .unwrap_or(false); Ok(CaptureShortcut { shortcut, registered, }) } /// Store a shortcut and make it live, or clear it with an empty string. /// /// Registers BEFORE storing, so a combination the system refuses is not written /// down as though it worked — the person would reopen the settings and find it /// listed as their shortcut while nothing happened when they pressed it. #[tauri::command] pub fn capture_shortcut_set( shortcut: String, app: AppHandle, db: State<'_, Db>, ) -> Result { let wanted = shortcut.trim().to_string(); register(&app, &wanted)?; let conn = db.conn()?; store::set_pref(&conn, SHORTCUT_PREF, &wanted).map_err(|e| e.to_string())?; log::info!( "quick capture shortcut {}", if wanted.is_empty() { "cleared".to_string() } else { format!("set to {wanted}") } ); Ok(CaptureShortcut { shortcut: wanted.clone(), registered: !wanted.is_empty(), }) } /// Hide the capture window and tell the board to reload. /// /// Hidden rather than closed so the next press has a window to show instead of one /// to build. Called after a save and on Escape alike; `saved` is what decides /// whether the board is told to look again. #[tauri::command] pub fn capture_done(saved: bool, app: AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window(CAPTURE_WINDOW) { window.hide().map_err(|e| e.to_string())?; } if saved { if let Some(main) = app.get_webview_window(crate::MAIN_WINDOW) { // Failure here is cosmetic — the note is saved either way and the board // will show it on its next load — so it is logged, not raised. if let Err(e) = main.emit(CAPTURED_EVENT, ()) { log::warn!("could not tell the board about a capture: {e}"); } } } Ok(()) }