use crate::core::{Color, Font, HorizontalAlignment, Point, Rect};
use crate::event::key_codes;
use crate::event::{Event, EventHandler};
use crate::render::{RenderCommand, RenderContext};
use crate::signal::Signal1;
use crate::widget::capability::coercion::{expect_bool, expect_string};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::menu_toolbar::popup_reveal::{PopupReveal, RevealDirection};
use crate::widget::metrics::effective_font;
use crate::widget::{BaseWidget, Draw, IconName, IconSource, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
const DEFAULT_MENU_WIDTH: u32 = 180;
const ITEM_HEIGHT: i32 = 28;
const PADDING: i32 = 8;
pub const MENU_BUTTON_ITEM_SCHEMA: crate::widget::capability::RecordSchema =
crate::widget::capability::RecordSchema {
fields: &[
crate::widget::capability::RecordField::required(
"id",
crate::widget::capability::PropertyValueKind::UInt,
),
crate::widget::capability::RecordField::required(
"text",
crate::widget::capability::PropertyValueKind::String,
),
crate::widget::capability::RecordField::optional(
"enabled",
crate::widget::capability::PropertyValueKind::Bool,
),
crate::widget::capability::RecordField::optional(
"checked",
crate::widget::capability::PropertyValueKind::Bool,
),
crate::widget::capability::RecordField::optional(
"icon_source",
crate::widget::capability::PropertyValueKind::IconSpec,
),
],
id_field: "id",
ids_unique: true,
ids_required: true,
};
fn menu_item_record(
item: &MenuItem,
icon_source: Option<IconSource>,
) -> Result<crate::widget::capability::RecordValue, CapabilityAccessError> {
let mut record = crate::widget::capability::RecordValue::new()
.with("id", CapabilityValue::UInt(item.id))
.with("text", CapabilityValue::String(item.text.clone()))
.with("enabled", CapabilityValue::Bool(item.enabled))
.with("checked", CapabilityValue::Bool(item.checked));
match icon_source {
None => {}
Some(IconSource::Builtin(name)) => {
record = record.with(
"icon_source",
CapabilityValue::IconSpec(crate::widget::icon_source::IconSpec::Builtin(name)),
);
}
Some(IconSource::StaticGeometry(_)) => {
return Err(CapabilityAccessError::IconSourceNotPersistable)
}
}
Ok(record)
}
pub struct MenuItem {
pub id: u64,
pub text: String,
pub icon: Option<String>,
pub enabled: bool,
pub checked: bool,
pub submenu: Option<Vec<MenuItem>>,
}
impl MenuItem {
pub fn new(id: u64, text: &str) -> Self {
Self {
id,
text: text.to_string(),
icon: None,
enabled: true,
checked: false,
submenu: None,
}
}
pub fn with_icon(mut self, icon: &str) -> Self {
self.icon = Some(icon.to_string());
self
}
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn with_checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
pub fn with_submenu(mut self, items: Vec<MenuItem>) -> Self {
self.submenu = Some(items);
self
}
}
pub struct MenuButton {
base: BaseWidget,
text: String,
menu_items: Vec<MenuItem>,
item_icon_sources: Vec<(u64, IconSource)>,
icon: Option<String>,
icon_source: Option<IconSource>,
menu_open: bool,
reveal: PopupReveal,
pub item_triggered: Signal1<u64>,
}
impl MenuButton {
pub fn new(text: &str, geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::MenuButton, geometry, "MenuButton"),
text: text.to_string(),
menu_items: Vec::new(),
item_icon_sources: Vec::new(),
icon: None,
icon_source: None,
menu_open: false,
reveal: PopupReveal::new(false),
item_triggered: Signal1::new(),
}
}
pub fn text(&self) -> &str {
&self.text
}
pub fn set_text(&mut self, text: &str) {
self.text = text.to_string();
self.base.request_redraw();
}
pub fn icon(&self) -> Option<&str> {
self.icon.as_deref()
}
pub fn set_icon(&mut self, icon: &str) {
self.icon = Some(icon.to_string());
self.icon_source = None;
self.base.request_redraw();
}
pub fn set_icon_source(&mut self, source: Option<IconSource>) {
self.icon = None;
self.icon_source = source;
self.base.request_redraw();
}
pub fn icon_source(&self) -> Option<IconSource> {
self.icon_source
}
pub fn clear_icon(&mut self) {
self.icon = None;
self.icon_source = None;
self.base.request_redraw();
}
pub fn add_item(&mut self, item: MenuItem) {
self.item_icon_sources.retain(|(id, _)| *id != item.id);
self.menu_items.push(item);
self.base.request_redraw();
}
pub fn set_item_icon_source(
&mut self,
id: u64,
source: Option<IconSource>,
) -> Result<(), crate::widget::IconItemError> {
let matches = self.menu_items.iter().filter(|item| item.id == id).count();
if matches == 0 {
return Err(crate::widget::IconItemError::ItemNotFound { id });
}
if matches > 1 {
return Err(crate::widget::IconItemError::AmbiguousItemId { id });
}
self.item_icon_sources.retain(|(stored_id, _)| *stored_id != id);
if let Some(source) = source {
self.item_icon_sources.push((id, source));
}
self.base.request_redraw();
Ok(())
}
pub fn item_icon_source(&self, id: u64) -> Option<IconSource> {
self.item_icon_sources.iter().find(|(stored_id, _)| *stored_id == id).map(|(_, s)| *s)
}
pub fn remove_item(&mut self, id: u64) -> bool {
let len_before = self.menu_items.len();
self.menu_items.retain(|item| item.id != id);
let removed = self.menu_items.len() < len_before;
if removed {
self.item_icon_sources.retain(|(item_id, _)| *item_id != id);
self.base.request_redraw();
}
removed
}
pub fn clear_items(&mut self) {
self.menu_items.clear();
self.item_icon_sources.clear();
self.base.request_redraw();
}
pub fn item_count(&self) -> usize {
self.menu_items.len()
}
pub fn items(&self) -> &[MenuItem] {
&self.menu_items
}
pub fn items_mut(&mut self) -> &mut Vec<MenuItem> {
self.item_icon_sources.clear();
&mut self.menu_items
}
pub fn is_menu_open(&self) -> bool {
self.menu_open
}
pub fn toggle_menu(&mut self) {
self.menu_open = !self.menu_open;
self.reveal.set_open(self.menu_open);
self.base.request_redraw();
}
pub fn open_menu(&mut self) {
self.menu_open = true;
self.reveal.set_open(true);
self.base.request_redraw();
}
pub fn close_menu(&mut self) {
self.menu_open = false;
self.reveal.set_open(false);
self.base.request_redraw();
}
fn menu_rect(&self) -> Rect {
let geom = self.geometry();
let menu_width = geom.width.max(DEFAULT_MENU_WIDTH);
let menu_height = (self.menu_items.len() as u32 * ITEM_HEIGHT as u32).max(1);
Rect::new(geom.x, geom.y + geom.height as i32, menu_width, menu_height)
}
fn item_rect(&self, index: usize) -> Rect {
let menu_rect = self.menu_rect();
Rect::new(
menu_rect.x,
menu_rect.y + index as i32 * ITEM_HEIGHT,
menu_rect.width,
ITEM_HEIGHT as u32,
)
}
fn hit_test_item(&self, pos: Point) -> Option<usize> {
if !self.menu_open {
return None;
}
let menu = self.menu_rect();
if !menu.contains_point(pos) {
return None;
}
for (i, item) in self.menu_items.iter().enumerate() {
let ir = self.item_rect(i);
if ir.contains_point(pos) && item.enabled {
return Some(i);
}
}
None
}
}
impl Widget for MenuButton {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(120, 28)
}
fn tick(&mut self, delta_ms: u32) -> bool {
self.reveal.tick(delta_ms)
}
fn is_animating(&self) -> bool {
self.reveal.is_animating()
}
impl_draw_bridge!();
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"item_triggered" => {
Some(EventSignalRef::mapped("item_triggered", &self.item_triggered, |v| {
CapabilityValue::UInt(*v)
}))
}
_ => None,
}
}
}
impl WidgetProperties for MenuButton {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"text" => Ok(CapabilityValue::String(self.text().to_string())),
"items" => {
Ok(CapabilityValue::Records(crate::widget::capability::RecordList::from_items(
&MENU_BUTTON_ITEM_SCHEMA,
self.menu_items
.iter()
.map(|item| menu_item_record(item, self.item_icon_source(item.id)))
.collect::<Result<Vec<_>, CapabilityAccessError>>()?,
)))
}
"item_count" => Ok(CapabilityValue::UInt(self.item_count() as u64)),
"expanded" => Ok(CapabilityValue::Bool(self.is_menu_open())),
"icon_source" => match self.icon_source() {
None => Ok(CapabilityValue::Null),
Some(IconSource::Builtin(name)) => Ok(CapabilityValue::IconSpec(
crate::widget::icon_source::IconSpec::Builtin(name),
)),
Some(IconSource::StaticGeometry(_)) => {
Err(CapabilityAccessError::IconSourceNotPersistable)
}
},
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"text" => {
self.set_text(&expect_string(value)?);
Ok(())
}
"expanded" => {
if expect_bool(value)? {
self.open_menu();
} else {
self.close_menu();
}
Ok(())
}
"icon_source" => {
match value {
CapabilityValue::Null => self.set_icon_source(None),
CapabilityValue::IconSpec(crate::widget::icon_source::IconSpec::Builtin(
name,
)) => self.set_icon_source(Some(IconSource::Builtin(name))),
CapabilityValue::IconSpec(crate::widget::icon_source::IconSpec::Resource {
..
}) => return Err(CapabilityAccessError::ResourceResolutionRequired),
_ => return Err(CapabilityAccessError::TypeMismatch),
}
Ok(())
}
"item_count" => Err(CapabilityAccessError::ReadOnlyProperty),
"items" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn set_resolved_icon_source(
&mut self,
source: Option<IconSource>,
) -> Result<(), CapabilityAccessError> {
self.set_icon_source(source);
Ok(())
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"text",
"icon_source",
"items",
"item_count",
"expanded",
BASE_PROPERTY_NAMES
]
}
}
impl Draw for MenuButton {
fn draw(&mut self, context: &mut RenderContext) {
let geom = self.geometry();
let is_enabled = self.base.is_enabled();
let style = self.base.style().clone();
let font = effective_font(&style);
let themed = crate::style::resolved_theme_style("menu_button");
let themed_bg = themed.as_ref().and_then(|r| r.background_color);
let themed_border = themed.as_ref().and_then(|r| r.border_color);
let themed_text = themed.as_ref().and_then(|r| r.text_color);
let background =
style.background_color.or(themed_bg).unwrap_or(Color::rgba(235, 235, 240, 200));
let border =
style.border_color.or(themed_border).unwrap_or(Color::rgba(180, 180, 190, 200));
let ink = style.text_color.or(themed_text).unwrap_or(Color::rgb(33, 33, 33));
let accent = crate::style::theme_manager()
.current_theme()
.map(|theme| theme.colors.accent)
.unwrap_or(Color::rgba(200, 200, 220, 200));
let disabled_ink = self.base.disabled_ink_on(ink, background);
let bg_color = if !is_enabled {
background.blend(&ink, 0.08)
} else if self.menu_open {
background.blend(&accent, 0.25)
} else {
background
};
context.fill_rounded_rect(geom, 4, bg_color);
context.draw_rounded_rect_stroke(geom, 4, border, 1);
let mut text_offset_x = geom.x + PADDING;
if let Some(source) = self.icon_source {
let side = geom.height.saturating_sub((PADDING * 2) as u32).clamp(1, 16);
let line = context.text_line(geom, font);
let icon_rect = Rect::new(text_offset_x, line.y, side, side);
let icon_color = if is_enabled { ink } else { disabled_ink };
if let Err(error) =
crate::widget::paint_icon(context, source.into(), icon_rect, icon_color)
{
log::warn!("menu button icon could not be painted: {error:?}");
}
text_offset_x += 22;
} else if let Some(ref icon_str) = self.icon {
let icon_font = Font::simple("sans-serif", 14.0);
let icon_line = context.text_line(geom, &icon_font);
context.draw_text(
Point::new(text_offset_x, icon_line.y),
icon_str,
&icon_font,
if is_enabled { ink } else { disabled_ink },
HorizontalAlignment::Left,
);
text_offset_x += 22; }
let text_color = if !is_enabled { disabled_ink } else { ink };
let line = context.text_line(geom, font);
context.draw_text(
Point::new(text_offset_x, line.y),
&self.text,
font,
text_color,
HorizontalAlignment::Left,
);
let arrow_x = geom.x + geom.width as i32 - PADDING - 8;
let arrow_y = geom.y + geom.height as i32 / 2 - 2;
let arrow_color = if !is_enabled { disabled_ink } else { ink };
context.execute_command(RenderCommand::DrawPath {
points: vec![
Point::new(arrow_x, arrow_y),
Point::new(arrow_x + 8, arrow_y),
Point::new(arrow_x + 4, arrow_y + 6),
],
closed: true,
color: arrow_color,
filled: true,
width: 1,
});
if self.menu_open || !self.reveal.is_closed() {
let menu = self.menu_rect();
let revealed = self.reveal.revealed(
menu,
RevealDirection::Down,
PopupReveal::travel_cap_for(crate::core::Size::new(menu.width, menu.height)),
);
let clipped = !self.reveal.is_closed();
if clipped {
context.push_clip(revealed.x, revealed.y, revealed.width, revealed.height);
}
let panel = crate::style::resolved_theme_style("menu_button")
.and_then(|r| r.background_color)
.or(themed_bg)
.unwrap_or(Color::WHITE);
let panel_border =
style.border_color.or(themed_border).unwrap_or(Color::rgba(190, 190, 200, 200));
let item_ink = style.text_color.or(themed_text).unwrap_or(Color::rgb(33, 33, 33));
let item_highlight = panel.blend(&accent, 0.3);
let item_rule = panel.blend(&item_ink, 0.08);
context.fill_rounded_rect(menu, 4, panel);
context.draw_rounded_rect_stroke(menu, 4, panel_border, 1);
for (i, item) in self.menu_items.iter().enumerate() {
let item_rect = self.item_rect(i);
if item.checked {
context.fill_rounded_rect(item_rect, 2, item_highlight);
}
let item_text_color =
if !item.enabled { item_ink.blend(&panel, 0.5) } else { item_ink };
let item_font = effective_font(&style);
let mut item_x = item_rect.x + PADDING;
let item_source =
self.item_icon_sources.iter().find(|(id, _)| *id == item.id).map(|(_, s)| *s);
if let Some(source) = item_source {
let icon_line = context.text_line(item_rect, item_font);
let side = icon_line.height.clamp(1, 16);
let icon_box = Rect::new(item_x, icon_line.y, side, side.min(item_rect.height));
if let Err(error) =
crate::widget::paint_icon(context, source.into(), icon_box, item_text_color)
{
log::warn!(
"menu item {id} icon could not be painted: {error:?}",
id = item.id
);
}
item_x += 20;
} else if let Some(ref item_icon) = item.icon {
let icon_font = Font::simple("sans-serif", 13.0);
let icon_line = context.text_line(item_rect, &icon_font);
context.draw_text(
Point::new(item_x, icon_line.y),
item_icon,
&icon_font,
item_text_color,
HorizontalAlignment::Left,
);
item_x += 20;
}
let item_line = context.text_line(item_rect, item_font);
context.draw_text(
Point::new(item_x, item_line.y),
&item.text,
item_font,
item_text_color,
HorizontalAlignment::Left,
);
if item.checked {
let check_x = item_rect.x + item_rect.width as i32 - PADDING - 12;
let check_line = context.text_line(item_rect, item_font);
let side = check_line.height.max(1);
let check_rect = Rect::new(
check_x,
check_line.y + (check_line.height as i32 - side as i32) / 2,
side,
side,
);
crate::widget::draw_icon_at(
context,
check_rect,
accent.contrast_color(),
IconName::Check,
);
}
if item.submenu.is_some() && item.enabled {
let sub_x = item_rect.x + item_rect.width as i32 - PADDING - 8;
let sub_y = item_rect.y + item_rect.height as i32 / 2 - 4;
context.execute_command(RenderCommand::DrawPath {
points: vec![
Point::new(sub_x, sub_y),
Point::new(sub_x, sub_y + 8),
Point::new(sub_x + 5, sub_y + 4),
],
closed: true,
color: item_ink.blend(&panel, 0.45),
filled: true,
width: 1,
});
}
if i + 1 < self.menu_items.len() {
context.draw_line(
Point::new(item_rect.x + 4, item_rect.y + item_rect.height as i32),
Point::new(
item_rect.x + item_rect.width as i32 - 4,
item_rect.y + item_rect.height as i32,
),
item_rule,
);
}
}
if clipped {
context.pop_clip();
}
}
}
}
impl EventHandler for MenuButton {
fn handle_event(&mut self, event: &Event) {
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button, .. } => {
if *button == 1 {
if self.geometry().contains_point(*pos) {
self.toggle_menu();
} else if self.menu_open {
if let Some(idx) = self.hit_test_item(*pos) {
let item_id = self.menu_items[idx].id;
self.item_triggered.emit(item_id);
self.close_menu();
} else {
self.close_menu();
}
}
}
}
Event::KeyPress { key, modifiers: _ } => {
if *key == key_codes::ESCAPE && self.menu_open {
self.close_menu();
}
}
_ => {
self.base.handle_event(event);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::svg::render_to_svg;
use std::sync::{Arc, Mutex};
#[test]
fn menu_button_icon_source_property_round_trips_and_refuses_the_unresolvable() {
use crate::widget::capability::types::CapabilityValue as V;
use crate::widget::icon_source::{IconColorMode, IconSpec};
let mut mb = MenuButton::new("File", Rect::new(0, 0, 100, 30));
assert_eq!(mb.get("icon_source"), Ok(V::Null));
let icon = IconSpec::Builtin(crate::widget::IconName::Info);
mb.set("icon_source", V::IconSpec(icon.clone()))
.expect("built-in descriptors apply without a resolver");
assert_eq!(mb.get("icon_source"), Ok(V::IconSpec(icon)));
assert_eq!(mb.icon_source(), Some(IconSource::Builtin(crate::widget::IconName::Info)));
assert_eq!(
mb.set(
"icon_source",
V::IconSpec(IconSpec::Resource {
id: "project/icons/save".into(),
color_mode: IconColorMode::Monochrome,
}),
),
Err(CapabilityAccessError::ResourceResolutionRequired)
);
mb.set("icon_source", V::Null).expect("null clears the icon");
assert_eq!(mb.icon_source(), None, "an explicit clear is Empty, not the old icon");
mb.set_resolved_icon_source(Some(IconSource::Builtin(crate::widget::IconName::Save)))
.expect("a resolved source applies");
assert_eq!(mb.icon_source(), Some(IconSource::Builtin(crate::widget::IconName::Save)));
static DATA: crate::widget::display_widgets::icon::IconData =
crate::widget::display_widgets::icon::IconData {
name: "runtime",
grid: 24,
paths: &[],
};
mb.set_icon_source(Some(IconSource::static_geometry(&DATA)));
assert_eq!(mb.get("icon_source"), Err(CapabilityAccessError::IconSourceNotPersistable));
}
#[test]
fn menu_button_default_creation() {
let mb = MenuButton::new("File", Rect::new(0, 0, 100, 30));
assert_eq!(mb.text(), "File");
assert_eq!(mb.item_count(), 0);
assert!(!mb.is_menu_open());
assert!(mb.icon().is_none());
}
#[test]
fn menu_button_add_remove_items() {
let mut mb = MenuButton::new("Edit", Rect::new(0, 0, 100, 30));
assert_eq!(mb.item_count(), 0);
mb.add_item(MenuItem::new(1, "Cut"));
mb.add_item(MenuItem::new(2, "Copy"));
mb.add_item(MenuItem::new(3, "Paste"));
assert_eq!(mb.item_count(), 3);
assert!(mb.remove_item(2));
assert_eq!(mb.item_count(), 2);
mb.clear_items();
assert_eq!(mb.item_count(), 0);
}
#[test]
fn menu_button_toggle_menu() {
let mut mb = MenuButton::new("File", Rect::new(0, 0, 100, 30));
assert!(!mb.is_menu_open());
mb.toggle_menu();
assert!(mb.is_menu_open());
mb.toggle_menu();
assert!(!mb.is_menu_open());
}
#[test]
fn menu_button_item_triggered_signal() {
let mut mb = MenuButton::new("File", Rect::new(0, 0, 100, 30));
mb.add_item(MenuItem::new(42, "Open"));
mb.add_item(MenuItem::new(99, "Save"));
let triggered_id = Arc::new(Mutex::new(0u64));
let tid = triggered_id.clone();
mb.item_triggered.connect(move |val| {
*tid.lock().unwrap() = *val;
});
mb.open_menu();
assert!(mb.is_menu_open());
let item_rect = mb.item_rect(0);
let click_pos = Point::new(item_rect.x + 5, item_rect.y + 5);
mb.handle_event(&Event::mouse_press(click_pos.x, click_pos.y, 1));
assert_eq!(*triggered_id.lock().unwrap(), 42);
assert!(!mb.is_menu_open());
}
#[test]
fn menu_button_click_outside_closes_menu() {
let mut mb = MenuButton::new("File", Rect::new(0, 0, 100, 30));
mb.add_item(MenuItem::new(1, "Item"));
mb.open_menu();
assert!(mb.is_menu_open());
mb.handle_event(&Event::mouse_press(500, 500, 1));
assert!(!mb.is_menu_open());
}
#[test]
fn menu_button_escape_closes_menu() {
let mut mb = MenuButton::new("File", Rect::new(0, 0, 100, 30));
mb.open_menu();
assert!(mb.is_menu_open());
mb.handle_event(&Event::key_press(27, 0));
assert!(!mb.is_menu_open());
}
#[test]
fn menu_button_disabled_blocks_events() {
let mut mb = MenuButton::new("File", Rect::new(0, 0, 100, 30));
mb.set_enabled(false);
let triggered_id = Arc::new(Mutex::new(0u64));
let tid = triggered_id.clone();
mb.item_triggered.connect(move |val| {
*tid.lock().unwrap() = *val;
});
mb.handle_event(&Event::mouse_press(10, 10, 1));
assert!(!mb.is_menu_open());
assert_eq!(*triggered_id.lock().unwrap(), 0);
}
#[test]
fn menu_button_icon() {
let mut mb = MenuButton::new("Menu", Rect::new(0, 0, 100, 30));
assert!(mb.icon().is_none());
mb.set_icon("๐");
assert_eq!(mb.icon(), Some("๐"));
let source = IconSource::builtin(IconName::Info);
mb.set_icon_source(Some(source));
assert_eq!(mb.icon_source(), Some(source));
assert!(mb.icon().is_none(), "typed geometry is not exposed as legacy text");
let svg = crate::widget::svg::render_to_svg(&mut mb);
assert!(svg.contains("<path"), "the typed icon reaches the SVG backend");
mb.set_icon("โ
");
assert_eq!(mb.icon_source(), None, "legacy text replaces typed geometry");
assert_eq!(mb.icon(), Some("โ
"));
mb.clear_icon();
assert!(mb.icon().is_none());
assert_eq!(mb.icon_source(), None);
}
#[test]
fn menu_button_item_sources_use_unique_ids_and_follow_mutation_lifecycle() {
static RING: crate::widget::IconData = crate::widget::IconData {
name: "menu-button-ring",
grid: 24,
paths: &["M2-2H22V-22H2Z M7-7V-17H17V-7Z"],
};
let mut button = MenuButton::new("Menu", Rect::new(0, 0, 140, 32));
button.add_item(MenuItem::new(7, "Open").with_icon("legacy"));
assert_eq!(
button.set_item_icon_source(8, Some(IconSource::static_geometry(&RING))),
Err(crate::widget::IconItemError::ItemNotFound { id: 8 })
);
button
.set_item_icon_source(7, Some(IconSource::static_geometry(&RING)))
.expect("unique existing id accepts a typed source");
assert_eq!(button.items()[0].icon.as_deref(), Some("legacy"));
button.open_menu();
let svg = crate::widget::svg::render_to_svg(&mut button);
assert!(svg.contains("fill-rule=\"nonzero\""), "item geometry reaches the menu painter");
button.add_item(MenuItem::new(7, "Duplicate"));
assert_eq!(
button.set_item_icon_source(7, Some(IconSource::static_geometry(&RING))),
Err(crate::widget::IconItemError::AmbiguousItemId { id: 7 })
);
assert!(button.item_icon_sources.is_empty(), "new duplicate invalidates stale mapping");
button.items_mut().clear();
assert!(button.item_icon_sources.is_empty(), "mutable access clears source associations");
}
#[test]
fn menu_button_svg_output() {
let mut mb = MenuButton::new("File", Rect::new(0, 0, 120, 30));
mb.add_item(MenuItem::new(1, "Open").with_icon("๐"));
mb.add_item(MenuItem::new(2, "Save").with_checked(true));
mb.open_menu();
let svg = render_to_svg(&mut mb);
assert!(svg.starts_with("<svg"), "SVG should start with <svg, got: {svg:.60}");
assert!(svg.ends_with("</svg>"), "SVG should end with </svg>");
}
}