mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-05 01:40:14 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1db9563379 | ||
|
|
341a112757 | ||
|
|
594d0ade4f | ||
|
|
92e6973640 | ||
|
|
2b03df3ad4 | ||
|
|
5fabf28c31 | ||
|
|
fa34e0c182 | ||
|
|
ccebb8ff93 | ||
|
|
b7d78c7e9e | ||
|
|
b26ab2b25c | ||
|
|
76f58fcb81 | ||
|
|
6a4c055094 | ||
|
|
d5564bce05 | ||
|
|
87ad11cccc | ||
|
|
b11ffe73ae | ||
|
|
f91b4173b6 | ||
|
|
98c3a307c3 | ||
|
|
6492b5f27e | ||
|
|
9c202d6f17 | ||
|
|
44b864ffaf | ||
|
|
035de82c84 | ||
|
|
181f329ec8 | ||
|
|
5ba9f9d7b8 | ||
|
|
c15682a088 | ||
|
|
0d253b6d8c | ||
|
|
c0f2f7dc87 | ||
|
|
f99407fb1f | ||
|
|
636454e639 | ||
|
|
7546ab992b | ||
|
|
f86b077cdf | ||
|
|
aa250cc1d7 | ||
|
|
dbcbcbac00 | ||
|
|
351b9d3a55 | ||
|
|
da02c19fcc | ||
|
|
64540f109b | ||
|
|
0af1ad909e | ||
|
|
7587130d59 | ||
|
|
e4f0599a2a | ||
|
|
17d3f00700 | ||
|
|
f46ea7d70c | ||
|
|
1403bbc4d3 | ||
|
|
b0793ca875 | ||
|
|
1c350101bc | ||
|
|
36d42e0136 | ||
|
|
c692648200 | ||
|
|
bf274560ae | ||
|
|
087978a4a0 | ||
|
|
9b5554e312 | ||
|
|
04dab602c7 | ||
|
|
576ff36f61 | ||
|
|
4b06407dae | ||
|
|
1a95692f50 | ||
|
|
7dfa7e470f | ||
|
|
8f7eb4d777 | ||
|
|
ffc035ba29 | ||
|
|
e1f10ccdc8 | ||
|
|
627bbbf9cc | ||
|
|
84baa5debc | ||
|
|
468bec487c | ||
|
|
71f899baac | ||
|
|
ccbeec6680 | ||
|
|
381832447c | ||
|
|
d7c194d728 | ||
|
|
f92150c64e | ||
|
|
66f305b747 | ||
|
|
b9accf92a8 | ||
|
|
d33cac774a | ||
|
|
b48a939d64 | ||
|
|
7849e8cf51 |
Generated
+52
-23
@@ -1422,6 +1422,19 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
"enum-map",
|
||||
"itertools 0.15.0",
|
||||
"log",
|
||||
"profiling",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
@@ -1475,6 +1488,26 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -4297,7 +4330,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -4321,7 +4354,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4331,7 +4364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4343,7 +4376,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"log",
|
||||
@@ -4356,7 +4389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading 0.9.0",
|
||||
@@ -4369,7 +4402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -4387,7 +4420,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4398,7 +4431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"log",
|
||||
@@ -4421,7 +4454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4431,7 +4464,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -4447,7 +4480,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4460,7 +4493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
@@ -4475,7 +4508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4485,11 +4518,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"log",
|
||||
@@ -4514,7 +4548,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"image",
|
||||
@@ -4526,7 +4560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
@@ -4542,7 +4576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6190,7 +6224,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -6510,11 +6544,6 @@ name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
|
||||
+3
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -60,6 +60,8 @@ craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-l
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
|
||||
egui = { version = "0.37" }
|
||||
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
# Monotonic clock on native and the browser (the `web_time::Instant` panics-free equivalent).
|
||||
web-time = "1.1"
|
||||
|
||||
@@ -425,7 +425,7 @@ fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session();
|
||||
let moves: Vec<(soundcraft_model::ClipId, i64)> =
|
||||
ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false)))).collect();
|
||||
ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false, &ids)))).collect();
|
||||
let s = e.session_mut();
|
||||
for (id, d) in &moves {
|
||||
crate::edit::move_clips(s, &[*id], *d, &[]);
|
||||
|
||||
@@ -301,6 +301,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
|
||||
cut_track(s, *t, range, sh, follow);
|
||||
}
|
||||
s.edit.selection = soundcraft_time::Range::point(range.start);
|
||||
cb.cut_from = Some(range.start);
|
||||
}
|
||||
let n: usize = cb.tracks.iter().map(Vec::len).sum();
|
||||
e.clipboard = cb;
|
||||
@@ -328,6 +329,7 @@ pub(super) fn clipboard_for_paste(e: &mut Engine) -> Clipboard {
|
||||
cb
|
||||
}
|
||||
|
||||
/// Pastes the clipboard at an explicit position or at the current edit insertion point.
|
||||
fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.paste", p)?;
|
||||
let given = position_param(e, "edit.paste", p, "at")?;
|
||||
@@ -335,9 +337,10 @@ fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session();
|
||||
let at = match given {
|
||||
Some(at) => at,
|
||||
None if s.edit.edit_mode == EditMode::GridRelative => {
|
||||
s.edit.grid.line_at_or_before(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate).saturating_add(cb.grid_offset)
|
||||
None if s.edit.edit_mode == EditMode::GridRelative && s.edit.selection.is_empty() && cb.cut_from == Some(s.edit.selection.start) => {
|
||||
s.edit.selection.start
|
||||
}
|
||||
None if s.edit.edit_mode == EditMode::GridRelative => s.grid_line_at_or_before(s.edit.selection.start).saturating_add(cb.grid_offset),
|
||||
None => s.edit.selection.start,
|
||||
};
|
||||
let sh = shuffle(s) && !cb.automation_only;
|
||||
@@ -668,11 +671,16 @@ fn separate(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"separated": n}))
|
||||
}
|
||||
|
||||
/// Separates selected clips at every active Grid boundary in the requested range.
|
||||
fn separate_on_grid(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.separate_on_grid", p)?;
|
||||
let r = range_param(e, "edit.separate_on_grid", p)?;
|
||||
let s = e.session_mut();
|
||||
let lines = s.edit.grid.lines(r.start, r.end, s.sample_rate, &s.tempo, s.frame_rate, 10_000);
|
||||
let max = if s.edit.grid_clips_markers { usize::MAX } else { 10_001 };
|
||||
let lines = s.grid_lines(r.start, r.end, max);
|
||||
if lines.len() > 10_000 {
|
||||
return Err(bad("edit.separate_on_grid", "the range contains more than 10,000 grid boundaries"));
|
||||
}
|
||||
let mut n = 0;
|
||||
for t in &tracks {
|
||||
for at in &lines {
|
||||
@@ -956,6 +964,7 @@ fn nudge(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"moved": n, "by": step}))
|
||||
}
|
||||
|
||||
/// Same-track placement at the existing start is a no-op; quantization is a separate command.
|
||||
fn move_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
if ids.is_empty() {
|
||||
@@ -978,17 +987,21 @@ fn move_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let mode = s.edit.edit_mode;
|
||||
let delta = match first {
|
||||
Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative),
|
||||
Some(_) if mode.is_grid() && p.get("to").is_some() && delta == 0 && dest.iter().all(|(from, to)| from == to) => 0,
|
||||
Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative, &ids),
|
||||
_ => delta,
|
||||
};
|
||||
let effective_delta = first.map_or(delta, |clip| delta.max(clip.start.saturating_neg()));
|
||||
let s = e.session_mut();
|
||||
let n = if mode == EditMode::Shuffle {
|
||||
ops::shuffle_clips(s, &ids, delta, &dest).map_err(|why| bad("edit.move_clips", why))?
|
||||
} else if effective_delta == 0 && dest.iter().all(|(from, to)| from == to) {
|
||||
0
|
||||
} else {
|
||||
ops::move_clips(s, &ids, delta, &dest)
|
||||
ops::move_clips(s, &ids, effective_delta, &dest)
|
||||
};
|
||||
s.edit.selected_clips = ids;
|
||||
Ok(json!({"moved": n, "by": delta}))
|
||||
Ok(json!({"moved": n, "by": if mode == EditMode::Shuffle { delta } else { effective_delta }}))
|
||||
}
|
||||
|
||||
fn trim_clip(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
|
||||
+166
-13
@@ -3,9 +3,9 @@ use super::{CommandSpec, always, bad, bool_or, f32_or, f64_or, i64_or, position_
|
||||
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_time::TimeFormat;
|
||||
use soundcraft_time::{GridValue, MAX_POSITION, NoteValue};
|
||||
use soundcraft_time::{FeetFramesValue, GridScale, GridValue, MAX_POSITION, NoteValue, TimeFormat, TimecodeValue};
|
||||
|
||||
/// Returns commands that control saved view, counter, Grid, and Nudge state.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(view "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5, None)),
|
||||
@@ -65,7 +65,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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| {
|
||||
let f = str_param(p, "format").and_then(TimeFormat::from_id).ok_or_else(|| bad("view.main_counter", "unknown format"))?;
|
||||
e.session_mut().edit.main_counter = f;
|
||||
let edit = &mut e.session_mut().edit;
|
||||
edit.main_counter = f;
|
||||
let scale = grid_scale(f);
|
||||
if edit.grid_follow_main && edit.grid.scale() != scale {
|
||||
edit.grid = GridValue::for_scale(scale);
|
||||
}
|
||||
if edit.grid_follow_main {
|
||||
edit.grid_clips_markers = false;
|
||||
}
|
||||
if edit.nudge_follow_main && edit.nudge.scale() != scale {
|
||||
edit.nudge = GridValue::for_scale(scale);
|
||||
}
|
||||
Ok(json!({"format": f}))
|
||||
}),
|
||||
cmd!(noundo "view.sub_counter", "Sub Counter", [], None, "{format?: … | null}", always, |e, p| {
|
||||
@@ -73,23 +84,58 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
e.session_mut().edit.sub_counter = f;
|
||||
Ok(json!({"format": f}))
|
||||
}),
|
||||
cmd!(noundo "view.grid", "Grid Value", [], Some("Shift+=/-"), "{value: '1/16' | '1 bar' | '1/8t' | '1/4.' | {seconds} | {frames} | {samples}, or step: ±n values larger or smaller; lines?: bool}", always, |e, p| {
|
||||
let g = value_or_step(p, e.session().edit.grid).ok_or_else(|| bad("view.grid", "unknown grid value"))?;
|
||||
cmd!(noundo "view.grid", "Grid Value", [], Some("Shift+=/-"), "{value?, step?, format?: bars_beats|min_secs|timecode|feet_frames|samples|clips_markers, follow_main?: bool, lines?: bool}", always, |e, p| {
|
||||
let current = e.session().edit.grid;
|
||||
let requested_scale = str_param(p, "format").and_then(GridScale::from_id);
|
||||
let mut g = requested_grid_value(p, current).ok_or_else(|| bad("view.grid", "unknown grid value or format"))?;
|
||||
let lines = p.get("lines").and_then(Value::as_bool);
|
||||
let follow = p.get("follow_main").and_then(Value::as_bool);
|
||||
if follow == Some(true) {
|
||||
let scale = grid_scale(e.session().edit.main_counter);
|
||||
if g.scale() != scale {
|
||||
g = GridValue::for_scale(scale);
|
||||
}
|
||||
}
|
||||
let s = e.session_mut();
|
||||
s.edit.grid = g;
|
||||
if p.get("value").is_some() || p.get("step").is_some() {
|
||||
s.edit.grid_clips_markers = false;
|
||||
}
|
||||
if let Some(scale) = requested_scale {
|
||||
s.edit.grid_clips_markers = scale == GridScale::ClipsMarkers;
|
||||
}
|
||||
if follow == Some(true) {
|
||||
s.edit.grid_clips_markers = false;
|
||||
}
|
||||
s.edit.grid_follow_main = follow.unwrap_or(
|
||||
s.edit.grid_follow_main && !s.edit.grid_clips_markers && g.scale() == grid_scale(s.edit.main_counter),
|
||||
);
|
||||
if let Some(l) = lines { s.edit.grid_lines = l }
|
||||
Ok(json!({"grid": g.label()}))
|
||||
let format = if s.edit.grid_clips_markers { GridScale::ClipsMarkers } else { g.scale() };
|
||||
Ok(json!({"grid": g.label(), "format": format.id(), "follow_main": s.edit.grid_follow_main}))
|
||||
}),
|
||||
cmd!(noundo "view.grid_lines", "Show Grid Lines", [], None, "{value?: bool}", always, |e, p| {
|
||||
let s = e.session_mut();
|
||||
s.edit.grid_lines = p.get("value").and_then(Value::as_bool).unwrap_or(!s.edit.grid_lines);
|
||||
Ok(json!({"value": s.edit.grid_lines}))
|
||||
}),
|
||||
cmd!(noundo "view.nudge", "Nudge Value", [], Some("Alt+Shift+=/-"), "{value or step: like grid}", always, |e, p| {
|
||||
let g = value_or_step(p, e.session().edit.nudge).ok_or_else(|| bad("view.nudge", "unknown nudge value"))?;
|
||||
e.session_mut().edit.nudge = g;
|
||||
Ok(json!({"nudge": g.label()}))
|
||||
cmd!(noundo "view.nudge", "Nudge Value", [], Some("Alt+Shift+=/-"), "{value?, step?, format?: bars_beats|min_secs|timecode|feet_frames|samples, follow_main?: bool}", always, |e, p| {
|
||||
let current = e.session().edit.nudge;
|
||||
if str_param(p, "format").and_then(GridScale::from_id) == Some(GridScale::ClipsMarkers) {
|
||||
return Err(bad("view.nudge", "Clips/Markers is available only for Grid"));
|
||||
}
|
||||
let mut g = requested_grid_value(p, current).ok_or_else(|| bad("view.nudge", "unknown nudge value or format"))?;
|
||||
let follow = p.get("follow_main").and_then(Value::as_bool);
|
||||
if follow == Some(true) {
|
||||
let scale = grid_scale(e.session().edit.main_counter);
|
||||
if g.scale() != scale {
|
||||
g = GridValue::for_scale(scale);
|
||||
}
|
||||
}
|
||||
let edit = &mut e.session_mut().edit;
|
||||
edit.nudge = g;
|
||||
edit.nudge_follow_main = follow.unwrap_or(edit.nudge_follow_main && g.scale() == grid_scale(edit.main_counter));
|
||||
Ok(json!({"nudge": g.label(), "format": g.scale().id(), "follow_main": edit.nudge_follow_main}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -137,7 +183,33 @@ fn value_or_step(p: &Value, current: GridValue) -> Option<GridValue> {
|
||||
Some((0..step.unsigned_abs().min(16)).fold(current, |g, _| g.stepped(step > 0)))
|
||||
}
|
||||
|
||||
/// Parse grid/nudge values.
|
||||
/// Maps a counter format to the corresponding Grid/Nudge scale.
|
||||
fn grid_scale(format: TimeFormat) -> GridScale {
|
||||
match format {
|
||||
TimeFormat::BarsBeats => GridScale::BarsBeats,
|
||||
TimeFormat::MinSecs => GridScale::MinSecs,
|
||||
TimeFormat::Timecode => GridScale::Timecode,
|
||||
TimeFormat::FeetFrames => GridScale::FeetFrames,
|
||||
TimeFormat::Samples => GridScale::Samples,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves an explicit value, a step, or a scale change while preserving the current increment.
|
||||
fn requested_grid_value(p: &Value, current: GridValue) -> Option<GridValue> {
|
||||
if p.get("value").is_some() || p.get("step").is_some() {
|
||||
return value_or_step(p, current);
|
||||
}
|
||||
let scale = str_param(p, "format").and_then(GridScale::from_id);
|
||||
match scale {
|
||||
Some(GridScale::ClipsMarkers) => Some(current),
|
||||
Some(scale) if current.scale() == scale => Some(current),
|
||||
Some(scale) => Some(GridValue::for_scale(scale)),
|
||||
None if p.get("follow_main").is_some() || p.get("lines").is_some() => Some(current),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse grid/nudge values; legacy `1` and `1/1` mean a bar, while `whole` means four quarter notes.
|
||||
pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
|
||||
let v = p.get(key)?;
|
||||
if let Some(sec) = v.get("seconds").and_then(Value::as_f64) {
|
||||
@@ -146,15 +218,23 @@ pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
|
||||
if let Some(f) = v.get("frames").and_then(Value::as_i64) {
|
||||
return Some(GridValue::Frames(f.clamp(1, 100_000)));
|
||||
}
|
||||
if let Some(value) = v.get("timecode").and_then(Value::as_str).and_then(TimecodeValue::from_id) {
|
||||
return Some(GridValue::Timecode(value));
|
||||
}
|
||||
if let Some(value) = v.get("feet_frames").and_then(Value::as_str).and_then(FeetFramesValue::from_id) {
|
||||
return Some(GridValue::FeetFrames(value));
|
||||
}
|
||||
if let Some(n) = v.get("samples").and_then(Value::as_i64) {
|
||||
return Some(GridValue::Samples(n.clamp(1, i64::from(u32::MAX))));
|
||||
}
|
||||
let s = v.as_str()?.trim().to_ascii_lowercase();
|
||||
let triplet = s.ends_with('t') || s.contains("triplet");
|
||||
let dotted = s.ends_with('.') || s.contains("dotted");
|
||||
let core: String = s.chars().take_while(|c| c.is_ascii_digit() || *c == '/' || *c == ' ' || c.is_ascii_alphabetic() && *c != 't').collect();
|
||||
let core = s.replace("triplet", "").replace("dotted", "");
|
||||
let core = core.trim().trim_end_matches(['t', '.']);
|
||||
let value = match core.trim() {
|
||||
"1 bar" | "bar" | "1/1" | "1" => NoteValue::Bar,
|
||||
"1" | "1/1" | "1 bar" | "bar" => NoteValue::Bar,
|
||||
"whole" | "whole note" => NoteValue::Whole,
|
||||
"1/2" => NoteValue::Half,
|
||||
"1/4" => NoteValue::Quarter,
|
||||
"1/8" => NoteValue::Eighth,
|
||||
@@ -170,15 +250,59 @@ pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use soundcraft_time::Samples;
|
||||
/// Grid values accept compact and word-form musical modifiers plus every absolute scale.
|
||||
#[test]
|
||||
fn grid_values_parse() {
|
||||
assert_eq!(grid_param(&json!({"v": "1/16"}), "v"), Some(GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false }));
|
||||
assert_eq!(grid_param(&json!({"v": "1/8t"}), "v"), Some(GridValue::Note { value: NoteValue::Eighth, dotted: false, triplet: true }));
|
||||
assert_eq!(grid_param(&json!({"v": "1/8 triplet"}), "v"), Some(GridValue::Note { value: NoteValue::Eighth, dotted: false, triplet: true }));
|
||||
assert_eq!(grid_param(&json!({"v": "1/4."}), "v"), Some(GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false }));
|
||||
for alias in ["1", "1/1"] {
|
||||
assert_eq!(grid_param(&json!({"v": alias}), "v"), Some(GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false }));
|
||||
}
|
||||
assert_eq!(grid_param(&json!({"v": "whole"}), "v"), Some(GridValue::Note { value: NoteValue::Whole, dotted: false, triplet: false }));
|
||||
assert_eq!(grid_param(&json!({"v": "whole note"}), "v"), Some(GridValue::Note { value: NoteValue::Whole, dotted: false, triplet: false }));
|
||||
assert_eq!(grid_param(&json!({"v": {"seconds": 0.5}}), "v"), Some(GridValue::Seconds(0.5)));
|
||||
assert_eq!(grid_param(&json!({"v": {"timecode": "quarter_frame"}}), "v"), Some(GridValue::Timecode(TimecodeValue::QuarterFrame)));
|
||||
assert_eq!(grid_param(&json!({"v": {"feet_frames": "foot"}}), "v"), Some(GridValue::FeetFrames(FeetFramesValue::Foot)));
|
||||
assert_eq!(grid_param(&json!({"v": "nonsense"}), "v"), None);
|
||||
}
|
||||
|
||||
/// Explicit values and formats retain Follow Main only when the selected scale still matches.
|
||||
#[test]
|
||||
fn explicit_grid_and_nudge_scales_keep_follow_state_consistent() {
|
||||
for command in ["view.grid", "view.nudge"] {
|
||||
let mut e = Engine::default();
|
||||
e.execute("view.main_counter", &json!({"format": "min_secs"})).unwrap();
|
||||
e.execute(command, &json!({"follow_main": true})).unwrap();
|
||||
let result = e.execute(command, &json!({"value": {"seconds": 0.1}})).unwrap();
|
||||
assert_eq!(result["follow_main"], true);
|
||||
let result = e.execute(command, &json!({"value": {"feet_frames": "foot"}})).unwrap();
|
||||
assert_eq!(result["follow_main"], false);
|
||||
e.execute(command, &json!({"follow_main": true})).unwrap();
|
||||
let result = e.execute(command, &json!({"format": "bars_beats"})).unwrap();
|
||||
assert_eq!(result["follow_main"], false);
|
||||
}
|
||||
let mut e = Engine::default();
|
||||
e.execute("view.grid", &json!({"format": "clips_markers"})).unwrap();
|
||||
assert!(!e.session().edit.grid_follow_main);
|
||||
e.execute("view.grid", &json!({"value": {"samples": 100}})).unwrap();
|
||||
assert!(!e.session().edit.grid_clips_markers);
|
||||
assert_eq!(e.session().edit.grid, GridValue::Samples(100));
|
||||
}
|
||||
|
||||
/// Historical bar aliases retain meter-dependent duration; the explicit whole note remains distinct.
|
||||
#[test]
|
||||
fn legacy_bar_aliases_and_whole_notes_remain_distinct_in_odd_meter() {
|
||||
let mut e = Engine::default();
|
||||
e.session_mut().tempo = soundcraft_time::TempoMap::new(120.0, 3, 4).unwrap();
|
||||
for (value, expected) in [("1", 72_000), ("1/1", 72_000), ("whole", 96_000)] {
|
||||
e.execute("view.grid", &json!({"value": value, "follow_main": false})).unwrap();
|
||||
let s = e.session();
|
||||
assert_eq!(s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate), expected);
|
||||
}
|
||||
}
|
||||
|
||||
fn view(e: &Engine) -> (f64, Samples) {
|
||||
(e.session().edit.zoom.samples_per_px, e.session().edit.zoom.scroll)
|
||||
}
|
||||
@@ -241,6 +365,7 @@ mod tests {
|
||||
assert_eq!(state(&e), before);
|
||||
}
|
||||
|
||||
/// Grid and Nudge steps drive edit movement while preserving their selected increments.
|
||||
#[test]
|
||||
fn grid_and_nudge_values_step_and_nudges_can_use_the_next_value() {
|
||||
let mut e = Engine::default();
|
||||
@@ -262,4 +387,32 @@ mod tests {
|
||||
assert_eq!(e.session().edit.selection.start, 6_000);
|
||||
assert_eq!(e.session().edit.nudge.label(), "1/16 note");
|
||||
}
|
||||
|
||||
/// Grid and Nudge expose independent scale-following state, while Clips/Markers remains Grid-only.
|
||||
#[test]
|
||||
fn grid_and_nudge_choose_scales_follow_the_main_counter_and_keep_clips_markers_grid_only() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("view.grid", &json!({"format": "timecode", "follow_main": false})).unwrap();
|
||||
assert_eq!(e.session().edit.grid, GridValue::Timecode(TimecodeValue::Frame));
|
||||
e.execute("view.grid", &json!({"value": {"timecode": "quarter_frame"}})).unwrap();
|
||||
e.execute("view.grid", &json!({"format": "clips_markers", "follow_main": false})).unwrap();
|
||||
assert_eq!(e.session().edit.grid, GridValue::Timecode(TimecodeValue::QuarterFrame));
|
||||
assert!(e.session().edit.grid_clips_markers);
|
||||
assert!(e.execute("view.nudge", &json!({"format": "clips_markers"})).is_err());
|
||||
|
||||
e.execute("view.grid", &json!({"follow_main": true})).unwrap();
|
||||
e.execute("view.nudge", &json!({"follow_main": true})).unwrap();
|
||||
assert_eq!((e.session().edit.grid, e.session().edit.nudge), (GridValue::Seconds(1.0), GridValue::Seconds(1.0)));
|
||||
assert!(!e.session().edit.grid_clips_markers);
|
||||
e.execute("view.main_counter", &json!({"format": "samples"})).unwrap();
|
||||
assert_eq!((e.session().edit.grid, e.session().edit.nudge), (GridValue::Samples(100), GridValue::Samples(100)));
|
||||
assert!(e.session().edit.grid_follow_main && e.session().edit.nudge_follow_main);
|
||||
|
||||
e.execute("view.grid", &json!({"format": "bars_beats", "follow_main": false})).unwrap();
|
||||
assert_eq!(e.session().edit.grid, GridValue::default());
|
||||
assert!(!e.session().edit.grid_follow_main);
|
||||
e.execute("view.main_counter", &json!({"format": "timecode"})).unwrap();
|
||||
assert_eq!(e.session().edit.grid, GridValue::default());
|
||||
assert_eq!(e.session().edit.nudge, GridValue::Timecode(TimecodeValue::Frame));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl Parti
|
||||
grid,
|
||||
nudge,
|
||||
grid_lines,
|
||||
grid_follow_main,
|
||||
nudge_follow_main,
|
||||
grid_clips_markers,
|
||||
main_counter,
|
||||
sub_counter,
|
||||
zoom: _,
|
||||
@@ -130,7 +133,17 @@ fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl Parti
|
||||
scrolling,
|
||||
solo_mode,
|
||||
),
|
||||
(pre_fader_metering, delay_compensation, keyboard_focus, &navigation.timeline_selection, flags, values),
|
||||
(
|
||||
grid_follow_main,
|
||||
nudge_follow_main,
|
||||
grid_clips_markers,
|
||||
pre_fader_metering,
|
||||
delay_compensation,
|
||||
keyboard_focus,
|
||||
&navigation.timeline_selection,
|
||||
flags,
|
||||
values,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -186,6 +199,9 @@ mod tests {
|
||||
("grid", |e| e.grid = soundcraft_time::GridValue::Seconds(2.0)),
|
||||
("nudge", |e| e.nudge = soundcraft_time::GridValue::Seconds(2.0)),
|
||||
("grid_lines", |e| e.grid_lines = !e.grid_lines),
|
||||
("grid_follow_main", |e| e.grid_follow_main = !e.grid_follow_main),
|
||||
("nudge_follow_main", |e| e.nudge_follow_main = !e.nudge_follow_main),
|
||||
("grid_clips_markers", |e| e.grid_clips_markers = !e.grid_clips_markers),
|
||||
("main_counter", |e| e.main_counter = TimeFormat::Samples),
|
||||
("sub_counter", |e| e.sub_counter = None),
|
||||
("zoom_toggle", |e| e.zoom_toggle = Some(ZoomToggle { samples_per_px: 64.0, scroll: 0, heights: Vec::new() })),
|
||||
|
||||
+11
-21
@@ -528,9 +528,15 @@ fn join_gain_env(l: &mut Clip, r: &Clip) {
|
||||
}
|
||||
|
||||
/// Move clips by `delta`, overwriting what they land on; `dest` sends whole tracks of them
|
||||
/// elsewhere (see [`destinations`]).
|
||||
/// elsewhere (see [`destinations`]). Same-track moves clamped to zero leave all material intact.
|
||||
pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> usize {
|
||||
let ids: HashSet<ClipId> = ids.iter().copied().collect();
|
||||
// Limit the shift before removing any clips, so a stationary edit cannot overwrite overlaps.
|
||||
let limit = s.tracks.iter().flat_map(|t| t.clips()).filter(|c| ids.contains(&c.id) && !locked(c)).map(|c| c.start.saturating_neg()).max();
|
||||
let delta = delta.max(limit.unwrap_or(0));
|
||||
if delta == 0 && dest.iter().all(|(from, to)| from == to) {
|
||||
return 0;
|
||||
}
|
||||
let mut moving: Vec<(TrackId, Clip)> = Vec::new();
|
||||
for t in &mut s.tracks {
|
||||
let tid = t.id;
|
||||
@@ -546,8 +552,6 @@ pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(Trac
|
||||
}
|
||||
}
|
||||
let n = moving.len();
|
||||
// Limit the shift once, so the group stops at zero together and keeps its spacing.
|
||||
let delta = delta.max(moving.iter().map(|(_, c)| c.start.saturating_neg()).max().unwrap_or(0));
|
||||
let placed = moving.into_iter().map(|(tid, mut c)| {
|
||||
c.start = c.start.saturating_add(delta);
|
||||
(lands(dest, tid), c)
|
||||
@@ -572,20 +576,7 @@ fn put_clips(s: &mut Session, placed: Vec<(TrackId, Clip)>) {
|
||||
|
||||
/// Whether the Edit window shows track `t`: not hidden, and not inside a closed folder at any level.
|
||||
pub fn shown(s: &Session, t: &Track) -> bool {
|
||||
!t.hidden && !folder_collapsed(s, t)
|
||||
}
|
||||
|
||||
/// Hidden because a folder at any level above this track is closed.
|
||||
fn folder_collapsed(s: &Session, track: &Track) -> bool {
|
||||
let mut current = track.folder;
|
||||
for _ in 0..64 {
|
||||
let Some(folder) = current.and_then(|folder| s.track(folder)) else { return false };
|
||||
if !folder.folder_open {
|
||||
return true;
|
||||
}
|
||||
current = folder.folder;
|
||||
}
|
||||
false
|
||||
s.track_shown(t)
|
||||
}
|
||||
|
||||
/// Where the tracks of clips `ids` go when track `from` goes to `to`: each keeps its distance
|
||||
@@ -790,12 +781,11 @@ pub fn shuffle_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(T
|
||||
/// How far a Grabber drag by `raw` moves `clip` in Grid mode: Absolute puts its sync point (its
|
||||
/// start when it has none inside the clip) on the nearest grid line; Relative moves it by whole
|
||||
/// grid steps, keeping its offset from the grid.
|
||||
pub fn grid_delta(s: &Session, clip: &Clip, raw: Samples, relative: bool) -> Samples {
|
||||
let (g, sr, map, rate) = (&s.edit.grid, s.sample_rate, &s.tempo, s.frame_rate);
|
||||
pub fn grid_delta(s: &Session, clip: &Clip, raw: Samples, relative: bool, moving: &[ClipId]) -> Samples {
|
||||
let sync = if (0..clip.length).contains(&clip.sync_point) { clip.sync_point } else { 0 };
|
||||
let anchor = clip.start.saturating_add(sync);
|
||||
let from = if relative { g.line_at_or_before(anchor, sr, map, rate) } else { anchor };
|
||||
g.snap(from.saturating_add(raw), sr, map, rate).saturating_sub(from)
|
||||
let from = if relative { s.grid_line_at_or_before_excluding(anchor, moving) } else { anchor };
|
||||
s.snap_to_grid_excluding(from.saturating_add(raw), moving).saturating_sub(from)
|
||||
}
|
||||
|
||||
/// Trim a clip's edges to `start` and/or `end` like the Trim tool, never past its file. In
|
||||
|
||||
@@ -330,9 +330,9 @@ fn grid_moves_snap_the_clip_start_or_keep_its_offset() {
|
||||
let (s, t) = session_with_clip();
|
||||
let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate);
|
||||
let c = s.track(t).unwrap().clips()[0].clone();
|
||||
assert_eq!(c.start + grid_delta(&s, &c, step + 200, false), step);
|
||||
assert_eq!(c.start + grid_delta(&s, &c, step + 200, true), step + 1000);
|
||||
assert_eq!(grid_delta(&s, &c, step / 3, true), 0);
|
||||
assert_eq!(c.start + grid_delta(&s, &c, step + 200, false, &[c.id]), step);
|
||||
assert_eq!(c.start + grid_delta(&s, &c, step + 200, true, &[c.id]), step + 1000);
|
||||
assert_eq!(grid_delta(&s, &c, step / 3, true, &[c.id]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -119,6 +119,8 @@ pub struct Clipboard {
|
||||
pub markers: Vec<soundcraft_model::MemoryLocation>,
|
||||
/// How far the copied range started after a grid line; Relative Grid pastes keep it.
|
||||
pub grid_offset: Samples,
|
||||
/// Original insertion point when the clipboard was populated by Cut.
|
||||
pub cut_from: Option<Samples>,
|
||||
/// Copy Special › Automation: pasting it leaves the clips alone, in Shuffle mode too.
|
||||
pub automation_only: bool,
|
||||
/// The Auto Rename schemes of the copied clips, keyed as in their `auto_name`.
|
||||
@@ -128,7 +130,7 @@ pub struct Clipboard {
|
||||
impl Clipboard {
|
||||
/// An empty clipboard for material copied from `range`.
|
||||
pub fn of_range(s: &Session, range: soundcraft_time::Range) -> Clipboard {
|
||||
let line = s.edit.grid.line_at_or_before(range.start, s.sample_rate, &s.tempo, s.frame_rate);
|
||||
let line = s.grid_line_at_or_before(range.start);
|
||||
Clipboard { length: range.len(), grid_offset: range.start.saturating_sub(line), ..Clipboard::default() }
|
||||
}
|
||||
|
||||
|
||||
@@ -220,6 +220,129 @@ fn relative_grid_pastes_keep_the_offset_from_the_grid() {
|
||||
assert_eq!(clips(&e, t).last(), Some(&(5 * step, 1000)));
|
||||
}
|
||||
|
||||
/// A relative paste restores the insertion point even when Cut creates a new boundary there.
|
||||
#[test]
|
||||
fn relative_grid_cut_pastes_back_at_its_insertion_boundary() {
|
||||
let (mut e, t, _) = long("relative_grid", &[(1_000, 2_000, 0)]);
|
||||
run(&mut e, "view.grid", json!({"format": "clips_markers", "follow_main": false}));
|
||||
run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": 1_500, "end": 2_000}));
|
||||
run(&mut e, "edit.paste", json!({"tracks": [t.0]}));
|
||||
assert_eq!(e.session().edit.selection.start, 1_500);
|
||||
assert!(clips(&e, t).contains(&(1_500, 500)), "{:?}", clips(&e, t));
|
||||
}
|
||||
|
||||
/// Moving a group excludes all its clips from Clips/Markers snap candidates.
|
||||
#[test]
|
||||
fn clips_markers_grid_ignores_every_clip_being_moved() {
|
||||
let (mut e, _, ids) = long("grid", &[(1_000, 1_000, 0), (4_000, 500, 2_000)]);
|
||||
e.session_mut().add_marker("Target", soundcraft_model::MarkerKind::Marker, 2_500, 2_500);
|
||||
run(&mut e, "view.grid", json!({"format": "clips_markers", "follow_main": false}));
|
||||
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "to": 1_000}));
|
||||
assert_eq!([start(&e, ids[0]), start(&e, ids[1])], [1_000, 4_000]);
|
||||
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "to": 2_200}));
|
||||
assert_eq!(start(&e, ids[0]), 2_500);
|
||||
assert_eq!(start(&e, ids[1]), 5_500);
|
||||
}
|
||||
|
||||
/// Zero and clamped moves preserve overlaps while off-grid quantization still changes position.
|
||||
#[test]
|
||||
fn zero_distance_move_keeps_overlapping_material_and_quantize_still_moves() {
|
||||
let (mut e, t, ids) = long("slip", &[(1_000, 1_000, 0), (1_500, 1_000, 2_000)]);
|
||||
let before = material(&e, t);
|
||||
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "by": 0}));
|
||||
assert_eq!(material(&e, t), before);
|
||||
|
||||
run(&mut e, "edit.select", json!({"clips": [ids[0].0]}));
|
||||
run(&mut e, "clip.quantize_to_grid", json!({}));
|
||||
assert_eq!(start(&e, ids[0]), 0);
|
||||
|
||||
let (mut e, t, ids) = long("slip", &[(0, 1_000, 0), (500, 1_000, 2_000)]);
|
||||
let before = material(&e, t);
|
||||
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "by": -1_000}));
|
||||
assert_eq!(material(&e, t), before);
|
||||
}
|
||||
|
||||
/// A vertical move remains a real edit and snaps its off-grid start on the destination track.
|
||||
#[test]
|
||||
fn zero_horizontal_grid_move_to_another_track_still_snaps() {
|
||||
let (mut e, _, [_, clip]) = engine("grid");
|
||||
let destination = new_track(&mut e);
|
||||
run(&mut e, "edit.move_clips", json!({"clips": [clip.0], "to": 2_000, "track": destination.0}));
|
||||
let (track, moved) = e.session().find_clip(clip).unwrap();
|
||||
assert_eq!(track, destination);
|
||||
assert_eq!(moved.start, 0);
|
||||
}
|
||||
|
||||
/// Nudge and quantization must preserve overlapping material when the clip cannot move.
|
||||
#[test]
|
||||
fn stationary_nudges_and_quantization_preserve_overlaps() {
|
||||
for (command, params) in [("edit.nudge", json!({"direction": -1})), ("clip.quantize_to_grid", json!({}))] {
|
||||
let (mut e, t, ids) = long("slip", &[(0, 1_000, 0), (500, 1_000, 2_000)]);
|
||||
run(&mut e, "edit.select", json!({"clips": [ids[0].0]}));
|
||||
let before = e.session().track(t).unwrap().clips().to_vec();
|
||||
let result = e.execute(command, ¶ms).unwrap();
|
||||
assert_eq!(e.session().track(t).unwrap().clips(), before, "{command}");
|
||||
if command == "edit.nudge" {
|
||||
assert_eq!(result["moved"], 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every Nudge scale shifts clips and insertion across a tempo change and drop-frame rates.
|
||||
#[test]
|
||||
fn every_nudge_scale_moves_by_its_selected_increment() {
|
||||
use soundcraft_time::{FrameRate, SampleRate, TICKS_PER_QUARTER};
|
||||
for rate in [FrameRate::Fps30, FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
|
||||
for at in [48_000, 144_000] {
|
||||
let cases = [
|
||||
(json!("1/4"), if at < 96_000 { 24_000 } else { 32_000 }),
|
||||
(json!({"seconds": 0.5}), 24_000),
|
||||
(json!({"timecode": "5_frames"}), rate.samples_at_frame(5, SampleRate::HZ_48000)),
|
||||
(json!({"feet_frames": "foot"}), rate.samples_at_frame(16, SampleRate::HZ_48000)),
|
||||
(json!({"samples": 100}), 100),
|
||||
];
|
||||
for (value, expected) in cases {
|
||||
let (mut e, track, ids) = long("slip", &[(at, 1_000, 0)]);
|
||||
e.session_mut().frame_rate = rate;
|
||||
e.session_mut().tempo.set_tempo(TICKS_PER_QUARTER * 4, 90.0).unwrap();
|
||||
run(&mut e, "edit.select", json!({"tracks": [track.0], "clips": [ids[0].0], "start": at, "end": at}));
|
||||
run(&mut e, "view.nudge", json!({"value": value, "follow_main": false}));
|
||||
let result = e.execute("edit.nudge", &json!({"direction": 1})).unwrap();
|
||||
assert_eq!(result["by"], expected, "{rate:?} at {at}");
|
||||
assert_eq!(result["moved"], 1);
|
||||
assert_eq!(start(&e, ids[0]), at + expected);
|
||||
assert_eq!(e.session().edit.selection.start, at + expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Separation refuses oversized numeric grids before splitting any material.
|
||||
#[test]
|
||||
fn separate_on_grid_refuses_a_truncated_boundary_set() {
|
||||
let (mut e, t, _) = long("grid", &[(0, 20_000, 0)]);
|
||||
run(&mut e, "view.grid", json!({"value": {"samples": 1}, "follow_main": false}));
|
||||
assert!(e.execute("edit.separate_on_grid", &json!({"tracks": [t.0], "start": 0, "end": 10_002})).is_err());
|
||||
assert_eq!(clips(&e, t), [(0, 20_000)]);
|
||||
}
|
||||
|
||||
/// Display thinning must never hide an oversized Clips/Markers separation request.
|
||||
#[test]
|
||||
fn separate_on_grid_refuses_dense_markers_before_changing_clips() {
|
||||
let (mut e, track, _) = long("grid", &[(0, 100_000, 0)]);
|
||||
let s = e.session_mut();
|
||||
for at in 1..=10_001 {
|
||||
s.add_marker("Boundary", soundcraft_model::MarkerKind::Marker, at, at);
|
||||
}
|
||||
run(&mut e, "view.grid", json!({"format": "clips_markers", "follow_main": false}));
|
||||
let before = e.session().clone();
|
||||
let undo_len = e.undo_history().len();
|
||||
let error = e.execute("edit.separate_on_grid", &json!({"tracks": [track.0], "start": 0, "end": 100_000})).unwrap_err();
|
||||
assert!(error.to_string().contains("10,000"), "{error}");
|
||||
assert_eq!(e.session(), &before);
|
||||
assert_eq!(e.undo_history().len(), undo_len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shuffle_moves_keep_crossfade_partners_whole() {
|
||||
let (mut e, t, ids) = long("slip", &[(0, 1000, 10_000), (1000, 1000, 20_000), (2000, 1000, 30_000)]);
|
||||
|
||||
+277
-1
@@ -5,7 +5,7 @@ use crate::{
|
||||
Bus, BusId, ChannelFormat, Clip, ClipId, Group, GroupId, MarkerId, MarkerKind, MemoryLocation, OutputPath, Source, SourceId, SourcePool,
|
||||
TRACK_COLORS, Track, TrackId, TrackKind, VideoSource,
|
||||
};
|
||||
use soundcraft_time::{FrameRate, GridValue, Range, SampleRate, Samples, TempoMap, TimeFormat};
|
||||
use soundcraft_time::{FrameRate, GridScale, GridValue, Range, SampleRate, Samples, TempoMap, TimeFormat};
|
||||
|
||||
/// Native session file extension.
|
||||
pub const SESSION_EXTENSION: &str = "scraft";
|
||||
@@ -147,6 +147,7 @@ pub struct ZoomState {
|
||||
}
|
||||
|
||||
impl Default for ZoomState {
|
||||
/// Starts with a practical overview zoom and five reusable preset levels.
|
||||
fn default() -> Self {
|
||||
ZoomState { samples_per_px: 1024.0, scroll: 0, waveform_zoom: 1.0, presets: [64.0, 256.0, 1024.0, 4096.0, 16384.0] }
|
||||
}
|
||||
@@ -179,6 +180,15 @@ pub struct EditState {
|
||||
pub playhead: Samples,
|
||||
pub grid: GridValue,
|
||||
pub nudge: GridValue,
|
||||
/// Grid/Nudge time scales follow the main counter when enabled.
|
||||
#[serde(default)]
|
||||
pub grid_follow_main: bool,
|
||||
#[serde(default)]
|
||||
pub nudge_follow_main: bool,
|
||||
/// Grid lines and snapping use visible clip boundaries and memory locations while the numeric
|
||||
/// Grid value is retained for when this mode is left.
|
||||
#[serde(default)]
|
||||
pub grid_clips_markers: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub grid_lines: bool,
|
||||
pub main_counter: TimeFormat,
|
||||
@@ -244,6 +254,7 @@ fn yes() -> bool {
|
||||
}
|
||||
|
||||
impl Default for EditState {
|
||||
/// Starts the Edit window in Slip mode with independent Grid and Nudge scales.
|
||||
fn default() -> Self {
|
||||
EditState {
|
||||
tool: Tool::Smart,
|
||||
@@ -255,6 +266,9 @@ impl Default for EditState {
|
||||
playhead: 0,
|
||||
grid: GridValue::default(),
|
||||
nudge: GridValue::Seconds(1.0),
|
||||
grid_follow_main: false,
|
||||
nudge_follow_main: false,
|
||||
grid_clips_markers: false,
|
||||
grid_lines: true,
|
||||
main_counter: TimeFormat::MinSecs,
|
||||
sub_counter: Some(TimeFormat::Samples),
|
||||
@@ -490,6 +504,138 @@ impl Session {
|
||||
soundcraft_time::parse_position(text, fmt, self.sample_rate, &self.tempo, self.frame_rate, self.timecode_start, self.feet_frames_start())
|
||||
}
|
||||
|
||||
/// Whether the Edit window shows a track: unhidden, with every parent folder open.
|
||||
pub fn track_shown(&self, track: &Track) -> bool {
|
||||
if track.hidden {
|
||||
return false;
|
||||
}
|
||||
let mut folder = track.folder;
|
||||
for _ in 0..64 {
|
||||
let Some(parent) = folder.and_then(|id| self.track(id)) else { return true };
|
||||
if !parent.folder_open {
|
||||
return false;
|
||||
}
|
||||
folder = parent.folder;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Collects unique visible active-playlist clip boundaries and memory-location boundaries.
|
||||
fn clip_marker_grid_points(&self, keep: impl Fn(Samples) -> bool, excluded: &[ClipId], include_zero: bool) -> Vec<Samples> {
|
||||
let mut points = Vec::new();
|
||||
let excluded: std::collections::HashSet<ClipId> = excluded.iter().copied().collect();
|
||||
if include_zero && keep(0) {
|
||||
points.push(0);
|
||||
}
|
||||
for clip in
|
||||
self.tracks.iter().filter(|track| self.track_shown(track)).flat_map(|track| track.clips()).filter(|clip| !excluded.contains(&clip.id))
|
||||
{
|
||||
points.extend([clip.start, clip.end()].into_iter().filter(|point| keep(*point)));
|
||||
}
|
||||
for marker in &self.markers {
|
||||
if keep(marker.start) {
|
||||
points.push(marker.start);
|
||||
}
|
||||
if marker.end != marker.start && keep(marker.end) {
|
||||
points.push(marker.end);
|
||||
}
|
||||
}
|
||||
points.sort_unstable();
|
||||
points.dedup();
|
||||
points
|
||||
}
|
||||
|
||||
/// Sample-domain Timecode reference; Feet+Frames applies its origin in frames separately.
|
||||
fn grid_origin(&self) -> Samples {
|
||||
match self.edit.grid.scale() {
|
||||
GridScale::Timecode => self.timecode_start,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid lines for the current Grid value, including clip and marker boundaries.
|
||||
pub fn grid_lines(&self, start: Samples, end: Samples, max: usize) -> Vec<Samples> {
|
||||
if !self.edit.grid_clips_markers {
|
||||
if let GridValue::FeetFrames(value) = self.edit.grid {
|
||||
return value.lines(start, end, self.sample_rate, self.frame_rate, self.feet_frames_start(), max);
|
||||
}
|
||||
let origin = self.grid_origin();
|
||||
return self
|
||||
.edit
|
||||
.grid
|
||||
.lines(start.saturating_add(origin), end.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate, max)
|
||||
.into_iter()
|
||||
.map(|point| point.saturating_sub(origin))
|
||||
.collect();
|
||||
}
|
||||
let points = self.clip_marker_grid_points(|point| point >= start && point < end, &[], true);
|
||||
if points.len() <= max {
|
||||
return points;
|
||||
}
|
||||
match max {
|
||||
0 => Vec::new(),
|
||||
1 => points.first().copied().into_iter().collect(),
|
||||
_ => {
|
||||
let span = i128::from(end).saturating_sub(i128::from(start)).max(1);
|
||||
let mut bucket = None;
|
||||
points
|
||||
.into_iter()
|
||||
.filter(|point| {
|
||||
let next = i128::from(*point).saturating_sub(i128::from(start)).saturating_mul(max as i128) / span;
|
||||
if bucket == Some(next) {
|
||||
false
|
||||
} else {
|
||||
bucket = Some(next);
|
||||
true
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The current Grid line at or before `position`.
|
||||
pub fn grid_line_at_or_before(&self, position: Samples) -> Samples {
|
||||
self.grid_line_at_or_before_excluding(position, &[])
|
||||
}
|
||||
|
||||
/// Find the current Grid line at or before `position` without boundaries of `excluded` clips.
|
||||
pub fn grid_line_at_or_before_excluding(&self, position: Samples, excluded: &[ClipId]) -> Samples {
|
||||
if !self.edit.grid_clips_markers {
|
||||
if let GridValue::FeetFrames(value) = self.edit.grid {
|
||||
return value.line_at_or_before(position, self.sample_rate, self.frame_rate, self.feet_frames_start());
|
||||
}
|
||||
let origin = self.grid_origin();
|
||||
return self
|
||||
.edit
|
||||
.grid
|
||||
.line_at_or_before(position.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate)
|
||||
.saturating_sub(origin);
|
||||
}
|
||||
self.clip_marker_grid_points(|_| true, excluded, true).into_iter().take_while(|point| *point <= position).last().unwrap_or(position)
|
||||
}
|
||||
|
||||
/// Snap to the nearest line in the current Grid.
|
||||
pub fn snap_to_grid(&self, position: Samples) -> Samples {
|
||||
self.snap_to_grid_excluding(position, &[])
|
||||
}
|
||||
|
||||
/// Snap to the nearest current Grid line without using boundaries of `excluded` clips.
|
||||
pub fn snap_to_grid_excluding(&self, position: Samples, excluded: &[ClipId]) -> Samples {
|
||||
if !self.edit.grid_clips_markers {
|
||||
if let GridValue::FeetFrames(value) = self.edit.grid {
|
||||
return value.snap(position, self.sample_rate, self.frame_rate, self.feet_frames_start());
|
||||
}
|
||||
let origin = self.grid_origin();
|
||||
return self.edit.grid.snap(position.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate).saturating_sub(origin);
|
||||
}
|
||||
let mut points = self.clip_marker_grid_points(|_| true, excluded, false);
|
||||
if !points.is_empty() {
|
||||
points.push(0);
|
||||
}
|
||||
points.into_iter().min_by_key(|point| i128::from(*point).saturating_sub(i128::from(position)).abs()).unwrap_or(position)
|
||||
}
|
||||
|
||||
pub fn next_track_color(&self) -> [u8; 3] {
|
||||
let n = self.tracks.iter().filter(|t| t.kind != TrackKind::Master).count();
|
||||
TRACK_COLORS.get(n % TRACK_COLORS.len()).copied().unwrap_or([128, 128, 128])
|
||||
@@ -762,6 +908,136 @@ mod tests {
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
|
||||
/// Clips/Markers mode snaps and draws at clip and memory-location boundaries.
|
||||
#[test]
|
||||
fn clips_markers_grid_uses_visible_clip_edges_and_memory_locations() {
|
||||
let mut s = Session::default();
|
||||
let track = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
|
||||
let clip = Clip::midi(s.new_clip_id(), "Notes", 1_000, 2_000, soundcraft_midi::Sequence::default());
|
||||
let clip_id = clip.id;
|
||||
s.track_mut(track).and_then(Track::playlist_mut).unwrap().clips.push(clip);
|
||||
let hidden = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
|
||||
let hidden_clip = Clip::midi(s.new_clip_id(), "Hidden", 3_200, 100, soundcraft_midi::Sequence::default());
|
||||
if let Some(track) = s.track_mut(hidden) {
|
||||
track.hidden = true;
|
||||
track.playlist_mut().unwrap().clips.push(hidden_clip);
|
||||
}
|
||||
s.add_marker("Cue", MarkerKind::Marker, 2_500, 2_500);
|
||||
s.add_marker("Selection", MarkerKind::Selection, 4_000, 4_500);
|
||||
s.edit.grid_clips_markers = true;
|
||||
|
||||
assert_eq!(s.grid_lines(0, 5_000, 20), [0, 1_000, 2_500, 3_000, 4_000, 4_500]);
|
||||
assert_eq!(s.grid_line_at_or_before(2_800), 2_500);
|
||||
assert_eq!(s.snap_to_grid(2_800), 3_000);
|
||||
assert_eq!(s.snap_to_grid(3_700), 4_000);
|
||||
assert_eq!(s.grid_lines(2_400, 4_100, 2), [2_500, 4_000]);
|
||||
assert_eq!(s.snap_to_grid(3_200), 3_000);
|
||||
assert_eq!(s.snap_to_grid_excluding(2_800, &[clip_id]), 2_500);
|
||||
|
||||
let folder = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
|
||||
s.track_mut(track).unwrap().folder = Some(folder);
|
||||
s.track_mut(folder).unwrap().folder_open = false;
|
||||
assert_eq!(s.grid_lines(0, 5_000, 20), [0, 2_500, 4_000, 4_500]);
|
||||
}
|
||||
|
||||
/// Open hidden folders keep their visible descendants eligible for lines and snapping.
|
||||
#[test]
|
||||
fn clips_markers_grid_keeps_visible_children_of_hidden_open_folders() {
|
||||
let mut s = Session::default();
|
||||
let outer = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
|
||||
let inner = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
|
||||
let child = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
|
||||
let clip = Clip::midi(s.new_clip_id(), "Visible", 1_000, 2_000, soundcraft_midi::Sequence::default());
|
||||
s.track_mut(inner).unwrap().folder = Some(outer);
|
||||
s.track_mut(inner).unwrap().hidden = true;
|
||||
s.track_mut(child).unwrap().folder = Some(inner);
|
||||
s.track_mut(child).unwrap().playlist_mut().unwrap().clips.push(clip);
|
||||
s.edit.grid_clips_markers = true;
|
||||
|
||||
assert_eq!(s.grid_lines(0, 5_000, 20), [0, 1_000, 3_000]);
|
||||
assert_eq!(s.snap_to_grid(2_800), 3_000);
|
||||
assert_eq!(s.grid_line_at_or_before(2_800), 1_000);
|
||||
|
||||
s.track_mut(child).unwrap().hidden = true;
|
||||
assert_eq!(s.grid_lines(0, 5_000, 20), [0]);
|
||||
assert_eq!(s.snap_to_grid(2_800), 2_800);
|
||||
s.track_mut(child).unwrap().hidden = false;
|
||||
s.track_mut(inner).unwrap().folder_open = false;
|
||||
assert_eq!(s.grid_lines(0, 5_000, 20), [0]);
|
||||
s.track_mut(inner).unwrap().folder_open = true;
|
||||
s.track_mut(outer).unwrap().folder_open = false;
|
||||
assert_eq!(s.grid_lines(0, 5_000, 20), [0]);
|
||||
}
|
||||
|
||||
/// With no real Clips/Markers boundaries, snapping leaves the position alone while drawing
|
||||
/// retains the session-start line.
|
||||
#[test]
|
||||
fn empty_clips_markers_grid_does_not_snap_everything_to_zero() {
|
||||
let mut s = Session::default();
|
||||
s.edit.grid_clips_markers = true;
|
||||
assert_eq!(s.grid_lines(0, 1_000, 10), [0]);
|
||||
assert_eq!(s.snap_to_grid(678), 678);
|
||||
}
|
||||
|
||||
/// Frame-based grids share the same references as their displayed counters.
|
||||
#[test]
|
||||
fn frame_grids_apply_session_counter_origins() {
|
||||
let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: FrameRate::Fps30, timecode_start: 1_600, ..Session::default() };
|
||||
s.edit.grid = GridValue::Timecode(soundcraft_time::TimecodeValue::Second);
|
||||
assert_eq!(s.grid_line_at_or_before(47_000), 46_400);
|
||||
assert_eq!(s.snap_to_grid(46_400), 46_400);
|
||||
|
||||
s.edit.grid = GridValue::FeetFrames(soundcraft_time::FeetFramesValue::Foot);
|
||||
s.edit.values.insert(FEET_FRAMES_START.into(), 1.0);
|
||||
assert_eq!(s.grid_line_at_or_before(25_000), 24_000);
|
||||
assert_eq!(s.snap_to_grid(24_000), 24_000);
|
||||
}
|
||||
|
||||
/// Every Feet+Frames increment shares the parser's single rounding at fractional frame rates.
|
||||
#[test]
|
||||
fn feet_frames_grid_matches_parsed_positions_with_signed_frame_origins() {
|
||||
use soundcraft_time::FeetFramesValue;
|
||||
for rate in [FrameRate::Fps23_976, FrameRate::Fps29_97, FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
|
||||
let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: rate, ..Session::default() };
|
||||
for origin in [-17, -1, 1, 17] {
|
||||
s.edit.values.insert(FEET_FRAMES_START.into(), origin as f64);
|
||||
for value in FeetFramesValue::ALL {
|
||||
s.edit.grid = GridValue::FeetFrames(value);
|
||||
for feet in [-64, -2, 0, 1, 2, 4, 7, 12, 128] {
|
||||
let at = s.parse_position(&format!("{feet}+00"), TimeFormat::FeetFrames).unwrap();
|
||||
assert_eq!(s.grid_line_at_or_before(at), at, "{rate:?} {value:?} origin {origin}, foot {feet}");
|
||||
assert_eq!(s.snap_to_grid(at), at);
|
||||
assert_eq!(s.grid_lines(at - 1, at + 2, 4), [at]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: FrameRate::Fps29_97, ..Session::default() };
|
||||
s.edit.values.insert(FEET_FRAMES_START.into(), 1.0);
|
||||
s.edit.grid = GridValue::FeetFrames(FeetFramesValue::Foot);
|
||||
assert_eq!(s.snap_to_grid(49_649), 49_650);
|
||||
assert_eq!(s.format_position(49_650, TimeFormat::FeetFrames), "2+00");
|
||||
}
|
||||
|
||||
/// Drop-frame minute boundaries keep their counter alignment with signed session origins.
|
||||
#[test]
|
||||
fn drop_frame_grids_apply_session_origins_at_minute_boundaries() {
|
||||
for (rate, minute_frame) in [(FrameRate::Fps29_97Drop, 1_800), (FrameRate::Fps59_94Drop, 3_600)] {
|
||||
for origin_frame in [-1, 1] {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let origin = rate.samples_at_frame(origin_frame, sr);
|
||||
let mut s = Session { sample_rate: sr, frame_rate: rate, timecode_start: origin, ..Session::default() };
|
||||
s.edit.grid = GridValue::Timecode(soundcraft_time::TimecodeValue::Second);
|
||||
for boundary in [-minute_frame, minute_frame] {
|
||||
let at = rate.samples_at_frame(boundary, sr) - origin;
|
||||
assert_eq!(s.grid_line_at_or_before(at), at, "{rate:?}, origin {origin_frame}");
|
||||
assert_eq!(s.snap_to_grid(at + 1), at, "{rate:?}, origin {origin_frame}");
|
||||
assert_eq!(s.grid_lines(at - 1, at + 2, 10), [at]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_with_legacy_fit_height_loads() {
|
||||
let mut s = Session::default();
|
||||
|
||||
+499
-20
@@ -1,11 +1,12 @@
|
||||
//! Grid and Nudge values: musical or absolute step sizes, grid lines and snapping.
|
||||
|
||||
use crate::{FrameRate, SampleRate, Samples, TICKS_PER_QUARTER, TempoMap, to_samples};
|
||||
use crate::{FrameRate, SampleRate, Samples, TICKS_PER_QUARTER, TempoMap, Timecode, to_samples};
|
||||
|
||||
/// Note values for Bars|Beats grids.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum NoteValue {
|
||||
Bar,
|
||||
Whole,
|
||||
Half,
|
||||
Quarter,
|
||||
Eighth,
|
||||
@@ -15,8 +16,10 @@ pub enum NoteValue {
|
||||
}
|
||||
|
||||
impl NoteValue {
|
||||
pub const ALL: [NoteValue; 7] = [
|
||||
/// Every musical increment in menu order, from largest to smallest.
|
||||
pub const ALL: [NoteValue; 8] = [
|
||||
NoteValue::Bar,
|
||||
NoteValue::Whole,
|
||||
NoteValue::Half,
|
||||
NoteValue::Quarter,
|
||||
NoteValue::Eighth,
|
||||
@@ -24,9 +27,11 @@ impl NoteValue {
|
||||
NoteValue::ThirtySecond,
|
||||
NoteValue::SixtyFourth,
|
||||
];
|
||||
/// Returns the nominal tick length; bars are adjusted for the active meter when used.
|
||||
pub fn ticks(self) -> i64 {
|
||||
match self {
|
||||
NoteValue::Bar => TICKS_PER_QUARTER * 4,
|
||||
NoteValue::Whole => TICKS_PER_QUARTER * 4,
|
||||
NoteValue::Half => TICKS_PER_QUARTER * 2,
|
||||
NoteValue::Quarter => TICKS_PER_QUARTER,
|
||||
NoteValue::Eighth => TICKS_PER_QUARTER / 2,
|
||||
@@ -35,9 +40,11 @@ impl NoteValue {
|
||||
NoteValue::SixtyFourth => TICKS_PER_QUARTER / 16,
|
||||
}
|
||||
}
|
||||
/// Returns the menu label for this musical increment.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
NoteValue::Bar => "1 bar",
|
||||
NoteValue::Whole => "Whole note",
|
||||
NoteValue::Half => "1/2 note",
|
||||
NoteValue::Quarter => "1/4 note",
|
||||
NoteValue::Eighth => "1/8 note",
|
||||
@@ -48,26 +55,257 @@ impl NoteValue {
|
||||
}
|
||||
}
|
||||
|
||||
/// The time scale used by a Grid or Nudge value.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum GridScale {
|
||||
BarsBeats,
|
||||
MinSecs,
|
||||
Timecode,
|
||||
FeetFrames,
|
||||
Samples,
|
||||
ClipsMarkers,
|
||||
}
|
||||
|
||||
impl GridScale {
|
||||
/// Numeric time scales shared by Grid and Nudge.
|
||||
pub const TIME: [GridScale; 5] = [GridScale::BarsBeats, GridScale::MinSecs, GridScale::Timecode, GridScale::FeetFrames, GridScale::Samples];
|
||||
|
||||
/// Returns the user-facing scale label.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
GridScale::BarsBeats => "Bars|Beats",
|
||||
GridScale::MinSecs => "Min:Secs",
|
||||
GridScale::Timecode => "Timecode",
|
||||
GridScale::FeetFrames => "Feet+Frames",
|
||||
GridScale::Samples => "Samples",
|
||||
GridScale::ClipsMarkers => "Clips/Markers",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stable command and session identifier.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
GridScale::BarsBeats => "bars_beats",
|
||||
GridScale::MinSecs => "min_secs",
|
||||
GridScale::Timecode => "timecode",
|
||||
GridScale::FeetFrames => "feet_frames",
|
||||
GridScale::Samples => "samples",
|
||||
GridScale::ClipsMarkers => "clips_markers",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a stable identifier or user-facing label.
|
||||
pub fn from_id(id: &str) -> Option<GridScale> {
|
||||
Self::TIME.into_iter().chain([GridScale::ClipsMarkers]).find(|scale| scale.id() == id || scale.label().eq_ignore_ascii_case(id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Timecode Grid/Nudge increments, ordered independently of the session frame rate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum TimecodeValue {
|
||||
Second,
|
||||
/// Five physical frames, independent of skipped drop-frame labels.
|
||||
FiveFrames,
|
||||
Frame,
|
||||
HalfFrame,
|
||||
QuarterFrame,
|
||||
SubFrame,
|
||||
}
|
||||
|
||||
impl TimecodeValue {
|
||||
/// Every Timecode increment in menu order, from largest to smallest.
|
||||
pub const ALL: [TimecodeValue; 6] = [
|
||||
TimecodeValue::Second,
|
||||
TimecodeValue::FiveFrames,
|
||||
TimecodeValue::Frame,
|
||||
TimecodeValue::HalfFrame,
|
||||
TimecodeValue::QuarterFrame,
|
||||
TimecodeValue::SubFrame,
|
||||
];
|
||||
|
||||
/// Returns the user-facing increment label.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TimecodeValue::Second => "1 second",
|
||||
TimecodeValue::FiveFrames => "5 frames",
|
||||
TimecodeValue::Frame => "1 frame",
|
||||
TimecodeValue::HalfFrame => "1/2 frame",
|
||||
TimecodeValue::QuarterFrame => "1/4 frame",
|
||||
TimecodeValue::SubFrame => "1 subframe",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stable command and session identifier.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
TimecodeValue::Second => "second",
|
||||
TimecodeValue::FiveFrames => "5_frames",
|
||||
TimecodeValue::Frame => "frame",
|
||||
TimecodeValue::HalfFrame => "half_frame",
|
||||
TimecodeValue::QuarterFrame => "quarter_frame",
|
||||
TimecodeValue::SubFrame => "subframe",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a stable identifier or user-facing label.
|
||||
pub fn from_id(id: &str) -> Option<TimecodeValue> {
|
||||
Self::ALL.into_iter().find(|value| value.id() == id || value.label().eq_ignore_ascii_case(id))
|
||||
}
|
||||
|
||||
/// Returns the increment length in frames at the session rate.
|
||||
fn frames(self, rate: FrameRate) -> f64 {
|
||||
match self {
|
||||
TimecodeValue::Second => rate.nominal() as f64,
|
||||
TimecodeValue::FiveFrames => 5.0,
|
||||
TimecodeValue::Frame => 1.0,
|
||||
TimecodeValue::HalfFrame => 0.5,
|
||||
TimecodeValue::QuarterFrame => 0.25,
|
||||
TimecodeValue::SubFrame => 0.01,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Feet+Frames Grid/Nudge increments in physical frames, independent of drop-frame label skips.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum FeetFramesValue {
|
||||
Foot,
|
||||
Frame,
|
||||
QuarterFrame,
|
||||
SubFrame,
|
||||
}
|
||||
|
||||
impl FeetFramesValue {
|
||||
/// Every Feet+Frames increment in menu order, from largest to smallest.
|
||||
pub const ALL: [FeetFramesValue; 4] = [FeetFramesValue::Foot, FeetFramesValue::Frame, FeetFramesValue::QuarterFrame, FeetFramesValue::SubFrame];
|
||||
|
||||
/// Returns the user-facing increment label.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FeetFramesValue::Foot => "1 foot",
|
||||
FeetFramesValue::Frame => "1 frame",
|
||||
FeetFramesValue::QuarterFrame => "1/4 frame",
|
||||
FeetFramesValue::SubFrame => "1 subframe",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stable command and session identifier.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
FeetFramesValue::Foot => "foot",
|
||||
FeetFramesValue::Frame => "frame",
|
||||
FeetFramesValue::QuarterFrame => "quarter_frame",
|
||||
FeetFramesValue::SubFrame => "subframe",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a stable identifier or user-facing label.
|
||||
pub fn from_id(id: &str) -> Option<FeetFramesValue> {
|
||||
Self::ALL.into_iter().find(|value| value.id() == id || value.label().eq_ignore_ascii_case(id))
|
||||
}
|
||||
|
||||
/// Returns the increment length in frames.
|
||||
fn frames(self) -> f64 {
|
||||
let (num, den) = self.frame_ratio();
|
||||
num as f64 / den as f64
|
||||
}
|
||||
|
||||
/// Exact numerator and denominator of the increment in physical frames.
|
||||
fn frame_ratio(self) -> (i128, i128) {
|
||||
match self {
|
||||
Self::Foot => (16, 1),
|
||||
Self::Frame => (1, 1),
|
||||
Self::QuarterFrame => (1, 4),
|
||||
Self::SubFrame => (1, 100),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the counter origin in frames before rounding once to the nearest sample.
|
||||
fn sample_line(self, index: i128, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
|
||||
let (num, den) = self.frame_ratio();
|
||||
let (rate_num, rate_den) = rate.rational();
|
||||
let frames = index * num - i128::from(origin) * den;
|
||||
let samples = frames * i128::from(sr.hz().max(1)) * i128::from(rate_den);
|
||||
let divisor = den * i128::from(rate_num);
|
||||
let magnitude = (samples.abs() + divisor / 2) / divisor;
|
||||
let rounded = if samples < 0 { -magnitude } else { magnitude };
|
||||
i64::try_from(rounded).unwrap_or(if rounded < 0 { i64::MIN } else { i64::MAX })
|
||||
}
|
||||
|
||||
/// Find a line index using exact frame arithmetic, allowing for whole-sample rounding.
|
||||
fn line_index(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> i128 {
|
||||
let (num, den) = self.frame_ratio();
|
||||
let (rate_num, rate_den) = rate.rational();
|
||||
let scale = i128::from(sr.hz().max(1)) * i128::from(rate_den);
|
||||
let frames = i128::from(position) * i128::from(rate_num) + i128::from(origin) * scale;
|
||||
let mut index = (frames * den).div_euclid(scale * num);
|
||||
if self.sample_line(index + 1, sr, rate, origin) <= position {
|
||||
index += 1;
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
/// The Feet+Frames line at or before a sample, relative to the counter's frame origin.
|
||||
pub fn line_at_or_before(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
|
||||
self.sample_line(self.line_index(position, sr, rate, origin), sr, rate, origin)
|
||||
}
|
||||
|
||||
/// The next distinct sample line; subframe increments may share a sample at low sample rates.
|
||||
fn next_line(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
|
||||
self.sample_line(self.line_index(position, sr, rate, origin) + 1, sr, rate, origin).max(position.saturating_add(1))
|
||||
}
|
||||
|
||||
/// Snap against frame-origin lines, choosing the earlier line when distances are equal.
|
||||
pub fn snap(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples {
|
||||
let lo = self.line_at_or_before(position, sr, rate, origin);
|
||||
let hi = self.next_line(lo, sr, rate, origin);
|
||||
if i128::from(position) - i128::from(lo) <= i128::from(hi) - i128::from(position) { lo } else { hi }
|
||||
}
|
||||
|
||||
/// Distinct Feet+Frames sample lines within `[start, end)`, capped at `max`.
|
||||
pub fn lines(self, start: Samples, end: Samples, sr: SampleRate, rate: FrameRate, origin: i64, max: usize) -> Vec<Samples> {
|
||||
let mut out = Vec::new();
|
||||
let mut line = self.line_at_or_before(start, sr, rate, origin);
|
||||
while line < end && out.len() < max {
|
||||
if line >= start {
|
||||
out.push(line);
|
||||
}
|
||||
let next = self.next_line(line, sr, rate, origin);
|
||||
if next <= line {
|
||||
break;
|
||||
}
|
||||
line = next;
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A grid or nudge step.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum GridValue {
|
||||
/// Musical: note value, optionally dotted or triplet.
|
||||
Note { value: NoteValue, dotted: bool, triplet: bool },
|
||||
/// Absolute seconds (Min:Secs grids: 0.001 … 10 s).
|
||||
/// Absolute seconds (Min:Secs grids: 0.001 … 1 s).
|
||||
Seconds(f64),
|
||||
/// Frames at the session timecode rate.
|
||||
/// Kept for sessions and commands written before named timecode increments were added.
|
||||
Frames(i64),
|
||||
/// A named Timecode increment, including fractional frames and one second.
|
||||
Timecode(TimecodeValue),
|
||||
/// A Feet+Frames increment (one foot is 16 frames).
|
||||
FeetFrames(FeetFramesValue),
|
||||
/// Raw samples.
|
||||
Samples(i64),
|
||||
}
|
||||
|
||||
impl Default for GridValue {
|
||||
/// Starts on a straight sixteenth-note Bars|Beats grid.
|
||||
fn default() -> Self {
|
||||
GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl GridValue {
|
||||
/// Returns the user-facing label for this increment.
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
GridValue::Note { value, dotted, triplet } => {
|
||||
@@ -80,15 +318,47 @@ impl GridValue {
|
||||
}
|
||||
s
|
||||
}
|
||||
GridValue::Seconds(s) => format!("{s:.3} sec"),
|
||||
GridValue::Seconds(s) => {
|
||||
if *s >= 1.0 {
|
||||
format!("{s} second{}", if *s == 1.0 { "" } else { "s" })
|
||||
} else {
|
||||
format!("{} ms", (s * 1000.0).round() as i64)
|
||||
}
|
||||
}
|
||||
GridValue::Frames(f) => format!("{f} frame{}", if *f == 1 { "" } else { "s" }),
|
||||
GridValue::Samples(n) => format!("{n} samples"),
|
||||
GridValue::Timecode(value) => value.label().to_string(),
|
||||
GridValue::FeetFrames(value) => value.label().to_string(),
|
||||
GridValue::Samples(n) => format!("{n} sample{}", if *n == 1 { "" } else { "s" }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The next larger or smaller value of the same kind, or `self` at either end. Note values keep their
|
||||
/// dot or triplet; seconds, frames and samples step by tens.
|
||||
/// Returns the time scale to which this increment belongs.
|
||||
pub fn scale(self) -> GridScale {
|
||||
match self {
|
||||
GridValue::Note { .. } => GridScale::BarsBeats,
|
||||
GridValue::Seconds(_) => GridScale::MinSecs,
|
||||
GridValue::Frames(_) | GridValue::Timecode(_) => GridScale::Timecode,
|
||||
GridValue::FeetFrames(_) => GridScale::FeetFrames,
|
||||
GridValue::Samples(_) => GridScale::Samples,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default increment for a newly selected time scale.
|
||||
pub fn for_scale(scale: GridScale) -> GridValue {
|
||||
match scale {
|
||||
GridScale::BarsBeats => GridValue::default(),
|
||||
GridScale::MinSecs => GridValue::Seconds(1.0),
|
||||
GridScale::Timecode => GridValue::Timecode(TimecodeValue::Frame),
|
||||
GridScale::FeetFrames => GridValue::FeetFrames(FeetFramesValue::Frame),
|
||||
GridScale::Samples => GridValue::Samples(100),
|
||||
GridScale::ClipsMarkers => GridValue::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The next larger or smaller menu value of the same kind, or `self` at either end. Note values keep
|
||||
/// their dot or triplet modifier.
|
||||
pub fn stepped(self, larger: bool) -> GridValue {
|
||||
/// Finds the next value in a numeric increment ladder without wrapping.
|
||||
fn next<T: PartialOrd + Copy>(ladder: &[T], v: T, larger: bool) -> Option<T> {
|
||||
if larger { ladder.iter().copied().find(|x| *x > v) } else { ladder.iter().rev().copied().find(|x| *x < v) }
|
||||
}
|
||||
@@ -98,9 +368,11 @@ impl GridValue {
|
||||
let j = if larger { i.checked_sub(1) } else { i.checked_add(1) };
|
||||
j.and_then(|j| NoteValue::ALL.get(j)).map_or(self, |&value| GridValue::Note { value, dotted, triplet })
|
||||
}
|
||||
GridValue::Seconds(s) => next(&[0.001, 0.01, 0.1, 1.0, 10.0], s, larger).map_or(self, GridValue::Seconds),
|
||||
GridValue::Seconds(s) => next(&[0.001, 0.01, 0.1, 0.5, 1.0], s, larger).map_or(self, GridValue::Seconds),
|
||||
GridValue::Frames(f) => next(&[1, 10, 100], f, larger).map_or(self, GridValue::Frames),
|
||||
GridValue::Samples(n) => next(&[1, 10, 100, 1_000, 10_000, 100_000], n, larger).map_or(self, GridValue::Samples),
|
||||
GridValue::Timecode(value) => step_enum(&TimecodeValue::ALL, value, larger).map_or(self, GridValue::Timecode),
|
||||
GridValue::FeetFrames(value) => step_enum(&FeetFramesValue::ALL, value, larger).map_or(self, GridValue::FeetFrames),
|
||||
GridValue::Samples(n) => next(&[1, 2, 10, 100, 1_000, 10_000], n, larger).map_or(self, GridValue::Samples),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,12 +398,17 @@ impl GridValue {
|
||||
}
|
||||
GridValue::Seconds(s) => sr.samples(s.clamp(0.000_01, 3600.0)).max(1),
|
||||
GridValue::Frames(f) => to_samples(rate.samples_per_frame(sr) * f.clamp(1, 1_000_000) as f64).max(1),
|
||||
GridValue::Timecode(value) => to_samples(rate.samples_per_frame(sr) * value.frames(rate)).max(1),
|
||||
GridValue::FeetFrames(value) => to_samples(rate.samples_per_frame(sr) * value.frames()).max(1),
|
||||
GridValue::Samples(n) => n.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Nearest grid line to `pos`.
|
||||
pub fn snap(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
|
||||
if let Self::FeetFrames(value) = *self {
|
||||
return value.snap(pos, sr, rate, 0);
|
||||
}
|
||||
let lo = self.line_at_or_before(pos, sr, map, rate);
|
||||
let hi = self.next_line(lo, sr, map, rate);
|
||||
if (pos - lo) <= (hi - pos) { lo } else { hi }
|
||||
@@ -140,6 +417,7 @@ impl GridValue {
|
||||
/// The grid line at or before `pos`.
|
||||
pub fn line_at_or_before(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
|
||||
match *self {
|
||||
Self::FeetFrames(value) => value.line_at_or_before(pos, sr, rate, 0),
|
||||
GridValue::Note { value, dotted, triplet } => {
|
||||
// Float ticks can land a hair either side of a line's own tick.
|
||||
let mut t = map.samples_to_ticks_f(pos, sr).floor() as i64;
|
||||
@@ -148,12 +426,14 @@ impl GridValue {
|
||||
} else if map.tick_to_samples(t.saturating_add(1), sr) <= pos {
|
||||
t = t.saturating_add(1);
|
||||
}
|
||||
// Grid lines restart at each bar line so odd meters stay aligned.
|
||||
// Sub-bar values restart at each bar so odd meters stay aligned. A whole note is
|
||||
// always four quarter notes and therefore keeps a continuous tick origin.
|
||||
let bb = map.bar_beat_at_tick(t);
|
||||
let bar_tick = map.bar_start_tick(bb.bar);
|
||||
let step = Self::note_ticks(value, dotted, triplet, map, bar_tick);
|
||||
let k = (t - bar_tick).div_euclid(step);
|
||||
map.tick_to_samples(bar_tick + k * step, sr)
|
||||
let origin = if value == NoteValue::Whole { 0 } else { bar_tick };
|
||||
let step = Self::note_ticks(value, dotted, triplet, map, origin);
|
||||
let k = (t - origin).div_euclid(step);
|
||||
map.tick_to_samples(origin + k * step, sr)
|
||||
}
|
||||
_ => self.abs_line(self.abs_index_at_or_before(pos, sr, rate), sr, rate),
|
||||
}
|
||||
@@ -162,21 +442,60 @@ impl GridValue {
|
||||
/// Position of absolute grid line `k` (Seconds, Frames, Samples), at an exact multiple of the step
|
||||
/// rounded once, so rounding never accumulates from line to line.
|
||||
fn abs_line(&self, k: i64, sr: SampleRate, rate: FrameRate) -> Samples {
|
||||
if let Self::FeetFrames(value) = *self {
|
||||
return value.sample_line(i128::from(k), sr, rate, 0);
|
||||
}
|
||||
if matches!(self, GridValue::Timecode(TimecodeValue::Second)) && rate.is_drop() {
|
||||
return rate.samples_at_frame(Self::drop_second_frame(k, rate), sr);
|
||||
}
|
||||
if let Some(frames) = self.frame_step(rate) {
|
||||
let whole = frames.round();
|
||||
if frames == whole {
|
||||
return rate.samples_at_frame(k.saturating_mul(whole as i64), sr);
|
||||
}
|
||||
let step = (rate.samples_per_frame(sr) * frames).max(1.0);
|
||||
return to_samples(k as f64 * step);
|
||||
}
|
||||
match *self {
|
||||
GridValue::Seconds(s) => to_samples(k as f64 * Self::seconds_step(s, sr)),
|
||||
GridValue::Frames(f) => rate.samples_at_frame(k.saturating_mul(f.clamp(1, 1_000_000)), sr),
|
||||
GridValue::Samples(n) => k.saturating_mul(n.max(1)),
|
||||
GridValue::Note { .. } => 0,
|
||||
GridValue::Frames(_) | GridValue::Timecode(_) | GridValue::FeetFrames(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the absolute grid line at or before `pos`.
|
||||
fn abs_index_at_or_before(&self, pos: Samples, sr: SampleRate, rate: FrameRate) -> i64 {
|
||||
let mut k = match *self {
|
||||
GridValue::Seconds(s) => (pos as f64 / Self::seconds_step(s, sr)).floor() as i64,
|
||||
GridValue::Frames(f) => rate.frames_at(pos, sr).div_euclid(f.clamp(1, 1_000_000)),
|
||||
GridValue::Samples(n) => return pos.div_euclid(n.max(1)),
|
||||
GridValue::Note { .. } => return 0,
|
||||
if matches!(self, GridValue::Timecode(TimecodeValue::Second)) && rate.is_drop() {
|
||||
let tc = Timecode::from_frames(rate.frames_at(pos, sr), rate);
|
||||
let magnitude = tc.hours.saturating_mul(3600).saturating_add(tc.minutes.saturating_mul(60)).saturating_add(tc.seconds);
|
||||
let mut k = if tc.negative { magnitude.saturating_neg() } else { magnitude };
|
||||
while self.abs_line(k, sr, rate) > pos {
|
||||
let previous = k.saturating_sub(1);
|
||||
if previous == k {
|
||||
break;
|
||||
}
|
||||
k = previous;
|
||||
}
|
||||
while self.abs_line(k.saturating_add(1), sr, rate) <= pos {
|
||||
let next = k.saturating_add(1);
|
||||
if next == k {
|
||||
break;
|
||||
}
|
||||
k = next;
|
||||
}
|
||||
return k;
|
||||
}
|
||||
let mut k = if let Some(frames) = self.frame_step(rate) {
|
||||
let step = (rate.samples_per_frame(sr) * frames).max(1.0);
|
||||
(pos as f64 / step).floor() as i64
|
||||
} else {
|
||||
match *self {
|
||||
GridValue::Seconds(s) => (pos as f64 / Self::seconds_step(s, sr)).floor() as i64,
|
||||
GridValue::Samples(n) => return pos.div_euclid(n.max(1)),
|
||||
GridValue::Note { .. } => return 0,
|
||||
GridValue::Frames(_) | GridValue::Timecode(_) | GridValue::FeetFrames(_) => return 0,
|
||||
}
|
||||
};
|
||||
// Lines are rounded to whole samples, so the floored index can be one off either way.
|
||||
if self.abs_line(k, sr, rate) > pos {
|
||||
@@ -192,8 +511,36 @@ impl GridValue {
|
||||
(s.clamp(0.000_01, 3600.0) * sr.as_f64()).max(1.0)
|
||||
}
|
||||
|
||||
/// Maps a signed whole-second timecode label to its physical frame at a drop-frame rate.
|
||||
fn drop_second_frame(index: i64, rate: FrameRate) -> i64 {
|
||||
let seconds = index.unsigned_abs();
|
||||
let total_minutes = seconds / 60;
|
||||
let frames = if seconds.is_multiple_of(60) && !total_minutes.is_multiple_of(10) { if rate.nominal() >= 60 { 4 } else { 2 } } else { 0 };
|
||||
Timecode {
|
||||
negative: index < 0,
|
||||
hours: i64::try_from(seconds / 3600).unwrap_or(i64::MAX),
|
||||
minutes: i64::try_from((seconds / 60) % 60).unwrap_or(0),
|
||||
seconds: i64::try_from(seconds % 60).unwrap_or(0),
|
||||
frames,
|
||||
}
|
||||
.to_frames(rate)
|
||||
}
|
||||
|
||||
/// A frame-based increment as a possibly fractional number of frames.
|
||||
fn frame_step(&self, rate: FrameRate) -> Option<f64> {
|
||||
match *self {
|
||||
GridValue::Frames(frames) => Some(frames.clamp(1, 1_000_000) as f64),
|
||||
GridValue::Timecode(value) => Some(value.frames(rate)),
|
||||
GridValue::FeetFrames(value) => Some(value.frames()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first grid line strictly after `line`.
|
||||
pub fn next_line(&self, line: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
|
||||
if let Self::FeetFrames(value) = *self {
|
||||
return value.next_line(line, sr, rate, 0);
|
||||
}
|
||||
if !matches!(self, GridValue::Note { .. }) {
|
||||
let next = self.abs_line(self.abs_index_at_or_before(line, sr, rate).saturating_add(1), sr, rate);
|
||||
return if next > line { next } else { line.saturating_add(1) };
|
||||
@@ -205,6 +552,9 @@ impl GridValue {
|
||||
|
||||
/// All grid lines within `[start, end)`, capped at `max` lines.
|
||||
pub fn lines(&self, start: Samples, end: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate, max: usize) -> Vec<Samples> {
|
||||
if let Self::FeetFrames(value) = *self {
|
||||
return value.lines(start, end, sr, rate, 0, max);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut p = self.line_at_or_before(start, sr, map, rate);
|
||||
while p < end && out.len() < max {
|
||||
@@ -221,10 +571,43 @@ impl GridValue {
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves one position through an ordered increment menu without wrapping.
|
||||
fn step_enum<T: Copy + PartialEq>(values: &[T], value: T, larger: bool) -> Option<T> {
|
||||
let index = values.iter().position(|candidate| *candidate == value)?;
|
||||
let next = if larger { index.checked_sub(1) } else { index.checked_add(1) }?;
|
||||
values.get(next).copied()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Frame origins are subtracted before rounding whole frames, quarter frames and subframes.
|
||||
#[test]
|
||||
fn feet_frames_origins_round_only_after_applying_the_frame_offset() {
|
||||
let (sr, rate) = (SampleRate::HZ_48000, FrameRate::Fps29_97);
|
||||
for at in [49_650, 100_901, 177_778] {
|
||||
assert_eq!(FeetFramesValue::Foot.line_at_or_before(at, sr, rate, 1), at);
|
||||
assert_eq!(FeetFramesValue::Foot.snap(at - 1, sr, rate, 1), at);
|
||||
}
|
||||
assert_eq!(FeetFramesValue::QuarterFrame.snap(50_049, sr, rate, 1), 50_050);
|
||||
assert_eq!(FeetFramesValue::SubFrame.lines(49_665, 49_667, sr, rate, 1, 4), [49_666]);
|
||||
}
|
||||
|
||||
/// Dense subframes retain distinct sample lines and remain bounded with extreme signed origins.
|
||||
#[test]
|
||||
fn feet_frames_subframes_handle_dense_steps_and_extreme_origins() {
|
||||
let sr = SampleRate::new(8_000).unwrap();
|
||||
let rate = FrameRate::Fps120;
|
||||
for origin in [i64::MIN, -1, 0, 1, i64::MAX] {
|
||||
assert_eq!(FeetFramesValue::SubFrame.lines(-3, 4, sr, rate, origin, 20), [-3, -2, -1, 0, 1, 2, 3]);
|
||||
for at in [i64::MIN, -1, 0, 1, i64::MAX] {
|
||||
assert_eq!(FeetFramesValue::SubFrame.line_at_or_before(at, sr, rate, origin), at);
|
||||
assert_eq!(FeetFramesValue::SubFrame.snap(at, sr, rate, origin), at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_lines_are_their_own_line_at_odd_tempos() {
|
||||
let (g, sr, rate) = (GridValue::default(), SampleRate::HZ_48000, FrameRate::Fps30);
|
||||
@@ -238,6 +621,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Musical boundaries stay exact across a tempo change while absolute scales remain
|
||||
/// independent of the tempo map.
|
||||
#[test]
|
||||
fn grids_stay_aligned_across_a_tempo_change() {
|
||||
let (sr, rate) = (SampleRate::HZ_48000, FrameRate::Fps29_97Drop);
|
||||
let mut map = TempoMap::default();
|
||||
map.set_tempo(TICKS_PER_QUARTER * 4, 90.0).unwrap();
|
||||
let musical = GridValue::Note { value: NoteValue::Quarter, dotted: false, triplet: false };
|
||||
for tick in [0, TICKS_PER_QUARTER * 3, TICKS_PER_QUARTER * 4, TICKS_PER_QUARTER * 5] {
|
||||
let line = map.tick_to_samples(tick, sr);
|
||||
assert_eq!(musical.line_at_or_before(line, sr, &map, rate), line);
|
||||
}
|
||||
for absolute in [
|
||||
GridValue::Seconds(0.5),
|
||||
GridValue::Timecode(TimecodeValue::Second),
|
||||
GridValue::FeetFrames(FeetFramesValue::Foot),
|
||||
GridValue::Samples(1_000),
|
||||
] {
|
||||
let line = absolute.next_line(absolute.line_at_or_before(100_000, sr, &map, rate), sr, &map, rate);
|
||||
assert_eq!(absolute.line_at_or_before(line, sr, &map, rate), line, "{absolute:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteenth_grid_at_120() {
|
||||
let g = GridValue::default();
|
||||
@@ -248,14 +654,33 @@ mod tests {
|
||||
assert_eq!(g.lines(0, 24_000, sr, &map, rate, 100), vec![0, 6000, 12000, 18000]);
|
||||
}
|
||||
|
||||
/// Absolute increments report their expected sample lengths at a fixed rate.
|
||||
#[test]
|
||||
fn absolute_grids() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
|
||||
assert_eq!(GridValue::Seconds(1.0).snap(70_000, sr, &map, rate), 48_000);
|
||||
assert_eq!(GridValue::Frames(1).step_samples(0, sr, &map, rate), 1920);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::Second).step_samples(0, sr, &map, rate), 48_000);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::FiveFrames).step_samples(0, sr, &map, rate), 9_600);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::HalfFrame).step_samples(0, sr, &map, rate), 960);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::QuarterFrame).step_samples(0, sr, &map, rate), 480);
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::SubFrame).step_samples(0, sr, &map, rate), 19);
|
||||
assert_eq!(GridValue::FeetFrames(FeetFramesValue::Foot).step_samples(0, sr, &map, rate), 30_720);
|
||||
assert_eq!(GridValue::Samples(0).step_samples(0, sr, &map, rate), 1);
|
||||
}
|
||||
|
||||
/// Bar length follows the meter while a whole note always spans four quarter notes.
|
||||
#[test]
|
||||
fn a_bar_and_a_whole_note_differ_in_three_four() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::new(120.0, 3, 4).unwrap(), FrameRate::Fps25);
|
||||
let note = |value| GridValue::Note { value, dotted: false, triplet: false };
|
||||
assert_eq!(note(NoteValue::Bar).step_samples(0, sr, &map, rate), 72_000);
|
||||
assert_eq!(note(NoteValue::Whole).step_samples(0, sr, &map, rate), 96_000);
|
||||
assert_eq!(note(NoteValue::Bar).line_at_or_before(96_000, sr, &map, rate), 72_000);
|
||||
assert_eq!(note(NoteValue::Whole).line_at_or_before(96_000, sr, &map, rate), 96_000);
|
||||
assert_eq!(note(NoteValue::Half).lines(0, 150_000, sr, &map, rate, 10), [0, 48_000, 72_000, 120_000, 144_000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seconds_grid_lines_land_on_whole_seconds() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_44100, TempoMap::default(), FrameRate::Fps30);
|
||||
@@ -268,6 +693,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy frame grids stay aligned to exact frame starts at fractional rates.
|
||||
#[test]
|
||||
fn frame_grid_lines_land_on_frame_starts() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
@@ -291,6 +717,55 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Named whole- and sub-frame grids retain the exact-multiple calculation used by legacy frame grids.
|
||||
#[test]
|
||||
fn named_frame_grids_land_on_exact_subdivisions() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let map = TempoMap::default();
|
||||
let grids = TimecodeValue::ALL.into_iter().map(GridValue::Timecode).chain(FeetFramesValue::ALL.into_iter().map(GridValue::FeetFrames));
|
||||
for rate in [FrameRate::Fps29_97, FrameRate::Fps23_976] {
|
||||
for grid in grids.clone() {
|
||||
for index in 0..200 {
|
||||
let line = grid.abs_line(index, sr, rate);
|
||||
assert_eq!(grid.line_at_or_before(line, sr, &map, rate), line, "{grid:?} {rate:?} line {index}");
|
||||
assert_eq!(grid.next_line(line, sr, &map, rate), grid.abs_line(index + 1, sr, rate), "{grid:?} {rate:?} line {index}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop-frame second grids follow labelled second boundaries in both timeline directions.
|
||||
#[test]
|
||||
fn drop_frame_second_grids_follow_timecode_labels() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let map = TempoMap::default();
|
||||
let grid = GridValue::Timecode(TimecodeValue::Second);
|
||||
for rate in [FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
|
||||
for seconds in [0, 59, 60, 61, 600, 601, 3600] {
|
||||
let frame = GridValue::drop_second_frame(seconds, rate);
|
||||
let line = rate.samples_at_frame(frame, sr);
|
||||
assert_eq!(grid.line_at_or_before(line, sr, &map, rate), line, "{rate:?} second {seconds}");
|
||||
assert_eq!(grid.line_at_or_before(-line, sr, &map, rate), -line, "{rate:?} second -{seconds}");
|
||||
for offset in -2..=rate.nominal() + 2 {
|
||||
let position = rate.samples_at_frame(frame.saturating_add(offset), sr);
|
||||
let before = grid.line_at_or_before(position, sr, &map, rate);
|
||||
assert!(before <= position, "{rate:?} second {seconds}, frame offset {offset}");
|
||||
assert!(grid.next_line(before, sr, &map, rate) > position, "{rate:?} second {seconds}, frame offset {offset}");
|
||||
}
|
||||
}
|
||||
let labelled = Timecode { negative: false, hours: 0, minutes: 1, seconds: 1, frames: 0 }.to_samples(rate, sr);
|
||||
assert_eq!(grid.snap(labelled, sr, &map, rate), labelled);
|
||||
}
|
||||
|
||||
for (rate, minute_frame) in [(FrameRate::Fps29_97Drop, 1800), (FrameRate::Fps59_94Drop, 3600)] {
|
||||
let boundary = rate.samples_at_frame(minute_frame, sr);
|
||||
assert_eq!(grid.line_at_or_before(boundary, sr, &map, rate), boundary, "{rate:?} positive minute boundary");
|
||||
assert_eq!(grid.snap(boundary, sr, &map, rate), boundary, "{rate:?} positive minute snap");
|
||||
assert_eq!(grid.line_at_or_before(-boundary, sr, &map, rate), -boundary, "{rate:?} negative minute boundary");
|
||||
assert_eq!(grid.snap(-boundary, sr, &map, rate), -boundary, "{rate:?} negative minute snap");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_are_capped() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
|
||||
@@ -306,6 +781,7 @@ mod tests {
|
||||
assert_eq!(d.step_samples(0, sr, &map, rate), 36_000);
|
||||
}
|
||||
|
||||
/// Stepping follows each menu ladder and stops at its endpoints.
|
||||
#[test]
|
||||
fn values_step_to_the_next_larger_or_smaller() {
|
||||
let note = |value, triplet| GridValue::Note { value, dotted: false, triplet };
|
||||
@@ -315,9 +791,12 @@ mod tests {
|
||||
assert_eq!(note(NoteValue::SixtyFourth, false).stepped(false), note(NoteValue::SixtyFourth, false));
|
||||
assert_eq!(GridValue::Seconds(0.01).stepped(true), GridValue::Seconds(0.1));
|
||||
assert_eq!(GridValue::Seconds(0.5).stepped(false), GridValue::Seconds(0.1));
|
||||
assert_eq!(GridValue::Seconds(10.0).stepped(true), GridValue::Seconds(10.0));
|
||||
assert_eq!(GridValue::Seconds(1.0).stepped(true), GridValue::Seconds(1.0));
|
||||
assert_eq!(GridValue::Frames(1).stepped(true), GridValue::Frames(10));
|
||||
assert_eq!(GridValue::Timecode(TimecodeValue::Frame).stepped(false), GridValue::Timecode(TimecodeValue::HalfFrame));
|
||||
assert_eq!(GridValue::FeetFrames(FeetFramesValue::Frame).stepped(true), GridValue::FeetFrames(FeetFramesValue::Foot));
|
||||
assert_eq!(GridValue::Samples(100).stepped(false), GridValue::Samples(10));
|
||||
assert_eq!(GridValue::Samples(1).stepped(true), GridValue::Samples(2));
|
||||
assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ mod tempo;
|
||||
mod timecode;
|
||||
|
||||
pub use format::{TimeFormat, format_length, format_position, parse_position};
|
||||
pub use grid::{GridValue, NoteValue};
|
||||
pub use grid::{FeetFramesValue, GridScale, GridValue, NoteValue, TimecodeValue};
|
||||
pub use tempo::{BarBeat, MeterEvent, TICKS_PER_QUARTER, TempoEvent, TempoMap};
|
||||
pub use timecode::{FrameRate, Timecode};
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ soundcraft-video.workspace = true
|
||||
# 0.37 makes the bundled faces opt-in; the app's own font loader starts from
|
||||
# `FontDefinitions::default()`, and kittest never supplies fonts to this root.
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_extras = { workspace = true }
|
||||
web-time.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
+322
-53
@@ -262,71 +262,213 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
|
||||
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak());
|
||||
ui.separator();
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
|
||||
if list.is_empty() {
|
||||
ui.label("No contributor data was built into this copy.");
|
||||
} else if v.table {
|
||||
table(ui, &list, &mut v);
|
||||
} else {
|
||||
if list.is_empty() {
|
||||
ui.label("No contributor data was built into this copy.");
|
||||
} else if v.table {
|
||||
// The table scrolls vertically itself, under a fixed header; it only scrolls sideways
|
||||
// once its columns are dragged wider than the dialog.
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v));
|
||||
} else {
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
contributors_grab_bag(ui, &list, v.names);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
}
|
||||
|
||||
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
|
||||
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
|
||||
for k in SortKey::ALL {
|
||||
let arrow = if v.key == k { if v.ascending { " ⏶" } else { " ⏷" } } else { "" };
|
||||
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
for c in list {
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
ui.label(group(c.prs));
|
||||
ui.label(group(c.commits));
|
||||
ui.label(group(c.lines_added));
|
||||
ui.label(group(c.lines_deleted));
|
||||
ui.label(signed(c.lines_delta()));
|
||||
ui.label(group(c.binary_added));
|
||||
ui.label(group(c.binary_deleted));
|
||||
ui.label(day(c.first_commit));
|
||||
ui.label(day(c.last_commit));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
/// How the contributor table's spare width is shared out: all of it to Name.
|
||||
const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
||||
|
||||
/// How the model table's spare width is shared out: to Company and Model, the text columns.
|
||||
const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0];
|
||||
|
||||
/// The narrowest a column can be dragged.
|
||||
const MIN_COLUMN: f32 = 28.0;
|
||||
|
||||
/// The width a column needs for its header and its widest cell.
|
||||
fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>) -> f32 {
|
||||
let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
|
||||
let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max);
|
||||
widest + 2.0 * ui.spacing().button_padding.x
|
||||
}
|
||||
|
||||
/// About › Models: AI models credited in Co-Authored-By trailers.
|
||||
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
|
||||
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
|
||||
/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
|
||||
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
|
||||
let spare = room - natural.iter().sum::<f32>();
|
||||
let weight = grow.iter().sum::<f32>();
|
||||
let slack = |w: f32| (w - MIN_COLUMN).max(0.0);
|
||||
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum();
|
||||
natural
|
||||
.iter()
|
||||
.zip(grow)
|
||||
.map(|(w, g)| {
|
||||
if *g > 0.0 && spare >= 0.0 {
|
||||
w + spare * g / weight
|
||||
} else if *g > 0.0 && flexible > 0.0 {
|
||||
w - (-spare).min(flexible) * slack(*w) / flexible
|
||||
} else {
|
||||
*w
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
|
||||
/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge:
|
||||
/// only a table dragged wider than the window scrolls sideways. Text too long for its column is
|
||||
/// clipped. When a column's natural width or the window changes, the columns go back to a new fit,
|
||||
/// dropping any dragged widths.
|
||||
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
|
||||
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
|
||||
let s = ui.spacing();
|
||||
let gaps = s.item_spacing.x * natural.len() as f32;
|
||||
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
|
||||
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
|
||||
// The table keeps its own column widths, so a change in what they were fitted to has to clear
|
||||
// them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available
|
||||
// width, so this compares the natural widths and the clip rect, which scrolling never moves.
|
||||
let fit = egui::Id::unique(("credits_fit", id));
|
||||
let measured = (natural.to_vec(), ui.clip_rect().width());
|
||||
let refit = ui.data_mut(|d| d.get_temp::<(Vec<f32>, f32)>(fit)).as_ref() != Some(&measured);
|
||||
ui.data_mut(|d| d.insert_temp(fit, measured));
|
||||
let mut t =
|
||||
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
|
||||
if refit {
|
||||
t.reset();
|
||||
}
|
||||
for w in widths {
|
||||
t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true));
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred.
|
||||
fn aligned<R>(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
|
||||
ui.with_layout(layout, add).inner
|
||||
}
|
||||
|
||||
fn row_height(ui: &egui::Ui) -> f32 {
|
||||
ui.text_style_height(&egui::TextStyle::Body) + 6.0
|
||||
}
|
||||
|
||||
/// One contributor's table cells after the name, in `SortKey::ALL` order.
|
||||
fn cells(c: &Contributor) -> [String; 9] {
|
||||
[
|
||||
group(c.prs),
|
||||
group(c.commits),
|
||||
group(c.lines_added),
|
||||
group(c.lines_deleted),
|
||||
signed(c.lines_delta()),
|
||||
group(c.binary_added),
|
||||
group(c.binary_deleted),
|
||||
day(c.first_commit).to_string(),
|
||||
day(c.last_commit).to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
/// A contributor-table header. The sorted column is highlighted; its direction shows on the
|
||||
/// toolbar's sort-order button, so no arrow widens the column.
|
||||
fn header_text(k: SortKey) -> RichText {
|
||||
RichText::new(k.label().1).strong()
|
||||
}
|
||||
|
||||
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
|
||||
let h = row_height(ui);
|
||||
let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect();
|
||||
let widths: Vec<f32> = SortKey::ALL
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &k)| match i.checked_sub(1) {
|
||||
None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))),
|
||||
Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())),
|
||||
})
|
||||
.collect();
|
||||
fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW)
|
||||
.header(h + 4.0, |mut header| {
|
||||
for k in SortKey::ALL {
|
||||
header.col(|ui| {
|
||||
let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))).on_hover_text(k.label().0);
|
||||
|
||||
if r.clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for (c, cells) in list.iter().zip(rows) {
|
||||
body.row(h, |mut row| {
|
||||
row.col(|ui| {
|
||||
aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)))
|
||||
.on_hover_text(c.summary());
|
||||
});
|
||||
for text in cells {
|
||||
row.col(|ui| {
|
||||
aligned(ui, true, |ui| ui.label(text));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
|
||||
pub fn models_ui(ui: &mut egui::Ui) {
|
||||
if MODELS.is_empty() {
|
||||
ui.label("No model credits were built into this copy.");
|
||||
return;
|
||||
}
|
||||
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
|
||||
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
|
||||
ui.label(RichText::new(h).strong());
|
||||
}
|
||||
ui.end_row();
|
||||
for m in MODELS {
|
||||
ui.label(m.company);
|
||||
ui.label(m.model);
|
||||
ui.label(m.version);
|
||||
ui.label(group(m.commits));
|
||||
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
|
||||
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
let h = row_height(ui);
|
||||
let titles = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"];
|
||||
// Company, Model and Version read as text; the counts line up on the right.
|
||||
let right = [false, false, false, true, true, true];
|
||||
let rows: Vec<[String; 6]> = MODELS
|
||||
.iter()
|
||||
.map(|m| {
|
||||
[
|
||||
m.company.to_string(),
|
||||
m.model.to_string(),
|
||||
m.version.to_string(),
|
||||
group(m.commits),
|
||||
format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64),
|
||||
format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let widths: Vec<f32> = titles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, title)| natural(ui, RichText::new(*title).strong(), rows.iter().filter_map(|r| r.get(i).cloned())))
|
||||
.collect();
|
||||
egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| {
|
||||
fitted_table(ui, "credits_models", &widths, &MODEL_GROW)
|
||||
.header(h + 4.0, |mut header| {
|
||||
for (title, r) in titles.into_iter().zip(right) {
|
||||
header.col(|ui| {
|
||||
aligned(ui, r, |ui| ui.label(RichText::new(title).strong()));
|
||||
});
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for cells in rows {
|
||||
body.row(h, |mut row| {
|
||||
for (text, r) in cells.into_iter().zip(right) {
|
||||
row.col(|ui| {
|
||||
aligned(ui, r, |ui| ui.label(text));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -406,4 +548,131 @@ mod tests {
|
||||
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
|
||||
assert_eq!(day(""), "—");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() {
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]);
|
||||
let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0);
|
||||
assert!((w.iter().sum::<f32>() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_little_room_shrinks_only_growing_columns() {
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() {
|
||||
let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0);
|
||||
assert!((w.iter().sum::<f32>() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}");
|
||||
assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}");
|
||||
assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]);
|
||||
}
|
||||
|
||||
const WINDOW: f32 = 640.5;
|
||||
const NATURAL: [f32; 3] = [120.0, 80.0, 80.0];
|
||||
const GROW: [f32; 3] = [1.0, 0.0, 0.0];
|
||||
|
||||
/// One frame of a table long enough to scroll; the rects of its header cells.
|
||||
fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec<egui::Event>) -> Vec<egui::Rect> {
|
||||
scrolled_frame(ctx, width, natural, 0.0, events)
|
||||
}
|
||||
|
||||
/// [`table_frame`], scrolled `offset` points sideways.
|
||||
fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec<egui::Event>) -> Vec<egui::Rect> {
|
||||
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)));
|
||||
let raw = egui::RawInput { screen_rect, events, ..Default::default() };
|
||||
let mut cells = Vec::new();
|
||||
let out = ctx.run_ui(raw, |ui| {
|
||||
cells.clear();
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| {
|
||||
fitted_table(ui, "test", natural, &GROW)
|
||||
.header(20.0, |mut header| {
|
||||
for _ in natural {
|
||||
header.col(|ui| cells.push(ui.max_rect()));
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for _ in 0..40 {
|
||||
body.row(20.0, |mut row| {
|
||||
for _ in natural {
|
||||
row.col(|ui| {
|
||||
ui.label("x");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
out.drop_without_applying_deltas();
|
||||
cells
|
||||
}
|
||||
|
||||
/// A new context with the table open and settled, and the rects of its header cells.
|
||||
fn opened_table() -> (egui::Context, Vec<egui::Rect>) {
|
||||
let ctx = egui::Context::default();
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
(ctx, cells)
|
||||
}
|
||||
|
||||
/// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go.
|
||||
fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) {
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
let start = egui::pos2(cell.right() + 4.0, cell.center().y);
|
||||
let halfway = start + egui::vec2(by / 2.0, 0.0);
|
||||
let end = start + egui::vec2(by, 0.0);
|
||||
let moved = Event::PointerMoved;
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
|
||||
table_frame(ctx, WINDOW, &NATURAL, vec![event]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
|
||||
let (_, opened) = opened_table();
|
||||
for (dragged, cell) in opened.iter().enumerate() {
|
||||
let (ctx, _) = opened_table();
|
||||
drag_edge(&ctx, *cell, -30.0);
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
|
||||
let want = if column == dragged { was.width() - 30.0 } else { was.width() };
|
||||
assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_fit_replaces_the_column_widths() {
|
||||
let (ctx, opened) = opened_table();
|
||||
drag_edge(&ctx, opened[0], -30.0);
|
||||
let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new());
|
||||
assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
|
||||
let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new());
|
||||
let widths: Vec<f32> = wider.iter().map(|c| c.width().round()).collect();
|
||||
assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
|
||||
assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
|
||||
let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new());
|
||||
assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_sideways_keeps_dragged_widths() {
|
||||
let (ctx, opened) = opened_table();
|
||||
drag_edge(&ctx, opened[0], 100.0);
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
|
||||
for offset in [13.3, 27.77, 41.1, 0.6] {
|
||||
scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
|
||||
let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
|
||||
assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,14 +167,20 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) {
|
||||
}
|
||||
app.edit_layout.content_h = content_h;
|
||||
let clip_rect = Rect::from_min_max(pos2(full.min.x, tl.min.y), pos2(full.max.x, tl.max.y));
|
||||
let grid = clips::GridLines::new(app.engine.session(), tl);
|
||||
let grid_revision = app.engine.revision;
|
||||
for (id, r) in app.edit_layout.rows.clone() {
|
||||
let row = rect(r);
|
||||
if row.max.y >= clip_rect.min.y && row.min.y <= clip_rect.max.y {
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(row));
|
||||
child.set_clip_rect(clip_rect);
|
||||
track_row(app, &mut child, TrackId(id), row, tl, &cols);
|
||||
track_row(app, &mut child, TrackId(id), row, tl, &cols, &grid);
|
||||
}
|
||||
}
|
||||
// Keep one consistent grid snapshot during this pass, then refresh after a row edits it.
|
||||
if app.engine.revision != grid_revision {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
// Below the tracks a click clears the selection and a double-click opens New Tracks.
|
||||
let empty = Rect::from_min_max(pos2(full.min.x, y.max(tl.min.y)), tl.max);
|
||||
if empty.height() > 4.0 {
|
||||
|
||||
@@ -25,26 +25,41 @@ pub(super) fn draw_strip_silence(painter: &egui::Painter, s: &Session, tl: Rect,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn grid_lines(painter: &egui::Painter, s: &Session, tl: Rect, lane: Rect) {
|
||||
let t = Tokens::current();
|
||||
let view = Range::new(sample_at(s, tl, tl.min.x).max(0), sample_at(s, tl, tl.max.x));
|
||||
let sr = s.sample_rate;
|
||||
// Bar lines always; grid lines when enabled and dense enough.
|
||||
let bar = GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false };
|
||||
let bar_px = bar.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
|
||||
if bar_px > 6.0 {
|
||||
let col = grid_veil(t.bar_line, t.playlist_bg);
|
||||
for at in bar.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 600) {
|
||||
let x = x_of(s, tl, at);
|
||||
painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, col));
|
||||
/// Grid positions computed once for the current viewport and shared by all visible lanes.
|
||||
#[derive(Default)]
|
||||
pub(super) struct GridLines {
|
||||
bars: Vec<f32>,
|
||||
grid: Vec<f32>,
|
||||
}
|
||||
|
||||
impl GridLines {
|
||||
/// Collect and project visible lines before drawing any lanes in this frame.
|
||||
pub(super) fn new(s: &Session, tl: Rect) -> Self {
|
||||
let mut lines = Self::default();
|
||||
let view = Range::new(sample_at(s, tl, tl.min.x).max(0), sample_at(s, tl, tl.max.x));
|
||||
let sr = s.sample_rate;
|
||||
// Bar lines always; grid lines when enabled and dense enough.
|
||||
let bar = GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false };
|
||||
let bar_px = bar.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
|
||||
if bar_px > 6.0 {
|
||||
lines.bars = bar.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 600).into_iter().map(|at| x_of(s, tl, at)).collect();
|
||||
}
|
||||
if s.edit.grid_lines {
|
||||
let step_px = s.edit.grid.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
|
||||
if s.edit.grid_clips_markers || step_px > 7.0 {
|
||||
let visible_columns = tl.width().ceil().clamp(1.0, 16_384.0) as usize;
|
||||
lines.grid = s.grid_lines(view.start, view.end, visible_columns).into_iter().map(|at| x_of(s, tl, at)).collect();
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
if s.edit.grid_lines {
|
||||
let step_px = s.edit.grid.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
|
||||
if step_px > 7.0 {
|
||||
let col = grid_veil(t.grid_line, t.playlist_bg);
|
||||
for at in s.edit.grid.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 2000) {
|
||||
let x = x_of(s, tl, at);
|
||||
|
||||
/// Draw the prepared lines on one lane, preserving bar and grid colors and stacking order.
|
||||
pub(super) fn draw(&self, painter: &egui::Painter, lane: Rect) {
|
||||
let t = Tokens::current();
|
||||
for (positions, color) in [(&self.bars, t.bar_line), (&self.grid, t.grid_line)] {
|
||||
let col = grid_veil(color, t.playlist_bg);
|
||||
for &x in positions {
|
||||
painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, col));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,8 @@ pub(super) fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let plan = drag_plan(ctx, &s, app.engine.revision, clips, (*from_track, *tt), *grab_at);
|
||||
*delta = move_delta(&plan, &s, *from_track, at - *grab_at, ctx.input(|i| i.modifiers.command));
|
||||
}
|
||||
Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = drag_snap(&s, at, ctx.input(|i| i.modifiers.command)),
|
||||
Some(Gesture::TrimStart { clip, to, .. }) => *to = trim_drag_snap(&s, *clip, at, true, ctx.input(|i| i.modifiers.command)),
|
||||
Some(Gesture::TrimEnd { clip, to, .. }) => *to = trim_drag_snap(&s, *clip, at, false, ctx.input(|i| i.modifiers.command)),
|
||||
Some(Gesture::MidiClip { to, .. }) => *to = at,
|
||||
Some(Gesture::Fade { clip, fade_in, to, .. }) => {
|
||||
if let Some(range) = s.find_clip(*clip).and_then(|(_, c)| fade_drag_range(c, *fade_in, at)) {
|
||||
@@ -206,7 +207,18 @@ fn finish_drag(app: &mut SoundApp, s: &Session, tl: Rect, p: Option<Pos2>) {
|
||||
|
||||
/// Grid snapping while dragging; holding Cmd/Ctrl swaps Grid and Slip.
|
||||
pub(super) fn drag_snap(s: &Session, at: Samples, flip: bool) -> Samples {
|
||||
if s.edit.edit_mode.is_grid() != flip { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at }
|
||||
drag_snap_excluding(s, at, flip, &[])
|
||||
}
|
||||
|
||||
/// Grid snapping that ignores boundaries belonging to clips changed by the gesture.
|
||||
pub(super) fn drag_snap_excluding(s: &Session, at: Samples, flip: bool, excluded: &[ClipId]) -> Samples {
|
||||
if s.edit.edit_mode.is_grid() != flip { s.snap_to_grid_excluding(at, excluded) } else { at }
|
||||
}
|
||||
|
||||
/// Snap a trim while preserving the clip edge when the pointer returns to its origin.
|
||||
pub(super) fn trim_drag_snap(s: &Session, clip: ClipId, at: Samples, start: bool, flip: bool) -> Samples {
|
||||
let origin = s.find_clip(clip).map(|(_, clip)| if start { clip.start } else { clip.end() });
|
||||
if origin == Some(at) { at } else { drag_snap_excluding(s, at, flip, &[clip]) }
|
||||
}
|
||||
|
||||
/// What a clip drag works out once per target track rather than every frame: where the tracks go
|
||||
@@ -216,6 +228,7 @@ pub(super) struct DragPlan {
|
||||
pub(super) dest: Option<Vec<(TrackId, TrackId)>>,
|
||||
pub(super) groups: Vec<(TrackId, Samples, Vec<Samples>)>,
|
||||
pub(super) moving: Vec<(TrackId, Samples, Samples)>,
|
||||
moving_ids: Vec<ClipId>,
|
||||
grabbed: Option<Clip>,
|
||||
}
|
||||
|
||||
@@ -246,10 +259,11 @@ pub(super) fn drag_plan(
|
||||
};
|
||||
let ids: HashSet<ClipId> = clips.iter().copied().collect();
|
||||
let movable = |c: &&Clip| ids.contains(&c.id) && !ops::locked(c);
|
||||
let moving = s.tracks.iter().flat_map(|t| t.clips().iter().filter(movable).map(|c| (t.id, c.start, c.length))).collect();
|
||||
let moving: Vec<_> = s.tracks.iter().flat_map(|t| t.clips().iter().filter(movable).map(|c| (t.id, c.start, c.length))).collect();
|
||||
let moving_ids: Vec<_> = s.tracks.iter().flat_map(|t| t.clips().iter().filter(movable).map(|c| c.id)).collect();
|
||||
let mine: Vec<&Clip> = s.track(from).map(|t| t.clips().iter().filter(movable).collect()).unwrap_or_default();
|
||||
let grabbed = mine.iter().rev().find(|c| c.range().contains(grab_at)).or_else(|| mine.iter().min_by_key(|c| c.start)).map(|c| (*c).clone());
|
||||
let plan = Arc::new(DragPlan { dest, groups, moving, grabbed });
|
||||
let plan = Arc::new(DragPlan { dest, groups, moving, moving_ids, grabbed });
|
||||
ctx.data_mut(|d| d.insert_temp(id, (key, Arc::clone(&plan))));
|
||||
plan
|
||||
}
|
||||
@@ -258,6 +272,10 @@ pub(super) fn drag_plan(
|
||||
/// Shuffle lands the earliest clip bound for the target track on a slot there, as
|
||||
/// `edit.move_clips` does; Grid snaps the grabbed clip.
|
||||
fn move_delta(plan: &DragPlan, s: &Session, from: TrackId, raw: Samples, flip: bool) -> Samples {
|
||||
let track_changed = plan.dest.as_ref().is_some_and(|dest| ops::lands(dest, from) != from);
|
||||
if raw == 0 && !track_changed {
|
||||
return 0;
|
||||
}
|
||||
let mode = s.edit.edit_mode;
|
||||
if mode == EditMode::Shuffle {
|
||||
let to = plan.dest.as_ref().map_or(from, |d| ops::lands(d, from));
|
||||
@@ -268,7 +286,7 @@ fn move_delta(plan: &DragPlan, s: &Session, from: TrackId, raw: Samples, flip: b
|
||||
} else if mode.is_grid() != flip
|
||||
&& let Some(c) = &plan.grabbed
|
||||
{
|
||||
ops::grid_delta(s, c, raw, mode == EditMode::GridRelative)
|
||||
ops::grid_delta(s, c, raw, mode == EditMode::GridRelative, &plan.moving_ids)
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@ use super::{
|
||||
|
||||
// ---- interaction -----------------------------------------------------------------------------
|
||||
|
||||
/// Apply snapping when the current edit mode or Snap to Grid option enables it.
|
||||
pub(super) fn snap(s: &Session, at: Samples) -> Samples {
|
||||
let on = s.edit.edit_mode.is_grid() || s.edit.flag(ops::SNAP_TO_GRID);
|
||||
if on { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at }
|
||||
if on { s.snap_to_grid(at) } else { at }
|
||||
}
|
||||
|
||||
pub(super) fn clip_at(track: &Track, at: Samples) -> Option<&Clip> {
|
||||
|
||||
@@ -2,6 +2,25 @@ use super::*;
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
/// A trim cannot snap to the clip's own old boundary in Clips/Markers mode.
|
||||
#[test]
|
||||
fn clips_markers_trim_ignores_the_trimmed_clip() {
|
||||
let mut s = Session::default();
|
||||
let track = s.add_track(TrackKind::Midi, soundcraft_model::ChannelFormat::Mono, None);
|
||||
let clip = Clip::midi(s.new_clip_id(), "Notes", 1_000, 1_000, soundcraft_midi::Sequence::default());
|
||||
let clip_id = clip.id;
|
||||
s.track_mut(track).and_then(Track::playlist_mut).unwrap().clips.push(clip);
|
||||
s.add_marker("Inside", soundcraft_model::MarkerKind::Marker, 1_500, 1_500);
|
||||
s.add_marker("Target", soundcraft_model::MarkerKind::Marker, 2_500, 2_500);
|
||||
s.edit.edit_mode = soundcraft_model::EditMode::Grid;
|
||||
s.edit.grid_clips_markers = true;
|
||||
|
||||
assert_eq!(super::drag::drag_snap(&s, 2_100, false), 2_000);
|
||||
assert_eq!(super::drag::drag_snap_excluding(&s, 2_100, false, &[clip_id]), 2_500);
|
||||
assert_eq!(super::drag::trim_drag_snap(&s, clip_id, 1_000, true, false), 1_000);
|
||||
assert_eq!(super::drag::trim_drag_snap(&s, clip_id, 2_000, false, false), 2_000);
|
||||
}
|
||||
|
||||
/// The painted header selectors are interactive controls; their accessible name is the field
|
||||
/// they edit, not the short value text ("Read", "Waveform") painted inside them.
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::automation::draw_automation;
|
||||
use super::clips::{ClipLane, draw_clip, draw_live_take, draw_strip_silence, grid_lines};
|
||||
use super::clips::{ClipLane, GridLines, draw_clip, draw_live_take, draw_strip_silence};
|
||||
use super::header::{header_column, track_header};
|
||||
use super::input::lane_interaction;
|
||||
use super::layout::{Column, HEADER_W, LANE_H, STRIP_W, lane_rect, sample_at, x_of};
|
||||
@@ -58,7 +58,8 @@ pub(crate) fn click_track(app: &mut SoundApp, id: TrackId, mods: egui::Modifiers
|
||||
|
||||
// ---- track rows ------------------------------------------------------------------------------
|
||||
|
||||
pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect, cols: &[Column]) {
|
||||
/// Draw one track with grid positions shared by all lanes in the current frame.
|
||||
pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect, cols: &[Column], grid: &GridLines) {
|
||||
let t = Tokens::current();
|
||||
let s = app.engine.session_arc();
|
||||
let Some(track) = s.track(id) else { return };
|
||||
@@ -84,7 +85,7 @@ pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect,
|
||||
let s = app.engine.session_arc();
|
||||
// Shown grid lines go over the clips; otherwise the bar lines stay behind them.
|
||||
if !s.edit.grid_lines {
|
||||
grid_lines(&painter, &s, tl, lane);
|
||||
grid.draw(&painter, lane);
|
||||
}
|
||||
let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x));
|
||||
let auto_view = AutoParam::parse(&track.view);
|
||||
@@ -98,7 +99,7 @@ pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect,
|
||||
draw_live_take(app, &painter, &s, track, tl, lane);
|
||||
crate::video_track::draw_lane(app, &painter, &s, track, tl, lane);
|
||||
if s.edit.grid_lines {
|
||||
grid_lines(&painter, &s, tl, lane);
|
||||
grid.draw(&painter, lane);
|
||||
}
|
||||
if let Some(p) = &auto_view {
|
||||
draw_automation(&painter, &s, track, p, tl, lane);
|
||||
|
||||
@@ -7,7 +7,7 @@ pub(super) fn about(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
let tab_id = egui::Id::unique("about_tab");
|
||||
egui::Window::new("About SoundCraft").open(&mut open).default_size(vec2(640.0, 420.0)).collapsible(false).show(ctx, |ui| {
|
||||
egui::Window::new("About SoundCraft").open(&mut open).default_size(vec2(820.0, 420.0)).collapsible(false).show(ctx, |ui| {
|
||||
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(tab_id)).unwrap_or(0);
|
||||
ui.horizontal(|ui| {
|
||||
for (i, l) in (0u8..).zip(["About", "Contributors", "Models"]) {
|
||||
|
||||
+339
-27
@@ -3,11 +3,11 @@
|
||||
use crate::theme::{Tokens, bold, mono, regular};
|
||||
use crate::widgets::{icon_button, key, key_fill};
|
||||
use crate::{SoundApp, icons};
|
||||
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use egui::{Align2, Color32, CornerRadius, Painter, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::edit::{SHUFFLE_LOCK, SNAP_TO_GRID};
|
||||
use soundcraft_model::{EditMode, EditState, Tool};
|
||||
use soundcraft_time::{TimeFormat, format_length};
|
||||
use soundcraft_model::{EditMode, EditState, Session, Tool};
|
||||
use soundcraft_time::{FeetFramesValue, GridScale, GridValue, NoteValue, TimeFormat, TimecodeValue, format_length};
|
||||
|
||||
pub const HEIGHT: f32 = 78.0;
|
||||
|
||||
@@ -281,12 +281,12 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) {
|
||||
ui.painter().text(pos2(r.max.x - 8.0, y), Align2::RIGHT_CENTER, format!("{st} · {dev}"), regular(10.0), t.text_dim);
|
||||
}
|
||||
|
||||
/// Grid-line toggle, grid value menu and nudge value menu.
|
||||
/// Draws the compact Grid and Nudge readouts and their scale menus.
|
||||
fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let t = Tokens::current();
|
||||
let (grid, nudge, lines) = {
|
||||
let (grid, nudge, lines, grid_follow, nudge_follow, clips_markers) = {
|
||||
let e = &app.engine.session().edit;
|
||||
(e.grid, e.nudge, e.grid_lines)
|
||||
(e.grid, e.nudge, e.grid_lines, e.grid_follow_main, e.nudge_follow_main, e.grid_clips_markers)
|
||||
};
|
||||
let (r, _) = ui.allocate_exact_size(vec2(170.0, 64.0), Sense::hover());
|
||||
ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, t.counter_border), StrokeKind::Inside);
|
||||
@@ -298,39 +298,243 @@ fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let _ = app.run("view.grid_lines", json!({}));
|
||||
}
|
||||
let gr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 6.0), pos2(r.max.x - 4.0, r.min.y + 26.0));
|
||||
ui.painter().text(pos2(gr.max.x - 14.0, gr.center().y), Align2::RIGHT_CENTER, grid.label(), mono(11.0), t.counter_text);
|
||||
let grid_label = if clips_markers { GridScale::ClipsMarkers.label().to_string() } else { compact_grid_label(app.engine.session(), grid) };
|
||||
if !clips_markers {
|
||||
draw_compact_note_icon(ui.painter(), gr, grid, t.counter_text);
|
||||
}
|
||||
ui.painter().text(pos2(gr.max.x - 14.0, gr.center().y), Align2::RIGHT_CENTER, grid_label, mono(11.0), t.counter_text);
|
||||
let gresp = ui.interact(gr, ui.scope_id().with("grid_value"), Sense::click());
|
||||
value_menu(app, &gresp, "view.grid");
|
||||
value_menu(app, &gresp, "view.grid", grid, grid_follow, clips_markers, true);
|
||||
ui.painter().text(pos2(r.min.x + 10.0, r.min.y + 44.0), Align2::LEFT_CENTER, "Nudge", regular(11.0), t.counter_label);
|
||||
let nr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 34.0), pos2(r.max.x - 4.0, r.min.y + 54.0));
|
||||
ui.painter().text(pos2(nr.max.x - 14.0, nr.center().y), Align2::RIGHT_CENTER, nudge.label(), mono(11.0), t.counter_text);
|
||||
draw_compact_note_icon(ui.painter(), nr, nudge, t.counter_text);
|
||||
ui.painter().text(
|
||||
pos2(nr.max.x - 14.0, nr.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
compact_grid_label(app.engine.session(), nudge),
|
||||
mono(11.0),
|
||||
t.counter_text,
|
||||
);
|
||||
let nresp = ui.interact(nr, ui.scope_id().with("nudge_value"), Sense::click());
|
||||
value_menu(app, &nresp, "view.nudge");
|
||||
value_menu(app, &nresp, "view.nudge", nudge, nudge_follow, false, false);
|
||||
for rr in [gr, nr] {
|
||||
icons::draw(ui.painter(), Rect::from_center_size(pos2(rr.max.x - 6.0, rr.center().y), vec2(10.0, 10.0)), "triangle_down", t.text_dim);
|
||||
}
|
||||
}
|
||||
|
||||
fn value_menu(app: &mut SoundApp, resp: &egui::Response, cmd: &str) {
|
||||
egui::Popup::menu(resp).show(|ui| {
|
||||
for v in ["1 bar", "1/2", "1/4", "1/8", "1/16", "1/32", "1/64", "1/8t", "1/16t", "1/4."] {
|
||||
if ui.button(v).clicked() {
|
||||
let _ = app.run(cmd, json!({"value": v}));
|
||||
/// Formats a Grid/Nudge increment in the compact counter style for its time scale.
|
||||
fn compact_grid_label(session: &Session, value: GridValue) -> String {
|
||||
match value {
|
||||
GridValue::Note { value, dotted, triplet } => {
|
||||
let at_tick = session.tempo.samples_to_ticks(session.edit.selection.start, session.sample_rate);
|
||||
let meter = session.tempo.meter_at_tick(at_tick);
|
||||
let mut ticks = if value == NoteValue::Bar { meter.ticks_per_bar() } else { value.ticks() };
|
||||
if dotted {
|
||||
ticks = ticks * 3 / 2;
|
||||
}
|
||||
if triplet {
|
||||
ticks = ticks * 2 / 3;
|
||||
}
|
||||
let per_bar = meter.ticks_per_bar().max(1);
|
||||
let per_beat = meter.ticks_per_beat().max(1);
|
||||
let bars = ticks / per_bar;
|
||||
let remaining = ticks % per_bar;
|
||||
format!("{bars}|{}|{:03}", remaining / per_beat, remaining % per_beat)
|
||||
}
|
||||
GridValue::Seconds(seconds) => {
|
||||
let samples = session.sample_rate.samples(seconds);
|
||||
format_length(session.edit.selection.start, samples, TimeFormat::MinSecs, session.sample_rate, &session.tempo, session.frame_rate)
|
||||
}
|
||||
GridValue::Frames(frames) => {
|
||||
let samples = session.frame_rate.samples_at_frame(frames, session.sample_rate);
|
||||
format!(
|
||||
"{}.00",
|
||||
format_length(session.edit.selection.start, samples, TimeFormat::Timecode, session.sample_rate, &session.tempo, session.frame_rate)
|
||||
)
|
||||
}
|
||||
GridValue::Timecode(value) => match value {
|
||||
TimecodeValue::Second => "00:00:01:00.00",
|
||||
TimecodeValue::FiveFrames => "00:00:00:05.00",
|
||||
TimecodeValue::Frame => "00:00:00:01.00",
|
||||
TimecodeValue::HalfFrame => "00:00:00:00.50",
|
||||
TimecodeValue::QuarterFrame => "00:00:00:00.25",
|
||||
TimecodeValue::SubFrame => "00:00:00:00.01",
|
||||
}
|
||||
.to_string(),
|
||||
GridValue::FeetFrames(value) => match value {
|
||||
FeetFramesValue::Foot => "1+00",
|
||||
FeetFramesValue::Frame => "0+01",
|
||||
FeetFramesValue::QuarterFrame => "0+00.25",
|
||||
FeetFramesValue::SubFrame => "0+00.01",
|
||||
}
|
||||
.to_string(),
|
||||
GridValue::Samples(samples) => samples.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the musical symbol beside a compact Bars|Beats value.
|
||||
fn draw_compact_note_icon(painter: &Painter, rect: Rect, value: GridValue, color: Color32) {
|
||||
let GridValue::Note { value, dotted, triplet } = value else { return };
|
||||
draw_note_value_icon(painter, Rect::from_min_size(pos2(rect.min.x + 1.0, rect.min.y + 2.0), vec2(14.0, 16.0)), value, dotted, triplet, color);
|
||||
}
|
||||
|
||||
/// Draws a musical duration and its optional dotted or triplet modifier.
|
||||
fn draw_note_value_icon(painter: &Painter, rect: Rect, value: NoteValue, dotted: bool, triplet: bool, color: Color32) {
|
||||
let stroke = Stroke::new(1.2, color);
|
||||
let head = pos2(rect.min.x + 4.0, rect.max.y - 3.5);
|
||||
if value == NoteValue::Bar {
|
||||
painter.line_segment([pos2(rect.min.x + 2.0, rect.min.y + 2.0), pos2(rect.min.x + 2.0, rect.max.y - 2.0)], stroke);
|
||||
painter.line_segment([pos2(rect.min.x + 5.0, rect.min.y + 2.0), pos2(rect.min.x + 5.0, rect.max.y - 2.0)], stroke);
|
||||
painter.line_segment([pos2(rect.min.x + 2.0, rect.min.y + 3.0), pos2(rect.min.x + 10.0, rect.min.y + 3.0)], stroke);
|
||||
} else {
|
||||
let hollow = matches!(value, NoteValue::Whole | NoteValue::Half);
|
||||
if hollow {
|
||||
painter.circle_stroke(head, 2.2, stroke);
|
||||
} else {
|
||||
painter.circle_filled(head, 2.2, color);
|
||||
}
|
||||
if value != NoteValue::Whole {
|
||||
let stem_x = head.x + 2.0;
|
||||
painter.line_segment([pos2(stem_x, head.y), pos2(stem_x, rect.min.y + 2.0)], stroke);
|
||||
let flags = match value {
|
||||
NoteValue::Eighth => 1,
|
||||
NoteValue::Sixteenth => 2,
|
||||
NoteValue::ThirtySecond => 3,
|
||||
NoteValue::SixtyFourth => 4,
|
||||
_ => 0,
|
||||
};
|
||||
for flag in 0..flags {
|
||||
let y = rect.min.y + 2.0 + flag as f32 * 2.0;
|
||||
painter.line_segment([pos2(stem_x, y), pos2(stem_x + 4.0, y + 2.5)], stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
if dotted {
|
||||
painter.circle_filled(pos2(rect.max.x - 1.5, head.y), 1.0, color);
|
||||
}
|
||||
if triplet {
|
||||
painter.text(pos2(rect.min.x, rect.min.y), Align2::LEFT_TOP, "3", regular(7.0), color);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the complete Grid or Nudge increment, scale, and follow-mode menu.
|
||||
fn value_menu(app: &mut SoundApp, resp: &egui::Response, cmd: &str, current: GridValue, follow_main: bool, clips_markers: bool, allow_clips: bool) {
|
||||
egui::Popup::menu(resp).show(|ui| {
|
||||
ui.set_min_width(210.0);
|
||||
match current.scale() {
|
||||
GridScale::BarsBeats => {
|
||||
let selected = match current {
|
||||
GridValue::Note { value, .. } => value,
|
||||
_ => NoteValue::Sixteenth,
|
||||
};
|
||||
let suffix = match current {
|
||||
GridValue::Note { dotted: true, .. } => ".",
|
||||
GridValue::Note { triplet: true, .. } => "t",
|
||||
_ => "",
|
||||
};
|
||||
for value in NoteValue::ALL {
|
||||
let id = match value {
|
||||
NoteValue::Bar => "1 bar",
|
||||
NoteValue::Whole => "whole",
|
||||
NoteValue::Half => "1/2",
|
||||
NoteValue::Quarter => "1/4",
|
||||
NoteValue::Eighth => "1/8",
|
||||
NoteValue::Sixteenth => "1/16",
|
||||
NoteValue::ThirtySecond => "1/32",
|
||||
NoteValue::SixtyFourth => "1/64",
|
||||
};
|
||||
menu_note_value(ui, app, cmd, selected == value, value, json!(format!("{id}{suffix}")));
|
||||
}
|
||||
ui.separator();
|
||||
let (value, dotted, triplet) = match current {
|
||||
GridValue::Note { value, dotted, triplet } => (value, dotted, triplet),
|
||||
_ => (NoteValue::Sixteenth, false, false),
|
||||
};
|
||||
let base = match value {
|
||||
NoteValue::Bar => "1 bar",
|
||||
NoteValue::Whole => "whole",
|
||||
NoteValue::Half => "1/2",
|
||||
NoteValue::Quarter => "1/4",
|
||||
NoteValue::Eighth => "1/8",
|
||||
NoteValue::Sixteenth => "1/16",
|
||||
NoteValue::ThirtySecond => "1/32",
|
||||
NoteValue::SixtyFourth => "1/64",
|
||||
};
|
||||
menu_modifier(ui, app, cmd, dotted, "Dotted", "·", json!(if dotted { base.to_string() } else { format!("{base}.") }));
|
||||
menu_modifier(ui, app, cmd, triplet, "Triplet", "3", json!(if triplet { base.to_string() } else { format!("{base}t") }));
|
||||
}
|
||||
GridScale::MinSecs => {
|
||||
for seconds in [1.0, 0.5, 0.1, 0.01, 0.001] {
|
||||
let value = GridValue::Seconds(seconds);
|
||||
menu_value(ui, app, cmd, current == value, &value.label(), json!({"seconds": seconds}));
|
||||
}
|
||||
}
|
||||
GridScale::Timecode => {
|
||||
for value in TimecodeValue::ALL {
|
||||
let selected = current == GridValue::Timecode(value)
|
||||
|| matches!(
|
||||
(current, value),
|
||||
(GridValue::Frames(5), TimecodeValue::FiveFrames) | (GridValue::Frames(1), TimecodeValue::Frame)
|
||||
);
|
||||
menu_value(ui, app, cmd, selected, value.label(), json!({"timecode": value.id()}));
|
||||
}
|
||||
}
|
||||
GridScale::FeetFrames => {
|
||||
for value in FeetFramesValue::ALL {
|
||||
menu_value(ui, app, cmd, current == GridValue::FeetFrames(value), value.label(), json!({"feet_frames": value.id()}));
|
||||
}
|
||||
}
|
||||
GridScale::Samples => {
|
||||
for samples in [10_000, 1_000, 100, 10, 2, 1] {
|
||||
let value = GridValue::Samples(samples);
|
||||
menu_value(ui, app, cmd, current == value, &value.label(), json!({"samples": samples}));
|
||||
}
|
||||
}
|
||||
GridScale::ClipsMarkers => {}
|
||||
}
|
||||
ui.separator();
|
||||
for (label, v) in [
|
||||
("1 msec", json!({"seconds": 0.001})),
|
||||
("10 msec", json!({"seconds": 0.01})),
|
||||
("100 msec", json!({"seconds": 0.1})),
|
||||
("1 second", json!({"seconds": 1.0})),
|
||||
("1 frame", json!({"frames": 1})),
|
||||
("100 samples", json!({"samples": 100})),
|
||||
] {
|
||||
if ui.button(label).clicked() {
|
||||
let _ = app.run(cmd, json!({"value": v}));
|
||||
for scale in GridScale::TIME {
|
||||
let selected = !clips_markers && current.scale() == scale;
|
||||
if ui.selectable_label(selected, scale.label()).clicked() {
|
||||
let _ = app.run(cmd, json!({"format": scale.id(), "follow_main": false}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
if allow_clips && ui.selectable_label(clips_markers, GridScale::ClipsMarkers.label()).clicked() {
|
||||
let _ = app.run(cmd, json!({"format": GridScale::ClipsMarkers.id(), "follow_main": false}));
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.selectable_label(follow_main, "Follow Main Time Scale").clicked() {
|
||||
let _ = app.run(cmd, json!({"follow_main": !follow_main}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Adds one text menu value and dispatches its command when selected.
|
||||
fn menu_value(ui: &mut Ui, app: &mut SoundApp, cmd: &str, selected: bool, label: &str, value: serde_json::Value) {
|
||||
if ui.selectable_label(selected, label).clicked() {
|
||||
let _ = app.run(cmd, json!({"value": value}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds one musical value with its duration symbol.
|
||||
fn menu_note_value(ui: &mut Ui, app: &mut SoundApp, cmd: &str, selected: bool, note: NoteValue, value: serde_json::Value) {
|
||||
ui.horizontal(|ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
|
||||
draw_note_value_icon(ui.painter(), rect, note, false, false, ui.visuals().text_color());
|
||||
menu_value(ui, app, cmd, selected, note.label(), value);
|
||||
});
|
||||
}
|
||||
|
||||
/// Adds a dotted or triplet modifier with its compact symbol.
|
||||
fn menu_modifier(ui: &mut Ui, app: &mut SoundApp, cmd: &str, selected: bool, label: &str, symbol: &str, value: serde_json::Value) {
|
||||
ui.horizontal(|ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
|
||||
ui.painter().text(rect.center(), Align2::CENTER_CENTER, symbol, regular(11.0), ui.visuals().text_color());
|
||||
menu_value(ui, app, cmd, selected, label, value);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -390,8 +594,7 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Count-off, meter and tempo readouts; the tempo value is editable in place for the tempo event at or before the
|
||||
/// playhead.
|
||||
/// Count-off, meter and tempo readouts; the tempo value edits the event at or before the playhead.
|
||||
fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let t = Tokens::current();
|
||||
let (bpm, meter, countoff, click, bars) = {
|
||||
@@ -453,6 +656,7 @@ fn tempo_value(app: &mut SoundApp, ui: &mut Ui, right: f32, y: f32, bpm: f64) {
|
||||
resp.on_hover_text("Drag to change the tempo (Shift: fine steps), or click to type it");
|
||||
}
|
||||
|
||||
/// Draws the compact stereo main-output meter.
|
||||
fn output_meter(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(26.0, 64.0), Sense::hover());
|
||||
let m = app.main_meter;
|
||||
@@ -466,6 +670,114 @@ fn output_meter(app: &mut SoundApp, ui: &mut Ui) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
/// Builds a compact Grid/Nudge toolbar test harness.
|
||||
fn harness() -> Harness<'static, SoundApp> {
|
||||
let app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
let mut harness = Harness::builder().with_size(vec2(700.0, 700.0)).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
let edit = &app.engine.session().edit;
|
||||
let (grid, grid_follow, clips, nudge, nudge_follow) =
|
||||
(edit.grid, edit.grid_follow_main, edit.grid_clips_markers, edit.nudge, edit.nudge_follow_main);
|
||||
let grid_anchor = ui.button("Grid menu");
|
||||
value_menu(app, &grid_anchor, "view.grid", grid, grid_follow, clips, true);
|
||||
let nudge_anchor = ui.button("Nudge menu");
|
||||
value_menu(app, &nudge_anchor, "view.nudge", nudge, nudge_follow, false, false);
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.run_steps(3);
|
||||
harness
|
||||
}
|
||||
|
||||
/// Opens a toolbar value menu and lets the popup settle.
|
||||
fn open(harness: &mut Harness<SoundApp>, label: &str) {
|
||||
harness.get_by_label(label).click();
|
||||
harness.run_steps(3);
|
||||
}
|
||||
|
||||
/// Compact values use their corresponding counter formats and symbols.
|
||||
#[test]
|
||||
fn compact_grid_values_use_their_counter_formats() {
|
||||
let session = Session::default();
|
||||
let note = |value, dotted, triplet| GridValue::Note { value, dotted, triplet };
|
||||
assert_eq!(compact_grid_label(&session, note(NoteValue::Quarter, false, false)), "0|1|000");
|
||||
assert_eq!(compact_grid_label(&session, note(NoteValue::Eighth, false, true)), "0|0|320");
|
||||
assert_eq!(compact_grid_label(&session, GridValue::Seconds(0.001)), "0:00.001");
|
||||
assert_eq!(compact_grid_label(&session, GridValue::Timecode(TimecodeValue::Second)), "00:00:01:00.00");
|
||||
assert_eq!(compact_grid_label(&session, GridValue::Timecode(TimecodeValue::QuarterFrame)), "00:00:00:00.25");
|
||||
assert_eq!(compact_grid_label(&session, GridValue::FeetFrames(FeetFramesValue::Foot)), "1+00");
|
||||
assert_eq!(compact_grid_label(&session, GridValue::Samples(10_000)), "10000");
|
||||
}
|
||||
|
||||
/// The Grid menu exposes every scale and retains its numeric value in Clips/Markers mode.
|
||||
#[test]
|
||||
fn grid_menu_exposes_each_scale_and_keeps_the_increment_in_clips_markers_mode() {
|
||||
let mut harness = harness();
|
||||
open(&mut harness, "Grid menu");
|
||||
for label in ["Bars|Beats", "Min:Secs", "Timecode", "Feet+Frames", "Samples", "Clips/Markers", "Follow Main Time Scale"] {
|
||||
let _ = harness.get_by_label(label);
|
||||
}
|
||||
for label in ["1 bar", "Whole note", "1/2 note", "1/4 note", "1/8 note", "1/16 note", "1/32 note", "1/64 note", "Dotted", "Triplet"] {
|
||||
let _ = harness.get_by_label(label);
|
||||
}
|
||||
|
||||
harness.get_by_label("Timecode").click();
|
||||
harness.run_steps(3);
|
||||
open(&mut harness, "Grid menu");
|
||||
for label in ["1 second", "5 frames", "1 frame", "1/2 frame", "1/4 frame", "1 subframe"] {
|
||||
let _ = harness.get_by_label(label);
|
||||
}
|
||||
harness.get_by_label("1/4 frame").click();
|
||||
harness.run_steps(3);
|
||||
open(&mut harness, "Grid menu");
|
||||
harness.get_by_label("Clips/Markers").click();
|
||||
harness.run_steps(3);
|
||||
let edit = &harness.state().engine.session().edit;
|
||||
assert_eq!(edit.grid, GridValue::Timecode(TimecodeValue::QuarterFrame));
|
||||
assert!(edit.grid_clips_markers);
|
||||
open(&mut harness, "Grid menu");
|
||||
harness.get_by_label("1/2 frame").click();
|
||||
harness.run_steps(3);
|
||||
let edit = &harness.state().engine.session().edit;
|
||||
assert_eq!(edit.grid, GridValue::Timecode(TimecodeValue::HalfFrame));
|
||||
assert!(!edit.grid_clips_markers);
|
||||
}
|
||||
|
||||
/// Selecting another musical increment preserves the active dotted or triplet modifier.
|
||||
#[test]
|
||||
fn note_menu_keeps_the_current_modifier_when_changing_value() {
|
||||
let mut harness = harness();
|
||||
harness.state_mut().run("view.grid", json!({"value": "1/8t"})).unwrap();
|
||||
harness.run_steps(3);
|
||||
open(&mut harness, "Grid menu");
|
||||
harness.get_by_label("1/16 note").click();
|
||||
harness.run_steps(3);
|
||||
assert_eq!(harness.state().engine.session().edit.grid, GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: true });
|
||||
open(&mut harness, "Grid menu");
|
||||
harness.get_by_label("Whole note").click();
|
||||
harness.run_steps(3);
|
||||
assert_eq!(harness.state().engine.session().edit.grid, GridValue::Note { value: NoteValue::Whole, dotted: false, triplet: true });
|
||||
}
|
||||
|
||||
/// The Nudge menu exposes every numeric scale without the Grid-only Clips/Markers mode.
|
||||
#[test]
|
||||
fn nudge_menu_has_every_numeric_scale_but_no_clips_markers_mode() {
|
||||
let mut harness = harness();
|
||||
open(&mut harness, "Nudge menu");
|
||||
for label in ["Bars|Beats", "Min:Secs", "Timecode", "Feet+Frames", "Samples", "Follow Main Time Scale"] {
|
||||
let _ = harness.get_by_label(label);
|
||||
}
|
||||
assert_eq!(harness.query_all_by_label("Clips/Markers").count(), 0);
|
||||
harness.get_by_label("Samples").click();
|
||||
harness.run_steps(3);
|
||||
open(&mut harness, "Nudge menu");
|
||||
for label in ["10000 samples", "1000 samples", "100 samples", "10 samples", "2 samples", "1 sample"] {
|
||||
let _ = harness.get_by_label(label);
|
||||
}
|
||||
}
|
||||
|
||||
/// #1262: the two link toggles differ only by their icon, so each option toggle needs its own.
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use egui::{Color32, FullOutput, Pos2, RawInput, Rect, Shape, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{ChannelFormat, Clip, TrackId, TrackKind};
|
||||
use soundcraft_ui_egui::theme::Tokens;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, edit_window};
|
||||
|
||||
@@ -22,6 +23,7 @@ fn near(a: [i32; 3], b: Color32) -> bool {
|
||||
a.iter().zip([b.r(), b.g(), b.b()]).all(|(x, y)| (x - i32::from(y)).abs() <= 6)
|
||||
}
|
||||
|
||||
/// Numeric Grid lines retain their stacking order over clips in both themes.
|
||||
#[test]
|
||||
fn grid_lines_are_drawn_over_clips_while_shown() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
@@ -68,3 +70,94 @@ fn grid_lines_are_drawn_over_clips_while_shown() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clips/Markers grid lines reach the timeline at memory-location boundaries.
|
||||
#[test]
|
||||
fn clips_markers_grid_draws_a_line_at_a_memory_location() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let kick = app.engine.session().track_by_name("Kick").unwrap();
|
||||
let clip = &kick.clips()[0];
|
||||
let marker = clip.start + clip.length / 3;
|
||||
app.run("markers.add", json!({"at": marker, "name": "Grid target"})).unwrap();
|
||||
app.run("view.grid", json!({"format": "clips_markers", "lines": true})).unwrap();
|
||||
|
||||
frame(&ctx, &mut app);
|
||||
let out = frame(&ctx, &mut app);
|
||||
let session = app.engine.session();
|
||||
let kick = session.track_by_name("Kick").unwrap();
|
||||
let [x0, y0, x1, y1] = app.edit_layout.timeline;
|
||||
let timeline = Rect::from_min_max(pos2(x0, y0), pos2(x1, y1));
|
||||
let row = app.edit_layout.rows.iter().find(|(id, _)| *id == kick.id.0).unwrap().1;
|
||||
let lane = Rect::from_min_max(pos2(timeline.min.x, row[1]), pos2(timeline.max.x, row[1] + kick.height.points()));
|
||||
let expected_x = edit_window::x_of(session, timeline, marker);
|
||||
|
||||
assert!(out.shapes.iter().any(|shape| {
|
||||
matches!(
|
||||
&shape.shape,
|
||||
Shape::LineSegment { points: [a, b], .. }
|
||||
if a.x == expected_x && b.x == expected_x && a.y == lane.min.y && b.y == lane.max.y
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
/// Whether a vertical grid line spans the main lane of a visible track at the requested sample.
|
||||
fn line_on_track(out: &FullOutput, app: &SoundApp, track: TrackId, at: i64) -> bool {
|
||||
let s = app.engine.session();
|
||||
let [x0, y0, x1, y1] = app.edit_layout.timeline;
|
||||
let timeline = Rect::from_min_max(pos2(x0, y0), pos2(x1, y1));
|
||||
let row = app.edit_layout.rows.iter().find(|(id, _)| *id == track.0).unwrap().1;
|
||||
let bottom = row[1] + s.track(track).unwrap().height.points();
|
||||
let x = edit_window::x_of(s, timeline, at);
|
||||
out.shapes.iter().any(|shape| {
|
||||
matches!(&shape.shape, Shape::LineSegment { points: [a, b], .. }
|
||||
if a.x == x && b.x == x && a.y == row[1] && b.y == bottom)
|
||||
})
|
||||
}
|
||||
|
||||
/// Shared frame lines reflect visible children, edited boundaries, scrolling and zoom on every lane.
|
||||
#[test]
|
||||
fn clips_markers_lines_refresh_across_lanes_and_hidden_open_folders() {
|
||||
let mut engine = soundcraft_engine::Engine::default();
|
||||
let s = engine.session_mut();
|
||||
let folder = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
|
||||
let child = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
|
||||
let other = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
|
||||
s.track_mut(folder).unwrap().hidden = true;
|
||||
s.track_mut(child).unwrap().folder = Some(folder);
|
||||
let clip = Clip::midi(s.new_clip_id(), "Visible", 1_200, 5_000, Default::default());
|
||||
let clip_id = clip.id;
|
||||
s.track_mut(child).unwrap().playlist_mut().unwrap().clips.push(clip);
|
||||
s.add_marker("Boundary", soundcraft_model::MarkerKind::Marker, 9_000, 9_000);
|
||||
s.edit.grid_clips_markers = true;
|
||||
s.edit.grid_lines = true;
|
||||
let mut app = SoundApp::new(engine, None, Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
for (start, marker, scroll, zoom) in [(1_200, 9_000, 0, 20.0), (3_000, 11_000, 0, 20.0), (3_000, 11_000, 1_000, 40.0)] {
|
||||
let s = app.engine.session_mut();
|
||||
s.find_clip_mut(clip_id).unwrap().start = start;
|
||||
s.markers[0].start = marker;
|
||||
s.markers[0].end = marker;
|
||||
s.edit.zoom.scroll = scroll;
|
||||
s.edit.zoom.samples_per_px = zoom;
|
||||
frame(&ctx, &mut app);
|
||||
let out = frame(&ctx, &mut app);
|
||||
assert!(!app.edit_layout.rows.iter().any(|(id, _)| *id == folder.0));
|
||||
for track in [child, other] {
|
||||
assert!(line_on_track(&out, &app, track, start));
|
||||
assert!(line_on_track(&out, &app, track, marker));
|
||||
if start != 1_200 {
|
||||
assert!(!line_on_track(&out, &app, track, 1_200));
|
||||
assert!(!line_on_track(&out, &app, track, 9_000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.engine.session_mut().track_mut(folder).unwrap().folder_open = false;
|
||||
frame(&ctx, &mut app);
|
||||
let out = frame(&ctx, &mut app);
|
||||
assert!(!app.edit_layout.rows.iter().any(|(id, _)| *id == child.0));
|
||||
assert!(!line_on_track(&out, &app, other, 3_000));
|
||||
assert!(line_on_track(&out, &app, other, 11_000));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user