Skip to main content

fret_ui_kit/declarative/
active_descendant.rs

1use fret_core::{NodeId, Point, Px, Rect};
2use fret_ui::elements::GlobalElementId;
3use fret_ui::scroll::ScrollHandle;
4use fret_ui::{ElementContext, UiHost};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct ActiveOption {
8    pub element: GlobalElementId,
9    pub node: NodeId,
10}
11
12pub fn active_element_for_index(
13    elements: &[GlobalElementId],
14    active_index: Option<usize>,
15) -> Option<GlobalElementId> {
16    active_index.and_then(|idx| elements.get(idx).copied())
17}
18
19/// Resolve an active descendant `NodeId` from a list of element IDs and an active index.
20///
21/// This is a small helper for cmdk/listbox-like composite widgets where:
22/// - focus stays on an owner node (often a `TextField`), and
23/// - the highlighted option is exposed via `active_descendant` (ADR 0073).
24pub fn active_descendant_for_index<H: UiHost>(
25    cx: &mut ElementContext<'_, H>,
26    elements: &[GlobalElementId],
27    active_index: Option<usize>,
28) -> Option<NodeId> {
29    active_option_for_index(cx, elements, active_index).map(|opt| opt.node)
30}
31
32pub fn active_option_for_index<H: UiHost>(
33    cx: &mut ElementContext<'_, H>,
34    elements: &[GlobalElementId],
35    active_index: Option<usize>,
36) -> Option<ActiveOption> {
37    let element = active_element_for_index(elements, active_index)?;
38    let node = cx.live_node_for_element(element)?;
39    Some(ActiveOption { element, node })
40}
41
42/// Scroll a child rectangle into view within a viewport, using the same conservative behavior as
43/// the runtime's focus-traversal scroll-into-view contract.
44pub fn scroll_handle_into_view_y(handle: &ScrollHandle, viewport: Rect, child: Rect) -> bool {
45    let viewport_h = viewport.size.height.0.max(0.0);
46    if viewport_h <= 0.0 {
47        return false;
48    }
49
50    let view_top = viewport.origin.y.0;
51    let view_bottom = view_top + viewport_h;
52    let child_top = child.origin.y.0;
53    let child_h = child.size.height.0.max(0.0);
54    let child_bottom = child_top + child_h;
55
56    // If the child is taller than the viewport, we cannot make it fully visible. Match the
57    // common DOM `scrollIntoView({ block: "nearest" })` outcome: only ensure the top edge is
58    // visible, and avoid runaway scrolling that would try to "fit" the bottom edge.
59    if child_h >= viewport_h - 0.01 {
60        let delta = child_top - view_top;
61        if delta.abs() <= 0.01 {
62            return false;
63        }
64
65        let prev = handle.offset();
66        handle.set_offset(Point::new(prev.x, Px(prev.y.0 + delta)));
67
68        let next = handle.offset();
69        return (prev.y.0 - next.y.0).abs() > 0.01;
70    }
71
72    let delta = if child_top < view_top {
73        child_top - view_top
74    } else if child_bottom > view_bottom {
75        child_bottom - view_bottom
76    } else {
77        0.0
78    };
79
80    if delta.abs() <= 0.01 {
81        return false;
82    }
83
84    let prev = handle.offset();
85    handle.set_offset(Point::new(prev.x, Px(prev.y.0 + delta)));
86
87    let next = handle.offset();
88    (prev.y.0 - next.y.0).abs() > 0.01
89}
90
91/// Align a child rectangle's top edge with the viewport top edge by adjusting the scroll handle.
92///
93/// This is intentionally stronger than [`scroll_handle_into_view_y`]: it scrolls even when the
94/// child is already visible.
95pub fn scroll_handle_align_top_y(handle: &ScrollHandle, viewport: Rect, child: Rect) -> bool {
96    let viewport_h = viewport.size.height.0.max(0.0);
97    if viewport_h <= 0.0 {
98        return false;
99    }
100
101    let view_top = viewport.origin.y.0;
102    let child_top = child.origin.y.0;
103    let delta = child_top - view_top;
104
105    if delta.abs() <= 0.01 {
106        return false;
107    }
108
109    let prev = handle.offset();
110    handle.set_offset(Point::new(prev.x, Px(prev.y.0 + delta)));
111
112    let next = handle.offset();
113    (prev.y.0 - next.y.0).abs() > 0.01
114}
115
116/// Best-effort "scroll active option into view" helper for cmdk/listbox-like widgets.
117///
118/// This uses last-frame bounds for both the scroll viewport element and the active item element.
119/// When either bound is missing, it does nothing.
120pub fn scroll_active_element_into_view_y<H: UiHost>(
121    cx: &mut ElementContext<'_, H>,
122    handle: &ScrollHandle,
123    viewport_element: GlobalElementId,
124    active_element: GlobalElementId,
125) -> bool {
126    let Some(viewport) = cx.last_bounds_for_element(viewport_element) else {
127        return false;
128    };
129    let Some(child) = cx.last_bounds_for_element(active_element) else {
130        return false;
131    };
132
133    scroll_handle_into_view_y(handle, viewport, child)
134}
135
136/// Best-effort "scroll active option to top edge" helper for cmdk/listbox-like widgets.
137///
138/// This uses last-frame bounds for both the scroll viewport element and the active item element.
139/// When either bound is missing, it does nothing.
140pub fn scroll_active_element_align_top_y<H: UiHost>(
141    cx: &mut ElementContext<'_, H>,
142    handle: &ScrollHandle,
143    viewport_element: GlobalElementId,
144    active_element: GlobalElementId,
145) -> bool {
146    let Some(viewport) = cx.last_bounds_for_element(viewport_element) else {
147        return false;
148    };
149    let Some(child) = cx.last_bounds_for_element(active_element) else {
150        return false;
151    };
152
153    scroll_handle_align_top_y(handle, viewport, child)
154}