Another project
0

Configure Feed

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

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