Merge drag-selection (#49) into trackpad

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-11 20:57:06 -04:00
commit c5efedd740
12 files changed
+318 -95

No files matched your search

+2 -1
View File
@@ -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.
+9 -9
View File
@@ -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] {
+1
View File
@@ -17,6 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
run: insert_state,
journal: false,
undoable: false,
view: false,
}]
}
+98 -41
View File
@@ -7,54 +7,59 @@ use soundcraft_time::{GridValue, NoteValue, TimeFormat};
pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
]
}
fn zoom(e: &mut Engine, f: f64) -> Result<Value> {
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<f64>) -> Result<Value> {
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<Value> {
@@ -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);
}
}
+1
View File
@@ -86,6 +86,7 @@ pub fn session(e: &Engine, full: bool) -> Value {
})).collect::<Vec<_>>(),
"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(),
})
+27 -6
View File
@@ -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<String>,
/// 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<Value> {
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::<String>().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<Value> {
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<Value> {
@@ -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::<String>().cloned()).unwrap_or_else(|| "panic".into())
}
#[cfg(test)]
mod merge_tests {
use super::*;
+3
View File
@@ -45,6 +45,9 @@ const KEYS: &[&str] = &[
"numerator",
"denominator",
"factor",
"x",
"by_px",
"samples_per_px",
"sensitivity",
"rating",
"direction",
+26 -5
View File
@@ -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<Value> {
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}`"))) };
+41 -19
View File
@@ -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<Samples>,
#[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);
}
}
+12 -13
View File
@@ -15,7 +15,7 @@ const VEL_H: f32 = 64.0;
pub struct MidiEditorState {
pub selected: Vec<usize>,
pub drag: Option<NoteDrag>,
pub top_pitch: i32,
pub scroll: f32,
pub clip: Option<ClipId>,
/// Rubber-band selection start (screen position).
pub band: Option<egui::Pos2>,
@@ -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",
+95
View File
@@ -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<ControlRequest>,
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);
}
+3 -1
View File
@@ -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. |