1use crate::geometry::{Point, Rect, Size};
7
8use super::model::{MenuEntry, MenuItem};
9use super::state::MenuAnchorKind;
10
11pub const ROW_H: f64 = 24.0;
12pub const SEP_H: f64 = 7.0;
13pub const MENU_W: f64 = 224.0;
14pub const BAR_H: f64 = 26.0;
15pub const VERTICAL_ROW_H: f64 = 36.0;
19pub const DEFAULT_FONT_SIZE: f64 = 14.0;
22pub const TOUCH_MIN: f64 = 44.0;
26const MARGIN: f64 = 4.0;
27
28#[derive(Clone, Copy, Debug, PartialEq)]
34pub struct MenuMetrics {
35 pub row_h: f64,
36 pub sep_h: f64,
37 pub bar_h: f64,
38 pub vertical_row_h: f64,
39 pub menu_w: f64,
40 pub default_font_size: f64,
41}
42
43pub fn effective_metrics() -> MenuMetrics {
61 let base = MenuMetrics {
62 row_h: ROW_H,
63 sep_h: SEP_H,
64 bar_h: BAR_H,
65 vertical_row_h: VERTICAL_ROW_H,
66 menu_w: MENU_W,
67 default_font_size: DEFAULT_FONT_SIZE,
68 };
69 if !crate::input_profile::touch_ui_active() {
70 return base;
71 }
72 let min_logical = TOUCH_MIN / crate::ux_scale::ux_scale().max(0.01);
78 let row_h = ROW_H.max(min_logical);
79 let bar_h = BAR_H.max(min_logical);
80 let vertical_row_h = VERTICAL_ROW_H.max(min_logical);
81 let grow = row_h / ROW_H;
82 MenuMetrics {
83 row_h,
84 sep_h: SEP_H,
85 bar_h,
86 vertical_row_h,
87 menu_w: MENU_W * grow,
88 default_font_size: DEFAULT_FONT_SIZE * grow,
89 }
90}
91
92pub fn menu_bar_height() -> f64 {
98 effective_metrics().bar_h
99}
100
101pub fn menu_vertical_row_height() -> f64 {
105 effective_metrics().vertical_row_h
106}
107
108#[derive(Clone, Debug)]
109pub struct PopupLayout {
110 pub rect: Rect,
111 pub rows: Vec<RowLayout>,
112 pub path_prefix: Vec<usize>,
113}
114
115#[derive(Clone, Debug)]
116pub struct RowLayout {
117 pub rect: Rect,
118 pub item_index: Option<usize>,
119}
120
121#[derive(Clone, Debug)]
122pub enum MenuHit {
123 Item(Vec<usize>),
124 Panel,
125}
126
127pub fn stack_layout(
128 root_items: &[MenuEntry],
129 anchor: Point,
130 anchor_kind: MenuAnchorKind,
131 open_path: &[usize],
132 viewport: Size,
133) -> Vec<PopupLayout> {
134 let m = effective_metrics();
137 let mut layouts = Vec::new();
138 let mut items = root_items;
139 let mut x = anchor.x;
140 let mut y_top = anchor.y;
141 let mut prefix = Vec::new();
142
143 loop {
144 let rect = popup_rect(items, Point::new(x, y_top), anchor_kind, viewport, &m);
145 let rows = row_layouts(items, rect, &m);
146 layouts.push(PopupLayout {
147 rect,
148 rows,
149 path_prefix: prefix.clone(),
150 });
151
152 let Some(next_idx) = open_path.get(prefix.len()).copied() else {
153 break;
154 };
155 let Some(item) = item_at(items, next_idx) else {
156 break;
157 };
158 if item.submenu.is_empty() {
159 break;
160 }
161 let Some(row) = layouts.last().and_then(|layout| {
162 layout
163 .rows
164 .iter()
165 .find(|row| row.item_index == Some(next_idx))
166 .cloned()
167 }) else {
168 break;
169 };
170 prefix.push(next_idx);
171 items = &item.submenu;
172 x = (rect.x + rect.width - 2.0).min(viewport.width - m.menu_w - MARGIN);
173 y_top = match anchor_kind {
180 MenuAnchorKind::BottomBar => row.rect.y,
181 _ => row.rect.y + row.rect.height,
182 };
183 }
184
185 layouts
186}
187
188pub fn hit_test(layouts: &[PopupLayout], pos: Point) -> Option<MenuHit> {
189 for layout in layouts.iter().rev() {
190 if !contains(layout.rect, pos) {
191 continue;
192 }
193 for row in &layout.rows {
194 if contains(row.rect, pos) {
195 if let Some(idx) = row.item_index {
196 let mut path = layout.path_prefix.clone();
197 path.push(idx);
198 return Some(MenuHit::Item(path));
199 }
200 return Some(MenuHit::Panel);
201 }
202 }
203 return Some(MenuHit::Panel);
204 }
205 None
206}
207
208pub fn item_at_path<'a>(items: &'a [MenuEntry], path: &[usize]) -> Option<&'a MenuItem> {
209 let mut current = items;
210 let mut item = None;
211 for &idx in path {
212 item = item_at(current, idx);
213 current = &item?.submenu;
214 }
215 item
216}
217
218pub fn item_at(items: &[MenuEntry], idx: usize) -> Option<&MenuItem> {
219 match items.get(idx)? {
220 MenuEntry::Item(item) => Some(item),
221 MenuEntry::Separator => None,
222 }
223}
224
225pub fn popup_height(items: &[MenuEntry], m: &MenuMetrics) -> f64 {
226 items
227 .iter()
228 .map(|entry| match entry {
229 MenuEntry::Item(_) => m.row_h,
230 MenuEntry::Separator => m.sep_h,
231 })
232 .sum::<f64>()
233 .max(m.row_h)
234}
235
236pub fn contains(rect: Rect, pos: Point) -> bool {
237 pos.x >= rect.x
238 && pos.x <= rect.x + rect.width
239 && pos.y >= rect.y
240 && pos.y <= rect.y + rect.height
241}
242
243fn popup_rect(
244 items: &[MenuEntry],
245 anchor: Point,
246 anchor_kind: MenuAnchorKind,
247 viewport: Size,
248 m: &MenuMetrics,
249) -> Rect {
250 let h = popup_height(items, m);
251 let x = anchor
252 .x
253 .clamp(MARGIN, (viewport.width - m.menu_w - MARGIN).max(MARGIN));
254 let (min_y, raw_y) = match anchor_kind {
255 MenuAnchorKind::Bar => (-viewport.height, anchor.y - h),
260 MenuAnchorKind::BottomBar => (MARGIN, anchor.y),
263 MenuAnchorKind::Context => (MARGIN, anchor.y - h),
264 };
265 let y = raw_y.clamp(min_y, (viewport.height - h - MARGIN).max(min_y));
266 Rect::new(x, y, m.menu_w, h)
267}
268
269fn row_layouts(items: &[MenuEntry], rect: Rect, m: &MenuMetrics) -> Vec<RowLayout> {
270 let mut y = rect.y + rect.height;
271 let mut rows = Vec::with_capacity(items.len());
272 for (idx, entry) in items.iter().enumerate() {
273 let h = match entry {
274 MenuEntry::Item(_) => m.row_h,
275 MenuEntry::Separator => m.sep_h,
276 };
277 y -= h;
278 rows.push(RowLayout {
279 rect: Rect::new(rect.x, y, rect.width, h),
280 item_index: matches!(entry, MenuEntry::Item(_)).then_some(idx),
281 });
282 }
283 rows
284}
285
286#[cfg(test)]
287mod metrics_tests {
288 use super::*;
289 use crate::input_profile::{set_input_profile, touch_ui_active, InputProfile};
290
291 fn desktop() {
295 set_input_profile(InputProfile::Desktop);
296 crate::touch_state::clear_last_touch_event_for_testing();
297 crate::ux_scale::set_ux_scale(1.0);
298 }
299
300 fn touch_via_latch(ux: f64) {
304 set_input_profile(InputProfile::Desktop);
305 crate::touch_state::clear_last_touch_event_for_testing();
306 crate::touch_state::note_touch_event();
307 crate::ux_scale::set_ux_scale(ux);
308 assert!(touch_ui_active(), "latch must activate touch sizing");
309 }
310
311 #[test]
312 fn desktop_metrics_are_the_bare_constants() {
313 let _guard = crate::input_profile::profile_test_lock();
314 desktop();
315 let m = effective_metrics();
316 assert_eq!(m.row_h, ROW_H);
317 assert_eq!(m.sep_h, SEP_H);
318 assert_eq!(m.bar_h, BAR_H);
319 assert_eq!(m.vertical_row_h, VERTICAL_ROW_H);
320 assert_eq!(m.menu_w, MENU_W);
321 assert_eq!(m.default_font_size, DEFAULT_FONT_SIZE);
322 assert_eq!(menu_bar_height(), BAR_H);
323 assert_eq!(menu_vertical_row_height(), VERTICAL_ROW_H);
324 desktop();
325 }
326
327 #[test]
328 fn touch_at_ux_1_floors_interactive_targets_at_44() {
329 let _guard = crate::input_profile::profile_test_lock();
330 touch_via_latch(1.0);
331 let m = effective_metrics();
332 assert_eq!(m.row_h, TOUCH_MIN);
335 assert_eq!(m.bar_h, TOUCH_MIN);
336 assert_eq!(m.vertical_row_h, TOUCH_MIN);
337 assert_eq!(menu_bar_height(), TOUCH_MIN);
338 assert_eq!(menu_vertical_row_height(), TOUCH_MIN);
339 let grow = TOUCH_MIN / ROW_H;
341 assert!((m.menu_w - MENU_W * grow).abs() < 1e-9);
342 assert!((m.default_font_size - DEFAULT_FONT_SIZE * grow).abs() < 1e-9);
343 assert_eq!(m.sep_h, SEP_H);
344 desktop();
345 }
346
347 #[test]
348 fn popup_row_hit_rects_are_44_tall_on_touch() {
349 let _guard = crate::input_profile::profile_test_lock();
350 touch_via_latch(1.0);
354 let items = vec![
355 MenuEntry::Item(MenuItem::action("A", "a")),
356 MenuEntry::Separator,
357 MenuEntry::Item(MenuItem::action("B", "b")),
358 ];
359 let layouts = stack_layout(
360 &items,
361 Point::new(20.0, 400.0),
362 MenuAnchorKind::Context,
363 &[],
364 Size::new(500.0, 500.0),
365 );
366 let rows = &layouts[0].rows;
367 assert_eq!(rows[0].rect.height, TOUCH_MIN, "item row must be 44 tall");
368 assert_eq!(rows[1].rect.height, SEP_H, "separator stays thin");
369 assert_eq!(rows[2].rect.height, TOUCH_MIN, "item row must be 44 tall");
370 assert_eq!(layouts[0].rect.width, MENU_W * (TOUCH_MIN / ROW_H));
371 desktop();
372 }
373
374 #[test]
375 fn touch_self_normalizes_against_ux_scale() {
376 let _guard = crate::input_profile::profile_test_lock();
377 touch_via_latch(1.7);
384 let m = effective_metrics();
385 assert_eq!(m.bar_h, BAR_H, "26px bar already clears 44 painted at 1.7");
386 assert_eq!(m.vertical_row_h, VERTICAL_ROW_H);
387 assert!((m.row_h - TOUCH_MIN / 1.7).abs() < 1e-9);
388 assert!(
389 (m.row_h * 1.7 - TOUCH_MIN).abs() < 1e-9,
390 "floored row paints exactly the touch minimum"
391 );
392
393 touch_via_latch(2.0);
397 let m = effective_metrics();
398 assert_eq!(m.row_h, ROW_H);
399 assert_eq!(m.bar_h, BAR_H);
400 assert_eq!(m.vertical_row_h, VERTICAL_ROW_H);
401 assert_eq!(m.menu_w, MENU_W);
402 assert_eq!(m.default_font_size, DEFAULT_FONT_SIZE);
403 desktop();
404 }
405
406 #[test]
407 fn effective_metrics_stay_finite_when_ux_scale_is_zero() {
408 let _guard = crate::input_profile::profile_test_lock();
413 touch_via_latch(1.0); crate::ux_scale::set_ux_scale_raw_for_test(0.0);
415 let m = effective_metrics();
416 assert!(
417 m.row_h.is_finite(),
418 "row_h must stay finite, got {}",
419 m.row_h
420 );
421 assert!(
422 m.bar_h.is_finite(),
423 "bar_h must stay finite, got {}",
424 m.bar_h
425 );
426 assert!(
427 m.vertical_row_h.is_finite(),
428 "vertical_row_h must stay finite, got {}",
429 m.vertical_row_h
430 );
431 assert!(
432 m.menu_w.is_finite(),
433 "menu_w must stay finite, got {}",
434 m.menu_w
435 );
436 assert!(
437 m.default_font_size.is_finite(),
438 "default_font_size must stay finite, got {}",
439 m.default_font_size
440 );
441 desktop();
442 }
443}