use std::collections::HashMap;
use crate::geometry::{LogicalPoint, LogicalRect, LogicalSize};
use crate::layout::{resolve_segments, SegmentMetrics};
use crate::menu::{Align, Axis, Content, Icon, Item, MenuId, Row, Segment, Stack};
use crate::render::{SceneDrawer, TextRun};
use crate::style::{Font, FontFamily, Rgba, Weight};
use crate::theme::{MenuOptions, Theme};
use crate::Menu;
type MeasureCache = HashMap<MeasureKey, f32>;
#[derive(PartialEq, Eq, Hash)]
struct MeasureKey {
text: String,
family_tag: u8,
family_name: String,
size_bits: u32,
weight: u16,
}
impl MeasureKey {
fn new(text: &str, font: &Font) -> Self {
let (family_tag, family_name) = match &font.family {
FontFamily::System => (0u8, String::new()),
FontFamily::SystemMono => (1u8, String::new()),
FontFamily::Named(name) => (2u8, name.clone()),
};
MeasureKey {
text: text.to_string(),
family_tag,
family_name,
size_bits: font.size.to_bits(),
weight: font.weight.ot_weight(),
}
}
}
fn measure_cached<D: SceneDrawer>(
drawer: &D,
cache: &mut MeasureCache,
text: &str,
font: &Font,
) -> f32 {
let key = MeasureKey::new(text, font);
if let Some(&w) = cache.get(&key) {
return w;
}
let w = drawer.measure_text(text, font);
cache.insert(key, w);
w
}
#[derive(Clone, Debug)]
pub struct LaidRow {
pub index: usize,
pub rect: LogicalRect,
pub id: MenuId,
pub interactive: bool,
}
#[derive(Clone, Debug, Default)]
pub struct LaidMenu {
pub size: LogicalSize,
pub rows: Vec<LaidRow>,
}
impl LaidMenu {
pub fn hit(&self, point: LogicalPoint) -> Option<usize> {
self.rows
.iter()
.find(|r| r.interactive && r.rect.contains(point))
.map(|r| r.index)
}
pub fn id_at(&self, point: LogicalPoint) -> Option<MenuId> {
self.rows
.iter()
.find(|r| r.interactive && r.rect.contains(point))
.map(|r| r.id.clone())
}
}
const SEPARATOR_HEIGHT: f32 = 11.0;
const ICON_SIZE: f32 = 16.0;
const TRAILING_COLUMN: f32 = 14.0;
const DEFAULT_MIN_WIDTH: f32 = 200.0;
const DEFAULT_MAX_WIDTH: f32 = 380.0;
const CONTENT_ROW_VPAD: f32 = 4.0;
fn is_submenu(item: &Item) -> bool {
matches!(item, Item::Submenu { .. })
}
fn row_leading_width(row: &Row, gap: f32) -> f32 {
if row.leading.is_some() || row.checked == Some(true) {
ICON_SIZE + gap
} else {
0.0
}
}
fn menu_reserves_gutter(menu: &Menu) -> bool {
menu.items.iter().any(|it| {
item_row(it)
.is_some_and(|r| r.checked.is_some() || matches!(r.leading, Some(Icon::Checkmark)))
})
}
fn row_lead(row: &Row, gap: f32, reserve_gutter: bool) -> f32 {
if reserve_gutter {
ICON_SIZE + gap
} else {
row_leading_width(row, gap)
}
}
fn row_font(row: &Row, seg: &Segment, base: &Font) -> Font {
if let Some(f) = &seg.font {
f.clone()
} else {
let mut f = base.clone();
let _ = row;
f.weight = base.weight;
f
}
}
fn row_intrinsic<D: SceneDrawer>(
d: &D,
cache: &mut MeasureCache,
row: &Row,
base: &Font,
base_color: Rgba,
theme: &Theme,
gap: f32,
) -> f32 {
if row.segments.is_empty() {
return 0.0;
}
let mut w = 0.0;
for (i, seg) in row.segments.iter().enumerate() {
let font = row_font(row, seg, base);
let seg_base = seg_base_color(seg, theme, base_color, false);
w += measure_segment(d, cache, seg, &font, theme, seg_base, false);
if i + 1 < row.segments.len() {
w += gap;
}
}
w
}
fn item_row(item: &Item) -> Option<&Row> {
match item {
Item::Row(r) | Item::SectionHeader(r) => Some(r),
Item::Submenu { label, .. } => Some(label),
Item::Separator | Item::Content(_) => None,
}
}
fn measure_content<D: SceneDrawer>(
drawer: &D,
cache: &mut MeasureCache,
theme: &Theme,
content: &Content,
) -> LogicalSize {
match content {
Content::Text(t) => {
let font = t.font.clone().unwrap_or_else(|| theme.row_font.clone());
let w = measure_cached(drawer, cache, &t.text, &font);
let h = drawer.line_height(&font);
LogicalSize::new(w, h)
}
Content::Image { size, .. } => LogicalSize::new(*size, *size),
Content::Stack(stack) => measure_stack(drawer, cache, theme, stack),
Content::Spacer => LogicalSize::new(0.0, 0.0),
}
}
fn measure_stack<D: SceneDrawer>(
drawer: &D,
cache: &mut MeasureCache,
theme: &Theme,
stack: &Stack,
) -> LogicalSize {
let mut main = 0.0_f32;
let mut cross = 0.0_f32;
for (i, child) in stack.children.iter().enumerate() {
let sz = measure_content(drawer, cache, theme, child);
let (m, c) = match stack.axis {
Axis::Horizontal => (sz.width, sz.height),
Axis::Vertical => (sz.height, sz.width),
};
main += m;
if i + 1 < stack.children.len() {
main += stack.spacing;
}
cross = cross.max(c);
}
match stack.axis {
Axis::Horizontal => LogicalSize::new(main, cross),
Axis::Vertical => LogicalSize::new(cross, main),
}
}
fn paint_content<D: SceneDrawer>(
drawer: &mut D,
cache: &mut MeasureCache,
theme: &Theme,
content: &Content,
rect: LogicalRect,
base_color: Rgba,
) {
match content {
Content::Text(t) => {
let font = t.font.clone().unwrap_or_else(|| theme.row_font.clone());
let color = t.color.map(|c| theme.resolve(c)).unwrap_or(base_color);
let w = measure_cached(drawer, cache, &t.text, &font);
let lh = drawer.line_height(&font);
let x = match t.align {
Align::Left => rect.origin.x,
Align::Center => rect.origin.x + (rect.size.width - w) / 2.0,
Align::Right => rect.origin.x + (rect.size.width - w),
};
let y = rect.origin.y + (rect.size.height - lh) / 2.0;
drawer.draw_text(&TextRun {
text: &t.text,
origin: LogicalPoint::new(x, y),
font: &font,
color,
weight: font.weight,
});
}
Content::Image { icon, size } => {
let x = rect.origin.x + (rect.size.width - size) / 2.0;
let y = rect.origin.y + (rect.size.height - size) / 2.0;
let dest = LogicalRect::new(LogicalPoint::new(x, y), LogicalSize::new(*size, *size));
match icon {
Icon::Png(bytes) | Icon::Svg(bytes) => {
if let Some(decoded) = drawer.decode_icon(bytes) {
let (rgba, w, h) = &*decoded;
drawer.draw_image(rgba, *w, *h, dest);
}
}
Icon::Checkmark => draw_glyph_centered(
drawer,
cache,
"\u{2713}",
&theme.row_font,
Weight::Bold,
base_color,
dest,
),
Icon::Symbol(_) => {}
}
}
Content::Stack(s) => paint_stack(drawer, cache, theme, s, rect, base_color),
Content::Spacer => {}
}
}
fn paint_stack<D: SceneDrawer>(
drawer: &mut D,
cache: &mut MeasureCache,
theme: &Theme,
stack: &Stack,
rect: LogicalRect,
base_color: Rgba,
) {
if stack.children.is_empty() {
return;
}
let sizes: Vec<LogicalSize> = stack
.children
.iter()
.map(|c| measure_content(drawer, cache, theme, c))
.collect();
let n = stack.children.len();
let spacing_total = stack.spacing * (n.saturating_sub(1)) as f32;
let main_avail = match stack.axis {
Axis::Horizontal => rect.size.width,
Axis::Vertical => rect.size.height,
} - spacing_total;
let cross_avail = match stack.axis {
Axis::Horizontal => rect.size.height,
Axis::Vertical => rect.size.width,
};
let mut fixed_main_sum = 0.0_f32;
let mut spacer_count = 0usize;
for (child, sz) in stack.children.iter().zip(&sizes) {
if matches!(child, Content::Spacer) {
spacer_count += 1;
} else {
fixed_main_sum += match stack.axis {
Axis::Horizontal => sz.width,
Axis::Vertical => sz.height,
};
}
}
let leftover = (main_avail - fixed_main_sum).max(0.0);
let spacer_share = if spacer_count > 0 {
leftover / spacer_count as f32
} else {
0.0
};
let cross_origin = match stack.axis {
Axis::Horizontal => rect.origin.y,
Axis::Vertical => rect.origin.x,
};
let mut main_pos = match stack.axis {
Axis::Horizontal => rect.origin.x,
Axis::Vertical => rect.origin.y,
};
for (child, sz) in stack.children.iter().zip(&sizes) {
let (m, c) = match stack.axis {
Axis::Horizontal => (sz.width, sz.height),
Axis::Vertical => (sz.height, sz.width),
};
let this_main = if matches!(child, Content::Spacer) {
spacer_share
} else {
m
};
let cross_pos = match stack.align {
Align::Left => cross_origin,
Align::Center => cross_origin + (cross_avail - c) / 2.0,
Align::Right => cross_origin + (cross_avail - c),
};
let child_rect = match stack.axis {
Axis::Horizontal => LogicalRect::new(
LogicalPoint::new(main_pos, cross_pos),
LogicalSize::new(this_main, c),
),
Axis::Vertical => LogicalRect::new(
LogicalPoint::new(cross_pos, main_pos),
LogicalSize::new(c, this_main),
),
};
paint_content(drawer, cache, theme, child, child_rect, base_color);
main_pos += this_main + stack.spacing;
}
}
fn style_pieces(
seg: &Segment,
base_color: Rgba,
base_weight: Weight,
theme: &Theme,
highlighted: bool,
) -> Vec<(String, Rgba, Weight)> {
if seg.runs.is_empty() {
return vec![(seg.text.clone(), base_color, base_weight)];
}
let mut pieces: Vec<(String, Rgba, Weight)> = Vec::new();
let mut u16_idx = 0usize;
for ch in seg.text.chars() {
let mut color = base_color;
let mut weight = base_weight;
for run in &seg.runs {
if u16_idx >= run.start && u16_idx < run.start.saturating_add(run.len) {
if !highlighted {
color = theme.resolve(run.color);
}
if let Some(w) = run.weight {
weight = w;
}
break;
}
}
match pieces.last_mut() {
Some((s, c, w)) if *c == color && *w == weight => s.push(ch),
_ => pieces.push((ch.to_string(), color, weight)),
}
u16_idx += ch.len_utf16();
}
pieces
}
fn seg_base_color(seg: &Segment, theme: &Theme, base_color: Rgba, highlighted: bool) -> Rgba {
if highlighted {
Rgba::WHITE
} else if let Some(c) = seg.color {
theme.resolve(c)
} else {
base_color
}
}
fn measure_segment<D: SceneDrawer>(
drawer: &D,
cache: &mut MeasureCache,
seg: &Segment,
font: &Font,
theme: &Theme,
base_color: Rgba,
highlighted: bool,
) -> f32 {
style_pieces(seg, base_color, font.weight, theme, highlighted)
.iter()
.map(|(text, _color, weight)| {
let pf = font.clone().with_weight(*weight);
measure_cached(drawer, cache, text, &pf)
})
.sum()
}
pub fn render_menu<D: SceneDrawer>(
drawer: &mut D,
menu: &Menu,
theme: &Theme,
opts: &MenuOptions,
highlight: Option<usize>,
) -> LaidMenu {
let pad = theme.padding;
let gap = theme.column_gap;
let base_font = theme.row_font.clone();
let reserve_gutter = match opts.gutter {
crate::theme::GutterPolicy::Always => true,
crate::theme::GutterPolicy::Never => false,
crate::theme::GutterPolicy::Auto => menu_reserves_gutter(menu),
};
let trailing_w = if menu.items.iter().any(is_submenu) {
TRAILING_COLUMN
} else {
0.0
};
let mut measure_cache: MeasureCache = HashMap::new();
let mut max_content = 0.0_f32;
for item in &menu.items {
if let Some(row) = item_row(item) {
let is_header = matches!(item, Item::SectionHeader(_));
let font = if is_header {
&theme.header_font
} else {
&base_font
};
let base_color = if is_header || !row.enabled {
theme.resolve(theme.secondary_label)
} else {
theme.resolve(theme.label)
};
let content = row_lead(row, gap, reserve_gutter)
+ row_intrinsic(
drawer,
&mut measure_cache,
row,
font,
base_color,
theme,
gap,
);
max_content = max_content.max(content);
} else if let Item::Content(stack) = item {
let stack_size = measure_stack(drawer, &mut measure_cache, theme, stack);
max_content = max_content.max(stack_size.width);
}
}
let min_w = opts.min_width.unwrap_or(DEFAULT_MIN_WIDTH);
let max_w = opts.max_width.unwrap_or(DEFAULT_MAX_WIDTH).max(min_w);
let desired = pad.left + max_content + trailing_w + pad.right;
let width = desired.clamp(min_w, max_w);
let content_left = pad.left;
let band_right = width - pad.right - trailing_w;
let mut y = pad.top;
let mut rows: Vec<LaidRow> = Vec::new();
let mut plan: Vec<(usize, f32, f32)> = Vec::new(); for (i, item) in menu.items.iter().enumerate() {
let h = match item {
Item::Separator => SEPARATOR_HEIGHT,
Item::SectionHeader(_) => theme.row_height,
Item::Row(r) | Item::Submenu { label: r, .. } => {
theme.row_height.max(r.min_height.unwrap_or(0.0))
}
Item::Content(stack) => {
let stack_size = measure_stack(drawer, &mut measure_cache, theme, stack);
theme
.row_height
.max(stack_size.height + CONTENT_ROW_VPAD * 2.0)
}
};
plan.push((i, y, h));
y += h;
}
let height = y + pad.bottom;
let size = LogicalSize::new(width, height);
drawer.begin_frame(size);
let bg = theme.resolve(theme.background);
drawer.fill_round_rect(
LogicalRect::new(LogicalPoint::new(0.0, 0.0), size),
theme.corner_radius,
bg,
);
for (i, ry, rh) in plan {
let item = &menu.items[i];
match item {
Item::Separator => {
let sep = LogicalRect::new(
LogicalPoint::new(pad.left, ry + rh / 2.0),
LogicalSize::new(width - pad.horizontal(), 1.0),
);
drawer.draw_separator(sep, theme.resolve(theme.separator));
}
Item::SectionHeader(row) => {
draw_row_content(
drawer,
&mut measure_cache,
row,
theme,
&theme.header_font,
content_left,
band_right,
reserve_gutter,
ry,
rh,
false,
false,
theme.resolve(theme.secondary_label),
);
}
Item::Content(stack) => {
if let Some(bg) = stack.background {
let band =
LogicalRect::new(LogicalPoint::new(0.0, ry), LogicalSize::new(width, rh));
drawer.fill_round_rect(band, 0.0, theme.resolve(bg));
}
let content_rect = LogicalRect::new(
LogicalPoint::new(content_left, ry + CONTENT_ROW_VPAD),
LogicalSize::new(
(band_right - content_left).max(1.0),
(rh - CONTENT_ROW_VPAD * 2.0).max(1.0),
),
);
paint_stack(
drawer,
&mut measure_cache,
theme,
stack,
content_rect,
theme.resolve(theme.label),
);
}
Item::Row(row) | Item::Submenu { label: row, .. } => {
let submenu = is_submenu(item);
let interactive = if submenu {
row.enabled
} else {
row.enabled && !row.id.is_none()
};
let highlighted = interactive && highlight == Some(i);
let rect =
LogicalRect::new(LogicalPoint::new(0.0, ry), LogicalSize::new(width, rh));
if highlighted {
let hl = LogicalRect::new(
LogicalPoint::new(pad.left - 2.0, ry + 1.0),
LogicalSize::new(width - pad.horizontal() + 4.0, rh - 2.0),
);
drawer.fill_round_rect(hl, 5.0, theme.resolve(theme.accent));
}
let base_color = if highlighted {
Rgba::WHITE
} else if !row.enabled {
theme.resolve(theme.secondary_label)
} else {
theme.resolve(theme.label)
};
draw_row_content(
drawer,
&mut measure_cache,
row,
theme,
&base_font,
content_left,
band_right,
reserve_gutter,
ry,
rh,
submenu,
highlighted,
base_color,
);
rows.push(LaidRow {
index: i,
rect,
id: row.id.clone(),
interactive,
});
}
}
}
LaidMenu { size, rows }
}
#[allow(clippy::too_many_arguments)]
fn draw_row_content<D: SceneDrawer>(
drawer: &mut D,
cache: &mut MeasureCache,
row: &Row,
theme: &Theme,
base_font: &Font,
content_left: f32,
band_right: f32,
reserve_gutter: bool,
ry: f32,
rh: f32,
submenu: bool,
highlighted: bool,
base_color: Rgba,
) {
let lead = row_lead(row, theme.column_gap, reserve_gutter);
let band_x = content_left + lead;
let band_w = (band_right - band_x).max(1.0);
if lead > 0.0 {
let icon_rect = LogicalRect::new(
LogicalPoint::new(content_left, ry + (rh - ICON_SIZE) / 2.0),
LogicalSize::new(ICON_SIZE, ICON_SIZE),
);
match &row.leading {
Some(Icon::Png(bytes)) => {
if let Some(decoded) = drawer.decode_icon(bytes) {
let (rgba, w, h) = &*decoded;
drawer.draw_image(rgba, *w, *h, icon_rect);
}
}
Some(Icon::Checkmark) => draw_glyph_centered(
drawer,
cache,
"\u{2713}",
base_font,
Weight::Bold,
if highlighted {
Rgba::WHITE
} else {
theme.resolve(theme.accent)
},
icon_rect,
),
_ => {
if row.checked == Some(true) {
draw_glyph_centered(
drawer,
cache,
"\u{2713}",
base_font,
Weight::Bold,
if highlighted {
Rgba::WHITE
} else {
theme.resolve(theme.accent)
},
icon_rect,
);
}
}
}
}
if !row.segments.is_empty() {
let metrics: Vec<SegmentMetrics> = row
.segments
.iter()
.map(|seg| {
let font = row_font(row, seg, base_font);
let seg_base = seg_base_color(seg, theme, base_color, highlighted);
SegmentMetrics::new(
measure_segment(drawer, cache, seg, &font, theme, seg_base, highlighted),
seg.flex,
seg.align,
)
})
.collect();
let boxes = resolve_segments(&metrics, band_w);
for (seg, bx) in row.segments.iter().zip(boxes.iter()) {
let font = row_font(row, seg, base_font);
let lh = drawer.line_height(&font);
let text_top = ry + (rh - lh) / 2.0;
let seg_base = seg_base_color(seg, theme, base_color, highlighted);
let pieces = style_pieces(seg, seg_base, font.weight, theme, highlighted);
let mut px = band_x + bx.text_x;
for (text, color, weight) in pieces {
let pf = font.clone().with_weight(weight);
let w = measure_cached(drawer, cache, &text, &pf);
drawer.draw_text(&TextRun {
text: &text,
origin: LogicalPoint::new(px, text_top),
font: &pf,
color,
weight,
});
px += w;
}
}
}
if submenu {
let chev_rect = LogicalRect::new(
LogicalPoint::new(band_x + band_w, ry),
LogicalSize::new(TRAILING_COLUMN, rh),
);
draw_glyph_centered(
drawer,
cache,
"\u{203A}", base_font,
Weight::Regular,
if highlighted {
Rgba::WHITE
} else {
theme.resolve(theme.secondary_label)
},
chev_rect,
);
}
}
#[allow(clippy::too_many_arguments)]
fn draw_glyph_centered<D: SceneDrawer>(
drawer: &mut D,
cache: &mut MeasureCache,
glyph: &str,
base_font: &Font,
weight: Weight,
color: Rgba,
rect: LogicalRect,
) {
let font = base_font.clone().with_weight(weight);
let w = measure_cached(drawer, cache, glyph, &font);
let lh = drawer.line_height(&font);
let origin = LogicalPoint::new(
rect.origin.x + (rect.size.width - w) / 2.0,
rect.origin.y + (rect.size.height - lh) / 2.0,
);
drawer.draw_text(&TextRun {
text: glyph,
origin,
font: &font,
color,
weight,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::menu::{Align, Flex, StyleRun, TextContent};
use crate::style::Color;
use std::sync::Arc;
fn demo_menu() -> Menu {
Menu::new()
.section_header(Row::info().label("Claude"))
.row(
Row::new("switch:claude:me")
.leading(Icon::Checkmark)
.checked(true)
.segments(vec![
Segment::new("me@example.com")
.flex(Flex::Grow)
.font(Font::system(13.0, Weight::Bold)),
Segment::new("47% / 89%")
.align(Align::Right)
.runs(vec![StyleRun::new(6, 3, Color::SystemRed)]),
]),
)
.separator()
.submenu(Row::new("settings").label("Settings"), Menu::new())
.row(Row::new("quit").segments(vec![
Segment::new("Quit").flex(Flex::Grow),
Segment::new("usagio v1")
.align(Align::Right)
.color(Color::SecondaryLabel),
]))
}
#[test]
fn repeated_render_reuses_shaped_runs_no_reshaping() {
let mut d = crate::render::RasterDrawer::new(2.0);
let menu = demo_menu();
let _ = render_menu(&mut d, &menu, &Theme::dark(), &MenuOptions::default(), None);
let after_first = d.shape_miss_count();
assert!(after_first > 0, "the first render shapes its runs");
let _ = render_menu(
&mut d,
&menu,
&Theme::dark(),
&MenuOptions::default(),
Some(0),
);
assert_eq!(
d.shape_miss_count(),
after_first,
"a repaint of unchanged text must not re-shape any run"
);
}
#[test]
fn render_produces_nonempty_size_and_hits() {
let mut d = crate::render::RasterDrawer::new(2.0);
let laid = render_menu(
&mut d,
&demo_menu(),
&Theme::dark(),
&MenuOptions::default(),
None,
);
assert!(laid.size.width > 100.0 && laid.size.height > 60.0);
assert_eq!(laid.rows.iter().filter(|r| r.interactive).count(), 3);
let (dw, dh) = d.device_size();
assert_eq!(dw, (laid.size.width * 2.0).round() as u32);
assert_eq!(dh, (laid.size.height * 2.0).round() as u32);
let any_opaque = d.framebuffer().pixels().chunks_exact(4).any(|p| p[3] > 0);
assert!(
any_opaque,
"rendered pixmap should not be fully transparent"
);
}
#[test]
fn style_run_semantic_color_follows_active_theme() {
let seg = Segment::new("ab").runs(vec![StyleRun::new(1, 1, Color::Label)]);
let base = Rgba::opaque(1, 2, 3);
let dark = style_pieces(&seg, base, Weight::Regular, &Theme::dark(), false);
let light = style_pieces(&seg, base, Weight::Regular, &Theme::light(), false);
let dark_b = dark.iter().find(|(s, ..)| s == "b").expect("styled piece");
let light_b = light.iter().find(|(s, ..)| s == "b").expect("styled piece");
assert_eq!(dark_b.1, Theme::dark().resolve(Color::Label));
assert_eq!(light_b.1, Theme::light().resolve(Color::Label));
assert_ne!(dark_b.1, light_b.1);
}
#[test]
fn style_run_color_is_suppressed_when_highlighted_but_weight_is_kept() {
let seg = Segment::new("ab").runs(vec![
StyleRun::new(1, 1, Color::SystemRed).weight(Weight::Bold)
]);
let white = Rgba::WHITE;
let hot = style_pieces(&seg, white, Weight::Regular, &Theme::light(), true);
for (_s, c, _w) in &hot {
assert_eq!(
*c, white,
"highlighted row keeps every piece at the base color"
);
}
let hot_b = hot.iter().find(|(s, ..)| s == "b").expect("styled piece");
assert_eq!(hot_b.2, Weight::Bold, "weight override survives highlight");
let cold = style_pieces(&seg, white, Weight::Regular, &Theme::light(), false);
let cold_b = cold.iter().find(|(s, ..)| s == "b").expect("styled piece");
assert_eq!(cold_b.1, Theme::light().resolve(Color::SystemRed));
}
#[test]
fn measure_segment_accounts_for_per_run_weight() {
let d = crate::render::RasterDrawer::new_headless(1.0);
let mut cache = MeasureCache::new();
let font = Font::default();
let theme = Theme::light();
let seg =
Segment::new("Quit").runs(vec![StyleRun::new(0, 4, Color::Label).weight(Weight::Bold)]);
let measured = measure_segment(&d, &mut cache, &seg, &font, &theme, Rgba::WHITE, false);
let drawn: f32 = style_pieces(&seg, Rgba::WHITE, font.weight, &theme, false)
.iter()
.map(|(t, _c, w)| measure_cached(&d, &mut cache, t, &font.clone().with_weight(*w)))
.sum();
assert_eq!(measured, drawn);
let naive = measure_cached(&d, &mut cache, &seg.text, &font);
assert!(measured >= naive);
}
#[test]
fn hit_testing_maps_points_to_rows() {
let mut d = crate::render::RasterDrawer::new(1.0);
let laid = render_menu(
&mut d,
&demo_menu(),
&Theme::light(),
&MenuOptions::default(),
None,
);
let account = &laid.rows[0];
let center = LogicalPoint::new(
account.rect.origin.x + account.rect.size.width / 2.0,
account.rect.origin.y + account.rect.size.height / 2.0,
);
assert_eq!(laid.hit(center), Some(account.index));
assert_eq!(laid.id_at(center).unwrap().as_str(), "switch:claude:me");
}
#[test]
fn measure_cached_matches_a_direct_measure_text_call() {
let d = crate::render::RasterDrawer::new_headless(1.0);
let font = Font::system(13.0, Weight::Regular);
let text = "Settings";
let direct = d.measure_text(text, &font);
let mut cache: MeasureCache = HashMap::new();
let cold = measure_cached(&d, &mut cache, text, &font);
assert_eq!(
cold, direct,
"first (cache-miss) call must match measure_text"
);
assert_eq!(cache.len(), 1);
let warm = measure_cached(&d, &mut cache, text, &font);
assert_eq!(
warm, direct,
"second (cache-hit) call must still match measure_text"
);
assert_eq!(
cache.len(),
1,
"a repeat (text, font) must not grow the cache"
);
}
#[test]
fn measure_cached_distinguishes_different_fonts_for_the_same_text() {
let d = crate::render::RasterDrawer::new_headless(1.0);
let text = "Settings";
let small = Font::system(11.0, Weight::Regular);
let large = Font::system(22.0, Weight::Regular);
let mut cache: MeasureCache = HashMap::new();
let w_small = measure_cached(&d, &mut cache, text, &small);
let w_large = measure_cached(&d, &mut cache, text, &large);
assert_eq!(w_small, d.measure_text(text, &small));
assert_eq!(w_large, d.measure_text(text, &large));
assert!(w_large > w_small, "a larger font must measure wider");
assert_eq!(cache.len(), 2);
}
#[derive(Default)]
struct RecordingDrawer {
texts: Vec<(String, f32, f32)>, images: Vec<LogicalRect>, }
impl SceneDrawer for RecordingDrawer {
fn begin_frame(&mut self, _size: LogicalSize) {}
fn fill_round_rect(&mut self, _r: LogicalRect, _cr: f32, _c: Rgba) {}
fn draw_separator(&mut self, _r: LogicalRect, _c: Rgba) {}
fn measure_text(&self, text: &str, _font: &Font) -> f32 {
text.chars().count() as f32 * 7.0
}
fn line_height(&self, _font: &Font) -> f32 {
14.0
}
fn draw_text(&mut self, run: &TextRun<'_>) {
self.texts
.push((run.text.to_string(), run.origin.x, run.origin.y));
}
fn draw_image(&mut self, _rgba: &[u8], _w: u32, _h: u32, dest: LogicalRect) {
self.images.push(dest);
}
fn decode_icon(&self, _bytes: &Arc<[u8]>) -> Option<crate::render::DecodedIcon> {
Some(std::rc::Rc::new((vec![0u8; 16], 2, 2)))
}
}
fn text_right_edge(d: &RecordingDrawer, needle: &str) -> f32 {
let (t, x, _) = d
.texts
.iter()
.find(|(t, ..)| t == needle)
.unwrap_or_else(|| panic!("no draw_text for {needle:?}; got {:?}", d.texts));
x + t.chars().count() as f32 * 7.0
}
#[test]
fn issue16_no_global_leading_gutter_shared_left_x() {
use std::sync::Arc;
let logo: Arc<[u8]> = Arc::from(vec![0u8; 8]);
let menu = Menu::new()
.row(
Row::new("hdr")
.leading(Icon::Png(logo.clone()))
.label("Claude")
.enabled(false),
)
.row(Row::new("acct").label("matthew@example.com"))
.row(Row::new("quit").label("Quit"));
let mut d = RecordingDrawer::default();
let _ = render_menu(&mut d, &menu, &Theme::dark(), &MenuOptions::default(), None);
assert_eq!(d.images.len(), 1, "one inline icon");
let icon_x = d.images[0].origin.x;
let acct_x = d
.texts
.iter()
.find(|(t, ..)| t == "matthew@example.com")
.map(|(_, x, _)| *x)
.expect("account row text");
let quit_x = d
.texts
.iter()
.find(|(t, ..)| t == "Quit")
.map(|(_, x, _)| *x)
.expect("quit row text");
assert!(
(acct_x - icon_x).abs() < 0.5,
"plain row must start at the icon's left x, not indented: icon={icon_x} acct={acct_x}"
);
assert!(
(quit_x - icon_x).abs() < 0.5,
"every plain row shares the same left x: icon={icon_x} quit={quit_x}"
);
let hdr_x = d
.texts
.iter()
.find(|(t, ..)| t == "Claude")
.map(|(_, x, _)| *x)
.expect("header text");
assert!(
hdr_x > icon_x + 8.0,
"the icon row's text follows its icon inline: icon={icon_x} hdr={hdr_x}"
);
}
#[test]
fn checkable_menu_reserves_gutter_so_rows_align() {
let menu = Menu::new()
.row(Row::new("a").checked(true).segments(vec![
Segment::new("me@example.com").flex(Flex::Grow),
Segment::new("47%").align(Align::Right),
]))
.row(Row::new("b").segments(vec![
Segment::new("you@example.com").flex(Flex::Grow),
Segment::new("20%").align(Align::Right),
]));
let mut d = RecordingDrawer::default();
let _ = render_menu(&mut d, &menu, &Theme::dark(), &MenuOptions::default(), None);
let x = |needle: &str| {
d.texts
.iter()
.find(|(t, ..)| t == needle)
.map(|(_, x, _)| *x)
.unwrap_or_else(|| panic!("no text {needle:?}"))
};
assert!(
(x("me@example.com") - x("you@example.com")).abs() < 0.5,
"checked and unchecked rows must share a text left x: {} vs {}",
x("me@example.com"),
x("you@example.com")
);
assert!(x("you@example.com") > 6.0);
}
#[test]
fn all_unchecked_checkable_menu_still_reserves_gutter() {
let text_x = |checked: bool| {
let menu = Menu::new()
.row(Row::new("a").checked(checked).label("Alpha"))
.row(Row::new("b").checked(checked).label("Beta"));
let mut d = RecordingDrawer::default();
let _ = render_menu(&mut d, &menu, &Theme::dark(), &MenuOptions::default(), None);
d.texts
.iter()
.find(|(t, ..)| t == "Alpha")
.map(|(_, x, _)| *x)
.expect("Alpha text")
};
let off = text_x(false);
let on = text_x(true);
assert!(
(off - on).abs() < 0.5,
"checkable rows must reserve the gutter whether checked or not: off={off} on={on}"
);
assert!(
off > 6.0,
"an all-unchecked checkable menu must still reserve the check column, text x={off}"
);
}
#[test]
fn issue15_leading_icons_and_tab_values_align_to_shared_columns() {
let logo: Arc<[u8]> = Arc::from(vec![0u8; 8]);
let menu = Menu::new()
.row(
Row::new("hdr:claude")
.leading(Icon::Png(logo.clone()))
.label("Claude")
.enabled(false),
)
.submenu(
Row::new("acct:short").segments(vec![
Segment::new("a@x.com").flex(Flex::Grow),
Segment::new("20% / 38%").align(Align::Right),
]),
Menu::new().row(Row::new("d").label("detail")),
)
.submenu(
Row::new("acct:longemail").segments(vec![
Segment::new("demo1@example.com").flex(Flex::Grow),
Segment::new("47% / 52%").align(Align::Right),
]),
Menu::new().row(Row::new("d2").label("detail")),
);
let mut d = RecordingDrawer::default();
let _ = render_menu(&mut d, &menu, &Theme::dark(), &MenuOptions::default(), None);
assert_eq!(d.images.len(), 1, "one leading icon drawn");
let icon_x = d.images[0].origin.x;
assert!(
icon_x < 12.0,
"leading icon must sit in the left gutter, got x={icon_x}"
);
let r_short = text_right_edge(&d, "20% / 38%");
let r_long = text_right_edge(&d, "47% / 52%");
assert!(
(r_short - r_long).abs() < 0.5,
"tab-stop values must share a right column: short={r_short} long={r_long}"
);
}
#[test]
fn measure_vertical_stack_of_three_texts() {
let d = RecordingDrawer::default();
let mut cache = MeasureCache::new();
let theme = Theme::light();
let stack = Stack::vertical(2.0)
.child(Content::Text(TextContent::new("a"))) .child(Content::Text(TextContent::new("bb"))) .child(Content::Text(TextContent::new("ccc")));
let size = measure_stack(&d, &mut cache, &theme, &stack);
assert_eq!(size.width, 21.0);
assert_eq!(size.height, 3.0 * 14.0 + 2.0 * 2.0);
}
#[test]
fn measure_horizontal_strip() {
let d = RecordingDrawer::default();
let mut cache = MeasureCache::new();
let theme = Theme::light();
let stack = Stack::horizontal(5.0)
.child(Content::Text(TextContent::new("ab"))) .child(Content::Image {
icon: Icon::Checkmark,
size: 20.0,
}) .child(Content::Text(TextContent::new("c")));
let size = measure_stack(&d, &mut cache, &theme, &stack);
assert_eq!(size.width, 14.0 + 20.0 + 7.0 + 2.0 * 5.0);
assert_eq!(size.height, 20.0);
}
#[test]
fn spacer_distributes_leftover_space_equally() {
let mut d = RecordingDrawer::default();
let mut cache = MeasureCache::new();
let theme = Theme::light();
let stack = Stack::horizontal(0.0)
.child(Content::Text(TextContent::new("a")))
.child(Content::Spacer)
.child(Content::Text(TextContent::new("bb")))
.child(Content::Spacer);
let rect = LogicalRect::new(LogicalPoint::new(0.0, 0.0), LogicalSize::new(100.0, 14.0));
paint_stack(&mut d, &mut cache, &theme, &stack, rect, Rgba::BLACK);
let a_x = d
.texts
.iter()
.find(|(t, ..)| t == "a")
.map(|(_, x, _)| *x)
.expect("'a' drawn");
let bb_x = d
.texts
.iter()
.find(|(t, ..)| t == "bb")
.map(|(_, x, _)| *x)
.expect("'bb' drawn");
assert_eq!(a_x, 0.0, "'a' sits flush at the stack's leading edge");
assert_eq!(bb_x, 7.0 + 39.5);
assert_eq!(bb_x + 2.0 * 7.0, 100.0 - 39.5);
}
#[test]
fn nested_stack_size_recurses() {
let d = RecordingDrawer::default();
let mut cache = MeasureCache::new();
let theme = Theme::light();
let cell = |time: &str, temp: &str| {
Content::Stack(
Stack::vertical(1.0)
.child(Content::Text(TextContent::new(time)))
.child(Content::Image {
icon: Icon::Checkmark,
size: 16.0,
})
.child(Content::Text(TextContent::new(temp))),
)
};
let strip = Stack::horizontal(3.0)
.child(cell("1PM", "72°"))
.child(cell("2PM", "70°"));
let size = measure_stack(&d, &mut cache, &theme, &strip);
let cell_height = 14.0 + 16.0 + 14.0 + 2.0 * 1.0;
assert_eq!(size.height, cell_height, "strip height = tallest cell");
assert_eq!(size.width, 21.0 * 2.0 + 3.0);
}
#[test]
fn item_content_sizes_and_paints_through_render_menu() {
let logo: std::sync::Arc<[u8]> = std::sync::Arc::from(vec![0u8; 8]);
let mut d = RecordingDrawer::default();
let menu = Menu::new().content(
Stack::vertical(2.0)
.child(Content::Text(TextContent::new("Weather")))
.child(Content::Image {
icon: Icon::Png(logo),
size: 16.0,
}),
);
let laid = render_menu(
&mut d,
&menu,
&Theme::light(),
&MenuOptions::default(),
None,
);
assert!(laid.rows.is_empty());
assert!(d.texts.iter().any(|(t, ..)| t == "Weather"));
assert_eq!(d.images.len(), 1, "the icon image was blitted");
let min_expected = 14.0 + 2.0 + 16.0;
assert!(laid.size.height > min_expected);
}
}