Another project
1use serde::Serialize;
2
3use crate::a11y::{AccessNode, Role, Toggled};
4use crate::frame::{FrameCtx, InteractDeclaration};
5use crate::hit_test::{Interaction, Sense};
6use crate::layout::LayoutRect;
7use crate::strings::StringKey;
8use crate::widget_id::WidgetId;
9
10use super::keys::take_activation;
11use super::paint::{GlyphMark, WidgetPaint};
12use super::visuals::{Indicator, IndicatorMark, push_indicator};
13
14#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)]
15pub enum CheckboxState {
16 Unchecked,
17 Checked,
18 Indeterminate,
19}
20
21impl CheckboxState {
22 #[must_use]
23 pub const fn next(self) -> Self {
24 match self {
25 Self::Unchecked => Self::Checked,
26 Self::Checked | Self::Indeterminate => Self::Unchecked,
27 }
28 }
29
30 #[must_use]
31 pub const fn is_active(self) -> bool {
32 matches!(self, Self::Checked | Self::Indeterminate)
33 }
34}
35
36#[derive(Copy, Clone, Debug, PartialEq)]
37pub struct Checkbox {
38 pub id: WidgetId,
39 pub rect: LayoutRect,
40 pub label: StringKey,
41 pub state: CheckboxState,
42 pub disabled: bool,
43}
44
45impl Checkbox {
46 #[must_use]
47 pub const fn new(
48 id: WidgetId,
49 rect: LayoutRect,
50 label: StringKey,
51 state: CheckboxState,
52 ) -> Self {
53 Self {
54 id,
55 rect,
56 label,
57 state,
58 disabled: false,
59 }
60 }
61
62 #[must_use]
63 pub const fn disabled(self, disabled: bool) -> Self {
64 Self { disabled, ..self }
65 }
66}
67
68#[derive(Clone, Debug, PartialEq)]
69pub struct CheckboxResponse {
70 pub interaction: Interaction,
71 pub state: CheckboxState,
72 pub toggled: bool,
73 pub paint: Vec<WidgetPaint>,
74}
75
76#[must_use]
77pub fn show_checkbox(ctx: &mut FrameCtx<'_>, checkbox: Checkbox) -> CheckboxResponse {
78 let interactive = !checkbox.disabled;
79 let toggled_state = match checkbox.state {
80 CheckboxState::Unchecked => Toggled::False,
81 CheckboxState::Checked => Toggled::True,
82 CheckboxState::Indeterminate => Toggled::Mixed,
83 };
84 let interaction = ctx.interact(
85 InteractDeclaration::new(checkbox.id, checkbox.rect, Sense::INTERACTIVE)
86 .focusable(interactive)
87 .disabled(!interactive)
88 .active(checkbox.state.is_active())
89 .a11y(
90 AccessNode::new(Role::CheckBox)
91 .with_label(checkbox.label)
92 .with_disabled(!interactive)
93 .with_toggled(toggled_state),
94 ),
95 );
96 let live_focused = ctx.is_focused(checkbox.id);
97 let toggled =
98 interactive && (interaction.click() || (live_focused && take_activation(ctx.input)));
99 let next_state = if toggled {
100 checkbox.state.next()
101 } else {
102 checkbox.state
103 };
104 let paint = build_paint(ctx, &checkbox, interaction, live_focused, next_state);
105 CheckboxResponse {
106 interaction,
107 state: next_state,
108 toggled,
109 paint,
110 }
111}
112
113fn build_paint(
114 ctx: &FrameCtx<'_>,
115 checkbox: &Checkbox,
116 interaction: Interaction,
117 live_focused: bool,
118 state: CheckboxState,
119) -> Vec<WidgetPaint> {
120 let mark = match state {
121 CheckboxState::Checked => Some(IndicatorMark::Check),
122 CheckboxState::Indeterminate => Some(IndicatorMark::Glyph(GlyphMark::Indeterminate)),
123 CheckboxState::Unchecked => None,
124 };
125 let mut paint = Vec::new();
126 push_indicator(
127 ctx,
128 &mut paint,
129 Indicator {
130 rect: checkbox.rect,
131 label: checkbox.label,
132 mark,
133 active: state.is_active(),
134 disabled: checkbox.disabled,
135 radius: ctx.theme().radius.sm,
136 },
137 interaction,
138 live_focused,
139 );
140 paint
141}
142
143#[cfg(test)]
144mod tests {
145 use std::sync::Arc;
146
147 use super::{Checkbox, CheckboxState, show_checkbox};
148 use crate::focus::FocusManager;
149 use crate::frame::FrameCtx;
150 use crate::hit_test::{HitFrame, HitState, resolve};
151 use crate::hotkey::HotkeyTable;
152 use crate::input::{
153 FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton,
154 PointerButtonMask, PointerSample,
155 };
156 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
157 use crate::strings::StringKey;
158 use crate::strings::StringTable;
159 use crate::theme::Theme;
160 use crate::widget_id::{WidgetId, WidgetKey};
161
162 const LABEL: StringKey = StringKey::new("checkbox.label");
163
164 fn rect() -> LayoutRect {
165 LayoutRect::new(
166 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
167 LayoutSize::new(LayoutPx::new(48.0), LayoutPx::new(20.0)),
168 )
169 }
170
171 fn id_widget() -> WidgetId {
172 WidgetId::ROOT.child(WidgetKey::new("checkbox"))
173 }
174
175 #[test]
176 fn next_cycles_through_states() {
177 assert_eq!(CheckboxState::Unchecked.next(), CheckboxState::Checked);
178 assert_eq!(CheckboxState::Checked.next(), CheckboxState::Unchecked);
179 assert_eq!(
180 CheckboxState::Indeterminate.next(),
181 CheckboxState::Unchecked
182 );
183 }
184
185 #[test]
186 fn space_key_when_focused_toggles() {
187 let theme = Arc::new(Theme::light());
188 let table = HotkeyTable::new();
189 let mut hits = HitFrame::new();
190 let state = HitState::new();
191 let mut focus = FocusManager::new();
192 focus.register_focusable(id_widget());
193 focus.request_focus(id_widget());
194 focus.end_frame();
195 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
196 input.keys_pressed.push(KeyEvent::new(
197 KeyCode::Named(NamedKey::Space),
198 ModifierMask::NONE,
199 ));
200 let checkbox = Checkbox::new(id_widget(), rect(), LABEL, CheckboxState::Unchecked);
201 let response = {
202 let mut shaper = bone_text::Shaper::new();
203 let mut a11y = crate::a11y::AccessTreeBuilder::new();
204 let mut ctx = FrameCtx::new(
205 theme,
206 &mut input,
207 &mut focus,
208 &table,
209 StringTable::empty(),
210 &mut hits,
211 &state,
212 &mut a11y,
213 &mut shaper,
214 );
215 show_checkbox(&mut ctx, checkbox)
216 };
217 assert!(response.toggled);
218 assert_eq!(response.state, CheckboxState::Checked);
219 }
220
221 #[test]
222 fn pointer_click_through_three_frames_flips_state() {
223 let theme = Arc::new(Theme::light());
224 let table = HotkeyTable::new();
225 let mut focus = FocusManager::new();
226 let mut hits = HitFrame::new();
227 let mut state = HitState::new();
228 let mut current = CheckboxState::Unchecked;
229 [press_snap(), release_snap(), idle_snap()]
230 .into_iter()
231 .for_each(|mut input| {
232 hits.clear();
233 let checkbox = Checkbox::new(id_widget(), rect(), LABEL, current);
234 let response = {
235 let mut shaper = bone_text::Shaper::new();
236 let mut a11y = crate::a11y::AccessTreeBuilder::new();
237 let mut ctx = FrameCtx::new(
238 theme.clone(),
239 &mut input,
240 &mut focus,
241 &table,
242 StringTable::empty(),
243 &mut hits,
244 &state,
245 &mut a11y,
246 &mut shaper,
247 );
248 show_checkbox(&mut ctx, checkbox)
249 };
250 current = response.state;
251 state = resolve(&state, &hits, &input, focus.focused());
252 });
253 assert_eq!(current, CheckboxState::Checked);
254 }
255
256 #[test]
257 fn indeterminate_checkbox_paints_indeterminate_mark() {
258 let theme = Arc::new(Theme::light());
259 let table = HotkeyTable::new();
260 let mut focus = FocusManager::new();
261 let mut hits = HitFrame::new();
262 let state = HitState::new();
263 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
264 let checkbox = Checkbox::new(id_widget(), rect(), LABEL, CheckboxState::Indeterminate);
265 let response = {
266 let mut shaper = bone_text::Shaper::new();
267 let mut a11y = crate::a11y::AccessTreeBuilder::new();
268 let mut ctx = FrameCtx::new(
269 theme,
270 &mut input,
271 &mut focus,
272 &table,
273 StringTable::empty(),
274 &mut hits,
275 &state,
276 &mut a11y,
277 &mut shaper,
278 );
279 show_checkbox(&mut ctx, checkbox)
280 };
281 let has_indeterminate = response.paint.iter().any(|p| {
282 matches!(
283 p,
284 super::WidgetPaint::Mark {
285 kind: super::GlyphMark::Indeterminate,
286 ..
287 }
288 )
289 });
290 assert!(has_indeterminate);
291 }
292
293 #[test]
294 fn disabled_checkbox_ignores_keys() {
295 let theme = Arc::new(Theme::light());
296 let table = HotkeyTable::new();
297 let mut hits = HitFrame::new();
298 let state = HitState::new();
299 let mut focus = FocusManager::new();
300 focus.register_focusable(id_widget());
301 focus.request_focus(id_widget());
302 focus.end_frame();
303 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
304 let event = KeyEvent::new(KeyCode::Named(NamedKey::Space), ModifierMask::NONE);
305 input.keys_pressed.push(event);
306 let checkbox =
307 Checkbox::new(id_widget(), rect(), LABEL, CheckboxState::Unchecked).disabled(true);
308 let response = {
309 let mut shaper = bone_text::Shaper::new();
310 let mut a11y = crate::a11y::AccessTreeBuilder::new();
311 let mut ctx = FrameCtx::new(
312 theme,
313 &mut input,
314 &mut focus,
315 &table,
316 StringTable::empty(),
317 &mut hits,
318 &state,
319 &mut a11y,
320 &mut shaper,
321 );
322 show_checkbox(&mut ctx, checkbox)
323 };
324 assert!(!response.toggled);
325 assert_eq!(input.keys_pressed, vec![event]);
326 }
327
328 fn press_snap() -> InputSnapshot {
329 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
330 s.pointer = Some(PointerSample::new(LayoutPos::new(
331 LayoutPx::new(10.0),
332 LayoutPx::new(10.0),
333 )));
334 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
335 s
336 }
337
338 fn release_snap() -> InputSnapshot {
339 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
340 s.pointer = Some(PointerSample::new(LayoutPos::new(
341 LayoutPx::new(12.0),
342 LayoutPx::new(12.0),
343 )));
344 s.buttons_released = PointerButtonMask::just(PointerButton::Primary);
345 s
346 }
347
348 fn idle_snap() -> InputSnapshot {
349 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
350 s.pointer = Some(PointerSample::new(LayoutPos::new(
351 LayoutPx::new(12.0),
352 LayoutPx::new(12.0),
353 )));
354 s
355 }
356}