Trackpad-friendly Edit window: smooth panning, pinch and Cmd+scroll zoom
- Pinch and Cmd+scroll zoom the timeline around the pointer. egui 0.36 turns
both into zoom_delta, so the old Cmd+scroll branch never ran; Shift+wheel
pans sideways again for the same reason.
- Two-finger panning keeps the sub-sample remainder, so slow motion still
moves at the closest zoom; the scrollbar drag pans the same way (its range
now takes in the view, so the thumb stays in the bar), and the rulers pan
too. Panning stays open-ended to the right, as before.
- A swipe keeps to the axis it starts along, so panning doesn't drift between
tracks and scrolling doesn't drift in time. The larger share of its first
6 points decides until egui reports the scroll over: the fingers lift, or
150 ms pass without wheel events.
- The MIDI editor scrolls by the point instead of rounding each frame's delta
to whole rows (slow swipes did nothing), and its range fits the panel
(pitch 0 could not be reached; a taller panel showed blank rows).
- Zoom and scroll write through `Engine::zoom_mut` (`view` commands): no
playback resync (a session copy sent to the audio thread every frame), no
copy of the whole session per frame, no journal entry per frame, and no
end to an undo gesture (scrolling during an automation pass made each
write its own undo step). New `view.zoom_by {factor, x?}`;
`view.scroll` saturates; Fill Window and Zoom to Selection stay within the
zoom range (zooming out after them on a long session zoomed in).
- Control channel: `ui.scroll` (trackpad swipe or wheel notches) and
`ui.zoom` (pinch); `session.inspect` reports zoom.
This commit is contained in:
parent
3bc2d92b4e
commit
992f60c79c
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -51,6 +51,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.
|
||||
|
|
@ -80,18 +82,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,
|
||||
}
|
||||
|
||||
pub fn x_of(s: &Session, tl: Rect, at: Samples) -> f32 {
|
||||
|
|
@ -195,23 +200,7 @@ fn main_area(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_y = (app.edit_layout.scroll_y - delta.y).clamp(0.0, (app.edit_layout.content_h - tl.height() + 40.0).max(0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
scroll_and_zoom(app, ui, full, tl);
|
||||
draw_rulers(app, ui, rulers_rect, tl, &rulers);
|
||||
// Tracks.
|
||||
let ids: Vec<TrackId> =
|
||||
|
|
@ -262,12 +251,45 @@ fn folder_collapsed(s: &Session, t: &Track) -> bool {
|
|||
t.folder.and_then(|f| s.track(f)).is_some_and(|f| !f.folder_open)
|
||||
}
|
||||
|
||||
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_y = (app.edit_layout.scroll_y - delta.y).clamp(0.0, (app.edit_layout.content_h - tl.height() + 40.0).max(0.0));
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
|
|
@ -276,7 +298,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);
|
||||
}
|
||||
|
|
@ -30,13 +30,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. |
|
||||
|
|
|
|||
Loading…
Reference in New Issue