mirror of
https://github.com/anomalyco/hex.git
synced 2026-11-15 18:39:38 +08:00
3144 lines
113 KiB
Rust
3144 lines
113 KiB
Rust
//! Linux dictation shortcuts: the one state machine every input feeds, the
|
||
//! X11 key grabs, the Wayland shortcuts portal, and the `hex` commands a
|
||
//! desktop keybinding runs. Dictation sees only [`HotkeyEvent`]s.
|
||
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::sync::mpsc::{self, Receiver, SyncSender, TrySendError};
|
||
use std::sync::{Arc, Mutex};
|
||
use std::thread::{self, JoinHandle};
|
||
use std::time::{Duration, Instant};
|
||
|
||
use color_eyre::eyre::{Result, WrapErr, eyre};
|
||
use serde::{Deserialize, Serialize};
|
||
use x11rb::connection::Connection;
|
||
use x11rb::errors::ReplyError;
|
||
use x11rb::protocol::ErrorKind;
|
||
use x11rb::protocol::Event;
|
||
use x11rb::protocol::xproto::{ButtonIndex, ConnectionExt, EventMask, GrabMode, ModMask, Window};
|
||
use x11rb::rust_connection::RustConnection;
|
||
|
||
use crate::app_settings::{HotkeyBinding, HotkeyKey, HotkeyModifiers, ModifierSide};
|
||
use crate::desktop::host::{DesktopGrantedShortcut, DesktopShortcut, DesktopShortcuts};
|
||
use crate::dictation::DictationFlavour;
|
||
use crate::linux::session::LinuxSession;
|
||
|
||
#[path = "input/x11.rs"]
|
||
mod x11;
|
||
pub(crate) use x11::{Keymap, MouseCapture, connect_x11};
|
||
|
||
/// A shortcut as X11 grabs and the portal see it: held modifiers and a
|
||
/// named key. Settings store it as a portable `HotkeyBinding`.
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(default)]
|
||
pub(crate) struct LinuxHotkey {
|
||
pub(crate) control: bool,
|
||
pub(crate) alt: bool,
|
||
pub(crate) shift: bool,
|
||
pub(crate) super_key: bool,
|
||
pub(crate) key: String,
|
||
}
|
||
|
||
impl Default for LinuxHotkey {
|
||
fn default() -> Self {
|
||
Self {
|
||
control: false,
|
||
alt: true,
|
||
shift: false,
|
||
super_key: false,
|
||
key: "space".into(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Linux names Command the Super key and Option Alt; keys go by name.
|
||
impl From<&HotkeyBinding> for LinuxHotkey {
|
||
fn from(binding: &HotkeyBinding) -> Self {
|
||
Self {
|
||
control: binding.modifiers.control.is_some(),
|
||
alt: binding.modifiers.option.is_some(),
|
||
shift: binding.modifiers.shift.is_some(),
|
||
super_key: binding.modifiers.command.is_some(),
|
||
key: binding
|
||
.key
|
||
.as_ref()
|
||
.map(HotkeyKey::name)
|
||
.unwrap_or_default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<&LinuxHotkey> for HotkeyBinding {
|
||
fn from(hotkey: &LinuxHotkey) -> Self {
|
||
let side = |held: bool| held.then_some(ModifierSide::Either);
|
||
Self {
|
||
modifiers: HotkeyModifiers {
|
||
control: side(hotkey.control),
|
||
option: side(hotkey.alt),
|
||
shift: side(hotkey.shift),
|
||
command: side(hotkey.super_key),
|
||
function: false,
|
||
},
|
||
key: (!hotkey.key.trim().is_empty()).then(|| HotkeyKey::named(&hotkey.key)),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl TryFrom<DesktopShortcut> for LinuxHotkey {
|
||
type Error = color_eyre::Report;
|
||
|
||
fn try_from(shortcut: DesktopShortcut) -> Result<Self> {
|
||
if shortcut.function {
|
||
return Err(eyre!("the Fn modifier cannot be registered on Linux"));
|
||
}
|
||
Ok(Self {
|
||
control: shortcut.control,
|
||
alt: shortcut.alt,
|
||
shift: shortcut.shift,
|
||
super_key: shortcut.platform,
|
||
key: if shortcut.key == " " {
|
||
"space".into()
|
||
} else {
|
||
shortcut.key.to_ascii_lowercase()
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Logical numbering is consistent with macOS/Windows; X11 reserves 4–7
|
||
/// for scroll wheels and puts auxiliary buttons at 8 and above.
|
||
pub(crate) fn mouse_button(name: &str) -> Option<u8> {
|
||
Some(match HotkeyKey::mouse_number(name)? {
|
||
1 => 1,
|
||
2 => 3,
|
||
3 => 2,
|
||
number => (number + 4) as u8,
|
||
})
|
||
}
|
||
|
||
pub(crate) fn mouse_name(button: u8) -> Option<String> {
|
||
let number = match button {
|
||
1 => 1,
|
||
2 => 3,
|
||
3 => 2,
|
||
8..=36 => u16::from(button) - 4,
|
||
_ => return None,
|
||
};
|
||
Some(format!("mouse{number}"))
|
||
}
|
||
|
||
impl LinuxHotkey {
|
||
pub(crate) fn validate(&self) -> Result<()> {
|
||
if self.key.trim().is_empty() {
|
||
return Err(eyre!("Linux hotkeys require a non-modifier key"));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn label(&self) -> String {
|
||
HotkeyBinding::from(self).keycaps().join("+")
|
||
}
|
||
|
||
/// The trigger the shortcuts portal asks the desktop for, in the
|
||
/// XDG shortcuts format: `CTRL+ALT+space`.
|
||
pub(crate) fn portal_trigger(&self) -> Result<String> {
|
||
if mouse_button(&self.key).is_some() {
|
||
return Err(eyre!(
|
||
"Wayland's shortcuts portal cannot request mouse buttons; bind the hex commands in the desktop settings"
|
||
));
|
||
}
|
||
let key = keysym_name(&self.key)?;
|
||
let mut parts = Vec::new();
|
||
for (held, name) in [
|
||
(self.control, "CTRL"),
|
||
(self.alt, "ALT"),
|
||
(self.shift, "SHIFT"),
|
||
(self.super_key, "LOGO"),
|
||
] {
|
||
if held {
|
||
parts.push(name);
|
||
}
|
||
}
|
||
parts.push(&key);
|
||
Ok(parts.join("+"))
|
||
}
|
||
|
||
pub(crate) fn modifier_mask(
|
||
&self,
|
||
connection: &RustConnection,
|
||
keymap: &Keymap,
|
||
) -> Result<ModMask> {
|
||
let mut result = ModMask::default();
|
||
for (enabled, symbols) in [
|
||
(self.control, &[XK_CONTROL_L, XK_CONTROL_R][..]),
|
||
(self.alt, &[XK_ALT_L, XK_ALT_R][..]),
|
||
(self.shift, &[XK_SHIFT_L, XK_SHIFT_R][..]),
|
||
(self.super_key, &[XK_SUPER_L, XK_SUPER_R][..]),
|
||
] {
|
||
if enabled {
|
||
result |= keymap.modifier_for(connection, symbols)?;
|
||
}
|
||
}
|
||
Ok(result)
|
||
}
|
||
}
|
||
|
||
/// The xkb keysym name of a key Settings records.
|
||
fn keysym_name(key: &str) -> Result<String> {
|
||
let key = key.to_ascii_lowercase();
|
||
let name = match key.as_str() {
|
||
"space" | " " => "space",
|
||
"enter" | "return" => "Return",
|
||
"tab" => "Tab",
|
||
"backspace" => "BackSpace",
|
||
"-" | "minus" => "minus",
|
||
"=" | "equal" => "equal",
|
||
"[" | "bracketleft" => "bracketleft",
|
||
"]" | "bracketright" => "bracketright",
|
||
";" | "semicolon" => "semicolon",
|
||
"'" | "apostrophe" => "apostrophe",
|
||
"`" | "grave" => "grave",
|
||
"\\" | "backslash" => "backslash",
|
||
"," | "comma" => "comma",
|
||
"." | "period" => "period",
|
||
"/" | "slash" => "slash",
|
||
// Labs › Special keys alone; settings hold these only while it's on.
|
||
"menu" => "Menu",
|
||
"pause" => "Pause",
|
||
"scroll lock" | "scrolllock" | "scroll_lock" => "Scroll_Lock",
|
||
// The ISO key left of 1; alone only with Labs › Section key alone.
|
||
"§" | "section" => "section",
|
||
key if key.len() == 1 && key.as_bytes()[0].is_ascii_alphanumeric() => key,
|
||
key if key.starts_with('f')
|
||
&& key[1..]
|
||
.parse::<u32>()
|
||
.is_ok_and(|number| (1..=24).contains(&number)) =>
|
||
{
|
||
return Ok(key.to_ascii_uppercase());
|
||
}
|
||
// A portal shortcut holds its key from every app, so the portal
|
||
// never binds Escape (#786).
|
||
"escape" => {
|
||
return Err(eyre!(
|
||
"the shortcuts portal can't bind Escape; it would take Escape from every app"
|
||
));
|
||
}
|
||
// Navigation and editing keys, named as GNOME's accelerators name
|
||
// them, so both Linux paths accept the same keys (#846).
|
||
key => match crate::linux::gnome_shortcuts::named_keysym(key) {
|
||
Some(name) => name,
|
||
None => return Err(eyre!("unsupported shortcut key: {key}")),
|
||
},
|
||
};
|
||
Ok(name.into())
|
||
}
|
||
|
||
const XK_SPACE: u32 = 0x20;
|
||
const XK_ESCAPE: u32 = 0xff1b;
|
||
pub(crate) const XK_SHIFT_L: u32 = 0xffe1;
|
||
pub(crate) const XK_SHIFT_R: u32 = 0xffe2;
|
||
pub(crate) const XK_CONTROL_L: u32 = 0xffe3;
|
||
pub(crate) const XK_CONTROL_R: u32 = 0xffe4;
|
||
pub(crate) const XK_ALT_L: u32 = 0xffe9;
|
||
pub(crate) const XK_ALT_R: u32 = 0xffea;
|
||
pub(crate) const XK_SUPER_L: u32 = 0xffeb;
|
||
pub(crate) const XK_SUPER_R: u32 = 0xffec;
|
||
const XK_NUM_LOCK: u32 = 0xff7f;
|
||
pub(crate) const KEY_PRESS: u8 = 2;
|
||
pub(crate) const KEY_RELEASE: u8 = 3;
|
||
const RELEASE_GRACE: Duration = Duration::from_millis(50);
|
||
pub(crate) const DOUBLE_TAP_WINDOW: Duration = Duration::from_millis(300);
|
||
/// Labs › Tap or hold: a Toggle press held this long is a hold, as macOS's
|
||
/// hold threshold.
|
||
const TAP_OR_HOLD_THRESHOLD: Duration = Duration::from_millis(300);
|
||
/// Desktops that repeat a held shortcut's press (KWin) repeat it sooner
|
||
/// than this, so a press after a longer gap is a new press.
|
||
const KEY_REPEAT_GAP: Duration = Duration::from_secs(1);
|
||
/// How often an X11 monitor checks timed shortcut state while a key is down
|
||
/// or a release is settling.
|
||
const POLL_INTERVAL: Duration = Duration::from_millis(5);
|
||
/// How long a listening portal waits for the desktop's next signal: a press
|
||
/// wakes it at once, and this only bounds how long a queued `hex` command
|
||
/// waits behind it.
|
||
const PORTAL_WAIT: Duration = Duration::from_millis(50);
|
||
/// How long a monitor without a portal sleeps: a `hex` command wakes it, and
|
||
/// its next portal check is due much later than this.
|
||
const COMMAND_WAIT: Duration = Duration::from_millis(250);
|
||
/// Binding through the portal can wait for the user to approve a dialog.
|
||
const PORTAL_READY_TIMEOUT: Duration = Duration::from_secs(65);
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
pub(crate) enum HotkeyEvent {
|
||
/// `flavour` is the shortcut that started the capture: dictation,
|
||
/// Dictate without processing, Labs › Terminal paste, or Labs › Second
|
||
/// dictation.
|
||
Start {
|
||
flavour: DictationFlavour,
|
||
},
|
||
Finish,
|
||
/// `hex dictation stop --print` finished the dictation: its transcript
|
||
/// goes to the reply instead of being pasted.
|
||
FinishPrinting(crate::control::PrintReply),
|
||
Cancel,
|
||
PasteLast,
|
||
/// Labs › Transform typed text: the focused field's text is processed
|
||
/// and replaced, while nothing records.
|
||
TypedText,
|
||
/// The Annotate key went down: nothing recording, a capture starts;
|
||
/// recording, the capture host waits to tell a tap from a hold.
|
||
Capture,
|
||
/// It came up: a tap stops the recording, a hold stops drawing.
|
||
CaptureUp,
|
||
}
|
||
|
||
/// What a `hex` command asks of the running listener. Each one is
|
||
/// idempotent: starting while recording or stopping while idle does nothing.
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||
pub(crate) enum ShortcutCommand {
|
||
DictationStart,
|
||
DictationStop,
|
||
DictationToggle,
|
||
/// `hex dictation cancel`: discards whatever records, in any mode.
|
||
DictationCancel,
|
||
PasteLast,
|
||
/// `hex typed-text`: the typed-text shortcut's one job.
|
||
TypedText,
|
||
/// `hex dictation start --raw` and `toggle --raw`.
|
||
DictationStartRaw,
|
||
DictationToggleRaw,
|
||
}
|
||
|
||
/// What the listener's command queue carries: a `hex` command, or a
|
||
/// printing stop with the reply only its own connection waits on.
|
||
#[derive(Debug, Eq, PartialEq)]
|
||
pub(crate) enum ListenerCommand {
|
||
Shortcut(ShortcutCommand),
|
||
StopAndPrint(crate::control::PrintReply),
|
||
}
|
||
|
||
impl From<ShortcutCommand> for ListenerCommand {
|
||
fn from(command: ShortcutCommand) -> Self {
|
||
Self::Shortcut(command)
|
||
}
|
||
}
|
||
|
||
/// How the dictation shortcut behaves, and the shortcuts registered
|
||
/// beside it.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub(crate) struct HotkeyOptions {
|
||
pub(crate) double_tap: bool,
|
||
/// Only a double-tap starts recording, which then stays locked.
|
||
pub(crate) double_tap_only: bool,
|
||
pub(crate) toggle: bool,
|
||
/// Labs › Tap or hold, in Toggle mode: a press held at least
|
||
/// [`TAP_OR_HOLD_THRESHOLD`] finishes its capture when it lifts.
|
||
pub(crate) tap_or_hold: bool,
|
||
pub(crate) paste_last: Option<LinuxHotkey>,
|
||
/// Dictate without processing, while it is bound.
|
||
pub(crate) raw_dictation: Option<LinuxHotkey>,
|
||
/// Labs › Transform typed text, while its lab is on and it is bound.
|
||
pub(crate) typed_text: Option<LinuxHotkey>,
|
||
/// Labs › Terminal paste shortcut, while the lab is on and it is bound.
|
||
pub(crate) terminal_paste: Option<LinuxHotkey>,
|
||
/// Labs › Second dictation shortcut, while the lab is on and it is bound:
|
||
/// another key that dictates exactly like the dictation shortcut.
|
||
pub(crate) second_dictation: Option<LinuxHotkey>,
|
||
/// Annotate's one key, while Annotate is on: tap to start and stop,
|
||
/// hold to draw.
|
||
pub(crate) capture: Option<LinuxHotkey>,
|
||
/// Toggle mode's cancel shortcut, which Hex takes only while a
|
||
/// recording runs; `None` in Hold mode, which has no cancel.
|
||
pub(crate) cancel: Option<LinuxHotkey>,
|
||
}
|
||
|
||
/// Whether Annotate draws now, so Escape is Hex's (it stops drawing).
|
||
fn annotate_drawing() -> bool {
|
||
#[cfg(hex_capture)]
|
||
return crate::capture_host::drawing();
|
||
#[cfg(not(hex_capture))]
|
||
false
|
||
}
|
||
|
||
/// What the running listener reports about its shortcuts.
|
||
#[derive(Clone, Debug, Default)]
|
||
pub(crate) struct HotkeyStatus {
|
||
shortcuts: Arc<Mutex<DesktopShortcuts>>,
|
||
/// Whether the shortcuts portal can show the desktop's dialog for them.
|
||
configurable: Arc<AtomicBool>,
|
||
/// Settings asked for that dialog.
|
||
configure: Arc<AtomicBool>,
|
||
/// The person disabled Hex: clear its shortcuts from the desktop's list
|
||
/// when the listener stops.
|
||
forget: Arc<AtomicBool>,
|
||
keys: ShortcutKeys,
|
||
}
|
||
|
||
impl HotkeyStatus {
|
||
pub(crate) fn shortcuts(&self) -> DesktopShortcuts {
|
||
self.shortcuts
|
||
.lock()
|
||
.unwrap_or_else(|error| error.into_inner())
|
||
.clone()
|
||
}
|
||
|
||
/// When the shortcut that asked for a paste has let go of its keys.
|
||
pub(crate) fn keys(&self) -> ShortcutKeys {
|
||
self.keys.clone()
|
||
}
|
||
|
||
pub(crate) fn configurable(&self) -> bool {
|
||
self.configurable.load(Ordering::Acquire)
|
||
}
|
||
|
||
/// Opens the desktop's dialog for Hex's shortcuts, when it has one.
|
||
pub(crate) fn configure(&self) {
|
||
self.configure.store(true, Ordering::Release);
|
||
}
|
||
|
||
/// Clears Hex's shortcuts from the desktop's list once the listener
|
||
/// stops, as Disable does; a restart for a settings change keeps them.
|
||
pub(crate) fn forget_on_stop(&self) {
|
||
self.forget.store(true, Ordering::Release);
|
||
}
|
||
|
||
fn set_shortcuts(&self, shortcuts: DesktopShortcuts) {
|
||
*self
|
||
.shortcuts
|
||
.lock()
|
||
.unwrap_or_else(|error| error.into_inner()) = shortcuts;
|
||
}
|
||
}
|
||
|
||
/// How long after a Wayland shortcut's last event its modifiers may still be
|
||
/// down. Hex cannot see them there, so this is the bounded estimate.
|
||
const MODIFIER_GRACE: Duration = Duration::from_millis(250);
|
||
/// The longest a press without its release holds a paste back, for desktops
|
||
/// that drop the release.
|
||
const HELD_LIMIT: Duration = Duration::from_secs(2);
|
||
|
||
/// When the keys of a Hex shortcut may still be down. Some Wayland
|
||
/// compositors (Hyprland) add every held physical modifier to virtual
|
||
/// keyboard input, turning a paste's Ctrl+V into Ctrl+Alt+V. Hex reads no
|
||
/// input devices, so the paste waits on the shortcut's own events: until a
|
||
/// modifier shortcut's release (portal Deactivated) plus [`MODIFIER_GRACE`],
|
||
/// and [`MODIFIER_GRACE`] after a `hex` command, whose keybinding is unknown.
|
||
#[derive(Clone, Debug, Default)]
|
||
pub(crate) struct ShortcutKeys(Arc<Mutex<HeldKeys>>);
|
||
|
||
#[derive(Debug, Default)]
|
||
struct HeldKeys {
|
||
/// Modifier shortcuts pressed without their release, by [`Shortcut::index`].
|
||
held: u8,
|
||
last: Option<Instant>,
|
||
}
|
||
|
||
impl HeldKeys {
|
||
/// When a paste may send its shortcut, or `None` for now.
|
||
fn quiet_at(&self) -> Option<Instant> {
|
||
let last = self.last?;
|
||
Some(
|
||
last + if self.held != 0 {
|
||
HELD_LIMIT
|
||
} else {
|
||
MODIFIER_GRACE
|
||
},
|
||
)
|
||
}
|
||
}
|
||
|
||
impl ShortcutKeys {
|
||
fn update(&self, change: impl FnOnce(&mut HeldKeys)) {
|
||
let mut keys = self.0.lock().unwrap_or_else(|error| error.into_inner());
|
||
change(&mut keys);
|
||
keys.last = Some(Instant::now());
|
||
}
|
||
|
||
fn pressed(&self, shortcut: Shortcut) {
|
||
self.update(|keys| keys.held |= 1 << shortcut.index());
|
||
}
|
||
|
||
fn released(&self, shortcut: Shortcut) {
|
||
self.update(|keys| keys.held &= !(1 << shortcut.index()));
|
||
}
|
||
|
||
fn command(&self) {
|
||
self.update(|_| {});
|
||
}
|
||
|
||
fn released_all(&self) {
|
||
self.0
|
||
.lock()
|
||
.unwrap_or_else(|error| error.into_inner())
|
||
.held = 0;
|
||
}
|
||
|
||
/// Whether a Hex shortcut is down now (pressed without its release), so
|
||
/// a newer capture owns the modifiers a paste sees held.
|
||
pub(crate) fn any_held(&self) -> bool {
|
||
self.0
|
||
.lock()
|
||
.unwrap_or_else(|error| error.into_inner())
|
||
.held
|
||
!= 0
|
||
}
|
||
|
||
/// Waits until the shortcut that asked for a paste has plausibly let
|
||
/// go of its modifiers, or a release ends the wait early.
|
||
pub(crate) fn settle(&self, stop: &AtomicBool) -> Result<()> {
|
||
loop {
|
||
if stop.load(Ordering::Acquire) {
|
||
return Err(eyre!("paste cancelled while stopping the listener"));
|
||
}
|
||
let quiet_at = self
|
||
.0
|
||
.lock()
|
||
.unwrap_or_else(|error| error.into_inner())
|
||
.quiet_at();
|
||
let Some(wait) = quiet_at.and_then(|at| at.checked_duration_since(Instant::now()))
|
||
else {
|
||
return Ok(());
|
||
};
|
||
thread::sleep(wait.min(Duration::from_millis(20)));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Whether a portal trigger holds modifiers: the one the desktop granted,
|
||
/// else the one Hex asked for.
|
||
fn trigger_has_modifiers(granted: Option<&str>, requested: &LinuxHotkey) -> bool {
|
||
match granted.map(str::trim).filter(|trigger| !trigger.is_empty()) {
|
||
Some(trigger) => crate::linux::portal::readable_trigger(trigger).contains('+'),
|
||
None => requested.control || requested.alt || requested.shift || requested.super_key,
|
||
}
|
||
}
|
||
|
||
/// The portal id of a shortcut asking for `trigger`. A desktop keeps the
|
||
/// trigger it stored for an id and ignores a later `preferred_trigger`
|
||
/// (GNOME, KDE), so a shortcut changed in Hex gets a new id, which the
|
||
/// desktop binds to the new trigger (#575). An unchanged shortcut keeps its
|
||
/// id, and with it any trigger the user assigned in the desktop.
|
||
///
|
||
/// With `desktop_assigns` (Hyprland), the desktop ignores the trigger and
|
||
/// the user binds the id by name in its config, so the id is the bare
|
||
/// `base`: recording another key in Hex must not leave that bind pointing
|
||
/// at a name Hex no longer listens to (#1376).
|
||
fn portal_shortcut_id(base: &str, trigger: &str, desktop_assigns: bool) -> String {
|
||
if desktop_assigns {
|
||
return base.to_string();
|
||
}
|
||
format!("{base}-{}", trigger.replace('+', "-"))
|
||
}
|
||
|
||
/// The name Hex 3.3 and earlier gave `shortcut` on Hyprland, the trigger in
|
||
/// the id (`dictation-ALT-space`), which configs written then still bind.
|
||
/// Hex binds it beside the bare id so those binds keep working while the
|
||
/// recorded key is the one they were written for. Only the shortcuts 3.3
|
||
/// had get one.
|
||
fn hyprland_legacy_id(shortcut: Shortcut, trigger: &str) -> Option<String> {
|
||
matches!(
|
||
shortcut,
|
||
Shortcut::Dictation | Shortcut::PasteLast | Shortcut::Capture | Shortcut::RawDictation
|
||
)
|
||
.then(|| portal_shortcut_id(shortcut.portal_id().0, trigger, false))
|
||
}
|
||
|
||
/// The trigger the desktop granted a shortcut, by the id Hex bound it under.
|
||
fn granted_trigger<'a>(
|
||
granted: &'a std::collections::HashMap<String, Option<String>>,
|
||
ids: &[Option<String>; Shortcut::ALL.len()],
|
||
shortcut: Shortcut,
|
||
) -> Option<&'a str> {
|
||
ids[shortcut.index()]
|
||
.as_deref()
|
||
.and_then(|id| granted.get(id))
|
||
.and_then(Option::as_deref)
|
||
}
|
||
|
||
/// What the desktop granted, as Settings shows it: each shortcut Hex asked
|
||
/// for. With `desktop_assigns` (Hyprland), a shortcut without a trigger is
|
||
/// unconfirmed rather than refused.
|
||
fn project_grants(
|
||
granted: &std::collections::HashMap<String, Option<String>>,
|
||
requested: &[Option<(Shortcut, &LinuxHotkey)>],
|
||
ids: &[Option<String>; Shortcut::ALL.len()],
|
||
desktop_assigns: bool,
|
||
) -> Vec<DesktopGrantedShortcut> {
|
||
requested
|
||
.iter()
|
||
.flatten()
|
||
.map(|(shortcut, _)| {
|
||
let trigger = granted_trigger(granted, ids, *shortcut)
|
||
.map(crate::linux::portal::readable_trigger);
|
||
DesktopGrantedShortcut {
|
||
name: shortcut.portal_id().1.into(),
|
||
unconfirmed: desktop_assigns && trigger.is_none(),
|
||
trigger,
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// A shortcut a backend reports pressed or released.
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
enum Shortcut {
|
||
Dictation,
|
||
PasteLast,
|
||
TypedText,
|
||
Capture,
|
||
RawDictation,
|
||
TerminalPaste,
|
||
/// Labs › Second dictation shortcut.
|
||
SecondDictation,
|
||
}
|
||
|
||
impl Shortcut {
|
||
const ALL: [Self; 7] = [
|
||
Self::Dictation,
|
||
Self::PasteLast,
|
||
Self::TypedText,
|
||
Self::Capture,
|
||
Self::RawDictation,
|
||
Self::TerminalPaste,
|
||
Self::SecondDictation,
|
||
];
|
||
|
||
fn index(self) -> usize {
|
||
self as usize
|
||
}
|
||
|
||
/// The dictation flavour a shortcut starts captures with; `None` for
|
||
/// the shortcuts that start no dictation.
|
||
fn flavour(self) -> Option<DictationFlavour> {
|
||
match self {
|
||
Self::Dictation => Some(DictationFlavour::Normal),
|
||
Self::RawDictation => Some(DictationFlavour::Raw),
|
||
Self::TerminalPaste => Some(DictationFlavour::TerminalPaste),
|
||
Self::SecondDictation => Some(DictationFlavour::Second),
|
||
Self::PasteLast | Self::TypedText | Self::Capture => None,
|
||
}
|
||
}
|
||
|
||
/// The dictation shortcut that starts captures with `flavour`.
|
||
fn for_flavour(flavour: DictationFlavour) -> Self {
|
||
match flavour {
|
||
DictationFlavour::Normal => Self::Dictation,
|
||
DictationFlavour::Raw => Self::RawDictation,
|
||
DictationFlavour::TerminalPaste => Self::TerminalPaste,
|
||
DictationFlavour::Second => Self::SecondDictation,
|
||
}
|
||
}
|
||
|
||
/// The portal's stable id and the description desktops show.
|
||
fn portal_id(self) -> (&'static str, &'static str) {
|
||
match self {
|
||
Self::Dictation => ("dictation", "Dictation"),
|
||
Self::PasteLast => ("paste-last", "Paste last dictation"),
|
||
Self::TypedText => ("typed-text", "Transform typed text"),
|
||
Self::Capture => ("capture", "Annotate"),
|
||
Self::RawDictation => ("raw-dictation", "Dictate without processing"),
|
||
Self::TerminalPaste => ("terminal-paste", "Terminal paste shortcut"),
|
||
Self::SecondDictation => ("second-dictation", "Second dictation shortcut"),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
enum Input {
|
||
Pressed(Shortcut),
|
||
Released(Shortcut),
|
||
/// Escape, which only stops Annotate's drawing (#786).
|
||
Escape,
|
||
/// The cancel shortcut, pressed while Hex held it.
|
||
Cancel,
|
||
/// The backend can no longer tell which keys are down.
|
||
Lost,
|
||
Command(ShortcutCommand),
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
enum Capture {
|
||
Idle,
|
||
/// Double-tap-only: the first tap is down and waits for the second.
|
||
Armed,
|
||
Held {
|
||
second_tap: bool,
|
||
},
|
||
Locked,
|
||
}
|
||
|
||
/// The shortcut rules every input shares: hold, double-tap lock, toggle,
|
||
/// double-tap only, Toggle's cancel, Paste Last, and commands.
|
||
struct ShortcutState {
|
||
double_tap: bool,
|
||
double_tap_only: bool,
|
||
/// Every press acts as the second tap of a double-tap: releasing locks.
|
||
toggle: bool,
|
||
/// Labs › Tap or hold in Toggle mode: a long press finishes on release.
|
||
tap_or_hold: bool,
|
||
/// Toggle mode has a cancel shortcut.
|
||
cancels: bool,
|
||
capture: Capture,
|
||
last_release: Option<Instant>,
|
||
last_press: Option<Instant>,
|
||
/// When the press that started the held capture went down (its
|
||
/// repeats don't move it).
|
||
held_since: Option<Instant>,
|
||
/// The dictation shortcut was pressed and its release hasn't arrived.
|
||
down: bool,
|
||
/// The desktop repeated the held press (GNOME, KWin), so repeats that
|
||
/// stop mean its keys came up.
|
||
repeating: bool,
|
||
/// The shortcut that owns the capture (or the last one): dictation,
|
||
/// Dictate without processing, Labs › Terminal paste, or Labs › Second
|
||
/// dictation.
|
||
flavour: DictationFlavour,
|
||
}
|
||
|
||
impl ShortcutState {
|
||
fn new(options: &HotkeyOptions) -> Self {
|
||
Self {
|
||
double_tap: options.double_tap,
|
||
double_tap_only: options.double_tap_only,
|
||
toggle: options.toggle,
|
||
tap_or_hold: options.toggle && options.tap_or_hold,
|
||
cancels: options.toggle && options.cancel.is_some(),
|
||
capture: Capture::Idle,
|
||
last_release: None,
|
||
last_press: None,
|
||
held_since: None,
|
||
down: false,
|
||
repeating: false,
|
||
flavour: DictationFlavour::Normal,
|
||
}
|
||
}
|
||
|
||
fn idle(&self) -> bool {
|
||
matches!(self.capture, Capture::Idle | Capture::Armed)
|
||
}
|
||
|
||
/// Whether the cancel shortcut is Hex's now: only in Toggle mode, and
|
||
/// only while something records, so the key stays with every app
|
||
/// otherwise.
|
||
fn wants_cancel(&self) -> bool {
|
||
self.cancels && matches!(self.capture, Capture::Held { .. } | Capture::Locked)
|
||
}
|
||
|
||
/// Whatever records ends unfinished: the backend lost track of the
|
||
/// keys, or the person cancelled it.
|
||
fn cancel(&mut self) -> Option<HotkeyEvent> {
|
||
let active = matches!(self.capture, Capture::Held { .. } | Capture::Locked);
|
||
self.capture = Capture::Idle;
|
||
self.last_release = None;
|
||
active.then_some(HotkeyEvent::Cancel)
|
||
}
|
||
|
||
fn start_dictation(&mut self, flavour: DictationFlavour) -> Option<HotkeyEvent> {
|
||
if !self.idle() {
|
||
return None;
|
||
}
|
||
// Like a locked capture: a shortcut press or `stop` finishes it.
|
||
self.flavour = flavour;
|
||
self.capture = Capture::Locked;
|
||
self.last_release = None;
|
||
Some(HotkeyEvent::Start { flavour })
|
||
}
|
||
|
||
/// Hands an idle state to the shortcut pressed, or reports that the
|
||
/// other shortcut's capture owns its keys. A tap of one never counts
|
||
/// toward the other's double tap.
|
||
fn claim(&mut self, flavour: DictationFlavour) -> bool {
|
||
if self.flavour == flavour {
|
||
return true;
|
||
}
|
||
if !self.idle() {
|
||
return false;
|
||
}
|
||
self.flavour = flavour;
|
||
self.capture = Capture::Idle;
|
||
self.last_release = None;
|
||
self.last_press = None;
|
||
self.down = false;
|
||
self.repeating = false;
|
||
true
|
||
}
|
||
|
||
/// A held press the desktop was repeating whose repeats stopped for
|
||
/// longer than any repeat interval, with no release: GNOME's portal sends
|
||
/// no deactivation when a modifier comes up before the key, so the keys
|
||
/// are up and this counts as the release. Never the first tap of a
|
||
/// double tap, since the release itself wasn't seen.
|
||
fn expire(&mut self, now: Instant) -> Option<HotkeyEvent> {
|
||
let stopped = self.down
|
||
&& self.repeating
|
||
&& self
|
||
.last_press
|
||
.is_some_and(|pressed| now.saturating_duration_since(pressed) >= KEY_REPEAT_GAP);
|
||
if !stopped {
|
||
return None;
|
||
}
|
||
let shortcut = Shortcut::for_flavour(self.flavour);
|
||
let event = self.update(Input::Released(shortcut), now);
|
||
self.last_release = None;
|
||
event
|
||
}
|
||
|
||
fn stop_dictation(&mut self) -> Option<HotkeyEvent> {
|
||
if !matches!(self.capture, Capture::Held { .. } | Capture::Locked) {
|
||
return None;
|
||
}
|
||
self.capture = Capture::Idle;
|
||
self.last_release = None;
|
||
Some(HotkeyEvent::Finish)
|
||
}
|
||
|
||
fn update(&mut self, input: Input, now: Instant) -> Option<HotkeyEvent> {
|
||
match input {
|
||
// Escape while Annotate draws stops drawing, never the recording,
|
||
// and it ends no dictation (#786).
|
||
Input::Escape => {
|
||
#[cfg(hex_capture)]
|
||
crate::capture_host::exit_drawing();
|
||
None
|
||
}
|
||
Input::Lost => {
|
||
self.down = false;
|
||
self.repeating = false;
|
||
self.cancel()
|
||
}
|
||
Input::Cancel if self.wants_cancel() => self.cancel(),
|
||
Input::Cancel => None,
|
||
Input::Pressed(
|
||
shortcut @ (Shortcut::Dictation
|
||
| Shortcut::RawDictation
|
||
| Shortcut::TerminalPaste
|
||
| Shortcut::SecondDictation),
|
||
) => {
|
||
let Some(flavour) = shortcut.flavour() else {
|
||
unreachable!("a dictation shortcut has a flavour");
|
||
};
|
||
if !self.claim(flavour) {
|
||
return None;
|
||
}
|
||
// A press with no release since the last one, sooner than a
|
||
// new press could come, is the desktop repeating a held key.
|
||
let repeat = self.down
|
||
&& self.last_press.is_some_and(|pressed| {
|
||
now.saturating_duration_since(pressed) < KEY_REPEAT_GAP
|
||
});
|
||
self.last_press = Some(now);
|
||
self.down = true;
|
||
self.repeating = repeat;
|
||
if repeat {
|
||
return None;
|
||
}
|
||
match self.capture {
|
||
Capture::Locked => {
|
||
self.capture = Capture::Idle;
|
||
self.last_release = None;
|
||
Some(HotkeyEvent::Finish)
|
||
}
|
||
Capture::Idle => {
|
||
let second_tap = self.toggle
|
||
|| self.last_release.take().is_some_and(|released| {
|
||
now.checked_duration_since(released)
|
||
.is_some_and(|gap| gap < DOUBLE_TAP_WINDOW)
|
||
});
|
||
if self.double_tap_only && !second_tap {
|
||
self.capture = Capture::Armed;
|
||
return None;
|
||
}
|
||
self.capture = Capture::Held { second_tap };
|
||
self.held_since = Some(now);
|
||
Some(HotkeyEvent::Start {
|
||
flavour: self.flavour,
|
||
})
|
||
}
|
||
// The desktop never sent the release that would have
|
||
// finished or locked this capture (Hyprland's Lua binds and
|
||
// GNOME's portal drop it when the modifier comes up first),
|
||
// so this new press finishes it.
|
||
Capture::Held { .. } => {
|
||
self.capture = Capture::Idle;
|
||
self.last_release = None;
|
||
Some(HotkeyEvent::Finish)
|
||
}
|
||
Capture::Armed => None,
|
||
}
|
||
}
|
||
Input::Released(
|
||
shortcut @ (Shortcut::Dictation
|
||
| Shortcut::RawDictation
|
||
| Shortcut::TerminalPaste
|
||
| Shortcut::SecondDictation),
|
||
) => {
|
||
if shortcut.flavour() != Some(self.flavour) {
|
||
return None;
|
||
}
|
||
self.down = false;
|
||
self.repeating = false;
|
||
match self.capture {
|
||
Capture::Armed => {
|
||
self.capture = Capture::Idle;
|
||
self.last_release = Some(now);
|
||
None
|
||
}
|
||
// Labs › Tap or hold: a Toggle press held long enough
|
||
// dictates only while held, as Hold does.
|
||
Capture::Held { second_tap: true }
|
||
if self.tap_or_hold
|
||
&& self.held_since.is_some_and(|pressed| {
|
||
now.saturating_duration_since(pressed) >= TAP_OR_HOLD_THRESHOLD
|
||
}) =>
|
||
{
|
||
self.capture = Capture::Idle;
|
||
self.last_release = None;
|
||
Some(HotkeyEvent::Finish)
|
||
}
|
||
Capture::Held { second_tap: true } => {
|
||
self.capture = Capture::Locked;
|
||
self.last_release = None;
|
||
None
|
||
}
|
||
Capture::Held { second_tap: false } => {
|
||
self.capture = Capture::Idle;
|
||
self.last_release = self.double_tap.then_some(now);
|
||
Some(HotkeyEvent::Finish)
|
||
}
|
||
Capture::Idle | Capture::Locked => None,
|
||
}
|
||
}
|
||
Input::Pressed(Shortcut::PasteLast) | Input::Command(ShortcutCommand::PasteLast) => {
|
||
self.idle().then_some(HotkeyEvent::PasteLast)
|
||
}
|
||
Input::Released(Shortcut::PasteLast) => None,
|
||
// The typed-text job owns no capture state: pressing its shortcut
|
||
// or sending `hex typed-text` while idle asks for one job, and
|
||
// while recording it does nothing at all.
|
||
Input::Pressed(Shortcut::TypedText) | Input::Command(ShortcutCommand::TypedText) => {
|
||
self.idle().then_some(HotkeyEvent::TypedText)
|
||
}
|
||
Input::Released(Shortcut::TypedText) => None,
|
||
Input::Pressed(Shortcut::Capture) => Some(HotkeyEvent::Capture),
|
||
Input::Released(Shortcut::Capture) => Some(HotkeyEvent::CaptureUp),
|
||
Input::Command(command) => match command {
|
||
ShortcutCommand::DictationStart => self.start_dictation(DictationFlavour::Normal),
|
||
ShortcutCommand::DictationStartRaw => self.start_dictation(DictationFlavour::Raw),
|
||
ShortcutCommand::DictationStop => self.stop_dictation(),
|
||
ShortcutCommand::DictationToggle => self
|
||
.stop_dictation()
|
||
.or_else(|| self.start_dictation(DictationFlavour::Normal)),
|
||
ShortcutCommand::DictationToggleRaw => self
|
||
.stop_dictation()
|
||
.or_else(|| self.start_dictation(DictationFlavour::Raw)),
|
||
ShortcutCommand::DictationCancel => self.cancel(),
|
||
ShortcutCommand::PasteLast | ShortcutCommand::TypedText => {
|
||
unreachable!("handled above")
|
||
}
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `hex` commands for whichever monitor runs now. The listener keeps one
|
||
/// queue while it registers its shortcuts again after a settings change.
|
||
#[derive(Clone)]
|
||
pub(crate) struct CommandQueue {
|
||
commands: Arc<Mutex<Receiver<ListenerCommand>>>,
|
||
/// Whether `hex` commands reach this listener: they go to the service,
|
||
/// never to a foreground `hex dictate` process.
|
||
connected: bool,
|
||
}
|
||
|
||
impl From<Receiver<ListenerCommand>> for CommandQueue {
|
||
fn from(receiver: Receiver<ListenerCommand>) -> Self {
|
||
Self {
|
||
commands: Arc::new(Mutex::new(receiver)),
|
||
connected: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl CommandQueue {
|
||
/// A queue nothing sends to: the foreground `hex dictate` listener.
|
||
pub(crate) fn disconnected() -> Self {
|
||
Self {
|
||
commands: Arc::new(Mutex::new(mpsc::channel().1)),
|
||
connected: false,
|
||
}
|
||
}
|
||
|
||
/// Whether this listener's `hex` commands arrive through this queue.
|
||
pub(crate) fn connected(&self) -> bool {
|
||
self.connected
|
||
}
|
||
}
|
||
|
||
/// The monitor thread's side: the state machine and its outputs. Backends
|
||
/// feed inputs, then [`Dispatcher::flush`].
|
||
struct Dispatcher {
|
||
state: ShortcutState,
|
||
sender: SyncSender<HotkeyEvent>,
|
||
commands: CommandQueue,
|
||
keys: ShortcutKeys,
|
||
pending: Vec<HotkeyEvent>,
|
||
}
|
||
|
||
impl Dispatcher {
|
||
fn new(
|
||
options: &HotkeyOptions,
|
||
sender: SyncSender<HotkeyEvent>,
|
||
commands: CommandQueue,
|
||
keys: ShortcutKeys,
|
||
) -> Self {
|
||
Self {
|
||
state: ShortcutState::new(options),
|
||
sender,
|
||
commands,
|
||
keys,
|
||
pending: Vec::new(),
|
||
}
|
||
}
|
||
|
||
fn input(&mut self, input: Input) {
|
||
self.pending
|
||
.extend(self.state.update(input, Instant::now()));
|
||
}
|
||
|
||
/// Releases a held dictation shortcut whose desktop stopped repeating it
|
||
/// without sending its release (see [`ShortcutState::expire`]).
|
||
fn expire(&mut self) {
|
||
let was_down = self.state.down;
|
||
let event = self.state.expire(Instant::now());
|
||
if was_down && !self.state.down {
|
||
tracing::info!(
|
||
"the held dictation shortcut stopped repeating without a release; treating it as released"
|
||
);
|
||
self.keys
|
||
.released(Shortcut::for_flavour(self.state.flavour));
|
||
}
|
||
self.pending.extend(event);
|
||
}
|
||
|
||
/// Feeds every queued `hex` command.
|
||
fn commands(&mut self) {
|
||
// `hex dictate` has no queue anything sends to: only shortcuts remain.
|
||
if !self.commands.connected() {
|
||
return;
|
||
}
|
||
let commands = self
|
||
.commands
|
||
.commands
|
||
.lock()
|
||
.unwrap_or_else(|error| error.into_inner());
|
||
let queued: Vec<_> = commands.try_iter().collect();
|
||
drop(commands);
|
||
for command in queued {
|
||
self.command(command);
|
||
}
|
||
}
|
||
|
||
/// Waits up to `timeout` for a `hex` command, so a monitor with no
|
||
/// shortcuts to watch sleeps until one arrives, then feeds it and any
|
||
/// others already queued.
|
||
fn wait_for_commands(&mut self, timeout: Duration) {
|
||
let waiting = self
|
||
.commands
|
||
.commands
|
||
.lock()
|
||
.unwrap_or_else(|error| error.into_inner());
|
||
let first = waiting.recv_timeout(timeout);
|
||
drop(waiting);
|
||
match first {
|
||
Ok(command) => self.command(command),
|
||
// Nothing can send another command; keep the loop from spinning
|
||
// until it sees the listener's stop.
|
||
Err(mpsc::RecvTimeoutError::Disconnected) => thread::sleep(POLL_INTERVAL),
|
||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||
}
|
||
self.commands();
|
||
}
|
||
|
||
fn command(&mut self, command: ListenerCommand) {
|
||
self.keys.command();
|
||
match command {
|
||
ListenerCommand::Shortcut(command) => self.input(Input::Command(command)),
|
||
// Recording stops now, so a following start isn't blocked.
|
||
ListenerCommand::StopAndPrint(reply) => match self.state.stop_dictation() {
|
||
Some(_) => self.pending.push(HotkeyEvent::FinishPrinting(reply)),
|
||
None => reply.send(crate::control::PrintedTranscript::NotRecording),
|
||
},
|
||
}
|
||
}
|
||
|
||
/// Returns false once dictation stopped listening.
|
||
fn flush(&mut self) -> Result<bool> {
|
||
for event in self.pending.drain(..) {
|
||
if !send_event(&self.sender, event)? {
|
||
return Ok(false);
|
||
}
|
||
}
|
||
Ok(true)
|
||
}
|
||
}
|
||
|
||
pub(crate) struct LinuxHotkeyMonitor {
|
||
pub(crate) events: Receiver<HotkeyEvent>,
|
||
pub(crate) errors: Receiver<String>,
|
||
stop: Arc<AtomicBool>,
|
||
worker: Option<JoinHandle<()>>,
|
||
}
|
||
|
||
impl LinuxHotkeyMonitor {
|
||
/// Registers the shortcuts on X11 or through the Wayland shortcuts
|
||
/// portal; without the portal only `commands` drive dictation.
|
||
pub(crate) fn start(
|
||
binding: LinuxHotkey,
|
||
options: HotkeyOptions,
|
||
status: HotkeyStatus,
|
||
commands: CommandQueue,
|
||
) -> Result<Self> {
|
||
Self::start_for(binding, options, LinuxSession::detect(), status, commands)
|
||
}
|
||
|
||
fn start_for(
|
||
binding: LinuxHotkey,
|
||
options: HotkeyOptions,
|
||
session: LinuxSession,
|
||
status: HotkeyStatus,
|
||
commands: CommandQueue,
|
||
) -> Result<Self> {
|
||
let (sender, events) = mpsc::sync_channel(16);
|
||
let (error_sender, errors) = mpsc::channel();
|
||
let (ready_sender, ready_receiver) = mpsc::sync_channel(1);
|
||
let stop = Arc::new(AtomicBool::new(false));
|
||
let started = Arc::new(AtomicBool::new(false));
|
||
let worker_stop = stop.clone();
|
||
let worker_started = started.clone();
|
||
let worker = thread::spawn(move || {
|
||
let keys = status.keys();
|
||
let backend = Backend {
|
||
stop: worker_stop,
|
||
ready: ready_sender.clone(),
|
||
started: worker_started.clone(),
|
||
status,
|
||
};
|
||
let dispatcher = Dispatcher::new(&options, sender, commands, keys);
|
||
let result = match session {
|
||
LinuxSession::X11 => backend.run_x11(dispatcher, binding, options),
|
||
LinuxSession::Wayland => backend.run_wayland(dispatcher, binding, options),
|
||
};
|
||
if let Err(error) = result {
|
||
let message = format!("{error:#}");
|
||
if worker_started.load(Ordering::Acquire) {
|
||
let _ = error_sender.send(message);
|
||
} else {
|
||
let _ = ready_sender.try_send(Err(eyre!(message)));
|
||
}
|
||
}
|
||
});
|
||
// Own the worker before waiting so every startup failure stops and joins it.
|
||
let monitor = Self {
|
||
events,
|
||
errors,
|
||
stop,
|
||
worker: Some(worker),
|
||
};
|
||
let timeout = match session {
|
||
LinuxSession::X11 => Duration::from_secs(2),
|
||
LinuxSession::Wayland => PORTAL_READY_TIMEOUT,
|
||
};
|
||
ready_receiver
|
||
.recv_timeout(timeout)
|
||
.map_err(|_| eyre!("timed out registering the Linux dictation shortcut"))??;
|
||
Ok(monitor)
|
||
}
|
||
}
|
||
|
||
impl Drop for LinuxHotkeyMonitor {
|
||
fn drop(&mut self) {
|
||
self.stop.store(true, Ordering::Release);
|
||
if let Some(worker) = self.worker.take() {
|
||
let _ = worker.join();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The worker thread's lifecycle: stop flag, startup handshake, status.
|
||
struct Backend {
|
||
stop: Arc<AtomicBool>,
|
||
ready: SyncSender<Result<()>>,
|
||
started: Arc<AtomicBool>,
|
||
status: HotkeyStatus,
|
||
}
|
||
|
||
impl Backend {
|
||
fn stopping(&self) -> bool {
|
||
self.stop.load(Ordering::Acquire)
|
||
}
|
||
|
||
fn mark_ready(&self, shortcuts: DesktopShortcuts) {
|
||
self.status.set_shortcuts(shortcuts);
|
||
self.started.store(true, Ordering::Release);
|
||
self.ready.try_send(Ok(())).ok();
|
||
}
|
||
|
||
fn run_wayland(
|
||
self,
|
||
mut dispatcher: Dispatcher,
|
||
binding: LinuxHotkey,
|
||
options: HotkeyOptions,
|
||
) -> Result<()> {
|
||
let portal = match crate::linux::portal::Portal::connect(&self.stop)? {
|
||
Some(portal) => portal,
|
||
None => {
|
||
tracing::warn!(
|
||
desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(),
|
||
"{}",
|
||
crate::linux::portal::MISSING_SHORTCUTS_PORTAL
|
||
);
|
||
match self.wait_for_portal(&mut dispatcher, true)? {
|
||
Some(portal) => portal,
|
||
None => return Ok(()),
|
||
}
|
||
}
|
||
};
|
||
self.run_portal(portal, dispatcher, binding, options)
|
||
}
|
||
|
||
/// Serves `hex` commands, and with `probe` looks for the shortcuts
|
||
/// portal again from time to time: a portal started before the desktop's
|
||
/// session environment exposes none until it is restarted (#875).
|
||
/// Returns the portal once it appears, or `None` when the listener stops.
|
||
fn wait_for_portal(
|
||
&self,
|
||
dispatcher: &mut Dispatcher,
|
||
probe: bool,
|
||
) -> Result<Option<crate::linux::portal::Portal>> {
|
||
self.mark_ready(DesktopShortcuts::Commands);
|
||
let mut probe = probe.then(|| crate::linux::portal::PortalProbe::new(self.stop.clone()));
|
||
while !self.stopping() {
|
||
// No portal: `hex` commands are the only input, so wait on their
|
||
// queue; the probe is due far less often than this.
|
||
dispatcher.wait_for_commands(COMMAND_WAIT);
|
||
if !dispatcher.flush()? {
|
||
return Ok(None);
|
||
}
|
||
if let Some(probe) = probe.as_mut()
|
||
&& let Some(portal) = probe.poll()?
|
||
{
|
||
return Ok(Some(portal));
|
||
}
|
||
}
|
||
Ok(None)
|
||
}
|
||
|
||
fn run_portal(
|
||
self,
|
||
mut portal: crate::linux::portal::Portal,
|
||
mut dispatcher: Dispatcher,
|
||
binding: LinuxHotkey,
|
||
options: HotkeyOptions,
|
||
) -> Result<()> {
|
||
let requested = [
|
||
Some((Shortcut::Dictation, &binding)),
|
||
options
|
||
.paste_last
|
||
.as_ref()
|
||
.map(|key| (Shortcut::PasteLast, key)),
|
||
options.capture.as_ref().map(|key| (Shortcut::Capture, key)),
|
||
options
|
||
.raw_dictation
|
||
.as_ref()
|
||
.map(|key| (Shortcut::RawDictation, key)),
|
||
options
|
||
.typed_text
|
||
.as_ref()
|
||
.map(|key| (Shortcut::TypedText, key)),
|
||
options
|
||
.terminal_paste
|
||
.as_ref()
|
||
.map(|key| (Shortcut::TerminalPaste, key)),
|
||
options
|
||
.second_dictation
|
||
.as_ref()
|
||
.map(|key| (Shortcut::SecondDictation, key)),
|
||
];
|
||
let desktop_assigns = crate::linux::portal::desktop_assigns_triggers();
|
||
let mut shortcuts = Vec::new();
|
||
let mut ids: [Option<String>; Shortcut::ALL.len()] = Default::default();
|
||
let mut legacy_ids: [Option<String>; Shortcut::ALL.len()] = Default::default();
|
||
for (shortcut, key) in requested.into_iter().flatten() {
|
||
let (base, description) = shortcut.portal_id();
|
||
// Keep keyboard shortcuts usable after moving X11 settings to
|
||
// Wayland. Mouse actions remain available through the CLI.
|
||
if mouse_button(&key.key).is_some() {
|
||
continue;
|
||
}
|
||
let trigger = key.portal_trigger()?;
|
||
let id = portal_shortcut_id(base, &trigger, desktop_assigns);
|
||
ids[shortcut.index()] = Some(id.clone());
|
||
shortcuts.push(crate::linux::portal::Shortcut {
|
||
id,
|
||
description,
|
||
trigger: Some(trigger.clone()),
|
||
});
|
||
if desktop_assigns && let Some(legacy) = hyprland_legacy_id(shortcut, &trigger) {
|
||
legacy_ids[shortcut.index()] = Some(legacy.clone());
|
||
shortcuts.push(crate::linux::portal::Shortcut {
|
||
id: legacy,
|
||
description,
|
||
trigger: Some(trigger),
|
||
});
|
||
}
|
||
}
|
||
// Every shortcut goes in this one bind: a second bind would replace
|
||
// the set on KDE. Escape is never among them (#786).
|
||
// On KDE, ids of shortcuts changed since keep holding their keys,
|
||
// which a new id could then never take; they go first (#898).
|
||
let keep = shortcuts
|
||
.iter()
|
||
.map(|shortcut| shortcut.id.as_str())
|
||
.collect::<Vec<_>>();
|
||
if let Err(error) = portal.forget_legacy_kde_shortcuts(&keep) {
|
||
tracing::warn!(
|
||
error = format!("{error:#}"),
|
||
"could not remove Hex's old shortcuts from KDE"
|
||
);
|
||
}
|
||
// GNOME asks for approval whenever Hex binds shortcuts it hasn't
|
||
// approved, as after a shortcut change; dismissing that dialog fails
|
||
// the bind (#723), and Enable binds again, so the dialog returns.
|
||
let granted = match portal.bind(&shortcuts, &self.stop) {
|
||
Ok(granted) => granted,
|
||
Err(error) if self.stopping() => return Err(error),
|
||
// The backend failed the bind rather than the person refusing it
|
||
// (niri with the GNOME backend and no GNOME Shell, #1014): treat
|
||
// the desktop as having no usable shortcuts portal. Binding again
|
||
// would fail the same way, so this run doesn't look for it again;
|
||
// a listener restart (Enable, a settings change) tries once more.
|
||
Err(error) if crate::linux::portal::is_backend_failure(&error) => {
|
||
tracing::warn!(
|
||
error = format!("{error:#}"),
|
||
desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(),
|
||
"the shortcuts portal's backend could not bind Hex's shortcuts; dictation follows the hex commands, which reach the service listener (`hex start`) but not a foreground `hex dictate`"
|
||
);
|
||
drop(portal);
|
||
self.wait_for_portal(&mut dispatcher, false)?;
|
||
return Ok(());
|
||
}
|
||
Err(error) => {
|
||
return Err(error.wrap_err(
|
||
"the desktop did not approve Hex's shortcuts; choose Enable to ask again",
|
||
));
|
||
}
|
||
};
|
||
let project = |granted: &std::collections::HashMap<String, Option<String>>| {
|
||
project_grants(granted, &requested, &ids, desktop_assigns)
|
||
};
|
||
let modifiers = |granted: &std::collections::HashMap<String, Option<String>>| {
|
||
Shortcut::ALL.map(|shortcut| {
|
||
requested.iter().flatten().any(|(requested, key)| {
|
||
*requested == shortcut
|
||
&& trigger_has_modifiers(granted_trigger(granted, &ids, shortcut), key)
|
||
})
|
||
})
|
||
};
|
||
let mut with_modifiers = modifiers(&granted);
|
||
let granted = project(&granted);
|
||
for shortcut in &granted {
|
||
match &shortcut.trigger {
|
||
None if shortcut.unconfirmed => {
|
||
tracing::info!(
|
||
name = %shortcut.name,
|
||
"the desktop assigns this Hex shortcut's key itself; Hex cannot confirm it"
|
||
);
|
||
}
|
||
None => {
|
||
tracing::warn!(
|
||
name = %shortcut.name,
|
||
"the desktop did not grant a Hex shortcut"
|
||
);
|
||
}
|
||
Some(_) => {}
|
||
}
|
||
}
|
||
self.status
|
||
.configurable
|
||
.store(portal.configurable(), Ordering::Release);
|
||
self.status.configure.store(false, Ordering::Release);
|
||
self.mark_ready(DesktopShortcuts::Portal(granted));
|
||
let shortcut_for = |id: &str| {
|
||
Shortcut::ALL.into_iter().find(|shortcut| {
|
||
ids[shortcut.index()].as_deref() == Some(id)
|
||
|| legacy_ids[shortcut.index()].as_deref() == Some(id)
|
||
})
|
||
};
|
||
while !self.stopping() {
|
||
// Wakes on the desktop's signals; the timeout only lets queued
|
||
// `hex` commands through while nothing is pressed.
|
||
for event in portal.wait_events(PORTAL_WAIT) {
|
||
let input = match event {
|
||
crate::linux::portal::Event::Pressed(id) => match shortcut_for(&id) {
|
||
Some(shortcut) => {
|
||
if with_modifiers[shortcut.index()] {
|
||
dispatcher.keys.pressed(shortcut);
|
||
}
|
||
Input::Pressed(shortcut)
|
||
}
|
||
None => continue,
|
||
},
|
||
crate::linux::portal::Event::Released(id) => match shortcut_for(&id) {
|
||
Some(shortcut) => {
|
||
if with_modifiers[shortcut.index()] {
|
||
dispatcher.keys.released(shortcut);
|
||
}
|
||
Input::Released(shortcut)
|
||
}
|
||
None => continue,
|
||
},
|
||
crate::linux::portal::Event::Changed(changed) => {
|
||
with_modifiers = modifiers(&changed);
|
||
self.status
|
||
.set_shortcuts(DesktopShortcuts::Portal(project(&changed)));
|
||
continue;
|
||
}
|
||
crate::linux::portal::Event::Closed => {
|
||
dispatcher.keys.released_all();
|
||
dispatcher.input(Input::Lost);
|
||
let _ = dispatcher.flush();
|
||
return Err(eyre!("the desktop closed Hex's shortcuts portal session"));
|
||
}
|
||
};
|
||
dispatcher.input(input);
|
||
}
|
||
dispatcher.expire();
|
||
dispatcher.commands();
|
||
if self.status.configure.swap(false, Ordering::AcqRel)
|
||
&& let Err(error) = portal.configure()
|
||
{
|
||
// The portal advertises ConfigureShortcuts even when the
|
||
// desktop's backend lacks it (GNOME, #723), so the call
|
||
// fails and opens nothing. Settings then drops its Open
|
||
// button rather than keep one that does nothing.
|
||
self.status.configurable.store(false, Ordering::Release);
|
||
tracing::warn!(
|
||
error = format!("{error:#}"),
|
||
"could not open the desktop's shortcut settings"
|
||
);
|
||
}
|
||
if !dispatcher.flush()? {
|
||
break;
|
||
}
|
||
}
|
||
if self.status.forget.load(Ordering::Acquire)
|
||
&& let Err(error) = portal.forget()
|
||
{
|
||
tracing::warn!(
|
||
error = format!("{error:#}"),
|
||
"could not clear Hex's shortcuts from the desktop"
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
pub(crate) fn send_event(sender: &SyncSender<HotkeyEvent>, event: HotkeyEvent) -> Result<bool> {
|
||
match sender.try_send(event) {
|
||
Ok(()) => Ok(true),
|
||
Err(TrySendError::Full(_)) => Err(eyre!("Linux hotkey event queue overflow")),
|
||
Err(TrySendError::Disconnected(_)) => Ok(false),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::x11::*;
|
||
use super::*;
|
||
use HotkeyEvent::{Cancel, Finish, PasteLast};
|
||
const START: HotkeyEvent = HotkeyEvent::Start {
|
||
flavour: DictationFlavour::Normal,
|
||
};
|
||
use ShortcutCommand as Command;
|
||
|
||
struct Test {
|
||
state: ShortcutState,
|
||
now: Instant,
|
||
events: Vec<HotkeyEvent>,
|
||
}
|
||
|
||
impl Test {
|
||
fn new(double_tap: bool) -> Self {
|
||
Self::with(HotkeyOptions {
|
||
double_tap,
|
||
..HotkeyOptions::default()
|
||
})
|
||
}
|
||
|
||
fn with(options: HotkeyOptions) -> Self {
|
||
Self {
|
||
state: ShortcutState::new(&options),
|
||
now: Instant::now(),
|
||
events: Vec::new(),
|
||
}
|
||
}
|
||
|
||
fn input(&mut self, input: Input) {
|
||
if let Some(event) = self.state.update(input, self.now) {
|
||
self.events.push(event);
|
||
}
|
||
}
|
||
|
||
fn press(&mut self, shortcut: Shortcut) {
|
||
self.input(Input::Pressed(shortcut));
|
||
}
|
||
|
||
fn release(&mut self, shortcut: Shortcut) {
|
||
self.input(Input::Released(shortcut));
|
||
}
|
||
|
||
fn command(&mut self, command: ShortcutCommand) {
|
||
self.input(Input::Command(command));
|
||
}
|
||
|
||
fn expire(&mut self) {
|
||
if let Some(event) = self.state.expire(self.now) {
|
||
self.events.push(event);
|
||
}
|
||
}
|
||
|
||
fn tap(&mut self) {
|
||
self.press(Shortcut::Dictation);
|
||
self.release(Shortcut::Dictation);
|
||
}
|
||
|
||
fn lock(&mut self) {
|
||
self.tap();
|
||
self.now += Duration::from_millis(20);
|
||
self.tap();
|
||
assert_eq!(self.events, [START, Finish, START]);
|
||
assert_eq!(self.state.capture, Capture::Locked);
|
||
self.events.clear();
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn raw_dictation_owns_the_capture_it_starts() {
|
||
const RAW: HotkeyEvent = HotkeyEvent::Start {
|
||
flavour: DictationFlavour::Raw,
|
||
};
|
||
let mut test = Test::new(true);
|
||
test.press(Shortcut::RawDictation);
|
||
// The dictation shortcut can't finish or join another's capture.
|
||
test.tap();
|
||
test.release(Shortcut::RawDictation);
|
||
assert_eq!(test.events, [RAW, Finish]);
|
||
// A dictation tap right after a raw one is not its second tap.
|
||
test.now += Duration::from_millis(20);
|
||
test.tap();
|
||
assert_eq!(test.events, [RAW, Finish, START, Finish]);
|
||
assert_eq!(test.state.capture, Capture::Idle);
|
||
test.command(Command::DictationToggleRaw);
|
||
test.command(Command::DictationToggleRaw);
|
||
assert_eq!(test.events, [RAW, Finish, START, Finish, RAW, Finish]);
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_paste_shortcut_owns_the_capture_it_starts() {
|
||
const TERMINAL: HotkeyEvent = HotkeyEvent::Start {
|
||
flavour: DictationFlavour::TerminalPaste,
|
||
};
|
||
let mut test = Test::new(true);
|
||
test.press(Shortcut::TerminalPaste);
|
||
// The dictation and raw shortcuts can't finish or join another's
|
||
// capture.
|
||
test.tap();
|
||
test.press(Shortcut::RawDictation);
|
||
test.release(Shortcut::RawDictation);
|
||
test.release(Shortcut::TerminalPaste);
|
||
assert_eq!(test.events, [TERMINAL, Finish]);
|
||
// A dictation tap right after is not its second tap.
|
||
test.now += Duration::from_millis(20);
|
||
test.tap();
|
||
assert_eq!(test.events, [TERMINAL, Finish, START, Finish]);
|
||
// A later held press starts and finishes a plain capture.
|
||
test.now += Duration::from_millis(400);
|
||
test.press(Shortcut::TerminalPaste);
|
||
test.release(Shortcut::TerminalPaste);
|
||
assert_eq!(
|
||
test.events,
|
||
[TERMINAL, Finish, START, Finish, TERMINAL, Finish]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn second_dictation_shortcut_owns_the_capture_it_starts() {
|
||
const SECOND: HotkeyEvent = HotkeyEvent::Start {
|
||
flavour: DictationFlavour::Second,
|
||
};
|
||
let mut test = Test::new(true);
|
||
test.press(Shortcut::SecondDictation);
|
||
// The dictation and raw shortcuts can't finish or join its capture.
|
||
test.tap();
|
||
test.press(Shortcut::RawDictation);
|
||
test.release(Shortcut::RawDictation);
|
||
test.release(Shortcut::SecondDictation);
|
||
assert_eq!(test.events, [SECOND, Finish]);
|
||
// A dictation tap right after is not its second tap either, and the
|
||
// second shortcut starts and finishes a later capture of its own.
|
||
test.now += Duration::from_millis(20);
|
||
test.tap();
|
||
assert_eq!(test.events, [SECOND, Finish, START, Finish]);
|
||
test.now += Duration::from_millis(400);
|
||
test.press(Shortcut::SecondDictation);
|
||
test.release(Shortcut::SecondDictation);
|
||
assert_eq!(test.events, [SECOND, Finish, START, Finish, SECOND, Finish]);
|
||
}
|
||
|
||
/// #786: only the shortcuts Hex asked for show; a key the desktop still
|
||
/// keeps for the removed Cancel dictation stays out.
|
||
#[test]
|
||
fn only_requested_shortcuts_are_shown() {
|
||
let binding = LinuxHotkey::default();
|
||
let requested = [
|
||
Some((Shortcut::Dictation, &binding)),
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
];
|
||
let mut ids: [Option<String>; Shortcut::ALL.len()] = Default::default();
|
||
ids[Shortcut::Dictation.index()] = Some("dictation-ALT-space".into());
|
||
let granted = std::collections::HashMap::from([
|
||
(
|
||
"dictation-ALT-space".to_string(),
|
||
Some("Alt+Space".to_string()),
|
||
),
|
||
("cancel-dictation".to_string(), Some("Esc".to_string())),
|
||
]);
|
||
let names = project_grants(&granted, &requested, &ids, false)
|
||
.into_iter()
|
||
.map(|grant| (grant.name, grant.trigger))
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(
|
||
names,
|
||
[("Dictation".to_string(), Some("Alt+Space".to_string()))]
|
||
);
|
||
}
|
||
|
||
/// #635: Hyprland's portal names no trigger for any shortcut, since its
|
||
/// config assigns the keys, so a missing one there is unconfirmed, not
|
||
/// refused; elsewhere it stays a refusal.
|
||
#[test]
|
||
fn a_missing_trigger_is_unconfirmed_where_the_desktop_assigns_keys() {
|
||
let binding = LinuxHotkey::default();
|
||
let requested = [
|
||
Some((Shortcut::Dictation, &binding)),
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
];
|
||
let mut ids: [Option<String>; Shortcut::ALL.len()] = Default::default();
|
||
ids[Shortcut::Dictation.index()] = Some("dictation-ALT-space".into());
|
||
let granted = std::collections::HashMap::from([("dictation-ALT-space".to_string(), None)]);
|
||
let unconfirmed = |desktop_assigns| {
|
||
project_grants(&granted, &requested, &ids, desktop_assigns)
|
||
.into_iter()
|
||
.map(|grant| (grant.name, grant.trigger, grant.unconfirmed))
|
||
.collect::<Vec<_>>()
|
||
};
|
||
assert_eq!(unconfirmed(true), [("Dictation".to_string(), None, true)]);
|
||
assert_eq!(unconfirmed(false), [("Dictation".to_string(), None, false)]);
|
||
let bound = std::collections::HashMap::from([(
|
||
"dictation-ALT-space".to_string(),
|
||
Some("Alt+Space".to_string()),
|
||
)]);
|
||
assert!(
|
||
!project_grants(&bound, &requested, &ids, true)[0].unconfirmed,
|
||
"a named trigger is confirmed"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_numbering_skips_wheel_buttons_and_round_trips() {
|
||
for (name, native) in [
|
||
("mouse1", 1),
|
||
("mouse2", 3),
|
||
("mouse3", 2),
|
||
("mouse4", 8),
|
||
("mouse5", 9),
|
||
("mouse32", 36),
|
||
] {
|
||
assert_eq!(mouse_button(name), Some(native));
|
||
assert_eq!(mouse_name(native).as_deref(), Some(name));
|
||
let key = LinuxHotkey {
|
||
key: name.into(),
|
||
..LinuxHotkey::default()
|
||
};
|
||
assert_eq!(LinuxHotkey::from(&HotkeyBinding::from(&key)), key);
|
||
assert!(
|
||
key.portal_trigger().is_err(),
|
||
"the XDG portal has no mouse trigger syntax"
|
||
);
|
||
}
|
||
for wheel in 4..=7 {
|
||
assert_eq!(mouse_name(wheel), None);
|
||
}
|
||
assert_eq!(mouse_button("mouse33"), None);
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires an isolated X11 server (Xvfb)"]
|
||
fn x11_mouse_grab_delivers_hold_and_release() {
|
||
let harness = X11TestHarness::new();
|
||
let binding = LinuxHotkey {
|
||
control: false,
|
||
alt: false,
|
||
shift: false,
|
||
super_key: false,
|
||
key: "mouse4".into(),
|
||
};
|
||
let (monitor, _commands) = x11_monitor(binding, double_tap(), Default::default());
|
||
send_key(&harness.connection, harness.root, 4, 8);
|
||
assert_eq!(
|
||
recv_event(&monitor),
|
||
HotkeyEvent::Start {
|
||
flavour: DictationFlavour::Normal
|
||
}
|
||
);
|
||
send_key(&harness.connection, harness.root, 5, 8);
|
||
assert_eq!(recv_event(&monitor), HotkeyEvent::Finish);
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires an isolated X11 server (Xvfb)"]
|
||
fn x11_mouse_recorder_captures_auxiliary_button_and_releases_its_grab() {
|
||
use x11rb::protocol::xproto::{
|
||
AtomEnum, CreateWindowAux, InputFocus, PropMode, WindowClass,
|
||
};
|
||
use x11rb::wrapper::ConnectionExt as _;
|
||
let harness = X11TestHarness::new();
|
||
let c = &harness.connection;
|
||
let window = c.generate_id().unwrap();
|
||
c.create_window(
|
||
x11rb::COPY_DEPTH_FROM_PARENT,
|
||
window,
|
||
harness.root,
|
||
0,
|
||
0,
|
||
200,
|
||
200,
|
||
0,
|
||
WindowClass::INPUT_OUTPUT,
|
||
0,
|
||
&CreateWindowAux::new(),
|
||
)
|
||
.unwrap()
|
||
.check()
|
||
.unwrap();
|
||
let atom = c
|
||
.intern_atom(false, b"_NET_WM_PID")
|
||
.unwrap()
|
||
.reply()
|
||
.unwrap()
|
||
.atom;
|
||
c.change_property32(
|
||
PropMode::REPLACE,
|
||
window,
|
||
atom,
|
||
AtomEnum::CARDINAL,
|
||
&[std::process::id()],
|
||
)
|
||
.unwrap()
|
||
.check()
|
||
.unwrap();
|
||
c.map_window(window).unwrap().check().unwrap();
|
||
c.set_input_focus(InputFocus::PARENT, window, x11rb::CURRENT_TIME)
|
||
.unwrap()
|
||
.check()
|
||
.unwrap();
|
||
c.warp_pointer(x11rb::NONE, window, 0, 0, 0, 0, 50, 50)
|
||
.unwrap()
|
||
.check()
|
||
.unwrap();
|
||
send_key(c, harness.root, 4, 1);
|
||
let mut capture = MouseCapture::start().unwrap();
|
||
assert!(capture.poll().unwrap().is_none());
|
||
assert!(
|
||
!capture.grabbed,
|
||
"the opening click must not become the binding"
|
||
);
|
||
send_key(c, harness.root, 5, 1);
|
||
assert!(capture.poll().unwrap().is_none());
|
||
assert!(capture.grabbed);
|
||
send_key(c, harness.root, 4, 9);
|
||
let deadline = Instant::now() + Duration::from_secs(1);
|
||
let captured = loop {
|
||
if let Some(binding) = capture.poll().unwrap() {
|
||
break binding;
|
||
}
|
||
assert!(Instant::now() < deadline);
|
||
thread::sleep(Duration::from_millis(5));
|
||
};
|
||
assert_eq!(captured.key, "mouse5");
|
||
send_key(c, harness.root, 5, 9);
|
||
drop(capture);
|
||
let status = c
|
||
.grab_pointer(
|
||
false,
|
||
window,
|
||
EventMask::BUTTON_PRESS,
|
||
GrabMode::ASYNC,
|
||
GrabMode::ASYNC,
|
||
x11rb::NONE,
|
||
x11rb::NONE,
|
||
x11rb::CURRENT_TIME,
|
||
)
|
||
.unwrap()
|
||
.reply()
|
||
.unwrap()
|
||
.status;
|
||
assert_eq!(status, x11rb::protocol::xproto::GrabStatus::SUCCESS);
|
||
c.ungrab_pointer(x11rb::CURRENT_TIME)
|
||
.unwrap()
|
||
.check()
|
||
.unwrap();
|
||
c.destroy_window(window).unwrap().check().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn default_binding_is_alt_space() {
|
||
assert_eq!(LinuxHotkey::default().label(), "Alt+Space");
|
||
}
|
||
|
||
#[test]
|
||
fn modifier_only_bindings_are_rejected() {
|
||
let binding = LinuxHotkey {
|
||
key: String::new(),
|
||
..LinuxHotkey::default()
|
||
};
|
||
assert!(binding.validate().is_err());
|
||
assert!(binding.portal_trigger().is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn standalone_function_keys_are_accepted() {
|
||
let binding = LinuxHotkey {
|
||
alt: false,
|
||
key: "f12".into(),
|
||
..LinuxHotkey::default()
|
||
};
|
||
assert_eq!(binding.label(), "F12");
|
||
assert!(binding.validate().is_ok());
|
||
}
|
||
|
||
/// Issue 767: Menu, Pause, and Scroll Lock, as a saved binding names
|
||
/// them, reach the portal and X11 under their xkb names.
|
||
#[test]
|
||
fn special_keys_have_portal_and_x11_names() {
|
||
for (name, portal, keysym_value) in [
|
||
("menu", "Menu", 0xff67),
|
||
("pause", "Pause", 0xff13),
|
||
("scrolllock", "Scroll_Lock", 0xff14),
|
||
] {
|
||
let saved = HotkeyBinding {
|
||
modifiers: HotkeyModifiers::default(),
|
||
key: Some(HotkeyKey::named(name)),
|
||
};
|
||
let hotkey = LinuxHotkey::from(&saved);
|
||
assert!(hotkey.validate().is_ok());
|
||
assert_eq!(hotkey.portal_trigger().unwrap(), portal);
|
||
assert_eq!(keysym(&hotkey.key).unwrap(), keysym_value);
|
||
assert_eq!(HotkeyBinding::from(&hotkey), saved);
|
||
}
|
||
}
|
||
|
||
/// Issue 1050: the § key, saved as `§` or named `section`, reaches the
|
||
/// portal as `section` and X11 as XK_section, and shows as §.
|
||
#[test]
|
||
fn the_section_key_has_portal_and_x11_names() {
|
||
for name in ["§", "section"] {
|
||
let saved = HotkeyBinding {
|
||
modifiers: HotkeyModifiers::default(),
|
||
key: Some(HotkeyKey::named(name)),
|
||
};
|
||
let hotkey = LinuxHotkey::from(&saved);
|
||
assert!(hotkey.validate().is_ok());
|
||
assert_eq!(hotkey.label(), "§");
|
||
assert_eq!(hotkey.portal_trigger().unwrap(), "section");
|
||
assert_eq!(keysym(&hotkey.key).unwrap(), 0xa7);
|
||
assert_eq!(HotkeyBinding::from(&hotkey), saved);
|
||
}
|
||
}
|
||
|
||
/// #846: navigation and editing keys GNOME's accelerators accept reach
|
||
/// the portal under the same xkb names, alone or with modifiers.
|
||
#[test]
|
||
fn navigation_keys_have_portal_triggers() {
|
||
for (name, portal) in [
|
||
("home", "Home"),
|
||
("end", "End"),
|
||
("pageup", "Page_Up"),
|
||
("pagedown", "Page_Down"),
|
||
("up", "Up"),
|
||
("down", "Down"),
|
||
("left", "Left"),
|
||
("right", "Right"),
|
||
("insert", "Insert"),
|
||
("delete", "Delete"),
|
||
("print", "Print"),
|
||
] {
|
||
let saved = HotkeyBinding {
|
||
modifiers: HotkeyModifiers::default(),
|
||
key: Some(HotkeyKey::named(name)),
|
||
};
|
||
let hotkey = LinuxHotkey::from(&saved);
|
||
assert!(hotkey.validate().is_ok());
|
||
assert_eq!(hotkey.portal_trigger().unwrap(), portal);
|
||
assert_eq!(
|
||
crate::linux::gnome_shortcuts::named_keysym(name),
|
||
Some(portal)
|
||
);
|
||
assert_eq!(HotkeyBinding::from(&hotkey), saved);
|
||
let with_control = LinuxHotkey {
|
||
control: true,
|
||
..hotkey
|
||
};
|
||
assert_eq!(
|
||
with_control.portal_trigger().unwrap(),
|
||
format!("CTRL+{portal}")
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn hotkeys_round_trip_through_the_portable_binding() {
|
||
let hotkey = LinuxHotkey {
|
||
control: true,
|
||
super_key: true,
|
||
key: "f12".into(),
|
||
..LinuxHotkey::default()
|
||
};
|
||
assert_eq!(LinuxHotkey::from(&HotkeyBinding::from(&hotkey)), hotkey);
|
||
}
|
||
|
||
#[test]
|
||
fn portal_triggers_use_xdg_modifiers_and_xkb_key_names() {
|
||
let trigger = |control, alt, shift, super_key, key: &str| {
|
||
LinuxHotkey {
|
||
control,
|
||
alt,
|
||
shift,
|
||
super_key,
|
||
key: key.into(),
|
||
}
|
||
.portal_trigger()
|
||
.unwrap()
|
||
};
|
||
assert_eq!(trigger(false, true, false, false, "space"), "ALT+space");
|
||
assert_eq!(
|
||
trigger(true, true, true, true, "f9"),
|
||
"CTRL+ALT+SHIFT+LOGO+F9"
|
||
);
|
||
assert_eq!(trigger(true, false, false, false, "-"), "CTRL+minus");
|
||
assert_eq!(trigger(true, false, false, false, "enter"), "CTRL+Return");
|
||
assert_eq!(trigger(false, false, false, true, "Q"), "LOGO+q");
|
||
assert_eq!(trigger(false, false, false, false, "7"), "7");
|
||
for unsupported in ["f25", "escape", "é"] {
|
||
assert!(
|
||
LinuxHotkey {
|
||
key: unsupported.into(),
|
||
..LinuxHotkey::default()
|
||
}
|
||
.portal_trigger()
|
||
.is_err()
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_changed_portal_trigger_gets_a_new_shortcut_id() {
|
||
let id =
|
||
|trigger: &str| portal_shortcut_id(Shortcut::Dictation.portal_id().0, trigger, false);
|
||
assert_eq!(id("ALT+space"), "dictation-ALT-space");
|
||
assert_eq!(id("ALT+space"), id("ALT+space"));
|
||
assert_ne!(id("ALT+space"), id("CTRL+ALT+space"));
|
||
let granted = std::collections::HashMap::from([
|
||
(
|
||
"dictation".to_string(),
|
||
Some("Press <alt>space".to_string()),
|
||
),
|
||
(id("CTRL+F9"), Some("Press <ctrl>F9".to_string())),
|
||
]);
|
||
let mut ids: [Option<String>; Shortcut::ALL.len()] = Default::default();
|
||
ids[Shortcut::Dictation.index()] = Some(id("CTRL+F9"));
|
||
assert_eq!(
|
||
granted_trigger(&granted, &ids, Shortcut::Dictation),
|
||
Some("Press <ctrl>F9")
|
||
);
|
||
assert_eq!(granted_trigger(&granted, &ids, Shortcut::PasteLast), None);
|
||
}
|
||
|
||
/// #1376: Hyprland binds Hex's shortcuts by name in its config and
|
||
/// ignores the trigger, so a key recorded in Hex keeps the same name.
|
||
#[test]
|
||
fn a_desktop_that_assigns_keys_keeps_one_shortcut_id() {
|
||
let id = |shortcut: Shortcut, trigger: &str| {
|
||
portal_shortcut_id(shortcut.portal_id().0, trigger, true)
|
||
};
|
||
assert_eq!(id(Shortcut::Dictation, "ALT+space"), "dictation");
|
||
assert_eq!(id(Shortcut::Dictation, "CTRL+ALT+j"), "dictation");
|
||
assert_eq!(id(Shortcut::PasteLast, "CTRL+ALT+v"), "paste-last");
|
||
assert_eq!(id(Shortcut::Capture, "F9"), "capture");
|
||
assert_eq!(id(Shortcut::RawDictation, "CTRL+r"), "raw-dictation");
|
||
assert_eq!(
|
||
id(Shortcut::TerminalPaste, "CTRL+SHIFT+t"),
|
||
"terminal-paste"
|
||
);
|
||
}
|
||
|
||
/// A config written for 3.3 binds `anomaly.hex:dictation-ALT-space`;
|
||
/// Hex binds that name too, for the key it was written for.
|
||
#[test]
|
||
fn hyprland_keeps_the_names_3_3_bound() {
|
||
assert_eq!(
|
||
hyprland_legacy_id(Shortcut::Dictation, "ALT+space").as_deref(),
|
||
Some("dictation-ALT-space")
|
||
);
|
||
assert_eq!(
|
||
hyprland_legacy_id(Shortcut::PasteLast, "CTRL+ALT+v").as_deref(),
|
||
Some("paste-last-CTRL-ALT-v")
|
||
);
|
||
assert_eq!(
|
||
hyprland_legacy_id(Shortcut::RawDictation, "F9").as_deref(),
|
||
Some("raw-dictation-F9")
|
||
);
|
||
// Shortcuts 3.3 didn't have were never bound by the old names.
|
||
assert_eq!(hyprland_legacy_id(Shortcut::TerminalPaste, "CTRL+t"), None);
|
||
assert_eq!(hyprland_legacy_id(Shortcut::SecondDictation, "F10"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn hold_starts_on_press_and_finishes_on_release() {
|
||
let mut test = Test::new(false);
|
||
test.press(Shortcut::Dictation);
|
||
test.now += Duration::from_millis(500);
|
||
test.release(Shortcut::Dictation);
|
||
test.now += Duration::from_millis(20);
|
||
test.tap();
|
||
assert_eq!(test.events, [START, Finish, START, Finish]);
|
||
}
|
||
|
||
#[test]
|
||
fn duplicate_presses_and_releases_do_not_start_finish_or_lock() {
|
||
let mut test = Test::new(true);
|
||
test.press(Shortcut::Dictation);
|
||
test.press(Shortcut::Dictation);
|
||
test.release(Shortcut::Dictation);
|
||
test.now += Duration::from_secs(1);
|
||
test.release(Shortcut::Dictation);
|
||
assert_eq!(test.events, [START, Finish]);
|
||
}
|
||
|
||
#[test]
|
||
fn double_tap_window_has_an_explicit_boundary() {
|
||
for (delay, expected) in [
|
||
(299, vec![START, Finish, START]),
|
||
(300, vec![START, Finish, START, Finish]),
|
||
] {
|
||
let mut test = Test::new(true);
|
||
test.tap();
|
||
test.now += Duration::from_millis(delay);
|
||
test.tap();
|
||
assert_eq!(test.events, expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn second_tap_locks_until_the_shortcut_is_pressed_again() {
|
||
let mut test = Test::new(true);
|
||
test.lock();
|
||
test.release(Shortcut::Dictation);
|
||
assert!(test.events.is_empty());
|
||
test.press(Shortcut::Dictation);
|
||
assert_eq!(test.events, [Finish]);
|
||
// The release of the finishing press does nothing.
|
||
test.release(Shortcut::Dictation);
|
||
test.now += Duration::from_secs(1);
|
||
test.tap();
|
||
assert_eq!(test.events, [Finish, START, Finish]);
|
||
}
|
||
|
||
#[test]
|
||
fn double_tap_only_ignores_a_single_hold_and_locks_on_the_second_tap() {
|
||
let mut test = Test::with(HotkeyOptions {
|
||
double_tap: true,
|
||
double_tap_only: true,
|
||
..HotkeyOptions::default()
|
||
});
|
||
test.press(Shortcut::Dictation);
|
||
test.now += Duration::from_millis(600);
|
||
test.release(Shortcut::Dictation);
|
||
assert!(test.events.is_empty(), "a hold alone never records");
|
||
test.now += Duration::from_secs(1);
|
||
test.tap();
|
||
test.now += Duration::from_millis(20);
|
||
test.tap();
|
||
assert_eq!(test.events, [START]);
|
||
assert_eq!(test.state.capture, Capture::Locked);
|
||
test.tap();
|
||
assert_eq!(test.events, [START, Finish]);
|
||
}
|
||
|
||
#[test]
|
||
fn toggle_mode_starts_on_a_tap_and_finishes_on_the_next() {
|
||
for double_tap in [false, true] {
|
||
let mut test = Test::with(HotkeyOptions {
|
||
double_tap,
|
||
toggle: true,
|
||
..HotkeyOptions::default()
|
||
});
|
||
test.tap();
|
||
assert_eq!(test.events, [START]);
|
||
assert_eq!(test.state.capture, Capture::Locked);
|
||
test.now += Duration::from_secs(5);
|
||
test.tap();
|
||
assert_eq!(test.events, [START, Finish]);
|
||
// A quick third tap starts a fresh capture rather than locking a double tap.
|
||
test.now += Duration::from_millis(20);
|
||
test.tap();
|
||
assert_eq!(test.events, [START, Finish, START]);
|
||
test.input(Input::Escape);
|
||
assert_eq!(test.events, [START, Finish, START]);
|
||
assert_eq!(test.state.capture, Capture::Locked);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_capture_that_would_lock_finishes_when_the_desktop_drops_its_release() {
|
||
// Hyprland's Lua binds send no release when Alt comes up before Z.
|
||
for toggle in [true, false] {
|
||
let mut test = Test::with(HotkeyOptions {
|
||
double_tap: !toggle,
|
||
toggle,
|
||
..HotkeyOptions::default()
|
||
});
|
||
if !toggle {
|
||
test.tap();
|
||
test.now += Duration::from_millis(20);
|
||
}
|
||
test.press(Shortcut::Dictation);
|
||
assert_eq!(test.state.capture, Capture::Held { second_tap: true });
|
||
test.events.clear();
|
||
test.now += Duration::from_secs(5);
|
||
test.press(Shortcut::Dictation);
|
||
assert_eq!(test.events, [Finish]);
|
||
assert_eq!(test.state.capture, Capture::Idle);
|
||
test.release(Shortcut::Dictation);
|
||
test.now += Duration::from_secs(1);
|
||
test.press(Shortcut::Dictation);
|
||
assert_eq!(test.events, [Finish, START]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_hold_whose_release_never_arrives_finishes_on_the_next_new_press() {
|
||
// GNOME's portal repeats a held shortcut's activation and sends no
|
||
// deactivation when Ctrl comes up before Space.
|
||
for double_tap in [false, true] {
|
||
let mut test = Test::new(double_tap);
|
||
test.press(Shortcut::Dictation);
|
||
for _ in 0..20 {
|
||
test.now += Duration::from_millis(33);
|
||
test.press(Shortcut::Dictation);
|
||
}
|
||
assert_eq!(test.events, [START], "repeats never finish the hold");
|
||
assert_eq!(test.state.capture, Capture::Held { second_tap: false });
|
||
// Ctrl up first: the repeats stop and no release comes.
|
||
test.now += Duration::from_secs(3);
|
||
test.press(Shortcut::Dictation);
|
||
assert_eq!(test.events, [START, Finish]);
|
||
// The finishing press's own repeats and release start nothing.
|
||
for _ in 0..20 {
|
||
test.now += Duration::from_millis(33);
|
||
test.press(Shortcut::Dictation);
|
||
}
|
||
test.release(Shortcut::Dictation);
|
||
assert_eq!(test.events, [START, Finish]);
|
||
assert_eq!(test.state.capture, Capture::Idle);
|
||
test.now += Duration::from_secs(1);
|
||
test.tap();
|
||
assert_eq!(test.events, [START, Finish, START, Finish]);
|
||
}
|
||
}
|
||
|
||
/// #1227: GNOME sends no release when a modifier comes up first, so a
|
||
/// hold whose repeats stop finishes by itself, and a press right after
|
||
/// starts a fresh capture rather than locking a double tap.
|
||
#[test]
|
||
fn a_hold_whose_repeats_stop_without_a_release_finishes_by_itself() {
|
||
for double_tap in [false, true] {
|
||
let mut test = Test::new(double_tap);
|
||
test.press(Shortcut::Dictation);
|
||
for _ in 0..20 {
|
||
test.now += Duration::from_millis(33);
|
||
test.press(Shortcut::Dictation);
|
||
}
|
||
test.now += Duration::from_millis(500);
|
||
test.expire();
|
||
assert_eq!(test.events, [START], "repeats may still come");
|
||
test.now += Duration::from_millis(500);
|
||
test.expire();
|
||
assert_eq!(test.events, [START, Finish]);
|
||
assert_eq!(test.state.capture, Capture::Idle);
|
||
test.expire();
|
||
assert_eq!(test.events, [START, Finish]);
|
||
// A quick press after it is a new capture, never a lock.
|
||
test.now += Duration::from_millis(100);
|
||
test.tap();
|
||
assert_eq!(test.events, [START, Finish, START, Finish]);
|
||
assert_eq!(test.state.capture, Capture::Idle);
|
||
// A hold that never repeated has nothing to go on: it waits for
|
||
// its release or the next press.
|
||
test.now += Duration::from_secs(1);
|
||
test.press(Shortcut::Dictation);
|
||
test.now += Duration::from_secs(5);
|
||
test.expire();
|
||
assert_eq!(test.events, [START, Finish, START, Finish, START]);
|
||
test.release(Shortcut::Dictation);
|
||
assert_eq!(test.events, [START, Finish, START, Finish, START, Finish]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_balanced_release_finishes_a_repeating_hold() {
|
||
// Space first, or both together: the desktop sends the release.
|
||
let mut test = Test::new(false);
|
||
test.press(Shortcut::Dictation);
|
||
for _ in 0..20 {
|
||
test.now += Duration::from_millis(33);
|
||
test.press(Shortcut::Dictation);
|
||
}
|
||
test.release(Shortcut::Dictation);
|
||
assert_eq!(test.events, [START, Finish]);
|
||
// A quick new press after a release is a press, not a repeat.
|
||
test.now += Duration::from_millis(100);
|
||
test.press(Shortcut::Dictation);
|
||
assert_eq!(test.events, [START, Finish, START]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_repeating_held_press_never_finishes_the_capture() {
|
||
// KWin repeats a held global shortcut's press.
|
||
let mut test = Test::with(HotkeyOptions {
|
||
toggle: true,
|
||
..HotkeyOptions::default()
|
||
});
|
||
test.press(Shortcut::Dictation);
|
||
test.now += Duration::from_millis(900);
|
||
for _ in 0..100 {
|
||
test.press(Shortcut::Dictation);
|
||
test.now += Duration::from_millis(40);
|
||
}
|
||
test.release(Shortcut::Dictation);
|
||
assert_eq!(test.events, [START]);
|
||
assert_eq!(test.state.capture, Capture::Locked);
|
||
}
|
||
|
||
fn tap_or_hold() -> HotkeyOptions {
|
||
HotkeyOptions {
|
||
toggle: true,
|
||
tap_or_hold: true,
|
||
..HotkeyOptions::default()
|
||
}
|
||
}
|
||
|
||
/// #1144: with Labs › Tap or hold, a held Toggle press dictates until it
|
||
/// lifts, and a short tap still locks until the next press.
|
||
#[test]
|
||
fn tap_or_hold_finishes_a_held_press_and_locks_a_tap() {
|
||
let mut test = Test::with(tap_or_hold());
|
||
test.press(Shortcut::Dictation);
|
||
test.now += Duration::from_millis(600);
|
||
test.release(Shortcut::Dictation);
|
||
assert_eq!(test.events, [START, Finish]);
|
||
assert_eq!(test.state.capture, Capture::Idle);
|
||
|
||
test.now += Duration::from_secs(1);
|
||
test.press(Shortcut::Dictation);
|
||
test.now += Duration::from_millis(299);
|
||
test.release(Shortcut::Dictation);
|
||
assert_eq!(test.events, [START, Finish, START]);
|
||
assert_eq!(test.state.capture, Capture::Locked);
|
||
test.now += Duration::from_secs(2);
|
||
test.press(Shortcut::Dictation);
|
||
test.now += Duration::from_millis(600);
|
||
test.release(Shortcut::Dictation);
|
||
assert_eq!(test.events, [START, Finish, START, Finish]);
|
||
assert_eq!(test.state.capture, Capture::Idle);
|
||
}
|
||
|
||
/// Off, or in Hold mode, a held Toggle press still locks as before.
|
||
#[test]
|
||
fn without_tap_or_hold_a_held_toggle_press_locks() {
|
||
for options in [
|
||
HotkeyOptions {
|
||
tap_or_hold: false,
|
||
..tap_or_hold()
|
||
},
|
||
HotkeyOptions {
|
||
toggle: false,
|
||
double_tap: true,
|
||
..tap_or_hold()
|
||
},
|
||
] {
|
||
let toggle = options.toggle;
|
||
let mut test = Test::with(options);
|
||
if !toggle {
|
||
test.tap();
|
||
test.now += Duration::from_millis(20);
|
||
}
|
||
test.events.clear();
|
||
test.press(Shortcut::Dictation);
|
||
test.now += Duration::from_millis(600);
|
||
test.release(Shortcut::Dictation);
|
||
assert_eq!(test.events, [START]);
|
||
assert_eq!(test.state.capture, Capture::Locked);
|
||
}
|
||
}
|
||
|
||
/// Repeats of a held press neither finish early nor restart the hold,
|
||
/// and a dropped release leaves the capture recording until a new press.
|
||
#[test]
|
||
fn tap_or_hold_survives_repeats_and_dropped_releases() {
|
||
let mut test = Test::with(tap_or_hold());
|
||
test.press(Shortcut::Dictation);
|
||
for _ in 0..20 {
|
||
test.now += Duration::from_millis(33);
|
||
test.press(Shortcut::Dictation);
|
||
}
|
||
assert_eq!(test.events, [START]);
|
||
test.release(Shortcut::Dictation);
|
||
assert_eq!(
|
||
test.events,
|
||
[START, Finish],
|
||
"held 660 ms from the first press"
|
||
);
|
||
|
||
test.now += Duration::from_secs(1);
|
||
test.press(Shortcut::Dictation);
|
||
test.now += Duration::from_secs(5);
|
||
assert_eq!(test.state.capture, Capture::Held { second_tap: true });
|
||
test.press(Shortcut::Dictation);
|
||
assert_eq!(test.events, [START, Finish, START, Finish]);
|
||
test.release(Shortcut::Dictation);
|
||
assert_eq!(test.events, [START, Finish, START, Finish]);
|
||
assert_eq!(test.state.capture, Capture::Idle);
|
||
}
|
||
|
||
/// Dictate without processing follows Tap or hold for its own capture,
|
||
/// and commands are unchanged.
|
||
#[test]
|
||
fn tap_or_hold_applies_to_raw_dictation_but_not_commands() {
|
||
const RAW: HotkeyEvent = HotkeyEvent::Start {
|
||
flavour: DictationFlavour::Raw,
|
||
};
|
||
let mut test = Test::with(tap_or_hold());
|
||
test.press(Shortcut::RawDictation);
|
||
test.now += Duration::from_millis(500);
|
||
test.tap();
|
||
test.release(Shortcut::RawDictation);
|
||
assert_eq!(test.events, [RAW, Finish]);
|
||
test.now += Duration::from_secs(1);
|
||
test.command(Command::DictationStart);
|
||
test.now += Duration::from_secs(1);
|
||
test.command(Command::DictationStop);
|
||
assert_eq!(test.events, [RAW, Finish, START, Finish]);
|
||
}
|
||
|
||
/// #786: Escape ends no dictation, held, locked, or idle.
|
||
#[test]
|
||
fn escape_never_ends_a_dictation() {
|
||
let mut test = Test::new(true);
|
||
test.input(Input::Escape);
|
||
assert!(test.events.is_empty());
|
||
test.press(Shortcut::Dictation);
|
||
test.input(Input::Escape);
|
||
test.release(Shortcut::Dictation);
|
||
assert_eq!(test.events, [START, Finish]);
|
||
|
||
let mut test = Test::new(true);
|
||
test.lock();
|
||
test.input(Input::Escape);
|
||
assert_eq!(test.state.capture, Capture::Locked);
|
||
test.tap();
|
||
assert_eq!(test.events, [Finish]);
|
||
}
|
||
|
||
fn toggle_with_cancel() -> HotkeyOptions {
|
||
HotkeyOptions {
|
||
toggle: true,
|
||
cancel: Some(LinuxHotkey {
|
||
alt: false,
|
||
key: "escape".into(),
|
||
..LinuxHotkey::default()
|
||
}),
|
||
..HotkeyOptions::default()
|
||
}
|
||
}
|
||
|
||
/// Toggle's cancel discards the recording, while the shortcut is still
|
||
/// down or after it locked, and the next press starts afresh.
|
||
#[test]
|
||
fn cancel_discards_a_toggled_recording() {
|
||
let mut test = Test::with(toggle_with_cancel());
|
||
test.press(Shortcut::Dictation);
|
||
assert!(test.state.wants_cancel());
|
||
test.input(Input::Cancel);
|
||
test.release(Shortcut::Dictation);
|
||
assert_eq!(test.events, [START, Cancel]);
|
||
assert_eq!(test.state.capture, Capture::Idle);
|
||
assert!(!test.state.wants_cancel());
|
||
|
||
test.tap();
|
||
assert_eq!(test.state.capture, Capture::Locked);
|
||
test.input(Input::Cancel);
|
||
test.input(Input::Cancel);
|
||
assert_eq!(test.events, [START, Cancel, START, Cancel]);
|
||
test.tap();
|
||
test.tap();
|
||
assert_eq!(test.events, [START, Cancel, START, Cancel, START, Finish]);
|
||
}
|
||
|
||
/// Hold mode has no cancel: the key is never Hex's, even while a
|
||
/// recording runs, and a press of it ends nothing.
|
||
#[test]
|
||
fn cancel_is_ignored_in_hold_mode() {
|
||
let mut test = Test::with(HotkeyOptions {
|
||
toggle: false,
|
||
double_tap: true,
|
||
..toggle_with_cancel()
|
||
});
|
||
test.press(Shortcut::Dictation);
|
||
assert!(!test.state.wants_cancel());
|
||
test.input(Input::Cancel);
|
||
test.release(Shortcut::Dictation);
|
||
assert_eq!(test.events, [START, Finish]);
|
||
test.events.clear();
|
||
test.now += Duration::from_millis(20);
|
||
test.tap();
|
||
test.input(Input::Cancel);
|
||
assert_eq!(test.state.capture, Capture::Locked);
|
||
assert!(test.events.iter().all(|event| *event != Cancel));
|
||
}
|
||
|
||
/// While nothing records, Hex doesn't take the cancel key, so it stays
|
||
/// with the app in front.
|
||
#[test]
|
||
fn cancel_is_not_taken_while_idle() {
|
||
let mut test = Test::with(toggle_with_cancel());
|
||
assert!(!test.state.wants_cancel());
|
||
test.input(Input::Cancel);
|
||
assert!(test.events.is_empty());
|
||
test.tap();
|
||
test.tap();
|
||
assert!(!test.state.wants_cancel());
|
||
assert_eq!(test.events, [START, Finish]);
|
||
// Toggle without a cancel shortcut never takes one either.
|
||
let mut test = Test::with(HotkeyOptions {
|
||
cancel: None,
|
||
..toggle_with_cancel()
|
||
});
|
||
test.tap();
|
||
assert!(!test.state.wants_cancel());
|
||
}
|
||
|
||
#[test]
|
||
fn lost_input_cancels_and_forgets_taps() {
|
||
for locked in [false, true] {
|
||
let mut test = Test::new(true);
|
||
if locked {
|
||
test.lock();
|
||
} else {
|
||
test.press(Shortcut::Dictation);
|
||
test.events.clear();
|
||
}
|
||
test.input(Input::Lost);
|
||
assert_eq!(test.events, [Cancel]);
|
||
assert_eq!(test.state.capture, Capture::Idle);
|
||
assert!(test.state.last_release.is_none());
|
||
test.release(Shortcut::Dictation);
|
||
test.tap();
|
||
assert_eq!(test.events, [Cancel, START, Finish]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn paste_last_fires_only_while_idle() {
|
||
let mut test = Test::new(true);
|
||
test.press(Shortcut::PasteLast);
|
||
test.release(Shortcut::PasteLast);
|
||
test.command(Command::PasteLast);
|
||
assert_eq!(test.events, [PasteLast, PasteLast]);
|
||
test.press(Shortcut::Dictation);
|
||
test.press(Shortcut::PasteLast);
|
||
test.command(Command::PasteLast);
|
||
assert_eq!(test.events, [PasteLast, PasteLast, START]);
|
||
}
|
||
|
||
#[test]
|
||
fn typed_text_fires_only_while_idle_and_changes_no_capture_state() {
|
||
let mut test = Test::new(true);
|
||
test.press(Shortcut::TypedText);
|
||
test.release(Shortcut::TypedText);
|
||
test.command(Command::TypedText);
|
||
assert_eq!(
|
||
test.events,
|
||
[HotkeyEvent::TypedText, HotkeyEvent::TypedText]
|
||
);
|
||
assert_eq!(test.state.capture, Capture::Idle);
|
||
// Recording owns the shortcut and `hex typed-text`: no job and no
|
||
// state change.
|
||
test.press(Shortcut::Dictation);
|
||
test.events.clear();
|
||
test.press(Shortcut::TypedText);
|
||
test.release(Shortcut::TypedText);
|
||
test.command(Command::TypedText);
|
||
assert!(test.events.is_empty());
|
||
assert!(matches!(test.state.capture, Capture::Held { .. }));
|
||
assert_eq!(
|
||
Shortcut::TypedText.portal_id(),
|
||
("typed-text", "Transform typed text")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dictation_commands_are_idempotent() {
|
||
let mut test = Test::new(true);
|
||
test.command(Command::DictationStop);
|
||
assert!(test.events.is_empty(), "stopping while idle does nothing");
|
||
test.command(Command::DictationStart);
|
||
test.command(Command::DictationStart);
|
||
assert_eq!(
|
||
test.events,
|
||
[START],
|
||
"starting while recording does nothing"
|
||
);
|
||
test.command(Command::DictationStop);
|
||
test.command(Command::DictationStop);
|
||
assert_eq!(test.events, [START, Finish]);
|
||
// A stop never counts as the first tap of a double-tap.
|
||
test.tap();
|
||
assert_eq!(test.events, [START, Finish, START, Finish]);
|
||
}
|
||
|
||
#[test]
|
||
fn dictation_toggle_commands() {
|
||
let mut test = Test::new(false);
|
||
test.command(Command::DictationToggle);
|
||
test.command(Command::DictationToggle);
|
||
test.command(Command::DictationToggle);
|
||
test.command(Command::DictationCancel);
|
||
test.command(Command::DictationCancel);
|
||
assert_eq!(test.events, [START, Finish, START, Cancel]);
|
||
}
|
||
|
||
#[test]
|
||
fn commands_and_shortcuts_share_one_capture() {
|
||
let mut test = Test::new(true);
|
||
// A shortcut press finishes a capture a command started.
|
||
test.command(Command::DictationStart);
|
||
test.tap();
|
||
// A stop command finishes a held shortcut.
|
||
test.now += Duration::from_secs(1);
|
||
test.press(Shortcut::Dictation);
|
||
test.command(Command::DictationStop);
|
||
test.release(Shortcut::Dictation);
|
||
// A locked double-tap stops through toggle.
|
||
test.now += Duration::from_secs(1);
|
||
test.lock_from(&[START, Finish, START, Finish]);
|
||
test.command(Command::DictationToggle);
|
||
assert_eq!(
|
||
test.events,
|
||
[START, Finish, START, Finish, START, Finish, START, Finish]
|
||
);
|
||
}
|
||
|
||
impl Test {
|
||
fn lock_from(&mut self, before: &[HotkeyEvent]) {
|
||
self.tap();
|
||
self.now += Duration::from_millis(20);
|
||
self.tap();
|
||
let mut expected = before.to_vec();
|
||
expected.extend([START, Finish, START]);
|
||
assert_eq!(self.events, expected);
|
||
assert_eq!(self.state.capture, Capture::Locked);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn x11_auto_repeat_pairs_never_release_the_shortcut() {
|
||
let start = Instant::now();
|
||
let mut keys = X11Keys::default();
|
||
assert_eq!(
|
||
keys.press(Shortcut::Dictation),
|
||
Some(Input::Pressed(Shortcut::Dictation))
|
||
);
|
||
for repeat in 1..5 {
|
||
let at = start + Duration::from_millis(30 * repeat);
|
||
keys.release(Shortcut::Dictation, at);
|
||
assert!(keys.expire(at + Duration::from_millis(10)).is_empty());
|
||
assert_eq!(keys.press(Shortcut::Dictation), None);
|
||
}
|
||
// Detectable auto-repeat sends presses alone.
|
||
assert_eq!(keys.press(Shortcut::Dictation), None);
|
||
assert!(keys.held(Shortcut::Dictation));
|
||
}
|
||
|
||
#[test]
|
||
fn x11_release_counts_after_the_grace_period() {
|
||
let start = Instant::now();
|
||
let mut keys = X11Keys::default();
|
||
keys.press(Shortcut::Dictation);
|
||
keys.release(Shortcut::Dictation, start);
|
||
assert!(keys.expire(start + RELEASE_GRACE / 2).is_empty());
|
||
assert_eq!(
|
||
keys.expire(start + RELEASE_GRACE),
|
||
[Input::Released(Shortcut::Dictation)]
|
||
);
|
||
assert!(keys.expire(start + RELEASE_GRACE * 2).is_empty());
|
||
assert!(!keys.held(Shortcut::Dictation));
|
||
assert_eq!(
|
||
keys.press(Shortcut::Dictation),
|
||
Some(Input::Pressed(Shortcut::Dictation))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn x11_ignores_releases_of_keys_it_never_saw_pressed() {
|
||
let start = Instant::now();
|
||
let mut keys = X11Keys::default();
|
||
keys.release(Shortcut::PasteLast, start);
|
||
assert!(keys.expire(start + Duration::from_secs(1)).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn x11_shortcuts_track_independently() {
|
||
let start = Instant::now();
|
||
let mut keys = X11Keys::default();
|
||
keys.press(Shortcut::Dictation);
|
||
keys.press(Shortcut::PasteLast);
|
||
keys.release(Shortcut::PasteLast, start);
|
||
assert_eq!(
|
||
keys.expire(start + RELEASE_GRACE),
|
||
[Input::Released(Shortcut::PasteLast)]
|
||
);
|
||
assert!(keys.held(Shortcut::Dictation));
|
||
}
|
||
|
||
/// #638: with detectable auto-repeat, a release under 50 ms before the
|
||
/// next press still counts, so a fast double tap starts double-tap-only
|
||
/// dictation; a held key's repeated presses stay ignored.
|
||
#[test]
|
||
fn x11_detectable_repeat_releases_at_once_for_fast_double_taps() {
|
||
let mut keys = X11Keys {
|
||
detectable_repeat: true,
|
||
..X11Keys::default()
|
||
};
|
||
let mut test = Test::with(HotkeyOptions {
|
||
double_tap: true,
|
||
double_tap_only: true,
|
||
..HotkeyOptions::default()
|
||
});
|
||
let feed = |input: Option<Input>, test: &mut Test| {
|
||
if let Some(input) = input {
|
||
test.input(input);
|
||
}
|
||
};
|
||
feed(keys.press(Shortcut::Dictation), &mut test);
|
||
test.now += Duration::from_millis(40);
|
||
let released = keys.release(Shortcut::Dictation, test.now);
|
||
assert_eq!(released, Some(Input::Released(Shortcut::Dictation)));
|
||
feed(released, &mut test);
|
||
test.now += Duration::from_millis(20);
|
||
feed(keys.press(Shortcut::Dictation), &mut test);
|
||
assert_eq!(test.events, [START]);
|
||
// Held: the server repeats only the press.
|
||
for _ in 0..5 {
|
||
test.now += Duration::from_millis(30);
|
||
assert_eq!(keys.press(Shortcut::Dictation), None);
|
||
}
|
||
assert!(keys.expire(test.now + RELEASE_GRACE).is_empty());
|
||
feed(keys.release(Shortcut::Dictation, test.now), &mut test);
|
||
assert_eq!(test.events, [START]);
|
||
assert_eq!(test.state.capture, Capture::Locked);
|
||
}
|
||
|
||
use std::sync::MutexGuard;
|
||
use x11rb::protocol::xtest;
|
||
|
||
static X11_TEST_LOCK: Mutex<()> = Mutex::new(());
|
||
|
||
struct X11TestHarness {
|
||
_guard: MutexGuard<'static, ()>,
|
||
connection: RustConnection,
|
||
root: Window,
|
||
keymap: Keymap,
|
||
alt: u8,
|
||
space: u8,
|
||
escape: u8,
|
||
}
|
||
|
||
impl X11TestHarness {
|
||
fn new() -> Self {
|
||
let guard = X11_TEST_LOCK
|
||
.lock()
|
||
.unwrap_or_else(|error| error.into_inner());
|
||
let (connection, root, keymap) = connect_x11().unwrap();
|
||
let alt = keymap.keycode(XK_ALT_L).unwrap();
|
||
let space = keymap.keycode(XK_SPACE).unwrap();
|
||
let escape = keymap.keycode(XK_ESCAPE).unwrap();
|
||
Self {
|
||
_guard: guard,
|
||
connection,
|
||
root,
|
||
keymap,
|
||
alt,
|
||
space,
|
||
escape,
|
||
}
|
||
}
|
||
|
||
fn send_key(&self, type_: u8, key: u8) {
|
||
send_key(&self.connection, self.root, type_, key);
|
||
}
|
||
|
||
fn tap_alt_space(&self) {
|
||
for (type_, key) in [
|
||
(KEY_PRESS, self.alt),
|
||
(KEY_PRESS, self.space),
|
||
(KEY_RELEASE, self.space),
|
||
(KEY_RELEASE, self.alt),
|
||
] {
|
||
self.send_key(type_, key);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn recv_event(monitor: &LinuxHotkeyMonitor) -> HotkeyEvent {
|
||
monitor.events.recv_timeout(Duration::from_secs(1)).unwrap()
|
||
}
|
||
|
||
fn send_key(connection: &RustConnection, root: Window, type_: u8, key: u8) {
|
||
xtest::fake_input(connection, type_, key, 0, root, 0, 0, 0)
|
||
.unwrap()
|
||
.check()
|
||
.unwrap();
|
||
connection.flush().unwrap();
|
||
}
|
||
|
||
fn x11_monitor(
|
||
binding: LinuxHotkey,
|
||
options: HotkeyOptions,
|
||
status: HotkeyStatus,
|
||
) -> (LinuxHotkeyMonitor, SyncSender<ListenerCommand>) {
|
||
let (commands, receiver) = mpsc::sync_channel(4);
|
||
let monitor = LinuxHotkeyMonitor::start_for(
|
||
binding,
|
||
options,
|
||
LinuxSession::X11,
|
||
status,
|
||
receiver.into(),
|
||
)
|
||
.unwrap();
|
||
(monitor, commands)
|
||
}
|
||
|
||
fn double_tap() -> HotkeyOptions {
|
||
HotkeyOptions {
|
||
double_tap: true,
|
||
..HotkeyOptions::default()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn lock_variants_preserve_the_configured_modifier() {
|
||
assert_eq!(
|
||
lock_variants(ModMask::M1, ModMask::M2),
|
||
[
|
||
ModMask::M1,
|
||
ModMask::M1 | ModMask::LOCK,
|
||
ModMask::M1 | ModMask::M2,
|
||
ModMask::M1 | ModMask::LOCK | ModMask::M2,
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn standalone_f12_resolves_to_the_x11_keysym() {
|
||
assert_eq!(keysym("f12").unwrap(), 0xffc9);
|
||
}
|
||
|
||
#[test]
|
||
fn full_event_queue_returns_an_error_instead_of_blocking_the_worker() {
|
||
let (sender, receiver) = mpsc::sync_channel(1);
|
||
assert!(
|
||
send_event(
|
||
&sender,
|
||
HotkeyEvent::Start {
|
||
flavour: DictationFlavour::Normal
|
||
}
|
||
)
|
||
.unwrap()
|
||
);
|
||
assert!(send_event(&sender, HotkeyEvent::Finish).is_err());
|
||
assert_eq!(
|
||
receiver.try_recv().unwrap(),
|
||
HotkeyEvent::Start {
|
||
flavour: DictationFlavour::Normal
|
||
}
|
||
);
|
||
drop(receiver);
|
||
assert!(!send_event(&sender, HotkeyEvent::Cancel).unwrap());
|
||
}
|
||
|
||
fn grab_error(kind: ErrorKind) -> ReplyError {
|
||
ReplyError::X11Error(x11rb::x11_utils::X11Error {
|
||
error_kind: kind,
|
||
error_code: 10,
|
||
sequence: 12,
|
||
bad_value: 1722,
|
||
minor_opcode: 0,
|
||
major_opcode: 33,
|
||
extension_name: None,
|
||
request_name: Some("GrabKey"),
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn escape_grab_conflict_is_not_fatal() {
|
||
assert!(is_grab_conflict(&grab_error(ErrorKind::Access)));
|
||
}
|
||
|
||
#[test]
|
||
fn non_conflict_grab_errors_stay_fatal() {
|
||
for kind in [ErrorKind::Value, ErrorKind::Window, ErrorKind::Match] {
|
||
assert!(
|
||
!is_grab_conflict(&grab_error(kind)),
|
||
"{kind:?} must stay fatal"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_paste_waits_for_a_modifier_shortcut_to_let_go() {
|
||
let keys = HeldKeys::default();
|
||
assert_eq!(keys.quiet_at(), None);
|
||
let now = Instant::now();
|
||
let held = HeldKeys {
|
||
held: 1 << Shortcut::PasteLast.index(),
|
||
last: Some(now),
|
||
};
|
||
assert_eq!(held.quiet_at(), Some(now + HELD_LIMIT));
|
||
let released = HeldKeys {
|
||
held: 0,
|
||
last: Some(now),
|
||
};
|
||
assert_eq!(released.quiet_at(), Some(now + MODIFIER_GRACE));
|
||
}
|
||
|
||
#[test]
|
||
fn granted_triggers_decide_whether_a_shortcut_holds_modifiers() {
|
||
let alt_space = LinuxHotkey::default();
|
||
assert!(trigger_has_modifiers(None, &alt_space));
|
||
assert!(trigger_has_modifiers(Some(""), &alt_space));
|
||
assert!(!trigger_has_modifiers(Some("Press End"), &alt_space));
|
||
assert!(trigger_has_modifiers(
|
||
Some("Press <ctrl><alt>v"),
|
||
&alt_space
|
||
));
|
||
let end = LinuxHotkey {
|
||
control: false,
|
||
alt: false,
|
||
shift: false,
|
||
super_key: false,
|
||
key: "End".into(),
|
||
};
|
||
assert!(!trigger_has_modifiers(None, &end));
|
||
}
|
||
|
||
#[test]
|
||
fn dropping_a_monitor_stops_and_joins_its_worker() {
|
||
let (_, events) = mpsc::sync_channel(1);
|
||
let (_, errors) = mpsc::channel();
|
||
let stop = Arc::new(AtomicBool::new(false));
|
||
let stopped = Arc::new(AtomicBool::new(false));
|
||
let worker_stop = stop.clone();
|
||
let worker_stopped = stopped.clone();
|
||
let worker = thread::spawn(move || {
|
||
while !worker_stop.load(Ordering::Acquire) {
|
||
thread::yield_now();
|
||
}
|
||
worker_stopped.store(true, Ordering::Release);
|
||
});
|
||
drop(LinuxHotkeyMonitor {
|
||
events,
|
||
errors,
|
||
stop,
|
||
worker: Some(worker),
|
||
});
|
||
assert!(stopped.load(Ordering::Acquire));
|
||
}
|
||
|
||
#[test]
|
||
fn commands_alone_drive_a_listener_without_shortcuts() {
|
||
let (sender, events) = mpsc::sync_channel(4);
|
||
let (commands, receiver) = mpsc::sync_channel(4);
|
||
let (ready, readiness) = mpsc::sync_channel(1);
|
||
let status = HotkeyStatus::default();
|
||
let backend = Backend {
|
||
stop: Arc::new(AtomicBool::new(false)),
|
||
ready,
|
||
started: Arc::new(AtomicBool::new(false)),
|
||
status: status.clone(),
|
||
};
|
||
let stop = backend.stop.clone();
|
||
// A starting listener claims no shortcut kind, not even X11's.
|
||
assert_eq!(status.shortcuts(), DesktopShortcuts::Unregistered);
|
||
let worker = thread::spawn(move || {
|
||
// Stopped well before its first portal check.
|
||
backend
|
||
.wait_for_portal(
|
||
&mut Dispatcher::new(
|
||
&HotkeyOptions::default(),
|
||
sender,
|
||
receiver.into(),
|
||
ShortcutKeys::default(),
|
||
),
|
||
true,
|
||
)
|
||
.map(|portal| assert!(portal.is_none()))
|
||
});
|
||
readiness.recv().unwrap().unwrap();
|
||
assert_eq!(status.shortcuts(), DesktopShortcuts::Commands);
|
||
commands.send(Command::DictationToggle.into()).unwrap();
|
||
commands.send(Command::DictationToggle.into()).unwrap();
|
||
let wait = || events.recv_timeout(Duration::from_secs(1)).unwrap();
|
||
assert_eq!([wait(), wait()], [START, Finish]);
|
||
stop.store(true, Ordering::Release);
|
||
worker.join().unwrap().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn an_idle_monitor_wakes_at_once_for_a_hex_command() {
|
||
let (sender, events) = mpsc::sync_channel(4);
|
||
let (commands, receiver) = mpsc::sync_channel(4);
|
||
let mut dispatcher = Dispatcher::new(
|
||
&HotkeyOptions::default(),
|
||
sender,
|
||
receiver.into(),
|
||
ShortcutKeys::default(),
|
||
);
|
||
let sent = thread::spawn(move || {
|
||
thread::sleep(Duration::from_millis(20));
|
||
commands.send(Command::DictationToggle.into()).unwrap();
|
||
});
|
||
let started = Instant::now();
|
||
dispatcher.wait_for_commands(Duration::from_secs(5));
|
||
assert!(dispatcher.flush().unwrap());
|
||
assert_eq!(events.recv_timeout(Duration::from_secs(1)).unwrap(), START);
|
||
assert!(started.elapsed() < Duration::from_secs(1));
|
||
sent.join().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn a_printing_stop_rides_the_command_queue_with_its_reply() {
|
||
use crate::control::{PrintReply, PrintedTranscript};
|
||
let (sender, events) = mpsc::sync_channel(4);
|
||
let (commands, receiver) = mpsc::sync_channel(4);
|
||
let mut dispatcher = Dispatcher::new(
|
||
&HotkeyOptions::default(),
|
||
sender,
|
||
receiver.into(),
|
||
ShortcutKeys::default(),
|
||
);
|
||
// Nothing records: the reply is answered at once.
|
||
let (reply, idle) = PrintReply::channel();
|
||
commands.send(ListenerCommand::StopAndPrint(reply)).unwrap();
|
||
dispatcher.commands();
|
||
assert_eq!(idle.try_recv().unwrap(), PrintedTranscript::NotRecording);
|
||
// The stop finishes in queue order and a later start isn't blocked.
|
||
let (reply, printed) = PrintReply::channel();
|
||
commands.send(Command::DictationStart.into()).unwrap();
|
||
commands.send(ListenerCommand::StopAndPrint(reply)).unwrap();
|
||
commands.send(Command::DictationStart.into()).unwrap();
|
||
dispatcher.commands();
|
||
assert!(dispatcher.flush().unwrap());
|
||
let received: Vec<_> = events.try_iter().collect();
|
||
assert!(matches!(
|
||
received.as_slice(),
|
||
[
|
||
HotkeyEvent::Start {
|
||
flavour: DictationFlavour::Normal
|
||
},
|
||
HotkeyEvent::FinishPrinting(_),
|
||
HotkeyEvent::Start {
|
||
flavour: DictationFlavour::Normal
|
||
}
|
||
]
|
||
));
|
||
// The listener owns the reply now; nothing has answered it yet.
|
||
assert!(printed.try_recv().is_err());
|
||
let Some(HotkeyEvent::FinishPrinting(reply)) = received.into_iter().nth(1) else {
|
||
unreachable!()
|
||
};
|
||
reply.send(PrintedTranscript::Text("Ship it.".into()));
|
||
assert_eq!(
|
||
printed.try_recv().unwrap(),
|
||
PrintedTranscript::Text("Ship it.".into())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires the active X11 desktop"]
|
||
fn grabbed_alt_space_delivers_press_and_release() {
|
||
let harness = X11TestHarness::new();
|
||
let (monitor, _commands) =
|
||
x11_monitor(LinuxHotkey::default(), double_tap(), Default::default());
|
||
harness.tap_alt_space();
|
||
assert_eq!(
|
||
recv_event(&monitor),
|
||
HotkeyEvent::Start {
|
||
flavour: DictationFlavour::Normal
|
||
}
|
||
);
|
||
assert_eq!(recv_event(&monitor), HotkeyEvent::Finish);
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires the active X11 desktop"]
|
||
fn second_tap_locks_until_the_trigger_is_pressed_again() {
|
||
let harness = X11TestHarness::new();
|
||
let (monitor, _commands) =
|
||
x11_monitor(LinuxHotkey::default(), double_tap(), Default::default());
|
||
|
||
harness.tap_alt_space();
|
||
assert_eq!(
|
||
recv_event(&monitor),
|
||
HotkeyEvent::Start {
|
||
flavour: DictationFlavour::Normal
|
||
}
|
||
);
|
||
assert_eq!(recv_event(&monitor), HotkeyEvent::Finish);
|
||
harness.tap_alt_space();
|
||
assert_eq!(
|
||
recv_event(&monitor),
|
||
HotkeyEvent::Start {
|
||
flavour: DictationFlavour::Normal
|
||
}
|
||
);
|
||
assert!(
|
||
monitor
|
||
.events
|
||
.recv_timeout(Duration::from_millis(100))
|
||
.is_err()
|
||
);
|
||
harness.tap_alt_space();
|
||
assert_eq!(recv_event(&monitor), HotkeyEvent::Finish);
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires the active X11 desktop"]
|
||
fn standalone_function_key_delivers_press_and_release() {
|
||
let harness = X11TestHarness::new();
|
||
let binding = LinuxHotkey {
|
||
alt: false,
|
||
key: "f24".into(),
|
||
..LinuxHotkey::default()
|
||
};
|
||
let (monitor, _commands) =
|
||
x11_monitor(binding, HotkeyOptions::default(), Default::default());
|
||
let trigger = harness.keymap.keycode(keysym("f24").unwrap()).unwrap();
|
||
harness.send_key(KEY_PRESS, trigger);
|
||
harness.send_key(KEY_RELEASE, trigger);
|
||
|
||
assert_eq!(
|
||
recv_event(&monitor),
|
||
HotkeyEvent::Start {
|
||
flavour: DictationFlavour::Normal
|
||
}
|
||
);
|
||
assert_eq!(recv_event(&monitor), HotkeyEvent::Finish);
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires the active X11 desktop"]
|
||
fn commands_and_grabs_share_the_x11_listener() {
|
||
let harness = X11TestHarness::new();
|
||
let (monitor, commands) =
|
||
x11_monitor(LinuxHotkey::default(), double_tap(), Default::default());
|
||
harness.send_key(KEY_PRESS, harness.alt);
|
||
harness.send_key(KEY_PRESS, harness.space);
|
||
assert_eq!(
|
||
recv_event(&monitor),
|
||
HotkeyEvent::Start {
|
||
flavour: DictationFlavour::Normal
|
||
}
|
||
);
|
||
commands.send(Command::DictationStop.into()).unwrap();
|
||
assert_eq!(recv_event(&monitor), HotkeyEvent::Finish);
|
||
// Releasing the held shortcut afterwards finishes nothing twice.
|
||
harness.send_key(KEY_RELEASE, harness.space);
|
||
harness.send_key(KEY_RELEASE, harness.alt);
|
||
assert!(
|
||
monitor
|
||
.events
|
||
.recv_timeout(Duration::from_millis(150))
|
||
.is_err()
|
||
);
|
||
commands.send(Command::DictationStart.into()).unwrap();
|
||
assert_eq!(
|
||
recv_event(&monitor),
|
||
HotkeyEvent::Start {
|
||
flavour: DictationFlavour::Normal
|
||
}
|
||
);
|
||
// Escape stays with applications while the capture records (#786).
|
||
thread::sleep(Duration::from_millis(50));
|
||
grab_variants(
|
||
&harness.connection,
|
||
harness.root,
|
||
harness.escape,
|
||
&[ModMask::ANY],
|
||
)
|
||
.unwrap();
|
||
harness
|
||
.connection
|
||
.ungrab_key(harness.escape, harness.root, ModMask::ANY)
|
||
.unwrap();
|
||
commands.send(Command::DictationStop.into()).unwrap();
|
||
assert_eq!(recv_event(&monitor), HotkeyEvent::Finish);
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires an isolated X11 display"]
|
||
fn conflicted_escape_fallback_releases_partial_grabs() {
|
||
let harness = X11TestHarness::new();
|
||
grab_variants(
|
||
&harness.connection,
|
||
harness.root,
|
||
harness.escape,
|
||
&[ModMask::LOCK],
|
||
)
|
||
.unwrap();
|
||
let (connection, _) = RustConnection::connect(None).unwrap();
|
||
let variants = lock_variants(ModMask::default(), ModMask::M2);
|
||
assert!(
|
||
grab_escape(&connection, harness.root, harness.escape, &variants)
|
||
.unwrap()
|
||
.is_empty()
|
||
);
|
||
// Plain Escape was grabbed before the lock variant failed. It must be freed.
|
||
grab_variants(&harness.connection, harness.root, harness.escape, &variants).unwrap();
|
||
assert!(grab_escape(&connection, 0, harness.escape, &variants).is_err());
|
||
}
|
||
}
|