diff --git a/README.md b/README.md index b297d75..dff916e 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,8 @@ where things are. Scrubber tools; cut, copy, paste, duplicate, repeat, shift, insert silence, separate (at selection, on grid, at transients), heal, consolidate, strip silence, nudge; fades and crossfades in five shapes, drawn straight from the clip corners; playlists with comping lanes; - clip gain lines; edit groups; single-key Commands Focus shortcuts; undo for everything. + clip gain lines; edit groups; single-key Commands Focus shortcuts; trackpad scrolling that + follows your fingers and pinch-to-zoom around the pointer; undo for everything. - **Mixing:** fader and pan with automation, pre/post sends, busses, aux inputs, master faders, VCA masters, routing folders, solo modes, mute and solo groups, phase invert and trim, clip effects, and automatic plugin delay compensation. diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a11ba71..729dae8 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -52,6 +52,8 @@ pub struct CommandSpec { pub run: Run, pub journal: bool, pub undoable: bool, + /// Only touches `edit.zoom`, through `Engine::zoom_mut`. + pub view: bool, } /// Serializable description of a command. @@ -81,18 +83,16 @@ impl CommandSpec { } } -/// `cmd!(id, label, [menu…], shortcut, params, enabled, run)`; `query`/`noundo` variants. +/// `cmd!(id, label, [menu…], shortcut, params, enabled, run)`; `query`/`noundo`/`view` variants. #[macro_export] macro_rules! cmd { - (query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { - $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false } - }; - (noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { - $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false } - }; - ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { - $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true } + (query $($t:tt)*) => { $crate::cmd!(@spec false, false, false; $($t)*) }; + (noundo $($t:tt)*) => { $crate::cmd!(@spec true, false, false; $($t)*) }; + (view $($t:tt)*) => { $crate::cmd!(@spec false, false, true; $($t)*) }; + (@spec $journal:literal, $undoable:literal, $view:literal; $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { + $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: $journal, undoable: $undoable, view: $view } }; + ($id:literal, $($t:tt)*) => { $crate::cmd!(@spec true, true, false; $id, $($t)*) }; } pub fn command_specs() -> &'static [CommandSpec] { diff --git a/crates/engine/src/cmd/plugin_state.rs b/crates/engine/src/cmd/plugin_state.rs index be533aa..b3ca0d3 100644 --- a/crates/engine/src/cmd/plugin_state.rs +++ b/crates/engine/src/cmd/plugin_state.rs @@ -17,6 +17,7 @@ pub fn specs() -> Vec { run: insert_state, journal: false, undoable: false, + view: false, }] } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 9f24e3c..7b36430 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -7,54 +7,59 @@ use soundcraft_time::{GridValue, NoteValue, TimeFormat}; pub fn specs() -> Vec { vec![ - cmd!(noundo "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5)), - cmd!(noundo "view.zoom_out", "Zoom Out", [], Some("Cmd+["), "{}", always, |e, _| zoom(e, 2.0)), - cmd!(noundo "view.zoom_set", "Set Zoom", [], None, "{samples_per_px}", always, |e, p| { - let v = f64_or(p, "samples_per_px", 1024.0).clamp(0.25, 1_000_000.0); - e.session_mut().edit.zoom.samples_per_px = v; + cmd!(view "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5, None)), + cmd!(view "view.zoom_out", "Zoom Out", [], Some("Cmd+["), "{}", always, |e, _| zoom(e, 2.0, None)), + cmd!(view "view.zoom_by", "Zoom By", [], None, "{factor: >1 zooms in, x?: px into the timeline to hold (else the insertion point if on screen)}", always, |e, p| { + let f = p.get("factor").and_then(Value::as_f64).filter(|f| *f > 0.0).ok_or_else(|| bad("view.zoom_by", "factor must be a positive number"))?; + zoom(e, 1.0 / f, p.get("x").and_then(Value::as_f64)) + }), + cmd!(view "view.zoom_set", "Set Zoom", [], None, "{samples_per_px}", always, |e, p| { + let v = f64_or(p, "samples_per_px", 1024.0).clamp(MIN_SPP, MAX_SPP); + e.zoom_mut().samples_per_px = v; Ok(json!({"samples_per_px": v})) }), - cmd!(noundo "view.zoom_preset", "Zoom Preset", [], Some("Cmd+Ctrl+1-5"), "{preset: 1..5, store?: bool}", always, |e, p| { + cmd!(view "view.zoom_preset", "Zoom Preset", [], Some("Cmd+Ctrl+1-5"), "{preset: 1..5, store?: bool}", always, |e, p| { let i = (i64_or(p, "preset", 1).clamp(1, 5) - 1) as usize; - let s = e.session_mut(); + let z = e.zoom_mut(); if bool_or(p, "store", false) { - if let Some(z) = s.edit.zoom.presets.get_mut(i) { *z = s.edit.zoom.samples_per_px } - } else if let Some(z) = s.edit.zoom.presets.get(i) { - s.edit.zoom.samples_per_px = *z; + if let Some(slot) = z.presets.get_mut(i) { *slot = z.samples_per_px } + } else if let Some(v) = z.presets.get(i) { + z.samples_per_px = v.clamp(MIN_SPP, MAX_SPP); } - Ok(json!({"samples_per_px": s.edit.zoom.samples_per_px})) + Ok(json!({"samples_per_px": z.samples_per_px})) }), - cmd!(noundo "view.zoom_to_selection", "Zoom to Selection", [], Some("Alt+F"), "{width_px?: 1200}", always, |e, p| { + cmd!(view "view.zoom_to_selection", "Zoom to Selection", [], Some("Alt+F"), "{width_px?: 1200}", always, |e, p| { let w = f64_or(p, "width_px", 1200.0).max(100.0); - let s = e.session_mut(); - let r = s.edit.selection; + let r = e.session().edit.selection; if !r.is_empty() { - s.edit.zoom.samples_per_px = (r.len() as f64 / w).max(0.25); - s.edit.zoom.scroll = r.start; + let z = e.zoom_mut(); + z.samples_per_px = (r.len() as f64 / w).clamp(MIN_SPP, MAX_SPP); + z.scroll = r.start; } Ok(json!({})) }), - cmd!(noundo "view.zoom_fit", "Fill Window With Session", [], Some("Alt+A"), "{width_px?: 1200}", always, |e, p| { + cmd!(view "view.zoom_fit", "Fill Window With Session", [], Some("Alt+A"), "{width_px?: 1200}", always, |e, p| { let w = f64_or(p, "width_px", 1200.0).max(100.0); - let s = e.session_mut(); + let s = e.session(); let end = s.content_end().max(s.sample_rate.samples(30.0)); - s.edit.zoom.samples_per_px = (end as f64 * 1.05 / w).max(0.25); - s.edit.zoom.scroll = 0; + let z = e.zoom_mut(); + z.samples_per_px = (end as f64 * 1.05 / w).clamp(MIN_SPP, MAX_SPP); + z.scroll = 0; Ok(json!({})) }), - cmd!(noundo "view.scroll", "Scroll Timeline", [], None, "{to?: position, by_px?: n}", always, |e, p| { + cmd!(view "view.scroll", "Scroll Timeline", [], None, "{to?: position, by_px?: n}", always, |e, p| { let to = position_param(e, "view.scroll", p, "to")?; let by = f64_or(p, "by_px", 0.0); - let s = e.session_mut(); - let spp = s.edit.zoom.samples_per_px; - s.edit.zoom.scroll = to.unwrap_or(s.edit.zoom.scroll + soundcraft_time::to_samples(by * spp)).max(0); - Ok(json!({"scroll": s.edit.zoom.scroll})) + let z = e.zoom_mut(); + let by = soundcraft_time::to_samples(by * z.samples_per_px); + z.scroll = to.unwrap_or(z.scroll.saturating_add(by)).clamp(0, MAX_POSITION); + Ok(json!({"scroll": z.scroll})) }), - cmd!(noundo "view.waveform_zoom", "Waveform Zoom", [], Some("Cmd+Alt+[ / ]"), "{factor?: 2 | value}", always, |e, p| { - let s = e.session_mut(); - let z = if let Some(v) = p.get("value").and_then(Value::as_f64) { v as f32 } else { s.edit.zoom.waveform_zoom * f32_or(p, "factor", 2.0) }; - s.edit.zoom.waveform_zoom = if z.is_finite() { z.clamp(0.25, 64.0) } else { 1.0 }; - Ok(json!({"waveform_zoom": s.edit.zoom.waveform_zoom})) + cmd!(view "view.waveform_zoom", "Waveform Zoom", [], Some("Cmd+Alt+[ / ]"), "{factor?: 2 | value}", always, |e, p| { + let z = e.zoom_mut(); + let w = if let Some(v) = p.get("value").and_then(Value::as_f64) { v as f32 } else { z.waveform_zoom * f32_or(p, "factor", 2.0) }; + z.waveform_zoom = if w.is_finite() { w.clamp(0.25, 64.0) } else { 1.0 }; + Ok(json!({"waveform_zoom": z.waveform_zoom})) }), cmd!(noundo "view.ruler", "Rulers", ["View", "Rulers"], None, "{ruler: bars_beats|min_secs|timecode|feet_frames|samples|tempo|meter|markers|key|chords, visible?: bool}", always, ruler), cmd!(noundo "view.main_counter", "Main Counter", ["View", "Main Counter"], None, "{format: bars_beats|min_secs|timecode|feet_frames|samples}", always, |e, p| { @@ -88,18 +93,19 @@ pub fn specs() -> Vec { ] } -fn zoom(e: &mut Engine, f: f64) -> Result { - let s = e.session_mut(); - let anchor = s.edit.selection.start; - let old = s.edit.zoom.samples_per_px; - let new = (old * f).clamp(0.25, 1_000_000.0); - // Keep the insertion point at the same screen x when it is visible. - let x = (anchor - s.edit.zoom.scroll) as f64 / old; - if (0.0..4000.0).contains(&x) { - s.edit.zoom.scroll = (anchor - soundcraft_time::to_samples(x * new)).max(0); +const MIN_SPP: f64 = 0.25; +const MAX_SPP: f64 = 1_000_000.0; + +fn zoom(e: &mut Engine, f: f64, x: Option) -> Result { + let insertion = e.session().edit.selection.start as f64; + let z = e.zoom_mut(); + let (old, scroll) = (z.samples_per_px, z.scroll as f64); + let new = (old * f).clamp(MIN_SPP, MAX_SPP); + if let Some(x) = x.or_else(|| Some((insertion - scroll) / old).filter(|x| (0.0..4000.0).contains(x))) { + z.scroll = soundcraft_time::to_samples(scroll + x * (old - new)).clamp(0, MAX_POSITION); } - s.edit.zoom.samples_per_px = new; - Ok(json!({"samples_per_px": new})) + z.samples_per_px = new; + Ok(json!({"samples_per_px": new, "scroll": z.scroll})) } fn ruler(e: &mut Engine, p: &Value) -> Result { @@ -157,4 +163,55 @@ mod tests { assert_eq!(grid_param(&json!({"v": {"seconds": 0.5}}), "v"), Some(GridValue::Seconds(0.5))); assert_eq!(grid_param(&json!({"v": "nonsense"}), "v"), None); } + + fn view(e: &Engine) -> (f64, Samples) { + (e.session().edit.zoom.samples_per_px, e.session().edit.zoom.scroll) + } + + #[test] + fn zoom_by_holds_the_position_under_the_pointer() { + let mut e = Engine::default(); + e.execute("view.zoom_set", &json!({"samples_per_px": 100.0})).unwrap(); + e.execute("view.scroll", &json!({"to": 48_000})).unwrap(); + e.execute("view.zoom_by", &json!({"factor": 2.0, "x": 300.0})).unwrap(); + assert_eq!(view(&e), (50.0, 78_000 - 300 * 50)); + e.execute("view.zoom_by", &json!({"factor": 0.5, "x": 300.0})).unwrap(); + assert_eq!(view(&e), (100.0, 48_000)); + } + + #[test] + fn zoom_and_scroll_stay_in_range() { + let mut e = Engine::default(); + e.execute("edit.select", &json!({"start": 0, "end": 4_000_000_000_i64})).unwrap(); + e.execute("view.zoom_to_selection", &json!({})).unwrap(); + assert_eq!(view(&e).0, MAX_SPP); + e.execute("view.zoom_by", &json!({"factor": 1e12, "x": 0.0})).unwrap(); + assert_eq!(view(&e).0, MIN_SPP); + e.execute("view.zoom_by", &json!({"factor": 1e-300, "x": 1e300})).unwrap(); + assert_eq!(view(&e), (MAX_SPP, 0)); + for bad in [json!(0.0), json!(-2.0), json!("2"), json!(null)] { + assert!(e.execute("view.zoom_by", &json!({"factor": bad})).is_err(), "{bad}"); + } + e.execute("view.scroll", &json!({"by_px": 1e300})).unwrap(); + assert_eq!(view(&e).1, MAX_POSITION); + e.execute("view.scroll", &json!({"by_px": -1e300})).unwrap(); + assert_eq!(view(&e).1, 0); + } + + #[test] + fn view_commands_skip_undo_the_journal_and_session_copies() { + let mut e = Engine::default(); + let t = e.execute("track.new", &json!({})).unwrap()["tracks"][0].clone(); + let steps = e.undo_history().len(); + for db in [-1.0, -2.0] { + e.execute_merged("mix.volume", &json!({"track": t, "db": db}), "fader").unwrap(); + e.execute("view.scroll", &json!({"by_px": 5.0})).unwrap(); + } + assert_eq!(e.undo_history().len(), steps + 1); + let state = |e: &Engine| (std::ptr::from_ref(e.session()), e.revision, e.journal.len()); + let before = state(&e); + e.execute("view.zoom_by", &json!({"factor": 2.0})).unwrap(); + e.execute("view.scroll", &json!({"by_px": 1.0})).unwrap(); + assert_eq!(state(&e), before); + } } diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index b5de447..191adb8 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -86,6 +86,7 @@ pub fn session(e: &Engine, full: bool) -> Value { })).collect::>(), "selection": {"start": s.edit.selection.start, "end": s.edit.selection.end, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips}, "edit_mode": s.edit.edit_mode, "tool": s.edit.tool, "grid": s.edit.grid.label(), "nudge": s.edit.nudge.label(), + "zoom": {"samples_per_px": s.edit.zoom.samples_per_px, "scroll": s.edit.zoom.scroll}, "transport": e.transport, "undo": e.undo_label(), "redo": e.redo_label(), }) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 50d82e4..d159b9b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -16,7 +16,7 @@ pub mod io; pub mod score; use serde_json::{Value, json}; -use soundcraft_model::{Clip, Session, TrackId}; +use soundcraft_model::{Clip, Session, TrackId, ZoomState}; use soundcraft_time::Samples; use std::sync::Arc; @@ -108,7 +108,7 @@ pub struct Engine { pub journal: Vec<(String, Value)>, /// Path of the session file, if saved. pub path: Option, - /// Revision counter; bumps on every document change (UI caches key on it). + /// Revision counter; bumps on every document change but zoom/scroll (UI caches key on it). pub revision: u64, dirty: bool, /// Gesture key for undo coalescing (see `execute_merged`). @@ -158,6 +158,12 @@ impl Engine { Arc::make_mut(&mut self.doc) } + /// Like `session_mut`, for `edit.zoom` only and without bumping `revision`. + pub fn zoom_mut(&mut self) -> &mut ZoomState { + self.dirty = true; + &mut Arc::make_mut(&mut self.doc).edit.zoom + } + /// Replace the whole document (open / new); clears history. pub fn replace_session(&mut self, s: Session) { self.transport = TransportStatus { position: s.edit.selection.start.max(0), ..TransportStatus::default() }; @@ -227,7 +233,6 @@ impl Engine { /// Run a command by id. Never panics: escaped panics become `EngineError::Internal` and the /// document is restored. pub fn execute(&mut self, id: &str, params: &Value) -> Result { - self.merge_key = None; let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; if let Err(reason) = (spec.enabled)(self) { // A call that names its targets (tracks, clips, a range) does not need a selection; @@ -237,6 +242,10 @@ impl Engine { return Err(EngineError::Disabled(id.to_string(), reason)); } } + if spec.view { + return self.execute_view(spec, params); + } + self.merge_key = None; let before = Arc::clone(&self.doc); let rev = self.revision; let dirty = self.dirty; @@ -244,12 +253,10 @@ impl Engine { let result = match result { Ok(r) => r, Err(p) => { - let msg = - p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); self.doc = before; self.revision = rev.wrapping_add(1); self.dirty = dirty; - return Err(EngineError::Internal(id.to_string(), msg)); + return Err(EngineError::Internal(id.to_string(), panic_message(&*p))); } }; match result { @@ -274,6 +281,16 @@ impl Engine { } } + fn execute_view(&mut self, spec: &CommandSpec, params: &Value) -> Result { + let zoom = self.doc.edit.zoom; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params))) + .unwrap_or_else(|p| Err(EngineError::Internal(spec.id.to_string(), panic_message(&*p)))); + if result.is_err() && self.doc.edit.zoom != zoom { + Arc::make_mut(&mut self.doc).edit.zoom = zoom; + } + result + } + /// Execute as part of a continuous gesture (fader drag, knob turn, slider): consecutive calls /// with the same `key` collapse into a single undo step. pub fn execute_merged(&mut self, id: &str, params: &Value, key: &str) -> Result { @@ -329,6 +346,10 @@ fn install_panic_hook() { }); } +fn panic_message(p: &(dyn std::any::Any + Send)) -> String { + p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()) +} + #[cfg(test)] mod merge_tests { use super::*; diff --git a/crates/engine/tests/hostile_params.rs b/crates/engine/tests/hostile_params.rs index 8a2a382..5aa6d39 100644 --- a/crates/engine/tests/hostile_params.rs +++ b/crates/engine/tests/hostile_params.rs @@ -45,6 +45,9 @@ const KEYS: &[&str] = &[ "numerator", "denominator", "factor", + "x", + "by_px", + "samples_per_px", "sensitivity", "rating", "direction", diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 93b0626..2ea380e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -7,6 +7,7 @@ //! - `engine.commands {filter?}`, `engine.parity`, `session.inspect {detail?}`, `ui.inspect` //! - `ui.menu.list`, `ui.menu.invoke {path: "Track > New..." | id}` (menu-style: may open dialogs) //! - `ui.click {x,y,button?,count?,mods?}`, `ui.move {x,y}`, `ui.drag {x,y,to_x,to_y,steps?}` +//! - `ui.scroll {x,y,dx?,dy?,unit?,steps?,mods?}` (trackpad swipe or wheel), `ui.zoom {x,y,factor,steps?}` (pinch) //! - `ui.key {key, cmd?, shift?, alt?, ctrl?}`, `ui.text {text}` //! - `ui.screenshot {path?}` (PNG; base64 when no path), `ui.set {...UiState fields}` //! - `app.quit` @@ -91,6 +92,8 @@ fn mods(p: &Value) -> egui::Modifiers { fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Option { let p = &req.params; let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; + let pos = egui::pos2(f("x"), f("y")); + let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200); Some(match req.method.as_str() { "engine.execute" | "command" => { let id = p.get("command").or_else(|| p.get("id")).and_then(Value::as_str).unwrap_or(""); @@ -147,11 +150,10 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti Err(e) => err(e), }, "ui.move" => { - app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x"), f("y")))); + app.synthetic.push(egui::Event::PointerMoved(pos)); ok(json!({})) } "ui.click" => { - let pos = egui::pos2(f("x"), f("y")); let button = match p.get("button").and_then(Value::as_str) { Some("right") | Some("secondary") => egui::PointerButton::Secondary, _ => egui::PointerButton::Primary, @@ -166,9 +168,7 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti ok(json!({})) } "ui.drag" => { - let a = egui::pos2(f("x"), f("y")); - let b = egui::pos2(f("to_x"), f("to_y")); - let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200); + let (a, b) = (pos, egui::pos2(f("to_x"), f("to_y"))); let m = mods(p); app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }); @@ -179,6 +179,27 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }); ok(json!({})) } + "ui.scroll" => { + let line = p.get("unit").and_then(Value::as_str) == Some("line"); + let unit = if line { egui::MouseWheelUnit::Line } else { egui::MouseWheelUnit::Point }; + let d = |k: &str| f(k).clamp(-100_000.0, 100_000.0) / steps as f32; + let (delta, modifiers) = (egui::vec2(d("dx"), d("dy")), mods(p)); + let wheel = |phase, delta| egui::Event::MouseWheel { unit, delta, phase, modifiers }; + app.synthetic.push(egui::Event::PointerMoved(pos)); + app.synthetic.extend((!line).then(|| wheel(egui::TouchPhase::Start, egui::Vec2::ZERO))); + app.synthetic.extend((0..steps).map(|_| wheel(egui::TouchPhase::Move, delta))); + app.synthetic.extend((!line).then(|| wheel(egui::TouchPhase::End, egui::Vec2::ZERO))); + ok(json!({})) + } + "ui.zoom" => { + let Some(factor) = p.get("factor").and_then(Value::as_f64).filter(|v| *v > 0.0) else { + return Some(err("ui.zoom needs a positive factor (> 1 zooms in)")); + }; + let each = factor.clamp(1e-3, 1e3).powf(1.0 / steps as f64) as f32; + app.synthetic.push(egui::Event::PointerMoved(pos)); + app.synthetic.extend((0..steps).map(|_| egui::Event::Zoom(each))); + ok(json!({})) + } "ui.key" => { let name = p.get("key").and_then(Value::as_str).unwrap_or(""); let Some(key) = key_from(name) else { return Some(err(format!("unknown key `{name}`"))) }; diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 1e24bab..f13bc78 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -44,6 +44,11 @@ pub struct EditLayout { pub last_scroll: Samples, /// Follow target issued by the last overlay frame (`None` when none). pub last_follow_to: Option, + #[serde(skip)] + pub scroll_carry: f64, + /// The current swipe's movement per axis until either passes 6 points; it scrolls only along the larger. + #[serde(skip)] + swipe: egui::Vec2, } impl EditLayout { @@ -203,23 +208,7 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { let rulers_rect = Rect::from_min_max(pos2(full.min.x, full.min.y), pos2(full.max.x, full.min.y + rulers_h)); app.edit_layout.timeline = [tl.min.x, tl.min.y, tl.max.x, tl.max.y]; ui.painter().rect_filled(full, 0.0, t.window_bg); - // Wheel: vertical scroll tracks, shift/horizontal scroll timeline, cmd = zoom. - let hovered = ui.rect_contains_pointer(Rect::from_min_max(pos2(full.min.x, tl.min.y), full.max)); - if hovered { - let (delta, mods) = ui.input(|i| (i.smooth_scroll_delta, i.modifiers)); - if mods.command && delta.y.abs() > 0.0 { - let id = if delta.y > 0.0 { "view.zoom_in" } else { "view.zoom_out" }; - let _ = app.run(id, json!({})); - } else { - let dx = if mods.shift { delta.y } else { delta.x }; - if dx.abs() > 0.0 { - let _ = app.engine.execute("view.scroll", &json!({"by_px": -dx})); - } - if !mods.shift && delta.y.abs() > 0.0 { - app.edit_layout.scroll_by(-delta.y, tl.height()); - } - } - } + scroll_and_zoom(app, ui, full, tl); draw_rulers(app, ui, rulers_rect, tl, &rulers); // Tracks. let mut y = tl.min.y - app.edit_layout.scroll_y; @@ -295,12 +284,45 @@ pub(crate) fn click_track(app: &mut SoundApp, id: TrackId, mods: egui::Modifiers let _ = app.run("edit.select", json!({"tracks": ids})); } +fn scroll_and_zoom(app: &mut SoundApp, ui: &Ui, area: Rect, tl: Rect) { + let (delta, zoom, pointer, scrolling) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos(), i.is_scrolling())); + let mut swipe = if scrolling { app.edit_layout.swipe } else { egui::Vec2::ZERO }; + if swipe.max_elem() < 6.0 { + swipe += delta.abs(); + } + app.edit_layout.swipe = swipe; + let Some(p) = pointer.filter(|_| tl.is_positive() && ui.rect_contains_pointer(area)) else { return }; + if zoom != 1.0 { + let _ = app.engine.execute("view.zoom_by", &json!({"factor": zoom, "x": p.x.clamp(tl.min.x, tl.max.x) - tl.min.x})); + } + let horizontal = swipe.x > swipe.y; + if horizontal && delta.x != 0.0 { + pan(app, -f64::from(delta.x)); + } + if !horizontal && delta.y != 0.0 { + app.edit_layout.scroll_by(-delta.y, tl.height()); + } +} + +fn pan(app: &mut SoundApp, px: f64) { + let z = app.engine.session().edit.zoom; + let want = (z.scroll as f64 + app.edit_layout.scroll_carry + px * z.samples_per_px).clamp(0.0, soundcraft_engine::cmd::MAX_POSITION as f64); + if want.is_nan() { + return; + } + let to = soundcraft_time::to_samples(want); + app.edit_layout.scroll_carry = want - to as f64; + if to != z.scroll { + let _ = app.engine.execute("view.scroll", &json!({"to": to})); + } +} + fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) { let t = Tokens::current(); ui.painter().rect_filled(sb, 0.0, t.panel_bg); let s = app.engine.session(); - let total = (s.content_end() + s.sample_rate.samples(30.0)).max(1) as f64; let vis = f64::from(tl.width()) * s.edit.zoom.samples_per_px; + let total = (s.content_end().saturating_add(s.sample_rate.samples(30.0)).max(1) as f64).max(s.edit.zoom.scroll as f64 + vis); let frac = (vis / total).clamp(0.02, 1.0) as f32; let off = (s.edit.zoom.scroll as f64 / total).clamp(0.0, 1.0) as f32; let thumb = Rect::from_min_size(pos2(sb.min.x + sb.width() * off, sb.min.y + 3.0), vec2((sb.width() * frac).max(24.0), sb.height() - 6.0)); @@ -309,7 +331,7 @@ fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) { if resp.dragged() { let dx = resp.drag_delta().x / sb.width(); let by_px = f64::from(dx) * total / s.edit.zoom.samples_per_px; - let _ = app.engine.execute("view.scroll", &json!({"by_px": by_px})); + pan(app, by_px); } } diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index a4882d4..e69d625 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -15,7 +15,7 @@ const VEL_H: f32 = 64.0; pub struct MidiEditorState { pub selected: Vec, pub drag: Option, - pub top_pitch: i32, + pub scroll: f32, pub clip: Option, /// Rubber-band selection start (screen position). pub band: Option, @@ -127,28 +127,27 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let vel = Rect::from_min_max(pos2(roll.min.x, roll.max.y + 4.0), pos2(roll.max.x, full.max.y - 2.0)); // Vertical range: centre on the notes the first time. let rows = (roll.height() / ROW_H).floor() as i32; - if app.midi.top_pitch == 0 || app.midi.clip != Some(cid) { + if app.midi.clip != Some(cid) { let hi = sequence.notes.iter().map(|n| i32::from(n.pitch)).max().unwrap_or(72); let lo = sequence.notes.iter().map(|n| i32::from(n.pitch)).min().unwrap_or(48); - let mid = (hi + lo) / 2; - app.midi.top_pitch = (mid + rows / 2).clamp(rows.min(127), 127); + app.midi.scroll = (127 - (hi + lo) / 2 - rows / 2) as f32 * ROW_H; app.midi.clip = Some(cid); } if ui.rect_contains_pointer(roll) { - let dy = ui.input(|i| i.smooth_scroll_delta.y); - if dy.abs() > 0.5 { - app.midi.top_pitch = (app.midi.top_pitch + (dy / ROW_H).round() as i32).clamp(rows.min(127), 127); - } + app.midi.scroll -= ui.input(|i| i.smooth_scroll_delta.y); } - let top = app.midi.top_pitch; + app.midi.scroll = app.midi.scroll.clamp(0.0, (128.0 * ROW_H - roll.height()).max(0.0)); + let scroll = app.midi.scroll; let px_per_tick = roll.width() / len_ticks as f32; let x_of = |tick: i64| roll.min.x + tick as f32 * px_per_tick; - let y_of = |pitch: i32| roll.min.y + (top - pitch) as f32 * ROW_H; + let y_of = |pitch: i32| roll.min.y + (127 - pitch) as f32 * ROW_H - scroll; + let pitch_at = |y: f32| 127 - ((y - roll.min.y + scroll) / ROW_H).floor() as i32; let painter = ui.painter().with_clip_rect(roll.union(keys)); // Background rows and keys. - for r in 0..=rows { + let top = pitch_at(roll.min.y); + for r in 0..=rows + 1 { let pitch = top - r; - let y = roll.min.y + r as f32 * ROW_H; + let y = y_of(pitch); let row = Rect::from_min_size(pos2(roll.min.x, y), vec2(roll.width(), ROW_H)); painter.rect_filled(row, 0.0, if is_black(pitch) { Color32::from_rgb(30, 30, 32) } else { Color32::from_rgb(40, 40, 42) }); let key = Rect::from_min_size(pos2(keys.min.x, y), vec2(KEY_W - 2.0, ROW_H - 1.0)); @@ -288,7 +287,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { // Audition on the instrument would go here (needs a live MIDI input path). } None => { - let pitch = (top - ((p.y - roll.min.y) / ROW_H).floor() as i32).clamp(0, 127); + let pitch = pitch_at(p.y).clamp(0, 127); let start = (((p.x - roll.min.x) / px_per_tick) as i64 / grid_ticks) * grid_ticks; let _ = app.run( "midi.note_add", diff --git a/crates/ui-egui/tests/trackpad.rs b/crates/ui-egui/tests/trackpad.rs new file mode 100644 index 0000000..83a4180 --- /dev/null +++ b/crates/ui-egui/tests/trackpad.rs @@ -0,0 +1,95 @@ +use egui::{Pos2, RawInput, Rect, pos2, vec2}; +use serde_json::{Value, json}; +use soundcraft_time::to_samples; +use soundcraft_ui_egui::{ControlRequest, Services, SoundApp}; +use std::sync::mpsc::{Sender, channel}; + +struct Rig { + ctx: egui::Context, + app: SoundApp, + tx: Sender, + width: f32, +} + +impl Rig { + fn new(width: f32) -> Rig { + let (tx, rx) = channel(); + let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()).with_control(rx); + let mut r = Rig { ctx: egui::Context::default(), app, tx, width }; + r.frames(6); + r + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + let mut raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 900.0))), ..Default::default() }; + self.app.raw_input_hook(&mut raw); + self.ctx + .run_ui(raw, |ui| { + self.app.logic(ui.ctx()); + self.app.ui(ui); + }) + .textures_delta + .clear(); + } + } + + fn send(&mut self, method: &str, params: Value) { + let (req, reply) = ControlRequest::new(method, params); + self.tx.send(req).unwrap(); + self.frames(40); + assert_eq!(reply.try_recv().unwrap()["ok"], true, "{method}"); + } + + fn view(&self) -> (f64, i64) { + let z = self.app.engine.session().edit.zoom; + (z.samples_per_px, z.scroll) + } + + fn timeline(&self) -> Rect { + let [x0, y0, x1, y1] = self.app.edit_layout.timeline; + Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)) + } +} + +#[test] +fn swipes_and_wheels_scroll_with_the_fingers_on_one_axis_even_slowly() { + let mut r = Rig::new(1400.0); + let (tl, (spp, scroll)) = (r.timeline(), r.view()); + let (x, y) = (tl.center().x, tl.center().y); + assert!(r.app.edit_layout.content_h - tl.height() > 60.0); + r.send("ui.scroll", json!({"x": x, "y": y, "dx": -240.0, "dy": -60.0})); + assert_eq!(r.view(), (spp, scroll + to_samples(240.0 * spp))); + assert_eq!(r.app.edit_layout.scroll_y, 0.0); + r.send("ui.scroll", json!({"x": x, "y": y, "dx": -30.0, "dy": -60.0})); + assert_eq!(r.view(), (spp, scroll + to_samples(240.0 * spp))); + assert_eq!(r.app.edit_layout.scroll_y, 60.0); + r.send("ui.scroll", json!({"x": x, "y": y, "dy": 1.0, "unit": "line", "steps": 1})); + assert!((r.app.edit_layout.scroll_y - 20.0).abs() < 1.0, "{}", r.app.edit_layout.scroll_y); + r.send("ui.scroll", json!({"x": x, "y": tl.min.y - 20.0, "dx": 100_000.0})); + assert_eq!(r.view(), (spp, 0)); + r.app.engine.execute("view.zoom_set", &json!({"samples_per_px": 0.25})).unwrap(); + let scroll_y = r.app.edit_layout.scroll_y; + r.send("ui.scroll", json!({"x": x, "y": y, "dx": -12.0, "dy": -6.0, "steps": 24})); + assert_eq!(r.view().1, 3); + assert_eq!(r.app.edit_layout.scroll_y, scroll_y); +} + +#[test] +fn pinch_and_cmd_scroll_zoom_around_the_pointer_and_shift_scroll_pans() { + let mut r = Rig::new(1400.0); + let tl = r.timeline(); + let (x, y) = (tl.min.x + 300.0, tl.center().y); + let under = |r: &Rig| r.view().1 as f64 + 300.0 * r.view().0; + let (spp, at) = (r.view().0, under(&r)); + r.send("ui.zoom", json!({"x": x, "y": y, "factor": 4.0})); + assert!((r.view().0 * 4.0 / spp - 1.0).abs() < 1e-4, "{spp} -> {}", r.view().0); + assert!((under(&r) - at).abs() < 8.0); + r.send("ui.scroll", json!({"x": x, "y": y, "dy": 100.0, "cmd": true})); + assert!(r.view().0 < spp / 4.0); + assert!((under(&r) - at).abs() < 8.0); + let ((spp, scroll), scroll_y) = (r.view(), r.app.edit_layout.scroll_y); + r.send("ui.scroll", json!({"x": x, "y": y, "dy": -100.0, "shift": true})); + assert_eq!(r.view(), (spp, scroll + to_samples(100.0 * spp))); + assert_eq!(r.app.edit_layout.scroll_y, scroll_y); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 32a4932..95a5bbf 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -42,13 +42,15 @@ Errors: `{"id": 1, "ok": false, "error": "bad parameters for `mix.volume`: no tr | `engine.execute` (alias `command`) | `{command, params}` | Run a command programmatically. **Never opens a dialog**; empty params use defaults or the current selection. | | `engine.commands` | `{filter?}` | Every command: id, label, menu path, shortcut, params doc, enabled + reason. Includes UI-layer commands (`window.*`). | | `engine.parity` | `{}` | Parity report against the incumbent's menu catalog. | -| `session.inspect` | `{detail?: "summary"|"full"}` | The document: tracks, clips (with times), inserts (+params when full), sends, routing, automation, markers, groups, busses, sources (`loaded` true when decoded audio is in the pool), selection, transport, undo label. | +| `session.inspect` | `{detail?: "summary"|"full"}` | The document: tracks, clips (with times), inserts (+params when full), sends, routing, automation, markers, groups, busses, sources (`loaded` true when decoded audio is in the pool), selection, zoom (`samples_per_px`, `scroll`), transport, undo label. | | `ui.inspect` | `{}` | UI state (`window`, panels, dialogs), window size, playing/position, audio device, and `edit_layout` (timeline rect and every track row's rect, for clicking). | | `ui.set` | any `UiState` fields | e.g. `{"window": "Mix", "narrow_mix": true}`. | | `ui.menu.list` | `{}` | Every catalog menu path and the command that implements it (or null). | | `ui.menu.invoke` | `{path}` or `{id}` | Like clicking the menu item: may open its dialog (e.g. `"Track > New..."`). | | `ui.click` | `{x, y, button?, count?, cmd?, shift?, alt?, ctrl?}` | Synthetic click (window points). | | `ui.drag` | `{x, y, to_x, to_y, steps?}` | Synthetic drag with the primary button. | +| `ui.scroll` | `{x, y, dx?, dy?, unit?: "point"\|"line", steps?, cmd?, shift?, alt?, ctrl?}` | A two-finger trackpad swipe over `(x, y)`: `dx`/`dy` points spread over `steps` frames (default 8). Positive values move the content right/down (natural scrolling). `unit: "line"` sends mouse-wheel notches instead. With `cmd` it zooms; with `shift` it scrolls sideways. | +| `ui.zoom` | `{x, y, factor, steps?}` | A trackpad pinch around `(x, y)`: `factor` > 1 spreads the fingers (zooms in). | | `ui.move` | `{x, y}` | Move the pointer. | | `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press (`Space`, `Enter`, `A`, `F7`, `=`…). | | `ui.text` | `{text}` | Type text into the focused field. |