From bca2b4af07456c89adbcef5b808c2def8e8b8c7a Mon Sep 17 00:00:00 2001 From: Mikkel konyher <146730338+mikkelkonyher@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:43:08 +0200 Subject: [PATCH] Drag across checkboxes to check or uncheck several (#399) Press a checkbox and drag over its neighbours: each box the pointer passes takes the state the first one switched to, until the button is released. A click toggles one box, as before. Same design as VectorCraft and PhotoCraft. widgets::checkbox wraps egui's checkbox and adds the drag; every ui.checkbox call in ui-egui uses it. Only boxes the pointer can see are set, so rows scrolled out of a list or under another window stay as they are. Tests: widgets::tests (drag sets, drag unchecks and stops at release, hidden rows untouched, a click toggles one) and tests/group_dialog.rs (dragging down the Group dialog's track list groups every track). --- crates/ui-egui/src/dialogs.rs | 12 +-- crates/ui-egui/src/extra_windows.rs | 4 +- crates/ui-egui/src/mix_window.rs | 4 +- crates/ui-egui/src/ops_windows.rs | 4 +- crates/ui-egui/src/video_window.rs | 2 +- crates/ui-egui/src/widgets.rs | 131 +++++++++++++++++++++++++++ crates/ui-egui/src/windows.rs | 4 +- crates/ui-egui/tests/group_dialog.rs | 44 +++++++++ docs/ui-parity.md | 4 +- 9 files changed, 193 insertions(+), 16 deletions(-) create mode 100644 crates/ui-egui/tests/group_dialog.rs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2ca955d..8bcdc42 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -335,7 +335,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { ui.selectable_value(sample_rate, r, format!("{r} Hz")); } }); - ui.checkbox(demo, "Start from the demo session"); + crate::widgets::checkbox(ui, demo, "Start from the demo session"); if buttons(ui, "Create", enter) { action = Some(( "session.new".into(), @@ -360,7 +360,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } }); }); - ui.checkbox(normalize, "Normalize to -0.1 dBFS"); + crate::widgets::checkbox(ui, normalize, "Normalize to -0.1 dBFS"); ui.label( egui::RichText::new("Bounces the edit selection, or the whole session when nothing is selected.") .small() @@ -484,13 +484,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { ui.text_edit_singleline(name); }); ui.horizontal(|ui| { - ui.checkbox(edit, "Edit group"); - ui.checkbox(mix, "Mix group"); + crate::widgets::checkbox(ui, edit, "Edit group"); + crate::widgets::checkbox(ui, mix, "Mix group"); }); ui.label("Tracks"); egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { for (_, n, on) in members.iter_mut() { - ui.checkbox(on, n.as_str()); + crate::widgets::checkbox(ui, on, n.as_str()); } }); if buttons(ui, "OK", enter) { @@ -525,7 +525,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { ui.add(egui::DragValue::new(bars_per_system).range(1..=16)); ui.end_row(); }); - ui.checkbox(show_track_names, "Show track names"); + crate::widgets::checkbox(ui, show_track_names, "Show track names"); if buttons(ui, "OK", enter) { action = Some(( "file.score_setup".into(), diff --git a/crates/ui-egui/src/extra_windows.rs b/crates/ui-egui/src/extra_windows.rs index e269573..df43024 100644 --- a/crates/ui-egui/src/extra_windows.rs +++ b/crates/ui-egui/src/extra_windows.rs @@ -158,7 +158,7 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) { ui.add(egui::Slider::new(&mut app.extra.ui_scale, 0.75..=1.5).step_by(0.05).custom_formatter(|v, _| format!("{:.0} %", v * 100.0))); ui.end_row(); ui.label("Reduce motion"); - ui.checkbox(&mut app.extra.reduce_motion, ""); + crate::widgets::checkbox(ui, &mut app.extra.reduce_motion, ""); ui.end_row(); ui.label("Theme"); let mut chosen = None; @@ -174,7 +174,7 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) { } ui.end_row(); ui.label("Auto-update configuration"); - ui.checkbox(&mut app.extra.auto_update_config, ""); + crate::widgets::checkbox(ui, &mut app.extra.auto_update_config, ""); ui.end_row(); }); if ui.button("Reset").clicked() { diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 01bb21a..d68677f 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -894,11 +894,11 @@ fn send_options_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, } ui.horizontal(|ui| { let mut mute = send.mute; - if ui.checkbox(&mut mute, "Mute").changed() { + if crate::widgets::checkbox(ui, &mut mute, "Mute").changed() { let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "mute": mute})); } let mut pre = send.pre_fader; - if ui.checkbox(&mut pre, "Pre-fader").changed() { + if crate::widgets::checkbox(ui, &mut pre, "Pre-fader").changed() { let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "pre_fader": pre})); } }); diff --git a/crates/ui-egui/src/ops_windows.rs b/crates/ui-egui/src/ops_windows.rs index 87c7dde..beba523 100644 --- a/crates/ui-egui/src/ops_windows.rs +++ b/crates/ui-egui/src/ops_windows.rs @@ -90,8 +90,8 @@ fn beat_detective(app: &mut SoundApp, ctx: &egui::Context) { egui::Window::new("Beat Detective").open(&mut open).default_size(vec2(360.0, 220.0)).show(ctx, |ui| { ui.label("Analyses the edit selection on the selected tracks."); ui.add(egui::Slider::new(&mut app.ops.beat_sensitivity, 0.0..=1.0).text("Sensitivity")); - ui.checkbox(&mut app.ops.beat_separate, "Separate clips at detected beats"); - ui.checkbox(&mut app.ops.beat_set_tempo, "Set the session tempo from the beats"); + crate::widgets::checkbox(ui, &mut app.ops.beat_separate, "Separate clips at detected beats"); + crate::widgets::checkbox(ui, &mut app.ops.beat_set_tempo, "Set the session tempo from the beats"); ui.horizontal(|ui| { if ui.button("Analyze").clicked() { let r = app.run("event.beat_detective", json!({"sensitivity": app.ops.beat_sensitivity, "separate": false, "set_tempo": false})); diff --git a/crates/ui-egui/src/video_window.rs b/crates/ui-egui/src/video_window.rs index 82648cc..2f2aa7f 100644 --- a/crates/ui-egui/src/video_window.rs +++ b/crates/ui-egui/src/video_window.rs @@ -37,7 +37,7 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) { let mut burn = app.ui.video_burn_in; egui::Window::new("Video").open(&mut open).default_size(vec2(480.0, 480.0 / aspect + 28.0)).resizable(true).show(ctx, |ui| { ui.horizontal(|ui| { - ui.checkbox(&mut burn, "Timecode burn-in"); + crate::widgets::checkbox(ui, &mut burn, "Timecode burn-in"); let text = match &pic { Some((k, ..)) => match app.video.info(*k) { Some(Ok(i)) => format!("{}x{} {:.3} fps {}", i.width, i.height, i.frame_rate, i.codec_detail), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index ad0ec6a..29a9dc4 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -461,9 +461,140 @@ pub fn multi_meter(ui: &Ui, r: Rect, levels: &[f32], labels: &[String], clip: bo } } +/// egui's checkbox, plus drag-to-set (#399): press one box and drag over its neighbours, and each +/// box the pointer passes takes the state the first one switched to. A click toggles one box. +pub fn checkbox(ui: &mut Ui, on: &mut bool, label: impl Into) -> Response { + // egui's own toggle works on a copy: `drag_toggle` decides clicks and drags alike. + let mut copy = *on; + let mut resp = ui.checkbox(&mut copy, label.into()).interact(Sense::drag()); + if drag_toggle(ui, &resp, on) { + resp.mark_changed(); + } + resp +} + +/// The new state lives in the egui context from the first box's drag start until the button is +/// released. Returns whether `on` changed. +fn drag_toggle(ui: &Ui, resp: &Response, on: &mut bool) -> bool { + let ctx = ui.ctx(); + let key = egui::Id::new("soundcraft-checkbox-drag-target"); + if !ctx.input(|i| i.pointer.primary_down()) { + ctx.data_mut(|d| d.remove::(key)); + } + if resp.drag_started_by(egui::PointerButton::Primary) { + *on = !*on; + ctx.data_mut(|d| d.insert_temp(key, *on)); + return true; + } + if resp.clicked() { + *on = !*on; + return true; + } + let Some(target) = ctx.data(|d| d.get_temp::(key)) else { return false }; + // Clipped and layer-aware: rows scrolled out of view or under another window stay as they are. + if ui.rect_contains_pointer(resp.rect) && *on != target { + *on = target; + return true; + } + false +} + #[cfg(test)] mod tests { use super::{clip_gain_from_pos, clip_gain_to_pos}; + use egui::{Event, Modifiers, PointerButton, Pos2}; + use egui_kittest::{Harness, kittest::Queryable}; + + fn press(h: &mut Harness<'_, [bool; 4]>, pos: Pos2, pressed: bool) { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(2); + } + + fn boxes() -> (Harness<'static, [bool; 4]>, Vec) { + let mut h = Harness::new_ui_state( + |ui, s: &mut [bool; 4]| { + ui.vertical(|ui| { + for b in s.iter_mut() { + super::checkbox(ui, b, "Box"); + } + }); + }, + [false, true, false, false], + ); + h.run(); + let at = h.get_all_by_label("Box").map(|n| n.rect().center()).collect(); + (h, at) + } + + /// #399: pressing a box and dragging over its neighbours sets them all to the state the first + /// one switched to; the box past the drag stays as it was. + #[test] + fn dragging_across_checkboxes_sets_them_all_like_the_first() { + let (mut h, at) = boxes(); + assert_eq!(at.len(), 4); + h.event(Event::PointerMoved(at[0])); + h.run_steps(1); + press(&mut h, at[0], true); + for p in &at[1..3] { + h.event(Event::PointerMoved(*p)); + h.run_steps(2); + } + press(&mut h, at[2], false); + assert_eq!(*h.state(), [true, true, true, false]); + } + + /// Dragging from a checked box unchecks; after release, moving over boxes changes nothing. + #[test] + fn dragging_from_a_checked_box_unchecks_and_release_ends_it() { + let (mut h, at) = boxes(); + h.state_mut().copy_from_slice(&[true, true, true, true]); + h.run(); + h.event(Event::PointerMoved(at[1])); + h.run_steps(1); + press(&mut h, at[1], true); + h.event(Event::PointerMoved(at[2])); + h.run_steps(2); + press(&mut h, at[2], false); + h.event(Event::PointerMoved(at[3])); + h.run_steps(2); + assert_eq!(*h.state(), [true, false, false, true]); + } + + /// A row scrolled out of view isn't set when the drag passes over the place it would be. + #[test] + fn dragging_past_a_scroll_area_leaves_hidden_rows_alone() { + let mut h = Harness::new_ui_state( + |ui, s: &mut [bool; 4]| { + egui::ScrollArea::vertical().max_height(30.0).show(ui, |ui| { + for b in s.iter_mut() { + super::checkbox(ui, b, "Box"); + } + }); + }, + [false; 4], + ); + h.run(); + let at: Vec = h.get_all_by_label("Box").map(|n| n.rect().center()).collect(); + assert_eq!(at.len(), 4); + h.event(Event::PointerMoved(at[0])); + h.run_steps(1); + press(&mut h, at[0], true); + h.event(Event::PointerMoved(at[3])); + h.run_steps(2); + press(&mut h, at[3], false); + assert_eq!(*h.state(), [true, false, false, false]); + } + + /// A click still toggles just the one box, once. + #[test] + fn a_click_toggles_one_checkbox() { + let (mut h, at) = boxes(); + h.event(Event::PointerMoved(at[1])); + h.run_steps(1); + press(&mut h, at[1], true); + press(&mut h, at[1], false); + assert_eq!(*h.state(), [false, false, false, false]); + } #[test] fn clip_gain_taper_spans_minus_144_to_plus_36_and_round_trips() { diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index b44c1b1..5e4ec2a 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -111,7 +111,7 @@ fn automation(app: &mut SoundApp, ctx: &egui::Context) { for f in ["volume", "pan", "mute", "send level", "send pan", "send mute", "plugin"] { let id = format!("automation.enable.{}", f.replace(' ', "_")); let mut on = !app.engine.session().edit.flag(&format!("{id}.off")); - if ui.checkbox(&mut on, f).changed() { + if crate::widgets::checkbox(ui, &mut on, f).changed() { app.engine.session_mut().edit.set_flag(&format!("{id}.off"), !on); } } @@ -427,7 +427,7 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) { ui.end_row(); }); let mut dc = app.engine.session().edit.delay_compensation; - if ui.checkbox(&mut dc, "Delay compensation").changed() { + if crate::widgets::checkbox(ui, &mut dc, "Delay compensation").changed() { let _ = app.run("options.delay_compensation", json!({"value": dc})); } if ui.button("Reconnect audio device").clicked() { diff --git a/crates/ui-egui/tests/group_dialog.rs b/crates/ui-egui/tests/group_dialog.rs new file mode 100644 index 0000000..e91bfec --- /dev/null +++ b/crates/ui-egui/tests/group_dialog.rs @@ -0,0 +1,44 @@ +use egui::accesskit::Role; +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, menus}; + +fn press(h: &mut Harness<'_, SoundApp>, pos: Pos2, pressed: bool) { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +/// #399: in Track > Group..., dragging down the Tracks list adds every track it passes, and OK +/// groups them all. +#[test] +fn dragging_down_the_group_dialogs_track_list_adds_each_track() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let tracks: Vec<(u64, String)> = app.engine.session().tracks.iter().map(|t| (t.id.0, t.name.clone())).collect(); + assert!(tracks.len() >= 4); + app.run("edit.select", json!({"tracks": [tracks[0].0], "exact": true})).unwrap(); + menus::invoke_menu(&mut app, "track.group", "Track > Group..."); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + let at: Vec = tracks[1..4].iter().map(|(_, n)| h.get_by_role_and_label(Role::CheckBox, n).rect().center()).collect(); + h.event(Event::PointerMoved(at[0])); + h.run_steps(1); + press(&mut h, at[0], true); + for p in &at[1..] { + h.event(Event::PointerMoved(*p)); + h.run_steps(2); + } + press(&mut h, at[2], false); + h.get_by_role_and_label(Role::Button, "OK").click(); + h.run_steps(2); + let session = h.state().engine.session(); + let members: Vec = session.groups.last().map(|g| g.members.iter().map(|m| m.0).collect()).unwrap_or_default(); + assert_eq!(members, tracks[..4].iter().map(|t| t.0).collect::>()); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 80ff25b..389af6e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first interaction checklist against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (drag across checkboxes, #399) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command @@ -33,6 +33,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Scroll/zoom: wheel, trackpad pinch | 🟡 | Anchored trackpad zoom in open PRs #112, #137 | | Drag and drop: Clip List → tracks, files → tracks | ✅ macOS/Windows; 🟡 Wayland (PR #150) | | | Context menus (clip, track name, ruler, Clip List) | 🟡 | Clip and track menus exist; depth lower than Pro Tools'; Clip List delete (#4) | +| Drag across checkboxes to check or uncheck several | ✅ | #399; every checkbox, as in the other Craft apps | ## Windows and panels @@ -65,3 +66,4 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---| | 2026-10-10 | major | Created: tools, modes, shortcuts, windows and meters checklist against Pro Tools 2026.4.1 | +| 2026-10-13 | minor | Drag across checkboxes (#399) |