Skip to main content

fret_ui_headless/
select_item_aligned.rs

1//! Radix Select `item-aligned` positioning math.
2//!
3//! Upstream reference:
4//! - `repo-ref/primitives/packages/react/select/src/select.tsx` (`SelectItemAlignedPosition`)
5
6use fret_core::{LayoutDirection, Px, Rect};
7
8/// Matches Radix `CONTENT_MARGIN` (px).
9pub const SELECT_ITEM_ALIGNED_CONTENT_MARGIN: Px = Px(10.0);
10
11#[derive(Debug, Clone, Copy)]
12pub struct SelectItemAlignedInputs {
13    pub direction: LayoutDirection,
14
15    pub window: Rect,
16    pub trigger: Rect,
17
18    /// The last known content panel rect (used to keep width stable across repositioning).
19    pub content: Rect,
20
21    /// Trigger value node rect (the text node that displays the selected value).
22    pub value_node: Rect,
23
24    /// Selected item's text rect inside the content.
25    pub selected_item_text: Rect,
26
27    /// Selected item (row) rect inside the content.
28    pub selected_item: Rect,
29
30    /// The listbox/viewport rect that establishes scroll offsets for items.
31    pub viewport: Rect,
32
33    pub content_border_top: Px,
34    pub content_padding_top: Px,
35    pub content_border_bottom: Px,
36    pub content_padding_bottom: Px,
37
38    pub viewport_padding_top: Px,
39    pub viewport_padding_bottom: Px,
40
41    /// Whether the alignment item is the first selectable item (Radix `items[0]`).
42    pub selected_item_is_first: bool,
43    /// Whether the alignment item is the last selectable item (Radix `items[items.length - 1]`).
44    pub selected_item_is_last: bool,
45
46    /// Scrollable height of all items (Radix `viewport.scrollHeight`).
47    pub items_height: Px,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub struct SelectItemAlignedOutputs {
52    pub left: Option<Px>,
53    pub right: Option<Px>,
54    pub top: Option<Px>,
55    pub bottom: Option<Px>,
56    pub width: Px,
57    pub min_width: Px,
58    pub height: Px,
59    pub min_height: Px,
60    pub max_height: Px,
61    /// Desired scroll offset to maintain trigger<->selected alignment when clamping to top.
62    pub scroll_to_y: Option<Px>,
63}
64
65fn clamp(px: Px, min: Px, max: Px) -> Px {
66    Px(px.0.clamp(min.0, max.0))
67}
68
69fn midpoint_y(rect: Rect) -> Px {
70    Px(rect.origin.y.0 + rect.size.height.0 / 2.0)
71}
72
73pub fn select_item_aligned_position(inputs: SelectItemAlignedInputs) -> SelectItemAlignedOutputs {
74    let margin = SELECT_ITEM_ALIGNED_CONTENT_MARGIN;
75
76    let window_left = inputs.window.origin.x;
77    let window_top = inputs.window.origin.y;
78    let window_right = Px(inputs.window.origin.x.0 + inputs.window.size.width.0);
79    let left_edge = Px(window_left.0 + margin.0);
80    let right_edge = Px(window_right.0 - margin.0);
81
82    // -----------------------------------------------------------------------------------------
83    // Horizontal positioning
84    // -----------------------------------------------------------------------------------------
85    let item_text_offset_ltr = Px(inputs.selected_item_text.origin.x.0 - inputs.content.origin.x.0);
86    let item_text_offset_rtl = Px((inputs.content.origin.x.0 + inputs.content.size.width.0)
87        - (inputs.selected_item_text.origin.x.0 + inputs.selected_item_text.size.width.0));
88
89    let (left, right, min_width, width) = match inputs.direction {
90        LayoutDirection::Ltr => {
91            let left = Px(inputs.value_node.origin.x.0 - item_text_offset_ltr.0);
92            let left_delta = Px(inputs.trigger.origin.x.0 - left.0);
93            let min_width = Px(inputs.trigger.size.width.0 + left_delta.0);
94            let width = Px(min_width.0.max(inputs.content.size.width.0));
95
96            let max_left = Px((right_edge.0 - width.0).max(left_edge.0));
97            let clamped_left = clamp(left, left_edge, max_left);
98
99            (Some(clamped_left), None, min_width, width)
100        }
101        LayoutDirection::Rtl => {
102            let right = Px((window_right.0
103                - inputs.value_node.origin.x.0
104                - inputs.value_node.size.width.0)
105                - item_text_offset_rtl.0);
106            let right_delta = Px((window_right.0
107                - inputs.trigger.origin.x.0
108                - inputs.trigger.size.width.0)
109                - right.0);
110            let min_width = Px(inputs.trigger.size.width.0 + right_delta.0);
111            let width = Px(min_width.0.max(inputs.content.size.width.0));
112
113            let max_right = Px((right_edge.0 - width.0).max(left_edge.0));
114            let clamped_right = clamp(right, left_edge, max_right);
115
116            (None, Some(clamped_right), min_width, width)
117        }
118    };
119
120    // -----------------------------------------------------------------------------------------
121    // Vertical positioning
122    // -----------------------------------------------------------------------------------------
123    let available_height = Px((inputs.window.size.height.0 - margin.0 * 2.0).max(0.0));
124
125    let selected_item_half_h = Px(inputs.selected_item.size.height.0 / 2.0);
126
127    let viewport_origin_y = inputs.viewport.origin.y;
128    // Radix uses `selectedItem.offsetTop`, measured from the viewport's padding edge.
129    //
130    // In Fret, the viewport rect typically reflects the viewport's outer box, and child layout
131    // already includes padding in its origin, so `selected_item.origin - viewport.origin` behaves
132    // like `offsetTop` (padding-inclusive). Keep the solver aligned with that coordinate system.
133    let selected_item_mid_offset =
134        Px((inputs.selected_item.origin.y.0 - viewport_origin_y.0) + selected_item_half_h.0);
135
136    let full_content_h = Px(inputs.content_border_top.0
137        + inputs.content_padding_top.0
138        + inputs.items_height.0
139        + inputs.content_padding_bottom.0
140        + inputs.content_border_bottom.0);
141
142    let min_height = Px((selected_item_half_h.0 * 10.0).min(full_content_h.0));
143
144    let top_edge_to_trigger_mid = Px(midpoint_y(inputs.trigger).0 - margin.0 - window_top.0);
145    // Radix constrains `maxHeight` to `window.innerHeight - CONTENT_MARGIN*2`, but when that would
146    // fall below the `minHeight` (5 rows) it can overflow the collision boundary and still clamp
147    // the origin to the top edge. Model this by relaxing the effective max height in that case.
148    let max_height = if available_height.0 < min_height.0 {
149        inputs.window.size.height
150    } else {
151        available_height
152    };
153    // Radix applies `min-height` and `max-height` to the wrapper. When `min-height` exceeds the
154    // computed max height, the wrapper can still grow (and overflow the collision boundary) so
155    // the listbox stays usable near tight edges.
156    let max_height = Px(max_height.0.max(min_height.0));
157
158    let trigger_mid_to_bottom_edge = Px(max_height.0 - top_edge_to_trigger_mid.0);
159
160    let content_top_to_item_mid =
161        Px(inputs.content_border_top.0 + inputs.content_padding_top.0 + selected_item_mid_offset.0);
162    let item_mid_to_content_bottom = Px(full_content_h.0 - content_top_to_item_mid.0);
163
164    let will_align_without_top_overflow = content_top_to_item_mid.0 <= top_edge_to_trigger_mid.0;
165
166    if will_align_without_top_overflow {
167        // Match Radix:
168        // `viewportOffsetBottom = content.clientHeight - viewport.offsetTop - viewport.offsetHeight`.
169        //
170        // `clientHeight` excludes borders, and `offsetTop` is measured from the content's padding
171        // edge. Compute the same offsets in window space by switching to the content inner box.
172        let content_inner_bottom = Px(inputs.content.origin.y.0 + inputs.content.size.height.0
173            - inputs.content_padding_bottom.0
174            - inputs.content_border_bottom.0);
175        let viewport_offset_bottom =
176            Px(content_inner_bottom.0
177                - (inputs.viewport.origin.y.0 + inputs.viewport.size.height.0));
178        let viewport_padding_bottom = if inputs.selected_item_is_last {
179            inputs.viewport_padding_bottom
180        } else {
181            Px(0.0)
182        };
183        let clamped_trigger_mid_to_bottom_edge = Px(trigger_mid_to_bottom_edge.0.max(
184            selected_item_half_h.0
185                + viewport_padding_bottom.0
186                + viewport_offset_bottom.0
187                + inputs.content_border_bottom.0,
188        ));
189        let height = Px(
190            (content_top_to_item_mid.0 + clamped_trigger_mid_to_bottom_edge.0).min(max_height.0),
191        );
192        let height = Px(height.0.min(full_content_h.0));
193        let height = Px(height.0.max(min_height.0));
194
195        SelectItemAlignedOutputs {
196            left,
197            right,
198            top: None,
199            bottom: Some(Px(0.0)),
200            width,
201            min_width,
202            height,
203            min_height,
204            max_height,
205            scroll_to_y: None,
206        }
207    } else {
208        // Match Radix:
209        // `viewport.offsetTop` is measured from the content's padding edge (border excluded).
210        let content_inner_top = Px(inputs.content.origin.y.0
211            + inputs.content_border_top.0
212            + inputs.content_padding_top.0);
213        let viewport_offset_top = Px(inputs.viewport.origin.y.0 - content_inner_top.0);
214        let viewport_padding_top = if inputs.selected_item_is_first {
215            inputs.viewport_padding_top
216        } else {
217            Px(0.0)
218        };
219        let clamped_top_edge_to_trigger_mid = Px(top_edge_to_trigger_mid.0.max(
220            inputs.content_border_top.0
221                + viewport_offset_top.0
222                + viewport_padding_top.0
223                + selected_item_half_h.0,
224        ));
225        let height = Px(
226            (clamped_top_edge_to_trigger_mid.0 + item_mid_to_content_bottom.0).min(max_height.0),
227        );
228        let height = Px(height.0.min(full_content_h.0));
229        let height = Px(height.0.max(min_height.0));
230
231        let scroll_to =
232            Px(content_top_to_item_mid.0 - top_edge_to_trigger_mid.0 + viewport_offset_top.0);
233
234        SelectItemAlignedOutputs {
235            left,
236            right,
237            top: Some(Px(0.0)),
238            bottom: None,
239            width,
240            min_width,
241            height,
242            min_height,
243            max_height,
244            scroll_to_y: Some(scroll_to),
245        }
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use fret_core::{Point, Px, Rect, Size};
253
254    fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
255        Rect::new(Point::new(Px(x), Px(y)), Size::new(Px(w), Px(h)))
256    }
257
258    #[test]
259    fn ltr_computes_min_width_and_clamps_left() {
260        let out = select_item_aligned_position(SelectItemAlignedInputs {
261            direction: LayoutDirection::Ltr,
262            window: rect(0.0, 0.0, 300.0, 200.0),
263            trigger: rect(100.0, 40.0, 80.0, 24.0),
264            content: rect(0.0, 0.0, 120.0, 100.0),
265            value_node: rect(110.0, 44.0, 60.0, 16.0),
266            selected_item_text: rect(20.0, 60.0, 60.0, 16.0),
267            selected_item: rect(10.0, 56.0, 100.0, 24.0),
268            viewport: rect(10.0, 50.0, 120.0, 100.0),
269            content_border_top: Px(1.0),
270            content_padding_top: Px(0.0),
271            content_border_bottom: Px(1.0),
272            content_padding_bottom: Px(0.0),
273            viewport_padding_top: Px(4.0),
274            viewport_padding_bottom: Px(4.0),
275            selected_item_is_first: false,
276            selected_item_is_last: false,
277            items_height: Px(200.0),
278        });
279
280        assert!(out.left.is_some());
281        assert_eq!(out.right, None);
282        assert!(out.min_width.0 >= 80.0);
283        assert!(out.width.0 >= out.min_width.0);
284        assert!(out.left.unwrap().0 >= SELECT_ITEM_ALIGNED_CONTENT_MARGIN.0);
285    }
286
287    #[test]
288    fn vertical_prefers_bottom_when_alignment_fits() {
289        let out = select_item_aligned_position(SelectItemAlignedInputs {
290            direction: LayoutDirection::Ltr,
291            window: rect(0.0, 0.0, 300.0, 200.0),
292            trigger: rect(20.0, 40.0, 80.0, 24.0),
293            content: rect(0.0, 0.0, 120.0, 100.0),
294            value_node: rect(30.0, 44.0, 60.0, 16.0),
295            selected_item_text: rect(20.0, 60.0, 60.0, 16.0),
296            selected_item: rect(10.0, 56.0, 100.0, 24.0),
297            viewport: rect(10.0, 50.0, 120.0, 100.0),
298            content_border_top: Px(1.0),
299            content_padding_top: Px(0.0),
300            content_border_bottom: Px(1.0),
301            content_padding_bottom: Px(0.0),
302            viewport_padding_top: Px(4.0),
303            viewport_padding_bottom: Px(4.0),
304            selected_item_is_first: false,
305            selected_item_is_last: false,
306            items_height: Px(200.0),
307        });
308
309        assert_eq!(out.bottom, Some(Px(0.0)));
310        assert_eq!(out.top, None);
311        assert_eq!(out.scroll_to_y, None);
312    }
313
314    #[test]
315    fn vertical_enforces_min_height_near_window_bottom() {
316        let out = select_item_aligned_position(SelectItemAlignedInputs {
317            direction: LayoutDirection::Ltr,
318            window: rect(0.0, 0.0, 520.0, 360.0),
319            trigger: rect(233.0, 283.0, 240.0, 36.0),
320            content: rect(0.0, 0.0, 240.0, 100.0),
321            value_node: rect(241.0, 291.0, 180.0, 20.0),
322            selected_item_text: rect(20.0, 10.0, 60.0, 20.0),
323            selected_item: rect(10.0, 4.0, 220.0, 32.0),
324            viewport: rect(10.0, 0.0, 240.0, 200.0),
325            content_border_top: Px(1.0),
326            content_padding_top: Px(4.0),
327            content_border_bottom: Px(1.0),
328            content_padding_bottom: Px(4.0),
329            viewport_padding_top: Px(4.0),
330            viewport_padding_bottom: Px(4.0),
331            selected_item_is_first: true,
332            selected_item_is_last: false,
333            items_height: Px(32.0 * 40.0),
334        });
335
336        assert!(out.min_height.0 >= 32.0 * 5.0);
337        assert!(out.height.0 >= out.min_height.0);
338    }
339}