- 示例工程
【免费下载链接】Windows-classic-samples
This repo contains samples that demonstrate the API used in Windows classic desktop applications.
导读
本篇文章以 fakemenu 示例 为骨架,深入拆解 Windows-classic-samples 仓库中一个非常经典的自绘控件范例:完全不用 TrackPopupMenu、不用真实菜单资源,仅凭一个自定义无激活弹窗(popup window)模拟出与系统上下文菜单行为几乎一致的颜色选择器。读完本文,你将掌握"弹窗不抢焦点""从窗口消息循环中窃取输入并重定向""使用 UxTheme 主题部件渲染菜单项"以及"键盘/鼠标双轨导航 + 多显示器定位"四套可直接复用的 Win32 技巧,并能在自己的桌面应用中复现同样的交互。
一、示例概览:这个 Fake Menu 到底演示了什么
README 将本示例定位为 "Menu theming sample application",核心承诺是:
- 弹窗捕获鼠标与键盘输入,但绝不激活自身,因此主窗口标题栏始终保持在激活高亮状态;
- 弹窗行为与系统上下文菜单一致:鼠标悬停切换高亮、方向键移动高亮、ESC 取消、Enter 确认;
- 弹窗尽量使用系统主题部件(theme parts)渲染,与真实菜单外观同源。
从 fakemenu.cpp 的源码看,这是一个单文件 Win32 程序(约 827 行,无额外资源文件),注册了两个窗口类:
ColorPick:负责绘制 16 种预设颜色条、处理鼠标/键盘导航的弹出窗口;FakeMenuDemo:主演示窗口,捕获WM_CONTEXTMENU后弹出上述颜色选择器。
运行后主窗口显示一行提示Fake Menu Demo - Right-click in window to change color,右键即可在鼠标位置弹出颜色菜单;选完颜色后主窗口背景立即变为所选颜色。源码注释(fakemenu.cpp)还列出了运行时应观察到的 5 个行为特征,本文后续将逐一对照实现进行验证。
二、构建与运行环境
2.1 操作系统要求
README 明确要求Windows Vista 及以上,并且"会利用可用的新特性"(will take advantage of newer features if available)。这里的"新特性"在源码中有两处明确体现:
- 启用ComCtl32 v6 视觉样式:通过链接器指令嵌入 manifest 依赖(fakemenu.cpp):
#pragma comment(linker, "\"/manifestdependency:type='Win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'\"")这是经典的主题化(themed)控件应用标配,否则
IsThemeActive()相关路径可能表现不同。 - 针对 Windows 10/11 的窗口圆角与边框颜色:当编译时定义了
NTDDI_WIN10_CO(Win10 21H2+ 常量)时,会调用DwmSetWindowAttribute设置DWMWCP_ROUNDSMALL圆角与取自主题的TMT_FILLCOLORHINT边框色;更高版本NTDDI_WIN10_NI下还会探测并使用新的菜单键盘焦点主题部件(详见第五、六节)。这些都是典型的"渐进增强"。
2.2 用 Visual Studio 构建
按 README 的步骤:
- 启动 Visual Studio,选择文件 → 打开 → 项目/解决方案;
- 进入示例目录,双击
Fakemenu.sln; - 选择生成 → 生成解决方案。
从 Fakemenu.vcxproj 可以确认工程配置细节:
- 支持Debug/Release × Win32/x64四种组合;仅包含
fakemenu.cpp一个编译单元; - 字符集为Unicode,子系统为Windows(GUI 应用,无控制台);
- 工具集默认
v140(VS2015),若使用 VS2017/VS2019 会自动切换到v141/v142(Fakemenu.vcxproj),因此用较新的 VS 打开也能直接构建; - 编译警告级别为 Level4 且TreatWarningAsError=true,意味着示例本身以零警告标准书写;
- 链接库由源码中的
#pragma comment(lib, ...)指定:Uxtheme.lib(主题 API)与dwmapi.lib(DWM 窗口属性),无需手动在工程里添加。
2.3 运行与操作
在 Visual Studio 中直接按调试 → 开始调试(或"开始执行(不调试)")即可运行。随后:
- 在窗口内单击右键,弹出模拟上下文菜单;
- 鼠标移动即可看到高亮条跟随(源码注释里明确写道 "The current fake-menu item highlight follows the mouse");
- 键盘按 ↑/↓ 移动高亮、ESC 放弃、Enter 确认;一旦使用键盘,菜单高亮会自动切换为"键盘焦点"视觉样式(这也是 README 之外的源码级行为细节)。
三、整体架构:弹窗状态机 + 模态消息循环
3.1 状态结构 COLORPICKSTATE
弹窗的全部运行状态保存在栈上结构体COLORPICKSTATE中(fakemenu.cpp),并通过GWLP_USERDATA与窗口实例关联:
typedef struct COLORPICKSTATE { BOOL fDone; // 置位即退出模态循环 int iSel; // 当前高亮项索引,-1 表示无高亮 int iResult; // 最终返回值(选中的颜色索引) HWND hwndOwner; // 所有者窗口 BOOL fKeyboardUsed; // 是否已使用键盘导航(决定绘制哪种高亮) HTHEME hTheme; // 当前活动的主题句柄(可能为 NULL) int iPartKeyboardFocus; // 键盘焦点部件:MENU_POPUPITEMKBFOCUS 或 MENU_POPUPITEM int iPartNonFocus; // 非焦点部件:MENU_POPUPITEM_FOCUSABLE 或 MENU_POPUPITEM int iStateFocus; // 焦点态:MPIF_HOT 或 MPI_HOT MARGINS marginsItem; // 菜单项的 content margins } COLORPICKSTATE;这个结构同时驱动着"绘制"与"输入路由"两个子系统,是理解整个示例的钥匙。
3.2 一次右键的完整调用链
以主窗口收到WM_CONTEXTMENU为起点(fakemenu.cpp):
- 若坐标是
(-1,-1)(键盘触发上下文菜单),按ClientToScreen换算为屏幕坐标; - 调用
ColorPick_Popup(hwnd, x, y)进入模态交互循环(fakemenu.cpp); - 返回选中颜色索引,非负则更新
g_clrBackground并重绘主窗口背景。
ColorPick_Popup的内部流程是:
- 线程一致性检查:
GetCurrentThreadId() != GetWindowThreadProcessId(hwndOwner, NULL)时直接返回 -1,注释明确说明原因——"我们必须与 owner 同线程,才能通过SetCapture(owner)看到它的鼠标键盘消息"(fakemenu.cpp)。这是该技术方案的硬性前提:弹窗必须与主窗口同线程; - 创建
WS_POPUP | WS_BORDER窗口,配合扩展样式WS_EX_TOOLWINDOW | WS_EX_DLGMODALFRAME | WS_EX_WINDOWEDGE | WS_EX_TOPMOST(fakemenu.cpp); - 用
AdjustWindowRectEx按目标客户区尺寸(宽CXFAKEMENU=100,高16×CYCOLOR(16)=256)反推窗口矩形; ColorPick_ChooseLocation计算不越屏、不跨显示器的弹出位置;CreateWindowEx+ShowWindow(hwndPopup, SW_SHOWNOACTIVATE)显示但不激活;SetCapture(hwndOwner)让所有鼠标消息进入 owner,再由消息循环"偷"走;- 进入
GetMessage模态循环,过滤、重定向输入,直到fDone或激活状态异常; - 收尾:
ReleaseCapture、DestroyWindow、CloseThemeData;若循环收到WM_QUIT则重新PostQuitMessage传递退出信号。
四、核心难点一:无激活弹窗与输入劫持
4.1 为什么不能直接弹出普通窗口
普通 pop-up 窗口在ShowWindow后会被激活,导致所有者窗口立刻失活(标题栏变灰)。源码开头注释一针见血(fakemenu.cpp):
通常弹出窗口会获得激活,导致 owner 窗口失活。要避免 owner 失活,弹出窗口就不应该接收激活。既然弹出窗口不激活,输入消息就不会投递给它,因此输入消息必须由消息循环显式检视。
这正是系统菜单的实现思路,示例把它完整复刻了一遍。
4.2 双层防线:MA_NOACTIVATE + SW_SHOWNOACTIVATE
弹窗通过两条独立机制保证自己不激活:
- 窗口过程处理
WM_MOUSEACTIVATE并返回MA_NOACTIVATE(fakemenu.cpp),使得点击弹窗本身也不会把激活权从 owner 抢走; - 显示时使用
SW_SHOWNOACTIVATE(fakemenu.cpp),显示但不激活。
配合WS_EX_TOPMOST(不被其他窗口遮挡)与WS_EX_TOOLWINDOW(不出现在任务栏),观感上完全等价于一个系统菜单。
4.3 输入劫持:SetCapture(owner) + 消息循环重路由
由于只有活动窗口才能捕获鼠标,示例的策略是:把捕获设在 owner 上,再从 owner 眼皮底下"偷"走鼠标消息(源码注释原文,fakemenu.cpp)。
模态循环在DispatchMessage之前对每条消息做"重路由"(fakemenu.cpp),分三类处理:
| 消息类别 | 消息 | 处理方式 |
|---|---|---|
| 客户区鼠标消息 | WM_MOUSEMOVE、WM_LBUTTONDOWN/UP/DBLCLK、WM_RBUTTON*、WM_MBUTTON* | 用MapWindowPoints把坐标从原始目标窗口转换到弹窗客户区,重写lParam,再把hwnd改为弹窗 |
| 非客户区鼠标消息 | WM_NCMOUSEMOVE、WM_NCLBUTTON*、WM_NCRBUTTON*、WM_NCMBUTTON* | 屏幕坐标无需转换,直接改hwnd |
| 键盘消息 | WM_KEYDOWN/UP、WM_CHAR、WM_DEADCHAR、WM_SYSKEY* | 直接改hwnd投递给弹窗 |
也就是说,弹窗自身虽然不是输入焦点窗口,却通过这条"消息拦截管道"以接近系统菜单的方式吃到全部输入。这是本示例最值得移植的部分。
4.4 循环的退出条件(防"菜单卡死")
模态循环在两类时机检查退出条件(循环顶部与Translate/Dispatch之后各检查一次,fakemenu.cpp):
cps.fDone被置位(鼠标松开、ESC、Enter,见下节);- 激活状态异常:
GetActiveWindow() != hwndOwner且不是 owner 的子窗口,或GetCapture() != hwndOwner——即用户中途 Alt+Tab 切走时,菜单立即自行关闭,避免输入无处可去。
这保证了 Fake Menu 永远不会成为"僵尸弹窗"。
五、核心难点二:用主题部件(Theme Parts)渲染菜单
5.1 打开与关闭主题
ColorPick_UpdateVisuals(fakemenu.cpp)负责随系统主题、对比度设置动态刷新渲染方案:
- 仅在
IsThemeActive() && !IsHighContrast()时调用OpenThemeData(hwnd, VSCLASS_MENU)获取菜单主题句柄,否则hTheme = NULL,回退到纯系统色绘制(高对比度模式下刻意不用主题,保证可读性); - 高对比度检测使用
SystemParametersInfo(SPI_GETHIGHCONTRAST)+HCF_HIGHCONTRASTON标志(fakemenu.cpp); - 监听
WM_THEMECHANGED与WM_SETTINGCHANGE消息并重新初始化视觉效果(fakemenu.cpp),运行中切换主题立即生效。
5.2 按 VSS 部件表绘制每一项
绘制逻辑在ColorPick_OnPaint(fakemenu.cpp),对照vssym32.h中的 Menu 部件(Part):
- 背景:先读取
MENU_POPUPBACKGROUND部件的TMT_FILLCOLORHINT颜色,用SetDCBrushColor + FillRect填充(带 DC_BRUSH 技巧,见FillRectClr,fakemenu.cpp); - 高亮项:
DrawThemeBackground(hTheme, hdc, MENU_POPUPITEM, MPI_HOT, ...)绘制悬停背景; - 非高亮项:
DrawThemeBackground(hTheme, hdc, MENU_POPUPITEM, MPI_NORMAL, ...); - 内容缩进:读取
MENU_POPUPITEM的TMT_CONTENTMARGINS作为各项内边距;读取失败时用GetSystemMetrics(SM_CXEDGE/SM_CYEDGE)兜底(fakemenu.cpp); - 无主题(高对比度)时退化为
GetSysColorBrush(COLOR_HIGHLIGHT)填充高亮。
5.3 键盘焦点的渐进增强
NTDDI_WIN10_NI(Windows 11)下,若IsThemePartDefined(hTheme, MENU_POPUPITEM_FOCUSABLE, MPIF_HOT)返回成功,则启用新的MENU_POPUPITEMKBFOCUS/MENU_POPUPITEM_FOCUSABLE部件(fakemenu.cpp)。绘制时再根据fKeyboardUsed决定用哪套部件与状态(fakemenu.cpp):
DrawThemeBackground(pcps->hTheme, hdc, pcps->fKeyboardUsed ? pcps->iPartKeyboardFocus : MENU_POPUPITEM, pcps->fKeyboardUsed ? pcps->iStateFocus : MPI_HOT, &rc, nullptr);这与 Windows 11 真实菜单"鼠标悬停高亮 vs 键盘焦点高亮"两套视觉完全对齐——老版本系统上则自动回退到传统MPI_HOT部件。
5.4 DWM 圆角与边框(Win10 21H2+)
在NTDDI_WIN10_CO条件下,主题存在时设置DWMWCP_ROUNDSMALL圆角,并把菜单边框色同步为主题给出的TMT_FILLCOLORHINT(DWMWA_BORDER_COLOR);无主题则恢复DWMWCP_DEFAULT与DWMWA_COLOR_DEFAULT(fakemenu.cpp)。这就是 README 所说"会利用可用新特性"在视觉层的最新体现。
六、核心难点三:鼠标与键盘的双轨导航
6.1 鼠标:坐标 → 命中 → 重绘最小区域
WM_MOUSEMOVE经消息循环重路由后到达ColorPick_OnMouseMove(fakemenu.cpp):当坐标落在[0, CXFAKEMENU) × [0, 16*CYCOLOR)内时按y / CYCOLOR计算命中项,否则置为 -1;随后ColorPick_ChangeSel只对旧/新选中项对应的两个矩形做InvalidateRect(fakemenu.cpp),即增量重绘,避免全窗口重绘浪费。
WM_LBUTTONUP时先补一次ColorPick_OnMouseMove(防止用户移动过快、松手瞬间坐标未被跟踪),再记录iResult并置位fDone(fakemenu.cpp)。
6.2 键盘:ESC 取消、Enter 确认、↑/↓ 循环移动
ColorPick_OnKeyDown(fakemenu.cpp)首先把fKeyboardUsed置为TRUE(触发第五节的"键盘焦点"视觉切换),然后:
VK_ESCAPE:仅置fDone = TRUE,iResult保持 -1(放弃);VK_RETURN:iResult = iSel后置fDone(接受当前高亮项);VK_UP:iSel > 0时减 1,否则循环跳到最后一项;VK_DOWN:未到末尾时加 1,否则循环跳回第一项。
其中 ↑/↓ 的"首尾回绕"行为(fakemenu.cpp)与系统菜单的循环导航一致,细节处理得相当到位。
七、核心难点四:多显示器下的弹出定位
系统菜单弹出时绝不会跨越两个显示器、也不会被切出屏幕外。ColorPick_ChooseLocation(fakemenu.cpp)复刻了这套逻辑,源码注释将其归纳为四条规则:
- 若 (x, y) 太高或太靠左,则滑入屏幕(钳到
rcMonitor.top/left); - 若能完整放下,则使用 (x, y) 原位置;
- 若太靠下,则向上滑(钳到
rcMonitor.bottom - cy); - 若太靠右,则向左翻转(
x = x - cx,即弹出到点击点的左侧)。
实现细节:先用MonitorFromPoint(pt, MONITOR_DEFAULTTONULL)找点击点所在显示器,找不到(点击点在空隙/负坐标区)则用MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)回退到 owner 所在显示器(fakemenu.cpp),再通过GetMonitorInfo获取该显示器的rcMonitor进行钳制。源码注释明确指出"我们使用真实菜单使用的相同逻辑"(fakemenu.cpp),因此多屏用户的体验与原生菜单一致。
八、完整流程图与关键代码位置速查
主窗口 WM_CONTEXTMENU(键盘触发时 ClientToScreen) │ ▼ ColorPick_Popup(hwndOwner, x, y) ① 线程一致性检查(必须与 owner 同线程) ② 创建 WS_POPUP+WS_BORDER / WS_EX_TOOLWINDOW|DLGMODALFRAME|WINDOWEDGE|TOPMOST ③ AdjustWindowRectEx 反推窗口矩形(客户区 100×256) ④ ColorPick_ChooseLocation 多显示器定位 ⑤ CreateWindowEx → ShowWindow(SW_SHOWNOACTIVATE) ⑥ SetCapture(hwndOwner) ← 鼠标输入入口 ⑦ 模态 GetMessage 循环: - 检查 fDone / 激活状态 / 捕获状态,异常即退出 - 客户区鼠标:MapWindowPoints 换算后改 hwnd - 非客户区鼠标 / 键盘:直接改 hwnd - TranslateMessage + DispatchMessage ⑧ ReleaseCapture / DestroyWindow / CloseThemeData │ ▼ 返回 iResult(选中颜色索引或 -1)→ 主窗口更新背景色| 关注点 | 关键位置 |
|---|---|
| 状态结构定义 | fakemenu.cpp |
| 主题打开/关闭与回退 | fakemenu.cpp |
| 主题部件绘制(Paint) | fakemenu.cpp |
| 鼠标/键盘导航 | fakemenu.cpp |
| 无激活 + 输入重路由循环 | fakemenu.cpp |
| 多显示器定位 | fakemenu.cpp |
| 主窗口与入口函数 | fakemenu.cpp |
| 工程配置(含平台工具集自动切换) | Fakemenu.vcxproj |
九、可移植的工程经验总结
把本示例的技术点抽离出来,可作为以下三类场景的直接参考:
- 无激活弹窗:任何"弹出面板不能抢焦点"的需求(下拉框、调色板、浮窗工具栏),核心就是
MA_NOACTIVATE+SW_SHOWNOACTIVATE+ 扩展样式组合,并保持与 owner 同线程; - 输入重定向:当弹窗本身拿不到输入时,采用"
SetCapture(owner)+ 模态循环MapWindowPoints重路由"方案;记得在循环中检查GetActiveWindow/GetCapture,保证用户切换窗口时菜单能及时自行关闭; - 主题化自绘:用
OpenThemeData(hwnd, VSCLASS_MENU)+DrawThemeBackground绘制菜单部件,并通过IsThemePartDefined做 Win11 新部件的渐进增强;同时用IsHighContrast()保留无障碍回退路径。
如果你需要继续阅读该目录下的其他 Shell 经典示例,可以对照参考 legacysamples 目录 下的appbar(应用栏)、enumdesk(桌面枚举)等样例,它们与本例共同构成了 Shell 编程的入门素材集。
- 示例工程
【免费下载链接】Windows-classic-samples
This repo contains samples that demonstrate the API used in Windows classic desktop applications.
相关推荐
Windows-universal-samples 实战:用 XAML 为 UWP 应用打造完整的上下文菜单体验
Windows universal samples 实战:用 XAML 为 UWP 应用打造完整的上下文菜单体验 导读 本文基于 Windows univers
示例工程Win11Debloat 系统瘦身与隐私清理:10 分钟移除预装应用、遥测和界面冗余,开机时间砍掉近一半
Win11Debloat 系统瘦身与隐私清理:10 分钟移除预装应用、遥测和界面冗余,开机时间砍掉近一半 Win11Debloat 系统瘦身是一个免安装的 Po
桌面应用CLICloudpods 中的 TOTP 双因子认证:基于 pquerna/otp 的一次性密码实现与接入指南
Cloudpods 中的 TOTP 双因子认证:基于 pquerna/otp 的一次性密码实现与接入指南 导读 本文围绕 Cloudpods 仓库内引入的 Go
后端云原生运维虚拟化人工智能AI 应用
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考