Fixed a Windows CI issue.
Some checks failed
CI / linux (push) Failing after 9m45s
CI / windows-cross (push) Successful in 8m3s
CI / release (push) Has been skipped

This commit is contained in:
Jeremy Karst 2026-09-05 03:09:12 -04:00
parent 488038c080
commit 22d52d5ac0
11 changed files with 450 additions and 101 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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 — `<Ctrl><Shift>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<Option<Binding>, BindingError> {
if setting.trim().is_empty() {
@ -271,6 +380,29 @@ mod tests {
assert_eq!(binding.gtk_accelerator(), "<Ctrl><Shift>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]

View file

@ -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<Duration>) {
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)) => {

View file

@ -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<egui::Event>| {
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
);

View file

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

View file

@ -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 {
#[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());
#[cfg(all(unix, not(target_os = "macos")))]
{
let command = toggle_command();
match detect() {
Desktop::Gnome => gnome::install(binding, &command),
Desktop::Kde => kde::install(binding),
Desktop::Unsupported => Err("this desktop is not supported".to_string()),
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> {
#[cfg(all(unix, not(target_os = "macos")))]
{
match detect() {
Desktop::Gnome => gnome::remove(),
Desktop::Kde => kde::remove(),
Desktop::Unsupported => Err("this desktop is not supported".to_string()),
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<String, String> {
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<u32> {
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::<u32>::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

View file

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

View file

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