mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-08 19:30:15 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddeb7e791f | ||
|
|
507786ad7a | ||
|
|
a7b7fb949e | ||
|
|
16e495550c | ||
|
|
76cc2aeac6 | ||
|
|
26cafa85bb | ||
|
|
8cdcab2697 | ||
|
|
436a2e22a8 | ||
|
|
b6f9f150a1 | ||
|
|
fbd7789b4a | ||
|
|
4edd315adb | ||
|
|
6c917ea378 | ||
|
|
c05dfc0a8e | ||
|
|
6f54446423 | ||
|
|
24451d5c42 | ||
|
|
7d508e2e44 | ||
|
|
fca0130b72 | ||
|
|
77b788007a | ||
|
|
e569fbc50e |
@@ -15,7 +15,14 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
|
||||
credits:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
release:
|
||||
needs: credits
|
||||
# The shared workflow creates the draft release, which needs write access to contents.
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -26,6 +33,7 @@ jobs:
|
||||
app-name: soundcraft
|
||||
app-title: SoundCraft
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
Generated
+18
-18
@@ -4330,7 +4330,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.18.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -4354,7 +4354,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.18.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4364,7 +4364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.18.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4376,7 +4376,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.18.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"log",
|
||||
@@ -4389,7 +4389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.18.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading 0.9.0",
|
||||
@@ -4402,7 +4402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.18.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -4420,7 +4420,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.18.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4431,7 +4431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.18.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"log",
|
||||
@@ -4454,7 +4454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.18.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4464,7 +4464,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.18.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -4480,7 +4480,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.18.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4493,7 +4493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.18.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
@@ -4508,7 +4508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.18.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4518,7 +4518,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.18.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -4548,7 +4548,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.18.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"image",
|
||||
@@ -4560,7 +4560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.18.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
@@ -4576,7 +4576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.18.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6224,7 +6224,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.18.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.18.0"
|
||||
version = "0.17.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
||||
+79
-15
@@ -178,8 +178,12 @@ pub fn stored_path(stored: &str, session: &Path) -> std::result::Result<PathBuf,
|
||||
craft_docpath::resolve_absolute(stored, Some(&session)).map_err(|why| format!("not opened: {why}"))
|
||||
}
|
||||
|
||||
/// Create `dir` and its parents, inside the automation write root when there is one.
|
||||
/// Create `dir` and its parents, inside the automation write root when there is one. The browser
|
||||
/// has no folders: there each file is delivered by its name ([`write_file`]) and this does nothing.
|
||||
pub(crate) fn make_dir(dir: impl AsRef<Path>) -> Result<()> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Ok(());
|
||||
}
|
||||
let dir = dir.as_ref();
|
||||
// The empty folder of a bare file name ("t.scraft") is the working directory, which the root
|
||||
// check would refuse as an empty path.
|
||||
@@ -563,29 +567,48 @@ impl From<StripNames> for soundcraft_model::AutoName {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write each clip as a WAV file named after it into `dir` (in the browser, delivered by name
|
||||
/// through [`write_file`]). Returns how many were written.
|
||||
pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) -> Result<usize> {
|
||||
let s = e.session();
|
||||
make_dir(dir)?;
|
||||
let mut n = 0;
|
||||
let mut names = ClipNames::default();
|
||||
for id in ids {
|
||||
let Some((t, c)) = s.find_clip(*id) else { continue };
|
||||
let audio = soundcraft_mix::render_clips(s, t, c.range());
|
||||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
|
||||
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let stem = sanitize_name(&c.name);
|
||||
let mut path = Path::new(dir).join(format!("{stem}.wav"));
|
||||
let mut k = 2u32;
|
||||
while path.exists() {
|
||||
path = Path::new(dir).join(format!("{stem}-{k}.wav"));
|
||||
k = k.saturating_add(1);
|
||||
if k > 10_000 {
|
||||
return Err(EngineError::Io(format!("{dir}: too many files named {stem}")));
|
||||
}
|
||||
}
|
||||
write_path(&path, bytes)?;
|
||||
n += 1;
|
||||
let path = clip_file(dir, &sanitize_name(&c.name), &mut names, Path::exists)?;
|
||||
write_file(&path.to_string_lossy(), &bytes)?;
|
||||
}
|
||||
Ok(n)
|
||||
Ok(names.taken.len())
|
||||
}
|
||||
|
||||
/// The files an export has named so far, and for each name the suffix to try next, so a run of
|
||||
/// same-named clips doesn't count up from 2 again for every clip.
|
||||
#[derive(Default)]
|
||||
struct ClipNames {
|
||||
taken: std::collections::HashSet<PathBuf>,
|
||||
next: std::collections::HashMap<String, u32>,
|
||||
}
|
||||
|
||||
/// The first of `<stem>.wav`, `<stem>-2.wav`, … in `dir` that is neither `on_disk` nor already
|
||||
/// named by this export, recorded in `names`: the browser has no disk to look at, so same-named
|
||||
/// clips are kept apart by `names`.
|
||||
fn clip_file(dir: &str, stem: &str, names: &mut ClipNames, on_disk: impl Fn(&Path) -> bool) -> Result<PathBuf> {
|
||||
let file = |k: u32| Path::new(dir).join(if k < 2 { format!("{stem}.wav") } else { format!("{stem}-{k}.wav") });
|
||||
let mut k = names.next.get(stem).copied().unwrap_or(1);
|
||||
let mut path = file(k);
|
||||
while on_disk(&path) || names.taken.contains(&path) {
|
||||
k = k.saturating_add(1);
|
||||
if k > 10_000 {
|
||||
return Err(EngineError::Io(format!("{dir}: too many files named {stem}")));
|
||||
}
|
||||
path = file(k);
|
||||
}
|
||||
names.next.insert(stem.to_owned(), k.saturating_add(1));
|
||||
names.taken.insert(path.clone());
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Write `session` to `path`; returns the session with updated media paths, the number of audio
|
||||
@@ -996,3 +1019,44 @@ mod stem_tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod clip_tests {
|
||||
/// The browser has no disk to find earlier files on: same-named clips get their own names
|
||||
/// from the export itself, as they do from the folder natively (#1409).
|
||||
#[test]
|
||||
fn same_named_clips_get_their_own_names_with_nothing_on_disk() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-clip-names-{}", std::process::id()));
|
||||
let mut names = super::ClipNames::default();
|
||||
for want in ["Take.wav", "Take-2.wav", "Take-3.wav"] {
|
||||
let path = super::clip_file(&dir.to_string_lossy(), "Take", &mut names, std::path::Path::exists).unwrap();
|
||||
assert_eq!(path, dir.join(want));
|
||||
}
|
||||
assert!(!dir.exists());
|
||||
}
|
||||
|
||||
/// A file already in the folder is still skipped, and a run of same-named clips picks up where
|
||||
/// the last one left off instead of looking at every earlier name again for each clip.
|
||||
#[test]
|
||||
fn same_named_clips_skip_files_on_disk_and_do_not_count_up_from_the_start_each_time() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-clip-disk-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("Take-2.wav"), b"").unwrap();
|
||||
let looked = std::cell::Cell::new(0usize);
|
||||
let on_disk = |p: &std::path::Path| {
|
||||
looked.set(looked.get() + 1);
|
||||
p.exists()
|
||||
};
|
||||
let mut names = super::ClipNames::default();
|
||||
for want in ["Take.wav", "Take-3.wav", "Take-4.wav"] {
|
||||
let path = super::clip_file(&dir.to_string_lossy(), "Take", &mut names, on_disk).unwrap();
|
||||
assert_eq!(path, dir.join(want));
|
||||
}
|
||||
let before = looked.get();
|
||||
for _ in 0..1_000 {
|
||||
super::clip_file(&dir.to_string_lossy(), "Take", &mut names, on_disk).unwrap();
|
||||
}
|
||||
assert_eq!(looked.get() - before, 1_000);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
+26
-10
@@ -33,10 +33,10 @@ fn as_audio_thread(audio: bool, f: impl FnOnce()) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn run_strips<T: Send>(work: &mut [T], f: impl Fn(&mut T) + Sync + Send) {
|
||||
use rayon::prelude::*;
|
||||
if work.len() >= 4 {
|
||||
// The workers render on behalf of this thread: on the audio thread, they are it too.
|
||||
let audio = on_audio_thread();
|
||||
work.par_iter_mut().for_each(|w| as_audio_thread(audio, || f(w)));
|
||||
// The audio thread is not a rayon worker, so it would park on a latch waiting for the shared
|
||||
// pool (also busy with offline work): it renders its strips itself.
|
||||
if work.len() >= 4 && !on_audio_thread() {
|
||||
work.par_iter_mut().for_each(|w| as_audio_thread(false, || f(w)));
|
||||
} else {
|
||||
work.iter_mut().for_each(f);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ pub(crate) fn run_strips<T: Send>(work: &mut [T], f: impl Fn(&mut T) + Sync + Se
|
||||
work.iter_mut().for_each(f);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod audio_thread_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -67,7 +67,7 @@ mod audio_thread_tests {
|
||||
|
||||
/// What `on_audio_thread` says inside each strip job, for strips run from a thread that is
|
||||
/// (or is not) the audio thread.
|
||||
fn seen_by_strips(audio: bool) -> Vec<bool> {
|
||||
fn seen_by_strips(audio: bool) -> Result<Vec<bool>, &'static str> {
|
||||
std::thread::spawn(move || {
|
||||
if audio {
|
||||
mark_audio_thread();
|
||||
@@ -78,15 +78,31 @@ mod audio_thread_tests {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
*w = Some(on_audio_thread());
|
||||
});
|
||||
work.into_iter().map(|w| w.unwrap()).collect()
|
||||
work.into_iter().collect::<Option<Vec<_>>>().ok_or("a strip did not run")
|
||||
})
|
||||
.join()
|
||||
.unwrap()
|
||||
.map_err(|_| "the strip render thread panicked")?
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_rendered_for_the_audio_thread_count_as_the_audio_thread_and_only_then() {
|
||||
assert!(seen_by_strips(true).iter().all(|&rt| rt), "a worker rendering for the audio thread is on it");
|
||||
assert!(seen_by_strips(false).iter().all(|&rt| !rt), "the workers don't stay marked afterwards");
|
||||
assert_eq!(seen_by_strips(true), Ok(vec![true; 64]), "strips rendered for the audio thread are on it");
|
||||
assert_eq!(seen_by_strips(false), Ok(vec![false; 64]), "the workers don't stay marked afterwards");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_audio_thread_never_hands_strips_to_the_rayon_pool() {
|
||||
let on_pool = std::thread::spawn(|| {
|
||||
mark_audio_thread();
|
||||
let mut work = vec![false; 8];
|
||||
run_strips(&mut work, |w| {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
*w = rayon::current_thread_index().is_some();
|
||||
});
|
||||
work.into_iter().filter(|&p| p).count()
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
assert_eq!(on_pool, 0, "the audio thread must not block on the rayon pool");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,6 +472,11 @@ pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWin
|
||||
// and bottom edges take frames (`drop_edges` keeps its default).
|
||||
style.new_dock_zones = true;
|
||||
style.side_edge_zones = false;
|
||||
// The MIDI body's rect stands for a piano roll on screen (the arrow keys go to it), so only a
|
||||
// frame that draws the panel records one: open behind another tab, the old rect is gone.
|
||||
if edit {
|
||||
ui.ctx().data_mut(|data| data.remove::<egui::Rect>(egui::Id::unique("soundcraft-midi-body")));
|
||||
}
|
||||
let output = DockArea::new(area(edit)).show_with_limits(
|
||||
ui,
|
||||
&workspace.layout,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::layout::STRIP_W;
|
||||
use super::tracks::{click_track, folder_depth, height_targets, in_edit_window, track_context_menu, view_label, view_menu};
|
||||
use super::{
|
||||
Align2, AutoParam, Color32, Pos2, Rect, Sense, Shape, SoundApp, Stroke, Tokens, Track, TrackHeight, TrackId, TrackKind, Ui, bold, groove, json,
|
||||
@@ -14,6 +15,7 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head:
|
||||
click_track(app, id, ui.input(|i| i.modifiers), in_edit_window);
|
||||
}
|
||||
bg.context_menu(|ui| track_context_menu(app, ui, id));
|
||||
color_strip(app, ui, id, head);
|
||||
resize_edge(app, ui, track, head);
|
||||
strip_buttons(app, ui, track, head);
|
||||
name_plate(app, ui, track, head, selected);
|
||||
@@ -26,6 +28,28 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head:
|
||||
header_meter(ui, Rect::from_min_max(pos2(head.max.x - 8.0, head.min.y + 1.0), pos2(head.max.x, head.max.y - 1.5)), &md, track.channels() >= 2);
|
||||
}
|
||||
|
||||
/// The colour strip: a click selects the track as the header does, a double click opens the Color
|
||||
/// Palette for the selected tracks. A plain click on a track that is already selected keeps the
|
||||
/// selection, so double-clicking one of several selected tracks colours them all. Registered after
|
||||
/// the header background, so it sits on top of it, and before the strip's own buttons and the
|
||||
/// resize edge, which stay on top of it.
|
||||
fn color_strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, head: Rect) {
|
||||
let strip = Rect::from_min_size(head.min, vec2(STRIP_W, head.height()));
|
||||
let resp = ui.interact(strip, ui.scope_id().with(("color_strip", id.0)), Sense::click());
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let selected = app.engine.session().edit.selected_tracks.contains(&id);
|
||||
if resp.double_clicked() {
|
||||
// A Cmd click toggles, so a Cmd double click's first click deselected the track: add it back.
|
||||
if !selected {
|
||||
click_track(app, id, mods, in_edit_window);
|
||||
}
|
||||
app.ui.show_color_palette = true;
|
||||
} else if resp.clicked() && !(selected && mods.is_none()) {
|
||||
click_track(app, id, mods, in_edit_window);
|
||||
}
|
||||
resp.context_menu(|ui| track_context_menu(app, ui, id));
|
||||
}
|
||||
|
||||
/// Drag the bottom edge of the header to resize the track (snaps to the height presets).
|
||||
fn resize_edge(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) {
|
||||
let id = track.id;
|
||||
|
||||
@@ -534,6 +534,53 @@ fn shift_and_cmd_click_on_track_names_extend_the_selection() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1254: double-clicking a track's colour strip selects the track and opens the Color Palette;
|
||||
/// a single click only selects it.
|
||||
#[test]
|
||||
fn double_clicking_the_color_strip_opens_the_color_palette() {
|
||||
let mut h = harness(3);
|
||||
let ids = row_ids(&h);
|
||||
let strip = |h: &Harness<SoundApp>, i: usize| pos2(row(h, i).min.x + layout::STRIP_W / 2.0, row(h, i).center().y);
|
||||
h.state_mut().engine.execute("edit.select", &json!({"tracks": [ids[0].0]})).unwrap();
|
||||
h.run_steps(2);
|
||||
let at = strip(&h, 1);
|
||||
click(&mut h, at);
|
||||
assert_eq!(h.state().engine.session().edit.selected_tracks, [ids[1]]);
|
||||
assert!(!h.state().ui.show_color_palette, "a single click on the strip doesn't open the palette");
|
||||
h.run_steps(30);
|
||||
let at = strip(&h, 2);
|
||||
click(&mut h, at);
|
||||
click(&mut h, at);
|
||||
assert_eq!(h.state().engine.session().edit.selected_tracks, [ids[2]]);
|
||||
assert!(h.state().ui.show_color_palette, "a double click on the strip opens the palette");
|
||||
}
|
||||
|
||||
/// #1254: a double click on the strip of one of several selected tracks keeps them all selected,
|
||||
/// so the palette colours every one; with Cmd held the double-clicked track ends selected too.
|
||||
#[test]
|
||||
fn double_clicking_a_selected_tracks_strip_keeps_the_selection() {
|
||||
let mut h = harness(3);
|
||||
let ids = row_ids(&h);
|
||||
let strip = |h: &Harness<SoundApp>, i: usize| pos2(row(h, i).min.x + layout::STRIP_W / 2.0, row(h, i).center().y);
|
||||
h.state_mut().engine.execute("edit.select", &json!({"tracks": [ids[0].0, ids[2].0]})).unwrap();
|
||||
h.run_steps(2);
|
||||
let at = strip(&h, 2);
|
||||
click(&mut h, at);
|
||||
assert_eq!(h.state().engine.session().edit.selected_tracks, [ids[0], ids[2]], "a plain click on a selected strip keeps the selection");
|
||||
assert!(!h.state().ui.show_color_palette);
|
||||
click(&mut h, at);
|
||||
assert_eq!(h.state().engine.session().edit.selected_tracks, [ids[0], ids[2]], "both tracks stay selected");
|
||||
assert!(h.state().ui.show_color_palette, "a double click on the strip opens the palette");
|
||||
|
||||
h.state_mut().ui.show_color_palette = false;
|
||||
h.run_steps(30);
|
||||
let at = strip(&h, 0);
|
||||
click_with(&mut h, at, egui::Modifiers::COMMAND);
|
||||
click_with(&mut h, at, egui::Modifiers::COMMAND);
|
||||
assert!(h.state().engine.session().edit.selected_tracks.contains(&ids[0]), "a Cmd double click leaves its track selected");
|
||||
assert!(h.state().ui.show_color_palette, "a Cmd double click on the strip opens the palette");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quick_drag_from_a_clip_edge_trims_it() {
|
||||
let mut h = smart_demo();
|
||||
|
||||
+134
-25
@@ -54,8 +54,13 @@ fn search_chord(m: Modifiers, mac: bool) -> bool {
|
||||
mac && m.command && m.ctrl
|
||||
}
|
||||
|
||||
/// Commands a host auto-repeat runs again, so holding a zoom key keeps zooming (#1377). Every other
|
||||
/// shortcut ignores auto-repeats (#1063): holding Cmd+Z undoes once. Decided by the command a chord
|
||||
/// resolves to, not by its keys.
|
||||
const REPEATING: [&str; 3] = ["view.zoom_in", "view.zoom_out", "view.waveform_zoom"];
|
||||
|
||||
/// Dispatch global keyboard shortcuts once no text field or dialog wants input, ignoring non-scripted auto-repeats
|
||||
/// while still running a host press alongside a scripted one.
|
||||
/// (except the [`REPEATING`] zoom commands) while still running a host press alongside a scripted one.
|
||||
pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
// Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom.
|
||||
ctx.options_mut(|o| o.zoom_with_keyboard = false);
|
||||
@@ -64,29 +69,29 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
}
|
||||
// A scripted press is identified by its exact position in this pass's events, not by matching
|
||||
// key/modifier values. A fresh host press with the same chord must still run its own action in
|
||||
// addition to the scripted one, while a host auto-repeat stays ignored: egui rewrites an
|
||||
// event's `repeat` flag from the physical key state, which says nothing about whether that
|
||||
// particular event was injected. Every non-scripted repeat is ignored as before.
|
||||
// addition to the scripted one, while a host auto-repeat runs only a zoom command: egui rewrites
|
||||
// an event's `repeat` flag from the physical key state, which says nothing about whether that
|
||||
// particular event was injected.
|
||||
let scripted = app.scripted_key_press_indices();
|
||||
let events: Vec<(Key, Modifiers)> = ctx.input(|i| {
|
||||
let events: Vec<(Key, Modifiers, bool)> = ctx.input(|i| {
|
||||
i.events
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, e)| match e {
|
||||
egui::Event::Key { key, pressed: true, repeat, modifiers, .. } => {
|
||||
if scripted.contains(&index) {
|
||||
Some((*key, *modifiers))
|
||||
} else {
|
||||
(!*repeat).then_some((*key, *modifiers))
|
||||
}
|
||||
}
|
||||
egui::Event::Key { key, pressed: true, repeat, modifiers, .. } => Some((*key, *modifiers, *repeat && !scripted.contains(&index))),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
// Cmd and Ctrl are separate keys only on macOS (asked at runtime so the web build in a Mac browser agrees).
|
||||
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
|
||||
for (key, mods) in events {
|
||||
for (key, mods, repeat) in events {
|
||||
if repeat {
|
||||
if let Some((id, params)) = repeating_command(key, mods, mac) {
|
||||
let _ = app.run(id, params);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if fixed(app, key, mods, mac) {
|
||||
continue;
|
||||
}
|
||||
@@ -105,6 +110,14 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
|
||||
}
|
||||
|
||||
/// The command a host auto-repeat of this chord runs, if it is one of [`REPEATING`]. A repeat
|
||||
/// resolves its chord the way a press falls through to these zooms ([`zoom_key`], then the
|
||||
/// registry) without the earlier [`fixed`] / [`fixed_more`] arms, so an arm added there for one of
|
||||
/// these chords must keep the two paths in agreement.
|
||||
fn repeating_command(key: Key, mods: Modifiers, mac: bool) -> Option<(&'static str, serde_json::Value)> {
|
||||
zoom_key(key, mods, mac).or_else(|| command_for(key, mods, mac).map(|id| (id, json!({})))).filter(|(id, _)| REPEATING.contains(id))
|
||||
}
|
||||
|
||||
fn command_for(key: Key, mods: Modifiers, mac: bool) -> Option<&'static str> {
|
||||
soundcraft_engine::command_specs()
|
||||
.iter()
|
||||
@@ -199,12 +212,7 @@ fn fixed_more(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
|
||||
let _ = app.run("edit.tab", json!({"back": m.alt || (!mac && m.ctrl), "extend": m.shift}));
|
||||
true
|
||||
}
|
||||
// The registry's key families "Cmd+Alt+[ / ]", "Cmd+Alt+0-5" and "Cmd+Ctrl+1-5", which `parse` can't read.
|
||||
Key::OpenBracket | Key::CloseBracket if mods_match(Modifiers::COMMAND | Modifiers::ALT, m, mac) => {
|
||||
let factor = if key == Key::CloseBracket { 2.0 } else { 0.5 };
|
||||
let _ = app.run("view.waveform_zoom", json!({"factor": factor}));
|
||||
true
|
||||
}
|
||||
// The registry's key families "Cmd+Alt+0-5" and "Cmd+Ctrl+1-5", which `parse` can't read ("Cmd+Alt+[ / ]" is in `zoom_key`).
|
||||
Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 if mods_match(Modifiers::COMMAND | Modifiers::ALT, m, mac) => {
|
||||
let _ = app.run("clip.rating", json!({"rating": digit(key)}));
|
||||
true
|
||||
@@ -232,6 +240,11 @@ fn fixed_more(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
|
||||
let _ = app.run(id, json!({}));
|
||||
true
|
||||
}
|
||||
// With an edit selection (a selected clip makes one), Left/Right centre the Edit window on its start/end.
|
||||
// Over the piano roll the arrows move its notes instead.
|
||||
Key::ArrowLeft | Key::ArrowRight if plain && app.ui.window == MainWindow::Edit && !piano_roll_has_pointer(app) => {
|
||||
centre_on_selection(app, key == Key::ArrowRight)
|
||||
}
|
||||
// Commands Keyboard Focus: single-key editing (Edit window only).
|
||||
k if !m.command
|
||||
&& !m.alt
|
||||
@@ -242,13 +255,26 @@ fn fixed_more(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
|
||||
{
|
||||
true
|
||||
}
|
||||
Key::R | Key::T if plain => {
|
||||
// Zoom out / in on the timeline (Commands Focus style).
|
||||
let id = if key == Key::T { "view.zoom_in" } else { "view.zoom_out" };
|
||||
let _ = app.run(id, json!({}));
|
||||
true
|
||||
_ => match zoom_key(key, m, mac) {
|
||||
Some((id, params)) => {
|
||||
let _ = app.run(id, params);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The zoom keys [`fixed`] handles, shared with [`repeating_command`]: T / R zoom the timeline in / out
|
||||
/// (Commands Focus style) and the registry's "Cmd+Alt+[ / ]", which `parse` can't read, steps the waveform zoom.
|
||||
fn zoom_key(key: Key, m: Modifiers, mac: bool) -> Option<(&'static str, serde_json::Value)> {
|
||||
let plain = !m.command && !m.alt && !m.ctrl && !m.shift;
|
||||
match key {
|
||||
Key::R | Key::T if plain => Some((if key == Key::T { "view.zoom_in" } else { "view.zoom_out" }, json!({}))),
|
||||
Key::OpenBracket | Key::CloseBracket if mods_match(Modifiers::COMMAND | Modifiers::ALT, m, mac) => {
|
||||
Some(("view.waveform_zoom", json!({"factor": if key == Key::CloseBracket { 2.0 } else { 0.5 }})))
|
||||
}
|
||||
_ => false,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,6 +419,30 @@ fn tab_clip(app: &mut SoundApp, back: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll the Edit window so the edit selection's start (or `end`) is in its middle. Returns false, leaving
|
||||
/// the key unhandled, when there is only an insertion point.
|
||||
fn centre_on_selection(app: &mut SoundApp, end: bool) -> bool {
|
||||
let s = app.engine.session();
|
||||
let sel = s.edit.selection;
|
||||
if sel.start >= sel.end {
|
||||
return false;
|
||||
}
|
||||
let [x0, _, x1, _] = app.edit_layout.timeline;
|
||||
let half = f64::from((x1 - x0).max(0.0)) / 2.0 * s.edit.zoom.samples_per_px.max(0.01);
|
||||
let at = if end { sel.end } else { sel.start };
|
||||
let _ = app.run("view.scroll", json!({"to": at.saturating_sub(soundcraft_time::to_samples(half)).max(0)}));
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether the pointer is over the MIDI editor, whose piano roll moves the selected notes with the arrow keys.
|
||||
fn piano_roll_has_pointer(app: &SoundApp) -> bool {
|
||||
app.ui.show_midi_editor
|
||||
&& app.docking_context.as_ref().is_some_and(|c| {
|
||||
let body = c.data(|d| d.get_temp::<egui::Rect>(egui::Id::unique("soundcraft-midi-body")));
|
||||
c.pointer_hover_pos().zip(body).is_some_and(|(p, r)| r.contains(p))
|
||||
})
|
||||
}
|
||||
|
||||
fn digit(key: Key) -> u8 {
|
||||
key.name().parse().unwrap_or(0)
|
||||
}
|
||||
@@ -480,4 +530,63 @@ mod tests {
|
||||
assert_eq!(command_for(Key::K, Modifiers { shift: true, ..PC_CTRL }, false), Some("file.export_clips"));
|
||||
assert!(crate::dialogs::takes_path("file.export_clips"));
|
||||
}
|
||||
|
||||
fn app_with_selection() -> crate::SoundApp {
|
||||
let mut app = crate::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, crate::Services::default());
|
||||
app.ui.window = crate::MainWindow::Edit;
|
||||
app.edit_layout.timeline = [0.0, 0.0, 100.0, 100.0];
|
||||
app.run("view.zoom_set", serde_json::json!({"samples_per_px": 10.0})).unwrap();
|
||||
app.run("edit.select", serde_json::json!({"start": 2000, "end": 4000})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_arrows_centre_selection_but_modified_arrows_do_not() {
|
||||
for mac in [false, true] {
|
||||
for (key, expected) in [(Key::ArrowLeft, 1500), (Key::ArrowRight, 3500)] {
|
||||
let mut app = app_with_selection();
|
||||
assert!(super::fixed_more(&mut app, key, Modifiers::NONE, mac));
|
||||
assert_eq!(app.engine.session().edit.zoom.scroll, expected);
|
||||
for modifiers in [Modifiers::SHIFT, Modifiers::ALT, Modifiers::CTRL, Modifiers::COMMAND, PC_CTRL, MAC_CMD] {
|
||||
app.run("view.scroll", serde_json::json!({"to": 123})).unwrap();
|
||||
assert!(!super::fixed_more(&mut app, key, modifiers, mac));
|
||||
assert_eq!(app.engine.session().edit.zoom.scroll, 123, "{key:?} {modifiers:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_arrows_do_not_scroll_the_mix_window_or_an_insertion_point() {
|
||||
for key in [Key::ArrowLeft, Key::ArrowRight] {
|
||||
let mut app = app_with_selection();
|
||||
app.ui.window = crate::MainWindow::Mix;
|
||||
assert!(!super::fixed_more(&mut app, key, Modifiers::NONE, false));
|
||||
assert_eq!(app.engine.session().edit.zoom.scroll, 0);
|
||||
app.ui.window = crate::MainWindow::Edit;
|
||||
app.run("edit.select", serde_json::json!({"start": 2000, "end": 2000})).unwrap();
|
||||
assert!(!super::fixed_more(&mut app, key, Modifiers::NONE, false));
|
||||
assert_eq!(app.engine.session().edit.zoom.scroll, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_arrows_leave_the_piano_roll_in_control_under_its_pointer() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_selection();
|
||||
app.ui.show_midi_editor = true;
|
||||
app.docking_context = Some(ctx.clone());
|
||||
let body = egui::Rect::from_min_max(egui::pos2(10.0, 10.0), egui::pos2(100.0, 100.0));
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("soundcraft-midi-body"), body));
|
||||
for (pointer, handled) in [(body.center(), false), (egui::pos2(200.0, 200.0), true)] {
|
||||
let mut output = ctx.run_ui(egui::RawInput { events: vec![egui::Event::PointerMoved(pointer)], ..Default::default() }, |_| {
|
||||
for key in [Key::ArrowLeft, Key::ArrowRight] {
|
||||
app.run("view.scroll", serde_json::json!({"to": 0})).unwrap();
|
||||
assert_eq!(super::fixed_more(&mut app, key, Modifiers::NONE, false), handled);
|
||||
assert_eq!(app.engine.session().edit.zoom.scroll > 0, handled);
|
||||
}
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,6 +840,78 @@ fn a_held_key_does_not_turn_a_scripted_press_into_a_repeat() {
|
||||
assert!(ctx.input(|i| i.key_down(egui::Key::Z)), "the host still holds Z after the repeat");
|
||||
}
|
||||
|
||||
/// Runs one input pass of `app` carrying host key `events` (no scripted input).
|
||||
fn key_pass(ctx: &egui::Context, app: &mut SoundApp, events: Vec<egui::Event>) {
|
||||
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0));
|
||||
let mut input = egui::RawInput { screen_rect: Some(rect), events, ..Default::default() };
|
||||
app.raw_input_hook(ctx, &mut input);
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// The demo app after a few idle passes, ready for keyboard input.
|
||||
fn app_for_keys() -> (egui::Context, SoundApp) {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
for _ in 0..4 {
|
||||
key_pass(&ctx, &mut app, Vec::new());
|
||||
}
|
||||
(ctx, app)
|
||||
}
|
||||
|
||||
/// A host press of `key` and then two host auto-repeats of it, each in its own pass, as holding the key gives.
|
||||
fn hold_key(ctx: &egui::Context, app: &mut SoundApp, key: egui::Key, modifiers: egui::Modifiers) {
|
||||
for repeat in [false, true, true] {
|
||||
key_pass(ctx, app, vec![egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat, modifiers }]);
|
||||
}
|
||||
key_pass(ctx, app, vec![egui::Event::Key { key, physical_key: Some(key), pressed: false, repeat: false, modifiers }]);
|
||||
}
|
||||
|
||||
/// #1377: holding a zoom key keeps zooming, one step per auto-repeat.
|
||||
#[test]
|
||||
fn holding_t_or_r_keeps_zooming_the_timeline() {
|
||||
let (ctx, mut app) = app_for_keys();
|
||||
let start = app.engine.session().edit.zoom.samples_per_px;
|
||||
hold_key(&ctx, &mut app, egui::Key::T, egui::Modifiers::NONE);
|
||||
assert_eq!(app.engine.session().edit.zoom.samples_per_px, start / 8.0, "T and two repeats zoom in three steps");
|
||||
hold_key(&ctx, &mut app, egui::Key::R, egui::Modifiers::NONE);
|
||||
assert_eq!(app.engine.session().edit.zoom.samples_per_px, start, "R and two repeats zoom back out three steps");
|
||||
}
|
||||
|
||||
/// #1377: the registry's Cmd+[ / Cmd+] and the Cmd+Alt+[ / ] waveform zoom repeat too.
|
||||
#[test]
|
||||
fn holding_a_zoom_chord_keeps_zooming() {
|
||||
let (ctx, mut app) = app_for_keys();
|
||||
let cmd = egui::Modifiers { command: true, ..egui::Modifiers::NONE };
|
||||
let start = app.engine.session().edit.zoom.samples_per_px;
|
||||
hold_key(&ctx, &mut app, egui::Key::OpenBracket, cmd);
|
||||
assert_eq!(app.engine.session().edit.zoom.samples_per_px, start * 8.0, "Cmd+[ and two repeats zoom out three steps");
|
||||
hold_key(&ctx, &mut app, egui::Key::CloseBracket, cmd);
|
||||
assert_eq!(app.engine.session().edit.zoom.samples_per_px, start, "Cmd+] and two repeats zoom in three steps");
|
||||
let waveform = app.engine.session().edit.zoom.waveform_zoom;
|
||||
hold_key(&ctx, &mut app, egui::Key::CloseBracket, egui::Modifiers { alt: true, ..cmd });
|
||||
assert_eq!(app.engine.session().edit.zoom.waveform_zoom, waveform * 8.0, "Cmd+Alt+] and two repeats raise the waveform three steps");
|
||||
}
|
||||
|
||||
/// Only the zoom commands repeat: a held registry chord (Cmd+Z) still undoes once and a held
|
||||
/// `fixed` key (Enter) still adds one marker.
|
||||
#[test]
|
||||
fn holding_cmd_z_or_enter_still_runs_once() {
|
||||
let (ctx, mut app) = app_for_keys();
|
||||
for _ in 0..3 {
|
||||
app.run("track.new", json!({"count": 1, "format": "stereo"})).unwrap();
|
||||
}
|
||||
let depth = app.engine.undo_history().len();
|
||||
hold_key(&ctx, &mut app, egui::Key::Z, egui::Modifiers { command: true, ..egui::Modifiers::NONE });
|
||||
assert_eq!(app.engine.undo_history().len(), depth - 1, "auto-repeats of Cmd+Z are ignored");
|
||||
let markers = app.engine.session().markers.len();
|
||||
hold_key(&ctx, &mut app, egui::Key::Enter, egui::Modifiers::NONE);
|
||||
assert_eq!(app.engine.session().markers.len(), markers + 1, "auto-repeats of Enter are ignored");
|
||||
}
|
||||
|
||||
/// A fresh physical Cmd+Z and an identical scripted Cmd+Z in one frame are distinct actions.
|
||||
/// Matching them by value would swallow the scripted press; provenance keeps both.
|
||||
#[test]
|
||||
|
||||
@@ -237,7 +237,7 @@ fn fmt_pos(app: &SoundApp, at: i64, f: TimeFormat) -> String {
|
||||
s.format_position(at, f)
|
||||
}
|
||||
|
||||
/// Main and sub position counters, a main-counter format menu, selection readouts and dirty/device status.
|
||||
/// Main and sub position counters with their format menus, selection readouts and dirty/device status.
|
||||
fn counters(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let t = Tokens::current();
|
||||
let (main_fmt, sub_fmt, sel) = {
|
||||
@@ -252,13 +252,7 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) {
|
||||
ui.painter().text(pos2(main_r.max.x - 18.0, main_r.center().y), Align2::RIGHT_CENTER, txt, mono(26.0), t.counter_text);
|
||||
let resp = ui.interact(main_r, ui.scope_id().with("main_counter"), Sense::click());
|
||||
icons::draw(ui.painter(), Rect::from_center_size(pos2(main_r.max.x - 9.0, main_r.center().y), vec2(10.0, 10.0)), "triangle_down", t.text_dim);
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
for f in TimeFormat::ALL {
|
||||
if ui.selectable_label(main_fmt == f, f.label()).clicked() {
|
||||
let _ = app.run("view.main_counter", json!({"format": f.id()}));
|
||||
}
|
||||
}
|
||||
});
|
||||
format_menu(app, &resp, main_fmt, "view.main_counter");
|
||||
// Start / End / Length.
|
||||
let s = app.engine.session();
|
||||
let rows = [
|
||||
@@ -276,11 +270,27 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) {
|
||||
ui.painter().line_segment([pos2(r.min.x + 6.0, r.max.y - 21.0), pos2(r.max.x - 6.0, r.max.y - 21.0)], Stroke::new(1.0, t.counter_rule));
|
||||
ui.painter().text(pos2(r.min.x + 10.0, y), Align2::LEFT_CENTER, "Sub", regular(11.0), t.counter_label);
|
||||
ui.painter().text(pos2(r.min.x + 150.0, y), Align2::RIGHT_CENTER, fmt_pos(app, pos, sub_fmt), mono(11.0), t.counter_text);
|
||||
let sub_r = Rect::from_min_max(pos2(r.min.x + 6.0, r.max.y - 20.0), pos2(r.min.x + 164.0, r.max.y - 2.0));
|
||||
let sub_resp = ui.interact(sub_r, ui.scope_id().with("sub_counter"), Sense::click());
|
||||
icons::draw(ui.painter(), Rect::from_center_size(pos2(r.min.x + 158.0, y), vec2(8.0, 8.0)), "triangle_down", t.text_dim);
|
||||
format_menu(app, &sub_resp, sub_fmt, "view.sub_counter");
|
||||
let st = if app.engine.is_dirty() { "modified" } else { "saved" };
|
||||
let dev = app.player.as_ref().map_or("no audio engine", |p| if p.silent { "silent clock" } else { "audio on" });
|
||||
ui.painter().text(pos2(r.max.x - 8.0, y), Align2::RIGHT_CENTER, format!("{st} · {dev}"), regular(10.0), t.text_dim);
|
||||
}
|
||||
|
||||
/// A counter's time-format menu: picking a format runs `command` (`view.main_counter` or
|
||||
/// `view.sub_counter`) with it.
|
||||
fn format_menu(app: &mut SoundApp, resp: &egui::Response, current: TimeFormat, command: &str) {
|
||||
egui::Popup::menu(resp).show(|ui| {
|
||||
for f in TimeFormat::ALL {
|
||||
if ui.selectable_label(current == f, f.label()).clicked() {
|
||||
let _ = app.run(command, json!({"format": f.id()}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Draws the compact Grid and Nudge readouts and their scale menus.
|
||||
fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let t = Tokens::current();
|
||||
|
||||
@@ -33,7 +33,6 @@ const KNOWN_UNREACHED: &[&str] = &[
|
||||
"transport.pause",
|
||||
"transport.status",
|
||||
"video.offset",
|
||||
"view.sub_counter",
|
||||
];
|
||||
|
||||
fn ui_source() -> String {
|
||||
|
||||
@@ -51,6 +51,7 @@ mod side_lists_text;
|
||||
mod strip_silence_dialog;
|
||||
mod strip_silence_rectangles;
|
||||
mod strip_silence_rename;
|
||||
mod sub_counter;
|
||||
mod toolbar_scroll;
|
||||
mod toolbar_tempo;
|
||||
mod track_height;
|
||||
|
||||
@@ -113,3 +113,109 @@ fn cmd_equals_toggles_mix_and_edit_without_zooming() {
|
||||
assert_eq!(ctx.zoom_factor(), 1.25, "{os:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// One full app frame on a 1600×1000 screen.
|
||||
fn app_frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec<egui::Event>) {
|
||||
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0)));
|
||||
ctx.run_ui(egui::RawInput { screen_rect, events, ..Default::default() }, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
}
|
||||
|
||||
fn arrow(key: Key) -> Vec<egui::Event> {
|
||||
[true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }).to_vec()
|
||||
}
|
||||
|
||||
/// Where `at` is drawn in the Edit window's timeline, and the timeline's middle (#1376).
|
||||
fn x_and_middle(app: &SoundApp, at: i64) -> (f32, f32) {
|
||||
let tl = soundcraft_ui_egui::edit_window::rect(app.edit_layout.timeline);
|
||||
(soundcraft_ui_egui::edit_window::x_of(app.engine.session(), tl, at), tl.center().x)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_and_right_arrows_centre_the_edit_window_on_the_selected_clip() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let (clip_id, clip) = app.engine.session().tracks.iter().flat_map(|t| t.clips()).find(|c| c.start > 100_000).map(|c| (c.id, c.range())).unwrap();
|
||||
app.run("view.zoom_set", json!({"samples_per_px": 10.0})).unwrap();
|
||||
app.run("view.scroll", json!({"to": 0})).unwrap();
|
||||
for _ in 0..3 {
|
||||
app_frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
app.run("edit.select", json!({"clips": [clip_id.0]})).unwrap();
|
||||
assert!(x_and_middle(&app, clip.start).0 > app.edit_layout.timeline[2], "the clip starts off-screen");
|
||||
|
||||
app_frame(&ctx, &mut app, arrow(Key::ArrowLeft));
|
||||
let (x, middle) = x_and_middle(&app, clip.start);
|
||||
assert!((x - middle).abs() < 1.0, "Left: the clip start at {x}, the middle at {middle}");
|
||||
app_frame(&ctx, &mut app, arrow(Key::ArrowRight));
|
||||
let (x, middle) = x_and_middle(&app, clip.end);
|
||||
assert!((x - middle).abs() < 1.0, "Right: the clip end at {x}, the middle at {middle}");
|
||||
|
||||
// An insertion point alone leaves the view where it is.
|
||||
app.run("edit.select", json!({"start": clip.start})).unwrap();
|
||||
let scroll = app.engine.session().edit.zoom.scroll;
|
||||
app_frame(&ctx, &mut app, arrow(Key::ArrowLeft));
|
||||
app_frame(&ctx, &mut app, arrow(Key::ArrowRight));
|
||||
assert_eq!(app.engine.session().edit.zoom.scroll, scroll);
|
||||
}
|
||||
|
||||
/// Over the piano roll the arrows move the selected note and leave the Edit window's view alone.
|
||||
#[test]
|
||||
fn the_piano_roll_keeps_the_plain_arrows() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.ui.show_midi_editor = true;
|
||||
app.run("edit.select", json!({"tracks": ["Keys"], "start": 0, "end": 480_000})).unwrap();
|
||||
app.run("view.zoom_set", json!({"samples_per_px": 10.0})).unwrap();
|
||||
app.run("view.scroll", json!({"to": 1_000_000})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..6 {
|
||||
app_frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
let body = ctx.data(|d| d.get_temp::<egui::Rect>(egui::Id::unique("soundcraft-midi-body"))).unwrap();
|
||||
app_frame(&ctx, &mut app, vec![egui::Event::PointerMoved(body.center())]);
|
||||
app.midi.selected = vec![0];
|
||||
let note_start = |app: &SoundApp| {
|
||||
let clip = soundcraft_ui_egui::midi_editor::target_clip(app).unwrap();
|
||||
match &app.engine.session().find_clip(clip).unwrap().1.content {
|
||||
soundcraft_model::ClipContent::Midi { sequence } => sequence.notes[app.midi.selected[0]].start,
|
||||
_ => panic!("not a MIDI clip"),
|
||||
}
|
||||
};
|
||||
let (before, scroll) = (note_start(&app), app.engine.session().edit.zoom.scroll);
|
||||
app_frame(&ctx, &mut app, arrow(Key::ArrowRight));
|
||||
assert!(note_start(&app) > before, "the note moved right");
|
||||
assert_eq!(app.engine.session().edit.zoom.scroll, scroll, "the Edit window kept its view");
|
||||
}
|
||||
|
||||
/// The MIDI editor open behind another tab has no piano roll on screen: the panel in front of it takes no
|
||||
/// arrows, so they centre the Edit window there as anywhere else.
|
||||
#[test]
|
||||
fn a_midi_editor_behind_another_tab_leaves_the_arrows_to_the_edit_window() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.ui.show_midi_editor = true;
|
||||
let (clip_id, clip) = app.engine.session().tracks.iter().flat_map(|t| t.clips()).find(|c| c.start > 100_000).map(|c| (c.id, c.range())).unwrap();
|
||||
app.run("view.zoom_set", json!({"samples_per_px": 10.0})).unwrap();
|
||||
app.run("view.scroll", json!({"to": 0})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..6 {
|
||||
app_frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
let body = ctx.data(|d| d.get_temp::<egui::Rect>(egui::Id::unique("soundcraft-midi-body"))).unwrap();
|
||||
app.run("ui.dock", json!({"operation": "open", "panel": "history"})).unwrap();
|
||||
app.run("ui.dock", json!({"operation": "move", "panel": "history", "target": "midi", "zone": "tab"})).unwrap();
|
||||
for _ in 0..3 {
|
||||
app_frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
assert!(app.ui.show_midi_editor, "the MIDI editor is still open");
|
||||
assert_ne!(app.ui.docking.layout.panel_state(&soundcraft_ui_egui::docking::Panel::Midi), craft_ui::docking::PanelState::Front);
|
||||
app_frame(&ctx, &mut app, vec![egui::Event::PointerMoved(body.center())]);
|
||||
app.run("edit.select", json!({"clips": [clip_id.0]})).unwrap();
|
||||
|
||||
app_frame(&ctx, &mut app, arrow(Key::ArrowLeft));
|
||||
let (x, middle) = x_and_middle(&app, clip.start);
|
||||
assert!((x - middle).abs() < 1.0, "Left: the clip start at {x}, the middle at {middle}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Clicking the toolbar's Sub counter offers the time formats, as the Main counter does (#1374).
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, RawInput, Rect, Shape, epaint::ClippedShape, vec2};
|
||||
use soundcraft_time::TimeFormat;
|
||||
use soundcraft_ui_egui::{Services, SoundApp};
|
||||
|
||||
struct Rig {
|
||||
ctx: egui::Context,
|
||||
app: SoundApp,
|
||||
shapes: Vec<ClippedShape>,
|
||||
}
|
||||
|
||||
impl Rig {
|
||||
fn new() -> Rig {
|
||||
let mut r = Rig {
|
||||
ctx: egui::Context::default(),
|
||||
app: SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()),
|
||||
shapes: Vec::new(),
|
||||
};
|
||||
r.frames(4);
|
||||
r
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<Event>) {
|
||||
let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() };
|
||||
let mut out = self.ctx.run_ui(raw, |ui| {
|
||||
self.app.logic(ui.ctx());
|
||||
self.app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
self.shapes = out.shapes;
|
||||
}
|
||||
|
||||
fn frames(&mut self, n: usize) {
|
||||
for _ in 0..n {
|
||||
self.frame(vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Centre of the last text drawn as `text` (a popup's items are painted over the window).
|
||||
fn text(&self, text: &str) -> Option<Pos2> {
|
||||
self.shapes.iter().rev().find_map(|c| match &c.shape {
|
||||
Shape::Text(t) if t.galley.text() == text => Some(t.galley.rect.translate(t.pos.to_vec2()).center()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn count(&self, text: &str) -> usize {
|
||||
self.shapes.iter().filter(|c| matches!(&c.shape, Shape::Text(t) if t.galley.text() == text)).count()
|
||||
}
|
||||
|
||||
fn click(&mut self, at: Pos2) {
|
||||
for pressed in [true, false] {
|
||||
self.frame(vec![
|
||||
Event::PointerMoved(at),
|
||||
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
}
|
||||
self.frames(2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sub_counter_menu_changes_its_format() {
|
||||
let mut r = Rig::new();
|
||||
let main = r.app.engine.session().edit.main_counter;
|
||||
assert_eq!(r.app.engine.session().edit.sub_counter, Some(TimeFormat::Samples));
|
||||
let sub = r.text("Sub").expect("the Sub counter is in the toolbar");
|
||||
let shown = r.count(TimeFormat::MinSecs.label());
|
||||
r.click(sub);
|
||||
assert_eq!(r.count(TimeFormat::MinSecs.label()), shown + 1, "clicking the Sub counter opens its format menu");
|
||||
let item = r.text(TimeFormat::MinSecs.label()).expect("clicking the Sub counter lists the formats");
|
||||
r.click(item);
|
||||
assert_eq!(r.app.engine.session().edit.sub_counter, Some(TimeFormat::MinSecs));
|
||||
assert_eq!(r.app.engine.session().edit.main_counter, main, "the Main counter keeps its format");
|
||||
}
|
||||
@@ -8,7 +8,8 @@ the AI models named in `Co-Authored-By` trailers.
|
||||
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
|
||||
disk or the network at run time; the web build has them too):
|
||||
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
|
||||
username. Never hand-edit.
|
||||
username. Never hand-edit. Release builds regenerate it for the release commit
|
||||
(`.github/workflows/release.yml`, `credits` job); the copy in the repository is not updated.
|
||||
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
|
||||
themselves. **Each contributor adds only their own entry**, in their own PR.
|
||||
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
|
||||
|
||||
Reference in New Issue
Block a user