Another project
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(ui): button + checkbox widgets

Lewis: May this revision serve well! <lu5a@proton.me>

+927
+602
crates/bone-ui/src/widgets/button.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + use crate::frame::{FrameCtx, InteractDeclaration}; 4 + use crate::hit_test::{Interaction, Sense}; 5 + use crate::layout::LayoutRect; 6 + use crate::strings::StringKey; 7 + use crate::theme::{ 8 + Border, Color, Radius, Step12, StrokeWidth, SurfaceLevel, Theme, TypographyRole, 9 + }; 10 + use crate::widget_id::WidgetId; 11 + 12 + use super::keys::take_activation; 13 + use super::paint::{ButtonPaintKind, WidgetPaint}; 14 + use super::visuals::{SurfaceVisuals, TextVisuals, push_focus_ring}; 15 + 16 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 17 + pub enum ButtonVariant { 18 + Primary, 19 + Secondary, 20 + Ghost, 21 + IconOnly, 22 + Destructive, 23 + } 24 + 25 + impl ButtonVariant { 26 + #[must_use] 27 + pub const fn paint_kind(self) -> ButtonPaintKind { 28 + match self { 29 + Self::Primary => ButtonPaintKind::Filled, 30 + Self::Secondary => ButtonPaintKind::Outlined, 31 + Self::Ghost => ButtonPaintKind::Ghost, 32 + Self::IconOnly => ButtonPaintKind::IconOnly, 33 + Self::Destructive => ButtonPaintKind::Danger, 34 + } 35 + } 36 + } 37 + 38 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 39 + pub enum ButtonState { 40 + Idle, 41 + Loading, 42 + Disabled, 43 + } 44 + 45 + impl ButtonState { 46 + #[must_use] 47 + pub const fn is_interactive(self) -> bool { 48 + matches!(self, Self::Idle) 49 + } 50 + } 51 + 52 + #[derive(Copy, Clone, Debug, PartialEq)] 53 + pub struct Button { 54 + pub id: WidgetId, 55 + pub rect: LayoutRect, 56 + pub label: StringKey, 57 + pub variant: ButtonVariant, 58 + pub state: ButtonState, 59 + } 60 + 61 + impl Button { 62 + #[must_use] 63 + pub const fn new( 64 + id: WidgetId, 65 + rect: LayoutRect, 66 + label: StringKey, 67 + variant: ButtonVariant, 68 + ) -> Self { 69 + Self { 70 + id, 71 + rect, 72 + label, 73 + variant, 74 + state: ButtonState::Idle, 75 + } 76 + } 77 + 78 + #[must_use] 79 + pub const fn with_state(self, state: ButtonState) -> Self { 80 + Self { state, ..self } 81 + } 82 + } 83 + 84 + #[derive(Clone, Debug, PartialEq)] 85 + pub struct ButtonResponse { 86 + pub interaction: Interaction, 87 + pub activated: bool, 88 + pub paint: Vec<WidgetPaint>, 89 + } 90 + 91 + #[must_use] 92 + pub fn show_button(ctx: &mut FrameCtx<'_>, button: Button) -> ButtonResponse { 93 + let interactive = button.state.is_interactive(); 94 + let interaction = ctx.interact( 95 + InteractDeclaration::new(button.id, button.rect, Sense::INTERACTIVE) 96 + .focusable(interactive) 97 + .disabled(!interactive), 98 + ); 99 + let live_focused = ctx.is_focused(button.id); 100 + let activated_via_pointer = interactive && interaction.click(); 101 + let activated_via_key = interactive && live_focused && take_activation(ctx.input); 102 + let visuals = button_visuals(&ctx.theme, button.variant, button.state, interaction); 103 + let paint = build_paint(ctx, &button, &visuals, live_focused); 104 + ButtonResponse { 105 + interaction, 106 + activated: activated_via_pointer || activated_via_key, 107 + paint, 108 + } 109 + } 110 + 111 + #[derive(Copy, Clone, Debug, PartialEq)] 112 + pub struct ButtonVisuals { 113 + pub surface: SurfaceVisuals, 114 + pub text: TextVisuals, 115 + pub kind: ButtonPaintKind, 116 + } 117 + 118 + #[must_use] 119 + pub fn button_visuals( 120 + theme: &Theme, 121 + variant: ButtonVariant, 122 + state: ButtonState, 123 + interaction: Interaction, 124 + ) -> ButtonVisuals { 125 + let radius = theme.radius.sm; 126 + let surface = surface_for(theme, variant, state, interaction, radius); 127 + let text = text_for(theme, variant, state, surface.fill); 128 + ButtonVisuals { 129 + surface, 130 + text, 131 + kind: variant.paint_kind(), 132 + } 133 + } 134 + 135 + fn surface_for( 136 + theme: &Theme, 137 + variant: ButtonVariant, 138 + state: ButtonState, 139 + interaction: Interaction, 140 + radius: Radius, 141 + ) -> SurfaceVisuals { 142 + let neutral = theme.colors.neutral; 143 + let accent = theme.colors.accent; 144 + let danger = theme.colors.danger; 145 + let disabled = matches!(state, ButtonState::Disabled); 146 + let pressed = interaction.pressed(); 147 + let hovered = interaction.hover(); 148 + let (fill, border) = match variant { 149 + ButtonVariant::Primary => { 150 + let base = if disabled { 151 + neutral.step(Step12::SUBTLE_BG) 152 + } else if pressed || hovered { 153 + accent.step(Step12::HOVER_SOLID) 154 + } else { 155 + accent.step(Step12::SOLID) 156 + }; 157 + (base, None) 158 + } 159 + ButtonVariant::Secondary | ButtonVariant::IconOnly => { 160 + let base = if disabled { 161 + neutral.step(Step12::APP_BG) 162 + } else if pressed { 163 + neutral.step(Step12::SELECTED_BG) 164 + } else if hovered { 165 + neutral.step(Step12::HOVER_BG) 166 + } else { 167 + neutral.step(Step12::ELEMENT_BG) 168 + }; 169 + let border = Border { 170 + width: StrokeWidth::HAIRLINE, 171 + color: neutral.step(if hovered { 172 + Step12::HOVER_BORDER 173 + } else { 174 + Step12::BORDER 175 + }), 176 + }; 177 + (base, Some(border)) 178 + } 179 + ButtonVariant::Ghost => { 180 + let base = if pressed && !disabled { 181 + neutral.step(Step12::SELECTED_BG) 182 + } else if hovered && !disabled { 183 + neutral.step(Step12::HOVER_BG) 184 + } else { 185 + Color::TRANSPARENT 186 + }; 187 + (base, None) 188 + } 189 + ButtonVariant::Destructive => { 190 + let base = if disabled { 191 + neutral.step(Step12::SUBTLE_BG) 192 + } else if pressed || hovered { 193 + danger.step(Step12::HOVER_SOLID) 194 + } else { 195 + danger.step(Step12::SOLID) 196 + }; 197 + (base, None) 198 + } 199 + }; 200 + SurfaceVisuals { 201 + fill, 202 + border, 203 + radius, 204 + elevation: None, 205 + } 206 + } 207 + 208 + fn text_for( 209 + theme: &Theme, 210 + variant: ButtonVariant, 211 + state: ButtonState, 212 + surface_fill: Color, 213 + ) -> TextVisuals { 214 + let role: TypographyRole = theme.typography.label; 215 + let color = if matches!(state, ButtonState::Disabled) { 216 + theme.colors.text_disabled() 217 + } else { 218 + match variant { 219 + ButtonVariant::Primary | ButtonVariant::Destructive => surface_fill.on_surface(), 220 + ButtonVariant::Secondary | ButtonVariant::Ghost | ButtonVariant::IconOnly => { 221 + theme.colors.text_primary() 222 + } 223 + } 224 + }; 225 + TextVisuals { color, role } 226 + } 227 + 228 + fn build_paint( 229 + ctx: &FrameCtx<'_>, 230 + button: &Button, 231 + visuals: &ButtonVisuals, 232 + live_focused: bool, 233 + ) -> Vec<WidgetPaint> { 234 + let mut paint = vec![ 235 + WidgetPaint::Surface { 236 + rect: button.rect, 237 + fill: visuals.surface.fill, 238 + border: visuals.surface.border, 239 + radius: visuals.surface.radius, 240 + elevation: visuals.surface.elevation, 241 + }, 242 + WidgetPaint::Label { 243 + rect: button.rect, 244 + text: super::paint::LabelText::Key(button.label), 245 + color: visuals.text.color, 246 + role: visuals.text.role, 247 + }, 248 + ]; 249 + if matches!(button.state, ButtonState::Loading) { 250 + paint.push(WidgetPaint::Mark { 251 + rect: button.rect, 252 + kind: super::paint::GlyphMark::Spinner, 253 + color: ctx.theme.colors.surface(SurfaceLevel::L1), 254 + }); 255 + } 256 + push_focus_ring( 257 + ctx, 258 + &mut paint, 259 + button.rect, 260 + visuals.surface.radius, 261 + live_focused, 262 + ); 263 + paint 264 + } 265 + 266 + #[cfg(test)] 267 + mod tests { 268 + use std::sync::Arc; 269 + 270 + use super::{Button, ButtonState, ButtonVariant, show_button}; 271 + use crate::focus::FocusManager; 272 + use crate::frame::FrameCtx; 273 + use crate::hit_test::{HitFrame, HitState, resolve}; 274 + use crate::hotkey::HotkeyTable; 275 + use crate::input::{ 276 + FrameInstant, InputSnapshot, KeyChar, KeyCode, KeyEvent, ModifierMask, NamedKey, 277 + PointerButton, PointerButtonMask, PointerSample, 278 + }; 279 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 280 + use crate::strings::StringKey; 281 + use crate::strings::StringTable; 282 + use crate::theme::Theme; 283 + use crate::widget_id::{WidgetId, WidgetKey}; 284 + 285 + const LABEL: StringKey = StringKey::new("button.label"); 286 + 287 + fn rect() -> LayoutRect { 288 + LayoutRect::new( 289 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 290 + LayoutSize::new(LayoutPx::new(80.0), LayoutPx::new(28.0)), 291 + ) 292 + } 293 + 294 + fn id(key: &'static str) -> WidgetId { 295 + WidgetId::ROOT.child(WidgetKey::new(key)) 296 + } 297 + 298 + fn cycle_press_release(button: Button) -> (HitState, Vec<bool>) { 299 + let theme = Arc::new(Theme::light()); 300 + let mut focus = FocusManager::new(); 301 + let table = HotkeyTable::new(); 302 + let mut hits = HitFrame::new(); 303 + let mut state = HitState::new(); 304 + let mut activations = Vec::new(); 305 + 306 + let frame = |snap: &mut InputSnapshot, 307 + focus: &mut FocusManager, 308 + hits: &mut HitFrame, 309 + state: &mut HitState, 310 + activations: &mut Vec<bool>| { 311 + hits.clear(); 312 + { 313 + let mut ctx = FrameCtx::new( 314 + theme.clone(), 315 + snap, 316 + focus, 317 + &table, 318 + StringTable::empty(), 319 + hits, 320 + state, 321 + ); 322 + let response = show_button(&mut ctx, button); 323 + activations.push(response.activated); 324 + } 325 + *state = resolve(state, hits, snap, focus.focused()); 326 + }; 327 + 328 + let mut press = InputSnapshot::idle(FrameInstant::ZERO); 329 + press.pointer = Some(PointerSample::new(LayoutPos::new( 330 + LayoutPx::new(10.0), 331 + LayoutPx::new(10.0), 332 + ))); 333 + press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 334 + frame( 335 + &mut press, 336 + &mut focus, 337 + &mut hits, 338 + &mut state, 339 + &mut activations, 340 + ); 341 + 342 + let mut release = InputSnapshot::idle(FrameInstant::ZERO); 343 + release.pointer = Some(PointerSample::new(LayoutPos::new( 344 + LayoutPx::new(15.0), 345 + LayoutPx::new(15.0), 346 + ))); 347 + release.buttons_released = PointerButtonMask::just(PointerButton::Primary); 348 + frame( 349 + &mut release, 350 + &mut focus, 351 + &mut hits, 352 + &mut state, 353 + &mut activations, 354 + ); 355 + 356 + let mut idle = InputSnapshot::idle(FrameInstant::ZERO); 357 + idle.pointer = Some(PointerSample::new(LayoutPos::new( 358 + LayoutPx::new(15.0), 359 + LayoutPx::new(15.0), 360 + ))); 361 + frame( 362 + &mut idle, 363 + &mut focus, 364 + &mut hits, 365 + &mut state, 366 + &mut activations, 367 + ); 368 + 369 + (state, activations) 370 + } 371 + 372 + #[test] 373 + fn primary_button_click_sets_activated() { 374 + let button = Button::new(id("ok"), rect(), LABEL, ButtonVariant::Primary); 375 + let (_, activations) = cycle_press_release(button); 376 + assert_eq!(activations, vec![false, false, true]); 377 + } 378 + 379 + #[test] 380 + fn disabled_button_never_activates() { 381 + let button = Button::new(id("ok"), rect(), LABEL, ButtonVariant::Primary) 382 + .with_state(ButtonState::Disabled); 383 + let (state, activations) = cycle_press_release(button); 384 + assert!(activations.iter().all(|a| !a)); 385 + assert!(state.interaction(button.id).disabled()); 386 + assert!(!state.interaction(button.id).click()); 387 + } 388 + 389 + fn focused_input_with(events: Vec<KeyEvent>) -> (InputSnapshot, FocusManager) { 390 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 391 + input.keys_pressed = events; 392 + let mut focus = FocusManager::new(); 393 + focus.register_focusable(id("ok")); 394 + focus.request_focus(id("ok")); 395 + focus.end_frame(); 396 + (input, focus) 397 + } 398 + 399 + #[test] 400 + fn enter_key_on_focused_button_activates() { 401 + let theme = Arc::new(Theme::light()); 402 + let table = HotkeyTable::new(); 403 + let mut hits = HitFrame::new(); 404 + let state = HitState::new(); 405 + let (mut input, mut focus) = focused_input_with(vec![KeyEvent::new( 406 + KeyCode::Named(NamedKey::Enter), 407 + ModifierMask::NONE, 408 + )]); 409 + let button = Button::new(id("ok"), rect(), LABEL, ButtonVariant::Primary); 410 + let response = { 411 + let mut ctx = FrameCtx::new( 412 + theme, 413 + &mut input, 414 + &mut focus, 415 + &table, 416 + StringTable::empty(), 417 + &mut hits, 418 + &state, 419 + ); 420 + show_button(&mut ctx, button) 421 + }; 422 + assert!(response.activated); 423 + assert!(input.keys_pressed.is_empty(), "consumed Enter"); 424 + } 425 + 426 + #[test] 427 + fn space_key_on_focused_button_activates() { 428 + let theme = Arc::new(Theme::light()); 429 + let table = HotkeyTable::new(); 430 + let mut hits = HitFrame::new(); 431 + let state = HitState::new(); 432 + let (mut input, mut focus) = focused_input_with(vec![KeyEvent::new( 433 + KeyCode::Named(NamedKey::Space), 434 + ModifierMask::NONE, 435 + )]); 436 + let button = Button::new(id("ok"), rect(), LABEL, ButtonVariant::Primary); 437 + let response = { 438 + let mut ctx = FrameCtx::new( 439 + theme, 440 + &mut input, 441 + &mut focus, 442 + &table, 443 + StringTable::empty(), 444 + &mut hits, 445 + &state, 446 + ); 447 + show_button(&mut ctx, button) 448 + }; 449 + assert!(response.activated); 450 + } 451 + 452 + #[test] 453 + fn unfocused_button_does_not_consume_keys() { 454 + let theme = Arc::new(Theme::light()); 455 + let mut focus = FocusManager::new(); 456 + let table = HotkeyTable::new(); 457 + let mut hits = HitFrame::new(); 458 + let state = HitState::new(); 459 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 460 + let event = KeyEvent::new(KeyCode::Named(NamedKey::Enter), ModifierMask::NONE); 461 + input.keys_pressed.push(event); 462 + let button = Button::new(id("ok"), rect(), LABEL, ButtonVariant::Primary); 463 + let response = { 464 + let mut ctx = FrameCtx::new( 465 + theme, 466 + &mut input, 467 + &mut focus, 468 + &table, 469 + StringTable::empty(), 470 + &mut hits, 471 + &state, 472 + ); 473 + show_button(&mut ctx, button) 474 + }; 475 + assert!(!response.activated); 476 + assert_eq!(input.keys_pressed, vec![event]); 477 + } 478 + 479 + #[test] 480 + fn loading_button_blocks_activation_and_keys() { 481 + let button = Button::new(id("ok"), rect(), LABEL, ButtonVariant::Primary) 482 + .with_state(ButtonState::Loading); 483 + let (_, activations) = cycle_press_release(button); 484 + assert!(!activations[0]); 485 + assert!(!activations[1]); 486 + } 487 + 488 + #[test] 489 + fn other_keys_pass_through() { 490 + let theme = Arc::new(Theme::light()); 491 + let table = HotkeyTable::new(); 492 + let mut hits = HitFrame::new(); 493 + let state = HitState::new(); 494 + let other = KeyEvent::new(KeyCode::Char(KeyChar::from_char('x')), ModifierMask::NONE); 495 + let (mut input, mut focus) = focused_input_with(vec![other]); 496 + let button = Button::new(id("ok"), rect(), LABEL, ButtonVariant::Primary); 497 + let _ = { 498 + let mut ctx = FrameCtx::new( 499 + theme, 500 + &mut input, 501 + &mut focus, 502 + &table, 503 + StringTable::empty(), 504 + &mut hits, 505 + &state, 506 + ); 507 + show_button(&mut ctx, button) 508 + }; 509 + assert_eq!( 510 + input.keys_pressed, 511 + vec![other], 512 + "non-activation keys remain" 513 + ); 514 + } 515 + 516 + #[test] 517 + fn ghost_variant_is_transparent_when_idle() { 518 + let theme = Theme::light(); 519 + let visuals = super::button_visuals( 520 + &theme, 521 + ButtonVariant::Ghost, 522 + ButtonState::Idle, 523 + crate::hit_test::Interaction::idle(), 524 + ); 525 + assert_eq!(visuals.surface.fill, crate::theme::Color::TRANSPARENT); 526 + } 527 + 528 + #[test] 529 + fn destructive_variant_uses_danger_solid() { 530 + let theme = Theme::light(); 531 + let visuals = super::button_visuals( 532 + &theme, 533 + ButtonVariant::Destructive, 534 + ButtonState::Idle, 535 + crate::hit_test::Interaction::idle(), 536 + ); 537 + assert_eq!(visuals.surface.fill, theme.colors.danger_solid()); 538 + } 539 + 540 + #[test] 541 + fn paint_includes_focus_ring_when_focused_via_keyboard() { 542 + let theme = Arc::new(Theme::light()); 543 + let table = HotkeyTable::new(); 544 + let mut hits = HitFrame::new(); 545 + let state = HitState::new(); 546 + let tab = KeyEvent::new(KeyCode::Named(NamedKey::Tab), ModifierMask::NONE); 547 + let (mut input, mut focus) = focused_input_with(vec![tab]); 548 + let button = Button::new(id("ok"), rect(), LABEL, ButtonVariant::Primary); 549 + let response = { 550 + let mut ctx = FrameCtx::new( 551 + theme, 552 + &mut input, 553 + &mut focus, 554 + &table, 555 + StringTable::empty(), 556 + &mut hits, 557 + &state, 558 + ); 559 + show_button(&mut ctx, button) 560 + }; 561 + assert!( 562 + response 563 + .paint 564 + .iter() 565 + .any(|p| matches!(p, super::WidgetPaint::FocusRing { .. })), 566 + "focused button paints focus ring", 567 + ); 568 + } 569 + 570 + #[test] 571 + fn focus_ring_hidden_when_pointer_modality() { 572 + let theme = Arc::new(Theme::light()); 573 + let table = HotkeyTable::new(); 574 + let mut hits = HitFrame::new(); 575 + let state = HitState::new(); 576 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 577 + let mut focus = FocusManager::new(); 578 + focus.register_focusable(id("ok")); 579 + focus.request_focus(id("ok")); 580 + focus.end_frame(); 581 + let button = Button::new(id("ok"), rect(), LABEL, ButtonVariant::Primary); 582 + let response = { 583 + let mut ctx = FrameCtx::new( 584 + theme, 585 + &mut input, 586 + &mut focus, 587 + &table, 588 + StringTable::empty(), 589 + &mut hits, 590 + &state, 591 + ); 592 + show_button(&mut ctx, button) 593 + }; 594 + assert!( 595 + !response 596 + .paint 597 + .iter() 598 + .any(|p| matches!(p, super::WidgetPaint::FocusRing { .. })), 599 + "pointer-modality focus must not draw ring", 600 + ); 601 + } 602 + }
+325
crates/bone-ui/src/widgets/checkbox.rs
··· 1 + use serde::Serialize; 2 + 3 + use crate::frame::{FrameCtx, InteractDeclaration}; 4 + use crate::hit_test::{Interaction, Sense}; 5 + use crate::layout::LayoutRect; 6 + use crate::strings::StringKey; 7 + use crate::widget_id::WidgetId; 8 + 9 + use super::keys::take_activation; 10 + use super::paint::{GlyphMark, WidgetPaint}; 11 + use super::visuals::{Indicator, push_indicator}; 12 + 13 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)] 14 + pub enum CheckboxState { 15 + Unchecked, 16 + Checked, 17 + Indeterminate, 18 + } 19 + 20 + impl CheckboxState { 21 + #[must_use] 22 + pub const fn next(self) -> Self { 23 + match self { 24 + Self::Unchecked => Self::Checked, 25 + Self::Checked | Self::Indeterminate => Self::Unchecked, 26 + } 27 + } 28 + 29 + #[must_use] 30 + pub const fn is_active(self) -> bool { 31 + matches!(self, Self::Checked | Self::Indeterminate) 32 + } 33 + } 34 + 35 + #[derive(Copy, Clone, Debug, PartialEq)] 36 + pub struct Checkbox { 37 + pub id: WidgetId, 38 + pub rect: LayoutRect, 39 + pub label: StringKey, 40 + pub state: CheckboxState, 41 + pub disabled: bool, 42 + } 43 + 44 + impl Checkbox { 45 + #[must_use] 46 + pub const fn new( 47 + id: WidgetId, 48 + rect: LayoutRect, 49 + label: StringKey, 50 + state: CheckboxState, 51 + ) -> Self { 52 + Self { 53 + id, 54 + rect, 55 + label, 56 + state, 57 + disabled: false, 58 + } 59 + } 60 + 61 + #[must_use] 62 + pub const fn disabled(self, disabled: bool) -> Self { 63 + Self { disabled, ..self } 64 + } 65 + } 66 + 67 + #[derive(Clone, Debug, PartialEq)] 68 + pub struct CheckboxResponse { 69 + pub interaction: Interaction, 70 + pub state: CheckboxState, 71 + pub toggled: bool, 72 + pub paint: Vec<WidgetPaint>, 73 + } 74 + 75 + #[must_use] 76 + pub fn show_checkbox(ctx: &mut FrameCtx<'_>, checkbox: Checkbox) -> CheckboxResponse { 77 + let interactive = !checkbox.disabled; 78 + let interaction = ctx.interact( 79 + InteractDeclaration::new(checkbox.id, checkbox.rect, Sense::INTERACTIVE) 80 + .focusable(interactive) 81 + .disabled(!interactive) 82 + .active(checkbox.state.is_active()), 83 + ); 84 + let live_focused = ctx.is_focused(checkbox.id); 85 + let toggled = interactive 86 + && (interaction.click() || (live_focused && take_activation(ctx.input))); 87 + let next_state = if toggled { 88 + checkbox.state.next() 89 + } else { 90 + checkbox.state 91 + }; 92 + let paint = build_paint(ctx, &checkbox, interaction, live_focused, next_state); 93 + CheckboxResponse { 94 + interaction, 95 + state: next_state, 96 + toggled, 97 + paint, 98 + } 99 + } 100 + 101 + fn build_paint( 102 + ctx: &FrameCtx<'_>, 103 + checkbox: &Checkbox, 104 + interaction: Interaction, 105 + live_focused: bool, 106 + state: CheckboxState, 107 + ) -> Vec<WidgetPaint> { 108 + let mark = match state { 109 + CheckboxState::Checked => Some(GlyphMark::Checkmark), 110 + CheckboxState::Indeterminate => Some(GlyphMark::Indeterminate), 111 + CheckboxState::Unchecked => None, 112 + }; 113 + let mut paint = Vec::new(); 114 + push_indicator( 115 + ctx, 116 + &mut paint, 117 + Indicator { 118 + rect: checkbox.rect, 119 + label: checkbox.label, 120 + mark, 121 + active: state.is_active(), 122 + disabled: checkbox.disabled, 123 + radius: ctx.theme.radius.sm, 124 + }, 125 + interaction, 126 + live_focused, 127 + ); 128 + paint 129 + } 130 + 131 + #[cfg(test)] 132 + mod tests { 133 + use std::sync::Arc; 134 + 135 + use super::{Checkbox, CheckboxState, show_checkbox}; 136 + use crate::focus::FocusManager; 137 + use crate::frame::FrameCtx; 138 + use crate::hit_test::{HitFrame, HitState, resolve}; 139 + use crate::hotkey::HotkeyTable; 140 + use crate::input::{ 141 + FrameInstant, InputSnapshot, KeyCode, KeyEvent, ModifierMask, NamedKey, PointerButton, 142 + PointerButtonMask, PointerSample, 143 + }; 144 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 145 + use crate::strings::StringKey; 146 + use crate::strings::StringTable; 147 + use crate::theme::Theme; 148 + use crate::widget_id::{WidgetId, WidgetKey}; 149 + 150 + const LABEL: StringKey = StringKey::new("checkbox.label"); 151 + 152 + fn rect() -> LayoutRect { 153 + LayoutRect::new( 154 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 155 + LayoutSize::new(LayoutPx::new(48.0), LayoutPx::new(20.0)), 156 + ) 157 + } 158 + 159 + fn id_widget() -> WidgetId { 160 + WidgetId::ROOT.child(WidgetKey::new("checkbox")) 161 + } 162 + 163 + #[test] 164 + fn next_cycles_through_states() { 165 + assert_eq!(CheckboxState::Unchecked.next(), CheckboxState::Checked); 166 + assert_eq!(CheckboxState::Checked.next(), CheckboxState::Unchecked); 167 + assert_eq!(CheckboxState::Indeterminate.next(), CheckboxState::Unchecked); 168 + } 169 + 170 + #[test] 171 + fn space_key_when_focused_toggles() { 172 + let theme = Arc::new(Theme::light()); 173 + let table = HotkeyTable::new(); 174 + let mut hits = HitFrame::new(); 175 + let state = HitState::new(); 176 + let mut focus = FocusManager::new(); 177 + focus.register_focusable(id_widget()); 178 + focus.request_focus(id_widget()); 179 + focus.end_frame(); 180 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 181 + input.keys_pressed.push(KeyEvent::new( 182 + KeyCode::Named(NamedKey::Space), 183 + ModifierMask::NONE, 184 + )); 185 + let checkbox = Checkbox::new(id_widget(), rect(), LABEL, CheckboxState::Unchecked); 186 + let response = { 187 + let mut ctx = FrameCtx::new( 188 + theme, 189 + &mut input, 190 + &mut focus, 191 + &table, 192 + StringTable::empty(), 193 + &mut hits, 194 + &state, 195 + ); 196 + show_checkbox(&mut ctx, checkbox) 197 + }; 198 + assert!(response.toggled); 199 + assert_eq!(response.state, CheckboxState::Checked); 200 + } 201 + 202 + #[test] 203 + fn pointer_click_through_three_frames_flips_state() { 204 + let theme = Arc::new(Theme::light()); 205 + let table = HotkeyTable::new(); 206 + let mut focus = FocusManager::new(); 207 + let mut hits = HitFrame::new(); 208 + let mut state = HitState::new(); 209 + let mut current = CheckboxState::Unchecked; 210 + [press_snap(), release_snap(), idle_snap()] 211 + .into_iter() 212 + .for_each(|mut input| { 213 + hits.clear(); 214 + let checkbox = Checkbox::new(id_widget(), rect(), LABEL, current); 215 + let response = { 216 + let mut ctx = FrameCtx::new( 217 + theme.clone(), 218 + &mut input, 219 + &mut focus, 220 + &table, 221 + StringTable::empty(), 222 + &mut hits, 223 + &state, 224 + ); 225 + show_checkbox(&mut ctx, checkbox) 226 + }; 227 + current = response.state; 228 + state = resolve(&state, &hits, &input, focus.focused()); 229 + }); 230 + assert_eq!(current, CheckboxState::Checked); 231 + } 232 + 233 + #[test] 234 + fn indeterminate_checkbox_paints_indeterminate_mark() { 235 + let theme = Arc::new(Theme::light()); 236 + let table = HotkeyTable::new(); 237 + let mut focus = FocusManager::new(); 238 + let mut hits = HitFrame::new(); 239 + let state = HitState::new(); 240 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 241 + let checkbox = Checkbox::new(id_widget(), rect(), LABEL, CheckboxState::Indeterminate); 242 + let response = { 243 + let mut ctx = FrameCtx::new( 244 + theme, 245 + &mut input, 246 + &mut focus, 247 + &table, 248 + StringTable::empty(), 249 + &mut hits, 250 + &state, 251 + ); 252 + show_checkbox(&mut ctx, checkbox) 253 + }; 254 + let has_indeterminate = response.paint.iter().any(|p| { 255 + matches!( 256 + p, 257 + super::WidgetPaint::Mark { 258 + kind: super::GlyphMark::Indeterminate, 259 + .. 260 + } 261 + ) 262 + }); 263 + assert!(has_indeterminate); 264 + } 265 + 266 + #[test] 267 + fn disabled_checkbox_ignores_keys() { 268 + let theme = Arc::new(Theme::light()); 269 + let table = HotkeyTable::new(); 270 + let mut hits = HitFrame::new(); 271 + let state = HitState::new(); 272 + let mut focus = FocusManager::new(); 273 + focus.register_focusable(id_widget()); 274 + focus.request_focus(id_widget()); 275 + focus.end_frame(); 276 + let mut input = InputSnapshot::idle(FrameInstant::ZERO); 277 + let event = KeyEvent::new(KeyCode::Named(NamedKey::Space), ModifierMask::NONE); 278 + input.keys_pressed.push(event); 279 + let checkbox = 280 + Checkbox::new(id_widget(), rect(), LABEL, CheckboxState::Unchecked).disabled(true); 281 + let response = { 282 + let mut ctx = FrameCtx::new( 283 + theme, 284 + &mut input, 285 + &mut focus, 286 + &table, 287 + StringTable::empty(), 288 + &mut hits, 289 + &state, 290 + ); 291 + show_checkbox(&mut ctx, checkbox) 292 + }; 293 + assert!(!response.toggled); 294 + assert_eq!(input.keys_pressed, vec![event]); 295 + } 296 + 297 + fn press_snap() -> InputSnapshot { 298 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 299 + s.pointer = Some(PointerSample::new(LayoutPos::new( 300 + LayoutPx::new(10.0), 301 + LayoutPx::new(10.0), 302 + ))); 303 + s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 304 + s 305 + } 306 + 307 + fn release_snap() -> InputSnapshot { 308 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 309 + s.pointer = Some(PointerSample::new(LayoutPos::new( 310 + LayoutPx::new(12.0), 311 + LayoutPx::new(12.0), 312 + ))); 313 + s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 314 + s 315 + } 316 + 317 + fn idle_snap() -> InputSnapshot { 318 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 319 + s.pointer = Some(PointerSample::new(LayoutPos::new( 320 + LayoutPx::new(12.0), 321 + LayoutPx::new(12.0), 322 + ))); 323 + s 324 + } 325 + }