Added hotkey for search and moved all colors to OKLCH.
This commit is contained in:
parent
304928c969
commit
4f2889982e
31 changed files with 3000 additions and 268 deletions
35
Cargo.lock
generated
35
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
33
README.md
33
README.md
|
|
@ -247,6 +247,21 @@ inside that folder.
|
|||
- **Help**: an in-app quickstart — first indexing run, example queries,
|
||||
what each tab does — pointing here for everything technical.
|
||||
|
||||
**Ctrl+Shift+F from anywhere** brings QuickSearch to the front, restoring
|
||||
it if it was minimized, and puts the cursor in the search box with the
|
||||
previous search selected, so the next thing you type is the new one. The
|
||||
Options window's Interface section rebinds it — click the button and press
|
||||
the keys — or switches it off. It is a system-wide shortcut, registered
|
||||
with Windows or with the X server, so it works while another application
|
||||
has focus. Wayland does not let an application claim a key, so there the
|
||||
shortcut is registered with your desktop through the XDG desktop portal
|
||||
instead; your desktop then has the final say over which key it is, and its
|
||||
own keyboard settings are where to change it. The Options window says which
|
||||
key it settled on. Wayland likewise gives no application a way to put itself
|
||||
in front of what you are doing, so under it the shortcut selects the Search
|
||||
tab and the search box but leaves raising the window to the desktop; on X11
|
||||
and Windows it raises and restores the window itself.
|
||||
|
||||
The bottom status bar always shows what the indexer is doing (phase,
|
||||
percent, files/sec) or the total indexed file count when idle. Applying a
|
||||
settings change to the index counts as something the indexer is doing: it
|
||||
|
|
@ -375,6 +390,19 @@ containing the binary, its config, and its index can be moved wholesale.
|
|||
|
||||
The GUI edits the config live; external edits apply on next start.
|
||||
|
||||
`[ui] search_hotkey` is the system-wide search shortcut, written the way
|
||||
the Options window prints it (`Ctrl+Shift+F`): Ctrl, Alt and Shift in any
|
||||
combination, plus one key, joined with `+`. An empty string switches it
|
||||
off. A value that is not a shortcut is not a config error — the app loads,
|
||||
says so in the Options window, and runs without one.
|
||||
|
||||
`[ui] color_scheme` is `dark` (the default) or `light`, changeable in the
|
||||
Options window and applied without a restart. It does not follow the
|
||||
desktop's own light/dark setting: on Linux nothing in the window system
|
||||
reports that, so the only way to know is to connect to the session message
|
||||
bus and subscribe to the user's settings feed — more of your session than a
|
||||
search tool should be in, to decide what color some text is.
|
||||
|
||||
**Changing what is indexed** does not throw the index away. Narrowing the
|
||||
scope — removing a folder, adding an ignore pattern, turning off hidden
|
||||
files or symlink following, shortening `content_extensions` — deletes
|
||||
|
|
@ -565,7 +593,10 @@ filter editors), `duplicates_tab.rs`, `logs_tab.rs` (a virtualized view of
|
|||
the core log ring), `options.rs` (draft-based settings
|
||||
editor shared between the window and the Manage tab), `platform.rs`
|
||||
(open / reveal-in-file-manager, and the Windows stdio setup a
|
||||
window-subsystem process needs before anything prints), `cli.rs` (terminal
|
||||
window-subsystem process needs before anything prints), `hotkey/` (the
|
||||
system-wide search shortcut: one key table feeding both a `RegisterHotKey`
|
||||
/ `XGrabKey` registration and, on Wayland, an XDG portal session on its own
|
||||
thread), `cli.rs` (terminal
|
||||
mode, shared with the `quicksearch-cli` binary). There is no
|
||||
pagination: the table is virtualized, so a single scroll list capped at
|
||||
`display_limit` renders in microseconds regardless of row count.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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())?;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
713
crates/quicksearch-gui/src/color.rs
Normal file
713
crates/quicksearch-gui/src/color.rs
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
408
crates/quicksearch-gui/src/hotkey/binding.rs
Normal file
408
crates/quicksearch-gui/src/hotkey/binding.rs
Normal 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
306
crates/quicksearch-gui/src/hotkey/mod.rs
Normal file
306
crates/quicksearch-gui/src/hotkey/mod.rs
Normal 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 (®istry.status, wanted) {
|
||||
(Status::Active, Some(binding)) => {
|
||||
quicksearch_core::log_info!("global shortcut: {} registered", binding)
|
||||
}
|
||||
(Status::Error(why), _) => quicksearch_core::log_warn!("global shortcut: {}", why),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the shortcut was pressed since this was last asked, clearing it.
|
||||
pub fn take_fired() -> bool {
|
||||
FIRED.swap(false, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// What to tell the user about the shortcut right now.
|
||||
pub fn status() -> Status {
|
||||
REGISTRY.with_borrow(|slot| match slot.as_ref() {
|
||||
None => Status::Disabled,
|
||||
// The portal answers on its own schedule, so it keeps its own status
|
||||
// and this one is stale the moment a bind is sent.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
Some(Registry {
|
||||
backend: Backend::Portal(portal),
|
||||
..
|
||||
}) => portal.status(),
|
||||
Some(registry) => registry.status.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Record a press and wake the UI. The repaint is the load-bearing half:
|
||||
/// with nothing happening on screen the app is idle, and a minimised window
|
||||
/// is not drawing at all, so without it the flag would sit unread until
|
||||
/// something else asked for a frame.
|
||||
fn fire(ctx: &egui::Context) {
|
||||
FIRED.store(true, Ordering::SeqCst);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
/// Hold `wanted` and nothing else. `None` releases without registering.
|
||||
fn register(&mut self, wanted: Option<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(())
|
||||
);
|
||||
}
|
||||
}
|
||||
201
crates/quicksearch-gui/src/hotkey/portal.rs
Normal file
201
crates/quicksearch-gui/src/hotkey/portal.rs
Normal 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));
|
||||
}
|
||||
94
crates/quicksearch-gui/src/hotkey/raise.rs
Normal file
94
crates/quicksearch-gui/src/hotkey/raise.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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, ¤t.ui.search_hotkey);
|
||||
ui.separator();
|
||||
|
||||
// Security acts on the live config, not the draft: each
|
||||
|
|
@ -203,15 +222,12 @@ impl OptionsWindow {
|
|||
crate::ui_util::more_below_hint(ui, &scroll);
|
||||
|
||||
ui.separator();
|
||||
let p = crate::color::palette(ui.visuals().dark_mode);
|
||||
ui.horizontal(|ui| {
|
||||
let apply = ui
|
||||
.add(crate::ui_util::bordered_button(
|
||||
"Apply & Save",
|
||||
if dirty {
|
||||
crate::ui_util::ORANGE
|
||||
} else {
|
||||
crate::ui_util::BLUE
|
||||
},
|
||||
if dirty { p.orange } else { p.blue },
|
||||
))
|
||||
.tip(&tips::APPLY_SAVE);
|
||||
if apply.clicked() {
|
||||
|
|
@ -224,7 +240,7 @@ impl OptionsWindow {
|
|||
ui.label(
|
||||
egui::RichText::new("Unsaved changes")
|
||||
.small()
|
||||
.color(crate::ui_util::ORANGE),
|
||||
.color(p.orange),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -245,6 +261,164 @@ impl OptionsWindow {
|
|||
}
|
||||
}
|
||||
|
||||
/// The color schemes, as stored and as shown. Stored lowercase so a
|
||||
/// hand-edited config reads like the rest of the file.
|
||||
const COLOR_SCHEMES: [(&str, &str); 2] = [("dark", "Dark"), ("light", "Light")];
|
||||
|
||||
/// What the dropdown shows for a stored value.
|
||||
///
|
||||
/// Resolved through [`crate::app::theme_for`] rather than by looking the string
|
||||
/// up, so the box says what the app will actually do with whatever is in the
|
||||
/// config file — including a value it does not recognise, which is dark and
|
||||
/// should read that way.
|
||||
fn scheme_label(value: &str) -> &'static str {
|
||||
match crate::app::theme_for(value) {
|
||||
egui::Theme::Dark => "Dark",
|
||||
egui::Theme::Light => "Light",
|
||||
}
|
||||
}
|
||||
|
||||
/// The color scheme dropdown. Returns the box, which is what the row's
|
||||
/// tooltip hangs off.
|
||||
fn color_scheme_edit(ui: &mut egui::Ui, setting: &mut String) -> egui::Response {
|
||||
egui::ComboBox::from_id_salt("cfg-color-scheme")
|
||||
.selected_text(scheme_label(setting))
|
||||
.show_ui(ui, |ui| {
|
||||
for (stored, label) in COLOR_SCHEMES {
|
||||
ui.selectable_value(setting, stored.to_string(), label);
|
||||
}
|
||||
})
|
||||
.response
|
||||
}
|
||||
|
||||
/// The search shortcut's control: a button showing the current binding that
|
||||
/// turns into a key-press reader when clicked, and a Clear beside it.
|
||||
///
|
||||
/// A reader rather than a text field because the shortcut is a thing you
|
||||
/// press, not a thing you spell, and because it keeps the only way to name a
|
||||
/// key inside [`crate::hotkey`] where the two backends agree on it.
|
||||
///
|
||||
/// Returns the button, which is what the row's tooltip hangs off.
|
||||
fn hotkey_edit(ui: &mut egui::Ui, setting: &mut String, capturing: &mut bool) -> egui::Response {
|
||||
let p = crate::color::palette(ui.visuals().dark_mode);
|
||||
ui.horizontal(|ui| {
|
||||
let label = if *capturing {
|
||||
"Press a key combination...".to_string()
|
||||
} else if setting.trim().is_empty() {
|
||||
"None".to_string()
|
||||
} else {
|
||||
setting.clone()
|
||||
};
|
||||
let button = ui.add(crate::ui_util::bordered_button(
|
||||
label,
|
||||
if *capturing { p.orange } else { p.blue },
|
||||
));
|
||||
// A second click backs out, so the button is never a one-way door.
|
||||
if button.clicked() {
|
||||
*capturing = !*capturing;
|
||||
} else if *capturing {
|
||||
match read_capture(ui) {
|
||||
Some(Some(binding)) => {
|
||||
*setting = binding.to_string();
|
||||
*capturing = false;
|
||||
}
|
||||
Some(None) => *capturing = false,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if ui
|
||||
.add_enabled(
|
||||
!setting.trim().is_empty(),
|
||||
egui::Button::new("Clear").small(),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
setting.clear();
|
||||
*capturing = false;
|
||||
}
|
||||
button
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
/// One frame of shortcut capture: `Some(Some(binding))` for a press worth
|
||||
/// binding, `Some(None)` for a cancel, `None` while nothing usable has
|
||||
/// arrived.
|
||||
///
|
||||
/// Reads raw events rather than `egui::Ui::input_mut`'s shortcut matching,
|
||||
/// which answers "was *this* combination pressed" and cannot report an
|
||||
/// arbitrary one. Presses that are not a valid shortcut, such as a bare
|
||||
/// letter, are ignored rather than treated as a cancel: they are almost
|
||||
/// always someone reaching for the modifier a moment too late.
|
||||
fn read_capture(ui: &egui::Ui) -> Option<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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ----------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ---------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue