use ratatui::buffer::Buffer;
use super::{ratatui_color, set_cell};
use crate::accelerator::{render_accelerator, Platform};
use crate::primitives::context_menu::{
ContextMenu, ContextMenuGlyphs, ContextMenuItem, ContextMenuItemMeasure, ContextMenuLayout,
ContextMenuPlacement,
};
use crate::theme::Theme;
pub const BORDER_INSET: f32 = 1.0;
fn shortcut_text(item: &ContextMenuItem, platform: Platform) -> Option<String> {
if let Some(ref det) = item.detail {
return det.spans.first().map(|s| s.text.clone());
}
item.key_equivalent
.as_ref()
.map(|acc| render_accelerator(acc, platform))
}
pub fn draw_context_menu(
buf: &mut Buffer,
menu: &ContextMenu,
layout: &ContextMenuLayout,
theme: &Theme,
) {
draw_context_menu_with_glyphs(buf, menu, layout, theme, None, false);
}
pub fn draw_context_menu_with_glyphs(
buf: &mut Buffer,
menu: &ContextMenu,
layout: &ContextMenuLayout,
theme: &Theme,
glyphs: Option<&ContextMenuGlyphs>,
nerd_fonts_enabled: bool,
) {
let bg = ratatui_color(theme.tab_bar_bg);
let fg = ratatui_color(theme.foreground);
let sep_fg = ratatui_color(theme.muted_fg);
let dim_fg = ratatui_color(theme.muted_fg);
let inner_x = layout.bounds.x.round() as u16;
let inner_y = layout.bounds.y.round() as u16;
let inner_w = layout.bounds.width.round() as u16;
let inner_h = layout.bounds.height.round() as u16;
if inner_w == 0 || inner_h == 0 {
return;
}
let left = inner_x.saturating_sub(1);
let top = inner_y.saturating_sub(1);
let right = inner_x.saturating_add(inner_w);
let bottom = inner_y.saturating_add(inner_h);
for cy in top..=bottom {
for cx in left..=right {
let ch = if cy == top {
if cx == left {
'┌'
} else if cx == right {
'┐'
} else {
'─'
}
} else if cy == bottom {
if cx == left {
'└'
} else if cx == right {
'┘'
} else {
'─'
}
} else if cx == left || cx == right {
'│'
} else {
' '
};
set_cell(buf, cx, cy, ch, fg, bg);
}
}
for vis in &layout.visible_items {
let item = &menu.items[vis.item_idx];
let row_y = vis.bounds.y.round() as u16;
if vis.is_separator {
for dx in 0..inner_w {
set_cell(buf, inner_x.saturating_add(dx), row_y, '─', sep_fg, bg);
}
continue;
}
let is_selected = vis.item_idx == menu.selected_idx;
let (item_fg, item_bg) = if is_selected && vis.clickable {
(bg, fg) } else if !vis.clickable {
(dim_fg, bg)
} else {
(fg, bg)
};
for dx in 0..inner_w {
set_cell(
buf,
inner_x.saturating_add(dx),
row_y,
' ',
item_fg,
item_bg,
);
}
if matches!(item.checked, Some(true)) {
let ch = glyphs
.and_then(ContextMenuGlyphs::checked_glyph)
.map(|icon| icon.resolve(nerd_fonts_enabled))
.and_then(|s| s.chars().next())
.unwrap_or('✓');
set_cell(buf, inner_x, row_y, ch, item_fg, item_bg);
}
let label = item
.label
.spans
.first()
.map(|s| s.text.as_str())
.unwrap_or("");
for (i, ch) in label.chars().enumerate() {
let col = inner_x.saturating_add(1).saturating_add(i as u16);
if col >= right {
break;
}
set_cell(buf, col, row_y, ch, item_fg, item_bg);
}
if item.submenu.is_some() {
let arrow_fg = if is_selected && vis.clickable {
item_fg
} else {
dim_fg
};
let arrow_ch = glyphs
.and_then(ContextMenuGlyphs::submenu_arrow_glyph)
.map(|icon| icon.resolve(nerd_fonts_enabled))
.and_then(|s| s.chars().next())
.unwrap_or('▶');
set_cell(
buf,
right.saturating_sub(1),
row_y,
arrow_ch,
arrow_fg,
item_bg,
);
} else if let Some(shortcut) = shortcut_text(item, Platform::Tui) {
let sc_w = shortcut.chars().count().min(u16::MAX as usize) as u16;
let sc_start = inner_x.saturating_add(inner_w.saturating_sub(sc_w.saturating_add(1)));
let sc_fg = if is_selected && vis.clickable {
item_fg
} else {
dim_fg
};
for (i, ch) in shortcut.chars().enumerate() {
let col = sc_start.saturating_add(i as u16);
if col >= right {
break;
}
set_cell(buf, col, row_y, ch, sc_fg, item_bg);
}
}
}
}
#[doc(hidden)]
pub fn draw_context_menu_with_submenus(
buf: &mut Buffer,
root_menu: &ContextMenu,
root_layout: &ContextMenuLayout,
submenu_path: &[usize],
selected_at_depth: &[usize],
viewport: crate::event::Rect,
theme: &Theme,
) {
draw_context_menu(buf, root_menu, root_layout, theme);
let vp = crate::event::Rect::new(
viewport.x + BORDER_INSET,
viewport.y + BORDER_INSET,
(viewport.width - BORDER_INSET * 2.0).max(0.0),
(viewport.height - BORDER_INSET * 2.0).max(0.0),
);
let mut parent_items: Vec<ContextMenuItem> = root_menu.items.clone();
let mut parent_bounds = root_layout.bounds;
let mut parent_vis: Vec<crate::primitives::context_menu::VisibleContextMenuItem> =
root_layout.visible_items.clone();
const MENU_WIDTH: f32 = 20.0;
for (depth, &path_idx) in submenu_path.iter().enumerate() {
let sub_items = match parent_items
.get(path_idx)
.and_then(|item| item.submenu.clone())
{
Some(items) => items,
None => break,
};
let preferred_x = parent_bounds.x + parent_bounds.width + 1.0;
let flipped_x = parent_bounds.x - MENU_WIDTH - 1.0;
let anchor_x = if preferred_x + MENU_WIDTH <= vp.x + vp.width {
preferred_x
} else if flipped_x >= vp.x {
flipped_x
} else {
(vp.x + vp.width - MENU_WIDTH).max(vp.x)
};
let anchor_y = parent_vis
.iter()
.find(|v| v.item_idx == path_idx)
.map(|v| v.bounds.y)
.unwrap_or(parent_bounds.y);
let selected = selected_at_depth.get(depth).copied().unwrap_or(0);
let sub_menu = ContextMenu {
id: crate::types::WidgetId::new("tui-submenu"),
items: sub_items.clone(),
selected_idx: selected,
bg: None,
placement: ContextMenuPlacement::AnchorPoint,
};
let sub_layout = sub_menu.layout(anchor_x, anchor_y, vp, MENU_WIDTH, |_| {
ContextMenuItemMeasure::new(1.0)
});
draw_context_menu(buf, &sub_menu, &sub_layout, theme);
parent_items = sub_items;
parent_bounds = sub_layout.bounds;
parent_vis = sub_layout.visible_items;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::context_menu::{
ContextMenu, ContextMenuHit, ContextMenuItem, ContextMenuItemMeasure,
};
use crate::types::{StyledSpan, StyledText, WidgetId};
use ratatui::layout::Rect;
fn item(label: &str, clickable: bool) -> ContextMenuItem {
ContextMenuItem {
id: if clickable {
Some(WidgetId::new(label))
} else {
None
},
label: StyledText {
spans: vec![StyledSpan::plain(label)],
},
disabled: !clickable,
..Default::default()
}
}
fn make_menu() -> ContextMenu {
ContextMenu {
id: WidgetId::new("menu"),
items: vec![
item("Open", true),
item("Open to Side", true),
ContextMenuItem::default(),
item("Delete", true),
],
selected_idx: 0,
bg: None,
placement: crate::primitives::context_menu::ContextMenuPlacement::default(),
}
}
fn cell_char(buf: &Buffer, x: u16, y: u16) -> char {
buf[(x, y)].symbol().chars().next().unwrap_or(' ')
}
#[test]
fn paints_corner_glyphs_and_items() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 10));
let menu = make_menu();
let layout = menu.layout(
2.0,
1.0,
crate::event::Rect::new(0.0, 0.0, 30.0, 10.0),
20.0,
|_| ContextMenuItemMeasure::new(1.0),
);
draw_context_menu(&mut buf, &menu, &layout, &Theme::default());
let bx = layout.bounds.x.round() as u16 - 1;
let by = layout.bounds.y.round() as u16 - 1;
let bw = layout.bounds.width.round() as u16 + 2;
let bh = layout.bounds.height.round() as u16 + 2;
assert_eq!(cell_char(&buf, bx, by), '┌');
assert_eq!(cell_char(&buf, bx + bw - 1, by), '┐');
assert_eq!(cell_char(&buf, bx, by + bh - 1), '└');
assert_eq!(cell_char(&buf, bx + bw - 1, by + bh - 1), '┘');
}
#[test]
fn context_menu_glyphs_override_swaps_checked_and_arrow_default_keeps_defaults() {
let mut checked_item = item("Word Wrap", true);
checked_item.checked = Some(true);
let mut submenu_item = item("Share", true);
submenu_item.submenu = Some(vec![item("Email", true)]);
let menu = ContextMenu {
id: WidgetId::new("menu"),
items: vec![checked_item, submenu_item],
selected_idx: 2, bg: None,
placement: crate::primitives::context_menu::ContextMenuPlacement::default(),
};
let layout = menu.layout(
2.0,
1.0,
crate::event::Rect::new(0.0, 0.0, 30.0, 10.0),
20.0,
|_| ContextMenuItemMeasure::new(1.0),
);
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 10));
draw_context_menu_with_glyphs(&mut buf, &menu, &layout, &Theme::default(), None, false);
let mut found_check = false;
let mut found_arrow = false;
for y in 0..10 {
for x in 0..30 {
match cell_char(&buf, x, y) {
'✓' => found_check = true,
'▶' => found_arrow = true,
_ => {}
}
}
}
assert!(found_check, "expected default '✓' check glyph");
assert!(found_arrow, "expected default '▶' submenu arrow");
let glyphs = ContextMenuGlyphs::new()
.with_checked_glyph("+")
.with_submenu_arrow_glyph(">");
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 10));
draw_context_menu_with_glyphs(
&mut buf,
&menu,
&layout,
&Theme::default(),
Some(&glyphs),
true,
);
let mut found_plus = false;
let mut found_gt = false;
for y in 0..10 {
for x in 0..30 {
let ch = cell_char(&buf, x, y);
assert_ne!(ch, '✓', "default check glyph leaked through override");
assert_ne!(ch, '▶', "default submenu arrow leaked through override");
match ch {
'+' => found_plus = true,
'>' => found_gt = true,
_ => {}
}
}
}
assert!(found_plus, "expected overridden '+' check glyph");
assert!(found_gt, "expected overridden '>' submenu arrow");
}
#[test]
fn separator_paints_horizontal_dashes() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 10));
let menu = make_menu();
let layout = menu.layout(
2.0,
1.0,
crate::event::Rect::new(0.0, 0.0, 30.0, 10.0),
20.0,
|_| ContextMenuItemMeasure::new(1.0),
);
draw_context_menu(&mut buf, &menu, &layout, &Theme::default());
let mut found_sep_row = false;
for vis in &layout.visible_items {
if vis.is_separator {
let row_y = vis.bounds.y.round() as u16;
let inner_x = layout.bounds.x.round() as u16;
let inner_w = layout.bounds.width.round() as u16;
let row: String = (inner_x..inner_x + inner_w)
.map(|x| cell_char(&buf, x, row_y))
.collect();
assert!(row.chars().all(|c| c == '─'), "separator row: {:?}", row);
found_sep_row = true;
break;
}
}
assert!(found_sep_row, "expected at least one separator row");
}
#[test]
fn selected_clickable_inverted() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 10));
let menu = make_menu(); let layout = menu.layout(
2.0,
1.0,
crate::event::Rect::new(0.0, 0.0, 30.0, 10.0),
20.0,
|_| ContextMenuItemMeasure::new(1.0),
);
let theme = Theme {
tab_bar_bg: crate::types::Color::rgb(0, 0, 0),
foreground: crate::types::Color::rgb(255, 255, 255),
..Theme::default()
};
draw_context_menu(&mut buf, &menu, &layout, &theme);
let inner_x = layout.bounds.x.round() as u16;
let row_y = layout.visible_items[0].bounds.y.round() as u16;
let bg = buf[(inner_x, row_y)].bg;
assert_eq!(bg, ratatui::style::Color::Rgb(255, 255, 255));
}
#[test]
fn checked_item_paints_check_glyph_in_left_padding() {
use crate::primitives::context_menu::ContextMenuItem;
let menu = ContextMenu {
id: WidgetId::new("menu"),
items: vec![
ContextMenuItem {
id: Some(WidgetId::new("toggle-sidebar")),
label: StyledText {
spans: vec![StyledSpan::plain("Toggle Sidebar")],
},
checked: Some(true),
..Default::default()
},
ContextMenuItem {
id: Some(WidgetId::new("toggle-panel")),
label: StyledText {
spans: vec![StyledSpan::plain("Toggle Panel")],
},
checked: Some(false),
..Default::default()
},
],
selected_idx: 0,
bg: None,
placement: crate::primitives::context_menu::ContextMenuPlacement::default(),
};
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 10));
let layout = menu.layout(
2.0,
1.0,
crate::event::Rect::new(0.0, 0.0, 30.0, 10.0),
20.0,
|_| ContextMenuItemMeasure::new(1.0),
);
draw_context_menu(&mut buf, &menu, &layout, &Theme::default());
let inner_x = layout.bounds.x.round() as u16;
let checked_row = layout.visible_items[0].bounds.y.round() as u16;
assert_eq!(
cell_char(&buf, inner_x, checked_row),
'✓',
"checked=Some(true) item should paint ✓ at inner_x",
);
let unchecked_row = layout.visible_items[1].bounds.y.round() as u16;
assert_eq!(
cell_char(&buf, inner_x, unchecked_row),
' ',
"checked=Some(false) item should leave the slot blank",
);
}
#[test]
fn key_equivalent_renders_as_right_aligned_shortcut() {
use crate::accelerator::{Accelerator, AcceleratorId, AcceleratorScope, KeyBinding};
use crate::primitives::context_menu::ContextMenuItem;
let menu = ContextMenu {
id: WidgetId::new("menu"),
items: vec![ContextMenuItem {
id: Some(WidgetId::new("save")),
label: StyledText {
spans: vec![StyledSpan::plain("Save")],
},
key_equivalent: Some(Accelerator {
id: AcceleratorId::new("editor.save"),
binding: KeyBinding::Save,
scope: AcceleratorScope::Global,
label: None,
}),
..Default::default()
}],
selected_idx: 0,
bg: None,
placement: crate::primitives::context_menu::ContextMenuPlacement::default(),
};
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 10));
let layout = menu.layout(
2.0,
1.0,
crate::event::Rect::new(0.0, 0.0, 30.0, 10.0),
20.0,
|_| ContextMenuItemMeasure::new(1.0),
);
draw_context_menu(&mut buf, &menu, &layout, &Theme::default());
let inner_x = layout.bounds.x.round() as u16;
let inner_w = layout.bounds.width.round() as u16;
let row_y = layout.visible_items[0].bounds.y.round() as u16;
let row: String = (inner_x..inner_x + inner_w)
.map(|x| cell_char(&buf, x, row_y))
.collect();
assert!(
row.contains("Ctrl+S"),
"row should contain rendered key_equivalent: row was {row:?}",
);
}
#[test]
fn detail_wins_over_key_equivalent_for_back_compat() {
use crate::accelerator::{Accelerator, AcceleratorId, AcceleratorScope, KeyBinding};
use crate::primitives::context_menu::ContextMenuItem;
let menu = ContextMenu {
id: WidgetId::new("menu"),
items: vec![ContextMenuItem {
id: Some(WidgetId::new("save")),
label: StyledText {
spans: vec![StyledSpan::plain("Save")],
},
detail: Some(StyledText {
spans: vec![StyledSpan::plain("⌘S-legacy")],
}),
key_equivalent: Some(Accelerator {
id: AcceleratorId::new("editor.save"),
binding: KeyBinding::Save,
scope: AcceleratorScope::Global,
label: None,
}),
..Default::default()
}],
selected_idx: 0,
bg: None,
placement: crate::primitives::context_menu::ContextMenuPlacement::default(),
};
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 10));
let layout = menu.layout(
2.0,
1.0,
crate::event::Rect::new(0.0, 0.0, 30.0, 10.0),
20.0,
|_| ContextMenuItemMeasure::new(1.0),
);
draw_context_menu(&mut buf, &menu, &layout, &Theme::default());
let inner_x = layout.bounds.x.round() as u16;
let inner_w = layout.bounds.width.round() as u16;
let row_y = layout.visible_items[0].bounds.y.round() as u16;
let row: String = (inner_x..inner_x + inner_w)
.map(|x| cell_char(&buf, x, row_y))
.collect();
assert!(
row.contains("legacy"),
"detail string should win over key_equivalent: row was {row:?}",
);
}
#[test]
fn zero_size_is_a_no_op() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
let menu = make_menu();
let layout = menu.layout(
0.0,
0.0,
crate::event::Rect::new(0.0, 0.0, 0.0, 0.0),
0.0,
|_| ContextMenuItemMeasure::new(0.0),
);
draw_context_menu(&mut buf, &menu, &layout, &Theme::default());
assert_eq!(cell_char(&buf, 0, 0), ' ');
}
fn submenu_parent_menu() -> ContextMenu {
use crate::types::{StyledSpan, StyledText, WidgetId};
let sub_item = ContextMenuItem {
id: Some(WidgetId::new("sub-a")),
label: StyledText {
spans: vec![StyledSpan::plain("SubItem")],
},
..Default::default()
};
ContextMenu {
id: WidgetId::new("menu"),
items: vec![
ContextMenuItem {
id: Some(WidgetId::new("parent")),
label: StyledText {
spans: vec![StyledSpan::plain("Parent")],
},
submenu: Some(vec![sub_item]),
..Default::default()
},
ContextMenuItem {
id: Some(WidgetId::new("leaf")),
label: StyledText {
spans: vec![StyledSpan::plain("Leaf")],
},
..Default::default()
},
],
selected_idx: 0,
bg: None,
placement: crate::primitives::context_menu::ContextMenuPlacement::default(),
}
}
#[test]
fn submenu_parent_paints_arrow_affordance() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 10));
let menu = submenu_parent_menu();
let layout = menu.layout(
2.0,
1.0,
crate::event::Rect::new(0.0, 0.0, 40.0, 10.0),
16.0,
|_| ContextMenuItemMeasure::new(1.0),
);
draw_context_menu(&mut buf, &menu, &layout, &Theme::default());
let inner_x = layout.bounds.x.round() as u16;
let inner_w = layout.bounds.width.round() as u16;
let row_y = layout.visible_items[0].bounds.y.round() as u16;
assert_eq!(
cell_char(&buf, inner_x + inner_w - 1, row_y),
'▶',
"submenu-parent item should show ▶ at rightmost inner column",
);
let leaf_row_y = layout.visible_items[1].bounds.y.round() as u16;
assert_ne!(
cell_char(&buf, inner_x + inner_w - 1, leaf_row_y),
'▶',
"plain leaf item must not show ▶",
);
}
#[test]
fn submenu_popup_anchored_at_right_edge() {
let mut buf = Buffer::empty(Rect::new(0, 0, 50, 10));
let menu = submenu_parent_menu();
let viewport = crate::event::Rect::new(0.0, 0.0, 50.0, 10.0);
let layout = menu.layout(2.0, 1.0, viewport, 16.0, |_| {
ContextMenuItemMeasure::new(1.0)
});
draw_context_menu_with_submenus(
&mut buf,
&menu,
&layout,
&[0],
&[0],
viewport,
&Theme::default(),
);
assert_eq!(cell_char(&buf, 1, 0), '┌', "root menu top-left corner");
assert_eq!(
cell_char(&buf, 18, 0),
'┌',
"child popup top-left corner should be at x=18 (right-edge anchor)",
);
}
#[test]
fn submenu_overflow_flips_left() {
let mut buf = Buffer::empty(Rect::new(0, 0, 50, 10));
let menu = submenu_parent_menu();
let viewport = crate::event::Rect::new(0.0, 0.0, 50.0, 10.0);
let layout = menu.layout(25.0, 1.0, viewport, 10.0, |_| {
ContextMenuItemMeasure::new(1.0)
});
draw_context_menu_with_submenus(
&mut buf,
&menu,
&layout,
&[0],
&[0],
viewport,
&Theme::default(),
);
assert_eq!(
cell_char(&buf, 3, 0),
'┌',
"flipped child popup top-left corner should be at x=3 (left-flip)",
);
}
#[test]
fn top_border_survives_when_menu_anchored_at_viewport_top_row() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 10));
let menu = make_menu();
let layout = menu.layout(
2.0,
0.0,
crate::event::Rect::new(1.0, 1.0, 29.0, 9.0),
20.0,
|_| ContextMenuItemMeasure::new(1.0),
);
draw_context_menu(&mut buf, &menu, &layout, &Theme::default());
let inner_x = layout.bounds.x.round() as u16;
let bx = inner_x - 1;
assert_eq!(
cell_char(&buf, bx, 0),
'┌',
"top-left corner must be visible when anchored at y=0"
);
assert_eq!(
cell_char(&buf, inner_x + 1, 1),
'O',
"first item (\"Open\") must render one row below the top border"
);
}
#[test]
fn left_border_survives_when_menu_anchored_at_viewport_left_edge() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 10));
let menu = make_menu();
let layout = menu.layout(
0.0,
1.0,
crate::event::Rect::new(1.0, 1.0, 29.0, 9.0),
20.0,
|_| ContextMenuItemMeasure::new(1.0),
);
draw_context_menu(&mut buf, &menu, &layout, &Theme::default());
let row_y = layout.visible_items[0].bounds.y.round() as u16;
assert_eq!(
cell_char(&buf, 0, row_y),
'│',
"left border must be visible when anchored at x=0"
);
assert_eq!(
cell_char(&buf, 2, row_y),
'O',
"first item (\"Open\") label must still start after the left border"
);
}
#[test]
fn clicks_land_on_painted_row_when_menu_anchored_at_viewport_corner() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 10));
let menu = make_menu();
let layout = menu.layout(
0.0,
0.0,
crate::event::Rect::new(1.0, 1.0, 29.0, 9.0),
20.0,
|_| ContextMenuItemMeasure::new(1.0),
);
draw_context_menu(&mut buf, &menu, &layout, &Theme::default());
let first = &layout.visible_items[0];
let item_x = first.bounds.x;
let row_y = first.bounds.y;
assert_eq!(
cell_char(&buf, item_x.round() as u16 + 1, row_y.round() as u16),
'O',
"\"Open\" must be painted at the row/column its own bounds report"
);
assert_eq!(
layout.hit_test(item_x - 1.0, row_y - 1.0),
ContextMenuHit::Empty,
"the border cell must not be classified as the first item"
);
assert_eq!(
layout.hit_test(item_x, row_y),
ContextMenuHit::Item(WidgetId::new("Open")),
"clicking the painted row of the first item must hit \"Open\""
);
}
fn buf_row(buf: &Buffer, y: u16) -> String {
(0..buf.area.width).map(|x| cell_char(buf, x, y)).collect()
}
#[test]
fn zero_origin_layout_paints_without_overflow() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 10));
let menu = make_menu();
let layout = menu.layout(
0.0,
0.0,
crate::event::Rect::new(0.0, 0.0, 30.0, 10.0),
20.0,
|_| ContextMenuItemMeasure::new(1.0),
);
assert_eq!(
(
layout.bounds.x.round() as u16,
layout.bounds.y.round() as u16
),
(0, 0),
"test precondition: a non-inset viewport puts the item region at the origin"
);
draw_context_menu(&mut buf, &menu, &layout, &Theme::default());
let first = &layout.visible_items[0];
assert_eq!(
cell_char(
&buf,
first.bounds.x.round() as u16 + 1,
first.bounds.y.round() as u16
),
'O',
"\"Open\" must paint at the row/column its own bounds report:\n{}",
buf_row(&buf, first.bounds.y.round() as u16)
);
assert_eq!(
layout.hit_test(first.bounds.x + 1.0, first.bounds.y),
ContextMenuHit::Item(WidgetId::new("Open")),
"paint and hit-test must agree even when the border has no cell"
);
let right = layout.bounds.width.round() as u16;
let bottom = layout.bounds.height.round() as u16;
assert_eq!(
cell_char(&buf, right, bottom),
'┘',
"bottom-right corner must still be painted"
);
}
#[test]
fn submenu_child_keeps_its_border_at_zero_origin_viewport() {
let mut buf = Buffer::empty(Rect::new(0, 0, 50, 10));
let menu = submenu_parent_menu();
let viewport = crate::event::Rect::new(0.0, 0.0, 50.0, 10.0);
let layout = menu.layout(0.0, 0.0, viewport, 16.0, |_| {
ContextMenuItemMeasure::new(1.0)
});
assert_eq!(
layout.bounds.y.round() as u16,
0,
"test precondition: the parent menu's item region starts on row 0"
);
draw_context_menu_with_submenus(
&mut buf,
&menu,
&layout,
&[0],
&[0],
viewport,
&Theme::default(),
);
let screen: String = (0..buf.area.height)
.map(|y| buf_row(&buf, y))
.collect::<Vec<_>>()
.join("\n");
assert!(
screen.contains("SubItem"),
"the child menu's item must render:\n{screen}"
);
assert!(
screen.lines().any(|line| {
matches!((line.find('┌'), line.rfind('┐')), (Some(l), Some(r)) if r > l)
}),
"the child popup's top border must be fully on-screen:\n{screen}"
);
}
#[test]
fn submenu_left_flip_against_left_edge_does_not_overflow() {
let mut buf = Buffer::empty(Rect::new(0, 0, 24, 8));
let menu = submenu_parent_menu();
let viewport = crate::event::Rect::new(0.0, 0.0, 24.0, 8.0);
let layout = menu.layout(1.0, 0.0, viewport, 16.0, |_| {
ContextMenuItemMeasure::new(1.0)
});
draw_context_menu_with_submenus(
&mut buf,
&menu,
&layout,
&[0],
&[0],
viewport,
&Theme::default(),
);
let screen: String = (0..buf.area.height)
.map(|y| buf_row(&buf, y))
.collect::<Vec<_>>()
.join("\n");
assert!(
screen.contains("SubItem"),
"the flipped child menu's item must render inside the buffer:\n{screen}"
);
}
}