From 5586b40cf06303392dd9ecdecb569a56201b3461 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 11:43:31 +0000 Subject: [PATCH 1/5] #1190: clear existing STL Clippy warning --- crates/model/src/stl.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/model/src/stl.rs b/crates/model/src/stl.rs index 6684858ad..34e9a7f06 100644 --- a/crates/model/src/stl.rs +++ b/crates/model/src/stl.rs @@ -175,10 +175,8 @@ fn ascii(bytes: &[u8]) -> Result { let Some(key) = it.next() else { continue }; let bad = || perr(format!("STL line {}: expected three numbers", ln + 1)); match key.to_ascii_lowercase().as_str() { - "solid" => { - if name.is_none() { - name = Some(line.get(5..).unwrap_or("").trim().to_string()); - } + "solid" if name.is_none() => { + name = Some(line.get(5..).unwrap_or("").trim().to_string()); } "facet" => { // `facet normal nx ny nz` From ab692dce702abaf1a0cc4d31fc1f0ae3044046b4 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 11:43:31 +0000 Subject: [PATCH 2/5] #1190: enable drag toggling for built-in checkboxes --- crates/ui-egui/src/header.rs | 4 +- crates/ui-egui/src/panels/anim_tools.rs | 2 +- crates/ui-egui/src/panels/comp_settings.rs | 6 +- crates/ui-egui/src/panels/dialogs.rs | 8 +- crates/ui-egui/src/panels/dialogs_3d.rs | 6 +- crates/ui-egui/src/panels/effect_controls.rs | 2 +- crates/ui-egui/src/panels/essential.rs | 2 +- crates/ui-egui/src/panels/forms.rs | 16 +- crates/ui-egui/src/panels/key_dialogs.rs | 2 +- .../ui-egui/src/panels/layer_styles_dialog.rs | 4 +- crates/ui-egui/src/panels/markers_ui.rs | 4 +- crates/ui-egui/src/panels/misc.rs | 2 +- crates/ui-egui/src/panels/paint_panels.rs | 6 +- crates/ui-egui/src/panels/panel_kit.rs | 2 +- crates/ui-egui/src/panels/path_vr_panels.rs | 2 +- crates/ui-egui/src/panels/precomp.rs | 4 +- crates/ui-egui/src/panels/project.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 2 +- crates/ui-egui/src/panels/render_queue.rs | 4 +- crates/ui-egui/src/panels/settings.rs | 2 +- crates/ui-egui/src/panels/text_panels.rs | 22 +- crates/ui-egui/src/panels/timeline.rs | 6 +- crates/ui-egui/src/panels/tracker.rs | 14 +- crates/ui-egui/src/widgets.rs | 191 +++++++++++++++++- crates/ui-egui/tests/ui_checkbox_drag.rs | 54 +++++ 25 files changed, 306 insertions(+), 63 deletions(-) create mode 100644 crates/ui-egui/tests/ui_checkbox_drag.rs diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index fb381cfd3..571229007 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -174,7 +174,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0)); // The engine owns snapping (View ▸ Snapping); the checkbox mirrors it. app.ui.snapping = app.session.state.snapping; - if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::new("snapping")).clicked() { + if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::new("snapping")).changed() { let _ = app.session.execute("view.snapping", serde_json::json!({})); app.ui.snapping = app.session.state.snapping; } @@ -591,7 +591,7 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter p.text(pos2(x, cy), Align2::LEFT_CENTER, tr("Mesh:"), Tokens::ui(12.0), t.text_dim); x += 40.0; let show = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0)); - if widgets::checkbox(ui, show, o.show_mesh, &t, egui::Id::new("puppet-show-mesh")).clicked() { + if widgets::checkbox(ui, show, o.show_mesh, &t, egui::Id::new("puppet-show-mesh")).changed() { let _ = app.session.execute("puppet.mesh", json!({"showMesh": !o.show_mesh})); } app.auto.add("header.puppet.showMesh", show, "Show mesh"); diff --git a/crates/ui-egui/src/panels/anim_tools.rs b/crates/ui-egui/src/panels/anim_tools.rs index 15a4f4f89..8d4066a7f 100644 --- a/crates/ui-egui/src/panels/anim_tools.rs +++ b/crates/ui-egui/src/panels/anim_tools.rs @@ -41,7 +41,7 @@ fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &st fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool { let t = app.tokens; let r = Rect::from_min_size(at, vec2(16.0, 16.0)); - let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("animtool-cb", auto))).clicked(); + let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("animtool-cb", auto))).changed(); ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text); app.auto.add(auto, r, text); if clicked { !on } else { on } diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 2cd19df5d..14b1e7a9c 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -321,7 +321,7 @@ pub fn show( let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px"))); auto.add("dialog.comp.width", r.rect, "Width"); let lbl = format!("Lock Aspect Ratio to {}", aspect_label(ow as f64, oh as f64)); - let r = ui.checkbox(&mut d.lock_aspect, lbl); + let r = crate::widgets::labelled_checkbox(ui, &mut d.lock_aspect, lbl); auto.add("dialog.comp.lockAspect", r.rect, "Lock Aspect Ratio"); if d.lock_aspect && ow != d.width && ow > 0 { d.height = ((d.width as f64) * oh as f64 / ow as f64).round().max(4.0) as u32; @@ -416,11 +416,11 @@ pub fn show( }); ui.end_row(); ui.label(""); - let r = ui.checkbox(&mut d.preserve_frame_rate, tr("Preserve frame rate when nested or in render queue")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.preserve_frame_rate, tr("Preserve frame rate when nested or in render queue")); auto.add("dialog.comp.preserveFrameRate", r.rect, "Preserve frame rate when nested or in render queue"); ui.end_row(); ui.label(""); - let r = ui.checkbox(&mut d.preserve_resolution, tr("Preserve resolution when nested")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.preserve_resolution, tr("Preserve resolution when nested")); auto.add("dialog.comp.preserveResolution", r.rect, "Preserve resolution when nested"); ui.end_row(); ui.label(egui::RichText::new(tr("Motion Blur")).strong()); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d773fc433..b458701fc 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -480,7 +480,11 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.horizontal(|ui| { let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut size[0]).range(1..=30000).suffix(" px"))); auto.add("dialog.solid.width", r.rect, "Width"); - let r = ui.checkbox(&mut lock, tr_args("Lock Aspect Ratio to {}", &[&(super::comp_settings::aspect_label(ow as f64, oh as f64))])); + let r = crate::widgets::labelled_checkbox( + ui, + &mut lock, + tr_args("Lock Aspect Ratio to {}", &[&(super::comp_settings::aspect_label(ow as f64, oh as f64))]), + ); auto.add("dialog.solid.lockAspect", r.rect, "Lock Aspect Ratio"); }); ui.end_row(); @@ -536,7 +540,7 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if editing.is_some() { ui.add_space(8.0); let r = ui - .add_enabled(shared, egui::Checkbox::new(&mut affect_all, tr("Affect all layers that use this solid"))) + .add_enabled(shared, crate::widgets::DragCheckbox::new(&mut affect_all, tr("Affect all layers that use this solid"))) .on_disabled_hover_text(tr("Only this layer uses this solid.")); auto.add("dialog.solid.affectAll", r.rect, "Affect all layers that use this solid"); if shared && !affect_all { diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index a1a67bc60..84cf8d009 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -201,12 +201,12 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); ui.end_row(); ui.label(tr("Depth of Field")); - ui.checkbox(&mut d.dof, tr("Enable Depth of Field")); + crate::widgets::labelled_checkbox(ui, &mut d.dof, tr("Enable Depth of Field")); ui.end_row(); ui.label(tr("Focus Distance")); ui.horizontal(|ui| { ui.add_enabled(!d.lock_to_zoom, crate::widgets::drag_value(egui::DragValue::new(&mut d.focus).range(0.0..=100000.0).suffix(" px"))); - ui.checkbox(&mut d.lock_to_zoom, tr("Lock to Zoom")); + crate::widgets::labelled_checkbox(ui, &mut d.lock_to_zoom, tr("Lock to Zoom")); }); ui.end_row(); ui.label(tr("Aperture")); @@ -301,7 +301,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); } ui.label(tr("Shadows")); - ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows")); + crate::widgets::labelled_checkbox(ui, &mut d.casts_shadows, tr("Casts Shadows")); ui.end_row(); ui.label(tr("Shadow Darkness")); ui.add_enabled(d.casts_shadows, crate::widgets::drag_value(egui::DragValue::new(&mut d.shadow_darkness).range(0.0..=100.0).suffix(" %"))); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39f..87bc006c3 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -404,7 +404,7 @@ fn prop_row( } Value::Bool(b) => { let cr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(16.0, 16.0)); - if widgets::checkbox(ui, cr, *b, &t, egui::Id::new(("ec-b", uid))).clicked() { + if widgets::checkbox(ui, cr, *b, &t, egui::Id::new(("ec-b", uid))).changed() { set(actions, json!(!b)); } p.text(pos2(cr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text_dim); diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index 2e99c7d39..3dd9be36a 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -353,7 +353,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } ControlType::Checkbox => { let mut on = v.as_bool(); - let r = ui.checkbox(&mut on, ""); + let r = crate::widgets::labelled_checkbox(ui, &mut on, ""); if r.changed() { out = Some(json!(on)); } diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 275e54b75..b37351336 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -814,7 +814,7 @@ fn form_grid(ui: &mut egui::Ui, f: &mut Form, regs: &mut Vec<(String, egui::Rect .inner .rect } - FieldKind::Bool(b) => ui.checkbox(b, "").rect, + FieldKind::Bool(b) => crate::widgets::labelled_checkbox(ui, b, "").rect, FieldKind::Choice { options, sel } => { let cur = options.get(*sel).map(|o| o.0.clone()).unwrap_or_default(); // The column keeps its width: a long choice (a GPU adapter's name) ends in an @@ -944,13 +944,13 @@ pub fn show_view_options(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Toke let mut close = false; super::dialogs::modal(ctx, "View Options", vec2(360.0, 330.0), t, |ui| { let v = &mut app.ui.viewer; - ui.checkbox(&mut v.show_layer_controls, tr("Layer controls")); - ui.checkbox(&mut v.show_masks, tr("Masks")); - ui.checkbox(&mut v.rulers, tr("Rulers")); - ui.checkbox(&mut v.guides, tr("Guides")); - ui.checkbox(&mut v.grid, tr("Grid")); - ui.checkbox(&mut v.safe_margins, tr("Title/action safe")); - ui.checkbox(&mut v.transparency_grid, tr("Transparency grid")); + crate::widgets::labelled_checkbox(ui, &mut v.show_layer_controls, tr("Layer controls")); + crate::widgets::labelled_checkbox(ui, &mut v.show_masks, tr("Masks")); + crate::widgets::labelled_checkbox(ui, &mut v.rulers, tr("Rulers")); + crate::widgets::labelled_checkbox(ui, &mut v.guides, tr("Guides")); + crate::widgets::labelled_checkbox(ui, &mut v.grid, tr("Grid")); + crate::widgets::labelled_checkbox(ui, &mut v.safe_margins, tr("Title/action safe")); + crate::widgets::labelled_checkbox(ui, &mut v.transparency_grid, tr("Transparency grid")); ui.add_space(12.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)).clicked() { diff --git a/crates/ui-egui/src/panels/key_dialogs.rs b/crates/ui-egui/src/panels/key_dialogs.rs index 73f76aaaa..56fd5185a 100644 --- a/crates/ui-egui/src/panels/key_dialogs.rs +++ b/crates/ui-egui/src/panels/key_dialogs.rs @@ -160,7 +160,7 @@ pub fn velocity(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); ui.add_space(8.0); } - let r = ui.checkbox(&mut d.continuous, tr("Continuous (Lock Outgoing to Incoming)")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.continuous, tr("Continuous (Lock Outgoing to Incoming)")); app.auto.add("dialog.velocity.continuous", r.rect, "Continuous"); if d.continuous { d.out_speed = d.in_speed.clone(); diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index c0b03f77e..b3b78fb57 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -86,7 +86,7 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option { match (v, &p.ui) { (KV::Bool(b), _) => { let mut on = *b; - ui.checkbox(&mut on, "").changed().then(|| json!(on)) + crate::widgets::labelled_checkbox(ui, &mut on, "").changed().then(|| json!(on)) } (KV::Enum(i), ParamUi::Popup { options }) => { let mut sel = *i as usize; @@ -170,7 +170,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if id != BLENDING { let g = styles.as_ref().and_then(|s| s.sub(id)); let mut on = g.is_some_and(|g| g.enabled); - if ui.checkbox(&mut on, "").changed() { + if crate::widgets::labelled_checkbox(ui, &mut on, "").changed() { if g.is_none() { actions.push(("layer.style.add".into(), json!({"layers": [lid], "style": id}))); } else { diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index 2fff7e1c8..102309765 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -155,7 +155,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); }); ui.add_space(8.0); - let r = ui.checkbox(&mut d.cue, tr("Cue Point")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.cue, tr("Cue Point")); app.auto.add("dialog.marker.cue", r.rect, "Cue Point"); ui.add_enabled_ui(d.cue, |ui| { ui.horizontal(|ui| { @@ -191,7 +191,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); ui.add_space(8.0); ui.horizontal(|ui| { - let r = ui.checkbox(&mut d.protected, tr("Protected Region")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.protected, tr("Protected Region")); app.auto.add("dialog.marker.protected", r.rect, "Protected Region"); ui.add_space(20.0); ui.label(tr("Label:")); diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index cd0ab6897..5a1b77c07 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -240,7 +240,7 @@ fn fmt_rate(r: f64) -> String { fn check_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, at: egui::Pos2, key: &str, text: &str, on: bool) -> bool { let t = app.tokens; let r = Rect::from_min_size(pos2(at.x, at.y - 8.0), vec2(16.0, 16.0)); - let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("pv-check", key))).clicked(); + let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("pv-check", key))).changed(); p.text(pos2(r.max.x + 5.0, at.y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text); app.auto.add(&format!("preview.{key}"), r, text); clicked diff --git a/crates/ui-egui/src/panels/paint_panels.rs b/crates/ui-egui/src/panels/paint_panels.rs index 675bc1e16..3c9332aea 100644 --- a/crates/ui-egui/src/panels/paint_panels.rs +++ b/crates/ui-egui/src/panels/paint_panels.rs @@ -210,7 +210,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .enumerate() { let r = Rect::from_min_size(pos2(x0 + i as f32 * (w / 2.0), y), vec2(18.0, 20.0)); - if widgets::checkbox(ui, r, on, &t, egui::Id::new(key)).clicked() { + if widgets::checkbox(ui, r, on, &t, egui::Id::new(key)).changed() { set(app, json!({k: !on})); } p.text(pos2(r.max.x + 4.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim); @@ -237,7 +237,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } y += 26.0; let r = Rect::from_min_size(pos2(x0, y), vec2(18.0, 20.0)); - if widgets::checkbox(ui, r, o.show_overlay, &t, egui::Id::new("paint.showOverlay")).clicked() { + if widgets::checkbox(ui, r, o.show_overlay, &t, egui::Id::new("paint.showOverlay")).changed() { set(app, json!({"showOverlay": !o.show_overlay})); } app.auto.add("paint.showOverlay", r, "Clone Source Overlay"); @@ -246,7 +246,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { set(app, json!({"overlayOpacity": nv})); } let dr = Rect::from_min_size(pos2(x0 + w - 18.0, y), vec2(18.0, 20.0)); - if widgets::checkbox(ui, dr, o.overlay_difference, &t, egui::Id::new("paint.overlayDifference")).clicked() { + if widgets::checkbox(ui, dr, o.overlay_difference, &t, egui::Id::new("paint.overlayDifference")).changed() { set(app, json!({"overlayDifference": !o.overlay_difference})); } app.auto.add("paint.overlayDifference", dr, "Difference mode overlay"); diff --git a/crates/ui-egui/src/panels/panel_kit.rs b/crates/ui-egui/src/panels/panel_kit.rs index cbf28c8ac..06dc8b4db 100644 --- a/crates/ui-egui/src/panels/panel_kit.rs +++ b/crates/ui-egui/src/panels/panel_kit.rs @@ -23,7 +23,7 @@ pub fn dropdown(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str pub fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool { let t = app.tokens; let r = Rect::from_min_size(at, vec2(16.0, 16.0)); - let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("kit-cb", auto))).clicked(); + let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("kit-cb", auto))).changed(); ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text); let full = Rect::from_min_size(at, vec2(22.0 + text.len() as f32 * 6.5, 16.0)); app.auto.add(auto, full, text); diff --git a/crates/ui-egui/src/panels/path_vr_panels.rs b/crates/ui-egui/src/panels/path_vr_panels.rs index 9dba0dd59..b9d747a3b 100644 --- a/crates/ui-egui/src/panels/path_vr_panels.rs +++ b/crates/ui-egui/src/panels/path_vr_panels.rs @@ -54,7 +54,7 @@ pub fn create_nulls(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { acts.push((id, if id == "paths.tracePath" { json!({"loop": looping}) } else { json!({}) })); } } - let r = ui.checkbox(&mut looping, tr("Loop (Trace Path)")); + let r = crate::widgets::labelled_checkbox(&mut ui, &mut looping, tr("Loop (Trace Path)")); app.auto.add("createNulls.loop", r.rect, "Loop"); ui.data_mut(|d| d.insert_temp(loop_id, looping)); run(app, &ctx, acts); diff --git a/crates/ui-egui/src/panels/precomp.rs b/crates/ui-egui/src/panels/precomp.rs index 8a40c56e9..f6d3068b1 100644 --- a/crates/ui-egui/src/panels/precomp.rs +++ b/crates/ui-egui/src/panels/precomp.rs @@ -141,11 +141,11 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ); ui.add_space(4.0); ui.add_enabled_ui(!d.leave, |ui| { - let r = ui.checkbox(&mut d.adjust, tr("Adjust composition duration to the time span of the selected layers")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.adjust, tr("Adjust composition duration to the time span of the selected layers")); app.auto.add("dialog.precompose.adjust", r.rect, "Adjust composition duration"); }); ui.add_space(8.0); - let r = ui.checkbox(&mut d.open, tr("Open New Composition")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.open, tr("Open New Composition")); app.auto.add("dialog.precompose.open", r.rect, "Open New Composition"); ui.add_space(14.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 46187fa8c..62938e635 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -1094,7 +1094,7 @@ fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) { ui.label(egui::RichText::new(crate::i18n::tr("Columns")).weak()); for (key, label, _) in COLUMNS { let mut on = app.ui.project_columns.iter().any(|c| c == key); - if ui.checkbox(&mut on, label).changed() { + if crate::widgets::labelled_checkbox(ui, &mut on, label).changed() { set_column(&mut app.ui.project_columns, key, on); } } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2649a38ef..9939b56fa 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -402,7 +402,7 @@ fn text_section( for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() { let ry = y + i as f32 * 24.0; let cr = Rect::from_min_size(pos2(x0, ry + 2.0), vec2(14.0, 14.0)); - if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("props-apply", key))).clicked() { + if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("props-apply", key))).changed() { let k = if key == "fill" { "applyFill" } else { "applyStroke" }; set(actions, json!({k: !on})); } diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index e07efe69e..539fb2af1 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -463,7 +463,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Notify (sound + notification when the queue finishes). let notify = app.session.project.render_prefs.notify; let nb = Rect::from_min_size(pos2(render_b.min.x - 8.0 - 2.0 * bw - 8.0, render_b.min.y + 4.0), vec2(18.0, 18.0)); - if widgets::checkbox(ui, nb, notify, &t, egui::Id::new("rq-notify")).clicked() { + if widgets::checkbox(ui, nb, notify, &t, egui::Id::new("rq-notify")).changed() { actions.push(("renderQueue.setNotify", json!({"notify": !notify}))); } p.text(pos2(nb.max.x + 5.0, nb.center().y), Align2::LEFT_CENTER, tr("Notify"), Tokens::ui(11.5), t.text_dim); @@ -589,7 +589,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&aid("twirl"), tw, "Twirl"); let cb = Rect::from_center_size(pos2(cx_render + 20.0, row.center().y), vec2(18.0, 18.0)); - if widgets::checkbox(ui, cb, it.render, &t, egui::Id::new(("rq-cb", it.id))).clicked() && !rendering { + if ui.add_enabled_ui(!rendering, |ui| widgets::checkbox(ui, cb, it.render, &t, egui::Id::new(("rq-cb", it.id)))).inner.changed() && !rendering { actions.push(("renderQueue.setRender", json!({"item": it.id, "render": !it.render}))); } app.auto.add(&aid("render"), cb, if it.render { "Render: on" } else { "Render: off" }); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index b2d2ca435..77eb7e7c0 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -106,7 +106,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value match kind { Kind::Bool => { let mut b = v.as_bool().unwrap_or(false); - let r = ui.checkbox(&mut b, tr(label)); + let r = crate::widgets::labelled_checkbox(ui, &mut b, tr(label)); if r.changed() { acts.push(Act::Set(key.into(), json!(b))); } diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index ae2294f1f..fd5e4e17b 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -188,7 +188,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() { let fx = x0 + i as f32 * (w / 2.0); let cr = Rect::from_min_size(pos2(fx, y + 3.0), vec2(14.0, 14.0)); - if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-apply", key))).clicked() && enabled { + if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-apply", key)))).inner.changed() && enabled { actions.push(json!({if key == "fill" { "applyFill" } else { "applyStroke" }: !on})); } app.auto.add(&format!("character.{key}.enabled"), cr, label); @@ -240,7 +240,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { y += 32.0; // Ligatures. let lr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0)); - if widgets::checkbox(ui, lr, doc.ligatures, &t, egui::Id::new("char-ligatures")).clicked() && enabled { + if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, lr, doc.ligatures, &t, egui::Id::new("char-ligatures"))).inner.changed() && enabled { actions.push(json!({"ligatures": !doc.ligatures})); } app.auto.add("character.ligatures", lr, "Ligatures"); @@ -264,7 +264,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .enumerate() { let cr = Rect::from_min_size(pos2(x0 + i as f32 * 120.0, y), vec2(14.0, 14.0)); - if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-vert", key))).clicked() && enabled { + if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-vert", key)))).inner.changed() && enabled { actions.push(json!({key: !on})); } app.auto.add(&format!("character.{key}"), cr, label); @@ -544,7 +544,7 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } y += 30.0; let hr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0)); - if widgets::checkbox(ui, hr, doc.hanging_punctuation, &t, egui::Id::new("para-hanging")).clicked() && enabled { + if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, hr, doc.hanging_punctuation, &t, egui::Id::new("para-hanging"))).inner.changed() && enabled { actions.push(json!({"hangingPunctuation": !doc.hanging_punctuation})); } app.auto.add("paragraph.hangingPunctuation", hr, "Roman Hanging Punctuation"); @@ -629,17 +629,15 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: ui.set_width(rect.width().max(180.0)); // Filter row: a checkbox that keeps only the variable fonts. let filter_h = 22.0; - let row = ui.add_sized([ui.available_width(), filter_h], egui::Button::selectable(false, "")); - let box_rect = Rect::from_min_size(row.rect.min, vec2(18.0, filter_h)); - let label_rect = Rect::from_min_max(pos2(box_rect.max.x + 2.0, row.rect.min.y), row.rect.max); - let boxed = widgets::checkbox(ui, box_rect, only_variable, &t, id.with("variable-check")); - ui.painter().text(label_rect.left_center(), Align2::LEFT_CENTER, tr("Variable fonts only"), Tokens::ui(11.5), t.text); + let row = ui.add_sized([ui.available_width(), filter_h], widgets::DragCheckbox::new(&mut only_variable, tr("Variable fonts only"))); app.auto.add(&format!("{auto}.variable"), row.rect, "Variable fonts only"); - let toggled = row.clicked() || boxed.clicked(); + let toggled = row.changed(); if toggled { - only_variable = !only_variable; ui.data_mut(|d| d.insert_temp(variable_id, only_variable)); - // Typing goes on into the search field. + } + // The native checkbox can take focus on release after changing on press. + // Typing goes on into the search field after either event. + if toggled || row.clicked() { ui.memory_mut(|m| m.request_focus(search_id)); } let query = state.query.trim().to_lowercase(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..79bfe7174 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2146,7 +2146,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("layer.setBlendMode".into(), json!({"layers": [layer.id.0], "mode": opts[i]}))); } let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0)); - if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::new(("pt", layer.id.0))).clicked() { + if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::new(("pt", layer.id.0))).changed() { actions.push(("layer.setSwitch".into(), json!({"layers": [layer.id.0], "switch": "preserveTransparency"}))); } // Track matte: layer picker + kind. @@ -2476,7 +2476,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "mode": opts[i]}))); } let ir = Rect::from_min_size(pos2(mr.max.x + 8.0, cy - 8.0), vec2(16.0, 16.0)); - if widgets::checkbox(ui, ir, inverted, &t, egui::Id::new(("minv", uid))).clicked() { + if widgets::checkbox(ui, ir, inverted, &t, egui::Id::new(("minv", uid))).changed() { actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "inverted": !inverted}))); } lp.text(pos2(ir.max.x + 4.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Inverted"), Tokens::ui(11.0), t.text_dim); @@ -4098,7 +4098,7 @@ fn value_editor( } Value::Bool(b) => { let r = Rect::from_min_size(pos2(x, at.y - 8.0), vec2(16.0, 16.0)); - if widgets::checkbox(ui, r, *b, &t, egui::Id::new(("cb", uid))).clicked() { + if widgets::checkbox(ui, r, *b, &t, egui::Id::new(("cb", uid))).changed() { set(actions, json!(!b)); } app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name); diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs index bd54625b8..aa537ff8c 100644 --- a/crates/ui-egui/src/panels/tracker.rs +++ b/crates/ui-egui/src/panels/tracker.rs @@ -246,10 +246,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ("scale", "Scale", settings.as_ref().is_some_and(|s| s.scale)), ] { let cr = Rect::from_min_size(pos2(xx, y + 2.0), vec2(16.0, 16.0)); - let resp = widgets::checkbox(ui, cr, on && transformish, &t, egui::Id::new(("tracker-cb", key))); + let resp = ui.add_enabled_ui(transformish, |ui| widgets::checkbox(ui, cr, on && transformish, &t, egui::Id::new(("tracker-cb", key)))).inner; app.auto.add(&format!("tracker.{key}"), cr, text); p.text(pos2(cr.max.x + 4.0, cr.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), if transformish { t.text } else { t.text_faint }); - if resp.clicked() && transformish { + if resp.changed() && transformish { run(app, &ctx, "track.setType", with(json!({ key: !on }))); } xx += 84.0; @@ -506,20 +506,20 @@ pub fn options_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) ui.add_space(8.0); ui.label(egui::RichText::new(crate::i18n::tr("Process Before Match")).font(Tokens::medium(12.5))); ui.horizontal(|ui| { - let r = ui.checkbox(&mut d.blur_on, crate::i18n::tr("Blur")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.blur_on, crate::i18n::tr("Blur")); app.auto.add("dialog.trackOptions.blurOn", r.rect, "Blur"); let r = ui.add_enabled(d.blur_on, crate::widgets::drag_value(egui::DragValue::new(&mut d.opts.blur).speed(0.1).range(0.5..=50.0).suffix(" pixels"))); app.auto.add("dialog.trackOptions.blur", r.rect, "Blur pixels"); - let r = ui.checkbox(&mut d.opts.enhance, crate::i18n::tr("Enhance")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.enhance, crate::i18n::tr("Enhance")); app.auto.add("dialog.trackOptions.enhance", r.rect, "Enhance"); }); ui.add_space(8.0); - let r = ui.checkbox(&mut d.opts.subpixel, crate::i18n::tr("Subpixel Positioning")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.subpixel, crate::i18n::tr("Subpixel Positioning")); app.auto.add("dialog.trackOptions.subpixel", r.rect, "Subpixel Positioning"); - let r = ui.checkbox(&mut d.opts.adapt_every_frame, crate::i18n::tr("Adapt Feature On Every Frame")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.adapt_every_frame, crate::i18n::tr("Adapt Feature On Every Frame")); app.auto.add("dialog.trackOptions.adaptEveryFrame", r.rect, "Adapt Feature On Every Frame"); - let r = ui.checkbox(&mut d.opts.track_shape, crate::i18n::tr("Follow Feature Rotation and Scale")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.track_shape, crate::i18n::tr("Follow Feature Rotation and Scale")); app.auto.add("dialog.trackOptions.trackShape", r.rect, "Follow Feature Rotation and Scale"); ui.add_space(8.0); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c005224d0..1ab352730 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -382,9 +382,96 @@ pub fn text_button(ui: &mut Ui, rect: Rect, text: &str, primary: bool, t: &Token resp } -/// Checkbox in the AE look (small square). +/// A primary-button gesture shared by custom and labelled checkboxes in this context. +#[derive(Clone, Default)] +struct CheckboxDrag { + press_frame: u64, + target: Option, + visited: std::collections::HashSet, +} + +/// Returns the requested value. Mixed checkboxes (`None`) and disabled controls are +/// skipped. Each checkbox is visited once, so crossing it again cannot toggle it back. +fn drag_toggle(ui: &Ui, response: &Response, value: Option) -> Option { + let (pressed, down, released) = ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down(), i.pointer.primary_released())); + let frame = ui.ctx().cumulative_frame_nr(); + // clicked() reads Context input; do not call it while Context data is locked. + let clicked = response.clicked(); + ui.data_mut(|data| { + let drag = data.get_temp_mut_or_default::(egui::Id::new("checkbox-drag")); + if (!down && !released) || (pressed && drag.press_frame != frame) { + *drag = CheckboxDrag { press_frame: frame, ..Default::default() }; + } + let on = value?; + if !response.enabled() { + return None; + } + if pressed && drag.target.is_none() && response.is_pointer_button_down_on() && response.contains_pointer() { + drag.target = Some(!on); + } + if down + && response.contains_pointer() + && let Some(target) = drag.target + { + if drag.visited.insert(response.id) && on != target { + return Some(target); + } + return None; + } + // The source already changed on press. Suppress its ordinary release click. + if released && drag.target.is_some() { + return None; + } + clicked.then_some(!on) + }) +} + +/// A labelled checkbox with the same drag behavior as the custom panel checkbox. +/// Use through `ui.add`, `ui.put`, or `ui.add_enabled` like `egui::Checkbox`. +pub struct DragCheckbox<'a> { + value: &'a mut bool, + text: egui::WidgetText, +} + +impl<'a> DragCheckbox<'a> { + pub fn new(value: &'a mut bool, text: impl Into) -> Self { + Self { value, text: text.into() } + } +} + +impl egui::Widget for DragCheckbox<'_> { + fn ui(self, ui: &mut Ui) -> Response { + let before = *self.value; + let mut response = ui.add(egui::Checkbox::new(self.value, self.text)); + // egui changes on release; our shared gesture changes on press and entry. + *self.value = before; + response.flags.remove(egui::response::Flags::CHANGED); + if response.clicked() { + ui.ctx().request_repaint(); + } + if let Some(value) = drag_toggle(ui, &response, Some(before)) { + *self.value = value; + response.mark_changed(); + ui.ctx().request_repaint(); + } + response + } +} + +/// A labelled checkbox that allocates its own rectangle. +pub fn labelled_checkbox(ui: &mut Ui, value: &mut bool, text: impl Into) -> Response { + ui.add(DragCheckbox::new(value, text)) +} + +/// Checkbox in the AE look (small square). `changed()` requests a toggle, including +/// keyboard activation and entry during a drag begun on another checkbox. pub fn checkbox(ui: &mut Ui, rect: Rect, on: bool, t: &Tokens, id: egui::Id) -> Response { - let resp = ui.interact(rect, id, Sense::click()); + let mut resp = ui.interact(rect, id, Sense::click()); + let next = drag_toggle(ui, &resp, Some(on)); + if next.is_some() { + resp.mark_changed(); + } + let on = next.unwrap_or(on); let b = Rect::from_center_size(rect.center(), vec2(12.0, 12.0)); ui.painter().rect_filled(b, 2.0, if on { t.accent } else { t.field_bg }); ui.painter().rect_stroke(b, 2.0, Stroke::new(1.0, if on { t.accent } else { t.field_border }), StrokeKind::Inside); @@ -666,6 +753,106 @@ pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec { mod tests { use super::*; + fn checkbox_frame(ctx: &egui::Context, values: &mut [Option; 5], events: Vec) { + let t = Tokens::for_kind(Default::default()); + let input = egui::RawInput { events, ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + for (index, value) in values.iter_mut().enumerate() { + let rect = Rect::from_min_size(pos2(20.0, 20.0 + index as f32 * 30.0), vec2(120.0, 20.0)); + ui.add_enabled_ui(index != 3, |ui| { + if index == 4 { + if let Some(on) = value { + ui.put(rect, DragCheckbox::new(on, crate::i18n::tr("Grid"))); + } + } else if let Some(on) = value { + if checkbox(ui, rect, *on, &t, egui::Id::new(index)).changed() { + *on = !*on; + } + } else { + let response = ui.interact(rect, egui::Id::new(index), Sense::click()); + assert_eq!(drag_toggle(ui, &response, None), None); + } + }); + } + }); + output.textures_delta.clear(); + } + + fn checkbox_pointer(y: f32, pressed: Option) -> Vec { + let pos = pos2(28.0, y); + let mut events = vec![egui::Event::PointerMoved(pos)]; + if let Some(pressed) = pressed { + events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + events + } + + #[test] + fn checkbox_drag_sets_one_state_skips_mixed_and_disabled_and_ends_on_release() { + let ctx = egui::Context::default(); + let mut values = [Some(false), Some(true), None, Some(false), Some(false)]; + checkbox_frame(&ctx, &mut values, vec![]); + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(true))); + assert_eq!(values[0], Some(true)); + // An already-matching neighbour must not invert, even after revisiting. + for y in [58.0, 88.0, 118.0, 148.0, 58.0, 28.0] { + checkbox_frame(&ctx, &mut values, checkbox_pointer(y, None)); + } + assert_eq!(values, [Some(true), Some(true), None, Some(false), Some(true)]); + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false))); + assert_eq!(values[0], Some(true), "release must not toggle the source again"); + // Begin on the labelled wrapper and drag back into custom checkboxes. + checkbox_frame(&ctx, &mut values, checkbox_pointer(148.0, Some(true))); + assert_eq!(values[4], Some(false)); + for y in [58.0, 28.0] { + checkbox_frame(&ctx, &mut values, checkbox_pointer(y, None)); + } + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false))); + assert_eq!(values, [Some(false), Some(false), None, Some(false), Some(false)]); + checkbox_frame(&ctx, &mut values, checkbox_pointer(58.0, None)); + assert_eq!(values[1], Some(false), "hover after release must not paint"); + } + + #[test] + fn checkbox_click_and_keyboard_toggle_once_and_drag_must_start_on_a_checkbox() { + let ctx = egui::Context::default(); + let mut values = [Some(false), Some(false), None, Some(false), Some(false)]; + checkbox_frame(&ctx, &mut values, vec![]); + // Pressing outside the checkboxes must not start a gesture on entry. + checkbox_frame(&ctx, &mut values, checkbox_pointer(200.0, Some(true))); + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, None)); + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false))); + assert_eq!(values[0], Some(false)); + for source_y in [88.0, 118.0] { + checkbox_frame(&ctx, &mut values, checkbox_pointer(source_y, Some(true))); + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, None)); + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false))); + assert_eq!(values[0], Some(false), "mixed and disabled sources cannot begin a drag"); + } + for y in [28.0, 148.0] { + checkbox_frame(&ctx, &mut values, checkbox_pointer(y, Some(true))); + checkbox_frame(&ctx, &mut values, checkbox_pointer(y, Some(false))); + } + assert_eq!(values[0], Some(true)); + assert_eq!(values[4], Some(true)); + ctx.memory_mut(|m| m.request_focus(egui::Id::new(0usize))); + checkbox_frame( + &ctx, + &mut values, + vec![egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ); + assert_eq!(values[0], Some(false), "keyboard activation still works"); + // Fast clicks may deliver press and release together in one input frame. + // This also exercises clicked() without an active drag (outside the data lock). + for (index, y) in [(0, 28.0), (4, 148.0)] { + let before = values[index]; + let mut events = checkbox_pointer(y, Some(true)); + events.extend(checkbox_pointer(y, Some(false))); + checkbox_frame(&ctx, &mut values, events); + assert_eq!(values[index], before.map(|on| !on)); + } + } + /// #488: an inline editor selects its whole text only as it opens. Should it be without the /// keyboard for a frame later (an input method, another handler), it takes the keyboard back /// with the caret where the user put it instead of selecting everything again, which made diff --git a/crates/ui-egui/tests/ui_checkbox_drag.rs b/crates/ui-egui/tests/ui_checkbox_drag.rs new file mode 100644 index 000000000..ba65e54dc --- /dev/null +++ b/crates/ui-egui/tests/ui_checkbox_drag.rs @@ -0,0 +1,54 @@ +//! #1190: the shared drag gesture also reaches real dialog controls. +use effectcraft_engine::Session; +use effectcraft_ui_egui::{EffectcraftApp, menus}; +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable as _}; +use serde_json::json; + +fn pointer(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, pressed: Option) { + h.event(Event::PointerMoved(at)); + h.step(); + if let Some(pressed) = pressed { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); +} + +#[test] +fn view_options_drag_checks_and_unchecks_neighbours() { + let mut session = Session::default(); + session.prefs.disk.disk_cache_enabled = false; + session.execute("comp.new", json!({"width":32,"height":32,"duration":1})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1200.0, 900.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.state_mut().ui.viewer.rulers = false; + h.state_mut().ui.viewer.guides = true; + h.state_mut().ui.viewer.grid = false; + h.run_steps(3); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "view.options", json!({})).unwrap(); + h.run_steps(3); + let rulers = h.get_by_label("Rulers").rect().center(); + let guides = h.get_by_label("Guides").rect().center(); + let grid = h.get_by_label("Grid").rect().center(); + pointer(&mut h, rulers, Some(true)); + assert!(h.state().ui.viewer.rulers); + pointer(&mut h, guides, None); + assert!(h.state().ui.viewer.guides, "an already-checked neighbour stays checked"); + pointer(&mut h, grid, None); + assert!(h.state().ui.viewer.grid); + pointer(&mut h, rulers, None); + pointer(&mut h, rulers, Some(false)); + assert!(h.state().ui.viewer.rulers, "revisiting and releasing does not undo the change"); + if let Ok(path) = std::env::var("CHECKBOX_REVIEW_PNG") { + h.render().unwrap().save(path).unwrap(); + } + pointer(&mut h, grid, Some(true)); + pointer(&mut h, guides, None); + pointer(&mut h, rulers, None); + pointer(&mut h, rulers, Some(false)); + let viewer = &h.state().ui.viewer; + assert!(!viewer.rulers && !viewer.guides && !viewer.grid); + pointer(&mut h, guides, None); + assert!(!h.state().ui.viewer.guides, "hover after release does not change a checkbox"); +} From 5b452a90fe6b617adaa3aa42ec84e59b9bc441c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 12:40:09 +0000 Subject: [PATCH 3/5] #1190: find the pressed checkbox and the ones crossed from the frame's ordered pointer events Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U --- crates/ui-egui/src/widgets.rs | 136 +++++++++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index cc6da8ebb..f8e2933ab 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -421,15 +421,50 @@ struct CheckboxDrag { press_frame: u64, target: Option, visited: std::collections::HashSet, + /// Checkboxes the pointer crossed in the frame of the press, before the checkbox it pressed on + /// had been drawn (so the value to set was not known): they take it in the next frame. + pending: std::collections::HashSet, +} + +/// A primary-button event of the frame, in the order it happened. +#[derive(Clone, Copy, PartialEq)] +enum Step { + Press, + Move, + Release, +} + +/// The frame's primary-button events in order, each with its position. egui hit-tests the whole +/// frame at the last pointer position, so a press followed by a move in one frame would otherwise +/// be taken for a press where the pointer ended. +fn pointer_steps(ui: &Ui) -> Vec<(Step, egui::Pos2)> { + ui.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::PointerMoved(pos) => Some((Step::Move, *pos)), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, .. } => { + Some((if *pressed { Step::Press } else { Step::Release }, *pos)) + } + _ => None, + }) + .collect() + }) } /// Returns the requested value. Mixed checkboxes (`None`) and disabled controls are -/// skipped. Each checkbox is visited once, so crossing it again cannot toggle it back. +/// skipped. Each checkbox is visited once, so crossing it again cannot toggle it back. The +/// checkbox a press lands on, and the ones the pointer passes over after it, are found from the +/// frame's ordered pointer events, not from where the pointer ended. fn drag_toggle(ui: &Ui, response: &Response, value: Option) -> Option { let (pressed, down, released) = ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down(), i.pointer.primary_released())); let frame = ui.ctx().cumulative_frame_nr(); // clicked() reads Context input; do not call it while Context data is locked. let clicked = response.clicked(); + // Whether the pointer at `pos` is over this checkbox itself (not a window above it). + let over = |pos: egui::Pos2| response.rect.contains(pos) && ui.clip_rect().contains(pos) && ui.ctx().layer_id_at(pos) == Some(response.layer_id); + // Resolved before the data lock below: layer_id_at reads the Context. + let steps: Vec<(Step, bool)> = pointer_steps(ui).into_iter().map(|(step, pos)| (step, over(pos))).collect(); ui.data_mut(|data| { let drag = data.get_temp_mut_or_default::(egui::Id::unique("checkbox-drag")); if (!down && !released) || (pressed && drag.press_frame != frame) { @@ -439,10 +474,43 @@ fn drag_toggle(ui: &Ui, response: &Response, value: Option) -> Option { + press_seen = true; + if drag.target.is_none() && over { + drag.target = Some(!on); + drag.visited.insert(response.id); + return Some(!on); + } + } + Step::Move if press_seen || down => match drag.target { + Some(target) if over => { + if drag.visited.insert(response.id) && on != target { + return Some(target); + } + } + None if press_seen && over => { + drag.pending.insert(response.id); + } + _ => {} + }, + _ => {} + } + } + // The pointer held still over this checkbox in a later frame of the gesture. + if down + && !pressed && response.contains_pointer() && let Some(target) = drag.target { @@ -971,6 +1039,66 @@ mod tests { assert_eq!(values[1], Some(false), "hover after release must not paint"); } + fn button(y: f32, pressed: bool) -> Vec { + let pos = pos2(28.0, y); + vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }] + } + + fn moved(y: f32) -> egui::Event { + egui::Event::PointerMoved(pos2(28.0, y)) + } + + /// A press and the moves after it can arrive in one frame (a quick flick): the press belongs to + /// the checkbox it landed on, not to the one the pointer ended over, and the ones passed over + /// follow the pressed one's new state, whether they are drawn before it or after it. + #[test] + fn a_press_and_a_move_in_one_frame_start_on_the_checkbox_pressed() { + // Rows are 30 apart, from y = 28; index 3 is disabled. + let (a, b) = (28.0, 58.0); + // Pressed on the first, moved to the second (drawn after it). + let ctx = egui::Context::default(); + let mut values = [Some(false); 5]; + values[2] = None; + checkbox_frame(&ctx, &mut values, vec![]); + let mut events = vec![moved(a)]; + events.extend(button(a, true)); + events.push(moved(b)); + checkbox_frame(&ctx, &mut values, events); + assert_eq!((values[0], values[1]), (Some(true), Some(true)), "the pressed one is the source, the one passed over follows"); + let mut events = vec![moved(b)]; + events.extend(button(b, false)); + checkbox_frame(&ctx, &mut values, events); + assert_eq!((values[0], values[1]), (Some(true), Some(true)), "release changes nothing"); + // Pressed on the second, moved to the first (drawn before it): the first takes the state in + // the next frame, while the button is still down. + let ctx = egui::Context::default(); + let mut values = [Some(false); 5]; + values[2] = None; + checkbox_frame(&ctx, &mut values, vec![]); + let mut events = vec![moved(b)]; + events.extend(button(b, true)); + events.push(moved(a)); + checkbox_frame(&ctx, &mut values, events); + assert_eq!(values[1], Some(true), "the pressed one is the source"); + checkbox_frame(&ctx, &mut values, vec![]); + assert_eq!(values[0], Some(true), "the one passed over takes the state"); + let mut events = vec![moved(a)]; + events.extend(button(a, false)); + checkbox_frame(&ctx, &mut values, events); + assert_eq!((values[0], values[1]), (Some(true), Some(true))); + // Pressed outside every checkbox, then moved onto one in the same frame: nothing starts. + let ctx = egui::Context::default(); + let mut values = [Some(false); 5]; + values[2] = None; + checkbox_frame(&ctx, &mut values, vec![]); + let mut events = vec![moved(200.0)]; + events.extend(button(200.0, true)); + events.push(moved(a)); + checkbox_frame(&ctx, &mut values, events); + checkbox_frame(&ctx, &mut values, vec![]); + assert_eq!(values, [Some(false), Some(false), None, Some(false), Some(false)], "a press outside starts no gesture"); + } + #[test] fn checkbox_click_and_keyboard_toggle_once_and_drag_must_start_on_a_checkbox() { let ctx = egui::Context::default(); From 90874c3dd2b0122740355337bd0103200111d1ce Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:28:09 +0000 Subject: [PATCH 4/5] #1190: apply checkbox crossings recorded before the target was known, even after release Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RDvxCR4xUtUfBJwkhwaFuD --- crates/ui-egui/src/widgets.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index f8e2933ab..cd00bffb1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -467,7 +467,9 @@ fn drag_toggle(ui: &Ui, response: &Response, value: Option) -> Option = pointer_steps(ui).into_iter().map(|(step, pos)| (step, over(pos))).collect(); ui.data_mut(|data| { let drag = data.get_temp_mut_or_default::(egui::Id::unique("checkbox-drag")); - if (!down && !released) || (pressed && drag.press_frame != frame) { + // A release in the press frame leaves crossings pending: keep them for the next frame. + let flushing = !drag.pending.is_empty() && drag.target.is_some() && frame <= drag.press_frame.saturating_add(1); + if (!down && !released && !flushing) || (pressed && drag.press_frame != frame) { *drag = CheckboxDrag { press_frame: frame, ..Default::default() }; } let on = value?; @@ -475,8 +477,7 @@ fn drag_toggle(ui: &Ui, response: &Response, value: Option) -> Option Date: Thu, 29 Oct 2026 14:16:36 +0000 Subject: [PATCH 5/5] #1190: a move after the release in the same frame does not carry the checkbox drag Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U --- crates/ui-egui/src/widgets.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index cd00bffb1..9c4f76e72 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -484,18 +484,26 @@ fn drag_toggle(ui: &Ui, response: &Response, value: Option) -> Option *step == Step::Release, + None => down, + }; let mut press_seen = false; for (step, over) in steps { match step { Step::Press => { press_seen = true; + held = true; if drag.target.is_none() && over { drag.target = Some(!on); drag.visited.insert(response.id); return Some(!on); } } - Step::Move if press_seen || down => match drag.target { + Step::Release => held = false, + Step::Move if held => match drag.target { Some(target) if over => { if drag.visited.insert(response.id) && on != target { return Some(target); @@ -1113,6 +1121,24 @@ mod tests { assert_eq!((values[0], values[1]), (Some(true), Some(true)), "a crossing before the release still takes the state"); } + /// A move that arrives after the release, in the frame of the click, is the pointer travelling without the button: + /// it must not carry the gesture onto the checkbox it ends over. + #[test] + fn a_move_after_the_release_in_the_same_frame_paints_nothing() { + let (a, b) = (28.0, 58.0); + let ctx = egui::Context::default(); + let mut values = [Some(false); 5]; + values[2] = None; + checkbox_frame(&ctx, &mut values, vec![]); + let mut events = vec![moved(a)]; + events.extend(button(a, true)); + events.extend(button(a, false)); + events.push(moved(b)); + checkbox_frame(&ctx, &mut values, events); + checkbox_frame(&ctx, &mut values, vec![]); + assert_eq!((values[0], values[1]), (Some(true), Some(false)), "the clicked one changes, the one moved onto afterwards does not"); + } + #[test] fn checkbox_click_and_keyboard_toggle_once_and_drag_must_start_on_a_checkbox() { let ctx = egui::Context::default();