diff --git a/Cargo.lock b/Cargo.lock index a8d3c23..c5aac2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,6 +1478,9 @@ name = "cursor-icon" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +dependencies = [ + "serde", +] [[package]] name = "curve25519-dalek" @@ -1804,6 +1807,9 @@ name = "dpi" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] [[package]] name = "dtoa" @@ -9419,6 +9425,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", + "serde", "smithay-client-toolkit 0.19.2", "smol_str 0.2.2", "tracing", diff --git a/README.md b/README.md index 19573f4..f9cde43 100644 --- a/README.md +++ b/README.md @@ -946,24 +946,24 @@ taken unasked, and a key bound to one action is refused for another until it is cleared there. Replay and Save clip use the Replay panel's picks, and a recording the Output panel's. -The key pressed is the key that fires, on any keyboard layout: the panel names -it as the layout prints it, and the system hears the key where it sits, through -the layout in use when the studio starts or a key is bound. On X11, a key that -types only a letter outside A to Z, such as ö on a German layout, is refused. -Keypad keys are refused everywhere, since the window hears keypad 1 as the 1 -above the letters. +The key pressed is the key that fires, wherever it sits, on any keyboard layout +and after a layout change: the panel names it as the layout printed it when it +was bound. Keypad keys bind apart from the keys above the letters, so keypad 1 +and 1 can do different things; the panel calls them **Keypad 1** and **1**. Bound keys fire even while another app, such as a game in full screen, has focus, and the app does not see them, so bind keys you do not type or play -with, such as a function key or one with modifiers: +with, such as a function key, a keypad key, or one with modifiers: - **macOS** and **Windows** register them with the system, which needs no - permission. A key another app already registered is refused, saying so. On - macOS 15 and later, the system refuses a key whose only modifiers are Option, - or Option and Shift. -- **Linux on X11** grabs them from the X server. Once a layout change moves - a key the studio holds, its keys stay as they are until it restarts, and the - panel says so. + permission. A key another app already registered is refused, saying so, as + is a key the system has no name for. On macOS 15 and later, the system + refuses a key whose only modifiers are Option, or Option and Shift. Windows + hears a keypad key only with NumLock on, cannot tell keypad Enter from Enter + so refuses it, and fires a binding on Enter for both. +- **Linux on X11** grabs them from the X server by where they sit, so any key + binds, such as ö on a German layout or a key with no name, and fires + whatever NumLock and CapsLock are. - **Linux on Wayland**, where no app hears keys meant for another, asks the desktop through its GlobalShortcuts portal (KDE Plasma, and GNOME 48 and later). The desktop shows its own dialog, may bind other keys than those diff --git a/quest/m1/README.md b/quest/m1/README.md index bb81f1e..7b26452 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -24,7 +24,6 @@ than opening an m2 for them. ## Required - [Viewer-side composition](/quest/m1/compose/README.md) - sources as their own tracks plus a layout track, composed by viewers and by a server -- [Hotkeys bind by physical key](/quest/m1/hotkeys-keypad.md) - keypad keys apart from the main row, any X11 key, and every binding across layout changes - [Scene graph view](/quest/m1/scene-graph.md) - a node view of picture and audio scenes, their routing, volume, pan, and waveforms - [Effects in the studio UI](/quest/m1/ui-effects.md) - rune-fx glow, bursts, metal and gradient chrome, and alert effects in the egui UI - [Rune UI spike](/quest/m1/rune-spike.md) - the monitors prototyped on Rune, with a verdict on moving the UI off egui diff --git a/quest/m1/hotkeys-keypad.md b/quest/m1/hotkeys-keypad.md deleted file mode 100644 index 855a9ed..0000000 --- a/quest/m1/hotkeys-keypad.md +++ /dev/null @@ -1,51 +0,0 @@ -# [M] Hotkeys bind by physical key - -## Goal - -A hotkey is the physical key the operator pressed, on every platform: -- Keypad keys (`Numpad0` to `Numpad9`, the keypad operators, and keypad - Enter) bind apart from the main-row keys with the same symbol, and fire - system-wide on macOS, Windows, X11, and the Wayland portal. Streamers often - put scenes on the keypad. -- On X11, any key binds, including one that types a letter outside A to Z - (such as ö on a German layout) and one winit cannot name. -- A keyboard layout change leaves every binding on its key without a restart: - on X11 no held grab strands, and on Windows no binding moves to the key the - new layout gives its virtual key. - -## Plan - -From [moq-dev/studio#61](https://github.com/moq-dev/studio/pull/61), which -refuses keypad keys and non-A-to-Z X11 letters, registers through the layout -in use when the studio starts or a key is bound, and asks for a restart after -an X11 layout change. Picked 2026-11-06 (maintainer, /quest-plan): keypad keys -as their own quest so #61 landed as reviewed, and the X11 keycode grab folded -in, since both rework the same grab path. - -- Carry winit's `PhysicalKey` through `Binding`, taken from the window's - winit key event rather than egui's, so `Numpad1` and `Digit1` stay distinct - and a key winit leaves `Unidentified` keeps its native code. egui-winit - drops a key it can name neither logically nor physically, so the Hotkeys - panel cannot take it from egui. This replaces the keypad twin tracking in - `rs/moq-studio/src/hotkeys/keypad.rs`. -- On X11, the studio grabs by keycode itself (XGrabKey through x11rb, which - `layout.rs` already reads the keyboard mapping with) instead of through - global-hotkey, which grabs by keysym and looks it up again in the current - layout on release (see `Layout::releases` in `layout.rs`). The keycode is - winit's Linux scancode plus 8 (`PhysicalKeyExtScancode::to_scancode`), for - a named key and an `Unidentified` one alike, so releasing a grab needs no - lookup and the restart note goes. -- On Windows, `RegisterHotKey` matches the virtual key the active layout gives - the pressed key, so `layout.rs` maps each binding's scancode to a virtual key - through that layout. The studio registers every binding again when that - layout changes, not only when a binding does, as `hear` does now. - Unregistering is by id, so nothing can strand. Recommended: read the - foreground window's layout, which the system translates the key with, since - a game usually has focus. -- Register global-hotkey's `Numpad*` codes on macOS and Windows, and ask the - GlobalShortcuts portal for `KP_*` keysyms on Wayland. macOS registers by key - position, so a layout change moves nothing there. A key winit leaves - `Unidentified` is refused outside X11, saying global-hotkey cannot name it. -- `README.md`'s Hotkeys section drops the keypad refusal, the X11 letter - refusal, the restart note, and the layout read when the studio starts or a - key is bound. diff --git a/rs/moq-studio/Cargo.toml b/rs/moq-studio/Cargo.toml index 6736a4f..6049f79 100644 --- a/rs/moq-studio/Cargo.toml +++ b/rs/moq-studio/Cargo.toml @@ -49,9 +49,6 @@ eframe = { version = "0.36", default-features = false, features = ["default_font egui = "0.36" # Docked, rearrangeable panels. 0.21 is the newest release and pins egui 0.36. egui_dock = "0.21" -# System-wide hotkeys on macOS, Windows, and X11; Wayland goes through the -# GlobalShortcuts portal instead. -global-hotkey = "0.9" hang = { workspace = true } # Stamps a recording's file name with when it started. Already in the graph # through moq-scene's humantime-serde. @@ -83,9 +80,11 @@ toml = { workspace = true } tracing = { workspace = true } url = { workspace = true } usage-rs = { workspace = true, features = ["completions", "config"] } -# The window's event loop, run here so keypad keys are noted before egui -# folds them into their main keyboard twins. eframe's version and features. -winit = { version = "0.30", default-features = false } +# The window's event loop, run here so a hotkey is the key winit names by where +# it sits, which egui folds into its main keyboard twin for a keypad key and +# drops for a key outside its own list. eframe's version and features, with +# `serde` to keep the key bound. +winit = { version = "0.30", default-features = false, features = ["serde"] } [target.'cfg(target_os = "linux")'.dependencies] # The GlobalShortcuts portal for hotkeys on Wayland. 0.13, as moq-video's @@ -100,14 +99,19 @@ moq-v4l = { workspace = true } pipewire = { version = "0.10", optional = true, features = ["v0_3_34"] } # Whether the window is on Wayland, where only the portal hears keys globally. raw-window-handle = "0.6" -# Which keysyms the X11 layout puts on each key, so a hotkey grabs the key -# pressed. 0.13, as global-hotkey uses, so default builds compile one. -x11rb = "0.13" +# Hotkeys on X11, grabbed by keycode so any key binds on any layout. `xkb` +# tells a held key's repeats from new presses. +x11rb = { version = "0.13", features = ["xkb"] } + +[target.'cfg(any(target_os = "macos", windows))'.dependencies] +# System-wide hotkeys on macOS and Windows; X11 grabs keys itself, and Wayland +# goes through the GlobalShortcuts portal. +global-hotkey = "0.9" [target.'cfg(windows)'.dependencies] -# Which virtual key the Windows layout puts on each key, so a hotkey -# registers the key pressed. -windows-sys = { version = "0.61", features = ["Win32_UI_Input_KeyboardAndMouse"] } +# Which virtual key the foreground window's layout puts on each key, so a +# hotkey registers the key pressed. +windows-sys = { version = "0.61", features = ["Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging"] } [dev-dependencies] # A clock that moves wall time too, which renewed tokens' expiries read. diff --git a/rs/moq-studio/src/hotkeys.rs b/rs/moq-studio/src/hotkeys.rs index 7de6840..298be7a 100644 --- a/rs/moq-studio/src/hotkeys.rs +++ b/rs/moq-studio/src/hotkeys.rs @@ -1,49 +1,212 @@ //! Keys the operator binds to studio actions, heard system-wide, so a replay -//! fires from inside a game: through the platform's global hotkeys on macOS, -//! Windows, and X11, and the desktop's GlobalShortcuts portal on Wayland. -//! Where neither is there, they work only while the studio has focus. +//! fires from inside a game: through the platform's global hotkeys on macOS +//! and Windows, a key grab on X11, and the desktop's GlobalShortcuts portal on +//! Wayland. Where neither is there, they work only while the studio has focus. +//! +//! A key is where it sits, as winit names it from the window's own key event: +//! egui names a keypad key as its main keyboard twin and drops a key it cannot +//! name, so the window hears keys before egui does. -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, mpsc}; -use egui::{Key, KeyboardShortcut}; -use global_hotkey::hotkey::{HotKey, Modifiers}; -use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; +use winit::keyboard::{Key, KeyCode, ModifiersState, NativeKeyCode, PhysicalKey}; use crate::controls::{self, Action, Controls}; -mod keypad; +// Tested on macOS too, which global-hotkey builds for. +#[cfg(any(windows, all(test, target_os = "macos")))] mod layout; +#[cfg(any(target_os = "macos", windows))] +mod native; #[cfg(target_os = "linux")] mod portal; - -pub use keypad::Keypad; -use layout::Layout; +#[cfg(target_os = "linux")] +mod x11; /// The key bound to each action. Every action starts unbound, so a key /// another app, such as a game, uses is never taken unasked. pub type Bindings = BTreeMap; /// A key bound to an action. -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Binding { - /// The key with its modifiers as the layout names it, which the panel - /// shows and the Wayland portal is asked for. - pub key: KeyboardShortcut, - /// Where the key sits, named for the key a US keyboard has there, which - /// the platform's hotkeys register through the layout in use. - pub position: Key, + /// The modifiers held with the key. + pub modifiers: egui::Modifiers, + /// Where the key sits, which the platform hears whatever the layout. + pub key: PhysicalKey, + /// What the layout put on the key when it was bound, ignoring modifiers, + /// which the panel shows and the Wayland portal is asked for. + pub name: Key, } impl Binding { /// Whether `self` and `other` are the same key, held with the same /// modifiers, whatever the layout called each. fn same(&self, other: &Binding) -> bool { - self.position == other.position && self.key.modifiers == other.key.modifiers + self.key == other.key && self.modifiers == other.modifiers + } + + /// The key with its modifiers, as the panel shows it. + pub fn shown(&self, ctx: &egui::Context) -> String { + let modifiers = egui::ModifierNames::NAMES.format(&self.modifiers, ctx.os().is_mac()); + match modifiers.is_empty() { + true => self.label(), + false => format!("{modifiers}+{}", self.label()), + } + } + + /// The key alone, as the layout printed it, and a keypad key by its place + /// on the keypad, which NumLock relabels. + fn label(&self) -> String { + if let Some((_, label, _)) = keypad(self.key) { + return format!("Keypad {label}"); + } + match (&self.name, self.key) { + (Key::Character(text), _) => text.to_uppercase(), + (Key::Named(named), _) => format!("{named:?}"), + (_, PhysicalKey::Code(code)) => format!("{code:?}"), + (_, PhysicalKey::Unidentified(native)) => match native { + NativeKeyCode::Xkb(code) => format!("Key {code}"), + NativeKeyCode::Windows(code) => format!("Key {code}"), + NativeKeyCode::MacOS(code) => format!("Key {code}"), + NativeKeyCode::Android(code) => format!("Key {code}"), + NativeKeyCode::Unidentified => "Unknown key".into(), + }, + } + } +} + +/// The keypad keys, each with what the panel calls it and its XKB keysym. +const KEYPAD: [(KeyCode, &str, &str); 18] = [ + (KeyCode::Numpad0, "0", "KP_0"), + (KeyCode::Numpad1, "1", "KP_1"), + (KeyCode::Numpad2, "2", "KP_2"), + (KeyCode::Numpad3, "3", "KP_3"), + (KeyCode::Numpad4, "4", "KP_4"), + (KeyCode::Numpad5, "5", "KP_5"), + (KeyCode::Numpad6, "6", "KP_6"), + (KeyCode::Numpad7, "7", "KP_7"), + (KeyCode::Numpad8, "8", "KP_8"), + (KeyCode::Numpad9, "9", "KP_9"), + (KeyCode::NumpadAdd, "+", "KP_Add"), + (KeyCode::NumpadSubtract, "-", "KP_Subtract"), + (KeyCode::NumpadMultiply, "*", "KP_Multiply"), + (KeyCode::NumpadDivide, "/", "KP_Divide"), + (KeyCode::NumpadDecimal, ".", "KP_Decimal"), + (KeyCode::NumpadComma, ",", "KP_Separator"), + (KeyCode::NumpadEqual, "=", "KP_Equal"), + (KeyCode::NumpadEnter, "Enter", "KP_Enter"), +]; + +/// `key`'s entry in [`KEYPAD`], if it is on the keypad. +fn keypad(key: PhysicalKey) -> Option<&'static (KeyCode, &'static str, &'static str)> { + KEYPAD.iter().find(|(code, ..)| key == PhysicalKey::Code(*code)) +} + +/// The keys pressed in the studio's window, which winit hands it before egui. +#[derive(Clone, Default)] +pub struct Keyboard(Arc>); + +#[derive(Default)] +struct Pressing { + /// The modifiers held now, as egui names them. + modifiers: egui::Modifiers, + taking: Taking, +} + +/// What a key pressed in the window does besides reaching egui. +#[derive(Default)] +enum Taking { + #[default] + Nothing, + /// The Hotkeys panel waits for a key: the first pressed, other than a + /// modifier, is kept for it, and no key reaches egui meanwhile, so the + /// key binds rather than acts. + Binding(Option), + /// Bound keys fire instead of reaching egui, where nothing hears them + /// outside the window. + Firing { + bound: Arc>, + fire: mpsc::Sender, + }, +} + +impl Keyboard { + /// Note the modifiers held from now on. + pub fn modifiers(&self, state: ModifiersState) { + let mac = cfg!(target_os = "macos"); + self.0.lock().unwrap().modifiers = egui::Modifiers { + alt: state.alt_key(), + ctrl: state.control_key(), + shift: state.shift_key(), + mac_cmd: mac && state.super_key(), + command: match mac { + true => state.super_key(), + false => state.control_key(), + }, + }; + } + + /// Hear `key` pressed in the window, where the layout puts `name` on it, + /// returning whether egui hears it too. + pub fn pressed(&self, key: PhysicalKey, name: Key, repeat: bool) -> bool { + let mut pressing = self.0.lock().unwrap(); + let pressed = Binding { + modifiers: pressing.modifiers, + key, + name, + }; + match &mut pressing.taking { + Taking::Nothing => true, + // A modifier is held with the key it waits for. + Taking::Binding(_) if modifier(key) => true, + Taking::Binding(taken) => { + if taken.is_none() && !repeat { + *taken = Some(pressed); + } + false + } + Taking::Firing { bound, fire } => { + if !bound.lock().unwrap().values().any(|bound| bound.same(&pressed)) { + return true; + } + // A held key fires once, and its repeats are taken too. + if !repeat { + let _ = fire.send(pressed); + } + false + } + } + } + + fn take(&self, taking: Taking) { + self.0.lock().unwrap().taking = taking; } } +/// Whether `key` is a modifier, held with a key rather than bound itself. +fn modifier(key: PhysicalKey) -> bool { + matches!( + key, + PhysicalKey::Code( + KeyCode::ShiftLeft + | KeyCode::ShiftRight + | KeyCode::ControlLeft + | KeyCode::ControlRight + | KeyCode::AltLeft + | KeyCode::AltRight + | KeyCode::SuperLeft + | KeyCode::SuperRight + | KeyCode::Meta + | KeyCode::Hyper + | KeyCode::Fn + | KeyCode::FnLock + ) + ) +} + /// The bound keys, heard wherever the platform lets the studio hear them. pub struct Hotkeys { /// The key bound to each action, which the thread firing them reads as @@ -52,28 +215,24 @@ pub struct Hotkeys { backend: Backend, /// The keys last handed to the backend. heard: Vec, - /// Why the platform refused each key it would not register. + /// Why the platform refused each key it would not hear. refused: Vec<(Binding, String)>, - /// Why the keys the platform holds cannot change, if they cannot. - stuck: Option, /// Whether bindings are held off while the Hotkeys panel waits for a key, /// which the thread firing them reads too. suspended: Arc, + keyboard: Keyboard, fire: mpsc::Sender, } /// Where bound keys are heard. enum Backend { - /// The platform's global hotkeys: `RegisterEventHotKey` on macOS, - /// `RegisterHotKey` on Windows, and a key grab on X11. - Native { - manager: GlobalHotKeyManager, - /// The hotkeys registered now, and the key each hears. - held: Vec<(Binding, HotKey)>, - /// The key each registered hotkey hears, by its id, which the - /// platform's event handler reads. - registered: Arc>>, - }, + /// The system's global hotkeys: `RegisterEventHotKey` on macOS, and + /// `RegisterHotKey` on Windows. + #[cfg(any(target_os = "macos", windows))] + Native(native::Native), + /// Key grabs from the X server. + #[cfg(target_os = "linux")] + X11(x11::X11), /// The desktop's GlobalShortcuts portal, on Wayland. #[cfg(target_os = "linux")] Portal(portal::Portal), @@ -103,12 +262,20 @@ pub enum Reach { Focused(String), } +/// What `Hotkeys::new` takes from the window it hears keys for. +pub struct Window<'a> { + pub frame: &'a eframe::Frame, + pub ctx: &'a egui::Context, + /// The keys the window hears before egui. + pub keyboard: Keyboard, +} + impl Hotkeys { /// Hear `bindings` from now on, firing each action on `controls` from a /// thread of its own. Called on the window's thread, which the platform's /// hotkeys must be registered from. pub fn new( - window: &eframe::Frame, + window: Window<'_>, controls: Arc, runtime: &tokio::runtime::Handle, bindings: Bindings, @@ -150,14 +317,14 @@ impl Hotkeys { } } }); - let backend = Backend::new(window, runtime, fire.clone()); + let backend = Backend::new(&window, runtime, fire.clone()); let mut hotkeys = Self { bound, backend, heard: Vec::new(), refused: Vec::new(), - stuck: None, suspended, + keyboard: window.keyboard, fire, }; hotkeys.set(bindings); @@ -172,10 +339,10 @@ impl Hotkeys { /// action, which is returned instead. pub fn bind(&mut self, action: &Action, key: Option) -> Result<(), Action> { let mut bindings = self.bindings(); - if let Some(key) = key + if let Some(key) = &key && let Some((other, _)) = bindings .iter() - .find(|(other, bound)| bound.same(&key) && *other != action) + .find(|(other, bound)| bound.same(key) && *other != action) { return Err(other.clone()); } @@ -200,82 +367,55 @@ impl Hotkeys { /// Hold every binding off while the Hotkeys panel waits for a key, so the /// key pressed binds rather than fires; or take them up again. pub fn suspend(&mut self, suspended: bool) { + self.keyboard.take(match suspended { + true => Taking::Binding(None), + false => Taking::Nothing, + }); if self.suspended.swap(suspended, Ordering::Relaxed) != suspended { self.set(self.bindings()); } } + /// The key pressed in the window since the Hotkeys panel began waiting + /// for one, if any. + pub fn taken(&self) -> Option { + match &mut self.keyboard.0.lock().unwrap().taking { + Taking::Binding(taken) => taken.take(), + _ => None, + } + } + /// Hear `bindings` instead of the ones heard now. fn set(&mut self, bindings: Bindings) { *self.bound.lock().unwrap() = bindings; self.hear(); } - /// Have the platform hear the keys bound now. On X11, each key it takes - /// or lets go of waits on the platform's event thread. + /// Have the platform hear the keys bound now, through the keyboard layout + /// in use where the platform matches keys through one. Called before each + /// frame too, so the keys follow a layout change. pub fn hear(&mut self) { let bindings = self.bindings(); let heard: Vec = match self.suspended.load(Ordering::Relaxed) { true => Vec::new(), - false => bindings.values().copied().collect(), + false => bindings.values().cloned().collect(), }; // A rename moves no key, so the platform is left alone. let moved = heard.len() != self.heard.len() || heard.iter().any(|key| !self.heard.iter().any(|was| was.same(key))); match &mut self.backend { - Backend::Native { - manager, - held, - registered, - } if moved => { - let layout = Layout::current(); - // Where the platform would let go of another key, or none, and keep - // holding these, they stay as they are. - let stuck = match &layout { - Ok(layout) => held - .iter() - .any(|(key, hotkey)| !layout.releases(key.position, hotkey.key)) - .then(|| "the keyboard layout changed since it took them".into()), - Err(err) => (!held.is_empty()).then(|| err.clone()), - }; - self.stuck = stuck; - if self.stuck.is_some() { - return; + #[cfg(any(target_os = "macos", windows))] + Backend::Native(native) => { + if moved || native.relaid() { + self.refused = native.hear(&heard); } - self.refused.clear(); - // `registered` is not locked while the manager works: on X11 it - // waits on the thread that runs the event handler, which locks it. - for (_, hotkey) in held.drain(..) { - if let Err(err) = manager.unregister(hotkey) { - tracing::warn!(%hotkey, error = %err, "cannot unregister a hotkey"); - } - } - let mut ids = HashMap::new(); - for key in &heard { - let hotkey = match &layout { - Ok(layout) => { - hotkey(layout, key).ok_or_else(|| "this key cannot be heard outside the studio".into()) - } - Err(err) => Err(err.clone()), - }; - let hotkey = match hotkey { - Ok(hotkey) => hotkey, - Err(reason) => { - self.refused.push((*key, reason)); - continue; - } - }; - match manager.register(hotkey) { - Ok(()) => { - held.push((*key, hotkey)); - ids.insert(hotkey.id(), *key); - } - Err(err) => self.refused.push((*key, refusal(err))), - } + } + #[cfg(target_os = "linux")] + Backend::X11(x11) => { + if moved { + self.refused = x11.hear(&heard); } - *registered.lock().unwrap() = ids; } - Backend::Native { .. } => {} // Held off, the desktop would be asked to bind them all again // after each key the panel takes. #[cfg(target_os = "linux")] @@ -295,15 +435,12 @@ impl Hotkeys { .map(|(_, why)| why.as_str()) } - /// Why the keys the platform holds stay as they are, keys cleared since - /// included, until the studio restarts. - pub fn stuck(&self) -> Option<&str> { - self.stuck.as_deref() - } - pub fn reach(&self) -> Reach { match &self.backend { - Backend::Native { .. } => Reach::Global, + #[cfg(any(target_os = "macos", windows))] + Backend::Native(_) => Reach::Global, + #[cfg(target_os = "linux")] + Backend::X11(_) => Reach::Global, #[cfg(target_os = "linux")] Backend::Portal(portal) => portal.reach(), Backend::Focused(reason) => Reach::Focused(reason.clone()), @@ -311,66 +448,55 @@ impl Hotkeys { } /// Fire the actions bound to keys pressed in the studio's window, where - /// nothing hears them outside it. A text field being typed in keeps its - /// keys, as the caller makes a page the preview passes the keyboard to. - /// A key that fires is taken, so nothing else in the window acts on it. - pub fn focused(&self, ctx: &egui::Context) { - let suspended = self.suspended.load(Ordering::Relaxed); - if suspended || ctx.text_edit_focused() || !matches!(self.reach(), Reach::Focused(_)) { + /// nothing hears them outside it, unless `typing`, as into a text field + /// or a page the preview passes the keyboard to. A key that fires is + /// taken, so nothing else in the window acts on it. Called each frame. + pub fn focused(&self, typing: bool) { + if self.suspended.load(Ordering::Relaxed) { return; } - let bindings = self.bound.lock().unwrap(); - ctx.input_mut(|input| { - input.events.retain(|event| { - let Some(pressed) = binding(event) else { - return true; - }; - if !bindings.values().any(|key| key.same(&pressed)) { - return true; - } - // A held key fires once, and its repeats are taken too. - if !matches!(event, egui::Event::Key { repeat: true, .. }) { - let _ = self.fire.send(pressed); - } - false - }); + let firing = !typing && matches!(self.reach(), Reach::Focused(_)); + self.keyboard.take(match firing { + true => Taking::Firing { + bound: self.bound.clone(), + fire: self.fire.clone(), + }, + false => Taking::Nothing, }); } } impl Backend { /// Pick where keys are heard from what the platform offers. - fn new(window: &eframe::Frame, runtime: &tokio::runtime::Handle, fire: mpsc::Sender) -> Self { + fn new(window: &Window<'_>, runtime: &tokio::runtime::Handle, fire: mpsc::Sender) -> Self { #[cfg(target_os = "linux")] { use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + let _ = window.ctx; let wayland = window + .frame .window_handle() .is_ok_and(|handle| matches!(handle.as_raw(), RawWindowHandle::Wayland(_))); if wayland { return Self::Portal(portal::Portal::new(runtime, fire)); } - } - #[cfg(not(target_os = "linux"))] - let _ = (window, runtime); - let manager = match GlobalHotKeyManager::new() { - Ok(manager) => manager, - Err(err) => return Self::Focused(format!("the platform's global hotkeys are unavailable: {err}")), - }; - let registered: Arc>> = Arc::default(); - let heard = registered.clone(); - GlobalHotKeyEvent::set_event_handler(Some(move |event: GlobalHotKeyEvent| { - if event.state() != HotKeyState::Pressed { - return; + match x11::X11::new(fire) { + Ok(x11) => Self::X11(x11), + Err(err) => Self::Focused(format!("cannot grab keys from the X server: {err:#}")), } - if let Some(key) = heard.lock().unwrap().get(&event.id()) { - let _ = fire.send(*key); + } + #[cfg(any(target_os = "macos", windows))] + { + let _ = window.frame; + match native::Native::new(window.ctx, runtime, fire) { + Ok(native) => Self::Native(native), + Err(err) => Self::Focused(format!("the platform's global hotkeys are unavailable: {err}")), } - })); - Self::Native { - manager, - held: Vec::new(), - registered, + } + #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] + { + let _ = (window, runtime, fire); + Self::Focused("this platform has no global hotkeys".into()) } } } @@ -386,8 +512,8 @@ fn renamed(bindings: &Bindings, renames: &BTreeMap>) -> B _ => None, }; match to { - Some(to) => drop(renamed.insert(Action::Scene(to), *key)), - None => kept.push((action.clone(), *key)), + Some(to) => drop(renamed.insert(Action::Scene(to), key.clone())), + None => kept.push((action.clone(), key.clone())), } } for (action, key) in kept { @@ -396,91 +522,94 @@ fn renamed(bindings: &Bindings, renames: &BTreeMap>) -> B renamed } -/// What the platform said when it refused a key. -fn refusal(err: global_hotkey::Error) -> String { - match err { - global_hotkey::Error::AlreadyRegistered(_) => "another app holds this key".into(), - err => format!("the platform refused this key: {err}"), - } -} - -/// The key `event` presses, ignoring repeats. -pub fn pressed(event: &egui::Event) -> Option { - match event { - egui::Event::Key { repeat: false, .. } => binding(event), - _ => None, - } -} +#[cfg(test)] +mod tests { + use winit::keyboard::NamedKey; -/// The key `event` presses or repeats, if it says where the key sits. -fn binding(event: &egui::Event) -> Option { - match event { - egui::Event::Key { - key, - physical_key: Some(position), - pressed: true, - modifiers, - .. - } => Some(Binding { - key: KeyboardShortcut::new(*modifiers, *key), - position: *position, - }), - _ => None, - } -} + use super::*; -/// `key` as the platform's hotkeys register it in `layout`, if they can hear it. -fn hotkey(layout: &Layout, key: &Binding) -> Option { - let held = key.key.modifiers; - let mut mods = Modifiers::empty(); - for (on, modifier) in [ - (held.shift, Modifiers::SHIFT), - (held.alt, Modifiers::ALT), - (held.ctrl, Modifiers::CONTROL), - (held.mac_cmd, Modifiers::META), - ] { - if on { - mods |= modifier; + fn key(code: KeyCode, name: &str) -> Binding { + Binding { + modifiers: egui::Modifiers::NONE, + key: PhysicalKey::Code(code), + name: Key::Character(name.into()), } } - Some(HotKey::new(Some(mods), layout.code(key.position)?)) -} - -#[cfg(test)] -mod tests { - use super::*; #[test] - fn a_key_is_the_same_wherever_the_layout_names_it() { - let press = |key, physical_key, modifiers| egui::Event::Key { - key, - physical_key, - pressed: true, - repeat: false, - modifiers, - }; + fn a_key_is_where_it_sits() { // Shift and the key a US keyboard prints 7 on make `/` on German. - let german = pressed(&press(Key::Slash, Some(Key::Num7), egui::Modifiers::SHIFT)).unwrap(); - assert_eq!(german.position, Key::Num7); - assert_eq!(german.key.logical_key, Key::Slash); - let us = pressed(&press(Key::Num7, Some(Key::Num7), egui::Modifiers::SHIFT)).unwrap(); + let mut german = key(KeyCode::Digit7, "7"); + german.modifiers = egui::Modifiers::SHIFT; + let mut us = key(KeyCode::Digit7, "7"); + us.modifiers = egui::Modifiers::SHIFT; + us.name = Key::Character("&".into()); assert!(us.same(&german)); - assert!(!us.same(&pressed(&press(Key::Num7, Some(Key::Num7), egui::Modifiers::NONE)).unwrap())); - // A key whose position is unknown cannot be registered by it. - assert_eq!(pressed(&press(Key::Pipe, None, egui::Modifiers::NONE)), None); + assert!(!us.same(&key(KeyCode::Digit7, "7"))); + // Keypad 1 is not the 1 above the letters, whatever NumLock makes it. + let keypad = key(KeyCode::Numpad1, "1"); + assert!(!keypad.same(&key(KeyCode::Digit1, "1"))); + assert_eq!(keypad.label(), "Keypad 1"); + let mut end = keypad.clone(); + end.name = Key::Named(NamedKey::End); + assert!(end.same(&keypad)); + assert_eq!(end.label(), "Keypad 1"); + assert_eq!(key(KeyCode::Digit1, "1").label(), "1"); + // A letter outside A to Z shows as the layout prints it. + assert_eq!(key(KeyCode::Semicolon, "ö").label(), "Ö"); + let unnamed = Binding { + modifiers: egui::Modifiers::NONE, + key: PhysicalKey::Unidentified(NativeKeyCode::Xkb(200)), + name: Key::Unidentified(winit::keyboard::NativeKey::Unidentified), + }; + assert_eq!(unnamed.label(), "Key 200"); } #[test] - fn bindings_follow_renames_at_once() { - let key = |key| Binding { - key: KeyboardShortcut::new(egui::Modifiers::NONE, key), - position: key, + fn the_panel_takes_the_key_pressed() { + let keyboard = Keyboard::default(); + let press = + |code, name: &str, repeat| keyboard.pressed(PhysicalKey::Code(code), Key::Character(name.into()), repeat); + // Nothing is taken unless the panel waits for a key. + assert!(press(KeyCode::KeyA, "a", false)); + keyboard.take(Taking::Binding(None)); + keyboard.modifiers(ModifiersState::CONTROL); + // A modifier is held for the key, which egui hears no more of. + assert!(keyboard.pressed( + PhysicalKey::Code(KeyCode::ControlLeft), + Key::Named(NamedKey::Control), + false + )); + assert!(!press(KeyCode::Numpad1, "1", false)); + assert!(!press(KeyCode::Digit1, "1", false)); + let Taking::Binding(Some(taken)) = &keyboard.0.lock().unwrap().taking else { + panic!("no key was taken"); }; + assert_eq!(taken.key, PhysicalKey::Code(KeyCode::Numpad1)); + assert!(taken.modifiers.ctrl); + } + + #[test] + fn focused_keys_fire_once_and_are_taken() { + let keyboard = Keyboard::default(); + let (fire, fired) = mpsc::channel(); + let bound = Arc::new(Mutex::new(Bindings::from([(Action::Cut, key(KeyCode::Numpad1, "1"))]))); + keyboard.take(Taking::Firing { bound, fire }); + let press = |code, repeat| keyboard.pressed(PhysicalKey::Code(code), Key::Character("1".into()), repeat); + assert!(press(KeyCode::Digit1, false), "an unbound key reaches egui"); + assert!(!press(KeyCode::Numpad1, false)); + assert!(!press(KeyCode::Numpad1, true)); + assert_eq!(fired.try_iter().count(), 1); + } + + #[test] + fn bindings_follow_renames_at_once() { let scene = |name: &str| Action::Scene(name.into()); + let (f1, f2, f3) = (key(KeyCode::F1, "F1"), key(KeyCode::F2, "F2"), key(KeyCode::F3, "F3")); let bindings = Bindings::from([ - (scene("A"), key(Key::F1)), - (scene("B"), key(Key::F2)), - (Action::Cut, key(Key::F3)), + (scene("A"), f1.clone()), + (scene("B"), f2.clone()), + (Action::Cut, f3.clone()), ]); // Swapping two names swaps their keys, and a removed scene keeps its. let renames = BTreeMap::from([ @@ -488,15 +617,15 @@ mod tests { ("B".to_string(), Some("A".to_string())), ]); let swapped = renamed(&bindings, &renames); - assert_eq!(swapped[&scene("A")], key(Key::F2)); - assert_eq!(swapped[&scene("B")], key(Key::F1)); - assert_eq!(swapped[&Action::Cut], key(Key::F3)); + assert_eq!(swapped[&scene("A")], f2); + assert_eq!(swapped[&scene("B")], f1); + assert_eq!(swapped[&Action::Cut], f3); let removed = BTreeMap::from([("A".to_string(), None)]); assert_eq!(renamed(&bindings, &removed), bindings); // B removed, then A renamed to B: A's key follows it over B's kept one. let onto = BTreeMap::from([("A".to_string(), Some("B".to_string()))]); let moved = renamed(&bindings, &onto); - assert_eq!(moved[&scene("B")], key(Key::F1)); + assert_eq!(moved[&scene("B")], f1); assert!(!moved.contains_key(&scene("A"))); } } diff --git a/rs/moq-studio/src/hotkeys/keypad.rs b/rs/moq-studio/src/hotkeys/keypad.rs deleted file mode 100644 index e675e06..0000000 --- a/rs/moq-studio/src/hotkeys/keypad.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! The keypad keys egui-winit 0.36 reports as their main keyboard twins -//! (`Numpad1` as `Key::Num1`), so binding one would register the other. The -//! window's event loop notes them before egui hears them, and each frame -//! forgets where they sit, so they are neither bound nor matched. - -use std::collections::BTreeSet; -use std::sync::{Arc, Mutex}; - -use egui::Key; -use winit::keyboard::{KeyCode, PhysicalKey}; - -/// The twins of the keypad keys pressed or released since the last frame. -#[derive(Clone, Default)] -pub struct Keypad(Arc>>); - -impl Keypad { - /// Note a key the window heard, before egui hears it. - pub fn note(&self, key: PhysicalKey) { - if let Some(twin) = twin(key) { - self.0.lock().unwrap().insert(twin); - } - } - - /// Forget where the keypad keys noted since the last frame sit, in that - /// frame's `events`. A twin pressed in the same frame is forgotten too, - /// which refuses it rather than binding the wrong key. - pub fn forget(&self, events: &mut [egui::Event]) { - let keypad = std::mem::take(&mut *self.0.lock().unwrap()); - for event in events { - if let egui::Event::Key { physical_key, .. } = event - && physical_key.is_some_and(|key| keypad.contains(&key)) - { - *physical_key = None; - } - } - } -} - -/// The key egui-winit reports for the keypad key `key`, where it folds one. -fn twin(key: PhysicalKey) -> Option { - let PhysicalKey::Code(code) = key else { - return None; - }; - Some(match code { - KeyCode::Numpad0 => Key::Num0, - KeyCode::Numpad1 => Key::Num1, - KeyCode::Numpad2 => Key::Num2, - KeyCode::Numpad3 => Key::Num3, - KeyCode::Numpad4 => Key::Num4, - KeyCode::Numpad5 => Key::Num5, - KeyCode::Numpad6 => Key::Num6, - KeyCode::Numpad7 => Key::Num7, - KeyCode::Numpad8 => Key::Num8, - KeyCode::Numpad9 => Key::Num9, - KeyCode::NumpadEnter => Key::Enter, - KeyCode::NumpadDivide => Key::Slash, - KeyCode::NumpadSubtract => Key::Minus, - KeyCode::NumpadAdd => Key::Plus, - _ => return None, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn keypad_keys_are_not_bound_as_their_twins() { - // egui reports keypad 1 and the top row's 1 alike, with NumLock on. - let press = || { - vec![egui::Event::Key { - key: Key::Num1, - physical_key: Some(Key::Num1), - pressed: true, - repeat: false, - modifiers: egui::Modifiers::CTRL, - }] - }; - let keypad = Keypad::default(); - - keypad.note(PhysicalKey::Code(KeyCode::Numpad1)); - let mut events = press(); - keypad.forget(&mut events); - assert_eq!(crate::hotkeys::pressed(&events[0]), None); - - // The next frame's top-row 1 binds where it sits. - keypad.note(PhysicalKey::Code(KeyCode::Digit1)); - let mut events = press(); - keypad.forget(&mut events); - let bound = crate::hotkeys::pressed(&events[0]).unwrap(); - assert_eq!(bound.position, Key::Num1); - } -} diff --git a/rs/moq-studio/src/hotkeys/layout.rs b/rs/moq-studio/src/hotkeys/layout.rs index 4d37d43..24f5fc3 100644 --- a/rs/moq-studio/src/hotkeys/layout.rs +++ b/rs/moq-studio/src/hotkeys/layout.rs @@ -1,129 +1,146 @@ -//! What the keyboard layout in use puts where each key sits, so a binding -//! registers the key the operator pressed on every layout. macOS registers a -//! key by where it sits, Windows by the virtual key the layout gives it, and -//! X11 by a keysym, which global-hotkey looks up in the layout itself. +//! The virtual key a Windows keyboard layout puts where each key sits. +//! `RegisterHotKey` matches the virtual key the foreground window's layout +//! gives the key pressed, so a binding registers through that layout, and +//! again whenever it changes. -use egui::Key; use global_hotkey::hotkey::Code; - -/// The keyboard layout in use when a binding is registered. -pub struct Layout { - #[cfg(windows)] - layout: windows_sys::Win32::UI::Input::KeyboardAndMouse::HKL, - #[cfg(target_os = "linux")] - keymap: Keymap, +use winit::keyboard::KeyCode; + +/// The foreground window's layout, which a task keeps watching. +#[cfg(windows)] +pub struct Foreground { + /// The layout the task saw last, as its `HKL`. + seen: std::sync::Arc, + /// The layout the keys registered through. + used: isize, } -impl Layout { - /// The layout in use now, read on the window's thread. - pub fn current() -> Result { - #[cfg(windows)] - { - // SAFETY: reads the calling thread's layout handle, and takes no pointers. - let layout = unsafe { windows_sys::Win32::UI::Input::KeyboardAndMouse::GetKeyboardLayout(0) }; - Ok(Self { layout }) - } - #[cfg(target_os = "linux")] - { - Ok(Self { - keymap: Keymap::current().map_err(|err| format!("cannot read the X11 keyboard layout: {err}"))?, - }) - } - #[cfg(not(any(windows, target_os = "linux")))] - Ok(Self {}) +#[cfg(windows)] +impl Foreground { + /// Watch the foreground window's layout, waking `ctx` when it changes so + /// the keys register again. Windows says nothing to an app whose window + /// is not the one changing layout, so it is read a few times a second. + pub fn new(ctx: egui::Context, runtime: &tokio::runtime::Handle) -> Self { + use std::sync::atomic::{AtomicIsize, Ordering}; + use std::sync::{Arc, Weak}; + + let used = foreground(); + let seen = Arc::new(AtomicIsize::new(used)); + let watching: Weak = Arc::downgrade(&seen); + runtime.spawn(async move { + let mut tick = kio::clock::interval(std::time::Duration::from_millis(250)); + loop { + tick.tick().await; + let Some(seen) = watching.upgrade() else { + return; + }; + let now = foreground(); + if seen.swap(now, Ordering::Relaxed) != now { + ctx.request_repaint(); + } + } + }); + Self { seen, used } } - /// The key the platform's hotkeys register for the key at `position`, - /// named for the key a US keyboard has there, if they can hear it. - pub fn code(&self, position: Key) -> Option { - #[cfg(windows)] - if let Some(scancode) = scancode(position) { - use windows_sys::Win32::UI::Input::KeyboardAndMouse::{MAPVK_VSC_TO_VK_EX, MapVirtualKeyExW}; - // SAFETY: a lookup in the layout `GetKeyboardLayout` returned, with no pointers. - let vk = unsafe { MapVirtualKeyExW(scancode, MAPVK_VSC_TO_VK_EX, self.layout) }; - return vk_code(vk); - } - #[cfg(target_os = "linux")] - if scancode(position).is_some() { - return self.keymap.code(position); - } - code(position) + /// Whether the foreground window's layout changed since the keys + /// registered through it. + pub fn moved(&self) -> bool { + self.seen.load(std::sync::atomic::Ordering::Relaxed) != self.used } - /// Whether the platform, letting go of `code` registered for the key at - /// `position`, finds that key in this layout. global-hotkey looks an X11 - /// grab's keysym up again to release it, so once the layout moves the - /// keysym it releases another key, or none. - pub fn releases(&self, position: Key, code: Code) -> bool { - #[cfg(target_os = "linux")] - if let Some(scancode) = scancode(position) { - return self.keymap.holds(code, scancode + 8); - } - let _ = (position, code); - true + /// Register keys through the foreground window's layout from now on. + pub fn follow(&mut self) { + self.used = self.seen.load(std::sync::atomic::Ordering::Relaxed); + } + + /// The key global-hotkey registers for `key`, one [`relabeled`] says the + /// layout picks the virtual key of. + pub fn code(&self, key: KeyCode) -> Option { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{MAPVK_VSC_TO_VK_EX, MapVirtualKeyExW}; + use winit::platform::scancode::PhysicalKeyExtScancode; + + let scancode = key.to_scancode()?; + // SAFETY: a lookup in a layout handle Windows gave, with no pointers. + let vk = unsafe { MapVirtualKeyExW(scancode, MAPVK_VSC_TO_VK_EX, self.used as _) }; + vk_code(vk) } } -/// The PC scancode of a key in the block whose keys a layout relabels, -/// named for the key a US keyboard has there. Linux's evdev codes for these -/// are the same, and X11's keycodes are those plus 8. -#[cfg(any(windows, target_os = "linux", test))] -fn scancode(position: Key) -> Option { - Some(match position { - Key::Num1 => 0x02, - Key::Num2 => 0x03, - Key::Num3 => 0x04, - Key::Num4 => 0x05, - Key::Num5 => 0x06, - Key::Num6 => 0x07, - Key::Num7 => 0x08, - Key::Num8 => 0x09, - Key::Num9 => 0x0a, - Key::Num0 => 0x0b, - Key::Minus => 0x0c, - Key::Equals => 0x0d, - Key::Q => 0x10, - Key::W => 0x11, - Key::E => 0x12, - Key::R => 0x13, - Key::T => 0x14, - Key::Y => 0x15, - Key::U => 0x16, - Key::I => 0x17, - Key::O => 0x18, - Key::P => 0x19, - Key::OpenBracket => 0x1a, - Key::CloseBracket => 0x1b, - Key::A => 0x1e, - Key::S => 0x1f, - Key::D => 0x20, - Key::F => 0x21, - Key::G => 0x22, - Key::H => 0x23, - Key::J => 0x24, - Key::K => 0x25, - Key::L => 0x26, - Key::Semicolon => 0x27, - Key::Quote => 0x28, - Key::Backtick => 0x29, - Key::Backslash => 0x2b, - Key::Z => 0x2c, - Key::X => 0x2d, - Key::C => 0x2e, - Key::V => 0x2f, - Key::B => 0x30, - Key::N => 0x31, - Key::M => 0x32, - Key::Comma => 0x33, - Key::Period => 0x34, - Key::Slash => 0x35, - _ => return None, - }) +/// The layout the foreground window types with, as its `HKL`. +#[cfg(windows)] +fn foreground() -> isize { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetKeyboardLayout; + use windows_sys::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowThreadProcessId}; + + // SAFETY: plain reads with no pointers kept; a null thread id, with no + // foreground window, reads this thread's layout, which the system's is. + unsafe { + let thread = GetWindowThreadProcessId(GetForegroundWindow(), std::ptr::null_mut()); + GetKeyboardLayout(thread) as isize + } +} + +/// Whether the layout picks the virtual key of `key`: the keys that type +/// letters, digits, and punctuation. +pub fn relabeled(key: KeyCode) -> bool { + matches!( + key, + KeyCode::Backquote + | KeyCode::Backslash + | KeyCode::BracketLeft + | KeyCode::BracketRight + | KeyCode::Comma + | KeyCode::Digit0 + | KeyCode::Digit1 + | KeyCode::Digit2 + | KeyCode::Digit3 + | KeyCode::Digit4 + | KeyCode::Digit5 + | KeyCode::Digit6 + | KeyCode::Digit7 + | KeyCode::Digit8 + | KeyCode::Digit9 + | KeyCode::Equal + | KeyCode::IntlBackslash + | KeyCode::IntlRo + | KeyCode::IntlYen + | KeyCode::KeyA + | KeyCode::KeyB + | KeyCode::KeyC + | KeyCode::KeyD + | KeyCode::KeyE + | KeyCode::KeyF + | KeyCode::KeyG + | KeyCode::KeyH + | KeyCode::KeyI + | KeyCode::KeyJ + | KeyCode::KeyK + | KeyCode::KeyL + | KeyCode::KeyM + | KeyCode::KeyN + | KeyCode::KeyO + | KeyCode::KeyP + | KeyCode::KeyQ + | KeyCode::KeyR + | KeyCode::KeyS + | KeyCode::KeyT + | KeyCode::KeyU + | KeyCode::KeyV + | KeyCode::KeyW + | KeyCode::KeyX + | KeyCode::KeyY + | KeyCode::KeyZ + | KeyCode::Minus + | KeyCode::Period + | KeyCode::Quote + | KeyCode::Semicolon + | KeyCode::Slash + ) } /// The key global-hotkey registers as the Windows virtual key `vk`, for the -/// keys a layout puts in the block `scancode` covers. -#[cfg(any(windows, test))] +/// keys [`relabeled`] covers. fn vk_code(vk: u32) -> Option { const LETTERS: [Code; 26] = [ Code::KeyA, @@ -184,220 +201,13 @@ fn vk_code(vk: u32) -> Option { }) } -/// The keysyms the X server's keyboard mapping puts on each keycode. -#[cfg(any(target_os = "linux", test))] -struct Keymap { - min_keycode: u32, - per_keycode: usize, - keysyms: Vec, -} - -#[cfg(any(target_os = "linux", test))] -impl Keymap { - #[cfg(target_os = "linux")] - fn current() -> anyhow::Result { - use x11rb::connection::Connection; - use x11rb::protocol::xproto::ConnectionExt; - - let (conn, _) = x11rb::connect(None)?; - let setup = conn.setup(); - let (min, max) = (setup.min_keycode, setup.max_keycode); - let mapping = conn.get_keyboard_mapping(min, max - min + 1)?.reply()?; - Ok(Self { - min_keycode: min.into(), - per_keycode: mapping.keysyms_per_keycode.into(), - keysyms: mapping.keysyms, - }) - } - - /// The key global-hotkey grabs at `position`: one whose keysym, which - /// global-hotkey looks up as the first keycode carrying it, is on this - /// key at any shift level. - fn code(&self, position: Key) -> Option { - let keycode = scancode(position)? + 8; - self.keysyms(keycode)? - .iter() - .filter_map(|keysym| KEYSYMS.iter().find(|(_, named)| named == keysym)) - .find(|(_, keysym)| self.first(*keysym) == Some(keycode)) - .map(|(code, _)| *code) - } - - /// Whether global-hotkey finds `code`'s keysym on `keycode`. - fn holds(&self, code: Code, keycode: u32) -> bool { - KEYSYMS - .iter() - .find(|(named, _)| *named == code) - .is_some_and(|(_, keysym)| self.first(*keysym) == Some(keycode)) - } - - fn keysyms(&self, keycode: u32) -> Option<&[u32]> { - let index = keycode.checked_sub(self.min_keycode)? as usize; - self.keysyms.chunks(self.per_keycode).nth(index) - } - - /// The first keycode carrying `keysym`, as global-hotkey looks one up. - fn first(&self, keysym: u32) -> Option { - let index = self - .keysyms - .chunks(self.per_keycode) - .position(|keysyms| keysyms.contains(&keysym))?; - Some(self.min_keycode + index as u32) - } -} - -/// The keysym global-hotkey 0.9 grabs for each key `scancode` covers. -#[cfg(any(target_os = "linux", test))] -const KEYSYMS: [(Code, u32); 47] = [ - (Code::KeyA, 0x41), - (Code::KeyB, 0x42), - (Code::KeyC, 0x43), - (Code::KeyD, 0x44), - (Code::KeyE, 0x45), - (Code::KeyF, 0x46), - (Code::KeyG, 0x47), - (Code::KeyH, 0x48), - (Code::KeyI, 0x49), - (Code::KeyJ, 0x4a), - (Code::KeyK, 0x4b), - (Code::KeyL, 0x4c), - (Code::KeyM, 0x4d), - (Code::KeyN, 0x4e), - (Code::KeyO, 0x4f), - (Code::KeyP, 0x50), - (Code::KeyQ, 0x51), - (Code::KeyR, 0x52), - (Code::KeyS, 0x53), - (Code::KeyT, 0x54), - (Code::KeyU, 0x55), - (Code::KeyV, 0x56), - (Code::KeyW, 0x57), - (Code::KeyX, 0x58), - (Code::KeyY, 0x59), - (Code::KeyZ, 0x5a), - (Code::Digit0, 0x30), - (Code::Digit1, 0x31), - (Code::Digit2, 0x32), - (Code::Digit3, 0x33), - (Code::Digit4, 0x34), - (Code::Digit5, 0x35), - (Code::Digit6, 0x36), - (Code::Digit7, 0x37), - (Code::Digit8, 0x38), - (Code::Digit9, 0x39), - (Code::Comma, 0x2c), - (Code::Minus, 0x2d), - (Code::Period, 0x2e), - (Code::Slash, 0x2f), - (Code::Semicolon, 0x3b), - (Code::Equal, 0x3d), - (Code::BracketLeft, 0x5b), - (Code::Backslash, 0x5c), - (Code::BracketRight, 0x5d), - (Code::Backquote, 0x60), - // `leftsinglequotemark`, not `apostrophe`, so no layout's apostrophe - // key grabs. - (Code::Quote, 0xad0), -]; - -/// `key` as global-hotkey names the key at its position on a US keyboard. -fn code(key: Key) -> Option { - Some(match key { - Key::ArrowDown => Code::ArrowDown, - Key::ArrowLeft => Code::ArrowLeft, - Key::ArrowRight => Code::ArrowRight, - Key::ArrowUp => Code::ArrowUp, - Key::Escape => Code::Escape, - Key::Tab => Code::Tab, - Key::Backspace => Code::Backspace, - Key::Enter => Code::Enter, - Key::Space => Code::Space, - Key::Insert => Code::Insert, - Key::Delete => Code::Delete, - Key::Home => Code::Home, - Key::End => Code::End, - Key::PageUp => Code::PageUp, - Key::PageDown => Code::PageDown, - Key::Comma => Code::Comma, - Key::Backslash => Code::Backslash, - Key::Slash => Code::Slash, - Key::OpenBracket => Code::BracketLeft, - Key::CloseBracket => Code::BracketRight, - Key::Backtick => Code::Backquote, - Key::Minus => Code::Minus, - Key::Period => Code::Period, - Key::Equals => Code::Equal, - Key::Semicolon => Code::Semicolon, - Key::Quote => Code::Quote, - Key::Num0 => Code::Digit0, - Key::Num1 => Code::Digit1, - Key::Num2 => Code::Digit2, - Key::Num3 => Code::Digit3, - Key::Num4 => Code::Digit4, - Key::Num5 => Code::Digit5, - Key::Num6 => Code::Digit6, - Key::Num7 => Code::Digit7, - Key::Num8 => Code::Digit8, - Key::Num9 => Code::Digit9, - Key::A => Code::KeyA, - Key::B => Code::KeyB, - Key::C => Code::KeyC, - Key::D => Code::KeyD, - Key::E => Code::KeyE, - Key::F => Code::KeyF, - Key::G => Code::KeyG, - Key::H => Code::KeyH, - Key::I => Code::KeyI, - Key::J => Code::KeyJ, - Key::K => Code::KeyK, - Key::L => Code::KeyL, - Key::M => Code::KeyM, - Key::N => Code::KeyN, - Key::O => Code::KeyO, - Key::P => Code::KeyP, - Key::Q => Code::KeyQ, - Key::R => Code::KeyR, - Key::S => Code::KeyS, - Key::T => Code::KeyT, - Key::U => Code::KeyU, - Key::V => Code::KeyV, - Key::W => Code::KeyW, - Key::X => Code::KeyX, - Key::Y => Code::KeyY, - Key::Z => Code::KeyZ, - Key::F1 => Code::F1, - Key::F2 => Code::F2, - Key::F3 => Code::F3, - Key::F4 => Code::F4, - Key::F5 => Code::F5, - Key::F6 => Code::F6, - Key::F7 => Code::F7, - Key::F8 => Code::F8, - Key::F9 => Code::F9, - Key::F10 => Code::F10, - Key::F11 => Code::F11, - Key::F12 => Code::F12, - Key::F13 => Code::F13, - Key::F14 => Code::F14, - Key::F15 => Code::F15, - Key::F16 => Code::F16, - Key::F17 => Code::F17, - Key::F18 => Code::F18, - Key::F19 => Code::F19, - Key::F20 => Code::F20, - Key::F21 => Code::F21, - Key::F22 => Code::F22, - Key::F23 => Code::F23, - Key::F24 => Code::F24, - _ => return None, - }) -} - #[cfg(test)] mod tests { use super::*; - /// Some of Microsoft's German layout (kbdgr.h): Z and Y swap, `#` is - /// VK_OEM_2, and ö is VK_OEM_3. The digit row keeps US virtual keys. + /// Some of Microsoft's German layout (kbdgr.h), by scancode: Z and Y + /// swap, `#` is VK_OEM_2, and ö is VK_OEM_3. The digit row keeps US + /// virtual keys. fn german_vk(scancode: u32) -> u32 { match scancode { 0x08 => 0x37, @@ -411,60 +221,16 @@ mod tests { #[test] fn windows_registers_what_the_layout_puts_there() { - let windows = |position| vk_code(german_vk(scancode(position).unwrap())); + let windows = |scancode| vk_code(german_vk(scancode)); // Shift+7 types `/` on German, which is Shift and VK_7, not VK_OEM_2. - assert_eq!(windows(Key::Num7), Some(Code::Digit7)); - assert_eq!(windows(Key::Y), Some(Code::KeyZ)); - assert_eq!(windows(Key::Backslash), Some(Code::Slash)); - assert_eq!(windows(Key::Semicolon), Some(Code::Backquote)); + assert_eq!(windows(0x08), Some(Code::Digit7)); + // The key a US keyboard has Y on. + assert_eq!(windows(0x15), Some(Code::KeyZ)); + assert_eq!(windows(0x2b), Some(Code::Slash)); + assert_eq!(windows(0x27), Some(Code::Backquote)); assert_eq!(vk_code(0xdf), None); - } - - /// A keymap with two keysyms per keycode from 8, with `keys` on theirs. - fn keymap(keys: &[(Key, [u32; 2])]) -> Keymap { - let mut keysyms = vec![0; 2 * 0x40]; - for (position, syms) in keys { - let index = scancode(*position).unwrap() as usize; - keysyms[2 * index..2 * index + 2].copy_from_slice(syms); - } - Keymap { - min_keycode: 8, - per_keycode: 2, - keysyms, - } - } - - #[test] - fn x11_grabs_what_the_layout_puts_there() { - let german = keymap(&[ - (Key::Num7, [0x37, 0x2f]), // 7, slash - (Key::Y, [0x7a, 0x5a]), // z, Z - (Key::Z, [0x79, 0x59]), // y, Y - (Key::Semicolon, [0xf6, 0xd6]), // odiaeresis, Odiaeresis - (Key::Slash, [0x2d, 0x5f]), // minus, underscore - ]); - assert_eq!(german.code(Key::Num7), Some(Code::Digit7)); - assert_eq!(german.code(Key::Y), Some(Code::KeyZ)); - assert_eq!(german.code(Key::Slash), Some(Code::Minus)); - // No key global-hotkey names makes ö, so it is refused rather than - // grabbing the key that makes `;`. - assert_eq!(german.code(Key::Semicolon), None); - // AZERTY's digit row makes `&` unshifted, and 1 with Shift. - let french = keymap(&[(Key::Num1, [0x26, 0x31])]); - assert_eq!(french.code(Key::Num1), Some(Code::Digit1)); - // A keysym an earlier key carries grabs that key, so this one is refused. - let twice = keymap(&[(Key::Num1, [0x31, 0x21]), (Key::Num2, [0x31, 0x40])]); - assert_eq!(twice.code(Key::Num2), None); - } - - #[test] - fn x11_grabs_are_released_only_where_the_layout_still_has_them() { - let keycode = |position| scancode(position).unwrap() + 8; - let german = keymap(&[(Key::Y, [0x7a, 0x5a]), (Key::Z, [0x79, 0x59])]); - let us = keymap(&[(Key::Y, [0x79, 0x59]), (Key::Z, [0x7a, 0x5a])]); - // German's Y position grabbed `z`, which US moves to the Z position. - assert!(german.holds(Code::KeyZ, keycode(Key::Y))); - assert!(!us.holds(Code::KeyZ, keycode(Key::Y))); - assert!(us.holds(Code::KeyY, keycode(Key::Y))); + // The keypad keeps its virtual keys whatever the layout. + assert!(relabeled(KeyCode::Digit1)); + assert!(!relabeled(KeyCode::Numpad1)); } } diff --git a/rs/moq-studio/src/hotkeys/native.rs b/rs/moq-studio/src/hotkeys/native.rs new file mode 100644 index 0000000..859a2f5 --- /dev/null +++ b/rs/moq-studio/src/hotkeys/native.rs @@ -0,0 +1,296 @@ +//! Hotkeys registered with the system through global-hotkey: on macOS by +//! where the key sits, and on Windows by the virtual key the foreground +//! window's layout puts there. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, mpsc}; + +use global_hotkey::hotkey::{Code, HotKey, Modifiers}; +use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; +use winit::keyboard::{KeyCode, PhysicalKey}; + +use super::Binding; + +/// The system's hotkeys, registered from the window's thread. +pub struct Native { + manager: GlobalHotKeyManager, + /// The hotkeys registered now. + held: Vec, + /// The key each registered hotkey hears, by its id, which the platform's + /// event handler reads. + registered: Arc>>, + /// The layout Windows matches a hotkey's virtual key through. + #[cfg(windows)] + layout: super::layout::Foreground, +} + +impl Native { + /// Hear registered keys from now on, sending each pressed to `fire`. + /// On Windows, `ctx` is woken when the foreground window's layout + /// changes, so the keys register again through it. + pub fn new( + ctx: &egui::Context, + runtime: &tokio::runtime::Handle, + fire: mpsc::Sender, + ) -> Result { + let manager = GlobalHotKeyManager::new()?; + let registered: Arc>> = Arc::default(); + let heard = registered.clone(); + GlobalHotKeyEvent::set_event_handler(Some(move |event: GlobalHotKeyEvent| { + if event.state() != HotKeyState::Pressed { + return; + } + if let Some(key) = heard.lock().unwrap().get(&event.id()) { + let _ = fire.send(key.clone()); + } + })); + #[cfg(not(windows))] + let _ = (ctx, runtime); + Ok(Self { + manager, + held: Vec::new(), + registered, + #[cfg(windows)] + layout: super::layout::Foreground::new(ctx.clone(), runtime), + }) + } + + /// Whether the layout the keys are matched through changed since they + /// were registered. + pub fn relaid(&self) -> bool { + #[cfg(windows)] + return self.layout.moved(); + #[cfg(not(windows))] + false + } + + /// Register `keys` in place of the keys registered now, returning why the + /// system refused each it would not hear. + pub fn hear(&mut self, keys: &[Binding]) -> Vec<(Binding, String)> { + #[cfg(windows)] + self.layout.follow(); + for hotkey in self.held.drain(..) { + if let Err(err) = self.manager.unregister(hotkey) { + tracing::warn!(%hotkey, error = %err, "cannot unregister a hotkey"); + } + } + let mut refused = Vec::new(); + let mut ids = HashMap::new(); + for key in keys { + let registered = self.hotkey(key).and_then(|hotkey| { + self.manager.register(hotkey).map_err(refusal)?; + Ok(hotkey) + }); + match registered { + Ok(hotkey) => { + self.held.push(hotkey); + ids.insert(hotkey.id(), key.clone()); + } + Err(why) => refused.push((key.clone(), why)), + } + } + *self.registered.lock().unwrap() = ids; + refused + } + + /// `key` as global-hotkey registers it, or why it cannot. + fn hotkey(&self, key: &Binding) -> Result { + let PhysicalKey::Code(code) = key.key else { + return Err("global-hotkey cannot name this key, so it cannot be heard outside the studio".into()); + }; + // RegisterHotKey matches virtual keys, and both Enter keys are VK_RETURN. + #[cfg(windows)] + if code == KeyCode::NumpadEnter { + return Err("Windows cannot tell keypad Enter from Enter outside the studio".into()); + } + #[cfg(windows)] + let code = match super::layout::relabeled(code) { + true => self.layout.code(code), + false => self::code(code), + }; + #[cfg(not(windows))] + let code = self::code(code); + let code = code.ok_or("this key cannot be heard outside the studio")?; + let held = key.modifiers; + let mut mods = Modifiers::empty(); + for (on, modifier) in [ + (held.shift, Modifiers::SHIFT), + (held.alt, Modifiers::ALT), + (held.ctrl, Modifiers::CONTROL), + (held.mac_cmd, Modifiers::META), + ] { + if on { + mods |= modifier; + } + } + Ok(HotKey::new(Some(mods), code)) + } +} + +/// What the platform said when it refused a key. +fn refusal(err: global_hotkey::Error) -> String { + match err { + global_hotkey::Error::AlreadyRegistered(_) => "another app holds this key".into(), + err => format!("the platform refused this key: {err}"), + } +} + +/// The key global-hotkey names `key`, where it sits. Both name keys by the +/// W3C UI Events codes. +fn code(key: KeyCode) -> Option { + Some(match key { + KeyCode::Backquote => Code::Backquote, + KeyCode::Backslash => Code::Backslash, + KeyCode::BracketLeft => Code::BracketLeft, + KeyCode::BracketRight => Code::BracketRight, + KeyCode::Comma => Code::Comma, + KeyCode::Digit0 => Code::Digit0, + KeyCode::Digit1 => Code::Digit1, + KeyCode::Digit2 => Code::Digit2, + KeyCode::Digit3 => Code::Digit3, + KeyCode::Digit4 => Code::Digit4, + KeyCode::Digit5 => Code::Digit5, + KeyCode::Digit6 => Code::Digit6, + KeyCode::Digit7 => Code::Digit7, + KeyCode::Digit8 => Code::Digit8, + KeyCode::Digit9 => Code::Digit9, + KeyCode::Equal => Code::Equal, + KeyCode::IntlBackslash => Code::IntlBackslash, + KeyCode::IntlRo => Code::IntlRo, + KeyCode::IntlYen => Code::IntlYen, + KeyCode::KeyA => Code::KeyA, + KeyCode::KeyB => Code::KeyB, + KeyCode::KeyC => Code::KeyC, + KeyCode::KeyD => Code::KeyD, + KeyCode::KeyE => Code::KeyE, + KeyCode::KeyF => Code::KeyF, + KeyCode::KeyG => Code::KeyG, + KeyCode::KeyH => Code::KeyH, + KeyCode::KeyI => Code::KeyI, + KeyCode::KeyJ => Code::KeyJ, + KeyCode::KeyK => Code::KeyK, + KeyCode::KeyL => Code::KeyL, + KeyCode::KeyM => Code::KeyM, + KeyCode::KeyN => Code::KeyN, + KeyCode::KeyO => Code::KeyO, + KeyCode::KeyP => Code::KeyP, + KeyCode::KeyQ => Code::KeyQ, + KeyCode::KeyR => Code::KeyR, + KeyCode::KeyS => Code::KeyS, + KeyCode::KeyT => Code::KeyT, + KeyCode::KeyU => Code::KeyU, + KeyCode::KeyV => Code::KeyV, + KeyCode::KeyW => Code::KeyW, + KeyCode::KeyX => Code::KeyX, + KeyCode::KeyY => Code::KeyY, + KeyCode::KeyZ => Code::KeyZ, + KeyCode::Minus => Code::Minus, + KeyCode::Period => Code::Period, + KeyCode::Quote => Code::Quote, + KeyCode::Semicolon => Code::Semicolon, + KeyCode::Slash => Code::Slash, + KeyCode::Backspace => Code::Backspace, + KeyCode::CapsLock => Code::CapsLock, + KeyCode::ContextMenu => Code::ContextMenu, + KeyCode::Enter => Code::Enter, + KeyCode::Space => Code::Space, + KeyCode::Tab => Code::Tab, + KeyCode::Convert => Code::Convert, + KeyCode::KanaMode => Code::KanaMode, + KeyCode::Lang1 => Code::Lang1, + KeyCode::Lang2 => Code::Lang2, + KeyCode::NonConvert => Code::NonConvert, + KeyCode::Delete => Code::Delete, + KeyCode::End => Code::End, + KeyCode::Help => Code::Help, + KeyCode::Home => Code::Home, + KeyCode::Insert => Code::Insert, + KeyCode::PageDown => Code::PageDown, + KeyCode::PageUp => Code::PageUp, + KeyCode::ArrowDown => Code::ArrowDown, + KeyCode::ArrowLeft => Code::ArrowLeft, + KeyCode::ArrowRight => Code::ArrowRight, + KeyCode::ArrowUp => Code::ArrowUp, + KeyCode::NumLock => Code::NumLock, + KeyCode::Numpad0 => Code::Numpad0, + KeyCode::Numpad1 => Code::Numpad1, + KeyCode::Numpad2 => Code::Numpad2, + KeyCode::Numpad3 => Code::Numpad3, + KeyCode::Numpad4 => Code::Numpad4, + KeyCode::Numpad5 => Code::Numpad5, + KeyCode::Numpad6 => Code::Numpad6, + KeyCode::Numpad7 => Code::Numpad7, + KeyCode::Numpad8 => Code::Numpad8, + KeyCode::Numpad9 => Code::Numpad9, + KeyCode::NumpadAdd => Code::NumpadAdd, + KeyCode::NumpadClear => Code::NumpadClear, + KeyCode::NumpadComma => Code::NumpadComma, + KeyCode::NumpadDecimal => Code::NumpadDecimal, + KeyCode::NumpadDivide => Code::NumpadDivide, + KeyCode::NumpadEnter => Code::NumpadEnter, + KeyCode::NumpadEqual => Code::NumpadEqual, + KeyCode::NumpadMultiply => Code::NumpadMultiply, + KeyCode::NumpadSubtract => Code::NumpadSubtract, + KeyCode::Escape => Code::Escape, + KeyCode::PrintScreen => Code::PrintScreen, + KeyCode::ScrollLock => Code::ScrollLock, + KeyCode::Pause => Code::Pause, + KeyCode::BrowserBack => Code::BrowserBack, + KeyCode::BrowserFavorites => Code::BrowserFavorites, + KeyCode::BrowserForward => Code::BrowserForward, + KeyCode::BrowserHome => Code::BrowserHome, + KeyCode::BrowserRefresh => Code::BrowserRefresh, + KeyCode::BrowserSearch => Code::BrowserSearch, + KeyCode::BrowserStop => Code::BrowserStop, + KeyCode::LaunchApp1 => Code::LaunchApp1, + KeyCode::LaunchApp2 => Code::LaunchApp2, + KeyCode::LaunchMail => Code::LaunchMail, + KeyCode::MediaPlayPause => Code::MediaPlayPause, + KeyCode::MediaSelect => Code::MediaSelect, + KeyCode::MediaStop => Code::MediaStop, + KeyCode::MediaTrackNext => Code::MediaTrackNext, + KeyCode::MediaTrackPrevious => Code::MediaTrackPrevious, + KeyCode::Sleep => Code::Sleep, + KeyCode::AudioVolumeDown => Code::AudioVolumeDown, + KeyCode::AudioVolumeMute => Code::AudioVolumeMute, + KeyCode::AudioVolumeUp => Code::AudioVolumeUp, + KeyCode::F1 => Code::F1, + KeyCode::F2 => Code::F2, + KeyCode::F3 => Code::F3, + KeyCode::F4 => Code::F4, + KeyCode::F5 => Code::F5, + KeyCode::F6 => Code::F6, + KeyCode::F7 => Code::F7, + KeyCode::F8 => Code::F8, + KeyCode::F9 => Code::F9, + KeyCode::F10 => Code::F10, + KeyCode::F11 => Code::F11, + KeyCode::F12 => Code::F12, + KeyCode::F13 => Code::F13, + KeyCode::F14 => Code::F14, + KeyCode::F15 => Code::F15, + KeyCode::F16 => Code::F16, + KeyCode::F17 => Code::F17, + KeyCode::F18 => Code::F18, + KeyCode::F19 => Code::F19, + KeyCode::F20 => Code::F20, + KeyCode::F21 => Code::F21, + KeyCode::F22 => Code::F22, + KeyCode::F23 => Code::F23, + KeyCode::F24 => Code::F24, + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keypad_keys_register_apart_from_the_main_row() { + assert_eq!(code(KeyCode::Numpad1), Some(Code::Numpad1)); + assert_eq!(code(KeyCode::Digit1), Some(Code::Digit1)); + assert_eq!(code(KeyCode::NumpadSubtract), Some(Code::NumpadSubtract)); + assert_eq!(code(KeyCode::ShiftLeft), None); + } +} diff --git a/rs/moq-studio/src/hotkeys/portal.rs b/rs/moq-studio/src/hotkeys/portal.rs index f131299..04d1647 100644 --- a/rs/moq-studio/src/hotkeys/portal.rs +++ b/rs/moq-studio/src/hotkeys/portal.rs @@ -7,9 +7,9 @@ use std::sync::{Arc, Mutex, mpsc}; use anyhow::Context; use ashpd::desktop::global_shortcuts::{GlobalShortcuts, NewShortcut}; -use egui::{Key, KeyboardShortcut}; use futures_util::StreamExt; use tokio::sync::watch; +use winit::keyboard::{Key, NamedKey, PhysicalKey}; use super::{Binding, Bindings, Reach}; use crate::controls::Action; @@ -72,7 +72,7 @@ async fn run( .iter() .map(|(action, binding)| { NewShortcut::new(id(action, binding), action.to_string()) - .preferred_trigger(trigger(&binding.key).as_deref()) + .preferred_trigger(trigger(binding).as_deref()) }) .collect(); let bound = portal @@ -99,7 +99,7 @@ async fn run( let event = event.context("the global shortcuts portal went away")?; // Its key fires whatever it is bound to by then, if anything. if let Some((_, key)) = by_id(&bindings, event.shortcut_id()) { - let _ = fire.send(*key); + let _ = fire.send(key.clone()); } } changed = wanted.changed() => { @@ -122,7 +122,7 @@ fn id(action: &Action, binding: &Binding) -> String { Action::Cut => "cut".into(), Action::Transition => "transition".into(), Action::Scene(_) => { - let held = binding.key.modifiers; + let held = binding.modifiers; let mut id = "scene/".to_string(); for (on, name) in [ (held.ctrl, "ctrl+"), @@ -134,7 +134,10 @@ fn id(action: &Action, binding: &Binding) -> String { id.push_str(name); } } - id.push_str(&format!("{:?}", binding.position)); + match binding.key { + PhysicalKey::Code(code) => id.push_str(&format!("{code:?}")), + PhysicalKey::Unidentified(native) => id.push_str(&format!("{native:?}")), + } id } Action::Replay => "replay".into(), @@ -150,10 +153,10 @@ fn by_id<'a>(bindings: &'a Bindings, id: &str) -> Option<(&'a Action, &'a Bindin .find(|(action, binding)| self::id(action, binding) == id) } -/// `shortcut` as a trigger for the desktop to offer first, its key named by +/// `binding` as a trigger for the desktop to offer first, its key named by /// its XKB keysym. -fn trigger(shortcut: &KeyboardShortcut) -> Option { - let held = shortcut.modifiers; +fn trigger(binding: &Binding) -> Option { + let held = binding.modifiers; let mut trigger = String::new(); for (on, name) in [ (held.ctrl, "CTRL+"), @@ -165,58 +168,87 @@ fn trigger(shortcut: &KeyboardShortcut) -> Option { trigger.push_str(name); } } - trigger.push_str(&keysym(shortcut.logical_key)?); + trigger.push_str(&keysym(binding)?); Some(trigger) } -fn keysym(key: Key) -> Option { - let name = match key { - Key::ArrowDown => "Down", - Key::ArrowLeft => "Left", - Key::ArrowRight => "Right", - Key::ArrowUp => "Up", - Key::Escape => "Escape", - Key::Tab => "Tab", - Key::Backspace => "BackSpace", - Key::Enter => "Return", - Key::Space => "space", - Key::Insert => "Insert", - Key::Delete => "Delete", - Key::Home => "Home", - Key::End => "End", - Key::PageUp => "Page_Up", - Key::PageDown => "Page_Down", - Key::Comma => "comma", - Key::Backslash => "backslash", - Key::Slash => "slash", - Key::OpenBracket => "bracketleft", - Key::CloseBracket => "bracketright", - Key::Backtick => "grave", - Key::Minus => "minus", - Key::Period => "period", - Key::Equals => "equal", - Key::Semicolon => "semicolon", - Key::Quote => "apostrophe", - // A letter or digit is its lower-case self, and a function key its - // name, as egui names them. - key => { - let name = key.name(); - let alphanumeric = name.len() == 1 && name.chars().all(|c| c.is_ascii_alphanumeric()); - let function = name.len() > 1 && name.starts_with('F') && name[1..].parse::().is_ok(); - return match (alphanumeric, function) { - (true, _) => Some(name.to_lowercase()), - (_, true) => Some(name.into()), - _ => None, +/// The XKB keysym of `binding`'s key: a keypad key's whatever NumLock makes +/// it, and otherwise the one the layout put on the key when it was bound. +fn keysym(binding: &Binding) -> Option { + if let Some((_, _, keysym)) = super::keypad(binding.key) { + return Some((*keysym).into()); + } + let name = match &binding.name { + Key::Named(named) => match named { + NamedKey::ArrowDown => "Down", + NamedKey::ArrowLeft => "Left", + NamedKey::ArrowRight => "Right", + NamedKey::ArrowUp => "Up", + NamedKey::Escape => "Escape", + NamedKey::Tab => "Tab", + NamedKey::Backspace => "BackSpace", + NamedKey::Enter => "Return", + NamedKey::Space => "space", + NamedKey::Insert => "Insert", + NamedKey::Delete => "Delete", + NamedKey::Home => "Home", + NamedKey::End => "End", + NamedKey::PageUp => "Page_Up", + NamedKey::PageDown => "Page_Down", + NamedKey::PrintScreen => "Print", + NamedKey::Pause => "Pause", + // A function key is its name, as winit names them. + named => { + let name = format!("{named:?}"); + let function = name.len() > 1 && name.starts_with('F') && name[1..].parse::().is_ok(); + return function.then_some(name); + } + }, + Key::Character(text) => { + let mut chars = text.chars(); + let (Some(char), None) = (chars.next(), chars.next()) else { + return None; }; + match char { + ',' => "comma", + '\\' => "backslash", + '/' => "slash", + '[' => "bracketleft", + ']' => "bracketright", + '`' => "grave", + '-' => "minus", + '.' => "period", + '=' => "equal", + ';' => "semicolon", + '\'' => "apostrophe", + // A letter or digit is its lower-case self, and any other + // character its Unicode keysym, as XKB names one. + char if char.is_ascii_alphanumeric() => return Some(char.to_ascii_lowercase().into()), + char => return Some(format!("U{:04X}", u32::from(char))), + } } + _ => return None, }; Some(name.into()) } #[cfg(test)] mod tests { + use winit::keyboard::{KeyCode, NativeKeyCode}; + use super::*; - use crate::hotkeys::Binding; + + fn key(modifiers: egui::Modifiers, code: KeyCode, name: Key) -> Binding { + Binding { + modifiers, + key: PhysicalKey::Code(code), + name, + } + } + + fn prints(text: &str) -> Key { + Key::Character(text.into()) + } #[test] fn unchanged_bindings_keep_the_session() { @@ -225,11 +257,8 @@ mod tests { bindings, reach: Arc::new(Mutex::new(Reach::Asking)), }; - let key = Binding { - key: KeyboardShortcut::new(egui::Modifiers::CTRL, Key::R), - position: Key::R, - }; - portal.set(Bindings::from([(Action::Cut, key)])); + let key = key(egui::Modifiers::CTRL, KeyCode::KeyR, prints("r")); + portal.set(Bindings::from([(Action::Cut, key.clone())])); assert!(wanted.has_changed().unwrap()); wanted.mark_unchanged(); // What the Hotkeys panel sets when it starts and stops waiting for a key. @@ -239,24 +268,23 @@ mod tests { #[test] fn a_scene_keeps_its_id_through_renames() { - let key = |modifiers, key| Binding { - key: KeyboardShortcut::new(modifiers, key), - position: key, - }; let scene = |name: &str| Action::Scene(name.into()); - let f1 = key(egui::Modifiers::NONE, Key::F1); + let f1 = key(egui::Modifiers::NONE, KeyCode::F1, Key::Named(NamedKey::F1)); assert_eq!(id(&scene("Intro"), &f1), "scene/F1"); assert_eq!(id(&scene("Intro"), &f1), id(&scene("Opening"), &f1)); let ctrl_shift = egui::Modifiers::CTRL | egui::Modifiers::SHIFT; assert_eq!( - id(&scene("Intro"), &key(ctrl_shift, Key::Num1)), - "scene/ctrl+shift+Num1" + id(&scene("Intro"), &key(ctrl_shift, KeyCode::Digit1, prints("1"))), + "scene/ctrl+shift+Digit1" + ); + // Keypad 1 is its own shortcut. + assert_eq!( + id(&scene("Intro"), &key(ctrl_shift, KeyCode::Numpad1, prints("1"))), + "scene/ctrl+shift+Numpad1" ); // An activation reads the bindings the session was opened with. - let bindings = Bindings::from([ - (scene("Opening"), f1), - (Action::Cut, key(egui::Modifiers::CTRL, Key::F1)), - ]); + let cut = key(egui::Modifiers::CTRL, KeyCode::F1, Key::Named(NamedKey::F1)); + let bindings = Bindings::from([(scene("Opening"), f1.clone()), (Action::Cut, cut)]); assert_eq!(by_id(&bindings, "scene/F1"), Some((&scene("Opening"), &f1))); assert_eq!(by_id(&bindings, "cut").map(|(action, _)| action), Some(&Action::Cut)); assert_eq!(by_id(&bindings, "unknown"), None); @@ -265,11 +293,40 @@ mod tests { #[test] fn triggers_name_keysyms() { let ctrl_shift = egui::Modifiers::CTRL | egui::Modifiers::SHIFT; - let trigger = |modifiers, key| trigger(&KeyboardShortcut::new(modifiers, key)); - assert_eq!(trigger(ctrl_shift, Key::R).as_deref(), Some("CTRL+SHIFT+r")); - assert_eq!(trigger(egui::Modifiers::NONE, Key::F9).as_deref(), Some("F9")); - assert_eq!(trigger(egui::Modifiers::ALT, Key::Num1).as_deref(), Some("ALT+1")); - assert_eq!(trigger(egui::Modifiers::NONE, Key::PageUp).as_deref(), Some("Page_Up")); - assert_eq!(trigger(egui::Modifiers::NONE, Key::Colon), None); + let trigger = |modifiers, code, name| trigger(&key(modifiers, code, name)); + assert_eq!( + trigger(ctrl_shift, KeyCode::KeyR, prints("r")).as_deref(), + Some("CTRL+SHIFT+r") + ); + let none = egui::Modifiers::NONE; + assert_eq!( + trigger(none, KeyCode::F9, Key::Named(NamedKey::F9)).as_deref(), + Some("F9") + ); + assert_eq!( + trigger(egui::Modifiers::ALT, KeyCode::Digit1, prints("1")).as_deref(), + Some("ALT+1") + ); + assert_eq!( + trigger(none, KeyCode::PageUp, Key::Named(NamedKey::PageUp)).as_deref(), + Some("Page_Up") + ); + // A keypad key is asked for as one, whatever NumLock makes it. + assert_eq!( + trigger(none, KeyCode::Numpad1, Key::Named(NamedKey::End)).as_deref(), + Some("KP_1") + ); + assert_eq!( + trigger(none, KeyCode::NumpadEnter, Key::Named(NamedKey::Enter)).as_deref(), + Some("KP_Enter") + ); + // German's ö, by its Unicode keysym. + assert_eq!(trigger(none, KeyCode::Semicolon, prints("ö")).as_deref(), Some("U00F6")); + let unnamed = Binding { + modifiers: none, + key: PhysicalKey::Unidentified(NativeKeyCode::Xkb(200)), + name: Key::Unidentified(winit::keyboard::NativeKey::Unidentified), + }; + assert_eq!(super::trigger(&unnamed), None); } } diff --git a/rs/moq-studio/src/hotkeys/x11.rs b/rs/moq-studio/src/hotkeys/x11.rs new file mode 100644 index 0000000..3b44822 --- /dev/null +++ b/rs/moq-studio/src/hotkeys/x11.rs @@ -0,0 +1,205 @@ +//! Hotkeys on X11, grabbed from the X server by keycode: the key where it +//! sits, so any key binds, one winit cannot name included, and a layout +//! change moves none. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex, mpsc}; + +use anyhow::Context; +use winit::keyboard::PhysicalKey; +use winit::platform::scancode::PhysicalKeyExtScancode; +use x11rb::connection::Connection; +use x11rb::errors::ReplyError; +use x11rb::protocol::xkb::{self, ConnectionExt as _}; +use x11rb::protocol::xproto::{ConnectionExt as _, GrabMode, ModMask, Window}; +use x11rb::protocol::{ErrorKind, Event}; +use x11rb::rust_connection::RustConnection; + +use super::Binding; + +/// A grabbed key: its keycode, and the modifiers held with it. +type Grab = (u8, ModMask); + +/// The keys grabbed from the X server, heard on a thread of its own. +pub struct X11 { + conn: Arc, + root: Window, + /// The binding each grabbed key fires, which the thread hearing them reads. + grabbed: Arc>>, +} + +impl X11 { + /// Hear grabbed keys from now on, sending each pressed to `fire`, from a + /// thread that waits on the X server for as long as the studio runs. + pub fn new(fire: mpsc::Sender) -> anyhow::Result { + let (conn, screen) = x11rb::connect(None).context("cannot reach the X server")?; + let xkb = conn.xkb_use_extension(1, 0)?.reply()?; + anyhow::ensure!(xkb.supported, "the X server has no XKB"); + // A held key repeats as presses alone, rather than a release and a + // press each, so it fires once. + conn.xkb_per_client_flags( + xkb::ID::USE_CORE_KBD.into(), + xkb::PerClientFlag::DETECTABLE_AUTO_REPEAT, + xkb::PerClientFlag::DETECTABLE_AUTO_REPEAT, + Default::default(), + Default::default(), + Default::default(), + )? + .reply()?; + let root = conn.setup().roots[screen].root; + let conn = Arc::new(conn); + let grabbed: Arc>> = Arc::default(); + let (waiting, reading) = (conn.clone(), grabbed.clone()); + std::thread::spawn(move || { + let mut held = HashSet::new(); + loop { + let event = match waiting.wait_for_event() { + Ok(event) => event, + Err(err) => { + tracing::warn!(error = %err, "lost the X server; hotkeys no longer fire"); + return; + } + }; + match event { + Event::KeyPress(press) => { + if !held.insert(press.detail) { + continue; + } + let held = ModMask::from(u16::from(press.state)) & modifiers(); + if let Some(key) = reading.lock().unwrap().get(&(press.detail, held)) { + let _ = fire.send(key.clone()); + } + } + Event::KeyRelease(release) => { + held.remove(&release.detail); + } + _ => {} + } + } + }); + Ok(Self { conn, root, grabbed }) + } + + /// Grab `keys` in place of the keys grabbed now, returning why the X + /// server refused each it would not let go of. + pub fn hear(&mut self, keys: &[Binding]) -> Vec<(Binding, String)> { + let was = std::mem::take(&mut *self.grabbed.lock().unwrap()); + for (keycode, held) in was.keys() { + self.ungrab(*keycode, *held, &locks()); + } + let mut refused = Vec::new(); + let mut grabbed = HashMap::new(); + for key in keys { + match self.grab(key) { + Ok(grab) => drop(grabbed.insert(grab, key.clone())), + Err(why) => refused.push((key.clone(), why)), + } + } + if let Err(err) = self.conn.flush() { + tracing::warn!(error = %err, "cannot reach the X server"); + } + *self.grabbed.lock().unwrap() = grabbed; + refused + } + + /// Grab `key` with every mix of the locks, or none of them. + fn grab(&self, key: &Binding) -> Result { + let setup = self.conn.setup(); + let keycode = keycode(key.key) + .filter(|keycode| (setup.min_keycode..=setup.max_keycode).contains(keycode)) + .ok_or("the X server has no keycode for this key")?; + let held = mask(key.modifiers); + let locks = locks(); + for (taken, lock) in locks.iter().enumerate() { + let grabbed = self + .conn + .grab_key( + false, + self.root, + held | *lock, + keycode, + GrabMode::ASYNC, + GrabMode::ASYNC, + ) + .map_err(ReplyError::from) + .and_then(|cookie| cookie.check()); + if let Err(err) = grabbed { + self.ungrab(keycode, held, &locks[..taken]); + return Err(match err { + ReplyError::X11Error(err) if err.error_kind == ErrorKind::Access => { + "another app holds this key".into() + } + err => format!("the X server refused this key: {err}"), + }); + } + } + Ok((keycode, held)) + } + + fn ungrab(&self, keycode: u8, held: ModMask, locks: &[ModMask]) { + for lock in locks { + if let Ok(cookie) = self.conn.ungrab_key(keycode, self.root, held | *lock) { + cookie.ignore_error(); + } + } + } +} + +/// The modifiers a binding can hold, which a grab matches exactly. +fn modifiers() -> ModMask { + ModMask::SHIFT | ModMask::CONTROL | ModMask::M1 | ModMask::M4 +} + +/// NumLock (Mod2) and CapsLock, which X11 counts as held modifiers, so each +/// key is grabbed with every mix of them. +fn locks() -> [ModMask; 4] { + [ + ModMask::from(0u16), + ModMask::M2, + ModMask::LOCK, + ModMask::M2 | ModMask::LOCK, + ] +} + +/// The X11 keycode of `key`, which is its Linux scancode plus 8, for a key +/// winit names and one it leaves unidentified alike. +fn keycode(key: PhysicalKey) -> Option { + u8::try_from(key.to_scancode()? + 8).ok() +} + +/// `modifiers` as the X11 modifier mask they hold. +fn mask(modifiers: egui::Modifiers) -> ModMask { + let mut mask = ModMask::from(0u16); + for (on, modifier) in [ + (modifiers.shift, ModMask::SHIFT), + (modifiers.ctrl, ModMask::CONTROL), + (modifiers.alt, ModMask::M1), + (modifiers.mac_cmd, ModMask::M4), + ] { + if on { + mask |= modifier; + } + } + mask +} + +#[cfg(test)] +mod tests { + use winit::keyboard::{KeyCode, NativeKeyCode}; + + use super::*; + + #[test] + fn keys_grab_by_keycode() { + // evdev's KEY_1 and KEY_KP1, plus 8. + assert_eq!(keycode(PhysicalKey::Code(KeyCode::Digit1)), Some(10)); + assert_eq!(keycode(PhysicalKey::Code(KeyCode::Numpad1)), Some(87)); + assert_eq!(keycode(PhysicalKey::Code(KeyCode::NumpadEnter)), Some(104)); + // The key a US keyboard has `;` on, which types ö on German. + assert_eq!(keycode(PhysicalKey::Code(KeyCode::Semicolon)), Some(47)); + assert_eq!(keycode(PhysicalKey::Unidentified(NativeKeyCode::Xkb(200))), Some(208)); + assert_eq!(keycode(PhysicalKey::Unidentified(NativeKeyCode::Xkb(300))), None); + let ctrl_shift = egui::Modifiers::CTRL | egui::Modifiers::SHIFT; + assert_eq!(mask(ctrl_shift), ModMask::CONTROL | ModMask::SHIFT); + } +} diff --git a/rs/moq-studio/src/main.rs b/rs/moq-studio/src/main.rs index 6e19103..9b3a97a 100644 --- a/rs/moq-studio/src/main.rs +++ b/rs/moq-studio/src/main.rs @@ -11,6 +11,7 @@ use std::sync::{Arc, OnceLock}; use anyhow::Context; use winit::event::WindowEvent; use winit::event_loop::{ActiveEventLoop, EventLoop}; +use winit::platform::modifier_supplement::KeyEventExtModifierSupplement; mod autosave; mod controls; @@ -232,7 +233,7 @@ struct Window { runtime: tokio::runtime::Handle, /// The engine once the studio is built, for the save on quit. started: Arc>>, - keypad: hotkeys::Keypad, + keyboard: hotkeys::Keyboard, } impl eframe::App for Window { @@ -253,7 +254,11 @@ impl eframe::App for Window { }; let _ = self.started.set(engine.clone()); let mut studio = ui::Studio::new(&self.render, engine, self.runtime.clone()); - studio.load(frame); + studio.load(hotkeys::Window { + frame, + ctx: ui.ctx(), + keyboard: self.keyboard.clone(), + }); self.app = App::Studio(Box::new(studio)); ui.ctx().request_repaint(); } @@ -263,17 +268,13 @@ impl eframe::App for Window { studio.save(storage); } } - - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) { - self.keypad.forget(&mut raw_input.events); - } } -/// eframe's handler for the window's events, noting keypad keys before egui -/// folds them into their twins. +/// eframe's handler for the window's events, which hears keys before egui, +/// so a hotkey is the key where it sits. struct Events<'a> { eframe: eframe::EframeWinitApplication<'a>, - keypad: hotkeys::Keypad, + keyboard: hotkeys::Keyboard, /// Whether the window was asked to close, since eframe only logs the error /// that stops it otherwise. closed: bool, @@ -294,7 +295,18 @@ impl winit::application::ApplicationHandler for Events<'_> { fn window_event(&mut self, event_loop: &ActiveEventLoop, id: winit::window::WindowId, event: WindowEvent) { match &event { - WindowEvent::KeyboardInput { event, .. } => self.keypad.note(event.physical_key), + // A synthetic press is a key held as the window gained focus. + WindowEvent::KeyboardInput { + event, + is_synthetic: false, + .. + } if event.state.is_pressed() => { + let name = event.key_without_modifiers(); + if !self.keyboard.pressed(event.physical_key, name, event.repeat) { + return; + } + } + WindowEvent::ModifiersChanged(modifiers) => self.keyboard.modifiers(modifiers.state()), WindowEvent::CloseRequested => self.closed = true, _ => {} } @@ -355,8 +367,8 @@ fn run( let event_loop = EventLoop::::with_user_event() .build() .context("cannot open the window")?; - let keypad = hotkeys::Keypad::default(); - let window_keypad = keypad.clone(); + let keyboard = hotkeys::Keyboard::default(); + let window_keyboard = keyboard.clone(); let eframe = eframe::create_native( "moq-studio", options, @@ -370,14 +382,14 @@ fn run( render, runtime, started, - keypad: window_keypad, + keyboard: window_keyboard, })) }), &event_loop, ); let mut events = Events { eframe, - keypad, + keyboard, closed: false, }; event_loop.run_app(&mut events).context("the window failed")?; diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index 98c3739..6270cf0 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -14,6 +14,7 @@ use moq_scene::{ Background, Blend, Canvas, Clip, Collection, Crop, Direction, Draft, Ease, Filter, Item, Keyframe, Layer, MaskMode, Rect, Scene, Source, Transition, Video, wgpu, }; +use winit::keyboard::{KeyCode, PhysicalKey}; use crate::autosave::Conflict; use crate::controls::{Action, Controls}; @@ -162,9 +163,9 @@ const HOTKEYS: &str = "hotkeys"; impl Studio { /// Take up what this user picked last time, and start hearing the keys /// they bound. Called from the window's thread, which `window` is. - pub fn load(&mut self, window: &eframe::Frame) { + pub fn load(&mut self, window: hotkeys::Window<'_>) { let mut bindings = Bindings::new(); - if let Some(storage) = window.storage() { + if let Some(storage) = window.frame.storage() { let panels = &mut self.panels; panels.speaker = eframe::get_value::>(storage, SPEAKER).flatten(); panels.snapping = eframe::get_value(storage, SNAPPING).unwrap_or_default(); @@ -196,6 +197,11 @@ impl eframe::App for Studio { if left { self.panels.end_binding(); } + // Before each frame, and while the window is hidden, so the keys + // follow a keyboard layout change while a game has focus. + if let Some(hotkeys) = &mut self.panels.hotkeys { + hotkeys.hear(); + } } fn ui(&mut self, ui: &mut Ui, frame: &mut eframe::Frame) { @@ -254,20 +260,12 @@ impl eframe::App for Studio { } drop(state); - if !renames.is_empty() - && let Some(hotkeys) = &mut self.panels.hotkeys - { - hotkeys.hear(); - } if let Some(fired) = self.panels.controls.fired() { self.panels.message = Some(fired); } - if let Some(hotkeys) = &self.panels.hotkeys - && !self.panels.interact.has_keyboard() - { - hotkeys.focused(ui.ctx()); + if let Some(hotkeys) = &self.panels.hotkeys { + hotkeys.focused(self.panels.interact.has_keyboard() || ui.ctx().text_edit_focused()); } - // Before the shortcuts, so the key pressed binds rather than undoes. self.panels.take_binding(ui.ctx()); self.panels.shortcuts(ui.ctx()); egui::Panel::top("menu").show(ui, |ui| self.panels.menu(ui)); @@ -3016,43 +3014,16 @@ impl Panels { self.end_binding(); return; } - let pressed = ctx.input_mut(|input| { - let pressed = input.events.iter().find_map(|event| match event { - egui::Event::Key { - key, - pressed: true, - repeat: false, - .. - } => Some((*key, hotkeys::pressed(event))), - _ => None, - }); - if pressed.is_some() { - input - .events - .retain(|event| !matches!(event, egui::Event::Key { pressed: true, .. })); - } - pressed - }); - let Some((name, pressed)) = pressed else { return }; - let Some(key) = pressed else { - // Keypad keys among them, which egui names as their main keyboard twins. - self.refusal = Some(( - action, - format!("{} cannot be bound: the studio cannot tell where it sits", name.name()), - )); - return; - }; + let Some(key) = hotkeys.taken() else { return }; self.binding = None; hotkeys.suspend(false); - if key.key.logical_key == egui::Key::Escape { + if key.key == PhysicalKey::Code(KeyCode::Escape) { return; } + let shown = key.shown(ctx); self.refusal = match hotkeys.bind(&action, Some(key)) { Ok(()) => None, - Err(other) => { - let key = ctx.format_shortcut(&key.key); - Some((action, format!("{key} is bound to {other}; clear it there first"))) - } + Err(other) => Some((action, format!("{shown} is bound to {other}; clear it there first"))), }; } @@ -3085,12 +3056,6 @@ impl Panels { format!("Bound keys work only while the studio has focus: {why}"), ), }; - if let Some(why) = hotkeys.stuck() { - ui.colored_label( - ui.visuals().error_fg_color, - format!("The bound keys stay as they are until the studio restarts: {why}"), - ); - } let scenes: Vec = { let state = self.engine.state.lock().unwrap(); state @@ -3121,7 +3086,7 @@ impl Panels { ui.strong("press a key, or Escape"); } else { match bound { - Some(bound) => ui.monospace(ui.ctx().format_shortcut(&bound.key)), + Some(bound) => ui.monospace(bound.shown(ui.ctx())), None => ui.weak("unbound"), }; }