Skip to main content

win_context_menu/
lib.rs

1//! # win-context-menu
2//!
3//! Show and interact with Windows Explorer context menus programmatically.
4//!
5//! This crate provides a safe Rust API to display the native Windows shell
6//! context menu for files and folders, enumerate menu items, and execute
7//! selected commands — all without requiring an Explorer window.
8//!
9//! ## Quick Start
10//!
11//! ```no_run
12//! use win_context_menu::{init_com, show_context_menu};
13//!
14//! fn main() -> win_context_menu::Result<()> {
15//!     let _com = init_com()?;
16//!     if let Some(selected) = show_context_menu(r"C:\Windows\notepad.exe")? {
17//!         println!("Selected: {}", selected.menu_item().label);
18//!         selected.execute()?;
19//!     }
20//!     Ok(())
21//! }
22//! ```
23//!
24//! ## Threading
25//!
26//! All operations require COM to be initialized in **single-threaded apartment
27//! (STA)** mode on the calling thread. Call [`init_com`] once at the start of
28//! your thread. The returned [`ComGuard`] must be kept alive for the duration
29//! of your context-menu work and **must not be moved to another thread**.
30//!
31//! ## Feature flags
32//!
33//! | Feature   | Description |
34//! |-----------|-------------|
35//! | `ffi`     | Enable C-compatible FFI exports (adds `serde_json` dependency) |
36//! | `serde`   | Derive `Serialize` / `Deserialize` for [`MenuItem`] |
37//! | `tracing` | *(reserved for future use)* |
38
39mod com;
40mod context_menu;
41mod error;
42#[cfg(feature = "ffi")]
43mod ffi;
44mod hidden_window;
45mod invoke;
46mod menu_items;
47mod shell_item;
48mod util;
49
50pub use com::ComGuard;
51pub use context_menu::ContextMenu;
52pub use error::{Error, Result};
53pub use menu_items::{InvokeParams, MenuItem, SelectedItem};
54pub use shell_item::ShellItems;
55
56#[cfg(feature = "ffi")]
57pub use ffi::*;
58
59use std::path::Path;
60
61/// Initialize COM for the current thread in STA mode.
62///
63/// Returns a guard that calls `CoUninitialize` on drop. Keep it alive for the
64/// lifetime of your context-menu operations.
65///
66/// # Errors
67///
68/// Returns [`Error::ComInit`] if COM has already been initialized with an
69/// incompatible threading model.
70///
71/// # Example
72///
73/// ```no_run
74/// let _com = win_context_menu::init_com()?;
75/// // … use ContextMenu, ShellItems, etc. …
76/// # Ok::<(), win_context_menu::Error>(())
77/// ```
78pub fn init_com() -> Result<ComGuard> {
79    ComGuard::new()
80}
81
82/// Convenience: show a context menu for a single path at the cursor position.
83///
84/// Equivalent to:
85/// ```no_run
86/// # use win_context_menu::*;
87/// # let path = r"C:\Windows\notepad.exe";
88/// let items = ShellItems::from_path(path)?;
89/// ContextMenu::new(items)?.show()?;
90/// # Ok::<(), Error>(())
91/// ```
92pub fn show_context_menu(path: impl AsRef<Path>) -> Result<Option<SelectedItem>> {
93    let items = ShellItems::from_path(path)?;
94    ContextMenu::new(items)?.show()
95}
96
97/// Convenience: show an extended context menu (Shift+right-click) at the
98/// cursor position.
99pub fn show_extended_context_menu(path: impl AsRef<Path>) -> Result<Option<SelectedItem>> {
100    let items = ShellItems::from_path(path)?;
101    ContextMenu::new(items)?.extended(true).show()
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn test_com_guard_init_and_drop() {
110        let guard = init_com();
111        assert!(guard.is_ok());
112    }
113
114    #[test]
115    fn test_shell_items_from_path() {
116        let _com = init_com().unwrap();
117        let result = ShellItems::from_path(r"C:\Windows\notepad.exe");
118        assert!(result.is_ok());
119    }
120
121    #[test]
122    fn test_shell_items_nonexistent() {
123        let _com = init_com().unwrap();
124        let result = ShellItems::from_path(r"C:\This\Does\Not\Exist\file.txt");
125        assert!(result.is_err());
126    }
127
128    #[test]
129    fn test_enumerate_notepad() {
130        let _com = init_com().unwrap();
131        let items = ShellItems::from_path(r"C:\Windows\notepad.exe").unwrap();
132        let menu = ContextMenu::new(items).unwrap();
133        let entries = menu.enumerate().unwrap();
134
135        assert!(!entries.is_empty(), "Context menu should have items");
136
137        let has_properties = entries.iter().any(|e| {
138            e.command_string
139                .as_deref()
140                .is_some_and(|s| s == "properties")
141        });
142        assert!(has_properties, "Should have a 'properties' verb");
143    }
144
145    #[test]
146    fn test_enumerate_extended() {
147        let _com = init_com().unwrap();
148        let items = ShellItems::from_path(r"C:\Windows\notepad.exe").unwrap();
149        let normal = ContextMenu::new(items).unwrap().enumerate().unwrap();
150
151        let items2 = ShellItems::from_path(r"C:\Windows\notepad.exe").unwrap();
152        let extended = ContextMenu::new(items2)
153            .unwrap()
154            .extended(true)
155            .enumerate()
156            .unwrap();
157
158        assert!(
159            extended.len() >= normal.len(),
160            "Extended menu should have at least as many items as normal"
161        );
162    }
163
164    #[test]
165    fn test_folder_background() {
166        let _com = init_com().unwrap();
167        let result = ShellItems::folder_background(r"C:\Windows");
168        assert!(result.is_ok());
169    }
170
171    #[test]
172    fn test_multi_paths_same_folder() {
173        let _com = init_com().unwrap();
174        let result = ShellItems::from_paths(&[
175            r"C:\Windows\notepad.exe",
176            r"C:\Windows\regedit.exe",
177        ]);
178        assert!(result.is_ok());
179    }
180
181    #[test]
182    fn test_multi_paths_different_folders_fails() {
183        let _com = init_com().unwrap();
184        let result = ShellItems::from_paths(&[
185            r"C:\Windows\notepad.exe",
186            r"C:\Users",
187        ]);
188        assert!(result.is_err());
189    }
190}