diff --git a/Cargo.lock b/Cargo.lock index 44e96c0b..08130ec9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2618,6 +2618,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" @@ -4388,10 +4405,13 @@ name = "moq-studio" version = "0.1.0" dependencies = [ "anyhow", + "ashpd", "bytes", "eframe", "egui", "egui_dock", + "futures-util", + "global-hotkey", "hang", "humantime", "kio", @@ -4409,6 +4429,7 @@ dependencies = [ "mp4-atom", "pollster 1.0.1", "qrcodegen", + "raw-window-handle", "same-file", "serde", "tempfile", @@ -4417,6 +4438,9 @@ dependencies = [ "tracing", "url", "usage-rs", + "windows-sys 0.61.2", + "winit", + "x11rb 0.13.2", ] [[package]] @@ -9296,6 +9320,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 31b03418..2e080328 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 @@ -215,7 +216,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 @@ -868,6 +874,49 @@ 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, 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 +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 +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 + macOS 15 and later, the system refuses a key whose only modifiers are Option, + or Option and Shift. +- **Linux on X11** grabs them from the X server. Once a layout change moves + a key the studio holds, its keys stay as they are until it restarts, and the + panel says so. +- **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 ac9a8e95..e7365eba 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 @@ -24,6 +24,5 @@ than opening an m2 for them. ## Required - [Viewer-side composition](/quest/m1/compose/README.md) - sources as their own tracks plus a layout track, composed by viewers and by a server -- [Hotkeys and editor preferences](/quest/m1/hotkeys.md) - system-wide hotkeys, arrow nudge, aspect lock, and persisted editor settings - [Keypad keys bind as hotkeys](/quest/m1/hotkeys-keypad.md) - scene and action keys on the keypad, apart from the main row - [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-keypad.md b/quest/m1/hotkeys-keypad.md index 3d6bf622..cac820c5 100644 --- a/quest/m1/hotkeys-keypad.md +++ b/quest/m1/hotkeys-keypad.md @@ -18,7 +18,3 @@ refuses keypad keys rather than binding them; picked 2026-11-06 (maintainer, - Register global-hotkey's `Numpad*` codes on macOS, Windows, and X11, and ask the GlobalShortcuts portal for `KP_*` keysyms on Wayland. - `README.md` drops the note that keypad keys are refused. - -## Required - -- [Hotkeys and editor preferences](/quest/m1/hotkeys.md) - the bindings and settings store this extends 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..d3e2c860 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. @@ -80,6 +83,25 @@ 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 +# 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" +# 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/controls.rs b/rs/moq-studio/src/controls.rs new file mode 100644 index 00000000..7e5adbc3 --- /dev/null +++ b/rs/moq-studio/src/controls.rs @@ -0,0 +1,241 @@ +//! 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, State}; +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: Arc>>>, +} + +/// 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: Arc::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(); + state.cut()?; + Ok(format!("cut to {}", on_program(state.compositor.program_scene()))) + } + Action::Transition => { + let mut state = self.engine.state.lock().unwrap(); + let transition = state.collection().transition.clone(); + state.transition(transition, kio::clock::Instant::now())?; + 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, false), + Action::SaveClip => self.replay(false, 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())) + } + } + } + } + } + + /// 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. 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()) + .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 messages above are shown, so how it went replaces them. + if let Some(save) = save { + let (outcome, fired) = (replay.outcome.clone(), self.fired.clone()); + self.runtime.spawn(async move { + 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) + } + + /// 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, + }; + 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}`"), None)); + } + let saving = format!("saving {of}"); + let engine = self.engine.clone(); + 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 + /// 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: Result) { + *self.fired.lock().unwrap() = Some(result.map_err(|err| format!("{action}: {err}"))); + } + + /// How the last action a hotkey fired went, once. + pub fn fired(&self) -> Option> { + self.fired.lock().unwrap().take() + } +} + +/// 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}`"), + None => "nothing".into(), + } +} diff --git a/rs/moq-studio/src/engine.rs b/rs/moq-studio/src/engine.rs index 2b608ba2..79ab35da 100644 --- a/rs/moq-studio/src/engine.rs +++ b/rs/moq-studio/src/engine.rs @@ -100,6 +100,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 { @@ -202,9 +207,30 @@ 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) + } + + /// 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. @@ -811,6 +837,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 new file mode 100644 index 00000000..7de68402 --- /dev/null +++ b/rs/moq-studio/src/hotkeys.rs @@ -0,0 +1,502 @@ +//! 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::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, mpsc}; + +use egui::{Key, KeyboardShortcut}; +use global_hotkey::hotkey::{HotKey, Modifiers}; +use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; + +use crate::controls::{self, 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 +/// another app, such as a game, uses is never taken unasked. +pub type Bindings = BTreeMap; + +/// A key bound to an action. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +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 { + /// The key bound to each action, which the thread firing them reads as + /// each key fires. + bound: Arc>, + backend: Backend, + /// The keys last handed to the backend. + 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, + fire: mpsc::Sender, +} + +/// Where bound keys are heard. +enum Backend { + /// The platform's global hotkeys: `RegisterEventHotKey` on macOS, + /// `RegisterHotKey` on Windows, and a key grab on X11. + Native { + manager: GlobalHotKeyManager, + /// The hotkeys registered now, and the key each hears. + held: Vec<(Binding, HotKey)>, + /// The key each registered hotkey hears, by its id, which the + /// platform's event handler reads. + registered: Arc>>, + }, + /// The 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, pressed) = mpsc::channel::(); + let bound: Arc> = Arc::default(); + let suspended = Arc::new(AtomicBool::new(false)); + let (held_off, reading) = (suspended.clone(), bound.clone()); + std::thread::spawn(move || { + 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; + } + // 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; + }; + 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.press(action); + } + } + } + }); + let backend = Backend::new(window, runtime, fire.clone()); + let mut hotkeys = Self { + bound, + backend, + heard: Vec::new(), + refused: Vec::new(), + stuck: None, + suspended, + fire, + }; + hotkeys.set(bindings); + hotkeys + } + + 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, _)) = bindings + .iter() + .find(|(other, bound)| bound.same(&key) && *other != action) + { + return Err(other.clone()); + } + match key { + Some(key) => bindings.insert(action.clone(), key), + None => bindings.remove(action), + }; + self.set(bindings); + Ok(()) + } + + /// 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. 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 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()); + } + } + + /// Hear `bindings` instead of the ones heard now. + fn set(&mut self, bindings: Bindings) { + *self.bound.lock().unwrap() = bindings; + self.hear(); + } + + /// Have the platform hear the keys bound now. On X11, each key it takes + /// or lets go of waits on the platform's event thread. + 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 it took them".into()), + Err(err) => (!held.is_empty()).then(|| err.clone()), + }; + self.stuck = stuck; + if self.stuck.is_some() { + 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(..) { + if let Err(err) = manager.unregister(hotkey) { + tracing::warn!(%hotkey, error = %err, "cannot unregister a hotkey"); + } + } + let mut ids = HashMap::new(); + for key in &heard { + let hotkey = match &layout { + Ok(layout) => { + hotkey(layout, key).ok_or_else(|| "this key cannot be heard outside the studio".into()) + } + Err(err) => Err(err.clone()), + }; + let hotkey = match hotkey { + Ok(hotkey) => hotkey, + Err(reason) => { + self.refused.push((*key, reason)); + continue; + } + }; + match manager.register(hotkey) { + Ok(()) => { + held.push((*key, hotkey)); + ids.insert(hotkey.id(), *key); + } + Err(err) => self.refused.push((*key, refusal(err))), + } + } + *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), + Backend::Focused(_) => {} + } + self.heard = heard; + } + + /// Why the platform would not hear `action`'s key, if it refused it. + pub fn refused(&self, action: &Action) -> Option<&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()) + } + + /// Why the keys the platform holds stay as they are, keys cleared since + /// included, until the studio restarts. + pub fn stuck(&self) -> Option<&str> { + self.stuck.as_deref() + } + + pub fn reach(&self) -> Reach { + match &self.backend { + Backend::Native { .. } => Reach::Global, + #[cfg(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, as the caller makes a page the preview passes the keyboard to. + /// A key that fires is taken, so nothing else in the window acts on it. + pub fn focused(&self, ctx: &egui::Context) { + let suspended = self.suspended.load(Ordering::Relaxed); + if suspended || ctx.text_edit_focused() || !matches!(self.reach(), Reach::Focused(_)) { + return; + } + let bindings = self.bound.lock().unwrap(); + ctx.input_mut(|input| { + input.events.retain(|event| { + let Some(pressed) = binding(event) else { + return true; + }; + if !bindings.values().any(|key| key.same(&pressed)) { + return true; + } + // A held key fires once, and its repeats are taken too. + if !matches!(event, egui::Event::Key { repeat: true, .. }) { + let _ = self.fire.send(pressed); + } + false + }); + }); + } +} + +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(); + GlobalHotKeyEvent::set_event_handler(Some(move |event: GlobalHotKeyEvent| { + if event.state() != HotKeyState::Pressed { + return; + } + if let Some(key) = heard.lock().unwrap().get(&event.id()) { + let _ = fire.send(*key); + } + })); + Self::Native { + manager, + held: Vec::new(), + registered, + } + } +} + +/// `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 { + 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. +fn refusal(err: global_hotkey::Error) -> String { + match err { + global_hotkey::Error::AlreadyRegistered(_) => "another app holds this key".into(), + err => format!("the platform refused this key: {err}"), + } +} + +/// The key `event` presses, ignoring repeats. +pub fn pressed(event: &egui::Event) -> Option { + match event { + egui::Event::Key { repeat: false, .. } => binding(event), + _ => None, + } +} + +/// The key `event` presses or repeats, if it says where the key sits. +fn binding(event: &egui::Event) -> Option { + match event { + egui::Event::Key { + key, + physical_key: Some(position), + pressed: true, + modifiers, + .. + } => Some(Binding { + key: KeyboardShortcut::new(*modifiers, *key), + position: *position, + }), + _ => None, + } +} + +/// `key` as the platform's hotkeys register it in `layout`, if they can hear it. +fn hotkey(layout: &Layout, key: &Binding) -> Option { + let held = key.key.modifiers; + let mut mods = Modifiers::empty(); + for (on, modifier) in [ + (held.shift, Modifiers::SHIFT), + (held.alt, Modifiers::ALT), + (held.ctrl, Modifiers::CONTROL), + (held.mac_cmd, Modifiers::META), + ] { + if on { + mods |= modifier; + } + } + Some(HotKey::new(Some(mods), layout.code(key.position)?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_key_is_the_same_wherever_the_layout_names_it() { + let press = |key, physical_key, modifiers| egui::Event::Key { + key, + physical_key, + pressed: true, + repeat: false, + modifiers, + }; + // 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| 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)), + (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); + // 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"))); + } +} 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/hotkeys/layout.rs b/rs/moq-studio/src/hotkeys/layout.rs new file mode 100644 index 00000000..4d37d43c --- /dev/null +++ b/rs/moq-studio/src/hotkeys/layout.rs @@ -0,0 +1,470 @@ +//! 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) + } + + /// 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, +/// 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) + } + + /// Whether global-hotkey finds `code`'s keysym on `keycode`. + fn holds(&self, code: Code, keycode: u32) -> bool { + KEYSYMS + .iter() + .find(|(named, _)| *named == code) + .is_some_and(|(_, keysym)| self.first(*keysym) == Some(keycode)) + } + + fn keysyms(&self, keycode: u32) -> Option<&[u32]> { + let index = keycode.checked_sub(self.min_keycode)? as usize; + self.keysyms.chunks(self.per_keycode).nth(index) + } + + /// The first keycode carrying `keysym`, as global-hotkey looks one up. + fn first(&self, keysym: u32) -> Option { + let index = self + .keysyms + .chunks(self.per_keycode) + .position(|keysyms| keysyms.contains(&keysym))?; + Some(self.min_keycode + index as u32) + } +} + +/// The keysym global-hotkey 0.9 grabs for each key `scancode` covers. +#[cfg(any(target_os = "linux", test))] +const KEYSYMS: [(Code, u32); 47] = [ + (Code::KeyA, 0x41), + (Code::KeyB, 0x42), + (Code::KeyC, 0x43), + (Code::KeyD, 0x44), + (Code::KeyE, 0x45), + (Code::KeyF, 0x46), + (Code::KeyG, 0x47), + (Code::KeyH, 0x48), + (Code::KeyI, 0x49), + (Code::KeyJ, 0x4a), + (Code::KeyK, 0x4b), + (Code::KeyL, 0x4c), + (Code::KeyM, 0x4d), + (Code::KeyN, 0x4e), + (Code::KeyO, 0x4f), + (Code::KeyP, 0x50), + (Code::KeyQ, 0x51), + (Code::KeyR, 0x52), + (Code::KeyS, 0x53), + (Code::KeyT, 0x54), + (Code::KeyU, 0x55), + (Code::KeyV, 0x56), + (Code::KeyW, 0x57), + (Code::KeyX, 0x58), + (Code::KeyY, 0x59), + (Code::KeyZ, 0x5a), + (Code::Digit0, 0x30), + (Code::Digit1, 0x31), + (Code::Digit2, 0x32), + (Code::Digit3, 0x33), + (Code::Digit4, 0x34), + (Code::Digit5, 0x35), + (Code::Digit6, 0x36), + (Code::Digit7, 0x37), + (Code::Digit8, 0x38), + (Code::Digit9, 0x39), + (Code::Comma, 0x2c), + (Code::Minus, 0x2d), + (Code::Period, 0x2e), + (Code::Slash, 0x2f), + (Code::Semicolon, 0x3b), + (Code::Equal, 0x3d), + (Code::BracketLeft, 0x5b), + (Code::Backslash, 0x5c), + (Code::BracketRight, 0x5d), + (Code::Backquote, 0x60), + // `leftsinglequotemark`, not `apostrophe`, so no layout's apostrophe + // key grabs. + (Code::Quote, 0xad0), +]; + +/// `key` as global-hotkey names the key at its position on a US keyboard. +fn code(key: Key) -> Option { + Some(match key { + Key::ArrowDown => Code::ArrowDown, + Key::ArrowLeft => Code::ArrowLeft, + Key::ArrowRight => Code::ArrowRight, + Key::ArrowUp => Code::ArrowUp, + Key::Escape => Code::Escape, + Key::Tab => Code::Tab, + Key::Backspace => Code::Backspace, + Key::Enter => Code::Enter, + Key::Space => Code::Space, + Key::Insert => Code::Insert, + Key::Delete => Code::Delete, + Key::Home => Code::Home, + Key::End => Code::End, + Key::PageUp => Code::PageUp, + Key::PageDown => Code::PageDown, + Key::Comma => Code::Comma, + Key::Backslash => Code::Backslash, + Key::Slash => Code::Slash, + Key::OpenBracket => Code::BracketLeft, + Key::CloseBracket => Code::BracketRight, + Key::Backtick => Code::Backquote, + Key::Minus => Code::Minus, + Key::Period => Code::Period, + Key::Equals => Code::Equal, + Key::Semicolon => Code::Semicolon, + Key::Quote => Code::Quote, + Key::Num0 => Code::Digit0, + Key::Num1 => Code::Digit1, + Key::Num2 => Code::Digit2, + Key::Num3 => Code::Digit3, + Key::Num4 => Code::Digit4, + Key::Num5 => Code::Digit5, + Key::Num6 => Code::Digit6, + Key::Num7 => Code::Digit7, + Key::Num8 => Code::Digit8, + Key::Num9 => Code::Digit9, + Key::A => Code::KeyA, + Key::B => Code::KeyB, + Key::C => Code::KeyC, + Key::D => Code::KeyD, + Key::E => Code::KeyE, + Key::F => Code::KeyF, + Key::G => Code::KeyG, + Key::H => Code::KeyH, + Key::I => Code::KeyI, + Key::J => Code::KeyJ, + Key::K => Code::KeyK, + Key::L => Code::KeyL, + Key::M => Code::KeyM, + Key::N => Code::KeyN, + Key::O => Code::KeyO, + Key::P => Code::KeyP, + Key::Q => Code::KeyQ, + Key::R => Code::KeyR, + Key::S => Code::KeyS, + Key::T => Code::KeyT, + Key::U => Code::KeyU, + Key::V => Code::KeyV, + Key::W => Code::KeyW, + Key::X => Code::KeyX, + Key::Y => Code::KeyY, + Key::Z => Code::KeyZ, + Key::F1 => Code::F1, + Key::F2 => Code::F2, + Key::F3 => Code::F3, + Key::F4 => Code::F4, + Key::F5 => Code::F5, + Key::F6 => Code::F6, + Key::F7 => Code::F7, + Key::F8 => Code::F8, + Key::F9 => Code::F9, + Key::F10 => Code::F10, + Key::F11 => Code::F11, + Key::F12 => Code::F12, + Key::F13 => Code::F13, + Key::F14 => Code::F14, + Key::F15 => Code::F15, + Key::F16 => Code::F16, + Key::F17 => Code::F17, + Key::F18 => Code::F18, + Key::F19 => Code::F19, + Key::F20 => Code::F20, + Key::F21 => Code::F21, + Key::F22 => Code::F22, + Key::F23 => Code::F23, + Key::F24 => Code::F24, + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Some of Microsoft's German layout (kbdgr.h): Z and Y swap, `#` is + /// VK_OEM_2, and ö is VK_OEM_3. The digit row keeps US virtual keys. + 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); + } + + #[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 new file mode 100644 index 00000000..f131299d --- /dev/null +++ b/rs/moq-studio/src/hotkeys/portal.rs @@ -0,0 +1,275 @@ +//! 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::{Binding, 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 } + } + + /// 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_if_modified(|wanted| { + let changed = *wanted != bindings; + *wanted = bindings; + changed + }); + } + + 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 keys of the shortcuts 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, binding)| { + NewShortcut::new(id(action, binding), action.to_string()) + .preferred_trigger(trigger(&binding.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| { + let (action, _) = by_id(&bindings, shortcut.id())?; + Some((action.clone(), 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")?; + // 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() => { + 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. 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(_) => { + 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 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 +/// 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::*; + use crate::hotkeys::Binding; + + #[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 = 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(); + // 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 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] + 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..c445521e 100644 --- a/rs/moq-studio/src/main.rs +++ b/rs/moq-studio/src/main.rs @@ -9,13 +9,17 @@ 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; mod devices; mod engine; mod feed; mod guests; mod health; +mod hotkeys; mod interact; mod ladder; mod mix; @@ -227,9 +231,16 @@ 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 { + 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), @@ -241,7 +252,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(); } @@ -251,6 +262,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. @@ -278,7 +351,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| { @@ -291,11 +369,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/snap.rs b/rs/moq-studio/src/snap.rs index b65e185c..977be5dd 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,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, 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. @@ -207,47 +267,106 @@ 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 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. + 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; - 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)); - } - // 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)); + match self.aspect.filter(|_| grab != Grab::Move) { + None => { + for axis in Axis::ALL { + if let Some(best) = self.nearest(grab, axis, &shown(&rect), others, canvas) { + shift(&mut rect, axis, grab.moves(axis), best); + } + } } - 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) => { + // 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| { + 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; + } } } 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 } } + + /// 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 @@ -404,13 +523,15 @@ 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 } } const SNAP: Snap = Snap { reach: 10.0, grid: None, + aspect: None, + posed: rect(0.0, 0.0, 0.0, 0.0), }; #[test] @@ -449,12 +570,123 @@ 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), + }; + // 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, 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)); + 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] + 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), }; - assert_eq!(bottom_right.drag(item, [10.0, -20.0]), rect(100.0, 100.0, 210.0, 80.0)); + // 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. + 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] @@ -521,8 +753,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 75b0b98a..8f209ef4 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,21 @@ 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, + hotkeys_shown: false, + refusal: None, preview, program, view: None, @@ -132,12 +143,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 +159,49 @@ 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()); + } + } + + /// 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) { @@ -175,7 +212,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 @@ -197,6 +234,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. @@ -211,6 +256,21 @@ impl eframe::App for Studio { } drop(state); + if !renames.is_empty() + && let Some(hotkeys) = &mut self.panels.hotkeys + { + hotkeys.hear(); + } + if let Some(fired) = self.panels.controls.fired() { + self.panels.message = Some(fired); + } + if let Some(hotkeys) = &self.panels.hotkeys + && !self.panels.interact.has_keyboard() + { + hotkeys.focused(ui.ctx()); + } + // 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 +331,17 @@ 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, + /// 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)>, preview: Registered, program: Registered, /// The canvas Preview, Program, and Items show: a further one by name, or @@ -320,8 +391,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 +399,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 +440,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), } } } @@ -503,6 +572,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. @@ -853,8 +928,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); @@ -917,6 +996,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)), @@ -928,12 +1011,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, }); @@ -951,27 +1037,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 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(|| { + // 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 { @@ -985,6 +1073,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(); @@ -1020,6 +1111,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(); @@ -1034,18 +1191,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 cut = state.cut(); - self.check(cut); + fire = Some(Action::Cut); } if ui .button("Transition") .on_hover_text("Take the preview to program by the transition set beside it") .clicked() { - let taken = state.transition(transition.clone(), kio::clock::Instant::now()); - self.check(taken); + fire = Some(Action::Transition); } match &next { Transition::Slide { direction, .. } | Transition::Wipe { direction, .. } => self.direction = *direction, @@ -1141,6 +1297,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()); } @@ -2373,11 +2533,14 @@ impl Panels { } } None => { + let mut each = self.controls.record_sources.load(Ordering::Relaxed); if ui.button("Start recording").clicked() { - let result = recorder.start(self.record_sources); + let result = recorder.start(each); self.check(result); } - ui.checkbox(&mut self.record_sources, "each source too"); + if ui.checkbox(&mut each, "each source too").changed() { + self.controls.record_sources.store(each, Ordering::Relaxed); + } ui.weak(format!("into {}", recorder.dir().display())); } }); @@ -2543,7 +2706,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(); } @@ -2620,36 +2787,178 @@ 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 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 panel ends, so the hotkeys + // are not held off unseen. + if !shown { + self.end_binding(); 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; + let pressed = ctx.input_mut(|input| { + let pressed = input.events.iter().find_map(|event| match event { + egui::Event::Key { + key, + pressed: true, + repeat: false, + .. + } => Some((*key, hotkeys::pressed(event))), + _ => None, + }); + if pressed.is_some() { + input + .events + .retain(|event| !matches!(event, egui::Event::Key { pressed: true, .. })); + } + pressed + }); + let Some((name, pressed)) = pressed else { return }; + let Some(key) = pressed else { + // Keypad keys among them, which egui names as their main keyboard twins. + self.refusal = Some(( + action, + format!("{} cannot be bound: the studio cannot tell where it sits", name.name()), + )); + return; + }; + self.binding = None; + hotkeys.suspend(false); + 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.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:#}"))); + } + + /// 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) { + 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."), + 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}"), + ), + }; + 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 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 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)); + let bindings = hotkeys.bindings(); + // A key bound to a scene the file no longer has, so it can be cleared. + let gone: Vec = 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 = bindings.get(action); + if self.binding.as_ref() == Some(action) { + ui.strong("press a key, or Escape"); + } else { + match bound { + Some(bound) => ui.monospace(ui.ctx().format_shortcut(&bound.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; + // 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); + } } /// The canvas's size and frame rate, applied at once rather than as each @@ -3468,7 +3777,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. @@ -3496,6 +3807,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, @@ -3525,6 +3839,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 { @@ -3942,29 +4273,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)]