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::linux::session::LinuxSession;
#[path = "input/x11.rs"]
mod x11;
pub(crate) use x11::{Keymap, MouseCapture, connect_x11};
#[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(),
}
}
}
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()
},
})
}
}
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("+")
}
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)
}
}
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",
"menu" => "Menu",
"pause" => "Pause",
"scroll lock" | "scrolllock" | "scroll_lock" => "Scroll_Lock",
"§" | "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());
}
"escape" => {
return Err(eyre!(
"the shortcuts portal can't bind Escape; it would take Escape from every app"
));
}
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);
const TAP_OR_HOLD_THRESHOLD: Duration = Duration::from_millis(300);
const KEY_REPEAT_GAP: Duration = Duration::from_secs(1);
const POLL_INTERVAL: Duration = Duration::from_millis(5);
const PORTAL_WAIT: Duration = Duration::from_millis(50);
const COMMAND_WAIT: Duration = Duration::from_millis(250);
const PORTAL_READY_TIMEOUT: Duration = Duration::from_secs(65);
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum HotkeyEvent {
Start {
raw: bool,
},
Finish,
FinishPrinting(crate::control::PrintReply),
Cancel,
PasteLast,
Capture,
CaptureUp,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub(crate) enum ShortcutCommand {
DictationStart,
DictationStop,
DictationToggle,
DictationCancel,
PasteLast,
DictationStartRaw,
DictationToggleRaw,
}
#[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)
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct HotkeyOptions {
pub(crate) double_tap: bool,
pub(crate) double_tap_only: bool,
pub(crate) toggle: bool,
pub(crate) tap_or_hold: bool,
pub(crate) paste_last: Option<LinuxHotkey>,
pub(crate) raw_dictation: Option<LinuxHotkey>,
pub(crate) capture: Option<LinuxHotkey>,
pub(crate) cancel: Option<LinuxHotkey>,
}
fn annotate_drawing() -> bool {
#[cfg(hex_capture)]
return crate::capture_host::drawing();
#[cfg(not(hex_capture))]
false
}
#[derive(Clone, Debug, Default)]
pub(crate) struct HotkeyStatus {
shortcuts: Arc<Mutex<DesktopShortcuts>>,
configurable: Arc<AtomicBool>,
configure: Arc<AtomicBool>,
forget: Arc<AtomicBool>,
keys: ShortcutKeys,
}
impl HotkeyStatus {
pub(crate) fn shortcuts(&self) -> DesktopShortcuts {
self.shortcuts
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone()
}
pub(crate) fn keys(&self) -> ShortcutKeys {
self.keys.clone()
}
pub(crate) fn configurable(&self) -> bool {
self.configurable.load(Ordering::Acquire)
}
pub(crate) fn configure(&self) {
self.configure.store(true, Ordering::Release);
}
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;
}
}
const MODIFIER_GRACE: Duration = Duration::from_millis(250);
const HELD_LIMIT: Duration = Duration::from_secs(2);
#[derive(Clone, Debug, Default)]
pub(crate) struct ShortcutKeys(Arc<Mutex<HeldKeys>>);
#[derive(Debug, Default)]
struct HeldKeys {
held: u8,
last: Option<Instant>,
}
impl HeldKeys {
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;
}
pub(crate) fn any_held(&self) -> bool {
self.0
.lock()
.unwrap_or_else(|error| error.into_inner())
.held
!= 0
}
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)));
}
}
}
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,
}
}
fn portal_shortcut_id(base: &str, trigger: &str, desktop_assigns: bool) -> String {
if desktop_assigns {
return base.to_string();
}
format!("{base}-{}", trigger.replace('+', "-"))
}
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)
}
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()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Shortcut {
Dictation,
PasteLast,
Capture,
RawDictation,
}
impl Shortcut {
const ALL: [Self; 4] = [
Self::Dictation,
Self::PasteLast,
Self::Capture,
Self::RawDictation,
];
fn index(self) -> usize {
self as usize
}
fn portal_id(self) -> (&'static str, &'static str) {
match self {
Self::Dictation => ("dictation", "Dictation"),
Self::PasteLast => ("paste-last", "Paste last dictation"),
Self::Capture => ("capture", "Annotate"),
Self::RawDictation => ("raw-dictation", "Dictate without processing"),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Input {
Pressed(Shortcut),
Released(Shortcut),
Escape,
Cancel,
Lost,
Command(ShortcutCommand),
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Capture {
Idle,
Armed,
Held {
second_tap: bool,
},
Locked,
}
struct ShortcutState {
double_tap: bool,
double_tap_only: bool,
toggle: bool,
tap_or_hold: bool,
cancels: bool,
capture: Capture,
last_release: Option<Instant>,
last_press: Option<Instant>,
held_since: Option<Instant>,
down: bool,
repeating: bool,
raw: bool,
}
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,
raw: false,
}
}
fn idle(&self) -> bool {
matches!(self.capture, Capture::Idle | Capture::Armed)
}
fn wants_cancel(&self) -> bool {
self.cancels && matches!(self.capture, Capture::Held { .. } | Capture::Locked)
}
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, raw: bool) -> Option<HotkeyEvent> {
if !self.idle() {
return None;
}
self.raw = raw;
self.capture = Capture::Locked;
self.last_release = None;
Some(HotkeyEvent::Start { raw })
}
fn claim(&mut self, raw: bool) -> bool {
if self.raw == raw {
return true;
}
if !self.idle() {
return false;
}
self.raw = raw;
self.capture = Capture::Idle;
self.last_release = None;
self.last_press = None;
self.down = false;
self.repeating = false;
true
}
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 = if self.raw {
Shortcut::RawDictation
} else {
Shortcut::Dictation
};
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 {
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)) => {
if !self.claim(shortcut == Shortcut::RawDictation) {
return None;
}
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 { raw: self.raw })
}
Capture::Held { .. } => {
self.capture = Capture::Idle;
self.last_release = None;
Some(HotkeyEvent::Finish)
}
Capture::Armed => None,
}
}
Input::Released(shortcut @ (Shortcut::Dictation | Shortcut::RawDictation)) => {
if (shortcut == Shortcut::RawDictation) != self.raw {
return None;
}
self.down = false;
self.repeating = false;
match self.capture {
Capture::Armed => {
self.capture = Capture::Idle;
self.last_release = Some(now);
None
}
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,
Input::Pressed(Shortcut::Capture) => Some(HotkeyEvent::Capture),
Input::Released(Shortcut::Capture) => Some(HotkeyEvent::CaptureUp),
Input::Command(command) => match command {
ShortcutCommand::DictationStart => self.start_dictation(false),
ShortcutCommand::DictationStartRaw => self.start_dictation(true),
ShortcutCommand::DictationStop => self.stop_dictation(),
ShortcutCommand::DictationToggle => self
.stop_dictation()
.or_else(|| self.start_dictation(false)),
ShortcutCommand::DictationToggleRaw => {
self.stop_dictation().or_else(|| self.start_dictation(true))
}
ShortcutCommand::DictationCancel => self.cancel(),
ShortcutCommand::PasteLast => unreachable!("handled above"),
},
}
}
}
#[derive(Clone)]
pub(crate) struct CommandQueue(Arc<Mutex<Receiver<ListenerCommand>>>);
impl From<Receiver<ListenerCommand>> for CommandQueue {
fn from(receiver: Receiver<ListenerCommand>) -> Self {
Self(Arc::new(Mutex::new(receiver)))
}
}
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()));
}
fn expire(&mut self) {
let raw = self.state.raw;
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(if raw {
Shortcut::RawDictation
} else {
Shortcut::Dictation
});
}
self.pending.extend(event);
}
fn commands(&mut self) {
let commands = self
.commands
.0
.lock()
.unwrap_or_else(|error| error.into_inner());
let queued: Vec<_> = commands.try_iter().collect();
drop(commands);
for command in queued {
self.command(command);
}
}
fn wait_for_commands(&mut self, timeout: Duration) {
let waiting = self
.commands
.0
.lock()
.unwrap_or_else(|error| error.into_inner());
let first = waiting.recv_timeout(timeout);
drop(waiting);
match first {
Ok(command) => self.command(command),
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)),
ListenerCommand::StopAndPrint(reply) => match self.state.stop_dictation() {
Some(_) => self.pending.push(HotkeyEvent::FinishPrinting(reply)),
None => reply.send(crate::control::PrintedTranscript::NotRecording),
},
}
}
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 {
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)));
}
}
});
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();
}
}
}
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)
}
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() {
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)),
];
let desktop_assigns = crate::linux::portal::desktop_assigns_triggers();
let mut shortcuts = Vec::new();
let mut ids: [Option<String>; Shortcut::ALL.len()] = Default::default();
for (shortcut, key) in requested.into_iter().flatten() {
let (base, description) = shortcut.portal_id();
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),
});
}
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"
);
}
let granted = match portal.bind(&shortcuts, &self.stop) {
Ok(granted) => granted,
Err(error) if self.stopping() => return Err(error),
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"
);
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))
};
while !self.stopping() {
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()
{
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 { raw: false };
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 { raw: true };
let mut test = Test::new(true);
test.press(Shortcut::RawDictation);
test.tap();
test.release(Shortcut::RawDictation);
assert_eq!(test.events, [RAW, Finish]);
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 only_requested_shortcuts_are_shown() {
let binding = LinuxHotkey::default();
let requested = [Some((Shortcut::Dictation, &binding)), 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()))]
);
}
#[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];
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 { raw: false });
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());
}
#[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);
}
}
#[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);
}
}
#[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);
}
#[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");
}
#[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]);
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]);
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() {
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() {
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 });
test.now += Duration::from_secs(3);
test.press(Shortcut::Dictation);
assert_eq!(test.events, [START, Finish]);
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]);
}
}
#[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]);
test.now += Duration::from_millis(100);
test.tap();
assert_eq!(test.events, [START, Finish, START, Finish]);
assert_eq!(test.state.capture, Capture::Idle);
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() {
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]);
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() {
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()
}
}
#[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);
}
#[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);
}
}
#[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);
}
#[test]
fn tap_or_hold_applies_to_raw_dictation_but_not_commands() {
const RAW: HotkeyEvent = HotkeyEvent::Start { raw: true };
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]);
}
#[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()
}
}
#[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]);
}
#[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));
}
#[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]);
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 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]);
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);
test.command(Command::DictationStart);
test.tap();
test.now += Duration::from_secs(1);
test.press(Shortcut::Dictation);
test.command(Command::DictationStop);
test.release(Shortcut::Dictation);
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);
}
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));
}
#[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]);
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 { raw: false }).unwrap());
assert!(send_event(&sender, HotkeyEvent::Finish).is_err());
assert_eq!(
receiver.try_recv().unwrap(),
HotkeyEvent::Start { raw: false }
);
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();
assert_eq!(status.shortcuts(), DesktopShortcuts::Unregistered);
let worker = thread::spawn(move || {
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(),
);
let (reply, idle) = PrintReply::channel();
commands.send(ListenerCommand::StopAndPrint(reply)).unwrap();
dispatcher.commands();
assert_eq!(idle.try_recv().unwrap(), PrintedTranscript::NotRecording);
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 { raw: false },
HotkeyEvent::FinishPrinting(_),
HotkeyEvent::Start { raw: false }
]
));
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 { raw: false });
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 { raw: false });
assert_eq!(recv_event(&monitor), HotkeyEvent::Finish);
harness.tap_alt_space();
assert_eq!(recv_event(&monitor), HotkeyEvent::Start { raw: false });
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 { raw: false });
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 { raw: false });
commands.send(Command::DictationStop.into()).unwrap();
assert_eq!(recv_event(&monitor), HotkeyEvent::Finish);
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 { raw: false });
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()
);
grab_variants(&harness.connection, harness.root, harness.escape, &variants).unwrap();
assert!(grab_escape(&connection, 0, harness.escape, &variants).is_err());
}
}