Skip to main content

agg_gui/widgets/menu/
geometry.rs

1//! Y-up menu geometry and hit testing.
2//!
3//! Popup menus are global overlays, so geometry is expressed in the owning
4//! widget's local coordinate space but clamped against the current viewport.
5
6use 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;
15/// Row height for a [`super::MenuOrientation::Vertical`] bar (a tall,
16/// narrow sidebar of top-level buttons).  Lives here with the other menu
17/// metric constants so [`effective_metrics`] can grow it on touch.
18pub const VERTICAL_ROW_H: f64 = 36.0;
19/// Default popup / bar text size before any touch growth or explicit
20/// [`super::MenuBar::with_font_size`] override.
21pub const DEFAULT_FONT_SIZE: f64 = 14.0;
22/// Minimum *painted* size (in physical CSS px at `ux_scale == 1.0`) for any
23/// interactive menu target on a touch device.  44 px is the long-standing
24/// Apple HIG / Material touch-target floor.
25pub const TOUCH_MIN: f64 = 44.0;
26const MARGIN: f64 = 4.0;
27
28/// The concrete menu metrics for the current frame.  Desktop values are the
29/// bare constants; on a touch device they grow so every interactive target
30/// clears [`TOUCH_MIN`] painted px.  Hit-testing and painting BOTH derive
31/// from this single struct (via [`stack_layout`] / the bar layout), so a
32/// grown popup can never have paint and hit rects disagree.
33#[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
43/// Effective menu metrics for the current frame.
44///
45/// When [`crate::input_profile::touch_ui_active`] is false these equal the
46/// desktop constants EXACTLY (bit-identical — desktop behaviour is
47/// unchanged).  When it is true, interactive heights are floored at
48/// `TOUCH_MIN` *painted* px.
49///
50/// The paint transform multiplies logical px by
51/// `effective_scale = device_scale * ux_scale`, and `device_scale` maps
52/// logical→physical so that at `ux_scale == 1.0` one logical px is one CSS
53/// px.  To guarantee `>= TOUCH_MIN` painted px the requirement in logical
54/// units is `TOUCH_MIN / ux_scale`.  This self-normalises against `ux_scale`:
55/// an app that already set `ux_scale >= TOUCH_MIN / BAR_H` (≈1.7) gets
56/// `TOUCH_MIN / 1.7 ≈ 25.9 < 26`, so `max()` is a no-op and nothing is
57/// double-scaled.  Width and default font size grow in lock-step with the
58/// row so the popup keeps its desktop proportions, just larger; separators
59/// stay thin because they are not tappable.
60pub 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    // `set_ux_scale` only debug-asserts positivity, so a release-mode
73    // caller can leave `ux_scale()` at `0.0`. Dividing by that would yield
74    // `+inf` metrics on this hot layout path (an infinitely tall menu bar).
75    // Clamp the divisor so a misbehaving shell degrades to large-but-finite
76    // geometry instead of poisoning layout.
77    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
92/// Current effective menu-bar height (logical px).  Host chrome that
93/// reserves vertical space for a top bar should call this **per layout**,
94/// not cache it at startup: the touch latch inside
95/// [`crate::input_profile::touch_ui_active`] can flip at runtime after the
96/// first touch, growing the bar from [`BAR_H`] to the touch minimum.
97pub fn menu_bar_height() -> f64 {
98    effective_metrics().bar_h
99}
100
101/// Current effective row height for a vertical (sidebar) menu bar (logical
102/// px).  Query per layout for the same runtime-latch reason as
103/// [`menu_bar_height`].
104pub 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    // One source of truth for every popup dimension this frame — paint and
135    // hit-test both flow through here, so they can never diverge.
136    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        // For top/context popups (open downward) the cascade
174        // anchors at the row's TOP so the submenu's top edge
175        // aligns with the parent row's top. For BottomBar popups
176        // (open upward) the cascade anchors at the row's BOTTOM
177        // so the submenu's bottom edge aligns with the parent
178        // row's bottom — visually symmetric.
179        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        // Top bar — popup hangs below the anchor (extends toward
256        // smaller y in Y-up). `Bar` allows negative-y clamp so a
257        // bar pushed flush against the viewport edge still places
258        // a popup correctly.
259        MenuAnchorKind::Bar => (-viewport.height, anchor.y - h),
260        // Bottom bar — popup rises ABOVE the anchor (extends
261        // toward larger y in Y-up). Popup rect's bottom = anchor.y.
262        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    /// Return every global this module reads to a known desktop baseline.
292    /// The input profile is a process-wide atomic other test files write,
293    /// so pin it explicitly; the touch latch and `ux_scale` are thread-local.
294    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    /// Activate touch sizing purely via the thread-local latch (a real
301    /// touch event) with the profile LEFT desktop — proving the runtime
302    /// fallback works even when the shell never called `set_input_profile`.
303    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        // At ux 1.0 logical px == painted px, so every interactive metric
333        // must equal the 44 px touch minimum.
334        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        // Width + font grow in lock-step with the row; separators stay thin.
340        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        // The row rects `stack_layout` produces feed BOTH hit-testing and
351        // painting, so verifying the interactive rows are 44 tall here
352        // proves the touch target the user presses is the one drawn.
353        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        // An app that already scaled the whole UI past the point where the
378        // base metric paints >= 44 px must NOT be grown further (no double
379        // scaling).  At ux 1.7 the 26 px bar paints 44.2 px, so `max()` is a
380        // no-op for the bar; the 36 px vertical row likewise.  The 24 px row
381        // paints only 40.8 px at ux 1.7, so it is floored to 44/1.7 (painting
382        // exactly 44) — still the floor, never base*ux.
383        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        // At ux large enough that every base already clears 44 painted
394        // (>= 44/24 ≈ 1.833), the whole struct is the desktop constants —
395        // the self-normalization no-op in full.
396        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        // `set_ux_scale` only debug-asserts positivity, so a release-mode
409        // shell can leave `ux_scale()` at 0.0.  Force that degenerate value
410        // past the assert via the raw test setter and confirm the divisor
411        // clamp keeps every metric large-but-finite rather than +inf.
412        let _guard = crate::input_profile::profile_test_lock();
413        touch_via_latch(1.0); // latch touch sizing so the divisor path runs
414        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}