1use 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
42const MARK_CHECKED: &str = "\u{2713}"; const MARK_INDETERMINATE: &str = "\u{2013}"; #[derive(Component, Clone, Copy, Debug, PartialEq)]
51pub struct CheckboxStyle {
52 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#[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#[derive(Component, Clone, Copy, Debug, Default)]
77pub struct Indeterminate;
78
79pub struct CheckboxPlugin;
82
83impl Plugin for CheckboxPlugin {
84 fn build(self, app: &mut App) {
85 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
99pub 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#[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 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 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}