Skip to main content

fret_ui_kit/primitives/
select.rs

1//! Select helpers (Radix `@radix-ui/react-select` outcomes).
2//!
3//! Upstream Select composes:
4//! - anchored floating placement (`@radix-ui/react-popper`)
5//! - portal rendering (`@radix-ui/react-portal`)
6//! - focus management + outside interaction blocking (`@radix-ui/react-focus-scope`, `DismissableLayer`)
7//! - aria hiding + scroll lock while open (`aria-hidden`, `react-remove-scroll`)
8//! - trigger open keys + typeahead selection while closed.
9//!
10//! In Fret, the "blocking outside interaction" outcome is typically modeled by installing the
11//! select content in a modal overlay layer (barrier-backed) while keeping the content semantics
12//! as `ListBox` rather than `Dialog`.
13//!
14//! This module is intentionally thin: it provides Radix-named entry points for trigger a11y and
15//! overlay request wiring without forcing a visual skin.
16
17use std::sync::{Arc, Mutex};
18use std::time::Duration;
19
20use fret_core::{AppWindowId, Edges, KeyCode, Modifiers, Point, PointerType, Px, Rect, Size};
21use fret_runtime::{Effect, Model, TimerToken};
22use fret_ui::action::{
23    ActionCx, DismissReason, DismissRequestCx, OnDismissRequest, OnPointerUp,
24    OnPressablePointerDown, OnPressablePointerUp, PointerDownCx, PointerMoveCx, PointerUpCx,
25    PressablePointerDownResult, PressablePointerUpResult, UiActionHost, UiPointerActionHost,
26};
27use fret_ui::element::{
28    AnyElement, Elements, LayoutStyle, PointerRegionProps, PressableA11y, PressableProps,
29    PressableState,
30};
31use fret_ui::elements::GlobalElementId;
32use fret_ui::overlay_placement::Side;
33use fret_ui::{ElementContext, UiHost};
34
35use crate::declarative::ModelWatchExt;
36use crate::headless::roving_focus;
37pub use crate::headless::select_item_aligned::{
38    SELECT_ITEM_ALIGNED_CONTENT_MARGIN, SelectItemAlignedInputs, SelectItemAlignedOutputs,
39    select_item_aligned_position,
40};
41use crate::headless::typeahead;
42use crate::overlay;
43use crate::primitives::dialog;
44use crate::primitives::popper;
45use crate::primitives::popper_arrow;
46use crate::primitives::portal_inherited;
47use crate::primitives::trigger_a11y;
48use crate::{IntoUiElement, OverlayController, OverlayPresence, OverlayRequest, collect_children};
49
50/// Stable per-overlay root naming convention for select overlays.
51pub fn select_root_name(id: GlobalElementId) -> String {
52    OverlayController::modal_root_name(id)
53}
54
55/// Returns a `Model<bool>` that behaves like Radix `useControllableState` for `open`.
56///
57/// This is a convenience helper for authoring Radix-shaped select roots:
58/// - if `controlled_open` is provided, it is used directly
59/// - otherwise an internal model is created (once) using `default_open` (Radix `defaultOpen`)
60pub fn select_use_open_model<H: UiHost>(
61    cx: &mut ElementContext<'_, H>,
62    controlled_open: Option<Model<bool>>,
63    default_open: impl FnOnce() -> bool,
64) -> crate::primitives::controllable_state::ControllableModel<bool> {
65    crate::primitives::open_state::open_use_model(cx, controlled_open, default_open)
66}
67
68/// Returns a `Model<Option<Arc<str>>>` that behaves like Radix `useControllableState` for `value`.
69///
70/// Radix models Select values as strings. Fret uses `Arc<str>` for stable, cheap-to-clone keys.
71pub fn select_use_value_model<H: UiHost>(
72    cx: &mut ElementContext<'_, H>,
73    controlled_value: Option<Model<Option<Arc<str>>>>,
74    default_value: impl FnOnce() -> Option<Arc<str>>,
75) -> crate::primitives::controllable_state::ControllableModel<Option<Arc<str>>> {
76    crate::primitives::controllable_state::use_controllable_model(
77        cx,
78        controlled_value,
79        default_value,
80    )
81}
82
83/// A Radix-shaped `Select` root configuration surface (open state only).
84///
85/// Upstream Select owns both `open` and `value` state. Fret's select primitive facade focuses on
86/// input and overlay wiring; recipes typically own the selection model. This root helper exists to
87/// standardize the controlled/uncontrolled open modeling (`open` / `defaultOpen`).
88#[derive(Debug, Clone, Default)]
89pub struct SelectRoot {
90    open: Option<Model<bool>>,
91    default_open: bool,
92}
93
94impl SelectRoot {
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    /// Sets the controlled `open` model (`Some`) or selects uncontrolled mode (`None`).
100    pub fn open(mut self, open: Option<Model<bool>>) -> Self {
101        self.open = open;
102        self
103    }
104
105    /// Sets the uncontrolled initial open value (Radix `defaultOpen`).
106    pub fn default_open(mut self, default_open: bool) -> Self {
107        self.default_open = default_open;
108        self
109    }
110
111    /// Returns a `Model<bool>` that behaves like Radix `useControllableState` for `open`.
112    pub fn use_open_model<H: UiHost>(
113        &self,
114        cx: &mut ElementContext<'_, H>,
115    ) -> crate::primitives::controllable_state::ControllableModel<bool> {
116        select_use_open_model(cx, self.open.clone(), || self.default_open)
117    }
118
119    pub fn open_model<H: UiHost>(&self, cx: &mut ElementContext<'_, H>) -> Model<bool> {
120        self.use_open_model(cx).model()
121    }
122
123    pub fn is_open<H: UiHost>(&self, cx: &mut ElementContext<'_, H>) -> bool {
124        let open_model = self.open_model(cx);
125        cx.watch_model(&open_model)
126            .layout()
127            .copied()
128            .unwrap_or(false)
129    }
130
131    pub fn modal_request<H: UiHost, I, T>(
132        &self,
133        cx: &mut ElementContext<'_, H>,
134        id: GlobalElementId,
135        trigger: GlobalElementId,
136        presence: OverlayPresence,
137        children: I,
138    ) -> OverlayRequest
139    where
140        I: IntoIterator<Item = T>,
141        T: IntoUiElement<H>,
142    {
143        modal_select_request(
144            id,
145            trigger,
146            self.open_model(cx),
147            presence,
148            collect_children(cx, children),
149        )
150    }
151}
152
153/// Stamps Radix-like trigger semantics:
154/// - `role=ComboBox`
155/// - `expanded` mirrors `aria-expanded`
156/// - `controls_element` mirrors `aria-controls` (by element id).
157pub fn apply_select_trigger_a11y(
158    trigger: AnyElement,
159    expanded: bool,
160    label: Option<Arc<str>>,
161    listbox_element: Option<GlobalElementId>,
162) -> AnyElement {
163    trigger_a11y::apply_trigger_semantics(
164        trigger,
165        Some(fret_core::SemanticsRole::ComboBox),
166        label,
167        Some(expanded),
168        listbox_element,
169    )
170}
171
172/// A11y metadata for a Radix-style select trigger pressable.
173pub fn select_trigger_a11y(
174    label: Option<Arc<str>>,
175    expanded: bool,
176    listbox_element: Option<GlobalElementId>,
177) -> PressableA11y {
178    PressableA11y {
179        role: Some(fret_core::SemanticsRole::ComboBox),
180        label,
181        expanded: Some(expanded),
182        controls_element: listbox_element.map(|id| id.0),
183        ..Default::default()
184    }
185}
186
187fn select_listbox_semantics_id_in_scope<H: UiHost>(
188    cx: &mut ElementContext<'_, H>,
189) -> GlobalElementId {
190    select_listbox_pressable_with_id_props::<H, _, _>(cx, |_cx, _st, _id| {
191        (
192            PressableProps {
193                layout: LayoutStyle::default(),
194                enabled: true,
195                focusable: false,
196                ..Default::default()
197            },
198            Vec::<AnyElement>::new(),
199        )
200    })
201    .id
202}
203
204/// Returns the stable semantics element id for a select listbox.
205///
206/// This mirrors Radix `SelectTrigger` / `SelectContent` behavior where the trigger advertises a
207/// `controls` relationship (`aria-controls`) to the listbox content.
208///
209/// Callers should use this root-name-scoped helper rather than trying to capture the listbox id
210/// from the mounted overlay subtree: the trigger needs a stable content id even when the listbox
211/// is not mounted yet.
212pub fn select_listbox_semantics_id<H: UiHost>(
213    cx: &mut ElementContext<'_, H>,
214    overlay_root_name: &str,
215) -> GlobalElementId {
216    let inherited = portal_inherited::PortalInherited::capture(cx);
217    portal_inherited::with_root_name_inheriting(cx, overlay_root_name, inherited, |cx| {
218        select_listbox_semantics_id_in_scope::<H>(cx)
219    })
220}
221
222/// Input-modality-gated initial focus targets for a select-like overlay.
223///
224/// This mirrors the Radix/shadcn "hand feel" contract:
225/// - pointer-open focuses the content container (not the active/selected entry)
226/// - keyboard-open focuses the selected/active entry when available
227#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
228pub struct SelectInitialFocusTargets {
229    pointer_content_focus: Option<GlobalElementId>,
230    keyboard_entry_focus: Option<GlobalElementId>,
231}
232
233impl SelectInitialFocusTargets {
234    pub fn new() -> Self {
235        Self::default()
236    }
237
238    pub fn pointer_content_focus(mut self, focus: Option<GlobalElementId>) -> Self {
239        self.pointer_content_focus = focus;
240        self
241    }
242
243    pub fn keyboard_entry_focus(mut self, focus: Option<GlobalElementId>) -> Self {
244        self.keyboard_entry_focus = focus;
245        self
246    }
247
248    pub fn resolve<H: UiHost>(
249        self,
250        cx: &mut ElementContext<'_, H>,
251        window: AppWindowId,
252    ) -> Option<GlobalElementId> {
253        if fret_ui::input_modality::is_keyboard(cx.app, Some(window)) {
254            self.keyboard_entry_focus.or(self.pointer_content_focus)
255        } else {
256            self.pointer_content_focus
257        }
258    }
259}
260
261/// Builds the select listbox element using a stable call path.
262///
263/// Use this instead of calling `ElementContext::pressable_with_id_props` directly when you need to
264/// derive a stable listbox element id (e.g. for trigger `aria-controls` relationships).
265pub fn select_listbox_pressable_with_id_props<H: UiHost, I, T>(
266    cx: &mut ElementContext<'_, H>,
267    f: impl FnOnce(&mut ElementContext<'_, H>, PressableState, GlobalElementId) -> (PressableProps, I),
268) -> AnyElement
269where
270    I: IntoIterator<Item = T>,
271    T: IntoUiElement<H>,
272{
273    cx.pressable_with_id_props(move |cx, st, id| {
274        let (props, items) = f(cx, st, id);
275        (props, collect_children(cx, items))
276    })
277}
278
279/// Radix Select trigger "open keys" (`OPEN_KEYS`).
280pub fn is_select_open_key(key: KeyCode) -> bool {
281    matches!(
282        key,
283        KeyCode::Space | KeyCode::Enter | KeyCode::ArrowUp | KeyCode::ArrowDown
284    )
285}
286
287/// Returns `true` when the open key is expected to also produce a click/activate event on key-up.
288pub fn select_open_key_suppresses_activate(key: KeyCode) -> bool {
289    matches!(key, KeyCode::Space | KeyCode::Enter)
290}
291
292/// Radix uses a 10px movement threshold to distinguish click-vs-drag outcomes after opening.
293///
294/// We reuse that threshold when emulating touch/pen click-to-open behavior for the trigger.
295pub const SELECT_TRIGGER_CLICK_SLOP_PX: f32 = 10.0;
296
297/// Base UI style delay before mouse-up is allowed to commit the already-selected option.
298///
299/// This protects against accidental commit when the list opens from a trigger pointer interaction
300/// and the release lands over the currently selected row.
301pub const SELECT_MOUSE_UP_SELECTED_DELAY_MS: u64 = 400;
302
303/// Base UI style delay before mouse-up is allowed to commit an unselected option.
304///
305/// When a selected option exists, this delay is shorter than `SELECT_MOUSE_UP_SELECTED_DELAY_MS`.
306pub const SELECT_MOUSE_UP_UNSELECTED_DELAY_MS: u64 = 200;
307
308/// Select mouse interaction policy knobs.
309///
310/// These are intended to make the "anti-misclick" semantics explicit, while keeping Radix-shaped
311/// defaults:
312/// - pointer-up suppression after opening on trigger mouse `pointerdown` (Radix)
313/// - delayed mouse-up selection commit gating (Base UI style; optional)
314#[derive(Debug, Clone, Copy, PartialEq)]
315pub struct SelectMousePolicies {
316    /// When `true`, install the Radix-style pointer-up suppression outcome after opening on mouse
317    /// `pointerdown` (the "pointer up guard").
318    pub pointer_up_guard: bool,
319
320    /// When `Some`, delay mouse-up commits after opening to avoid accidental selection when the
321    /// release lands over an option row (Base UI style).
322    pub mouse_up_selection_gate: Option<SelectMouseUpSelectionGatePolicy>,
323}
324
325impl Default for SelectMousePolicies {
326    fn default() -> Self {
327        Self {
328            pointer_up_guard: true,
329            mouse_up_selection_gate: Some(SelectMouseUpSelectionGatePolicy::default()),
330        }
331    }
332}
333
334#[derive(Debug, Clone, Copy, PartialEq)]
335pub struct SelectMouseUpSelectionGatePolicy {
336    pub selected_delay: Duration,
337    pub unselected_delay_when_has_selected: Duration,
338    pub unselected_delay_when_no_selected: Duration,
339}
340
341impl Default for SelectMouseUpSelectionGatePolicy {
342    fn default() -> Self {
343        Self {
344            selected_delay: Duration::from_millis(SELECT_MOUSE_UP_SELECTED_DELAY_MS),
345            unselected_delay_when_has_selected: Duration::from_millis(
346                SELECT_MOUSE_UP_UNSELECTED_DELAY_MS,
347            ),
348            unselected_delay_when_no_selected: Duration::from_millis(
349                SELECT_MOUSE_UP_SELECTED_DELAY_MS,
350            ),
351        }
352    }
353}
354
355/// Timer-driven mouse-up commit gate for select item rows.
356///
357/// Mirrors Base UI's delayed `onMouseUp` selection policy:
358/// - unselected rows are blocked for ~200ms after opening when a selected row exists
359/// - selected rows are blocked for ~400ms after opening
360/// - if no selected row exists in the list, both paths are blocked for ~400ms
361#[derive(Debug, Default)]
362pub struct SelectMouseUpSelectionGateState {
363    allow_selected_mouse_up: bool,
364    allow_unselected_mouse_up: bool,
365    selected_delay_token: Option<TimerToken>,
366    unselected_delay_token: Option<TimerToken>,
367}
368
369impl SelectMouseUpSelectionGateState {
370    fn cancel_timer(host: &mut dyn UiActionHost, token: &mut Option<TimerToken>) {
371        if let Some(token) = token.take() {
372            host.push_effect(Effect::CancelTimer { token });
373        }
374    }
375
376    fn arm_timer(
377        host: &mut dyn UiActionHost,
378        window: AppWindowId,
379        after: Duration,
380        slot: &mut Option<TimerToken>,
381    ) {
382        Self::cancel_timer(host, slot);
383        let token = host.next_timer_token();
384        host.push_effect(Effect::SetTimer {
385            window: Some(window),
386            token,
387            after,
388            repeat: None,
389        });
390        *slot = Some(token);
391    }
392
393    pub fn arm_on_open(
394        &mut self,
395        host: &mut dyn UiActionHost,
396        window: AppWindowId,
397        has_selected_item_in_list: bool,
398    ) {
399        self.arm_on_open_with_policy(
400            host,
401            window,
402            has_selected_item_in_list,
403            SelectMouseUpSelectionGatePolicy::default(),
404        );
405    }
406
407    pub fn arm_on_open_with_policy(
408        &mut self,
409        host: &mut dyn UiActionHost,
410        window: AppWindowId,
411        has_selected_item_in_list: bool,
412        policy: SelectMouseUpSelectionGatePolicy,
413    ) {
414        self.allow_selected_mouse_up = false;
415        self.allow_unselected_mouse_up = false;
416
417        let unselected_delay = if has_selected_item_in_list {
418            policy.unselected_delay_when_has_selected
419        } else {
420            policy.unselected_delay_when_no_selected
421        };
422
423        Self::arm_timer(
424            host,
425            window,
426            unselected_delay,
427            &mut self.unselected_delay_token,
428        );
429        Self::arm_timer(
430            host,
431            window,
432            policy.selected_delay,
433            &mut self.selected_delay_token,
434        );
435    }
436
437    pub fn reset_without_cancel(&mut self) {
438        self.allow_selected_mouse_up = false;
439        self.allow_unselected_mouse_up = false;
440        self.selected_delay_token = None;
441        self.unselected_delay_token = None;
442    }
443
444    pub fn clear_and_cancel(&mut self, host: &mut dyn UiActionHost) {
445        Self::cancel_timer(host, &mut self.selected_delay_token);
446        Self::cancel_timer(host, &mut self.unselected_delay_token);
447        self.allow_selected_mouse_up = false;
448        self.allow_unselected_mouse_up = false;
449    }
450
451    pub fn on_timer(&mut self, token: TimerToken) -> bool {
452        let mut handled = false;
453        if self.unselected_delay_token == Some(token) {
454            self.unselected_delay_token = None;
455            self.allow_unselected_mouse_up = true;
456            handled = true;
457        }
458        if self.selected_delay_token == Some(token) {
459            self.selected_delay_token = None;
460            self.allow_selected_mouse_up = true;
461            handled = true;
462        }
463        handled
464    }
465
466    pub fn should_allow_item_mouse_up(&self, item_is_selected: bool) -> bool {
467        if item_is_selected {
468            self.allow_selected_mouse_up
469        } else {
470            self.allow_unselected_mouse_up
471        }
472    }
473}
474
475pub type SelectMouseOpenGuard = Arc<Mutex<SelectMouseOpenGuardState>>;
476
477pub fn select_mouse_open_guard() -> SelectMouseOpenGuard {
478    Arc::new(Mutex::new(SelectMouseOpenGuardState::default()))
479}
480
481pub fn select_mouse_open_guard_clear(guard: &SelectMouseOpenGuard) {
482    let mut guard = guard.lock().unwrap_or_else(|e| e.into_inner());
483    guard.clear();
484}
485
486pub fn select_mouse_open_guard_record_if_opened(
487    guard: &SelectMouseOpenGuard,
488    was_open: bool,
489    now_open: bool,
490    down_pos: Point,
491) {
492    let mut guard = guard.lock().unwrap_or_else(|e| e.into_inner());
493    guard.record_if_opened(was_open, now_open, down_pos);
494}
495
496/// Returns `true` when a mouse pointer-up should be treated as part of the original trigger click.
497///
498/// Upstream Radix installs a one-shot "pointer up guard" after opening on mouse `pointerdown` to
499/// avoid immediately selecting an item or dismissing when the pointer is released without moving.
500pub fn select_mouse_open_is_within_click_slop(down: Point, up: Point) -> bool {
501    let dx = (down.x.0 - up.x.0).abs();
502    let dy = (down.y.0 - up.y.0).abs();
503    dx <= SELECT_TRIGGER_CLICK_SLOP_PX && dy <= SELECT_TRIGGER_CLICK_SLOP_PX
504}
505
506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
507pub enum SelectMouseOpenGuardPointerUpDecision {
508    NoGuard,
509    Suppress,
510    Allow,
511}
512
513pub fn select_mouse_open_guard_pointer_up_decision(
514    guard: &mut SelectMouseOpenGuardState,
515    up: PointerUpCx,
516) -> SelectMouseOpenGuardPointerUpDecision {
517    if let (Some(last_tick), Some(decision)) = (
518        guard.last_pointer_up_tick_id,
519        guard.last_pointer_up_decision,
520    ) {
521        // The same platform pointer-up can be routed to multiple elements (e.g. trigger capture +
522        // barrier dismissal). Prefer a tolerant tick match so both consumers observe the same
523        // decision even if the runtime assigns adjacent `TickId`s during routing.
524        if up.tick_id == last_tick || up.tick_id.0 == last_tick.0 + 1 {
525            return decision;
526        }
527    }
528
529    if up.button != fret_core::MouseButton::Left {
530        return SelectMouseOpenGuardPointerUpDecision::NoGuard;
531    }
532    if !matches!(up.pointer_type, PointerType::Mouse | PointerType::Unknown) {
533        return SelectMouseOpenGuardPointerUpDecision::NoGuard;
534    }
535
536    let decision = if let Some(down) = guard.take() {
537        let up_pos = up.position_window.unwrap_or(up.position);
538        if select_mouse_open_is_within_click_slop(down, up_pos) {
539            SelectMouseOpenGuardPointerUpDecision::Suppress
540        } else {
541            SelectMouseOpenGuardPointerUpDecision::Allow
542        }
543    } else {
544        SelectMouseOpenGuardPointerUpDecision::NoGuard
545    };
546
547    guard.last_pointer_up_tick_id = Some(up.tick_id);
548    guard.last_pointer_up_decision = Some(decision);
549
550    decision
551}
552
553pub fn select_mouse_open_guard_pointer_up_decision_shared(
554    guard: &SelectMouseOpenGuard,
555    up: PointerUpCx,
556) -> SelectMouseOpenGuardPointerUpDecision {
557    let mut guard = guard.lock().unwrap_or_else(|e| e.into_inner());
558    select_mouse_open_guard_pointer_up_decision(&mut guard, up)
559}
560
561/// Returns `true` when a pointer-up event should be treated as the original trigger click release
562/// after opening via mouse `pointerdown`.
563///
564/// Upstream Radix installs a one-shot guard to avoid the `pointerup` immediately selecting an
565/// option or dismissing the overlay. We model that guard via `SelectMouseOpenGuardState`.
566pub fn select_mouse_open_guard_should_suppress_pointer_up(
567    guard: &mut SelectMouseOpenGuardState,
568    up: PointerUpCx,
569) -> bool {
570    select_mouse_open_guard_pointer_up_decision(guard, up)
571        == SelectMouseOpenGuardPointerUpDecision::Suppress
572}
573
574pub fn select_mouse_open_guard_should_suppress_pointer_up_shared(
575    guard: &SelectMouseOpenGuard,
576    up: PointerUpCx,
577) -> bool {
578    select_mouse_open_guard_pointer_up_decision_shared(guard, up)
579        == SelectMouseOpenGuardPointerUpDecision::Suppress
580}
581
582#[derive(Debug, Clone, Copy, PartialEq)]
583pub struct SelectItemAlignedLayout {
584    pub outputs: SelectItemAlignedOutputs,
585    pub rect: Rect,
586    pub side: Side,
587}
588
589/// Returns a window-space content rect for Radix Select's `item-aligned` positioning mode.
590///
591/// Upstream Radix computes CSS `left/right` + `top/bottom` style values. In Fret we convert that
592/// output into a concrete `Rect` in window space so renderer backends and non-shadcn recipes can
593/// reuse the same contract without reimplementing the mapping logic.
594pub fn select_item_aligned_layout(inputs: SelectItemAlignedInputs) -> SelectItemAlignedLayout {
595    let outputs = select_item_aligned_position(inputs);
596
597    let margin = SELECT_ITEM_ALIGNED_CONTENT_MARGIN;
598    let window_left = inputs.window.origin.x;
599    let window_top = inputs.window.origin.y;
600    let window_right = Px(window_left.0 + inputs.window.size.width.0);
601    let window_bottom = Px(window_top.0 + inputs.window.size.height.0);
602
603    let clamp_y = |y: Px| {
604        let min_y = Px(window_top.0 + margin.0);
605        let max_y = Px((window_bottom.0 - margin.0 - outputs.height.0).max(min_y.0));
606        Px(y.0.clamp(min_y.0, max_y.0))
607    };
608
609    let trigger_mid_y = Px(inputs.trigger.origin.y.0 + inputs.trigger.size.height.0 / 2.0);
610
611    let x = if let Some(left) = outputs.left {
612        left
613    } else if let Some(right) = outputs.right {
614        Px(window_right.0 - right.0 - outputs.width.0)
615    } else {
616        Px(window_left.0 + margin.0)
617    };
618
619    let y = if outputs.top.is_some() {
620        Px(window_top.0 + margin.0)
621    } else if outputs.bottom.is_some() {
622        // Radix positions the wrapper relative to the window edges (top/bottom), but the visible
623        // listbox content itself is aligned so the selected item's midpoint matches the trigger's
624        // midpoint (when it fits without top overflow). Map that outcome directly into window
625        // space so the resulting rect matches the web goldens for short lists.
626        let selected_item_half_h = Px(inputs.selected_item.size.height.0 / 2.0);
627        // Keep the window-space mapping consistent with `select_item_aligned_position`:
628        // `selectedItem.offsetTop` is relative to the viewport padding edge (padding-inclusive).
629        let selected_item_mid_offset = Px((inputs.selected_item.origin.y.0
630            - inputs.viewport.origin.y.0)
631            + selected_item_half_h.0);
632        let content_top_to_item_mid = Px(inputs.content_border_top.0
633            + inputs.content_padding_top.0
634            + selected_item_mid_offset.0);
635        clamp_y(Px(trigger_mid_y.0 - content_top_to_item_mid.0))
636    } else {
637        Px(window_top.0 + margin.0)
638    };
639
640    let side = if outputs.bottom.is_some() {
641        Side::Bottom
642    } else {
643        Side::Top
644    };
645
646    SelectItemAlignedLayout {
647        outputs,
648        rect: Rect::new(Point::new(x, y), Size::new(outputs.width, outputs.height)),
649        side,
650    }
651}
652
653#[derive(Debug, Clone, Copy, PartialEq)]
654pub struct SelectItemAlignedElementInputs {
655    pub direction: popper::LayoutDirection,
656    pub window: Rect,
657    pub trigger: Rect,
658
659    /// Minimum content width (matches CSS `min-width` on the content element).
660    pub content_min_width: Px,
661
662    pub content_border_top: Px,
663    pub content_padding_top: Px,
664    pub content_border_bottom: Px,
665    pub content_padding_bottom: Px,
666
667    pub viewport_padding_top: Px,
668    pub viewport_padding_bottom: Px,
669
670    pub selected_item_is_first: bool,
671    pub selected_item_is_last: bool,
672
673    pub value_node: GlobalElementId,
674    pub viewport: GlobalElementId,
675    pub listbox: GlobalElementId,
676    pub content_panel: GlobalElementId,
677    /// Optional scroll max offset (Y) for the viewport.
678    ///
679    /// When provided, it is used to derive a stable `items_height` (`viewport.scrollHeight`) even
680    /// when the listbox content element's bounds are clipped by the scroll viewport.
681    pub scroll_max_offset_y: Option<Px>,
682    /// Optional probe element that represents the intrinsic content width (e.g. max item label).
683    ///
684    /// When present, the measured width is used as an additional lower bound for the item-aligned
685    /// solver. This mirrors Radix's behavior where the content can grow beyond the trigger width
686    /// when items require more space.
687    pub content_width_probe: Option<GlobalElementId>,
688    pub selected_item: GlobalElementId,
689    pub selected_item_text: GlobalElementId,
690}
691
692pub fn select_item_aligned_layout_from_elements<H: UiHost>(
693    cx: &ElementContext<'_, H>,
694    inputs: SelectItemAlignedElementInputs,
695) -> Option<SelectItemAlignedLayout> {
696    let value_node = overlay::anchor_bounds_for_element(cx, inputs.value_node)?;
697    // The item-aligned solver expects DOM-like layout-space measurements (`offsetTop` etc). Using
698    // visual bounds here is incorrect because it bakes in render transforms (e.g. presence scale),
699    // which can cause the overlay to "chase" its own animation and jitter on open/close.
700    //
701    // Prefer layout bounds for the scroll viewport. Fall back to the generic anchor helper only
702    // when we have no recorded layout bounds yet (e.g. very first mount).
703    let viewport = cx
704        .last_bounds_for_element(inputs.viewport)
705        .or_else(|| overlay::anchor_bounds_for_element(cx, inputs.viewport))?;
706    // Item-aligned select positioning uses `offsetTop`-like measurements that must remain stable
707    // as the viewport scrolls. Prefer layout bounds for scrolled descendants (they do not include
708    // the scroll render transform) so wheel scrolling cannot cause the overlay to "chase" the
709    // selected item and drift off-screen.
710    let listbox = cx
711        .last_bounds_for_element(inputs.listbox)
712        .or_else(|| overlay::anchor_bounds_for_element(cx, inputs.listbox))?;
713    let mut content = cx
714        .last_bounds_for_element(inputs.content_panel)
715        .or_else(|| overlay::anchor_bounds_for_element(cx, inputs.content_panel))?;
716    content.size.width = Px(content.size.width.0.max(inputs.content_min_width.0));
717    let selected_item = cx
718        .last_bounds_for_element(inputs.selected_item)
719        .or_else(|| overlay::anchor_bounds_for_element(cx, inputs.selected_item))?;
720    let selected_item_text = cx
721        .last_bounds_for_element(inputs.selected_item_text)
722        .or_else(|| overlay::anchor_bounds_for_element(cx, inputs.selected_item_text))?;
723    // The headless solver expects `items_height` to match Radix `viewport.scrollHeight`.
724    //
725    // Prefer deriving this from the viewport height plus its max scroll offset when available,
726    // since scroll implementations may clip content element bounds in a way that makes "content
727    // height" appear equal to the viewport height for short lists.
728    let items_height = if let Some(max_y) = inputs.scroll_max_offset_y {
729        Px((viewport.size.height.0 + max_y.0).max(0.0))
730    } else {
731        // Fallback to content bounds: in many shadcn ports `inputs.listbox` points at the element
732        // that lays out the full listbox content (including viewport padding such as `p-1`).
733        listbox.size.height
734    };
735
736    if let Some(probe_id) = inputs.content_width_probe
737        && let Some(probe) = cx.last_bounds_for_element(probe_id)
738        && probe.size.width.0.is_finite()
739        && probe.size.width.0 > 0.0
740    {
741        // The solver expects the "content" rect to reflect the last known content panel width.
742        // Inflate it with an intrinsic width measurement so the panel can grow to fit long labels.
743        //
744        // We reuse the vertical border thickness as the horizontal border thickness (shadcn/radix
745        // uses a uniform border width).
746        let border_extra = Px(inputs.content_border_top.0 * 2.0);
747        let probed_width = Px(probe.size.width.0 + border_extra.0);
748        content.size.width = Px(content.size.width.0.max(probed_width.0));
749    }
750
751    Some(select_item_aligned_layout(SelectItemAlignedInputs {
752        direction: inputs.direction,
753        window: inputs.window,
754        trigger: inputs.trigger,
755        content,
756        value_node,
757        selected_item_text,
758        selected_item,
759        viewport,
760        content_border_top: inputs.content_border_top,
761        content_padding_top: inputs.content_padding_top,
762        content_border_bottom: inputs.content_border_bottom,
763        content_padding_bottom: inputs.content_padding_bottom,
764        viewport_padding_top: inputs.viewport_padding_top,
765        viewport_padding_bottom: inputs.viewport_padding_bottom,
766        selected_item_is_first: inputs.selected_item_is_first,
767        selected_item_is_last: inputs.selected_item_is_last,
768        items_height,
769    }))
770}
771
772#[derive(Debug, Clone, Copy, PartialEq)]
773pub struct SelectResolvedContentPlacement {
774    pub placement: SelectContentPlacement,
775    pub item_aligned_layout: Option<SelectItemAlignedLayout>,
776}
777
778pub fn select_resolve_content_placement(
779    anchor: Rect,
780    outer: Rect,
781    desired: Size,
782    popper_placement: popper::PopperContentPlacement,
783    arrow_size: Option<Px>,
784    item_aligned_layout: Option<SelectItemAlignedLayout>,
785) -> SelectResolvedContentPlacement {
786    if let Some(item_aligned_layout) = item_aligned_layout {
787        return SelectResolvedContentPlacement {
788            placement: select_content_placement_item_aligned(anchor, item_aligned_layout),
789            item_aligned_layout: Some(item_aligned_layout),
790        };
791    }
792
793    SelectResolvedContentPlacement {
794        placement: select_content_placement_popper(
795            outer,
796            anchor,
797            desired,
798            popper_placement,
799            arrow_size,
800        ),
801        item_aligned_layout: None,
802    }
803}
804
805pub fn select_resolve_content_placement_from_elements<H: UiHost>(
806    cx: &ElementContext<'_, H>,
807    anchor: Rect,
808    outer: Rect,
809    desired: Size,
810    popper_placement: popper::PopperContentPlacement,
811    arrow_size: Option<Px>,
812    item_aligned: Option<SelectItemAlignedElementInputs>,
813) -> SelectResolvedContentPlacement {
814    let item_aligned_layout =
815        item_aligned.and_then(|inputs| select_item_aligned_layout_from_elements(cx, inputs));
816    select_resolve_content_placement(
817        anchor,
818        outer,
819        desired,
820        popper_placement,
821        arrow_size,
822        item_aligned_layout,
823    )
824}
825
826#[derive(Debug, Clone, Copy, PartialEq)]
827pub struct SelectContentPlacement {
828    pub placed: Rect,
829    pub wrapper_insets: Edges,
830    pub side: Side,
831    pub transform_origin: Point,
832    pub popper_layout: Option<fret_ui::overlay_placement::AnchoredPanelLayout>,
833}
834
835pub fn select_content_placement_item_aligned(
836    anchor: Rect,
837    layout: SelectItemAlignedLayout,
838) -> SelectContentPlacement {
839    let pseudo_layout = fret_ui::overlay_placement::AnchoredPanelLayout {
840        rect: layout.rect,
841        side: layout.side,
842        align: popper::Align::Center,
843        arrow: None,
844    };
845
846    SelectContentPlacement {
847        placed: layout.rect,
848        wrapper_insets: Edges::all(Px(0.0)),
849        side: layout.side,
850        transform_origin: popper::popper_content_transform_origin(&pseudo_layout, anchor, None),
851        popper_layout: None,
852    }
853}
854
855pub fn select_content_placement_popper(
856    outer: Rect,
857    anchor: Rect,
858    desired: Size,
859    placement: popper::PopperContentPlacement,
860    arrow_size: Option<Px>,
861) -> SelectContentPlacement {
862    // Radix Select (position="popper") relies on Popper for *placement* but uses CSS max-height
863    // (via `--radix-select-content-available-height`) to constrain the listbox. Floating UI does
864    // not clamp the floating rect size as part of collision shifting, so the content can overflow
865    // the collision boundary when it is taller than the available space.
866    //
867    // Keep the desired size intact and let collision logic affect only the origin.
868    let layout = popper::popper_content_layout_unclamped(outer, anchor, desired, placement);
869    let wrapper_insets = popper_arrow::wrapper_insets(&layout, placement.arrow_protrusion);
870    let transform_origin = popper::popper_content_transform_origin(&layout, anchor, arrow_size);
871
872    SelectContentPlacement {
873        placed: layout.rect,
874        wrapper_insets,
875        side: layout.side,
876        transform_origin,
877        popper_layout: Some(layout),
878    }
879}
880
881#[derive(Debug, Clone, Copy, PartialEq)]
882pub struct SelectPopperVars {
883    pub available_width: Px,
884    pub available_height: Px,
885    pub trigger_width: Px,
886    pub trigger_height: Px,
887}
888
889pub fn select_popper_desired_width(outer: Rect, anchor: Rect, min_width: Px) -> Px {
890    popper::popper_desired_width(outer, anchor, min_width)
891}
892
893/// Compute Radix-like "select popper vars" (`--radix-select-*`) for recipes.
894///
895/// Upstream Radix wires these through from `@radix-ui/react-popper`:
896/// - `--radix-select-content-available-width`
897/// - `--radix-select-content-available-height`
898/// - `--radix-select-trigger-width`
899/// - `--radix-select-trigger-height`
900///
901/// In Fret, we compute the same concepts as a structured return value so recipes can size and
902/// constrain the listbox without relying on CSS variables.
903pub fn select_popper_vars(
904    outer: Rect,
905    anchor: Rect,
906    min_width: Px,
907    placement: popper::PopperContentPlacement,
908) -> SelectPopperVars {
909    let metrics =
910        popper::popper_available_metrics_for_placement(outer, anchor, min_width, placement);
911    SelectPopperVars {
912        available_width: metrics.available_width,
913        available_height: metrics.available_height,
914        trigger_width: metrics.anchor_width,
915        trigger_height: metrics.anchor_height,
916    }
917}
918
919/// Compute a Radix-like default max height for select popper content.
920///
921/// Upstream Radix sets `max-height: var(--radix-select-content-available-height)` for shadcn
922/// recipes. In Fret, we compute the same concept using `popper_available_metrics(...)` so recipes
923/// can size the listbox without relying on CSS variables.
924pub fn select_popper_available_height(
925    outer: Rect,
926    anchor: Rect,
927    min_width: Px,
928    placement: popper::PopperContentPlacement,
929) -> Px {
930    select_popper_vars(outer, anchor, min_width, placement).available_height
931}
932
933/// Radix-like select typeahead clear timeout (in milliseconds).
934///
935/// Upstream Radix resets the typeahead search 1 second after it was last updated.
936pub const SELECT_TYPEAHEAD_CLEAR_TIMEOUT_MS: u64 = 1000;
937
938/// Timer-driven typeahead query state (Radix-style).
939#[derive(Debug, Default)]
940pub struct TimedTypeaheadState {
941    query: String,
942    clear_token: Option<TimerToken>,
943}
944
945impl TimedTypeaheadState {
946    pub fn query(&self) -> &str {
947        self.query.as_str()
948    }
949
950    pub fn clear_and_cancel(&mut self, host: &mut dyn UiActionHost) {
951        if let Some(token) = self.clear_token.take() {
952            host.push_effect(Effect::CancelTimer { token });
953        }
954        self.query.clear();
955    }
956
957    pub fn on_timer(&mut self, token: TimerToken) -> bool {
958        if self.clear_token == Some(token) {
959            self.clear_token = None;
960            self.query.clear();
961            return true;
962        }
963        false
964    }
965
966    pub fn push_key_and_arm_timer(
967        &mut self,
968        host: &mut dyn UiActionHost,
969        window: AppWindowId,
970        key: KeyCode,
971        timeout: Duration,
972    ) -> Option<char> {
973        let ch = fret_core::keycode_to_ascii_lowercase(key)?;
974        self.query.push(ch);
975        if let Some(token) = self.clear_token.take() {
976            host.push_effect(Effect::CancelTimer { token });
977        }
978        let token = host.next_timer_token();
979        self.clear_token = Some(token);
980        host.push_effect(Effect::SetTimer {
981            window: Some(window),
982            token,
983            after: timeout,
984            repeat: None,
985        });
986        Some(ch)
987    }
988}
989
990/// Closed-state trigger policy for Radix-style select.
991///
992/// This models two coupled Radix outcomes:
993/// - Trigger open keys open the listbox on key-down (and suppress the ensuing key-up activation).
994/// - While closed, alphanumeric typeahead updates the selected value without opening.
995#[derive(Debug, Default)]
996pub struct SelectTriggerKeyState {
997    suppress_next_activate: bool,
998    typeahead: TimedTypeaheadState,
999}
1000
1001impl SelectTriggerKeyState {
1002    pub fn take_suppress_next_activate(&mut self) -> bool {
1003        let v = self.suppress_next_activate;
1004        self.suppress_next_activate = false;
1005        v
1006    }
1007
1008    pub fn clear_typeahead(&mut self, host: &mut dyn UiActionHost) {
1009        self.typeahead.clear_and_cancel(host);
1010    }
1011
1012    pub fn reset_typeahead_buffer(&mut self) {
1013        self.typeahead.query.clear();
1014        self.typeahead.clear_token = None;
1015    }
1016
1017    pub fn typeahead_query(&self) -> &str {
1018        self.typeahead.query()
1019    }
1020
1021    pub fn push_typeahead_key_and_arm_timer(
1022        &mut self,
1023        host: &mut dyn UiActionHost,
1024        window: AppWindowId,
1025        key: KeyCode,
1026    ) -> Option<char> {
1027        let timeout = Duration::from_millis(SELECT_TYPEAHEAD_CLEAR_TIMEOUT_MS);
1028        self.typeahead
1029            .push_key_and_arm_timer(host, window, key, timeout)
1030    }
1031
1032    pub fn on_timer(&mut self, token: TimerToken) -> bool {
1033        self.typeahead.on_timer(token)
1034    }
1035
1036    pub fn handle_key_down_when_closed(
1037        &mut self,
1038        host: &mut dyn UiActionHost,
1039        window: AppWindowId,
1040        open: &Model<bool>,
1041        value: &Model<Option<Arc<str>>>,
1042        values: &[Arc<str>],
1043        labels: &[Arc<str>],
1044        disabled: &[bool],
1045        key: KeyCode,
1046        modifiers: Modifiers,
1047        repeat: bool,
1048    ) -> bool {
1049        if repeat {
1050            return false;
1051        }
1052
1053        let is_open = host.models_mut().get_copied(open).unwrap_or(false);
1054        if is_open {
1055            return false;
1056        }
1057
1058        let is_modifier_key = modifiers.ctrl || modifiers.alt || modifiers.meta || modifiers.alt_gr;
1059        if is_modifier_key {
1060            return false;
1061        }
1062
1063        if key == KeyCode::Space && !self.typeahead.query().is_empty() {
1064            return true;
1065        }
1066
1067        if is_select_open_key(key) {
1068            if select_open_key_suppresses_activate(key) {
1069                self.suppress_next_activate = true;
1070            }
1071            self.typeahead.clear_and_cancel(host);
1072            let _ = host.models_mut().update(open, |v| *v = true);
1073            host.request_redraw(window);
1074            return true;
1075        }
1076
1077        let timeout = Duration::from_millis(SELECT_TYPEAHEAD_CLEAR_TIMEOUT_MS);
1078        let Some(_ch) = self
1079            .typeahead
1080            .push_key_and_arm_timer(host, window, key, timeout)
1081        else {
1082            return false;
1083        };
1084
1085        let current = host.models_mut().read(value, |v| v.clone()).ok().flatten();
1086        let current_idx = current
1087            .as_ref()
1088            .and_then(|v| values.iter().position(|it| it.as_ref() == v.as_ref()));
1089
1090        if let Some(next) = typeahead::match_prefix_arc_str(
1091            labels,
1092            disabled,
1093            self.typeahead.query(),
1094            current_idx,
1095            true,
1096        ) && let Some(next_value) = values.get(next).cloned()
1097        {
1098            let _ = host.models_mut().update(value, |v| *v = Some(next_value));
1099            host.request_redraw(window);
1100        }
1101
1102        true
1103    }
1104}
1105
1106/// One-shot pointer-up guard used when a select is opened via mouse `pointerdown`.
1107///
1108/// This mirrors Radix Select's behavior: the pointer-up that completes the click should not
1109/// immediately select an item nor dismiss the content.
1110#[derive(Debug, Default)]
1111pub struct SelectMouseOpenGuardState {
1112    mouse_open_down_pos: Option<Point>,
1113    last_pointer_up_tick_id: Option<fret_runtime::TickId>,
1114    last_pointer_up_decision: Option<SelectMouseOpenGuardPointerUpDecision>,
1115}
1116
1117impl SelectMouseOpenGuardState {
1118    pub fn clear(&mut self) {
1119        self.mouse_open_down_pos = None;
1120        self.last_pointer_up_tick_id = None;
1121        self.last_pointer_up_decision = None;
1122    }
1123
1124    pub fn record_if_opened(&mut self, was_open: bool, now_open: bool, down_pos: Point) {
1125        if !was_open && now_open {
1126            self.mouse_open_down_pos = Some(down_pos);
1127        } else {
1128            self.mouse_open_down_pos = None;
1129        }
1130        self.last_pointer_up_tick_id = None;
1131        self.last_pointer_up_decision = None;
1132    }
1133
1134    pub fn take(&mut self) -> Option<Point> {
1135        self.mouse_open_down_pos.take()
1136    }
1137}
1138
1139/// Pointer policy for Radix-style select triggers.
1140///
1141/// Upstream Radix opens on `pointerdown` for mouse (and prevents the trigger from stealing focus),
1142/// while touch/pen devices open on click to avoid scroll-to-open.
1143#[derive(Debug, Default)]
1144pub struct SelectTriggerPointerState {
1145    down_pos: Option<Point>,
1146    moved: bool,
1147    captured: bool,
1148}
1149
1150impl SelectTriggerPointerState {
1151    fn reset(&mut self) {
1152        self.down_pos = None;
1153        self.moved = false;
1154        self.captured = false;
1155    }
1156
1157    fn moved_beyond_slop(&self, current: Point) -> bool {
1158        let Some(down) = self.down_pos else {
1159            return false;
1160        };
1161        (down.x.0 - current.x.0).abs() > SELECT_TRIGGER_CLICK_SLOP_PX
1162            || (down.y.0 - current.y.0).abs() > SELECT_TRIGGER_CLICK_SLOP_PX
1163    }
1164
1165    pub fn handle_pointer_down(
1166        &mut self,
1167        host: &mut dyn UiPointerActionHost,
1168        action_cx: ActionCx,
1169        down: PointerDownCx,
1170        open: &Model<bool>,
1171        enabled: bool,
1172    ) -> bool {
1173        if !enabled {
1174            return false;
1175        }
1176        if down.button != fret_core::MouseButton::Left {
1177            return false;
1178        }
1179
1180        let is_macos_ctrl_click = cfg!(target_os = "macos")
1181            && down.modifiers.ctrl
1182            && down.pointer_type == PointerType::Mouse;
1183        if is_macos_ctrl_click {
1184            return false;
1185        }
1186
1187        match down.pointer_type {
1188            PointerType::Mouse | PointerType::Unknown => {
1189                let was_open = host.models_mut().get_copied(open).unwrap_or(false);
1190                if was_open {
1191                    let _ = host.models_mut().update(open, |v| *v = false);
1192                    host.request_focus(action_cx.target);
1193                    host.request_redraw(action_cx.window);
1194                    return true;
1195                }
1196
1197                let _ = host.models_mut().update(open, |v| *v = true);
1198                host.request_redraw(action_cx.window);
1199                host.prevent_default(fret_runtime::DefaultAction::FocusOnPointerDown);
1200                true
1201            }
1202            PointerType::Touch | PointerType::Pen => {
1203                self.down_pos = Some(down.position);
1204                self.moved = false;
1205                self.captured = true;
1206                host.capture_pointer();
1207                true
1208            }
1209        }
1210    }
1211
1212    pub fn handle_pointer_move(
1213        &mut self,
1214        _host: &mut dyn UiPointerActionHost,
1215        _action_cx: ActionCx,
1216        mv: PointerMoveCx,
1217    ) -> bool {
1218        if !self.captured {
1219            return false;
1220        }
1221        if !self.moved && self.moved_beyond_slop(mv.position) {
1222            self.moved = true;
1223        }
1224        true
1225    }
1226
1227    pub fn handle_pointer_up(
1228        &mut self,
1229        host: &mut dyn UiPointerActionHost,
1230        action_cx: ActionCx,
1231        up: PointerUpCx,
1232        open: &Model<bool>,
1233        enabled: bool,
1234    ) -> bool {
1235        if !enabled {
1236            self.reset();
1237            return false;
1238        }
1239        if up.button != fret_core::MouseButton::Left {
1240            self.reset();
1241            return false;
1242        }
1243        if !self.captured {
1244            self.reset();
1245            return false;
1246        }
1247
1248        host.release_pointer_capture();
1249        self.captured = false;
1250
1251        let should_open = !self.moved
1252            && self.down_pos.is_some()
1253            && !self.moved_beyond_slop(up.position)
1254            && host.bounds().contains(up.position);
1255
1256        self.reset();
1257
1258        if should_open {
1259            let _ = host.models_mut().update(open, |v| *v = true);
1260            host.request_redraw(action_cx.window);
1261        }
1262        true
1263    }
1264}
1265
1266/// Open-state listbox policy for Radix-style select content.
1267///
1268/// This mirrors Radix outcomes inside `SelectContent`:
1269/// - `Escape` closes.
1270/// - `Tab` is suppressed (select should not be navigated using Tab).
1271/// - `Home/End/ArrowUp/ArrowDown` move the active option (skipping disabled).
1272/// - `Enter/Space` commits the active option and closes.
1273/// - Typeahead search moves the active option (with repeated-search normalization).
1274#[derive(Debug, Default)]
1275pub struct SelectContentKeyState {
1276    active_row: Option<usize>,
1277    typeahead: TimedTypeaheadState,
1278}
1279
1280impl SelectContentKeyState {
1281    pub fn active_row(&self) -> Option<usize> {
1282        self.active_row
1283    }
1284
1285    pub fn set_active_row(&mut self, row: Option<usize>) {
1286        self.active_row = row;
1287    }
1288
1289    pub fn reset_on_open(&mut self, initial_active_row: Option<usize>) {
1290        self.active_row = initial_active_row;
1291        self.typeahead.query.clear();
1292        self.typeahead.clear_token = None;
1293    }
1294
1295    pub fn clear_typeahead(&mut self, host: &mut dyn UiActionHost) {
1296        self.typeahead.clear_and_cancel(host);
1297    }
1298
1299    pub fn on_timer(&mut self, token: TimerToken) -> bool {
1300        self.typeahead.on_timer(token)
1301    }
1302
1303    pub fn handle_key_down_when_open(
1304        &mut self,
1305        host: &mut dyn UiActionHost,
1306        action_cx: ActionCx,
1307        open: &Model<bool>,
1308        value: &Model<Option<Arc<str>>>,
1309        values_by_row: &[Option<Arc<str>>],
1310        labels_by_row: &[Arc<str>],
1311        disabled_by_row: &[bool],
1312        on_value_change: Option<&Arc<dyn Fn(&mut dyn UiActionHost, ActionCx, Arc<str>) + 'static>>,
1313        key: KeyCode,
1314        repeat: bool,
1315        loop_navigation: bool,
1316    ) -> bool {
1317        if repeat {
1318            return false;
1319        }
1320
1321        let window = action_cx.window;
1322        let is_open = host.models_mut().get_copied(open).unwrap_or(false);
1323        if !is_open {
1324            return false;
1325        }
1326
1327        if key == KeyCode::Space && !self.typeahead.query().is_empty() {
1328            return true;
1329        }
1330
1331        let current = self
1332            .active_row
1333            .or_else(|| roving_focus::first_enabled(disabled_by_row));
1334
1335        match key {
1336            KeyCode::Tab => true,
1337            KeyCode::Escape => {
1338                let _ = host.models_mut().update(open, |v| *v = false);
1339                host.request_redraw(window);
1340                true
1341            }
1342            KeyCode::Home => {
1343                self.active_row = roving_focus::first_enabled(disabled_by_row);
1344                host.request_redraw(window);
1345                true
1346            }
1347            KeyCode::End => {
1348                self.active_row = roving_focus::last_enabled(disabled_by_row);
1349                host.request_redraw(window);
1350                true
1351            }
1352            KeyCode::ArrowDown | KeyCode::ArrowUp => {
1353                let Some(current) = current else {
1354                    return true;
1355                };
1356                let forward = key == KeyCode::ArrowDown;
1357                self.active_row =
1358                    roving_focus::next_enabled(disabled_by_row, current, forward, loop_navigation)
1359                        .or(Some(current));
1360                host.request_redraw(window);
1361                true
1362            }
1363            KeyCode::Enter | KeyCode::Space => {
1364                let Some(active_row) = current else {
1365                    return true;
1366                };
1367                let is_disabled = disabled_by_row.get(active_row).copied().unwrap_or(true);
1368                if is_disabled {
1369                    return true;
1370                }
1371                if let Some(chosen_value) = values_by_row.get(active_row).cloned().flatten() {
1372                    let before = host.models_mut().read(value, |v| v.clone()).ok().flatten();
1373                    let did_change = before.as_deref() != Some(chosen_value.as_ref());
1374                    if did_change {
1375                        let _ = host
1376                            .models_mut()
1377                            .update(value, |v| *v = Some(chosen_value.clone()));
1378                        if let Some(on_value_change) = on_value_change {
1379                            on_value_change(host, action_cx, chosen_value.clone());
1380                        }
1381                    }
1382                    let _ = host.models_mut().update(open, |v| *v = false);
1383                    host.request_redraw(window);
1384                }
1385                true
1386            }
1387            _ => {
1388                let timeout = Duration::from_millis(SELECT_TYPEAHEAD_CLEAR_TIMEOUT_MS);
1389                let Some(_ch) = self
1390                    .typeahead
1391                    .push_key_and_arm_timer(host, window, key, timeout)
1392                else {
1393                    return false;
1394                };
1395
1396                let next = typeahead::match_prefix_arc_str(
1397                    labels_by_row,
1398                    disabled_by_row,
1399                    self.typeahead.query(),
1400                    current,
1401                    true,
1402                );
1403                if next != self.active_row {
1404                    self.active_row = next;
1405                    host.request_redraw(window);
1406                }
1407                true
1408            }
1409        }
1410    }
1411}
1412
1413/// Layout used for a Radix-like select modal barrier element.
1414///
1415/// This is a re-export of the shared modal barrier layout from `primitives::dialog`.
1416pub fn select_modal_barrier_layout() -> LayoutStyle {
1417    dialog::modal_barrier_layout()
1418}
1419
1420/// Builds a full-window modal barrier for Radix-like select overlays.
1421///
1422/// This is a thin wrapper over `primitives::dialog::modal_barrier` so non-shadcn users can reuse
1423/// the same "disable outside pointer events" outcome without depending on dialog primitives.
1424pub fn select_modal_barrier<H: UiHost, I, T>(
1425    cx: &mut ElementContext<'_, H>,
1426    open: Model<bool>,
1427    dismiss_on_press: bool,
1428    children: I,
1429) -> AnyElement
1430where
1431    I: IntoIterator<Item = T>,
1432    T: IntoUiElement<H>,
1433{
1434    select_modal_barrier_with_dismiss_handler(cx, open, dismiss_on_press, None, children)
1435}
1436
1437/// Builds a full-window modal barrier for Radix-like select overlays while routing dismissals
1438/// through an optional dismiss handler.
1439///
1440/// When `on_dismiss_request` is provided and `dismiss_on_press` is enabled, barrier presses invoke
1441/// the handler with `DismissReason::OutsidePress` and do not close `open` automatically.
1442pub fn select_modal_barrier_with_dismiss_handler<H: UiHost, I, T>(
1443    cx: &mut ElementContext<'_, H>,
1444    open: Model<bool>,
1445    dismiss_on_press: bool,
1446    on_dismiss_request: Option<OnDismissRequest>,
1447    children: I,
1448) -> AnyElement
1449where
1450    I: IntoIterator<Item = T>,
1451    T: IntoUiElement<H>,
1452{
1453    dialog::modal_barrier_with_dismiss_handler(
1454        cx,
1455        open,
1456        dismiss_on_press,
1457        on_dismiss_request,
1458        children,
1459    )
1460}
1461
1462/// Convenience helper to assemble select modal overlay children in a Radix-like order: barrier then
1463/// content.
1464pub fn select_modal_layer_elements<H: UiHost, I, T>(
1465    cx: &mut ElementContext<'_, H>,
1466    open: Model<bool>,
1467    dismiss_on_press: bool,
1468    barrier_children: I,
1469    content: AnyElement,
1470) -> Elements
1471where
1472    I: IntoIterator<Item = T>,
1473    T: IntoUiElement<H>,
1474{
1475    Elements::from([
1476        select_modal_barrier(cx, open, dismiss_on_press, barrier_children),
1477        content,
1478    ])
1479}
1480
1481/// Convenience helper to assemble select modal overlay children in a Radix-like order (barrier then
1482/// content), while routing barrier presses through an optional dismiss handler.
1483pub fn select_modal_layer_elements_with_dismiss_handler<H: UiHost, I, T>(
1484    cx: &mut ElementContext<'_, H>,
1485    open: Model<bool>,
1486    dismiss_on_press: bool,
1487    on_dismiss_request: Option<OnDismissRequest>,
1488    barrier_children: I,
1489    content: AnyElement,
1490) -> Elements
1491where
1492    I: IntoIterator<Item = T>,
1493    T: IntoUiElement<H>,
1494{
1495    Elements::from([
1496        select_modal_barrier_with_dismiss_handler(
1497            cx,
1498            open,
1499            dismiss_on_press,
1500            on_dismiss_request,
1501            barrier_children,
1502        ),
1503        content,
1504    ])
1505}
1506
1507/// Builds a pointer region that guards the next mouse `pointerup` after opening on `pointerdown`.
1508///
1509/// This element should be installed as a top-most sibling in the overlay (i.e. after the content)
1510/// so it can swallow the click-release that opened the select, matching Radix's one-shot
1511/// pointer-up guard even when the release lands over the content.
1512pub fn select_modal_barrier_pointer_up_guard<H: UiHost>(
1513    cx: &mut ElementContext<'_, H>,
1514    _open: Model<bool>,
1515    guard: SelectMouseOpenGuard,
1516) -> AnyElement {
1517    let down = guard
1518        .lock()
1519        .unwrap_or_else(|e| e.into_inner())
1520        .mouse_open_down_pos;
1521    let enabled = down.is_some();
1522    let layout = if let Some(down) = down {
1523        // Only cover the click-slop rect around the trigger down position. If the pointer-up lands
1524        // outside this rect, we should not intercept it (drag-to-select / outside-dismiss).
1525        let slop = SELECT_TRIGGER_CLICK_SLOP_PX;
1526        let size = Px(slop * 2.0);
1527        let left = Px((down.x.0 - slop).max(0.0));
1528        let top = Px((down.y.0 - slop).max(0.0));
1529
1530        let mut layout = LayoutStyle::default();
1531        layout.position = fret_ui::element::PositionStyle::Absolute;
1532        layout.inset = fret_ui::element::InsetStyle {
1533            left: Some(left).into(),
1534            right: None.into(),
1535            top: Some(top).into(),
1536            bottom: None.into(),
1537        };
1538        layout.size.width = fret_ui::element::Length::Px(size);
1539        layout.size.height = fret_ui::element::Length::Px(size);
1540        layout
1541    } else {
1542        select_modal_barrier_layout()
1543    };
1544    cx.pointer_region(
1545        PointerRegionProps {
1546            layout,
1547            enabled,
1548            capture_phase_pointer_moves: false,
1549        },
1550        move |cx| {
1551            let guard_for_pointer_up = guard.clone();
1552            cx.pointer_region_on_pointer_up(Arc::new(move |_host, _action_cx, up: PointerUpCx| {
1553                match select_mouse_open_guard_pointer_up_decision_shared(&guard_for_pointer_up, up)
1554                {
1555                    SelectMouseOpenGuardPointerUpDecision::NoGuard => false,
1556                    SelectMouseOpenGuardPointerUpDecision::Suppress => true,
1557                    // Outside the click slop this element should not be hit, but keep the behavior
1558                    // conservative in case the host routing changes.
1559                    SelectMouseOpenGuardPointerUpDecision::Allow => false,
1560                }
1561            }));
1562            Vec::new()
1563        },
1564    )
1565}
1566
1567/// Convenience helper to assemble select modal overlay children with a pointer-up guard installed
1568/// inside the barrier (Radix-like behavior when opening on mouse `pointerdown`).
1569pub fn select_modal_layer_elements_with_pointer_up_guard<H: UiHost, I, T>(
1570    cx: &mut ElementContext<'_, H>,
1571    open: Model<bool>,
1572    dismiss_on_press: bool,
1573    guard: SelectMouseOpenGuard,
1574    barrier_children: I,
1575    content: AnyElement,
1576) -> Elements
1577where
1578    I: IntoIterator<Item = T>,
1579    T: IntoUiElement<H>,
1580{
1581    let guard_el = select_modal_barrier_pointer_up_guard(cx, open.clone(), guard);
1582    Elements::from([
1583        select_modal_barrier(cx, open, dismiss_on_press, barrier_children),
1584        content,
1585        guard_el,
1586    ])
1587}
1588
1589/// Convenience helper to assemble select modal overlay children with a pointer-up guard installed
1590/// inside the barrier (Radix behavior when opening on mouse `pointerdown`), while routing barrier
1591/// presses through an optional dismiss handler.
1592pub fn select_modal_layer_elements_with_pointer_up_guard_and_dismiss_handler<H: UiHost, I, T>(
1593    cx: &mut ElementContext<'_, H>,
1594    open: Model<bool>,
1595    dismiss_on_press: bool,
1596    on_dismiss_request: Option<OnDismissRequest>,
1597    guard: SelectMouseOpenGuard,
1598    barrier_children: I,
1599    content: AnyElement,
1600) -> Elements
1601where
1602    I: IntoIterator<Item = T>,
1603    T: IntoUiElement<H>,
1604{
1605    let guard_el = select_modal_barrier_pointer_up_guard(cx, open.clone(), guard.clone());
1606    let barrier_children = collect_children(cx, barrier_children);
1607    let barrier = if dismiss_on_press {
1608        let open_for_pressable = open.clone();
1609        let guard_for_pressable = guard;
1610        let on_dismiss_request_for_pressable = on_dismiss_request.clone();
1611        cx.pressable(
1612            PressableProps {
1613                layout: select_modal_barrier_layout(),
1614                enabled: true,
1615                focusable: false,
1616                ..Default::default()
1617            },
1618            move |cx, _st| {
1619                let open_for_pointer_up = open_for_pressable.clone();
1620                let guard_for_pointer_up = guard_for_pressable.clone();
1621                let on_dismiss_request_for_pointer_up = on_dismiss_request_for_pressable.clone();
1622                cx.pressable_add_on_pointer_up(Arc::new(move |host, action_cx, up| {
1623                    match select_mouse_open_guard_pointer_up_decision_shared(
1624                        &guard_for_pointer_up,
1625                        up,
1626                    ) {
1627                        SelectMouseOpenGuardPointerUpDecision::Suppress => {
1628                            host.request_redraw(action_cx.window);
1629                            return PressablePointerUpResult::SkipActivate;
1630                        }
1631                        SelectMouseOpenGuardPointerUpDecision::Allow
1632                        | SelectMouseOpenGuardPointerUpDecision::NoGuard => {}
1633                    }
1634
1635                    if let Some(on_dismiss_request) = on_dismiss_request_for_pointer_up.as_ref() {
1636                        let mut req = DismissRequestCx::new(DismissReason::OutsidePress {
1637                            pointer: Some(fret_ui::action::OutsidePressCx {
1638                                pointer_id: up.pointer_id,
1639                                pointer_type: up.pointer_type,
1640                                button: up.button,
1641                                modifiers: up.modifiers,
1642                                click_count: up.click_count,
1643                            }),
1644                        });
1645                        on_dismiss_request(host, action_cx, &mut req);
1646                        if !req.default_prevented() {
1647                            let _ = host
1648                                .models_mut()
1649                                .update(&open_for_pointer_up, |v| *v = false);
1650                        }
1651                    } else {
1652                        let _ = host
1653                            .models_mut()
1654                            .update(&open_for_pointer_up, |v| *v = false);
1655                    }
1656
1657                    PressablePointerUpResult::SkipActivate
1658                }));
1659
1660                barrier_children
1661            },
1662        )
1663    } else {
1664        cx.container(
1665            fret_ui::element::ContainerProps {
1666                layout: select_modal_barrier_layout(),
1667                ..Default::default()
1668            },
1669            move |_cx| barrier_children,
1670        )
1671    };
1672    Elements::from([barrier, content, guard_el])
1673}
1674
1675/// Returns an item-level pointer-up handler that respects the "open via mouse pointerdown" guard.
1676///
1677/// When a select is opened on mouse `pointerdown`, the click-release `pointerup` can land on top of
1678/// the content. Radix installs a one-shot pointer-up guard to ensure that release does not
1679/// immediately select an item. This helper mirrors that behavior for listbox options.
1680pub fn select_item_pointer_up_handler(
1681    open: Model<bool>,
1682    value: Model<Option<Arc<str>>>,
1683    item_value: Arc<str>,
1684    item_disabled: bool,
1685    mouse_open_guard: SelectMouseOpenGuard,
1686) -> OnPointerUp {
1687    select_item_pointer_up_handler_with_mouse_up_gate(
1688        open,
1689        value,
1690        item_value,
1691        item_disabled,
1692        mouse_open_guard,
1693        false,
1694        None,
1695    )
1696}
1697
1698/// Same as `select_item_pointer_up_handler`, plus an optional delayed mouse-up commit gate.
1699///
1700/// When `mouse_up_gate` is present, mouse pointer-up commit is only allowed if the gate currently
1701/// permits the row type (`item_is_selected` vs unselected), mirroring Base UI's delayed mouse-up
1702/// policy after opening.
1703pub fn select_item_pointer_up_handler_with_mouse_up_gate(
1704    open: Model<bool>,
1705    value: Model<Option<Arc<str>>>,
1706    item_value: Arc<str>,
1707    item_disabled: bool,
1708    mouse_open_guard: SelectMouseOpenGuard,
1709    item_is_selected: bool,
1710    mouse_up_gate: Option<Arc<Mutex<SelectMouseUpSelectionGateState>>>,
1711) -> OnPointerUp {
1712    Arc::new(move |host, action_cx, up: PointerUpCx| {
1713        if up.button != fret_core::MouseButton::Left {
1714            return false;
1715        }
1716        if !matches!(up.pointer_type, PointerType::Mouse | PointerType::Unknown) {
1717            return false;
1718        }
1719        if item_disabled {
1720            return true;
1721        }
1722        if select_mouse_open_guard_should_suppress_pointer_up_shared(&mouse_open_guard, up) {
1723            return true;
1724        }
1725        if let Some(mouse_up_gate) = mouse_up_gate.as_ref() {
1726            let gate = mouse_up_gate.lock().unwrap_or_else(|e| e.into_inner());
1727            if !gate.should_allow_item_mouse_up(item_is_selected) {
1728                return true;
1729            }
1730        }
1731
1732        let _ = host
1733            .models_mut()
1734            .update(&value, |v| *v = Some(item_value.clone()));
1735        let _ = host.models_mut().update(&open, |v| *v = false);
1736        host.request_redraw(action_cx.window);
1737        true
1738    })
1739}
1740
1741/// Pressable pointer handlers for select listbox items.
1742///
1743/// This consolidates the Radix pointer-up guard (opening on trigger mouse `pointerdown`) with
1744/// Base UI's delayed mouse-up commit policy (200/400ms) in one place so shadcn recipes can share
1745/// the same behavior without re-assembling handler logic.
1746#[derive(Clone)]
1747pub struct SelectItemPressablePointerHandlers {
1748    pub on_pointer_down: OnPressablePointerDown,
1749    pub on_pointer_up: OnPressablePointerUp,
1750}
1751
1752/// Builds item-level `Pressable` pointer handlers that:
1753/// - record whether the item received a mouse `pointerdown`
1754/// - commit selection on `pointerup` when the interaction is not treated as a click (`is_click=false`)
1755/// - gate "mouse-up without pointerdown" commits using Radix's pointer-up guard + Base UI's
1756///   delayed selection window.
1757///
1758/// Notes:
1759/// - When the item had a pointer-down and the pointer-up is a click, this returns `Continue` so the
1760///   default pressable activation path commits the selection.
1761/// - `mouse_up_gate` is only consulted for the "no item pointerdown" mouse-up commit path, matching
1762///   Base UI's semantics.
1763pub fn select_item_pressable_pointer_handlers_with_mouse_up_gate(
1764    open: Model<bool>,
1765    value: Model<Option<Arc<str>>>,
1766    item_value: Arc<str>,
1767    item_disabled: bool,
1768    mouse_open_guard: SelectMouseOpenGuard,
1769    item_is_selected: bool,
1770    mouse_up_gate: Option<Arc<Mutex<SelectMouseUpSelectionGateState>>>,
1771    on_value_change: Option<Arc<dyn Fn(&mut dyn UiActionHost, ActionCx, Arc<str>) + 'static>>,
1772) -> SelectItemPressablePointerHandlers {
1773    let did_pointer_down = Arc::new(Mutex::new(false));
1774
1775    let did_pointer_down_for_down = did_pointer_down.clone();
1776    let mouse_open_guard_for_down = mouse_open_guard.clone();
1777    let on_pointer_down: OnPressablePointerDown = Arc::new(move |_host, _action_cx, down| {
1778        if matches!(down.pointer_type, PointerType::Mouse | PointerType::Unknown) {
1779            select_mouse_open_guard_clear(&mouse_open_guard_for_down);
1780            let mut pressed = did_pointer_down_for_down
1781                .lock()
1782                .unwrap_or_else(|e| e.into_inner());
1783            *pressed = true;
1784        }
1785        PressablePointerDownResult::Continue
1786    });
1787
1788    let did_pointer_down_for_up = did_pointer_down.clone();
1789    let open_for_up = open.clone();
1790    let value_for_up = value.clone();
1791    let item_value_for_up = item_value.clone();
1792    let mouse_open_guard_for_up = mouse_open_guard.clone();
1793    let on_value_change_for_up = on_value_change.clone();
1794    let on_pointer_up: OnPressablePointerUp = Arc::new(move |host, action_cx, up| {
1795        if up.button != fret_core::MouseButton::Left {
1796            return PressablePointerUpResult::Continue;
1797        }
1798        if !matches!(up.pointer_type, PointerType::Mouse | PointerType::Unknown) {
1799            return PressablePointerUpResult::Continue;
1800        }
1801        if item_disabled {
1802            return PressablePointerUpResult::SkipActivate;
1803        }
1804
1805        let had_pointer_down = {
1806            let mut pressed = did_pointer_down_for_up
1807                .lock()
1808                .unwrap_or_else(|e| e.into_inner());
1809            let had = *pressed;
1810            *pressed = false;
1811            had
1812        };
1813
1814        if had_pointer_down && up.is_click {
1815            return PressablePointerUpResult::Continue;
1816        }
1817
1818        if !had_pointer_down {
1819            if select_mouse_open_guard_should_suppress_pointer_up_shared(
1820                &mouse_open_guard_for_up,
1821                up,
1822            ) {
1823                return PressablePointerUpResult::SkipActivate;
1824            }
1825            if let Some(mouse_up_gate) = mouse_up_gate.as_ref() {
1826                let gate = mouse_up_gate.lock().unwrap_or_else(|e| e.into_inner());
1827                if !gate.should_allow_item_mouse_up(item_is_selected) {
1828                    return PressablePointerUpResult::SkipActivate;
1829                }
1830            }
1831        }
1832
1833        let before = host
1834            .models_mut()
1835            .read(&value_for_up, |v| v.clone())
1836            .ok()
1837            .flatten();
1838        let did_change = before.as_deref() != Some(item_value_for_up.as_ref());
1839        let _ = host
1840            .models_mut()
1841            .update(&value_for_up, |v| *v = Some(item_value_for_up.clone()));
1842        let _ = host.models_mut().update(&open_for_up, |v| *v = false);
1843        if did_change && let Some(on_value_change) = on_value_change_for_up.as_ref() {
1844            on_value_change(host, action_cx, item_value_for_up.clone());
1845        }
1846        host.request_redraw(action_cx.window);
1847        PressablePointerUpResult::SkipActivate
1848    });
1849
1850    SelectItemPressablePointerHandlers {
1851        on_pointer_down,
1852        on_pointer_up,
1853    }
1854}
1855
1856/// Builds an overlay request for a Radix-style select content overlay.
1857///
1858/// This uses a modal overlay layer to approximate Radix Select's outside interaction blocking.
1859pub fn modal_select_request(
1860    id: GlobalElementId,
1861    trigger: GlobalElementId,
1862    open: Model<bool>,
1863    presence: OverlayPresence,
1864    children: impl IntoIterator<Item = AnyElement>,
1865) -> OverlayRequest {
1866    let children: Vec<AnyElement> = children.into_iter().collect();
1867    let mut request = OverlayRequest::modal(id, Some(trigger), open, presence, children);
1868    request.close_on_window_focus_lost = true;
1869    request.close_on_window_resize = true;
1870    request.root_name = Some(select_root_name(id));
1871    request
1872}
1873
1874/// Builds an overlay request for a Radix-style select content overlay while routing dismissals
1875/// through an optional dismiss handler (Radix `DismissableLayer` "preventDefault" outcome).
1876pub fn modal_select_request_with_dismiss_handler(
1877    id: GlobalElementId,
1878    trigger: GlobalElementId,
1879    open: Model<bool>,
1880    presence: OverlayPresence,
1881    on_dismiss_request: Option<OnDismissRequest>,
1882    children: impl IntoIterator<Item = AnyElement>,
1883) -> OverlayRequest {
1884    let mut request = modal_select_request(id, trigger, open, presence, children);
1885    request.dismissible_on_dismiss_request = on_dismiss_request;
1886    request
1887}
1888
1889/// Requests a select overlay for the current window.
1890pub fn request_select<H: UiHost>(cx: &mut ElementContext<'_, H>, request: OverlayRequest) {
1891    OverlayController::request(cx, request);
1892}
1893
1894#[cfg(test)]
1895mod tests {
1896    use super::*;
1897
1898    use std::cell::Cell;
1899
1900    use fret_app::App;
1901    use fret_core::{
1902        AppWindowId, Event, Modifiers, MouseButtons, Point, PointerEvent, PointerId, PointerType,
1903        Px, Rect, Size,
1904    };
1905    use fret_core::{MaterialDescriptor, MaterialId, MaterialRegistrationError, MaterialService};
1906    use fret_core::{PathCommand, SvgId, SvgService};
1907    use fret_core::{PathConstraints, PathId, PathMetrics, PathService, PathStyle};
1908    use fret_core::{Scene, Transform2D};
1909    use fret_core::{TextBlobId, TextConstraints, TextInput, TextMetrics, TextService};
1910    use fret_ui::action::{UiActionHostAdapter, UiFocusActionHost, UiPointerActionHost};
1911    use fret_ui::element::{ContainerProps, ElementKind, LayoutStyle, Length, PressableProps};
1912    use std::time::Duration;
1913
1914    use fret_ui::UiTree;
1915
1916    #[derive(Default)]
1917    struct FakeServices;
1918
1919    impl TextService for FakeServices {
1920        fn prepare(
1921            &mut self,
1922            _input: &TextInput,
1923            _constraints: TextConstraints,
1924        ) -> (TextBlobId, TextMetrics) {
1925            (
1926                TextBlobId::default(),
1927                TextMetrics {
1928                    size: fret_core::Size::new(Px(0.0), Px(0.0)),
1929                    baseline: Px(0.0),
1930                },
1931            )
1932        }
1933
1934        fn release(&mut self, _blob: TextBlobId) {}
1935    }
1936
1937    impl PathService for FakeServices {
1938        fn prepare(
1939            &mut self,
1940            _commands: &[PathCommand],
1941            _style: PathStyle,
1942            _constraints: PathConstraints,
1943        ) -> (PathId, PathMetrics) {
1944            (PathId::default(), PathMetrics::default())
1945        }
1946
1947        fn release(&mut self, _path: PathId) {}
1948    }
1949
1950    impl SvgService for FakeServices {
1951        fn register_svg(&mut self, _bytes: &[u8]) -> SvgId {
1952            SvgId::default()
1953        }
1954
1955        fn unregister_svg(&mut self, _svg: SvgId) -> bool {
1956            true
1957        }
1958    }
1959
1960    impl MaterialService for FakeServices {
1961        fn register_material(
1962            &mut self,
1963            _desc: MaterialDescriptor,
1964        ) -> Result<MaterialId, MaterialRegistrationError> {
1965            Err(MaterialRegistrationError::Unsupported)
1966        }
1967
1968        fn unregister_material(&mut self, _id: MaterialId) -> bool {
1969            true
1970        }
1971    }
1972
1973    fn bounds() -> Rect {
1974        Rect::new(
1975            Point::new(Px(0.0), Px(0.0)),
1976            Size::new(Px(200.0), Px(120.0)),
1977        )
1978    }
1979
1980    #[test]
1981    fn select_root_open_model_uses_controlled_model() {
1982        let window = AppWindowId::default();
1983        let mut app = App::new();
1984        let b = bounds();
1985
1986        let controlled = app.models_mut().insert(true);
1987
1988        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
1989            let root = SelectRoot::new()
1990                .open(Some(controlled.clone()))
1991                .default_open(false);
1992            assert_eq!(root.open_model(cx), controlled);
1993        });
1994    }
1995
1996    #[test]
1997    fn select_initial_focus_targets_gate_by_input_modality() {
1998        let window = AppWindowId::default();
1999        let mut app = App::new();
2000        let b = bounds();
2001
2002        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
2003            let pointer_focus = GlobalElementId(0x111);
2004            let keyboard_focus = GlobalElementId(0x222);
2005
2006            // Pointer modality: prefer pointer content focus.
2007            fret_ui::input_modality::update_for_event(
2008                cx.app,
2009                window,
2010                &Event::Pointer(PointerEvent::Move {
2011                    position: Point::new(Px(1.0), Px(2.0)),
2012                    buttons: MouseButtons::default(),
2013                    modifiers: Modifiers::default(),
2014                    pointer_id: PointerId(0),
2015                    pointer_type: PointerType::Mouse,
2016                }),
2017            );
2018            assert_eq!(
2019                SelectInitialFocusTargets::new()
2020                    .pointer_content_focus(Some(pointer_focus))
2021                    .keyboard_entry_focus(Some(keyboard_focus))
2022                    .resolve(cx, window),
2023                Some(pointer_focus)
2024            );
2025
2026            // Keyboard modality: prefer keyboard entry focus.
2027            fret_ui::input_modality::update_for_event(
2028                cx.app,
2029                window,
2030                &Event::KeyDown {
2031                    key: fret_core::KeyCode::KeyA,
2032                    modifiers: Modifiers::default(),
2033                    repeat: false,
2034                },
2035            );
2036            assert_eq!(
2037                SelectInitialFocusTargets::new()
2038                    .pointer_content_focus(Some(pointer_focus))
2039                    .keyboard_entry_focus(Some(keyboard_focus))
2040                    .resolve(cx, window),
2041                Some(keyboard_focus)
2042            );
2043        });
2044    }
2045
2046    #[test]
2047    fn select_use_value_model_prefers_controlled_and_does_not_call_default() {
2048        let window = AppWindowId::default();
2049        let mut app = App::new();
2050        let b = bounds();
2051
2052        let controlled = app.models_mut().insert(Some(Arc::from("a")));
2053        let called = Cell::new(0);
2054
2055        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
2056            let out = select_use_value_model(cx, Some(controlled.clone()), || {
2057                called.set(called.get() + 1);
2058                None
2059            });
2060            assert!(out.is_controlled());
2061            assert_eq!(out.model(), controlled);
2062        });
2063
2064        assert_eq!(called.get(), 0);
2065    }
2066
2067    #[test]
2068    fn select_item_aligned_layout_from_elements_ignores_visual_bounds_for_viewport() {
2069        let window = AppWindowId::default();
2070        let mut app = App::new();
2071        let mut ui: UiTree<App> = UiTree::new();
2072        ui.set_window(window);
2073
2074        let mut services = FakeServices::default();
2075
2076        let prepare_frame = |app: &mut App, window: AppWindowId| {
2077            let frame_id = app.frame_id();
2078            app.with_global_mut_untracked(
2079                fret_ui::elements::ElementRuntime::default,
2080                |rt, _app| {
2081                    rt.prepare_window_for_frame(window, frame_id);
2082                },
2083            );
2084        };
2085
2086        let b = Rect::new(
2087            Point::new(Px(0.0), Px(0.0)),
2088            Size::new(Px(320.0), Px(240.0)),
2089        );
2090
2091        // Fixed layout-space geometry for the headless solver.
2092        let trigger = Rect::new(
2093            Point::new(Px(120.0), Px(32.0)),
2094            Size::new(Px(120.0), Px(28.0)),
2095        );
2096        let value_node = Rect::new(
2097            Point::new(Px(132.0), Px(40.0)),
2098            Size::new(Px(80.0), Px(16.0)),
2099        );
2100        let content_panel = Rect::new(
2101            Point::new(Px(80.0), Px(72.0)),
2102            Size::new(Px(200.0), Px(140.0)),
2103        );
2104        let viewport = Rect::new(
2105            Point::new(Px(80.0), Px(88.0)),
2106            Size::new(Px(200.0), Px(108.0)),
2107        );
2108        let listbox = Rect::new(
2109            Point::new(Px(80.0), Px(88.0)),
2110            Size::new(Px(200.0), Px(420.0)),
2111        );
2112        let selected_item = Rect::new(
2113            Point::new(Px(80.0), Px(120.0)),
2114            Size::new(Px(200.0), Px(28.0)),
2115        );
2116        let selected_item_text = Rect::new(
2117            Point::new(Px(96.0), Px(126.0)),
2118            Size::new(Px(120.0), Px(16.0)),
2119        );
2120
2121        fn render_frame(
2122            ui: &mut UiTree<App>,
2123            app: &mut App,
2124            services: &mut FakeServices,
2125            window: AppWindowId,
2126            b: Rect,
2127            trigger: Rect,
2128            value_node: Rect,
2129            content_panel: Rect,
2130            viewport: Rect,
2131            listbox: Rect,
2132            selected_item: Rect,
2133            selected_item_text: Rect,
2134            got: Option<&Cell<Option<SelectItemAlignedLayout>>>,
2135        ) -> fret_core::NodeId {
2136            let viewport_id: Cell<Option<GlobalElementId>> = Cell::new(None);
2137            let listbox_id: Cell<Option<GlobalElementId>> = Cell::new(None);
2138            let content_panel_id: Cell<Option<GlobalElementId>> = Cell::new(None);
2139            let selected_item_id: Cell<Option<GlobalElementId>> = Cell::new(None);
2140            let selected_item_text_id: Cell<Option<GlobalElementId>> = Cell::new(None);
2141
2142            fret_ui::declarative::render_root(ui, app, services, window, b, "test", |cx| {
2143                let abs = |rect: Rect| {
2144                    let mut layout = LayoutStyle::default();
2145                    layout.position = fret_ui::element::PositionStyle::Absolute;
2146                    layout.inset.left = Some(rect.origin.x).into();
2147                    layout.inset.top = Some(rect.origin.y).into();
2148                    layout.size.width = Length::Px(rect.size.width);
2149                    layout.size.height = Length::Px(rect.size.height);
2150                    ContainerProps {
2151                        layout,
2152                        ..Default::default()
2153                    }
2154                };
2155
2156                let value_node_el = cx.container(abs(value_node), |_cx| Vec::new());
2157
2158                let mut transform_layout = LayoutStyle::default();
2159                transform_layout.size.width = Length::Fill;
2160                transform_layout.size.height = Length::Fill;
2161                let transformed = cx.render_transform_props(
2162                    fret_ui::element::RenderTransformProps {
2163                        layout: transform_layout,
2164                        transform: Transform2D::scale_uniform(0.5),
2165                    },
2166                    |cx| {
2167                        let viewport_el = cx.container(abs(viewport), |_cx| Vec::new());
2168                        viewport_id.set(Some(viewport_el.id));
2169                        let listbox_el = cx.container(abs(listbox), |_cx| Vec::new());
2170                        listbox_id.set(Some(listbox_el.id));
2171                        let content_panel_el = cx.container(abs(content_panel), |_cx| Vec::new());
2172                        content_panel_id.set(Some(content_panel_el.id));
2173                        let selected_item_el = cx.container(abs(selected_item), |_cx| Vec::new());
2174                        selected_item_id.set(Some(selected_item_el.id));
2175                        let selected_item_text_el =
2176                            cx.container(abs(selected_item_text), |_cx| Vec::new());
2177                        selected_item_text_id.set(Some(selected_item_text_el.id));
2178
2179                        vec![
2180                            viewport_el,
2181                            listbox_el,
2182                            content_panel_el,
2183                            selected_item_el,
2184                            selected_item_text_el,
2185                        ]
2186                    },
2187                );
2188
2189                if let Some(got) = got {
2190                    let inputs = SelectItemAlignedElementInputs {
2191                        direction: popper::LayoutDirection::Ltr,
2192                        window: b,
2193                        trigger,
2194                        content_min_width: Px(80.0),
2195                        content_border_top: Px(1.0),
2196                        content_padding_top: Px(0.0),
2197                        content_border_bottom: Px(1.0),
2198                        content_padding_bottom: Px(0.0),
2199                        viewport_padding_top: Px(4.0),
2200                        viewport_padding_bottom: Px(4.0),
2201                        selected_item_is_first: false,
2202                        selected_item_is_last: false,
2203                        value_node: value_node_el.id,
2204                        viewport: viewport_id.get().expect("viewport id"),
2205                        listbox: listbox_id.get().expect("listbox id"),
2206                        content_panel: content_panel_id.get().expect("content_panel id"),
2207                        scroll_max_offset_y: None,
2208                        content_width_probe: None,
2209                        selected_item: selected_item_id.get().expect("selected_item id"),
2210                        selected_item_text: selected_item_text_id
2211                            .get()
2212                            .expect("selected_item_text id"),
2213                    };
2214                    got.set(select_item_aligned_layout_from_elements(&*cx, inputs));
2215                }
2216
2217                vec![value_node_el, transformed]
2218            })
2219        }
2220
2221        // Frame 0: mount the geometry nodes and paint with a render transform so last-visual-bounds
2222        // differ from last-layout-bounds for overlay elements.
2223        app.set_frame_id(fret_core::FrameId(app.frame_id().0.saturating_add(1)));
2224        OverlayController::begin_frame(&mut app, window);
2225        prepare_frame(&mut app, window);
2226        let root0 = render_frame(
2227            &mut ui,
2228            &mut app,
2229            &mut services,
2230            window,
2231            b,
2232            trigger,
2233            value_node,
2234            content_panel,
2235            viewport,
2236            listbox,
2237            selected_item,
2238            selected_item_text,
2239            None,
2240        );
2241        ui.set_root(root0);
2242        ui.layout_all(&mut app, &mut services, b, 1.0);
2243        let mut scene = Scene::default();
2244        ui.paint_all(&mut app, &mut services, b, &mut scene, 1.0);
2245
2246        // Frame boundary: commit recorded bounds so `last_*_bounds_for_element` can observe them.
2247        app.set_frame_id(fret_core::FrameId(app.frame_id().0.saturating_add(1)));
2248        OverlayController::begin_frame(&mut app, window);
2249        prepare_frame(&mut app, window);
2250
2251        // Expected output is based on layout-space geometry (render transforms must not affect it).
2252        let expected = select_item_aligned_layout(SelectItemAlignedInputs {
2253            direction: fret_core::LayoutDirection::Ltr,
2254            window: b,
2255            trigger,
2256            content: content_panel,
2257            value_node,
2258            selected_item_text,
2259            selected_item,
2260            viewport,
2261            content_border_top: Px(1.0),
2262            content_padding_top: Px(0.0),
2263            content_border_bottom: Px(1.0),
2264            content_padding_bottom: Px(0.0),
2265            viewport_padding_top: Px(4.0),
2266            viewport_padding_bottom: Px(4.0),
2267            selected_item_is_first: false,
2268            selected_item_is_last: false,
2269            items_height: listbox.size.height,
2270        });
2271
2272        let got: Cell<Option<SelectItemAlignedLayout>> = Cell::new(None);
2273        let root1 = render_frame(
2274            &mut ui,
2275            &mut app,
2276            &mut services,
2277            window,
2278            b,
2279            trigger,
2280            value_node,
2281            content_panel,
2282            viewport,
2283            listbox,
2284            selected_item,
2285            selected_item_text,
2286            Some(&got),
2287        );
2288        ui.set_root(root1);
2289        ui.layout_all(&mut app, &mut services, b, 1.0);
2290
2291        let got = got.get().expect("expected resolved layout");
2292        assert_eq!(got.rect, expected.rect);
2293        assert_eq!(got.side, expected.side);
2294        assert_eq!(got.outputs, expected.outputs);
2295    }
2296
2297    struct PointerHost<'a> {
2298        app: &'a mut App,
2299        bounds: Rect,
2300        prevented_focus_on_pointer_down: bool,
2301    }
2302
2303    impl fret_ui::action::UiActionHost for PointerHost<'_> {
2304        fn models_mut(&mut self) -> &mut fret_runtime::ModelStore {
2305            self.app.models_mut()
2306        }
2307
2308        fn push_effect(&mut self, effect: Effect) {
2309            self.app.push_effect(effect);
2310        }
2311
2312        fn request_redraw(&mut self, window: AppWindowId) {
2313            self.app.request_redraw(window);
2314        }
2315
2316        fn next_timer_token(&mut self) -> TimerToken {
2317            self.app.next_timer_token()
2318        }
2319
2320        fn next_clipboard_token(&mut self) -> fret_runtime::ClipboardToken {
2321            self.app.next_clipboard_token()
2322        }
2323
2324        fn next_share_sheet_token(&mut self) -> fret_runtime::ShareSheetToken {
2325            self.app.next_share_sheet_token()
2326        }
2327    }
2328
2329    impl UiFocusActionHost for PointerHost<'_> {
2330        fn request_focus(&mut self, _target: GlobalElementId) {}
2331    }
2332
2333    impl fret_ui::action::UiDragActionHost for PointerHost<'_> {
2334        fn begin_drag_with_kind(
2335            &mut self,
2336            _pointer_id: fret_core::PointerId,
2337            _kind: fret_runtime::DragKindId,
2338            _source_window: fret_core::AppWindowId,
2339            _start: fret_core::Point,
2340        ) {
2341        }
2342
2343        fn begin_cross_window_drag_with_kind(
2344            &mut self,
2345            _pointer_id: fret_core::PointerId,
2346            _kind: fret_runtime::DragKindId,
2347            _source_window: fret_core::AppWindowId,
2348            _start: fret_core::Point,
2349        ) {
2350        }
2351
2352        fn drag(&self, _pointer_id: fret_core::PointerId) -> Option<&fret_runtime::DragSession> {
2353            None
2354        }
2355
2356        fn drag_mut(
2357            &mut self,
2358            _pointer_id: fret_core::PointerId,
2359        ) -> Option<&mut fret_runtime::DragSession> {
2360            None
2361        }
2362
2363        fn cancel_drag(&mut self, _pointer_id: fret_core::PointerId) {}
2364    }
2365
2366    impl UiPointerActionHost for PointerHost<'_> {
2367        fn bounds(&self) -> Rect {
2368            self.bounds
2369        }
2370
2371        fn capture_pointer(&mut self) {}
2372
2373        fn release_pointer_capture(&mut self) {}
2374
2375        fn prevent_default(&mut self, action: fret_runtime::DefaultAction) {
2376            if action == fret_runtime::DefaultAction::FocusOnPointerDown {
2377                self.prevented_focus_on_pointer_down = true;
2378            }
2379        }
2380
2381        fn set_cursor_icon(&mut self, _icon: fret_core::CursorIcon) {}
2382    }
2383
2384    #[test]
2385    fn apply_select_trigger_a11y_sets_role_expanded_and_controls() {
2386        let window = AppWindowId::default();
2387        let mut app = App::new();
2388        let b = bounds();
2389
2390        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
2391            let trigger = cx.pressable(
2392                PressableProps {
2393                    layout: LayoutStyle::default(),
2394                    enabled: true,
2395                    focusable: true,
2396                    ..Default::default()
2397                },
2398                |_cx, _st| Vec::new(),
2399            );
2400
2401            let listbox = GlobalElementId(0xbeef);
2402            let trigger =
2403                apply_select_trigger_a11y(trigger, true, Some(Arc::from("Select")), Some(listbox));
2404
2405            let ElementKind::Pressable(PressableProps { a11y, .. }) = &trigger.kind else {
2406                panic!("expected pressable trigger");
2407            };
2408            assert_eq!(a11y.role, Some(fret_core::SemanticsRole::ComboBox));
2409            assert_eq!(a11y.expanded, Some(true));
2410            assert_eq!(a11y.controls_element, Some(listbox.0));
2411            assert_eq!(a11y.label.as_deref(), Some("Select"));
2412        });
2413    }
2414
2415    #[test]
2416    fn select_listbox_semantics_id_matches_mounted_listbox_id() {
2417        let window = AppWindowId::default();
2418        let mut app = App::new();
2419        let b = bounds();
2420
2421        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
2422            let overlay_root_name = "select-overlay";
2423            let expected = select_listbox_semantics_id::<App>(cx, overlay_root_name);
2424            let inherited = portal_inherited::PortalInherited::capture(cx);
2425            let actual = portal_inherited::with_root_name_inheriting(
2426                cx,
2427                overlay_root_name,
2428                inherited,
2429                |cx| {
2430                    select_listbox_pressable_with_id_props::<App, _, _>(cx, |_cx, _st, _id| {
2431                        (
2432                            PressableProps {
2433                                layout: LayoutStyle::default(),
2434                                enabled: true,
2435                                focusable: false,
2436                                ..Default::default()
2437                            },
2438                            Vec::<AnyElement>::new(),
2439                        )
2440                    })
2441                    .id
2442                },
2443            );
2444            assert_eq!(expected, actual);
2445        });
2446    }
2447
2448    #[test]
2449    fn modal_select_request_sets_default_root_name() {
2450        let mut app = App::new();
2451        let open = app.models_mut().insert(false);
2452        let id = GlobalElementId(0x123);
2453        let trigger = GlobalElementId(0x456);
2454
2455        let req = modal_select_request(
2456            id,
2457            trigger,
2458            open,
2459            OverlayPresence::instant(true),
2460            Vec::new(),
2461        );
2462        let expected = select_root_name(id);
2463        assert_eq!(req.root_name.as_deref(), Some(expected.as_str()));
2464    }
2465
2466    #[test]
2467    fn select_content_placement_item_aligned_has_no_wrapper_insets_and_origin_on_rect_edge() {
2468        let window = Rect::new(
2469            Point::new(Px(0.0), Px(0.0)),
2470            Size::new(Px(300.0), Px(200.0)),
2471        );
2472        let anchor = Rect::new(
2473            Point::new(Px(100.0), Px(80.0)),
2474            Size::new(Px(80.0), Px(24.0)),
2475        );
2476
2477        let item_layout = select_item_aligned_layout(SelectItemAlignedInputs {
2478            direction: popper::LayoutDirection::Ltr,
2479            window,
2480            trigger: anchor,
2481            content: Rect::new(
2482                Point::new(Px(0.0), Px(0.0)),
2483                Size::new(Px(160.0), Px(120.0)),
2484            ),
2485            value_node: Rect::new(
2486                Point::new(Px(110.0), Px(84.0)),
2487                Size::new(Px(60.0), Px(16.0)),
2488            ),
2489            selected_item_text: Rect::new(
2490                Point::new(Px(20.0), Px(40.0)),
2491                Size::new(Px(80.0), Px(16.0)),
2492            ),
2493            selected_item: Rect::new(
2494                Point::new(Px(10.0), Px(36.0)),
2495                Size::new(Px(140.0), Px(24.0)),
2496            ),
2497            viewport: Rect::new(
2498                Point::new(Px(10.0), Px(30.0)),
2499                Size::new(Px(160.0), Px(120.0)),
2500            ),
2501            content_border_top: Px(1.0),
2502            content_padding_top: Px(0.0),
2503            content_border_bottom: Px(1.0),
2504            content_padding_bottom: Px(0.0),
2505            viewport_padding_top: Px(4.0),
2506            viewport_padding_bottom: Px(4.0),
2507            selected_item_is_first: false,
2508            selected_item_is_last: false,
2509            items_height: Px(240.0),
2510        });
2511
2512        let placement = select_content_placement_item_aligned(anchor, item_layout);
2513        assert_eq!(placement.wrapper_insets, Edges::all(Px(0.0)));
2514        assert!(placement.popper_layout.is_none());
2515
2516        match placement.side {
2517            Side::Bottom => assert_eq!(placement.transform_origin.y, placement.placed.origin.y),
2518            Side::Top => {
2519                assert_eq!(
2520                    placement.transform_origin.y,
2521                    Px(placement.placed.origin.y.0 + placement.placed.size.height.0)
2522                )
2523            }
2524            Side::Left | Side::Right => {}
2525        }
2526    }
2527
2528    #[test]
2529    fn select_content_placement_popper_exposes_layout_and_wrapper_insets_when_arrow_enabled() {
2530        let outer = Rect::new(
2531            Point::new(Px(0.0), Px(0.0)),
2532            Size::new(Px(300.0), Px(200.0)),
2533        );
2534        let anchor = Rect::new(
2535            Point::new(Px(120.0), Px(40.0)),
2536            Size::new(Px(80.0), Px(24.0)),
2537        );
2538        let desired = Size::new(Px(180.0), Px(120.0));
2539
2540        let (arrow_options, arrow_protrusion) =
2541            popper::diamond_arrow_options(true, Px(12.0), Px(4.0));
2542        let placement = popper::PopperContentPlacement::new(
2543            popper::LayoutDirection::Ltr,
2544            Side::Bottom,
2545            popper::Align::Start,
2546            Px(6.0),
2547        )
2548        .with_align_offset(Px(0.0))
2549        .with_arrow(arrow_options, arrow_protrusion);
2550
2551        let out =
2552            select_content_placement_popper(outer, anchor, desired, placement, Some(Px(12.0)));
2553        assert!(out.popper_layout.is_some());
2554        assert_eq!(out.placed, out.popper_layout.unwrap().rect);
2555        assert!(
2556            out.wrapper_insets.top.0 > 0.0
2557                || out.wrapper_insets.bottom.0 > 0.0
2558                || out.wrapper_insets.left.0 > 0.0
2559                || out.wrapper_insets.right.0 > 0.0
2560        );
2561    }
2562
2563    #[test]
2564    fn select_resolve_content_placement_prefers_item_aligned_layout_when_provided() {
2565        let outer = Rect::new(
2566            Point::new(Px(0.0), Px(0.0)),
2567            Size::new(Px(300.0), Px(200.0)),
2568        );
2569        let anchor = Rect::new(
2570            Point::new(Px(120.0), Px(40.0)),
2571            Size::new(Px(80.0), Px(24.0)),
2572        );
2573        let desired = Size::new(Px(180.0), Px(120.0));
2574
2575        let item_layout = select_item_aligned_layout(SelectItemAlignedInputs {
2576            direction: popper::LayoutDirection::Ltr,
2577            window: outer,
2578            trigger: anchor,
2579            content: Rect::new(Point::new(Px(0.0), Px(0.0)), desired),
2580            value_node: Rect::new(
2581                Point::new(Px(130.0), Px(44.0)),
2582                Size::new(Px(60.0), Px(16.0)),
2583            ),
2584            selected_item_text: Rect::new(
2585                Point::new(Px(20.0), Px(40.0)),
2586                Size::new(Px(80.0), Px(16.0)),
2587            ),
2588            selected_item: Rect::new(
2589                Point::new(Px(10.0), Px(36.0)),
2590                Size::new(Px(140.0), Px(24.0)),
2591            ),
2592            viewport: Rect::new(
2593                Point::new(Px(10.0), Px(30.0)),
2594                Size::new(Px(160.0), Px(120.0)),
2595            ),
2596            content_border_top: Px(1.0),
2597            content_padding_top: Px(0.0),
2598            content_border_bottom: Px(1.0),
2599            content_padding_bottom: Px(0.0),
2600            viewport_padding_top: Px(4.0),
2601            viewport_padding_bottom: Px(4.0),
2602            selected_item_is_first: false,
2603            selected_item_is_last: false,
2604            items_height: Px(240.0),
2605        });
2606
2607        let popper_placement = popper::PopperContentPlacement::new(
2608            popper::LayoutDirection::Ltr,
2609            Side::Bottom,
2610            popper::Align::Start,
2611            Px(6.0),
2612        );
2613        let resolved = select_resolve_content_placement(
2614            anchor,
2615            outer,
2616            desired,
2617            popper_placement,
2618            None,
2619            Some(item_layout),
2620        );
2621
2622        assert!(resolved.item_aligned_layout.is_some());
2623        assert!(resolved.placement.popper_layout.is_none());
2624    }
2625
2626    #[test]
2627    fn select_open_keys_match_radix_defaults() {
2628        assert!(is_select_open_key(KeyCode::Enter));
2629        assert!(is_select_open_key(KeyCode::Space));
2630        assert!(is_select_open_key(KeyCode::ArrowDown));
2631        assert!(is_select_open_key(KeyCode::ArrowUp));
2632        assert!(!is_select_open_key(KeyCode::Escape));
2633
2634        assert!(select_open_key_suppresses_activate(KeyCode::Enter));
2635        assert!(select_open_key_suppresses_activate(KeyCode::Space));
2636        assert!(!select_open_key_suppresses_activate(KeyCode::ArrowDown));
2637        assert!(!select_open_key_suppresses_activate(KeyCode::ArrowUp));
2638    }
2639
2640    #[test]
2641    fn select_popper_available_height_tracks_flipped_side_space() {
2642        let outer = Rect::new(
2643            Point::new(Px(0.0), Px(0.0)),
2644            Size::new(Px(100.0), Px(100.0)),
2645        );
2646        let anchor = Rect::new(
2647            Point::new(Px(10.0), Px(70.0)),
2648            Size::new(Px(30.0), Px(10.0)),
2649        );
2650
2651        // Preferred bottom won't fit for a tall list; the solver should flip to top, and the
2652        // available height should match the top space.
2653        let placement = popper::PopperContentPlacement::new(
2654            popper::LayoutDirection::Ltr,
2655            Side::Bottom,
2656            popper::Align::Start,
2657            Px(0.0),
2658        );
2659
2660        let vars = select_popper_vars(outer, anchor, Px(0.0), placement);
2661        assert!(vars.available_height.0 > 60.0 && vars.available_height.0 < 80.0);
2662    }
2663
2664    #[test]
2665    fn select_popper_desired_width_respects_min_width_and_outer_bounds() {
2666        let outer = Rect::new(Point::new(Px(0.0), Px(0.0)), Size::new(Px(80.0), Px(100.0)));
2667        let anchor = Rect::new(
2668            Point::new(Px(10.0), Px(10.0)),
2669            Size::new(Px(24.0), Px(10.0)),
2670        );
2671
2672        assert_eq!(
2673            select_popper_desired_width(outer, anchor, Px(0.0)),
2674            Px(24.0)
2675        );
2676        assert_eq!(
2677            select_popper_desired_width(outer, anchor, Px(40.0)),
2678            Px(40.0)
2679        );
2680        assert_eq!(
2681            select_popper_desired_width(outer, anchor, Px(100.0)),
2682            Px(80.0)
2683        );
2684    }
2685
2686    #[test]
2687    fn trigger_typeahead_updates_value_without_opening() {
2688        let window = AppWindowId::default();
2689        let mut app = App::new();
2690        let open = app.models_mut().insert(false);
2691        let value = app.models_mut().insert(None::<Arc<str>>);
2692
2693        let values: Vec<Arc<str>> = vec![Arc::from("alpha"), Arc::from("beta")];
2694        let labels: Vec<Arc<str>> = vec![Arc::from("Alpha"), Arc::from("Beta")];
2695        let disabled = vec![false, false];
2696
2697        let mut state = SelectTriggerKeyState::default();
2698        let mut host = UiActionHostAdapter { app: &mut app };
2699        assert!(state.handle_key_down_when_closed(
2700            &mut host,
2701            window,
2702            &open,
2703            &value,
2704            &values,
2705            &labels,
2706            &disabled,
2707            KeyCode::KeyB,
2708            Modifiers::default(),
2709            false,
2710        ));
2711
2712        assert!(!app.models().get_copied(&open).unwrap_or(false));
2713        assert_eq!(
2714            app.models().get_cloned(&value).flatten().as_deref(),
2715            Some("beta")
2716        );
2717
2718        let effects = app.flush_effects();
2719        assert!(
2720            effects.iter().any(|e| matches!(
2721                e,
2722                Effect::SetTimer { after, .. }
2723                    if *after == Duration::from_millis(SELECT_TYPEAHEAD_CLEAR_TIMEOUT_MS)
2724            )),
2725            "expected a typeahead clear timer"
2726        );
2727    }
2728
2729    #[test]
2730    fn trigger_open_key_opens_and_suppresses_activate() {
2731        let window = AppWindowId::default();
2732        let mut app = App::new();
2733        let open = app.models_mut().insert(false);
2734        let value = app.models_mut().insert(None::<Arc<str>>);
2735
2736        let values: Vec<Arc<str>> = vec![Arc::from("alpha")];
2737        let labels: Vec<Arc<str>> = vec![Arc::from("Alpha")];
2738        let disabled = vec![false];
2739
2740        let mut state = SelectTriggerKeyState::default();
2741        let mut host = UiActionHostAdapter { app: &mut app };
2742        assert!(state.handle_key_down_when_closed(
2743            &mut host,
2744            window,
2745            &open,
2746            &value,
2747            &values,
2748            &labels,
2749            &disabled,
2750            KeyCode::Enter,
2751            Modifiers::default(),
2752            false,
2753        ));
2754
2755        assert!(app.models().get_copied(&open).unwrap_or(false));
2756        assert!(state.take_suppress_next_activate());
2757    }
2758
2759    #[test]
2760    fn content_arrow_navigation_updates_active_row() {
2761        let window = AppWindowId::default();
2762        let mut app = App::new();
2763        let open = app.models_mut().insert(true);
2764        let value = app.models_mut().insert(None::<Arc<str>>);
2765
2766        let values_by_row: Vec<Option<Arc<str>>> = vec![
2767            Some(Arc::from("alpha")),
2768            Some(Arc::from("beta")),
2769            Some(Arc::from("gamma")),
2770        ];
2771        let labels_by_row: Vec<Arc<str>> =
2772            vec![Arc::from("Alpha"), Arc::from("Beta"), Arc::from("Gamma")];
2773        let disabled_by_row = vec![false, true, false];
2774
2775        let mut state = SelectContentKeyState::default();
2776        let mut host = UiActionHostAdapter { app: &mut app };
2777
2778        assert!(state.handle_key_down_when_open(
2779            &mut host,
2780            ActionCx {
2781                window,
2782                target: GlobalElementId(1),
2783            },
2784            &open,
2785            &value,
2786            &values_by_row,
2787            &labels_by_row,
2788            &disabled_by_row,
2789            None,
2790            KeyCode::ArrowDown,
2791            false,
2792            true,
2793        ));
2794        // Skips disabled row 1, so we land on row 2.
2795        assert_eq!(state.active_row(), Some(2));
2796    }
2797
2798    #[test]
2799    fn content_tab_is_suppressed() {
2800        let window = AppWindowId::default();
2801        let mut app = App::new();
2802        let open = app.models_mut().insert(true);
2803        let value = app.models_mut().insert(None::<Arc<str>>);
2804
2805        let values_by_row: Vec<Option<Arc<str>>> = vec![Some(Arc::from("beta"))];
2806        let labels_by_row: Vec<Arc<str>> = vec![Arc::from("Beta")];
2807        let disabled_by_row = vec![false];
2808
2809        let mut state = SelectContentKeyState::default();
2810        let mut host = UiActionHostAdapter { app: &mut app };
2811        assert!(state.handle_key_down_when_open(
2812            &mut host,
2813            ActionCx {
2814                window,
2815                target: GlobalElementId(1),
2816            },
2817            &open,
2818            &value,
2819            &values_by_row,
2820            &labels_by_row,
2821            &disabled_by_row,
2822            None,
2823            KeyCode::Tab,
2824            false,
2825            true,
2826        ));
2827
2828        assert!(app.models().get_copied(&open).unwrap_or(false));
2829        assert_eq!(state.active_row(), None);
2830        assert_eq!(app.models().get_cloned(&value).flatten().as_deref(), None);
2831    }
2832
2833    #[test]
2834    fn content_enter_commits_value_and_closes() {
2835        let window = AppWindowId::default();
2836        let mut app = App::new();
2837        let open = app.models_mut().insert(true);
2838        let value = app.models_mut().insert(None::<Arc<str>>);
2839
2840        let values_by_row: Vec<Option<Arc<str>>> = vec![Some(Arc::from("beta"))];
2841        let labels_by_row: Vec<Arc<str>> = vec![Arc::from("Beta")];
2842        let disabled_by_row = vec![false];
2843
2844        let mut state = SelectContentKeyState::default();
2845        state.set_active_row(Some(0));
2846
2847        let mut host = UiActionHostAdapter { app: &mut app };
2848        assert!(state.handle_key_down_when_open(
2849            &mut host,
2850            ActionCx {
2851                window,
2852                target: GlobalElementId(1),
2853            },
2854            &open,
2855            &value,
2856            &values_by_row,
2857            &labels_by_row,
2858            &disabled_by_row,
2859            None,
2860            KeyCode::Enter,
2861            false,
2862            true,
2863        ));
2864
2865        assert!(!app.models().get_copied(&open).unwrap_or(false));
2866        assert_eq!(
2867            app.models().get_cloned(&value).flatten().as_deref(),
2868            Some("beta")
2869        );
2870    }
2871
2872    #[test]
2873    fn trigger_pointer_mouse_down_opens() {
2874        let window = AppWindowId::default();
2875        let mut app = App::new();
2876        let open = app.models_mut().insert(false);
2877
2878        let mut state = SelectTriggerPointerState::default();
2879        let mut host = PointerHost {
2880            app: &mut app,
2881            bounds: bounds(),
2882            prevented_focus_on_pointer_down: false,
2883        };
2884
2885        assert!(state.handle_pointer_down(
2886            &mut host,
2887            ActionCx {
2888                window,
2889                target: GlobalElementId(1),
2890            },
2891            PointerDownCx {
2892                pointer_id: fret_core::PointerId(0),
2893                position: Point::new(Px(10.0), Px(12.0)),
2894                position_local: Point::new(Px(10.0), Px(12.0)),
2895                position_window: Some(Point::new(Px(10.0), Px(12.0))),
2896                tick_id: fret_runtime::TickId(0),
2897                pixels_per_point: 1.0,
2898                button: fret_core::MouseButton::Left,
2899                modifiers: Modifiers::default(),
2900                click_count: 1,
2901                pointer_type: PointerType::Mouse,
2902                hit_is_text_input: false,
2903                hit_is_pressable: false,
2904                hit_pressable_target: None,
2905                hit_pressable_target_in_descendant_subtree: false,
2906            },
2907            &open,
2908            true,
2909        ));
2910        assert!(host.models_mut().get_copied(&open).unwrap_or(false));
2911        assert!(host.prevented_focus_on_pointer_down);
2912    }
2913
2914    #[test]
2915    fn mouse_open_guard_pointer_up_decision_is_reusable_within_tick() {
2916        let guard = select_mouse_open_guard();
2917
2918        select_mouse_open_guard_record_if_opened(
2919            &guard,
2920            false,
2921            true,
2922            Point::new(Px(10.0), Px(12.0)),
2923        );
2924
2925        let up = PointerUpCx {
2926            pointer_id: fret_core::PointerId(0),
2927            position: Point::new(Px(10.0), Px(12.0)),
2928            position_local: Point::new(Px(10.0), Px(12.0)),
2929            position_window: Some(Point::new(Px(10.0), Px(12.0))),
2930            tick_id: fret_runtime::TickId(42),
2931            pixels_per_point: 1.0,
2932            velocity_window: None,
2933            button: fret_core::MouseButton::Left,
2934            modifiers: Modifiers::default(),
2935            is_click: true,
2936            click_count: 1,
2937            pointer_type: PointerType::Mouse,
2938            down_hit_pressable_target: None,
2939            down_hit_pressable_target_in_descendant_subtree: false,
2940        };
2941
2942        assert_eq!(
2943            select_mouse_open_guard_pointer_up_decision_shared(&guard, up),
2944            SelectMouseOpenGuardPointerUpDecision::Suppress
2945        );
2946        assert_eq!(
2947            select_mouse_open_guard_pointer_up_decision_shared(&guard, up),
2948            SelectMouseOpenGuardPointerUpDecision::Suppress
2949        );
2950
2951        let up_next_tick = PointerUpCx {
2952            tick_id: fret_runtime::TickId(43),
2953            ..up
2954        };
2955        assert_eq!(
2956            select_mouse_open_guard_pointer_up_decision_shared(&guard, up_next_tick),
2957            SelectMouseOpenGuardPointerUpDecision::Suppress
2958        );
2959
2960        let up_clear = PointerUpCx {
2961            tick_id: fret_runtime::TickId(44),
2962            ..up
2963        };
2964        assert_eq!(
2965            select_mouse_open_guard_pointer_up_decision_shared(&guard, up_clear),
2966            SelectMouseOpenGuardPointerUpDecision::NoGuard
2967        );
2968    }
2969
2970    #[test]
2971    fn trigger_pointer_touch_opens_on_click_like_up() {
2972        let window = AppWindowId::default();
2973        let mut app = App::new();
2974        let open = app.models_mut().insert(false);
2975
2976        let mut state = SelectTriggerPointerState::default();
2977        let mut host = PointerHost {
2978            app: &mut app,
2979            bounds: bounds(),
2980            prevented_focus_on_pointer_down: false,
2981        };
2982
2983        assert!(state.handle_pointer_down(
2984            &mut host,
2985            ActionCx {
2986                window,
2987                target: GlobalElementId(1),
2988            },
2989            PointerDownCx {
2990                pointer_id: fret_core::PointerId(0),
2991                position: Point::new(Px(10.0), Px(12.0)),
2992                position_local: Point::new(Px(10.0), Px(12.0)),
2993                position_window: Some(Point::new(Px(10.0), Px(12.0))),
2994                tick_id: fret_runtime::TickId(0),
2995                pixels_per_point: 1.0,
2996                button: fret_core::MouseButton::Left,
2997                modifiers: Modifiers::default(),
2998                click_count: 1,
2999                pointer_type: PointerType::Touch,
3000                hit_is_text_input: false,
3001                hit_is_pressable: false,
3002                hit_pressable_target: None,
3003                hit_pressable_target_in_descendant_subtree: false,
3004            },
3005            &open,
3006            true,
3007        ));
3008        assert!(!host.models_mut().get_copied(&open).unwrap_or(false));
3009
3010        assert!(state.handle_pointer_up(
3011            &mut host,
3012            ActionCx {
3013                window,
3014                target: GlobalElementId(1),
3015            },
3016            PointerUpCx {
3017                pointer_id: fret_core::PointerId(0),
3018                position: Point::new(Px(13.0), Px(15.0)),
3019                position_local: Point::new(Px(13.0), Px(15.0)),
3020                position_window: Some(Point::new(Px(13.0), Px(15.0))),
3021                tick_id: fret_runtime::TickId(0),
3022                pixels_per_point: 1.0,
3023                velocity_window: None,
3024                button: fret_core::MouseButton::Left,
3025                modifiers: Modifiers::default(),
3026                is_click: true,
3027                click_count: 1,
3028                pointer_type: PointerType::Touch,
3029                down_hit_pressable_target: None,
3030                down_hit_pressable_target_in_descendant_subtree: false,
3031            },
3032            &open,
3033            true,
3034        ));
3035        assert!(host.models_mut().get_copied(&open).unwrap_or(false));
3036    }
3037
3038    #[test]
3039    fn trigger_pointer_touch_drag_does_not_open() {
3040        let window = AppWindowId::default();
3041        let mut app = App::new();
3042        let open = app.models_mut().insert(false);
3043
3044        let mut state = SelectTriggerPointerState::default();
3045        let mut host = PointerHost {
3046            app: &mut app,
3047            bounds: bounds(),
3048            prevented_focus_on_pointer_down: false,
3049        };
3050
3051        assert!(state.handle_pointer_down(
3052            &mut host,
3053            ActionCx {
3054                window,
3055                target: GlobalElementId(1),
3056            },
3057            PointerDownCx {
3058                pointer_id: fret_core::PointerId(0),
3059                position: Point::new(Px(10.0), Px(12.0)),
3060                position_local: Point::new(Px(10.0), Px(12.0)),
3061                position_window: Some(Point::new(Px(10.0), Px(12.0))),
3062                tick_id: fret_runtime::TickId(0),
3063                pixels_per_point: 1.0,
3064                button: fret_core::MouseButton::Left,
3065                modifiers: Modifiers::default(),
3066                click_count: 1,
3067                pointer_type: PointerType::Touch,
3068                hit_is_text_input: false,
3069                hit_is_pressable: false,
3070                hit_pressable_target: None,
3071                hit_pressable_target_in_descendant_subtree: false,
3072            },
3073            &open,
3074            true,
3075        ));
3076        assert!(state.handle_pointer_move(
3077            &mut host,
3078            ActionCx {
3079                window,
3080                target: GlobalElementId(1),
3081            },
3082            PointerMoveCx {
3083                pointer_id: fret_core::PointerId(0),
3084                position: Point::new(Px(40.0), Px(12.0)),
3085                position_local: Point::new(Px(40.0), Px(12.0)),
3086                position_window: Some(Point::new(Px(40.0), Px(12.0))),
3087                tick_id: fret_runtime::TickId(0),
3088                pixels_per_point: 1.0,
3089                velocity_window: None,
3090                buttons: fret_core::MouseButtons {
3091                    left: true,
3092                    right: false,
3093                    middle: false,
3094                },
3095                modifiers: Modifiers::default(),
3096                pointer_type: PointerType::Touch,
3097            },
3098        ));
3099        assert!(state.handle_pointer_up(
3100            &mut host,
3101            ActionCx {
3102                window,
3103                target: GlobalElementId(1),
3104            },
3105            PointerUpCx {
3106                pointer_id: fret_core::PointerId(0),
3107                position: Point::new(Px(40.0), Px(12.0)),
3108                position_local: Point::new(Px(40.0), Px(12.0)),
3109                position_window: Some(Point::new(Px(40.0), Px(12.0))),
3110                tick_id: fret_runtime::TickId(0),
3111                pixels_per_point: 1.0,
3112                velocity_window: None,
3113                button: fret_core::MouseButton::Left,
3114                modifiers: Modifiers::default(),
3115                is_click: false,
3116                click_count: 1,
3117                pointer_type: PointerType::Touch,
3118                down_hit_pressable_target: None,
3119                down_hit_pressable_target_in_descendant_subtree: false,
3120            },
3121            &open,
3122            true,
3123        ));
3124        assert!(!host.models_mut().get_copied(&open).unwrap_or(false));
3125    }
3126}