mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-13 14:59:09 +00:00
Merge pull request #413 from mikkelkonyher/checkbox-drag
Drag across checkboxes to check or uncheck several (#399)
This commit is contained in:
9 files changed
+193
-16
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user