From 42e06da576c6bb676ac3e92bf96124bed364d5b5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 1 Sep 2026 09:02:39 -0400 Subject: [PATCH] desktop: a global hotkey opens a small window to write in, and nothing else MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The other half of #1899. Press the combination anywhere and a 520x220 window arrives over whatever you were doing; type, Ctrl/Cmd+Enter, it is gone. The board never comes forward, which is the whole point — bringing the app up to write one line is the friction this removes. ## There is no default shortcut, deliberately A global shortcut is the one setting here that can collide with software this app knows nothing about. Any default is a key combination taken away from something on somebody's machine, silently, at install time. So the feature is OFF until a combination is chosen, and choosing one is how it turns on. CommandOrControl+Shift+N is offered as a one-click suggestion, never applied on the user's behalf. ## Stored and live are reported separately `CaptureShortcut` carries both `shortcut` and `registered`, because they genuinely disagree: a combination another app grabbed first is saved and does nothing when pressed, and on Wayland a compositor may refuse global grabs outright. Saying only "your shortcut is X" would be a lie with a keystroke attached, so the settings row says "saved but isn't active — something else is holding it". `capture_shortcut_set` registers BEFORE storing, so a combination the system refuses is never written down as though it worked. Registration at startup is best-effort and logged: a shortcut that worked when it was chosen can be taken by something installed later, and the app must still open. ## Two windows, one database, no shared store The capture window runs a second copy of the frontend with its own Pinia stores, so a note saved there is invisible to the board until it is told. It is told — `capture_done(saved)` emits to `main`, and BoardView reloads. The emit failing is cosmetic (the note is already in SQLite) so it is logged, not raised. The window is opened at `index.html?capture=1` rather than at `/capture` because the bundled assets are served as FILES: a path with no file behind it 404s in the production build while routing fine under the dev server. The router turns the query into the route. It is hidden rather than closed on the way out, and it keeps its text. A capture interrupted by something more urgent is still there on the next press, which is what makes Escape safe to press. A failed save also keeps the window open holding the text — hiding it would throw away the only copy of something just written in order to report a problem you could retry your way out of. ## Where the setting lives Rule 25 says a tunable belongs in the UI, and this one has to be. It sits in the desktop's Sync screen beside the update channel, not in admin Settings: that screen is the SERVER's and bounces on desktop anyway, while this is a property of one installation on one machine. Persisted with the same `store::set_pref` the update channel uses. No @tauri-apps/api dependency was added — everything routes through `invoke` and the `withGlobalTauri` global, as the rest of the bridge does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c --- desktop/src-tauri/Cargo.toml | 5 + desktop/src-tauri/capabilities/default.json | 4 +- desktop/src-tauri/src/capture.rs | 229 ++++++++++++++++++++ desktop/src-tauri/src/lib.rs | 11 + frontend/src/desktop/bridge.ts | 53 +++++ frontend/src/router/index.ts | 16 ++ frontend/src/views/BoardView.vue | 15 +- frontend/src/views/CaptureView.vue | 87 ++++++++ frontend/src/views/SyncView.vue | 94 ++++++++ 9 files changed, 511 insertions(+), 3 deletions(-) create mode 100644 desktop/src-tauri/src/capture.rs create mode 100644 frontend/src/views/CaptureView.vue diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index f7720e1..875e6d6 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -64,3 +64,8 @@ tauri-plugin-log = "2" # the plugin declares android support level "none", which is why the Android client # gets a server-served update path instead (Scribe note 2725). tauri-plugin-updater = "2" + +# The system-wide quick-capture hotkey. Desktop only by nature — Android has no +# concept of a global shortcut, and its half of this feature is a share-sheet +# intent filter instead. +tauri-plugin-global-shortcut = "2" diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 1116ed7..5f301f1 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Core capability for the main ThoughtSync window.", - "windows": ["main"], + "description": "Core capability for the ThoughtSync windows: the board and the quick-capture window.", + "windows": ["main", "capture"], "permissions": ["core:default"] } diff --git a/desktop/src-tauri/src/capture.rs b/desktop/src-tauri/src/capture.rs new file mode 100644 index 0000000..933ec96 --- /dev/null +++ b/desktop/src-tauri/src/capture.rs @@ -0,0 +1,229 @@ +//! 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. [`SUGGESTED`] is offered by the UI as a +//! starting point, not applied on its behalf. +//! +//! ## 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 thoughtsync_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"; + +/// What the UI offers as a starting point. NOT applied automatically — see above. +/// +/// `CommandOrControl+Shift+N`: the Command/Control split is Tauri's own portable +/// spelling, and Shift+N is rare enough to be free on most desktops while still +/// meaning "new" to the person pressing it. +pub const SUGGESTED: &str = "CommandOrControl+Shift+N"; + +/// 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 = "thoughtsync://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.0.lock().map_err(|e| e.to_string())?; + 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.0.lock().map_err(|e| e.to_string())?; + 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("main") { + // 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(()) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c187efc..bb460b3 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ //! remains here is the Tauri command surface (`commands`), desktop integration //! (menu-entry install for the Linux AppImage), the in-app updater, and boot. +mod capture; mod commands; mod integration; mod update; @@ -80,6 +81,10 @@ pub fn run() { // build without a signing key still starts normally and simply reports that // updates aren't configured. .plugin(tauri_plugin_updater::Builder::new().build()) + // The quick-capture hotkey. Registering the combination itself happens in + // `setup`, once the store is open and can be asked which one to use — the + // plugin only has to exist before then. + .plugin(tauri_plugin_global_shortcut::Builder::new().build()) // Attachment bytes are served to the webview from the local blob store // (M10.7f). Registered on the BUILDER because a scheme has to exist before // the webview is created; the directory it reads from arrives later, in @@ -118,6 +123,9 @@ pub fn run() { // in this directory saying which one the user picked (issue 2183). update::adopt_installer_channel(&db, &dir); sweep_local_trash(&db); + // Before the store is handed to the app: `restore` needs to read the + // stored shortcut out of it, and after `manage` the Db has moved. + capture::restore(app.handle(), &db); app.manage(db); // Attachment bytes live beside the database, filed by content hash, so a // synced image is readable with no network (M10.7d). @@ -176,6 +184,9 @@ pub fn run() { update::update_channel_set, update::update_check, update::update_install, + capture::capture_shortcut_get, + capture::capture_shortcut_set, + capture::capture_done, ]) .run(tauri::generate_context!()) .expect("error while running the ThoughtSync desktop app"); diff --git a/frontend/src/desktop/bridge.ts b/frontend/src/desktop/bridge.ts index 00e22af..2c4237a 100644 --- a/frontend/src/desktop/bridge.ts +++ b/frontend/src/desktop/bridge.ts @@ -5,6 +5,13 @@ interface TauriGlobal { core: { invoke: (cmd: string, args?: Record) => Promise }; + // Also from `withGlobalTauri`. Needed because quick capture puts the app in TWO + // windows, each with its own Pinia stores — a note saved in one is invisible to + // the other until something says so, and an event is the only channel between + // them that does not involve polling SQLite. + event?: { + listen: (event: string, handler: (e: { payload: T }) => void) => Promise<() => void>; + }; } declare global { @@ -192,3 +199,49 @@ export const updates = { */ install: () => invoke("update_install"), }; + + +// --- Quick capture (#1899) --------------------------------------------------- + +/** + * The stored hotkey and whether the OS actually accepted it. + * + * They disagree more often than you would like: a combination can be saved and + * refuse to register because a window manager or another app already holds it, + * and on Wayland a compositor may refuse global grabs entirely. `registered: + * false` alongside a non-empty `shortcut` is precisely that case, and the UI has + * to say so — a hotkey that silently does nothing is worse than none, because + * there is nothing to look at and nothing to fix. + */ +export interface CaptureShortcut { + /** The stored combination, or "" when quick capture is off. */ + shortcut: string; + registered: boolean; +} + +/** Offered as a starting point, never applied on the user's behalf. */ +export const SUGGESTED_CAPTURE_SHORTCUT = "CommandOrControl+Shift+N"; + +/** Fired at the main window after a capture is saved. */ +const CAPTURED_EVENT = "thoughtsync://captured"; + +export const capture = { + shortcut: () => invoke("capture_shortcut_get"), + /** Pass "" to turn quick capture off. Rejects if the system refuses it. */ + setShortcut: (shortcut: string) => invoke("capture_shortcut_set", { shortcut }), + /** Hide the capture window; `saved` decides whether the board is told to reload. */ + done: (saved: boolean) => invoke("capture_done", { saved }), +}; + +/** + * Run `handler` whenever a note is captured in the other window. + * + * Returns an unlisten function, or a no-op on the web build and on any desktop + * runtime that does not expose the event API — the board simply keeps showing + * what it has until its next load, which is a stale list rather than a broken one. + */ +export async function onCaptured(handler: () => void): Promise<() => void> { + const events = window.__TAURI__?.event; + if (!events) return () => {}; + return events.listen(CAPTURED_EVENT, () => handler()); +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 43a94f0..cee5542 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -24,6 +24,15 @@ const router = createRouter({ { path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") }, ], }, + { + // The quick-capture window (#1899). Its own route because it is its own + // WINDOW — no shell, no nav, one field. Desktop only: there is no global + // hotkey in a browser tab and nothing to summon it. + path: "/capture", + name: "capture", + component: () => import("../views/CaptureView.vue"), + meta: { requiresAuth: true, requiresDesktop: true }, + }, { path: "/settings", name: "settings", @@ -89,6 +98,13 @@ router.beforeEach(async (to) => { if (to.meta.requiresDesktop && !isDesktop()) { return { name: "board" }; } + // The capture window is opened at `index.html?capture=1` rather than at + // `/capture`, because the bundled assets are served as files and a path with no + // file behind it 404s in the production build — it only routes under the dev + // server. A query string survives that, and this is where it becomes a route. + if (to.query.capture === "1" && to.name !== "capture") { + return { name: "capture" }; + } // Deliberately NOT applied to /login and /register: bouncing those on desktop // would loop against the requiresAuth guard above the moment a session is // missing. Nothing on the desktop navigates to them any more (AppShell's sign-out diff --git a/frontend/src/views/BoardView.vue b/frontend/src/views/BoardView.vue index aac62e9..00d06ca 100644 --- a/frontend/src/views/BoardView.vue +++ b/frontend/src/views/BoardView.vue @@ -11,7 +11,7 @@ import EmptyState from "../components/EmptyState.vue"; import FilterBar from "../components/FilterBar.vue"; import NoteGrid from "../components/NoteGrid.vue"; import NoteEditor from "../components/NoteEditor.vue"; -import { isDesktop, sync as syncBridge } from "../desktop/bridge"; +import { isDesktop, onCaptured, sync as syncBridge } from "../desktop/bridge"; const notes = useNotesStore(); const config = useConfigStore(); @@ -227,8 +227,21 @@ onMounted(() => { .catch(() => {}); } }); +// A note written in the quick-capture window lands in the same SQLite file but a +// different Pinia store — this window has no way to know unless it is told. +// Registered as a promise because the listener is set up asynchronously, and +// unregistered on the way out so a board that has been navigated away from does +// not keep reloading itself. +let stopCaptureListener: (() => void) | null = null; +onMounted(() => { + void onCaptured(() => void reload()).then((stop) => { + stopCaptureListener = stop; + }); +}); + onBeforeUnmount(() => { window.removeEventListener("keydown", onBoardKey); + stopCaptureListener?.(); ui.boardCardFocused = false; }); watch([currentView, currentLabel, facetKey], reload); diff --git a/frontend/src/views/CaptureView.vue b/frontend/src/views/CaptureView.vue new file mode 100644 index 0000000..9bc53ae --- /dev/null +++ b/frontend/src/views/CaptureView.vue @@ -0,0 +1,87 @@ + + +