extern crate alloc;
use alloc::vec::Vec;
use core::hash::Hash;
use azul_css::AzString;
use crate::{
callbacks::{CoreCallback, CoreCallbackType},
refany::RefAny,
resources::ImageRef,
window::{ContextMenuMouseButton, OptionVirtualKeyCodeCombo},
};
#[derive(Debug, Default, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
#[repr(C)]
pub struct Menu {
pub items: MenuItemVec,
pub position: MenuPopupPosition,
pub context_mouse_btn: ContextMenuMouseButton,
}
impl_option!(
Menu,
OptionMenu,
copy = false,
[Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord]
);
impl Menu {
#[must_use]
pub const fn create(items: MenuItemVec) -> Self {
Self {
items,
position: MenuPopupPosition::AutoCursor,
context_mouse_btn: ContextMenuMouseButton::Right,
}
}
#[must_use]
pub const fn with_position(mut self, position: MenuPopupPosition) -> Self {
self.position = position;
self
}
#[must_use]
pub fn get_hash(&self) -> u64 {
use core::hash::Hasher;
let mut hasher = crate::hash::DefaultHasher::new();
self.hash(&mut hasher);
hasher.finish()
}
}
#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
#[repr(C)]
pub enum MenuPopupPosition {
BottomLeftOfCursor,
BottomRightOfCursor,
TopLeftOfCursor,
TopRightOfCursor,
BottomOfHitRect,
LeftOfHitRect,
TopOfHitRect,
RightOfHitRect,
AutoCursor,
AutoHitRect,
}
impl Default for MenuPopupPosition {
fn default() -> Self {
Self::AutoCursor
}
}
#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
#[repr(C)]
pub enum MenuItemState {
Normal,
Greyed,
Disabled,
}
#[allow(variant_size_differences)] #[derive(Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
#[repr(C, u8)]
#[allow(clippy::large_enum_variant)] pub enum MenuItem {
String(StringMenuItem),
Separator,
BreakLine,
}
impl_option!(
MenuItem,
OptionMenuItem,
copy = false,
[Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord]
);
impl_vec!(MenuItem, MenuItemVec, MenuItemVecDestructor, MenuItemVecDestructorType, MenuItemVecSlice, OptionMenuItem);
impl_vec_clone!(MenuItem, MenuItemVec, MenuItemVecDestructor);
impl_vec_debug!(MenuItem, MenuItemVec);
impl_vec_partialeq!(MenuItem, MenuItemVec);
impl_vec_partialord!(MenuItem, MenuItemVec);
impl_vec_hash!(MenuItem, MenuItemVec);
impl_vec_eq!(MenuItem, MenuItemVec);
impl_vec_ord!(MenuItem, MenuItemVec);
#[derive(Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
#[repr(C)]
pub struct StringMenuItem {
pub label: AzString,
pub accelerator: OptionVirtualKeyCodeCombo,
pub callback: OptionCoreMenuCallback,
pub menu_item_state: MenuItemState,
pub icon: OptionMenuItemIcon,
pub children: MenuItemVec,
}
impl StringMenuItem {
#[must_use]
pub const fn create(label: AzString) -> Self {
Self {
label,
accelerator: OptionVirtualKeyCodeCombo::None,
callback: OptionCoreMenuCallback::None,
menu_item_state: MenuItemState::Normal,
icon: OptionMenuItemIcon::None,
children: MenuItemVec::from_const_slice(&[]),
}
}
#[must_use]
pub fn with_children(mut self, children: MenuItemVec) -> Self {
self.children = children;
self
}
#[must_use]
pub fn with_child(mut self, child: MenuItem) -> Self {
let mut children = self.children.into_library_owned_vec();
children.push(child);
self.children = children.into();
self
}
#[must_use]
pub fn with_callback<I: Into<CoreCallback>>(mut self, data: RefAny, callback: I) -> Self {
self.callback = Some(CoreMenuCallback {
refany: data,
callback: callback.into(),
})
.into();
self
}
}
#[derive(Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
#[repr(C, u8)]
pub enum MenuItemIcon {
Checkbox(bool),
Image(ImageRef),
}
impl_option!(
MenuItemIcon,
OptionMenuItemIcon,
copy = false,
[Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord]
);
#[derive(Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
#[repr(C)]
pub struct CoreMenuCallback {
pub refany: RefAny,
pub callback: CoreCallback,
}
impl_option!(
CoreMenuCallback,
OptionCoreMenuCallback,
copy = false,
[Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord]
);
#[cfg(test)]
mod autotest_generated {
use alloc::{format, string::String, vec, vec::Vec};
use super::*;
use crate::{
refany::RefAny,
window::{ContextMenuMouseButton, VirtualKeyCodeCombo, VirtualKeyCodeVec},
};
const ALL_POSITIONS: [MenuPopupPosition; 10] = [
MenuPopupPosition::BottomLeftOfCursor,
MenuPopupPosition::BottomRightOfCursor,
MenuPopupPosition::TopLeftOfCursor,
MenuPopupPosition::TopRightOfCursor,
MenuPopupPosition::BottomOfHitRect,
MenuPopupPosition::LeftOfHitRect,
MenuPopupPosition::TopOfHitRect,
MenuPopupPosition::RightOfHitRect,
MenuPopupPosition::AutoCursor,
MenuPopupPosition::AutoHitRect,
];
fn item(label: &str) -> MenuItem {
MenuItem::String(StringMenuItem::create(label.into()))
}
fn item_vec(labels: &[&str]) -> MenuItemVec {
labels.iter().map(|l| item(l)).collect::<Vec<_>>().into()
}
fn labels_of(v: &MenuItemVec) -> Vec<&str> {
v.as_slice()
.iter()
.map(|i| match i {
MenuItem::String(s) => s.label.as_str(),
MenuItem::Separator => "<sep>",
MenuItem::BreakLine => "<br>",
})
.collect()
}
fn all_distinct(hashes: &[u64]) -> bool {
let mut sorted = hashes.to_vec();
sorted.sort_unstable();
sorted.dedup();
sorted.len() == hashes.len()
}
#[test]
fn menu_create_defaults_and_extreme_inputs() {
for items in [
MenuItemVec::new(),
MenuItemVec::from_const_slice(&[]),
item_vec(&["a"]),
vec![MenuItem::Separator; 10_000].into(),
] {
let expected_len = items.len();
let menu = Menu::create(items);
assert_eq!(menu.items.len(), expected_len);
assert_eq!(menu.position, MenuPopupPosition::AutoCursor);
assert_eq!(menu.context_mouse_btn, ContextMenuMouseButton::Right);
let _ = menu.get_hash();
}
}
#[test]
fn menu_create_preserves_item_order_and_contents() {
let source = vec![
item("File"),
MenuItem::Separator,
item("Edit"),
MenuItem::BreakLine,
item(""),
];
let menu = Menu::create(source.clone().into());
assert_eq!(menu.items.as_slice(), source.as_slice());
assert_eq!(labels_of(&menu.items), ["File", "<sep>", "Edit", "<br>", ""]);
}
#[test]
fn menu_create_of_empty_vec_equals_default() {
assert_eq!(Menu::create(MenuItemVec::new()), Menu::default());
assert_eq!(
Menu::create(MenuItemVec::new()).get_hash(),
Menu::default().get_hash()
);
}
#[test]
fn menu_item_vec_roundtrip_through_library_owned_vec() {
let source = vec![item("a"), MenuItem::Separator, item("\u{1F600}")];
let decoded = MenuItemVec::from(source.clone()).into_library_owned_vec();
assert_eq!(decoded, source);
assert!(MenuItemVec::from_const_slice(&[])
.into_library_owned_vec()
.is_empty());
}
#[test]
fn menu_with_position_sets_field_and_keeps_items() {
for pos in ALL_POSITIONS {
let menu = Menu::create(item_vec(&["a", "b"])).with_position(pos);
assert_eq!(menu.position, pos);
assert_eq!(menu.items.len(), 2);
assert_eq!(labels_of(&menu.items), ["a", "b"]);
assert_eq!(menu.context_mouse_btn, ContextMenuMouseButton::Right);
}
}
#[test]
fn menu_with_position_last_call_wins() {
let menu = Menu::create(MenuItemVec::new())
.with_position(MenuPopupPosition::TopOfHitRect)
.with_position(MenuPopupPosition::LeftOfHitRect)
.with_position(MenuPopupPosition::AutoHitRect);
assert_eq!(menu.position, MenuPopupPosition::AutoHitRect);
}
#[test]
fn menu_get_hash_is_deterministic() {
let build = || {
Menu::create(item_vec(&["File", "Edit"])).with_position(MenuPopupPosition::TopOfHitRect)
};
let a = build();
let b = build();
assert_eq!(a.get_hash(), a.get_hash(), "repeated calls must agree");
assert_eq!(
a.get_hash(),
b.get_hash(),
"independently built equal menus must agree"
);
}
#[test]
fn menu_get_hash_on_empty_and_default_does_not_panic() {
let heap_empty = Menu::create(MenuItemVec::new());
let static_empty = Menu::create(MenuItemVec::from_const_slice(&[]));
assert_eq!(heap_empty, static_empty);
assert_eq!(heap_empty.get_hash(), static_empty.get_hash());
assert_eq!(Menu::default().get_hash(), heap_empty.get_hash());
}
#[test]
fn menu_get_hash_reacts_to_position_and_mouse_button() {
let hashes: Vec<u64> = ALL_POSITIONS
.iter()
.map(|p| Menu::create(item_vec(&["a"])).with_position(*p).get_hash())
.collect();
assert!(all_distinct(&hashes), "each popup position must hash apart");
let btn_hashes: Vec<u64> = [
ContextMenuMouseButton::Right,
ContextMenuMouseButton::Middle,
ContextMenuMouseButton::Left,
]
.iter()
.map(|b| {
let mut menu = Menu::create(item_vec(&["a"]));
menu.context_mouse_btn = *b;
menu.get_hash()
})
.collect();
assert!(all_distinct(&btn_hashes));
}
#[test]
fn menu_get_hash_distinguishes_nesting_from_flattening() {
let nested = Menu::create(MenuItemVec::from_item(MenuItem::String(
StringMenuItem::create("a".into()).with_child(item("b")),
)));
let flat = Menu::create(item_vec(&["a", "b"]));
assert_ne!(nested, flat);
assert_ne!(nested.get_hash(), flat.get_hash());
}
#[test]
fn menu_get_hash_distinguishes_item_kinds_and_states() {
let sep = Menu::create(MenuItemVec::from_item(MenuItem::Separator));
let br = Menu::create(MenuItemVec::from_item(MenuItem::BreakLine));
assert_ne!(sep.get_hash(), br.get_hash());
let with_state = |state: MenuItemState| {
let mut s = StringMenuItem::create("x".into());
s.menu_item_state = state;
Menu::create(MenuItemVec::from_item(MenuItem::String(s))).get_hash()
};
assert!(all_distinct(&[
with_state(MenuItemState::Normal),
with_state(MenuItemState::Greyed),
with_state(MenuItemState::Disabled),
]));
let with_icon = |icon: OptionMenuItemIcon| {
let mut s = StringMenuItem::create("x".into());
s.icon = icon;
Menu::create(MenuItemVec::from_item(MenuItem::String(s))).get_hash()
};
assert!(all_distinct(&[
with_icon(OptionMenuItemIcon::None),
with_icon(Some(MenuItemIcon::Checkbox(false)).into()),
with_icon(Some(MenuItemIcon::Checkbox(true)).into()),
]));
}
#[test]
fn menu_get_hash_separates_absent_accelerator_from_empty_one() {
let mut with_empty_combo = StringMenuItem::create("x".into());
with_empty_combo.accelerator = Some(VirtualKeyCodeCombo {
keys: VirtualKeyCodeVec::new(),
})
.into();
let none = Menu::create(MenuItemVec::from_item(MenuItem::String(
StringMenuItem::create("x".into()),
)));
let empty = Menu::create(MenuItemVec::from_item(MenuItem::String(with_empty_combo)));
assert_ne!(none.get_hash(), empty.get_hash());
}
#[test]
fn menu_get_hash_reacts_to_unicode_and_nul_labels() {
let labels = [
"",
"a",
"a\u{0}b",
"a\u{0}",
"\u{0}a",
"e\u{301}", "\u{e9}", "\u{202e}x", "\u{1F469}\u{200D}\u{1F4BB}", "\u{10FFFF}",
];
let hashes: Vec<u64> = labels
.iter()
.map(|l| Menu::create(item_vec(&[l])).get_hash())
.collect();
assert!(all_distinct(&hashes), "distinct labels must hash apart");
for l in labels {
let a = Menu::create(item_vec(&[l]));
let b = Menu::create(item_vec(&[l]));
assert_eq!(a.get_hash(), b.get_hash());
}
}
#[test]
fn menu_get_hash_handles_large_menus() {
let mut labels: Vec<String> = (0..10_000).map(|i| format!("item-{i}")).collect();
let big: MenuItemVec = labels
.iter()
.map(|l| item(l.as_str()))
.collect::<Vec<_>>()
.into();
let menu = Menu::create(big);
assert_eq!(menu.items.len(), 10_000);
assert_eq!(menu.get_hash(), menu.get_hash());
labels[9_999] = String::from("item-flipped");
let flipped = Menu::create(
labels
.iter()
.map(|l| item(l.as_str()))
.collect::<Vec<_>>()
.into(),
);
assert_ne!(menu.get_hash(), flipped.get_hash());
}
#[test]
fn menu_get_hash_survives_deeply_nested_submenus() {
const DEPTH: usize = 200;
let mut nested = StringMenuItem::create("leaf".into());
for i in 0..DEPTH {
nested = StringMenuItem::create(format!("lvl-{i}").as_str().into())
.with_child(MenuItem::String(nested));
}
let menu = Menu::create(MenuItemVec::from_item(MenuItem::String(nested)));
assert_eq!(menu.get_hash(), menu.get_hash());
}
#[test]
fn menu_get_hash_is_stable_across_clone_without_callbacks() {
let menu = Menu::create(item_vec(&["File", "Edit"]))
.with_position(MenuPopupPosition::BottomOfHitRect);
let cloned = menu.clone();
assert_eq!(menu, cloned);
assert_eq!(menu.get_hash(), cloned.get_hash());
}
#[test]
fn menu_get_hash_is_unstable_across_clone_with_callback_known_hazard() {
let menu = Menu::create(MenuItemVec::from_item(MenuItem::String(
StringMenuItem::create("Save".into()).with_callback(RefAny::new(1u32), 0xDEAD_usize),
)));
let cloned = menu.clone();
assert_ne!(menu, cloned, "clone identity leaks through RefAny::instance_id");
assert_ne!(menu.get_hash(), cloned.get_hash());
}
#[test]
fn string_menu_item_create_defaults() {
let it = StringMenuItem::create("File".into());
assert_eq!(it.label.as_str(), "File");
assert!(it.accelerator.is_none());
assert!(it.callback.is_none());
assert_eq!(it.menu_item_state, MenuItemState::Normal);
assert!(it.icon.is_none());
assert!(it.children.is_empty());
assert_eq!(it.children.len(), 0);
}
#[test]
fn string_menu_item_create_preserves_extreme_labels() {
let huge = "\u{1F600}".repeat(256 * 1024); for label in [
String::new(),
String::from("a\u{0}b"),
String::from("\u{202e}\u{200d}\u{feff}"),
"x".repeat(1024 * 1024),
huge,
] {
let it = StringMenuItem::create(label.as_str().into());
assert_eq!(it.label.as_str(), label.as_str(), "label must survive byte-for-byte");
assert_eq!(it.label.as_str().len(), label.len());
assert!(it.children.is_empty());
}
}
#[test]
fn with_children_sets_replaces_and_clears() {
let it = StringMenuItem::create("File".into()).with_children(item_vec(&["New", "Open"]));
assert_eq!(it.children.len(), 2);
assert_eq!(labels_of(&it.children), ["New", "Open"]);
let it = it.with_children(item_vec(&["Only"]));
assert_eq!(it.children.len(), 1);
assert_eq!(labels_of(&it.children), ["Only"]);
let it = it.with_children(MenuItemVec::new());
assert!(it.children.is_empty());
assert_eq!(it.children.get(0), None);
}
#[test]
fn with_children_keeps_other_fields_and_accepts_large_input() {
let big: MenuItemVec = (0..5_000)
.map(|i| item(format!("c{i}").as_str()))
.collect::<Vec<_>>()
.into();
let it = StringMenuItem::create("root".into()).with_children(big);
assert_eq!(it.label.as_str(), "root");
assert_eq!(it.children.len(), 5_000);
assert_eq!(it.menu_item_state, MenuItemState::Normal);
assert!(it.callback.is_none());
match it.children.get(4_999) {
Some(MenuItem::String(s)) => assert_eq!(s.label.as_str(), "c4999"),
other => panic!("unexpected tail item: {other:?}"),
}
assert_eq!(it.children.get(5_000), None, "index at len must be None");
}
#[test]
fn with_child_copies_out_of_the_static_backing() {
let it = StringMenuItem::create("File".into()).with_child(item("New"));
assert_eq!(it.children.len(), 1);
assert_eq!(labels_of(&it.children), ["New"]);
assert!(it.children.capacity() >= 1);
assert!(StringMenuItem::create("Edit".into()).children.is_empty());
}
#[test]
fn with_child_appends_in_order_and_after_with_children() {
let it = StringMenuItem::create("File".into())
.with_child(item("a"))
.with_child(MenuItem::Separator)
.with_child(item("b"));
assert_eq!(labels_of(&it.children), ["a", "<sep>", "b"]);
let it = StringMenuItem::create("File".into())
.with_children(item_vec(&["x"]))
.with_child(item("y"));
assert_eq!(labels_of(&it.children), ["x", "y"]);
}
#[test]
fn with_child_does_not_alias_a_clone() {
let base = StringMenuItem::create("File".into()).with_child(item("shared"));
let extended = base.clone().with_child(item("extra"));
assert_eq!(base.children.len(), 1, "clone must not write back into the original");
assert_eq!(extended.children.len(), 2);
assert_eq!(labels_of(&extended.children), ["shared", "extra"]);
}
#[test]
fn with_child_repeated_many_times_keeps_contents() {
const N: usize = 2_000;
let mut it = StringMenuItem::create("root".into());
for i in 0..N {
it = it.with_child(item(format!("c{i}").as_str()));
}
assert_eq!(it.children.len(), N);
assert_eq!(labels_of(&it.children)[0], "c0");
assert_eq!(labels_of(&it.children)[N - 1], "c1999");
assert_eq!(it.label.as_str(), "root");
}
#[test]
fn with_callback_stores_pointer_value_and_data() {
for cb in [0_usize, 1, usize::MAX, usize::MAX - 1] {
let mut it = StringMenuItem::create("Save".into()).with_callback(RefAny::new(42u64), cb);
assert!(it.callback.is_some());
{
let stored = it.callback.as_ref().expect("callback set");
assert_eq!(stored.callback.cb, cb, "raw fn-pointer usize must survive verbatim");
assert!(
stored.callback.ctx.is_none(),
"native Rust callbacks carry no FFI ctx"
);
}
let data = it.callback.as_mut().expect("callback set");
let value = data.refany.downcast_ref::<u64>().expect("payload is u64");
assert_eq!(*value, 42u64);
}
}
#[test]
fn with_callback_keeps_other_fields_and_last_call_wins() {
let it = StringMenuItem::create("Save".into())
.with_children(item_vec(&["kid"]))
.with_callback(RefAny::new(1u8), 111_usize)
.with_callback(RefAny::new(2u8), 222_usize);
let stored = it.callback.as_ref().expect("callback set");
assert_eq!(stored.callback.cb, 222_usize);
assert_eq!(it.label.as_str(), "Save");
assert_eq!(it.children.len(), 1, "callback must not disturb children");
assert_eq!(it.menu_item_state, MenuItemState::Normal);
}
#[test]
fn with_callback_changes_menu_hash() {
let plain = Menu::create(MenuItemVec::from_item(MenuItem::String(
StringMenuItem::create("Save".into()),
)));
let with_cb = Menu::create(MenuItemVec::from_item(MenuItem::String(
StringMenuItem::create("Save".into()).with_callback(RefAny::new(1u32), 7_usize),
)));
assert_ne!(plain.get_hash(), with_cb.get_hash());
}
}