323 lines
13 KiB
Rust
323 lines
13 KiB
Rust
//! Keyboard shortcuts. Most come from the command registry's `shortcut` strings; a few
|
|
//! context-sensitive keys (Space, Enter, F-keys, nudge, Tab) are handled here.
|
|
|
|
use crate::{MainWindow, SoundApp};
|
|
use egui::{Key, Modifiers};
|
|
use serde_json::json;
|
|
|
|
/// Parse "Cmd+Shift+X" style strings. Returns None for descriptive ones ("F1-F4", "Plus/Minus").
|
|
pub fn parse(s: &str) -> Option<(Modifiers, Key)> {
|
|
let mut m = Modifiers::NONE;
|
|
let mut key = None;
|
|
for part in s.split('+') {
|
|
match part.trim() {
|
|
"Cmd" => m.command = true,
|
|
"Shift" => m.shift = true,
|
|
"Alt" => m.alt = true,
|
|
"Ctrl" => m.ctrl = true,
|
|
"" => {}
|
|
k => {
|
|
if key.is_some() {
|
|
return None;
|
|
}
|
|
key = Some(match k {
|
|
"Space" => Key::Space,
|
|
"Enter" => Key::Enter,
|
|
"Home" => Key::Home,
|
|
"End" => Key::End,
|
|
"Tab" => Key::Tab,
|
|
"[" => Key::OpenBracket,
|
|
"]" => Key::CloseBracket,
|
|
"," => Key::Comma,
|
|
"." => Key::Period,
|
|
"/" => Key::Slash,
|
|
"'" => Key::Quote,
|
|
"=" => Key::Equals,
|
|
other => Key::from_name(other)?,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
key.map(|k| (m, k))
|
|
}
|
|
|
|
/// Whether the held modifiers `got` fire a shortcut that asks for `want`.
|
|
fn mods_match(want: Modifiers, got: Modifiers, mac: bool) -> bool {
|
|
// Off macOS egui reports Ctrl as both `ctrl` and `command`: Ctrl is the shortcut's Cmd, and a shortcut's
|
|
// Ctrl (the Mac Control key) has no key of its own there, so Ctrl+X is Cut, not Cmd+Ctrl+X.
|
|
let ctrl = if mac { want.ctrl == got.ctrl } else { !want.ctrl };
|
|
want.command == got.command && want.shift == got.shift && want.alt == got.alt && ctrl
|
|
}
|
|
|
|
/// Cmd+Ctrl+S opens Search. Off macOS Ctrl+S is Save (Cmd+S), so Search is reached from the Window menu.
|
|
fn search_chord(m: Modifiers, mac: bool) -> bool {
|
|
mac && m.command && m.ctrl
|
|
}
|
|
|
|
pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
|
|
// Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom.
|
|
ctx.options_mut(|o| o.zoom_with_keyboard = false);
|
|
if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() {
|
|
return;
|
|
}
|
|
let events: Vec<(Key, Modifiers)> = ctx.input(|i| {
|
|
i.events
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. } => Some((*key, *modifiers)),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
});
|
|
// Cmd and Ctrl are separate keys only on macOS (asked at runtime so the web build in a Mac browser agrees).
|
|
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
|
|
for (key, mods) in events {
|
|
if fixed(app, key, mods, mac) {
|
|
continue;
|
|
}
|
|
let hit =
|
|
soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac)));
|
|
if let Some(c) = hit {
|
|
let id = c.id;
|
|
if crate::menus::invoke_shortcut_dialog(app, id) {
|
|
continue;
|
|
}
|
|
let _ = app.run(id, json!({}));
|
|
} else if let Some(c) =
|
|
crate::menus::UI_COMMANDS.iter().find(|c| c.3.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac)))
|
|
{
|
|
let _ = app.run(c.0, json!({}));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Keys with context-dependent meaning. Returns true when handled.
|
|
fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
|
|
let plain = !m.command && !m.alt && !m.ctrl && !m.shift;
|
|
match key {
|
|
Key::Space if !m.command && !m.alt => {
|
|
let id = if m.shift { "transport.half_speed" } else { "transport.toggle" };
|
|
let _ = app.run(id, json!({}));
|
|
true
|
|
}
|
|
Key::Space if m.command => {
|
|
let _ = app.run("transport.record", json!({}));
|
|
true
|
|
}
|
|
Key::S if search_chord(m, mac) => {
|
|
let _ = app.run("window.search", json!({}));
|
|
true
|
|
}
|
|
Key::Equals if m.command && !(mac && m.ctrl) => {
|
|
let _ = app.run("window.toggle_mix_edit", json!({}));
|
|
true
|
|
}
|
|
Key::Enter if plain => {
|
|
let _ = app.run("markers.add", json!({}));
|
|
true
|
|
}
|
|
Key::F1 | Key::F2 | Key::F3 | Key::F4 if plain => {
|
|
let mode = match key {
|
|
Key::F1 => "shuffle",
|
|
Key::F2 => "slip",
|
|
Key::F3 => "spot",
|
|
_ => "grid",
|
|
};
|
|
let _ = app.run("edit.mode", json!({"mode": mode}));
|
|
true
|
|
}
|
|
Key::F5 | Key::F6 | Key::F7 | Key::F8 | Key::F9 | Key::F10 if plain => {
|
|
let tool = match key {
|
|
Key::F5 => "zoom",
|
|
Key::F6 => "trim",
|
|
Key::F7 => "selector",
|
|
Key::F8 => "grabber",
|
|
Key::F9 => "scrubber",
|
|
_ => "pencil",
|
|
};
|
|
let _ = app.run("edit.tool", json!({"tool": tool}));
|
|
true
|
|
}
|
|
Key::Plus | Key::Minus if !m.command && app.ui.window == MainWindow::Edit => {
|
|
let dir = if key == Key::Plus { 1 } else { -1 };
|
|
let _ = app.run("edit.nudge", json!({"direction": dir}));
|
|
true
|
|
}
|
|
Key::Tab if plain || m.alt => {
|
|
// Tab to next clip boundary (or transient when enabled).
|
|
let s = app.engine.session();
|
|
let at = s.edit.selection.start;
|
|
let back = m.alt;
|
|
if s.edit.tab_to_transient
|
|
&& !back
|
|
&& let Some(t) = s.edit.selected_tracks.first().copied()
|
|
{
|
|
let r = soundcraft_time::Range::new(at + 1, s.content_end());
|
|
if let Some(n) = soundcraft_engine::io::transients_in(s, t, r, 0.5).into_iter().find(|x| *x > at + 64) {
|
|
let _ = app.run("transport.locate", json!({"at": n}));
|
|
return true;
|
|
}
|
|
}
|
|
let mut edges: Vec<i64> = s
|
|
.tracks
|
|
.iter()
|
|
.filter(|t| s.edit.selected_tracks.contains(&t.id))
|
|
.flat_map(|t| t.clips().iter().flat_map(|c| [c.start, c.end()]))
|
|
.collect();
|
|
edges.sort_unstable();
|
|
let next = if back { edges.iter().rev().find(|e| **e < at).copied() } else { edges.iter().find(|e| **e > at).copied() };
|
|
if let Some(n) = next {
|
|
let _ = app.run("transport.locate", json!({"at": n}));
|
|
}
|
|
true
|
|
}
|
|
Key::ArrowUp | Key::ArrowDown if m.ctrl && !m.shift => {
|
|
let id = if key == Key::ArrowUp { "edit.extend_selection_up" } else { "edit.extend_selection_down" };
|
|
let _ = app.run(id, json!({}));
|
|
true
|
|
}
|
|
// Ctrl+Shift+Up/Down nudge clip gain off macOS too (the registry's Ctrl is the Mac Control key).
|
|
Key::ArrowUp | Key::ArrowDown if !mac && m.ctrl && m.shift && !m.alt => {
|
|
let id = if key == Key::ArrowUp { "clip.gain_nudge_up" } else { "clip.gain_nudge_down" };
|
|
let _ = app.run(id, json!({}));
|
|
true
|
|
}
|
|
// Commands Keyboard Focus: single-key editing (Edit window only).
|
|
k if plain && app.ui.window == MainWindow::Edit && app.engine.session().edit.keyboard_focus == "commands" && commands_focus(app, k) => true,
|
|
Key::R | Key::T if plain => {
|
|
// Zoom out / in on the timeline (Commands Focus style).
|
|
let id = if key == Key::T { "view.zoom_in" } else { "view.zoom_out" };
|
|
let _ = app.run(id, json!({}));
|
|
true
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Single-key commands. Returns true when `key` was handled.
|
|
fn commands_focus(app: &mut SoundApp, key: Key) -> bool {
|
|
let id = match key {
|
|
Key::A => "edit.trim_start_to_insertion",
|
|
Key::S => "edit.trim_end_to_insertion",
|
|
Key::D => "edit.fade_to_start",
|
|
Key::G => "edit.fade_to_end",
|
|
Key::F => "edit.fades_create",
|
|
Key::B => "edit.separate",
|
|
Key::X => "edit.cut",
|
|
Key::C => "edit.copy",
|
|
Key::V => "edit.paste",
|
|
Key::Z => "edit.undo",
|
|
Key::P => {
|
|
move_selection_track(app, -1);
|
|
return true;
|
|
}
|
|
Key::Semicolon => {
|
|
move_selection_track(app, 1);
|
|
return true;
|
|
}
|
|
Key::L | Key::Quote => {
|
|
tab_clip(app, key == Key::L);
|
|
return true;
|
|
}
|
|
_ => return false,
|
|
};
|
|
let _ = app.run(id, json!({}));
|
|
true
|
|
}
|
|
|
|
/// Move the edit selection to the track above/below (P / ;).
|
|
fn move_selection_track(app: &mut SoundApp, dir: i64) {
|
|
let s = app.engine.session();
|
|
let visible: Vec<u64> = s.tracks.iter().filter(|t| !t.hidden).map(|t| t.id.0).collect();
|
|
let Some(cur) = s.edit.selected_tracks.first().map(|t| t.0) else { return };
|
|
let Some(i) = visible.iter().position(|t| *t == cur) else { return };
|
|
let j = (i as i64 + dir).clamp(0, visible.len() as i64 - 1) as usize;
|
|
if let Some(t) = visible.get(j) {
|
|
let sel = s.edit.selection;
|
|
let _ = app.run("edit.select", json!({"tracks": [t], "start": sel.start, "end": sel.end, "exact": true}));
|
|
}
|
|
}
|
|
|
|
/// Tab to the previous/next clip boundary on the selected tracks (L / ').
|
|
fn tab_clip(app: &mut SoundApp, back: bool) {
|
|
let s = app.engine.session();
|
|
let at = s.edit.selection.start;
|
|
let mut edges: Vec<i64> =
|
|
s.tracks.iter().filter(|t| s.edit.selected_tracks.contains(&t.id)).flat_map(|t| t.clips().iter().flat_map(|c| [c.start, c.end()])).collect();
|
|
edges.sort_unstable();
|
|
let next = if back { edges.iter().rev().find(|e| **e < at).copied() } else { edges.iter().find(|e| **e > at).copied() };
|
|
if let Some(n) = next {
|
|
let _ = app.run("transport.locate", json!({"at": n}));
|
|
}
|
|
}
|
|
|
|
/// Shortcut text for display. The registry stores macOS-style strings ("Cmd+N"); off macOS the Cmd
|
|
/// modifier is the Ctrl key. Mac-only Control chords ("Cmd+Ctrl+S") have no key there, so they show nothing.
|
|
pub fn shortcut_label(s: &str, mac: bool) -> String {
|
|
if mac || !s.split('+').any(|p| p == "Cmd") {
|
|
return s.to_string();
|
|
}
|
|
if s.split('+').any(|p| p == "Ctrl") {
|
|
return String::new();
|
|
}
|
|
s.split('+').map(|p| if p == "Cmd" { "Ctrl" } else { p }).collect::<Vec<_>>().join("+")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod label_tests {
|
|
use super::shortcut_label;
|
|
|
|
#[test]
|
|
fn cmd_becomes_ctrl_off_mac() {
|
|
assert_eq!(shortcut_label("Cmd+N", false), "Ctrl+N");
|
|
assert_eq!(shortcut_label("Cmd+Shift+W", false), "Ctrl+Shift+W");
|
|
assert_eq!(shortcut_label("Cmd+Alt+B", false), "Ctrl+Alt+B");
|
|
assert_eq!(shortcut_label("Cmd+,", false), "Ctrl+,");
|
|
assert_eq!(shortcut_label("Shift+R", false), "Shift+R");
|
|
assert_eq!(shortcut_label("Cmd+Ctrl+S", false), "");
|
|
assert_eq!(shortcut_label("", false), "");
|
|
}
|
|
|
|
#[test]
|
|
fn unchanged_on_mac() {
|
|
for s in ["Cmd+N", "Cmd+Shift+W", "Cmd+Alt+B", "Shift+R", "Cmd+Ctrl+S"] {
|
|
assert_eq!(shortcut_label(s, true), s);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{mods_match, parse, search_chord};
|
|
use egui::Modifiers;
|
|
|
|
/// Ctrl held on Windows or Linux, as egui reports it.
|
|
const PC_CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
|
|
/// Cmd held on macOS.
|
|
const MAC_CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
|
|
|
|
fn want(s: &str) -> Modifiers {
|
|
parse(s).map(|(m, _)| m).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn ctrl_is_cmd_off_macos() {
|
|
assert!(mods_match(want("Cmd+S"), PC_CTRL, false));
|
|
assert!(mods_match(want("Cmd+Shift+N"), Modifiers { shift: true, ..PC_CTRL }, false));
|
|
assert!(mods_match(want("Cmd+X"), PC_CTRL, false));
|
|
assert!(!mods_match(want("Cmd+Ctrl+X"), PC_CTRL, false));
|
|
assert!(!mods_match(want("Cmd+S"), Modifiers::NONE, false));
|
|
assert!(!search_chord(PC_CTRL, false));
|
|
}
|
|
|
|
#[test]
|
|
fn cmd_and_ctrl_stay_apart_on_macos() {
|
|
assert!(mods_match(want("Cmd+S"), MAC_CMD, true));
|
|
assert!(!mods_match(want("Cmd+S"), Modifiers::CTRL, true));
|
|
assert!(mods_match(want("Cmd+Ctrl+X"), Modifiers { ctrl: true, ..MAC_CMD }, true));
|
|
assert!(!mods_match(want("Cmd+X"), Modifiers { ctrl: true, ..MAC_CMD }, true), "Cmd+Ctrl+X is not Cut");
|
|
assert!(!search_chord(MAC_CMD, true));
|
|
assert!(search_chord(Modifiers { ctrl: true, ..MAC_CMD }, true));
|
|
}
|
|
}
|