Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 + tools/ci) with a "Connect to a Different Server" menu item and popout that lets the desktop app switch to any self-hosted Fluxer instance, plus fixes for well-known discovery on single-domain self-hosted deployments and a false-positive ERR_ABORTED on same-origin client redirects during the switch. Defaults to chat.fluxr.chat and uses an isolated userData directory from the official build.
654 lines
19 KiB
Rust
654 lines
19 KiB
Rust
#![allow(non_snake_case)]
|
|
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::thread::{self, JoinHandle};
|
|
|
|
use napi::{
|
|
Env, Status,
|
|
bindgen_prelude::{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::record::{
|
|
self, ConnectionExt as RecordConnectionExt, ExtRange, Range, Range8, Range16,
|
|
};
|
|
use x11rb::protocol::xproto::{
|
|
ConnectionExt as XprotoConnectionExt, GetKeyboardMappingReply, Keycode,
|
|
};
|
|
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::keymap;
|
|
use crate::modifiers::{self, Modifiers};
|
|
use crate::mouse::{self, MouseClassification};
|
|
use crate::x11;
|
|
|
|
const RECORD_FROM_SERVER: u8 = 0;
|
|
const RECORD_START_OF_DATA: u8 = 4;
|
|
|
|
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;
|
|
|
|
#[repr(C)]
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct XKeyButtonProto {
|
|
type_: u8,
|
|
detail: u8,
|
|
seq_l: u8,
|
|
seq_h: u8,
|
|
time: u32,
|
|
root: u32,
|
|
event: u32,
|
|
child: u32,
|
|
root_x: i16,
|
|
root_y: i16,
|
|
event_x: i16,
|
|
event_y: i16,
|
|
state: u16,
|
|
same_screen: u8,
|
|
pad0: u8,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum EventKind {
|
|
KeyDown,
|
|
KeyUp,
|
|
MouseDown,
|
|
MouseUp,
|
|
MouseMove,
|
|
Wheel,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct DecodedEvent {
|
|
pub kind: EventKind,
|
|
pub keycode: u32,
|
|
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,
|
|
pub mods: Modifiers,
|
|
}
|
|
|
|
impl DecodedEvent {
|
|
fn new(kind: EventKind, mods: Modifiers) -> Self {
|
|
Self {
|
|
kind,
|
|
keycode: 0,
|
|
key_name: String::new(),
|
|
button: 0,
|
|
delta_x: 0,
|
|
delta_y: 0,
|
|
x: 0,
|
|
y: 0,
|
|
has_xy: false,
|
|
mods,
|
|
}
|
|
}
|
|
}
|
|
|
|
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("ctrlKey", event.mods.ctrl)?;
|
|
object.set("altKey", event.mods.alt)?;
|
|
object.set("shiftKey", event.mods.shift)?;
|
|
object.set("metaKey", event.mods.meta)?;
|
|
match event.kind {
|
|
EventKind::KeyDown | EventKind::KeyUp => {
|
|
object.set("keycode", event.keycode)?;
|
|
object.set("keyName", event.key_name.as_str())?;
|
|
}
|
|
EventKind::MouseDown | EventKind::MouseUp => {
|
|
object.set("button", u32::from(event.button))?;
|
|
if event.has_xy {
|
|
object.set("x", event.x)?;
|
|
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,
|
|
>,
|
|
>;
|
|
|
|
#[derive(Clone)]
|
|
struct KeysymCache {
|
|
min_keycode: Keycode,
|
|
syms: Vec<u32>,
|
|
}
|
|
|
|
impl KeysymCache {
|
|
fn build(reply: &GetKeyboardMappingReply, min_keycode: Keycode) -> Self {
|
|
let per = reply.keysyms_per_keycode as usize;
|
|
let count = if per == 0 {
|
|
0
|
|
} else {
|
|
reply.keysyms.len() / per
|
|
};
|
|
let mut syms = Vec::with_capacity(count);
|
|
if per > 0 {
|
|
for i in 0..count {
|
|
syms.push(reply.keysyms[i * per]);
|
|
}
|
|
}
|
|
Self { min_keycode, syms }
|
|
}
|
|
|
|
fn lookup(&self, keycode: u8) -> u32 {
|
|
if keycode < self.min_keycode {
|
|
return 0;
|
|
}
|
|
let idx = (keycode - self.min_keycode) as usize;
|
|
self.syms.get(idx).copied().unwrap_or(0)
|
|
}
|
|
}
|
|
|
|
struct Active {
|
|
ctrl_conn: Arc<RustConnection>,
|
|
record_ctx: record::Context,
|
|
worker: Option<JoinHandle<()>>,
|
|
stop: Arc<AtomicBool>,
|
|
}
|
|
|
|
struct Inner {
|
|
callback: EventTsfn,
|
|
active: Mutex<Option<Active>>,
|
|
}
|
|
|
|
#[napi]
|
|
pub struct InputHook {
|
|
inner: Arc<Inner>,
|
|
}
|
|
|
|
#[napi]
|
|
impl InputHook {
|
|
#[napi(constructor)]
|
|
pub fn new(callback: Function<DecodedEvent, UnknownReturnValue>) -> Result<Self> {
|
|
let tsfn = callback
|
|
.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),
|
|
}),
|
|
})
|
|
}
|
|
|
|
#[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(())
|
|
}
|
|
|
|
#[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 {
|
|
tear_down(active);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for InputHook {
|
|
fn drop(&mut self) {
|
|
if let Ok(mut guard) = self.inner.active.lock()
|
|
&& let Some(active) = guard.take()
|
|
{
|
|
tear_down(active);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn start_record(callback: EventTsfn) -> Result<Active> {
|
|
if !detect_display_server().supports_global_xrecord() {
|
|
return Err(generic_error(
|
|
"InputHook.start failed: WaylandUnsupported — global input \
|
|
capture is blocked by the Wayland security model. Use \
|
|
@fluxer/linux-evdev for kernel-level capture when the user has \
|
|
input device access."
|
|
.to_string(),
|
|
));
|
|
}
|
|
|
|
let (ctrl_conn, _) = x11rb::connect(None)
|
|
.map_err(|err| generic_error(format!("InputHook.start failed: NoXDisplay: {err}")))?;
|
|
let (data_conn, _) = x11rb::connect(None)
|
|
.map_err(|err| generic_error(format!("InputHook.start failed: NoXDisplay: {err}")))?;
|
|
let ctrl_conn = Arc::new(ctrl_conn);
|
|
|
|
let has_record = ctrl_conn
|
|
.extension_information(record::X11_EXTENSION_NAME)
|
|
.map_err(|err| generic_error(format!("InputHook.start failed: {err}")))?
|
|
.is_some();
|
|
if !has_record {
|
|
return Err(generic_error(
|
|
"InputHook.start failed: RecordExtensionUnavailable",
|
|
));
|
|
}
|
|
ctrl_conn
|
|
.record_query_version(
|
|
record::X11_XML_VERSION.0 as _,
|
|
record::X11_XML_VERSION.1 as _,
|
|
)
|
|
.map_err(|err| generic_error(format!("InputHook.start failed: RecordQueryVersion: {err}")))?
|
|
.reply()
|
|
.map_err(|err| {
|
|
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 record_ctx = ctrl_conn
|
|
.generate_id()
|
|
.map_err(|err| generic_error(format!("InputHook.start failed: GenerateId: {err}")))?;
|
|
let empty = Range8 { first: 0, last: 0 };
|
|
let empty_ext = ExtRange {
|
|
major: empty,
|
|
minor: Range16 { first: 0, last: 0 },
|
|
};
|
|
let range = Range {
|
|
core_requests: empty,
|
|
core_replies: empty,
|
|
ext_requests: empty_ext,
|
|
ext_replies: empty_ext,
|
|
delivered_events: empty,
|
|
device_events: Range8 {
|
|
first: KEY_PRESS,
|
|
last: MOTION_NOTIFY,
|
|
},
|
|
errors: empty,
|
|
client_started: false,
|
|
client_died: false,
|
|
};
|
|
ctrl_conn
|
|
.record_create_context(record_ctx, 0, &[record::CS::ALL_CLIENTS.into()], &[range])
|
|
.map_err(|err| {
|
|
generic_error(format!(
|
|
"InputHook.start failed: RecordCreateContext: {err}"
|
|
))
|
|
})?
|
|
.check()
|
|
.map_err(|err| {
|
|
generic_error(format!(
|
|
"InputHook.start failed: RecordCreateContext: {err}"
|
|
))
|
|
})?;
|
|
|
|
let stop = Arc::new(AtomicBool::new(false));
|
|
|
|
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}")))?;
|
|
|
|
Ok(Active {
|
|
ctrl_conn,
|
|
record_ctx,
|
|
worker: Some(worker),
|
|
stop,
|
|
})
|
|
}
|
|
|
|
fn worker_main(
|
|
data_conn: RustConnection,
|
|
record_ctx: record::Context,
|
|
keysyms: KeysymCache,
|
|
callback: EventTsfn,
|
|
stop: Arc<AtomicBool>,
|
|
) {
|
|
let cookie = match data_conn.record_enable_context(record_ctx) {
|
|
Ok(c) => c,
|
|
Err(_) => {
|
|
stop.store(true, Ordering::Release);
|
|
return;
|
|
}
|
|
};
|
|
|
|
for reply in cookie {
|
|
if stop.load(Ordering::Acquire) {
|
|
break;
|
|
}
|
|
let reply = match reply {
|
|
Ok(r) => r,
|
|
Err(ReplyError::ConnectionError(_)) => break,
|
|
Err(_) => continue,
|
|
};
|
|
if reply.client_swapped {
|
|
continue;
|
|
}
|
|
match reply.category {
|
|
RECORD_START_OF_DATA => continue,
|
|
RECORD_FROM_SERVER => {}
|
|
_ => continue,
|
|
}
|
|
let mut data: &[u8] = &reply.data;
|
|
while !data.is_empty() {
|
|
let consumed = decode_one(data, &keysyms, &callback, &stop);
|
|
if consumed == 0 || consumed > data.len() {
|
|
break;
|
|
}
|
|
data = &data[consumed..];
|
|
}
|
|
}
|
|
}
|
|
|
|
fn decode_one(
|
|
data: &[u8],
|
|
keysyms: &KeysymCache,
|
|
callback: &EventTsfn,
|
|
stop: &Arc<AtomicBool>,
|
|
) -> usize {
|
|
if data.is_empty() {
|
|
return 0;
|
|
}
|
|
let type_ = data[0];
|
|
match type_ {
|
|
KEY_PRESS | KEY_RELEASE | BUTTON_PRESS | BUTTON_RELEASE | MOTION_NOTIFY => {
|
|
if data.len() < std::mem::size_of::<XKeyButtonProto>() {
|
|
return 0;
|
|
}
|
|
|
|
let evt: XKeyButtonProto =
|
|
unsafe { std::ptr::read_unaligned(data.as_ptr() as *const XKeyButtonProto) };
|
|
handle_event(&evt, keysyms, callback, stop);
|
|
32
|
|
}
|
|
0 => {
|
|
if data.len() < 8 {
|
|
return 0;
|
|
}
|
|
let (length, _) = match u32::try_parse(&data[4..]) {
|
|
Ok(v) => v,
|
|
Err(_) => return 0,
|
|
};
|
|
32 + (length as usize) * 4
|
|
}
|
|
_ => 32,
|
|
}
|
|
}
|
|
|
|
fn handle_event(
|
|
evt: &XKeyButtonProto,
|
|
keysyms: &KeysymCache,
|
|
callback: &EventTsfn,
|
|
stop: &Arc<AtomicBool>,
|
|
) {
|
|
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);
|
|
let mut event = DecodedEvent::new(
|
|
if evt.type_ == KEY_PRESS {
|
|
EventKind::KeyDown
|
|
} else {
|
|
EventKind::KeyUp
|
|
},
|
|
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.x = i32::from(evt.root_x);
|
|
event.y = i32::from(evt.root_y);
|
|
event.has_xy = true;
|
|
dispatch(callback, stop, event);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn dispatch(callback: &EventTsfn, stop: &Arc<AtomicBool>, event: DecodedEvent) {
|
|
let status = callback.call(event, ThreadsafeFunctionCallMode::NonBlocking);
|
|
if status == Status::Closing {
|
|
stop.store(true, Ordering::Release);
|
|
}
|
|
}
|
|
|
|
fn tear_down(mut active: Active) {
|
|
active.stop.store(true, Ordering::Release);
|
|
|
|
if active.record_ctx != 0 {
|
|
let _ = active.ctrl_conn.record_disable_context(active.record_ctx);
|
|
let _ = active.ctrl_conn.sync();
|
|
}
|
|
if let Some(worker) = active.worker.take() {
|
|
let _ = worker.join();
|
|
}
|
|
if active.record_ctx != 0 {
|
|
let _ = active.ctrl_conn.record_free_context(active.record_ctx);
|
|
let _ = active.ctrl_conn.sync();
|
|
}
|
|
}
|
|
|
|
#[napi(js_name = "isAvailable")]
|
|
pub fn is_available() -> bool {
|
|
if !detect_display_server().supports_global_xrecord() {
|
|
return false;
|
|
}
|
|
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())
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
const _ASSERT_PROTO_LAYOUT: fn() = || {
|
|
use std::mem::offset_of;
|
|
let _ = offset_of!(XKeyButtonProto, seq_l);
|
|
let _ = offset_of!(XKeyButtonProto, seq_h);
|
|
let _ = offset_of!(XKeyButtonProto, event);
|
|
let _ = offset_of!(XKeyButtonProto, child);
|
|
let _ = offset_of!(XKeyButtonProto, event_x);
|
|
let _ = offset_of!(XKeyButtonProto, event_y);
|
|
let _ = offset_of!(XKeyButtonProto, same_screen);
|
|
let _ = offset_of!(XKeyButtonProto, pad0);
|
|
let _ = offset_of!(XKeyButtonProto, root);
|
|
let _ = offset_of!(XKeyButtonProto, time);
|
|
};
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn decoded_keydown_carries_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();
|
|
assert!(matches!(event.kind, EventKind::KeyDown));
|
|
assert_eq!(event.keycode, 0x0061);
|
|
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);
|
|
}
|
|
|
|
#[test]
|
|
fn x_key_button_proto_layout_offsets_match_x11_wire_format() {
|
|
use std::mem::offset_of;
|
|
assert_eq!(offset_of!(XKeyButtonProto, type_), 0);
|
|
assert_eq!(offset_of!(XKeyButtonProto, detail), 1);
|
|
|
|
assert_eq!(offset_of!(XKeyButtonProto, time), 4);
|
|
assert_eq!(offset_of!(XKeyButtonProto, root_x), 20);
|
|
assert_eq!(offset_of!(XKeyButtonProto, root_y), 22);
|
|
assert_eq!(offset_of!(XKeyButtonProto, state), 28);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn keysym_cache_returns_zero_below_min_keycode() {
|
|
let cache = KeysymCache {
|
|
min_keycode: 8,
|
|
syms: vec![0x61, 0x62, 0x63],
|
|
};
|
|
assert_eq!(cache.lookup(7), 0);
|
|
assert_eq!(cache.lookup(8), 0x61);
|
|
assert_eq!(cache.lookup(10), 0x63);
|
|
assert_eq!(cache.lookup(255), 0);
|
|
}
|
|
}
|