Skip to main content

azul_core/
menu.rs

1//! Menu system for context menus, dropdown menus, and application menus.
2//!
3//! This module provides a cross-platform menu abstraction modeled after the Windows API,
4//! supporting hierarchical menus with separators, icons, keyboard accelerators, and callbacks.
5//!
6//! # Core vs Layout Types
7//!
8//! This module uses `CoreMenuCallback` with `usize` placeholders instead of function pointers
9//! to avoid circular dependencies between `azul-core` and `azul-layout`. The actual function
10//! pointers are stored in `azul-layout` and converted via unsafe code with identical memory
11//! layout.
12
13extern crate alloc;
14
15use alloc::vec::Vec;
16use core::hash::Hash;
17
18use azul_css::AzString;
19
20use crate::{
21    callbacks::{CoreCallback, CoreCallbackType},
22    refany::RefAny,
23    resources::ImageRef,
24    window::{
25        ContextMenuMouseButton, KeyboardState, OptionVirtualKeyCodeCombo, VirtualKeyCode,
26        VirtualKeyCodeCombo,
27    },
28};
29
30/// Does `combo` describe the chord the keyboard is in, with `pressed` as the
31/// key that just went down?
32///
33/// THE shared accelerator rule, used by every platform that dispatches menu
34/// accelerators itself (Windows, X11, Wayland, headless; macOS's menu bar
35/// uses `AppKit` key equivalents, its context menus this).
36///
37/// * The combo names its modifiers with the modifier keys (`LControl`,
38///   `LShift`, `LAlt`, `LWin`; the right-hand twins are equivalent) and
39///   exactly ONE non-modifier key, which must be `pressed`.
40/// * `LWin` / `RWin` mean the platform's PRIMARY shortcut modifier — Cmd on
41///   macOS, Ctrl everywhere else — so `[LWin, S]` is Cmd+S on a Mac and
42///   Ctrl+S on Windows/Linux from one definition (the MWA-A2 rule behind
43///   `KeyboardState::primary_down`). `LControl` stays the Control key on
44///   every platform.
45/// * The match is EXACT: `Ctrl+S` does not fire while Shift is also held, so
46///   `Ctrl+S` and `Ctrl+Shift+S` can coexist in one menu.
47#[must_use]
48pub fn accelerator_matches(
49    combo: &VirtualKeyCodeCombo,
50    keyboard: &KeyboardState,
51    pressed: VirtualKeyCode,
52) -> bool {
53    let mut want_ctrl = false;
54    let mut want_shift = false;
55    let mut want_alt = false;
56    let mut want_primary = false;
57    let mut main_key: Option<VirtualKeyCode> = None;
58    for key in combo.keys.as_ref() {
59        match key {
60            VirtualKeyCode::LControl | VirtualKeyCode::RControl => want_ctrl = true,
61            VirtualKeyCode::LShift | VirtualKeyCode::RShift => want_shift = true,
62            VirtualKeyCode::LAlt | VirtualKeyCode::RAlt => want_alt = true,
63            VirtualKeyCode::LWin | VirtualKeyCode::RWin => want_primary = true,
64            other => {
65                if main_key.is_some() {
66                    // Two non-modifier keys: not a chord this rule can match.
67                    return false;
68                }
69                main_key = Some(*other);
70            }
71        }
72    }
73    if main_key != Some(pressed) {
74        return false;
75    }
76    if want_shift != keyboard.shift_down() || want_alt != keyboard.alt_down() {
77        return false;
78    }
79    if cfg!(target_os = "macos") {
80        want_ctrl == keyboard.ctrl_down() && want_primary == keyboard.super_down()
81    } else {
82        // Ctrl IS the primary modifier here; the Super/Windows key never
83        // takes part in an application chord.
84        (want_ctrl || want_primary) == keyboard.ctrl_down() && !keyboard.super_down()
85    }
86}
87
88/// Depth-first search of `items` for the first enabled entry whose
89/// accelerator matches the chord (see [`accelerator_matches`]).
90fn find_accelerated_in<'a>(
91    items: &'a [MenuItem],
92    keyboard: &KeyboardState,
93    pressed: VirtualKeyCode,
94) -> Option<&'a StringMenuItem> {
95    for item in items {
96        let MenuItem::String(s) = item else {
97            continue;
98        };
99        if s.menu_item_state == MenuItemState::Normal {
100            if let OptionVirtualKeyCodeCombo::Some(combo) = &s.accelerator {
101                if accelerator_matches(combo, keyboard, pressed) {
102                    return Some(s);
103                }
104            }
105        }
106        if let Some(found) = find_accelerated_in(s.children.as_ref(), keyboard, pressed) {
107            return Some(found);
108        }
109    }
110    None
111}
112
113/// Represents a menu (context menu, dropdown menu, or application menu).
114///
115/// A menu consists of a list of items that can be displayed as a popup or
116/// attached to a window's menu bar. Modeled after the Windows API for
117/// cross-platform consistency.
118///
119/// # Fields
120///
121/// * `items` - The menu items to display
122/// * `position` - Where the menu should appear (for popups)
123/// * `context_mouse_btn` - Which mouse button triggers the context menu
124#[derive(Debug, Default, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
125#[repr(C)]
126pub struct Menu {
127    pub items: MenuItemVec,
128    pub position: MenuPopupPosition,
129    pub context_mouse_btn: ContextMenuMouseButton,
130}
131
132impl_option!(
133    Menu,
134    OptionMenu,
135    copy = false,
136    [Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord]
137);
138
139impl Menu {
140    /// The first enabled item anywhere in this menu whose accelerator
141    /// matches the chord the keyboard is in, `pressed` being the key that
142    /// just went down. Greyed / disabled items never match.
143    #[must_use]
144    pub fn find_accelerated_item<'a>(
145        &'a self,
146        keyboard: &KeyboardState,
147        pressed: VirtualKeyCode,
148    ) -> Option<&'a StringMenuItem> {
149        find_accelerated_in(self.items.as_ref(), keyboard, pressed)
150    }
151
152    /// Creates a new menu with the given items.
153    ///
154    /// Uses default position (`AutoCursor`) and right mouse button for context menus.
155    #[must_use]
156    pub const fn create(items: MenuItemVec) -> Self {
157        Self {
158            items,
159            position: MenuPopupPosition::AutoCursor,
160            context_mouse_btn: ContextMenuMouseButton::Right,
161        }
162    }
163
164    /// Builder method to set the popup position.
165    #[must_use]
166    pub const fn with_position(mut self, position: MenuPopupPosition) -> Self {
167        self.position = position;
168        self
169    }
170
171    /// Computes a 64-bit hash of this menu using the `HighwayHash` algorithm.
172    ///
173    /// This is used to detect changes in menu structure for caching and optimization.
174    #[must_use]
175    pub fn get_hash(&self) -> u64 {
176        use core::hash::Hasher;
177        let mut hasher = crate::hash::DefaultHasher::new();
178        self.hash(&mut hasher);
179        hasher.finish()
180    }
181}
182
183/// Specifies where a popup menu should appear relative to the cursor or clicked element.
184///
185/// This positioning information is ignored for application-level menus (menu bars)
186/// and only applies to context menus and dropdowns.
187#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
188#[repr(C)]
189pub enum MenuPopupPosition {
190    /// Position menu below and to the left of the cursor
191    BottomLeftOfCursor,
192    /// Position menu below and to the right of the cursor
193    BottomRightOfCursor,
194    /// Position menu above and to the left of the cursor
195    TopLeftOfCursor,
196    /// Position menu above and to the right of the cursor
197    TopRightOfCursor,
198    /// Position menu below the rectangle that was clicked
199    BottomOfHitRect,
200    /// Position menu to the left of the rectangle that was clicked
201    LeftOfHitRect,
202    /// Position menu above the rectangle that was clicked
203    TopOfHitRect,
204    /// Position menu to the right of the rectangle that was clicked
205    RightOfHitRect,
206    /// Automatically calculate position based on available screen space near cursor
207    AutoCursor,
208    /// Automatically calculate position based on available screen space near clicked rect
209    AutoHitRect,
210}
211
212impl Default for MenuPopupPosition {
213    fn default() -> Self {
214        Self::AutoCursor
215    }
216}
217
218/// Describes the interactive state of a menu item.
219///
220/// Menu items can be in different states that affect their appearance and behavior:
221///
222/// - Normal items are clickable and render normally
223/// - Greyed items are visually disabled (greyed out) and non-clickable
224/// - Disabled items are non-clickable but retain normal appearance
225#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
226#[repr(C)]
227pub enum MenuItemState {
228    /// Normal menu item (default)
229    Normal,
230    /// Menu item is greyed out and clicking it does nothing
231    Greyed,
232    /// Menu item is disabled, but NOT greyed out
233    Disabled,
234}
235#[allow(variant_size_differences)]
236// repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
237/// Represents a single item in a menu.
238///
239/// Menu items can be regular text items with labels and callbacks,
240/// visual separators, or line breaks for horizontal menu layouts.
241#[derive(Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
242#[repr(C, u8)]
243#[allow(clippy::large_enum_variant)] // #[repr(C,u8)] FFI enum: boxing a variant changes the C ABI/api.json
244pub enum MenuItem {
245    /// A regular menu item with a label, optional icon, callback, and sub-items
246    String(StringMenuItem),
247    /// A visual separator line (only rendered in vertical layouts)
248    Separator,
249    /// Forces a line break when the menu is laid out horizontally
250    BreakLine,
251}
252
253impl_option!(
254    MenuItem,
255    OptionMenuItem,
256    copy = false,
257    [Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord]
258);
259
260impl_vec!(
261    MenuItem,
262    MenuItemVec,
263    MenuItemVecDestructor,
264    MenuItemVecDestructorType,
265    MenuItemVecSlice,
266    OptionMenuItem
267);
268impl_vec_clone!(MenuItem, MenuItemVec, MenuItemVecDestructor);
269impl_vec_debug!(MenuItem, MenuItemVec);
270impl_vec_partialeq!(MenuItem, MenuItemVec);
271impl_vec_partialord!(MenuItem, MenuItemVec);
272impl_vec_hash!(MenuItem, MenuItemVec);
273impl_vec_eq!(MenuItem, MenuItemVec);
274impl_vec_ord!(MenuItem, MenuItemVec);
275
276/// A menu item with a text label and optional features.
277///
278/// `StringMenuItem` represents a clickable menu entry that can have:
279///
280/// - A text label
281/// - An optional keyboard accelerator (e.g., Ctrl+C)
282/// - An optional callback function
283/// - An optional icon (checkbox or image)
284/// - A state (normal, greyed, or disabled)
285/// - Child menu items (for sub-menus)
286#[derive(Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
287#[repr(C)]
288pub struct StringMenuItem {
289    /// Label of the menu
290    /// (ex. "File", "Edit", "View")
291    pub label: AzString,
292    /// Optional accelerator combination
293    /// (ex. "CTRL + X" = [`VirtualKeyCode::Ctrl`, `VirtualKeyCode::X`]) for keyboard shortcut
294    pub accelerator: OptionVirtualKeyCodeCombo,
295    /// Optional callback to call
296    pub callback: OptionCoreMenuCallback,
297    /// State (normal, greyed, disabled)
298    pub menu_item_state: MenuItemState,
299    /// Optional icon for the menu entry
300    pub icon: OptionMenuItemIcon,
301    /// Sub-menus of this item (separators and line-breaks can't have sub-menus)
302    pub children: MenuItemVec,
303}
304
305impl StringMenuItem {
306    /// Creates a new menu item with the given label.
307    /// All optional fields default to `None` / `Normal`.
308    #[must_use]
309    pub const fn create(label: AzString) -> Self {
310        Self {
311            label,
312            accelerator: OptionVirtualKeyCodeCombo::None,
313            callback: OptionCoreMenuCallback::None,
314            menu_item_state: MenuItemState::Normal,
315            icon: OptionMenuItemIcon::None,
316            children: MenuItemVec::from_const_slice(&[]),
317        }
318    }
319
320    /// Sets the child menu items for this item, creating a sub-menu.
321    #[must_use]
322    pub fn with_children(mut self, children: MenuItemVec) -> Self {
323        self.children = children;
324        self
325    }
326
327    /// Adds a single child menu item to this item.
328    #[must_use]
329    pub fn with_child(mut self, child: MenuItem) -> Self {
330        let mut children = self.children.into_library_owned_vec();
331        children.push(child);
332        self.children = children.into();
333        self
334    }
335
336    /// Attaches a callback function to this menu item.
337    ///
338    /// # Parameters
339    ///
340    /// * `data` - User data passed to the callback
341    /// * `callback` - Function pointer (as usize) to invoke when item is clicked
342    ///
343    /// # Note
344    ///
345    /// This uses `CoreCallbackType` (usize) instead of a real function pointer
346    /// to avoid circular dependencies. The conversion happens in azul-layout.
347    #[must_use]
348    pub fn with_callback<I: Into<CoreCallback>>(mut self, data: RefAny, callback: I) -> Self {
349        self.callback = Some(CoreMenuCallback {
350            refany: data,
351            callback: callback.into(),
352        })
353        .into();
354        self
355    }
356}
357
358/// Optional icon displayed next to a menu item.
359///
360/// Icons can be either:
361/// - A checkbox (checked or unchecked)
362/// - A custom image (typically 16x16 pixels)
363#[derive(Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
364#[repr(C, u8)]
365pub enum MenuItemIcon {
366    /// Displays a checkbox, with `true` = checked, `false` = unchecked
367    Checkbox(bool),
368    /// Displays a custom image (typically 16x16 format)
369    Image(ImageRef),
370}
371
372impl_option!(
373    MenuItemIcon,
374    OptionMenuItemIcon,
375    copy = false,
376    [Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord]
377);
378
379// Core menu callback types (usize-based placeholders)
380//
381// Similar to CoreCallback, these use usize instead of function pointers
382// to avoid circular dependencies. Will be converted to real function
383// pointers in azul-layout.
384//
385// IMPORTANT: Memory layout must be identical to the real callback types!
386// Tests for this are in azul-layout/src/callbacks.rs
387
388/// Menu callback using usize placeholder for function pointer.
389///
390/// This type is used in `azul-core` to represent menu item callbacks without
391/// creating circular dependencies with `azul-layout`. The actual function pointer
392/// is stored as a `usize` and converted via unsafe code in `azul-layout`.
393#[derive(Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
394#[repr(C)]
395pub struct CoreMenuCallback {
396    /// User data passed to the callback when the menu item is clicked
397    pub refany: RefAny,
398    /// Callback function pointer stored as usize (converted to real fn pointer in azul-layout)
399    pub callback: CoreCallback,
400}
401
402impl_option!(
403    CoreMenuCallback,
404    OptionCoreMenuCallback,
405    copy = false,
406    [Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord]
407);
408
409#[cfg(test)]
410#[path = "menu_test.rs"]
411mod menu_test;