feat: initial implementation of mousefree

Keyboard-driven mouse navigation overlay for Wayland using
wlr-layer-shell and wlr-virtual-pointer. Two-key grid selection,
click/double-click/right-click/drag, three-tier cursor nudge
(hjkl/HJKL/Ctrl+hjkl), and scroll passthrough.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
swaitsandClaude Opus 4.6 committed 2026-04-03 08:36:22 -06:00
commit e22d8251ed
12 files changed
+3307

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
//! System font loading via fontconfig and fontdue.
//!
//! Discovers a sans-serif font using `fc-match`, loads it once at startup,
//! and exposes it for glyph rasterization during rendering.
use anyhow::{Context, Result};
use std::sync::OnceLock;
static FONT: OnceLock<fontdue::Font> = OnceLock::new();
/// Discover and load a system sans-serif font. Must be called once at startup.
pub fn init() -> Result<()> {
let font = try_fc_match().context(
"could not find a system font via fc-match. Is fontconfig installed? Try: fc-match sans-serif",
)?;
FONT.set(font).ok();
Ok(())
}
/// Returns the loaded font, or `None` if [`init`] has not been called.
pub fn font() -> Option<&'static fontdue::Font> {
FONT.get()
}
fn try_fc_match() -> Option<fontdue::Font> {
let output = std::process::Command::new("fc-match")
.args(["--format=%{file}", "sans-serif"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let path = String::from_utf8_lossy(&output.stdout);
let data = std::fs::read(path.trim()).ok()?;
fontdue::Font::from_bytes(data, fontdue::FontSettings::default()).ok()
}
+399
View File
@@ -0,0 +1,399 @@
//! Grid selection state machine and application state.
//!
//! Tracks the user's progress through column selection, row selection, and
//! post-selection actions (nudge, undo). The key arrays define the grid layout.
/// Keys displayed along the top axis of the grid (columns).
///
/// Letters only — no digits — so every pill is unambiguously two letters.
pub const COL_KEYS: &[char] = &['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'p'];
/// Keys displayed along the side axis of the grid (rows).
///
/// Inspired by Vimium's easy-to-reach character set. Overlaps with column
/// keys on purpose — the state machine disambiguates (first press = column,
/// second = row), so combos like "aa" and "ss" are valid and fast to type.
/// Ordered by keyboard row (top → home → bottom ≈ screen top → bottom).
pub const ROW_KEYS: &[char] = &[
'w', 'e', 'r', 't', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'p', 'c', 'm',
];
/// Returns the number of columns in the selection grid.
pub fn grid_cols() -> u32 {
COL_KEYS.len() as u32
}
/// Returns the number of rows in the selection grid.
pub fn grid_rows() -> u32 {
ROW_KEYS.len() as u32
}
#[cfg(test)]
fn is_col_key(ch: char) -> bool {
COL_KEYS.contains(&ch)
}
/// Returns `true` if `ch` is a valid row-selection key.
pub fn is_row_key(ch: char) -> bool {
ROW_KEYS.contains(&ch)
}
// ---------------------------------------------------------------------------
// Selection phase — enforces valid state transitions at the type level.
// ---------------------------------------------------------------------------
/// Tracks how far the user has progressed through the two-key grid selection.
#[derive(Clone, Debug)]
pub enum SelectionPhase {
/// No key pressed yet; cursor sits at screen center.
Initial { cursor: (u32, u32) },
/// Column key pressed; waiting for the row key.
ColumnSelected { col: char, cursor: (u32, u32) },
/// Both keys pressed; cell is locked and cursor is centered in it.
CellSelected {
col: char,
row: char,
cursor: (u32, u32),
},
}
impl SelectionPhase {
pub fn new(w: u32, h: u32) -> Self {
Self::Initial {
cursor: (w / 2, h / 2),
}
}
pub fn cursor(&self) -> (u32, u32) {
match *self {
Self::Initial { cursor }
| Self::ColumnSelected { cursor, .. }
| Self::CellSelected { cursor, .. } => cursor,
}
}
pub fn is_cell_selected(&self) -> bool {
matches!(self, Self::CellSelected { .. })
}
/// Returns the pending column key, if in the `ColumnSelected` phase.
pub fn pending_col(&self) -> Option<char> {
match *self {
Self::ColumnSelected { col, .. } => Some(col),
_ => None,
}
}
/// Builds the two-character label shown in the status bar (e.g. "AQ").
pub fn cell_label(&self) -> String {
match *self {
Self::CellSelected { col, row, .. } => {
let mut s = String::with_capacity(2);
s.push(col);
s.push(row);
s
}
_ => String::new(),
}
}
/// Transition: accept a column key, snap cursor horizontally.
pub fn select_column(&self, col: char, w: u32, h: u32) -> Option<Self> {
let col_idx = COL_KEYS.iter().position(|&c| c == col)? as u32;
let cols = grid_cols();
if cols == 0 || w < cols {
return None;
}
let cell_w = w / cols;
Some(Self::ColumnSelected {
col,
cursor: (col_idx * cell_w + cell_w / 2, h / 2),
})
}
/// Transition: accept a row key (must already have a column), lock the cell.
pub fn select_cell(&self, row: char, w: u32, h: u32) -> Option<Self> {
let col = self.pending_col()?;
let col_idx = COL_KEYS.iter().position(|&c| c == col)? as u32;
let row_idx = ROW_KEYS.iter().position(|&c| c == row)? as u32;
let cols = grid_cols();
let rows = grid_rows();
if cols == 0 || rows == 0 || w < cols || h < rows {
return None;
}
let cell_w = w / cols;
let cell_h = h / rows;
Some(Self::CellSelected {
col,
row,
cursor: (col_idx * cell_w + cell_w / 2, row_idx * cell_h + cell_h / 2),
})
}
/// Offset the cursor by `(dx, dy)` pixels, clamping to screen bounds.
pub fn nudge(&self, dx: i32, dy: i32, w: u32, h: u32) -> Self {
let (cx, cy) = self.cursor();
let mut out = self.clone();
let new_cursor = (
(cx as i32 + dx).clamp(0, w as i32 - 1) as u32,
(cy as i32 + dy).clamp(0, h as i32 - 1) as u32,
);
match &mut out {
Self::Initial { cursor }
| Self::ColumnSelected { cursor, .. }
| Self::CellSelected { cursor, .. } => *cursor = new_cursor,
}
out
}
/// Step back one phase. Returns `None` from `Initial` (caller decides
/// whether that means "exit" or "ignore").
pub fn undo(&self, w: u32, h: u32) -> Option<Self> {
match self {
Self::CellSelected { .. } | Self::ColumnSelected { .. } => Some(Self::new(w, h)),
Self::Initial { .. } => None,
}
}
}
// ---------------------------------------------------------------------------
// Top-level application state.
// ---------------------------------------------------------------------------
/// Complete UI state: the selection phase plus optional drag origin.
pub struct AppState {
pub phase: SelectionPhase,
pub drag_origin: Option<(u32, u32)>,
}
impl AppState {
pub fn new(w: u32, h: u32) -> Self {
Self {
phase: SelectionPhase::new(w, h),
drag_origin: None,
}
}
pub fn is_dragging(&self) -> bool {
self.drag_origin.is_some()
}
}
// ===========================================================================
// Tests.
// ===========================================================================
#[cfg(test)]
mod tests {
use super::*;
const W: u32 = 1920;
const H: u32 = 1080;
// -- Grid constants -----------------------------------------------------
#[test]
fn grid_dimensions_match_key_arrays() {
assert_eq!(grid_cols() as usize, COL_KEYS.len());
assert_eq!(grid_rows() as usize, ROW_KEYS.len());
}
#[test]
fn is_col_key_accepts_all_col_keys() {
for &k in COL_KEYS {
assert!(is_col_key(k), "{k} should be a column key");
}
}
#[test]
fn is_row_key_accepts_all_row_keys() {
for &k in ROW_KEYS {
assert!(is_row_key(k), "{k} should be a row key");
}
}
#[test]
fn row_keys_cover_all_col_keys() {
// Every column key is also a valid row key, enabling fast double-tap
// combos like "aa" and "ss".
for &k in COL_KEYS {
assert!(is_row_key(k), "{k} is a col key but not a row key");
}
}
// -- SelectionPhase transitions -----------------------------------------
#[test]
fn initial_cursor_is_screen_center() {
let phase = SelectionPhase::new(W, H);
assert_eq!(phase.cursor(), (W / 2, H / 2));
assert!(!phase.is_cell_selected());
assert_eq!(phase.pending_col(), None);
}
#[test]
fn select_column_transitions_to_column_selected() {
let phase = SelectionPhase::new(W, H);
let next = phase.select_column('a', W, H).unwrap();
assert!(next.pending_col().is_some());
assert_eq!(next.pending_col(), Some('a'));
assert!(!next.is_cell_selected());
}
#[test]
fn select_column_snaps_cursor_x_to_cell_center() {
let phase = SelectionPhase::new(W, H);
let cell_w = W / grid_cols();
// First column ('a' at index 0).
let next = phase.select_column('a', W, H).unwrap();
assert_eq!(next.cursor().0, cell_w / 2);
// Last column ('p' at index 9).
let next = phase.select_column('p', W, H).unwrap();
assert_eq!(next.cursor().0, 9 * cell_w + cell_w / 2);
}
#[test]
fn select_cell_from_column_selected() {
let phase = SelectionPhase::new(W, H).select_column('a', W, H).unwrap();
let next = phase.select_cell('w', W, H);
assert!(next.is_some());
let next = next.unwrap();
assert!(next.is_cell_selected());
let cell_w = W / grid_cols();
let cell_h = H / grid_rows();
assert_eq!(next.cursor(), (cell_w / 2, cell_h / 2));
}
#[test]
fn select_cell_from_initial_returns_none() {
let phase = SelectionPhase::new(W, H);
assert!(phase.select_cell('w', W, H).is_none());
}
#[test]
fn select_cell_with_overlapping_key() {
// 'a' is both a col key and a row key — "aa" should work.
let phase = SelectionPhase::new(W, H).select_column('a', W, H).unwrap();
let next = phase.select_cell('a', W, H);
assert!(next.is_some());
assert!(next.unwrap().is_cell_selected());
}
#[test]
fn select_cell_with_invalid_row_key_returns_none() {
let phase = SelectionPhase::new(W, H).select_column('a', W, H).unwrap();
// '!' is not a row key.
assert!(phase.select_cell('!', W, H).is_none());
}
#[test]
fn cell_label_only_when_selected() {
let initial = SelectionPhase::new(W, H);
assert_eq!(initial.cell_label(), "");
let col = initial.select_column('d', W, H).unwrap();
assert_eq!(col.cell_label(), "");
let cell = col.select_cell('w', W, H).unwrap();
assert_eq!(cell.cell_label(), "dw");
}
// -- Nudge --------------------------------------------------------------
#[test]
fn nudge_moves_cursor() {
let phase = SelectionPhase::new(W, H);
let nudged = phase.nudge(10, -5, W, H);
let (cx, cy) = nudged.cursor();
assert_eq!(cx, W / 2 + 10);
assert_eq!(cy, H / 2 - 5);
}
#[test]
fn nudge_clamps_to_zero() {
let phase = SelectionPhase::new(W, H);
let nudged = phase.nudge(-10000, -10000, W, H);
assert_eq!(nudged.cursor(), (0, 0));
}
#[test]
fn nudge_clamps_to_screen_edge() {
let phase = SelectionPhase::new(W, H);
let nudged = phase.nudge(10000, 10000, W, H);
assert_eq!(nudged.cursor(), (W - 1, H - 1));
}
#[test]
fn nudge_preserves_phase_variant() {
let cell = SelectionPhase::new(W, H)
.select_column('a', W, H)
.unwrap()
.select_cell('w', W, H)
.unwrap();
let nudged = cell.nudge(5, 5, W, H);
assert!(nudged.is_cell_selected());
}
// -- Undo ---------------------------------------------------------------
#[test]
fn undo_from_cell_selected_returns_initial() {
let cell = SelectionPhase::new(W, H)
.select_column('a', W, H)
.unwrap()
.select_cell('w', W, H)
.unwrap();
let prev = cell.undo(W, H);
assert!(prev.is_some());
assert!(!prev.unwrap().is_cell_selected());
}
#[test]
fn undo_from_column_selected_returns_initial() {
let col = SelectionPhase::new(W, H).select_column('a', W, H).unwrap();
let prev = col.undo(W, H);
assert!(prev.is_some());
assert_eq!(prev.unwrap().pending_col(), None);
}
#[test]
fn undo_from_initial_returns_none() {
let initial = SelectionPhase::new(W, H);
assert!(initial.undo(W, H).is_none());
}
// -- AppState -----------------------------------------------------------
#[test]
fn app_state_initial_not_dragging() {
let state = AppState::new(W, H);
assert!(!state.is_dragging());
}
#[test]
fn app_state_dragging_when_origin_set() {
let mut state = AppState::new(W, H);
state.drag_origin = Some((100, 200));
assert!(state.is_dragging());
}
// -- Grid math covers full screen ---------------------------------------
#[test]
fn grid_cells_tile_screen() {
let cell_w = W / grid_cols();
let cell_h = H / grid_rows();
// Every cell center should be within screen bounds.
for col in 0..grid_cols() {
for row in 0..grid_rows() {
let cx = col * cell_w + cell_w / 2;
let cy = row * cell_h + cell_h / 2;
assert!(cx < W, "col {col} center x={cx} out of bounds");
assert!(cy < H, "row {row} center y={cy} out of bounds");
}
}
}
}
+267
View File
@@ -0,0 +1,267 @@
//! mousefree — keyboard-driven mouse navigation for Wayland.
//!
//! Displays a full-screen overlay grid, lets the user select a cell with two
//! keystrokes, then performs mouse actions (click, drag, scroll) at that
//! location via a virtual pointer.
mod font;
mod input;
mod render;
mod wayland;
use anyhow::Context;
use tiny_skia::Pixmap;
use input::{AppState, SelectionPhase};
use wayland::{KeyEvent, WaylandBackend};
/// Pixels per pixel-perfect cursor nudge (Alt+hjkl).
const CURSOR_NUDGE_XS: i32 = 1;
/// Pixels per cursor nudge (hjkl).
const CURSOR_NUDGE_SM: i32 = 8;
/// Pixels per large cursor nudge (HJKL).
const CURSOR_NUDGE_MD: i32 = 16;
/// Pixels per extra-large cursor nudge (Ctrl+hjkl).
const CURSOR_NUDGE_LG: i32 = 32;
fn main() -> anyhow::Result<()> {
font::init()?;
let mut wl = WaylandBackend::new()?;
let (w, h) = wl.screen_size();
let mut pixmap = Pixmap::new(w, h).context("failed to create pixmap")?;
let buf_size = (w as usize)
.checked_mul(h as usize)
.and_then(|n| n.checked_mul(4))
.context("screen dimensions too large for pixel buffer")?;
let mut argb_buf = vec![0u8; buf_size];
let mut state = AppState::new(w, h);
draw(&mut wl, &mut pixmap, &mut argb_buf, &state)?;
while let Some(key) = wl.next_key()? {
match handle_key(&key, &mut state, &mut wl, w, h)? {
KeyAction::Redraw => draw(&mut wl, &mut pixmap, &mut argb_buf, &state)?,
KeyAction::Exit => {
wl.exit()?;
break;
}
KeyAction::None => {}
}
}
Ok(())
}
/// Result of processing a single key event.
enum KeyAction {
None,
Redraw,
Exit,
}
fn draw(
wl: &mut WaylandBackend,
pixmap: &mut Pixmap,
argb_buf: &mut [u8],
state: &AppState,
) -> anyhow::Result<()> {
render::render_grid(pixmap, state);
render::render_status_bar(pixmap, state);
render::pixmap_to_argb8888(pixmap, argb_buf);
let (w, h) = wl.screen_size();
wl.present(argb_buf, w, h)
}
// ---------------------------------------------------------------------------
// Key dispatch — thin router that delegates to focused handlers.
// ---------------------------------------------------------------------------
fn handle_key(
key: &KeyEvent,
state: &mut AppState,
wl: &mut WaylandBackend,
w: u32,
h: u32,
) -> anyhow::Result<KeyAction> {
match key {
KeyEvent::Close => {
wl.wait_for_key_release()?;
Ok(KeyAction::Exit)
}
KeyEvent::Undo => handle_undo(state, wl, w, h),
// Nudge cursor (only when a cell is locked).
KeyEvent::Char('h' | 'j' | 'k' | 'l' | 'H' | 'J' | 'K' | 'L')
| KeyEvent::CtrlChar('h' | 'j' | 'k' | 'l')
| KeyEvent::AltChar('h' | 'j' | 'k' | 'l')
if state.phase.is_cell_selected() =>
{
handle_nudge(key, state, wl, w, h)
}
// Click / drag actions (only when a cell is locked).
KeyEvent::Click
| KeyEvent::DoubleClick
| KeyEvent::TripleClick
| KeyEvent::RightClick
| KeyEvent::Char('/')
if state.phase.is_cell_selected() =>
{
handle_action(key, state, wl)
}
// Two-key grid selection (only before a cell is locked).
KeyEvent::Char(ch) if !state.phase.is_cell_selected() => {
handle_grid_input(*ch, state, wl, w, h)
}
// Scroll passes through to the underlying window.
KeyEvent::ScrollUp => {
wl.scroll_up()?;
Ok(KeyAction::Redraw)
}
KeyEvent::ScrollDown => {
wl.scroll_down()?;
Ok(KeyAction::Redraw)
}
KeyEvent::ScrollLeft => {
wl.scroll_left()?;
Ok(KeyAction::Redraw)
}
KeyEvent::ScrollRight => {
wl.scroll_right()?;
Ok(KeyAction::Redraw)
}
_ => Ok(KeyAction::None),
}
}
// ---------------------------------------------------------------------------
// Focused handlers.
// ---------------------------------------------------------------------------
fn handle_undo(
state: &mut AppState,
wl: &mut WaylandBackend,
w: u32,
h: u32,
) -> anyhow::Result<KeyAction> {
if let Some(prev) = state.phase.undo(w, h) {
state.phase = prev;
state.drag_origin = None;
let (cx, cy) = state.phase.cursor();
wl.move_mouse(cx, cy)?;
Ok(KeyAction::Redraw)
} else {
// Already at Initial — back out of the overlay entirely.
wl.wait_for_key_release()?;
Ok(KeyAction::Exit)
}
}
fn handle_nudge(
key: &KeyEvent,
state: &mut AppState,
wl: &mut WaylandBackend,
w: u32,
h: u32,
) -> anyhow::Result<KeyAction> {
let (dx, dy) = match key {
KeyEvent::Char('h' | 'H') | KeyEvent::CtrlChar('h') | KeyEvent::AltChar('h') => (-1, 0),
KeyEvent::Char('j' | 'J') | KeyEvent::CtrlChar('j') | KeyEvent::AltChar('j') => (0, 1),
KeyEvent::Char('k' | 'K') | KeyEvent::CtrlChar('k') | KeyEvent::AltChar('k') => (0, -1),
KeyEvent::Char('l' | 'L') | KeyEvent::CtrlChar('l') | KeyEvent::AltChar('l') => (1, 0),
_ => unreachable!("handle_nudge called with non-hjkl key"),
};
let step = match key {
KeyEvent::AltChar(_) => CURSOR_NUDGE_XS,
KeyEvent::CtrlChar(_) => CURSOR_NUDGE_LG,
KeyEvent::Char('H' | 'J' | 'K' | 'L') => CURSOR_NUDGE_MD,
_ => CURSOR_NUDGE_SM,
};
state.phase = state.phase.nudge(dx * step, dy * step, w, h);
let (cx, cy) = state.phase.cursor();
wl.move_mouse(cx, cy)?;
Ok(KeyAction::Redraw)
}
/// Handle click, double-click, right-click, and drag start/complete.
fn handle_action(
key: &KeyEvent,
state: &mut AppState,
wl: &mut WaylandBackend,
) -> anyhow::Result<KeyAction> {
let (cx, cy) = state.phase.cursor();
// If already dragging, complete the drag on click or '/'.
if let Some((ox, oy)) = state.drag_origin {
if matches!(key, KeyEvent::Click | KeyEvent::Char('/')) {
wl.wait_for_key_release()?;
wl.drag_select(ox, oy, cx, cy)?;
return Ok(KeyAction::Exit);
}
}
match key {
KeyEvent::Click => {
wl.wait_for_key_release()?;
wl.click(cx, cy)?;
Ok(KeyAction::Exit)
}
KeyEvent::DoubleClick => {
wl.wait_for_key_release()?;
wl.double_click(cx, cy)?;
Ok(KeyAction::Exit)
}
KeyEvent::TripleClick => {
wl.wait_for_key_release()?;
wl.triple_click(cx, cy)?;
Ok(KeyAction::Exit)
}
KeyEvent::RightClick if !state.is_dragging() => {
wl.wait_for_key_release()?;
wl.right_click(cx, cy)?;
Ok(KeyAction::Exit)
}
// '/' starts a drag: remember origin, reset selection for drop target.
KeyEvent::Char('/') if !state.is_dragging() => {
state.drag_origin = Some((cx, cy));
let (w, h) = wl.screen_size();
state.phase = SelectionPhase::new(w, h);
Ok(KeyAction::Redraw)
}
_ => Ok(KeyAction::None),
}
}
fn handle_grid_input(
ch: char,
state: &mut AppState,
wl: &mut WaylandBackend,
w: u32,
h: u32,
) -> anyhow::Result<KeyAction> {
// Second key: select row. Checked FIRST so that overlapping col/row
// keys (e.g. "aa", "ss") complete the cell rather than re-selecting
// the column.
if state.phase.pending_col().is_some() && input::is_row_key(ch) {
if let Some(next) = state.phase.select_cell(ch, w, h) {
state.phase = next;
let (cx, cy) = state.phase.cursor();
wl.move_mouse(cx, cy)?;
return Ok(KeyAction::Redraw);
}
}
// First key (or column re-selection if ch isn't a valid row key).
if let Some(next) = state.phase.select_column(ch, w, h) {
state.phase = next;
let (cx, cy) = state.phase.cursor();
wl.move_mouse(cx, cy)?;
return Ok(KeyAction::Redraw);
}
Ok(KeyAction::None)
}
+840
View File
@@ -0,0 +1,840 @@
//! Overlay rendering: hint-pill grid, crosshair, and status bar.
//!
//! All drawing targets a `tiny_skia::Pixmap` which is then converted to
//! Wayland's ARGB8888 format for presentation.
use tiny_skia::{Color, FillRule, Paint, PathBuilder, Pixmap, Rect, Shader, Stroke, Transform};
use crate::{
font::font,
input::{self, AppState, SelectionPhase},
};
// ===========================================================================
// Color palette.
//
// Design: "dark glass" — semi-transparent dark pills that read well over any
// background. One accent (blue) for interactive states, warm amber for drag.
// Three-tier visual hierarchy: matched > default > dimmed.
// ===========================================================================
// -- Hint pills (two-letter bubbles on the grid) ----------------------------
const PILL_W: f32 = 36.0;
const PILL_H: f32 = 22.0;
const PILL_RADIUS: f32 = 6.0;
const PILL_FONT_SIZE: f32 = 14.0;
const PILL_LETTER_GAP: f32 = 2.0;
const PILL_BOLD_PASSES: i32 = 2;
const PILL_SHADOW_OFFSET: f32 = 1.5;
const PILL_BORDER_WIDTH: f32 = 1.0;
// Default state — dark glass with a hint of blue, bright border frames the text.
fn pill_bg() -> Color {
Color::from_rgba8(16, 20, 38, 0xD8)
}
fn pill_border() -> Color {
Color::from_rgba8(130, 135, 150, 0xB0)
}
fn pill_text() -> Color {
Color::from_rgba8(240, 242, 250, 0xFF)
}
// Dimmed state — columns that don't match the pending selection fade away.
fn pill_dim_bg() -> Color {
Color::from_rgba8(15, 17, 28, 0x20)
}
fn pill_dim_border() -> Color {
Color::from_rgba8(15, 17, 28, 0x20)
}
fn pill_dim_text() -> Color {
Color::from_rgba8(90, 95, 110, 0x30)
}
// Match state — the active column blazes with the accent blue.
fn pill_match_bg() -> Color {
Color::from_rgba8(45, 110, 230, 0xF0)
}
fn pill_match_border() -> Color {
Color::from_rgba8(110, 170, 255, 0xE0)
}
fn pill_match_text() -> Color {
Color::from_rgba8(255, 255, 255, 0xFF)
}
fn pill_shadow() -> Color {
Color::from_rgba8(0, 0, 0, 0x48)
}
// -- Crosshair (shown after cell selection) ---------------------------------
fn crosshair_color() -> Color {
Color::from_rgba8(55, 120, 235, 0xFF) // same accent blue
}
fn crosshair_drag_color() -> Color {
Color::from_rgba8(240, 175, 55, 0xFF) // warm amber
}
fn crosshair_outline() -> Color {
Color::from_rgba8(0, 0, 0, 0xB0)
}
const CROSSHAIR_ARM: f32 = 20.0;
const CROSSHAIR_GAP: f32 = 4.0;
const CROSSHAIR_THICK: f32 = 2.0;
// -- Screen overlay ---------------------------------------------------------
fn screen_dim() -> Color {
Color::from_rgba8(0, 0, 0, 0x30)
}
// -- Status bar (bottom-center help text) -----------------------------------
fn status_bg() -> Color {
Color::from_rgba8(16, 18, 28, 0xE8)
}
/// Accent blue for key labels — matches the pill match / crosshair palette.
fn status_key_color() -> Color {
Color::from_rgba8(110, 170, 255, 0xFF)
}
/// Muted text for action descriptions.
fn status_desc_color() -> Color {
Color::from_rgba8(170, 175, 190, 0xD0)
}
/// Dim separator between key groups.
fn status_sep_color() -> Color {
Color::from_rgba8(80, 85, 100, 0x80)
}
/// Bright highlight for the cell label / state indicator.
fn status_label_color() -> Color {
Color::from_rgba8(255, 255, 255, 0xFF)
}
/// Warm amber for drag state indicator.
fn status_drag_color() -> Color {
Color::from_rgba8(240, 175, 55, 0xFF)
}
const STATUS_FONT_SIZE: f32 = 15.5;
const STATUS_PAD_X: f32 = 14.0;
const STATUS_PAD_Y: f32 = 7.0;
const STATUS_BAR_RADIUS: f32 = 8.0;
const STATUS_BOLD_PASSES: i32 = 2;
// ===========================================================================
// Public rendering entry points.
// ===========================================================================
/// Renders either the hint-pill grid (pre-selection) or the crosshair
/// (post-selection) into `pixmap`.
pub fn render_grid(pixmap: &mut Pixmap, state: &AppState) {
pixmap.fill(Color::TRANSPARENT);
// Dim the desktop so the overlay elements stand out.
let w = pixmap.width() as f32;
let h = pixmap.height() as f32;
fill_rect(pixmap, 0.0, 0.0, w, h, screen_dim());
if state.phase.is_cell_selected() {
draw_crosshair(pixmap, state.phase.cursor(), state.is_dragging());
} else {
draw_pill_grid(pixmap, &state.phase);
}
}
/// Renders the context-sensitive help bar at the bottom of the screen.
pub fn render_status_bar(pixmap: &mut Pixmap, state: &AppState) {
let segments = status_segments(state);
let w = pixmap.width() as f32;
let h = pixmap.height() as f32;
let Some(ft) = font() else { return };
// Pre-rasterize all glyphs to measure total width.
let rasterized: Vec<_> = segments
.iter()
.map(|seg| {
let glyphs: Vec<_> = seg
.text
.chars()
.map(|ch| ft.rasterize(ch, STATUS_FONT_SIZE))
.collect();
let w: f32 = glyphs.iter().map(|(m, _)| m.advance_width).sum();
(glyphs, w)
})
.collect();
let text_w: f32 = rasterized.iter().map(|(_, w)| w).sum();
let bar_h = STATUS_FONT_SIZE + STATUS_PAD_Y * 2.0;
let bar_w = text_w + STATUS_PAD_X * 2.0;
let bar_x = (w - bar_w) / 2.0;
let bar_y = h - bar_h - 8.0;
fill_rounded_rect(
pixmap,
bar_x,
bar_y,
bar_w,
bar_h,
STATUS_BAR_RADIUS,
status_bg(),
);
let mut gx = bar_x + STATUS_PAD_X;
let baseline_y = bar_y + STATUS_PAD_Y + STATUS_FONT_SIZE;
for (i, seg) in segments.iter().enumerate() {
let bold = seg.bold;
let (ref glyphs, _) = rasterized[i];
for (metrics, bitmap) in glyphs {
let passes = if bold { STATUS_BOLD_PASSES } else { 1 };
for off in 0..passes {
blit_glyph(
pixmap,
gx + metrics.xmin as f32 + off as f32,
baseline_y - metrics.height as f32 - metrics.ymin as f32,
metrics.width,
metrics.height,
bitmap,
seg.color,
);
}
gx += metrics.advance_width;
}
}
}
/// Convert a tiny_skia `Pixmap` (premultiplied RGBA) to Wayland's ARGB8888
/// format (premultiplied BGRA in memory on little-endian).
pub fn pixmap_to_argb8888(pixmap: &Pixmap, out: &mut [u8]) {
let src = pixmap.data();
assert_eq!(
out.len(),
src.len(),
"output buffer size must match pixmap data size"
);
// Swap R and B channels: RGBA → BGRA.
for (dst, src) in out.chunks_exact_mut(4).zip(src.chunks_exact(4)) {
dst[0] = src[2]; // B
dst[1] = src[1]; // G
dst[2] = src[0]; // R
dst[3] = src[3]; // A
}
}
// ===========================================================================
// Private drawing helpers.
// ===========================================================================
fn draw_crosshair(pixmap: &mut Pixmap, (cx, cy): (u32, u32), dragging: bool) {
let cx = cx as f32;
let cy = cy as f32;
let color = if dragging {
crosshair_drag_color()
} else {
crosshair_color()
};
let half = CROSSHAIR_THICK / 2.0;
let arm = CROSSHAIR_ARM;
let gap = CROSSHAIR_GAP;
// Outline: slightly larger rectangles behind each arm.
let outline = crosshair_outline();
let o = 1.0; // outline padding
fill_rect(
pixmap,
cx - arm - o,
cy - half - o,
arm - gap + o,
CROSSHAIR_THICK + o * 2.0,
outline,
);
fill_rect(
pixmap,
cx + gap,
cy - half - o,
arm - gap + o,
CROSSHAIR_THICK + o * 2.0,
outline,
);
fill_rect(
pixmap,
cx - half - o,
cy - arm - o,
CROSSHAIR_THICK + o * 2.0,
arm - gap + o,
outline,
);
fill_rect(
pixmap,
cx - half - o,
cy + gap,
CROSSHAIR_THICK + o * 2.0,
arm - gap + o,
outline,
);
// Colored arms.
fill_rect(
pixmap,
cx - arm,
cy - half,
arm - gap,
CROSSHAIR_THICK,
color,
);
fill_rect(
pixmap,
cx + gap,
cy - half,
arm - gap,
CROSSHAIR_THICK,
color,
);
fill_rect(
pixmap,
cx - half,
cy - arm,
CROSSHAIR_THICK,
arm - gap,
color,
);
fill_rect(
pixmap,
cx - half,
cy + gap,
CROSSHAIR_THICK,
arm - gap,
color,
);
}
fn draw_pill_grid(pixmap: &mut Pixmap, phase: &SelectionPhase) {
let w = pixmap.width() as f32;
let h = pixmap.height() as f32;
let cols = input::grid_cols() as f32;
let rows = input::grid_rows() as f32;
let cell_w = w / cols;
let cell_h = h / rows;
let pending = phase.pending_col();
for (row_idx, &row_key) in input::ROW_KEYS.iter().enumerate() {
for (col_idx, &col_key) in input::COL_KEYS.iter().enumerate() {
let cx = col_idx as f32 * cell_w + cell_w / 2.0;
let cy = row_idx as f32 * cell_h + cell_h / 2.0;
let col_match = pending == Some(col_key);
let (bg, border, text) = match (pending.is_some(), col_match) {
(true, false) => (pill_dim_bg(), pill_dim_border(), pill_dim_text()),
(_, true) => (pill_match_bg(), pill_match_border(), pill_match_text()),
_ => (pill_bg(), pill_border(), pill_text()),
};
draw_hint_pill(
pixmap,
cx,
cy,
col_key.to_ascii_uppercase(),
row_key.to_ascii_uppercase(),
bg,
border,
text,
);
}
}
}
/// Draw a single hint pill: shadow, rounded-rect fill, border stroke, text.
#[allow(clippy::too_many_arguments)]
fn draw_hint_pill(
pixmap: &mut Pixmap,
cx: f32,
cy: f32,
ch1: char,
ch2: char,
bg: Color,
border: Color,
text_color: Color,
) {
let px = cx - PILL_W / 2.0;
let py = cy - PILL_H / 2.0;
// Shadow (offset down-right).
fill_rounded_rect(
pixmap,
px + PILL_SHADOW_OFFSET,
py + PILL_SHADOW_OFFSET,
PILL_W,
PILL_H,
PILL_RADIUS,
pill_shadow(),
);
// Background fill.
fill_rounded_rect(pixmap, px, py, PILL_W, PILL_H, PILL_RADIUS, bg);
// Border stroke.
stroke_rounded_rect(
pixmap,
px,
py,
PILL_W,
PILL_H,
PILL_RADIUS,
PILL_BORDER_WIDTH,
border,
);
// Text (two characters, faux-bold via horizontal repetition).
let Some(ft) = font() else { return };
let (m1, bm1) = ft.rasterize(ch1, PILL_FONT_SIZE);
let (m2, bm2) = ft.rasterize(ch2, PILL_FONT_SIZE);
let text_w = m1.advance_width + PILL_LETTER_GAP + m2.advance_width;
let start_x = px + (PILL_W - text_w) / 2.0;
let baseline_y = py + PILL_H / 2.0 + PILL_FONT_SIZE / 3.0;
let g1x = start_x + m1.xmin as f32;
let g1y = baseline_y - m1.height as f32 - m1.ymin as f32;
let g2x = start_x + m1.advance_width + PILL_LETTER_GAP + m2.xmin as f32;
let g2y = baseline_y - m2.height as f32 - m2.ymin as f32;
for off in 0..PILL_BOLD_PASSES {
blit_glyph(
pixmap,
g1x + off as f32,
g1y,
m1.width,
m1.height,
&bm1,
text_color,
);
blit_glyph(
pixmap,
g2x + off as f32,
g2y,
m2.width,
m2.height,
&bm2,
text_color,
);
}
}
/// A styled text segment in the status bar.
struct Span {
text: String,
color: Color,
bold: bool,
}
impl Span {
fn key(s: &str) -> Self {
Self {
text: s.to_string(),
color: status_key_color(),
bold: true,
}
}
fn desc(s: &str) -> Self {
Self {
text: s.to_string(),
color: status_desc_color(),
bold: false,
}
}
fn sep() -> Self {
Self {
text: " \u{00b7} ".to_string(), // middle dot
color: status_sep_color(),
bold: false,
}
}
fn label(s: &str) -> Self {
Self {
text: s.to_string(),
color: status_label_color(),
bold: true,
}
}
fn drag(s: &str) -> Self {
Self {
text: s.to_string(),
color: status_drag_color(),
bold: true,
}
}
}
/// Builds styled segments for the status bar.
fn status_segments(state: &AppState) -> Vec<Span> {
let dragging = state.is_dragging();
let phase = &state.phase;
if phase.is_cell_selected() && dragging {
let mut s = vec![
Span::drag("DRAG "),
Span::label(&phase.cell_label()),
Span::sep(),
Span::key("/ "),
Span::key("Space"),
Span::desc(" drop"),
];
push_nudge_spans(&mut s);
push_nav_spans(&mut s);
s
} else if phase.is_cell_selected() {
let mut s = vec![
Span::label(&phase.cell_label()),
Span::sep(),
Span::key("Space"),
Span::desc(" click"),
Span::sep(),
Span::key("Enter"),
Span::desc(" double"),
Span::sep(),
Span::key("S-Enter"),
Span::desc(" triple"),
Span::sep(),
Span::key(" . "),
Span::desc(" right"),
Span::sep(),
Span::key("/"),
Span::desc(" drag"),
];
push_nudge_spans(&mut s);
push_nav_spans(&mut s);
s
} else if let Some(col) = phase.pending_col() {
vec![
Span::label(&format!("{col}_")),
Span::desc(" type row key"),
Span::sep(),
Span::key("BS"),
Span::desc(" back"),
Span::sep(),
Span::key("Esc"),
Span::desc(" quit"),
]
} else if dragging {
vec![
Span::drag("DRAG"),
Span::desc(" type col+row to pick drop target"),
Span::sep(),
Span::key("Esc"),
Span::desc(" quit"),
]
} else {
vec![
Span::desc("type col+row to select"),
Span::sep(),
Span::key("Esc"),
Span::desc(" quit"),
]
}
}
fn push_nudge_spans(s: &mut Vec<Span>) {
s.push(Span::sep());
s.push(Span::key("hjkl"));
s.push(Span::desc(" nudge"));
s.push(Span::sep());
s.push(Span::key("HJKL"));
s.push(Span::desc(" big"));
s.push(Span::sep());
s.push(Span::key("C-hjkl"));
s.push(Span::desc(" bigger"));
s.push(Span::sep());
s.push(Span::key("A-hjkl"));
s.push(Span::desc(" 1px"));
}
fn push_nav_spans(s: &mut Vec<Span>) {
s.push(Span::sep());
s.push(Span::key("BS"));
s.push(Span::desc(" back"));
s.push(Span::sep());
s.push(Span::key("Esc"));
s.push(Span::desc(" quit"));
}
/// Plain-text version of the status bar content for testing.
#[cfg(test)]
pub(crate) fn status_text(state: &AppState) -> String {
status_segments(state)
.iter()
.map(|s| s.text.as_str())
.collect()
}
// ===========================================================================
// tiny_skia drawing primitives.
// ===========================================================================
fn paint(color: Color) -> Paint<'static> {
Paint {
shader: Shader::SolidColor(color),
anti_alias: true,
..Paint::default()
}
}
fn fill_rect(pixmap: &mut Pixmap, x: f32, y: f32, w: f32, h: f32, color: Color) {
if let Some(rect) = Rect::from_xywh(x, y, w, h) {
let path = PathBuilder::from_rect(rect);
pixmap.fill_path(
&path,
&paint(color),
FillRule::Winding,
Transform::identity(),
None,
);
}
}
fn rounded_rect_path(x: f32, y: f32, w: f32, h: f32, r: f32) -> Option<tiny_skia::Path> {
let r = r.min(w / 2.0).min(h / 2.0);
let mut pb = PathBuilder::new();
pb.move_to(x + r, y);
pb.line_to(x + w - r, y);
pb.quad_to(x + w, y, x + w, y + r);
pb.line_to(x + w, y + h - r);
pb.quad_to(x + w, y + h, x + w - r, y + h);
pb.line_to(x + r, y + h);
pb.quad_to(x, y + h, x, y + h - r);
pb.line_to(x, y + r);
pb.quad_to(x, y, x + r, y);
pb.close();
pb.finish()
}
fn fill_rounded_rect(pixmap: &mut Pixmap, x: f32, y: f32, w: f32, h: f32, r: f32, color: Color) {
if let Some(path) = rounded_rect_path(x, y, w, h, r) {
pixmap.fill_path(
&path,
&paint(color),
FillRule::Winding,
Transform::identity(),
None,
);
}
}
#[allow(clippy::too_many_arguments)]
fn stroke_rounded_rect(
pixmap: &mut Pixmap,
x: f32,
y: f32,
w: f32,
h: f32,
r: f32,
width: f32,
color: Color,
) {
if let Some(path) = rounded_rect_path(x, y, w, h, r) {
let stroke = Stroke {
width,
..Stroke::default()
};
pixmap.stroke_path(&path, &paint(color), &stroke, Transform::identity(), None);
}
}
/// Composite a fontdue glyph bitmap onto the pixmap. The bitmap contains
/// per-pixel coverage values; we blend them with `color` using standard
/// source-over compositing.
fn blit_glyph(
pixmap: &mut Pixmap,
x: f32,
y: f32,
gw: usize,
gh: usize,
bitmap: &[u8],
color: Color,
) {
let pw = pixmap.width() as i32;
let ph = pixmap.height() as i32;
let ix = x.round() as i32;
let iy = y.round() as i32;
let data = pixmap.data_mut();
let cr = (color.red() * 255.0 + 0.5) as u32;
let cg = (color.green() * 255.0 + 0.5) as u32;
let cb = (color.blue() * 255.0 + 0.5) as u32;
let ca = (color.alpha() * 255.0 + 0.5) as u32;
for row in 0..gh {
let py = iy + row as i32;
if py < 0 || py >= ph {
continue;
}
for col in 0..gw {
let px = ix + col as i32;
if px < 0 || px >= pw {
continue;
}
let coverage = bitmap[row * gw + col] as u32;
if coverage == 0 {
continue;
}
let off = (py as usize * pw as usize + px as usize) * 4;
let dst = &mut data[off..off + 4];
let src_a = (ca * coverage + 127) / 255;
let inv = 255 - src_a;
// tiny_skia stores premultiplied RGBA.
dst[0] = ((cr * src_a + dst[0] as u32 * inv + 127) / 255) as u8;
dst[1] = ((cg * src_a + dst[1] as u32 * inv + 127) / 255) as u8;
dst[2] = ((cb * src_a + dst[2] as u32 * inv + 127) / 255) as u8;
dst[3] = ((src_a * 255 + dst[3] as u32 * inv + 127) / 255) as u8;
}
}
}
// ===========================================================================
// Tests.
// ===========================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rounded_rect_path_builds_successfully() {
assert!(rounded_rect_path(0.0, 0.0, 40.0, 24.0, 6.0).is_some());
}
#[test]
fn rounded_rect_path_clamps_radius() {
// Radius larger than half the dimension should still produce a path.
assert!(rounded_rect_path(0.0, 0.0, 10.0, 10.0, 20.0).is_some());
}
#[test]
fn fill_rect_does_not_panic_on_zero_size() {
let mut pm = Pixmap::new(4, 4).unwrap();
fill_rect(&mut pm, 0.0, 0.0, 0.0, 0.0, Color::BLACK);
}
#[test]
fn fill_rect_paints_pixels() {
let mut pm = Pixmap::new(4, 4).unwrap();
fill_rect(
&mut pm,
0.0,
0.0,
4.0,
4.0,
Color::from_rgba8(255, 0, 0, 255),
);
// Center pixel should be opaque red (premultiplied RGBA).
let off = (2 * 4 + 2) * 4;
let px = &pm.data()[off..off + 4];
assert_eq!(px, &[255, 0, 0, 255]);
}
#[test]
fn blit_glyph_full_coverage_opaque() {
let mut pm = Pixmap::new(4, 4).unwrap();
blit_glyph(&mut pm, 0.0, 0.0, 1, 1, &[255], Color::WHITE);
assert_eq!(&pm.data()[0..4], &[255, 255, 255, 255]);
}
#[test]
fn blit_glyph_zero_coverage_leaves_buffer_unchanged() {
let mut pm = Pixmap::new(4, 4).unwrap();
let before = pm.data().to_vec();
blit_glyph(&mut pm, 0.0, 0.0, 1, 1, &[0], Color::WHITE);
assert_eq!(pm.data(), &before[..]);
}
#[test]
fn blit_glyph_negative_coords_do_not_panic() {
let mut pm = Pixmap::new(4, 4).unwrap();
blit_glyph(&mut pm, -5.0, -5.0, 2, 2, &[255; 4], Color::WHITE);
}
#[test]
fn pixmap_to_argb8888_swaps_channels() {
let mut pm = Pixmap::new(1, 1).unwrap();
// Paint a single red pixel.
fill_rect(
&mut pm,
0.0,
0.0,
1.0,
1.0,
Color::from_rgba8(255, 0, 0, 255),
);
let mut out = [0u8; 4];
pixmap_to_argb8888(&pm, &mut out);
// RGBA [255, 0, 0, 255] → BGRA [0, 0, 255, 255].
assert_eq!(out, [0, 0, 255, 255]);
}
// -- Status text ----------------------------------------------------------
#[test]
fn status_text_initial() {
let state = AppState::new(1920, 1080);
let text = status_text(&state);
assert!(text.contains("col+row"));
assert!(text.contains("Esc"));
}
#[test]
fn status_text_column_selected() {
let mut state = AppState::new(1920, 1080);
state.phase = state.phase.select_column('a', 1920, 1080).unwrap();
let text = status_text(&state);
assert!(text.contains("a_"));
assert!(text.contains("row key"));
}
#[test]
fn status_text_cell_selected() {
let mut state = AppState::new(1920, 1080);
state.phase = state.phase.select_column('a', 1920, 1080).unwrap();
state.phase = state.phase.select_cell('w', 1920, 1080).unwrap();
let text = status_text(&state);
assert!(text.contains("aw"));
assert!(text.contains("Space"));
assert!(text.contains("click"));
assert!(text.contains("triple"));
}
#[test]
fn status_text_dragging() {
let mut state = AppState::new(1920, 1080);
state.drag_origin = Some((100, 200));
let text = status_text(&state);
assert!(text.contains("DRAG"));
}
#[test]
fn status_text_drag_with_cell() {
let mut state = AppState::new(1920, 1080);
state.phase = state.phase.select_column('a', 1920, 1080).unwrap();
state.phase = state.phase.select_cell('w', 1920, 1080).unwrap();
state.drag_origin = Some((100, 200));
let text = status_text(&state);
assert!(text.contains("DRAG"));
assert!(text.contains("drop"));
}
// -- Render smoke tests ---------------------------------------------------
#[test]
fn render_grid_initial_does_not_panic() {
let mut pm = Pixmap::new(320, 240).unwrap();
let state = AppState::new(320, 240);
render_grid(&mut pm, &state);
}
#[test]
fn render_grid_cell_selected_draws_crosshair() {
let mut pm = Pixmap::new(320, 240).unwrap();
let mut state = AppState::new(320, 240);
state.phase = state.phase.select_column('a', 320, 240).unwrap();
state.phase = state.phase.select_cell('w', 320, 240).unwrap();
render_grid(&mut pm, &state);
// The pixmap should have non-transparent pixels from the crosshair.
assert!(pm.data().iter().any(|&b| b != 0));
}
}
+970
View File
@@ -0,0 +1,970 @@
//! Wayland backend: layer-shell overlay, virtual pointer, and keyboard input.
//!
//! Manages the Wayland connection, creates a full-screen layer-shell surface for
//! the overlay, and translates raw keyboard events into semantic [`KeyEvent`]s.
use std::collections::VecDeque;
use std::io::Write;
use std::os::fd::AsFd;
use std::time::{SystemTime, UNIX_EPOCH};
use wayland_client::protocol::wl_pointer::{Axis, AxisSource, ButtonState};
use wayland_client::{
delegate_noop,
protocol::{
wl_buffer, wl_compositor, wl_keyboard, wl_pointer, wl_region, wl_registry, wl_seat, wl_shm,
wl_shm_pool, wl_surface,
},
Connection, Dispatch, EventQueue, QueueHandle, WEnum,
};
use wayland_protocols_wlr::{
layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1},
virtual_pointer::v1::client::{zwlr_virtual_pointer_manager_v1, zwlr_virtual_pointer_v1},
};
use anyhow::{Context, Result};
// Linux evdev button codes.
const BTN_LEFT: u32 = 0x110;
const BTN_RIGHT: u32 = 0x111;
// Linux evdev key codes for modifier keys.
const KEY_LCTRL: u32 = 29;
const KEY_LSHIFT: u32 = 42;
const KEY_LALT: u32 = 56;
const KEY_RSHIFT: u32 = 54;
const KEY_RCTRL: u32 = 97;
const KEY_RALT: u32 = 100;
// Modifier bitmasks in the Wayland `mods_depressed` field.
const MOD_SHIFT: u32 = 1;
const MOD_CTRL: u32 = 4;
const MOD_ALT: u32 = 8;
// Drag animation timing (~150ms total).
const DRAG_SETTLE_MS: u64 = 25;
const DRAG_PRESS_SETTLE_MS: u64 = 30;
const DRAG_INTERP_STEPS: u32 = 12;
const DRAG_STEP_DELAY_MS: u64 = 5;
const DRAG_RELEASE_SETTLE_MS: u64 = 35;
// Maximum number of event loop iterations to wait for a key release before
// giving up. Prevents an infinite hang if the compositor drops the event.
const KEY_RELEASE_MAX_DISPATCHES: u32 = 200;
// ===========================================================================
// Public key event type.
// ===========================================================================
/// Semantic key event emitted by the backend and consumed by the main loop.
pub enum KeyEvent {
/// A printable character (post-shift).
Char(char),
/// Ctrl held while pressing a character key.
CtrlChar(char),
/// Alt held while pressing a character key.
AltChar(char),
/// Space — mapped to left-click.
Click,
/// Enter — mapped to double-click.
DoubleClick,
/// Shift+Enter — mapped to triple-click.
TripleClick,
/// Period — mapped to right-click.
RightClick,
/// Escape or Ctrl-[ — close the overlay.
Close,
/// Backspace — undo one selection step.
Undo,
ScrollUp,
ScrollDown,
ScrollLeft,
ScrollRight,
}
// ===========================================================================
// Internal physical key abstraction.
// ===========================================================================
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PhysicalKey {
Char(char),
Space,
Enter,
Escape,
Backspace,
Up,
Down,
Left,
Right,
}
/// Maps a physical key to the semantic `KeyEvent` the main loop cares about.
fn physical_to_event(key: PhysicalKey) -> Option<KeyEvent> {
match key {
PhysicalKey::Space => Some(KeyEvent::Click),
PhysicalKey::Enter => Some(KeyEvent::DoubleClick),
PhysicalKey::Escape => Some(KeyEvent::Close),
PhysicalKey::Backspace => Some(KeyEvent::Undo),
PhysicalKey::Up => Some(KeyEvent::ScrollUp),
PhysicalKey::Down => Some(KeyEvent::ScrollDown),
PhysicalKey::Left => Some(KeyEvent::ScrollLeft),
PhysicalKey::Right => Some(KeyEvent::ScrollRight),
PhysicalKey::Char('.') => Some(KeyEvent::RightClick),
PhysicalKey::Char(c) => Some(KeyEvent::Char(c)),
}
}
// ===========================================================================
// WaylandBackend — public API for the main loop.
// ===========================================================================
/// Owns the Wayland connection, event queue, and all protocol objects.
///
/// Designed so that public methods can freely call `roundtrip` /
/// `blocking_dispatch` without borrowing conflicts.
pub struct WaylandBackend {
state: WaylandState,
event_queue: EventQueue<WaylandState>,
queue_handle: QueueHandle<WaylandState>,
}
impl WaylandBackend {
pub fn new() -> Result<Self> {
let conn = Connection::connect_to_env().context("connect to Wayland display")?;
let mut event_queue = conn.new_event_queue();
let queue_handle = event_queue.handle();
conn.display().get_registry(&queue_handle, ());
let mut state = WaylandState {
conn,
compositor: None,
shm: None,
surface: None,
layer_shell: None,
layer_surface: None,
seat: None,
vp_manager: None,
virtual_pointer: None,
screen_w: 0,
screen_h: 0,
configured: false,
pending_keys: VecDeque::new(),
shift_held: false,
ctrl_held: false,
alt_held: false,
awaiting_key_release: false,
};
event_queue
.roundtrip(&mut state)
.context("initial roundtrip")?;
// Verify required protocol support before proceeding.
if state.compositor.is_none() {
anyhow::bail!("compositor does not advertise wl_compositor");
}
if state.shm.is_none() {
anyhow::bail!("compositor does not advertise wl_shm");
}
if state.layer_shell.is_none() {
anyhow::bail!(
"compositor does not support wlr-layer-shell-unstable-v1. \
Supported compositors: Sway, Hyprland, river, etc."
);
}
if state.vp_manager.is_none() {
anyhow::bail!(
"compositor does not support wlr-virtual-pointer-unstable-v1. \
Supported compositors: Sway, Hyprland, river, etc."
);
}
// Bind the virtual pointer now that we know which globals are available.
if let Some(manager) = state.vp_manager.take() {
state.virtual_pointer =
Some(manager.create_virtual_pointer(state.seat.as_ref(), &queue_handle, ()));
}
state.init_layer_surface(&queue_handle)?;
event_queue
.roundtrip(&mut state)
.context("roundtrip after layer surface")?;
while !state.configured {
event_queue
.blocking_dispatch(&mut state)
.context("waiting for configure")?;
}
Ok(Self {
state,
event_queue,
queue_handle,
})
}
// -- Queries ------------------------------------------------------------
pub fn screen_size(&self) -> (u32, u32) {
(self.state.screen_w, self.state.screen_h)
}
// -- Event loop ---------------------------------------------------------
/// Blocks until the next key event arrives, or returns `None` when the
/// surface has been torn down (i.e. the overlay is closed).
pub fn next_key(&mut self) -> Result<Option<KeyEvent>> {
loop {
if let Some(key) = self.state.pending_keys.pop_front() {
return Ok(Some(key));
}
if self.state.surface.is_none() {
return Ok(None);
}
self.event_queue
.blocking_dispatch(&mut self.state)
.context("blocking_dispatch")?;
}
}
// -- Rendering ----------------------------------------------------------
/// Copy an ARGB8888 pixel buffer to the overlay surface via wl_shm.
pub fn present(&mut self, pixels: &[u8], width: u32, height: u32) -> Result<()> {
// Check availability before allocating resources.
let shm = self.state.shm.as_ref().context("wl_shm not available")?;
let surface = self
.state
.surface
.as_ref()
.context("wl_surface not available")?;
let stride = width * 4;
let mut file = tempfile::tempfile().context("create shm tempfile")?;
file.write_all(pixels).context("write pixel buffer")?;
let pool = shm.create_pool(file.as_fd(), pixels.len() as i32, &self.queue_handle, ());
let buffer = pool.create_buffer(
0,
width as i32,
height as i32,
stride as i32,
wl_shm::Format::Argb8888,
&self.queue_handle,
(),
);
surface.attach(Some(&buffer), 0, 0);
surface.damage_buffer(0, 0, width as i32, height as i32);
surface.commit();
pool.destroy();
Ok(())
}
// -- Mouse actions ------------------------------------------------------
/// Warp the virtual pointer to absolute screen coordinates.
pub fn move_mouse(&mut self, x: u32, y: u32) -> Result<()> {
if let Some(vp) = &self.state.virtual_pointer {
vp.motion_absolute(timestamp(), x, y, self.state.screen_w, self.state.screen_h);
vp.frame();
}
self.state.conn.flush().context("flush after move_mouse")?;
Ok(())
}
/// Tear down the overlay, move to `(x, y)`, and left-click once.
pub fn click(&mut self, x: u32, y: u32) -> Result<()> {
self.click_at(x, y, BTN_LEFT, 1)
}
/// Tear down the overlay, move to `(x, y)`, and left-click twice.
pub fn double_click(&mut self, x: u32, y: u32) -> Result<()> {
self.click_at(x, y, BTN_LEFT, 2)
}
/// Tear down the overlay, move to `(x, y)`, and left-click three times.
pub fn triple_click(&mut self, x: u32, y: u32) -> Result<()> {
self.click_at(x, y, BTN_LEFT, 3)
}
/// Tear down the overlay, move to `(x, y)`, and right-click once.
pub fn right_click(&mut self, x: u32, y: u32) -> Result<()> {
self.click_at(x, y, BTN_RIGHT, 1)
}
/// Tear down the overlay, then perform a click-drag from `(x1, y1)` to
/// `(x2, y2)`.
///
/// Simulates a realistic drag by sending intermediate motion events
/// over time so that compositors and applications recognize the gesture.
pub fn drag_select(&mut self, x1: u32, y1: u32, x2: u32, y2: u32) -> Result<()> {
use std::thread::sleep;
use std::time::Duration;
self.teardown_surface()?;
let (sw, sh) = (self.state.screen_w, self.state.screen_h);
// 1. Move to the drag origin and let focus settle.
self.send_motion(x1, y1, sw, sh)?;
self.roundtrip("motion to drag start")?;
sleep(Duration::from_millis(DRAG_SETTLE_MS));
// 2. Press and hold — give the app time to register the press.
self.send_button(BTN_LEFT, ButtonState::Pressed)?;
self.roundtrip("press at drag start")?;
sleep(Duration::from_millis(DRAG_PRESS_SETTLE_MS));
// 3. Animate the pointer from start to end so apps see continuous
// motion and cross their drag threshold.
let step_delay = Duration::from_millis(DRAG_STEP_DELAY_MS);
for i in 1..=DRAG_INTERP_STEPS {
let t = i as f64 / DRAG_INTERP_STEPS as f64;
let x = x1 as f64 + (x2 as f64 - x1 as f64) * t;
let y = y1 as f64 + (y2 as f64 - y1 as f64) * t;
let cx = (x.clamp(0.0, sw.saturating_sub(1) as f64)) as u32;
let cy = (y.clamp(0.0, sh.saturating_sub(1) as f64)) as u32;
self.send_motion(cx, cy, sw, sh)?;
self.state
.conn
.flush()
.context("flush during drag motion")?;
sleep(step_delay);
}
self.roundtrip("motion to drag end")?;
sleep(Duration::from_millis(DRAG_RELEASE_SETTLE_MS));
// 4. Release the button at the destination.
self.send_button(BTN_LEFT, ButtonState::Released)?;
self.roundtrip("release at drag end")
}
// -- Scroll -------------------------------------------------------------
pub fn scroll_up(&mut self) -> Result<()> {
self.scroll(Axis::VerticalScroll, -15.0, -1)
}
pub fn scroll_down(&mut self) -> Result<()> {
self.scroll(Axis::VerticalScroll, 15.0, 1)
}
pub fn scroll_left(&mut self) -> Result<()> {
self.scroll(Axis::HorizontalScroll, -15.0, -1)
}
pub fn scroll_right(&mut self) -> Result<()> {
self.scroll(Axis::HorizontalScroll, 15.0, 1)
}
// -- Lifecycle ----------------------------------------------------------
/// Block until the key that triggered the current action is released.
/// This prevents the key-up event from leaking to the underlying window
/// after the overlay is torn down.
pub fn wait_for_key_release(&mut self) -> Result<()> {
self.state.awaiting_key_release = true;
let mut dispatches = 0u32;
while self.state.awaiting_key_release {
self.event_queue
.blocking_dispatch(&mut self.state)
.context("waiting for key release")?;
dispatches += 1;
if dispatches >= KEY_RELEASE_MAX_DISPATCHES {
self.state.awaiting_key_release = false;
break;
}
}
Ok(())
}
/// Destroy the virtual pointer and tear down the overlay surface.
///
/// Does NOT send safety button releases — the compositor releases all
/// buttons when the virtual pointer object is destroyed. Sending
/// redundant releases here caused spurious extra clicks (e.g. double-click
/// turning into triple-click).
pub fn exit(&mut self) -> Result<()> {
if let Some(vp) = self.state.virtual_pointer.take() {
vp.destroy();
}
self.state.conn.flush().context("flush vp destroy")?;
self.teardown_surface()
}
/// Re-create the overlay after it was torn down (e.g. for scroll).
pub fn reopen(&mut self) -> Result<()> {
self.state.configured = false;
self.state.init_layer_surface(&self.queue_handle)?;
self.roundtrip("reopen")?;
while !self.state.configured {
self.event_queue
.blocking_dispatch(&mut self.state)
.context("waiting for configure after reopen")?;
}
Ok(())
}
// -- Private helpers ----------------------------------------------------
/// Shared implementation for click, double_click, and right_click.
///
/// Tears down the overlay so the compositor updates focus, moves the
/// virtual pointer to `(x, y)`, then sends `count` press/release pairs
/// on `button`.
fn click_at(&mut self, x: u32, y: u32, button: u32, count: u32) -> Result<()> {
self.teardown_surface()?;
self.send_motion(x, y, self.state.screen_w, self.state.screen_h)?;
self.roundtrip("motion before click")?;
if let Some(vp) = &self.state.virtual_pointer {
for _ in 0..count {
let ts = timestamp();
vp.button(ts, button, ButtonState::Pressed);
vp.frame();
vp.button(ts, button, ButtonState::Released);
vp.frame();
}
}
self.roundtrip("click")
}
fn scroll(&mut self, axis: Axis, value: f64, discrete: i32) -> Result<()> {
self.teardown_surface()?;
if let Some(vp) = &self.state.virtual_pointer {
// axis_source tells the compositor this is a wheel event, not a
// touchpad gesture. axis_discrete provides the notch count that
// many compositors require alongside the continuous value.
vp.axis_source(AxisSource::Wheel);
vp.axis_discrete(timestamp(), axis, value, discrete);
vp.frame();
}
self.state.conn.flush().context("flush after scroll")?;
self.reopen()
}
fn teardown_surface(&mut self) -> Result<()> {
if let Some(layer_surface) = self.state.layer_surface.take() {
layer_surface.destroy();
}
if let Some(surface) = self.state.surface.take() {
surface.destroy();
}
self.roundtrip("surface teardown")
}
/// Convenience wrapper around `event_queue.roundtrip` with context.
fn roundtrip(&mut self, context: &str) -> Result<()> {
self.event_queue
.roundtrip(&mut self.state)
.with_context(|| format!("roundtrip ({context})"))?;
Ok(())
}
fn send_motion(&self, x: u32, y: u32, sw: u32, sh: u32) -> Result<()> {
if let Some(vp) = &self.state.virtual_pointer {
vp.motion_absolute(timestamp(), x, y, sw, sh);
vp.frame();
}
Ok(())
}
fn send_button(&self, button: u32, button_state: ButtonState) -> Result<()> {
if let Some(vp) = &self.state.virtual_pointer {
vp.button(timestamp(), button, button_state);
vp.frame();
}
Ok(())
}
}
impl Drop for WaylandBackend {
fn drop(&mut self) {
let _ = self.exit();
}
}
// ===========================================================================
// Internal Wayland dispatch state.
// ===========================================================================
struct WaylandState {
conn: Connection,
// Globals bound during registry enumeration.
compositor: Option<wl_compositor::WlCompositor>,
shm: Option<wl_shm::WlShm>,
seat: Option<wl_seat::WlSeat>,
layer_shell: Option<zwlr_layer_shell_v1::ZwlrLayerShellV1>,
vp_manager: Option<zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1>,
// Per-session objects.
surface: Option<wl_surface::WlSurface>,
layer_surface: Option<zwlr_layer_surface_v1::ZwlrLayerSurfaceV1>,
virtual_pointer: Option<zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1>,
// Screen geometry reported by the layer-surface configure.
screen_w: u32,
screen_h: u32,
configured: bool,
// Keyboard state.
pending_keys: VecDeque<KeyEvent>,
shift_held: bool,
ctrl_held: bool,
alt_held: bool,
awaiting_key_release: bool,
}
impl WaylandState {
/// Create and configure a full-screen layer-shell overlay surface.
fn init_layer_surface(&mut self, qh: &QueueHandle<Self>) -> Result<()> {
let compositor = self
.compositor
.as_ref()
.context("wl_compositor not available")?;
let layer_shell = self
.layer_shell
.as_ref()
.context("zwlr_layer_shell_v1 not available")?;
let surface = compositor.create_surface(qh, ());
let layer_surface = layer_shell.get_layer_surface(
&surface,
None,
zwlr_layer_shell_v1::Layer::Overlay,
"mousefree".to_string(),
qh,
(),
);
layer_surface.set_size(0, 0);
layer_surface.set_anchor(
zwlr_layer_surface_v1::Anchor::Top
| zwlr_layer_surface_v1::Anchor::Bottom
| zwlr_layer_surface_v1::Anchor::Left
| zwlr_layer_surface_v1::Anchor::Right,
);
layer_surface.set_exclusive_zone(-1);
layer_surface
.set_keyboard_interactivity(zwlr_layer_surface_v1::KeyboardInteractivity::Exclusive);
surface.commit();
self.surface = Some(surface);
self.layer_surface = Some(layer_surface);
Ok(())
}
}
// ===========================================================================
// Wayland dispatch implementations.
// ===========================================================================
impl Dispatch<wl_registry::WlRegistry, ()> for WaylandState {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
let wl_registry::Event::Global {
name,
interface,
version,
} = event
else {
return;
};
match interface.as_str() {
"wl_compositor" => {
state.compositor = Some(registry.bind(name, version.min(4), qh, ()));
}
"wl_shm" => {
state.shm = Some(registry.bind(name, 1, qh, ()));
}
"wl_seat" => {
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
}
"zwlr_layer_shell_v1" => {
state.layer_shell = Some(registry.bind(name, version.min(4), qh, ()));
}
"zwlr_virtual_pointer_manager_v1" => {
state.vp_manager = Some(registry.bind(name, version.min(2), qh, ()));
}
_ => {}
}
}
}
impl Dispatch<wl_seat::WlSeat, ()> for WaylandState {
fn event(
_state: &mut Self,
seat: &wl_seat::WlSeat,
event: wl_seat::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_seat::Event::Capabilities {
capabilities: WEnum::Value(caps),
} = event
{
if caps.contains(wl_seat::Capability::Keyboard) {
seat.get_keyboard(qh, ());
}
}
}
}
impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandState {
fn event(
state: &mut Self,
_: &wl_keyboard::WlKeyboard,
event: wl_keyboard::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
wl_keyboard::Event::Modifiers { mods_depressed, .. } => {
state.shift_held = (mods_depressed & MOD_SHIFT) != 0;
state.ctrl_held = (mods_depressed & MOD_CTRL) != 0;
state.alt_held = (mods_depressed & MOD_ALT) != 0;
}
wl_keyboard::Event::Key {
key,
state: WEnum::Value(wl_keyboard::KeyState::Pressed),
..
} => {
// Ignore bare modifier press events; we track modifiers via
// the Modifiers event instead.
if matches!(key, KEY_LSHIFT | KEY_RSHIFT | KEY_LCTRL | KEY_RCTRL | KEY_LALT | KEY_RALT) {
return;
}
// Ctrl-[ acts as Escape (evdev keycode 26 = '[').
if key == 26 && state.ctrl_held {
state.pending_keys.push_back(KeyEvent::Close);
return;
}
if let Some(physical) = keycode_to_key(key, state.shift_held) {
// Shift+Enter → triple-click.
if physical == PhysicalKey::Enter && state.shift_held {
state.pending_keys.push_back(KeyEvent::TripleClick);
return;
}
// Modifier+letter emits CtrlChar/AltChar so the main loop
// can distinguish nudge tiers.
if let PhysicalKey::Char(c) = physical {
if state.ctrl_held {
state.pending_keys.push_back(KeyEvent::CtrlChar(c));
return;
}
if state.alt_held {
state.pending_keys.push_back(KeyEvent::AltChar(c));
return;
}
}
if let Some(ev) = physical_to_event(physical) {
state.pending_keys.push_back(ev);
}
}
}
wl_keyboard::Event::Key {
state: WEnum::Value(wl_keyboard::KeyState::Released),
..
} => {
state.awaiting_key_release = false;
}
_ => {}
}
}
}
impl Dispatch<zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, ()> for WaylandState {
fn event(
state: &mut Self,
layer_surface: &zwlr_layer_surface_v1::ZwlrLayerSurfaceV1,
event: zwlr_layer_surface_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
zwlr_layer_surface_v1::Event::Configure {
serial,
width,
height,
} => {
layer_surface.ack_configure(serial);
const MAX_SCREEN_DIM: u32 = 16384;
if width > 0 && height > 0 && width <= MAX_SCREEN_DIM && height <= MAX_SCREEN_DIM {
state.screen_w = width;
state.screen_h = height;
state.configured = true;
}
}
zwlr_layer_surface_v1::Event::Closed => {
state.pending_keys.push_back(KeyEvent::Close);
}
_ => {}
}
}
}
delegate_noop!(WaylandState: ignore wl_compositor::WlCompositor);
delegate_noop!(WaylandState: ignore wl_region::WlRegion);
delegate_noop!(WaylandState: ignore wl_surface::WlSurface);
delegate_noop!(WaylandState: ignore wl_shm::WlShm);
delegate_noop!(WaylandState: ignore wl_shm_pool::WlShmPool);
delegate_noop!(WaylandState: ignore wl_buffer::WlBuffer);
delegate_noop!(WaylandState: ignore wl_pointer::WlPointer);
delegate_noop!(WaylandState: ignore zwlr_layer_shell_v1::ZwlrLayerShellV1);
delegate_noop!(WaylandState: ignore zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1);
delegate_noop!(WaylandState: ignore zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1);
// ===========================================================================
// Helpers.
// ===========================================================================
/// Returns a u32 millisecond timestamp for Wayland pointer events.
///
/// Wayland timestamps are u32 milliseconds from an arbitrary epoch.
/// Wrapping every ~49 days is expected and harmless per the protocol.
fn timestamp() -> u32 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u32
}
/// Translates a Linux evdev key code into a [`PhysicalKey`].
///
/// **Note:** This table assumes a US QWERTY layout. Other layouts will
/// produce incorrect characters. A proper fix requires xkb integration.
fn keycode_to_key(kc: u32, shift_held: bool) -> Option<PhysicalKey> {
// Non-character keys — independent of shift state.
match kc {
1 => return Some(PhysicalKey::Escape),
14 => return Some(PhysicalKey::Backspace),
28 => return Some(PhysicalKey::Enter),
57 => return Some(PhysicalKey::Space),
104 => return Some(PhysicalKey::Up),
105 => return Some(PhysicalKey::Left),
106 => return Some(PhysicalKey::Right),
109 => return Some(PhysicalKey::Down),
_ => {}
}
// Character keys — shift selects the alternate mapping.
let ch = if shift_held {
match kc {
2 => '!',
3 => '@',
4 => '#',
5 => '$',
6 => '%',
7 => '^',
8 => '&',
9 => '*',
10 => '(',
11 => ')',
12 => '_',
13 => '+',
26 => '{',
27 => '}',
43 => '|',
39 => ':',
40 => '"',
41 => '~',
51 => '<',
52 => '>',
53 => '?',
16 => 'Q',
17 => 'W',
18 => 'E',
19 => 'R',
20 => 'T',
21 => 'Y',
22 => 'U',
23 => 'I',
24 => 'O',
25 => 'P',
30 => 'A',
31 => 'S',
32 => 'D',
33 => 'F',
34 => 'G',
35 => 'H',
36 => 'J',
37 => 'K',
38 => 'L',
44 => 'Z',
45 => 'X',
46 => 'C',
47 => 'V',
48 => 'B',
49 => 'N',
50 => 'M',
_ => return None,
}
} else {
match kc {
2 => '1',
3 => '2',
4 => '3',
5 => '4',
6 => '5',
7 => '6',
8 => '7',
9 => '8',
10 => '9',
11 => '0',
12 => '-',
13 => '=',
26 => '[',
27 => ']',
43 => '\\',
39 => ';',
40 => '\'',
41 => '`',
51 => ',',
52 => '.',
53 => '/',
16 => 'q',
17 => 'w',
18 => 'e',
19 => 'r',
20 => 't',
21 => 'y',
22 => 'u',
23 => 'i',
24 => 'o',
25 => 'p',
30 => 'a',
31 => 's',
32 => 'd',
33 => 'f',
34 => 'g',
35 => 'h',
36 => 'j',
37 => 'k',
38 => 'l',
44 => 'z',
45 => 'x',
46 => 'c',
47 => 'v',
48 => 'b',
49 => 'n',
50 => 'm',
_ => return None,
}
};
Some(PhysicalKey::Char(ch))
}
// ===========================================================================
// Tests.
// ===========================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keycode_letters_unshifted() {
assert_eq!(keycode_to_key(16, false), Some(PhysicalKey::Char('q')));
assert_eq!(keycode_to_key(30, false), Some(PhysicalKey::Char('a')));
assert_eq!(keycode_to_key(44, false), Some(PhysicalKey::Char('z')));
assert_eq!(keycode_to_key(50, false), Some(PhysicalKey::Char('m')));
}
#[test]
fn keycode_letters_shifted() {
assert_eq!(keycode_to_key(16, true), Some(PhysicalKey::Char('Q')));
assert_eq!(keycode_to_key(30, true), Some(PhysicalKey::Char('A')));
assert_eq!(keycode_to_key(50, true), Some(PhysicalKey::Char('M')));
}
#[test]
fn keycode_digits() {
assert_eq!(keycode_to_key(2, false), Some(PhysicalKey::Char('1')));
assert_eq!(keycode_to_key(11, false), Some(PhysicalKey::Char('0')));
assert_eq!(keycode_to_key(2, true), Some(PhysicalKey::Char('!')));
assert_eq!(keycode_to_key(11, true), Some(PhysicalKey::Char(')')));
}
#[test]
fn keycode_special_keys() {
assert_eq!(keycode_to_key(1, false), Some(PhysicalKey::Escape));
assert_eq!(keycode_to_key(14, false), Some(PhysicalKey::Backspace));
assert_eq!(keycode_to_key(28, false), Some(PhysicalKey::Enter));
assert_eq!(keycode_to_key(57, false), Some(PhysicalKey::Space));
// Delete (111) is intentionally unmapped — returns None so the key is
// consumed by the overlay without triggering any action.
assert_eq!(keycode_to_key(111, false), None);
}
#[test]
fn keycode_special_keys_ignore_shift() {
assert_eq!(keycode_to_key(1, true), Some(PhysicalKey::Escape));
assert_eq!(keycode_to_key(57, true), Some(PhysicalKey::Space));
}
#[test]
fn keycode_unknown_returns_none() {
assert_eq!(keycode_to_key(999, false), None);
assert_eq!(keycode_to_key(999, true), None);
}
#[test]
fn keycode_punctuation() {
assert_eq!(keycode_to_key(52, false), Some(PhysicalKey::Char('.')));
assert_eq!(keycode_to_key(53, false), Some(PhysicalKey::Char('/')));
assert_eq!(keycode_to_key(52, true), Some(PhysicalKey::Char('>')));
assert_eq!(keycode_to_key(53, true), Some(PhysicalKey::Char('?')));
}
#[test]
fn physical_to_event_mappings() {
assert!(matches!(
physical_to_event(PhysicalKey::Space),
Some(KeyEvent::Click)
));
assert!(matches!(
physical_to_event(PhysicalKey::Enter),
Some(KeyEvent::DoubleClick)
));
assert!(matches!(
physical_to_event(PhysicalKey::Escape),
Some(KeyEvent::Close)
));
assert!(matches!(
physical_to_event(PhysicalKey::Backspace),
Some(KeyEvent::Undo)
));
assert!(matches!(
physical_to_event(PhysicalKey::Char('.')),
Some(KeyEvent::RightClick)
));
assert!(matches!(
physical_to_event(PhysicalKey::Char('a')),
Some(KeyEvent::Char('a'))
));
// All PhysicalKey variants now map to a KeyEvent (no dead paths).
}
#[test]
fn timestamp_does_not_panic() {
// Mainly confirms unwrap_or_default works even if the clock is weird.
let _ = timestamp();
}
}