Skip to main content

win_context_menu/
context_menu.rs

1//! Core context-menu builder: query, display, and enumerate shell menus.
2//!
3//! The [`ContextMenu`] builder wraps the Win32 `IContextMenu` / `HMENU`
4//! lifecycle: obtain the interface from `IShellFolder`, call
5//! `QueryContextMenu` to populate an `HMENU`, optionally show it with
6//! `TrackPopupMenu`, and finally invoke or inspect the result.
7
8use windows::Win32::Foundation::HWND;
9use windows::Win32::UI::Shell::Common::ITEMIDLIST;
10use windows::Win32::UI::Shell::{
11    CMF_EXPLORE, CMF_EXTENDEDVERBS, CMF_NORMAL, GCS_VERBA, IContextMenu, IContextMenu2,
12    IContextMenu3,
13};
14use windows::Win32::UI::WindowsAndMessaging::*;
15use windows::core::{Interface, PSTR};
16
17use crate::error::{Error, Result};
18use crate::hidden_window::HiddenWindow;
19use crate::invoke::invoke_command;
20use crate::menu_items::{InvokeParams, MenuItem, SelectedItem};
21use crate::shell_item::ShellItems;
22
23/// First command ID passed to `QueryContextMenu`. IDs in the range
24/// `[ID_FIRST, ID_LAST]` belong to our menu.
25const ID_FIRST: u32 = 1;
26/// Last command ID.
27const ID_LAST: u32 = 0x7FFF;
28
29/// Builder for displaying a Windows Explorer context menu.
30///
31/// Create one via [`ContextMenu::new`], optionally configure it with
32/// [`extended`](ContextMenu::extended) or [`owner`](ContextMenu::owner), then
33/// call [`show`](ContextMenu::show) / [`show_at`](ContextMenu::show_at) to
34/// display the menu, or [`enumerate`](ContextMenu::enumerate) to list items
35/// without showing anything.
36///
37/// # Example
38///
39/// ```no_run
40/// use win_context_menu::{init_com, ContextMenu, ShellItems};
41///
42/// let _com = init_com()?;
43/// let items = ShellItems::from_path(r"C:\Windows\notepad.exe")?;
44/// let selected = ContextMenu::new(items)?.extended(true).show()?;
45/// if let Some(sel) = selected {
46///     sel.execute()?;
47/// }
48/// # Ok::<(), win_context_menu::Error>(())
49/// ```
50pub struct ContextMenu {
51    items: ShellItems,
52    extended: bool,
53    owner_hwnd: Option<isize>,
54}
55
56impl ContextMenu {
57    /// Create a new context menu builder for the given shell items.
58    pub fn new(items: ShellItems) -> Result<Self> {
59        Ok(Self {
60            items,
61            extended: false,
62            owner_hwnd: None,
63        })
64    }
65
66    /// Enable extended verbs (equivalent to Shift+right-click).
67    ///
68    /// Extended menus expose additional items like "Copy as path" or "Open
69    /// PowerShell window here" that are normally hidden.
70    pub fn extended(mut self, yes: bool) -> Self {
71        self.extended = yes;
72        self
73    }
74
75    /// Set an explicit owner window handle (as a raw `isize` / `HWND`).
76    ///
77    /// If not set, a hidden helper window is created automatically. Set this
78    /// when embedding the menu in an existing GUI application (e.g., Electron
79    /// or a native Win32 app) so the menu is owned by your main window.
80    pub fn owner(mut self, hwnd: isize) -> Self {
81        self.owner_hwnd = Some(hwnd);
82        self
83    }
84
85    /// Show the context menu at the specified screen coordinates.
86    ///
87    /// Returns `Ok(Some(item))` if the user selected an item, or `Ok(None)` if
88    /// the menu was dismissed without a selection.
89    pub fn show_at(self, x: i32, y: i32) -> Result<Option<SelectedItem>> {
90        let ctx_menu = self.get_context_menu()?;
91
92        // SAFETY: `CreatePopupMenu` allocates a new empty HMENU. Cannot fail
93        // in practice, but we propagate the error anyway.
94        let hmenu = unsafe { CreatePopupMenu().map_err(Error::Windows)? };
95
96        let flags = self.query_flags();
97        // SAFETY: `ctx_menu` is a valid IContextMenu obtained from the shell.
98        // `hmenu` is a valid empty menu handle. `ID_FIRST`..`ID_LAST` defines
99        // the range of command IDs the shell may assign.
100        unsafe {
101            ctx_menu
102                .QueryContextMenu(hmenu, 0, ID_FIRST, ID_LAST, flags)
103                .map_err(Error::QueryContextMenu)?;
104        }
105
106        // Query for IContextMenu2/3 for owner-drawn submenu support.
107        let ctx2: Option<IContextMenu2> = ctx_menu.cast().ok();
108        let ctx3: Option<IContextMenu3> = ctx_menu.cast().ok();
109
110        let hidden_window = HiddenWindow::new()?;
111        hidden_window.set_context_menu_handlers(ctx2, ctx3);
112
113        let hwnd = if let Some(h) = self.owner_hwnd {
114            HWND(h as *mut _)
115        } else {
116            hidden_window.hwnd
117        };
118
119        // SAFETY: `SetForegroundWindow` with our window handle. Required so
120        // the menu dismisses when the user clicks outside it.
121        unsafe {
122            let _ = SetForegroundWindow(hwnd);
123        }
124
125        // SAFETY: `TrackPopupMenu` shows a modal popup menu. `TPM_RETURNCMD`
126        // means the selected command ID is returned directly instead of being
127        // posted as a message. The return value is 0 if nothing was selected.
128        let cmd = unsafe {
129            TrackPopupMenu(
130                hmenu,
131                TPM_RETURNCMD | TPM_RIGHTBUTTON,
132                x,
133                y,
134                0,
135                hidden_window.hwnd,
136                None,
137            )
138        };
139
140        let selected = if cmd.as_bool() {
141            let command_id = cmd.0 as u32;
142            let item = get_menu_item_info_for_id(&ctx_menu, hmenu, command_id)?;
143            let ctx_menu_clone = ctx_menu.clone();
144            let hwnd_val = hwnd;
145            Some(SelectedItem {
146                menu_item: item,
147                command_id,
148                invoker: Some(Box::new(move |params: Option<InvokeParams>| {
149                    invoke_command(&ctx_menu_clone, command_id - ID_FIRST, hwnd_val, params)
150                })),
151            })
152        } else {
153            None
154        };
155
156        // SAFETY: `hmenu` is a valid menu handle we created above.
157        unsafe {
158            let _ = DestroyMenu(hmenu);
159        }
160
161        Ok(selected)
162    }
163
164    /// Show the context menu at the current cursor position.
165    ///
166    /// Convenience wrapper around [`show_at`](ContextMenu::show_at).
167    pub fn show(self) -> Result<Option<SelectedItem>> {
168        let mut point = windows::Win32::Foundation::POINT::default();
169        // SAFETY: `GetCursorPos` writes the current cursor position into the
170        // provided POINT struct.
171        unsafe {
172            let _ = GetCursorPos(&mut point);
173        }
174        self.show_at(point.x, point.y)
175    }
176
177    /// Enumerate all menu items without showing the menu.
178    ///
179    /// Returns a flat list of [`MenuItem`] structs (submenus are nested inside
180    /// the `submenu` field). Useful for building custom UIs or for testing.
181    pub fn enumerate(&self) -> Result<Vec<MenuItem>> {
182        let ctx_menu = self.get_context_menu_ref()?;
183
184        // SAFETY: `CreatePopupMenu` allocates a new empty HMENU.
185        let hmenu = unsafe { CreatePopupMenu().map_err(Error::Windows)? };
186
187        let flags = self.query_flags();
188        // SAFETY: Same as in `show_at`.
189        unsafe {
190            ctx_menu
191                .QueryContextMenu(hmenu, 0, ID_FIRST, ID_LAST, flags)
192                .map_err(Error::QueryContextMenu)?;
193        }
194
195        let items = enumerate_menu(&ctx_menu, hmenu)?;
196
197        // SAFETY: `hmenu` is a valid menu handle we created above.
198        unsafe {
199            let _ = DestroyMenu(hmenu);
200        }
201
202        Ok(items)
203    }
204
205    fn query_flags(&self) -> u32 {
206        let mut flags = CMF_NORMAL | CMF_EXPLORE;
207        if self.extended {
208            flags |= CMF_EXTENDEDVERBS;
209        }
210        flags
211    }
212
213    fn get_context_menu(&self) -> Result<IContextMenu> {
214        self.get_context_menu_ref()
215    }
216
217    fn get_context_menu_ref(&self) -> Result<IContextMenu> {
218        if self.items.is_background {
219            // Background context menu — ask the folder's IShellFolder for a
220            // view object implementing IContextMenu.
221            // SAFETY: `CreateViewObject` is a COM call on our valid
222            // IShellFolder. A default (null) HWND is acceptable here.
223            unsafe {
224                let menu: IContextMenu = self
225                    .items
226                    .parent
227                    .CreateViewObject(HWND::default())
228                    .map_err(Error::GetContextMenu)?;
229                Ok(menu)
230            }
231        } else {
232            // Item context menu — ask the parent folder for a UI object that
233            // implements IContextMenu for the given child PIDLs.
234            let pidl_ptrs: Vec<*const ITEMIDLIST> =
235                self.items.child_pidls.iter().map(|p| p.as_ptr()).collect();
236
237            // SAFETY: `GetUIObjectOf` is a COM call on our valid IShellFolder.
238            // `pidl_ptrs` contains valid child-relative PIDLs owned by
239            // `self.items.child_pidls`.
240            unsafe {
241                let menu: IContextMenu = self
242                    .items
243                    .parent
244                    .GetUIObjectOf(HWND::default(), &pidl_ptrs, None)
245                    .map_err(Error::GetContextMenu)?;
246                Ok(menu)
247            }
248        }
249    }
250}
251
252/// Walk every item in an `HMENU` and build a `Vec<MenuItem>`.
253fn enumerate_menu(ctx_menu: &IContextMenu, hmenu: HMENU) -> Result<Vec<MenuItem>> {
254    // SAFETY: `GetMenuItemCount` with a valid HMENU.
255    let count = unsafe { GetMenuItemCount(hmenu) };
256    if count < 0 {
257        return Ok(Vec::new());
258    }
259
260    let mut items = Vec::new();
261
262    for i in 0..count {
263        let mut mii = MENUITEMINFOW {
264            cbSize: std::mem::size_of::<MENUITEMINFOW>() as u32,
265            fMask: MIIM_ID | MIIM_FTYPE | MIIM_STATE | MIIM_SUBMENU | MIIM_STRING,
266            ..Default::default()
267        };
268
269        // First call: get the required buffer size for the label string.
270        // SAFETY: `GetMenuItemInfoW` with `fByPosition = true` reads info
271        // about the i-th item. With `cch = 0` it just returns the string
272        // length in `mii.cch`.
273        unsafe {
274            let _ = GetMenuItemInfoW(hmenu, i as u32, true, &mut mii);
275        }
276
277        if mii.fType.contains(MFT_SEPARATOR) {
278            items.push(MenuItem::separator());
279            continue;
280        }
281
282        // Second call: actually read the label text.
283        let mut label_buf = vec![0u16; (mii.cch + 1) as usize];
284        mii.dwTypeData = windows::core::PWSTR(label_buf.as_mut_ptr());
285        mii.cch += 1;
286        // SAFETY: `label_buf` is large enough (cch + 1 wide chars).
287        unsafe {
288            let _ = GetMenuItemInfoW(hmenu, i as u32, true, &mut mii);
289        }
290
291        let label = String::from_utf16_lossy(&label_buf[..mii.cch as usize])
292            .replace('&', "");
293
294        let id = mii.wID;
295
296        let command_string = if (ID_FIRST..=ID_LAST).contains(&id) {
297            get_verb(ctx_menu, id - ID_FIRST)
298        } else {
299            None
300        };
301
302        let submenu = if !mii.hSubMenu.is_invalid() {
303            Some(enumerate_menu(ctx_menu, mii.hSubMenu)?)
304        } else {
305            None
306        };
307
308        items.push(MenuItem {
309            id,
310            label,
311            command_string,
312            is_separator: false,
313            is_disabled: mii.fState.contains(MFS_DISABLED),
314            is_checked: mii.fState.contains(MFS_CHECKED),
315            is_default: mii.fState.contains(MFS_DEFAULT),
316            submenu,
317        });
318    }
319
320    Ok(items)
321}
322
323/// Try to get the ANSI verb string for a command at the given offset.
324fn get_verb(ctx_menu: &IContextMenu, offset: u32) -> Option<String> {
325    let mut buf = [0u8; 256];
326    // SAFETY: `GetCommandString` with `GCS_VERBA` writes an ANSI string
327    // into `buf`. We provide `buf.len()` as the maximum size. The shell
328    // handler returns `S_OK` if a verb is available, otherwise an error.
329    unsafe {
330        ctx_menu
331            .GetCommandString(
332                offset as usize,
333                GCS_VERBA,
334                None,
335                PSTR(buf.as_mut_ptr()),
336                buf.len() as u32,
337            )
338            .ok()?;
339    }
340    let s = crate::util::ansi_buf_to_string(&buf);
341    if s.is_empty() {
342        None
343    } else {
344        Some(s)
345    }
346}
347
348/// Retrieve `MenuItem` metadata for a specific command ID in the menu.
349fn get_menu_item_info_for_id(
350    ctx_menu: &IContextMenu,
351    hmenu: HMENU,
352    command_id: u32,
353) -> Result<MenuItem> {
354    let mut mii = MENUITEMINFOW {
355        cbSize: std::mem::size_of::<MENUITEMINFOW>() as u32,
356        fMask: MIIM_ID | MIIM_FTYPE | MIIM_STATE | MIIM_STRING,
357        ..Default::default()
358    };
359
360    // First pass: get the string length.
361    // SAFETY: `GetMenuItemInfoW` with `fByPosition = false` looks up by
362    // command ID.
363    unsafe {
364        GetMenuItemInfoW(hmenu, command_id, false, &mut mii).map_err(Error::GetMenuItemInfo)?;
365    }
366
367    // Second pass: read the actual string.
368    let mut label_buf = vec![0u16; (mii.cch + 1) as usize];
369    mii.dwTypeData = windows::core::PWSTR(label_buf.as_mut_ptr());
370    mii.cch += 1;
371    // SAFETY: `label_buf` is large enough.
372    unsafe {
373        let _ = GetMenuItemInfoW(hmenu, command_id, false, &mut mii);
374    }
375
376    let label =
377        String::from_utf16_lossy(&label_buf[..mii.cch as usize]).replace('&', "");
378
379    let command_string = if command_id >= ID_FIRST {
380        get_verb(ctx_menu, command_id - ID_FIRST)
381    } else {
382        None
383    };
384
385    Ok(MenuItem {
386        id: command_id,
387        label,
388        command_string,
389        is_separator: false,
390        is_disabled: mii.fState.contains(MFS_DISABLED),
391        is_checked: mii.fState.contains(MFS_CHECKED),
392        is_default: mii.fState.contains(MFS_DEFAULT),
393        submenu: None,
394    })
395}