use crate::accelerator::Accelerator;
use crate::event::Rect;
use crate::types::{Color, Icon, StyledText, WidgetId};
use serde::{Deserialize, Serialize};
pub type MenuItem = ContextMenuItem;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextMenu {
pub id: WidgetId,
pub items: Vec<ContextMenuItem>,
pub selected_idx: usize,
#[serde(default)]
pub bg: Option<Color>,
#[serde(default)]
pub placement: ContextMenuPlacement,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct ContextMenuGlyphs {
checked: Option<Icon>,
submenu_arrow: Option<Icon>,
}
impl ContextMenuGlyphs {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_checked_glyph(mut self, glyph: impl Into<Icon>) -> Self {
self.checked = Some(glyph.into());
self
}
#[must_use]
pub fn with_submenu_arrow_glyph(mut self, glyph: impl Into<Icon>) -> Self {
self.submenu_arrow = Some(glyph.into());
self
}
#[must_use]
pub fn checked_glyph(&self) -> Option<&Icon> {
self.checked.as_ref()
}
#[must_use]
pub fn submenu_arrow_glyph(&self) -> Option<&Icon> {
self.submenu_arrow.as_ref()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ContextMenuPlacement {
#[default]
AnchorPoint,
Below,
Above,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolvedContextMenuPlacement {
AnchorPoint,
Below,
Above,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextMenuItem {
#[serde(default)]
pub id: Option<WidgetId>,
pub label: StyledText,
#[serde(default)]
pub detail: Option<StyledText>,
#[serde(default)]
pub disabled: bool,
#[serde(default)]
pub key_equivalent: Option<Accelerator>,
#[serde(default)]
pub checked: Option<bool>,
#[serde(default)]
pub submenu: Option<Vec<ContextMenuItem>>,
}
impl ContextMenuItem {
pub fn is_separator(&self) -> bool {
self.id.is_none()
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ContextMenuItemMeasure {
pub height: f32,
}
impl ContextMenuItemMeasure {
pub fn new(height: f32) -> Self {
Self { height }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct VisibleContextMenuItem {
pub item_idx: usize,
pub bounds: Rect,
pub is_separator: bool,
pub clickable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContextMenuHit {
Item(WidgetId),
Inert,
Empty,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ContextMenuLayout {
pub bounds: Rect,
pub visible_items: Vec<VisibleContextMenuItem>,
pub hit_regions: Vec<(Rect, ContextMenuHit)>,
pub resolved_placement: ResolvedContextMenuPlacement,
}
impl ContextMenuLayout {
pub fn hit_test(&self, x: f32, y: f32) -> ContextMenuHit {
let inside = x >= self.bounds.x
&& x < self.bounds.x + self.bounds.width
&& y >= self.bounds.y
&& y < self.bounds.y + self.bounds.height;
if !inside {
return ContextMenuHit::Empty;
}
for (rect, hit) in &self.hit_regions {
if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
return hit.clone();
}
}
ContextMenuHit::Inert
}
}
impl ContextMenu {
pub fn layout<F>(
&self,
anchor_x: f32,
anchor_y: f32,
viewport: Rect,
menu_width: f32,
measure_item: F,
) -> ContextMenuLayout
where
F: Fn(usize) -> ContextMenuItemMeasure,
{
self.layout_at(
Rect::new(anchor_x, anchor_y, 0.0, 0.0),
viewport,
menu_width,
measure_item,
)
}
pub fn layout_at<F>(
&self,
anchor: Rect,
viewport: Rect,
menu_width: f32,
measure_item: F,
) -> ContextMenuLayout
where
F: Fn(usize) -> ContextMenuItemMeasure,
{
let measures: Vec<ContextMenuItemMeasure> =
(0..self.items.len()).map(&measure_item).collect();
let total_height: f32 = measures.iter().map(|m| m.height).sum();
let x = if anchor.x + menu_width > viewport.x + viewport.width {
(viewport.x + viewport.width - menu_width).max(viewport.x)
} else {
anchor.x.max(viewport.x)
};
let viewport_top = viewport.y;
let viewport_bottom = viewport.y + viewport.height;
let (y, resolved_placement) = match self.placement {
ContextMenuPlacement::AnchorPoint => {
let y_pref = anchor.y;
let y = if y_pref + total_height > viewport_bottom {
(viewport_bottom - total_height).max(viewport_top)
} else {
y_pref.max(viewport_top)
};
(y, ResolvedContextMenuPlacement::AnchorPoint)
}
ContextMenuPlacement::Below => {
let space_below = viewport_bottom - (anchor.y + anchor.height);
let space_above = anchor.y - viewport_top;
if total_height > space_below && space_above > space_below {
let y_pref = anchor.y - total_height;
let y = y_pref.max(viewport_top);
(y, ResolvedContextMenuPlacement::Above)
} else {
let y_pref = anchor.y + anchor.height;
let y = if y_pref + total_height > viewport_bottom {
(viewport_bottom - total_height).max(viewport_top)
} else {
y_pref.max(viewport_top)
};
(y, ResolvedContextMenuPlacement::Below)
}
}
ContextMenuPlacement::Above => {
let space_below = viewport_bottom - (anchor.y + anchor.height);
let space_above = anchor.y - viewport_top;
if total_height > space_above && space_below > space_above {
let y_pref = anchor.y + anchor.height;
let y = if y_pref + total_height > viewport_bottom {
(viewport_bottom - total_height).max(viewport_top)
} else {
y_pref.max(viewport_top)
};
(y, ResolvedContextMenuPlacement::Below)
} else {
let y_pref = anchor.y - total_height;
let y = y_pref.max(viewport_top);
(y, ResolvedContextMenuPlacement::Above)
}
}
};
let clipped_height = total_height.min(viewport_bottom - y);
let bounds = Rect::new(x, y, menu_width, clipped_height);
let mut visible_items: Vec<VisibleContextMenuItem> = Vec::new();
let mut hit_regions: Vec<(Rect, ContextMenuHit)> = Vec::new();
let mut cursor_y = y;
for (i, item) in self.items.iter().enumerate() {
if cursor_y >= y + clipped_height {
break;
}
let h = measures[i].height;
let remaining = y + clipped_height - cursor_y;
let draw_h = h.min(remaining).max(0.0);
if draw_h <= 0.0 {
break;
}
let item_bounds = Rect::new(x, cursor_y, menu_width, draw_h);
let is_sep = item.is_separator();
let clickable = !is_sep && !item.disabled;
visible_items.push(VisibleContextMenuItem {
item_idx: i,
bounds: item_bounds,
is_separator: is_sep,
clickable,
});
if clickable {
if let Some(id) = &item.id {
hit_regions.push((item_bounds, ContextMenuHit::Item(*id)));
}
} else {
hit_regions.push((item_bounds, ContextMenuHit::Inert));
}
cursor_y += h;
}
ContextMenuLayout {
bounds,
visible_items,
hit_regions,
resolved_placement,
}
}
pub fn first_selectable(&self) -> usize {
self.items
.iter()
.position(|item| !item.is_separator() && !item.disabled)
.unwrap_or(0)
}
pub fn move_selection(&self, current: usize, delta: i32) -> usize {
if self.items.is_empty() {
return current;
}
let n = self.items.len() as i32;
let mut idx = current as i32;
for _ in 0..self.items.len() {
idx = (idx + delta).rem_euclid(n);
let item = &self.items[idx as usize];
if !item.is_separator() && !item.disabled {
return idx as usize;
}
}
current
}
pub fn measure_generic_width(&self, char_width: f32, border_chrome_inset: f32) -> f32 {
let max_chars = self
.items
.iter()
.map(Self::item_content_chars)
.max()
.unwrap_or(0);
let content_w = (max_chars as f32 + 2.0) * char_width;
(content_w - border_chrome_inset * 2.0).max(char_width)
}
fn item_content_chars(item: &ContextMenuItem) -> usize {
let prefix_len = if item.checked.is_some() { 2 } else { 0 };
let label_len: usize = item
.label
.spans
.iter()
.map(|s| s.text.chars().count())
.sum();
let trailing_len = if item.submenu.is_some() {
1
} else if let Some(det) = &item.detail {
det.spans.iter().map(|s| s.text.chars().count()).sum()
} else {
0
};
let gap = if trailing_len > 0 { 1 } else { 0 };
prefix_len + label_len + gap + trailing_len
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{ContextMenu, ContextMenuGlyphs, ContextMenuItem, ContextMenuLayout};
use crate::accelerator::{render_accelerator, Platform};
use crate::paint_surface::PaintSurface;
use crate::style::Style;
use crate::theme::Theme;
use crate::types::{Color, WidgetId};
use crate::{Point, Rect};
pub(super) fn shortcut_text(item: &ContextMenuItem, platform: Platform) -> Option<String> {
if let Some(ref det) = item.detail {
return Some(det.spans.iter().map(|sp| sp.text.as_str()).collect());
}
item.key_equivalent
.as_ref()
.map(|acc| render_accelerator(acc, platform))
}
pub(crate) fn paint(
menu: &ContextMenu,
menu_layout: &ContextMenuLayout,
platform: Platform,
surface: &mut dyn PaintSurface,
theme: &Theme,
style: &Style,
) -> Vec<(Rect, WidgetId)> {
paint_with_glyphs(
menu,
menu_layout,
platform,
surface,
theme,
style,
None,
false,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn paint_with_glyphs(
menu: &ContextMenu,
menu_layout: &ContextMenuLayout,
platform: Platform,
surface: &mut dyn PaintSurface,
theme: &Theme,
style: &Style,
glyphs: Option<&ContextMenuGlyphs>,
nerd_fonts_enabled: bool,
) -> Vec<(Rect, WidgetId)> {
let bounds = menu_layout.bounds;
if bounds.width <= 0.0 || bounds.height <= 0.0 {
return Vec::new();
}
let check_glyph: std::borrow::Cow<'_, str> = glyphs
.and_then(ContextMenuGlyphs::checked_glyph)
.map(|icon| std::borrow::Cow::Borrowed(icon.resolve(nerd_fonts_enabled)))
.unwrap_or(std::borrow::Cow::Owned(crate::codicon::CHECK.to_string()));
let submenu_arrow_glyph: std::borrow::Cow<'_, str> = glyphs
.and_then(ContextMenuGlyphs::submenu_arrow_glyph)
.map(|icon| std::borrow::Cow::Borrowed(icon.resolve(nerd_fonts_enabled)))
.unwrap_or(std::borrow::Cow::Owned(
crate::codicon::CHEVRON_RIGHT.to_string(),
));
let bg = menu.bg.unwrap_or(theme.hover_bg);
surface.surface_draw_shadow(
bounds,
style.corner_radius,
style.shadow_elevation,
Color::rgba(0, 0, 0, 100),
);
surface.surface_fill_rounded_rect(bounds, style.corner_radius, bg);
surface.surface_stroke_rounded_rect(
bounds,
style.corner_radius,
theme.border_fg,
style.border_width,
);
let mut rects: Vec<(Rect, WidgetId)> = Vec::new();
for vis in &menu_layout.visible_items {
let row = vis.bounds;
if vis.is_separator {
let sep_y = row.y + row.height * 0.5;
surface.surface_draw_line(
Point::new(row.x + 4.0, sep_y),
Point::new(row.x + row.width - 4.0, sep_y),
theme.muted_fg,
1.0,
);
continue;
}
let is_selected = vis.item_idx == menu.selected_idx && vis.clickable;
if is_selected {
let sel_rect =
Rect::new(row.x + 1.0, row.y, (row.width - 2.0).max(0.0), row.height);
surface.surface_fill_rect(sel_rect, theme.selected_bg);
}
if vis.clickable {
if let Some(ref id) = menu.items[vis.item_idx].id {
rects.push((row, *id));
}
}
}
for vis in &menu_layout.visible_items {
if vis.is_separator {
continue;
}
let item = &menu.items[vis.item_idx];
let row = vis.bounds;
let has_check_slot = item.checked.is_some();
let label_text: String = if has_check_slot {
std::iter::once(" ".to_string())
.chain(item.label.spans.iter().map(|s| s.text.clone()))
.collect()
} else {
item.label.spans.iter().map(|s| s.text.clone()).collect()
};
let label_fg = if vis.clickable {
theme.foreground
} else {
theme.muted_fg
};
let (_, lh) = surface.surface_measure_text(&label_text);
let text_y = row.y + (row.height - lh) / 2.0;
surface.surface_draw_text_run(
Rect::new(row.x + 8.0, text_y, (row.width - 8.0).max(0.0), lh),
&label_text,
label_fg,
);
if item.checked == Some(true) {
let (cw, ch) = surface.surface_measure_text(&check_glyph);
surface.surface_draw_icon_glyph(
Rect::new(row.x + 8.0, text_y, cw.max(1.0), ch.max(1.0)),
&check_glyph,
label_fg,
);
}
if item.submenu.is_some() {
let (aw, ah) = surface.surface_measure_text(&submenu_arrow_glyph);
surface.surface_draw_icon_glyph(
Rect::new(row.x + row.width - aw - 8.0, text_y, aw.max(1.0), ah),
&submenu_arrow_glyph,
label_fg,
);
} else if let Some(shortcut) = shortcut_text(item, platform) {
if !shortcut.is_empty() {
let (sw, sh) = surface.surface_measure_text(&shortcut);
surface.surface_draw_text_run(
Rect::new(row.x + row.width - sw - 8.0, text_y, sw.max(1.0), sh),
&shortcut,
theme.muted_fg,
);
}
}
}
surface.surface_stroke_rect(bounds, theme.hover_border, 1.0);
rects
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{StyledText, WidgetId};
fn action(id: &str) -> ContextMenuItem {
ContextMenuItem {
id: Some(WidgetId::new(id)),
label: StyledText::plain(id),
..Default::default()
}
}
fn separator() -> ContextMenuItem {
ContextMenuItem::default()
}
fn disabled(id: &str) -> ContextMenuItem {
ContextMenuItem {
id: Some(WidgetId::new(id)),
label: StyledText::plain(id),
disabled: true,
..Default::default()
}
}
fn menu(items: Vec<ContextMenuItem>) -> ContextMenu {
ContextMenu {
id: WidgetId::new("test-menu"),
items,
selected_idx: 0,
bg: None,
placement: ContextMenuPlacement::Below,
}
}
#[test]
fn first_selectable_skips_separator_and_disabled() {
let m = menu(vec![separator(), disabled("x"), action("a"), action("b")]);
assert_eq!(m.first_selectable(), 2);
}
#[test]
fn first_selectable_returns_zero_when_all_inert() {
let m = menu(vec![separator(), disabled("x")]);
assert_eq!(m.first_selectable(), 0);
}
#[test]
fn first_selectable_first_item() {
let m = menu(vec![action("a"), separator(), action("b")]);
assert_eq!(m.first_selectable(), 0);
}
#[test]
fn move_selection_skips_separators() {
let m = menu(vec![action("a"), separator(), action("b")]);
assert_eq!(m.move_selection(0, 1), 2);
assert_eq!(m.move_selection(2, -1), 0);
}
#[test]
fn move_selection_skips_disabled() {
let m = menu(vec![action("a"), disabled("x"), action("b")]);
assert_eq!(m.move_selection(0, 1), 2);
}
#[test]
fn move_selection_wraps_forward() {
let m = menu(vec![action("a"), separator(), action("b")]);
assert_eq!(m.move_selection(2, 1), 0);
}
#[test]
fn move_selection_wraps_backward() {
let m = menu(vec![action("a"), separator(), action("b")]);
assert_eq!(m.move_selection(0, -1), 2);
}
#[test]
fn move_selection_returns_current_when_no_selectable() {
let m = menu(vec![separator(), disabled("x")]);
assert_eq!(m.move_selection(0, 1), 0);
}
#[test]
fn move_selection_empty_menu() {
let m = menu(vec![]);
assert_eq!(m.move_selection(0, 1), 0);
}
#[test]
fn deserialises_old_shape_without_new_fields() {
let json = r#"{
"id": "copy",
"label": {"spans":[{"text":"Copy","fg":null,"bg":null,"bold":false,"italic":false,"underline":false}]},
"detail": null,
"disabled": false
}"#;
let item: ContextMenuItem = serde_json::from_str(json).expect("old shape parses");
assert_eq!(item.id, Some(WidgetId::new("copy")));
assert!(item.key_equivalent.is_none());
assert!(item.checked.is_none());
assert!(item.submenu.is_none());
}
#[test]
fn menu_item_type_alias_compiles() {
let item: super::MenuItem = action("save");
assert_eq!(item.id, Some(WidgetId::new("save")));
}
#[test]
fn nested_submenu_round_trips_through_serde() {
let json = r#"{
"id": "view",
"label": {"spans":[{"text":"View","fg":null,"bg":null,"bold":false,"italic":false,"underline":false}]},
"submenu": [
{
"id": "toggle_sidebar",
"label": {"spans":[{"text":"Toggle Sidebar","fg":null,"bg":null,"bold":false,"italic":false,"underline":false}]},
"checked": true
}
]
}"#;
let item: ContextMenuItem = serde_json::from_str(json).expect("nested shape parses");
let nested = item.submenu.as_ref().expect("submenu present");
assert_eq!(nested.len(), 1);
assert_eq!(nested[0].checked, Some(true));
}
fn cm_action(id: &str, label: &str) -> ContextMenuItem {
ContextMenuItem {
id: Some(WidgetId::new(id)),
label: StyledText::plain(label),
..Default::default()
}
}
fn cm_separator() -> ContextMenuItem {
ContextMenuItem::default()
}
#[test]
fn context_menu_layout_flat() {
let menu = ContextMenu {
id: WidgetId::new("m"),
items: vec![
cm_action("cut", "Cut"),
cm_action("copy", "Copy"),
cm_separator(),
cm_action("paste", "Paste"),
],
selected_idx: 0,
bg: None,
placement: ContextMenuPlacement::default(),
};
let viewport = Rect::new(0.0, 0.0, 800.0, 600.0);
let layout = menu.layout(100.0, 100.0, viewport, 160.0, |_| {
ContextMenuItemMeasure::new(20.0)
});
assert_eq!(layout.bounds.x, 100.0);
assert_eq!(layout.bounds.y, 100.0);
assert_eq!(layout.bounds.width, 160.0);
assert_eq!(layout.bounds.height, 80.0); assert_eq!(layout.visible_items.len(), 4);
assert!(layout.visible_items[2].is_separator);
assert!(!layout.visible_items[2].clickable);
match layout.hit_test(120.0, 125.0) {
ContextMenuHit::Item(id) => assert_eq!(id.as_str(), "copy"),
_ => panic!("expected Item(copy)"),
}
assert_eq!(layout.hit_test(120.0, 150.0), ContextMenuHit::Inert);
assert_eq!(layout.hit_test(500.0, 500.0), ContextMenuHit::Empty);
}
#[test]
fn context_menu_layout_shifts_left_when_overflow() {
let menu = ContextMenu {
id: WidgetId::new("m"),
items: vec![cm_action("a", "A")],
selected_idx: 0,
bg: None,
placement: ContextMenuPlacement::default(),
};
let viewport = Rect::new(0.0, 0.0, 200.0, 200.0);
let layout = menu.layout(180.0, 50.0, viewport, 100.0, |_| {
ContextMenuItemMeasure::new(20.0)
});
assert_eq!(layout.bounds.x, 100.0); }
#[test]
fn context_menu_layout_shifts_up_when_overflow() {
let menu = ContextMenu {
id: WidgetId::new("m"),
items: vec![cm_action("a", "A"), cm_action("b", "B")],
selected_idx: 0,
bg: None,
placement: ContextMenuPlacement::default(),
};
let viewport = Rect::new(0.0, 0.0, 200.0, 100.0);
let layout = menu.layout(10.0, 80.0, viewport, 100.0, |_| {
ContextMenuItemMeasure::new(20.0)
});
assert_eq!(layout.bounds.y, 60.0); }
#[test]
fn context_menu_layout_below_places_at_anchor_bottom() {
let menu = ContextMenu {
id: WidgetId::new("m"),
items: vec![cm_action("a", "A"), cm_action("b", "B")],
selected_idx: 0,
bg: None,
placement: ContextMenuPlacement::Below,
};
let viewport = Rect::new(0.0, 0.0, 800.0, 600.0);
let layout = menu.layout_at(Rect::new(10.0, 10.0, 80.0, 20.0), viewport, 100.0, |_| {
ContextMenuItemMeasure::new(20.0)
});
assert_eq!(layout.bounds.y, 30.0);
assert_eq!(
layout.resolved_placement,
ResolvedContextMenuPlacement::Below
);
}
#[test]
fn context_menu_layout_below_flips_above_when_no_room() {
let menu = ContextMenu {
id: WidgetId::new("m"),
items: (0..6).map(|i| cm_action(&format!("i{i}"), "X")).collect(),
selected_idx: 0,
bg: None,
placement: ContextMenuPlacement::Below,
};
let viewport = Rect::new(0.0, 0.0, 200.0, 200.0);
let layout = menu.layout_at(Rect::new(10.0, 180.0, 80.0, 20.0), viewport, 100.0, |_| {
ContextMenuItemMeasure::new(20.0)
});
assert_eq!(
layout.resolved_placement,
ResolvedContextMenuPlacement::Above
);
assert_eq!(layout.bounds.y, 60.0);
}
#[test]
fn context_menu_layout_above_places_at_anchor_top() {
let menu = ContextMenu {
id: WidgetId::new("m"),
items: vec![
cm_action("a", "A"),
cm_action("b", "B"),
cm_action("c", "C"),
],
selected_idx: 0,
bg: None,
placement: ContextMenuPlacement::Above,
};
let viewport = Rect::new(0.0, 0.0, 200.0, 100.0);
let layout = menu.layout_at(Rect::new(10.0, 99.0, 80.0, 1.0), viewport, 100.0, |_| {
ContextMenuItemMeasure::new(20.0)
});
assert_eq!(
layout.resolved_placement,
ResolvedContextMenuPlacement::Above
);
assert_eq!(layout.bounds.y, 39.0);
}
#[test]
fn context_menu_layout_disabled_items_inert() {
let mut menu = ContextMenu {
id: WidgetId::new("m"),
items: vec![cm_action("delete", "Delete")],
selected_idx: 0,
bg: None,
placement: ContextMenuPlacement::default(),
};
menu.items[0].disabled = true;
let viewport = Rect::new(0.0, 0.0, 800.0, 600.0);
let layout = menu.layout(10.0, 10.0, viewport, 100.0, |_| {
ContextMenuItemMeasure::new(20.0)
});
assert!(!layout.visible_items[0].clickable);
assert_eq!(layout.hit_test(50.0, 15.0), ContextMenuHit::Inert);
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[test]
fn shortcut_text_detail_wins_over_key_equivalent() {
use crate::accelerator::{Accelerator, AcceleratorId, AcceleratorScope, KeyBinding};
let item = ContextMenuItem {
id: Some(WidgetId::new("save")),
label: StyledText::plain("Save"),
detail: Some(StyledText::plain("legacy-string")),
key_equivalent: Some(Accelerator {
id: AcceleratorId::new("editor.save"),
binding: KeyBinding::Save,
scope: AcceleratorScope::Global,
label: None,
}),
..Default::default()
};
let shortcut =
super::native_surface_paint::shortcut_text(&item, crate::accelerator::Platform::Linux)
.expect("detail wins");
assert_eq!(shortcut, "legacy-string");
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[test]
fn shortcut_text_falls_back_to_key_equivalent_per_platform() {
use crate::accelerator::{Accelerator, AcceleratorId, AcceleratorScope, KeyBinding};
let item = ContextMenuItem {
id: Some(WidgetId::new("save")),
label: StyledText::plain("Save"),
key_equivalent: Some(Accelerator {
id: AcceleratorId::new("editor.save"),
binding: KeyBinding::Save,
scope: AcceleratorScope::Global,
label: None,
}),
..Default::default()
};
let shortcut =
super::native_surface_paint::shortcut_text(&item, crate::accelerator::Platform::Macos)
.expect("key_equivalent produces a string");
assert!(
shortcut.contains('⌘'),
"macOS shortcut should contain ⌘, got {shortcut:?}",
);
}
#[test]
fn measure_generic_width_widens_for_longer_label() {
let short = menu(vec![cm_action("a", "Cut")]);
let long = menu(vec![cm_action("a", "Toggle Sidebar Visibility")]);
let short_w = short.measure_generic_width(1.0, 0.0);
let long_w = long.measure_generic_width(1.0, 0.0);
assert!(
long_w > short_w,
"longer label should produce a wider menu: {long_w} vs {short_w}"
);
}
#[test]
fn measure_generic_width_scales_with_char_width() {
let m = menu(vec![cm_action("a", "Copy")]);
let w1 = m.measure_generic_width(1.0, 0.0);
let w2 = m.measure_generic_width(2.0, 0.0);
assert_eq!(w2, w1 * 2.0);
}
#[test]
fn measure_generic_width_checked_item_reserves_prefix_space() {
let unchecked = menu(vec![cm_action("a", "Wrap")]);
let mut checked_items = vec![cm_action("a", "Wrap")];
checked_items[0].checked = Some(true);
let checked = menu(checked_items);
assert!(
checked.measure_generic_width(1.0, 0.0) > unchecked.measure_generic_width(1.0, 0.0),
"checked item reserves extra prefix width"
);
}
#[test]
fn measure_generic_width_submenu_arrow_reserves_trailing_space() {
let plain = menu(vec![cm_action("a", "View")]);
let mut submenu_items = vec![cm_action("a", "View")];
submenu_items[0].submenu = Some(vec![cm_action("b", "Toggle Sidebar")]);
let with_submenu = menu(submenu_items);
assert!(
with_submenu.measure_generic_width(1.0, 0.0) > plain.measure_generic_width(1.0, 0.0),
"submenu arrow reserves extra trailing width"
);
}
#[test]
fn measure_generic_width_detail_reserves_trailing_space() {
let plain = menu(vec![cm_action("a", "Save")]);
let mut detail_items = vec![cm_action("a", "Save")];
detail_items[0].detail = Some(StyledText::plain("Ctrl+S"));
let with_detail = menu(detail_items);
assert!(
with_detail.measure_generic_width(1.0, 0.0) > plain.measure_generic_width(1.0, 0.0),
"detail text reserves extra trailing width"
);
}
#[test]
fn measure_generic_width_border_chrome_inset_shrinks_result() {
let m = menu(vec![cm_action("a", "Copy")]);
let no_inset = m.measure_generic_width(1.0, 0.0);
let with_inset = m.measure_generic_width(1.0, 1.0);
assert_eq!(with_inset, no_inset - 2.0);
}
#[test]
fn measure_generic_width_never_shrinks_below_one_char() {
let m = menu(vec![cm_action("a", "X")]);
let w = m.measure_generic_width(1.0, 1000.0);
assert_eq!(w, 1.0, "clamped to at least one char_width");
}
#[test]
fn measure_generic_width_empty_menu() {
let m = menu(vec![]);
assert_eq!(m.measure_generic_width(1.0, 0.0), 2.0);
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
mod glyph_override {
use super::*;
use crate::accelerator::Platform;
use crate::paint_surface::PaintSurface;
use crate::style::Style;
use crate::theme::Theme;
use crate::types::Color;
use crate::{Point, Rect, Viewport};
#[derive(Default)]
struct RecordingSurface {
runs: Vec<String>,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(0.0, 0.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
16.0
}
fn surface_char_width(&self) -> f32 {
8.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(text.chars().count() as f32 * 8.0, 16.0)
}
fn surface_fill_rect(&mut self, _rect: Rect, _color: Color) {}
fn surface_fill_rounded_rect(&mut self, _rect: Rect, _radius: f32, _color: Color) {}
fn surface_stroke_rect(&mut self, _rect: Rect, _color: Color, _stroke_width: f32) {}
fn surface_stroke_rounded_rect(
&mut self,
_rect: Rect,
_radius: f32,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_draw_text_run(&mut self, _rect: Rect, text: &str, _color: Color) {
self.runs.push(text.to_string());
}
fn surface_draw_line(
&mut self,
_from: Point,
_to: Point,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_push_clip(&mut self, _rect: Rect) {}
fn surface_pop_clip(&mut self) {}
fn surface_draw_image(
&mut self,
_rect: Rect,
_image: &crate::Image,
) -> crate::backend::ImagePaintResult {
crate::backend::ImagePaintResult::Unsupported
}
}
fn popup_layout(item: ContextMenuItem) -> (ContextMenu, ContextMenuLayout) {
let m = menu(vec![item]);
let viewport = Rect::new(0.0, 0.0, 800.0, 600.0);
let layout = m.layout(10.0, 10.0, viewport, 100.0, |_| {
ContextMenuItemMeasure::new(20.0)
});
(m, layout)
}
#[test]
fn no_override_paints_default_codicon_check() {
let mut item = cm_action("a", "Save");
item.checked = Some(true);
let (m, layout) = popup_layout(item);
let mut surface = RecordingSurface::default();
native_surface_paint::paint_with_glyphs(
&m,
&layout,
Platform::Linux,
&mut surface,
&Theme::default(),
&Style::default(),
None,
false,
);
let expected = crate::codicon::CHECK.to_string();
assert!(
surface.runs.iter().any(|r| r == &expected),
"expected default codicon check among {:?}",
surface.runs
);
}
#[test]
fn override_replaces_check_glyph() {
let mut item = cm_action("a", "Save");
item.checked = Some(true);
let (m, layout) = popup_layout(item);
let glyphs = ContextMenuGlyphs::new().with_checked_glyph("V");
let mut surface = RecordingSurface::default();
native_surface_paint::paint_with_glyphs(
&m,
&layout,
Platform::Linux,
&mut surface,
&Theme::default(),
&Style::default(),
Some(&glyphs),
true,
);
assert!(surface.runs.iter().any(|r| r == "V"));
let default_codicon = crate::codicon::CHECK.to_string();
assert!(!surface.runs.iter().any(|r| r == &default_codicon));
}
#[test]
fn override_replaces_submenu_arrow_glyph() {
let mut item = cm_action("a", "More");
item.submenu = Some(vec![cm_action("b", "Nested")]);
let (m, layout) = popup_layout(item);
let glyphs = ContextMenuGlyphs::new().with_submenu_arrow_glyph(">");
let mut surface = RecordingSurface::default();
native_surface_paint::paint_with_glyphs(
&m,
&layout,
Platform::Linux,
&mut surface,
&Theme::default(),
&Style::default(),
Some(&glyphs),
true,
);
assert!(surface.runs.iter().any(|r| r == ">"));
let default_codicon = crate::codicon::CHEVRON_RIGHT.to_string();
assert!(!surface.runs.iter().any(|r| r == &default_codicon));
}
}
}