Merge pull request #413 from mikkelkonyher/checkbox-drag

Drag across checkboxes to check or uncheck several (#399)
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-13 06:54:02 -04:00
commit c0cb993408
9 files changed
+193 -16

No files matched your search

+6 -6
View File
@@ -345,7 +345,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(),
@@ -382,7 +382,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()
@@ -514,13 +514,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) {
@@ -555,7 +555,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(),
+2 -2
View File
@@ -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() {
+2 -2
View File
@@ -1080,11 +1080,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}));
}
});
+2 -2
View File
@@ -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}));
+1 -1
View File
@@ -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),
+131
View File
@@ -499,9 +499,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<egui::WidgetText>) -> 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::<bool>(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::<bool>(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<Pos2>) {
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<Pos2> = 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() {
+2 -2
View File
@@ -114,7 +114,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);
}
}
@@ -545,7 +545,7 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) {
});
ui.label(egui::RichText::new("The mix runs in 512-sample blocks; the buffer size sets the device's own latency.").small().color(t.text_dim));
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() {
+44
View File
@@ -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<Pos2> = 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<u64> = 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::<Vec<_>>());
}
+3 -1
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (waveforms preview fades during drag) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (drag across checkboxes, #399; waveforms preview fades during drag) · **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
@@ -36,6 +36,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
@@ -67,6 +68,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Drag across checkboxes (#399) |
| 2026-10-13 | minor | Waveforms and fade curves share temporary envelopes during drag, without rebuilding media or editing the document before release. Readiness estimate unchanged |
| 2026-10-13 | minor | Fade curves use bounded physical-pixel sampling; midpoint handles stay visible and inside the clip, with repeat-drag coverage after extreme bends. Readiness estimate unchanged |
| 2026-10-13 | minor | Continuous fade curvature from a midpoint drag, shared UI/audio envelope, one-step undo and saved session data. Readiness estimate unchanged |