1use fret_core::{LayoutDirection, Px, Rect};
7
8pub 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 pub content: Rect,
20
21 pub value_node: Rect,
23
24 pub selected_item_text: Rect,
26
27 pub selected_item: Rect,
29
30 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 pub selected_item_is_first: bool,
43 pub selected_item_is_last: bool,
45
46 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 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 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 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 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 let max_height = if available_height.0 < min_height.0 {
149 inputs.window.size.height
150 } else {
151 available_height
152 };
153 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 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 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}