mirror of
https://github.com/aphrody-labs/soundcraft.git
synced 2026-10-13 19:26:53 +00:00
Merge drag-selection (#49) into trackpad
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
c5efedd740
12 files changed
+318
-95
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -17,6 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
run: insert_state,
|
||||
journal: false,
|
||||
undoable: false,
|
||||
view: false,
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -45,6 +45,9 @@ const KEYS: &[&str] = &[
|
||||
"numerator",
|
||||
"denominator",
|
||||
"factor",
|
||||
"x",
|
||||
"by_px",
|
||||
"samples_per_px",
|
||||
"sensitivity",
|
||||
"rating",
|
||||
"direction",
|
||||
|
||||
@@ -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}`"))) };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
Reference in new issue
Block a user