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}