Another project
1
fork

Configure Feed

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

feat(ui): dropdown widget

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

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