Skip to main content

lumen_primitives/
checkbox.rs

1//! `<checkbox>` behavior: box + label control on top of the shared
2//! [`Toggleable`] machinery, with a tri-state `indeterminate` mode.
3//!
4//! The markup parser desugars `<checkbox label="...">` into a row (tag
5//! `checkbox`, carrying [`Toggleable`] + [`CheckboxStyle`]) with two
6//! real child elements - a `.checkbox-box` tile (carrying
7//! [`CheckboxBox`]) and a `.checkbox-label` label - so every visual is
8//! CSS-reachable through the skins:
9//!
10//! ```css
11//! .checkbox-box     { width: 18; height: 18; bg: ...; border: ...; }
12//! checkbox:checked  { bg: var(--lumen-accent); }  /* box fill when on */
13//! checkbox:focus    { outline: 2 var(--lumen-accent); }
14//! ```
15//!
16//! Behavior reuse:
17//! - Click anywhere on the row (box, label, gap) toggles -
18//!   [`crate::controls::flip_toggle_on_click`] resolves child hits to
19//!   the ancestor [`Toggleable`], exactly like `<toggle>`.
20//! - Space on the focused checkbox toggles -
21//!   `lumen_input::activate_focused_on_enter`'s press-and-release FSM
22//!   emits the same `ClickEvent`.
23//! - `bind-checked` / `on_toggle(id, checked)` come with `Toggleable`.
24//!
25//! Tri-state: `indeterminate="true"` inserts the [`Indeterminate`]
26//! marker. The box renders a dash while it's present; the first user
27//! toggle (click / Space - anything that fires
28//! [`crate::controls::ToggleChanged`]) clears it, mirroring the web's
29//! `indeterminate` IDL attribute and Qt's `PartiallyChecked`. Script
30//! `bind-checked` writes do not clear it.
31
32use bevy_ecs::message::MessageReader;
33use bevy_ecs::prelude::*;
34use lumen_core::components::{Color, TextContent, Visuals};
35use lumen_core::style_overrides::{StyleOverrides, StyleOwner};
36
37use crate::held::{hold_fill, release_fill};
38use lumen_core::prelude::*;
39
40use crate::controls::ToggleChanged;
41
42/// Glyph shown in the box while checked.
43const MARK_CHECKED: &str = "\u{2713}"; // check mark
44/// Glyph shown in the box while indeterminate.
45const MARK_INDETERMINATE: &str = "\u{2013}"; // en dash
46
47/// Per-checkbox fill for the checked / indeterminate box state,
48/// resolved at spawn from `checkbox:checked { bg: ... }` (routed through
49/// `Attributes::checked_bg`) with the accent fallback.
50#[derive(Component, Clone, Copy, Debug, PartialEq)]
51pub struct CheckboxStyle {
52    /// Box fill while checked or indeterminate.
53    pub checked_bg: Color,
54}
55
56impl Default for CheckboxStyle {
57    fn default() -> Self {
58        Self {
59            checked_bg: crate::controls::TOGGLE_CHECKED_BG,
60        }
61    }
62}
63
64/// Marker on the `.checkbox-box` child tile. Its checked fill is held in
65/// [`StyleOverrides`] over the box's own.
66#[derive(Component, Clone, Copy, Debug, Default)]
67#[require(StyleOverrides)]
68pub struct CheckboxBox;
69
70impl StyleOwner for CheckboxBox {
71    const RANK: u8 = 20;
72}
73
74/// Tri-state marker on the checkbox ROOT: renders a dash regardless of
75/// `checked` until the first user toggle removes it.
76#[derive(Component, Clone, Copy, Debug, Default)]
77pub struct Indeterminate;
78
79/// Plugin: registers the visual sync + indeterminate-clear systems.
80/// The toggle behavior itself ships with [`crate::ControlsPlugin`].
81pub struct CheckboxPlugin;
82
83impl Plugin for CheckboxPlugin {
84    fn build(self, app: &mut App) {
85        // Clear runs after the click consumer that emits ToggleChanged
86        // so a same-tick user toggle clears the dash immediately; the
87        // visual sync runs last.
88        app.add_systems(
89            TickStage::Systems,
90            clear_indeterminate_on_user_toggle.after(crate::controls::flip_toggle_on_click),
91        );
92        app.add_systems(
93            TickStage::Systems,
94            sync_checkbox_visuals.after(clear_indeterminate_on_user_toggle),
95        );
96    }
97}
98
99/// First user toggle clears [`Indeterminate`] (web/Qt tri-state
100/// contract). Listens to [`ToggleChanged`] - fired only by the user
101/// input path, not by `bind-checked` signal pulls.
102pub fn clear_indeterminate_on_user_toggle(
103    mut commands: Commands,
104    mut changes: MessageReader<ToggleChanged>,
105    indeterminate: Query<(), With<Indeterminate>>,
106) {
107    for ev in changes.read() {
108        if indeterminate.contains(ev.entity) {
109            commands.entity(ev.entity).remove::<Indeterminate>();
110        }
111    }
112}
113
114/// Keep every `.checkbox-box` child in step with its parent state:
115///
116/// - fill: `checked_bg` while checked OR indeterminate, the box's own
117///   fill otherwise;
118/// - mark glyph: `MARK_CHECKED` checked, `MARK_INDETERMINATE` indeterminate,
119///   empty otherwise.
120///
121/// Runs every tick (marker components move without a single Changed
122/// filter to hang off); writes only on actual diffs so change
123/// detection stays quiet on idle frames.
124#[allow(clippy::type_complexity)]
125pub fn sync_checkbox_visuals(
126    mut commands: Commands,
127    parents_q: Query<(&Toggleable, Option<&Indeterminate>, &CheckboxStyle)>,
128    mut boxes: Query<
129        (
130            Entity,
131            &ChildOf,
132            &mut Visuals,
133            &mut StyleOverrides,
134            Option<&mut TextContent>,
135        ),
136        With<CheckboxBox>,
137    >,
138) {
139    for (box_e, child_of, mut vis, mut overrides, text) in &mut boxes {
140        let Ok((t, indeterminate, style)) = parents_q.get(child_of.parent()) else {
141            continue;
142        };
143        let indeterminate = indeterminate.is_some();
144        // The checked fill is held over the box's own (CSS-authored) fill,
145        // which is what the box shows again unchecked.
146        if t.checked || indeterminate {
147            hold_fill::<CheckboxBox>(&mut overrides, &mut vis, style.checked_bg);
148        } else {
149            release_fill::<CheckboxBox>(&mut overrides, &mut vis);
150        }
151        let mark = if indeterminate {
152            MARK_INDETERMINATE
153        } else if t.checked {
154            MARK_CHECKED
155        } else {
156            ""
157        };
158        match text {
159            Some(mut tc) => {
160                if tc.0 != mark {
161                    tc.0 = mark.to_string();
162                    commands.entity(box_e).insert(DirtyLayout);
163                }
164            }
165            None => {
166                if !mark.is_empty() {
167                    commands
168                        .entity(box_e)
169                        .insert((TextContent(mark.to_string()), DirtyLayout));
170                }
171            }
172        }
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use bevy_ecs::message::Messages;
180    use bevy_ecs::system::RunSystemOnce;
181
182    fn spawn_checkbox(world: &mut World, checked: bool, indeterminate: bool) -> (Entity, Entity) {
183        let root = world
184            .spawn((Toggleable { checked }, CheckboxStyle::default()))
185            .id();
186        if indeterminate {
187            world.entity_mut(root).insert(Indeterminate);
188        }
189        let bx = world
190            .spawn((
191                CheckboxBox,
192                Visuals {
193                    fill: Some(Fill::Solid(Color::rgb(0.2, 0.2, 0.2))),
194                    ..Default::default()
195                },
196                ChildOf(root),
197            ))
198            .id();
199        (root, bx)
200    }
201
202    fn box_fill(world: &World, bx: Entity) -> Option<Color> {
203        world
204            .get::<Visuals>(bx)
205            .and_then(|v| v.fill.as_ref().and_then(Fill::as_solid))
206    }
207
208    fn box_mark(world: &World, bx: Entity) -> String {
209        world
210            .get::<TextContent>(bx)
211            .map(|t| t.0.clone())
212            .unwrap_or_default()
213    }
214
215    #[test]
216    fn checked_box_paints_accent_and_mark() {
217        let mut world = World::new();
218        let (_, bx) = spawn_checkbox(&mut world, true, false);
219        world.run_system_once(sync_checkbox_visuals).unwrap();
220        assert_eq!(
221            box_fill(&world, bx),
222            Some(CheckboxStyle::default().checked_bg)
223        );
224        assert_eq!(box_mark(&world, bx), MARK_CHECKED);
225    }
226
227    #[test]
228    fn unchecked_box_keeps_resting_fill_and_no_mark() {
229        let mut world = World::new();
230        let (_, bx) = spawn_checkbox(&mut world, false, false);
231        world.run_system_once(sync_checkbox_visuals).unwrap();
232        assert_eq!(box_fill(&world, bx), Some(Color::rgb(0.2, 0.2, 0.2)));
233        assert_eq!(box_mark(&world, bx), "");
234    }
235
236    #[test]
237    fn indeterminate_renders_dash_even_when_unchecked() {
238        let mut world = World::new();
239        let (_, bx) = spawn_checkbox(&mut world, false, true);
240        world.run_system_once(sync_checkbox_visuals).unwrap();
241        assert_eq!(box_mark(&world, bx), MARK_INDETERMINATE);
242        assert_eq!(
243            box_fill(&world, bx),
244            Some(CheckboxStyle::default().checked_bg),
245            "indeterminate paints the checked fill behind the dash"
246        );
247    }
248
249    #[test]
250    fn toggle_round_trip_restores_resting_fill() {
251        let mut world = World::new();
252        let (root, bx) = spawn_checkbox(&mut world, false, false);
253        world.run_system_once(sync_checkbox_visuals).unwrap();
254        world.get_mut::<Toggleable>(root).unwrap().checked = true;
255        world.run_system_once(sync_checkbox_visuals).unwrap();
256        assert_eq!(box_mark(&world, bx), MARK_CHECKED);
257        world.get_mut::<Toggleable>(root).unwrap().checked = false;
258        world.run_system_once(sync_checkbox_visuals).unwrap();
259        assert_eq!(
260            box_fill(&world, bx),
261            Some(Color::rgb(0.2, 0.2, 0.2)),
262            "resting fill captured before the first swap is restored"
263        );
264        assert_eq!(box_mark(&world, bx), "");
265    }
266
267    #[test]
268    fn user_toggle_clears_indeterminate() {
269        let mut world = World::new();
270        world.init_resource::<Messages<ToggleChanged>>();
271        let (root, _) = spawn_checkbox(&mut world, false, true);
272        world
273            .resource_mut::<Messages<ToggleChanged>>()
274            .write(ToggleChanged {
275                entity: root,
276                checked: true,
277            });
278        world
279            .run_system_once(clear_indeterminate_on_user_toggle)
280            .unwrap();
281        assert!(
282            world.get::<Indeterminate>(root).is_none(),
283            "first user toggle drops the tri-state marker"
284        );
285    }
286
287    #[test]
288    fn signal_pull_does_not_clear_indeterminate() {
289        // `apply_checked_bindings` mutates Toggleable directly without
290        // emitting ToggleChanged - the dash must survive.
291        let mut world = World::new();
292        world.init_resource::<Messages<ToggleChanged>>();
293        let (root, _) = spawn_checkbox(&mut world, false, true);
294        world.get_mut::<Toggleable>(root).unwrap().checked = true;
295        world
296            .run_system_once(clear_indeterminate_on_user_toggle)
297            .unwrap();
298        assert!(
299            world.get::<Indeterminate>(root).is_some(),
300            "non-user writes keep the tri-state marker"
301        );
302    }
303}