From 22d52d5ac071f36cf66d2fafa3f911c2ffa9adcb Mon Sep 17 00:00:00 2001 From: Jeremy Karst Date: Sat, 5 Sep 2026 03:09:12 -0400 Subject: [PATCH] Fixed a Windows CI issue. --- config_example.toml | 2 +- crates/quicksearch-gui/Cargo.toml | 12 +- crates/quicksearch-gui/src/activate.rs | 4 +- crates/quicksearch-gui/src/app/status_bar.rs | 30 +- crates/quicksearch-gui/src/hotkey/binding.rs | 132 ++++++++ crates/quicksearch-gui/src/manage_tab.rs | 2 +- .../quicksearch-gui/src/manage_tab/tests.rs | 39 ++- crates/quicksearch-gui/src/settings_tab.rs | 20 +- crates/quicksearch-gui/src/shortcut_setup.rs | 290 +++++++++++++----- crates/quicksearch-gui/src/tips.rs | 16 +- packaging/quicksearch.nsi | 4 + 11 files changed, 450 insertions(+), 101 deletions(-) diff --git a/config_example.toml b/config_example.toml index d196ecd..ba9cb47 100644 --- a/config_example.toml +++ b/config_example.toml @@ -173,7 +173,7 @@ tutorial_seen = false [search] # Start with the fuzzy passes enabled. -fuzzy_default = true +fuzzy_default = false # Ceiling on the fuzzy stages' typo budget. The allowance grows with the # search term, one edit per three characters, up to this value, so 2 # means "1 edit for 3-5 character terms, 2 for anything longer". 0 turns diff --git a/crates/quicksearch-gui/Cargo.toml b/crates/quicksearch-gui/Cargo.toml index 4a19c66..6d4dfa9 100644 --- a/crates/quicksearch-gui/Cargo.toml +++ b/crates/quicksearch-gui/Cargo.toml @@ -59,6 +59,13 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] } # This is the zero-setup path; `--toggle` covers the app not running. global-hotkey = "0.8" +# The window handle behind both native raise paths — `activate::raise`'s X11 +# `_NET_ACTIVE_WINDOW` message and its Win32 `SetForegroundWindow` — neither of +# which winit will do for us. Not target-gated: eframe depends on this +# unconditionally (it is what `HasWindowHandle for Frame` is written against), +# so naming it here adds no crate on any platform. +raw-window-handle = "0.6" + # 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. @@ -83,10 +90,9 @@ pollster = "0.4" # Raising the window from either 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 -# `activate::raise`. Both are already in the tree (winit's own X11 backend, -# and eframe's window handle), so neither adds a crate. +# `activate::raise`. Already in the tree as winit's own X11 backend, so this +# adds no crate. x11rb = "0.13" -raw-window-handle = "0.6" # XTEST, for `examples/raiseprobe.rs` only: it synthesises the global key # press that proves the shortcut path end to end, which no command-line tool diff --git a/crates/quicksearch-gui/src/activate.rs b/crates/quicksearch-gui/src/activate.rs index ba44898..9db07e9 100644 --- a/crates/quicksearch-gui/src/activate.rs +++ b/crates/quicksearch-gui/src/activate.rs @@ -23,7 +23,6 @@ pub mod raise; pub use raise::raise; -use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -133,6 +132,9 @@ pub(crate) fn fire(ctx: &egui::Context) { #[cfg(unix)] mod imp { use super::*; + // The socket is the only reader and writer here; Windows uses the Win32 + // pipe calls rather than `std::io`. + use std::io::{Read, Write}; use std::os::unix::net::{UnixListener, UnixStream}; /// Ask the instance configured by `config_path` to come forward. diff --git a/crates/quicksearch-gui/src/app/status_bar.rs b/crates/quicksearch-gui/src/app/status_bar.rs index 98fb089..f503585 100644 --- a/crates/quicksearch-gui/src/app/status_bar.rs +++ b/crates/quicksearch-gui/src/app/status_bar.rs @@ -2,6 +2,7 @@ use super::*; +use crate::tips::{self, Tipped}; use crate::ui_util::hint; impl QuickSearchApp { @@ -48,18 +49,19 @@ impl QuickSearchApp { ui.label( egui::RichText::new(match (r.total, r.fraction()) { (Some(total), Some(frac)) => format!( - "Applying configuration change · {} / {} ({:.0}%)", + "Rebuilding FTS cache · {} / {} ({:.0}%)", group_thousands(r.examined as u64), group_thousands(total as u64), frac * 100.0 ), _ => format!( - "Applying configuration change · {} entries", + "Rebuilding FTS cache · {} entries", group_thousands(r.examined as u64) ), }) .small(), - ); + ) + .tip(&tips::REBUILDING_FTS); progress_widget(ui, r.fraction()); } ReconcileState::Finished(r) => { @@ -74,21 +76,28 @@ impl QuickSearchApp { } } IndexingStatus::Preparing { start_time, step } => { - let (label, frac) = match step { + // Only the reconcile step has a wait worth explaining; + // the rest are over in a moment. + let (label, frac, tip) = match step { PrepStep::PreviousRun => { - ("Finishing the previous run…".to_string(), None) + ("Finishing the previous run…".to_string(), None, None) + } + PrepStep::OpeningIndex => { + ("Opening the index…".to_string(), None, None) + } + PrepStep::Starting => { + ("Getting the index ready…".to_string(), None, None) } - PrepStep::OpeningIndex => ("Opening the index…".to_string(), None), - PrepStep::Starting => ("Getting the index ready…".to_string(), None), PrepStep::Reconciling(r) => ( format!( - "Applying configuration change · {} entries", + "Rebuilding FTS cache · {} entries", group_thousands(r.examined as u64) ), r.fraction(), + Some(&tips::REBUILDING_FTS), ), }; - ui.label( + let response = ui.label( egui::RichText::new(format!( "{} · {}", label, @@ -96,6 +105,9 @@ impl QuickSearchApp { )) .small(), ); + if let Some(tip) = tip { + response.tip(tip); + } progress_widget(ui, frac); } IndexingStatus::Idle => { diff --git a/crates/quicksearch-gui/src/hotkey/binding.rs b/crates/quicksearch-gui/src/hotkey/binding.rs index bb28a7b..27b3500 100644 --- a/crates/quicksearch-gui/src/hotkey/binding.rs +++ b/crates/quicksearch-gui/src/hotkey/binding.rs @@ -156,9 +156,33 @@ impl Binding { .expect("every Binding key comes from KEYS") } + /// The key combination as the integer `Qt::Key | Qt::Modifier` value + /// KGlobalAccel's DBus `setShortcut` takes. Covered like [`Self::row`]: + /// `tests::every_key_has_a_qt_code` proves the mapping total over + /// [`KEYS`], so a new row cannot reach KDE as a panic. + /// + /// This and the two spellings below exist for the desktops + /// `crate::shortcut_setup` and [`super::portal`] write to, all of which + /// are unix; off unix nothing calls them and the lint would say so. + #[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))] + pub fn qt_key_code(&self) -> u32 { + let mut code = qt_key(self.key); + for (held, bit) in [ + (self.shift, 0x0200_0000), + (self.ctrl, 0x0400_0000), + (self.alt, 0x0800_0000), + ] { + if held { + code |= bit; + } + } + code + } + /// The accelerator in GTK's syntax — `f` — which is what a /// GNOME custom keybinding's `binding` key stores. GTK keyval names are /// the X11 keysym names, so the keysym column serves both spellings. + #[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))] pub fn gtk_accelerator(&self) -> String { let mut out = String::new(); for (held, name) in [ @@ -176,6 +200,7 @@ impl Binding { /// The trigger in the XDG shortcuts spec's syntax: uppercase modifiers /// and an xkbcommon keysym, joined with `+`. + #[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))] pub fn portal_trigger(&self) -> String { let mut out = String::new(); for (held, name) in [ @@ -250,6 +275,90 @@ impl FromStr for Binding { } } +/// The `Qt::Key` value for a bindable key. Printable keys are their ASCII +/// uppercase; the named keys are Qt's `0x0100_00xx` block. A fourth [`KEYS`] +/// column in all but layout: kept as a match so the table stays readable, +/// with `tests::every_key_has_a_qt_code` holding the two together. +#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))] +fn qt_key(key: Key) -> u32 { + match key { + Key::A => 0x41, + Key::B => 0x42, + Key::C => 0x43, + Key::D => 0x44, + Key::E => 0x45, + Key::F => 0x46, + Key::G => 0x47, + Key::H => 0x48, + Key::I => 0x49, + Key::J => 0x4A, + Key::K => 0x4B, + Key::L => 0x4C, + Key::M => 0x4D, + Key::N => 0x4E, + Key::O => 0x4F, + Key::P => 0x50, + Key::Q => 0x51, + Key::R => 0x52, + Key::S => 0x53, + Key::T => 0x54, + Key::U => 0x55, + Key::V => 0x56, + Key::W => 0x57, + Key::X => 0x58, + Key::Y => 0x59, + Key::Z => 0x5A, + Key::Num0 => 0x30, + Key::Num1 => 0x31, + Key::Num2 => 0x32, + Key::Num3 => 0x33, + Key::Num4 => 0x34, + Key::Num5 => 0x35, + Key::Num6 => 0x36, + Key::Num7 => 0x37, + Key::Num8 => 0x38, + Key::Num9 => 0x39, + Key::F1 => 0x0100_0030, + Key::F2 => 0x0100_0031, + Key::F3 => 0x0100_0032, + Key::F4 => 0x0100_0033, + Key::F5 => 0x0100_0034, + Key::F6 => 0x0100_0035, + Key::F7 => 0x0100_0036, + Key::F8 => 0x0100_0037, + Key::F9 => 0x0100_0038, + Key::F10 => 0x0100_0039, + Key::F11 => 0x0100_003A, + Key::F12 => 0x0100_003B, + Key::Space => 0x20, + Key::Enter => 0x0100_0004, + Key::Tab => 0x0100_0001, + Key::Backspace => 0x0100_0003, + Key::Delete => 0x0100_0007, + Key::Insert => 0x0100_0006, + Key::Home => 0x0100_0010, + Key::End => 0x0100_0011, + Key::PageUp => 0x0100_0016, + Key::PageDown => 0x0100_0017, + Key::ArrowUp => 0x0100_0013, + Key::ArrowDown => 0x0100_0015, + Key::ArrowLeft => 0x0100_0012, + Key::ArrowRight => 0x0100_0014, + Key::Comma => 0x2C, + Key::Period => 0x2E, + Key::Slash => 0x2F, + Key::Backslash => 0x5C, + Key::Semicolon => 0x3B, + Key::Quote => 0x27, + Key::Backtick => 0x60, + Key::Minus => 0x2D, + Key::Equals => 0x3D, + Key::OpenBracket => 0x5B, + Key::CloseBracket => 0x5D, + other => unreachable!("{other:?} is not in KEYS; see every_key_has_a_qt_code"), + } +} + /// Empty means "no shortcut" rather than an error. pub fn parse_setting(setting: &str) -> Result, BindingError> { if setting.trim().is_empty() { @@ -271,6 +380,29 @@ mod tests { assert_eq!(binding.gtk_accelerator(), "f"); } + /// `qt_key`'s `unreachable!` is only sound while every row of [`KEYS`] + /// has an arm; this is what holds the match and the table together. + #[test] + fn every_key_has_a_qt_code() { + for (key, token, _) in KEYS { + let code = qt_key(*key); + assert_ne!(code, 0, "{token} has no Qt key code"); + } + } + + /// The values KGlobalAccel actually receives, spot-checked against + /// `Qt::Key`: the default binding (verified live against Plasma 6.6), + /// a named key, and a punctuation key. + #[test] + fn qt_key_codes_match_qt() { + let binding: Binding = "Ctrl+Shift+F".parse().unwrap(); + assert_eq!(binding.qt_key_code(), 0x0600_0046); + let binding: Binding = "Alt+PageUp".parse().unwrap(); + assert_eq!(binding.qt_key_code(), 0x0900_0016); + let binding: Binding = "Ctrl+Comma".parse().unwrap(); + assert_eq!(binding.qt_key_code(), 0x0400_002C); + } + /// The keysym column doubles as the GTK keyval, so a named key must come /// out under GTK's name for it, not egui's. #[test] diff --git a/crates/quicksearch-gui/src/manage_tab.rs b/crates/quicksearch-gui/src/manage_tab.rs index 65014c6..6ad5a96 100644 --- a/crates/quicksearch-gui/src/manage_tab.rs +++ b/crates/quicksearch-gui/src/manage_tab.rs @@ -735,7 +735,7 @@ fn waiting_row(ui: &mut egui::Ui, label: &str, elapsed: Duration) { /// `elapsed` is `Some` for a run's prologue; the between-runs pass has none. fn reconcile_row(ui: &mut egui::Ui, r: &ReconcileProgress, elapsed: Option) { ui.horizontal(|ui| { - ui.label("Applying configuration change"); + ui.label("Rebuilding FTS cache").tip(&tips::REBUILDING_FTS); ui.label(egui::RichText::new("|").weak()); match (r.total, r.fraction()) { (Some(total), Some(frac)) => { diff --git a/crates/quicksearch-gui/src/manage_tab/tests.rs b/crates/quicksearch-gui/src/manage_tab/tests.rs index 90ae603..a485e64 100644 --- a/crates/quicksearch-gui/src/manage_tab/tests.rs +++ b/crates/quicksearch-gui/src/manage_tab/tests.rs @@ -628,7 +628,7 @@ fn a_reconcile_reports_how_far_through_the_index_it_is() { ) .join(" | "); - assert!(text.contains("Applying configuration change"), "{}", text); + assert!(text.contains("Rebuilding FTS cache"), "{}", text); assert!( text.contains("2,500,000 / 8,000,000 (31%) entries checked"), "{}", @@ -637,6 +637,41 @@ fn a_reconcile_reports_how_far_through_the_index_it_is() { assert!(text.contains("1,204 entries removed"), "{}", text); } +/// The pass can run for minutes with the window unresponsive, so the line +/// that names it has to be where the explanation is reachable from. +#[test] +fn the_reconcile_status_explains_the_wait_on_hover() { + let ctx = crate::test_ui::ctx(); + ctx.style_mut(|s| { + s.interaction.tooltip_delay = 0.0; + s.interaction.show_tooltips_only_when_still = false; + }); + let mut tab = ManageTab::new(); + let cfg = cfg_with_root(); + let state = preparing_state(PrepStep::Reconciling(ReconcileProgress::default())); + let mut run = |events: Vec| { + ctx.run(raw_input(events), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + tab.ui(ui, &state, &cfg); + }); + }) + }; + + run(vec![]); + let settled = run(vec![]); + let pos = crate::test_ui::painted_text_center(&settled, "Rebuilding FTS cache") + .expect("status line painted"); + let opening: String = crate::tips::REBUILDING_FTS.body.chars().take(40).collect(); + let mut out = run(vec![egui::Event::PointerMoved(pos)]); + for _ in 0..3 { + if painted_text(&out).join("\n").contains(&opening) { + return; + } + out = run(vec![]); + } + panic!("no tooltip on the status line: {:?}", painted_text(&out)); +} + /// Whole-range deletions read no rows; the display must not invent a denominator. #[test] fn a_reconcile_without_a_row_count_shows_no_denominator() { @@ -669,7 +704,7 @@ fn a_prune_between_runs_is_reported_instead_of_idle() { let text = frame_text(&ctx, &mut tab, &state).join(" | "); assert!( - text.contains("Applying configuration change"), + text.contains("Rebuilding FTS cache"), "the scan is invisible: {}", text ); diff --git a/crates/quicksearch-gui/src/settings_tab.rs b/crates/quicksearch-gui/src/settings_tab.rs index ca338a8..bae668f 100644 --- a/crates/quicksearch-gui/src/settings_tab.rs +++ b/crates/quicksearch-gui/src/settings_tab.rs @@ -466,6 +466,21 @@ fn hotkey_note(ui: &mut egui::Ui, draft: &str, live: &str) { ), None, ), + // On Windows a failed registration is the *expected* state + // whenever the Start-menu shortcut owns the same key — Explorer + // registers it at logon, wins, and every press then reaches us + // through the `--toggle` relay anyway. An error color would cry + // wolf on every installed copy. + Status::Error(why) if cfg!(windows) => ( + format!( + "Another program holds this key ({}) — usually the Start \ + menu shortcut that starts QuickSearch, which also brings \ + it forward while it is running. If the key does nothing, \ + pick a different combination.", + why + ), + None, + ), Status::Error(why) => ( format!("The shortcut is not active: {}.", why), Some(crate::color::palette(ui.visuals().dark_mode).orange), @@ -570,10 +585,7 @@ fn shortcut_note_for(ui: &mut egui::Ui, hotkey_setting: &str, desktop: crate::sh state.installed = true; state.feedback = Some(( true, - "Added to your desktop's keyboard shortcuts. If the \ - key does not answer right away, it will after the \ - next login." - .to_string(), + "Added to your desktop's keyboard shortcuts.".to_string(), )); } Err(e) => state.feedback = Some((false, e)), diff --git a/crates/quicksearch-gui/src/shortcut_setup.rs b/crates/quicksearch-gui/src/shortcut_setup.rs index c10e56e..3d2cdb7 100644 --- a/crates/quicksearch-gui/src/shortcut_setup.rs +++ b/crates/quicksearch-gui/src/shortcut_setup.rs @@ -18,6 +18,11 @@ use crate::hotkey::Binding; /// Where a binding can be written. `Unsupported` hides the one-click button /// and leaves the manual flow. +/// +/// Off unix the two named desktops are unreachable — [`detect`] answers +/// `Unsupported` and the dispatchers below never name them — so the variants +/// exist there only to keep this one enum for every platform. +#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))] #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Desktop { Gnome, @@ -52,31 +57,75 @@ fn desktop_for(desktops: &str) -> Desktop { /// Whether the binding this module writes is currently present. Asks the /// desktop, so callers should cache rather than poll every frame. +/// +/// The three functions here are split by platform the same way [`detect`] is: +/// the desktops that have a home for a binding are all unix, and their modules +/// only exist there, so naming them off unix would not compile. pub fn installed() -> bool { - match detect() { - Desktop::Gnome => gnome::installed(), - Desktop::Kde => kde::installed(), - Desktop::Unsupported => false, + #[cfg(all(unix, not(target_os = "macos")))] + { + match detect() { + Desktop::Gnome => gnome::installed(), + Desktop::Kde => kde::installed(), + Desktop::Unsupported => false, + } + } + #[cfg(not(all(unix, not(target_os = "macos"))))] + { + false } } /// Write `binding` → `quicksearch --toggle` into the desktop's keyboard /// configuration. Idempotent: a second install rewrites the same entry. pub fn install(binding: &Binding) -> Result<(), String> { - let command = format!("{} --toggle", crate::activate::command_name()); - match detect() { - Desktop::Gnome => gnome::install(binding, &command), - Desktop::Kde => kde::install(binding), - Desktop::Unsupported => Err("this desktop is not supported".to_string()), + #[cfg(all(unix, not(target_os = "macos")))] + { + let command = toggle_command(); + match detect() { + Desktop::Gnome => gnome::install(binding, &command), + Desktop::Kde => kde::install(binding, &command), + Desktop::Unsupported => Err(UNSUPPORTED.to_string()), + } + } + #[cfg(not(all(unix, not(target_os = "macos"))))] + { + let _ = binding; + Err(UNSUPPORTED.to_string()) + } +} + +/// What every platform without a home for a binding answers. On Windows the +/// closed-app binding is the installer's `.lnk`, so there is nothing here to +/// write and the Settings tab shows the manual note instead. +const UNSUPPORTED: &str = "this desktop is not supported"; + +/// The command line the key runs, quoted for a path with spaces in it — +/// both GNOME's `command` key and a desktop file's `Exec` split on +/// whitespace and honour double quotes. +#[cfg(all(unix, not(target_os = "macos")))] +fn toggle_command() -> String { + let exe = crate::activate::command_name(); + if exe.contains(char::is_whitespace) { + format!("\"{}\" --toggle", exe) + } else { + format!("{} --toggle", exe) } } /// Delete the entry [`install`] wrote; a no-op if it is already gone. pub fn remove() -> Result<(), String> { - match detect() { - Desktop::Gnome => gnome::remove(), - Desktop::Kde => kde::remove(), - Desktop::Unsupported => Err("this desktop is not supported".to_string()), + #[cfg(all(unix, not(target_os = "macos")))] + { + match detect() { + Desktop::Gnome => gnome::remove(), + Desktop::Kde => kde::remove(), + Desktop::Unsupported => Err(UNSUPPORTED.to_string()), + } + } + #[cfg(not(all(unix, not(target_os = "macos"))))] + { + Err(UNSUPPORTED.to_string()) } } @@ -222,89 +271,133 @@ fn format_string_list(paths: &[String]) -> String { format!("[{}]", quoted.join(", ")) } -/// KDE: the global-shortcuts entry for the `Search` action that -/// `packaging/quicksearch.desktop` declares (`Exec=quicksearch --toggle`). -/// kglobalaccel launches desktop-file actions itself, so no command is -/// written here — only the key, in the file KDE's own Shortcuts settings -/// page reads and edits. +/// KDE: registration with the kglobalaccel daemon over DBus, which is the +/// only writer `kglobalshortcutsrc` has — the daemon rewrites that file at +/// will and *drops* groups it did not create, so editing it directly (the +/// first version of this module) produced an entry that neither fired nor +/// survived. `setShortcut` takes effect immediately and the daemon does its +/// own persisting. +/// +/// What the key *runs* is a small desktop file in +/// `~/.local/share/kglobalaccel/`, the directory Plasma itself uses for +/// custom command shortcuts: a component whose name ends in `.desktop` +/// resolves to that file, and its `_launch` action runs the `Exec` line. +/// The file's presence is also this module's "installed" marker — written +/// last on install, deleted on remove, and free of a per-frame DBus call. +/// +/// Verified live against Plasma 6.6: register → the key launches a closed +/// QuickSearch; `unregister` → the daemon drops the entry from its config. #[cfg(all(unix, not(target_os = "macos")))] mod kde { use super::*; + use std::path::PathBuf; - const FILE: &str = "kglobalshortcutsrc"; - const GROUP: &str = "quicksearch.desktop"; + /// Ends in `.desktop`: what makes the daemon treat the component as a + /// service it can launch rather than an app that must be running. + const COMPONENT: &str = "quicksearch-search.desktop"; + const ACTION: &str = "_launch"; + /// KGlobalAccel's NoAutoloading flag: set the key now, not merely as a + /// default for the next load. + const SET_NOW: &str = "4"; - /// Plasma 6's tool first; 5's second. The first present wins. - fn config_tool(names: [&'static str; 2]) -> &'static str { - let on_path = |name: &str| { - std::env::var_os("PATH").is_some_and(|path| { - std::env::split_paths(&path).any(|dir| dir.join(name).is_file()) - }) - }; - if on_path(names[0]) { - names[0] - } else { - names[1] - } + /// The action id every `org.kde.KGlobalAccel` call takes, in GVariant + /// text: `[component, action, component friendly, action friendly]`. + pub(super) fn action_id() -> String { + format!("['{}', '{}', 'QuickSearch', 'QuickSearch']", COMPONENT, ACTION) } - /// The entry format is `active,default,description`. - pub(super) fn entry(binding: &Binding) -> String { - format!("{},none,Search", binding) + /// One `gdbus call` against the daemon. gdbus over qdbus because it + /// takes GVariant text for the list arguments, which qdbus cannot spell. + fn call(method: &str, args: &[&str]) -> Result { + let method = format!("org.kde.KGlobalAccel.{}", method); + let mut argv = vec![ + "call", + "--session", + "--dest", + "org.kde.kglobalaccel", + "--object-path", + "/kglobalaccel", + "--method", + &method, + ]; + argv.extend(args); + run("gdbus", &argv) + } + + /// Where the launch target lives; the daemon looks here by name. + pub(super) fn desktop_file() -> PathBuf { + let data = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| { + PathBuf::from(std::env::var_os("HOME").unwrap_or_default()) + .join(".local/share") + }); + data.join("kglobalaccel").join(COMPONENT) + } + + /// `NoDisplay`: the entry exists to be launched by a key, not to appear + /// in menus next to the real QuickSearch entry. + pub(super) fn desktop_entry(command: &str) -> String { + format!( + "[Desktop Entry]\nType=Application\nName=QuickSearch\nNoDisplay=true\nExec={}\n", + command + ) } pub(super) fn installed() -> bool { - let tool = config_tool(["kreadconfig6", "kreadconfig5"]); - run(tool, &["--file", FILE, "--group", GROUP, "--key", "Search"]) - .map(|out| { - let active = out.trim().split(',').next().unwrap_or(""); - !active.is_empty() && active != "none" - }) - .unwrap_or(false) + desktop_file().is_file() } - pub(super) fn install(binding: &Binding) -> Result<(), String> { - let tool = config_tool(["kwriteconfig6", "kwriteconfig5"]); - let entry = entry(binding); - for (key, value) in [("_k_friendly_name", "QuickSearch"), ("Search", &entry)] { - run( - tool, - &["--file", FILE, "--group", GROUP, "--key", key, value], - )?; + pub(super) fn install(binding: &Binding, command: &str) -> Result<(), String> { + let id = action_id(); + call("doRegister", &[&id])?; + let keys = format!("[{}]", binding.qt_key_code()); + let reply = call("setShortcut", &[&id, &keys, SET_NOW])?; + // The daemon answers with the keys now in force; ours missing means + // it kept something else — the key is taken. + if !reply_ints(&reply).contains(&binding.qt_key_code()) { + let _ = call("unregister", &[COMPONENT, ACTION]); + return Err(format!( + "your desktop refused {} — probably already in use", + binding + )); } - reload(); + // The file last: it is the installed marker, so nothing marks this + // installed until the key is actually in force. + let path = desktop_file(); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("creating {}: {}", dir.display(), e))?; + } + std::fs::write(&path, desktop_entry(command)) + .map_err(|e| format!("writing {}: {}", path.display(), e))?; + // Executable, or KConfig refuses to trust the file's Exec line + // ("not owned by root and executable flag not set") — the same bit + // Plasma's own Shortcuts page sets on the files it creates here. + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) + .map_err(|e| format!("marking {} executable: {}", path.display(), e))?; Ok(()) } pub(super) fn remove() -> Result<(), String> { - let tool = config_tool(["kwriteconfig6", "kwriteconfig5"]); - for key in ["Search", "_k_friendly_name"] { - run( - tool, - &["--file", FILE, "--group", GROUP, "--key", key, "--delete"], - )?; + call("unregister", &[COMPONENT, ACTION])?; + let path = desktop_file(); + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("deleting {}: {}", path.display(), e)), } - reload(); - Ok(()) } - /// Ask kglobalaccel to re-read its file. Best-effort: without it the - /// binding takes effect at the next login, which install's caller says. - fn reload() { - for qdbus in ["qdbus6", "qdbus"] { - if run( - qdbus, - &[ - "org.kde.kglobalaccel", - "/kglobalaccel", - "org.kde.KGlobalAccel.reloadConfig", - ], - ) - .is_ok() - { - return; - } - } + /// The integers out of a gdbus reply like `([100663366],)`. Wrong or + /// hostile shapes yield fewer integers, never a panic. + pub(super) fn reply_ints(reply: &str) -> Vec { + reply + .split(|c: char| !c.is_ascii_digit()) + .filter(|s| !s.is_empty()) + .filter_map(|s| s.parse().ok()) + .collect() } } @@ -402,11 +495,50 @@ mod tests { } } + /// What the KDE launch key runs: a well-formed desktop entry whose Exec + /// is the toggle command, hidden from menus. #[cfg(all(unix, not(target_os = "macos")))] #[test] - fn the_kde_entry_carries_the_binding_first() { - let binding: Binding = "Ctrl+Shift+F".parse().unwrap(); - assert_eq!(kde::entry(&binding), "Ctrl+Shift+F,none,Search"); + fn the_kde_desktop_entry_launches_the_toggle() { + let entry = kde::desktop_entry("/opt/qs/quicksearch --toggle"); + assert!(entry.starts_with("[Desktop Entry]\n")); + assert!(entry.contains("Exec=/opt/qs/quicksearch --toggle\n")); + assert!(entry.contains("NoDisplay=true\n")); + assert!(kde::desktop_file().ends_with("kglobalaccel/quicksearch-search.desktop")); + } + + /// The GVariant action id every KGlobalAccel call names; `_launch` is + /// the action that runs a `.desktop` component's Exec. + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn the_kde_action_id_names_the_launch_action() { + assert_eq!( + kde::action_id(), + "['quicksearch-search.desktop', '_launch', 'QuickSearch', 'QuickSearch']" + ); + } + + /// gdbus replies, including hostile ones, must parse to integers or to + /// nothing — never panic. + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn gdbus_replies_parse_to_integers_or_nothing() { + assert_eq!(kde::reply_ints("([100663366],)"), [100663366]); + assert_eq!(kde::reply_ints("([1, 2],)"), [1, 2]); + assert_eq!(kde::reply_ints("([],)"), Vec::::new()); + for garbage in ["", "(true,)", "nonsense", "([99999999999999999999],)"] { + let _ = kde::reply_ints(garbage); + } + } + + /// A path with a space would otherwise split into a broken Exec line. + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn the_toggle_command_survives_spaces_in_the_path() { + // `toggle_command` reads the real exe path; both shapes it can + // produce must parse back to program + one flag. + let command = toggle_command(); + assert!(command.ends_with(" --toggle"), "{command}"); } /// The fixed GNOME path is load-bearing twice over: idempotence and diff --git a/crates/quicksearch-gui/src/tips.rs b/crates/quicksearch-gui/src/tips.rs index e7dfb0b..64abbc0 100644 --- a/crates/quicksearch-gui/src/tips.rs +++ b/crates/quicksearch-gui/src/tips.rs @@ -1,4 +1,5 @@ -//! Plain-language tooltips: every configuration control explains itself on hover. +//! Plain-language tooltips: every configuration control, and any status line +//! whose wait needs explaining, explains itself on hover. const TIP_WIDTH: f32 = 420.0; @@ -559,6 +560,18 @@ pub static CLEAR_INDEX: Tip = Tip { caution: Some("This cannot be undone: the index has to be built from scratch again."), }; +// --- Index status -------------------------------------------------------- + +pub static REBUILDING_FTS: Tip = Tip { + title: "Rebuilding the full-text search cache", + body: "A setting changed that affects which text is searchable, so \ + QuickSearch is modifying the search index. On a \ + large index this can take several minutes, and the progress may look \ + frozen while it runs. You can still search during this time!", + examples: &[], + caution: None, +}; + // --- Manage Index tab: indexed folders ----------------------------------- pub static ADD_ROOT: Tip = Tip { @@ -711,6 +724,7 @@ mod tests { &STOP_INDEXING, &RETURN_TO_AUTO, &CLEAR_INDEX, + &REBUILDING_FTS, &ADD_ROOT, &REMOVE_ROOT, &ROOT_WORKERS, diff --git a/packaging/quicksearch.nsi b/packaging/quicksearch.nsi index 4eddb59..a522c6e 100644 --- a/packaging/quicksearch.nsi +++ b/packaging/quicksearch.nsi @@ -187,6 +187,10 @@ Section "Search hotkey (Ctrl+Shift+F)" SecHotkey ; on the Settings tab rewrites this .lnk to match (per-user installs ; only; this all-users file needs elevation). ; + ; Explorer registers this key at logon, so the app's own RegisterHotKey + ; for the same combination loses; either way the press works — directly, + ; or by Explorer launching `--toggle`, which relays to a running window. + ; ; Rewrites the same shortcut the section above creates: NSIS cannot add a ; hotkey to an existing .lnk, and creating it twice is harmless. CreateShortcut "$SMPROGRAMS\${APP}.lnk" "$INSTDIR\quicksearch.exe" "--toggle" \