use crate::geometry::{Point, Rect, Size};
use super::model::{MenuEntry, MenuItem};
use super::state::MenuAnchorKind;
pub const ROW_H: f64 = 24.0;
pub const SEP_H: f64 = 7.0;
pub const MENU_W: f64 = 224.0;
pub const BAR_H: f64 = 26.0;
pub const VERTICAL_ROW_H: f64 = 36.0;
pub const DEFAULT_FONT_SIZE: f64 = 14.0;
pub const TOUCH_MIN: f64 = 44.0;
const MARGIN: f64 = 4.0;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MenuMetrics {
pub row_h: f64,
pub sep_h: f64,
pub bar_h: f64,
pub vertical_row_h: f64,
pub menu_w: f64,
pub default_font_size: f64,
}
pub fn effective_metrics() -> MenuMetrics {
let base = MenuMetrics {
row_h: ROW_H,
sep_h: SEP_H,
bar_h: BAR_H,
vertical_row_h: VERTICAL_ROW_H,
menu_w: MENU_W,
default_font_size: DEFAULT_FONT_SIZE,
};
if !crate::input_profile::touch_ui_active() {
return base;
}
let min_logical = TOUCH_MIN / crate::ux_scale::ux_scale().max(0.01);
let row_h = ROW_H.max(min_logical);
let bar_h = BAR_H.max(min_logical);
let vertical_row_h = VERTICAL_ROW_H.max(min_logical);
let grow = row_h / ROW_H;
MenuMetrics {
row_h,
sep_h: SEP_H,
bar_h,
vertical_row_h,
menu_w: MENU_W * grow,
default_font_size: DEFAULT_FONT_SIZE * grow,
}
}
pub fn menu_bar_height() -> f64 {
effective_metrics().bar_h
}
pub fn menu_vertical_row_height() -> f64 {
effective_metrics().vertical_row_h
}
#[derive(Clone, Debug)]
pub struct PopupLayout {
pub rect: Rect,
pub rows: Vec<RowLayout>,
pub path_prefix: Vec<usize>,
}
#[derive(Clone, Debug)]
pub struct RowLayout {
pub rect: Rect,
pub item_index: Option<usize>,
}
#[derive(Clone, Debug)]
pub enum MenuHit {
Item(Vec<usize>),
Panel,
}
pub fn stack_layout(
root_items: &[MenuEntry],
anchor: Point,
anchor_kind: MenuAnchorKind,
open_path: &[usize],
viewport: Size,
) -> Vec<PopupLayout> {
let m = effective_metrics();
let mut layouts = Vec::new();
let mut items = root_items;
let mut x = anchor.x;
let mut y_top = anchor.y;
let mut prefix = Vec::new();
loop {
let rect = popup_rect(items, Point::new(x, y_top), anchor_kind, viewport, &m);
let rows = row_layouts(items, rect, &m);
layouts.push(PopupLayout {
rect,
rows,
path_prefix: prefix.clone(),
});
let Some(next_idx) = open_path.get(prefix.len()).copied() else {
break;
};
let Some(item) = item_at(items, next_idx) else {
break;
};
if item.submenu.is_empty() {
break;
}
let Some(row) = layouts.last().and_then(|layout| {
layout
.rows
.iter()
.find(|row| row.item_index == Some(next_idx))
.cloned()
}) else {
break;
};
prefix.push(next_idx);
items = &item.submenu;
x = (rect.x + rect.width - 2.0).min(viewport.width - m.menu_w - MARGIN);
y_top = match anchor_kind {
MenuAnchorKind::BottomBar => row.rect.y,
_ => row.rect.y + row.rect.height,
};
}
layouts
}
pub fn hit_test(layouts: &[PopupLayout], pos: Point) -> Option<MenuHit> {
for layout in layouts.iter().rev() {
if !contains(layout.rect, pos) {
continue;
}
for row in &layout.rows {
if contains(row.rect, pos) {
if let Some(idx) = row.item_index {
let mut path = layout.path_prefix.clone();
path.push(idx);
return Some(MenuHit::Item(path));
}
return Some(MenuHit::Panel);
}
}
return Some(MenuHit::Panel);
}
None
}
pub fn item_at_path<'a>(items: &'a [MenuEntry], path: &[usize]) -> Option<&'a MenuItem> {
let mut current = items;
let mut item = None;
for &idx in path {
item = item_at(current, idx);
current = &item?.submenu;
}
item
}
pub fn item_at(items: &[MenuEntry], idx: usize) -> Option<&MenuItem> {
match items.get(idx)? {
MenuEntry::Item(item) => Some(item),
MenuEntry::Separator => None,
}
}
pub fn popup_height(items: &[MenuEntry], m: &MenuMetrics) -> f64 {
items
.iter()
.map(|entry| match entry {
MenuEntry::Item(_) => m.row_h,
MenuEntry::Separator => m.sep_h,
})
.sum::<f64>()
.max(m.row_h)
}
pub fn contains(rect: Rect, pos: Point) -> bool {
pos.x >= rect.x
&& pos.x <= rect.x + rect.width
&& pos.y >= rect.y
&& pos.y <= rect.y + rect.height
}
fn popup_rect(
items: &[MenuEntry],
anchor: Point,
anchor_kind: MenuAnchorKind,
viewport: Size,
m: &MenuMetrics,
) -> Rect {
let h = popup_height(items, m);
let x = anchor
.x
.clamp(MARGIN, (viewport.width - m.menu_w - MARGIN).max(MARGIN));
let (min_y, raw_y) = match anchor_kind {
MenuAnchorKind::Bar => (-viewport.height, anchor.y - h),
MenuAnchorKind::BottomBar => (MARGIN, anchor.y),
MenuAnchorKind::Context => (MARGIN, anchor.y - h),
};
let y = raw_y.clamp(min_y, (viewport.height - h - MARGIN).max(min_y));
Rect::new(x, y, m.menu_w, h)
}
fn row_layouts(items: &[MenuEntry], rect: Rect, m: &MenuMetrics) -> Vec<RowLayout> {
let mut y = rect.y + rect.height;
let mut rows = Vec::with_capacity(items.len());
for (idx, entry) in items.iter().enumerate() {
let h = match entry {
MenuEntry::Item(_) => m.row_h,
MenuEntry::Separator => m.sep_h,
};
y -= h;
rows.push(RowLayout {
rect: Rect::new(rect.x, y, rect.width, h),
item_index: matches!(entry, MenuEntry::Item(_)).then_some(idx),
});
}
rows
}
#[cfg(test)]
mod metrics_tests {
use super::*;
use crate::input_profile::{set_input_profile, touch_ui_active, InputProfile};
fn desktop() {
set_input_profile(InputProfile::Desktop);
crate::touch_state::clear_last_touch_event_for_testing();
crate::ux_scale::set_ux_scale(1.0);
}
fn touch_via_latch(ux: f64) {
set_input_profile(InputProfile::Desktop);
crate::touch_state::clear_last_touch_event_for_testing();
crate::touch_state::note_touch_event();
crate::ux_scale::set_ux_scale(ux);
assert!(touch_ui_active(), "latch must activate touch sizing");
}
#[test]
fn desktop_metrics_are_the_bare_constants() {
let _guard = crate::input_profile::profile_test_lock();
desktop();
let m = effective_metrics();
assert_eq!(m.row_h, ROW_H);
assert_eq!(m.sep_h, SEP_H);
assert_eq!(m.bar_h, BAR_H);
assert_eq!(m.vertical_row_h, VERTICAL_ROW_H);
assert_eq!(m.menu_w, MENU_W);
assert_eq!(m.default_font_size, DEFAULT_FONT_SIZE);
assert_eq!(menu_bar_height(), BAR_H);
assert_eq!(menu_vertical_row_height(), VERTICAL_ROW_H);
desktop();
}
#[test]
fn touch_at_ux_1_floors_interactive_targets_at_44() {
let _guard = crate::input_profile::profile_test_lock();
touch_via_latch(1.0);
let m = effective_metrics();
assert_eq!(m.row_h, TOUCH_MIN);
assert_eq!(m.bar_h, TOUCH_MIN);
assert_eq!(m.vertical_row_h, TOUCH_MIN);
assert_eq!(menu_bar_height(), TOUCH_MIN);
assert_eq!(menu_vertical_row_height(), TOUCH_MIN);
let grow = TOUCH_MIN / ROW_H;
assert!((m.menu_w - MENU_W * grow).abs() < 1e-9);
assert!((m.default_font_size - DEFAULT_FONT_SIZE * grow).abs() < 1e-9);
assert_eq!(m.sep_h, SEP_H);
desktop();
}
#[test]
fn popup_row_hit_rects_are_44_tall_on_touch() {
let _guard = crate::input_profile::profile_test_lock();
touch_via_latch(1.0);
let items = vec![
MenuEntry::Item(MenuItem::action("A", "a")),
MenuEntry::Separator,
MenuEntry::Item(MenuItem::action("B", "b")),
];
let layouts = stack_layout(
&items,
Point::new(20.0, 400.0),
MenuAnchorKind::Context,
&[],
Size::new(500.0, 500.0),
);
let rows = &layouts[0].rows;
assert_eq!(rows[0].rect.height, TOUCH_MIN, "item row must be 44 tall");
assert_eq!(rows[1].rect.height, SEP_H, "separator stays thin");
assert_eq!(rows[2].rect.height, TOUCH_MIN, "item row must be 44 tall");
assert_eq!(layouts[0].rect.width, MENU_W * (TOUCH_MIN / ROW_H));
desktop();
}
#[test]
fn touch_self_normalizes_against_ux_scale() {
let _guard = crate::input_profile::profile_test_lock();
touch_via_latch(1.7);
let m = effective_metrics();
assert_eq!(m.bar_h, BAR_H, "26px bar already clears 44 painted at 1.7");
assert_eq!(m.vertical_row_h, VERTICAL_ROW_H);
assert!((m.row_h - TOUCH_MIN / 1.7).abs() < 1e-9);
assert!(
(m.row_h * 1.7 - TOUCH_MIN).abs() < 1e-9,
"floored row paints exactly the touch minimum"
);
touch_via_latch(2.0);
let m = effective_metrics();
assert_eq!(m.row_h, ROW_H);
assert_eq!(m.bar_h, BAR_H);
assert_eq!(m.vertical_row_h, VERTICAL_ROW_H);
assert_eq!(m.menu_w, MENU_W);
assert_eq!(m.default_font_size, DEFAULT_FONT_SIZE);
desktop();
}
#[test]
fn effective_metrics_stay_finite_when_ux_scale_is_zero() {
let _guard = crate::input_profile::profile_test_lock();
touch_via_latch(1.0); crate::ux_scale::set_ux_scale_raw_for_test(0.0);
let m = effective_metrics();
assert!(
m.row_h.is_finite(),
"row_h must stay finite, got {}",
m.row_h
);
assert!(
m.bar_h.is_finite(),
"bar_h must stay finite, got {}",
m.bar_h
);
assert!(
m.vertical_row_h.is_finite(),
"vertical_row_h must stay finite, got {}",
m.vertical_row_h
);
assert!(
m.menu_w.is_finite(),
"menu_w must stay finite, got {}",
m.menu_w
);
assert!(
m.default_font_size.is_finite(),
"default_font_size must stay finite, got {}",
m.default_font_size
);
desktop();
}
}