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                    _hidden_window: Some(hidden_window),
175                })
176            } else {
177                let item = get_menu_item_info_for_id(&ctx_menu, hmenu, command_id)?;
178                let ctx_menu_clone = ctx_menu.clone();
179                let hwnd_val = hwnd;
180                Some(SelectedItem {
181                    menu_item: item,
182                    command_id,
183                    invoker: Some(Box::new(move |params: Option<InvokeParams>| {
184                        invoke_command(&ctx_menu_clone, command_id - ID_FIRST, hwnd_val, params)
185                    })),
186                    _hidden_window: Some(hidden_window),
187                })
188            }
189        } else {
190            None
191        };
192
193        // SAFETY: `hmenu` is a valid menu handle we created above.
194        unsafe {
195            let _ = DestroyMenu(hmenu);
196        }
197
198        Ok(selected)
199    }
200
201    /// Show the context menu at the current cursor position.
202    ///
203    /// Convenience wrapper around [`show_at`](ContextMenu::show_at).
204    pub fn show(self) -> Result<Option<SelectedItem>> {
205        let mut point = windows::Win32::Foundation::POINT::default();
206        // SAFETY: `GetCursorPos` writes the current cursor position into the
207        // provided POINT struct.
208        unsafe {
209            let _ = GetCursorPos(&mut point);
210        }
211        self.show_at(point.x, point.y)
212    }
213
214    /// Enumerate all menu items without showing the menu.
215    ///
216    /// Returns a flat list of [`MenuItem`] structs (submenus are nested inside
217    /// the `submenu` field). Useful for building custom UIs or for testing.
218    pub fn enumerate(&self) -> Result<Vec<MenuItem>> {
219        let hidden_window = HiddenWindow::new()?;
220        let ctx_menu = self.get_context_menu_with_hwnd(hidden_window.hwnd)?;
221
222        // SAFETY: `CreatePopupMenu` allocates a new empty HMENU.
223        let hmenu = unsafe { CreatePopupMenu().map_err(Error::Windows)? };
224
225        let flags = self.query_flags();
226        // SAFETY: Same as in `show_at`.
227        // SAFETY: Same as in `show_at`.
228        unsafe {
229            ctx_menu
230                .QueryContextMenu(hmenu, 0, ID_FIRST, ID_LAST, flags)
231                .map_err(Error::QueryContextMenu)?;
232        }
233
234        // For background menus, inject clipboard-related items (Paste).
235        if self.items.is_background {
236            inject_clipboard_items(hmenu);
237        }
238
239        let items = enumerate_menu(&ctx_menu, hmenu)?;
240
241        // SAFETY: `hmenu` is a valid menu handle we created above.
242        unsafe {
243            let _ = DestroyMenu(hmenu);
244        }
245
246        Ok(items)
247    }
248
249    /// Invoke a shell verb directly without showing the menu.
250    ///
251    /// This is useful for programmatically executing commands like "copy", "cut",
252    /// or "paste" in response to keyboard shortcuts.
253    ///
254    /// # Example
255    ///
256    /// ```no_run
257    /// use win_context_menu::{init_com, ContextMenu, ShellItems};
258    ///
259    /// let _com = init_com()?;
260    /// let items = ShellItems::from_path(r"C:\some\file.txt")?;
261    /// ContextMenu::new(items)?.invoke_verb("copy")?;
262    /// # Ok::<(), win_context_menu::Error>(())
263    /// ```
264    pub fn invoke_verb(&self, verb: &str) -> Result<()> {
265        let hidden_window = HiddenWindow::new()?;
266        let hwnd = if let Some(h) = self.owner_hwnd {
267            HWND(h as *mut _)
268        } else {
269            hidden_window.hwnd
270        };
271
272        let ctx_menu = self.get_context_menu_with_hwnd(hwnd)?;
273
274        // QueryContextMenu is required before InvokeCommand — the shell handler
275        // needs it to initialise internal state even when we don't show a menu.
276        let hmenu = unsafe { CreatePopupMenu().map_err(Error::Windows)? };
277        let flags = self.query_flags();
278        unsafe {
279            ctx_menu
280                .QueryContextMenu(hmenu, 0, ID_FIRST, ID_LAST, flags)
281                .map_err(Error::QueryContextMenu)?;
282        }
283
284        let result = crate::invoke::invoke_command_by_verb(&ctx_menu, verb, hwnd);
285
286        unsafe {
287            let _ = DestroyMenu(hmenu);
288        }
289
290        result
291    }
292
293    fn query_flags(&self) -> u32 {
294        let mut flags = CMF_NORMAL;
295        if !self.items.is_background {
296            flags |= CMF_EXPLORE;
297        }
298        if self.extended {
299            flags |= CMF_EXTENDEDVERBS;
300        }
301        flags
302    }
303
304    fn get_context_menu_with_hwnd(&self, hwnd: HWND) -> Result<IContextMenu> {
305        if self.items.is_background {
306            // Background context menu — ask the folder's IShellFolder for the
307            // background menu via CreateViewObject.
308            // SAFETY: `CreateViewObject` is a COM call on our valid IShellFolder.
309            unsafe {
310                let menu: IContextMenu = self
311                    .items
312                    .parent
313                    .CreateViewObject(hwnd)
314                    .map_err(Error::GetContextMenu)?;
315                Ok(menu)
316            }
317        } else {
318            // Item context menu — ask the parent folder for a UI object that
319            // implements IContextMenu for the given child PIDLs.
320            let pidl_ptrs: Vec<*const ITEMIDLIST> =
321                self.items.child_pidls.iter().map(|p| p.as_ptr()).collect();
322
323            // SAFETY: `GetUIObjectOf` is a COM call on our valid IShellFolder.
324            // `pidl_ptrs` contains valid child-relative PIDLs owned by
325            // `self.items.child_pidls`.
326            unsafe {
327                let menu: IContextMenu = self
328                    .items
329                    .parent
330                    .GetUIObjectOf(hwnd, &pidl_ptrs, None)
331                    .map_err(Error::GetContextMenu)?;
332                Ok(menu)
333            }
334        }
335    }
336}
337
338/// Walk every item in an `HMENU` and build a `Vec<MenuItem>`.
339fn enumerate_menu(ctx_menu: &IContextMenu, hmenu: HMENU) -> Result<Vec<MenuItem>> {
340    // SAFETY: `GetMenuItemCount` with a valid HMENU.
341    let count = unsafe { GetMenuItemCount(hmenu) };
342    if count < 0 {
343        return Ok(Vec::new());
344    }
345
346    let mut items = Vec::new();
347
348    for i in 0..count {
349        let mut mii = MENUITEMINFOW {
350            cbSize: std::mem::size_of::<MENUITEMINFOW>() as u32,
351            fMask: MIIM_ID | MIIM_FTYPE | MIIM_STATE | MIIM_SUBMENU | MIIM_STRING,
352            ..Default::default()
353        };
354
355        // First call: get the required buffer size for the label string.
356        // SAFETY: `GetMenuItemInfoW` with `fByPosition = true` reads info
357        // about the i-th item. With `cch = 0` it just returns the string
358        // length in `mii.cch`.
359        unsafe {
360            let _ = GetMenuItemInfoW(hmenu, i as u32, true, &mut mii);
361        }
362
363        if mii.fType.contains(MFT_SEPARATOR) {
364            items.push(MenuItem::separator());
365            continue;
366        }
367
368        // Second call: actually read the label text.
369        let mut label_buf = vec![0u16; (mii.cch + 1) as usize];
370        mii.dwTypeData = windows::core::PWSTR(label_buf.as_mut_ptr());
371        mii.cch += 1;
372        // SAFETY: `label_buf` is large enough (cch + 1 wide chars).
373        unsafe {
374            let _ = GetMenuItemInfoW(hmenu, i as u32, true, &mut mii);
375        }
376
377        let label = String::from_utf16_lossy(&label_buf[..mii.cch as usize])
378            .replace('&', "");
379
380        let id = mii.wID;
381
382        let command_string = if (ID_FIRST..=ID_LAST).contains(&id) {
383            get_verb(ctx_menu, id - ID_FIRST)
384        } else {
385            None
386        };
387
388        let submenu = if !mii.hSubMenu.is_invalid() {
389            Some(enumerate_menu(ctx_menu, mii.hSubMenu)?)
390        } else {
391            None
392        };
393
394        items.push(MenuItem {
395            id,
396            label,
397            command_string,
398            is_separator: false,
399            is_disabled: mii.fState.contains(MFS_DISABLED),
400            is_checked: mii.fState.contains(MFS_CHECKED),
401            is_default: mii.fState.contains(MFS_DEFAULT),
402            submenu,
403        });
404    }
405
406    Ok(items)
407}
408
409/// Try to get the ANSI verb string for a command at the given offset.
410fn get_verb(ctx_menu: &IContextMenu, offset: u32) -> Option<String> {
411    let mut buf = [0u8; 256];
412    // SAFETY: `GetCommandString` with `GCS_VERBA` writes an ANSI string
413    // into `buf`. We provide `buf.len()` as the maximum size. The shell
414    // handler returns `S_OK` if a verb is available, otherwise an error.
415    unsafe {
416        ctx_menu
417            .GetCommandString(
418                offset as usize,
419                GCS_VERBA,
420                None,
421                PSTR(buf.as_mut_ptr()),
422                buf.len() as u32,
423            )
424            .ok()?;
425    }
426    let s = crate::util::ansi_buf_to_string(&buf);
427    if s.is_empty() {
428        None
429    } else {
430        Some(s)
431    }
432}
433
434/// Retrieve `MenuItem` metadata for a specific command ID in the menu.
435fn get_menu_item_info_for_id(
436    ctx_menu: &IContextMenu,
437    hmenu: HMENU,
438    command_id: u32,
439) -> Result<MenuItem> {
440    let mut mii = MENUITEMINFOW {
441        cbSize: std::mem::size_of::<MENUITEMINFOW>() as u32,
442        fMask: MIIM_ID | MIIM_FTYPE | MIIM_STATE | MIIM_STRING,
443        ..Default::default()
444    };
445
446    // First pass: get the string length.
447    // SAFETY: `GetMenuItemInfoW` with `fByPosition = false` looks up by
448    // command ID.
449    unsafe {
450        GetMenuItemInfoW(hmenu, command_id, false, &mut mii).map_err(Error::GetMenuItemInfo)?;
451    }
452
453    // Second pass: read the actual string.
454    let mut label_buf = vec![0u16; (mii.cch + 1) as usize];
455    mii.dwTypeData = windows::core::PWSTR(label_buf.as_mut_ptr());
456    mii.cch += 1;
457    // SAFETY: `label_buf` is large enough.
458    unsafe {
459        let _ = GetMenuItemInfoW(hmenu, command_id, false, &mut mii);
460    }
461
462    let label =
463        String::from_utf16_lossy(&label_buf[..mii.cch as usize]).replace('&', "");
464
465    let command_string = if command_id >= ID_FIRST {
466        get_verb(ctx_menu, command_id - ID_FIRST)
467    } else {
468        None
469    };
470
471    Ok(MenuItem {
472        id: command_id,
473        label,
474        command_string,
475        is_separator: false,
476        is_disabled: mii.fState.contains(MFS_DISABLED),
477        is_checked: mii.fState.contains(MFS_CHECKED),
478        is_default: mii.fState.contains(MFS_DEFAULT),
479        submenu: None,
480    })
481}
482
483/// Check if the clipboard has file data and inject "Paste" at the top of the menu.
484fn inject_clipboard_items(hmenu: HMENU) {
485    let has_files = clipboard_has_files();
486
487    if has_files {
488        // Insert a separator + "Paste" at position 0 (top of menu)
489        let paste_label: Vec<u16> = "貼り付け(V)\0".encode_utf16().collect();
490        let mii = MENUITEMINFOW {
491            cbSize: std::mem::size_of::<MENUITEMINFOW>() as u32,
492            fMask: MIIM_ID | MIIM_STRING | MIIM_FTYPE,
493            fType: MFT_STRING,
494            wID: ID_PASTE_INJECTED,
495            dwTypeData: windows::core::PWSTR(paste_label.as_ptr() as *mut _),
496            cch: paste_label.len() as u32 - 1,
497            ..Default::default()
498        };
499        // SAFETY: `hmenu` is a valid menu handle. We insert at position 0.
500        unsafe {
501            let _ = InsertMenuItemW(hmenu, 0, true, &mii);
502        }
503
504        // Add separator after Paste
505        let sep = MENUITEMINFOW {
506            cbSize: std::mem::size_of::<MENUITEMINFOW>() as u32,
507            fMask: MIIM_FTYPE,
508            fType: MFT_SEPARATOR,
509            ..Default::default()
510        };
511        // SAFETY: Insert separator at position 1 (after Paste).
512        unsafe {
513            let _ = InsertMenuItemW(hmenu, 1, true, &sep);
514        }
515    }
516}
517
518/// Check if the system clipboard contains file data (CF_HDROP).
519fn clipboard_has_files() -> bool {
520    // SAFETY: `OleGetClipboard` retrieves the current OLE clipboard data object.
521    let data_obj = unsafe { OleGetClipboard() };
522    let data_obj = match data_obj {
523        Ok(d) => d,
524        Err(_) => return false,
525    };
526
527    let fmt = FORMATETC {
528        cfFormat: CF_HDROP,
529        ptd: std::ptr::null_mut(),
530        dwAspect: 1, // DVASPECT_CONTENT
531        lindex: -1,
532        tymed: 1, // TYMED_HGLOBAL
533    };
534
535    // SAFETY: `QueryGetData` checks if the data object supports the given format.
536    let result = unsafe { data_obj.QueryGetData(&fmt) };
537    result.is_ok()
538}