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