☰
Windows-classic-samples 源码解析:用无激活弹窗打造“看起来像系统上下文菜单“的 Fake Menu
2026/10/16 2:38:01 网站建设 项目流程
  • 示例工程

【免费下载链接】Windows-classic-samples

This repo contains samples that demonstrate the API used in Windows classic desktop applications.

项目地址:https://gitcode.com/gh_mirrors/wi/Windows-classic-samples
点击查看免费下载

导读

本篇文章以 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)。这里的"新特性"在源码中有两处明确体现:

  1. 启用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()相关路径可能表现不同。

  2. 针对 Windows 10/11 的窗口圆角与边框颜色:当编译时定义了NTDDI_WIN10_CO(Win10 21H2+ 常量)时,会调用DwmSetWindowAttribute设置DWMWCP_ROUNDSMALL圆角与取自主题的TMT_FILLCOLORHINT边框色;更高版本NTDDI_WIN10_NI下还会探测并使用新的菜单键盘焦点主题部件(详见第五、六节)。这些都是典型的"渐进增强"。

2.2 用 Visual Studio 构建

按 README 的步骤:

  1. 启动 Visual Studio,选择文件 → 打开 → 项目/解决方案;
  2. 进入示例目录,双击Fakemenu.sln;
  3. 选择生成 → 生成解决方案。

从 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,-1)(键盘触发上下文菜单),按ClientToScreen换算为屏幕坐标;
  2. 调用ColorPick_Popup(hwnd, x, y)进入模态交互循环(fakemenu.cpp);
  3. 返回选中颜色索引,非负则更新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)复刻了这套逻辑,源码注释将其归纳为四条规则:

  1. 若 (x, y) 太高或太靠左,则滑入屏幕(钳到rcMonitor.top/left);
  2. 若能完整放下,则使用 (x, y) 原位置;
  3. 若太靠下,则向上滑(钳到rcMonitor.bottom - cy);
  4. 若太靠右,则向左翻转(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

九、可移植的工程经验总结

把本示例的技术点抽离出来,可作为以下三类场景的直接参考:

  1. 无激活弹窗:任何"弹出面板不能抢焦点"的需求(下拉框、调色板、浮窗工具栏),核心就是MA_NOACTIVATE+SW_SHOWNOACTIVATE+ 扩展样式组合,并保持与 owner 同线程;
  2. 输入重定向:当弹窗本身拿不到输入时,采用"SetCapture(owner)+ 模态循环MapWindowPoints重路由"方案;记得在循环中检查GetActiveWindow/GetCapture,保证用户切换窗口时菜单能及时自行关闭;
  3. 主题化自绘:用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.

项目地址:https://gitcode.com/gh_mirrors/wi/Windows-classic-samples
点击查看免费下载

相关推荐

上一篇:Loop:免费开源Mac窗口管理工具,用径向菜单提升你的工作效率
下一篇:Swift Testing 议题严重级别:ST-0013 提案解析——用 warning 记录不中断测试的问题

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询