Added hotkey for search and moved all colors to OKLCH.

This commit is contained in:
= 2026-08-09 02:58:13 -04:00
parent 304928c969
commit 4f2889982e
31 changed files with 3000 additions and 268 deletions

35
Cargo.lock generated
View file

@ -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",
]

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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<String>,
/// 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();

View file

@ -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();
}

View file

@ -176,11 +176,7 @@ fn xml_members_under<R: Read + Seek>(
}
/// A format whose whole text lives in one member under one spec.
fn single_member(
path: &Path,
member: &str,
spec: &TextSpec,
) -> Result<String, Box<dyn Error>> {
fn single_member(path: &Path, member: &str, spec: &TextSpec) -> Result<String, Box<dyn Error>> {
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", "<worksheet><sheetData>\
<row><c t=\"n\"><v>7</v></c></row></sheetData></worksheet>")],
&[(
"xl/worksheets/sheet1.xml",
"<worksheet><sheetData>\
<row><c t=\"n\"><v>7</v></c></row></sheetData></worksheet>",
)],
);
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", "<w:t>bad &nonsuch; entity</w:t>"),
("odt", "content.xml", "<text:p>bad &nonsuch; entity</text:p>"),
("ods", "content.xml", "<text:p>bad &nonsuch; entity</text:p>"),
("pptx", "ppt/slides/slide1.xml", "<a:t>bad &nonsuch; entity</a:t>"),
(
"docx",
"word/document.xml",
"<w:t>bad &nonsuch; entity</w:t>",
),
(
"odt",
"content.xml",
"<text:p>bad &nonsuch; entity</text:p>",
),
(
"ods",
"content.xml",
"<text:p>bad &nonsuch; entity</text:p>",
),
(
"pptx",
"ppt/slides/slide1.xml",
"<a:t>bad &nonsuch; entity</a:t>",
),
] {
let p = container(&format!("bad-{ext}"), ext, &[(member, body)]);
assert!(

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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())?;

View file

@ -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 {

View file

@ -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}");

View file

@ -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.

View file

@ -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.

View file

@ -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<String>,
key_source: KeySource,
) -> Result<QuickSearchApp, String> {
// 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<egui::Color32>);
/// 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<f64>, colors: &Palette) -> Vec<Span> {
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<Span> {
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<egui::Rect> {
@ -1307,7 +1404,7 @@ fn unsaved_changes_modal(ctx: &egui::Context, source: UnsavedSource) -> Option<U
if ui
.add(crate::ui_util::bordered_button(
"Apply & Save",
crate::ui_util::BLUE,
palette(ui.visuals().dark_mode).blue,
))
.clicked()
{
@ -1395,7 +1492,7 @@ fn reconcile_quit_modal(ctx: &egui::Context) -> Option<bool> {
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<usize>) -> 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
);
}
}
}

View file

@ -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<f64> = 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]);
}
}
}

View file

@ -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<Binding> {
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<Binding, BindingError> {
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<Option<Binding>, 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::<Binding>(), 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::<global_hotkey::hotkey::HotKey>().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::<Binding>(),
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::<Binding>(), Err(BindingError::NoModifier));
assert_eq!("Ctrl".parse::<Binding>(), Err(BindingError::NoKey));
assert_eq!("Ctrl+Shift".parse::<Binding>(), Err(BindingError::NoKey));
assert_eq!("Ctrl+A+B".parse::<Binding>(), Err(BindingError::TwoKeys));
assert_eq!(
"Ctrl+".parse::<Binding>(),
Err(BindingError::UnknownToken(String::new()))
);
assert_eq!(
"Ctrl+Nope".parse::<Binding>(),
Err(BindingError::UnknownToken("Nope".to_string()))
);
assert_eq!("".parse::<Binding>(), Err(BindingError::Empty));
// Escape has to stay free for the capture widget's own cancel.
assert_eq!(
"Ctrl+Escape".parse::<Binding>(),
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())
);
}
}

View file

@ -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<Option<Registry>> = 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<HotKey>,
},
#[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 (&registry.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<Binding>) -> 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<Backend, String> {
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<Backend, String> {
grab_backend()
}
fn grab_backend() -> Result<Backend, String> {
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(())
);
}
}

View file

@ -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<Option<String>>,
status: Arc<Mutex<Status>>,
}
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<String>) {
*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<Mutex<Status>>,
mut commands: mpsc::UnboundedReceiver<Option<String>>,
) {
// `'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<Session<'static, GlobalShortcuts<'static>>> = 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<Status>, 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<Status>, message: String) {
quicksearch_core::log_warn!("global shortcut: {}", message);
set(ctx, status, Status::Error(message));
}

View file

@ -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<dyn std::error::Error>> {
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
}
}
}

View file

@ -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)

View file

@ -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() {

View file

@ -45,6 +45,8 @@ pub struct OptionsWindow {
/// `use_keychain` preference it was probed under.
keychain_probed_for: Option<bool>,
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, &current.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<Option<crate::hotkey::Binding>> {
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::Event>,
) -> 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::Event>,
) -> 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<egui::Event> {
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

View file

@ -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 {

View file

@ -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<egui::Color32> = (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 ----------------------------------------------

View file

@ -100,6 +100,27 @@ pub fn painted_text(out: &egui::FullOutput) -> Vec<String> {
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::<Vec<_>>()
})
.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

View file

@ -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,

View file

@ -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<String> {
/// 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<egui::Color32> {
fn pattern_border(text: &str, dark_mode: bool) -> Option<egui::Color32> {
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 ---------------------------------------------------

View file

@ -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
};

View file

@ -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