mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
feat(desktop): portal-based Linux global shortcuts and PTT (#3185)
This commit is contained in:
1 parent
5799ef705d
commit
8f58fcc4c4
149 files changed
+26849
-8769
No files matched your search
+2
-21
@@ -4,6 +4,7 @@ export type InputEvent =
|
||||
| {
|
||||
type: 'keydown' | 'keyup';
|
||||
keycode: number;
|
||||
x11Keycode: number;
|
||||
keyName: string;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
@@ -19,32 +20,12 @@ export type InputEvent =
|
||||
metaKey: boolean;
|
||||
x?: number;
|
||||
y?: number;
|
||||
}
|
||||
| {
|
||||
type: 'mousemove';
|
||||
x: number;
|
||||
y: number;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
shiftKey: boolean;
|
||||
metaKey: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'wheel';
|
||||
x?: number;
|
||||
y?: number;
|
||||
deltaX: number;
|
||||
deltaY: number;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
shiftKey: boolean;
|
||||
metaKey: boolean;
|
||||
};
|
||||
|
||||
export declare class InputHook {
|
||||
constructor(callback: (event: InputEvent) => void);
|
||||
|
||||
start(): void;
|
||||
start(): Promise<void>;
|
||||
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DisplayServer {
|
||||
X11,
|
||||
@@ -18,31 +21,45 @@ impl DisplayServer {
|
||||
}
|
||||
|
||||
pub fn detect_display_server() -> DisplayServer {
|
||||
let wayland_socket = wayland_socket_path(
|
||||
std::env::var_os("WAYLAND_DISPLAY").as_deref(),
|
||||
std::env::var_os("XDG_RUNTIME_DIR").as_deref(),
|
||||
);
|
||||
detect_from(
|
||||
std::env::var("XDG_SESSION_TYPE").ok().as_deref(),
|
||||
std::env::var("DISPLAY").ok().as_deref(),
|
||||
std::env::var("WAYLAND_DISPLAY").ok().as_deref(),
|
||||
wayland_socket.is_some_and(|path| path.exists()),
|
||||
)
|
||||
}
|
||||
|
||||
fn wayland_socket_path(
|
||||
wayland_display: Option<&OsStr>,
|
||||
xdg_runtime_dir: Option<&OsStr>,
|
||||
) -> Option<PathBuf> {
|
||||
let display = Path::new(wayland_display.filter(|value| !value.is_empty())?);
|
||||
if display.is_absolute() {
|
||||
return Some(display.to_path_buf());
|
||||
}
|
||||
let runtime_dir = xdg_runtime_dir.filter(|value| !value.is_empty())?;
|
||||
Some(Path::new(runtime_dir).join(display))
|
||||
}
|
||||
|
||||
fn detect_from(
|
||||
xdg_session_type: Option<&str>,
|
||||
display: Option<&str>,
|
||||
wayland_display: Option<&str>,
|
||||
wayland_socket_exists: bool,
|
||||
) -> DisplayServer {
|
||||
let has_x11 = display.is_some_and(|v| !v.is_empty());
|
||||
let has_wayland = wayland_display.is_some_and(|v| !v.is_empty());
|
||||
let xdg_is_wayland = xdg_session_type == Some("wayland");
|
||||
match (has_x11, has_wayland) {
|
||||
let is_wayland = match xdg_session_type {
|
||||
Some("x11") => return DisplayServer::X11,
|
||||
Some("wayland") => true,
|
||||
_ => wayland_socket_exists,
|
||||
};
|
||||
match (is_wayland, has_x11) {
|
||||
(true, true) => DisplayServer::WaylandWithXwayland,
|
||||
(true, false) if xdg_is_wayland => DisplayServer::WaylandWithXwayland,
|
||||
(true, false) => DisplayServer::X11,
|
||||
(false, true) => DisplayServer::Wayland,
|
||||
(false, false) => match xdg_session_type {
|
||||
Some("x11") => DisplayServer::X11,
|
||||
Some("wayland") => DisplayServer::Wayland,
|
||||
_ => DisplayServer::Unknown,
|
||||
},
|
||||
(true, false) => DisplayServer::Wayland,
|
||||
(false, true) => DisplayServer::X11,
|
||||
(false, false) => DisplayServer::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,61 +70,101 @@ mod tests {
|
||||
#[test]
|
||||
fn detect_pure_x11_from_display_only() {
|
||||
assert_eq!(
|
||||
detect_from(Some("x11"), Some(":0"), None),
|
||||
detect_from(Some("x11"), Some(":0"), false),
|
||||
DisplayServer::X11
|
||||
);
|
||||
assert_eq!(detect_from(None, Some(":0"), None), DisplayServer::X11);
|
||||
assert_eq!(detect_from(None, Some(":0"), false), DisplayServer::X11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_pure_wayland_from_wayland_display_only() {
|
||||
fn x11_session_type_wins_over_leaked_wayland_display() {
|
||||
assert_eq!(
|
||||
detect_from(Some("wayland"), None, Some("wayland-0")),
|
||||
DisplayServer::Wayland
|
||||
);
|
||||
assert_eq!(
|
||||
detect_from(None, None, Some("wayland-0")),
|
||||
DisplayServer::Wayland
|
||||
detect_from(Some("x11"), Some(":0"), true),
|
||||
DisplayServer::X11
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_xwayland_when_both_sockets_present() {
|
||||
let ds = detect_from(Some("wayland"), Some(":0"), Some("wayland-0"));
|
||||
fn detect_pure_wayland_from_socket_only() {
|
||||
assert_eq!(
|
||||
detect_from(Some("wayland"), None, true),
|
||||
DisplayServer::Wayland
|
||||
);
|
||||
assert_eq!(detect_from(None, None, true), DisplayServer::Wayland);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_xwayland_when_socket_and_display_present() {
|
||||
let ds = detect_from(None, Some(":0"), true);
|
||||
assert_eq!(ds, DisplayServer::WaylandWithXwayland);
|
||||
assert!(!ds.supports_global_xrecord());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_xwayland_from_wayland_session_with_display_only() {
|
||||
let ds = detect_from(Some("wayland"), Some(":0"), None);
|
||||
let ds = detect_from(Some("wayland"), Some(":0"), false);
|
||||
assert_eq!(ds, DisplayServer::WaylandWithXwayland);
|
||||
assert!(!ds.supports_global_xrecord());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_wayland_socket_without_session_type_is_x11() {
|
||||
assert_eq!(
|
||||
detect_from(Some("tty"), Some(":0"), false),
|
||||
DisplayServer::X11
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_unknown_when_nothing_set() {
|
||||
assert_eq!(detect_from(None, None, None), DisplayServer::Unknown);
|
||||
assert_eq!(detect_from(Some("tty"), None, None), DisplayServer::Unknown);
|
||||
assert_eq!(detect_from(None, None, false), DisplayServer::Unknown);
|
||||
assert_eq!(
|
||||
detect_from(Some("tty"), None, false),
|
||||
DisplayServer::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_empty_display_var_is_ignored() {
|
||||
assert_eq!(
|
||||
detect_from(Some("wayland"), Some(""), Some("wayland-0")),
|
||||
detect_from(Some("wayland"), Some(""), true),
|
||||
DisplayServer::Wayland
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xdg_session_type_fallback_when_sockets_missing() {
|
||||
assert_eq!(detect_from(Some("x11"), None, None), DisplayServer::X11);
|
||||
fn xdg_session_type_without_sockets() {
|
||||
assert_eq!(detect_from(Some("x11"), None, false), DisplayServer::X11);
|
||||
assert_eq!(
|
||||
detect_from(Some("wayland"), None, None),
|
||||
detect_from(Some("wayland"), None, false),
|
||||
DisplayServer::Wayland
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wayland_socket_path_resolves_relative_names_under_runtime_dir() {
|
||||
assert_eq!(
|
||||
wayland_socket_path(
|
||||
Some(OsStr::new("wayland-0")),
|
||||
Some(OsStr::new("/run/user/1000"))
|
||||
),
|
||||
Some(PathBuf::from("/run/user/1000/wayland-0"))
|
||||
);
|
||||
assert_eq!(
|
||||
wayland_socket_path(Some(OsStr::new("/tmp/wl")), None),
|
||||
Some(PathBuf::from("/tmp/wl"))
|
||||
);
|
||||
assert_eq!(
|
||||
wayland_socket_path(Some(OsStr::new("wayland-0")), None),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
wayland_socket_path(Some(OsStr::new("")), Some(OsStr::new("/run"))),
|
||||
None
|
||||
);
|
||||
assert_eq!(wayland_socket_path(None, Some(OsStr::new("/run"))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_xrecord_support_is_only_for_pure_x11() {
|
||||
assert!(DisplayServer::X11.supports_global_xrecord());
|
||||
|
||||
@@ -2,34 +2,37 @@
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{self, SyncSender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
|
||||
use napi::{
|
||||
Env, Status,
|
||||
bindgen_prelude::{Function, Object, Result, ToNapiValue},
|
||||
Env, Status, Task,
|
||||
bindgen_prelude::{AsyncTask, Function, Object, Result, ToNapiValue},
|
||||
sys,
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode, UnknownReturnValue},
|
||||
};
|
||||
use napi_derive::napi;
|
||||
use x11rb::connection::{Connection, RequestConnection};
|
||||
use x11rb::errors::ReplyError;
|
||||
use x11rb::protocol::Event;
|
||||
use x11rb::protocol::record::{
|
||||
self, ConnectionExt as RecordConnectionExt, ExtRange, Range, Range8, Range16,
|
||||
};
|
||||
use x11rb::protocol::xproto::{
|
||||
ConnectionExt as XprotoConnectionExt, GetKeyboardMappingReply, Keycode,
|
||||
ConnectionExt as XprotoConnectionExt, GetKeyboardMappingReply, Keycode, Mapping,
|
||||
};
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
use x11rb::wrapper::ConnectionExt as WrapperConnectionExt;
|
||||
use x11rb::x11_utils::TryParse;
|
||||
|
||||
use crate::env::{DisplayServer, detect_display_server};
|
||||
use crate::env::detect_display_server;
|
||||
use crate::keymap;
|
||||
use crate::modifiers::{self, Modifiers};
|
||||
use crate::mouse::{self, MouseClassification};
|
||||
use crate::x11;
|
||||
use crate::mouse;
|
||||
|
||||
const RECORD_FROM_SERVER: u8 = 0;
|
||||
const RECORD_START_OF_DATA: u8 = 4;
|
||||
@@ -38,7 +41,7 @@ const KEY_PRESS: u8 = 2;
|
||||
const KEY_RELEASE: u8 = 3;
|
||||
const BUTTON_PRESS: u8 = 4;
|
||||
const BUTTON_RELEASE: u8 = 5;
|
||||
const MOTION_NOTIFY: u8 = 6;
|
||||
const START_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -66,18 +69,26 @@ pub enum EventKind {
|
||||
KeyUp,
|
||||
MouseDown,
|
||||
MouseUp,
|
||||
MouseMove,
|
||||
Wheel,
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::KeyDown => "keydown",
|
||||
Self::KeyUp => "keyup",
|
||||
Self::MouseDown => "mousedown",
|
||||
Self::MouseUp => "mouseup",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DecodedEvent {
|
||||
pub kind: EventKind,
|
||||
pub keycode: u32,
|
||||
pub x11_keycode: u8,
|
||||
pub key_name: String,
|
||||
pub button: u8,
|
||||
pub delta_x: i32,
|
||||
pub delta_y: i32,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub has_xy: bool,
|
||||
@@ -89,10 +100,9 @@ impl DecodedEvent {
|
||||
Self {
|
||||
kind,
|
||||
keycode: 0,
|
||||
x11_keycode: 0,
|
||||
key_name: String::new(),
|
||||
button: 0,
|
||||
delta_x: 0,
|
||||
delta_y: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
has_xy: false,
|
||||
@@ -105,15 +115,7 @@ impl ToNapiValue for DecodedEvent {
|
||||
unsafe fn to_napi_value(raw_env: sys::napi_env, event: Self) -> Result<sys::napi_value> {
|
||||
let env = Env::from_raw(raw_env);
|
||||
let mut object = Object::new(&env)?;
|
||||
let kind = match event.kind {
|
||||
EventKind::KeyDown => "keydown",
|
||||
EventKind::KeyUp => "keyup",
|
||||
EventKind::MouseDown => "mousedown",
|
||||
EventKind::MouseUp => "mouseup",
|
||||
EventKind::MouseMove => "mousemove",
|
||||
EventKind::Wheel => "wheel",
|
||||
};
|
||||
object.set("type", kind)?;
|
||||
object.set("type", event.kind.as_str())?;
|
||||
object.set("ctrlKey", event.mods.ctrl)?;
|
||||
object.set("altKey", event.mods.alt)?;
|
||||
object.set("shiftKey", event.mods.shift)?;
|
||||
@@ -121,6 +123,7 @@ impl ToNapiValue for DecodedEvent {
|
||||
match event.kind {
|
||||
EventKind::KeyDown | EventKind::KeyUp => {
|
||||
object.set("keycode", event.keycode)?;
|
||||
object.set("x11Keycode", u32::from(event.x11_keycode))?;
|
||||
object.set("keyName", event.key_name.as_str())?;
|
||||
}
|
||||
EventKind::MouseDown | EventKind::MouseUp => {
|
||||
@@ -130,36 +133,13 @@ impl ToNapiValue for DecodedEvent {
|
||||
object.set("y", event.y)?;
|
||||
}
|
||||
}
|
||||
EventKind::MouseMove => {
|
||||
object.set("x", event.x)?;
|
||||
object.set("y", event.y)?;
|
||||
}
|
||||
EventKind::Wheel => {
|
||||
object.set("deltaX", event.delta_x)?;
|
||||
object.set("deltaY", event.delta_y)?;
|
||||
if event.has_xy {
|
||||
object.set("x", event.x)?;
|
||||
object.set("y", event.y)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
unsafe { <Object<'_> as ToNapiValue>::to_napi_value(raw_env, object) }
|
||||
}
|
||||
}
|
||||
|
||||
const EVENT_QUEUE_LIMIT: usize = 1024;
|
||||
|
||||
type EventTsfn = Arc<
|
||||
ThreadsafeFunction<
|
||||
DecodedEvent,
|
||||
UnknownReturnValue,
|
||||
DecodedEvent,
|
||||
Status,
|
||||
false,
|
||||
true,
|
||||
EVENT_QUEUE_LIMIT,
|
||||
>,
|
||||
>;
|
||||
type EventTsfn =
|
||||
Arc<ThreadsafeFunction<DecodedEvent, UnknownReturnValue, DecodedEvent, Status, false, true>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct KeysymCache {
|
||||
@@ -180,6 +160,21 @@ impl KeysymCache {
|
||||
Self { min_keycode, syms }
|
||||
}
|
||||
|
||||
fn fetch(conn: &RustConnection) -> std::result::Result<Self, String> {
|
||||
let setup = conn.setup();
|
||||
let min_keycode = setup.min_keycode;
|
||||
let count = setup
|
||||
.max_keycode
|
||||
.saturating_sub(min_keycode)
|
||||
.saturating_add(1);
|
||||
let mapping = conn
|
||||
.get_keyboard_mapping(min_keycode, count)
|
||||
.map_err(|err| format!("GetKeyboardMapping: {err}"))?
|
||||
.reply()
|
||||
.map_err(|err| format!("GetKeyboardMapping: {err}"))?;
|
||||
Ok(Self::build(&mapping, min_keycode))
|
||||
}
|
||||
|
||||
fn lookup(&self, keycode: u8) -> u32 {
|
||||
if keycode < self.min_keycode {
|
||||
return 0;
|
||||
@@ -199,6 +194,78 @@ struct Active {
|
||||
struct Inner {
|
||||
callback: EventTsfn,
|
||||
active: Mutex<Option<Active>>,
|
||||
generation: AtomicU64,
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Option<Active>>> {
|
||||
self.active
|
||||
.lock()
|
||||
.map_err(|_| generic_error("InputHook lock poisoned"))
|
||||
}
|
||||
|
||||
fn start(&self, generation: u64) -> Result<()> {
|
||||
let dead = {
|
||||
let mut guard = self.lock()?;
|
||||
if guard
|
||||
.as_ref()
|
||||
.is_some_and(|active| !active.stop.load(Ordering::Acquire))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
guard.take()
|
||||
};
|
||||
if let Some(dead) = dead {
|
||||
tear_down(dead);
|
||||
}
|
||||
let started = start_record(self.callback.clone())?;
|
||||
let mut guard = self.lock()?;
|
||||
if self.generation.load(Ordering::Acquire) != generation {
|
||||
drop(guard);
|
||||
tear_down(started);
|
||||
return Err(generic_error(
|
||||
"InputHook.start failed: stopped during start",
|
||||
));
|
||||
}
|
||||
if guard
|
||||
.as_ref()
|
||||
.is_some_and(|active| !active.stop.load(Ordering::Acquire))
|
||||
{
|
||||
drop(guard);
|
||||
tear_down(started);
|
||||
return Ok(());
|
||||
}
|
||||
let replaced = guard.replace(started);
|
||||
drop(guard);
|
||||
if let Some(dead) = replaced {
|
||||
tear_down(dead);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop(&self) -> Result<Option<Active>> {
|
||||
let mut guard = self.lock()?;
|
||||
self.generation.fetch_add(1, Ordering::AcqRel);
|
||||
Ok(guard.take())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StartTask {
|
||||
inner: Arc<Inner>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl Task for StartTask {
|
||||
type Output = ();
|
||||
type JsValue = ();
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
self.inner.start(self.generation)
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _env: Env, _output: Self::Output) -> Result<Self::JsValue> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
@@ -214,43 +281,28 @@ impl InputHook {
|
||||
.build_threadsafe_function::<DecodedEvent>()
|
||||
.weak::<true>()
|
||||
.callee_handled::<false>()
|
||||
.max_queue_size::<EVENT_QUEUE_LIMIT>()
|
||||
.build()
|
||||
.map_err(|err| generic_error(format!("failed to create TSFN: {}", err.reason)))?;
|
||||
Ok(Self {
|
||||
inner: Arc::new(Inner {
|
||||
callback: Arc::new(tsfn),
|
||||
active: Mutex::new(None),
|
||||
generation: AtomicU64::new(0),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn start(&self) -> Result<()> {
|
||||
let mut guard = self
|
||||
.inner
|
||||
.active
|
||||
.lock()
|
||||
.map_err(|_| generic_error("InputHook lock poisoned"))?;
|
||||
if guard.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let active = start_record(self.inner.callback.clone())?;
|
||||
*guard = Some(active);
|
||||
Ok(())
|
||||
pub fn start(&self) -> AsyncTask<StartTask> {
|
||||
AsyncTask::new(StartTask {
|
||||
inner: self.inner.clone(),
|
||||
generation: self.inner.generation.load(Ordering::Acquire),
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn stop(&self) -> Result<()> {
|
||||
let active = {
|
||||
let mut guard = self
|
||||
.inner
|
||||
.active
|
||||
.lock()
|
||||
.map_err(|_| generic_error("InputHook lock poisoned"))?;
|
||||
guard.take()
|
||||
};
|
||||
if let Some(active) = active {
|
||||
if let Some(active) = self.inner.stop()? {
|
||||
tear_down(active);
|
||||
}
|
||||
Ok(())
|
||||
@@ -259,9 +311,7 @@ impl InputHook {
|
||||
|
||||
impl Drop for InputHook {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut guard) = self.inner.active.lock()
|
||||
&& let Some(active) = guard.take()
|
||||
{
|
||||
if let Ok(Some(active)) = self.inner.stop() {
|
||||
tear_down(active);
|
||||
}
|
||||
}
|
||||
@@ -304,18 +354,8 @@ fn start_record(callback: EventTsfn) -> Result<Active> {
|
||||
generic_error(format!("InputHook.start failed: RecordQueryVersion: {err}"))
|
||||
})?;
|
||||
|
||||
let setup = ctrl_conn.setup();
|
||||
let min_keycode = setup.min_keycode;
|
||||
let max_keycode = setup.max_keycode;
|
||||
let count = max_keycode.saturating_sub(min_keycode).saturating_add(1);
|
||||
let mapping = ctrl_conn
|
||||
.get_keyboard_mapping(min_keycode, count)
|
||||
.map_err(|err| generic_error(format!("InputHook.start failed: GetKeyboardMapping: {err}")))?
|
||||
.reply()
|
||||
.map_err(|err| {
|
||||
generic_error(format!("InputHook.start failed: GetKeyboardMapping: {err}"))
|
||||
})?;
|
||||
let keysyms = KeysymCache::build(&mapping, min_keycode);
|
||||
let keysyms = KeysymCache::fetch(&ctrl_conn)
|
||||
.map_err(|err| generic_error(format!("InputHook.start failed: {err}")))?;
|
||||
|
||||
let record_ctx = ctrl_conn
|
||||
.generate_id()
|
||||
@@ -333,7 +373,7 @@ fn start_record(callback: EventTsfn) -> Result<Active> {
|
||||
delivered_events: empty,
|
||||
device_events: Range8 {
|
||||
first: KEY_PRESS,
|
||||
last: MOTION_NOTIFY,
|
||||
last: BUTTON_RELEASE,
|
||||
},
|
||||
errors: empty,
|
||||
client_started: false,
|
||||
@@ -354,168 +394,248 @@ fn start_record(callback: EventTsfn) -> Result<Active> {
|
||||
})?;
|
||||
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (ready_tx, ready_rx) = mpsc::sync_channel(1);
|
||||
|
||||
let worker_ctrl = ctrl_conn.clone();
|
||||
let worker_callback = callback.clone();
|
||||
let worker_stop = stop.clone();
|
||||
let worker = thread::Builder::new()
|
||||
.name("fluxer-linux-input-hook".to_string())
|
||||
.spawn(move || {
|
||||
worker_main(data_conn, record_ctx, keysyms, worker_callback, worker_stop);
|
||||
})
|
||||
.map_err(|err| generic_error(format!("InputHook.start failed: thread spawn: {err}")))?;
|
||||
worker_main(
|
||||
&data_conn,
|
||||
Worker {
|
||||
ctrl_conn: worker_ctrl,
|
||||
record_ctx,
|
||||
keysyms,
|
||||
callback: worker_callback,
|
||||
stop: worker_stop,
|
||||
ready: Some(ready_tx),
|
||||
held: HeldInputs::default(),
|
||||
},
|
||||
);
|
||||
});
|
||||
let worker = match worker {
|
||||
Ok(worker) => worker,
|
||||
Err(err) => {
|
||||
let _ = ctrl_conn.record_free_context(record_ctx);
|
||||
let _ = ctrl_conn.sync();
|
||||
return Err(generic_error(format!(
|
||||
"InputHook.start failed: thread spawn: {err}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Active {
|
||||
let active = Active {
|
||||
ctrl_conn,
|
||||
record_ctx,
|
||||
worker: Some(worker),
|
||||
stop,
|
||||
})
|
||||
};
|
||||
let failure = match ready_rx.recv_timeout(START_TIMEOUT) {
|
||||
Ok(Ok(())) => return Ok(active),
|
||||
Ok(Err(reason)) => reason,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => "RecordEnableContext timed out".to_string(),
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
"RecordEnableContext worker exited".to_string()
|
||||
}
|
||||
};
|
||||
abandon(active);
|
||||
Err(generic_error(format!("InputHook.start failed: {failure}")))
|
||||
}
|
||||
|
||||
fn worker_main(
|
||||
data_conn: RustConnection,
|
||||
struct Worker {
|
||||
ctrl_conn: Arc<RustConnection>,
|
||||
record_ctx: record::Context,
|
||||
keysyms: KeysymCache,
|
||||
callback: EventTsfn,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
let cookie = match data_conn.record_enable_context(record_ctx) {
|
||||
ready: Option<SyncSender<std::result::Result<(), String>>>,
|
||||
held: HeldInputs,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct HeldInputs {
|
||||
keys: BTreeSet<u8>,
|
||||
buttons: BTreeSet<u8>,
|
||||
}
|
||||
|
||||
impl HeldInputs {
|
||||
fn track(&mut self, event: &DecodedEvent) {
|
||||
match event.kind {
|
||||
EventKind::KeyDown => {
|
||||
self.keys.insert(event.x11_keycode);
|
||||
}
|
||||
EventKind::KeyUp => {
|
||||
self.keys.remove(&event.x11_keycode);
|
||||
}
|
||||
EventKind::MouseDown => {
|
||||
self.buttons.insert(event.button);
|
||||
}
|
||||
EventKind::MouseUp => {
|
||||
self.buttons.remove(&event.button);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn take_releases(&mut self, keysyms: &KeysymCache) -> Vec<DecodedEvent> {
|
||||
let mods = modifiers::from_state(0);
|
||||
let keys = std::mem::take(&mut self.keys)
|
||||
.into_iter()
|
||||
.map(|x11_keycode| key_event(EventKind::KeyUp, x11_keycode, keysyms, mods));
|
||||
let buttons = std::mem::take(&mut self.buttons).into_iter().map(|button| {
|
||||
let mut event = DecodedEvent::new(EventKind::MouseUp, mods);
|
||||
event.button = button;
|
||||
event
|
||||
});
|
||||
keys.chain(buttons).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
fn report_ready(&mut self, result: std::result::Result<(), String>) {
|
||||
if let Some(ready) = self.ready.take() {
|
||||
let _ = ready.send(result);
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_keysyms_on_mapping_change(&mut self, data_conn: &RustConnection) {
|
||||
let mut keyboard_changed = false;
|
||||
while let Ok(Some(event)) = data_conn.poll_for_event() {
|
||||
if let Event::MappingNotify(notify) = event
|
||||
&& notify.request == Mapping::KEYBOARD
|
||||
{
|
||||
keyboard_changed = true;
|
||||
}
|
||||
}
|
||||
if !keyboard_changed {
|
||||
return;
|
||||
}
|
||||
if let Ok(keysyms) = KeysymCache::fetch(&self.ctrl_conn) {
|
||||
self.keysyms = keysyms;
|
||||
}
|
||||
while let Ok(Some(_)) = self.ctrl_conn.poll_for_event() {}
|
||||
}
|
||||
}
|
||||
|
||||
fn worker_main(data_conn: &RustConnection, mut worker: Worker) {
|
||||
let cookie = match data_conn.record_enable_context(worker.record_ctx) {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
stop.store(true, Ordering::Release);
|
||||
Err(err) => {
|
||||
worker.stop.store(true, Ordering::Release);
|
||||
worker.report_ready(Err(format!("RecordEnableContext: {err}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for reply in cookie {
|
||||
if stop.load(Ordering::Acquire) {
|
||||
if worker.stop.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
let reply = match reply {
|
||||
Ok(r) => r,
|
||||
Err(err) if worker.ready.is_some() => {
|
||||
worker.report_ready(Err(format!("RecordEnableContext: {err}")));
|
||||
break;
|
||||
}
|
||||
Err(ReplyError::ConnectionError(_)) => break,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if reply.client_swapped {
|
||||
if reply.category == RECORD_START_OF_DATA {
|
||||
worker.report_ready(Ok(()));
|
||||
continue;
|
||||
}
|
||||
match reply.category {
|
||||
RECORD_START_OF_DATA => continue,
|
||||
RECORD_FROM_SERVER => {}
|
||||
_ => continue,
|
||||
if reply.client_swapped || reply.category != RECORD_FROM_SERVER {
|
||||
continue;
|
||||
}
|
||||
worker.refresh_keysyms_on_mapping_change(data_conn);
|
||||
let mut data: &[u8] = &reply.data;
|
||||
while !data.is_empty() {
|
||||
let consumed = decode_one(data, &keysyms, &callback, &stop);
|
||||
let (consumed, event) = decode_one(data, &worker.keysyms);
|
||||
if let Some(event) = event {
|
||||
worker.held.track(&event);
|
||||
dispatch(&worker.callback, &worker.stop, event);
|
||||
}
|
||||
if consumed == 0 || consumed > data.len() {
|
||||
break;
|
||||
}
|
||||
data = &data[consumed..];
|
||||
}
|
||||
}
|
||||
if !worker.stop.swap(true, Ordering::AcqRel) {
|
||||
for event in worker.held.take_releases(&worker.keysyms) {
|
||||
dispatch(&worker.callback, &worker.stop, event);
|
||||
}
|
||||
}
|
||||
worker.report_ready(Err("RecordEnableContext ended before data".to_string()));
|
||||
}
|
||||
|
||||
fn decode_one(
|
||||
data: &[u8],
|
||||
keysyms: &KeysymCache,
|
||||
callback: &EventTsfn,
|
||||
stop: &Arc<AtomicBool>,
|
||||
) -> usize {
|
||||
fn decode_one(data: &[u8], keysyms: &KeysymCache) -> (usize, Option<DecodedEvent>) {
|
||||
if data.is_empty() {
|
||||
return 0;
|
||||
return (0, None);
|
||||
}
|
||||
let type_ = data[0];
|
||||
match type_ {
|
||||
KEY_PRESS | KEY_RELEASE | BUTTON_PRESS | BUTTON_RELEASE | MOTION_NOTIFY => {
|
||||
match data[0] {
|
||||
KEY_PRESS | KEY_RELEASE | BUTTON_PRESS | BUTTON_RELEASE => {
|
||||
if data.len() < std::mem::size_of::<XKeyButtonProto>() {
|
||||
return 0;
|
||||
return (0, None);
|
||||
}
|
||||
|
||||
let evt: XKeyButtonProto =
|
||||
unsafe { std::ptr::read_unaligned(data.as_ptr() as *const XKeyButtonProto) };
|
||||
handle_event(&evt, keysyms, callback, stop);
|
||||
32
|
||||
(32, decode_event(&evt, keysyms))
|
||||
}
|
||||
0 => {
|
||||
if data.len() < 8 {
|
||||
return 0;
|
||||
return (0, None);
|
||||
}
|
||||
let (length, _) = match u32::try_parse(&data[4..]) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return 0,
|
||||
Err(_) => return (0, None),
|
||||
};
|
||||
32 + (length as usize) * 4
|
||||
(32 + (length as usize) * 4, None)
|
||||
}
|
||||
_ => 32,
|
||||
_ => (32, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_event(
|
||||
evt: &XKeyButtonProto,
|
||||
fn key_event(
|
||||
kind: EventKind,
|
||||
x11_keycode: u8,
|
||||
keysyms: &KeysymCache,
|
||||
callback: &EventTsfn,
|
||||
stop: &Arc<AtomicBool>,
|
||||
) {
|
||||
mods: Modifiers,
|
||||
) -> DecodedEvent {
|
||||
let keysym = keysyms.lookup(x11_keycode);
|
||||
let mut event = DecodedEvent::new(kind, mods);
|
||||
event.keycode = keysym;
|
||||
event.x11_keycode = x11_keycode;
|
||||
event.key_name = keymap::keysym_to_name(keysym)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
event
|
||||
}
|
||||
|
||||
fn decode_event(evt: &XKeyButtonProto, keysyms: &KeysymCache) -> Option<DecodedEvent> {
|
||||
let mods = modifiers::from_state(u32::from(evt.state));
|
||||
match evt.type_ {
|
||||
KEY_PRESS | KEY_RELEASE => {
|
||||
let _lookup = x11::xkb_lookup_for_base();
|
||||
let keysym = keysyms.lookup(evt.detail);
|
||||
KEY_PRESS => Some(key_event(EventKind::KeyDown, evt.detail, keysyms, mods)),
|
||||
KEY_RELEASE => Some(key_event(EventKind::KeyUp, evt.detail, keysyms, mods)),
|
||||
BUTTON_PRESS | BUTTON_RELEASE => {
|
||||
let button = mouse::browser_button(u32::from(evt.detail))?;
|
||||
let mut event = DecodedEvent::new(
|
||||
if evt.type_ == KEY_PRESS {
|
||||
EventKind::KeyDown
|
||||
if evt.type_ == BUTTON_PRESS {
|
||||
EventKind::MouseDown
|
||||
} else {
|
||||
EventKind::KeyUp
|
||||
EventKind::MouseUp
|
||||
},
|
||||
mods,
|
||||
);
|
||||
event.keycode = keysym;
|
||||
event.key_name = match keymap::keysym_to_name(keysym) {
|
||||
Some(name) => name.to_string(),
|
||||
None => keymap::fallback_name(keysym),
|
||||
};
|
||||
dispatch(callback, stop, event);
|
||||
}
|
||||
BUTTON_PRESS | BUTTON_RELEASE => {
|
||||
let cls = mouse::classify(u32::from(evt.detail));
|
||||
match cls {
|
||||
MouseClassification::Button(b) => {
|
||||
let mut event = DecodedEvent::new(
|
||||
if evt.type_ == BUTTON_PRESS {
|
||||
EventKind::MouseDown
|
||||
} else {
|
||||
EventKind::MouseUp
|
||||
},
|
||||
mods,
|
||||
);
|
||||
event.button = b;
|
||||
event.x = i32::from(evt.root_x);
|
||||
event.y = i32::from(evt.root_y);
|
||||
event.has_xy = true;
|
||||
dispatch(callback, stop, event);
|
||||
}
|
||||
MouseClassification::Wheel(dir) => {
|
||||
if evt.type_ == BUTTON_PRESS {
|
||||
let mut event = DecodedEvent::new(EventKind::Wheel, mods);
|
||||
event.delta_x = dir.delta_x();
|
||||
event.delta_y = dir.delta_y();
|
||||
event.x = i32::from(evt.root_x);
|
||||
event.y = i32::from(evt.root_y);
|
||||
event.has_xy = true;
|
||||
dispatch(callback, stop, event);
|
||||
}
|
||||
}
|
||||
MouseClassification::Ignored => {}
|
||||
}
|
||||
}
|
||||
MOTION_NOTIFY => {
|
||||
let mut event = DecodedEvent::new(EventKind::MouseMove, mods);
|
||||
event.button = button;
|
||||
event.x = i32::from(evt.root_x);
|
||||
event.y = i32::from(evt.root_y);
|
||||
event.has_xy = true;
|
||||
dispatch(callback, stop, event);
|
||||
Some(event)
|
||||
}
|
||||
_ => {}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,6 +646,13 @@ fn dispatch(callback: &EventTsfn, stop: &Arc<AtomicBool>, event: DecodedEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
fn abandon(active: Active) {
|
||||
active.stop.store(true, Ordering::Release);
|
||||
let _ = active.ctrl_conn.record_disable_context(active.record_ctx);
|
||||
let _ = active.ctrl_conn.record_free_context(active.record_ctx);
|
||||
let _ = active.ctrl_conn.flush();
|
||||
}
|
||||
|
||||
fn tear_down(mut active: Active) {
|
||||
active.stop.store(true, Ordering::Release);
|
||||
|
||||
@@ -550,11 +677,6 @@ pub fn is_available() -> bool {
|
||||
x11rb::connect(None).is_ok()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn detected_display_server() -> DisplayServer {
|
||||
detect_display_server()
|
||||
}
|
||||
|
||||
fn generic_error(reason: impl Into<String>) -> napi::Error {
|
||||
napi::Error::new(Status::GenericFailure, reason.into())
|
||||
}
|
||||
@@ -578,26 +700,78 @@ const _ASSERT_PROTO_LAYOUT: fn() = || {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cache_with(x11_keycode: u8, keysym: u32) -> KeysymCache {
|
||||
let mut syms = vec![0; 248];
|
||||
syms[usize::from(x11_keycode - 8)] = keysym;
|
||||
KeysymCache {
|
||||
min_keycode: 8,
|
||||
syms,
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_event(type_: u8, detail: u8, state: u16) -> [u8; 32] {
|
||||
let mut data = [0u8; 32];
|
||||
data[0] = type_;
|
||||
data[1] = detail;
|
||||
data[28..30].copy_from_slice(&state.to_ne_bytes());
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_keydown_has_keysym_and_name() {
|
||||
let mut event = DecodedEvent::new(EventKind::KeyDown, modifiers::from_state(0));
|
||||
event.keycode = 0x0061;
|
||||
event.key_name = keymap::keysym_to_name(0x0061).unwrap().to_string();
|
||||
fn decoded_keydown_has_keysym_raw_keycode_and_name() {
|
||||
let (consumed, event) = decode_one(&wire_event(KEY_PRESS, 38, 0), &cache_with(38, 0x0061));
|
||||
let event = event.expect("key event");
|
||||
assert_eq!(consumed, 32);
|
||||
assert!(matches!(event.kind, EventKind::KeyDown));
|
||||
assert_eq!(event.keycode, 0x0061);
|
||||
assert_eq!(event.x11_keycode, 38);
|
||||
assert_eq!(event.key_name, "A");
|
||||
assert!(!event.has_xy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_wheel_uses_120_step_deltas() {
|
||||
let dir = mouse::WheelDirection::Down;
|
||||
let mut event = DecodedEvent::new(EventKind::Wheel, modifiers::from_state(0));
|
||||
event.delta_x = dir.delta_x();
|
||||
event.delta_y = dir.delta_y();
|
||||
event.has_xy = true;
|
||||
assert_eq!(event.delta_x, 0);
|
||||
assert_eq!(event.delta_y, 120);
|
||||
fn unknown_keysym_has_no_layout_key_name() {
|
||||
let (_, event) = decode_one(&wire_event(KEY_PRESS, 24, 0x4), &cache_with(24, 0x6ca));
|
||||
let event = event.expect("key event");
|
||||
assert_eq!(event.keycode, 0x6ca);
|
||||
assert_eq!(event.x11_keycode, 24);
|
||||
assert_eq!(event.key_name, "");
|
||||
assert!(event.mods.ctrl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_buttons_are_not_decoded() {
|
||||
let cache = cache_with(38, 0x0061);
|
||||
assert!(
|
||||
decode_one(&wire_event(BUTTON_PRESS, 4, 0), &cache)
|
||||
.1
|
||||
.is_none()
|
||||
);
|
||||
let (_, event) = decode_one(&wire_event(BUTTON_PRESS, 8, 0), &cache);
|
||||
assert_eq!(event.expect("button event").button, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_inputs_release_every_pressed_key_and_button_once() {
|
||||
let cache = cache_with(38, 0x0061);
|
||||
let mut held = HeldInputs::default();
|
||||
for data in [
|
||||
wire_event(KEY_PRESS, 38, 0),
|
||||
wire_event(KEY_PRESS, 37, 0),
|
||||
wire_event(KEY_RELEASE, 37, 0),
|
||||
wire_event(BUTTON_PRESS, 9, 0),
|
||||
] {
|
||||
held.track(&decode_one(&data, &cache).1.expect("event"));
|
||||
}
|
||||
let releases = held.take_releases(&cache);
|
||||
assert_eq!(releases.len(), 2);
|
||||
assert!(matches!(releases[0].kind, EventKind::KeyUp));
|
||||
assert_eq!(releases[0].x11_keycode, 38);
|
||||
assert_eq!(releases[0].key_name, "A");
|
||||
assert!(!releases[0].mods.ctrl);
|
||||
assert!(matches!(releases[1].kind, EventKind::MouseUp));
|
||||
assert_eq!(releases[1].button, 4);
|
||||
assert!(held.take_releases(&cache).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -614,25 +788,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn event_kind_to_string_matches_js_contract() {
|
||||
let cases: &[(EventKind, &str)] = &[
|
||||
(EventKind::KeyDown, "keydown"),
|
||||
(EventKind::KeyUp, "keyup"),
|
||||
(EventKind::MouseDown, "mousedown"),
|
||||
(EventKind::MouseUp, "mouseup"),
|
||||
(EventKind::MouseMove, "mousemove"),
|
||||
(EventKind::Wheel, "wheel"),
|
||||
];
|
||||
for (kind, expected) in cases {
|
||||
let label = match kind {
|
||||
EventKind::KeyDown => "keydown",
|
||||
EventKind::KeyUp => "keyup",
|
||||
EventKind::MouseDown => "mousedown",
|
||||
EventKind::MouseUp => "mouseup",
|
||||
EventKind::MouseMove => "mousemove",
|
||||
EventKind::Wheel => "wheel",
|
||||
};
|
||||
assert_eq!(label, *expected);
|
||||
}
|
||||
assert_eq!(EventKind::KeyDown.as_str(), "keydown");
|
||||
assert_eq!(EventKind::KeyUp.as_str(), "keyup");
|
||||
assert_eq!(EventKind::MouseDown.as_str(), "mousedown");
|
||||
assert_eq!(EventKind::MouseUp.as_str(), "mouseup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -157,10 +157,6 @@ pub fn keysym_to_name(keysym: u32) -> Option<&'static str> {
|
||||
.map(|(_, name)| *name)
|
||||
}
|
||||
|
||||
pub fn fallback_name(keysym: u32) -> String {
|
||||
format!("Key{keysym}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -200,9 +196,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_keysym_falls_back_to_key_n() {
|
||||
fn unknown_keysym_has_no_name() {
|
||||
assert!(keysym_to_name(0x12345).is_none());
|
||||
assert_eq!(fallback_name(0x12345), "Key74565");
|
||||
assert!(keysym_to_name(0x6ca).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,7 +6,6 @@ pub mod env;
|
||||
pub mod keymap;
|
||||
pub mod modifiers;
|
||||
pub mod mouse;
|
||||
pub mod x11;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod hook;
|
||||
|
||||
@@ -1,51 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WheelDirection {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl WheelDirection {
|
||||
pub fn delta_x(self) -> i32 {
|
||||
match self {
|
||||
Self::Left => -120,
|
||||
Self::Right => 120,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delta_y(self) -> i32 {
|
||||
match self {
|
||||
Self::Up => -120,
|
||||
Self::Down => 120,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MouseClassification {
|
||||
Button(u8),
|
||||
Wheel(WheelDirection),
|
||||
|
||||
Ignored,
|
||||
}
|
||||
|
||||
pub fn classify(x11_button: u32) -> MouseClassification {
|
||||
pub fn browser_button(x11_button: u32) -> Option<u8> {
|
||||
match x11_button {
|
||||
1 => MouseClassification::Button(0),
|
||||
2 => MouseClassification::Button(1),
|
||||
3 => MouseClassification::Button(2),
|
||||
4 => MouseClassification::Wheel(WheelDirection::Up),
|
||||
5 => MouseClassification::Wheel(WheelDirection::Down),
|
||||
6 => MouseClassification::Wheel(WheelDirection::Left),
|
||||
7 => MouseClassification::Wheel(WheelDirection::Right),
|
||||
8 => MouseClassification::Button(3),
|
||||
9 => MouseClassification::Button(4),
|
||||
_ => MouseClassification::Ignored,
|
||||
1 => Some(0),
|
||||
2 => Some(1),
|
||||
3 => Some(2),
|
||||
8 => Some(3),
|
||||
9 => Some(4),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,36 +17,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn primary_buttons_map_to_browser_indices() {
|
||||
assert_eq!(classify(1), MouseClassification::Button(0));
|
||||
assert_eq!(classify(2), MouseClassification::Button(1));
|
||||
assert_eq!(classify(3), MouseClassification::Button(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_wheel_delta_y_120_step() {
|
||||
assert_eq!(classify(4), MouseClassification::Wheel(WheelDirection::Up));
|
||||
assert_eq!(WheelDirection::Up.delta_y(), -120);
|
||||
assert_eq!(WheelDirection::Down.delta_y(), 120);
|
||||
assert_eq!(WheelDirection::Up.delta_x(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_wheel_delta_x_120_step() {
|
||||
assert_eq!(WheelDirection::Left.delta_x(), -120);
|
||||
assert_eq!(WheelDirection::Right.delta_x(), 120);
|
||||
assert_eq!(WheelDirection::Left.delta_y(), 0);
|
||||
assert_eq!(browser_button(1), Some(0));
|
||||
assert_eq!(browser_button(2), Some(1));
|
||||
assert_eq!(browser_button(3), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn back_forward_buttons_map_to_3_and_4() {
|
||||
assert_eq!(classify(8), MouseClassification::Button(3));
|
||||
assert_eq!(classify(9), MouseClassification::Button(4));
|
||||
assert_eq!(browser_button(8), Some(3));
|
||||
assert_eq!(browser_button(9), Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_buttons_are_ignored() {
|
||||
for button in 4..=7 {
|
||||
assert_eq!(browser_button(button), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_buttons_are_ignored() {
|
||||
assert_eq!(classify(0), MouseClassification::Ignored);
|
||||
assert_eq!(classify(15), MouseClassification::Ignored);
|
||||
assert_eq!(classify(255), MouseClassification::Ignored);
|
||||
assert_eq!(browser_button(0), None);
|
||||
assert_eq!(browser_button(15), None);
|
||||
assert_eq!(browser_button(255), None);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct XkbLookup {
|
||||
pub group: u32,
|
||||
pub level: u32,
|
||||
}
|
||||
|
||||
pub fn xkb_lookup_for_base() -> XkbLookup {
|
||||
XkbLookup { group: 0, level: 0 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn xkb_lookup_for_base_pins_group_level_to_zero() {
|
||||
let lookup = xkb_lookup_for_base();
|
||||
assert_eq!(lookup.group, 0);
|
||||
assert_eq!(lookup.level, 0);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user