Fixed a Windows CI issue.
This commit is contained in:
parent
488038c080
commit
22d52d5ac0
11 changed files with 450 additions and 101 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" \
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue