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}