Files

3144 lines
113 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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());
}
}