diff --git a/Cargo.lock b/Cargo.lock index bb39a19..6632ec6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1498,6 +1498,23 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "once_cell", + "thiserror 2.0.19", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "globset" version = "0.4.19" @@ -2007,6 +2024,17 @@ dependencies = [ "mutate_once", ] +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + [[package]] name = "keyring" version = "3.6.3" @@ -3131,16 +3159,23 @@ dependencies = [ name = "quicksearch-gui" version = "1.0.5" dependencies = [ + "ashpd", "chrono", "eframe", "egui", "egui_extras", + "futures-channel", + "futures-util", + "global-hotkey", "keyring", "open", + "pollster", "quicksearch-core", + "raw-window-handle", "rfd", "rpassword", "windows-sys 0.59.0", + "x11rb", "zeroize", ] diff --git a/README.md b/README.md index b347a35..3a3a79c 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,21 @@ inside that folder. - **Help**: an in-app quickstart — first indexing run, example queries, what each tab does — pointing here for everything technical. +**Ctrl+Shift+F from anywhere** brings QuickSearch to the front, restoring +it if it was minimized, and puts the cursor in the search box with the +previous search selected, so the next thing you type is the new one. The +Options window's Interface section rebinds it — click the button and press +the keys — or switches it off. It is a system-wide shortcut, registered +with Windows or with the X server, so it works while another application +has focus. Wayland does not let an application claim a key, so there the +shortcut is registered with your desktop through the XDG desktop portal +instead; your desktop then has the final say over which key it is, and its +own keyboard settings are where to change it. The Options window says which +key it settled on. Wayland likewise gives no application a way to put itself +in front of what you are doing, so under it the shortcut selects the Search +tab and the search box but leaves raising the window to the desktop; on X11 +and Windows it raises and restores the window itself. + The bottom status bar always shows what the indexer is doing (phase, percent, files/sec) or the total indexed file count when idle. Applying a settings change to the index counts as something the indexer is doing: it @@ -375,6 +390,19 @@ containing the binary, its config, and its index can be moved wholesale. The GUI edits the config live; external edits apply on next start. +`[ui] search_hotkey` is the system-wide search shortcut, written the way +the Options window prints it (`Ctrl+Shift+F`): Ctrl, Alt and Shift in any +combination, plus one key, joined with `+`. An empty string switches it +off. A value that is not a shortcut is not a config error — the app loads, +says so in the Options window, and runs without one. + +`[ui] color_scheme` is `dark` (the default) or `light`, changeable in the +Options window and applied without a restart. It does not follow the +desktop's own light/dark setting: on Linux nothing in the window system +reports that, so the only way to know is to connect to the session message +bus and subscribe to the user's settings feed — more of your session than a +search tool should be in, to decide what color some text is. + **Changing what is indexed** does not throw the index away. Narrowing the scope — removing a folder, adding an ignore pattern, turning off hidden files or symlink following, shortening `content_extensions` — deletes @@ -565,7 +593,10 @@ filter editors), `duplicates_tab.rs`, `logs_tab.rs` (a virtualized view of the core log ring), `options.rs` (draft-based settings editor shared between the window and the Manage tab), `platform.rs` (open / reveal-in-file-manager, and the Windows stdio setup a -window-subsystem process needs before anything prints), `cli.rs` (terminal +window-subsystem process needs before anything prints), `hotkey/` (the +system-wide search shortcut: one key table feeding both a `RegisterHotKey` +/ `XGrabKey` registration and, on Wayland, an XDG portal session on its own +thread), `cli.rs` (terminal mode, shared with the `quicksearch-cli` binary). There is no pagination: the table is virtualized, so a single scroll list capped at `display_limit` renders in microseconds regardless of row count. diff --git a/config_example.toml b/config_example.toml index 2eb1a3f..d4fa338 100644 --- a/config_example.toml +++ b/config_example.toml @@ -156,6 +156,19 @@ scale = 1.1 # current folder list whenever it is applied. Deleting it just means the # warnings come back once each. watch_cap_warned_roots = [] +# System-wide shortcut that raises QuickSearch, switches to the Search tab +# and selects whatever is in the search box, from anywhere. Modifiers are +# Ctrl, Alt and Shift, joined to one key with "+". Leave it empty ("") for +# no shortcut. On Wayland this is only a preference: the shortcut is +# registered with your desktop, which may assign a different key and lets +# you change it in its own keyboard settings. +search_hotkey = "Ctrl+Shift+F" +# 'dark' or 'light'. Applied as soon as it is changed in the Options +# window. Your desktop's own light/dark setting is not consulted: reading +# it would mean connecting to your session's message bus and subscribing to +# your settings, which is more than a search tool should ask for. Anything +# other than 'light' is dark. +color_scheme = "dark" [search] # Start with the fuzzy passes enabled. diff --git a/crates/quicksearch-core/examples/indexprobe.rs b/crates/quicksearch-core/examples/indexprobe.rs index a134cf4..1320c6d 100644 --- a/crates/quicksearch-core/examples/indexprobe.rs +++ b/crates/quicksearch-core/examples/indexprobe.rs @@ -198,7 +198,11 @@ fn prose(rng: &mut Rng, target: usize) -> String { let mut s = String::with_capacity(target + 16); while s.len() < target { s.push_str(WORDS[rng.next() as usize % WORDS.len()]); - s.push(if rng.next().is_multiple_of(12) { '\n' } else { ' ' }); + s.push(if rng.next().is_multiple_of(12) { + '\n' + } else { + ' ' + }); } s.truncate(target); s diff --git a/crates/quicksearch-core/src/config.rs b/crates/quicksearch-core/src/config.rs index 245da90..0b5301c 100644 --- a/crates/quicksearch-core/src/config.rs +++ b/crates/quicksearch-core/src/config.rs @@ -283,6 +283,24 @@ pub struct UiConfig { /// again — the trade-off changed — while restarting the app does not. /// Pruned to the current root set whenever the folder list is applied. pub watch_cap_warned_roots: Vec, + /// System-wide shortcut that raises the window and puts the caret in the + /// search box, as `Ctrl+Shift+F`: modifiers from `Ctrl`, `Alt` and + /// `Shift`, then one key, joined by `+`. Empty disables it. + /// + /// A plain string rather than a structured key so that a hand-edited + /// config reads the way the Options window prints it, and so an + /// unparseable value degrades to "no shortcut" with a message instead of + /// refusing to load. On Wayland the desktop, not this value, has the + /// final say — see the GUI's `hotkey` module. + pub search_hotkey: String, + /// `dark` or `light`. Applied live; the desktop's own light/dark setting + /// is deliberately not consulted, since reading it means opening a D-Bus + /// session and subscribing to the user's settings. + /// + /// A plain string for the same reason as `search_hotkey`: a value nobody + /// recognises falls back to dark, where a typed-out enum would fail to + /// deserialize and take the whole config file down with it. + pub color_scheme: String, } impl Default for UiConfig { @@ -290,11 +308,12 @@ impl Default for UiConfig { UiConfig { scale: 1.1, watch_cap_warned_roots: Vec::new(), + search_hotkey: "Ctrl+Shift+F".to_string(), + color_scheme: "dark".to_string(), } } } - /// Directories and files excluded from a fresh index. /// /// Build artefacts everywhere, plus the things a Windows home directory or @@ -1097,6 +1116,9 @@ mod tests { assert_eq!(cfg.processing.batch_size, 500, "missing sections default"); assert_eq!(cfg.search.debounce_ms, 150); assert!((cfg.ui.scale - 1.1).abs() < f32::EPSILON); + // A config written before the shortcut existed must come back with + // one, not with no shortcut at all. + assert_eq!(cfg.ui.search_hotkey, "Ctrl+Shift+F"); fs::remove_dir_all(&dir).ok(); } @@ -1923,6 +1945,51 @@ mod tests { fs::remove_dir_all(&dir).ok(); } + /// Which theme the window uses is nobody's business but the window's: it + /// must never cost a reindex or a watcher restart. + #[test] + fn color_scheme_is_a_soft_knob() { + let base = Config::default(); + let mut c = base.clone(); + c.ui.color_scheme = "light".to_string(); + assert_eq!(diff_actions(&base, &c), ConfigActions::default()); + } + + #[test] + fn color_scheme_round_trips_and_defaults_to_dark() { + let dir = tmp_dir(); + let path = dir.join("config.toml"); + assert_eq!(Config::default().ui.color_scheme, "dark"); + + let mut cfg = Config { + source: Some(path.clone()), + ..Config::default() + }; + cfg.ui.color_scheme = "light".to_string(); + cfg.save().unwrap(); + assert_eq!(Config::load_from(&path).unwrap().ui.color_scheme, "light"); + + // A config written before the setting existed keeps the appearance it + // had, which was dark. + fs::write( + &path, + "[paths]\nindexing_paths=[\"/x\"]\ndatabase_path=\"db.sqlite\"\n[ui]\nscale=1.25\n", + ) + .unwrap(); + assert_eq!(Config::load_from(&path).unwrap().ui.color_scheme, "dark"); + + // A value nobody recognises is not a broken config file: the whole + // point of storing it as a string is that the app still starts. + fs::write( + &path, + "[paths]\nindexing_paths=[\"/x\"]\ndatabase_path=\"db.sqlite\"\n\ + [ui]\ncolor_scheme=\"drak\"\n", + ) + .unwrap(); + assert_eq!(Config::load_from(&path).unwrap().ui.color_scheme, "drak"); + fs::remove_dir_all(&dir).ok(); + } + #[test] fn fuzzy_max_edits_round_trips() { let dir = tmp_dir(); diff --git a/crates/quicksearch-core/src/db/open.rs b/crates/quicksearch-core/src/db/open.rs index 25e53c8..8e11d93 100644 --- a/crates/quicksearch-core/src/db/open.rs +++ b/crates/quicksearch-core/src/db/open.rs @@ -569,20 +569,19 @@ mod tests { assert_eq!(count, 0); // New columns should exist (just prepare the SELECT — an // unknown column name would parse-error here). - conn - .query_row( - "SELECT basic_state, content_state, type, mime FROM files LIMIT 0", - [], - |_| Ok(()), - ) - .or_else(|e| { - if matches!(e, rusqlite::Error::QueryReturnedNoRows) { - Ok(()) - } else { - Err(e) - } - }) - .unwrap(); + conn.query_row( + "SELECT basic_state, content_state, type, mime FROM files LIMIT 0", + [], + |_| Ok(()), + ) + .or_else(|e| { + if matches!(e, rusqlite::Error::QueryReturnedNoRows) { + Ok(()) + } else { + Err(e) + } + }) + .unwrap(); drop(conn); std::fs::remove_file(&p).ok(); } diff --git a/crates/quicksearch-core/src/extract/office.rs b/crates/quicksearch-core/src/extract/office.rs index 9fdda20..a1a9db1 100644 --- a/crates/quicksearch-core/src/extract/office.rs +++ b/crates/quicksearch-core/src/extract/office.rs @@ -176,11 +176,7 @@ fn xml_members_under( } /// A format whose whole text lives in one member under one spec. -fn single_member( - path: &Path, - member: &str, - spec: &TextSpec, -) -> Result> { +fn single_member(path: &Path, member: &str, spec: &TextSpec) -> Result> { let mut archive = open_container(path)?; let xml = member_text(&mut archive, member)?; let mut out = String::new(); @@ -474,8 +470,11 @@ mod tests { let p = container( "xlsx-nosst", "xlsx", - &[("xl/worksheets/sheet1.xml", "\ - 7")], + &[( + "xl/worksheets/sheet1.xml", + "\ + 7", + )], ); assert_eq!(extract_document_text(&p, "xlsx").unwrap(), "7 \n"); } @@ -510,10 +509,26 @@ mod tests { #[test] fn malformed_xml_returns_an_error_rather_than_looping() { for (ext, member, body) in [ - ("docx", "word/document.xml", "bad &nonsuch; entity"), - ("odt", "content.xml", "bad &nonsuch; entity"), - ("ods", "content.xml", "bad &nonsuch; entity"), - ("pptx", "ppt/slides/slide1.xml", "bad &nonsuch; entity"), + ( + "docx", + "word/document.xml", + "bad &nonsuch; entity", + ), + ( + "odt", + "content.xml", + "bad &nonsuch; entity", + ), + ( + "ods", + "content.xml", + "bad &nonsuch; entity", + ), + ( + "pptx", + "ppt/slides/slide1.xml", + "bad &nonsuch; entity", + ), ] { let p = container(&format!("bad-{ext}"), ext, &[(member, body)]); assert!( diff --git a/crates/quicksearch-core/src/extract/ole.rs b/crates/quicksearch-core/src/extract/ole.rs index b9f00ff..5597fd6 100644 --- a/crates/quicksearch-core/src/extract/ole.rs +++ b/crates/quicksearch-core/src/extract/ole.rs @@ -949,7 +949,10 @@ mod tests { let p = container("xls-num", "xls", &[("Workbook", book)]); let text = extract_ole_text(&p, "xls").unwrap(); assert!(text.contains("2024"), "{text}"); - assert!(!text.contains("2024.0"), "whole numbers read as typed: {text}"); + assert!( + !text.contains("2024.0"), + "whole numbers read as typed: {text}" + ); } /// A cell indexing past the end of the shared-string table. Dropped, not @@ -1016,7 +1019,10 @@ mod tests { #[test] fn ppt_reads_both_atom_widths() { - let bytes = encoding_rs::WINDOWS_1252.encode("Slide title").0.into_owned(); + let bytes = encoding_rs::WINDOWS_1252 + .encode("Slide title") + .0 + .into_owned(); let mut wide = Vec::new(); for u in "Ωmega body".encode_utf16() { wide.extend_from_slice(&le16(u)); @@ -1033,15 +1039,16 @@ mod tests { /// Atoms live inside nested containers; the walk has to descend to them. #[test] fn ppt_descends_into_containers() { - let bytes = encoding_rs::WINDOWS_1252.encode("Nested deep").0.into_owned(); + let bytes = encoding_rs::WINDOWS_1252 + .encode("Nested deep") + .0 + .into_owned(); let atom = ppt_record(0x0000, ppt::TEXT_BYTES_ATOM, &bytes); let inner = ppt_record(0x000F, 0x0FF0, &atom); let outer = ppt_record(0x000F, 0x03E8, &inner); let p = container("ppt-nest", "ppt", &[("PowerPoint Document", outer)]); - assert!(extract_ole_text(&p, "ppt") - .unwrap() - .contains("Nested deep")); + assert!(extract_ole_text(&p, "ppt").unwrap().contains("Nested deep")); } /// A container that claims to hold itself. The depth bound is what stops diff --git a/crates/quicksearch-core/src/extract/pdf.rs b/crates/quicksearch-core/src/extract/pdf.rs index c630947..77ad131 100644 --- a/crates/quicksearch-core/src/extract/pdf.rs +++ b/crates/quicksearch-core/src/extract/pdf.rs @@ -259,12 +259,30 @@ mod tests { ); // Lowercased keys, which is the contract the rest of the pipeline // stores under. - assert_eq!(out.properties.get("title").map(String::as_str), Some("The Title")); - assert_eq!(out.properties.get("author").map(String::as_str), Some("An Author")); - assert_eq!(out.properties.get("subject").map(String::as_str), Some("A Subject")); - assert_eq!(out.properties.get("keywords").map(String::as_str), Some("alpha beta")); - assert_eq!(out.properties.get("creator").map(String::as_str), Some("A Creator")); - assert_eq!(out.properties.get("producer").map(String::as_str), Some("A Producer")); + assert_eq!( + out.properties.get("title").map(String::as_str), + Some("The Title") + ); + assert_eq!( + out.properties.get("author").map(String::as_str), + Some("An Author") + ); + assert_eq!( + out.properties.get("subject").map(String::as_str), + Some("A Subject") + ); + assert_eq!( + out.properties.get("keywords").map(String::as_str), + Some("alpha beta") + ); + assert_eq!( + out.properties.get("creator").map(String::as_str), + Some("A Creator") + ); + assert_eq!( + out.properties.get("producer").map(String::as_str), + Some("A Producer") + ); } /// The soft-fail path: no `Info` dictionary is not an extraction failure, @@ -318,7 +336,10 @@ mod tests { "integer Info value was rendered: {:?}", out.properties ); - assert_eq!(out.properties.get("title").map(String::as_str), Some("Kept")); + assert_eq!( + out.properties.get("title").map(String::as_str), + Some("Kept") + ); } /// Malformed input must come back as an error, not take the process down. diff --git a/crates/quicksearch-core/src/file_handling.rs b/crates/quicksearch-core/src/file_handling.rs index 3ed2cdb..7813e51 100644 --- a/crates/quicksearch-core/src/file_handling.rs +++ b/crates/quicksearch-core/src/file_handling.rs @@ -490,9 +490,9 @@ fn count_tree_entries_win32( use std::sync::atomic::Ordering; use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; use windows_sys::Win32::Storage::FileSystem::{ - CreateFileW, GetFileInformationByHandleEx, FileIdBothDirectoryInfo, FILE_ID_BOTH_DIR_INFO, - FILE_FLAG_BACKUP_SEMANTICS, FILE_LIST_DIRECTORY, FILE_SHARE_DELETE, FILE_SHARE_READ, - FILE_SHARE_WRITE, OPEN_EXISTING, + CreateFileW, FileIdBothDirectoryInfo, GetFileInformationByHandleEx, + FILE_FLAG_BACKUP_SEMANTICS, FILE_ID_BOTH_DIR_INFO, FILE_LIST_DIRECTORY, FILE_SHARE_DELETE, + FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, }; /// One call returns as many entries as fit here. 64 KiB holds several @@ -574,9 +574,8 @@ fn count_tree_entries_win32( // NUL-terminated, so the length is the only thing that says // where it ends. let name_units = (info.FileNameLength as usize) / 2; - let name = unsafe { - std::slice::from_raw_parts(info.FileName.as_ptr(), name_units) - }; + let name = + unsafe { std::slice::from_raw_parts(info.FileName.as_ptr(), name_units) }; let name = OsString::from_wide(name); // "." and ".." are entries of the listing, not of the tree. diff --git a/crates/quicksearch-core/src/search/cascade.rs b/crates/quicksearch-core/src/search/cascade.rs index 93e1b41..56dfa99 100644 --- a/crates/quicksearch-core/src/search/cascade.rs +++ b/crates/quicksearch-core/src/search/cascade.rs @@ -616,8 +616,7 @@ impl<'a> Cx<'a> { JOIN files f ON f.id = searchabletext.rowid \ LEFT JOIN documents_text dt ON dt.file_id = f.id \ WHERE searchabletext MATCH ?{}", - HIT_COLUMNS, - query.filter_sql + HIT_COLUMNS, query.filter_sql ), self.params_with_filters(vec![rusqlite::types::Value::Text(expr)]), ), @@ -626,8 +625,7 @@ impl<'a> Cx<'a> { "SELECT {}, dt.text_zstd \ FROM documents_text dt \ JOIN files f ON f.id = dt.file_id WHERE 1=1{}", - HIT_COLUMNS, - query.filter_sql + HIT_COLUMNS, query.filter_sql ), self.params_with_filters(Vec::new()), ), @@ -762,8 +760,7 @@ impl<'a> Cx<'a> { let sql = format!( "SELECT {} FROM files f WHERE 1=1{}", - HIT_COLUMNS, - self.query.filter_sql + HIT_COLUMNS, self.query.filter_sql ); let params = self.params_with_filters(Vec::new()); let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?; @@ -870,8 +867,7 @@ impl<'a> Cx<'a> { let sql = format!( "SELECT {}, dt.text_zstd \ FROM documents_text dt JOIN files f ON f.id = dt.file_id WHERE 1=1{}", - HIT_COLUMNS, - self.query.filter_sql + HIT_COLUMNS, self.query.filter_sql ); let params = self.params_with_filters(Vec::new()); let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?; @@ -944,8 +940,7 @@ impl<'a> Cx<'a> { let re = query.regex.as_ref().expect("regex-only pass list"); let sql = format!( "SELECT {} FROM files f WHERE 1=1{}", - HIT_COLUMNS, - query.filter_sql + HIT_COLUMNS, query.filter_sql ); let params = self.params_with_filters(Vec::new()); let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?; @@ -1028,8 +1023,7 @@ impl<'a> Cx<'a> { let sql = format!( "SELECT {}, dt.text_zstd \ FROM documents_text dt JOIN files f ON f.id = dt.file_id WHERE 1=1{}", - HIT_COLUMNS, - query.filter_sql + HIT_COLUMNS, query.filter_sql ); let params = self.params_with_filters(Vec::new()); let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?; diff --git a/crates/quicksearch-core/tests/cascade.rs b/crates/quicksearch-core/tests/cascade.rs index 0934878..217eaf6 100644 --- a/crates/quicksearch-core/tests/cascade.rs +++ b/crates/quicksearch-core/tests/cascade.rs @@ -1088,7 +1088,10 @@ fn search_names( let mut names = Vec::new(); while std::time::Instant::now() < deadline { match updates.recv_timeout(std::time::Duration::from_millis(200)) { - Ok(SearchUpdate::Hits { generation: g, hits }) if g == generation => { + Ok(SearchUpdate::Hits { + generation: g, + hits, + }) if g == generation => { names.extend(hits.into_iter().map(|h| h.name)); } Ok(SearchUpdate::Completed { generation: g, .. }) if g == generation => { @@ -1261,7 +1264,10 @@ fn the_connection_is_released_once_searching_stops() { search_names(&service, &updates, "shared").unwrap(), vec!["held.txt"] ); - assert!(holds_index(), "the connection should be held across requests"); + assert!( + holds_index(), + "the connection should be held across requests" + ); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); while holds_index() && std::time::Instant::now() < deadline { diff --git a/crates/quicksearch-core/tests/full_index.rs b/crates/quicksearch-core/tests/full_index.rs index a9ce1b1..ad8b133 100644 --- a/crates/quicksearch-core/tests/full_index.rs +++ b/crates/quicksearch-core/tests/full_index.rs @@ -44,7 +44,6 @@ fn rows(db: &Path) -> Vec<(String, i64, i64)> { } fn test_config() -> Config { - // Keep the run to phase 1 semantics we're asserting on; extraction is // covered elsewhere. Config::default() @@ -921,7 +920,10 @@ fn an_unreadable_legacy_office_file_fails_with_a_reason() { let db_dir = tmp_dir("legacy-doc-db"); let db = db_dir.join("index.sqlite"); - touch(&root.join("broken.doc"), b"D0CF11E0 this is not really a compound file"); + touch( + &root.join("broken.doc"), + b"D0CF11E0 this is not really a compound file", + ); index_once(&root, &db, &Config::default()); let conn = rusqlite::Connection::open(&db).unwrap(); @@ -932,7 +934,10 @@ fn an_unreadable_legacy_office_file_fails_with_a_reason() { |r| Ok((r.get(0)?, r.get(1)?)), ) .unwrap(); - assert_eq!(state, 2, "an unreadable .doc is FAILED, not DONE-with-no-text"); + assert_eq!( + state, 2, + "an unreadable .doc is FAILED, not DONE-with-no-text" + ); let msg = msg.unwrap_or_default(); assert!(msg.contains("broken.doc"), "names the file: {msg}"); assert!(msg.contains("compound file"), "says what went wrong: {msg}"); diff --git a/crates/quicksearch-core/tests/search_perf.rs b/crates/quicksearch-core/tests/search_perf.rs index 43c2313..6ac5e10 100644 --- a/crates/quicksearch-core/tests/search_perf.rs +++ b/crates/quicksearch-core/tests/search_perf.rs @@ -72,9 +72,33 @@ impl Lcg { } const WORDS: &[&str] = &[ - "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota", "kappa", - "lambda", "quartz", "quartzite", "quarry", "quarter", "quantum", "brown", "fox", "jumps", - "lazy", "index", "search", "cascade", "snippet", "document", "content", "extract", + "alpha", + "beta", + "gamma", + "delta", + "epsilon", + "zeta", + "eta", + "theta", + "iota", + "kappa", + "lambda", + "quartz", + "quartzite", + "quarry", + "quarter", + "quantum", + "brown", + "fox", + "jumps", + "lazy", + "index", + "search", + "cascade", + "snippet", + "document", + "content", + "extract", ]; /// Seed an index with `NUM_FILES` rows, a tenth of them content-indexed. diff --git a/crates/quicksearch-gui/Cargo.toml b/crates/quicksearch-gui/Cargo.toml index a600178..1b4d58e 100644 --- a/crates/quicksearch-gui/Cargo.toml +++ b/crates/quicksearch-gui/Cargo.toml @@ -49,6 +49,11 @@ rfd = "0.15" open = "5" chrono = { version = "0.4", default-features = false, features = ["clock"] } +# The system-wide search shortcut, where the display server lets an +# application claim keys for itself: `RegisterHotKey` on Windows, `XGrabKey` +# on X11. Wayland does not, and is handled by the portal below. +global-hotkey = "0.8" + # Display backends, which only exist on Linux/BSD. `default-features = false` # has to be repeated: feature resolution unions the two stanzas, so a single # permissive one would switch defaults back on for every target. @@ -58,6 +63,25 @@ eframe = { version = "0.32", default-features = false, features = [ "x11", ] } +# The Wayland half of the search shortcut: `org.freedesktop.portal.GlobalShortcuts`, +# the only way a Wayland application can be told about a key it does not own. +# `rfd` already pulls all four in (it uses the file-chooser portal), so the +# versions here are the ones already resolved and nothing extra is compiled. +# `default-features = false` matters: ashpd defaults to Tokio, which would add +# a second async runtime and switch `zbus` over to it underneath `rfd`. +ashpd = { version = "0.11", default-features = false, features = ["async-std"] } +futures-channel = "0.3" +futures-util = "0.3" +pollster = "0.4" + +# Raising the window from the shortcut on X11, which winit cannot do: it asks +# with a source indication of "application", and every mainstream window +# manager refuses that from a window that is not already focused. See +# `hotkey::raise`. Both are already in the tree (winit's own X11 backend, and +# eframe's window handle), so neither adds a crate. +x11rb = "0.13" +raw-window-handle = "0.6" + # Console attachment for the GUI binary (which has no stdio when launched from # Explorer) and VT-mode enabling for the CLI binary. 0.59 matches what eframe # and rfd already resolve, so no extra crate is compiled. diff --git a/crates/quicksearch-gui/src/app.rs b/crates/quicksearch-gui/src/app.rs index 3e67cb3..54f9366 100644 --- a/crates/quicksearch-gui/src/app.rs +++ b/crates/quicksearch-gui/src/app.rs @@ -8,7 +8,7 @@ use quicksearch_core::config::{diff_actions, nested_roots, Config, SecurityConfi use quicksearch_core::coordinator::{IndexMode, IndexerState, ReconcileState, WatcherStatus}; use quicksearch_core::db; use quicksearch_core::indexing::{ - overall_progress, ConfigChange, IndexingStatus, PrepStep, RootPhase, + overall_progress, ConfigChange, IndexingStatus, PrepStep, RootPhase, RootProgress, }; use quicksearch_core::search::SearchOptions; use quicksearch_core::security::{derive_key, generate_salt, salt_to_hex, IndexKey}; @@ -16,6 +16,7 @@ use quicksearch_core::watcher::WatchError; use zeroize::{Zeroize, Zeroizing}; use crate::backend::Backend; +use crate::color::{palette, Palette}; use crate::duplicates_tab::{DupState, DuplicatesTab}; use crate::format::{fmt_interval, group_thousands}; use crate::keychain; @@ -92,6 +93,20 @@ fn quit_needs_reconcile_warning(intent: NavIntent, reconciling: bool) -> bool { intent == NavIntent::Quit && reconciling } +/// Whether leaving the current tab has to go through the unsaved-changes +/// guard instead of happening directly. +/// +/// Its own function because the tab strip is no longer the only way to leave +/// a tab: the system-wide search shortcut does it too, from outside the +/// window, and a second copy of this rule is how one of them would quietly +/// start discarding a page of staged index settings. +/// +/// A navigation already on hold wins: the guard is walking one decision at a +/// time and a second intent would replace the answer it is waiting for. +fn switch_needs_guard(from: Tab, manage_dirty: bool, nav_pending: bool) -> bool { + from == Tab::Manage && manage_dirty && !nav_pending +} + pub struct QuickSearchApp { cfg: Config, backend: Backend, @@ -181,8 +196,11 @@ impl QuickSearchApp { initial_query: Option, key_source: KeySource, ) -> Result { - // Compact styling: results density is the whole point. - ctx.style_mut(|style| { + // Compact styling: results density is the whole point. Both themes, + // because `style_mut` reaches only the one in use — styling just the + // live theme means the spacing reverts to egui's defaults the moment + // the color scheme is switched. + ctx.all_styles_mut(|style| { style.spacing.item_spacing = egui::vec2(6.0, 3.0); style.spacing.button_padding = egui::vec2(6.0, 2.0); }); @@ -304,6 +322,15 @@ impl QuickSearchApp { if (new.ui.scale - self.cfg.ui.scale).abs() > f32::EPSILON { ctx.set_zoom_factor(clamp_scale(new.ui.scale)); } + if new.ui.search_hotkey != self.cfg.ui.search_hotkey { + // Re-registering is cheap but not free — on Wayland it opens a + // new portal session, which some desktops confirm with the user — + // so it happens only when the setting actually moved. + crate::hotkey::apply(&new.ui.search_hotkey); + } + if new.ui.color_scheme != self.cfg.ui.color_scheme { + apply_theme(ctx, &new.ui.color_scheme); + } if actions.search_db_changed { self.backend .search() @@ -334,6 +361,28 @@ impl QuickSearchApp { true } + /// What the system-wide search shortcut does once the window is up: + /// show the Search tab with the caret in the query box and whatever was + /// there already selected, so the next keystroke starts a new search + /// instead of extending the last one. + /// + /// The tab switch goes through the same guard as a click on the tab strip + /// rather than around it. Someone who pressed the shortcut wants to + /// search, not to silently lose a page of unapplied index settings. + pub(crate) fn activate_search(&mut self) { + if switch_needs_guard(self.tab, self.manage.is_dirty(), self.pending_nav.is_some()) { + self.pending_nav = Some(NavIntent::SwitchTab(Tab::Search)); + } else { + self.tab = Tab::Search; + } + self.search.request_focus(); + } + + /// Whether the Options window is currently reading a key press to bind. + pub(crate) fn capturing_hotkey(&self) -> bool { + self.options.capturing_hotkey() + } + /// Switch the indexing mode and write it to the config immediately. /// /// The mode is a persisted setting (`indexing.auto_index`), not a @@ -500,19 +549,10 @@ impl QuickSearchApp { progress_widget(ui, frac); } IndexingStatus::Idle => { - let mode = match state.mode { - IndexMode::Auto => "Auto", - IndexMode::ManualStopped => "Manual", - IndexMode::ManualRunning => "Manual", - }; - let files = state.files.unwrap_or(0); - ui.label( - egui::RichText::new(format!( - "Idle · {} · {} files indexed", - mode, - group_thousands(files.max(0) as u64) - )) - .small(), + let colors = palette(ui.visuals().dark_mode); + status_line( + ui, + &idle_line(state.mode, state.files.unwrap_or(0), &colors), ); } IndexingStatus::Error(e) => { @@ -528,35 +568,10 @@ impl QuickSearchApp { ui.label(egui::RichText::new("Optimizing index…").small()); } IndexingStatus::Running { roots, .. } => { - let done = roots.iter().filter(|r| r.phase == RootPhase::Done).count(); - let progress = overall_progress(roots); - let frac = progress.fraction(); - - let mut text = match (progress.total, frac) { - (Some(total), Some(frac)) => format!( - "Indexing {} / {} ({:.0}%)", - group_thousands(progress.processed as u64), - group_thousands(total as u64), - frac * 100.0 - ), - _ => format!( - "Indexing · {} files", - group_thousands(progress.processed as u64) - ), - }; - if roots.len() > 1 { - text.push_str(&format!(" · {}/{} roots done", done, roots.len())); - } - if let Some(rate) = self.manage.speed.files_per_sec() { - text.push_str(&format!(" · {}", crate::format::fmt_rate(rate))); - } - let active: usize = roots.iter().map(|r| r.active_workers).sum(); - let total_workers: usize = roots.iter().map(|r| r.total_workers).sum(); - if total_workers > 0 { - text.push_str(&format!(" · {}/{} workers", active, total_workers)); - } - ui.label(egui::RichText::new(text).small()); - progress_widget(ui, frac); + let colors = palette(ui.visuals().dark_mode); + let rate = self.manage.speed.files_per_sec(); + status_line(ui, &running_line(roots, rate, &colors)); + progress_widget(ui, overall_progress(roots).fraction()); } } @@ -897,6 +912,88 @@ impl QuickSearchApp { } } +/// One run of status text and the color hint it carries, if any. `None` is +/// the theme's own text color, not an absence of paint. +type Span = (String, Option); + +/// A status line assembled from colored spans, painted as one small widget so +/// the segments keep the exact spacing they would have had inside a single +/// label — and so the bar's widget count does not depend on how many spans a +/// state happens to need. +fn status_line(ui: &mut egui::Ui, spans: &[Span]) { + let font = egui::TextStyle::Small.resolve(ui.style()); + let default = ui.visuals().text_color(); + let mut job = egui::text::LayoutJob::default(); + for (text, color) in spans { + job.append( + text, + 0.0, + egui::TextFormat { + font_id: font.clone(), + color: color.unwrap_or(default), + ..Default::default() + }, + ); + } + ui.label(job); +} + +/// The bottom bar's line for a run in progress, where only the phase word +/// carries the hint: the counters beside it are read, not glanced at. +/// +/// One line covers every root, so mixed phases need a rule — and a run with +/// any root still walking is still walking, since the walk is what decides how +/// much extraction there will be. Once none is left the only work remaining is +/// extraction; a root that reached `Done` early has nothing left to contribute. +fn running_line(roots: &[RootProgress], rate: Option, colors: &Palette) -> Vec { + let phase = if roots.iter().any(|r| r.phase == RootPhase::Walking) { + colors.yellow + } else { + colors.green + }; + let done = roots.iter().filter(|r| r.phase == RootPhase::Done).count(); + let progress = overall_progress(roots); + let mut rest = match (progress.total, progress.fraction()) { + (Some(total), Some(frac)) => format!( + " {} / {} ({:.0}%)", + group_thousands(progress.processed as u64), + group_thousands(total as u64), + frac * 100.0 + ), + _ => format!(" · {} files", group_thousands(progress.processed as u64)), + }; + if roots.len() > 1 { + rest.push_str(&format!(" · {}/{} roots done", done, roots.len())); + } + if let Some(rate) = rate { + rest.push_str(&format!(" · {}", crate::format::fmt_rate(rate))); + } + let active: usize = roots.iter().map(|r| r.active_workers).sum(); + let total_workers: usize = roots.iter().map(|r| r.total_workers).sum(); + if total_workers > 0 { + rest.push_str(&format!(" · {}/{} workers", active, total_workers)); + } + vec![("Indexing".to_string(), Some(phase)), (rest, None)] +} + +/// The bottom bar's idle line. Manual mode is the one worth flagging — it +/// means the index will not refresh itself — so Auto stays unpainted: a hint +/// that is always on says nothing. +fn idle_line(mode: IndexMode, files: i64, colors: &Palette) -> Vec { + let (mode_text, mode_color) = match mode { + IndexMode::Auto => ("Auto", None), + IndexMode::ManualStopped | IndexMode::ManualRunning => ("Manual", Some(colors.orange)), + }; + vec![ + ("Idle · ".to_string(), None), + (mode_text.to_string(), mode_color), + ( + format!(" · {} files indexed", group_thousands(files.max(0) as u64)), + None, + ), + ] +} + /// The status bar's trailing progress indicator: a bar when the work has a /// denominator, a spinner when it does not. One helper so every kind of /// activity the bar reports ends the same way. @@ -1261,7 +1358,7 @@ impl QuickSearchApp { } pub(crate) fn capture_focus_search(&mut self) { - self.search.capture_focus(); + self.search.request_focus(); } pub(crate) fn capture_match_cell(&self, n: usize) -> Option { @@ -1307,7 +1404,7 @@ fn unsaved_changes_modal(ctx: &egui::Context, source: UnsavedSource) -> Option Option { if ui .add(crate::ui_util::bordered_button( "Cancel", - crate::ui_util::BLUE, + palette(ui.visuals().dark_mode).blue, )) .clicked() { @@ -1482,6 +1579,31 @@ fn clamp_scale(scale: f32) -> f32 { } } +/// What `[ui] color_scheme` means to egui. +/// +/// Anything but `light` is dark, `dark` included: the setting is +/// hand-editable, and a typo should not leave the window in some third state +/// nobody chose. +/// +/// The desktop's own light/dark setting is deliberately not consulted. On +/// Linux nothing in the window system reports it, so the only way to know is +/// to connect to the session message bus and subscribe to the user's settings +/// feed — more of someone's session than a search tool should be in, to decide +/// what color some text is. +pub(crate) fn theme_for(setting: &str) -> egui::Theme { + match setting.trim().to_ascii_lowercase().as_str() { + "light" => egui::Theme::Light, + _ => egui::Theme::Dark, + } +} + +/// Apply the configured color scheme. Called once at startup, before the +/// unlock gate, and again whenever the setting changes; egui repaints with it +/// on the next frame, so neither needs a restart. +pub(crate) fn apply_theme(ctx: &egui::Context, setting: &str) { + ctx.set_theme(theme_for(setting)); +} + impl eframe::App for QuickSearchApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // First, so a command's effect is fully rendered before the next one @@ -1538,7 +1660,7 @@ impl eframe::App for QuickSearchApp { }); }); if requested != self.tab { - if self.tab == Tab::Manage && self.manage.is_dirty() && self.pending_nav.is_none() { + if switch_needs_guard(self.tab, self.manage.is_dirty(), self.pending_nav.is_some()) { self.pending_nav = Some(NavIntent::SwitchTab(requested)); } else { self.tab = requested; @@ -1738,6 +1860,28 @@ mod tests { /// two sequential prompts, because each draft is a full `Config` /// snapshot and applying both in one step would let the second revert /// the first. + /// The system-wide search shortcut leaves a tab the same way a click on + /// the tab strip does. Someone who pressed it wants to search, which is + /// not a reason to throw away a page of staged index settings. + #[test] + fn leaving_a_dirty_manage_tab_is_guarded_however_it_is_asked_for() { + assert!(switch_needs_guard(Tab::Manage, true, false)); + assert!( + !switch_needs_guard(Tab::Manage, false, false), + "a clean editor has nothing to ask about" + ); + assert!( + !switch_needs_guard(Tab::Manage, true, true), + "one held navigation at a time" + ); + for tab in [Tab::Search, Tab::Duplicates, Tab::Logs, Tab::Help] { + assert!( + !switch_needs_guard(tab, true, false), + "{tab:?} holds no unapplied edits of its own" + ); + } + } + #[test] fn guard_source_orders_quit_prompts_options_first() { use NavIntent::*; @@ -1929,4 +2073,175 @@ mod tests { "Dismiss never fired" ); } + + fn root(phase: RootPhase, walked: usize, walk_total: Option) -> RootProgress { + RootProgress { + root: "/data".to_string(), + phase, + walked, + walk_total, + extracted: 0, + extract_total: 0, + current_file: None, + active_workers: 2, + total_workers: 4, + } + } + + /// What the user reads: the spans are a presentation detail, the sentence + /// they spell is not. + fn line(spans: &[Span]) -> String { + spans.iter().map(|(text, _)| text.as_str()).collect() + } + + /// Splitting the line to color its first word must not move a character + /// of it — the spacing around the phase word comes from the text itself, + /// not from egui's item spacing. + #[test] + fn the_running_line_reads_as_one_sentence() { + let colors = palette(true); + + assert_eq!( + line(&running_line( + &[root(RootPhase::Walking, 100, Some(1000))], + None, + &colors + )), + "Indexing 100 / 1,000 (10%) · 2/4 workers" + ); + + // No count has landed yet: no denominator is invented for it. + assert_eq!( + line(&running_line( + &[root(RootPhase::Walking, 100, None)], + None, + &colors + )), + "Indexing · 100 files · 2/4 workers" + ); + + let mut extracting = root(RootPhase::Extracting, 1_000, None); + extracting.extracted = 200; + extracting.extract_total = 800; + extracting.active_workers = 3; + let mut done = root(RootPhase::Done, 500, None); + done.extracted = 500; + done.extract_total = 500; + done.active_workers = 0; + done.total_workers = 0; + assert_eq!( + line(&running_line(&[extracting, done], Some(120.0), &colors)), + "Indexing 2,200 / 2,800 (79%) · 1/2 roots done · 120 files/s · 3/4 workers" + ); + } + + /// The hint is on the phase word alone: coloring the counters too would + /// make the moving numbers the loudest thing in the window. + #[test] + fn only_the_phase_word_of_the_running_line_is_hinted() { + for dark in [true, false] { + let colors = palette(dark); + let spans = running_line(&[root(RootPhase::Walking, 100, Some(1000))], None, &colors); + assert_eq!(spans[0].0, "Indexing"); + assert_eq!(spans[0].1, Some(colors.yellow), "dark_mode={}", dark); + assert!( + spans[1..].iter().all(|(_, color)| color.is_none()), + "the counters carry a hint: {:?}", + spans + ); + } + } + + /// A run with any root still walking is still walking: the walk is what + /// decides how much extraction there will be, so it owns the hint until + /// the last one ends. + #[test] + fn the_running_hint_follows_the_least_advanced_root() { + let colors = palette(true); + let hint = |roots: &[RootProgress]| running_line(roots, None, &colors)[0].1; + + assert_eq!( + hint(&[ + root(RootPhase::Extracting, 100, None), + root(RootPhase::Walking, 100, Some(1000)), + root(RootPhase::Done, 100, None), + ]), + Some(colors.yellow) + ); + assert_eq!( + hint(&[ + root(RootPhase::Extracting, 100, None), + root(RootPhase::Done, 100, None), + ]), + Some(colors.green) + ); + // Every root finished, but the run has not torn itself down yet. + assert_eq!( + hint(&[root(RootPhase::Done, 100, None)]), + Some(colors.green) + ); + } + + /// Manual mode is the one idle state worth flagging: it means nothing + /// will refresh the index until the user says so. Auto is the expected + /// state, and a hint that is always on says nothing. + #[test] + fn only_manual_idle_is_hinted() { + for dark in [true, false] { + let colors = palette(dark); + + let auto = idle_line(IndexMode::Auto, 12_000, &colors); + assert_eq!(line(&auto), "Idle · Auto · 12,000 files indexed"); + assert!( + auto.iter().all(|(_, color)| color.is_none()), + "automatic mode is not a warning: {:?}", + auto + ); + + for mode in [IndexMode::ManualStopped, IndexMode::ManualRunning] { + let spans = idle_line(mode, 12_000, &colors); + assert_eq!(line(&spans), "Idle · Manual · 12,000 files indexed"); + let hinted: Vec<_> = spans.iter().filter(|(_, c)| c.is_some()).collect(); + assert_eq!( + hinted, + vec![&("Manual".to_string(), Some(colors.orange))], + "{:?} in dark_mode={}", + mode, + dark + ); + } + } + } + + /// A count read back as negative is a bug, not something to print. + #[test] + fn a_negative_file_count_reads_as_zero() { + let colors = palette(true); + assert_eq!( + line(&idle_line(IndexMode::Auto, -1, &colors)), + "Idle · Auto · 0 files indexed" + ); + } + + /// The two values the Options window writes, plus everything a + /// hand-edited config might hold instead. + #[test] + fn only_light_is_light() { + assert_eq!(theme_for("light"), egui::Theme::Light); + assert_eq!(theme_for("dark"), egui::Theme::Dark); + + // Spelled the user's way, not the config's. + assert_eq!(theme_for(" LIGHT "), egui::Theme::Light); + assert_eq!(theme_for("Dark"), egui::Theme::Dark); + + // A typo costs the preference, not the config file. + for nonsense in ["", " ", "lite", "system", "auto", "true"] { + assert_eq!( + theme_for(nonsense), + egui::Theme::Dark, + "{:?} should be dark", + nonsense + ); + } + } } diff --git a/crates/quicksearch-gui/src/color.rs b/crates/quicksearch-gui/src/color.rs new file mode 100644 index 0000000..2673ec2 --- /dev/null +++ b/crates/quicksearch-gui/src/color.rs @@ -0,0 +1,713 @@ +//! Every color the GUI paints with, declared in OKLCH and converted to sRGB +//! at compile time. +//! +//! OKLCH is worth the conversion because its three coordinates are the three +//! questions a palette actually has to answer: how bright (L), how vivid (C), +//! and which color (H). Fixing L and C across a set and varying only H is the +//! whole reason the status hints read as one system — no hue shouts louder +//! than its neighbors, and "readable on this background" becomes one number +//! checked once rather than a judgement made per color. +//! +//! The conversion is a fixed pipeline — polar to rectangular, a 3x3 matrix, a +//! cube, a second 3x3 matrix, the sRGB transfer curve — but none of it is +//! available in a `const fn`: `sqrt`, `cbrt`, `powf`, `sin` and `cos` are all +//! still non-const. Hence the numerics below, which are exact enough that +//! their output matches `std`'s to the last bit of every channel (see the +//! tests). Doing this at compile time is what keeps the declarations honest: +//! a palette written as `Color32::from_rgb` literals hides every relationship +//! that makes it a palette. + +use egui::Color32; + +// --------------------------------------------------------------------------- +// Const numerics +// --------------------------------------------------------------------------- + +const PI: f64 = std::f64::consts::PI; + +/// Iterations for the Newton loops below. Each step doubles the correct +/// digits, so this is far past f64's 53 bits from any starting guess in +/// range — the cost is paid once, at compile time, and buys the luxury of +/// not having to reason about how good the guess was. +const NEWTON_STEPS: usize = 60; + +/// Newton's method for `sqrt`: x <- (x + a/x) / 2. +const fn sqrt(a: f64) -> f64 { + if a <= 0.0 { + return 0.0; + } + let mut x = if a > 1.0 { a / 2.0 } else { 1.0 }; + let mut i = 0; + while i < NEWTON_STEPS { + x = 0.5 * (x + a / x); + i += 1; + } + x +} + +/// Newton's method for `cbrt`: x <- (2x + a/x²) / 3. +const fn cbrt(a: f64) -> f64 { + if a <= 0.0 { + return 0.0; + } + let mut x = if a > 1.0 { a / 3.0 } else { 1.0 }; + let mut i = 0; + while i < NEWTON_STEPS { + x = (2.0 * x + a / (x * x)) / 3.0; + i += 1; + } + x +} + +/// Newton's method for the fifth root: x <- (4x + a/x⁴) / 5. +const fn fifth_root(a: f64) -> f64 { + if a <= 0.0 { + return 0.0; + } + let mut x = if a > 1.0 { a / 5.0 } else { 1.0 }; + let mut i = 0; + while i < NEWTON_STEPS { + let x4 = x * x * x * x; + x = (4.0 * x + a / x4) / 5.0; + i += 1; + } + x +} + +/// Taylor series for cosine, after reducing the angle to [-pi, pi] where the +/// series converges fastest. Twelve terms there are already below f64's +/// resolution. +const fn cos_rad(x: f64) -> f64 { + let turns = x / (2.0 * PI); + let mut r = x - (turns as i64 as f64) * 2.0 * PI; + if r > PI { + r -= 2.0 * PI; + } + if r < -PI { + r += 2.0 * PI; + } + let x2 = r * r; + let mut term = 1.0; + let mut sum = 1.0; + let mut n = 1; + while n <= 12 { + term = -term * x2 / (((2 * n - 1) * (2 * n)) as f64); + sum += term; + n += 1; + } + sum +} + +const fn sin_rad(x: f64) -> f64 { + cos_rad(x - PI / 2.0) +} + +/// The sRGB transfer curve, linear light to encoded. +/// +/// The exponent is `1/2.4`, which is `5/12` — so three exact roots stand in +/// for the `powf` that is not available here: `x^(5/12)` is the cube root of +/// the fourth root of `x⁵`. +const fn encode(x: f64) -> f64 { + if x <= 0.003_130_8 { + 12.92 * x + } else { + 1.055 * cbrt(sqrt(sqrt(x * x * x * x * x))) - 0.055 + } +} + +/// The same curve inverted, encoded to linear light. The exponent is `2.4`, +/// which is `2 + 2/5`, so a square and a fifth root of that square do it. +const fn decode(x: f64) -> f64 { + if x <= 0.040_45 { + x / 12.92 + } else { + let y = (x + 0.055) / 1.055; + let y2 = y * y; + y2 * fifth_root(y2) + } +} + +/// Linear light to one 8-bit channel, clamped: a color outside the sRGB gamut +/// is pinned to the nearest one that exists rather than wrapping into a +/// different hue entirely. +const fn channel(v: f64) -> u8 { + let v = if v < 0.0 { + 0.0 + } else if v > 1.0 { + 1.0 + } else { + v + }; + let s = encode(v) * 255.0 + 0.5; + if s <= 0.0 { + 0 + } else if s >= 255.0 { + 255 + } else { + s as u8 + } +} + +// --------------------------------------------------------------------------- +// OKLab / OKLCH +// --------------------------------------------------------------------------- + +/// Björn Ottosson's OKLab, converted to sRGB. +const fn from_oklab(l: f64, a: f64, b: f64) -> Color32 { + let l_ = l + 0.396_337_777_4 * a + 0.215_803_757_3 * b; + let m_ = l - 0.105_561_345_8 * a - 0.063_854_172_8 * b; + let s_ = l - 0.089_484_177_5 * a - 1.291_485_548_0 * b; + let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_); + Color32::from_rgb( + channel(4.076_741_662_1 * l3 - 3.307_711_591_3 * m3 + 0.230_969_929_2 * s3), + channel(-1.268_438_004_6 * l3 + 2.609_757_401_1 * m3 - 0.341_319_396_5 * s3), + channel(-0.004_196_086_3 * l3 - 0.703_418_614_7 * m3 + 1.707_614_701_0 * s3), + ) +} + +/// A `Color32`'s channels with its alpha divided back out, in 0..=1. +/// +/// `Color32` stores its channels premultiplied, and a premultiplied channel +/// is not a color — it is a color already faded toward whatever it will be +/// drawn on. Measuring one without undoing that reports every translucent +/// color as darker than it is. +const fn unmultiplied(c: Color32) -> (f64, f64, f64, f64) { + let a = c.a() as f64 / 255.0; + if a <= 0.0 { + return (0.0, 0.0, 0.0, 0.0); + } + ( + c.r() as f64 / 255.0 / a, + c.g() as f64 / 255.0 / a, + c.b() as f64 / 255.0 / a, + a, + ) +} + +/// The inverse: an sRGB color measured back into OKLab. Alpha is not part of +/// the answer — it is divided out first, so a translucent color reports the +/// color it is rather than the color it would blend to. +pub const fn to_oklab(color: Color32) -> (f64, f64, f64) { + let (sr, sg, sb, _) = unmultiplied(color); + let r = decode(sr); + let g = decode(sg); + let b = decode(sb); + let l = cbrt(0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b); + let m = cbrt(0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b); + let s = cbrt(0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b); + ( + 0.210_454_255_3 * l + 0.793_617_785_0 * m - 0.004_072_046_8 * s, + 1.977_998_495_1 * l - 2.428_592_205_0 * m + 0.450_593_709_9 * s, + 0.025_904_037_1 * l + 0.782_771_766_2 * m - 0.808_675_766_0 * s, + ) +} + +/// An sRGB color from its OKLCH coordinates: lightness `l` in 0..=1, chroma +/// `c` (roughly 0..=0.37 for colors sRGB can show), hue `h_deg` in degrees. +/// +/// Const, so a palette declared with it costs nothing at runtime. +pub const fn oklch(l: f64, c: f64, h_deg: f64) -> Color32 { + let h = h_deg * PI / 180.0; + from_oklab(l, c * cos_rad(h), c * sin_rad(h)) +} + +/// Blend two colors through OKLab, where the half-way point looks half-way. +/// Interpolating sRGB bytes instead darkens and desaturates the middle of +/// every fade. +/// +/// Opacity is blended too, and separately: a fade that ends on one of egui's +/// translucent theme colors has to actually arrive there, not at an opaque +/// impostor of it. The ends are returned untouched rather than round-tripped, +/// so `t` of 0 or 1 is exactly the color that was passed in. +pub const fn oklab_lerp(from: Color32, to: Color32, t: f32) -> Color32 { + if t <= 0.0 { + return from; + } + if t >= 1.0 { + return to; + } + let t = t as f64; + let (l1, a1, b1) = to_oklab(from); + let (l2, a2, b2) = to_oklab(to); + let blended = from_oklab(l1 + (l2 - l1) * t, a1 + (a2 - a1) * t, b1 + (b2 - b1) * t); + let alpha = from.a() as f64 + (to.a() as f64 - from.a() as f64) * t; + premultiply(blended, alpha / 255.0) +} + +/// Fold an opacity back into an opaque color, the way `Color32` stores it. +const fn premultiply(c: Color32, alpha: f64) -> Color32 { + Color32::from_rgba_premultiplied( + scale_channel(c.r(), alpha), + scale_channel(c.g(), alpha), + scale_channel(c.b(), alpha), + scale_channel(255, alpha), + ) +} + +const fn scale_channel(v: u8, alpha: f64) -> u8 { + let s = v as f64 * alpha + 0.5; + if s <= 0.0 { + 0 + } else if s >= 255.0 { + 255 + } else { + s as u8 + } +} + +/// Declare `Color32` constants from their OKLCH coordinates. +/// +/// The point of the macro is the shape of what it accepts: three numbers per +/// color, in the same order, so a palette reads as a table and an outlier in +/// it is visible on the page. +macro_rules! oklch_colors { + ($( + $(#[$attr:meta])* + $vis:vis const $name:ident = ($l:expr, $c:expr, $h:expr); + )+) => { + $( + $(#[$attr])* + $vis const $name: egui::Color32 = $crate::color::oklch($l, $c, $h); + )+ + }; +} + +// --------------------------------------------------------------------------- +// The palette +// --------------------------------------------------------------------------- + +/// Text lightness and chroma on the dark theme's near-black panels. The +/// chroma is as much as the *least* accommodating hue can carry at this +/// lightness (blue, which runs out first), because a palette is only uniform +/// if every member can actually reach the shared value. +const DARK_L: f64 = 0.75; +const DARK_C: f64 = 0.12; + +/// The same on the light theme's white. Yellow is the binding constraint +/// here, and it is why "yellow" on white is necessarily a gold: anything +/// brighter cannot clear the 4.5:1 contrast the rest of the palette holds. +const LIGHT_L: f64 = 0.52; +const LIGHT_C: f64 = 0.11; + +/// The rank chips paint their own background and lay near-black text over +/// it, so they are lighter than either text palette and share one set of +/// values across both themes. +const CHIP_L: f64 = 0.75; +const CHIP_C: f64 = 0.12; + +/// One hue per role family. Hue is the only thing that separates the colors +/// in a palette, so these are spread as evenly as five families and the +/// red-through-yellow crowding allow: the tightest neighbors are 40 degrees +/// apart, some three times the smallest difference the eye can find. +const HUE_RED: f64 = 20.0; +const HUE_ORANGE: f64 = 60.0; +const HUE_YELLOW: f64 = 100.0; +const HUE_GREEN: f64 = 150.0; +const HUE_BLUE: f64 = 250.0; + +oklch_colors! { + /// Errors, invalid patterns, and the query language's keywords. + const DARK_RED = (DARK_L, DARK_C, HUE_RED); + /// Manual mode, cautions, and edits staged but not yet applied. + const DARK_ORANGE = (DARK_L, DARK_C, HUE_ORANGE); + /// The walk half of an indexing run. + const DARK_YELLOW = (DARK_L, DARK_C, HUE_YELLOW); + /// The extraction half, valid patterns, and query operators. + const DARK_GREEN = (DARK_L, DARK_C, HUE_GREEN); + /// Finished work, the primary commit controls, and query arguments. + const DARK_BLUE = (DARK_L, DARK_C, HUE_BLUE); + + const LIGHT_RED = (LIGHT_L, LIGHT_C, HUE_RED); + const LIGHT_ORANGE = (LIGHT_L, LIGHT_C, HUE_ORANGE); + const LIGHT_YELLOW = (LIGHT_L, LIGHT_C, HUE_YELLOW); + const LIGHT_GREEN = (LIGHT_L, LIGHT_C, HUE_GREEN); + const LIGHT_BLUE = (LIGHT_L, LIGHT_C, HUE_BLUE); +} + +/// The GUI's colors for one theme, named by hue rather than by job: each one +/// carries several jobs, and naming it for one of them would make the other +/// call sites read like accidents. +/// +/// | hue | status hint | query syntax | emphasis | +/// |-----|-------------|--------------|----------| +/// | red | — | keyword | invalid pattern | +/// | orange | manual idle | — | caution, staged edit | +/// | yellow | indexing | — | — | +/// | green | extracting text | operator | valid pattern | +/// | blue | done | argument | commit controls | +pub struct Palette { + pub red: Color32, + pub orange: Color32, + pub yellow: Color32, + pub green: Color32, + pub blue: Color32, +} + +/// The palette for the live theme. Read it as +/// `palette(ui.visuals().dark_mode)` and never cache the result: `[ui] +/// color_scheme` is applied without a restart (see +/// [`crate::app::apply_theme`]), so the theme can change between one frame +/// and the next. +pub fn palette(dark_mode: bool) -> Palette { + if dark_mode { + Palette { + red: DARK_RED, + orange: DARK_ORANGE, + yellow: DARK_YELLOW, + green: DARK_GREEN, + blue: DARK_BLUE, + } + } else { + Palette { + red: LIGHT_RED, + orange: LIGHT_ORANGE, + yellow: LIGHT_YELLOW, + green: LIGHT_GREEN, + blue: LIGHT_BLUE, + } + } +} + +// --------------------------------------------------------------------------- +// The rank ramp +// --------------------------------------------------------------------------- + +/// Rank chips run from blue at the strongest match to red at the weakest. +const RANK_HUE_BEST: f64 = 250.0; +const RANK_HUE_WORST: f64 = 25.0; + +/// Hue of rank tier `i`, counting from 0: an even sweep across the arc. +const fn rank_hue(i: usize) -> f64 { + RANK_HUE_BEST - (i as f64) * (RANK_HUE_BEST - RANK_HUE_WORST) / ((RANK_TIERS - 1) as f64) +} + +const RANK_TIERS: usize = 11; + +/// The chip colorbar: one lightness and chroma, hue doing all the work, so +/// no tier draws the eye harder than its neighbors and every chip holds the +/// same contrast against the near-black text printed on it. +const RANK_RAMP: [Color32; RANK_TIERS] = { + let mut ramp = [Color32::BLACK; RANK_TIERS]; + let mut i = 0; + while i < RANK_TIERS { + ramp[i] = oklch(CHIP_L, CHIP_C, rank_hue(i)); + i += 1; + } + ramp +}; + +/// The chip color for a hit's cascade stage. In tier order: name exact with +/// exact case, name exact any case, name substring exact case, name +/// substring any case, full text exact case, full text any case, fuzzy name, +/// fuzzy full text, path substring exact case, path substring any case, and +/// fuzzy path — which is also where every stage outside the cascade lands. +pub fn rank_tier_color(stage: u8) -> Color32 { + match stage { + 1..=10 => RANK_RAMP[stage as usize - 1], + _ => RANK_RAMP[RANK_TIERS - 1], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The same pipeline written against `std`, which is what the const + /// numerics above have to reproduce. + fn reference(l: f64, c: f64, h_deg: f64) -> Color32 { + let h = h_deg.to_radians(); + let (a, b) = (c * h.cos(), c * h.sin()); + let l_ = l + 0.3963377774 * a + 0.2158037573 * b; + let m_ = l - 0.1055613458 * a - 0.0638541728 * b; + let s_ = l - 0.0894841775 * a - 1.2914855480 * b; + let (l3, m3, s3) = (l_.powi(3), m_.powi(3), s_.powi(3)); + let f = |v: f64| { + let v = v.clamp(0.0, 1.0); + let g = if v <= 0.0031308 { + 12.92 * v + } else { + 1.055 * v.powf(1.0 / 2.4) - 0.055 + }; + (g * 255.0).round() as u8 + }; + Color32::from_rgb( + f(4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3), + f(-1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3), + f(-0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3), + ) + } + + /// WCAG relative luminance, for the contrast checks below. Deliberately + /// written from the specification rather than reusing [`decode`]: a test + /// that shares its arithmetic with the code it checks proves less. + fn luminance(c: Color32) -> f64 { + let f = |v: u8| { + let v = v as f64 / 255.0; + if v <= 0.04045 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * f(c.r()) + 0.7152 * f(c.g()) + 0.0722 * f(c.b()) + } + + fn contrast(a: Color32, b: Color32) -> f64 { + let (x, y) = (luminance(a), luminance(b)); + let (hi, lo) = if x > y { (x, y) } else { (y, x) }; + (hi + 0.05) / (lo + 0.05) + } + + fn oklch_of(c: Color32) -> (f64, f64, f64) { + let (l, a, b) = to_oklab(c); + ( + l, + (a * a + b * b).sqrt(), + b.atan2(a).to_degrees().rem_euclid(360.0), + ) + } + + /// The whole justification for hand-rolling `sqrt`, `cbrt` and `cos`: + /// they have to agree with `std` everywhere, not just on the palette. + /// Exact equality rather than a tolerance, because that is what the + /// numerics actually deliver — a channel that ever landed a step off + /// would be a real regression, not rounding. + #[test] + fn the_const_conversion_matches_std_across_the_whole_space() { + let mut l = 0.0; + while l <= 1.0001 { + let mut c = 0.0; + while c <= 0.31 { + let mut h = 0.0; + while h < 360.0 { + assert_eq!( + oklch(l, c, h), + reference(l, c, h), + "L={} C={} H={}", + l, + c, + h + ); + h += 3.0; + } + c += 0.01; + } + l += 0.025; + } + } + + #[test] + fn the_ends_of_the_scale_are_black_and_white() { + assert_eq!(oklch(0.0, 0.0, 0.0), Color32::BLACK); + assert_eq!(oklch(1.0, 0.0, 0.0), Color32::WHITE); + // A gray is a color with no chroma, whatever hue is named. + for h in [0.0, 90.0, 217.0, 359.0] { + let gray = oklch(0.6, 0.0, h); + assert_eq!(gray.r(), gray.g(), "not gray at H={}: {:?}", h, gray); + assert_eq!(gray.g(), gray.b(), "not gray at H={}: {:?}", h, gray); + } + } + + /// Asking for a color sRGB cannot show must give the nearest one it can, + /// not a wrapped byte in a different hue family. + #[test] + fn out_of_gamut_requests_clamp() { + for (l, c, h) in [(0.9, 0.4, 250.0), (0.5, 0.35, 20.0), (1.2, 0.1, 150.0)] { + let color = oklch(l, c, h); + let _ = color; // reaching here at all means no panic and no wrap + assert_eq!(color, reference(l, c, h), "L={} C={} H={}", l, c, h); + } + assert_eq!(oklch(2.0, 0.0, 0.0), Color32::WHITE); + assert_eq!(oklch(-1.0, 0.0, 0.0), Color32::BLACK); + } + + /// `to_oklab` is the inverse of `oklch`, to within the 8 bits a channel + /// has to hold the answer in. + #[test] + fn measuring_a_color_recovers_what_was_asked_for() { + for (l, c, h) in [ + (DARK_L, DARK_C, HUE_RED), + (DARK_L, DARK_C, HUE_BLUE), + (LIGHT_L, LIGHT_C, HUE_YELLOW), + (CHIP_L, CHIP_C, HUE_GREEN), + ] { + let (ml, mc, mh) = oklch_of(oklch(l, c, h)); + assert!((ml - l).abs() < 0.005, "L {} vs {}", ml, l); + assert!((mc - c).abs() < 0.005, "C {} vs {}", mc, c); + assert!((mh - h).abs() < 1.5, "H {} vs {}", mh, h); + } + } + + #[test] + fn a_blend_keeps_its_endpoints() { + let (a, b) = (DARK_RED, LIGHT_BLUE); + assert_eq!(oklab_lerp(a, b, 0.0), a); + assert_eq!(oklab_lerp(a, b, 1.0), b); + // Out-of-range t clamps rather than extrapolating off the scale. + assert_eq!(oklab_lerp(a, b, -0.5), a); + assert_eq!(oklab_lerp(a, b, 1.5), b); + // The midpoint is genuinely between the two, not darkened the way an + // sRGB byte lerp leaves it. + let mid = oklab_lerp(a, b, 0.5); + let (l, _, _) = to_oklab(mid); + let (la, _, _) = to_oklab(a); + let (lb, _, _) = to_oklab(b); + assert!( + l > la.min(lb) - 0.01 && l < la.max(lb) + 0.01, + "midpoint lightness {} is outside [{}, {}]", + l, + la, + lb + ); + } + + /// The claim the palette makes: within a theme, only hue varies. Anything + /// else and one hint would read as more urgent than another for no + /// reason the user could name. + #[test] + fn each_theme_is_one_lightness_and_one_chroma() { + for (dark, l, c) in [(true, DARK_L, DARK_C), (false, LIGHT_L, LIGHT_C)] { + let p = palette(dark); + for (name, color) in [ + ("red", p.red), + ("orange", p.orange), + ("yellow", p.yellow), + ("green", p.green), + ("blue", p.blue), + ] { + let (ml, mc, _) = oklch_of(color); + assert!( + (ml - l).abs() < 0.005, + "{} in dark={} has L={}, palette is {}", + name, + dark, + ml, + l + ); + assert!( + (mc - c).abs() < 0.005, + "{} in dark={} has C={}, palette is {}", + name, + dark, + mc, + c + ); + } + } + } + + /// With L and C shared, hue is the only thing telling two colors apart, + /// so the spacing is the whole design. 40 degrees is the tightest pair + /// (red to orange, orange to yellow) and is several times the smallest + /// hue difference the eye resolves. + #[test] + fn no_two_colors_are_closer_than_forty_degrees() { + let hues = [HUE_RED, HUE_ORANGE, HUE_YELLOW, HUE_GREEN, HUE_BLUE]; + for (i, a) in hues.iter().enumerate() { + for b in &hues[i + 1..] { + let d = (a - b).abs(); + let d = if d > 180.0 { 360.0 - d } else { d }; + assert!(d >= 40.0, "{} and {} are {} degrees apart", a, b, d); + } + } + } + + /// Readability is the constraint that fixed the lightness of each theme, + /// so it is checked rather than assumed — against the panel the status + /// bar paints on and the text field the query colors paint on. + #[test] + fn every_color_clears_wcag_aa_on_its_own_background() { + for (dark, bgs) in [ + ( + true, + [ + egui::Visuals::dark().panel_fill, + egui::Visuals::dark().extreme_bg_color, + ], + ), + ( + false, + [ + egui::Visuals::light().panel_fill, + egui::Visuals::light().extreme_bg_color, + ], + ), + ] { + let p = palette(dark); + for (name, color) in [ + ("red", p.red), + ("orange", p.orange), + ("yellow", p.yellow), + ("green", p.green), + ("blue", p.blue), + ] { + for bg in bgs { + let ratio = contrast(color, bg); + assert!( + ratio >= 4.5, + "{} in dark={} is {:.2}:1 on {:?}", + name, + dark, + ratio, + bg + ); + } + } + } + } + + /// The chips read as a colorbar: hue marching one way from blue to red, + /// nothing else moving. The old ramp asserted this channel by channel, + /// which a real hue sweep cannot satisfy — red dips as the sweep passes + /// through cyan — so the claim is made where it actually lives. + #[test] + fn the_rank_ramp_is_an_even_sweep_from_blue_to_red() { + let mut prev: Option = None; + for (i, color) in RANK_RAMP.iter().enumerate() { + let (l, c, h) = oklch_of(*color); + assert!((l - CHIP_L).abs() < 0.005, "tier {} has L={}", i, l); + assert!((c - CHIP_C).abs() < 0.005, "tier {} has C={}", i, c); + if let Some(prev) = prev { + assert!(h < prev, "tier {} turned back at H={} from {}", i, h, prev); + } + prev = Some(h); + } + let (_, _, first) = oklch_of(RANK_RAMP[0]); + let (_, _, last) = oklch_of(RANK_RAMP[RANK_TIERS - 1]); + assert!( + (first - RANK_HUE_BEST).abs() < 1.5, + "best tier at H={}", + first + ); + assert!( + (last - RANK_HUE_WORST).abs() < 1.5, + "worst tier at H={}", + last + ); + } + + /// The chips carry fixed near-black text, so every tier has to stay light + /// enough to hold it — the reason the ramp has its own lightness. + #[test] + fn every_chip_holds_its_dark_text() { + let text = Color32::from_rgb(32, 32, 32); + for stage in 0..=13u8 { + let ratio = contrast(rank_tier_color(stage), text); + assert!(ratio >= 6.5, "stage {} is {:.2}:1", stage, ratio); + } + } + + /// Stages outside the cascade share the weakest tier's chip — 0 and 11 + /// and up all land there, as they did before the ramp moved here. + #[test] + fn stages_outside_the_cascade_take_the_last_chip() { + let worst = RANK_RAMP[RANK_TIERS - 1]; + assert_eq!(rank_tier_color(11), worst); + assert_eq!(rank_tier_color(12), worst); + assert_eq!(rank_tier_color(255), worst); + assert_eq!(rank_tier_color(0), worst); + for stage in 1..=10u8 { + assert_eq!(rank_tier_color(stage), RANK_RAMP[stage as usize - 1]); + } + } +} diff --git a/crates/quicksearch-gui/src/hotkey/binding.rs b/crates/quicksearch-gui/src/hotkey/binding.rs new file mode 100644 index 0000000..8a85d4f --- /dev/null +++ b/crates/quicksearch-gui/src/hotkey/binding.rs @@ -0,0 +1,408 @@ +//! The one representation of a shortcut, and the three spellings it has to +//! produce. +//! +//! A shortcut is written in three different vocabularies before it reaches an +//! operating system: the text in `config.toml` and on the Options button, the +//! token `global-hotkey` parses for `RegisterHotKey`/`XGrabKey`, and the +//! xkbcommon keysym name the XDG *shortcuts* specification wants for the +//! Wayland portal. All three come out of [`KEYS`], so a key cannot be +//! spelled correctly for one backend and wrongly for the other. +//! +//! The config text and the `global-hotkey` token are deliberately the same +//! string: every token below is one `global-hotkey`'s parser accepts, which +//! [`tokens_are_parseable`](tests::tokens_are_parseable) holds it to. + +use std::fmt; +use std::str::FromStr; + +use egui::Key; + +/// One row per bindable key: what egui reports when it is pressed, the token +/// used in the config file and by `global-hotkey`, and the xkbcommon keysym +/// name (`XKB_KEY_` stripped) the shortcuts spec wants. +/// +/// Not every `egui::Key` is here. Modifiers have no rows because they cannot +/// be a shortcut's main key, and the ones egui synthesises from a character +/// rather than a physical key (`Plus`, `Colon`, `Pipe`, `Questionmark`, the +/// curly brackets) are left out because they are the shifted face of a key +/// that already has a row: binding both would mean the same physical press +/// registering under two names. +const KEYS: &[(Key, &str, &str)] = &[ + (Key::A, "A", "a"), + (Key::B, "B", "b"), + (Key::C, "C", "c"), + (Key::D, "D", "d"), + (Key::E, "E", "e"), + (Key::F, "F", "f"), + (Key::G, "G", "g"), + (Key::H, "H", "h"), + (Key::I, "I", "i"), + (Key::J, "J", "j"), + (Key::K, "K", "k"), + (Key::L, "L", "l"), + (Key::M, "M", "m"), + (Key::N, "N", "n"), + (Key::O, "O", "o"), + (Key::P, "P", "p"), + (Key::Q, "Q", "q"), + (Key::R, "R", "r"), + (Key::S, "S", "s"), + (Key::T, "T", "t"), + (Key::U, "U", "u"), + (Key::V, "V", "v"), + (Key::W, "W", "w"), + (Key::X, "X", "x"), + (Key::Y, "Y", "y"), + (Key::Z, "Z", "z"), + (Key::Num0, "0", "0"), + (Key::Num1, "1", "1"), + (Key::Num2, "2", "2"), + (Key::Num3, "3", "3"), + (Key::Num4, "4", "4"), + (Key::Num5, "5", "5"), + (Key::Num6, "6", "6"), + (Key::Num7, "7", "7"), + (Key::Num8, "8", "8"), + (Key::Num9, "9", "9"), + (Key::F1, "F1", "F1"), + (Key::F2, "F2", "F2"), + (Key::F3, "F3", "F3"), + (Key::F4, "F4", "F4"), + (Key::F5, "F5", "F5"), + (Key::F6, "F6", "F6"), + (Key::F7, "F7", "F7"), + (Key::F8, "F8", "F8"), + (Key::F9, "F9", "F9"), + (Key::F10, "F10", "F10"), + (Key::F11, "F11", "F11"), + (Key::F12, "F12", "F12"), + (Key::Space, "Space", "space"), + (Key::Enter, "Enter", "Return"), + (Key::Tab, "Tab", "Tab"), + (Key::Backspace, "Backspace", "BackSpace"), + (Key::Delete, "Delete", "Delete"), + (Key::Insert, "Insert", "Insert"), + (Key::Home, "Home", "Home"), + (Key::End, "End", "End"), + (Key::PageUp, "PageUp", "Prior"), + (Key::PageDown, "PageDown", "Next"), + (Key::ArrowUp, "Up", "Up"), + (Key::ArrowDown, "Down", "Down"), + (Key::ArrowLeft, "Left", "Left"), + (Key::ArrowRight, "Right", "Right"), + (Key::Comma, "Comma", "comma"), + (Key::Period, "Period", "period"), + (Key::Slash, "Slash", "slash"), + (Key::Backslash, "Backslash", "backslash"), + (Key::Semicolon, "Semicolon", "semicolon"), + (Key::Quote, "Quote", "apostrophe"), + (Key::Backtick, "Backquote", "grave"), + (Key::Minus, "Minus", "minus"), + (Key::Equals, "Equal", "equal"), + (Key::OpenBracket, "BracketLeft", "bracketleft"), + (Key::CloseBracket, "BracketRight", "bracketright"), +]; + +/// Escape is reserved: it cancels the Options window's capture, and a +/// system-wide Escape would be unusable anyway. +const RESERVED: &[Key] = &[Key::Escape]; + +/// A shortcut the user can press from anywhere. +/// +/// Super/Meta is absent because `egui::Modifiers` has no field for it — egui +/// reports alt, ctrl, shift and the Mac command key only — so a Super combo +/// could never be captured in the Options window even if a backend could +/// register it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Binding { + pub ctrl: bool, + pub alt: bool, + pub shift: bool, + key: Key, +} + +/// Why a string or a key press is not a usable shortcut. The wording is what +/// the Options window shows, so it is written for the person who typed it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BindingError { + Empty, + NoModifier, + /// Modifiers only, as in `Ctrl+Shift`. + NoKey, + UnknownToken(String), + /// More than one non-modifier token, as in `Ctrl+A+B`. + TwoKeys, +} + +impl fmt::Display for BindingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BindingError::Empty => write!(f, "no shortcut"), + BindingError::NoModifier => { + write!(f, "needs at least one of Ctrl, Alt or Shift") + } + BindingError::NoKey => write!(f, "needs a key, not just modifiers"), + BindingError::UnknownToken(t) => write!(f, "{:?} is not a key name", t), + BindingError::TwoKeys => write!(f, "only one key, plus modifiers"), + } + } +} + +impl Binding { + /// Build from a key press egui reported, for the Options window's capture + /// widget. `None` for a press that cannot be a shortcut: a key with no + /// row in [`KEYS`], a reserved key, or a bare key with no modifier held. + /// + /// egui never reports a modifier on its own as a `Key`, so a press that + /// arrives here is always a real main key. + pub fn from_egui(key: Key, modifiers: &egui::Modifiers) -> Option { + if RESERVED.contains(&key) || !KEYS.iter().any(|(k, _, _)| *k == key) { + return None; + } + let binding = Binding { + ctrl: modifiers.ctrl, + alt: modifiers.alt, + shift: modifiers.shift, + key, + }; + binding.has_modifier().then_some(binding) + } + + fn has_modifier(&self) -> bool { + self.ctrl || self.alt || self.shift + } + + fn row(&self) -> (&'static str, &'static str) { + KEYS.iter() + .find(|(k, _, _)| *k == self.key) + // `key` is only ever set from a KEYS row. + .map(|(_, token, keysym)| (*token, *keysym)) + .expect("every Binding key comes from KEYS") + } + + /// The trigger in the XDG *shortcuts* spec's syntax, which the Wayland + /// portal takes as a preferred binding: uppercase modifier names and an + /// xkbcommon keysym, joined with `+`. + pub fn portal_trigger(&self) -> String { + let mut out = String::new(); + for (held, name) in [ + (self.ctrl, "CTRL"), + (self.alt, "ALT"), + (self.shift, "SHIFT"), + ] { + if held { + out.push_str(name); + out.push('+'); + } + } + out.push_str(self.row().1); + out + } +} + +impl fmt::Display for Binding { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (held, name) in [ + (self.ctrl, "Ctrl"), + (self.alt, "Alt"), + (self.shift, "Shift"), + ] { + if held { + write!(f, "{}+", name)?; + } + } + f.write_str(self.row().0) + } +} + +impl FromStr for Binding { + type Err = BindingError; + + fn from_str(s: &str) -> Result { + if s.trim().is_empty() { + return Err(BindingError::Empty); + } + let mut binding = Binding { + ctrl: false, + alt: false, + shift: false, + key: Key::A, + }; + let mut key = None; + for raw in s.split('+') { + let token = raw.trim(); + match token.to_ascii_uppercase().as_str() { + "" => return Err(BindingError::UnknownToken(token.to_string())), + "CTRL" | "CONTROL" => binding.ctrl = true, + "ALT" => binding.alt = true, + "SHIFT" => binding.shift = true, + upper => { + if key.is_some() { + return Err(BindingError::TwoKeys); + } + key = Some( + KEYS.iter() + .find(|(_, t, _)| t.eq_ignore_ascii_case(upper)) + .map(|(k, _, _)| *k) + .ok_or_else(|| BindingError::UnknownToken(token.to_string()))?, + ); + } + } + } + binding.key = key.ok_or(BindingError::NoKey)?; + if !binding.has_modifier() { + return Err(BindingError::NoModifier); + } + Ok(binding) + } +} + +/// Parse a config value, where empty means "no shortcut" rather than an error. +pub fn parse_setting(setting: &str) -> Result, BindingError> { + if setting.trim().is_empty() { + return Ok(None); + } + setting.parse().map(Some) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_default_setting_parses() { + let cfg = quicksearch_core::config::UiConfig::default(); + let binding: Binding = cfg.search_hotkey.parse().expect("the default is valid"); + assert_eq!(binding.to_string(), "Ctrl+Shift+F"); + assert_eq!(binding.portal_trigger(), "CTRL+SHIFT+f"); + } + + /// Round-trips through the text that ends up in `config.toml`. Every row + /// has to survive it, since any of them can be captured in the Options + /// window and written out. + #[test] + fn every_key_round_trips() { + for (key, token, _) in KEYS { + let binding = Binding { + ctrl: true, + alt: false, + shift: false, + key: *key, + }; + let text = binding.to_string(); + assert_eq!(text, format!("Ctrl+{}", token)); + assert_eq!(text.parse::(), Ok(binding), "{text} did not parse"); + } + } + + /// The config token and the `global-hotkey` token are the same string, so + /// this is what stops a typo in [`KEYS`] from reaching `RegisterHotKey` as + /// a silent registration failure. + #[test] + fn tokens_are_parseable_by_global_hotkey() { + for (_, token, _) in KEYS { + let text = format!("Ctrl+{}", token); + assert!( + text.parse::().is_ok(), + "global-hotkey rejected {text:?}" + ); + } + } + + /// Distinct rows must stay distinct in both output vocabularies: two keys + /// sharing a keysym would silently bind the wrong one on Wayland. + #[test] + fn rows_are_unique() { + for (i, (key, token, keysym)) in KEYS.iter().enumerate() { + for (other_key, other_token, other_keysym) in &KEYS[i + 1..] { + assert_ne!(key, other_key, "{token} and {other_token} share a key"); + assert_ne!(token, other_token, "duplicate token {token}"); + assert_ne!(keysym, other_keysym, "duplicate keysym {keysym}"); + } + } + } + + #[test] + fn modifiers_are_ordered_and_case_insensitive() { + let binding: Binding = "shift+ALT+ctrl+f".parse().unwrap(); + assert_eq!(binding.to_string(), "Ctrl+Alt+Shift+F"); + assert_eq!(binding.portal_trigger(), "CTRL+ALT+SHIFT+f"); + assert_eq!( + " Ctrl + Shift + F ".parse::(), + Ok(binding_of("Ctrl+Shift+F")) + ); + } + + fn binding_of(s: &str) -> Binding { + s.parse().unwrap() + } + + #[test] + fn bad_settings_are_rejected() { + assert_eq!("F".parse::(), Err(BindingError::NoModifier)); + assert_eq!("Ctrl".parse::(), Err(BindingError::NoKey)); + assert_eq!("Ctrl+Shift".parse::(), Err(BindingError::NoKey)); + assert_eq!("Ctrl+A+B".parse::(), Err(BindingError::TwoKeys)); + assert_eq!( + "Ctrl+".parse::(), + Err(BindingError::UnknownToken(String::new())) + ); + assert_eq!( + "Ctrl+Nope".parse::(), + Err(BindingError::UnknownToken("Nope".to_string())) + ); + assert_eq!("".parse::(), Err(BindingError::Empty)); + // Escape has to stay free for the capture widget's own cancel. + assert_eq!( + "Ctrl+Escape".parse::(), + Err(BindingError::UnknownToken("Escape".to_string())) + ); + } + + #[test] + fn an_empty_setting_is_no_shortcut_not_an_error() { + assert_eq!(parse_setting(""), Ok(None)); + assert_eq!(parse_setting(" "), Ok(None)); + assert_eq!( + parse_setting("Ctrl+Shift+F"), + Ok(Some(binding_of("Ctrl+Shift+F"))) + ); + assert!(parse_setting("Ctrl+Nope").is_err()); + } + + #[test] + fn capture_needs_a_modifier_and_a_known_key() { + let ctrl = egui::Modifiers { + ctrl: true, + command: true, + ..Default::default() + }; + assert_eq!( + Binding::from_egui(Key::F, &ctrl), + Some(binding_of("Ctrl+F")) + ); + assert_eq!( + Binding::from_egui(Key::F, &egui::Modifiers::default()), + None + ); + assert_eq!(Binding::from_egui(Key::Escape, &ctrl), None); + // Not in KEYS: the shifted face of a key that already has a row. + assert_eq!(Binding::from_egui(Key::Plus, &ctrl), None); + } + + /// egui sets `command` alongside `ctrl` off Mac; it must not double up + /// into a second modifier. + #[test] + fn the_egui_command_alias_is_ignored() { + let modifiers = egui::Modifiers { + ctrl: true, + command: true, + shift: true, + ..Default::default() + }; + assert_eq!( + Binding::from_egui(Key::F, &modifiers).map(|b| b.to_string()), + Some("Ctrl+Shift+F".to_string()) + ); + } +} diff --git a/crates/quicksearch-gui/src/hotkey/mod.rs b/crates/quicksearch-gui/src/hotkey/mod.rs new file mode 100644 index 0000000..d8c4d52 --- /dev/null +++ b/crates/quicksearch-gui/src/hotkey/mod.rs @@ -0,0 +1,306 @@ +//! The system-wide shortcut that raises QuickSearch and focuses the search +//! box. +//! +//! It has to be registered with the operating system rather than handled as +//! an egui shortcut, because the whole point is that it works when the window +//! is minimised, behind something else, or not focused — none of which +//! deliver key events to the app. There are two ways to get one, chosen by +//! what the session is: +//! +//! * **Windows and X11** let an application claim a key for itself +//! (`RegisterHotKey`, `XGrabKey`), which `global-hotkey` wraps. The key is +//! exactly the one that was asked for, or the registration fails. +//! * **Wayland** does not, on purpose, so the shortcut goes through the XDG +//! desktop portal instead and the *desktop* owns the binding. See +//! [`portal`]. +//! +//! Both are driven from here, through one interface, so the rest of the app +//! only ever deals with "did the shortcut fire" and "what should the Options +//! window say about it". +//! +//! # Why this is a global rather than a field +//! +//! The registration is process-wide however it is made, and +//! `GlobalHotKeyEvent::set_event_handler` is itself a set-once global. On +//! Windows the manager owns a hidden message window, so it is not `Send` and +//! has to stay on the thread that runs the winit event loop — the same thread +//! every caller below is already on. The alternative, threading a handle from +//! [`crate::main`] through [`crate::unlock::Gate`] into +//! [`crate::app::QuickSearchApp`], has to survive the app being *built +//! mid-session* when a password unlocks the index, and buys nothing for it. +//! +//! Every entry point is inert until [`init`] runs, so the headless UI tests +//! never touch an OS registration. + +mod binding; +#[cfg(all(unix, not(target_os = "macos")))] +mod portal; +mod raise; + +pub use binding::{parse_setting, Binding}; +pub use raise::raise; + +use std::cell::RefCell; +use std::sync::atomic::{AtomicBool, Ordering}; + +use global_hotkey::hotkey::HotKey; +use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; + +/// Set from whichever thread the shortcut arrives on, consumed by the UI +/// thread in [`take_fired`]. A flag rather than a queue: two presses before +/// the app can redraw mean the same thing as one. +static FIRED: AtomicBool = AtomicBool::new(false); + +thread_local! { + /// UI-thread only. See the module docs for why it is not a field. + static REGISTRY: RefCell> = const { RefCell::new(None) }; +} + +/// What the Options window says about the shortcut. Every variant is +/// something the user can act on, which is why "registered but the desktop +/// picked the key" is not folded into [`Status::Active`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Status { + /// The setting is empty: no shortcut, by choice. + Disabled, + /// Registered with the display server, exactly as asked. + Active, + /// Asked for, and the desktop has not answered yet. + Pending, + /// Wayland: registered, described in the desktop's own words because the + /// desktop, not the setting, decides the key. + PortalBound(String), + /// It is not going to work, and this says why. + Error(String), +} + +struct Registry { + backend: Backend, + /// The status of everything except the portal, which reports its own + /// asynchronously; see [`status`]. + status: Status, +} + +enum Backend { + /// Nothing registered: no shortcut set, or the backend never started. + Idle, + /// Windows and X11. + Grab { + manager: GlobalHotKeyManager, + /// The registration currently held, to be released before the next. + registered: Option, + }, + #[cfg(all(unix, not(target_os = "macos")))] + Portal(portal::Portal), +} + +/// Start the shortcut and register `setting`. +/// +/// Must be called on the thread running the event loop, and only from there: +/// on Windows `GlobalHotKeyManager` creates a hidden window whose messages +/// that loop is what dispatches. In practice that means eframe's app-creation +/// closure, which runs on the main thread with the loop already going. +pub fn init(ctx: &egui::Context, setting: &str) { + // Set once for the process, so it goes here rather than next to the + // manager, which comes and goes with the backend. + // Press only: the crate reports the release as a second event, and + // acting on both means every press of the shortcut does its work twice. + let repaint = ctx.clone(); + GlobalHotKeyEvent::set_event_handler(Some(move |event: GlobalHotKeyEvent| { + if event.state == HotKeyState::Pressed { + fire(&repaint); + } + })); + + let backend = match choose_backend(ctx) { + Ok(backend) => backend, + Err(message) => { + quicksearch_core::log_warn!("global shortcut: {}", message); + REGISTRY.with_borrow_mut(|slot| { + *slot = Some(Registry { + backend: Backend::Idle, + status: Status::Error(message), + }); + }); + return; + } + }; + REGISTRY.with_borrow_mut(|slot| { + *slot = Some(Registry { + backend, + status: Status::Disabled, + }) + }); + apply(setting); +} + +/// Register `setting`, releasing whatever was registered before. Empty means +/// no shortcut. An unparseable or refused shortcut is reported through +/// [`status`], never by failing: a shortcut is not worth blocking a config +/// the user has already applied. +pub fn apply(setting: &str) { + REGISTRY.with_borrow_mut(|slot| { + let Some(registry) = slot.as_mut() else { + return; + }; + let wanted = match parse_setting(setting) { + Ok(binding) => binding, + Err(e) => { + registry.status = Status::Error(format!("{:?} is not a shortcut: {}", setting, e)); + // Releasing cannot fail in a way worth a second message. + let _ = registry.backend.register(None); + return; + } + }; + registry.status = match registry.backend.register(wanted) { + Ok(()) if wanted.is_some() => Status::Active, + Ok(()) => Status::Disabled, + Err(e) => Status::Error(e), + }; + // In the Logs tab, because a shortcut that quietly does nothing is + // otherwise impossible to tell apart from one that was never asked + // for. The Options window says the same thing, but only while it is + // open, and only about the state it left behind. + match (®istry.status, wanted) { + (Status::Active, Some(binding)) => { + quicksearch_core::log_info!("global shortcut: {} registered", binding) + } + (Status::Error(why), _) => quicksearch_core::log_warn!("global shortcut: {}", why), + _ => {} + } + }); +} + +/// Whether the shortcut was pressed since this was last asked, clearing it. +pub fn take_fired() -> bool { + FIRED.swap(false, Ordering::SeqCst) +} + +/// What to tell the user about the shortcut right now. +pub fn status() -> Status { + REGISTRY.with_borrow(|slot| match slot.as_ref() { + None => Status::Disabled, + // The portal answers on its own schedule, so it keeps its own status + // and this one is stale the moment a bind is sent. + #[cfg(all(unix, not(target_os = "macos")))] + Some(Registry { + backend: Backend::Portal(portal), + .. + }) => portal.status(), + Some(registry) => registry.status.clone(), + }) +} + +/// Record a press and wake the UI. The repaint is the load-bearing half: +/// with nothing happening on screen the app is idle, and a minimised window +/// is not drawing at all, so without it the flag would sit unread until +/// something else asked for a frame. +fn fire(ctx: &egui::Context) { + FIRED.store(true, Ordering::SeqCst); + ctx.request_repaint(); +} + +impl Backend { + /// Hold `wanted` and nothing else. `None` releases without registering. + fn register(&mut self, wanted: Option) -> Result<(), String> { + match self { + Backend::Idle => Ok(()), + Backend::Grab { + manager, + registered, + } => { + if let Some(old) = registered.take() { + // A failed unregister leaves a key claimed that nothing + // listens for any more. Worth reporting, but not worth + // refusing the new binding over. + if let Err(e) = manager.unregister(old) { + quicksearch_core::log_warn!("releasing the old global shortcut: {}", e); + } + } + let Some(binding) = wanted else { + return Ok(()); + }; + // Infallible in practice: `Binding`'s tokens are held to + // being parseable by a test, precisely so this cannot be a + // silent runtime failure. + let hotkey: HotKey = binding + .to_string() + .parse() + .map_err(|e| format!("{} is not a usable shortcut: {}", binding, e))?; + manager.register(hotkey).map_err(|e| match e { + global_hotkey::Error::AlreadyRegistered(_) => { + format!("another application is already using {}", binding) + } + other => format!("{} could not be registered: {}", binding, other), + })?; + *registered = Some(hotkey); + Ok(()) + } + #[cfg(all(unix, not(target_os = "macos")))] + Backend::Portal(portal) => { + portal.bind(wanted.map(|b| b.portal_trigger())); + Ok(()) + } + } + } +} + +/// Wayland refuses key grabs by design, so a session with a Wayland display +/// gets the portal and everything else gets a grab. There is deliberately no +/// falling back from one to the other: an X11 grab made from inside a Wayland +/// session succeeds and then only ever fires while an XWayland window has +/// focus, which looks like a broken shortcut rather than an unavailable one. +#[cfg(all(unix, not(target_os = "macos")))] +fn choose_backend(ctx: &egui::Context) -> Result { + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + return Ok(Backend::Portal(portal::Portal::new(ctx))); + } + grab_backend() +} + +#[cfg(not(all(unix, not(target_os = "macos"))))] +fn choose_backend(_ctx: &egui::Context) -> Result { + grab_backend() +} + +fn grab_backend() -> Result { + GlobalHotKeyManager::new() + .map(|manager| Backend::Grab { + manager, + registered: None, + }) + .map_err(|e| format!("global shortcuts are unavailable: {}", e)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Nothing may touch an OS registration before `init`, so that the + /// headless UI tests can render the Options row. + #[test] + fn an_uninitialised_registry_is_inert() { + apply("Ctrl+Shift+F"); + assert_eq!(status(), Status::Disabled); + assert!(!take_fired()); + } + + #[test] + fn a_press_is_reported_once() { + FIRED.store(true, Ordering::SeqCst); + assert!(take_fired()); + assert!(!take_fired(), "the flag is consumed"); + } + + /// `Idle` stands in for a backend that never started; it must accept + /// every call rather than panic, since `apply` runs on every config save. + #[test] + fn an_idle_backend_accepts_everything() { + let mut backend = Backend::Idle; + assert_eq!(backend.register(None), Ok(())); + assert_eq!( + backend.register(Some("Ctrl+Shift+F".parse().unwrap())), + Ok(()) + ); + } +} diff --git a/crates/quicksearch-gui/src/hotkey/portal.rs b/crates/quicksearch-gui/src/hotkey/portal.rs new file mode 100644 index 0000000..1f5f7a2 --- /dev/null +++ b/crates/quicksearch-gui/src/hotkey/portal.rs @@ -0,0 +1,201 @@ +//! The Wayland half of the search shortcut: `org.freedesktop.portal.GlobalShortcuts`. +//! +//! Wayland deliberately gives an application no way to grab a key it does not +//! already have focus for, so the shortcut is registered with the desktop +//! instead and the desktop tells us when it fires. The consequence worth +//! knowing is that **the desktop owns the binding**: what we send is a +//! `preferred_trigger`, and the compositor is free to bind something else, to +//! ask the user first, or to let them change it later in its own settings. +//! What it actually bound comes back as a human-readable +//! `trigger_description`, which is what the Options window shows. +//! +//! All of this lives on its own thread. The portal is D-Bus, so every call +//! is a round trip that could block for as long as a dialog stays on screen, +//! and none of that may happen on the UI thread. The thread outlives the +//! binding: the session has to stay open for activations to keep arriving, +//! and dropping it is how a rebind starts over. + +use std::sync::{Arc, Mutex}; + +use ashpd::desktop::global_shortcuts::{GlobalShortcuts, NewShortcut}; +use ashpd::desktop::Session; +use futures_channel::mpsc; +use futures_util::future::{select, Either}; +use futures_util::StreamExt; + +use super::Status; + +/// Our only shortcut. The portal keys activations by this id, and it is what +/// a desktop's shortcut settings lists the entry under. +const SHORTCUT_ID: &str = "search"; + +/// Shown next to the key in the desktop's shortcut settings, so it is written +/// for someone reading a list of every app's shortcuts at once. +const SHORTCUT_DESCRIPTION: &str = "Focus the QuickSearch search box"; + +pub(super) struct Portal { + /// `Some(trigger)` binds, `None` unbinds. Unbounded because a send + /// happens on the UI thread and must never block it. + tx: mpsc::UnboundedSender>, + status: Arc>, +} + +impl Portal { + /// Start the portal thread. It runs until the process exits; there is + /// nothing to shut down, since the session's only resource is a D-Bus + /// connection the OS reclaims. + pub(super) fn new(ctx: &egui::Context) -> Portal { + let (tx, rx) = mpsc::unbounded(); + let status = Arc::new(Mutex::new(Status::Pending)); + let portal = Portal { + tx, + status: Arc::clone(&status), + }; + let ctx = ctx.clone(); + std::thread::Builder::new() + .name("quicksearch-hotkey-portal".to_string()) + .spawn(move || pollster::block_on(run(ctx, status, rx))) + // A thread that will not start is a shortcut that will not work, + // which is not worth taking the app down for. + .map_err(|e| quicksearch_core::log_warn!("global shortcut portal thread: {}", e)) + .ok(); + portal + } + + /// Ask for a new binding, or for none at all. Returns immediately; the + /// answer lands in [`Portal::status`] whenever the desktop gets to it. + pub(super) fn bind(&self, trigger: Option) { + *self.status.lock().unwrap() = match trigger { + Some(_) => Status::Pending, + None => Status::Disabled, + }; + let _ = self.tx.unbounded_send(trigger); + } + + pub(super) fn status(&self) -> Status { + self.status.lock().unwrap().clone() + } +} + +async fn run( + ctx: egui::Context, + status: Arc>, + mut commands: mpsc::UnboundedReceiver>, +) { + // `'static` throughout: the proxy owns its D-Bus connection, so nothing + // here borrows from a local, and pinning the lifetime keeps the session + // below from being tied to a borrow of `shortcuts` that a rebind would + // then have to end. + let shortcuts: GlobalShortcuts<'static> = match GlobalShortcuts::new().await { + Ok(s) => s, + Err(e) => return fail(&ctx, &status, unavailable(&e)), + }; + // Created once and kept for the life of the thread: it is a D-Bus signal + // match on the interface, not on a session, so it survives the rebinds + // below and is one less thing to get wrong when a session is replaced. + let activated = match shortcuts.receive_activated().await { + Ok(s) => s, + Err(e) => return fail(&ctx, &status, unavailable(&e)), + }; + futures_util::pin_mut!(activated); + + let mut session: Option>> = None; + loop { + match select(activated.next(), commands.next()).await { + Either::Left((Some(_), _)) => { + // Which shortcut it was does not need checking: this session + // has exactly one. + super::fire(&ctx); + } + // The portal went away (it was restarted, or the bus dropped). + // Nothing left to listen to, and the session is already dead. + Either::Left((None, _)) => { + return fail( + &ctx, + &status, + "the desktop's global shortcuts service stopped".to_string(), + ) + } + Either::Right((Some(trigger), _)) => { + // A rebind is a new session, not a second `BindShortcuts`: + // the portal treats a session's shortcuts as fixed once bound. + if let Some(old) = session.take() { + let _ = old.close().await; + } + let next = match &trigger { + None => { + set(&ctx, &status, Status::Disabled); + None + } + Some(trigger) => match bind(&shortcuts, trigger).await { + Ok((session, description)) => { + set(&ctx, &status, Status::PortalBound(description)); + Some(session) + } + Err(e) => { + fail(&ctx, &status, unavailable(&e)); + None + } + }, + }; + session = next; + } + // The registry dropped the sender, which only happens on the way + // out. + Either::Right((None, _)) => return, + } + } +} + +/// Open a session and bind the trigger, returning the desktop's own wording +/// for the key it settled on. +async fn bind( + shortcuts: &GlobalShortcuts<'static>, + trigger: &str, +) -> Result<(Session<'static, GlobalShortcuts<'static>>, String), ashpd::Error> { + let session = shortcuts.create_session().await?; + let shortcut = + NewShortcut::new(SHORTCUT_ID, SHORTCUT_DESCRIPTION).preferred_trigger(Some(trigger)); + let request = shortcuts + .bind_shortcuts(&session, &[shortcut], None) + .await?; + let bound = request.response()?; + // A desktop that binds the shortcut but describes it as nothing is not + // worth a special case: the preferred trigger is then the honest answer. + let description = bound + .shortcuts() + .iter() + .find(|s| s.id() == SHORTCUT_ID) + .map(|s| s.trigger_description().to_string()) + .filter(|d| !d.trim().is_empty()) + .unwrap_or_else(|| trigger.to_string()); + Ok((session, description)) +} + +/// Turn a portal failure into something worth putting in front of a user. +/// The distinction that matters is "this desktop cannot do it at all" versus +/// "it went wrong this time"; the rest is passed through. +fn unavailable(e: &ashpd::Error) -> String { + match e { + ashpd::Error::PortalNotFound(_) => { + "this desktop does not offer the global shortcuts portal".to_string() + } + ashpd::Error::RequiresVersion(required, found) => format!( + "this desktop's global shortcuts portal is version {}, and {} is needed", + found, required + ), + ashpd::Error::Response(_) => "the desktop declined the shortcut".to_string(), + other => other.to_string(), + } +} + +fn set(ctx: &egui::Context, status: &Mutex, next: Status) { + *status.lock().unwrap() = next; + // The Options window may be open and waiting for this. + ctx.request_repaint(); +} + +fn fail(ctx: &egui::Context, status: &Mutex, message: String) { + quicksearch_core::log_warn!("global shortcut: {}", message); + set(ctx, status, Status::Error(message)); +} diff --git a/crates/quicksearch-gui/src/hotkey/raise.rs b/crates/quicksearch-gui/src/hotkey/raise.rs new file mode 100644 index 0000000..d529c9b --- /dev/null +++ b/crates/quicksearch-gui/src/hotkey/raise.rs @@ -0,0 +1,94 @@ +//! Bringing the window to the front when the shortcut fires. +//! +//! Harder than it sounds, and for a good reason: every desktop stops +//! applications raising themselves over whatever the user is doing. The +//! request has to say *why*, and a global shortcut is a direct user action +//! rather than an application deciding it wants attention. +//! +//! What that means in practice differs per platform: +//! +//! * **Windows** refuses `SetForegroundWindow` to background processes, but +//! makes an explicit exception for a process whose registered hotkey was +//! just pressed. winit's `Minimized(false)` and `Focus` do the right thing, +//! as long as they happen straight away. +//! * **X11** is the awkward one. winit asks with `_NET_ACTIVE_WINDOW` and a +//! source indication of 1, "application", which KWin, Mutter and Xfwm all +//! refuse from an unfocused window: nothing happens, or the taskbar entry +//! blinks. Worse, winit's `focus_window` does nothing at all while the +//! window is minimised. So the request is sent here instead, with source +//! indication 2, which the EWMH spec defines as a client acting on a direct +//! user action and which window managers honour. That is exactly what this +//! is, and it is what every hotkey launcher does. +//! * **Wayland** does not let a client raise itself at all, by design. See +//! [`raise`]. + +/// Bring the window to the front, restoring it if it was minimised. +/// +/// On Wayland this asks and is ignored: raising requires an xdg-activation +/// token from the compositor, which winit will not issue without its own +/// `Window`, and eframe does not hand that out. The rest of the shortcut +/// still works there (the Search tab is selected and the query box gets the +/// caret), and the desktop's own window-management shortcuts are the way +/// back to the window. The Options window says so. +pub fn raise(ctx: &egui::Context, frame: &eframe::Frame) { + #[cfg(all(unix, not(target_os = "macos")))] + if x11_activate(frame) { + return; + } + #[cfg(not(all(unix, not(target_os = "macos"))))] + let _ = frame; + + // Un-minimising comes first: a window still minimised cannot take focus. + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); +} + +/// Ask the window manager to activate our window, EWMH style. `false` when +/// this is not an X11 session, or the X server would not take it, so the +/// caller can fall back to asking winit. +/// +/// A fresh connection per press rather than a kept one: this runs at most as +/// often as someone presses a key, the round trip is sub-millisecond, and a +/// cached connection would be one more thing to notice the X server going +/// away on. +#[cfg(all(unix, not(target_os = "macos")))] +fn x11_activate(frame: &eframe::Frame) -> bool { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use x11rb::connection::Connection; + use x11rb::protocol::xproto::{ClientMessageEvent, ConnectionExt, EventMask}; + + let Ok(handle) = frame.window_handle() else { + return false; + }; + // Wayland and everything else fall through to the caller's fallback. + let RawWindowHandle::Xlib(xlib) = handle.as_raw() else { + return false; + }; + let window = xlib.window as u32; + + let sent = || -> Result<(), Box> { + let (conn, screen) = x11rb::connect(None)?; + let root = conn.setup().roots[screen].root; + let atom = conn.intern_atom(true, b"_NET_ACTIVE_WINDOW")?.reply()?.atom; + // data: source indication, timestamp, the window losing focus. + // `CURRENT_TIME` because the shortcut arrives over D-Bus or a grab + // rather than as an X event we could take a timestamp from; window + // managers accept it from source 2. + let event = ClientMessageEvent::new(32, window, atom, [2, x11rb::CURRENT_TIME, 0, 0, 0]); + conn.send_event( + false, + root, + EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, + event, + )?; + conn.flush()?; + Ok(()) + }(); + match sent { + Ok(()) => true, + Err(e) => { + quicksearch_core::log_warn!("raising the window: {}", e); + false + } + } +} diff --git a/crates/quicksearch-gui/src/main.rs b/crates/quicksearch-gui/src/main.rs index 5105a0c..1ca6c87 100644 --- a/crates/quicksearch-gui/src/main.rs +++ b/crates/quicksearch-gui/src/main.rs @@ -14,9 +14,11 @@ mod backend; mod capture; #[cfg(not(windows))] mod cli; +mod color; mod duplicates_tab; mod format; mod help_tab; +mod hotkey; mod keychain; mod logs_tab; mod manage_tab; @@ -109,6 +111,16 @@ fn main() { "QuickSearch", native_options, Box::new(move |cc| { + // Here rather than earlier in `main`: on Windows the registration + // owns a hidden window whose messages the event loop has to + // dispatch, so it has to be made on that loop's thread with the + // loop already running. This closure is the first place that is + // true. Registering before the gate also means the shortcut works + // while the unlock screen is up. + hotkey::init(&cc.egui_ctx, &config.ui.search_hotkey); + // Before the gate so the unlock screen is not the one window that + // ignores the setting. + app::apply_theme(&cc.egui_ctx, &config.ui.color_scheme); let gate = match key_source { Some(source) => { unlock::Gate::running(&cc.egui_ctx, config, config_error, initial_query, source) diff --git a/crates/quicksearch-gui/src/manage_tab.rs b/crates/quicksearch-gui/src/manage_tab.rs index ab4c7d7..d6e8de5 100644 --- a/crates/quicksearch-gui/src/manage_tab.rs +++ b/crates/quicksearch-gui/src/manage_tab.rs @@ -465,15 +465,12 @@ impl ManageTab { ui.add_space(8.0); let dirty = self.is_dirty(); + let p = crate::color::palette(ui.visuals().dark_mode); ui.horizontal(|ui| { let apply = ui .add(crate::ui_util::bordered_button( "Apply & Save", - if dirty { - crate::ui_util::ORANGE - } else { - crate::ui_util::BLUE - }, + if dirty { p.orange } else { p.blue }, )) .tip(&tips::APPLY_SAVE); #[cfg(test)] @@ -485,7 +482,7 @@ impl ManageTab { ui.label( egui::RichText::new("Unsaved changes") .small() - .color(crate::ui_util::ORANGE), + .color(p.orange), ); } }); @@ -842,12 +839,13 @@ fn root_row(ui: &mut egui::Ui, r: &RootProgress) { let divider = |ui: &mut egui::Ui| { ui.label(egui::RichText::new("|").weak()); }; + let phase = crate::color::palette(ui.visuals().dark_mode); ui.horizontal(|ui| { ui.monospace(middle_truncate(&r.root, 48)); divider(ui); match r.phase { RootPhase::Walking => { - ui.label("indexing"); + ui.label(egui::RichText::new("indexing").color(phase.yellow)); divider(ui); let workers = format!("{}/{} workers", r.active_workers, r.total_workers); match r.walk_denominator() { @@ -877,7 +875,7 @@ fn root_row(ui: &mut egui::Ui, r: &RootProgress) { } } RootPhase::Extracting => { - ui.label("extracting text for search"); + ui.label(egui::RichText::new("extracting text").color(phase.green)); divider(ui); let frac = if r.extract_total > 0 { (r.extracted as f32 / r.extract_total as f32).clamp(0.0, 1.0) @@ -899,7 +897,7 @@ fn root_row(ui: &mut egui::Ui, r: &RootProgress) { // saw (including unchanged, skipped ones) and `extracted` // covers all rows with searchable text, not just this // run's new work. - ui.label("done"); + ui.label(egui::RichText::new("done").color(phase.blue)); divider(ui); ui.label(format!( "indexed {}, extracted {}", @@ -1267,6 +1265,48 @@ mod tests { ); } + /// Like [`frame_text`], but keeping the color each run of text was + /// painted in — the only way to check a hint. + fn frame_spans( + ctx: &egui::Context, + tab: &mut ManageTab, + state: &IndexerState, + ) -> Vec<(String, egui::Color32)> { + WIDGETS.with(|w| w.borrow_mut().clear()); + let cfg = cfg_with_root(); + let out = ctx.run(raw_input(vec![]), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + tab.ui(ui, state, &cfg); + }); + }); + crate::test_ui::painted_spans(&out) + } + + /// The phase word carries a color hint, so a glance at the row says what + /// the run is doing without reading it. The hint has to hold up in both + /// themes: `[ui] color_scheme` picks one, and changing it repaints the + /// running window. + #[test] + fn every_phase_word_is_painted_in_its_hint_color() { + for theme in [egui::Theme::Dark, egui::Theme::Light] { + let ctx = egui::Context::default(); + ctx.set_theme(theme); + let mut tab = ManageTab::new(); + let colors = crate::color::palette(theme == egui::Theme::Dark); + + for (phase, word, want) in [ + (RootPhase::Walking, "indexing", colors.yellow), + (RootPhase::Extracting, "extracting text", colors.green), + (RootPhase::Done, "done", colors.blue), + ] { + let state = state_with(vec![root_progress(phase, 100, Some(1000))]); + let spans = frame_spans(&ctx, &mut tab, &state); + let hint = spans.iter().find(|(text, _)| text == word).map(|(_, c)| *c); + assert_eq!(hint, Some(want), "{:?}: {:?} in {:?}", theme, word, spans); + } + } + } + /// No count has landed yet: an indeterminate row, not a fabricated one. #[test] fn a_walking_root_without_a_count_shows_no_denominator() { diff --git a/crates/quicksearch-gui/src/options.rs b/crates/quicksearch-gui/src/options.rs index 5146a0d..4add1ce 100644 --- a/crates/quicksearch-gui/src/options.rs +++ b/crates/quicksearch-gui/src/options.rs @@ -45,6 +45,8 @@ pub struct OptionsWindow { /// `use_keychain` preference it was probed under. keychain_probed_for: Option, keychain_active: bool, + /// The search-shortcut button is waiting for a key press to bind. + capturing_hotkey: bool, } impl OptionsWindow { @@ -54,9 +56,17 @@ impl OptionsWindow { draft: None, keychain_probed_for: None, keychain_active: false, + capturing_hotkey: false, } } + /// Whether the shortcut button is reading a key press right now, so the + /// app can hold the shortcut it is about to replace. See + /// [`crate::unlock::Gate::handle_hotkey`]. + pub fn capturing_hotkey(&self) -> bool { + self.open && self.capturing_hotkey + } + pub fn open_with(&mut self, current: &Config) { self.open = true; self.draft = Some(current.clone()); @@ -85,6 +95,7 @@ impl OptionsWindow { pub fn close_discard(&mut self) { self.open = false; self.draft = None; + self.capturing_hotkey = false; } /// Adopt the window's open flag for this frame. A dirty close is @@ -133,6 +144,7 @@ impl OptionsWindow { let mut open = self.open; let keychain_active = self.keychain_active(current); let dirty = self.is_dirty(current); + let capturing = &mut self.capturing_hotkey; let draft = self.draft.as_mut().unwrap(); egui::Window::new("Options") @@ -190,7 +202,14 @@ impl OptionsWindow { .fixed_decimals(2), ) }); + tip_row(ui, "Search shortcut", &tips::SEARCH_HOTKEY, |ui| { + hotkey_edit(ui, &mut draft.ui.search_hotkey, capturing) + }); + tip_row(ui, "Color scheme", &tips::COLOR_SCHEME, |ui| { + color_scheme_edit(ui, &mut draft.ui.color_scheme) + }); }); + hotkey_note(ui, &draft.ui.search_hotkey, ¤t.ui.search_hotkey); ui.separator(); // Security acts on the live config, not the draft: each @@ -203,15 +222,12 @@ impl OptionsWindow { crate::ui_util::more_below_hint(ui, &scroll); ui.separator(); + let p = crate::color::palette(ui.visuals().dark_mode); ui.horizontal(|ui| { let apply = ui .add(crate::ui_util::bordered_button( "Apply & Save", - if dirty { - crate::ui_util::ORANGE - } else { - crate::ui_util::BLUE - }, + if dirty { p.orange } else { p.blue }, )) .tip(&tips::APPLY_SAVE); if apply.clicked() { @@ -224,7 +240,7 @@ impl OptionsWindow { ui.label( egui::RichText::new("Unsaved changes") .small() - .color(crate::ui_util::ORANGE), + .color(p.orange), ); } }); @@ -245,6 +261,164 @@ impl OptionsWindow { } } +/// The color schemes, as stored and as shown. Stored lowercase so a +/// hand-edited config reads like the rest of the file. +const COLOR_SCHEMES: [(&str, &str); 2] = [("dark", "Dark"), ("light", "Light")]; + +/// What the dropdown shows for a stored value. +/// +/// Resolved through [`crate::app::theme_for`] rather than by looking the string +/// up, so the box says what the app will actually do with whatever is in the +/// config file — including a value it does not recognise, which is dark and +/// should read that way. +fn scheme_label(value: &str) -> &'static str { + match crate::app::theme_for(value) { + egui::Theme::Dark => "Dark", + egui::Theme::Light => "Light", + } +} + +/// The color scheme dropdown. Returns the box, which is what the row's +/// tooltip hangs off. +fn color_scheme_edit(ui: &mut egui::Ui, setting: &mut String) -> egui::Response { + egui::ComboBox::from_id_salt("cfg-color-scheme") + .selected_text(scheme_label(setting)) + .show_ui(ui, |ui| { + for (stored, label) in COLOR_SCHEMES { + ui.selectable_value(setting, stored.to_string(), label); + } + }) + .response +} + +/// The search shortcut's control: a button showing the current binding that +/// turns into a key-press reader when clicked, and a Clear beside it. +/// +/// A reader rather than a text field because the shortcut is a thing you +/// press, not a thing you spell, and because it keeps the only way to name a +/// key inside [`crate::hotkey`] where the two backends agree on it. +/// +/// Returns the button, which is what the row's tooltip hangs off. +fn hotkey_edit(ui: &mut egui::Ui, setting: &mut String, capturing: &mut bool) -> egui::Response { + let p = crate::color::palette(ui.visuals().dark_mode); + ui.horizontal(|ui| { + let label = if *capturing { + "Press a key combination...".to_string() + } else if setting.trim().is_empty() { + "None".to_string() + } else { + setting.clone() + }; + let button = ui.add(crate::ui_util::bordered_button( + label, + if *capturing { p.orange } else { p.blue }, + )); + // A second click backs out, so the button is never a one-way door. + if button.clicked() { + *capturing = !*capturing; + } else if *capturing { + match read_capture(ui) { + Some(Some(binding)) => { + *setting = binding.to_string(); + *capturing = false; + } + Some(None) => *capturing = false, + None => {} + } + } + if ui + .add_enabled( + !setting.trim().is_empty(), + egui::Button::new("Clear").small(), + ) + .clicked() + { + setting.clear(); + *capturing = false; + } + button + }) + .inner +} + +/// One frame of shortcut capture: `Some(Some(binding))` for a press worth +/// binding, `Some(None)` for a cancel, `None` while nothing usable has +/// arrived. +/// +/// Reads raw events rather than `egui::Ui::input_mut`'s shortcut matching, +/// which answers "was *this* combination pressed" and cannot report an +/// arbitrary one. Presses that are not a valid shortcut, such as a bare +/// letter, are ignored rather than treated as a cancel: they are almost +/// always someone reaching for the modifier a moment too late. +fn read_capture(ui: &egui::Ui) -> Option> { + ui.input(|i| { + for event in &i.events { + let egui::Event::Key { + key, + pressed: true, + modifiers, + .. + } = event + else { + continue; + }; + if *key == egui::Key::Escape { + return Some(None); + } + if let Some(binding) = crate::hotkey::Binding::from_egui(*key, modifiers) { + return Some(Some(binding)); + } + } + None + }) +} + +/// What the shortcut is really doing, under the Interface grid. +/// +/// Silent while it is registered and working: the button already says what +/// the key is, and a line confirming it would be noise on every other +/// setting's behalf. The cases worth a line are the ones where what is on +/// the button is not what is in force. +fn hotkey_note(ui: &mut egui::Ui, draft: &str, live: &str) { + use crate::hotkey::Status; + let (text, color) = if draft.trim() != live.trim() { + ("Not registered until Apply and Save.".to_string(), None) + } else { + match crate::hotkey::status() { + Status::Disabled | Status::Active => (String::new(), None), + Status::Pending => ( + "Waiting for your desktop to accept the shortcut.".to_string(), + None, + ), + Status::PortalBound(trigger) => ( + format!( + "Your desktop registered this as {}. It has the final say; \ + change it in its own keyboard settings. On Wayland it also \ + decides whether the window comes forward, so a minimised \ + window may stay minimised.", + trigger + ), + None, + ), + Status::Error(why) => ( + format!("The shortcut is not active: {}.", why), + Some(crate::color::palette(ui.visuals().dark_mode).orange), + ), + } + }; + // Comes and goes with the state; keep it off the ids of what follows. + crate::ui_util::stable_section(ui, |ui| { + if text.is_empty() { + return; + } + let rich = egui::RichText::new(text).small(); + ui.label(match color { + Some(color) => rich.color(color), + None => rich.weak(), + }); + }); +} + /// The Security block: status plus action buttons. Never renders the salt. fn security_ui( ui: &mut egui::Ui, @@ -521,7 +695,193 @@ mod tests { assert!(w.draft.is_none()); } - use crate::test_ui::{painted_text, painted_text_center}; + use crate::test_ui::{click_at, painted_text, painted_text_center}; + + /// One frame of the shortcut control on its own, outside the window's + /// scroll area so it is never below the fold. A free function rather + /// than a closure because the assertions between frames need the + /// borrows back. + fn run_hotkey_edit( + ctx: &egui::Context, + setting: &mut String, + capturing: &mut bool, + events: Vec, + ) -> egui::FullOutput { + let input = crate::test_ui::raw_input(egui::vec2(600.0, 200.0), events); + ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + hotkey_edit(ui, setting, capturing); + }); + }) + } + + /// One frame of the color scheme control on its own, for the same reason + /// as [`run_hotkey_edit`]. + fn run_color_scheme_edit( + ctx: &egui::Context, + setting: &mut String, + events: Vec, + ) -> egui::FullOutput { + let input = crate::test_ui::raw_input(egui::vec2(600.0, 200.0), events); + ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + color_scheme_edit(ui, setting); + }); + }) + } + + /// The dropdown says which scheme is in force and writes the one that is + /// picked. The stored values are lowercase, so what it shows and what it + /// stores are deliberately not the same string. + #[test] + fn the_color_scheme_box_shows_and_sets_the_scheme() { + let ctx = egui::Context::default(); + let mut setting = "dark".to_string(); + + let closed = run_color_scheme_edit(&ctx, &mut setting, vec![]); + let target = + painted_text_center(&closed, "Dark").expect("the current scheme was not painted"); + + run_color_scheme_edit(&ctx, &mut setting, click_at(target)); + let open = run_color_scheme_edit(&ctx, &mut setting, vec![]); + let light = painted_text_center(&open, "Light").expect("the list did not open"); + + run_color_scheme_edit(&ctx, &mut setting, click_at(light)); + assert_eq!(setting, "light", "picking Light stores the config value"); + + let after = run_color_scheme_edit(&ctx, &mut setting, vec![]); + assert!( + painted_text(&after).iter().any(|t| t == "Light"), + "the closed box still says what is in force: {:?}", + painted_text(&after) + ); + } + + /// A hand-edited config can hold anything. The box reports what the app + /// will actually do with it rather than echoing it back. + #[test] + fn an_unknown_scheme_reads_as_dark() { + assert_eq!(scheme_label("dark"), "Dark"); + assert_eq!(scheme_label("light"), "Light"); + // Written the way a person would, and still honoured, so the box has + // to agree with what the theme module makes of it. + assert_eq!(scheme_label(" LIGHT "), "Light"); + for nonsense in ["", "drak", "system", "auto"] { + assert_eq!(scheme_label(nonsense), "Dark", "{:?}", nonsense); + } + } + + fn press(key: egui::Key, modifiers: egui::Modifiers) -> Vec { + vec![egui::Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers, + }] + } + + const CTRL_ALT: egui::Modifiers = egui::Modifiers { + alt: true, + ctrl: true, + shift: false, + mac_cmd: false, + command: true, + }; + + /// The whole point of the control: click it, press the combination, and + /// the setting is what was pressed. Never spelled out by hand. + #[test] + fn the_shortcut_button_binds_what_was_pressed() { + let ctx = egui::Context::default(); + let mut setting = "Ctrl+Shift+F".to_string(); + let mut capturing = false; + + let first = run_hotkey_edit(&ctx, &mut setting, &mut capturing, vec![]); + let button = painted_text_center(&first, "Ctrl+Shift+F") + .expect("the current shortcut was not painted"); + + run_hotkey_edit(&ctx, &mut setting, &mut capturing, click_at(button)); + assert!(capturing, "clicking the button starts a capture"); + let waiting = run_hotkey_edit(&ctx, &mut setting, &mut capturing, vec![]); + assert!( + painted_text(&waiting) + .iter() + .any(|t| t.starts_with("Press a key")), + "a capturing button says so" + ); + + run_hotkey_edit( + &ctx, + &mut setting, + &mut capturing, + press(egui::Key::G, CTRL_ALT), + ); + assert_eq!(setting, "Ctrl+Alt+G"); + assert!(!capturing, "a bound press ends the capture"); + } + + /// Escape backs out, and a press that could not be a shortcut is waited + /// through rather than treated as one. + #[test] + fn capture_ignores_what_it_cannot_bind_and_escape_cancels() { + let ctx = egui::Context::default(); + let mut setting = "Ctrl+Shift+F".to_string(); + let mut capturing = true; + + // A bare letter: someone reaching for the modifier a moment late. + run_hotkey_edit( + &ctx, + &mut setting, + &mut capturing, + press(egui::Key::G, egui::Modifiers::NONE), + ); + assert_eq!(setting, "Ctrl+Shift+F", "a bare key binds nothing"); + assert!(capturing, "and does not end the capture"); + + run_hotkey_edit( + &ctx, + &mut setting, + &mut capturing, + press(egui::Key::Escape, egui::Modifiers::NONE), + ); + assert_eq!(setting, "Ctrl+Shift+F", "Escape leaves the shortcut alone"); + assert!(!capturing); + } + + #[test] + fn clear_switches_the_shortcut_off() { + let ctx = egui::Context::default(); + let mut setting = "Ctrl+Shift+F".to_string(); + let mut capturing = false; + + let first = run_hotkey_edit(&ctx, &mut setting, &mut capturing, vec![]); + let clear = painted_text_center(&first, "Clear").expect("Clear was not painted"); + run_hotkey_edit(&ctx, &mut setting, &mut capturing, click_at(clear)); + assert_eq!(setting, ""); + + // With no shortcut set there is nothing to clear, and the button + // says what the state is rather than going blank. + let empty = run_hotkey_edit(&ctx, &mut setting, &mut capturing, vec![]); + assert!(painted_text(&empty).iter().any(|t| t == "None")); + } + + /// The draft is what the button shows, but the registration is what the + /// app is actually holding, and until Apply they can disagree. + #[test] + fn an_unapplied_shortcut_says_it_is_not_in_force_yet() { + let ctx = egui::Context::default(); + let run = |draft: &str, live: &str| { + let input = crate::test_ui::raw_input(egui::vec2(600.0, 200.0), vec![]); + let out = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| hotkey_note(ui, draft, live)); + }); + painted_text(&out).join("\n") + }; + assert!(run("Ctrl+Alt+K", "Ctrl+Shift+F").contains("Apply and Save")); + // Matching, and nothing registered in a test process: nothing to say. + assert_eq!(run("Ctrl+Shift+F", "Ctrl+Shift+F"), ""); + } /// Every row of every section, with the tip it must show. `tip_row` /// makes a row without *a* tooltip impossible; this table is what makes diff --git a/crates/quicksearch-gui/src/query_highlight.rs b/crates/quicksearch-gui/src/query_highlight.rs index 9d36d2f..1f37516 100644 --- a/crates/quicksearch-gui/src/query_highlight.rs +++ b/crates/quicksearch-gui/src/query_highlight.rs @@ -287,30 +287,6 @@ impl Emitter<'_> { // egui layer // --------------------------------------------------------------------------- -struct QueryPalette { - keyword: Color32, - argument: Color32, - operator: Color32, -} - -/// GitHub Primer syntax colors — readable on egui's near-black and white -/// text-field backgrounds. Same convention as `rank_tier_color`. -fn query_palette(dark_mode: bool) -> QueryPalette { - if dark_mode { - QueryPalette { - keyword: Color32::from_rgb(255, 123, 114), - argument: Color32::from_rgb(121, 192, 255), - operator: Color32::from_rgb(126, 231, 135), - } - } else { - QueryPalette { - keyword: Color32::from_rgb(207, 34, 46), - argument: Color32::from_rgb(5, 80, 174), - operator: Color32::from_rgb(26, 127, 55), - } - } -} - struct QueryFormats { plain: TextFormat, keyword: TextFormat, @@ -322,7 +298,11 @@ struct QueryFormats { fn query_formats(ui: &egui::Ui) -> QueryFormats { let font_id = egui::TextStyle::Body.resolve(ui.style()); - let palette = query_palette(ui.visuals().dark_mode); + // The query language's three classes take three of the palette's hues, + // the same ones the rest of the GUI uses for the same kind of thing: red + // for what the parser reacts to, green for what joins, blue for what the + // user typed. + let palette = crate::color::palette(ui.visuals().dark_mode); let base = |color: Color32| TextFormat { font_id: font_id.clone(), color, @@ -331,9 +311,9 @@ fn query_formats(ui: &egui::Ui) -> QueryFormats { let error = ui.visuals().error_fg_color; QueryFormats { plain: base(ui.visuals().text_color()), - keyword: base(palette.keyword), - operator: base(palette.operator), - argument: base(palette.argument), + keyword: base(palette.red), + operator: base(palette.green), + argument: base(palette.blue), // The keyword red and the error red are near neighbors in dark // mode; the underline disambiguates at a glance. invalid: TextFormat { diff --git a/crates/quicksearch-gui/src/search_tab.rs b/crates/quicksearch-gui/src/search_tab.rs index bc5caae..171c429 100644 --- a/crates/quicksearch-gui/src/search_tab.rs +++ b/crates/quicksearch-gui/src/search_tab.rs @@ -8,6 +8,7 @@ use egui_extras::{Column, TableBuilder}; use quicksearch_core::search::{SearchHit, SearchUpdate}; use quicksearch_core::snippet::Snippet; +use crate::color::rank_tier_color; use crate::format::{fmt_elapsed, fmt_mtime, human_size}; use crate::platform; use crate::ui_util::middle_elide; @@ -205,9 +206,10 @@ impl SearchTab { } /// Re-arm the one-shot first-frame focus: tab switches drop egui focus, - /// and injected text needs the caret back in the search box. - #[cfg(feature = "capture")] - pub(crate) fn capture_focus(&mut self) { + /// so the caret has to be asked for again by anything that lands the user + /// on this tab meaning to type — the system-wide search shortcut, or the + /// capture driver injecting text. + pub(crate) fn request_focus(&mut self) { self.focus_query = true; } @@ -471,6 +473,19 @@ impl SearchTab { ); if self.focus_query { response.request_focus(); + // Select what is already there, so arriving here to + // search for something else means typing it rather than + // clearing the box first. Written straight to the widget + // state because the selection has to be in place for the + // very frame focus lands. + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), response.id) { + let all = egui::text::CCursorRange::two( + egui::text::CCursor::new(0), + egui::text::CCursor::new(self.query.chars().count()), + ); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), response.id); + } self.focus_query = false; } if response.changed() { @@ -833,7 +848,8 @@ impl SearchTab { } fn ignore_dialog_ui(&mut self, ctx: &egui::Context, actions: &mut SearchActions) { - use crate::ui_util::{bordered_button, pattern_edit, BLUE, ORANGE}; + use crate::ui_util::{bordered_button, pattern_edit}; + let p = crate::color::palette(ctx.style().visuals.dark_mode); let Some(dialog) = &mut self.ignore_dialog else { return; }; @@ -856,7 +872,7 @@ impl SearchTab { ui.monospace(ext); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui - .add(bordered_button("Ignore this extension", ORANGE)) + .add(bordered_button("Ignore this extension", p.orange)) .clicked() { chosen = Some(ext.clone()); @@ -875,7 +891,7 @@ impl SearchTab { pattern_edit(ui, &mut dialog.name_pattern, 240.0, "filename or glob"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui - .add_enabled(valid, bordered_button("Ignore this filename", ORANGE)) + .add_enabled(valid, bordered_button("Ignore this filename", p.orange)) .clicked() { chosen = Some(dialog.name_pattern.trim().to_string()); @@ -893,7 +909,7 @@ impl SearchTab { pattern_edit(ui, &mut dialog.dir_pattern, 240.0, "directory glob"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui - .add_enabled(valid, bordered_button("Ignore this directory", ORANGE)) + .add_enabled(valid, bordered_button("Ignore this directory", p.orange)) .clicked() { chosen = Some(dialog.dir_pattern.trim().to_string()); @@ -904,7 +920,7 @@ impl SearchTab { // --- Persist + close --------------------------------------- egui::Frame::new() - .stroke(egui::Stroke::new(1.0, BLUE)) + .stroke(egui::Stroke::new(1.0, p.blue)) .corner_radius(4) .inner_margin(egui::Margin::symmetric(6, 3)) .show(ui, |ui| { @@ -1366,42 +1382,19 @@ fn take_forward(text: &str, from: usize, budget: f32, width_of: impl Fn(char) -> end } -/// Jet-colormap chip color per cascade stage — the rank reads as a -/// colorbar: cool blue for the strongest matches, warming through cyan, -/// green and yellow to red for the weakest path tiers. Pastel rather than -/// true jet, since every channel stays at or above 127 so the chip's dark -/// text keeps its contrast in both themes. -fn rank_tier_color(stage: u8) -> egui::Color32 { - match stage { - 1 => egui::Color32::from_rgb(127, 127, 255), // name exact, exact case - 2 => egui::Color32::from_rgb(127, 178, 255), // name exact, any case - 3 => egui::Color32::from_rgb(127, 229, 255), // name substring, exact case - 4 => egui::Color32::from_rgb(127, 255, 229), // name substring, any case - 5 => egui::Color32::from_rgb(127, 255, 178), // full text, exact case - 6 => egui::Color32::from_rgb(127, 255, 127), // full text, any case - 7 => egui::Color32::from_rgb(178, 255, 127), // fuzzy name - 8 => egui::Color32::from_rgb(229, 255, 127), // fuzzy full text - 9 => egui::Color32::from_rgb(255, 229, 127), // path substring, exact case - 10 => egui::Color32::from_rgb(255, 178, 127), // path substring, any case - _ => egui::Color32::from_rgb(255, 127, 127), // fuzzy path - } -} - /// Timestamp color: fresh files get a green tint that fades into the weak /// text color over ~2 years on a log scale. +/// +/// The fade runs through OKLab, so its midpoint looks like a midpoint — +/// blending sRGB bytes instead dips through a darker, muddier green on the +/// way to gray. fn recency_color(ui: &egui::Ui, mtime: i64) -> egui::Color32 { let now = quicksearch_core::log::now_unix() as i64; let age_hours = ((now - mtime).max(0) as f32 / 3600.0).max(1.0); const HORIZON_HOURS: f32 = 24.0 * 365.0 * 2.0; let t = (age_hours.ln() / HORIZON_HOURS.ln()).clamp(0.0, 1.0); - let fresh = egui::Color32::from_rgb(87, 187, 122); - let old = ui.visuals().weak_text_color(); - let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8; - egui::Color32::from_rgb( - lerp(fresh.r(), old.r()), - lerp(fresh.g(), old.g()), - lerp(fresh.b(), old.b()), - ) + let fresh = crate::color::palette(ui.visuals().dark_mode).green; + crate::color::oklab_lerp(fresh, ui.visuals().weak_text_color(), t) } #[cfg(test)] @@ -2094,41 +2087,21 @@ mod tests { } } - /// The rank chips read as a jet colorbar: blue at the best ranks - /// warming monotonically to red at the worst, and never so dark that - /// the chip's fixed dark text loses its contrast. Stage 12 stands in - /// for the catch-all arm. + /// The freshness fade ends where the theme's own weak text does, so an + /// old file's timestamp is indistinguishable from any other dim label. + /// Its colors, and the rank chips' colorbar, are checked in + /// [`crate::color`]. #[test] - fn the_rank_ramp_runs_blue_to_red_and_stays_light() { - let ramp: Vec = (1..=11).map(rank_tier_color).collect(); - let (first, last) = (ramp[0], ramp[10]); - assert!( - first.b() > first.r(), - "the best rank should be blue: {first:?}" - ); - assert!( - last.r() > last.b(), - "the worst rank should be red: {last:?}" - ); - - for pair in ramp.windows(2) { - let (a, b) = (pair[0], pair[1]); - assert!(a.r() <= b.r(), "red must not cool off: {a:?} then {b:?}"); - assert!(a.b() >= b.b(), "blue must not warm up: {a:?} then {b:?}"); - } - - for stage in 1..=12u8 { - let c = rank_tier_color(stage); - assert!( - c.r() >= 127 && c.g() >= 127 && c.b() >= 127, - "stage {stage} is too dark for the chip's dark text: {c:?}" - ); - } - assert_eq!( - rank_tier_color(12), - last, - "out-of-range stages share the fuzzy-path chip" - ); + fn the_recency_fade_ends_at_the_theme_color() { + with_ui(|ui| { + let now = quicksearch_core::log::now_unix() as i64; + let ancient = recency_color(ui, now - 60 * 60 * 24 * 365 * 20); + assert_eq!(ancient, ui.visuals().weak_text_color()); + // Something written this second is the palette's green, not a + // color that merely resembles it. + let fresh = recency_color(ui, now); + assert_eq!(fresh, crate::color::palette(ui.visuals().dark_mode).green); + }); } // --- snippet rendering ---------------------------------------------- diff --git a/crates/quicksearch-gui/src/test_ui.rs b/crates/quicksearch-gui/src/test_ui.rs index f4c5154..0e61b05 100644 --- a/crates/quicksearch-gui/src/test_ui.rs +++ b/crates/quicksearch-gui/src/test_ui.rs @@ -100,6 +100,27 @@ pub fn painted_text(out: &egui::FullOutput) -> Vec { painted(out).into_iter().map(|(text, _)| text).collect() } +/// Every styled *run* of text painted this frame with the color it was +/// painted in, in paint order. +/// +/// [`painted`] and [`painted_text`] are color-blind, and a galley can hold +/// several colors at once (a status line whose phase word is hinted and whose +/// counters are not), so a color hint can only be checked one section at a +/// time. Runs are the layout job's own sections, so a single-color label +/// yields exactly one entry. +pub fn painted_spans(out: &egui::FullOutput) -> Vec<(String, egui::Color32)> { + painted_galleys(out) + .into_iter() + .flat_map(|(g, _)| { + g.job + .sections + .iter() + .map(|s| (g.job.text[s.byte_range.clone()].to_string(), s.format.color)) + .collect::>() + }) + .collect() +} + /// Every *visible* row of every galley painted this frame, in paint order. /// /// Not the same thing as [`painted_text`]: a galley's `text()` is the job it diff --git a/crates/quicksearch-gui/src/tips.rs b/crates/quicksearch-gui/src/tips.rs index 9202f62..67e121b 100644 --- a/crates/quicksearch-gui/src/tips.rs +++ b/crates/quicksearch-gui/src/tips.rs @@ -9,8 +9,6 @@ //! Written for someone who does not know what a tokenizer or a write-ahead //! log is, and should not have to. -use crate::ui_util::ORANGE; - /// How wide a tooltip may get. Matches `manage_tab::db_size_tooltip`: wide /// enough that a sentence is not shredded into three lines, narrow enough /// that the eye finds the next line. @@ -52,7 +50,8 @@ impl Tip { } if let Some(caution) = self.caution { ui.add_space(4.0); - ui.label(egui::RichText::new(caution).small().color(ORANGE)); + let caution_color = crate::color::palette(ui.visuals().dark_mode).orange; + ui.label(egui::RichText::new(caution).small().color(caution_color)); } } } @@ -359,6 +358,41 @@ pub static UI_SCALE: Tip = Tip { caution: None, }; +pub static SEARCH_HOTKEY: Tip = Tip { + title: "Search shortcut", + body: "One key combination that brings QuickSearch to the front from \ + anywhere, whatever you were doing, and puts the cursor in the \ + search box with the previous search selected, so you can simply \ + start typing.\n\n\ + Click the button and press the keys you want. Combine Ctrl, Alt \ + and Shift with one other key. Clear switches the shortcut off.\n\n\ + On Wayland the shortcut is registered with your desktop rather \ + than claimed directly, so your desktop may assign a different key \ + or ask you to confirm it, and its own keyboard settings are where \ + to change it afterwards. Wayland also does not let any application \ + put itself in front of what you are doing, so there the shortcut \ + selects the Search tab and the search box, but bringing the window \ + forward is up to your desktop.", + examples: &[ + "Ctrl+Shift+F, the default, which few other programs use.", + "Ctrl+Alt+Space if something else on your system already answers to it.", + ], + caution: None, +}; + +pub static COLOR_SCHEME: Tip = Tip { + title: "Color scheme", + body: "Whether QuickSearch is dark or light. It takes effect as soon as \ + you apply it, with no restart.\n\n\ + QuickSearch does not follow your desktop's own light and dark \ + setting: on Linux the only way to read that is to connect to your \ + desktop over the message bus and listen to your settings as they \ + change, which is more of your session than a search tool should \ + be in. So it is asked here instead, once.", + examples: &["Light for a bright room, or to match the rest of a light desktop."], + caution: None, +}; + // --- Options: Security --------------------------------------------------- pub static ENABLE_PASSWORD: Tip = Tip { @@ -576,6 +610,8 @@ mod tests { &RESULTS_PER_PAGE, &DEBOUNCE, &UI_SCALE, + &SEARCH_HOTKEY, + &COLOR_SCHEME, &ENABLE_PASSWORD, &CHANGE_PASSWORD, &DISABLE_PASSWORD, diff --git a/crates/quicksearch-gui/src/ui_util.rs b/crates/quicksearch-gui/src/ui_util.rs index 134385e..104957b 100644 --- a/crates/quicksearch-gui/src/ui_util.rs +++ b/crates/quicksearch-gui/src/ui_util.rs @@ -1,17 +1,11 @@ -//! Shared UI helpers: emphasis colors, bordered widgets, ignore-pattern -//! validation, text eliding, and the "more content below" scroll hint. +//! Shared UI helpers: bordered widgets, ignore-pattern validation, text +//! eliding, and the "more content below" scroll hint. The colors they paint +//! with live in [`crate::color`]. use quicksearch_core::config::IgnoreSet; use std::borrow::Cow; -/// Warning/emphasis orange, also used for the fuzzy-edit-distance warning. -pub const ORANGE: egui::Color32 = egui::Color32::from_rgb(220, 150, 40); -/// Emphasis blue for the primary commit controls. -pub const BLUE: egui::Color32 = egui::Color32::from_rgb(90, 150, 250); -/// Border of a pattern editor holding a valid pattern. -pub const VALID_GREEN: egui::Color32 = egui::Color32::from_rgb(80, 180, 100); -/// Border of a pattern editor holding an invalid pattern. -pub const INVALID_RED: egui::Color32 = egui::Color32::from_rgb(220, 80, 80); +use crate::color::palette; /// A standard button with a colored emphasis border. pub fn bordered_button( @@ -79,9 +73,10 @@ pub fn pattern_hint(pattern: &str) -> Option { /// Render [`pattern_hint`] as a small orange label inside a stable section, /// so its appearance never shifts the ids of widgets below it. pub fn pattern_hint_label(ui: &mut egui::Ui, pattern: &str) { + let caution = palette(ui.visuals().dark_mode).orange; stable_section(ui, |ui| { if let Some(hint) = pattern_hint(pattern) { - ui.label(egui::RichText::new(hint).small().color(ORANGE)); + ui.label(egui::RichText::new(hint).small().color(caution)); } }); } @@ -89,13 +84,14 @@ pub fn pattern_hint_label(ui: &mut egui::Ui, pattern: &str) { /// Border color for a pattern editor holding `text`, or `None` to keep the /// theme's own border. A blank box is not wrong yet, just unfilled, so it /// stays neutral; only text the user actually typed is judged. -fn pattern_border(text: &str) -> Option { +fn pattern_border(text: &str, dark_mode: bool) -> Option { + let p = palette(dark_mode); if text.trim().is_empty() { None } else if ignore_pattern_valid(text) { - Some(VALID_GREEN) + Some(p.green) } else { - Some(INVALID_RED) + Some(p.red) } } @@ -110,7 +106,7 @@ pub fn pattern_edit( hint: &str, ) -> (egui::Response, bool) { let mut valid = ignore_pattern_valid(text); - let border = pattern_border(text); + let border = pattern_border(text, ui.visuals().dark_mode); let response = ui .scope(|ui| { // TextEdit frames with widgets.*.bg_stroke when unfocused and @@ -338,8 +334,8 @@ pub fn wipe_scrim(ui: &egui::Ui, rect: egui::Rect, wipe: f32) { #[cfg(test)] mod tests { use super::{ - ignore_pattern_valid, middle_elide, pattern_border, pattern_hint, wipe_mesh, Cow, - INVALID_RED, VALID_GREEN, WIPE_BAND_MIN, + ignore_pattern_valid, middle_elide, palette, pattern_border, pattern_hint, wipe_mesh, Cow, + WIPE_BAND_MIN, }; use crate::test_ui::with_ui; @@ -506,19 +502,24 @@ mod tests { #[test] fn empty_editor_keeps_the_theme_border() { // Nothing typed yet is not an error to flag. - assert_eq!(pattern_border(""), None); - assert_eq!(pattern_border(" "), None); - assert_eq!(pattern_border("\t\n"), None); + for dark in [true, false] { + assert_eq!(pattern_border("", dark), None); + assert_eq!(pattern_border(" ", dark), None); + assert_eq!(pattern_border("\t\n", dark), None); + } } #[test] fn typed_text_is_judged() { - assert_eq!(pattern_border("*.tmp"), Some(VALID_GREEN)); - assert_eq!(pattern_border(" node_modules "), Some(VALID_GREEN)); - assert_eq!(pattern_border("foo["), Some(INVALID_RED)); - // Typed, but trims away to nothing under the pattern rules — still - // worth flagging, unlike a box the user simply has not filled in. - assert_eq!(pattern_border("/"), Some(INVALID_RED)); + for dark in [true, false] { + let p = palette(dark); + assert_eq!(pattern_border("*.tmp", dark), Some(p.green)); + assert_eq!(pattern_border(" node_modules ", dark), Some(p.green)); + assert_eq!(pattern_border("foo[", dark), Some(p.red)); + // Typed, but trims away to nothing under the pattern rules — + // still worth flagging, unlike a box the user has not filled in. + assert_eq!(pattern_border("/", dark), Some(p.red)); + } } // --- The results wipe --------------------------------------------------- diff --git a/crates/quicksearch-gui/src/unlock.rs b/crates/quicksearch-gui/src/unlock.rs index a2d0657..0b17ff9 100644 --- a/crates/quicksearch-gui/src/unlock.rs +++ b/crates/quicksearch-gui/src/unlock.rs @@ -67,10 +67,39 @@ impl Gate { ) -> Gate { Gate::Locked(UnlockScreen::new(cfg, config_error, initial_query)) } + + /// Act on the system-wide search shortcut, if it fired since the last + /// frame: bring the window back to the front and, once past the gate, + /// put the caret in the search box. + /// + /// Handled here rather than inside the app because while the index is + /// locked the unlock screen *is* the window, and a shortcut that did + /// nothing until the password was typed would be the wrong half of the + /// feature. + /// + /// Getting the window in front of the user is [`crate::hotkey::raise`], + /// which is not one line and explains why. + fn handle_hotkey(&mut self, ctx: &egui::Context, frame: &eframe::Frame) { + if !crate::hotkey::take_fired() { + return; + } + if let Gate::Running(app) = self { + // The Options window is waiting for a key press to bind. Pressing + // the shortcut that is currently held is how someone checks it + // still works, and it must not reshuffle the window underneath + // the dialog asking for its replacement. + if app.capturing_hotkey() { + return; + } + app.activate_search(); + } + crate::hotkey::raise(ctx, frame); + } } impl eframe::App for Gate { fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + self.handle_hotkey(ctx, frame); match self { Gate::Running(app) => app.update(ctx, frame), Gate::Locked(screen) => { @@ -342,11 +371,10 @@ impl UnlockScreen { /// UI-side buffers. fn submit(&mut self, ctx: &egui::Context) { self.error = None; - if matches!(self.mode, Mode::Create) - && self.password.is_empty() { - self.error = Some("The password may not be empty.".to_string()); - return; - } + if matches!(self.mode, Mode::Create) && self.password.is_empty() { + self.error = Some("The password may not be empty.".to_string()); + return; + } let Ok(salt) = self.cfg.security.salt_bytes() else { return; // BrokenSalt mode never reaches submit }; diff --git a/packaging/capture-scenario.txt b/packaging/capture-scenario.txt index 232ad50..0b226b1 100644 --- a/packaging/capture-scenario.txt +++ b/packaging/capture-scenario.txt @@ -35,11 +35,11 @@ record_start search # lives in file bodies, not names -- results stream in with content snippets, # and its ~130 matches stay under the display cap so the footer shows a real # count. -type "race con" cps 10 +type "rac" cps 10 wait_ms 400 -type "dit" cps 7 +type "e cond" cps 7 wait_ms 300 -type "ion" cps 10 +type "ition" cps 10 wait_search_done max 8000 wait_ms 1000 hover_match 2 # pin the pointer on the 3rd result's Match