From db2677ae7345b620e307e7a2689282316daf8e75 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 22:29:32 -0800 Subject: [PATCH 01/12] chore(quest): claim quest/m1/hotkeys.md Quest-Claim: 62eb5b0927c7a628 From 8766c6f733ff3e24b085fdaa9a6152367413ccdf Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 23:00:16 -0800 Subject: [PATCH 02/12] feat(studio): system-wide hotkeys, arrow nudge, aspect lock, and kept editor settings Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 30 +++ README.md | 40 ++- quest/m0/destinations.md | 3 +- quest/m1/README.md | 3 +- quest/m1/hotkeys.md | 42 --- rs/moq-studio/Cargo.toml | 11 + rs/moq-studio/src/controls.rs | 208 +++++++++++++++ rs/moq-studio/src/hotkeys.rs | 378 +++++++++++++++++++++++++++ rs/moq-studio/src/hotkeys/portal.rs | 215 ++++++++++++++++ rs/moq-studio/src/main.rs | 4 +- rs/moq-studio/src/snap.rs | 192 ++++++++++++-- rs/moq-studio/src/ui.rs | 382 ++++++++++++++++++++++------ 12 files changed, 1362 insertions(+), 146 deletions(-) delete mode 100644 quest/m1/hotkeys.md create mode 100644 rs/moq-studio/src/controls.rs create mode 100644 rs/moq-studio/src/hotkeys.rs create mode 100644 rs/moq-studio/src/hotkeys/portal.rs diff --git a/Cargo.lock b/Cargo.lock index 36db5085..6ec88f87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2529,6 +2529,23 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "global-hotkey" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adda8c76a5139df3bae0eb0413c2cd746f66594e0bb66231a4daa0be3172da70" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "once_cell", + "thiserror 2.0.21", + "windows-sys 0.59.0", + "x11rb 0.13.2", + "xkeysym", +] + [[package]] name = "glow" version = "0.17.0" @@ -4251,10 +4268,13 @@ name = "moq-studio" version = "0.1.0" dependencies = [ "anyhow", + "ashpd", "bytes", "eframe", "egui", "egui_dock", + "futures-util", + "global-hotkey", "hang", "humantime", "kio", @@ -4272,6 +4292,7 @@ dependencies = [ "mp4-atom", "pollster 1.0.1", "qrcodegen", + "raw-window-handle", "same-file", "serde", "tempfile", @@ -8881,6 +8902,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/README.md b/README.md index c041ca1a..ad51e590 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ Every panel is a tab you can drag, split, or stack. | Output | Start and stop recording, pick the mix it records, set the canvas size and frame rate, add, resize, and remove further canvases, and see the broadcast path, whether the program is encoding, the encoder sessions open, and each canvas's renditions' encoder work. | | Health | See how the stream reaches viewers, relay by relay and track by track, and the cause of each problem, and which recordings a crash left unfinished were repaired at launch. | | Replay | Replay the last moments of the program or a source in a replay source, slowed down if you like, or save them as a clip. | +| Hotkeys | Bind keys to cut, transition, preview a scene, replay, save a clip, and start or stop recording, and clear them (see [Hotkeys](#hotkeys)). | A new source, from the Sources or Devices panel, is placed over whatever the Items panel shows. The **top layer** is drawn over every scene, in preview and @@ -191,7 +192,12 @@ edges and centers to the canvas's edges and center, to the other items shown, and to the grid while **Grid** is on, drawing a guide through what it lines up with. A moved item also snaps to the spacing of its neighbors, marking the equal spaces. Hold Ctrl (Cmd on macOS) to drag without snapping, or turn -**Snap** off. The snap and grid settings last until the studio quits. +**Snap** off. Resizing a camera, display, window, image, media file, broadcast, +or replay keeps its shape, and resizing anything else does not; hold Shift to +flip that. Click the preview, then the arrow keys nudge the selected item a +pixel, or ten with Shift held, and a held key undoes as one step. The snap and +grid settings are kept per user, with the [hotkeys](#hotkeys), not in the scene +file. Items showing the same source are **linked**: one capture, with the source's filters, wherever it is shown. A **clone** is a new source showing another's @@ -796,6 +802,38 @@ CPU, which the compositor uploads. A browser source takes that path with no display at all too, and there renders its page in software, WebGL included, so a WebGL page runs slower. +## Hotkeys + +The Hotkeys panel binds a key to **Cut**, **Transition**, **Replay**, **Save +clip**, starting or stopping a recording, and putting each scene in preview. +Press **Bind**, then the key with any modifiers; Escape cancels. Every action +starts unbound, so no key a game uses is 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. + +Bound keys fire even while another app, such as a game in full screen, has +focus, and the app does not see them: + +- **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. +- **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 + asked, and lists them in its shortcut settings; the panel shows what it + bound. Without the portal, or if the dialog is declined, bound keys work only + while the studio has focus, and the panel says so. + +The bindings, the snap and grid settings, and the output **Listen** plays on +are kept per user in the app's settings, `~/Library/Application +Support/moq-studio` on macOS, `$XDG_DATA_HOME/moq-studio` (or +`~/.local/share/moq-studio`) on Linux, and `%APPDATA%\moq-studio\data` on +Windows, apart from the scene file, so a scene file moves between machines +without one operator's keys. A key bound to a scene follows it when renamed in +the studio. + ## Replay Add a `replay` source and place it like any other, such as full screen in a diff --git a/quest/m0/destinations.md b/quest/m0/destinations.md index 8230f8cf..1e8b45c9 100644 --- a/quest/m0/destinations.md +++ b/quest/m0/destinations.md @@ -6,7 +6,8 @@ A creator adds any number of RTMP, RTMPS, and WHIP destinations alongside MoQ, each with its own codec and bitrate, and goes live to all of them at once. Destinations that take the same picture scene and audio scene with identical encode settings share one encoder. Each destination's status and errors show -in the UI. +in the UI. Going live and stopping are actions the Hotkeys panel can bind, +as recording is. The fan-out lands here as a UI-free egress library, with the studio as its first consumer. The library subscribes once to a MoQ broadcast and remuxes it diff --git a/quest/m1/README.md b/quest/m1/README.md index fab4c79f..13971282 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -6,7 +6,7 @@ The work held until m0 ships. A studio broadcast can be composed by its viewers and by a server from the sources' own tracks and a layout track, matching the program the studio composites, so a viewer can toggle and prioritize elements. The studio gains features m0 left out: overlay assets -and restarts, global hotkeys, and isolated guest recordings. +and restarts, and isolated guest recordings. ## Plan @@ -26,6 +26,5 @@ than opening an m2 for them. - [Viewer-side composition](/quest/m1/compose/README.md) - sources as their own tracks plus a layout track, composed by viewers and by a server - [Overlays restart on air](/quest/m1/overlay-restart.md) - an overlay's CSS timeline restarts at each cut unless set to keep running - [Overlay assets](/quest/m1/overlay-assets.md) - overlays use fonts and images from data: URLs or files beside the scene -- [Hotkeys and editor preferences](/quest/m1/hotkeys.md) - system-wide hotkeys, arrow nudge, aspect lock, and persisted editor settings - [Isolated guest recordings](/quest/m1/guest-recording.md) - record-each-source also writes each guest's tracks to its own file - [Scene graph view](/quest/m1/scene-graph.md) - a node view of picture and audio scenes, their routing, volume, pan, and waveforms diff --git a/quest/m1/hotkeys.md b/quest/m1/hotkeys.md deleted file mode 100644 index 8c02bf7a..00000000 --- a/quest/m1/hotkeys.md +++ /dev/null @@ -1,42 +0,0 @@ -# [L] Hotkeys and editor preferences - -## Goal - -The operator binds keys to studio actions, and they fire system-wide, even -while another app such as a game has focus: -- replay -- save a clip -- cut, transition, and select a scene -- start and stop recording and going live - -In the focused preview, arrow keys nudge the selected item, and holding a -modifier while resizing locks its aspect ratio. -Snap and grid settings and the hotkey bindings persist in an app settings -store, apart from the scene file. - -## Plan - -From [moq-dev/studio#15](https://github.com/moq-dev/studio/pull/15) and -[moq-dev/studio#20](https://github.com/moq-dev/studio/pull/20). - -Decided 2026-11-04 with the maintainer (/quest-plan): - -- ✅ **Global hotkeys in this quest**, as OBS has, since an operator fires a - replay from a game. Rejected: focused-only first with global later. -- ✅ **No default bindings.** Every action starts unbound, like OBS, so a - global key never collides with a game. Arrow-key nudge is always on but - works only in the focused preview. - -Implementation: - -- Per platform: the XDG GlobalShortcuts portal on Wayland (falling back to - focused-only where the portal is missing, said in the UI), `RegisterHotKey` - on Windows, and an event tap on macOS, which needs the Input Monitoring - grant; ask for it when the first key is bound. Prefer a maintained crate - that covers these over writing each. -- The settings store lives in the platform config directory, not the scene - file, so a scene file moves between machines without carrying one - operator's keys. -- A Hotkeys panel binds and clears keys, and shows a conflict. -- Aspect lock: the default for video sources, which have a fixed shape. -- `README.md` documents the panel, the macOS grant, and the Wayland portal. diff --git a/rs/moq-studio/Cargo.toml b/rs/moq-studio/Cargo.toml index b465a38c..40beec96 100644 --- a/rs/moq-studio/Cargo.toml +++ b/rs/moq-studio/Cargo.toml @@ -49,6 +49,9 @@ 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. @@ -81,6 +84,14 @@ tracing = { workspace = true } url = { workspace = true } usage-rs = { workspace = true, features = ["completions", "config"] } +[target.'cfg(target_os = "linux")'.dependencies] +# The GlobalShortcuts portal for hotkeys on Wayland. 0.13, as moq-video's +# PipeWire capture uses, so the two share one D-Bus stack. +ashpd = { version = "0.13", default-features = false, features = ["global_shortcuts", "tokio"] } +futures-util = "0.3" +# Whether the window is on Wayland, where only the portal hears keys globally. +raw-window-handle = "0.6" + [dev-dependencies] # A clock that moves wall time too, which renewed tokens' expiries read. kio = { workspace = true, features = ["manual"] } diff --git a/rs/moq-studio/src/controls.rs b/rs/moq-studio/src/controls.rs new file mode 100644 index 00000000..1f27ab4b --- /dev/null +++ b/rs/moq-studio/src/controls.rs @@ -0,0 +1,208 @@ +//! What the operator does from a panel's button or a hotkey: switch scenes, +//! replay, save a clip, and record. Hotkeys fire from outside the window's +//! frames, so a key pressed while a game has focus acts at once, even while +//! the window is hidden and draws nothing. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use anyhow::Context; +use moq_scene::Source; + +use crate::engine::Engine; +use crate::replay; + +/// Something a hotkey can be bound to. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +pub enum Action { + /// Put the preview on program at once. + Cut, + /// Take the preview to program by the transition the Program panel sets. + Transition, + /// Put a scene, by name, in preview. + Scene(String), + /// Play the recent past the Replay panel picks in its replay source. + Replay, + /// Save the recent past the Replay panel picks as a clip. + SaveClip, + /// Start recording, or stop the recording under way. + Record, +} + +impl Action { + /// The actions every scene file has, as the Hotkeys panel lists them. + pub const FIXED: [Self; 5] = [Self::Cut, Self::Transition, Self::Replay, Self::SaveClip, Self::Record]; +} + +impl std::fmt::Display for Action { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Cut => write!(f, "Cut"), + Self::Transition => write!(f, "Transition"), + Self::Scene(name) => write!(f, "Preview scene `{name}`"), + Self::Replay => write!(f, "Replay"), + Self::SaveClip => write!(f, "Save clip"), + Self::Record => write!(f, "Start or stop recording"), + } + } +} + +/// The engine as panels and hotkeys drive it, with the picks both share. +pub struct Controls { + pub engine: Arc, + runtime: tokio::runtime::Handle, + /// The Replay panel's picks, which a replay or clip from a hotkey takes too. + pub replay: Mutex, + /// Whether the next recording gives each source a file of its own too. + pub record_sources: AtomicBool, + /// How the last action a hotkey fired went, until the status bar takes it. + fired: Mutex>>, +} + +/// The Replay panel's choices, and how the last replay or clip went. +pub struct Replay { + pub of: replay::Of, + pub length: std::time::Duration, + pub speed: f32, + /// The replay source a replay plays in. + pub into: Option, + /// Set by the replay or save running in the background. + pub outcome: Arc>>>, +} + +impl Default for Replay { + fn default() -> Self { + Self { + of: replay::Of::Program, + length: replay::LENGTHS[1], + speed: replay::SPEEDS[0], + into: None, + outcome: Arc::default(), + } + } +} + +impl Controls { + pub fn new(engine: Arc, runtime: tokio::runtime::Handle) -> Self { + Self { + engine, + runtime, + replay: Mutex::default(), + record_sources: AtomicBool::new(false), + fired: Mutex::default(), + } + } + + /// Do `action`, saying what it did. + pub fn fire(&self, action: &Action) -> anyhow::Result { + match action { + Action::Cut => { + let mut state = self.engine.state.lock().unwrap(); + let state = &mut *state; + state.compositor.cut(&state.sources); + Ok(format!("cut to {}", on_program(state.compositor.program_scene()))) + } + Action::Transition => { + let mut state = self.engine.state.lock().unwrap(); + let state = &mut *state; + let transition = state.collection().transition.clone(); + let now = kio::clock::Instant::now(); + state.compositor.transition(transition, &state.sources, now); + Ok(format!("took {}", on_program(state.compositor.program_scene()))) + } + Action::Scene(name) => { + let mut state = self.engine.state.lock().unwrap(); + anyhow::ensure!(state.collection().scene(name).is_some(), "no scene is called `{name}`"); + state.compositor.set_preview(Some(name.clone())); + Ok(format!("previewing `{name}`")) + } + Action::Replay => self.replay(true), + Action::SaveClip => self.replay(false), + Action::Record => { + let recorder = &self.engine.recorder; + match recorder.status().filter(|status| !status.done) { + Some(status) if status.stopping => anyhow::bail!("the last recording is still finishing"), + Some(_) => { + recorder.stop(); + Ok("stopping the recording".into()) + } + None => { + recorder.start(self.record_sources.load(Ordering::Relaxed))?; + Ok(format!("recording into {}", recorder.dir().display())) + } + } + } + } + } + + /// Play the Replay panel's pick in its replay source, or save it as a + /// clip. Copied now, without waiting on anything, so the span ends here and + /// replays apply in the order they were asked for. + fn replay(&self, play: bool) -> anyhow::Result { + let replay = self.replay.lock().unwrap(); + let outcome = replay.outcome.clone(); + let result = self.take(&replay, play); + let message = result.as_ref().map_err(|err| format!("{err:#}")).cloned(); + *outcome.lock().unwrap() = Some(message); + result + } + + fn take(&self, replay: &Replay, play: bool) -> anyhow::Result { + let into = match play { + true => Some(self.replay_source(replay.into.as_deref())?), + false => None, + }; + let take = replay::take(&self.engine, &replay.of, replay.length, into.is_some())?; + let of = replay.of.clone(); + if let Some(into) = into { + replay::play(&self.engine, take, replay.speed, &into)?; + return Ok(format!("replaying {of} in `{into}`")); + } + let saving = format!("saving {of}"); + let engine = self.engine.clone(); + let outcome = replay.outcome.clone(); + self.runtime.spawn(async move { + let result = replay::save(take, engine.autosave.path()) + .await + .map(|path| format!("saved {of} to {}", path.display())); + *outcome.lock().unwrap() = Some(result.map_err(|err| format!("{err:#}"))); + }); + Ok(saving) + } + + /// The replay source a replay plays in: `picked`, or the first there is + /// while the panel has picked none, or one since removed. + fn replay_source(&self, picked: Option<&str>) -> anyhow::Result { + let state = self.engine.state.lock().unwrap(); + let sources = &state.collection().sources; + let replays = || { + sources + .iter() + .filter(|(_, source)| matches!(source, Source::Replay)) + .map(|(name, _)| name) + }; + let name = replays() + .find(|name| Some(name.as_str()) == picked) + .or_else(|| replays().next()) + .context("no replay source; add one in Sources")?; + Ok(name.clone()) + } + + /// Keep how `action`, fired by a hotkey, went for the status bar. + pub fn report(&self, action: &Action, result: anyhow::Result) { + let result = result.map_err(|err| format!("{action}: {err:#}")); + *self.fired.lock().unwrap() = Some(result); + } + + /// How the last action a hotkey fired went, once. + pub fn fired(&self) -> Option> { + self.fired.lock().unwrap().take() + } +} + +fn on_program(scene: Option<&str>) -> String { + match scene { + Some(scene) => format!("`{scene}`"), + None => "nothing".into(), + } +} diff --git a/rs/moq-studio/src/hotkeys.rs b/rs/moq-studio/src/hotkeys.rs new file mode 100644 index 00000000..b875cf07 --- /dev/null +++ b/rs/moq-studio/src/hotkeys.rs @@ -0,0 +1,378 @@ +//! 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. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, Mutex, mpsc}; + +use egui::{Key, KeyboardShortcut}; +use global_hotkey::hotkey::{Code, HotKey, Modifiers}; +use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; + +use crate::controls::{Action, Controls}; + +#[cfg(target_os = "linux")] +mod portal; + +/// 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; + +/// The bound keys, heard wherever the platform lets the studio hear them. +pub struct Hotkeys { + bindings: Bindings, + backend: Backend, + /// Why the platform refused each binding it would not register. + refused: BTreeMap, + /// Whether bindings are held off while the Hotkeys panel waits for a key. + suspended: bool, + 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 action each registered hotkey fires, by its id. + registered: Arc>>, + }, + /// The desktop's GlobalShortcuts portal, on Wayland. + #[cfg(target_os = "linux")] + Portal(portal::Portal), + /// Only while the studio has focus, and why. + Focused(String), +} + +/// How far bound keys reach, as the Hotkeys panel says. +#[derive(Clone)] +pub enum Reach { + /// Every app, through the platform. + Global, + /// Every app, through the desktop's shortcut settings, which may have + /// bound different keys than asked: those it bound, by action. + #[cfg_attr( + not(target_os = "linux"), + expect(dead_code, reason = "only Wayland's portal binds through the desktop") + )] + Desktop(BTreeMap), + /// Waiting on the desktop to bind them. + #[cfg_attr( + not(target_os = "linux"), + expect(dead_code, reason = "only Wayland's portal binds through the desktop") + )] + Asking, + /// Only while the studio has focus, and why. + Focused(String), +} + +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, + controls: Arc, + runtime: &tokio::runtime::Handle, + bindings: Bindings, + ) -> Self { + let (fire, fired) = mpsc::channel::(); + std::thread::spawn(move || { + for action in fired { + let result = controls.fire(&action); + controls.report(&action, result); + } + }); + let backend = Backend::new(window, runtime, fire.clone()); + let mut hotkeys = Self { + bindings: Bindings::new(), + backend, + refused: BTreeMap::new(), + suspended: false, + fire, + }; + hotkeys.set(bindings); + hotkeys + } + + pub fn bindings(&self) -> &Bindings { + &self.bindings + } + + /// Bind `action` to `key`, or unbind it, unless `key` is bound to another + /// action, which is returned instead. + pub fn bind(&mut self, action: &Action, key: Option) -> Result<(), Action> { + if let Some(key) = key + && let Some((other, _)) = self + .bindings + .iter() + .find(|(other, bound)| **bound == key && *other != action) + { + return Err(other.clone()); + } + let mut bindings = self.bindings.clone(); + match key { + Some(key) => bindings.insert(action.clone(), key), + None => bindings.remove(action), + }; + self.set(bindings); + Ok(()) + } + + /// Move a binding to a scene renamed from `from` to `to`. + pub fn rename(&mut self, from: &str, to: &str) { + let mut bindings = self.bindings.clone(); + if let Some(key) = bindings.remove(&Action::Scene(from.into())) { + bindings.insert(Action::Scene(to.into()), key); + self.set(bindings); + } + } + + /// 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) { + if self.suspended != suspended { + self.suspended = suspended; + self.set(self.bindings.clone()); + } + } + + /// Hear `bindings` instead of the ones heard now. + fn set(&mut self, bindings: Bindings) { + self.refused.clear(); + let heard = match self.suspended { + true => &Bindings::new(), + false => &bindings, + }; + match &mut self.backend { + Backend::Native { manager, registered } => { + let mut registered = registered.lock().unwrap(); + for (id, action) in registered.drain() { + if let Some(hotkey) = self.bindings.get(&action).and_then(hotkey) + && let Err(err) = manager.unregister(hotkey) + { + tracing::warn!(%id, error = %err, "cannot unregister a hotkey"); + } + } + for (action, key) in heard { + let Some(hotkey) = hotkey(key) else { + let reason = "this key cannot be heard outside the studio".to_string(); + self.refused.insert(action.clone(), reason); + continue; + }; + match manager.register(hotkey) { + Ok(()) => drop(registered.insert(hotkey.id(), action.clone())), + Err(err) => drop(self.refused.insert(action.clone(), refusal(err))), + } + } + } + // Held off, the desktop would be asked to bind them all again + // after each key the panel takes. + #[cfg(target_os = "linux")] + Backend::Portal(portal) => portal.set(bindings.clone()), + Backend::Focused(_) => {} + } + self.bindings = bindings; + } + + /// Why the platform would not hear `action`'s key, if it refused it. + pub fn refused(&self, action: &Action) -> Option<&str> { + self.refused.get(action).map(String::as_str) + } + + pub fn reach(&self) -> Reach { + match &self.backend { + Backend::Native { .. } => Reach::Global, + #[cfg(target_os = "linux")] + Backend::Portal(portal) => portal.reach(), + Backend::Focused(reason) => Reach::Focused(reason.clone()), + } + } + + /// 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. + pub fn focused(&self, ctx: &egui::Context) { + if self.suspended || ctx.text_edit_focused() || !matches!(self.reach(), Reach::Focused(_)) { + return; + } + ctx.input(|input| { + for event in &input.events { + let Some(pressed) = pressed(event) else { continue }; + let bound = self.bindings.iter().find(|(_, key)| pressed == **key); + if let Some((action, _)) = bound { + let _ = self.fire.send(action.clone()); + } + } + }); + } +} + +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 { + #[cfg(target_os = "linux")] + { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + let wayland = window + .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(); + let fire = Mutex::new(fire); + GlobalHotKeyEvent::set_event_handler(Some(move |event: GlobalHotKeyEvent| { + if event.state() != HotKeyState::Pressed { + return; + } + if let Some(action) = heard.lock().unwrap().get(&event.id()) { + let _ = fire.lock().unwrap().send(action.clone()); + } + })); + Self::Native { manager, registered } + } +} + +/// 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, with the modifiers held, ignoring repeats. +pub fn pressed(event: &egui::Event) -> Option { + match event { + egui::Event::Key { + key, + physical_key, + pressed: true, + repeat: false, + modifiers, + } => Some(KeyboardShortcut::new(*modifiers, physical_key.unwrap_or(*key))), + _ => None, + } +} + +/// `key` as the platform's hotkeys name it, if they can hear it: by where it +/// sits on the keyboard, as egui reports it. +fn hotkey(key: &KeyboardShortcut) -> Option { + 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; + } + } + Some(HotKey::new(Some(mods), code(key.logical_key)?)) +} + +/// Where `key` sits on a US keyboard, which is how the platforms name keys. +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, + }) +} diff --git a/rs/moq-studio/src/hotkeys/portal.rs b/rs/moq-studio/src/hotkeys/portal.rs new file mode 100644 index 00000000..989add5e --- /dev/null +++ b/rs/moq-studio/src/hotkeys/portal.rs @@ -0,0 +1,215 @@ +//! Global shortcuts on Wayland, which lets no app hear keys meant for +//! another: the studio asks the desktop's GlobalShortcuts portal for each +//! binding, and the desktop's shortcut settings decide the keys. + +use std::collections::BTreeMap; +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 super::{Bindings, Reach}; +use crate::controls::Action; + +/// A session with the portal, kept by a task of its own. +pub struct Portal { + /// The bindings the task asks the desktop for. + bindings: watch::Sender, + reach: Arc>, +} + +impl Portal { + pub fn new(runtime: &tokio::runtime::Handle, fire: mpsc::Sender) -> Self { + let (bindings, wanted) = watch::channel(Bindings::new()); + let reach = Arc::new(Mutex::new(Reach::Asking)); + let task = reach.clone(); + runtime.spawn(async move { + if let Err(err) = run(wanted, fire, &task).await { + *task.lock().unwrap() = Reach::Focused(format!("{err:#}")); + } + }); + Self { bindings, reach } + } + + pub fn set(&self, bindings: Bindings) { + self.bindings.send_replace(bindings); + } + + pub fn reach(&self) -> Reach { + self.reach.lock().unwrap().clone() + } +} + +/// Ask the desktop for each set of `wanted` bindings in turn, in a session of +/// its own, and fire the actions it reports. +async fn run( + mut wanted: watch::Receiver, + fire: mpsc::Sender, + reach: &Mutex, +) -> anyhow::Result<()> { + let portal = GlobalShortcuts::new() + .await + .context("the desktop has no global shortcuts portal")?; + let mut activated = portal.receive_activated().await?; + loop { + let bindings = wanted.borrow_and_update().clone(); + let session = portal.create_session(Default::default()).await?; + // No session is bound without bindings, so the desktop asks nothing + // until the first key is. + *reach.lock().unwrap() = Reach::Desktop(BTreeMap::new()); + if !bindings.is_empty() { + *reach.lock().unwrap() = Reach::Asking; + let shortcuts: Vec = bindings + .iter() + .map(|(action, key)| { + NewShortcut::new(id(action), action.to_string()).preferred_trigger(trigger(key).as_deref()) + }) + .collect(); + let bound = portal + .bind_shortcuts(&session, &shortcuts, None, Default::default()) + .await? + .response(); + *reach.lock().unwrap() = match bound { + Ok(bound) => Reach::Desktop( + bound + .shortcuts() + .iter() + .filter_map(|shortcut| { + Some((action(shortcut.id())?, shortcut.trigger_description().to_string())) + }) + .collect(), + ), + Err(err) => Reach::Focused(format!("the desktop did not bind them: {err}")), + }; + } + loop { + tokio::select! { + event = activated.next() => { + let event = event.context("the global shortcuts portal went away")?; + // Only this session is open, so every activation is its own. + if let Some(action) = action(event.shortcut_id()) { + let _ = fire.send(action); + } + } + changed = wanted.changed() => { + if changed.is_err() { + return Ok(()); + } + break; + } + } + } + session.close().await?; + } +} + +/// The id the desktop's settings keep `action`'s shortcut under, the same +/// across runs so a key the operator picked there stays. +fn id(action: &Action) -> String { + match action { + Action::Cut => "cut".into(), + Action::Transition => "transition".into(), + Action::Scene(name) => format!("scene/{name}"), + Action::Replay => "replay".into(), + Action::SaveClip => "save-clip".into(), + Action::Record => "record".into(), + } +} + +/// The action a shortcut `id` is kept under. +fn action(id: &str) -> Option { + if let Some(name) = id.strip_prefix("scene/") { + return Some(Action::Scene(name.into())); + } + Action::FIXED.into_iter().find(|action| self::id(action) == id) +} + +/// `shortcut` 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; + let mut trigger = String::new(); + for (on, name) in [ + (held.ctrl, "CTRL+"), + (held.alt, "ALT+"), + (held.shift, "SHIFT+"), + (held.mac_cmd, "LOGO+"), + ] { + if on { + trigger.push_str(name); + } + } + trigger.push_str(&keysym(shortcut.logical_key)?); + 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, + }; + } + }; + Some(name.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_round_trip() { + for action in Action::FIXED.into_iter().chain([Action::Scene("Live/Intro".into())]) { + assert_eq!(super::action(&id(&action)), Some(action)); + } + assert_eq!(super::action("unknown"), None); + } + + #[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); + } +} diff --git a/rs/moq-studio/src/main.rs b/rs/moq-studio/src/main.rs index ca72f407..7f8e7212 100644 --- a/rs/moq-studio/src/main.rs +++ b/rs/moq-studio/src/main.rs @@ -11,11 +11,13 @@ use std::sync::{Arc, OnceLock}; use anyhow::Context; mod autosave; +mod controls; mod devices; mod engine; mod feed; mod guests; mod health; +mod hotkeys; mod interact; mod ladder; mod mix; @@ -241,7 +243,7 @@ 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.storage()); + studio.load(frame); self.app = App::Studio(Box::new(studio)); ui.ctx().request_repaint(); } diff --git a/rs/moq-studio/src/snap.rs b/rs/moq-studio/src/snap.rs index b65e185c..200ca512 100644 --- a/rs/moq-studio/src/snap.rs +++ b/rs/moq-studio/src/snap.rs @@ -122,15 +122,70 @@ impl Grab { /// `rect` with what this grab holds dragged by `delta`. A resized edge /// stops at the opposite one rather than turning the item inside out. - pub fn drag(self, rect: Rect, delta: [f32; 2]) -> Rect { - let mut rect = rect; + /// + /// With an `aspect`, the width over height, a resize keeps that shape: the + /// held edge along one axis leads, as the one held alone or, at a corner, + /// the one dragged further for its size, and the other axis follows. + pub fn drag(self, rect: Rect, delta: [f32; 2], aspect: Option) -> Rect { + let mut dragged = rect; for axis in Axis::ALL { - shift(&mut rect, axis, self.moves(axis), axis.of(delta)); + shift(&mut dragged, axis, self.moves(axis), axis.of(delta)); + } + let lead = match self { + Self::Move | Self::Resize { x: None, y: None } => return dragged, + Self::Resize { x: Some(_), y: None } => Axis::X, + Self::Resize { x: None, y: Some(_) } => Axis::Y, + Self::Resize { .. } => { + // How far each axis grew or shrank, for its size. + let change = |axis: Axis| { + let (start, end) = axis.span(&rect); + let (from, to) = axis.span(&dragged); + ((to - from) - (end - start)).abs() / (end - start).max(1.0) + }; + if change(Axis::X) >= change(Axis::Y) { + Axis::X + } else { + Axis::Y + } + } + }; + match aspect { + Some(aspect) => self.keep_aspect(dragged, aspect, lead), + None => dragged, } + } + + /// `rect` reshaped to `aspect`, the width over height, keeping its span + /// along `lead`. The other axis moves the edge this grab holds on it, or + /// stays centered where it holds none. + fn keep_aspect(self, rect: Rect, aspect: f32, lead: Axis) -> Rect { + let mut rect = rect; + let (start, end) = lead.span(&rect); + let follow = lead.other(); + let length = match lead { + Axis::X => (end - start) / aspect, + Axis::Y => (end - start) * aspect, + }; + let (from, to) = follow.span(&rect); + let span = match self.moves(follow) { + Moves::Edge(Side::Start) => (to - length, to), + Moves::Edge(Side::End) => (from, from + length), + Moves::Both | Moves::Neither => { + let center = (from + to) / 2.0; + (center - length / 2.0, center + length / 2.0) + } + }; + follow.set_span(&mut rect, span); rect } } +/// The width over height of `rect`, if it has a shape to keep. +pub fn aspect(rect: &Rect) -> Option { + let aspect = rect.width / rect.height; + (aspect.is_finite() && aspect > 0.0).then_some(aspect) +} + /// Move what `moves` names of `rect` along `axis` by `by`. fn shift(rect: &mut Rect, axis: Axis, moves: Moves, by: f32) { let (start, end) = axis.span(rect); @@ -173,6 +228,8 @@ pub struct Snap { pub reach: f32, /// The spacing of a grid to snap to, in canvas pixels, if any. pub grid: Option, + /// The width over height a resize keeps, if any. + pub aspect: Option, } /// A dragged item once snapped, and the hints to draw while it is held. @@ -211,29 +268,28 @@ impl Snap { /// the grid. Along each axis, the nearest line within reach of a moving /// edge or center wins, as does the spacing of a neighboring pair when the /// whole item moves. + /// + /// A resize keeping an aspect snaps only the axis whose snap is nearer, + /// and the other follows its shape. pub fn apply(&self, grab: Grab, rect: Rect, others: &[Rect], canvas: &Canvas) -> Snapped { let mut rect = rect; - for axis in Axis::ALL { - let moves = grab.moves(axis); - let points = points(axis.span(&rect), moves); - let mut shifts: Vec = Vec::new(); - for line in lines(axis, others, canvas) { - shifts.extend(points.iter().map(|point| line - point)); - } - if let Some(grid) = self.grid { - shifts.extend(points.iter().map(|point| (point / grid).round() * grid - point)); + match self.aspect.filter(|_| grab != Grab::Move) { + None => { + for axis in Axis::ALL { + if let Some(best) = self.nearest(grab, axis, &rect, others, canvas) { + shift(&mut rect, axis, grab.moves(axis), best); + } + } } - // Spacing places the whole item, so a resize never takes it. An - // alignment wins a tie, since it comes first. - if moves == Moves::Both { - shifts.extend(spacings(axis, &rect, others)); - } - let best = shifts - .into_iter() - .filter(|shift| shift.abs() <= self.reach) - .min_by(|a, b| a.abs().total_cmp(&b.abs())); - if let Some(best) = best { - shift(&mut rect, axis, moves, best); + Some(aspect) => { + let best = Axis::ALL + .into_iter() + .filter_map(|axis| Some((axis, self.nearest(grab, axis, &rect, others, canvas)?))) + .min_by(|(_, a), (_, b)| a.abs().total_cmp(&b.abs())); + if let Some((axis, best)) = best { + shift(&mut rect, axis, grab.moves(axis), best); + rect = grab.keep_aspect(rect, aspect, axis); + } } } @@ -248,6 +304,29 @@ impl Snap { } Snapped { rect, guides, gaps } } + + /// The shortest shift along `axis`, within reach, that snaps what `grab` + /// moves of `rect`. + fn nearest(&self, grab: Grab, axis: Axis, rect: &Rect, others: &[Rect], canvas: &Canvas) -> Option { + let moves = grab.moves(axis); + let points = points(axis.span(rect), moves); + let mut shifts: Vec = Vec::new(); + for line in lines(axis, others, canvas) { + shifts.extend(points.iter().map(|point| line - point)); + } + if let Some(grid) = self.grid { + shifts.extend(points.iter().map(|point| (point / grid).round() * grid - point)); + } + // Spacing places the whole item, so a resize never takes it. An + // alignment wins a tie, since it comes first. + if moves == Moves::Both { + shifts.extend(spacings(axis, rect, others)); + } + shifts + .into_iter() + .filter(|shift| shift.abs() <= self.reach) + .min_by(|a, b| a.abs().total_cmp(&b.abs())) + } } /// The positions along an axis that `moves` carries: every edge and the @@ -411,6 +490,7 @@ mod tests { const SNAP: Snap = Snap { reach: 10.0, grid: None, + aspect: None, }; #[test] @@ -449,12 +529,72 @@ mod tests { x: Some(Side::Start), y: None, }; - assert_eq!(left.drag(item, [500.0, 40.0]), rect(300.0, 100.0, 0.0, 100.0)); + assert_eq!(left.drag(item, [500.0, 40.0], None), rect(300.0, 100.0, 0.0, 100.0)); + let bottom_right = Grab::Resize { + x: Some(Side::End), + y: Some(Side::End), + }; + assert_eq!( + bottom_right.drag(item, [10.0, -20.0], None), + rect(100.0, 100.0, 210.0, 80.0) + ); + } + + #[test] + fn a_locked_resize_keeps_the_shape() { + let item = rect(100.0, 100.0, 200.0, 100.0); + let shape = aspect(&item); + // A corner follows the axis dragged further for its size, here the + // width, and the height grows from the top edge it does not hold. + let bottom_right = Grab::Resize { + x: Some(Side::End), + y: Some(Side::End), + }; + assert_eq!( + bottom_right.drag(item, [100.0, 10.0], shape), + rect(100.0, 100.0, 300.0, 150.0) + ); + let top_left = Grab::Resize { + x: Some(Side::Start), + y: Some(Side::Start), + }; + assert_eq!( + top_left.drag(item, [10.0, 40.0], shape), + rect(180.0, 140.0, 120.0, 60.0) + ); + // An edge alone grows the other axis about its center. + let right = Grab::Resize { + x: Some(Side::End), + y: None, + }; + assert_eq!(right.drag(item, [50.0, 30.0], shape), rect(100.0, 87.5, 250.0, 125.0)); + // Moving keeps the shape anyway. + assert_eq!( + Grab::Move.drag(item, [5.0, 5.0], shape), + rect(105.0, 105.0, 200.0, 100.0) + ); + assert_eq!(aspect(&rect(0.0, 0.0, 10.0, 0.0)), None); + } + + #[test] + fn a_locked_resize_snaps_one_axis_and_keeps_the_shape() { + let snap = Snap { + aspect: Some(2.0), + ..SNAP + }; let bottom_right = Grab::Resize { x: Some(Side::End), y: Some(Side::End), }; - assert_eq!(bottom_right.drag(item, [10.0, -20.0]), rect(100.0, 100.0, 210.0, 80.0)); + // The right edge at 396 is 4 px from the other item's left edge, and + // the bottom at 247 is 3 px from its top, which is nearer and wins. + let others = [rect(400.0, 250.0, 100.0, 50.0)]; + let snapped = snap.apply(bottom_right, rect(100.0, 100.0, 296.0, 147.0), &others, &CANVAS); + assert_eq!(snapped.rect, rect(100.0, 100.0, 300.0, 150.0)); + assert_eq!(snapped.guides.len(), 2, "both edges land on the other item's"); + // With nothing in reach it stays as dragged. + let item = rect(100.0, 100.0, 300.0, 150.0); + assert_eq!(snap.apply(bottom_right, item, &[], &CANVAS).rect, item); } #[test] @@ -521,8 +661,8 @@ mod tests { #[test] fn the_grid() { let snap = Snap { - reach: 10.0, grid: Some(64.0), + ..SNAP }; // The left edge is 6 px from 256, and the top 2 px from 320. let snapped = snap.apply(Grab::Move, rect(250.0, 322.0, 100.0, 100.0), &[], &CANVAS); diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index 188e79c5..6b22a70c 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -1,8 +1,8 @@ //! The window: docked panels over the engine's state. use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; use std::sync::atomic::Ordering; -use std::sync::{Arc, Mutex}; use std::time::Duration; use anyhow::Context; @@ -16,9 +16,11 @@ use moq_scene::{ }; use crate::autosave::Conflict; +use crate::controls::{Action, Controls}; use crate::devices::{self, Listing, Pick}; use crate::engine::{Engine, State}; use crate::health::{self, Verdict}; +use crate::hotkeys::{self, Bindings, Hotkeys, Reach}; use crate::interact::Interact; use crate::replay::{self, Of}; use crate::snap::{self, Grab, Snap}; @@ -41,6 +43,7 @@ enum Tab { Output, Health, Replay, + Hotkeys, } impl Tab { @@ -60,6 +63,7 @@ impl Tab { Self::Output => "Output", Self::Health => "Health", Self::Replay => "Replay", + Self::Hotkeys => "Hotkeys", } } } @@ -99,14 +103,20 @@ impl Studio { Tab::Health, Tab::Audio, Tab::Output, + Tab::Hotkeys, ], ); + let controls = Arc::new(Controls::new(engine.clone(), runtime.clone())); Self { dock, panels: Panels { engine, runtime, + controls, + hotkeys: None, + binding: None, + refusal: None, preview, program, view: None, @@ -132,12 +142,10 @@ impl Studio { new_item: None, add: AddSource::default(), devices: Devices::default(), - record_sources: false, interacting: false, interact: Interact::default(), previewed: false, bound: BTreeMap::new(), - replay: Replay::default(), guest: String::new(), shown: None, message: (dropped > 0).then(|| Err(dropped_message(dropped))), @@ -150,21 +158,36 @@ impl Studio { /// What the canvas picker calls the main canvas. const MAIN: &str = "Main"; -/// Where the output Listen plays on is kept, per user. +/// Where this user's picks are kept, in the app's settings rather than the +/// scene file, which moves between machines without one operator's keys. const SPEAKER: &str = "speaker"; +const SNAPPING: &str = "snapping"; +const HOTKEYS: &str = "hotkeys"; impl Studio { - /// Take up what this user picked last time, from `storage`. - pub fn load(&mut self, storage: Option<&dyn eframe::Storage>) { - if let Some(storage) = storage { - self.panels.speaker = eframe::get_value::>(storage, SPEAKER).flatten(); + /// 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) { + let mut bindings = Bindings::new(); + if let Some(storage) = window.storage() { + let panels = &mut self.panels; + panels.speaker = eframe::get_value::>(storage, SPEAKER).flatten(); + panels.snapping = eframe::get_value(storage, SNAPPING).unwrap_or_default(); + bindings = eframe::get_value(storage, HOTKEYS).unwrap_or_default(); } + let hotkeys = Hotkeys::new(window, self.panels.controls.clone(), &self.panels.runtime, bindings); + self.panels.hotkeys = Some(hotkeys); } } impl eframe::App for Studio { fn save(&mut self, storage: &mut dyn eframe::Storage) { - eframe::set_value(storage, SPEAKER, &self.panels.speaker); + let panels = &self.panels; + eframe::set_value(storage, SPEAKER, &panels.speaker); + eframe::set_value(storage, SNAPPING, &panels.snapping); + if let Some(hotkeys) = &panels.hotkeys { + eframe::set_value(storage, HOTKEYS, hotkeys.bindings()); + } } fn ui(&mut self, ui: &mut Ui, frame: &mut eframe::Frame) { @@ -211,6 +234,14 @@ impl eframe::App for Studio { } drop(state); + if let Some(fired) = self.panels.controls.fired() { + self.panels.message = Some(fired); + } + if let Some(hotkeys) = &self.panels.hotkeys { + hotkeys.focused(ui.ctx()); + } + // 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)); if let Some(conflict) = self.panels.engine.autosave.conflict() { @@ -271,6 +302,15 @@ impl Registered { struct Panels { engine: Arc, runtime: tokio::runtime::Handle, + /// What buttons and hotkeys both do, with the picks they share. + controls: Arc, + /// The bound keys, once the window can register them. + hotkeys: Option, + /// The action the Hotkeys panel waits on a key for. + binding: Option, + /// The last key the Hotkeys panel would not bind, by the action it was + /// pressed for, and why. + refusal: Option<(Action, String)>, preview: Registered, program: Registered, /// The canvas Preview, Program, and Items show: a further one by name, or @@ -320,8 +360,6 @@ struct Panels { new_item: Option, add: AddSource, devices: Devices, - /// Whether the next recording gives each source a file of its own too. - record_sources: bool, /// Whether the preview passes the mouse and keyboard to browser sources. interacting: bool, interact: Interact, @@ -330,7 +368,6 @@ struct Panels { /// The page `interact` holds state for under each name, so one stopped or /// restarted under it, as an edit or a reload does, is forgotten. bound: BTreeMap, - replay: Replay, /// The name typed for the next guest. guest: String, /// The join link on screen. @@ -372,6 +409,7 @@ impl TabViewer for Panels { Tab::Output => self.output(ui), Tab::Health => self.health(ui), Tab::Replay => self.replay(ui), + Tab::Hotkeys => self.hotkeys(ui), } } } @@ -487,6 +525,12 @@ impl Gestures { } (response.dragged() || response.has_focus()).then_some(self.0) } + + /// A gesture of its own. + fn next(&mut self) -> u64 { + self.0 += 1; + self.0 + } } /// Build an edit labelled `label` with `build` and commit it. @@ -837,8 +881,12 @@ impl Panels { } ui.separator(); let free = ui.ctx().format_modifiers(egui::Modifiers::COMMAND); + let flip = ui.ctx().format_modifiers(egui::Modifiers::SHIFT); ui.checkbox(&mut self.snapping.on, "Snap").on_hover_text(format!( - "Snap a dragged item to the canvas, other items, and the grid. Hold {free} to drag freely." + "Snap a dragged item to the canvas, other items, and the grid. Hold {free} to drag freely.\n\ + Resizing keeps the shape of a camera, screen, image, or video, but not of other sources; hold \ + {flip} to flip that. With the preview focused, arrow keys nudge the selected item, ten pixels \ + with {flip}." )); ui.checkbox(&mut self.snapping.grid, "Grid"); let largest = canvas.width.max(canvas.height); @@ -901,6 +949,10 @@ impl Panels { let reach = view.canvas(REACH); let hit = |pos: egui::Pos2| snap::hit(&items, selected, view.point(pos), reach); + // Focused, the preview takes the arrow keys, which nudge. + if response.clicked() || response.drag_started() { + response.request_focus(); + } if response.clicked() { match response.interact_pointer_pos().and_then(hit) { Some((index, _)) => self.selection = Some(Selection::Item(layer.clone(), index)), @@ -912,12 +964,15 @@ impl Panels { && let Some((index, grab)) = hit(origin) { self.selection = Some(Selection::Item(layer.clone(), index)); + let collection = state.collection(); + let source = collection.sources.get(collection.capture(&resting[index].source)); self.drag = Some(Drag { layer: layer.clone(), index, grab, rect: resting[index].rect, origin: view.point(origin), + fixed: fixed_shape(source), gesture, hints: None, }); @@ -945,12 +1000,15 @@ impl Panels { width: rect.width + sign * (shown.width - rest.width), height: rect.height + sign * (shown.height - rest.height), }; - let rect = drag.grab.drag(drag.rect, delta); - let free = ui.input(|input| input.modifiers.command); - drag.hints = (self.snapping.on && !free).then(|| { + let held = ui.input(|input| input.modifiers); + // Shift flips whether a resize keeps the item's shape. + let aspect = (drag.fixed != held.shift).then(|| snap::aspect(&drag.rect)).flatten(); + let rect = drag.grab.drag(drag.rect, delta, aspect); + drag.hints = (self.snapping.on && !held.command).then(|| { let snap = Snap { reach, grid: self.snapping.grid.then_some(self.snapping.spacing as f32), + aspect, }; let others = others(state, &drag.layer, drag.index, since); snap.apply(drag.grab, away(rect, 1.0), &others, &canvas) @@ -969,6 +1027,9 @@ impl Panels { if !response.dragged() { self.drag = None; } + if response.has_focus() && self.drag.is_none() && result.is_ok() { + result = self.nudge(ui, state, &layer, response.id); + } // Drawn from the collection and selection as this frame left them. let items = state.collection().items(&layer).unwrap_or_default(); @@ -1004,6 +1065,72 @@ impl Panels { result } + /// Move the selected item of `layer` a canvas pixel for each arrow key + /// pressed while the preview, `id`, has focus, or ten with Shift held. A + /// held key undoes as one move. + fn nudge(&mut self, ui: &Ui, state: &mut State, layer: &Layer, id: egui::Id) -> anyhow::Result<()> { + let arrows = egui::EventFilter { + horizontal_arrows: true, + vertical_arrows: true, + ..Default::default() + }; + ui.memory_mut(|memory| memory.set_focus_lock_filter(id, arrows)); + let index = match &self.selection { + Some(Selection::Item(selected, index)) if selected == layer => *index, + _ => return Ok(()), + }; + let mut delta = [0.0_f32; 2]; + let mut fresh = false; + ui.input(|input| { + for event in &input.events { + let egui::Event::Key { + key, + pressed: true, + repeat, + modifiers, + .. + } = event + else { + continue; + }; + let step = if modifiers.shift { 10.0 } else { 1.0 }; + let [x, y] = match key { + egui::Key::ArrowLeft => [-step, 0.0], + egui::Key::ArrowRight => [step, 0.0], + egui::Key::ArrowUp => [0.0, -step], + egui::Key::ArrowDown => [0.0, step], + _ => continue, + }; + delta = [delta[0] + x, delta[1] + y]; + fresh |= !repeat; + } + }); + let Some(item) = state + .collection() + .items(layer) + .and_then(|items| items.get(index)) + .cloned() + else { + return Ok(()); + }; + if delta == [0.0; 2] { + return Ok(()); + } + let gesture = match fresh { + true => self.gestures.next(), + false => self.gestures.0, + }; + let rect = Rect { + x: item.rect.x + delta[0], + y: item.rect.y + delta[1], + ..item.rect + }; + let label = format!("Move `{}`", item.source); + edit(state, label, Some(gesture), |draft| { + draft.set_item(layer, index, Item { rect, ..item }) + }) + } + fn program(&mut self, ui: &mut Ui) { let engine = self.engine.clone(); let mut state = engine.state.lock().unwrap(); @@ -1018,19 +1145,17 @@ impl Panels { .map(|(name, _)| name.clone()) .collect(); let mut gesture = None; + let mut fire = None; ui.horizontal(|ui| { if ui.button("Cut").clicked() { - let state = &mut *state; - state.compositor.cut(&state.sources); + fire = Some(Action::Cut); } if ui .button("Transition") .on_hover_text("Take the preview to program by the transition set beside it") .clicked() { - let state = &mut *state; - let now = kio::clock::Instant::now(); - state.compositor.transition(transition.clone(), &state.sources, now); + fire = Some(Action::Transition); } match &next { Transition::Slide { direction, .. } | Transition::Wipe { direction, .. } => self.direction = *direction, @@ -1126,6 +1251,10 @@ impl Panels { }; drop(state); self.check(result); + if let Some(action) = fire { + let result = self.controls.fire(&action).map(drop); + self.check(result); + } picture(ui, self.program.id, canvas, egui::Sense::hover()); } @@ -1235,13 +1364,17 @@ impl Panels { self.check(result); } - /// Keep a selected item of the scene renamed from `from` selected. + /// Keep a selected item of the scene renamed from `from` selected, and a + /// key bound to it bound. fn renamed(&mut self, from: &str, to: &str) { if let Some(Selection::Item(Layer { scene: Some(scene), .. }, _)) = &mut self.selection && scene == from { *scene = to.to_string(); } + if let Some(hotkeys) = &mut self.hotkeys { + hotkeys.rename(from, to); + } } /// Every edit, oldest first; pick one to undo or redo to just after it. @@ -2335,10 +2468,14 @@ impl Panels { } None => { if ui.button("Start recording").clicked() { - let result = recorder.start(self.record_sources); + let result = self.controls.fire(&Action::Record).map(drop); self.check(result); } - ui.checkbox(&mut self.record_sources, "each source too"); + let sources = &self.controls.record_sources; + let mut each = sources.load(Ordering::Relaxed); + if ui.checkbox(&mut each, "each source too").changed() { + sources.store(each, Ordering::Relaxed); + } ui.weak(format!("into {}", recorder.dir().display())); } }); @@ -2503,7 +2640,11 @@ impl Panels { .collect::>(); (replayable, named(|source| matches!(source, Source::Replay))) }; - let panel = &mut self.replay; + let controls = self.controls.clone(); + // Held while drawing, never with the engine's state, which a replay + // locks under it. + let mut guard = controls.replay.lock().unwrap(); + let panel = &mut *guard; if panel.into.as_ref().is_none_or(|into| !replays.contains(into)) { panel.into = replays.first().cloned(); } @@ -2580,36 +2721,132 @@ impl Panels { Err(err) => ui.colored_label(ui.visuals().error_fg_color, err), }; } - if !play && !save { + let action = match (play, save) { + (true, _) => Action::Replay, + (_, true) => Action::SaveClip, + _ => return, + }; + drop(guard); + // How it went shows above, from the next frame. + let _ = controls.fire(&action); + } + + /// Bind the key pressed to the action the Hotkeys panel waits on, or cancel + /// on Escape. + fn take_binding(&mut self, ctx: &egui::Context) { + let (Some(action), Some(hotkeys)) = (self.binding.clone(), &mut self.hotkeys) else { + return; + }; + let pressed = ctx.input_mut(|input| { + let pressed = input.events.iter().find_map(hotkeys::pressed); + if pressed.is_some() { + input.events.retain(|event| hotkeys::pressed(event).is_none()); + } + pressed + }); + let Some(key) = pressed else { return }; + self.binding = None; + hotkeys.suspend(false); + if key.logical_key == egui::Key::Escape { return; } - - // Copied at the click, without waiting on anything, so the span ends - // there and replays apply in the order they were asked for. - let outcome = panel.outcome.clone(); - let into = panel.into.as_ref().filter(|_| play); - let take = replay::take(&self.engine, &panel.of, panel.length, into.is_some()); - let take = match take { - Ok(take) => take, - Err(err) => { - *outcome.lock().unwrap() = Some(Err(format!("{err:#}"))); - return; + self.refusal = match hotkeys.bind(&action, Some(key)) { + Ok(()) => None, + Err(other) => { + let key = ctx.format_shortcut(&key); + Some((action, format!("{key} is bound to {other}; clear it there first"))) } }; - let of = panel.of.clone(); - if let Some(into) = into { - let result = - replay::play(&self.engine, take, panel.speed, into).map(|()| format!("replaying {of} in `{into}`")); - *outcome.lock().unwrap() = Some(result.map_err(|err| format!("{err:#}"))); + } + + /// Bind keys to actions, heard even while another app has focus where the + /// platform lets the studio hear them. + fn hotkeys(&mut self, ui: &mut Ui) { + let Some(hotkeys) = &mut self.hotkeys else { return; - } - let engine = self.engine.clone(); - self.runtime.spawn(async move { - let result = replay::save(take, engine.autosave.path()) - .await - .map(|path| format!("saved {of} to {}", path.display())); - *outcome.lock().unwrap() = Some(result.map_err(|err| format!("{err:#}"))); + }; + let reach = hotkeys.reach(); + match &reach { + Reach::Global => ui.label("Bound keys work even while another app, such as a game, has focus."), + Reach::Desktop(_) => ui.label( + "Bound keys work even while another app has focus. The desktop's shortcut settings decide the \ + keys, shown beside each.", + ), + Reach::Asking => ui.label("Asking the desktop to bind the keys."), + Reach::Focused(why) => ui.colored_label( + ui.visuals().warn_fg_color, + format!("Bound keys work only while the studio has focus: {why}"), + ), + }; + let scenes: Vec = { + let state = self.engine.state.lock().unwrap(); + state + .collection() + .scenes + .iter() + .map(|scene| scene.name.clone()) + .collect() + }; + let mut actions: Vec = Action::FIXED.to_vec(); + actions.extend(scenes.iter().cloned().map(Action::Scene)); + // A key bound to a scene the file no longer has, so it can be cleared. + let gone: Vec = hotkeys + .bindings() + .keys() + .filter(|action| matches!(action, Action::Scene(name) if !scenes.contains(name))) + .cloned() + .collect(); + actions.extend(gone.iter().cloned()); + + let mut bind = None; + let mut clear = None; + egui::Grid::new("hotkeys").striped(true).show(ui, |ui| { + for action in &actions { + ui.label(action.to_string()); + let bound = hotkeys.bindings().get(action); + if self.binding.as_ref() == Some(action) { + ui.strong("press a key, or Escape"); + } else { + match bound { + Some(key) => ui.monospace(ui.ctx().format_shortcut(key)), + None => ui.weak("unbound"), + }; + } + if ui.small_button("Bind").clicked() { + bind = Some(action.clone()); + } + if ui + .add_enabled(bound.is_some(), egui::Button::new("Clear").small()) + .clicked() + { + clear = Some(action.clone()); + } + let refusal = self.refusal.as_ref().filter(|(refused, _)| refused == action); + if let Some((_, why)) = refusal { + ui.colored_label(ui.visuals().error_fg_color, why); + } else if let Some(why) = hotkeys.refused(action) { + ui.colored_label(ui.visuals().error_fg_color, why); + } else if gone.contains(action) { + ui.weak("no such scene"); + } else if let (Reach::Desktop(triggers), Some(_)) = (&reach, bound) { + match triggers.get(action) { + Some(trigger) => ui.label(format!("the desktop bound {trigger}")), + None => ui.weak("the desktop bound no key"), + }; + } + ui.end_row(); + } }); + if let Some(action) = bind { + self.refusal = None; + self.binding = Some(action); + hotkeys.suspend(true); + } + if let Some(action) = clear { + self.refusal = None; + // Unbinding never conflicts. + let _ = hotkeys.bind(&action, None); + } } /// The canvas's size and frame rate, applied at once rather than as each @@ -3414,7 +3651,9 @@ fn picture(ui: &mut Ui, texture: egui::TextureId, canvas: Canvas, sense: egui::S .inner } -/// How the preview snaps a dragged item, for this session. +/// How the preview snaps a dragged item, kept in this user's settings. +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(default)] struct Snapping { on: bool, /// Whether the grid is drawn and snapped to. @@ -3442,6 +3681,9 @@ struct Drag { /// drag started. Each frame drags from these, so snaps never accumulate. rect: Rect, origin: [f32; 2], + /// Whether the item's source draws a picture of its own shape, which a + /// resize keeps unless Shift is held. + fixed: bool, /// Kept from the start, since the frame that releases the drag no longer /// reports it but still moves the item. gesture: Option, @@ -3471,6 +3713,23 @@ fn others(state: &State, layer: &Layer, index: usize, since: Duration) -> Vec) -> bool { + matches!( + source, + Some( + Source::Camera { .. } + | Source::Display { .. } + | Source::Window { .. } + | Source::Image { .. } + | Source::Media { .. } + | Source::Broadcast { .. } + | Source::Replay + ) + ) +} + fn cursor(grab: Grab) -> egui::CursorIcon { use snap::Side::{End, Start}; match grab { @@ -3850,29 +4109,6 @@ impl AddSource { } } -/// The Replay panel's choices, and how the last replay or clip went. -struct Replay { - of: Of, - length: Duration, - speed: f32, - /// The replay source a replay plays in. - into: Option, - /// Set by the replay or save running in the background. - outcome: Arc>>>, -} - -impl Default for Replay { - fn default() -> Self { - Self { - of: Of::Program, - length: replay::LENGTHS[1], - speed: replay::SPEEDS[0], - into: None, - outcome: Arc::default(), - } - } -} - /// The audio outputs Listen can play on, listed once the Audio panel is shown /// and again on Refresh. #[derive(Default)] From 4927056203e9468c4cb683ce696a93f02a8b28bb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 23:18:52 -0800 Subject: [PATCH 03/12] fix(studio): never hold the hotkey map while the platform registers Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- rs/moq-studio/src/hotkeys.rs | 40 ++++++++++++++++++++++++------------ rs/moq-studio/src/ui.rs | 11 +++++----- 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index ad51e590..5a22d1d4 100644 --- a/README.md +++ b/README.md @@ -812,7 +812,8 @@ 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. Bound keys fire even while another app, such as a game in full screen, has -focus, and the app does not see them: +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: - **macOS** and **Windows** register them with the system, which needs no permission. A key another app already registered is refused, saying so. On diff --git a/rs/moq-studio/src/hotkeys.rs b/rs/moq-studio/src/hotkeys.rs index b875cf07..2b60f734 100644 --- a/rs/moq-studio/src/hotkeys.rs +++ b/rs/moq-studio/src/hotkeys.rs @@ -36,7 +36,10 @@ enum Backend { /// `RegisterHotKey` on Windows, and a key grab on X11. Native { manager: GlobalHotKeyManager, - /// The action each registered hotkey fires, by its id. + /// The hotkeys registered now. + held: Vec, + /// The action each registered hotkey fires, by its id, which the + /// platform's event handler reads. registered: Arc>>, }, /// The desktop's GlobalShortcuts portal, on Wayland. @@ -147,15 +150,19 @@ impl Hotkeys { false => &bindings, }; match &mut self.backend { - Backend::Native { manager, registered } => { - let mut registered = registered.lock().unwrap(); - for (id, action) in registered.drain() { - if let Some(hotkey) = self.bindings.get(&action).and_then(hotkey) - && let Err(err) = manager.unregister(hotkey) - { - tracing::warn!(%id, error = %err, "cannot unregister a hotkey"); + Backend::Native { + manager, + held, + registered, + } => { + // `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 (action, key) in heard { let Some(hotkey) = hotkey(key) else { let reason = "this key cannot be heard outside the studio".to_string(); @@ -163,10 +170,14 @@ impl Hotkeys { continue; }; match manager.register(hotkey) { - Ok(()) => drop(registered.insert(hotkey.id(), action.clone())), + Ok(()) => { + held.push(hotkey); + ids.insert(hotkey.id(), action.clone()); + } Err(err) => drop(self.refused.insert(action.clone(), refusal(err))), } } + *registered.lock().unwrap() = ids; } // Held off, the desktop would be asked to bind them all again // after each key the panel takes. @@ -193,7 +204,7 @@ 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. + /// keys, as the caller makes a page the preview passes the keyboard to. pub fn focused(&self, ctx: &egui::Context) { if self.suspended || ctx.text_edit_focused() || !matches!(self.reach(), Reach::Focused(_)) { return; @@ -231,16 +242,19 @@ impl Backend { }; let registered: Arc>> = Arc::default(); let heard = registered.clone(); - let fire = Mutex::new(fire); GlobalHotKeyEvent::set_event_handler(Some(move |event: GlobalHotKeyEvent| { if event.state() != HotKeyState::Pressed { return; } if let Some(action) = heard.lock().unwrap().get(&event.id()) { - let _ = fire.lock().unwrap().send(action.clone()); + let _ = fire.send(action.clone()); } })); - Self::Native { manager, registered } + Self::Native { + manager, + held: Vec::new(), + registered, + } } } diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index 6b22a70c..f80b83ea 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -237,7 +237,9 @@ impl eframe::App for Studio { if let Some(fired) = self.panels.controls.fired() { self.panels.message = Some(fired); } - if let Some(hotkeys) = &self.panels.hotkeys { + if let Some(hotkeys) = &self.panels.hotkeys + && !self.panels.interact.has_keyboard() + { hotkeys.focused(ui.ctx()); } // Before the shortcuts, so the key pressed binds rather than undoes. @@ -2467,14 +2469,13 @@ impl Panels { } } None => { + let mut each = self.controls.record_sources.load(Ordering::Relaxed); if ui.button("Start recording").clicked() { - let result = self.controls.fire(&Action::Record).map(drop); + let result = recorder.start(each); self.check(result); } - let sources = &self.controls.record_sources; - let mut each = sources.load(Ordering::Relaxed); if ui.checkbox(&mut each, "each source too").changed() { - sources.store(each, Ordering::Relaxed); + self.controls.record_sources.store(each, Ordering::Relaxed); } ui.weak(format!("into {}", recorder.dir().display())); } From 0d6c3c6c41e813a8be6d629b223c6479f23bb8c4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 23:35:22 -0800 Subject: [PATCH 04/12] fix(studio): hotkeys follow every scene rename and never fire while a key is being bound Co-Authored-By: Claude Opus 5.5 --- rs/moq-studio/src/engine.rs | 13 +++++ rs/moq-studio/src/hotkeys.rs | 93 ++++++++++++++++++++++++++++-------- rs/moq-studio/src/ui.rs | 15 +++--- 3 files changed, 96 insertions(+), 25 deletions(-) diff --git a/rs/moq-studio/src/engine.rs b/rs/moq-studio/src/engine.rs index fc42fb78..0ec058e9 100644 --- a/rs/moq-studio/src/engine.rs +++ b/rs/moq-studio/src/engine.rs @@ -99,6 +99,11 @@ pub struct State { stale: bool, /// For the autosave, which writes the collection once edits settle. edits: autosave::Edits, + /// What became of each scene the edits, undos, and redos since the window + /// last took them renamed or removed, one map per edit as + /// [`Collection::renames`] reads it, so what the window follows a scene by + /// name with, such as a hotkey, follows it too. + renamed: Vec>>, } impl State { @@ -201,9 +206,16 @@ impl State { } self.compositor.retain(&collection); self.collection = collection; + self.renamed + .extend(renames.into_iter().filter(|renames| !renames.is_empty())); Ok(()) } + /// The scene renames and removals since the last call, oldest first. + pub fn take_renames(&mut self) -> Vec>> { + std::mem::take(&mut self.renamed) + } + /// The sources whose pictures any canvas's program draws, through clones: /// those [`placed`](Self::placed) there, and the clip a stinger under way /// plays with each mask its source's filters read. @@ -786,6 +798,7 @@ impl Engine { compositor, stale: true, edits: autosave.edits(), + renamed: Vec::new(), }), autosave, stats, diff --git a/rs/moq-studio/src/hotkeys.rs b/rs/moq-studio/src/hotkeys.rs index 2b60f734..85922095 100644 --- a/rs/moq-studio/src/hotkeys.rs +++ b/rs/moq-studio/src/hotkeys.rs @@ -4,6 +4,7 @@ //! Where neither is there, they work only while the studio has focus. use std::collections::{BTreeMap, HashMap}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, mpsc}; use egui::{Key, KeyboardShortcut}; @@ -25,8 +26,9 @@ pub struct Hotkeys { backend: Backend, /// Why the platform refused each binding it would not register. refused: BTreeMap, - /// Whether bindings are held off while the Hotkeys panel waits for a key. - suspended: bool, + /// Whether bindings are held off while the Hotkeys panel waits for a key, + /// which the thread firing them reads too. + suspended: Arc, fire: mpsc::Sender, } @@ -82,8 +84,15 @@ impl Hotkeys { bindings: Bindings, ) -> Self { let (fire, fired) = mpsc::channel::(); + let suspended = Arc::new(AtomicBool::new(false)); + let held_off = suspended.clone(); std::thread::spawn(move || { for action in fired { + // A key the desktop holds on Wayland still fires while the panel + // waits for one, and the panel never sees it. + if held_off.load(Ordering::Relaxed) { + continue; + } let result = controls.fire(&action); controls.report(&action, result); } @@ -93,7 +102,7 @@ impl Hotkeys { bindings: Bindings::new(), backend, refused: BTreeMap::new(), - suspended: false, + suspended, fire, }; hotkeys.set(bindings); @@ -124,11 +133,12 @@ impl Hotkeys { Ok(()) } - /// Move a binding to a scene renamed from `from` to `to`. - pub fn rename(&mut self, from: &str, to: &str) { - let mut bindings = self.bindings.clone(); - if let Some(key) = bindings.remove(&Action::Scene(from.into())) { - bindings.insert(Action::Scene(to.into()), key); + /// Move each binding to a scene `renames` renamed, all at once, so a swap + /// of two names swaps their keys. A removed scene keeps its key, which an + /// undo may bring back. + pub fn rename(&mut self, renames: &BTreeMap>) { + let bindings = renamed(&self.bindings, renames); + if bindings != self.bindings { self.set(bindings); } } @@ -136,8 +146,7 @@ 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) { - if self.suspended != suspended { - self.suspended = suspended; + if self.suspended.swap(suspended, Ordering::Relaxed) != suspended { self.set(self.bindings.clone()); } } @@ -145,7 +154,7 @@ impl Hotkeys { /// Hear `bindings` instead of the ones heard now. fn set(&mut self, bindings: Bindings) { self.refused.clear(); - let heard = match self.suspended { + let heard = match self.suspended.load(Ordering::Relaxed) { true => &Bindings::new(), false => &bindings, }; @@ -205,18 +214,23 @@ 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) { - if self.suspended || ctx.text_edit_focused() || !matches!(self.reach(), Reach::Focused(_)) { + let suspended = self.suspended.load(Ordering::Relaxed); + if suspended || ctx.text_edit_focused() || !matches!(self.reach(), Reach::Focused(_)) { return; } - ctx.input(|input| { - for event in &input.events { - let Some(pressed) = pressed(event) else { continue }; - let bound = self.bindings.iter().find(|(_, key)| pressed == **key); - if let Some((action, _)) = bound { - let _ = self.fire.send(action.clone()); + ctx.input_mut(|input| { + input.events.retain(|event| { + let bound = pressed(event).and_then(|pressed| self.bindings.iter().find(|(_, key)| pressed == **key)); + match bound { + Some((action, _)) => { + let _ = self.fire.send(action.clone()); + false + } + None => true, } - } + }); }); } } @@ -258,6 +272,20 @@ impl Backend { } } +/// `bindings` with each scene `renames` renamed bound by its new name. +fn renamed(bindings: &Bindings, renames: &BTreeMap>) -> Bindings { + bindings + .iter() + .map(|(action, key)| match action { + Action::Scene(name) => match renames.get(name) { + Some(Some(to)) => (Action::Scene(to.clone()), *key), + _ => (action.clone(), *key), + }, + action => (action.clone(), *key), + }) + .collect() +} + /// What the platform said when it refused a key. fn refusal(err: global_hotkey::Error) -> String { match err { @@ -390,3 +418,30 @@ fn code(key: Key) -> Option { _ => return None, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bindings_follow_renames_at_once() { + let key = |key| KeyboardShortcut::new(egui::Modifiers::NONE, key); + let scene = |name: &str| Action::Scene(name.into()); + let bindings = Bindings::from([ + (scene("A"), key(Key::F1)), + (scene("B"), key(Key::F2)), + (Action::Cut, key(Key::F3)), + ]); + // Swapping two names swaps their keys, and a removed scene keeps its. + let renames = BTreeMap::from([ + ("A".to_string(), Some("B".to_string())), + ("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)); + let removed = BTreeMap::from([("A".to_string(), None)]); + assert_eq!(renamed(&bindings, &removed), bindings); + } +} diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index f80b83ea..7a598766 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -198,7 +198,7 @@ impl eframe::App for Studio { self.panels.view = view; self.panels.deselect_item(); } - let state = engine.state.lock().unwrap(); + let mut state = engine.state.lock().unwrap(); // One removed since, by an edit, undo, or reload, falls back to the main // canvas. if let Some(name) = &self.panels.view @@ -220,6 +220,7 @@ impl eframe::App for Studio { let framerate = state.compositor.canvas().framerate; ui.ctx().request_repaint_after(Duration::from_secs(1) / framerate); self.panels.follow_reloads(&state); + let renames = state.take_renames(); // A page not painting through an X display copies through the // studio's clipboard, even if the preview let go of the page before // the copy came back. @@ -234,6 +235,12 @@ impl eframe::App for Studio { } drop(state); + // A key bound to a scene follows it through renames, undos, and redos. + if let Some(hotkeys) = &mut self.panels.hotkeys { + for renames in &renames { + hotkeys.rename(renames); + } + } if let Some(fired) = self.panels.controls.fired() { self.panels.message = Some(fired); } @@ -1366,17 +1373,13 @@ impl Panels { self.check(result); } - /// Keep a selected item of the scene renamed from `from` selected, and a - /// key bound to it bound. + /// Keep a selected item of the scene renamed from `from` selected. fn renamed(&mut self, from: &str, to: &str) { if let Some(Selection::Item(Layer { scene: Some(scene), .. }, _)) = &mut self.selection && scene == from { *scene = to.to_string(); } - if let Some(hotkeys) = &mut self.hotkeys { - hotkeys.rename(from, to); - } } /// Every edit, oldest first; pick one to undo or redo to just after it. From 217574266fd2110e5f2133604f05e04cf34f8d0f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 23:47:00 -0800 Subject: [PATCH 05/12] fix(studio): a held focused hotkey fires once, and a renamed scene keeps its key Co-Authored-By: Claude Opus 5.5 --- rs/moq-studio/src/hotkeys.rs | 59 +++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/rs/moq-studio/src/hotkeys.rs b/rs/moq-studio/src/hotkeys.rs index 85922095..68672a88 100644 --- a/rs/moq-studio/src/hotkeys.rs +++ b/rs/moq-studio/src/hotkeys.rs @@ -222,14 +222,25 @@ impl Hotkeys { } ctx.input_mut(|input| { input.events.retain(|event| { - let bound = pressed(event).and_then(|pressed| self.bindings.iter().find(|(_, key)| pressed == **key)); - match bound { - Some((action, _)) => { - let _ = self.fire.send(action.clone()); - false - } - None => true, + let egui::Event::Key { + key, + physical_key, + pressed: true, + repeat, + modifiers, + } = event + else { + return true; + }; + let pressed = KeyboardShortcut::new(*modifiers, physical_key.unwrap_or(*key)); + let Some((action, _)) = self.bindings.iter().find(|(_, key)| pressed == **key) else { + return true; + }; + // A held key fires once, and its repeats are taken too. + if !repeat { + let _ = self.fire.send(action.clone()); } + false }); }); } @@ -272,18 +283,25 @@ impl Backend { } } -/// `bindings` with each scene `renames` renamed bound by its new name. +/// `bindings` with each scene `renames` renamed bound by its new name. A +/// renamed scene's key wins over one kept for a removed scene of that name. fn renamed(bindings: &Bindings, renames: &BTreeMap>) -> Bindings { - bindings - .iter() - .map(|(action, key)| match action { - Action::Scene(name) => match renames.get(name) { - Some(Some(to)) => (Action::Scene(to.clone()), *key), - _ => (action.clone(), *key), - }, - action => (action.clone(), *key), - }) - .collect() + let mut renamed = Bindings::new(); + let mut kept = Vec::new(); + for (action, key) in bindings { + let to = match action { + Action::Scene(name) => renames.get(name).cloned().flatten(), + _ => None, + }; + match to { + Some(to) => drop(renamed.insert(Action::Scene(to), *key)), + None => kept.push((action.clone(), *key)), + } + } + for (action, key) in kept { + renamed.entry(action).or_insert(key); + } + renamed } /// What the platform said when it refused a key. @@ -443,5 +461,10 @@ mod tests { assert_eq!(swapped[&Action::Cut], key(Key::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!(!moved.contains_key(&scene("A"))); } } From 0d34486a8437c38b81be95c90d0d648964bc2a1e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 00:46:39 -0800 Subject: [PATCH 06/12] fix(studio): keys bind as each platform hears them, the portal keeps its session, and a posed resize keeps its shape at rest Co-Authored-By: Claude Opus 5.5 --- rs/moq-studio/src/hotkeys.rs | 36 ++++++++++++++--- rs/moq-studio/src/hotkeys/portal.rs | 24 ++++++++++- rs/moq-studio/src/snap.rs | 63 ++++++++++++++++++++++++----- rs/moq-studio/src/ui.rs | 23 +++++------ 4 files changed, 117 insertions(+), 29 deletions(-) diff --git a/rs/moq-studio/src/hotkeys.rs b/rs/moq-studio/src/hotkeys.rs index 68672a88..08613a77 100644 --- a/rs/moq-studio/src/hotkeys.rs +++ b/rs/moq-studio/src/hotkeys.rs @@ -232,7 +232,7 @@ impl Hotkeys { else { return true; }; - let pressed = KeyboardShortcut::new(*modifiers, physical_key.unwrap_or(*key)); + let pressed = KeyboardShortcut::new(*modifiers, heard(*key, *physical_key)); let Some((action, _)) = self.bindings.iter().find(|(_, key)| pressed == **key) else { return true; }; @@ -321,13 +321,25 @@ pub fn pressed(event: &egui::Event) -> Option { pressed: true, repeat: false, modifiers, - } => Some(KeyboardShortcut::new(*modifiers, physical_key.unwrap_or(*key))), + } => Some(KeyboardShortcut::new(*modifiers, heard(*key, *physical_key))), _ => None, } } -/// `key` as the platform's hotkeys name it, if they can hear it: by where it -/// sits on the keyboard, as egui reports it. +/// The key the platform's hotkeys hear for a press of `key`, as the layout +/// names it, at `physical`, where it sits on a US keyboard. macOS registers +/// a key by where it sits. Windows' virtual keys and the keysyms X11 and the +/// Wayland portal bind name what the layout prints on it, so a key only +/// Shift makes, such as `!`, is named by where it sits instead. +fn heard(key: Key, physical: Option) -> Key { + let physical = physical.unwrap_or(key); + match cfg!(target_os = "macos") || code(key).is_none() { + true => physical, + false => key, + } +} + +/// `key` as the platform's hotkeys name it, if they can hear it. fn hotkey(key: &KeyboardShortcut) -> Option { let held = key.modifiers; let mut mods = Modifiers::empty(); @@ -344,7 +356,8 @@ fn hotkey(key: &KeyboardShortcut) -> Option { Some(HotKey::new(Some(mods), code(key.logical_key)?)) } -/// Where `key` sits on a US keyboard, which is how the platforms name keys. +/// `key` as the platforms name it: where it sits on a US keyboard, which +/// Windows and X11 take for the key the layout prints that on. fn code(key: Key) -> Option { Some(match key { Key::ArrowDown => Code::ArrowDown, @@ -441,6 +454,19 @@ fn code(key: Key) -> Option { mod tests { use super::*; + #[test] + fn keys_bind_as_the_platform_hears_them() { + // On AZERTY, the key printed A sits where a US keyboard has Q. + let a = heard(Key::A, Some(Key::Q)); + match cfg!(target_os = "macos") { + true => assert_eq!(a, Key::Q), + false => assert_eq!(a, Key::A), + } + // Shift and the key printed 1 make `!`, which no platform binds. + assert_eq!(heard(Key::Exclamationmark, Some(Key::Num1)), Key::Num1); + assert_eq!(heard(Key::F9, None), Key::F9); + } + #[test] fn bindings_follow_renames_at_once() { let key = |key| KeyboardShortcut::new(egui::Modifiers::NONE, key); diff --git a/rs/moq-studio/src/hotkeys/portal.rs b/rs/moq-studio/src/hotkeys/portal.rs index 989add5e..5c246475 100644 --- a/rs/moq-studio/src/hotkeys/portal.rs +++ b/rs/moq-studio/src/hotkeys/portal.rs @@ -34,8 +34,14 @@ impl Portal { Self { bindings, reach } } + /// Ask the desktop for `bindings`, unless it already holds them: a new + /// session brings its dialog back, and declining it loses every key. pub fn set(&self, bindings: Bindings) { - self.bindings.send_replace(bindings); + self.bindings.send_if_modified(|wanted| { + let changed = *wanted != bindings; + *wanted = bindings; + changed + }); } pub fn reach(&self) -> Reach { @@ -194,6 +200,22 @@ fn keysym(key: Key) -> Option { mod tests { use super::*; + #[test] + fn unchanged_bindings_keep_the_session() { + let (bindings, mut wanted) = watch::channel(Bindings::new()); + let portal = Portal { + bindings, + reach: Arc::new(Mutex::new(Reach::Asking)), + }; + let key = KeyboardShortcut::new(egui::Modifiers::CTRL, Key::R); + portal.set(Bindings::from([(Action::Cut, key)])); + assert!(wanted.has_changed().unwrap()); + wanted.mark_unchanged(); + // What the Hotkeys panel sets when it starts and stops waiting for a key. + portal.set(Bindings::from([(Action::Cut, key)])); + assert!(!wanted.has_changed().unwrap()); + } + #[test] fn ids_round_trip() { for action in Action::FIXED.into_iter().chain([Action::Scene("Live/Intro".into())]) { diff --git a/rs/moq-studio/src/snap.rs b/rs/moq-studio/src/snap.rs index 200ca512..b21e0cd6 100644 --- a/rs/moq-studio/src/snap.rs +++ b/rs/moq-studio/src/snap.rs @@ -228,8 +228,11 @@ pub struct Snap { pub reach: f32, /// The spacing of a grid to snap to, in canvas pixels, if any. pub grid: Option, - /// The width over height a resize keeps, if any. + /// The width over height a resize keeps, if any, of the item at rest. pub aspect: Option, + /// How far keyframes have the item from where it rests, added to each + /// field of its rect. It snaps where it shows, but keeps its shape at rest. + pub posed: Rect, } /// A dragged item once snapped, and the hints to draw while it is held. @@ -264,19 +267,27 @@ pub struct Gap { } impl Snap { - /// Snap `rect`, which `grab` has dragged, to the canvas, the `others`, and - /// the grid. Along each axis, the nearest line within reach of a moving - /// edge or center wins, as does the spacing of a neighboring pair when the - /// whole item moves. + /// Snap `rect`, the item at rest once `grab` has dragged it, to the + /// canvas, the `others`, and the grid, where it shows. Along each axis, + /// the nearest line within reach of a moving edge or center wins, as does + /// the spacing of a neighboring pair when the whole item moves. /// /// A resize keeping an aspect snaps only the axis whose snap is nearer, - /// and the other follows its shape. + /// and the other follows its shape. The snapped rect is at rest. pub fn apply(&self, grab: Grab, rect: Rect, others: &[Rect], canvas: &Canvas) -> Snapped { + // An edge shown is the edge at rest plus a constant, so a shift + // measured where it shows moves it as far at rest. + let shown = |rect: &Rect| Rect { + x: rect.x + self.posed.x, + y: rect.y + self.posed.y, + width: rect.width + self.posed.width, + height: rect.height + self.posed.height, + }; let mut rect = rect; match self.aspect.filter(|_| grab != Grab::Move) { None => { for axis in Axis::ALL { - if let Some(best) = self.nearest(grab, axis, &rect, others, canvas) { + if let Some(best) = self.nearest(grab, axis, &shown(&rect), others, canvas) { shift(&mut rect, axis, grab.moves(axis), best); } } @@ -284,7 +295,7 @@ impl Snap { Some(aspect) => { let best = Axis::ALL .into_iter() - .filter_map(|axis| Some((axis, self.nearest(grab, axis, &rect, others, canvas)?))) + .filter_map(|axis| Some((axis, self.nearest(grab, axis, &shown(&rect), others, canvas)?))) .min_by(|(_, a), (_, b)| a.abs().total_cmp(&b.abs())); if let Some((axis, best)) = best { shift(&mut rect, axis, grab.moves(axis), best); @@ -295,11 +306,12 @@ impl Snap { let mut guides = Vec::new(); let mut gaps = Vec::new(); + let showing = shown(&rect); for axis in Axis::ALL { let moves = grab.moves(axis); - guides.extend(lined_up(axis, &rect, &points(axis.span(&rect), moves), others, canvas)); + guides.extend(lined_up(axis, &showing, &points(axis.span(&showing), moves), others, canvas)); if moves == Moves::Both { - gaps.extend(equal_gaps(axis, &rect, others)); + gaps.extend(equal_gaps(axis, &showing, others)); } } Snapped { rect, guides, gaps } @@ -483,7 +495,7 @@ mod tests { framerate: 30, }; - fn rect(x: f32, y: f32, width: f32, height: f32) -> Rect { + const fn rect(x: f32, y: f32, width: f32, height: f32) -> Rect { Rect { x, y, width, height } } @@ -491,6 +503,7 @@ mod tests { reach: 10.0, grid: None, aspect: None, + posed: rect(0.0, 0.0, 0.0, 0.0), }; #[test] @@ -597,6 +610,34 @@ mod tests { assert_eq!(snap.apply(bottom_right, item, &[], &CANVAS).rect, item); } + #[test] + fn a_posed_item_snaps_where_it_shows_and_keeps_its_shape_at_rest() { + // Resting at 200x100, with a keyframe adding 100 to the height. + let snap = Snap { + aspect: Some(2.0), + posed: rect(0.0, 0.0, 0.0, 100.0), + ..SNAP + }; + let right = Grab::Resize { + x: Some(Side::End), + y: None, + }; + let dragged = right.drag(rect(100.0, 100.0, 200.0, 100.0), [20.0, 0.0], snap.aspect); + assert_eq!(dragged, rect(100.0, 95.0, 220.0, 110.0)); + // Its right edge already lies on the other item's left edge. + let others = [rect(320.0, 600.0, 100.0, 100.0)]; + let snapped = snap.apply(right, dragged, &others, &CANVAS); + assert_eq!(snapped.rect, dragged); + assert_eq!(snapped.guides.len(), 1); + // Offset where it shows, it snaps there and keeps its shape at rest. + let snap = Snap { + posed: rect(4.0, 0.0, 0.0, 100.0), + ..snap + }; + let snapped = snap.apply(right, dragged, &others, &CANVAS); + assert_eq!(snapped.rect, rect(100.0, 96.0, 216.0, 108.0)); + } + #[test] fn moves_snap_to_the_canvas_center_and_edges() { // Its center is 3 px right of the canvas's and its top 4 px below it. diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index 7a598766..f073c8c6 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -999,30 +999,29 @@ impl Panels { .items(&drag.layer) .and_then(|items| items.get(drag.index)) .cloned(); - // How far the keyframes have the item from where it rests, which - // snapping, done where the item shows, leaves alone. - let shown = item.as_ref().map_or(drag.rect, |item| item.posed(since).rect); - let rest = item.as_ref().map_or(drag.rect, |item| item.rect); - let away = |rect: Rect, sign: f32| Rect { - x: rect.x + sign * (shown.x - rest.x), - y: rect.y + sign * (shown.y - rest.y), - width: rect.width + sign * (shown.width - rest.width), - height: rect.height + sign * (shown.height - rest.height), - }; let held = ui.input(|input| input.modifiers); // Shift flips whether a resize keeps the item's shape. let aspect = (drag.fixed != held.shift).then(|| snap::aspect(&drag.rect)).flatten(); let rect = drag.grab.drag(drag.rect, delta, aspect); drag.hints = (self.snapping.on && !held.command).then(|| { + // How far the keyframes have the item from where it rests. + let shown = item.as_ref().map_or(drag.rect, |item| item.posed(since).rect); + let rest = item.as_ref().map_or(drag.rect, |item| item.rect); let snap = Snap { reach, grid: self.snapping.grid.then_some(self.snapping.spacing as f32), aspect, + posed: Rect { + x: shown.x - rest.x, + y: shown.y - rest.y, + width: shown.width - rest.width, + height: shown.height - rest.height, + }, }; let others = others(state, &drag.layer, drag.index, since); - snap.apply(drag.grab, away(rect, 1.0), &others, &canvas) + snap.apply(drag.grab, rect, &others, &canvas) }); - let rect = drag.hints.as_ref().map_or(rect, |snapped| away(snapped.rect, -1.0)); + let rect = drag.hints.as_ref().map_or(rect, |snapped| snapped.rect); if let Some(item) = item && item.rect != rect { From 51868fb9cd35a3c093b0a1ac43f24d85e31b9077 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 00:58:02 -0800 Subject: [PATCH 07/12] style: cargo fmt Co-Authored-By: Claude Opus 5.5 --- rs/moq-studio/src/snap.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/rs/moq-studio/src/snap.rs b/rs/moq-studio/src/snap.rs index b21e0cd6..c1b08c8a 100644 --- a/rs/moq-studio/src/snap.rs +++ b/rs/moq-studio/src/snap.rs @@ -309,7 +309,13 @@ impl Snap { let showing = shown(&rect); for axis in Axis::ALL { let moves = grab.moves(axis); - guides.extend(lined_up(axis, &showing, &points(axis.span(&showing), moves), others, canvas)); + guides.extend(lined_up( + axis, + &showing, + &points(axis.span(&showing), moves), + others, + canvas, + )); if moves == Moves::Both { gaps.extend(equal_gaps(axis, &showing, others)); } From 5e2c99a5365fe6c59a03d911be13f34d90917b45 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 07:48:31 -0800 Subject: [PATCH 08/12] fix(studio): hotkeys register the key pressed on every layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A binding keeps the key as the layout names it, for the panel and the Wayland portal, and where it sits, which macOS registers directly, Windows through MapVirtualKeyExW, and X11 through the server's keymap. German Shift+7 now registers Shift+VK_7 instead of VK_OEM_2 (#), and a key X11 cannot name, such as ö, is refused instead of grabbing another. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 + README.md | 5 + rs/moq-studio/Cargo.toml | 8 + rs/moq-studio/src/hotkeys.rs | 229 ++++++--------- rs/moq-studio/src/hotkeys/layout.rs | 438 ++++++++++++++++++++++++++++ rs/moq-studio/src/hotkeys/portal.rs | 10 +- rs/moq-studio/src/ui.rs | 6 +- 7 files changed, 546 insertions(+), 152 deletions(-) create mode 100644 rs/moq-studio/src/hotkeys/layout.rs diff --git a/Cargo.lock b/Cargo.lock index 05b43f0f..a63e3034 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4320,6 +4320,8 @@ dependencies = [ "tracing", "url", "usage-rs", + "windows-sys 0.61.2", + "x11rb 0.13.2", ] [[package]] diff --git a/README.md b/README.md index 318575ed..9652600e 100644 --- a/README.md +++ b/README.md @@ -827,6 +827,11 @@ starts unbound, so no key a game uses is 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. + 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: diff --git a/rs/moq-studio/Cargo.toml b/rs/moq-studio/Cargo.toml index 40beec96..3f789c43 100644 --- a/rs/moq-studio/Cargo.toml +++ b/rs/moq-studio/Cargo.toml @@ -91,6 +91,14 @@ ashpd = { version = "0.13", default-features = false, features = ["global_shortc futures-util = "0.3" # 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" + +[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"] } [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 08613a77..26bd27b4 100644 --- a/rs/moq-studio/src/hotkeys.rs +++ b/rs/moq-studio/src/hotkeys.rs @@ -8,17 +8,39 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, mpsc}; use egui::{Key, KeyboardShortcut}; -use global_hotkey::hotkey::{Code, HotKey, Modifiers}; +use global_hotkey::hotkey::{HotKey, Modifiers}; use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; use crate::controls::{Action, Controls}; +mod layout; #[cfg(target_os = "linux")] mod portal; +use layout::Layout; + /// 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; +pub type Bindings = BTreeMap; + +/// A key bound to an action. +#[derive(Clone, Copy, 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, +} + +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 + } +} /// The bound keys, heard wherever the platform lets the studio hear them. pub struct Hotkeys { @@ -115,12 +137,12 @@ impl Hotkeys { /// Bind `action` to `key`, or unbind it, unless `key` is bound to another /// action, which is returned instead. - pub fn bind(&mut self, action: &Action, key: Option) -> Result<(), Action> { + pub fn bind(&mut self, action: &Action, key: Option) -> Result<(), Action> { if let Some(key) = key && let Some((other, _)) = self .bindings .iter() - .find(|(other, bound)| **bound == key && *other != action) + .find(|(other, bound)| bound.same(&key) && *other != action) { return Err(other.clone()); } @@ -172,11 +194,20 @@ impl Hotkeys { } } let mut ids = HashMap::new(); + let layout = Layout::current(); for (action, key) in heard { - let Some(hotkey) = hotkey(key) else { - let reason = "this key cannot be heard outside the studio".to_string(); - self.refused.insert(action.clone(), reason); - continue; + 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.insert(action.clone(), reason); + continue; + } }; match manager.register(hotkey) { Ok(()) => { @@ -222,22 +253,14 @@ impl Hotkeys { } ctx.input_mut(|input| { input.events.retain(|event| { - let egui::Event::Key { - key, - physical_key, - pressed: true, - repeat, - modifiers, - } = event - else { + let Some(pressed) = binding(event) else { return true; }; - let pressed = KeyboardShortcut::new(*modifiers, heard(*key, *physical_key)); - let Some((action, _)) = self.bindings.iter().find(|(_, key)| pressed == **key) else { + let Some((action, _)) = self.bindings.iter().find(|(_, key)| key.same(&pressed)) else { return true; }; // A held key fires once, and its repeats are taken too. - if !repeat { + if !matches!(event, egui::Event::Key { repeat: true, .. }) { let _ = self.fire.send(action.clone()); } false @@ -312,36 +335,34 @@ fn refusal(err: global_hotkey::Error) -> String { } } -/// The key `event` presses, with the modifiers held, ignoring repeats. -pub fn pressed(event: &egui::Event) -> Option { +/// The key `event` presses, ignoring repeats. +pub fn pressed(event: &egui::Event) -> Option { + match event { + egui::Event::Key { repeat: false, .. } => binding(event), + _ => None, + } +} + +/// 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, + physical_key: Some(position), pressed: true, - repeat: false, modifiers, - } => Some(KeyboardShortcut::new(*modifiers, heard(*key, *physical_key))), + .. + } => Some(Binding { + key: KeyboardShortcut::new(*modifiers, *key), + position: *position, + }), _ => None, } } -/// The key the platform's hotkeys hear for a press of `key`, as the layout -/// names it, at `physical`, where it sits on a US keyboard. macOS registers -/// a key by where it sits. Windows' virtual keys and the keysyms X11 and the -/// Wayland portal bind name what the layout prints on it, so a key only -/// Shift makes, such as `!`, is named by where it sits instead. -fn heard(key: Key, physical: Option) -> Key { - let physical = physical.unwrap_or(key); - match cfg!(target_os = "macos") || code(key).is_none() { - true => physical, - false => key, - } -} - -/// `key` as the platform's hotkeys name it, if they can hear it. -fn hotkey(key: &KeyboardShortcut) -> Option { - let held = key.modifiers; +/// `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), @@ -353,101 +374,7 @@ fn hotkey(key: &KeyboardShortcut) -> Option { mods |= modifier; } } - Some(HotKey::new(Some(mods), code(key.logical_key)?)) -} - -/// `key` as the platforms name it: where it sits on a US keyboard, which -/// Windows and X11 take for the key the layout prints that on. -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, - }) + Some(HotKey::new(Some(mods), layout.code(key.position)?)) } #[cfg(test)] @@ -455,21 +382,31 @@ mod tests { use super::*; #[test] - fn keys_bind_as_the_platform_hears_them() { - // On AZERTY, the key printed A sits where a US keyboard has Q. - let a = heard(Key::A, Some(Key::Q)); - match cfg!(target_os = "macos") { - true => assert_eq!(a, Key::Q), - false => assert_eq!(a, Key::A), - } - // Shift and the key printed 1 make `!`, which no platform binds. - assert_eq!(heard(Key::Exclamationmark, Some(Key::Num1)), Key::Num1); - assert_eq!(heard(Key::F9, None), Key::F9); + 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, + }; + // 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(); + 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); } #[test] fn bindings_follow_renames_at_once() { - let key = |key| KeyboardShortcut::new(egui::Modifiers::NONE, key); + let key = |key| Binding { + key: KeyboardShortcut::new(egui::Modifiers::NONE, key), + position: key, + }; let scene = |name: &str| Action::Scene(name.into()); let bindings = Bindings::from([ (scene("A"), key(Key::F1)), diff --git a/rs/moq-studio/src/hotkeys/layout.rs b/rs/moq-studio/src/hotkeys/layout.rs new file mode 100644 index 00000000..d7edb70e --- /dev/null +++ b/rs/moq-studio/src/hotkeys/layout.rs @@ -0,0 +1,438 @@ +//! 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. + +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, +} + +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 {}) + } + + /// 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) + } +} + +/// 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 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))] +fn vk_code(vk: u32) -> Option { + const LETTERS: [Code; 26] = [ + Code::KeyA, + Code::KeyB, + Code::KeyC, + Code::KeyD, + Code::KeyE, + Code::KeyF, + Code::KeyG, + Code::KeyH, + Code::KeyI, + Code::KeyJ, + Code::KeyK, + Code::KeyL, + Code::KeyM, + Code::KeyN, + Code::KeyO, + Code::KeyP, + Code::KeyQ, + Code::KeyR, + Code::KeyS, + Code::KeyT, + Code::KeyU, + Code::KeyV, + Code::KeyW, + Code::KeyX, + Code::KeyY, + Code::KeyZ, + ]; + const DIGITS: [Code; 10] = [ + Code::Digit0, + Code::Digit1, + Code::Digit2, + Code::Digit3, + Code::Digit4, + Code::Digit5, + Code::Digit6, + Code::Digit7, + Code::Digit8, + Code::Digit9, + ]; + Some(match vk { + 0x30..=0x39 => DIGITS[(vk - 0x30) as usize], + 0x41..=0x5a => LETTERS[(vk - 0x41) as usize], + 0xba => Code::Semicolon, // VK_OEM_1 + 0xbb => Code::Equal, // VK_OEM_PLUS + 0xbc => Code::Comma, // VK_OEM_COMMA + 0xbd => Code::Minus, // VK_OEM_MINUS + 0xbe => Code::Period, // VK_OEM_PERIOD + 0xbf => Code::Slash, // VK_OEM_2 + 0xc0 => Code::Backquote, // VK_OEM_3 + 0xdb => Code::BracketLeft, // VK_OEM_4 + 0xdc => Code::Backslash, // VK_OEM_5 + 0xdd => Code::BracketRight, // VK_OEM_6 + 0xde => Code::Quote, // VK_OEM_7 + 0xe2 => Code::IntlBackslash, // VK_OEM_102 + _ => return None, + }) +} + +/// 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) + } + + 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. + fn german_vk(scancode: u32) -> u32 { + match scancode { + 0x08 => 0x37, + 0x15 => 0x5a, + 0x2c => 0x59, + 0x2b => 0xbf, + 0x27 => 0xc0, + other => panic!("no German virtual key for {other:#x} in this test"), + } + } + + #[test] + fn windows_registers_what_the_layout_puts_there() { + let windows = |position| vk_code(german_vk(scancode(position).unwrap())); + // 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!(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); + } +} diff --git a/rs/moq-studio/src/hotkeys/portal.rs b/rs/moq-studio/src/hotkeys/portal.rs index 5c246475..c55a192d 100644 --- a/rs/moq-studio/src/hotkeys/portal.rs +++ b/rs/moq-studio/src/hotkeys/portal.rs @@ -70,8 +70,8 @@ async fn run( *reach.lock().unwrap() = Reach::Asking; let shortcuts: Vec = bindings .iter() - .map(|(action, key)| { - NewShortcut::new(id(action), action.to_string()).preferred_trigger(trigger(key).as_deref()) + .map(|(action, binding)| { + NewShortcut::new(id(action), action.to_string()).preferred_trigger(trigger(&binding.key).as_deref()) }) .collect(); let bound = portal @@ -199,6 +199,7 @@ fn keysym(key: Key) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::hotkeys::Binding; #[test] fn unchanged_bindings_keep_the_session() { @@ -207,7 +208,10 @@ mod tests { bindings, reach: Arc::new(Mutex::new(Reach::Asking)), }; - let key = KeyboardShortcut::new(egui::Modifiers::CTRL, Key::R); + let key = Binding { + key: KeyboardShortcut::new(egui::Modifiers::CTRL, Key::R), + position: Key::R, + }; portal.set(Bindings::from([(Action::Cut, key)])); assert!(wanted.has_changed().unwrap()); wanted.mark_unchanged(); diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index f073c8c6..05590722 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -2750,13 +2750,13 @@ impl Panels { let Some(key) = pressed else { return }; self.binding = None; hotkeys.suspend(false); - if key.logical_key == egui::Key::Escape { + if key.key.logical_key == egui::Key::Escape { return; } self.refusal = match hotkeys.bind(&action, Some(key)) { Ok(()) => None, Err(other) => { - let key = ctx.format_shortcut(&key); + let key = ctx.format_shortcut(&key.key); Some((action, format!("{key} is bound to {other}; clear it there first"))) } }; @@ -2811,7 +2811,7 @@ impl Panels { ui.strong("press a key, or Escape"); } else { match bound { - Some(key) => ui.monospace(ui.ctx().format_shortcut(key)), + Some(bound) => ui.monospace(ui.ctx().format_shortcut(&bound.key)), None => ui.weak("unbound"), }; } From 6330bd9907116e32c505f115d1160dd0a3422654 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 08:04:43 -0800 Subject: [PATCH 09/12] fix(studio): refuse keypad keys, which egui reports as their twins egui-winit 0.36 folds Numpad1 into Key::Num1 (and the keypad's Enter, divide, and subtract into their main keyboard keys), so binding keypad 1 registered the top-row 1. The window's event loop now runs eframe's handler and notes keypad keys before egui hears them, and each frame drops those keys' positions, so the Hotkeys panel refuses them, saying so, and the focused fallback never matches them. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + README.md | 2 + rs/moq-studio/Cargo.toml | 3 + rs/moq-studio/src/hotkeys.rs | 2 + rs/moq-studio/src/hotkeys/keypad.rs | 93 +++++++++++++++++++++++++++++ rs/moq-studio/src/main.rs | 86 ++++++++++++++++++++++++-- rs/moq-studio/src/ui.rs | 24 +++++++- 7 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 rs/moq-studio/src/hotkeys/keypad.rs diff --git a/Cargo.lock b/Cargo.lock index a63e3034..2e8bff2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4321,6 +4321,7 @@ dependencies = [ "url", "usage-rs", "windows-sys 0.61.2", + "winit", "x11rb 0.13.2", ] diff --git a/README.md b/README.md index 9652600e..d1f82006 100644 --- a/README.md +++ b/README.md @@ -831,6 +831,8 @@ 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. 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 diff --git a/rs/moq-studio/Cargo.toml b/rs/moq-studio/Cargo.toml index 3f789c43..d3e2c860 100644 --- a/rs/moq-studio/Cargo.toml +++ b/rs/moq-studio/Cargo.toml @@ -83,6 +83,9 @@ 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 } [target.'cfg(target_os = "linux")'.dependencies] # The GlobalShortcuts portal for hotkeys on Wayland. 0.13, as moq-video's diff --git a/rs/moq-studio/src/hotkeys.rs b/rs/moq-studio/src/hotkeys.rs index 26bd27b4..bc2b561e 100644 --- a/rs/moq-studio/src/hotkeys.rs +++ b/rs/moq-studio/src/hotkeys.rs @@ -13,10 +13,12 @@ use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; use crate::controls::{Action, Controls}; +mod keypad; mod layout; #[cfg(target_os = "linux")] mod portal; +pub use keypad::Keypad; use layout::Layout; /// The key bound to each action. Every action starts unbound, so a key diff --git a/rs/moq-studio/src/hotkeys/keypad.rs b/rs/moq-studio/src/hotkeys/keypad.rs new file mode 100644 index 00000000..e675e06a --- /dev/null +++ b/rs/moq-studio/src/hotkeys/keypad.rs @@ -0,0 +1,93 @@ +//! 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/main.rs b/rs/moq-studio/src/main.rs index 7f8e7212..50e01f85 100644 --- a/rs/moq-studio/src/main.rs +++ b/rs/moq-studio/src/main.rs @@ -9,6 +9,8 @@ use std::process::ExitCode; use std::sync::{Arc, OnceLock}; use anyhow::Context; +use winit::event::WindowEvent; +use winit::event_loop::{ActiveEventLoop, EventLoop}; mod autosave; mod controls; @@ -229,6 +231,7 @@ struct Window { runtime: tokio::runtime::Handle, /// The engine once the studio is built, for the save on quit. started: Arc>>, + keypad: hotkeys::Keypad, } impl eframe::App for Window { @@ -253,6 +256,68 @@ 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. +struct Events<'a> { + eframe: eframe::EframeWinitApplication<'a>, + keypad: hotkeys::Keypad, + /// Whether the window was asked to close, since eframe only logs the error + /// that stops it otherwise. + closed: bool, +} + +impl winit::application::ApplicationHandler for Events<'_> { + fn new_events(&mut self, event_loop: &ActiveEventLoop, cause: winit::event::StartCause) { + self.eframe.new_events(event_loop, cause); + } + + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + self.eframe.resumed(event_loop); + } + + fn user_event(&mut self, event_loop: &ActiveEventLoop, event: eframe::UserEvent) { + self.eframe.user_event(event_loop, event); + } + + 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), + WindowEvent::CloseRequested => self.closed = true, + _ => {} + } + self.eframe.window_event(event_loop, id, event); + } + + fn device_event( + &mut self, + event_loop: &ActiveEventLoop, + id: winit::event::DeviceId, + event: winit::event::DeviceEvent, + ) { + self.eframe.device_event(event_loop, id, event); + } + + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + self.eframe.about_to_wait(event_loop); + } + + fn suspended(&mut self, event_loop: &ActiveEventLoop) { + self.eframe.suspended(event_loop); + } + + fn exiting(&mut self, event_loop: &ActiveEventLoop) { + self.eframe.exiting(event_loop); + } + + fn memory_warning(&mut self, event_loop: &ActiveEventLoop) { + self.eframe.memory_warning(event_loop); + } } /// Show the window until it is closed. @@ -280,7 +345,12 @@ fn run( }, ..Default::default() }; - eframe::run_native( + let event_loop = EventLoop::::with_user_event() + .build() + .context("cannot open the window")?; + let keypad = hotkeys::Keypad::default(); + let window_keypad = keypad.clone(); + let eframe = eframe::create_native( "moq-studio", options, Box::new(move |cc| { @@ -293,11 +363,19 @@ fn run( render, runtime, started, + keypad: window_keypad, })) }), - ) - .map_err(|err| anyhow::anyhow!("{err}")) - .context("the window failed") + &event_loop, + ); + let mut events = Events { + eframe, + keypad, + closed: false, + }; + event_loop.run_app(&mut events).context("the window failed")?; + anyhow::ensure!(events.closed, "the window failed; the error is logged above"); + Ok(()) } #[cfg(test)] diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index 05590722..209fc9a7 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -2741,13 +2741,31 @@ impl Panels { return; }; let pressed = ctx.input_mut(|input| { - let pressed = input.events.iter().find_map(hotkeys::pressed); + 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| hotkeys::pressed(event).is_none()); + input + .events + .retain(|event| !matches!(event, egui::Event::Key { pressed: true, .. })); } pressed }); - let Some(key) = pressed else { return }; + 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; + }; self.binding = None; hotkeys.suspend(false); if key.key.logical_key == egui::Key::Escape { From 833ee5f7562e1a619dc6a0e9b0990d07e50d1214 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 15:01:57 -0800 Subject: [PATCH 10/12] fix(studio): settle the online review of the hotkeys PR - A clip's "saving" message is written before its save starts, so the save's result always replaces it. - A hotkey sends its key, and the firing thread reads what it is bound to under the state lock the window moves keys through renames under. A key cleared since does nothing, and a scene renamed since is previewed by its new name, following renames the window has yet to take. - A scene's portal shortcut is kept under an id named for its key, so a rename keeps the desktop's assignment. - On X11, a key whose grab global-hotkey can no longer find after a layout change stays held, and the panel says to restart, instead of being forgotten while still grabbed. - A locked resize snaps only where no edge moves out of reach, preferring the snap that moves the item least. - Clearing the action the Hotkeys panel waits on stops the wait. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- rs/moq-studio/src/controls.rs | 60 +++++++---- rs/moq-studio/src/engine.rs | 14 +++ rs/moq-studio/src/hotkeys.rs | 154 +++++++++++++++++++--------- rs/moq-studio/src/hotkeys/layout.rs | 32 ++++++ rs/moq-studio/src/hotkeys/portal.rs | 80 ++++++++++----- rs/moq-studio/src/snap.rs | 61 +++++++++-- rs/moq-studio/src/ui.rs | 29 ++++-- 8 files changed, 323 insertions(+), 111 deletions(-) diff --git a/README.md b/README.md index f399dc12..5be04877 100644 --- a/README.md +++ b/README.md @@ -898,7 +898,9 @@ with, such as a function key or one with modifiers: 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. +- **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 beside each. - **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/rs/moq-studio/src/controls.rs b/rs/moq-studio/src/controls.rs index 4cf81c1e..ed353c28 100644 --- a/rs/moq-studio/src/controls.rs +++ b/rs/moq-studio/src/controls.rs @@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex}; use anyhow::Context; use moq_scene::Source; -use crate::engine::Engine; +use crate::engine::{Engine, State}; use crate::replay; /// Something a hotkey can be bound to. @@ -107,12 +107,7 @@ impl Controls { state.transition(transition, kio::clock::Instant::now())?; Ok(format!("took {}", on_program(state.compositor.program_scene()))) } - Action::Scene(name) => { - let mut state = self.engine.state.lock().unwrap(); - anyhow::ensure!(state.collection().scene(name).is_some(), "no scene is called `{name}`"); - state.compositor.set_preview(Some(name.clone())); - Ok(format!("previewing `{name}`")) - } + Action::Scene(name) => preview(&mut self.engine.state.lock().unwrap(), name), Action::Replay => self.replay(true), Action::SaveClip => self.replay(false), Action::Record => { @@ -137,14 +132,27 @@ impl Controls { /// replays apply in the order they were asked for. fn replay(&self, play: bool) -> anyhow::Result { let replay = self.replay.lock().unwrap(); - let outcome = replay.outcome.clone(); - let result = self.take(&replay, play); - let message = result.as_ref().map_err(|err| format!("{err:#}")).cloned(); - *outcome.lock().unwrap() = Some(message); - result + let taken = self.take(&replay, play); + let message = taken.as_ref().map(|(message, _)| message.clone()); + *replay.outcome.lock().unwrap() = Some(message.map_err(|err| format!("{err:#}"))); + let (message, save) = taken?; + // Started once the message above is written, so how it went replaces it. + if let Some(save) = save { + let outcome = replay.outcome.clone(); + self.runtime.spawn(async move { + let result = save.await; + *outcome.lock().unwrap() = Some(result.map_err(|err| format!("{err:#}"))); + }); + } + Ok(message) } - fn take(&self, replay: &Replay, play: bool) -> anyhow::Result { + /// What a replay or clip does at once, and the save a clip still runs. + fn take( + &self, + replay: &Replay, + play: bool, + ) -> anyhow::Result<(String, Option> + use<>>)> { let into = match play { true => Some(self.replay_source(replay.into.as_deref())?), false => None, @@ -153,18 +161,15 @@ impl Controls { let of = replay.of.clone(); if let Some(into) = into { replay::play(&self.engine, take, replay.speed, &into)?; - return Ok(format!("replaying {of} in `{into}`")); + return Ok((format!("replaying {of} in `{into}`"), None)); } let saving = format!("saving {of}"); let engine = self.engine.clone(); - let outcome = replay.outcome.clone(); - self.runtime.spawn(async move { - let result = replay::save(take, engine.autosave.path()) - .await - .map(|path| format!("saved {of} to {}", path.display())); - *outcome.lock().unwrap() = Some(result.map_err(|err| format!("{err:#}"))); - }); - Ok(saving) + let save = async move { + let path = replay::save(take, engine.autosave.path()).await?; + Ok(format!("saved {of} to {}", path.display())) + }; + Ok((saving, Some(save))) } /// The replay source a replay plays in: `picked`, or the first there is @@ -197,6 +202,17 @@ impl Controls { } } +/// Put the scene called `name` in preview, under `state` locked by the +/// caller, following any rename the window has yet to move its key through. +pub fn preview(state: &mut State, name: &str) -> anyhow::Result { + let scene = state + .follow(name) + .filter(|scene| state.collection().scene(scene).is_some()) + .with_context(|| format!("no scene is called `{name}`"))?; + state.compositor.set_preview(Some(scene.clone())); + Ok(format!("previewing `{scene}`")) +} + fn on_program(scene: Option<&str>) -> String { match scene { Some(scene) => format!("`{scene}`"), diff --git a/rs/moq-studio/src/engine.rs b/rs/moq-studio/src/engine.rs index 6e91e3c6..79ab35da 100644 --- a/rs/moq-studio/src/engine.rs +++ b/rs/moq-studio/src/engine.rs @@ -217,6 +217,20 @@ impl State { std::mem::take(&mut self.renamed) } + /// What the renames not yet taken call `scene` now, or `None` if one + /// removed it. + pub fn follow(&self, scene: &str) -> Option { + let mut scene = scene.to_string(); + for renames in &self.renamed { + match renames.get(&scene) { + Some(Some(to)) => scene = to.clone(), + Some(None) => return None, + None => {} + } + } + Some(scene) + } + /// The sources whose pictures any canvas's program draws, through clones: /// those [`placed`](Self::placed) there, and the clip a stinger under way /// plays with each mask its source's filters read. diff --git a/rs/moq-studio/src/hotkeys.rs b/rs/moq-studio/src/hotkeys.rs index bc2b561e..ea1187b9 100644 --- a/rs/moq-studio/src/hotkeys.rs +++ b/rs/moq-studio/src/hotkeys.rs @@ -11,7 +11,7 @@ use egui::{Key, KeyboardShortcut}; use global_hotkey::hotkey::{HotKey, Modifiers}; use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; -use crate::controls::{Action, Controls}; +use crate::controls::{self, Action, Controls}; mod keypad; mod layout; @@ -46,14 +46,18 @@ impl Binding { /// The bound keys, heard wherever the platform lets the studio hear them. pub struct Hotkeys { - bindings: Bindings, + /// The key bound to each action, which the thread firing them reads as + /// each key fires. + bound: Arc>, backend: Backend, - /// Why the platform refused each binding it would not register. - refused: BTreeMap, + /// The keys last handed to the backend. + heard: Vec, + /// Why the platform refused each key it would not register. + refused: Vec<(Binding, String)>, /// Whether bindings are held off while the Hotkeys panel waits for a key, /// which the thread firing them reads too. suspended: Arc, - fire: mpsc::Sender, + fire: mpsc::Sender, } /// Where bound keys are heard. @@ -62,11 +66,11 @@ enum Backend { /// `RegisterHotKey` on Windows, and a key grab on X11. Native { manager: GlobalHotKeyManager, - /// The hotkeys registered now. - held: Vec, - /// The action each registered hotkey fires, by its id, which the + /// 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>>, + registered: Arc>>, }, /// The desktop's GlobalShortcuts portal, on Wayland. #[cfg(target_os = "linux")] @@ -107,25 +111,46 @@ impl Hotkeys { runtime: &tokio::runtime::Handle, bindings: Bindings, ) -> Self { - let (fire, fired) = mpsc::channel::(); + let (fire, pressed) = mpsc::channel::(); + let bound: Arc> = Arc::default(); let suspended = Arc::new(AtomicBool::new(false)); - let held_off = suspended.clone(); + let (held_off, reading) = (suspended.clone(), bound.clone()); std::thread::spawn(move || { - for action in fired { + for key in pressed { // A key the desktop holds on Wayland still fires while the panel // waits for one, and the panel never sees it. if held_off.load(Ordering::Relaxed) { continue; } - let result = controls.fire(&action); + // What a key does is read as it fires, under the lock the window + // moves keys through renames under, so a key cleared since does + // nothing and one whose scene was renamed previews it by its new name. + let mut state = controls.engine.state.lock().unwrap(); + let action = reading + .lock() + .unwrap() + .iter() + .find(|(_, bound)| bound.same(&key)) + .map(|(action, _)| action.clone()); + let Some(action) = action else { + continue; + }; + let result = match &action { + Action::Scene(name) => controls::preview(&mut state, name), + action => { + drop(state); + controls.fire(action) + } + }; controls.report(&action, result); } }); let backend = Backend::new(window, runtime, fire.clone()); let mut hotkeys = Self { - bindings: Bindings::new(), + bound, backend, - refused: BTreeMap::new(), + heard: Vec::new(), + refused: Vec::new(), suspended, fire, }; @@ -133,22 +158,21 @@ impl Hotkeys { hotkeys } - pub fn bindings(&self) -> &Bindings { - &self.bindings + pub fn bindings(&self) -> Bindings { + self.bound.lock().unwrap().clone() } /// Bind `action` to `key`, or unbind it, unless `key` is bound to another /// 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 - && let Some((other, _)) = self - .bindings + && let Some((other, _)) = bindings .iter() .find(|(other, bound)| bound.same(&key) && *other != action) { return Err(other.clone()); } - let mut bindings = self.bindings.clone(); match key { Some(key) => bindings.insert(action.clone(), key), None => bindings.remove(action), @@ -159,45 +183,72 @@ impl Hotkeys { /// Move each binding to a scene `renames` renamed, all at once, so a swap /// of two names swaps their keys. A removed scene keeps its key, which an - /// undo may bring back. + /// undo may bring back. Called under the state lock `renames` was taken + /// under, so a key fired meanwhile reads the names on one side of them; + /// [`hear`](Self::hear) then follows outside it. pub fn rename(&mut self, renames: &BTreeMap>) { - let bindings = renamed(&self.bindings, renames); - if bindings != self.bindings { - self.set(bindings); - } + let mut bound = self.bound.lock().unwrap(); + *bound = renamed(&bound, renames); } /// 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) { if self.suspended.swap(suspended, Ordering::Relaxed) != suspended { - self.set(self.bindings.clone()); + self.set(self.bindings()); } } /// Hear `bindings` instead of the ones heard now. fn set(&mut self, bindings: Bindings) { - self.refused.clear(); - let heard = match self.suspended.load(Ordering::Relaxed) { - true => &Bindings::new(), - false => &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. + 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(), }; + // 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 the studio took its keys; restart it to change them" + .into() + }), + Err(err) => (!held.is_empty()).then(|| err.clone()), + }; + if let Some(why) = stuck { + self.refused = bindings.values().map(|key| (*key, why.clone())).collect(); + return; + } + 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(..) { + 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(); - let layout = Layout::current(); - for (action, key) in heard { + 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()) @@ -207,32 +258,38 @@ impl Hotkeys { let hotkey = match hotkey { Ok(hotkey) => hotkey, Err(reason) => { - self.refused.insert(action.clone(), reason); + self.refused.push((*key, reason)); continue; } }; match manager.register(hotkey) { Ok(()) => { - held.push(hotkey); - ids.insert(hotkey.id(), action.clone()); + held.push((*key, hotkey)); + ids.insert(hotkey.id(), *key); } - Err(err) => drop(self.refused.insert(action.clone(), refusal(err))), + Err(err) => self.refused.push((*key, refusal(err))), } } *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")] - Backend::Portal(portal) => portal.set(bindings.clone()), + Backend::Portal(portal) => portal.set(bindings), Backend::Focused(_) => {} } - self.bindings = bindings; + self.heard = heard; } /// Why the platform would not hear `action`'s key, if it refused it. pub fn refused(&self, action: &Action) -> Option<&str> { - self.refused.get(action).map(String::as_str) + let bindings = self.bound.lock().unwrap(); + let key = bindings.get(action)?; + self.refused + .iter() + .find(|(refused, _)| refused.same(key)) + .map(|(_, why)| why.as_str()) } pub fn reach(&self) -> Reach { @@ -253,17 +310,18 @@ impl Hotkeys { if suspended || ctx.text_edit_focused() || !matches!(self.reach(), Reach::Focused(_)) { return; } + let bindings = self.bound.lock().unwrap(); ctx.input_mut(|input| { input.events.retain(|event| { let Some(pressed) = binding(event) else { return true; }; - let Some((action, _)) = self.bindings.iter().find(|(_, key)| key.same(&pressed)) else { + 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(action.clone()); + let _ = self.fire.send(pressed); } false }); @@ -273,7 +331,7 @@ impl Hotkeys { 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: &eframe::Frame, runtime: &tokio::runtime::Handle, fire: mpsc::Sender) -> Self { #[cfg(target_os = "linux")] { use raw_window_handle::{HasWindowHandle, RawWindowHandle}; @@ -290,14 +348,14 @@ impl Backend { Ok(manager) => manager, Err(err) => return Self::Focused(format!("the platform's global hotkeys are unavailable: {err}")), }; - let registered: Arc>> = Arc::default(); + 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(action) = heard.lock().unwrap().get(&event.id()) { - let _ = fire.send(action.clone()); + if let Some(key) = heard.lock().unwrap().get(&event.id()) { + let _ = fire.send(*key); } })); Self::Native { diff --git a/rs/moq-studio/src/hotkeys/layout.rs b/rs/moq-studio/src/hotkeys/layout.rs index d7edb70e..4d37d43c 100644 --- a/rs/moq-studio/src/hotkeys/layout.rs +++ b/rs/moq-studio/src/hotkeys/layout.rs @@ -49,6 +49,19 @@ impl Layout { } code(position) } + + /// 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 + } } /// The PC scancode of a key in the block whose keys a layout relabels, @@ -209,6 +222,14 @@ impl Keymap { .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) @@ -435,4 +456,15 @@ mod tests { 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))); + } } diff --git a/rs/moq-studio/src/hotkeys/portal.rs b/rs/moq-studio/src/hotkeys/portal.rs index c55a192d..f131299d 100644 --- a/rs/moq-studio/src/hotkeys/portal.rs +++ b/rs/moq-studio/src/hotkeys/portal.rs @@ -11,7 +11,7 @@ use egui::{Key, KeyboardShortcut}; use futures_util::StreamExt; use tokio::sync::watch; -use super::{Bindings, Reach}; +use super::{Binding, Bindings, Reach}; use crate::controls::Action; /// A session with the portal, kept by a task of its own. @@ -22,7 +22,7 @@ pub struct Portal { } impl Portal { - pub fn new(runtime: &tokio::runtime::Handle, fire: mpsc::Sender) -> Self { + pub fn new(runtime: &tokio::runtime::Handle, fire: mpsc::Sender) -> Self { let (bindings, wanted) = watch::channel(Bindings::new()); let reach = Arc::new(Mutex::new(Reach::Asking)); let task = reach.clone(); @@ -50,10 +50,10 @@ impl Portal { } /// Ask the desktop for each set of `wanted` bindings in turn, in a session of -/// its own, and fire the actions it reports. +/// its own, and fire the keys of the shortcuts it reports. async fn run( mut wanted: watch::Receiver, - fire: mpsc::Sender, + fire: mpsc::Sender, reach: &Mutex, ) -> anyhow::Result<()> { let portal = GlobalShortcuts::new() @@ -71,7 +71,8 @@ async fn run( let shortcuts: Vec = bindings .iter() .map(|(action, binding)| { - NewShortcut::new(id(action), action.to_string()).preferred_trigger(trigger(&binding.key).as_deref()) + NewShortcut::new(id(action, binding), action.to_string()) + .preferred_trigger(trigger(&binding.key).as_deref()) }) .collect(); let bound = portal @@ -84,7 +85,8 @@ async fn run( .shortcuts() .iter() .filter_map(|shortcut| { - Some((action(shortcut.id())?, shortcut.trigger_description().to_string())) + let (action, _) = by_id(&bindings, shortcut.id())?; + Some((action.clone(), shortcut.trigger_description().to_string())) }) .collect(), ), @@ -95,9 +97,9 @@ async fn run( tokio::select! { event = activated.next() => { let event = event.context("the global shortcuts portal went away")?; - // Only this session is open, so every activation is its own. - if let Some(action) = action(event.shortcut_id()) { - let _ = fire.send(action); + // 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); } } changed = wanted.changed() => { @@ -113,24 +115,39 @@ async fn run( } /// The id the desktop's settings keep `action`'s shortcut under, the same -/// across runs so a key the operator picked there stays. -fn id(action: &Action) -> String { +/// across runs so a key the operator picked there stays. A scene's is named +/// for the key bound to it, which follows the scene through renames. +fn id(action: &Action, binding: &Binding) -> String { match action { Action::Cut => "cut".into(), Action::Transition => "transition".into(), - Action::Scene(name) => format!("scene/{name}"), + Action::Scene(_) => { + let held = binding.key.modifiers; + let mut id = "scene/".to_string(); + for (on, name) in [ + (held.ctrl, "ctrl+"), + (held.alt, "alt+"), + (held.shift, "shift+"), + (held.mac_cmd, "logo+"), + ] { + if on { + id.push_str(name); + } + } + id.push_str(&format!("{:?}", binding.position)); + id + } Action::Replay => "replay".into(), Action::SaveClip => "save-clip".into(), Action::Record => "record".into(), } } -/// The action a shortcut `id` is kept under. -fn action(id: &str) -> Option { - if let Some(name) = id.strip_prefix("scene/") { - return Some(Action::Scene(name.into())); - } - Action::FIXED.into_iter().find(|action| self::id(action) == id) +/// The binding among `bindings` whose shortcut is kept under `id`. +fn by_id<'a>(bindings: &'a Bindings, id: &str) -> Option<(&'a Action, &'a Binding)> { + bindings + .iter() + .find(|(action, binding)| self::id(action, binding) == id) } /// `shortcut` as a trigger for the desktop to offer first, its key named by @@ -221,11 +238,28 @@ mod tests { } #[test] - fn ids_round_trip() { - for action in Action::FIXED.into_iter().chain([Action::Scene("Live/Intro".into())]) { - assert_eq!(super::action(&id(&action)), Some(action)); - } - assert_eq!(super::action("unknown"), None); + 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); + 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" + ); + // 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)), + ]); + 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); } #[test] diff --git a/rs/moq-studio/src/snap.rs b/rs/moq-studio/src/snap.rs index c1b08c8a..977be5dd 100644 --- a/rs/moq-studio/src/snap.rs +++ b/rs/moq-studio/src/snap.rs @@ -272,8 +272,9 @@ impl Snap { /// the nearest line within reach of a moving edge or center wins, as does /// the spacing of a neighboring pair when the whole item moves. /// - /// A resize keeping an aspect snaps only the axis whose snap is nearer, - /// and the other follows its shape. The snapped rect is at rest. + /// A resize keeping an aspect snaps only the axis whose snap moves the + /// item least, and the other follows its shape, as long as no edge moves + /// out of reach. The snapped rect is at rest. pub fn apply(&self, grab: Grab, rect: Rect, others: &[Rect], canvas: &Canvas) -> Snapped { // An edge shown is the edge at rest plus a constant, so a shift // measured where it shows moves it as far at rest. @@ -293,13 +294,34 @@ impl Snap { } } Some(aspect) => { + // How far a snap moves any edge, the one that follows the + // shape included. + let moved = |snapped: &Rect| { + Axis::ALL + .into_iter() + .map(|axis| { + let ((start, end), (from, to)) = (axis.span(&rect), axis.span(snapped)); + (from - start).abs().max((to - end).abs()) + }) + .fold(0.0, f32::max) + }; let best = Axis::ALL .into_iter() - .filter_map(|axis| Some((axis, self.nearest(grab, axis, &shown(&rect), others, canvas)?))) - .min_by(|(_, a), (_, b)| a.abs().total_cmp(&b.abs())); - if let Some((axis, best)) = best { - shift(&mut rect, axis, grab.moves(axis), best); - rect = grab.keep_aspect(rect, aspect, axis); + .filter_map(|axis| { + let mut snapped = rect; + shift( + &mut snapped, + axis, + grab.moves(axis), + self.nearest(grab, axis, &shown(&rect), others, canvas)?, + ); + let snapped = grab.keep_aspect(snapped, aspect, axis); + let moved = moved(&snapped); + (moved <= self.reach).then_some((moved, snapped)) + }) + .min_by(|(a, _), (b, _)| a.total_cmp(b)); + if let Some((_, snapped)) = best { + rect = snapped; } } } @@ -606,7 +628,8 @@ mod tests { y: Some(Side::End), }; // The right edge at 396 is 4 px from the other item's left edge, and - // the bottom at 247 is 3 px from its top, which is nearer and wins. + // the bottom at 247 is 3 px from its top, but snapping the bottom + // would move the right edge 6 px, so the right edge's snap wins. let others = [rect(400.0, 250.0, 100.0, 50.0)]; let snapped = snap.apply(bottom_right, rect(100.0, 100.0, 296.0, 147.0), &others, &CANVAS); assert_eq!(snapped.rect, rect(100.0, 100.0, 300.0, 150.0)); @@ -616,6 +639,28 @@ mod tests { assert_eq!(snap.apply(bottom_right, item, &[], &CANVAS).rect, item); } + #[test] + fn a_locked_snap_moves_no_edge_out_of_reach() { + let snap = Snap { + aspect: Some(100.0), + ..SNAP + }; + let bottom_right = Grab::Resize { + x: Some(Side::End), + y: Some(Side::End), + }; + // A thin strip's bottom is 5 px from the other item's top, and its right + // edge 8 px from its left. Snapping the bottom would widen it 500 px. + let strip = rect(100.0, 100.0, 1000.0, 10.0); + let others = [rect(1108.0, 115.0, 50.0, 50.0)]; + let snapped = snap.apply(bottom_right, strip, &others, &CANVAS).rect; + assert_eq!((snapped.x, snapped.y, snapped.width), (100.0, 100.0, 1008.0)); + assert!((snapped.height - 10.08).abs() < 1e-3); + // With only the bottom's snap near, it stays as dragged. + let others = [rect(1300.0, 115.0, 50.0, 50.0)]; + assert_eq!(snap.apply(bottom_right, strip, &others, &CANVAS).rect, strip); + } + #[test] fn a_posed_item_snaps_where_it_shows_and_keeps_its_shape_at_rest() { // Resting at 200x100, with a keyframe adding 100 to the height. diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index 322ef790..2955cee4 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -186,7 +186,7 @@ impl eframe::App for Studio { eframe::set_value(storage, SPEAKER, &panels.speaker); eframe::set_value(storage, SNAPPING, &panels.snapping); if let Some(hotkeys) = &panels.hotkeys { - eframe::set_value(storage, HOTKEYS, hotkeys.bindings()); + eframe::set_value(storage, HOTKEYS, &hotkeys.bindings()); } } @@ -220,7 +220,14 @@ impl eframe::App for Studio { let framerate = state.compositor.canvas().framerate; ui.ctx().request_repaint_after(Duration::from_secs(1) / framerate); self.panels.follow_reloads(&state); + // A key bound to a scene follows it through renames, undos, and redos, + // under this lock, so a key fired meanwhile names the scene it means. let renames = state.take_renames(); + if let Some(hotkeys) = &mut self.panels.hotkeys { + for renames in &renames { + hotkeys.rename(renames); + } + } // A page not painting through an X display copies through the // studio's clipboard, even if the preview let go of the page before // the copy came back. @@ -235,11 +242,10 @@ impl eframe::App for Studio { } drop(state); - // A key bound to a scene follows it through renames, undos, and redos. - if let Some(hotkeys) = &mut self.panels.hotkeys { - for renames in &renames { - hotkeys.rename(renames); - } + 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); @@ -2851,9 +2857,9 @@ impl Panels { }; let mut actions: Vec = Action::FIXED.to_vec(); actions.extend(scenes.iter().cloned().map(Action::Scene)); + let bindings = hotkeys.bindings(); // A key bound to a scene the file no longer has, so it can be cleared. - let gone: Vec = hotkeys - .bindings() + let gone: Vec = bindings .keys() .filter(|action| matches!(action, Action::Scene(name) if !scenes.contains(name))) .cloned() @@ -2865,7 +2871,7 @@ impl Panels { egui::Grid::new("hotkeys").striped(true).show(ui, |ui| { for action in &actions { ui.label(action.to_string()); - let bound = hotkeys.bindings().get(action); + let bound = bindings.get(action); if self.binding.as_ref() == Some(action) { ui.strong("press a key, or Escape"); } else { @@ -2906,6 +2912,11 @@ impl Panels { } if let Some(action) = clear { self.refusal = None; + // Clearing the action waiting for a key stops the wait. + if self.binding.as_ref() == Some(&action) { + self.binding = None; + hotkeys.suspend(false); + } // Unbinding never conflicts. let _ = hotkeys.bind(&action, None); } From 4a24431d1eed287f7d378a1a73fc0c6b9f20b355 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 15:22:26 -0800 Subject: [PATCH 11/12] fix(studio): end abandoned key captures and report hotkey clip saves - The Hotkeys panel's wait for a key ends when the window loses focus or the panel is not shown, so the hotkeys are not held off while the operator plays. - A clip saved by a hotkey shows how its save went in the status bar too, written after the "saving" message, since the Replay panel may be hidden. - On X11, keys stuck after a layout change are reported once for the panel, so the warning stays when every binding is cleared. Co-Authored-By: Claude Opus 5.5 --- README.md | 11 ++++---- rs/moq-studio/src/controls.rs | 50 ++++++++++++++++++++++++----------- rs/moq-studio/src/hotkeys.rs | 31 ++++++++++++++-------- rs/moq-studio/src/ui.rs | 18 +++++++++++++ 4 files changed, 79 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 5be04877..2e080328 100644 --- a/README.md +++ b/README.md @@ -878,10 +878,11 @@ a WebGL page runs slower. The Hotkeys panel binds a key to **Cut**, **Transition**, **Replay**, **Save clip**, starting or stopping a recording, and putting each scene in preview. -Press **Bind**, then the key with any modifiers; Escape cancels. Every action -starts unbound, so no key a game uses is 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. +Press **Bind**, then the key with any modifiers; Escape, or leaving the window +or the panel, cancels. Every action starts unbound, so no key a game uses is +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 @@ -900,7 +901,7 @@ with, such as a function key or one with modifiers: 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 beside each. + panel says so. - **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/rs/moq-studio/src/controls.rs b/rs/moq-studio/src/controls.rs index ed353c28..7e5adbc3 100644 --- a/rs/moq-studio/src/controls.rs +++ b/rs/moq-studio/src/controls.rs @@ -56,7 +56,7 @@ pub struct Controls { /// Whether the next recording gives each source a file of its own too. pub record_sources: AtomicBool, /// How the last action a hotkey fired went, until the status bar takes it. - fired: Mutex>>, + fired: Arc>>>, } /// The Replay panel's choices, and how the last replay or clip went. @@ -89,7 +89,7 @@ impl Controls { runtime, replay: Mutex::default(), record_sources: AtomicBool::new(false), - fired: Mutex::default(), + fired: Arc::default(), } } @@ -108,8 +108,8 @@ impl Controls { Ok(format!("took {}", on_program(state.compositor.program_scene()))) } Action::Scene(name) => preview(&mut self.engine.state.lock().unwrap(), name), - Action::Replay => self.replay(true), - Action::SaveClip => self.replay(false), + Action::Replay => self.replay(true, false), + Action::SaveClip => self.replay(false, false), Action::Record => { let recorder = &self.engine.recorder; match recorder.status().filter(|status| !status.done) { @@ -127,21 +127,42 @@ impl Controls { } } + /// Do `action` for a hotkey, keeping how it went for the status bar. + pub fn press(&self, action: &Action) { + match action { + Action::Replay => drop(self.replay(true, true)), + Action::SaveClip => drop(self.replay(false, true)), + action => self.report(action, self.fire(action).map_err(|err| format!("{err:#}"))), + } + } + /// Play the Replay panel's pick in its replay source, or save it as a /// clip. Copied now, without waiting on anything, so the span ends here and - /// replays apply in the order they were asked for. - fn replay(&self, play: bool) -> anyhow::Result { + /// replays apply in the order they were asked for. How it went shows in + /// the Replay panel, and in the status bar too when a hotkey `pressed` it, + /// since the panel may be hidden. + fn replay(&self, play: bool, pressed: bool) -> anyhow::Result { let replay = self.replay.lock().unwrap(); + let action = if play { Action::Replay } else { Action::SaveClip }; let taken = self.take(&replay, play); - let message = taken.as_ref().map(|(message, _)| message.clone()); - *replay.outcome.lock().unwrap() = Some(message.map_err(|err| format!("{err:#}"))); + let message = taken + .as_ref() + .map(|(message, _)| message.clone()) + .map_err(|err| format!("{err:#}")); + *replay.outcome.lock().unwrap() = Some(message.clone()); + if pressed { + self.report(&action, message); + } let (message, save) = taken?; - // Started once the message above is written, so how it went replaces it. + // Started once the messages above are shown, so how it went replaces them. if let Some(save) = save { - let outcome = replay.outcome.clone(); + let (outcome, fired) = (replay.outcome.clone(), self.fired.clone()); self.runtime.spawn(async move { - let result = save.await; - *outcome.lock().unwrap() = Some(result.map_err(|err| format!("{err:#}"))); + let result = save.await.map_err(|err| format!("{err:#}")); + *outcome.lock().unwrap() = Some(result.clone()); + if pressed { + *fired.lock().unwrap() = Some(result.map_err(|err| format!("{action}: {err}"))); + } }); } Ok(message) @@ -191,9 +212,8 @@ impl Controls { } /// Keep how `action`, fired by a hotkey, went for the status bar. - pub fn report(&self, action: &Action, result: anyhow::Result) { - let result = result.map_err(|err| format!("{action}: {err:#}")); - *self.fired.lock().unwrap() = Some(result); + pub fn report(&self, action: &Action, result: Result) { + *self.fired.lock().unwrap() = Some(result.map_err(|err| format!("{action}: {err}"))); } /// How the last action a hotkey fired went, once. diff --git a/rs/moq-studio/src/hotkeys.rs b/rs/moq-studio/src/hotkeys.rs index ea1187b9..7de68402 100644 --- a/rs/moq-studio/src/hotkeys.rs +++ b/rs/moq-studio/src/hotkeys.rs @@ -54,6 +54,8 @@ pub struct Hotkeys { heard: Vec, /// Why the platform refused each key it would not register. 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, @@ -135,14 +137,17 @@ impl Hotkeys { let Some(action) = action else { continue; }; - let result = match &action { - Action::Scene(name) => controls::preview(&mut state, name), + match &action { + Action::Scene(name) => { + let result = controls::preview(&mut state, name); + drop(state); + controls.report(&action, result.map_err(|err| format!("{err:#}"))); + } action => { drop(state); - controls.fire(action) + controls.press(action); } - }; - controls.report(&action, result); + } } }); let backend = Backend::new(window, runtime, fire.clone()); @@ -151,6 +156,7 @@ impl Hotkeys { backend, heard: Vec::new(), refused: Vec::new(), + stuck: None, suspended, fire, }; @@ -229,14 +235,11 @@ impl Hotkeys { Ok(layout) => held .iter() .any(|(key, hotkey)| !layout.releases(key.position, hotkey.key)) - .then(|| { - "the keyboard layout changed since the studio took its keys; restart it to change them" - .into() - }), + .then(|| "the keyboard layout changed since it took them".into()), Err(err) => (!held.is_empty()).then(|| err.clone()), }; - if let Some(why) = stuck { - self.refused = bindings.values().map(|key| (*key, why.clone())).collect(); + self.stuck = stuck; + if self.stuck.is_some() { return; } self.refused.clear(); @@ -292,6 +295,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, diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index 2955cee4..409d379c 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -116,6 +116,7 @@ impl Studio { controls, hotkeys: None, binding: None, + hotkeys_shown: false, refusal: None, preview, program, @@ -323,6 +324,8 @@ struct Panels { hotkeys: Option, /// The action the Hotkeys panel waits on a key for. binding: Option, + /// Whether the Hotkeys panel was drawn since the last frame took this. + hotkeys_shown: bool, /// The last key the Hotkeys panel would not bind, by the action it was /// pressed for, and why. refusal: Option<(Action, String)>, @@ -2784,9 +2787,17 @@ impl Panels { /// Bind the key pressed to the action the Hotkeys panel waits on, or cancel /// on Escape. fn take_binding(&mut self, ctx: &egui::Context) { + let shown = std::mem::take(&mut self.hotkeys_shown); let (Some(action), Some(hotkeys)) = (self.binding.clone(), &mut self.hotkeys) else { return; }; + // A wait the operator left, for another app or another panel, ends, + // so the hotkeys are not held off while they play. + if !shown || !ctx.input(|input| input.focused) { + self.binding = None; + hotkeys.suspend(false); + return; + } let pressed = ctx.input_mut(|input| { let pressed = input.events.iter().find_map(|event| match event { egui::Event::Key { @@ -2833,6 +2844,7 @@ impl Panels { let Some(hotkeys) = &mut self.hotkeys else { return; }; + self.hotkeys_shown = true; let reach = hotkeys.reach(); match &reach { Reach::Global => ui.label("Bound keys work even while another app, such as a game, has focus."), @@ -2846,6 +2858,12 @@ 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 From 74215c4834cada781b03d336b9bfeaefa46dde23 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 6 Nov 2026 15:30:19 -0800 Subject: [PATCH 12/12] fix(studio): end a key capture the operator left while the window is hidden eframe skips `App::ui` while the window is minimized or occluded, so a capture left for a game was only ended on the next drawn frame, holding every hotkey off meanwhile. The check for leaving the window moves to `App::logic`, which runs while hidden, reading the viewport's focus, minimized, and occluded state. Co-Authored-By: Claude Opus 5.5 --- rs/moq-studio/src/main.rs | 6 ++++++ rs/moq-studio/src/ui.rs | 31 ++++++++++++++++++++++++++----- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/rs/moq-studio/src/main.rs b/rs/moq-studio/src/main.rs index 50e01f85..c445521e 100644 --- a/rs/moq-studio/src/main.rs +++ b/rs/moq-studio/src/main.rs @@ -235,6 +235,12 @@ struct Window { } impl eframe::App for Window { + fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + if let App::Studio(studio) = &mut self.app { + studio.logic(ctx, frame); + } + } + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { let engine = match &mut self.app { App::Studio(studio) => return studio.ui(ui, frame), diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index 409d379c..8f209ef4 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -191,6 +191,19 @@ impl eframe::App for Studio { } } + /// Run before each frame, and while the window is hidden, when `ui` is not. + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // A wait for a key ends once the operator leaves the window, such as + // for a game, so the hotkeys are not held off while they play. + let left = ctx.input(|input| { + let viewport = input.viewport(); + viewport.focused == Some(false) || viewport.minimized == Some(true) || viewport.occluded == Some(true) + }); + if left { + self.panels.end_binding(); + } + } + fn ui(&mut self, ui: &mut Ui, frame: &mut eframe::Frame) { let engine = self.panels.engine.clone(); // A canvas picked last frame is shown from this one on, so every panel @@ -2791,11 +2804,10 @@ impl Panels { let (Some(action), Some(hotkeys)) = (self.binding.clone(), &mut self.hotkeys) else { return; }; - // A wait the operator left, for another app or another panel, ends, - // so the hotkeys are not held off while they play. - if !shown || !ctx.input(|input| input.focused) { - self.binding = None; - hotkeys.suspend(false); + // A wait the operator left for another panel ends, so the hotkeys + // are not held off unseen. + if !shown { + self.end_binding(); return; } let pressed = ctx.input_mut(|input| { @@ -2838,6 +2850,15 @@ impl Panels { }; } + /// Stop waiting for a key to bind, taking the hotkeys up again. + fn end_binding(&mut self) { + if self.binding.take().is_some() + && let Some(hotkeys) = &mut self.hotkeys + { + hotkeys.suspend(false); + } + } + /// Bind keys to actions, heard even while another app has focus where the /// platform lets the studio hear them. fn hotkeys(&mut self, ui: &mut Ui) {