Compare commits

..
19 Commits
Author SHA1 Message Date
FredApps ddeb7e791f Merge pull request #1454 from FredApps/fix/restore-shortcut-plain-guard
fix(ui): restore plain arrow guard after zoom shortcut extraction
2026-11-08 17:54:53 +01:00
FredApps 507786ad7a fix(ui): restore plain arrow guard after zoom shortcut extraction 2026-11-08 17:43:16 +01:00
FredApps a7b7fb949e Merge pull request #1398 from ZIDAN44/fix/1377-zoom-key-repeat
fix(ui): holding a zoom key keeps zooming (#1377)
2026-11-08 17:07:09 +01:00
FredApps 16e495550c Merge pull request #1397 from ZIDAN44/fix/1254-color-strip-double-click
fix(ui): double-clicking a track's colour strip opens the Color Palette (#1254)
2026-11-08 17:05:43 +01:00
FredApps 76cc2aeac6 Merge pull request #1006 from Silvertree2010/fix/886-audit-the-audio-callback-blocks
Render strips serially on the audio thread instead of using rayon
2026-11-08 02:21:27 +01:00
FredApps 26cafa85bb test(mix): report strip helper errors without unwrap 2026-11-08 02:10:22 +01:00
FredApps 8cdcab2697 fix(mix): keep audio thread tests native-only 2026-11-08 02:05:36 +01:00
Luis 436a2e22a8 Release: refresh contributor credits for each release (#1424) (#1425)
About > Contributors still showed an old snapshot, because nothing
regenerated contributors/contributors.json before a release. Opt in to
the shared refresh from craft-common-actions: a read-only credits job
regenerates the snapshot for the release commit, and the shared release
workflow installs it before every source build. The snapshot is not
committed back.
2026-11-08 01:41:45 +01:00
FredApps b6f9f150a1 Merge pull request #1417 from ZIDAN44/fix/1374-sub-counter-format-menu
fix(ui): the Sub counter opens its format menu, as the Main counter does (#1374)
2026-11-07 15:41:05 +01:00
FredApps fbd7789b4a Merge pull request #1416 from ZIDAN44/fix/1409-web-export-clips
fix(engine): Bounce Stems and Export Clips work in the web build (#1409)
2026-11-07 15:40:58 +01:00
FredApps 4edd315adb Merge pull request #1415 from ZIDAN44/fix/1376-arrows-scroll-to-selection
fix(ui): Left and Right arrows centre the Edit window on the selection (#1376)
2026-11-07 15:40:53 +01:00
Zinadin Zidan 6c917ea378 fix(ui): a MIDI editor behind another tab leaves the arrows to the Edit window (review)
The piano roll's rect stayed recorded after its tab went behind another
one, so with the pointer over the panel now in front, Left and Right
still went to the hidden piano roll and the Edit window did not scroll.
The rect is now recorded only by a frame that draws the MIDI panel.
2026-11-07 02:02:43 +06:00
Zinadin Zidan c05dfc0a8e fix(engine): Export Clips names a run of same-named clips in one pass (review)
Each clip looked for its file name from the start again, checking the
disk for every earlier name, so exporting many clips of one name took
time growing with the square of their number. The export now remembers
the next suffix to try for each name. Files already in the folder are
still skipped.
2026-11-07 02:01:24 +06:00
Zinadin Zidan 6f54446423 fix(ui): the Sub counter opens its format menu, as the Main counter does (#1374)
Clicking the Sub counter (its label, value or the new small triangle) opens
the time formats and runs view.sub_counter. The Main counter's menu moved
into format_menu(), which both counters use, and view.sub_counter leaves the
list of commands with no way in from the UI.
2026-11-07 00:27:10 +06:00
Zinadin Zidan 24451d5c42 fix(ui): Left and Right arrows centre the Edit window on the selection (#1376)
With a selection in the Edit window, plain Left scrolls the view so the
selection's start is in the middle of the timeline, and Right its end.
Without a selection the keys do nothing, as before. With the pointer over
the MIDI editor the arrows keep moving notes there.
2026-11-07 00:27:09 +06:00
Zinadin Zidan 7d508e2e44 fix(engine): Bounce Stems and Export Clips work in the web build (#1409)
Both made a folder first, which fails in the browser. Making a folder is
now a no-op on wasm32, where each file is delivered by name, and Export
Clips writes through write_file, the path bounce and export already use,
instead of std::fs. Two clips with the same name no longer get the same
file name when nothing is on disk. Native behaviour is unchanged.
2026-11-07 00:27:09 +06:00
Zinadin ZidanandRahul fca0130b72 fix(ui): double-clicking a track's colour strip opens the Color Palette (#1254)
The colour strip at the left of an Edit-window track header now has its own
click area. A double-click selects the track, keeping a multi-selection it
is part of, and opens Window > Color Palette, which colours the selected
tracks. A click on an unselected track's strip selects it as the header
did; a plain click on an already selected track's strip leaves the selection
alone, so a double-click can colour several tracks. Right-click still opens
the track menu.

Co-Authored-By: Rahul <rahulsingh.ranjan06@gmail.com>
2026-11-06 00:50:57 +06:00
Zinadin Zidan 77b788007a fix(ui): holding a zoom key keeps zooming (#1377)
The shortcut dispatcher drops every key auto-repeat (#1063), so a held zoom
key zoomed one step. A repeat now runs when its chord leads to view.zoom_in,
view.zoom_out or view.waveform_zoom (T/R, Cmd+[ / ], Cmd+Alt+[ / ]); every
other repeat is still dropped, so a held Cmd+Z still undoes once.
2026-11-06 00:50:46 +06:00
NetterSilverandClaude Haiku 5.5 e569fbc50e Render strips serially on the audio thread instead of using rayon
Fixes #886

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 02:07:56 +01:00
16 changed files with 617 additions and 79 deletions
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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");
}
}
+5
View File
@@ -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,
+24
View File
@@ -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;
+47
View File
@@ -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
View File
@@ -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();
}
}
}
+72
View File
@@ -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]
+18 -8
View File
@@ -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 {
+1
View File
@@ -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;
+106
View File
@@ -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}");
}
+76
View File
@@ -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");
}
+2 -1
View File
@@ -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