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::{GenericSignal, 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};
pub use crate::widget::menu_toolbar::MAX_SUBMENU_DEPTH;
use crate::widget::metrics::{effective_font, estimate_text_width};
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)
}
#[derive(Clone, Debug)]
pub struct MenuItem {
pub id: u64,
pub text: String,
pub icon: Option<String>,
pub enabled: bool,
pub checked: bool,
pub submenu: Option<Vec<MenuItem>>,
pub separator: bool,
pub shortcut: String,
pub action_id: Option<String>,
}
impl MenuItem {
pub fn new(id: u64, text: &str) -> Self {
Self {
id,
text: text.to_string(),
icon: None,
enabled: true,
checked: false,
submenu: None,
separator: false,
shortcut: String::new(),
action_id: None,
}
}
pub fn separator(id: u64) -> Self {
let mut item = Self::new(id, "");
item.separator = true;
item.enabled = false;
item
}
pub fn with_shortcut(mut self, shortcut: &str) -> Self {
self.shortcut = shortcut.to_string();
self
}
pub fn with_action_id(mut self, action_id: &str) -> Self {
self.action_id = Some(action_id.to_string());
self
}
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(crate) fn menu_panel_rect(anchor: Rect, item_count: usize) -> Rect {
let menu_width = anchor.width.max(DEFAULT_MENU_WIDTH);
let menu_height = (item_count as u32 * ITEM_HEIGHT as u32).max(1);
Rect::new(anchor.x, anchor.y + anchor.height as i32, menu_width, menu_height)
}
pub(crate) fn menu_item_rect(panel: Rect, index: usize) -> Rect {
Rect::new(panel.x, panel.y + index as i32 * ITEM_HEIGHT, panel.width, ITEM_HEIGHT as u32)
}
pub(crate) fn menu_submenu_panel_rect(parent: Rect, anchor_row: Rect, item_count: usize) -> Rect {
let width = parent.width.max(DEFAULT_MENU_WIDTH);
let height = (item_count as u32 * ITEM_HEIGHT as u32).max(1);
Rect::new(parent.x + parent.width as i32, anchor_row.y, width, height)
}
struct SubmenuPanel {
items: Vec<MenuItem>,
rect: Rect,
}
pub(crate) fn draw_menu_panel(
context: &mut RenderContext,
panel: Rect,
items: &[MenuItem],
icon_sources: &[(u64, IconSource)],
style: &crate::style::WidgetStyle,
accent: Color,
) {
draw_menu_panel_with_branches(context, panel, items, icon_sources, style, accent, true);
}
pub(crate) fn draw_menu_panel_with_branches(
context: &mut RenderContext,
panel: Rect,
items: &[MenuItem],
icon_sources: &[(u64, IconSource)],
style: &crate::style::WidgetStyle,
accent: Color,
branches_open: bool,
) {
let themed = crate::style::resolved_theme_style_shared("menu_button");
let panel_fill =
themed.as_ref().and_then(|resolved| resolved.background_color).unwrap_or(Color::WHITE);
let panel_border = style
.border_color
.or_else(|| themed.as_ref().and_then(|resolved| resolved.border_color))
.unwrap_or(Color::rgba(190, 190, 200, 200));
let item_ink = style
.text_color
.or_else(|| themed.as_ref().and_then(|resolved| resolved.text_color))
.unwrap_or(Color::rgb(33, 33, 33));
let item_highlight = panel_fill.blend(&accent, 0.3);
let item_rule = panel_fill.blend(&item_ink, 0.08);
context.fill_rounded_rect(panel, 4, panel_fill);
context.draw_rounded_rect_stroke(panel, 4, panel_border, 1);
let item_font = effective_font(style);
for (i, item) in items.iter().enumerate() {
let item_rect = menu_item_rect(panel, i);
if item.separator {
context.draw_line(
Point::new(item_rect.x + 4, item_rect.y + item_rect.height as i32 / 2),
Point::new(
item_rect.x + item_rect.width as i32 - 4,
item_rect.y + item_rect.height as i32 / 2,
),
item_rule,
);
continue;
}
if item.checked {
context.fill_rounded_rect(item_rect, 2, item_highlight);
}
let item_text_color =
if !item.enabled { item_ink.blend(&panel_fill, 0.5) } else { item_ink };
let mut item_x = item_rect.x + PADDING;
let item_source =
icon_sources.iter().find(|(id, _)| *id == item.id).map(|(_, source)| *source);
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);
let shortcut_width = if item.shortcut.is_empty() {
0
} else {
estimate_text_width(&item.shortcut, item_font, 1.0) + 8
};
let mark_zone = if item.checked {
14
} else if branches_open && item.submenu.is_some() && item.enabled {
12
} else {
0
};
let trailing = shortcut_width.max(mark_zone);
let item_text_right = item_rect.x + item_rect.width as i32 - PADDING - trailing as i32;
let item_text_width = (item_text_right - item_x).max(0) as u32;
context.draw_text_fitted_cow(
Rect::new(item_x, item_line.y, item_text_width, item_line.height.max(1)),
&item.text,
item_font,
item_text_color,
HorizontalAlignment::Left,
);
if !item.shortcut.is_empty() {
let shortcut_band = Rect::new(
item_rect.x + item_rect.width as i32 - PADDING - shortcut_width as i32,
item_line.y,
shortcut_width,
item_line.height.max(1),
);
context.draw_text_fitted_cow(
shortcut_band,
&item.shortcut,
item_font,
item_ink.blend(&panel_fill, 0.55),
HorizontalAlignment::Right,
);
}
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 branches_open && 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),
]
.into(),
closed: true,
color: item_ink.blend(&panel_fill, 0.45),
filled: true,
width: 1,
});
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MenuButtonPopup {
#[default]
InstantPopup,
MenuButtonPopup,
DelayedPopup,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MenuArrowType {
None,
#[default]
Down,
Up,
Left,
Right,
}
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,
submenus: Vec<SubmenuPanel>,
active_index: Option<usize>,
popup_mode: MenuButtonPopup,
arrow_type: MenuArrowType,
popup_delay_ms: u32,
press_elapsed: Option<u32>,
pub clicked: GenericSignal,
pub about_to_show: GenericSignal,
pub about_to_hide: GenericSignal,
pub item_triggered: Signal1<u64>,
pub submenu_requested: Signal1<String>,
pub action_triggered: Signal1<String>,
}
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),
submenus: Vec::new(),
active_index: None,
popup_mode: MenuButtonPopup::InstantPopup,
arrow_type: MenuArrowType::Down,
popup_delay_ms: 300,
press_elapsed: None,
clicked: GenericSignal::new(),
about_to_show: GenericSignal::new(),
about_to_hide: GenericSignal::new(),
item_triggered: Signal1::new(),
submenu_requested: Signal1::new(),
action_triggered: Signal1::new(),
}
}
pub fn text(&self) -> &str {
&self.text
}
pub fn set_text(&mut self, text: &str) {
if self.text == text {
return;
}
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) {
if self.icon.as_deref() == Some(icon) && self.icon_source.is_none() {
return;
}
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.close_all_submenus();
self.base.request_redraw();
}
removed
}
pub fn clear_items(&mut self) {
self.menu_items.clear();
self.item_icon_sources.clear();
self.close_all_submenus();
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();
self.close_all_submenus();
&mut self.menu_items
}
pub fn is_menu_open(&self) -> bool {
self.menu_open
}
pub fn is_submenu_open(&self) -> bool {
!self.submenus.is_empty()
}
pub fn submenu_depth(&self) -> usize {
self.submenus.len()
}
pub fn popup_mode(&self) -> MenuButtonPopup {
self.popup_mode
}
pub fn set_popup_mode(&mut self, mode: MenuButtonPopup) {
if self.popup_mode == mode {
return;
}
self.popup_mode = mode;
self.press_elapsed = None;
self.base.request_redraw();
}
pub fn arrow_type(&self) -> MenuArrowType {
self.arrow_type
}
pub fn set_arrow_type(&mut self, arrow: MenuArrowType) {
if self.arrow_type == arrow {
return;
}
self.arrow_type = arrow;
self.base.request_redraw();
}
pub fn popup_delay_ms(&self) -> u32 {
self.popup_delay_ms
}
pub fn set_popup_delay_ms(&mut self, delay_ms: u32) {
if self.popup_delay_ms == delay_ms {
return;
}
self.popup_delay_ms = delay_ms;
}
pub fn arrow_rect(&self) -> Rect {
let geom = self.geometry();
if self.arrow_type == MenuArrowType::None {
return geom;
}
let width = geom.width.min(20);
Rect::new(geom.x + geom.width as i32 - width as i32, geom.y, width, geom.height)
}
pub fn is_press_pending(&self) -> bool {
self.press_elapsed.is_some()
}
pub fn toggle_menu(&mut self) {
if self.menu_open {
self.close_menu();
} else {
self.open_menu();
}
}
pub fn open_menu(&mut self) {
if self.menu_open {
return;
}
self.about_to_show.emit();
self.menu_open = true;
self.reveal.set_open(true);
#[cfg(not(alloc_frugal))]
crate::widget::runtime::set_popup_grab(self.id());
self.base.request_redraw();
}
pub fn close_menu(&mut self) {
if !self.menu_open && self.submenus.is_empty() {
return;
}
self.menu_open = false;
self.reveal.set_open(false);
self.submenus.clear();
self.active_index = None;
#[cfg(not(alloc_frugal))]
crate::widget::runtime::clear_popup_grab_for(self.id());
self.base.request_redraw();
self.about_to_hide.emit();
}
fn close_submenu(&mut self) {
if self.submenus.pop().is_none() {
return;
}
#[cfg(not(alloc_frugal))]
if self.menu_open {
crate::widget::runtime::set_popup_grab(self.id());
}
self.base.request_redraw();
}
fn close_all_submenus(&mut self) {
if self.submenus.is_empty() {
return;
}
self.submenus.clear();
#[cfg(not(alloc_frugal))]
if self.menu_open {
crate::widget::runtime::set_popup_grab(self.id());
}
self.base.request_redraw();
}
fn items_at(&self, level: usize) -> Option<&[MenuItem]> {
if level == 0 {
Some(&self.menu_items)
} else {
self.submenus.get(level - 1).map(|panel| panel.items.as_slice())
}
}
fn panel_rect_at(&self, level: usize) -> Option<Rect> {
if level == 0 {
Some(self.menu_rect())
} else {
self.submenus.get(level - 1).map(|panel| panel.rect)
}
}
fn row_rect_at(&self, level: usize, index: usize) -> Option<Rect> {
let panel = self.panel_rect_at(level)?;
let len = self.items_at(level)?.len();
(index < len).then(|| menu_item_rect(panel, index))
}
fn open_submenu_at(&mut self, level: usize, index: usize) {
if level >= MAX_SUBMENU_DEPTH {
return;
}
let Some(item) = self.items_at(level).and_then(|items| items.get(index)) else {
return;
};
if !item.enabled {
return;
}
let Some(children) = item.submenu.clone().filter(|children| !children.is_empty()) else {
return;
};
let Some(parent) = self.panel_rect_at(level) else {
return;
};
let Some(anchor_row) = self.row_rect_at(level, index) else {
return;
};
let text = item.text.clone();
let count = children.len();
self.submenu_requested.emit(text);
self.submenus.truncate(level);
self.submenus.push(SubmenuPanel {
items: children,
rect: menu_submenu_panel_rect(parent, anchor_row, count),
});
if level == 0 {
self.active_index = Some(index);
}
#[cfg(not(alloc_frugal))]
crate::widget::runtime::set_popup_grab(self.id());
self.base.request_redraw();
}
fn activate_row_at(&mut self, level: usize, index: usize) {
let Some(item) = self.items_at(level).and_then(|items| items.get(index)) else {
return;
};
if !item.enabled || item.separator {
return;
}
let id = item.id;
let text = item.text.clone();
let action_id = item.action_id.clone();
let is_branch = item.submenu.as_ref().is_some_and(|children| !children.is_empty());
if is_branch {
if level >= MAX_SUBMENU_DEPTH {
self.submenu_requested.emit(text);
} else {
self.open_submenu_at(level, index);
}
return;
}
self.item_triggered.emit(id);
if let Some(action_id) = action_id {
self.action_triggered.emit(action_id);
}
self.close_menu();
}
fn activate_top_level(&mut self, index: usize) {
self.activate_row_at(0, index);
}
fn keyboard_branch_index_at(&self, level: usize) -> Option<usize> {
let is_branch = |index: usize| {
self.items_at(level).and_then(|items| items.get(index)).is_some_and(|item| {
item.enabled && item.submenu.as_ref().is_some_and(|c| !c.is_empty())
})
};
if level == 0 {
if let Some(index) = self.active_index.filter(|index| is_branch(*index)) {
return Some(index);
}
}
(0..self.items_at(level)?.len()).find(|index| is_branch(*index))
}
fn hit_test_submenu(&self, pos: Point) -> Option<(usize, usize)> {
for (offset, panel) in self.submenus.iter().enumerate().rev() {
if !panel.rect.contains_point(pos) {
continue;
}
let level = offset + 1;
for (index, item) in panel.items.iter().enumerate() {
let row = menu_item_rect(panel.rect, index);
if item.enabled && !item.separator && row.contains_point(pos) {
return Some((level, index));
}
}
}
None
}
fn menu_rect(&self) -> Rect {
menu_panel_rect(self.geometry(), self.menu_items.len())
}
fn item_rect(&self, index: usize) -> Rect {
menu_item_rect(self.menu_rect(), index)
}
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 && !item.separator {
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 contains_point(&self, point: crate::core::Point) -> bool {
self.base().contains_point_with_touch_expansion(point)
|| (self.menu_open && self.menu_rect().contains_point(point))
|| self.submenus.iter().any(|panel| panel.rect.contains_point(point))
}
fn has_open_popup(&self) -> bool {
self.menu_open
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(120, 28)
}
fn tick(&mut self, delta_ms: u32) -> bool {
let mut animating = self.reveal.tick(delta_ms);
if let Some(elapsed) = self.press_elapsed.as_mut() {
*elapsed = elapsed.saturating_add(delta_ms);
if *elapsed >= self.popup_delay_ms {
self.press_elapsed = None;
self.open_menu();
animating = true;
} else {
animating = true;
}
}
animating
}
fn is_animating(&self) -> bool {
self.reveal.is_animating() || self.press_elapsed.is_some()
}
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)
}))
}
"submenu_requested" => {
Some(EventSignalRef::mapped("submenu_requested", &self.submenu_requested, |v| {
CapabilityValue::String(v.clone())
}))
}
"about_to_hide" => Some(EventSignalRef::unit("about_to_hide", &self.about_to_hide)),
"about_to_show" => Some(EventSignalRef::unit("about_to_show", &self.about_to_show)),
"clicked" => Some(EventSignalRef::unit("clicked", &self.clicked)),
"action_triggered" => {
Some(EventSignalRef::mapped("action_triggered", &self.action_triggered, |v| {
CapabilityValue::String(v.clone())
}))
}
_ => 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();
let font = effective_font(style);
let themed = crate::style::resolved_theme_style_shared("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);
let arrow_x = if self.arrow_type == MenuArrowType::None {
geom.x + geom.width as i32 - PADDING
} else {
geom.x + geom.width as i32 - PADDING - 8
};
let text_band_width = ((arrow_x - 4) - text_offset_x).max(0) as u32;
context.draw_text_fitted_cow(
Rect::new(text_offset_x, line.y, text_band_width, line.height.max(1)),
&self.text,
font,
text_color,
HorizontalAlignment::Left,
);
let arrow_y = geom.y + geom.height as i32 / 2 - 2;
let arrow_color = if !is_enabled { disabled_ink } else { ink };
if self.arrow_type != MenuArrowType::None {
let points = match self.arrow_type {
MenuArrowType::Down | MenuArrowType::None => vec![
Point::new(arrow_x, arrow_y),
Point::new(arrow_x + 8, arrow_y),
Point::new(arrow_x + 4, arrow_y + 6),
],
MenuArrowType::Up => vec![
Point::new(arrow_x, arrow_y + 6),
Point::new(arrow_x + 8, arrow_y + 6),
Point::new(arrow_x + 4, arrow_y),
],
MenuArrowType::Left => vec![
Point::new(arrow_x + 6, arrow_y),
Point::new(arrow_x + 6, arrow_y + 6),
Point::new(arrow_x, arrow_y + 3),
],
MenuArrowType::Right => vec![
Point::new(arrow_x, arrow_y),
Point::new(arrow_x, arrow_y + 6),
Point::new(arrow_x + 6, arrow_y + 3),
],
};
context.execute_command(RenderCommand::DrawPath {
points: points.into(),
closed: true,
color: arrow_color,
filled: true,
width: 1,
});
}
if self.popup_mode == MenuButtonPopup::MenuButtonPopup && is_enabled {
let divider_x = self.arrow_rect().x;
context.draw_line(
Point::new(divider_x, geom.y + 4),
Point::new(divider_x, geom.y + geom.height as i32 - 4),
border,
);
}
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);
}
draw_menu_panel(
context,
menu,
&self.menu_items,
&self.item_icon_sources,
style,
accent,
);
if clipped {
context.pop_clip();
}
}
for (offset, panel) in self.submenus.iter().enumerate() {
draw_menu_panel_with_branches(
context,
panel.rect,
&panel.items,
&self.item_icon_sources,
style,
accent,
offset + 1 < MAX_SUBMENU_DEPTH,
);
}
}
}
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 {
return;
}
if self.geometry().contains_point(*pos) {
let on_arrow = self.arrow_rect().contains_point(*pos);
match self.popup_mode {
MenuButtonPopup::InstantPopup => self.toggle_menu(),
MenuButtonPopup::MenuButtonPopup => {
if on_arrow || self.arrow_type == MenuArrowType::None {
self.toggle_menu();
} else {
self.clicked.emit();
}
}
MenuButtonPopup::DelayedPopup => {
if self.menu_open {
self.close_menu();
} else if on_arrow {
self.open_menu();
} else {
self.press_elapsed = Some(0);
self.base.request_redraw();
}
}
}
} else if self.menu_open {
if let Some((level, index)) = self.hit_test_submenu(*pos) {
self.activate_row_at(level, index);
} else if let Some(index) = self.hit_test_item(*pos) {
self.active_index = Some(index);
self.activate_top_level(index);
} else if !self.submenus.is_empty() {
self.close_submenu();
} else {
self.close_menu();
}
}
}
Event::MouseRelease { .. } => {
self.press_elapsed = None;
}
Event::KeyPress { key, modifiers: _ } => match *key {
key_codes::ESCAPE if !self.submenus.is_empty() => self.close_submenu(),
key_codes::ESCAPE if self.menu_open => self.close_menu(),
key_codes::RIGHT if self.menu_open => {
let level = self.submenus.len();
if let Some(index) = self.keyboard_branch_index_at(level) {
self.open_submenu_at(level, index);
}
}
key_codes::LEFT if !self.submenus.is_empty() => self.close_submenu(),
key_codes::SPACE | key_codes::ENTER => self.toggle_menu(),
_ => { }
},
Event::MouseLeave { .. } => {
self.press_elapsed = None;
self.base.handle_event(event);
}
_ => {
self.base.handle_event(event);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::svg::render_to_svg;
use std::sync::atomic::{AtomicUsize, Ordering};
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());
}
fn button_with_branch() -> MenuButton {
let mut mb = MenuButton::new("File", Rect::new(0, 0, 100, 30));
mb.add_item(
MenuItem::new(1, "Recent")
.with_submenu(vec![MenuItem::new(2, "Doc"), MenuItem::new(3, "Sheet")]),
);
mb.add_item(MenuItem::new(4, "Exit"));
mb
}
#[test]
fn menu_button_branch_opens_its_child_panel_without_triggering() {
let mut mb = button_with_branch();
let triggered = Arc::new(AtomicUsize::new(0));
let count = triggered.clone();
mb.item_triggered.connect(move |_| {
count.fetch_add(1, Ordering::SeqCst);
});
mb.open_menu();
let row = mb.item_rect(0);
mb.handle_event(&Event::mouse_press(row.x + 5, row.y + 5, 1));
assert!(mb.is_menu_open(), "a branch activation must not close the parent menu");
assert_eq!(triggered.load(Ordering::SeqCst), 0, "a branch is not a leaf activation");
assert_eq!(mb.submenus[0].items.len(), 2);
let panel = mb.submenus[0].rect;
assert!(panel.x >= mb.menu_rect().x + mb.menu_rect().width as i32);
assert!(mb.contains_point(Point::new(panel.x + 5, panel.y + 5)));
}
#[test]
fn menu_button_submenu_requested_carries_the_branch_text() {
let mut mb = button_with_branch();
let label = Arc::new(Mutex::new(String::new()));
let captured = label.clone();
mb.submenu_requested.connect(move |text| {
*captured.lock().unwrap() = text.to_string();
});
mb.open_menu();
let row = mb.item_rect(0);
mb.handle_event(&Event::mouse_press(row.x + 5, row.y + 5, 1));
assert_eq!(*label.lock().unwrap(), "Recent");
}
#[test]
fn menu_button_escape_collapses_the_child_panel_first() {
let mut mb = button_with_branch();
mb.open_menu();
let row = mb.item_rect(0);
mb.handle_event(&Event::mouse_press(row.x + 5, row.y + 5, 1));
assert!(mb.is_submenu_open());
mb.handle_event(&Event::key_press(27, 0));
assert!(mb.is_menu_open(), "the first Escape returns to the parent menu");
assert!(!mb.is_submenu_open());
mb.handle_event(&Event::key_press(27, 0));
assert!(!mb.is_menu_open());
}
#[test]
fn menu_button_child_leaf_selection_emits_item_triggered() {
let mut mb = button_with_branch();
let chosen = Arc::new(Mutex::new(0u64));
let captured = chosen.clone();
mb.item_triggered.connect(move |id| {
*captured.lock().unwrap() = *id;
});
mb.open_menu();
let branch = mb.item_rect(0);
mb.handle_event(&Event::mouse_press(branch.x + 5, branch.y + 5, 1));
let panel = mb.submenus[0].rect;
mb.handle_event(&Event::mouse_press(panel.x + 5, panel.y + 5, 1));
assert_eq!(*chosen.lock().unwrap(), 2);
assert!(!mb.is_menu_open(), "a leaf selection closes the whole popup");
assert!(!mb.is_submenu_open());
}
#[test]
fn menu_button_right_opens_a_branch_and_left_returns_to_the_menu() {
let mut mb = button_with_branch();
mb.open_menu();
mb.handle_event(&Event::key_press(39, 0));
assert!(mb.is_submenu_open());
mb.handle_event(&Event::key_press(37, 0));
assert!(!mb.is_submenu_open());
assert!(mb.is_menu_open());
}
fn button_with_deep_chain() -> MenuButton {
let l5 = MenuItem::new(50, "Level 5").with_submenu(vec![MenuItem::new(51, "Leaf 5")]);
let l4 = MenuItem::new(40, "Level 4").with_submenu(vec![l5, MenuItem::new(41, "Leaf 4")]);
let l3 = MenuItem::new(30, "Level 3").with_submenu(vec![l4, MenuItem::new(31, "Leaf 3")]);
let l2 = MenuItem::new(20, "Level 2").with_submenu(vec![l3, MenuItem::new(21, "Leaf 2")]);
let l1 = MenuItem::new(10, "Level 1").with_submenu(vec![l2, MenuItem::new(11, "Leaf 1")]);
let mut mb = MenuButton::new("File", Rect::new(0, 0, 100, 30));
mb.add_item(l1);
mb.add_item(MenuItem::new(60, "Other").with_submenu(vec![MenuItem::new(61, "Other 1")]));
mb
}
fn click_row(mb: &mut MenuButton, level: usize, index: usize) {
let row = if level == 0 {
mb.item_rect(index)
} else {
menu_item_rect(mb.submenus[level - 1].rect, index)
};
mb.handle_event(&Event::mouse_press(row.x + 5, row.y + 5, 1));
}
#[test]
fn menu_button_opens_four_nested_child_panels() {
let mut mb = button_with_deep_chain();
mb.open_menu();
for _ in 0..MAX_SUBMENU_DEPTH {
mb.handle_event(&Event::key_press(key_codes::RIGHT, 0));
}
assert_eq!(mb.submenu_depth(), MAX_SUBMENU_DEPTH);
assert!(mb.is_menu_open(), "opening panels must never close the menu");
let mut parent = mb.menu_rect();
for panel in &mb.submenus {
assert_eq!(panel.rect.x, parent.x + parent.width as i32);
assert!(
mb.contains_point(Point::new(panel.rect.x + 2, panel.rect.y + 2)),
"every open panel accepts the pointer"
);
parent = panel.rect;
}
assert_eq!(mb.submenus[3].items[0].id, 50, "the fourth panel holds the deepest branch");
}
#[test]
fn menu_button_refuses_a_fifth_panel_but_announces_the_branch() {
let mut mb = button_with_deep_chain();
let requested = Arc::new(Mutex::new(Vec::new()));
let captured = requested.clone();
mb.submenu_requested.connect(move |text| {
captured.lock().unwrap().push(text.to_string());
});
let triggered = Arc::new(AtomicUsize::new(0));
let count = triggered.clone();
mb.item_triggered.connect(move |_| {
count.fetch_add(1, Ordering::SeqCst);
});
mb.open_menu();
for _ in 0..MAX_SUBMENU_DEPTH {
mb.handle_event(&Event::key_press(key_codes::RIGHT, 0));
}
let before = requested.lock().unwrap().len();
click_row(&mut mb, MAX_SUBMENU_DEPTH, 0);
assert_eq!(mb.submenu_depth(), MAX_SUBMENU_DEPTH, "no fifth panel may open");
assert!(mb.is_menu_open());
assert_eq!(triggered.load(Ordering::SeqCst), 0, "a branch is not a leaf activation");
let after = requested.lock().unwrap().clone();
assert_eq!(after.len(), before + 1);
assert_eq!(after.last().map(String::as_str), Some("Level 5"));
}
#[test]
fn menu_button_escape_unwinds_one_panel_per_press() {
let mut mb = button_with_deep_chain();
mb.open_menu();
for _ in 0..MAX_SUBMENU_DEPTH {
mb.handle_event(&Event::key_press(key_codes::RIGHT, 0));
}
assert_eq!(mb.submenu_depth(), MAX_SUBMENU_DEPTH);
for expected in (0..MAX_SUBMENU_DEPTH).rev() {
mb.handle_event(&Event::key_press(key_codes::ESCAPE, 0));
assert_eq!(mb.submenu_depth(), expected);
assert!(mb.is_menu_open(), "Escape leaves the menu open until it is last");
}
mb.handle_event(&Event::key_press(key_codes::ESCAPE, 0));
assert!(!mb.is_menu_open());
}
#[test]
fn menu_button_deep_leaf_selection_emits_item_triggered() {
let mut mb = button_with_deep_chain();
let chosen = Arc::new(Mutex::new(0u64));
let captured = chosen.clone();
mb.item_triggered.connect(move |id| {
*captured.lock().unwrap() = *id;
});
mb.open_menu();
for level in 0..MAX_SUBMENU_DEPTH {
click_row(&mut mb, level, 0);
}
assert_eq!(mb.submenu_depth(), MAX_SUBMENU_DEPTH);
click_row(&mut mb, MAX_SUBMENU_DEPTH, 1);
assert_eq!(*chosen.lock().unwrap(), 41, "Leaf 4 sits on the deepest level");
assert!(!mb.is_menu_open(), "a leaf selection closes the whole popup");
assert_eq!(mb.submenu_depth(), 0);
}
#[test]
fn menu_button_opening_a_sibling_branch_discards_the_deeper_panels() {
let mut mb = button_with_deep_chain();
mb.open_menu();
click_row(&mut mb, 0, 0);
click_row(&mut mb, 1, 0);
assert_eq!(mb.submenu_depth(), 2);
click_row(&mut mb, 0, 1);
assert_eq!(mb.submenu_depth(), 1);
assert_eq!(mb.submenus[0].items[0].id, 61);
}
#[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>");
}
#[test]
fn split_mode_separates_the_face_from_the_arrow() {
let mut mb = MenuButton::new("Run", Rect::new(0, 0, 120, 30));
mb.add_item(MenuItem::new(1, "Open"));
mb.set_popup_mode(MenuButtonPopup::MenuButtonPopup);
let clicks = Arc::new(AtomicUsize::new(0));
let sink = clicks.clone();
mb.clicked.connect(move || {
sink.fetch_add(1, Ordering::SeqCst);
});
mb.handle_event(&Event::mouse_press(10, 10, 1));
assert_eq!(clicks.load(Ordering::SeqCst), 1);
assert!(!mb.is_menu_open(), "the face must not open the menu in split mode");
let arrow = mb.arrow_rect();
mb.handle_event(&Event::mouse_press(arrow.x + 4, arrow.y + 4, 1));
assert!(mb.is_menu_open(), "the arrow segment opens the menu");
assert_eq!(clicks.load(Ordering::SeqCst), 1, "the arrow is not the face");
}
#[test]
fn a_delayed_press_opens_after_the_hold_time() {
let mut mb = MenuButton::new("Run", Rect::new(0, 0, 120, 30));
mb.add_item(MenuItem::new(1, "Open"));
mb.set_popup_mode(MenuButtonPopup::DelayedPopup);
mb.set_popup_delay_ms(100);
mb.handle_event(&Event::mouse_press(10, 10, 1));
assert!(mb.is_press_pending());
assert!(!mb.is_menu_open());
assert!(mb.tick(60));
assert!(!mb.is_menu_open(), "60 ms is before the 100 ms delay");
assert!(mb.tick(60));
assert!(mb.is_menu_open(), "the second tick crosses the delay");
assert!(!mb.is_press_pending());
}
#[test]
fn releasing_a_delayed_press_cancels_it() {
let mut mb = MenuButton::new("Run", Rect::new(0, 0, 120, 30));
mb.set_popup_mode(MenuButtonPopup::DelayedPopup);
mb.set_popup_delay_ms(500);
mb.handle_event(&Event::mouse_press(10, 10, 1));
assert!(mb.is_press_pending());
mb.handle_event(&Event::mouse_release(10, 10, 1));
assert!(!mb.is_press_pending());
mb.tick(1000);
assert!(!mb.is_menu_open());
}
#[test]
fn arrow_type_is_configurable() {
let mut mb = MenuButton::new("Run", Rect::new(0, 0, 120, 30));
assert_eq!(mb.arrow_type(), MenuArrowType::Down);
let full = mb.arrow_rect();
assert_eq!(full.width, 20);
mb.set_arrow_type(MenuArrowType::None);
assert_eq!(mb.arrow_rect().width, 120, "no arrow means the whole button is the target");
let without = render_to_svg(&mut mb);
mb.set_arrow_type(MenuArrowType::Right);
let with = render_to_svg(&mut mb);
assert_ne!(without, with, "the arrow direction must reach the painter");
}
#[test]
fn menu_button_announces_show_and_hide() {
let mut mb = MenuButton::new("Run", Rect::new(0, 0, 120, 30));
let shown = Arc::new(AtomicUsize::new(0));
let hidden = Arc::new(AtomicUsize::new(0));
let s = shown.clone();
let h = hidden.clone();
mb.about_to_show.connect(move || {
s.fetch_add(1, Ordering::SeqCst);
});
mb.about_to_hide.connect(move || {
h.fetch_add(1, Ordering::SeqCst);
});
mb.open_menu();
mb.open_menu();
assert_eq!(shown.load(Ordering::SeqCst), 1);
mb.close_menu();
mb.close_menu();
assert_eq!(hidden.load(Ordering::SeqCst), 1);
}
}