Skip to main content

hyprcorrect_platform/linux/
hotkey.rs

1//! Global trigger via a Hyprland inline keybind + signals.
2//!
3//! At startup the daemon adds an inline Hyprland keybind whose `exec`
4//! reads the daemon's PID file and `kill -USR1`s that PID
5//! specifically. Hyprland intercepts the chord — terminals and other
6//! focused apps never see it — and the daemon catches the signal as
7//! [`HotkeyEvent::Trigger`].
8//!
9//! The PID-file-based targeting is deliberate: `pkill -x hyprcorrect`
10//! would match the prefs subprocess too (it shares the daemon's
11//! binary name and therefore its `/proc/PID/comm`) and silently
12//! terminate the prefs window when the user pressed the chord. The
13//! file is written by the daemon at startup and removed on shutdown
14//! — see [`hyprcorrect_core::runtime`].
15//!
16//! `SIGHUP` arrives as [`HotkeyEvent::Reload`] and is the prefs
17//! window's signal to the running daemon that the config has
18//! changed.
19//!
20//! Hyprland-specific. The cross-compositor route is the
21//! `GlobalShortcuts` portal (DESIGN.md); that has its own auto-bind
22//! limitation on `xdg-desktop-portal-hyprland` today, so we'll revisit
23//! it together with M3's portable backends.
24
25use std::process::Command;
26use std::sync::mpsc::{self, Receiver, Sender};
27
28use hyprcorrect_core::{Chord, runtime};
29use signal_hook::consts::{SIGHUP, SIGINT, SIGTERM, SIGUSR1, SIGUSR2};
30use signal_hook::iterator::Signals;
31
32/// A daemon-level event driven by the operating-system signal stream.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum HotkeyEvent {
35    /// `SIGUSR1` — the trigger chord fired. Run `fix-last-word`.
36    Trigger,
37    /// `SIGHUP` — the user saved the config. Reload it and rebind the
38    /// trigger if the chord changed.
39    Reload,
40    /// `SIGUSR2` — the prefs window entered chord-capture mode and
41    /// wants Hyprland to stop intercepting the chord so the prefs
42    /// window can see the key press. The daemon temporarily
43    /// uninstalls its bind; `Reload` reinstalls it after capture.
44    Release,
45    /// `SIGTERM` / `SIGINT` — the daemon should shut down cleanly so
46    /// the Hyprland bind and PID file are removed.
47    Shutdown,
48}
49
50/// An error registering the Hyprland keybind or signal handler.
51#[derive(Debug, thiserror::Error)]
52pub enum HotkeyError {
53    /// `hyprctl` could not bind the trigger chord.
54    #[error("hyprctl could not bind the trigger chord: {0}")]
55    Hyprctl(String),
56    /// `hyprctl` could not unbind the trigger chord.
57    #[error("hyprctl could not unbind the trigger chord: {0}")]
58    HyprctlUnbind(String),
59    /// Could not install the signal handler.
60    #[error("could not install signal handler: {0}")]
61    Signal(String),
62    /// Could not spawn the signal-listener thread.
63    #[error("could not spawn the signal-listener thread: {0}")]
64    Thread(String),
65}
66
67/// Install the Hyprland inline keybind for the given chord, tagged
68/// with an `action` label ("word", "sentence", "review", …).
69///
70/// The bind's `exec` writes the action label to the runtime action
71/// file and then `kill -USR1`s the daemon — the daemon reads the
72/// label in its trigger handler to pick which fix to run. Hyprland's
73/// `exec` already wraps the command in `sh -c`, so shell
74/// substitution (`>`, `&&`, `$(...)`) works without extra quoting.
75///
76/// Idempotent: first runs `hyprctl keyword unbind` for the same
77/// chord so a previous (uncleanly-shut-down) daemon's bind doesn't
78/// leave duplicates behind.
79///
80/// # Errors
81///
82/// See [`HotkeyError`].
83pub fn install_bind(chord: &Chord, action: &str) -> Result<(), HotkeyError> {
84    let _ = uninstall_bind(chord);
85    let pid_path = runtime::pid_path();
86    let action_path = runtime::action_path();
87    let bind_value = format!(
88        "{mods}, {key}, exec, printf %s {action} > {action_path} && kill -USR1 $(cat {pid_path})",
89        mods = chord.hyprland_modifiers(),
90        key = chord.hyprland_key(),
91        action_path = action_path.display(),
92        pid_path = pid_path.display(),
93    );
94    let output = Command::new("hyprctl")
95        .args(["keyword", "bind", &bind_value])
96        .output()
97        .map_err(|e| HotkeyError::Hyprctl(format!("invoke hyprctl: {e}")))?;
98    if !output.status.success() {
99        let stderr = String::from_utf8_lossy(&output.stderr);
100        let stdout = String::from_utf8_lossy(&output.stdout);
101        return Err(HotkeyError::Hyprctl(format!(
102            "hyprctl exited non-zero — stdout: {stdout} stderr: {stderr}"
103        )));
104    }
105    Ok(())
106}
107
108/// Remove the Hyprland inline keybind for the given chord. Calling
109/// this for an unbound chord is silently fine.
110///
111/// # Errors
112///
113/// Returns [`HotkeyError::HyprctlUnbind`] only on `hyprctl` invocation
114/// failure (not on "nothing to unbind").
115pub fn uninstall_bind(chord: &Chord) -> Result<(), HotkeyError> {
116    let unbind_value = format!(
117        "{mods}, {key}",
118        mods = chord.hyprland_modifiers(),
119        key = chord.hyprland_key(),
120    );
121    let output = Command::new("hyprctl")
122        .args(["keyword", "unbind", &unbind_value])
123        .output()
124        .map_err(|e| HotkeyError::HyprctlUnbind(format!("invoke hyprctl: {e}")))?;
125    if !output.status.success() {
126        let stderr = String::from_utf8_lossy(&output.stderr);
127        return Err(HotkeyError::HyprctlUnbind(stderr.into_owned()));
128    }
129    Ok(())
130}
131
132/// Start the signal listener.
133///
134/// Installs handlers for `SIGUSR1` (trigger), `SIGHUP` (reload), and
135/// `SIGTERM` / `SIGINT` (shutdown) and returns a receiver of
136/// [`HotkeyEvent`]s. The shutdown signals let the daemon clean up its
137/// Hyprland bind and PID file even when killed via `pkill` or Ctrl-C.
138///
139/// # Errors
140///
141/// See [`HotkeyError`].
142pub fn signal_channel() -> Result<Receiver<HotkeyEvent>, HotkeyError> {
143    let mut signals = Signals::new([SIGUSR1, SIGUSR2, SIGHUP, SIGTERM, SIGINT])
144        .map_err(|e| HotkeyError::Signal(e.to_string()))?;
145    let (tx, rx) = mpsc::channel();
146    std::thread::Builder::new()
147        .name("hyprcorrect-signal".into())
148        .spawn(move || forward_signals(&mut signals, &tx))
149        .map_err(|e| HotkeyError::Thread(e.to_string()))?;
150    Ok(rx)
151}
152
153fn forward_signals(signals: &mut Signals, tx: &Sender<HotkeyEvent>) {
154    for signal in signals.forever() {
155        let event = match signal {
156            SIGUSR1 => HotkeyEvent::Trigger,
157            SIGUSR2 => HotkeyEvent::Release,
158            SIGHUP => HotkeyEvent::Reload,
159            SIGTERM | SIGINT => HotkeyEvent::Shutdown,
160            _ => continue,
161        };
162        if tx.send(event).is_err() {
163            break; // receiver dropped — daemon is shutting down
164        }
165    }
166}