Another project
0

Configure Feed

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

at main 42 kB View raw
1use core::time::Duration; 2 3use crate::a11y::{AccessNode, Role}; 4use crate::frame::{FrameCtx, InteractDeclaration}; 5use crate::hit_test::{Interaction, Sense, ZLayer}; 6use crate::input::{FrameInstant, KeyCode, KeyEvent, ModifierMask, NamedKey}; 7use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 8use crate::strings::StringKey; 9use crate::theme::{Border, Step12, StrokeWidth}; 10use crate::widget_id::{WidgetId, WidgetKey}; 11 12use super::keys::{TakeKey, take_key}; 13use super::paint::{GlyphMark, LabelText, WidgetPaint}; 14use super::visuals::push_focus_ring; 15 16const TYPEAHEAD_RESET: Duration = Duration::from_millis(750); 17 18#[derive(Clone, Debug, PartialEq)] 19pub struct DropdownItem<T: Clone + PartialEq> { 20 pub value: T, 21 pub label: StringKey, 22} 23 24#[derive(Clone, Debug, Default, PartialEq, Eq)] 25pub struct DropdownState { 26 pub open: bool, 27 pub highlighted: Option<usize>, 28 pub filter: String, 29 pub typeahead_last: Option<FrameInstant>, 30 pub focus_seated: bool, 31 pub last_hovered: Option<usize>, 32} 33 34impl DropdownState { 35 #[must_use] 36 pub fn closed() -> Self { 37 Self::default() 38 } 39 40 fn clear_typeahead(&mut self) { 41 self.filter.clear(); 42 self.typeahead_last = None; 43 } 44} 45 46pub struct Dropdown<'state, T: Clone + PartialEq> { 47 pub id: WidgetId, 48 pub trigger_rect: LayoutRect, 49 pub item_height: LayoutPx, 50 pub items: Vec<DropdownItem<T>>, 51 pub selected: Option<T>, 52 pub placeholder: StringKey, 53 pub state: &'state mut DropdownState, 54 pub disabled: bool, 55} 56 57impl<'state, T: Clone + PartialEq> Dropdown<'state, T> { 58 #[must_use] 59 pub fn new( 60 id: WidgetId, 61 trigger_rect: LayoutRect, 62 item_height: LayoutPx, 63 items: Vec<DropdownItem<T>>, 64 selected: Option<T>, 65 placeholder: StringKey, 66 state: &'state mut DropdownState, 67 ) -> Self { 68 Self { 69 id, 70 trigger_rect, 71 item_height, 72 items, 73 selected, 74 placeholder, 75 state, 76 disabled: false, 77 } 78 } 79 80 #[must_use] 81 pub fn disabled(self, disabled: bool) -> Self { 82 Self { disabled, ..self } 83 } 84} 85 86fn popup_rect_below(trigger: LayoutRect, item_height: LayoutPx, count: usize) -> LayoutRect { 87 #[allow(clippy::cast_precision_loss, reason = "dropdown item counts are small")] 88 let count_f32 = count as f32; 89 LayoutRect::new( 90 LayoutPos::new( 91 trigger.origin.x, 92 LayoutPx::new(trigger.origin.y.value() + trigger.size.height.value()), 93 ), 94 LayoutSize::new( 95 trigger.size.width, 96 LayoutPx::new(item_height.value() * count_f32), 97 ), 98 ) 99} 100 101#[derive(Clone, Debug, PartialEq)] 102pub struct DropdownResponse<T: Clone + PartialEq> { 103 pub interaction: Interaction, 104 pub selected: Option<T>, 105 pub changed: bool, 106 pub paint: Vec<WidgetPaint>, 107} 108 109#[must_use] 110pub fn show_dropdown<T: Clone + PartialEq>( 111 ctx: &mut FrameCtx<'_>, 112 dropdown: Dropdown<'_, T>, 113) -> DropdownResponse<T> { 114 let Dropdown { 115 id, 116 trigger_rect, 117 item_height, 118 items, 119 selected: initial_selected, 120 placeholder, 121 state, 122 disabled, 123 } = dropdown; 124 let interactive = !disabled; 125 let popup_rect = popup_rect_below(trigger_rect, item_height, items.len()); 126 let trigger_interaction = ctx.interact( 127 InteractDeclaration::new(id, trigger_rect, Sense::INTERACTIVE) 128 .focusable(interactive) 129 .disabled(!interactive) 130 .active(state.open) 131 .a11y( 132 AccessNode::new(Role::ComboBox) 133 .with_label(placeholder) 134 .with_disabled(!interactive) 135 .with_expanded(state.open), 136 ), 137 ); 138 let live_focused = ctx.is_focused(id); 139 if state.open && state.focus_seated && !live_focused { 140 state.open = false; 141 } 142 if state.open && pointer_pressed_outside(ctx.input, trigger_rect, popup_rect) { 143 state.open = false; 144 } 145 let pointer_toggled = interactive && trigger_interaction.click(); 146 let keyboard_opened = interactive && live_focused && !state.open && { 147 take_key( 148 ctx.input, 149 &[ 150 TakeKey::named(NamedKey::Enter), 151 TakeKey::named(NamedKey::Space), 152 TakeKey::named(NamedKey::ArrowDown), 153 ], 154 ) 155 .is_some() 156 }; 157 if pointer_toggled { 158 state.open = !state.open; 159 } else if keyboard_opened { 160 state.open = true; 161 } 162 if state.open && live_focused { 163 state.focus_seated = true; 164 } 165 if state.open && state.highlighted.is_none() { 166 state.highlighted = current_index(&items, initial_selected.as_ref()) 167 .or_else(|| (!items.is_empty()).then_some(0)); 168 } 169 if !state.open { 170 state.clear_typeahead(); 171 state.focus_seated = false; 172 state.last_hovered = None; 173 } 174 let mut selected = initial_selected.clone(); 175 let mut changed = false; 176 let mut paint = trigger_paint( 177 ctx, 178 trigger_rect, 179 disabled, 180 trigger_label(&items, initial_selected.as_ref(), placeholder), 181 trigger_interaction, 182 live_focused, 183 ); 184 if state.open && interactive { 185 render_open_popup( 186 ctx, 187 PopupInputs { 188 id, 189 popup_rect, 190 item_height, 191 label: placeholder, 192 items: &items, 193 initial_selected: initial_selected.as_ref(), 194 state, 195 live_focused, 196 }, 197 &mut selected, 198 &mut changed, 199 &mut paint, 200 ); 201 } 202 DropdownResponse { 203 interaction: trigger_interaction, 204 selected, 205 changed, 206 paint, 207 } 208} 209 210struct PopupInputs<'a, T: Clone + PartialEq> { 211 id: WidgetId, 212 popup_rect: LayoutRect, 213 item_height: LayoutPx, 214 label: StringKey, 215 items: &'a [DropdownItem<T>], 216 initial_selected: Option<&'a T>, 217 state: &'a mut DropdownState, 218 live_focused: bool, 219} 220 221fn render_open_popup<T: Clone + PartialEq>( 222 ctx: &mut FrameCtx<'_>, 223 inputs: PopupInputs<'_, T>, 224 selected: &mut Option<T>, 225 changed: &mut bool, 226 paint: &mut Vec<WidgetPaint>, 227) { 228 let PopupInputs { 229 id, 230 popup_rect, 231 item_height, 232 label, 233 items, 234 initial_selected, 235 state, 236 live_focused, 237 } = inputs; 238 if live_focused { 239 if let Some(action) = take_keyboard_action(ctx) { 240 let page_size = visible_item_count(popup_rect, item_height); 241 apply_keyboard_action(action, items, page_size, state, selected, changed); 242 } 243 typeahead_step(ctx, items, state); 244 } 245 ctx.a11y.push( 246 id.child(WidgetKey::new("popup")), 247 popup_rect, 248 AccessNode::new(Role::ListBox).with_label(label), 249 ); 250 let mut popup_paints: Vec<WidgetPaint> = Vec::new(); 251 popup_paints.push(WidgetPaint::Surface { 252 rect: popup_rect, 253 fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L1), 254 border: Some(Border { 255 width: StrokeWidth::HAIRLINE, 256 color: ctx.theme().colors.neutral.step(Step12::BORDER), 257 }), 258 radius: ctx.theme().radius.sm, 259 elevation: Some(ctx.theme().elevation.level1), 260 }); 261 let prev_hovered = state.last_hovered; 262 let mut current_hovered: Option<usize> = None; 263 items.iter().enumerate().for_each(|(index, item)| { 264 let item_rect = item_rect(popup_rect, item_height, index); 265 let item_id = id.child_indexed(WidgetKey::new("item"), index as u64); 266 let item_interaction = ctx.interact( 267 InteractDeclaration::new(item_id, item_rect, Sense::INTERACTIVE) 268 .at_z(ZLayer::POPUP) 269 .active(Some(&item.value) == initial_selected) 270 .a11y( 271 AccessNode::new(Role::ListBoxOption) 272 .with_label(item.label) 273 .with_selected(Some(&item.value) == initial_selected), 274 ), 275 ); 276 if item_interaction.hover() { 277 current_hovered = Some(index); 278 if prev_hovered != Some(index) { 279 state.highlighted = Some(index); 280 } 281 } 282 if item_interaction.click() { 283 let next = Some(item.value.clone()); 284 *changed = next != *selected; 285 *selected = next; 286 state.open = false; 287 } 288 let highlighted = state.highlighted == Some(index); 289 popup_paints.push(WidgetPaint::Surface { 290 rect: item_rect, 291 fill: if highlighted { 292 ctx.theme().colors.accent.step(Step12::HOVER_BG) 293 } else if Some(&item.value) == initial_selected { 294 ctx.theme().colors.neutral.step(Step12::SELECTED_BG) 295 } else { 296 ctx.theme().colors.surface(crate::theme::SurfaceLevel::L1) 297 }, 298 border: None, 299 radius: ctx.theme().radius.none, 300 elevation: None, 301 }); 302 popup_paints.push(WidgetPaint::Label { 303 rect: item_rect, 304 text: LabelText::Key(item.label), 305 color: ctx.theme().colors.text_primary(), 306 role: ctx.theme().typography.body, 307 }); 308 }); 309 state.last_hovered = current_hovered; 310 paint.push(WidgetPaint::Popup { 311 paints: popup_paints, 312 }); 313} 314 315fn pointer_pressed_outside( 316 input: &crate::input::InputSnapshot, 317 trigger: LayoutRect, 318 popup: LayoutRect, 319) -> bool { 320 if input.buttons_pressed.is_empty() { 321 return false; 322 } 323 let Some(sample) = input.pointer else { 324 return false; 325 }; 326 !trigger.contains(sample.position) && !popup.contains(sample.position) 327} 328 329fn apply_keyboard_action<T: Clone + PartialEq>( 330 action: KeyboardAction, 331 items: &[DropdownItem<T>], 332 page_size: usize, 333 state: &mut DropdownState, 334 selected: &mut Option<T>, 335 changed: &mut bool, 336) { 337 state.clear_typeahead(); 338 let count = items.len(); 339 match action { 340 KeyboardAction::Up => { 341 state.highlighted = move_highlight(state.highlighted, count, HighlightStep::Prev); 342 } 343 KeyboardAction::Down => { 344 state.highlighted = move_highlight(state.highlighted, count, HighlightStep::Next); 345 } 346 KeyboardAction::PageUp => { 347 state.highlighted = page_highlight(state.highlighted, count, page_size, false); 348 } 349 KeyboardAction::PageDown => { 350 state.highlighted = page_highlight(state.highlighted, count, page_size, true); 351 } 352 KeyboardAction::Home => { 353 if count > 0 { 354 state.highlighted = Some(0); 355 } 356 } 357 KeyboardAction::End => { 358 if count > 0 { 359 state.highlighted = Some(count - 1); 360 } 361 } 362 KeyboardAction::Enter => { 363 if let Some(idx) = state.highlighted 364 && let Some(item) = items.get(idx) 365 { 366 let next = Some(item.value.clone()); 367 *changed = next != *selected; 368 *selected = next; 369 state.open = false; 370 } 371 } 372 KeyboardAction::Escape => { 373 state.open = false; 374 } 375 } 376} 377 378fn page_highlight( 379 current: Option<usize>, 380 count: usize, 381 page: usize, 382 forward: bool, 383) -> Option<usize> { 384 if count == 0 { 385 return None; 386 } 387 let step = page.max(1); 388 let idx = current.unwrap_or(if forward { 0 } else { count - 1 }); 389 let next = if forward { 390 (idx + step).min(count - 1) 391 } else { 392 idx.saturating_sub(step) 393 }; 394 Some(next) 395} 396 397fn trigger_label<T: Clone + PartialEq>( 398 items: &[DropdownItem<T>], 399 selected: Option<&T>, 400 placeholder: StringKey, 401) -> StringKey { 402 selected 403 .and_then(|sel| items.iter().find(|i| &i.value == sel).map(|i| i.label)) 404 .unwrap_or(placeholder) 405} 406 407fn typeahead_step<T: Clone + PartialEq>( 408 ctx: &mut FrameCtx<'_>, 409 items: &[DropdownItem<T>], 410 state: &mut DropdownState, 411) { 412 let now = ctx.input.frame; 413 let pending = core::mem::take(&mut ctx.input.keys_pressed); 414 let unconsumed = pending.into_iter().fold(Vec::new(), |mut acc, event| { 415 let Some(typed) = printable_char(event) else { 416 acc.push(event); 417 return acc; 418 }; 419 if state 420 .typeahead_last 421 .is_some_and(|t| now.since(t) > TYPEAHEAD_RESET) 422 { 423 state.filter.clear(); 424 } 425 state.filter.push(typed); 426 state.typeahead_last = Some(now); 427 let needle = state.filter.to_lowercase(); 428 let matched = items.iter().position(|item| { 429 ctx.strings.contains(item.label) 430 && ctx 431 .strings 432 .resolve(item.label) 433 .to_lowercase() 434 .starts_with(&needle) 435 }); 436 if let Some(idx) = matched { 437 state.highlighted = Some(idx); 438 } 439 acc 440 }); 441 ctx.input.keys_pressed = unconsumed; 442} 443 444fn printable_char(event: KeyEvent) -> Option<char> { 445 if event.modifiers != ModifierMask::NONE && event.modifiers != ModifierMask::SHIFT { 446 return None; 447 } 448 match event.code { 449 KeyCode::Char(c) => Some(c.get()), 450 KeyCode::Named(NamedKey::Space) => Some(' '), 451 KeyCode::Named(_) => None, 452 } 453} 454 455fn visible_item_count(popup: LayoutRect, item_height: LayoutPx) -> usize { 456 let h = item_height.value(); 457 if h <= 0.0 { 458 return 0; 459 } 460 #[allow( 461 clippy::cast_possible_truncation, 462 clippy::cast_sign_loss, 463 reason = "popup heights yield small non-negative usize" 464 )] 465 let count = (popup.size.height.value() / h).floor() as usize; 466 count 467} 468 469fn item_rect(popup: LayoutRect, item_height: LayoutPx, index: usize) -> LayoutRect { 470 #[allow( 471 clippy::cast_precision_loss, 472 reason = "dropdown indices fit in f32 mantissa" 473 )] 474 let index_f32 = index as f32; 475 let y = popup.origin.y.value() + index_f32 * item_height.value(); 476 LayoutRect::new( 477 LayoutPos::new(popup.origin.x, LayoutPx::new(y)), 478 LayoutSize::new(popup.size.width, item_height), 479 ) 480} 481 482fn current_index<T: Clone + PartialEq>( 483 items: &[DropdownItem<T>], 484 selected: Option<&T>, 485) -> Option<usize> { 486 selected.and_then(|sel| items.iter().position(|item| &item.value == sel)) 487} 488 489#[derive(Copy, Clone, Debug)] 490enum HighlightStep { 491 Prev, 492 Next, 493} 494 495fn move_highlight(current: Option<usize>, count: usize, step: HighlightStep) -> Option<usize> { 496 if count == 0 { 497 return None; 498 } 499 let next = match (current, step) { 500 (None, HighlightStep::Next) => 0, 501 (None, HighlightStep::Prev) => count - 1, 502 (Some(idx), HighlightStep::Next) => (idx + 1) % count, 503 (Some(idx), HighlightStep::Prev) => (idx + count - 1) % count, 504 }; 505 Some(next) 506} 507 508#[derive(Copy, Clone, Debug)] 509enum KeyboardAction { 510 Up, 511 Down, 512 PageUp, 513 PageDown, 514 Home, 515 End, 516 Enter, 517 Escape, 518} 519 520fn take_keyboard_action(ctx: &mut FrameCtx<'_>) -> Option<KeyboardAction> { 521 let event = take_key( 522 ctx.input, 523 &[ 524 TakeKey::named(NamedKey::ArrowUp), 525 TakeKey::named(NamedKey::ArrowDown), 526 TakeKey::named(NamedKey::PageUp), 527 TakeKey::named(NamedKey::PageDown), 528 TakeKey::named(NamedKey::Home), 529 TakeKey::named(NamedKey::End), 530 TakeKey::named(NamedKey::Enter), 531 TakeKey::named(NamedKey::Escape), 532 ], 533 )?; 534 let action = match event.code { 535 KeyCode::Named(NamedKey::ArrowUp) => KeyboardAction::Up, 536 KeyCode::Named(NamedKey::ArrowDown) => KeyboardAction::Down, 537 KeyCode::Named(NamedKey::PageUp) => KeyboardAction::PageUp, 538 KeyCode::Named(NamedKey::PageDown) => KeyboardAction::PageDown, 539 KeyCode::Named(NamedKey::Home) => KeyboardAction::Home, 540 KeyCode::Named(NamedKey::End) => KeyboardAction::End, 541 KeyCode::Named(NamedKey::Enter) => KeyboardAction::Enter, 542 KeyCode::Named(NamedKey::Escape) => KeyboardAction::Escape, 543 _ => unreachable!("take_key only returns the listed candidates"), 544 }; 545 Some(action) 546} 547 548fn trigger_paint( 549 ctx: &FrameCtx<'_>, 550 trigger_rect: LayoutRect, 551 disabled: bool, 552 label_text: StringKey, 553 interaction: Interaction, 554 live_focused: bool, 555) -> Vec<WidgetPaint> { 556 let neutral = ctx.theme().colors.neutral; 557 let radius = ctx.theme().radius.sm; 558 let hovered = interaction.hover(); 559 let pressed = interaction.pressed(); 560 let fill = if disabled { 561 neutral.step(Step12::SUBTLE_BG) 562 } else if pressed { 563 neutral.step(Step12::SELECTED_BG) 564 } else if hovered { 565 neutral.step(Step12::HOVER_BG) 566 } else { 567 neutral.step(Step12::ELEMENT_BG) 568 }; 569 let border = Border { 570 width: StrokeWidth::HAIRLINE, 571 color: neutral.step(if hovered { 572 Step12::HOVER_BORDER 573 } else { 574 Step12::BORDER 575 }), 576 }; 577 let mut paint = vec![ 578 WidgetPaint::Surface { 579 rect: trigger_rect, 580 fill, 581 border: Some(border), 582 radius, 583 elevation: None, 584 }, 585 WidgetPaint::Label { 586 rect: trigger_rect, 587 text: LabelText::Key(label_text), 588 color: if disabled { 589 ctx.theme().colors.text_disabled() 590 } else { 591 ctx.theme().colors.text_primary() 592 }, 593 role: ctx.theme().typography.body, 594 }, 595 WidgetPaint::Mark { 596 rect: trigger_rect, 597 kind: GlyphMark::Chevron, 598 color: ctx.theme().colors.text_secondary(), 599 }, 600 ]; 601 push_focus_ring(ctx, &mut paint, trigger_rect, radius, live_focused); 602 paint 603} 604 605#[cfg(test)] 606mod tests { 607 use std::sync::Arc; 608 609 use super::{Dropdown, DropdownItem, DropdownState, show_dropdown}; 610 use crate::focus::FocusManager; 611 use crate::frame::FrameCtx; 612 use crate::hit_test::{HitFrame, HitState, resolve}; 613 use crate::hotkey::HotkeyTable; 614 use crate::input::{ 615 FrameInstant, InputSnapshot, KeyChar, KeyCode, KeyEvent, ModifierMask, NamedKey, 616 PointerButton, PointerButtonMask, PointerSample, 617 }; 618 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 619 use crate::strings::StringKey; 620 use crate::strings::StringTable; 621 use crate::theme::Theme; 622 use crate::widget_id::{WidgetId, WidgetKey}; 623 624 #[derive(Copy, Clone, Debug, PartialEq, Eq)] 625 enum Choice { 626 Apple, 627 Banana, 628 Cherry, 629 } 630 631 const PLACEHOLDER: StringKey = StringKey::new("dropdown.choose"); 632 633 fn dropdown_id() -> WidgetId { 634 WidgetId::ROOT.child(WidgetKey::new("dropdown")) 635 } 636 637 fn trigger_rect() -> LayoutRect { 638 LayoutRect::new( 639 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 640 LayoutSize::new(LayoutPx::new(160.0), LayoutPx::new(28.0)), 641 ) 642 } 643 644 const ITEM_HEIGHT: LayoutPx = LayoutPx::new(24.0); 645 646 fn items() -> Vec<DropdownItem<Choice>> { 647 vec![ 648 DropdownItem { 649 value: Choice::Apple, 650 label: StringKey::new("fruit.apple"), 651 }, 652 DropdownItem { 653 value: Choice::Banana, 654 label: StringKey::new("fruit.banana"), 655 }, 656 DropdownItem { 657 value: Choice::Cherry, 658 label: StringKey::new("fruit.cherry"), 659 }, 660 ] 661 } 662 663 fn render_with( 664 state: &mut DropdownState, 665 selected: Option<Choice>, 666 focus: &mut FocusManager, 667 snap: &mut InputSnapshot, 668 prev: &HitState, 669 ) -> (super::DropdownResponse<Choice>, HitState, HitFrame) { 670 let theme = Arc::new(Theme::light()); 671 let table = HotkeyTable::new(); 672 let mut hits = HitFrame::new(); 673 let widget = Dropdown::new( 674 dropdown_id(), 675 trigger_rect(), 676 ITEM_HEIGHT, 677 items(), 678 selected, 679 PLACEHOLDER, 680 state, 681 ); 682 let response = { 683 let mut shaper = bone_text::Shaper::new(); 684 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 685 let mut ctx = FrameCtx::new( 686 theme, 687 snap, 688 focus, 689 &table, 690 StringTable::empty(), 691 &mut hits, 692 prev, 693 &mut a11y, 694 &mut shaper, 695 ); 696 show_dropdown(&mut ctx, widget) 697 }; 698 let next_state = resolve(prev, &hits, snap, focus.focused()); 699 (response, next_state, hits) 700 } 701 702 fn pointer_press_at(pos: LayoutPos) -> InputSnapshot { 703 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 704 s.pointer = Some(PointerSample::new(pos)); 705 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 706 s 707 } 708 709 fn pointer_release_at(pos: LayoutPos) -> InputSnapshot { 710 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 711 s.pointer = Some(PointerSample::new(pos)); 712 s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 713 s 714 } 715 716 fn pointer_idle_at(pos: LayoutPos) -> InputSnapshot { 717 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 718 s.pointer = Some(PointerSample::new(pos)); 719 s 720 } 721 722 fn click_at( 723 state: &mut DropdownState, 724 selected: &mut Option<Choice>, 725 focus: &mut FocusManager, 726 prev: &mut HitState, 727 pos: LayoutPos, 728 ) { 729 [ 730 pointer_press_at(pos), 731 pointer_release_at(pos), 732 pointer_idle_at(pos), 733 ] 734 .into_iter() 735 .for_each(|mut snap| { 736 let (response, next, _) = render_with(state, *selected, focus, &mut snap, prev); 737 if response.changed { 738 *selected = response.selected; 739 } 740 *prev = next; 741 }); 742 } 743 744 fn focused_setup() -> FocusManager { 745 let mut focus = FocusManager::new(); 746 focus.register_focusable(dropdown_id()); 747 focus.request_focus(dropdown_id()); 748 focus.end_frame(); 749 focus 750 } 751 752 fn open_state(highlighted: Option<usize>) -> DropdownState { 753 DropdownState { 754 open: true, 755 highlighted, 756 filter: String::new(), 757 typeahead_last: None, 758 focus_seated: true, 759 last_hovered: None, 760 } 761 } 762 763 #[test] 764 fn click_trigger_opens_dropdown() { 765 let mut state = DropdownState::closed(); 766 let mut selected = None; 767 let mut focus = FocusManager::new(); 768 let mut prev = HitState::new(); 769 click_at( 770 &mut state, 771 &mut selected, 772 &mut focus, 773 &mut prev, 774 LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(10.0)), 775 ); 776 assert!(state.open, "first click opens"); 777 click_at( 778 &mut state, 779 &mut selected, 780 &mut focus, 781 &mut prev, 782 LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(10.0)), 783 ); 784 assert!(!state.open, "second click closes"); 785 } 786 787 #[test] 788 fn click_item_selects_and_closes() { 789 let mut state = open_state(Some(0)); 790 let mut selected = None; 791 let mut focus = focused_setup(); 792 let mut prev = HitState::new(); 793 let item_pos = LayoutPos::new(LayoutPx::new(20.0), LayoutPx::new(28.0 + 12.0)); 794 click_at(&mut state, &mut selected, &mut focus, &mut prev, item_pos); 795 assert_eq!(selected, Some(Choice::Apple)); 796 assert!(!state.open); 797 } 798 799 #[test] 800 fn arrow_down_then_enter_picks_next_item() { 801 let mut state = open_state(Some(0)); 802 let selected = None; 803 let mut focus = focused_setup(); 804 let prev = HitState::new(); 805 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 806 snap.keys_pressed.push(KeyEvent::new( 807 KeyCode::Named(NamedKey::ArrowDown), 808 ModifierMask::NONE, 809 )); 810 let (_, _, _) = render_with(&mut state, selected, &mut focus, &mut snap, &prev); 811 assert_eq!(state.highlighted, Some(1)); 812 813 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 814 snap.keys_pressed.push(KeyEvent::new( 815 KeyCode::Named(NamedKey::Enter), 816 ModifierMask::NONE, 817 )); 818 let (response, _, _) = render_with(&mut state, selected, &mut focus, &mut snap, &prev); 819 assert_eq!(response.selected, Some(Choice::Banana)); 820 assert!(!state.open); 821 } 822 823 #[test] 824 fn escape_closes_without_changing_selection() { 825 let mut state = open_state(Some(2)); 826 let selected = Some(Choice::Apple); 827 let mut focus = focused_setup(); 828 let prev = HitState::new(); 829 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 830 snap.keys_pressed.push(KeyEvent::new( 831 KeyCode::Named(NamedKey::Escape), 832 ModifierMask::NONE, 833 )); 834 let (response, _, _) = render_with(&mut state, selected, &mut focus, &mut snap, &prev); 835 assert_eq!(response.selected, selected); 836 assert!(!response.changed); 837 assert!(!state.open); 838 } 839 840 #[test] 841 fn home_end_jumps_to_extremes() { 842 let mut state = open_state(Some(1)); 843 let mut focus = focused_setup(); 844 let prev = HitState::new(); 845 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 846 snap.keys_pressed.push(KeyEvent::new( 847 KeyCode::Named(NamedKey::Home), 848 ModifierMask::NONE, 849 )); 850 let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev); 851 assert_eq!(state.highlighted, Some(0)); 852 853 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 854 snap.keys_pressed.push(KeyEvent::new( 855 KeyCode::Named(NamedKey::End), 856 ModifierMask::NONE, 857 )); 858 let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev); 859 assert_eq!(state.highlighted, Some(2)); 860 } 861 862 #[test] 863 fn arrow_keys_wrap_around_list() { 864 let mut state = open_state(Some(2)); 865 let mut focus = focused_setup(); 866 let prev = HitState::new(); 867 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 868 snap.keys_pressed.push(KeyEvent::new( 869 KeyCode::Named(NamedKey::ArrowDown), 870 ModifierMask::NONE, 871 )); 872 let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev); 873 assert_eq!(state.highlighted, Some(0)); 874 } 875 876 #[test] 877 fn focused_closed_dropdown_opens_on_enter() { 878 let mut state = DropdownState::closed(); 879 let mut focus = focused_setup(); 880 let prev = HitState::new(); 881 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 882 snap.keys_pressed.push(KeyEvent::new( 883 KeyCode::Named(NamedKey::Enter), 884 ModifierMask::NONE, 885 )); 886 let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev); 887 assert!(state.open, "Enter on focused trigger opens"); 888 assert!(snap.keys_pressed.is_empty(), "Enter consumed"); 889 } 890 891 #[test] 892 fn focused_closed_dropdown_opens_on_arrow_down() { 893 let mut state = DropdownState::closed(); 894 let mut focus = focused_setup(); 895 let prev = HitState::new(); 896 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 897 snap.keys_pressed.push(KeyEvent::new( 898 KeyCode::Named(NamedKey::ArrowDown), 899 ModifierMask::NONE, 900 )); 901 let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev); 902 assert!(state.open); 903 } 904 905 #[test] 906 fn open_dropdown_auto_closes_when_focus_leaves() { 907 let mut state = open_state(Some(0)); 908 let mut focus = FocusManager::new(); 909 let prev = HitState::new(); 910 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 911 let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev); 912 assert!(!state.open, "open + seated + no live focus closes"); 913 } 914 915 fn typeahead_render_with( 916 state: &mut DropdownState, 917 focus: &mut FocusManager, 918 snap: &mut InputSnapshot, 919 prev: &HitState, 920 strings: &StringTable, 921 ) -> super::DropdownResponse<Choice> { 922 let theme = Arc::new(Theme::light()); 923 let table = HotkeyTable::new(); 924 let mut hits = HitFrame::new(); 925 let widget = Dropdown::new( 926 dropdown_id(), 927 trigger_rect(), 928 ITEM_HEIGHT, 929 items(), 930 None, 931 PLACEHOLDER, 932 state, 933 ); 934 let mut shaper = bone_text::Shaper::new(); 935 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 936 let mut ctx = FrameCtx::new( 937 theme, 938 snap, 939 focus, 940 &table, 941 strings, 942 &mut hits, 943 prev, 944 &mut a11y, 945 &mut shaper, 946 ); 947 show_dropdown(&mut ctx, widget) 948 } 949 950 #[test] 951 fn typeahead_highlights_first_matching_label() { 952 let mut state = open_state(Some(0)); 953 let mut focus = focused_setup(); 954 let prev = HitState::new(); 955 let strings = StringTable::from_entries([ 956 (StringKey::new("fruit.apple"), "Apple".to_owned()), 957 (StringKey::new("fruit.banana"), "Banana".to_owned()), 958 (StringKey::new("fruit.cherry"), "Cherry".to_owned()), 959 ]); 960 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 961 snap.keys_pressed.push(KeyEvent::new( 962 KeyCode::Char(KeyChar::from_char('b')), 963 ModifierMask::NONE, 964 )); 965 let _ = typeahead_render_with(&mut state, &mut focus, &mut snap, &prev, &strings); 966 assert_eq!(state.highlighted, Some(1)); 967 assert_eq!(state.filter, "b"); 968 } 969 970 #[test] 971 fn typeahead_filter_clears_on_navigation() { 972 let mut state = open_state(Some(0)); 973 state.filter = "ban".to_owned(); 974 let mut focus = focused_setup(); 975 let prev = HitState::new(); 976 let strings = StringTable::new(); 977 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 978 snap.keys_pressed.push(KeyEvent::new( 979 KeyCode::Named(NamedKey::ArrowDown), 980 ModifierMask::NONE, 981 )); 982 let _ = typeahead_render_with(&mut state, &mut focus, &mut snap, &prev, &strings); 983 assert!(state.filter.is_empty(), "navigation clears filter"); 984 } 985 986 #[test] 987 fn pointer_hover_over_item_updates_highlight() { 988 let mut state = open_state(Some(0)); 989 let mut focus = focused_setup(); 990 let mut prev = HitState::new(); 991 let item_pos = LayoutPos::new(LayoutPx::new(20.0), LayoutPx::new(28.0 + 24.0 + 12.0)); 992 (0..2).for_each(|_| { 993 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 994 snap.pointer = Some(PointerSample::new(item_pos)); 995 let (_, next, _) = render_with(&mut state, None, &mut focus, &mut snap, &prev); 996 prev = next; 997 }); 998 assert_eq!( 999 state.highlighted, 1000 Some(1), 1001 "hover over second item bumps highlight", 1002 ); 1003 } 1004 1005 #[test] 1006 fn keyboard_nav_holds_against_stationary_pointer() { 1007 let mut state = open_state(Some(0)); 1008 let mut focus = focused_setup(); 1009 let mut prev = HitState::new(); 1010 let item1_pos = LayoutPos::new(LayoutPx::new(20.0), LayoutPx::new(28.0 + 24.0 + 12.0)); 1011 (0..2).for_each(|_| { 1012 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1013 snap.pointer = Some(PointerSample::new(item1_pos)); 1014 let (_, next, _) = render_with(&mut state, None, &mut focus, &mut snap, &prev); 1015 prev = next; 1016 }); 1017 assert_eq!(state.highlighted, Some(1), "hover seeded highlight"); 1018 1019 let mut nav_snap = InputSnapshot::idle(FrameInstant::ZERO); 1020 nav_snap.pointer = Some(PointerSample::new(item1_pos)); 1021 nav_snap.keys_pressed.push(KeyEvent::new( 1022 KeyCode::Named(NamedKey::ArrowDown), 1023 ModifierMask::NONE, 1024 )); 1025 let (_, after_nav, _) = render_with(&mut state, None, &mut focus, &mut nav_snap, &prev); 1026 prev = after_nav; 1027 assert_eq!( 1028 state.highlighted, 1029 Some(2), 1030 "ArrowDown wins because hovered item did not change", 1031 ); 1032 1033 let mut idle_snap = InputSnapshot::idle(FrameInstant::ZERO); 1034 idle_snap.pointer = Some(PointerSample::new(item1_pos)); 1035 let _ = render_with(&mut state, None, &mut focus, &mut idle_snap, &prev); 1036 assert_eq!( 1037 state.highlighted, 1038 Some(2), 1039 "stationary pointer cannot reclaim highlight from keyboard", 1040 ); 1041 } 1042 1043 #[test] 1044 fn outside_pointer_press_closes_open_popup() { 1045 let mut state = open_state(Some(0)); 1046 let mut focus = focused_setup(); 1047 let prev = HitState::new(); 1048 let mut snap = pointer_press_at(LayoutPos::new(LayoutPx::new(500.0), LayoutPx::new(500.0))); 1049 let (_, _, _) = render_with(&mut state, None, &mut focus, &mut snap, &prev); 1050 assert!(!state.open, "click outside closes"); 1051 } 1052 1053 #[test] 1054 fn outside_press_with_no_pointer_does_not_close() { 1055 let mut state = open_state(Some(0)); 1056 let mut focus = focused_setup(); 1057 let prev = HitState::new(); 1058 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1059 snap.buttons_pressed = 1060 crate::input::PointerButtonMask::just(crate::input::PointerButton::Primary); 1061 let (_, _, _) = render_with(&mut state, None, &mut focus, &mut snap, &prev); 1062 assert!(state.open, "no pointer sample = no outside-click signal"); 1063 } 1064 1065 #[test] 1066 fn typeahead_resets_after_quiet_window() { 1067 let mut state = open_state(Some(0)); 1068 let mut focus = focused_setup(); 1069 let prev = HitState::new(); 1070 let strings = StringTable::from_entries([ 1071 (StringKey::new("fruit.apple"), "Apple".to_owned()), 1072 (StringKey::new("fruit.banana"), "Banana".to_owned()), 1073 (StringKey::new("fruit.cherry"), "Cherry".to_owned()), 1074 ]); 1075 let mut snap = InputSnapshot::idle(FrameInstant::from_duration( 1076 core::time::Duration::from_millis(100), 1077 )); 1078 snap.keys_pressed.push(KeyEvent::new( 1079 KeyCode::Char(KeyChar::from_char('a')), 1080 ModifierMask::NONE, 1081 )); 1082 let _ = typeahead_render_with(&mut state, &mut focus, &mut snap, &prev, &strings); 1083 assert_eq!(state.filter, "a"); 1084 1085 let mut snap2 = InputSnapshot::idle(FrameInstant::from_duration( 1086 core::time::Duration::from_millis(100 + 800), 1087 )); 1088 snap2.keys_pressed.push(KeyEvent::new( 1089 KeyCode::Char(KeyChar::from_char('b')), 1090 ModifierMask::NONE, 1091 )); 1092 let _ = typeahead_render_with(&mut state, &mut focus, &mut snap2, &prev, &strings); 1093 assert_eq!(state.filter, "b", "quiet window reset filter to fresh char"); 1094 assert_eq!(state.highlighted, Some(1)); 1095 } 1096 1097 #[test] 1098 fn page_down_jumps_by_visible_count() { 1099 let mut state = open_state(Some(0)); 1100 let mut focus = focused_setup(); 1101 let prev = HitState::new(); 1102 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1103 snap.keys_pressed.push(KeyEvent::new( 1104 KeyCode::Named(NamedKey::PageDown), 1105 ModifierMask::NONE, 1106 )); 1107 let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev); 1108 assert_eq!( 1109 state.highlighted, 1110 Some(2), 1111 "PageDown jumps to last when within visible count" 1112 ); 1113 } 1114 1115 #[test] 1116 fn page_up_clamps_at_first() { 1117 let mut state = open_state(Some(1)); 1118 let mut focus = focused_setup(); 1119 let prev = HitState::new(); 1120 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1121 snap.keys_pressed.push(KeyEvent::new( 1122 KeyCode::Named(NamedKey::PageUp), 1123 ModifierMask::NONE, 1124 )); 1125 let _ = render_with(&mut state, None, &mut focus, &mut snap, &prev); 1126 assert_eq!(state.highlighted, Some(0)); 1127 } 1128 1129 #[test] 1130 fn popup_rect_below_anchors_under_trigger_and_sizes_to_items() { 1131 let trigger = trigger_rect(); 1132 let popup = super::popup_rect_below(trigger, ITEM_HEIGHT, 4); 1133 assert_eq!(popup.origin.x, trigger.origin.x); 1134 assert!( 1135 (popup.origin.y.value() - (trigger.origin.y.value() + trigger.size.height.value())) 1136 .abs() 1137 < 1e-6, 1138 "popup y must sit at trigger bottom" 1139 ); 1140 assert_eq!(popup.size.width, trigger.size.width); 1141 assert!( 1142 (popup.size.height.value() - 96.0).abs() < 1e-6, 1143 "4 items × 24px = 96px" 1144 ); 1145 } 1146 1147 #[test] 1148 fn typeahead_skips_unresolved_keys() { 1149 let mut state = open_state(Some(0)); 1150 let mut focus = focused_setup(); 1151 let prev = HitState::new(); 1152 let strings = StringTable::new(); 1153 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 1154 snap.keys_pressed.push(KeyEvent::new( 1155 KeyCode::Char(KeyChar::from_char('f')), 1156 ModifierMask::NONE, 1157 )); 1158 let _ = typeahead_render_with(&mut state, &mut focus, &mut snap, &prev, &strings); 1159 assert_eq!( 1160 state.highlighted, 1161 Some(0), 1162 "highlight unchanged when no string entries match", 1163 ); 1164 } 1165 1166 #[test] 1167 fn disabled_dropdown_swallows_clicks() { 1168 let mut state = DropdownState::closed(); 1169 let mut focus = FocusManager::new(); 1170 let mut prev = HitState::new(); 1171 let theme = Arc::new(Theme::light()); 1172 let table = HotkeyTable::new(); 1173 [ 1174 pointer_press_at(LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(10.0))), 1175 pointer_release_at(LayoutPos::new(LayoutPx::new(10.0), LayoutPx::new(10.0))), 1176 ] 1177 .into_iter() 1178 .for_each(|mut snap| { 1179 let mut hits = HitFrame::new(); 1180 let widget = Dropdown::new( 1181 dropdown_id(), 1182 trigger_rect(), 1183 ITEM_HEIGHT, 1184 items(), 1185 None, 1186 PLACEHOLDER, 1187 &mut state, 1188 ) 1189 .disabled(true); 1190 let _ = { 1191 let mut shaper = bone_text::Shaper::new(); 1192 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 1193 let mut ctx = FrameCtx::new( 1194 theme.clone(), 1195 &mut snap, 1196 &mut focus, 1197 &table, 1198 StringTable::empty(), 1199 &mut hits, 1200 &prev, 1201 &mut a11y, 1202 &mut shaper, 1203 ); 1204 show_dropdown(&mut ctx, widget) 1205 }; 1206 prev = resolve(&prev, &hits, &snap, focus.focused()); 1207 }); 1208 assert!(!state.open); 1209 } 1210 1211 #[test] 1212 fn open_popup_wins_hit_over_sibling_pushed_after_at_base_z() { 1213 use crate::frame::InteractDeclaration; 1214 use crate::hit_test::{HitItem, Sense, ZLayer}; 1215 let mut state = open_state(Some(0)); 1216 let mut focus = focused_setup(); 1217 let prev = HitState::new(); 1218 let theme = Arc::new(Theme::light()); 1219 let table = HotkeyTable::new(); 1220 let mut hits = HitFrame::new(); 1221 let item_pos = LayoutPos::new(LayoutPx::new(20.0), LayoutPx::new(28.0 + 12.0)); 1222 let mut snap = pointer_press_at(item_pos); 1223 let widget = Dropdown::new( 1224 dropdown_id(), 1225 trigger_rect(), 1226 ITEM_HEIGHT, 1227 items(), 1228 None, 1229 PLACEHOLDER, 1230 &mut state, 1231 ); 1232 let sibling_id = WidgetId::ROOT.child(WidgetKey::new("sibling_under_popup")); 1233 { 1234 let mut shaper = bone_text::Shaper::new(); 1235 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 1236 let mut ctx = FrameCtx::new( 1237 theme, 1238 &mut snap, 1239 &mut focus, 1240 &table, 1241 StringTable::empty(), 1242 &mut hits, 1243 &prev, 1244 &mut a11y, 1245 &mut shaper, 1246 ); 1247 let _ = show_dropdown(&mut ctx, widget); 1248 let _ = ctx.interact(InteractDeclaration::new( 1249 sibling_id, 1250 LayoutRect::new( 1251 LayoutPos::new(LayoutPx::ZERO, LayoutPx::new(28.0)), 1252 LayoutSize::new(LayoutPx::new(160.0), LayoutPx::new(72.0)), 1253 ), 1254 Sense::INTERACTIVE, 1255 )); 1256 } 1257 let item_at_idx_0 = dropdown_id().child_indexed(WidgetKey::new("item"), 0); 1258 let Some(popup_item) = hits.items().iter().find(|h| h.id == item_at_idx_0) else { 1259 panic!("popup item must be registered in hit frame"); 1260 }; 1261 assert_eq!(popup_item.z, ZLayer::POPUP); 1262 let next = resolve(&prev, &hits, &snap, focus.focused()); 1263 let hit_id = hits 1264 .items() 1265 .iter() 1266 .filter(|item: &&HitItem| { 1267 item.sense.contains(Sense::HOVER) && item.rect.contains(item_pos) 1268 }) 1269 .max_by_key(|item: &&HitItem| item.z) 1270 .map(|item: &HitItem| item.id); 1271 assert_eq!( 1272 hit_id, 1273 Some(item_at_idx_0), 1274 "popup item must beat later sibling at base z when both contain pointer", 1275 ); 1276 assert!( 1277 next.interaction(item_at_idx_0).pressed(), 1278 "press routes to popup item, not sibling", 1279 ); 1280 assert!( 1281 !next.interaction(sibling_id).pressed(), 1282 "sibling did not consume press despite later push order", 1283 ); 1284 } 1285}