Compare commits

..
10 Commits
Author SHA1 Message Date
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
FredApps bb0e070ab9 Merge pull request #1407 from storytold/house-rules
Copy the shared house rules into AGENTS.md
2026-11-06 14:59:25 +01:00
10 changed files with 324 additions and 25 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 }}
+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);
}
}
+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,
+29
View File
@@ -232,6 +232,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
@@ -393,6 +398,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)
}
+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