From bb4e55ebb467d590bccfd858290c93cf4649e516 Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Mon, 12 Oct 2026 23:11:18 -0600 Subject: [PATCH] Checkboxes: a pressed box shows it, as in Photoshop (#4476) (#4553) * Checkboxes: a pressed box shows it, as in Photoshop (#4476) Pressing a checkbox, its label or a Brush Settings section row gave no feedback until the release. While pressed, an unchecked box now darkens a little and a checked one turns a little less opaque. widgets::check_box paints the box for both the shared checkbox and the Brush Settings section list (which drew its own). The section boxes now get the field fill like every other checkbox. Tests: section_boxes_show_a_pressed_state, checkboxes_show_a_pressed_state_from_their_label. Co-Authored-By: Claude Opus 5.5 * Checkboxes: checkbox-only Brush Settings rows show the pressed state too main made the rows without settings (Wet Edges, Build-up, ...) one checkbox across the row; a press anywhere on such a row presses its box. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/brush_panel.rs | 13 ++-- crates/ui-egui/src/brush_panel_tests.rs | 98 +++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 43 ++++++++--- 3 files changed, 134 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index e388b60f51..d91e6ff459 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -295,15 +295,12 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting }); resp }); - if *flag { - ui.painter().rect_filled(br, 2.0, t.accent); - let (a, m, c) = (br.left_center() + vec2(3.0, 0.5), br.center_bottom() + vec2(-1.0, -3.5), br.right_top() + vec2(-3.0, 3.5)); - ui.painter().line_segment([a, m], Stroke::new(1.6, Color32::WHITE)); - ui.painter().line_segment([m, c], Stroke::new(1.6, Color32::WHITE)); - } else { - ui.painter().rect_stroke(br, 2.0, Stroke::new(1.2, t.text_faint), egui::StrokeKind::Inside); - } let on_lock = resp.interact_pointer_pos().is_some_and(|p| p.x > r.right() - 22.0); + // A press on the row (but not its lock) presses the box too: a release there + // turns the section on, or toggles a checkbox-only row (#4476). + let pressed = box_resp.as_ref().is_some_and(|b| b.is_pointer_button_down_on()) || (resp.is_pointer_button_down_on() && !on_lock); + let hovered = box_resp.as_ref().is_some_and(|b| b.hovered()) || (toggle_only && resp.hovered()); + widgets::check_box(ui.painter(), br, *flag, hovered, pressed, &t); if box_resp.is_some_and(|resp| resp.clicked()) || (toggle_only && resp.clicked() && !on_lock) { *flag = !*flag; } else if !toggle_only && resp.clicked() && !on_lock { diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 7723bede12..133d849930 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -1285,3 +1285,101 @@ fn brushes_button_at_top_of_brush_settings_opens_brushes_panel() { assert!(h.state().ui.panels.brush_settings); assert_eq!(h.state().ui.brush_tab, 1); } + +/// The fill painted for the check box in `rect` in the last frame (the topmost filled rect there). +fn box_fill(h: &Harness<'_, PhotocraftApp>, rect: egui::Rect) -> Option { + h.output() + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Rect(r) if (r.rect.min - rect.min).length() < 1.0 && (r.rect.max - rect.max).length() < 1.0 && r.fill != Color32::TRANSPARENT => { + Some(r.fill) + } + _ => None, + }) + .next_back() +} + +/// #4476: while the mouse is down on a section's row or its box, the box shows it's pressed, as +/// in Photoshop: an unchecked box darkens, a checked one turns less opaque. The release then +/// acts as before. +#[test] +fn section_boxes_show_a_pressed_state() { + use egui_kittest::kittest::Queryable; + let mut h = app_harness(); + h.key_press(egui::Key::F5); + h.run_steps(3); + let t = Tokens::get(&h.ctx); + let boxr = h.get_by_label("Enable Shape Dynamics").rect(); + let row = h.get_by_label("Shape Dynamics").rect(); + assert!(!h.state().session.tools.brush.shape_dynamics.enabled); + assert_eq!(box_fill(&h, boxr), Some(widgets::check_box_fill(false, false, &t))); + // Held on the row's name: the unchecked box darkens; the release turns the section on. + let at = egui::pos2(row.center().x, row.center().y); + h.drag_at(at); + h.run_steps(2); + assert_eq!(box_fill(&h, boxr), Some(widgets::check_box_fill(false, true, &t)), "pressed, unchecked"); + h.drop_at(at); + h.run_steps(3); + assert!(h.state().session.tools.brush.shape_dynamics.enabled); + assert_eq!(box_fill(&h, boxr), Some(widgets::check_box_fill(true, false, &t))); + // Held on the checked box itself: less opaque, then the release turns it off. + h.drag_at(boxr.center()); + h.run_steps(2); + assert_eq!(box_fill(&h, boxr), Some(widgets::check_box_fill(true, true, &t)), "pressed, checked"); + h.drop_at(boxr.center()); + h.run_steps(3); + assert!(!h.state().session.tools.brush.shape_dynamics.enabled); + // A checkbox-only row (no settings of its own) is one checkbox across the row: a press + // anywhere on it presses its box, and the release toggles it. + let wet = h.get_by_label("Wet Edges").rect(); + let wet_box = egui::Rect::from_center_size(egui::pos2(wet.left() + 12.0, wet.center().y), vec2(13.0, 13.0)); + let was = h.state().session.tools.brush.wet_edges; + h.drag_at(wet.center()); + h.run_steps(2); + assert_eq!(box_fill(&h, wet_box), Some(widgets::check_box_fill(was, true, &t)), "pressed checkbox-only row"); + h.drop_at(wet.center()); + h.run_steps(3); + assert_ne!(h.state().session.tools.brush.wet_edges, was); + // The four looks are distinct. + let looks = [(false, false), (false, true), (true, false), (true, true)].map(|(on, pressed)| widgets::check_box_fill(on, pressed, &t)); + for (i, a) in looks.iter().enumerate() { + assert!(looks.iter().skip(i + 1).all(|b| b != a), "{looks:?}"); + } +} + +/// The shared check box (options bars, dialogs) does the same, and pressing its label counts. +#[test] +fn checkboxes_show_a_pressed_state_from_their_label() { + use egui_kittest::kittest::Queryable; + let mut h = Harness::builder().with_size(vec2(300.0, 100.0)).build_ui_state( + |ui, on: &mut bool| { + widgets::checkbox(ui, on, "Sample All Layers"); + }, + false, + ); + h.run(); + let t = Tokens::get(&h.ctx); + let label = h.get_by_label("Sample All Layers").rect(); + let boxr = egui::Rect::from_min_size(egui::pos2(label.left() - 6.0 - 14.0, label.center().y - 7.0), vec2(14.0, 14.0)); + let fill = |h: &Harness<'_, bool>| { + h.output() + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Rect(r) if (r.rect.center() - boxr.center()).length() < 1.5 && r.rect.width() < 15.0 && r.fill != Color32::TRANSPARENT => { + Some(r.fill) + } + _ => None, + }) + .next_back() + }; + assert_eq!(fill(&h), Some(widgets::check_box_fill(false, false, &t))); + h.drag_at(label.center()); + h.run(); + assert_eq!(fill(&h), Some(widgets::check_box_fill(false, true, &t)), "pressing the label presses the box"); + h.drop_at(label.center()); + h.run(); + assert!(*h.state(), "the release checks it"); + assert_eq!(fill(&h), Some(widgets::check_box_fill(true, false, &t))); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 32f5cd457d..474bef66e4 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1603,21 +1603,12 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { ui.spacing_mut().item_spacing.x = 6.0; let (rect, resp) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::click()); let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); - let p = ui.painter(); - if *on { - p.rect_filled(rect, 2.0, t.accent); - let a = rect.left_center() + vec2(3.0, 0.5); - let b = rect.center_bottom() + vec2(-1.0, -3.5); - let c = rect.right_top() + vec2(-3.0, 3.5); - p.line_segment([a, b], Stroke::new(1.8, Color32::WHITE)); - p.line_segment([b, c], Stroke::new(1.8, Color32::WHITE)); - } else { - p.rect_filled(rect, 2.0, t.field); - p.rect_stroke(rect, 2.0, Stroke::new(1.5, if resp.hovered() { t.text_dim } else { t.text_faint }), StrokeKind::Inside); - } let l = ui .add(egui::Label::new(egui::RichText::new(tl!(label)).color(t.text_dim)).selectable(false).sense(Sense::click())) .on_hover_cursor(egui::CursorIcon::PointingHand); + // Pressing the label presses the box (#4476). + let pressed = resp.is_pointer_button_down_on() || l.is_pointer_button_down_on(); + check_box(ui.painter(), rect, *on, resp.hovered() || l.hovered(), pressed, &t); resp.union(l) }) .inner; @@ -1628,6 +1619,34 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { resp } +/// The fill of a check box: the accent when `on`, else the field colour. While it's pressed +/// (the mouse is down on it or on its label) Photoshop shows that a release will act: an +/// unchecked box darkens a little and a checked one turns a little less opaque (#4476). +pub fn check_box_fill(on: bool, pressed: bool, t: &Tokens) -> Color32 { + match (on, pressed) { + (true, false) => t.accent, + (true, true) => t.accent.gamma_multiply(0.75), + (false, false) => t.field, + (false, true) => t.field.lerp_to_gamma(Color32::BLACK, 0.18), + } +} + +/// Paint a check box in `rect`: [`check_box_fill`] with a white tick when `on`, else an +/// outline that brightens while `hovered` or `pressed`. +pub fn check_box(p: &egui::Painter, rect: Rect, on: bool, hovered: bool, pressed: bool, t: &Tokens) { + p.rect_filled(rect, 2.0, check_box_fill(on, pressed, t)); + if on { + let a = rect.left_center() + vec2(3.0, 0.5); + let b = rect.center_bottom() + vec2(-1.0, -3.5); + let c = rect.right_top() + vec2(-3.0, 3.5); + let tick = Stroke::new(rect.width() * 0.125, Color32::WHITE); + p.line_segment([a, b], tick); + p.line_segment([b, c], tick); + } else { + p.rect_stroke(rect, 2.0, Stroke::new(1.5, if hovered || pressed { t.text_dim } else { t.text_faint }), StrokeKind::Inside); + } +} + /// Small chevron for dropdowns (replaces egui's filled triangle). pub fn chevron_icon(ui: &Ui, rect: Rect, visuals: &egui::style::WidgetVisuals, _open: bool) { let c = rect.center();