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, TrailingGutterPolicy};
use crate::Menu;
type MeasureCache = HashMap<u64, (MeasureKey, f32)>;
#[derive(PartialEq, Eq)]
struct MeasureKey {
text: String,
family_tag: u8,
family_name: String,
size_bits: u32,
weight: u16,
spacing_bits: u32,
}
fn font_family_parts(font: &Font) -> (u8, &str) {
match &font.family {
FontFamily::System => (0u8, ""),
FontFamily::SystemMono => (1u8, ""),
FontFamily::Named(name) => (2u8, name.as_str()),
}
}
impl MeasureKey {
fn new(text: &str, font: &Font) -> Self {
let (family_tag, family_name) = font_family_parts(font);
MeasureKey {
text: text.to_string(),
family_tag,
family_name: family_name.to_string(),
size_bits: font.size.to_bits(),
weight: font.weight.ot_weight(),
spacing_bits: font.letter_spacing.to_bits(),
}
}
fn matches(&self, text: &str, font: &Font) -> bool {
let (family_tag, family_name) = font_family_parts(font);
self.text == text
&& self.family_tag == family_tag
&& self.family_name == family_name
&& self.size_bits == font.size.to_bits()
&& self.weight == font.weight.ot_weight()
&& self.spacing_bits == font.letter_spacing.to_bits()
}
}
fn measure_key_hash(text: &str, font: &Font) -> u64 {
use std::hash::{Hash, Hasher};
let (family_tag, family_name) = font_family_parts(font);
let mut h = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut h);
family_tag.hash(&mut h);
family_name.hash(&mut h);
font.size.to_bits().hash(&mut h);
font.weight.ot_weight().hash(&mut h);
font.letter_spacing.to_bits().hash(&mut h);
h.finish()
}
fn measure_cached<D: SceneDrawer>(
drawer: &D,
cache: &mut MeasureCache,
text: &str,
font: &Font,
) -> f32 {
let hash = measure_key_hash(text, font);
if let Some((key, w)) = cache.get(&hash) {
if key.matches(text, font) {
return *w;
}
}
let w = drawer.measure_text(text, font);
cache.insert(hash, (MeasureKey::new(text, font), 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;
const DISABLED_ALPHA: f32 = 0.5;
fn finite_or(v: f32, fallback: f32) -> f32 {
if v.is_finite() {
v
} else {
fallback
}
}
fn dim_rgba(c: Rgba, alpha: f32) -> Rgba {
let alpha = alpha.clamp(0.0, 1.0);
Rgba::new(c.r, c.g, c.b, (c.a as f32 * alpha).round() as u8)
}
fn dim_color(c: Rgba, enabled: bool) -> Rgba {
if enabled {
c
} else {
dim_rgba(c, DISABLED_ALPHA)
}
}
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 menu_reserves_trailing(menu: &Menu) -> bool {
menu.items
.iter()
.any(|it| is_submenu(it) || item_row(it).is_some_and(|r| row_trailing_width(r) > 0.0))
}
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_trailing_width(row: &Row) -> f32 {
if row.trailing.is_some() {
TRAILING_COLUMN
} else {
0.0
}
}
fn row_font(seg: &Segment, base: &Font) -> Font {
seg.font.clone().unwrap_or_else(|| base.clone())
}
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(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));
draw_icon(drawer, cache, &theme.row_font, icon, dest, base_color, true);
}
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;
}
}
}
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 reserve_trailing = match opts.trailing_gutter {
TrailingGutterPolicy::Always => true,
TrailingGutterPolicy::Never => false,
TrailingGutterPolicy::Auto => menu_reserves_trailing(menu),
};
let trailing_w = if reserve_trailing {
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 = finite_or(
opts.min_width.unwrap_or(DEFAULT_MIN_WIDTH),
DEFAULT_MIN_WIDTH,
);
let max_w = finite_or(
opts.max_width.unwrap_or(DEFAULT_MAX_WIDTH),
DEFAULT_MAX_WIDTH,
)
.max(min_w);
let max_content = finite_or(max_content, 0.0);
let desired = finite_or(pad.left + max_content + trailing_w + pad.right, min_w);
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(finite_or(r.min_height.unwrap_or(0.0), 0.0))
}
Item::Content(stack) => {
let stack_size = measure_stack(drawer, &mut measure_cache, theme, stack);
theme
.row_height
.max(finite_or(stack_size.height, 0.0) + 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,
reserve_trailing,
ry,
rh,
false, true, 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));
fill_row_background(drawer, theme, row, rect);
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.row_highlight));
}
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,
reserve_trailing,
ry,
rh,
submenu,
false, 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,
reserve_trailing: bool,
ry: f32,
rh: f32,
submenu: bool,
is_header: bool,
highlighted: bool,
base_color: Rgba,
) {
let icon_enabled = highlighted || is_header || row.enabled;
let glyph_color = if highlighted {
Rgba::WHITE
} else {
theme.resolve(theme.accent)
};
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),
);
if let Some(icon) = &row.leading {
draw_icon(
drawer,
cache,
base_font,
icon,
icon_rect,
glyph_color,
icon_enabled,
);
} else if row.checked == Some(true) && !is_header {
draw_icon(
drawer,
cache,
base_font,
&Icon::Checkmark,
icon_rect,
glyph_color,
icon_enabled,
);
}
}
if !row.segments.is_empty() {
let metrics: Vec<SegmentMetrics> = row
.segments
.iter()
.map(|seg| {
let font = row_font(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(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;
}
}
}
let trailing_col_x = if reserve_trailing {
band_x + band_w
} else {
band_x + band_w - TRAILING_COLUMN
};
if let Some(icon) = &row.trailing {
let trailing_rect = LogicalRect::new(
LogicalPoint::new(
trailing_col_x + (TRAILING_COLUMN - ICON_SIZE) / 2.0,
ry + (rh - ICON_SIZE) / 2.0,
),
LogicalSize::new(ICON_SIZE, ICON_SIZE),
);
draw_icon(
drawer,
cache,
base_font,
icon,
trailing_rect,
glyph_color,
icon_enabled,
);
}
if submenu {
let chev_rect = LogicalRect::new(
LogicalPoint::new(trailing_col_x, 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_icon<D: SceneDrawer>(
drawer: &mut D,
cache: &mut MeasureCache,
base_font: &Font,
icon: &Icon,
rect: LogicalRect,
glyph_color: Rgba,
enabled: bool,
) {
let alpha = if enabled { 1.0 } else { DISABLED_ALPHA };
match icon {
Icon::Png(bytes) | Icon::Svg(bytes) => {
if let Some(decoded) = drawer.decode_icon(bytes) {
let (rgba, w, h) = &*decoded;
drawer.draw_image_alpha(rgba, *w, *h, rect, alpha);
}
}
Icon::Checkmark => draw_glyph_centered(
drawer,
cache,
"\u{2713}",
base_font,
Weight::Bold,
dim_color(glyph_color, enabled),
rect,
),
Icon::Symbol(_) => draw_glyph_centered(
drawer,
cache,
"\u{25AA}", base_font,
Weight::Regular,
dim_color(glyph_color, enabled),
rect,
),
}
}
fn fill_row_background<D: SceneDrawer>(
drawer: &mut D,
theme: &Theme,
row: &Row,
rect: LogicalRect,
) {
if let Some(bg) = row.background {
drawer.fill_round_rect(rect, 0.0, theme.resolve(bg));
}
}
#[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::geometry::Insets;
use crate::menu::{Align, Flex, StyleRun, TextContent};
use crate::style::Color;
use std::sync::Arc;
fn demo_menu() -> Menu {
Menu::new()
.section_header(Row::label_only("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, PartialEq)]
struct RecordingDrawer {
texts: Vec<(String, f32, f32)>, images: Vec<LogicalRect>, fills: Vec<(LogicalRect, Rgba)>, text_colors: Vec<(String, Rgba)>, image_alphas: Vec<(LogicalRect, f32)>, fill_radii: Vec<f32>, separators: Vec<(LogicalRect, Rgba)>, text_fonts: Vec<Font>, }
impl SceneDrawer for RecordingDrawer {
fn begin_frame(&mut self, _size: LogicalSize) {}
fn fill_round_rect(&mut self, r: LogicalRect, cr: f32, c: Rgba) {
self.fills.push((r, c));
self.fill_radii.push(cr);
}
fn draw_separator(&mut self, r: LogicalRect, c: Rgba) {
self.separators.push((r, c));
}
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));
self.text_colors.push((run.text.to_string(), run.color));
self.text_fonts.push(run.font.clone());
}
fn draw_image(&mut self, rgba: &[u8], w: u32, h: u32, dest: LogicalRect) {
self.draw_image_alpha(rgba, w, h, dest, 1.0);
}
fn draw_image_alpha(
&mut self,
_rgba: &[u8],
_w: u32,
_h: u32,
dest: LogicalRect,
alpha: f32,
) {
self.images.push(dest);
self.image_alphas.push((dest, alpha));
}
fn decode_icon(&self, _bytes: &Arc<[u8]>) -> Option<crate::render::DecodedIcon> {
Some(std::rc::Rc::new((vec![255u8; 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 struct_literal_min_greater_than_max_does_not_panic() {
let menu = Menu::new().row(Row::new("q").label("Quit"));
let opts = MenuOptions {
min_width: Some(100.0),
max_width: Some(50.0),
..Default::default()
};
let mut d = RecordingDrawer::default();
let laid = render_menu(&mut d, &menu, &Theme::dark(), &opts, None);
assert!(
laid.size.width.is_finite(),
"width must be finite, not NaN, with min > max"
);
assert_eq!(
laid.size.width, 100.0,
"min>max must normalize to the floor (100), not panic or go degenerate"
);
}
#[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}"
);
}
fn trailing_probe_menu() -> Menu {
Menu::new()
.row(Row::new("plain").segments(vec![
Segment::new("Battery").flex(Flex::Grow),
Segment::new("47%").align(Align::Right),
]))
.submenu(
Row::new("more").label("More"),
Menu::new().row(Row::new("d").label("detail")),
)
}
const CHEVRON: &str = "\u{203A}";
#[test]
fn trailing_auto_with_submenu_reserves_column() {
let menu = trailing_probe_menu();
let mut d = RecordingDrawer::default();
let laid = render_menu(&mut d, &menu, &Theme::dark(), &MenuOptions::default(), None);
let inner_right = laid.size.width - Theme::dark().padding.right;
let pct_right = text_right_edge(&d, "47%");
assert!(
(inner_right - pct_right - TRAILING_COLUMN).abs() < 0.5,
"Auto + submenu must inset right content by the trailing column: \
inner_right={inner_right} pct_right={pct_right} col={TRAILING_COLUMN}"
);
assert!(
d.texts.iter().any(|(t, ..)| t == CHEVRON),
"the submenu chevron must still be drawn"
);
}
#[test]
fn trailing_auto_without_submenu_reaches_edge() {
let menu = Menu::new().row(Row::new("plain").segments(vec![
Segment::new("Battery").flex(Flex::Grow),
Segment::new("47%").align(Align::Right),
]));
let mut d = RecordingDrawer::default();
let laid = render_menu(&mut d, &menu, &Theme::dark(), &MenuOptions::default(), None);
let inner_right = laid.size.width - Theme::dark().padding.right;
let pct_right = text_right_edge(&d, "47%");
assert!(
(inner_right - pct_right).abs() < 0.5,
"Auto without a submenu must let right content reach the edge: \
inner_right={inner_right} pct_right={pct_right}"
);
}
#[test]
fn trailing_never_reaches_edge_with_submenu() {
let menu = trailing_probe_menu();
let mut d = RecordingDrawer::default();
let opts = MenuOptions::default().trailing_gutter(TrailingGutterPolicy::Never);
let laid = render_menu(&mut d, &menu, &Theme::dark(), &opts, None);
let inner_right = laid.size.width - Theme::dark().padding.right;
let pct_right = text_right_edge(&d, "47%");
assert!(
(inner_right - pct_right).abs() < 0.5,
"Never must let right content reach the edge despite a submenu: \
inner_right={inner_right} pct_right={pct_right}"
);
assert!(
d.texts.iter().any(|(t, ..)| t == CHEVRON),
"the submenu chevron must still be drawn under Never"
);
}
#[test]
fn trailing_always_reserves_without_submenu() {
let menu = Menu::new().row(Row::new("plain").segments(vec![
Segment::new("Battery").flex(Flex::Grow),
Segment::new("47%").align(Align::Right),
]));
let mut d = RecordingDrawer::default();
let opts = MenuOptions::default().trailing_gutter(TrailingGutterPolicy::Always);
let laid = render_menu(&mut d, &menu, &Theme::dark(), &opts, None);
let inner_right = laid.size.width - Theme::dark().padding.right;
let pct_right = text_right_edge(&d, "47%");
assert!(
(inner_right - pct_right - TRAILING_COLUMN).abs() < 0.5,
"Always must reserve the trailing column even with no submenu: \
inner_right={inner_right} pct_right={pct_right} col={TRAILING_COLUMN}"
);
}
#[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);
}
#[test]
fn nan_min_width_is_clamped() {
let menu = Menu::new().row(Row::new("a").label("Alpha"));
let opts = MenuOptions::default().min_width(f32::NAN);
let mut d = RecordingDrawer::default();
let laid = render_menu(&mut d, &menu, &Theme::dark(), &opts, None);
assert!(
laid.size.width.is_finite() && laid.size.height.is_finite(),
"geometry must be finite despite a NaN min_width: {:?}",
laid.size
);
assert!(laid.size.width > 0.0);
}
#[test]
fn svg_leading_icon_is_rendered() {
let svg: Arc<[u8]> = Arc::from(vec![1u8, 2, 3]);
let menu = Menu::new().row(Row::new("a").leading(Icon::Svg(svg)).label("Alpha"));
let mut d = RecordingDrawer::default();
render_menu(&mut d, &menu, &Theme::dark(), &MenuOptions::default(), None);
assert_eq!(
d.images.len(),
1,
"an Svg leading icon must blit, not be dropped"
);
}
#[test]
fn header_ignores_checked_but_submenu_parent_honors_it() {
let menu = Menu::new()
.section_header(Row::label_only("Header").checked(true))
.submenu(Row::new("more").label("More").checked(true), Menu::new());
let mut d = RecordingDrawer::default();
render_menu(&mut d, &menu, &Theme::dark(), &MenuOptions::default(), None);
let checks = d.texts.iter().filter(|(t, ..)| t == "\u{2713}").count();
assert_eq!(
checks, 1,
"only the submenu-parent row paints a checkmark, not the header; texts={:?}",
d.texts
);
}
#[test]
fn issue_a_trailing_icon_is_drawn() {
let icon: Arc<[u8]> = Arc::from(vec![9u8; 4]);
let menu = Menu::new().row(Row::new("a").label("Alpha").trailing(Icon::Png(icon)));
let mut d = RecordingDrawer::default();
let laid = render_menu(&mut d, &menu, &Theme::dark(), &MenuOptions::default(), None);
assert_eq!(d.images.len(), 1, "the trailing icon must be blitted");
let icon_x = d.images[0].origin.x;
let label_x = d
.texts
.iter()
.find(|(t, ..)| t == "Alpha")
.map(|(_, x, _)| *x)
.expect("label text");
assert!(
icon_x > label_x,
"trailing icon must sit right of the label: icon={icon_x} label={label_x}"
);
assert!(
icon_x + ICON_SIZE <= laid.size.width + 0.5,
"trailing icon must stay within the popup width"
);
}
#[test]
fn issue_b_row_background_is_filled() {
let menu = Menu::new().row(Row::new("a").label("Alpha").background(Color::SystemRed));
let mut d = RecordingDrawer::default();
render_menu(&mut d, &menu, &Theme::dark(), &MenuOptions::default(), None);
let want = Theme::dark().resolve(Color::SystemRed);
assert!(
d.fills.iter().any(|(_, c)| *c == want),
"a row with Row::background must be filled with it; fills={:?}",
d.fills
);
}
#[test]
fn issue_d_overlapping_style_runs_the_later_run_wins() {
let seg = Segment::new("ab").runs(vec![
StyleRun::new(0, 2, Color::SystemRed), StyleRun::new(1, 1, Color::SystemGreen), ]);
let theme = Theme::light();
let pieces = style_pieces(&seg, Rgba::BLACK, Weight::Regular, &theme, false);
let a = pieces.iter().find(|(s, ..)| s == "a").expect("piece a");
let b = pieces.iter().find(|(s, ..)| s == "b").expect("piece b");
assert_eq!(
a.1,
theme.resolve(Color::SystemRed),
"'a' is covered only by the red run"
);
assert_eq!(
b.1,
theme.resolve(Color::SystemGreen),
"'b' is covered by both runs; the later (green) run must win"
);
}
#[test]
fn issue_e_disabled_row_dims_checkmark_and_icon() {
let icon: Arc<[u8]> = Arc::from(vec![7u8; 4]);
let menu = Menu::new()
.row(
Row::new("i")
.label("IconRow")
.leading(Icon::Png(icon))
.enabled(false),
)
.row(Row::new("c").label("CheckRow").checked(true).enabled(false));
let mut d = RecordingDrawer::default();
render_menu(&mut d, &menu, &Theme::dark(), &MenuOptions::default(), None);
assert!(
d.image_alphas
.iter()
.any(|(_, a)| (*a - DISABLED_ALPHA).abs() < 1e-6),
"a disabled row's icon must blit at DISABLED_ALPHA; got {:?}",
d.image_alphas
);
let check = d
.text_colors
.iter()
.find(|(t, _)| t == "\u{2713}")
.expect("a checkmark glyph was drawn");
assert!(
check.1.a < 255,
"a disabled row's checkmark must be dimmed, got alpha {}",
check.1.a
);
}
#[test]
fn draw_icon_funnel_draws_every_icon_variant() {
let png: Arc<[u8]> = Arc::from(vec![1u8; 4]);
let svg: Arc<[u8]> = Arc::from(vec![2u8; 4]);
let cases: Vec<(&str, Icon)> = vec![
("Png", Icon::Png(png)),
("Svg", Icon::Svg(svg)),
("Checkmark", Icon::Checkmark),
("Symbol", Icon::Symbol("gear")),
];
let font = Font::default();
let rect = LogicalRect::new(
LogicalPoint::new(0.0, 0.0),
LogicalSize::new(ICON_SIZE, ICON_SIZE),
);
for (name, icon) in cases {
let mut d = RecordingDrawer::default();
let mut cache = MeasureCache::new();
draw_icon(&mut d, &mut cache, &font, &icon, rect, Rgba::WHITE, true);
let ops = d.images.len() + d.texts.len();
assert!(ops > 0, "Icon::{name} must emit at least one draw op");
}
}
#[test]
fn row_highlight_uses_the_theme_field() {
let mut theme = Theme::dark();
theme.accent = Color::Rgba(1, 2, 3, 255);
theme.row_highlight = Color::Rgba(9, 8, 7, 255);
let menu = Menu::new().row(Row::new("a").label("Alpha"));
let mut d = RecordingDrawer::default();
render_menu(&mut d, &menu, &theme, &MenuOptions::default(), Some(0));
let want = theme.resolve(theme.row_highlight);
let accent = theme.resolve(theme.accent);
assert!(
d.fills.iter().any(|(_, c)| *c == want),
"hover fill must use row_highlight; fills={:?}",
d.fills
);
assert!(
!d.fills.iter().any(|(_, c)| *c == accent),
"hover fill must NOT use accent"
);
}
fn field_probe_menu() -> Menu {
Menu::new()
.section_header(Row::label_only("Header"))
.row(Row::new("checked").checked(true).label("Checked"))
.row(Row::new("plain").label("Plain"))
.row(Row::new("disabled").label("Disabled").enabled(false))
.separator()
.row(Row::new("last").label("Last"))
}
fn probe_ops(theme: &Theme) -> RecordingDrawer {
let mut d = RecordingDrawer::default();
render_menu(
&mut d,
&field_probe_menu(),
theme,
&MenuOptions::default(),
Some(2),
);
d
}
#[test]
fn every_theme_field_reaches_the_paint_layer() {
type Mutator = fn(&mut Theme);
let fields: &[(&str, Mutator)] = &[
("background", |t| t.background = Color::rgb(1, 2, 3)),
("label", |t| t.label = Color::rgb(1, 2, 3)),
("secondary_label", |t| {
t.secondary_label = Color::rgb(4, 5, 6)
}),
("accent", |t| t.accent = Color::rgb(7, 8, 9)),
("separator", |t| t.separator = Color::rgb(10, 11, 12)),
("row_highlight", |t| {
t.row_highlight = Color::rgb(13, 14, 15)
}),
("row_font", |t| {
t.row_font = Font::system(30.0, Weight::Bold)
}),
("header_font", |t| {
t.header_font = Font::system(30.0, Weight::Bold)
}),
("row_font.letter_spacing", |t| {
t.row_font = t.row_font.clone().with_letter_spacing(5.0)
}),
("row_height", |t| t.row_height += 40.0),
("corner_radius", |t| t.corner_radius += 20.0),
("padding", |t| t.padding = Insets::symmetric(40.0, 40.0)),
("column_gap", |t| t.column_gap += 30.0),
];
let base = Theme::light();
let base_ops = probe_ops(&base);
for (name, mutate) in fields {
let mut variant = base.clone();
mutate(&mut variant);
let variant_ops = probe_ops(&variant);
assert!(
base_ops != variant_ops,
"Theme::{name} did not change any recorded draw op — the field is \
either dead (set but never rendered) or not exercised by \
field_probe_menu(); investigate before assuming it's inert",
);
}
}
#[test]
fn corner_radius_rounds_the_panel_not_the_highlight() {
let mut theme = Theme::light();
theme.corner_radius = 17.0;
let mut d = RecordingDrawer::default();
render_menu(
&mut d,
&Menu::new().row(Row::new("a").label("Alpha")),
&theme,
&MenuOptions::default(),
Some(0),
);
assert_eq!(
d.fill_radii.first().copied(),
Some(17.0),
"the popup background must be rounded at theme.corner_radius"
);
assert!(
d.fill_radii.iter().any(|r| (*r - 5.0).abs() < f32::EPSILON),
"the hover highlight uses a fixed 5.0 radius, not corner_radius; \
radii={:?}",
d.fill_radii
);
}
fn op_extents(d: &RecordingDrawer) -> (f32, f32, f32, f32) {
let mut max_x = f32::MIN;
let mut max_y = f32::MIN;
let mut min_x = f32::MAX;
let mut min_y = f32::MAX;
let mut acc = |x0: f32, y0: f32, x1: f32, y1: f32| {
min_x = min_x.min(x0);
min_y = min_y.min(y0);
max_x = max_x.max(x1);
max_y = max_y.max(y1);
};
for (t, x, y) in &d.texts {
acc(*x, *y, x + t.chars().count() as f32 * 7.0, y + 14.0);
}
for r in &d.images {
acc(r.origin.x, r.origin.y, r.max_x(), r.max_y());
}
for (r, _) in &d.fills {
acc(r.origin.x, r.origin.y, r.max_x(), r.max_y());
}
for (r, _) in &d.separators {
acc(r.origin.x, r.origin.y, r.max_x(), r.max_y());
}
(max_x, max_y, min_x, min_y)
}
#[test]
fn measured_size_bounds_every_drawn_primitive() {
let logo: Arc<[u8]> = Arc::from(vec![0u8; 8]);
let long = "a-very-long-account-label-that-would-overflow@example.com";
let cases: Vec<(&str, Menu)> = vec![
("empty", Menu::new()),
("single", Menu::new().row(Row::new("a").label("Alpha"))),
(
"many_rows",
(0..20).fold(Menu::new(), |m, i| {
m.row(Row::new(format!("r{i}")).label(format!("Row {i}")))
}),
),
("long_label", Menu::new().row(Row::new("a").label(long))),
(
"icons_lead_and_trail",
Menu::new().row(
Row::new("a")
.leading(Icon::Png(logo.clone()))
.label("Withicons")
.trailing(Icon::Checkmark),
),
),
(
"headers_seps_submenu_checks",
Menu::new()
.section_header(Row::label_only("Header"))
.row(Row::new("c").checked(true).label("Checked").enabled(false))
.separator()
.submenu(Row::new("more").label("More"), Menu::new())
.row(Row::new("d").checked(false).label("Unchecked")),
),
(
"flush_right",
Menu::new().row(Row::new("a").segments(vec![
Segment::new(long).flex(Flex::Grow),
Segment::new("100%").align(Align::Right),
])),
),
(
"unicode",
Menu::new().row(Row::new("u").label("café 日本語 🎉 Ω")),
),
];
let opts = MenuOptions::default().min_width(1.0).max_width(100_000.0);
const TOL: f32 = 1.0;
for (name, menu) in &cases {
for highlight in [None, Some(0usize)] {
let mut d = RecordingDrawer::default();
let laid = render_menu(&mut d, menu, &Theme::light(), &opts, highlight);
let (w, h) = (laid.size.width, laid.size.height);
assert!(w.is_finite() && h.is_finite(), "{name}: non-finite size");
let (bg_rect, _) = d.fills.first().expect("a panel background fill");
assert!(
(bg_rect.origin.x).abs() < TOL
&& (bg_rect.origin.y).abs() < TOL
&& (bg_rect.max_x() - w).abs() < TOL
&& (bg_rect.max_y() - h).abs() < TOL,
"{name}: panel background must equal the measured size {:?}, got {bg_rect:?}",
laid.size
);
let (max_x, max_y, min_x, min_y) = op_extents(&d);
assert!(
min_x >= -TOL && min_y >= -TOL,
"{name} (hl={highlight:?}): a primitive starts before the popup origin \
(min_x={min_x}, min_y={min_y})"
);
assert!(
max_x <= w + TOL && max_y <= h + TOL,
"{name} (hl={highlight:?}): a primitive overflows the measured size \
{w}x{h} (max_x={max_x}, max_y={max_y}) — a measure/draw mismatch"
);
assert!(
(max_x - w).abs() < TOL && (max_y - h).abs() < TOL,
"{name} (hl={highlight:?}): measured size {w}x{h} exceeds the drawn extent \
(max_x={max_x}, max_y={max_y}) — the popup is larger than what it paints"
);
}
}
}
#[test]
fn every_icon_variant_renders_in_leading_and_trailing_slots() {
let png: Arc<[u8]> = Arc::from(vec![1u8; 4]);
let svg: Arc<[u8]> = Arc::from(vec![2u8; 4]);
let variants: Vec<(&str, Icon)> = vec![
("Png", Icon::Png(png)),
("Svg", Icon::Svg(svg)),
("Checkmark", Icon::Checkmark),
("Symbol", Icon::Symbol("gear")),
];
for (name, icon) in variants {
for slot in ["leading", "trailing"] {
let row = if slot == "leading" {
Row::new("r").leading(icon.clone()).label("Label")
} else {
Row::new("r").label("Label").trailing(icon.clone())
};
let mut d = RecordingDrawer::default();
render_menu(
&mut d,
&Menu::new().row(row),
&Theme::dark(),
&MenuOptions::default(),
None,
);
let ops = d.images.len() + d.texts.len();
assert!(
ops > 1,
"Icon::{name} in the {slot} slot emitted no icon draw op (only \
the label); images={} texts={:?}",
d.images.len(),
d.texts
);
}
}
}
#[test]
fn disabled_row_dims_both_leading_and_trailing_icons() {
let lead: Arc<[u8]> = Arc::from(vec![3u8; 4]);
let trail: Arc<[u8]> = Arc::from(vec![4u8; 4]);
let menu = Menu::new().row(
Row::new("a")
.leading(Icon::Png(lead))
.label("Both")
.trailing(Icon::Png(trail))
.enabled(false),
);
let mut d = RecordingDrawer::default();
render_menu(&mut d, &menu, &Theme::dark(), &MenuOptions::default(), None);
assert_eq!(d.image_alphas.len(), 2, "both icons must blit");
assert!(
d.image_alphas
.iter()
.all(|(_, a)| (*a - DISABLED_ALPHA).abs() < 1e-6),
"a disabled row must dim BOTH its leading and trailing icons; got {:?}",
d.image_alphas
);
}
#[test]
fn disabled_row_with_icon_and_checked_dims_and_prefers_the_icon() {
let icon: Arc<[u8]> = Arc::from(vec![5u8; 4]);
let menu = Menu::new().row(
Row::new("a")
.leading(Icon::Png(icon))
.checked(true)
.label("Item")
.enabled(false),
);
let mut d = RecordingDrawer::default();
render_menu(&mut d, &menu, &Theme::dark(), &MenuOptions::default(), None);
assert!(
!d.texts.iter().any(|(t, ..)| t == "\u{2713}"),
"a leading icon must win the gutter over the checkmark; texts={:?}",
d.texts
);
assert_eq!(d.image_alphas.len(), 1);
assert!((d.image_alphas[0].1 - DISABLED_ALPHA).abs() < 1e-6);
let label = d
.text_colors
.iter()
.find(|(t, _)| t == "Item")
.expect("label drawn");
assert_eq!(
label.1,
Theme::dark().resolve(Theme::dark().secondary_label)
);
}
#[test]
fn three_overlapping_style_runs_last_wins() {
let seg = Segment::new("abc").runs(vec![
StyleRun::new(0, 3, Color::SystemRed), StyleRun::new(0, 2, Color::SystemGreen), StyleRun::new(1, 2, Color::SystemYellow), ]);
let theme = Theme::light();
let pieces = style_pieces(&seg, Rgba::BLACK, Weight::Regular, &theme, false);
assert_eq!(
pieces,
vec![
(
"a".to_string(),
theme.resolve(Color::SystemGreen),
Weight::Regular
),
(
"bc".to_string(),
theme.resolve(Color::SystemYellow),
Weight::Regular
),
],
"last-wins across a 3-deep overlap, with same-color chars merged"
);
}
#[test]
fn empty_menu_lays_out_to_a_finite_padded_popup() {
let mut d = RecordingDrawer::default();
let laid = render_menu(
&mut d,
&Menu::new(),
&Theme::light(),
&MenuOptions::default(),
None,
);
assert!(laid.rows.is_empty());
assert_eq!(laid.size.width, DEFAULT_MIN_WIDTH);
let pad = Theme::light().padding;
assert!((laid.size.height - (pad.top + pad.bottom)).abs() < f32::EPSILON);
assert_eq!(d.fills.len(), 1, "only the panel background is filled");
}
#[test]
fn unicode_label_renders_and_hit_tests() {
let label = "café 日本語 🎉 Ω";
let menu = Menu::new().row(Row::new("u").label(label));
let mut d = RecordingDrawer::default();
let laid = render_menu(
&mut d,
&menu,
&Theme::light(),
&MenuOptions::default(),
None,
);
assert!(
d.texts.iter().any(|(t, ..)| t == label),
"the unicode label must be drawn verbatim; texts={:?}",
d.texts
);
let row = &laid.rows[0];
let center = LogicalPoint::new(
row.rect.origin.x + row.rect.size.width / 2.0,
row.rect.origin.y + row.rect.size.height / 2.0,
);
assert_eq!(laid.id_at(center).unwrap().as_str(), "u");
}
#[test]
fn kitchen_sink_menu_paints_all_item_kinds() {
let logo: Arc<[u8]> = Arc::from(vec![6u8; 8]);
let menu = Menu::new()
.section_header(Row::label_only("Section"))
.row(Row::new("plain").label("Plain"))
.row(
Row::new("cd")
.checked(true)
.enabled(false)
.label("CheckedDisabled"),
)
.row(
Row::new("fancy")
.leading(Icon::Png(logo.clone()))
.label("Fancy")
.trailing(Icon::Checkmark)
.background(Color::SystemRed),
)
.separator()
.submenu(Row::new("more").label("More"), Menu::new())
.content(
Stack::horizontal(2.0)
.child(Content::Text(TextContent::new("Extra")))
.child(Content::Image {
icon: Icon::Png(logo),
size: 16.0,
}),
);
let mut d = RecordingDrawer::default();
let laid = render_menu(&mut d, &menu, &Theme::dark(), &MenuOptions::default(), None);
assert_eq!(laid.rows.iter().filter(|r| r.interactive).count(), 3);
let want = Theme::dark().resolve(Color::SystemRed);
assert!(
d.fills.iter().any(|(_, c)| *c == want),
"the tinted row's background must be filled"
);
assert_eq!(d.images.len(), 2, "leading and content icons blit");
assert!(
d.texts.iter().any(|(t, ..)| t == "\u{2713}"),
"the trailing checkmark glyph is drawn"
);
}
}