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;
15const MARGIN: f64 = 4.0;
16
17#[derive(Clone, Debug)]
18pub struct PopupLayout {
19    pub rect: Rect,
20    pub rows: Vec<RowLayout>,
21    pub path_prefix: Vec<usize>,
22}
23
24#[derive(Clone, Debug)]
25pub struct RowLayout {
26    pub rect: Rect,
27    pub item_index: Option<usize>,
28}
29
30#[derive(Clone, Debug)]
31pub enum MenuHit {
32    Item(Vec<usize>),
33    Panel,
34}
35
36pub fn stack_layout(
37    root_items: &[MenuEntry],
38    anchor: Point,
39    anchor_kind: MenuAnchorKind,
40    open_path: &[usize],
41    viewport: Size,
42) -> Vec<PopupLayout> {
43    let mut layouts = Vec::new();
44    let mut items = root_items;
45    let mut x = anchor.x;
46    let mut y_top = anchor.y;
47    let mut prefix = Vec::new();
48
49    loop {
50        let rect = popup_rect(items, Point::new(x, y_top), anchor_kind, viewport);
51        let rows = row_layouts(items, rect);
52        layouts.push(PopupLayout {
53            rect,
54            rows,
55            path_prefix: prefix.clone(),
56        });
57
58        let Some(next_idx) = open_path.get(prefix.len()).copied() else {
59            break;
60        };
61        let Some(item) = item_at(items, next_idx) else {
62            break;
63        };
64        if item.submenu.is_empty() {
65            break;
66        }
67        let Some(row) = layouts.last().and_then(|layout| {
68            layout
69                .rows
70                .iter()
71                .find(|row| row.item_index == Some(next_idx))
72                .cloned()
73        }) else {
74            break;
75        };
76        prefix.push(next_idx);
77        items = &item.submenu;
78        x = (rect.x + rect.width - 2.0).min(viewport.width - MENU_W - MARGIN);
79        // For top/context popups (open downward) the cascade
80        // anchors at the row's TOP so the submenu's top edge
81        // aligns with the parent row's top. For BottomBar popups
82        // (open upward) the cascade anchors at the row's BOTTOM
83        // so the submenu's bottom edge aligns with the parent
84        // row's bottom — visually symmetric.
85        y_top = match anchor_kind {
86            MenuAnchorKind::BottomBar => row.rect.y,
87            _ => row.rect.y + row.rect.height,
88        };
89    }
90
91    layouts
92}
93
94pub fn hit_test(layouts: &[PopupLayout], pos: Point) -> Option<MenuHit> {
95    for layout in layouts.iter().rev() {
96        if !contains(layout.rect, pos) {
97            continue;
98        }
99        for row in &layout.rows {
100            if contains(row.rect, pos) {
101                if let Some(idx) = row.item_index {
102                    let mut path = layout.path_prefix.clone();
103                    path.push(idx);
104                    return Some(MenuHit::Item(path));
105                }
106                return Some(MenuHit::Panel);
107            }
108        }
109        return Some(MenuHit::Panel);
110    }
111    None
112}
113
114pub fn item_at_path<'a>(items: &'a [MenuEntry], path: &[usize]) -> Option<&'a MenuItem> {
115    let mut current = items;
116    let mut item = None;
117    for &idx in path {
118        item = item_at(current, idx);
119        current = &item?.submenu;
120    }
121    item
122}
123
124pub fn item_at(items: &[MenuEntry], idx: usize) -> Option<&MenuItem> {
125    match items.get(idx)? {
126        MenuEntry::Item(item) => Some(item),
127        MenuEntry::Separator => None,
128    }
129}
130
131pub fn popup_height(items: &[MenuEntry]) -> f64 {
132    items
133        .iter()
134        .map(|entry| match entry {
135            MenuEntry::Item(_) => ROW_H,
136            MenuEntry::Separator => SEP_H,
137        })
138        .sum::<f64>()
139        .max(ROW_H)
140}
141
142pub fn contains(rect: Rect, pos: Point) -> bool {
143    pos.x >= rect.x
144        && pos.x <= rect.x + rect.width
145        && pos.y >= rect.y
146        && pos.y <= rect.y + rect.height
147}
148
149fn popup_rect(
150    items: &[MenuEntry],
151    anchor: Point,
152    anchor_kind: MenuAnchorKind,
153    viewport: Size,
154) -> Rect {
155    let h = popup_height(items);
156    let x = anchor
157        .x
158        .clamp(MARGIN, (viewport.width - MENU_W - MARGIN).max(MARGIN));
159    let (min_y, raw_y) = match anchor_kind {
160        // Top bar — popup hangs below the anchor (extends toward
161        // smaller y in Y-up). `Bar` allows negative-y clamp so a
162        // bar pushed flush against the viewport edge still places
163        // a popup correctly.
164        MenuAnchorKind::Bar => (-viewport.height, anchor.y - h),
165        // Bottom bar — popup rises ABOVE the anchor (extends
166        // toward larger y in Y-up). Popup rect's bottom = anchor.y.
167        MenuAnchorKind::BottomBar => (MARGIN, anchor.y),
168        MenuAnchorKind::Context => (MARGIN, anchor.y - h),
169    };
170    let y = raw_y.clamp(min_y, (viewport.height - h - MARGIN).max(min_y));
171    Rect::new(x, y, MENU_W, h)
172}
173
174fn row_layouts(items: &[MenuEntry], rect: Rect) -> Vec<RowLayout> {
175    let mut y = rect.y + rect.height;
176    let mut rows = Vec::with_capacity(items.len());
177    for (idx, entry) in items.iter().enumerate() {
178        let h = match entry {
179            MenuEntry::Item(_) => ROW_H,
180            MenuEntry::Separator => SEP_H,
181        };
182        y -= h;
183        rows.push(RowLayout {
184            rect: Rect::new(rect.x, y, rect.width, h),
185            item_index: matches!(entry, MenuEntry::Item(_)).then_some(idx),
186        });
187    }
188    rows
189}