winapi_easy/ui/
menu.rs

1//! Menus and menu items.
2
3use std::cell::RefCell;
4use std::io::ErrorKind;
5use std::marker::PhantomData;
6use std::rc::Rc;
7use std::{
8    io,
9    mem,
10};
11
12pub(crate) use private::MenuHandle;
13use windows::Win32::UI::WindowsAndMessaging::{
14    CreateMenu,
15    CreatePopupMenu,
16    DestroyMenu,
17    GetMenuItemCount,
18    GetMenuItemID,
19    HMENU,
20    InsertMenuItemW,
21    IsMenu,
22    MENUINFO,
23    MENUITEMINFOW,
24    MF_BYPOSITION,
25    MFS_CHECKED,
26    MFS_DISABLED,
27    MFT_RADIOCHECK,
28    MFT_SEPARATOR,
29    MFT_STRING,
30    MIIM_FTYPE,
31    MIIM_ID,
32    MIIM_STATE,
33    MIIM_STRING,
34    MIIM_SUBMENU,
35    MIM_STYLE,
36    MNS_NOTIFYBYPOS,
37    RemoveMenu,
38    SetMenuInfo,
39    SetMenuItemInfoW,
40    TrackPopupMenu,
41};
42
43#[expect(clippy::wildcard_imports)]
44use self::private::*;
45use crate::internal::{
46    ResultExt,
47    ReturnValue,
48};
49use crate::string::ZeroTerminatedWideString;
50use crate::ui::{
51    Point,
52    WindowHandle,
53};
54
55mod private {
56    #[expect(clippy::wildcard_imports)]
57    use super::*;
58
59    #[cfg(test)]
60    static_assertions::assert_not_impl_any!(MenuHandle: Send, Sync);
61
62    #[derive(Eq, PartialEq, Debug)]
63    pub struct MenuHandle {
64        pub(super) raw_handle: HMENU,
65        pub(super) marker: PhantomData<*mut ()>,
66    }
67
68    pub trait MenuKindPrivate {
69        type MenuItem: MenuItemKind;
70        fn new_handle() -> io::Result<MenuHandle>;
71    }
72
73    pub trait MenuItemKind: Clone {
74        fn call_with_raw_menu_info<O>(&self, call: impl FnOnce(MENUITEMINFOW) -> O) -> O;
75    }
76}
77
78impl MenuHandle {
79    fn new_menu() -> io::Result<Self> {
80        let handle = unsafe { CreateMenu()?.if_null_get_last_error()? };
81        let result = Self {
82            raw_handle: handle,
83            marker: PhantomData,
84        };
85        result.set_notify_by_pos()?;
86        Ok(result)
87    }
88
89    fn new_submenu() -> io::Result<Self> {
90        let handle = unsafe { CreatePopupMenu()?.if_null_get_last_error()? };
91        let result = Self {
92            raw_handle: handle,
93            marker: PhantomData,
94        };
95        result.set_notify_by_pos()?;
96        Ok(result)
97    }
98
99    #[expect(dead_code)]
100    pub(crate) fn from_non_null(raw_handle: HMENU) -> Self {
101        Self {
102            raw_handle,
103            marker: PhantomData,
104        }
105    }
106
107    pub(crate) fn from_maybe_null(handle: HMENU) -> Option<Self> {
108        if handle.is_null() {
109            None
110        } else {
111            Some(Self {
112                raw_handle: handle,
113                marker: PhantomData,
114            })
115        }
116    }
117
118    pub(crate) fn as_raw_handle(&self) -> HMENU {
119        self.raw_handle
120    }
121
122    /// Sets the menu to send `WM_MENUCOMMAND` instead of `WM_COMMAND` messages.
123    ///
124    /// According to docs: This is a menu header style and has no effect when applied to individual sub menus.
125    fn set_notify_by_pos(&self) -> io::Result<()> {
126        let raw_menu_info = MENUINFO {
127            cbSize: mem::size_of::<MENUINFO>()
128                .try_into()
129                .unwrap_or_else(|_| unreachable!()),
130            fMask: MIM_STYLE,
131            dwStyle: MNS_NOTIFYBYPOS,
132            cyMax: 0,
133            hbrBack: Default::default(),
134            dwContextHelpID: 0,
135            dwMenuData: 0,
136        };
137        unsafe {
138            SetMenuInfo(self.raw_handle, &raw const raw_menu_info)?;
139        }
140        Ok(())
141    }
142
143    fn insert_menu_item<MI: MenuItemKind>(&self, item: &MI, idx: u32) -> io::Result<()> {
144        let insert_call = |raw_item_info| {
145            unsafe {
146                InsertMenuItemW(self.raw_handle, idx, true, &raw const raw_item_info)?;
147            }
148            Ok(())
149        };
150        item.call_with_raw_menu_info(insert_call)
151    }
152
153    fn modify_menu_item<MI: MenuItemKind>(&self, item: &MI, idx: u32) -> io::Result<()> {
154        let insert_call = |raw_item_info| {
155            unsafe {
156                SetMenuItemInfoW(self.raw_handle, idx, true, &raw const raw_item_info)?;
157            }
158            Ok(())
159        };
160        item.call_with_raw_menu_info(insert_call)
161    }
162
163    /// Removes an item.
164    ///
165    /// If the item contains a submenu, the submenu itself is preserved.
166    fn remove_item(&self, idx: u32) -> io::Result<()> {
167        unsafe {
168            RemoveMenu(self.raw_handle, idx, MF_BYPOSITION)?;
169        }
170        Ok(())
171    }
172
173    pub(crate) fn get_item_id(&self, item_idx: u32) -> io::Result<u32> {
174        let id = unsafe { GetMenuItemID(self.raw_handle, item_idx.cast_signed()) };
175        id.if_eq_to_error((-1i32).cast_unsigned(), || ErrorKind::Other.into())?;
176        Ok(id)
177    }
178
179    fn get_item_count(&self) -> io::Result<i32> {
180        let count = unsafe { GetMenuItemCount(Some(self.raw_handle)) };
181        count.if_eq_to_error(-1, io::Error::last_os_error)?;
182        Ok(count)
183    }
184
185    #[expect(dead_code)]
186    fn is_menu(&self) -> bool {
187        unsafe { IsMenu(self.raw_handle).as_bool() }
188    }
189
190    fn destroy(&self) -> io::Result<()> {
191        unsafe {
192            DestroyMenu(self.raw_handle)?;
193        }
194        Ok(())
195    }
196}
197
198impl From<MenuHandle> for HMENU {
199    fn from(value: MenuHandle) -> Self {
200        value.raw_handle
201    }
202}
203
204impl From<&MenuHandle> for HMENU {
205    fn from(value: &MenuHandle) -> Self {
206        value.raw_handle
207    }
208}
209
210pub trait MenuKind: MenuKindPrivate {}
211
212#[derive(Debug)]
213pub enum MenuBarKind {}
214
215impl MenuKindPrivate for MenuBarKind {
216    type MenuItem = TextMenuItem;
217
218    fn new_handle() -> io::Result<MenuHandle> {
219        MenuHandle::new_menu()
220    }
221}
222
223impl MenuKind for MenuBarKind {}
224
225#[derive(Debug)]
226pub enum SubMenuKind {}
227
228impl MenuKindPrivate for SubMenuKind {
229    type MenuItem = SubMenuItem;
230
231    fn new_handle() -> io::Result<MenuHandle> {
232        MenuHandle::new_submenu()
233    }
234}
235
236impl MenuKind for SubMenuKind {}
237
238#[cfg(test)]
239static_assertions::assert_not_impl_any!(Menu<MenuBarKind>: Send, Sync);
240#[cfg(test)]
241static_assertions::assert_not_impl_any!(Menu<SubMenuKind>: Send, Sync);
242
243/// Generic menu (top-level or submenu).
244#[derive(Debug)]
245pub struct Menu<MK: MenuKind> {
246    handle: MenuHandle,
247    items: Vec<MK::MenuItem>,
248}
249
250impl<MK: MenuKind> Menu<MK> {
251    pub fn new() -> io::Result<Self> {
252        Ok(Self {
253            handle: MK::new_handle()?,
254            items: Vec::new(),
255        })
256    }
257
258    pub fn new_from_items<I>(items: I) -> io::Result<Self>
259    where
260        I: IntoIterator<Item = MK::MenuItem>,
261    {
262        let mut result = Self::new()?;
263        result.insert_menu_items(items)?;
264        Ok(result)
265    }
266
267    pub fn as_handle(&self) -> &MenuHandle {
268        &self.handle
269    }
270
271    /// Inserts a menu item before the item with the given index.
272    ///
273    /// If no index is given, it will be inserted after the last item.
274    ///
275    /// # Panics
276    ///
277    /// Will panic if the given index is greater than the current amount of items.
278    pub fn insert_menu_item(&mut self, item: MK::MenuItem, index: Option<u32>) -> io::Result<()> {
279        let handle_item_count: u32 = self
280            .handle
281            .get_item_count()?
282            .try_into()
283            .unwrap_or_else(|_| unreachable!());
284        assert_eq!(handle_item_count, self.items.len().try_into().unwrap());
285        let idx = match index {
286            Some(idx) => idx,
287            None => handle_item_count,
288        };
289        self.handle.insert_menu_item(&item, idx)?;
290        self.items.insert(idx.try_into().unwrap(), item);
291        Ok(())
292    }
293
294    pub fn insert_menu_items<I>(&mut self, items: I) -> io::Result<()>
295    where
296        I: IntoIterator<Item = MK::MenuItem>,
297    {
298        for item in items {
299            self.insert_menu_item(item, None)?;
300        }
301        Ok(())
302    }
303
304    /// Modifies a menu item at the given index using the given closure.
305    ///
306    /// # Panics
307    ///
308    /// Will panic if the given index is out of bounds.
309    pub fn modify_menu_item_by_index(
310        &mut self,
311        index: u32,
312        modify_fn: impl FnOnce(&mut MK::MenuItem) -> io::Result<()>,
313    ) -> io::Result<()> {
314        let item = &mut self.items[usize::try_from(index).unwrap()];
315        let mut modified_item = item.clone();
316        modify_fn(&mut modified_item)?;
317        self.handle.modify_menu_item(&modified_item, index)?;
318        *item = modified_item;
319        Ok(())
320    }
321
322    /// Removes a menu item.
323    ///
324    /// # Panics
325    ///
326    /// Will panic if the given index is out of bounds.
327    pub fn remove_menu_item(&mut self, index: u32) -> io::Result<()> {
328        let index_usize = usize::try_from(index).unwrap();
329        assert!(index_usize < self.items.len());
330        self.handle.remove_item(index)?;
331        let _ = self.items.remove(index_usize);
332        Ok(())
333    }
334}
335
336impl Menu<SubMenuKind> {
337    /// Modifies all text menu items with the given ID using the given closure.
338    ///
339    /// Will do nothing if no item with a matching ID is found.
340    pub fn modify_text_menu_items_by_id(
341        &mut self,
342        id: u32,
343        mut modify_fn: impl FnMut(&mut TextMenuItem) -> io::Result<()>,
344    ) -> io::Result<()> {
345        let indexes: Vec<_> = (0..)
346            .zip(&self.items)
347            .filter_map(|(index, item)| match item {
348                SubMenuItem::Text(text_menu_item) => {
349                    if text_menu_item.id == id {
350                        Some(index)
351                    } else {
352                        None
353                    }
354                }
355                SubMenuItem::Separator => None,
356            })
357            .collect();
358        let mut internal_modify_fn = |item: &mut SubMenuItem| {
359            if let SubMenuItem::Text(item) = item {
360                modify_fn(item)?;
361            } else {
362                unreachable!()
363            }
364            Ok(())
365        };
366        for index in indexes {
367            self.modify_menu_item_by_index(index, &mut internal_modify_fn)?;
368        }
369        Ok(())
370    }
371
372    /// Shows the popup menu at the given coordinates.
373    ///
374    /// The coordinates can for example be retrieved from the window message handler, see
375    /// [`crate::ui::messaging::ListenerMessageVariant::NotificationIconContextSelect`]
376    ///
377    /// The given window needs to be the foreground window for the menu to show
378    /// (use [`WindowHandle::set_as_foreground`]).
379    pub fn show_menu(&self, window: WindowHandle, coords: Point) -> io::Result<()> {
380        unsafe {
381            TrackPopupMenu(
382                self.handle.raw_handle,
383                Default::default(),
384                coords.x,
385                coords.y,
386                None,
387                window.into(),
388                None,
389            )
390            .if_null_get_last_error_else_drop()?;
391        }
392        Ok(())
393    }
394}
395
396impl<MK: MenuKind> Drop for Menu<MK> {
397    fn drop(&mut self) {
398        let size_u32 = u32::try_from(self.items.len()).unwrap();
399        // Remove all items first to avoid submenus getting destroyed by `DestroyMenu`
400        for index in (0..size_u32).rev() {
401            self.remove_menu_item(index)
402                .unwrap_or_default_and_print_error();
403        }
404        self.handle.destroy().unwrap_or_default_and_print_error();
405    }
406}
407
408/// A top-level menu.
409///
410/// Can be added to a window with [`crate::ui::window::Window::set_menu`].
411pub type MenuBar = Menu<MenuBarKind>;
412
413/// A submenu or popup menu.
414///
415/// Can for example be used with [`crate::ui::window::NotificationIcon`].
416pub type SubMenu = Menu<SubMenuKind>;
417
418/// A submenu item.
419///
420/// Can be added with [`SubMenu::insert_menu_item`].
421#[derive(Clone, Debug)]
422pub enum SubMenuItem {
423    Text(TextMenuItem),
424    Separator,
425}
426
427impl MenuItemKind for SubMenuItem {
428    fn call_with_raw_menu_info<O>(&self, call: impl FnOnce(MENUITEMINFOW) -> O) -> O {
429        match self {
430            SubMenuItem::Text(text_item) => text_item.call_with_raw_menu_info(call),
431            SubMenuItem::Separator => {
432                let mut item_info = default_raw_item_info();
433                item_info.fMask |= MIIM_FTYPE;
434                item_info.fType |= MFT_SEPARATOR;
435                call(item_info)
436            }
437        }
438    }
439}
440
441#[derive(Clone, Default, Debug)]
442pub struct TextMenuItem {
443    pub id: u32,
444    pub text: String,
445    pub disabled: bool,
446    pub item_symbol: Option<ItemSymbol>,
447    pub sub_menu: Option<Rc<RefCell<SubMenu>>>,
448}
449
450impl TextMenuItem {
451    pub fn default_with_text(id: u32, text: impl Into<String>) -> Self {
452        Self {
453            id,
454            text: text.into(),
455            disabled: false,
456            item_symbol: None,
457            sub_menu: None,
458        }
459    }
460}
461
462impl MenuItemKind for TextMenuItem {
463    fn call_with_raw_menu_info<O>(&self, call: impl FnOnce(MENUITEMINFOW) -> O) -> O {
464        // Must outlive the `MENUITEMINFOW` struct
465        let mut text_wide_string = ZeroTerminatedWideString::from_os_str(&self.text);
466        let mut item_info = default_raw_item_info();
467        item_info.fMask |= MIIM_FTYPE | MIIM_STATE | MIIM_ID | MIIM_SUBMENU | MIIM_STRING;
468        item_info.fType |= MFT_STRING;
469        item_info.cch = text_wide_string.as_ref().len().try_into().unwrap();
470        item_info.dwTypeData = text_wide_string.as_raw_pwstr();
471        if self.disabled {
472            item_info.fState |= MFS_DISABLED;
473        }
474        if let Some(checkmark) = self.item_symbol {
475            item_info.fState |= MFS_CHECKED;
476            match checkmark {
477                ItemSymbol::CheckMark => (),
478                ItemSymbol::RadioButton => item_info.fType |= MFT_RADIOCHECK,
479            }
480        }
481        // `MFS_HILITE` highlights an item as if selected, but only once, and has no further effects, so we skip it.
482
483        item_info.wID = self.id;
484        if let Some(submenu) = &self.sub_menu {
485            item_info.hSubMenu = submenu.borrow().handle.raw_handle;
486        }
487        call(item_info)
488    }
489}
490
491#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
492pub enum ItemSymbol {
493    #[default]
494    CheckMark,
495    RadioButton,
496}
497
498fn default_raw_item_info() -> MENUITEMINFOW {
499    MENUITEMINFOW {
500        cbSize: mem::size_of::<MENUITEMINFOW>()
501            .try_into()
502            .unwrap_or_else(|_| unreachable!()),
503        ..Default::default()
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn create_test_menu() -> io::Result<()> {
513        let mut menu = SubMenu::new()?;
514        const TEST_ID: u32 = 42;
515        const TEST_ID2: u32 = 43;
516        menu.insert_menu_items([
517            SubMenuItem::Text(TextMenuItem::default_with_text(TEST_ID, "text")),
518            SubMenuItem::Separator,
519        ])?;
520        menu.modify_menu_item_by_index(0, |item| {
521            if let SubMenuItem::Text(item) = item {
522                item.disabled = true;
523                Ok(())
524            } else {
525                panic!()
526            }
527        })?;
528        menu.modify_menu_item_by_index(1, |item| {
529            *item = SubMenuItem::Text(TextMenuItem::default_with_text(TEST_ID2, "text2"));
530            Ok(())
531        })?;
532        let submenu2: Rc<RefCell<_>> = {
533            let submenu2 = SubMenu::new_from_items([SubMenuItem::Separator])?;
534            Rc::new(RefCell::new(submenu2))
535        };
536        {
537            let mut menu2 = SubMenu::new()?;
538            menu2.insert_menu_item(
539                SubMenuItem::Text(TextMenuItem {
540                    sub_menu: Some(submenu2.clone()),
541                    ..TextMenuItem::default_with_text(0, "")
542                }),
543                None,
544            )?;
545        }
546        menu.insert_menu_item(
547            SubMenuItem::Text(TextMenuItem {
548                sub_menu: Some(submenu2),
549                ..TextMenuItem::default_with_text(0, "Submenu")
550            }),
551            None,
552        )?;
553        assert_eq!(menu.handle.get_item_count()?, 3);
554        assert_eq!(menu.handle.get_item_id(0)?, TEST_ID);
555        assert_eq!(menu.handle.get_item_id(1)?, TEST_ID2);
556
557        let menu = Rc::new(RefCell::new(menu));
558        let menu_bar = MenuBar::new_from_items([TextMenuItem {
559            sub_menu: Some(menu),
560            ..TextMenuItem::default_with_text(0, "File")
561        }])?;
562        assert_eq!(menu_bar.handle.get_item_count()?, 1);
563
564        Ok(())
565    }
566}