1. 光标热区命中:MFC 里到底发生了什么
在 MFC 桌面程序里做「光标热区」这件事,本质上是回答两个问题:鼠标现在压在哪个控件或区域上,以及这个区域该显示什么光标。前者由 Windows 的命中测试(Hit Test)机制给出,后者由CWnd::OnSetCursor决定。你只要在窗口类里重写OnSetCursor,就能在系统准备设置光标的那一刻插一脚,根据pWnd->GetDlgCtrlID()或自定义区域判断,返回IDC_CROSS之类的光标资源。
这套机制适合谁?适合正在用 MFC 写工具类软件、需要「鼠标移到某块区域就变十字」的开发者,比如截图工具、绘图板、取色器、图像标注程序。它不依赖第三方库,纯 Win32 + MFC 就能跑通。我试过在一个对话框程序里把按钮和一块自绘区域都做成热区,移动鼠标时光标在箭头和十字之间切换,同时状态栏打印命中区域名,调试起来很直观。
需要提前说清楚的一点:OnSetCursor会被频繁调用,鼠标每移动一点就可能触发一次。所以里面的逻辑要轻,别做耗时操作,更别在里面弹窗或写文件。判断命中、设置光标、更新一行日志,这三件事就够了。下面从环境准备讲到可复制的配置骨架,再到编译验证和排错,一步步来。
2. TaoToken 前置:统一 Key 与 API 通道
在动手写代码之前,先把 AI 辅助工具链的接入通道准备好。TaoToken 提供统一的 Key 和 API 通道,让编辑器插件、命令行工具、对话客户端共用一套凭证,省得每个工具单独配一遍。官网入口是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 基址是 https://taotoken.net/api 。
你需要先拿到一个 Key。登录后进入控制台,在 API Keys 页面创建一个新 Key,复制保存。这个 Key 后面会写进config.toml和settings.json两个骨架文件里。注意 Key 只显示一次,丢了就重新生成。
注意:Key 属于敏感凭证,不要提交到公开仓库,也不要在截图里露出完整字符串。本地配置文件建议加进
.gitignore。
接入文档在 https://taotoken.net/doc ,里面有各工具的字段说明。模型对话入口在 https://taotoken.net/chat ,可以用来快速验证 Key 是否可用。如果你打算长期做编码和 Agent 类任务,可以了解 Coding Plan:https://taotoken.net/coding-plan 。控制台地址是 https://taotoken.net/console ,API Keys 管理页是 https://taotoken.net/api-keys 。
把 Key 准备好之后,我们进入配置骨架部分。这两个文件的作用是:config.toml给命令行类工具用,settings.json给编辑器插件类工具用,两者共用同一个 Key 和 API 基址。
3. 可复制配置:config.toml 与 settings.json 骨架
先给config.toml。这个文件通常放在工具约定的配置目录下,字段名以接入文档为准,下面是一份可直接改 Key 使用的骨架:
# config.toml - 统一 API 通道配置骨架 # 将 YOUR_TAOTOKEN_KEY 替换为你在控制台创建的 Key [provider] name = "taotoken" base_url = "https://taotoken.net/api" api_key = "YOUR_TAOTOKEN_KEY" [model] default = "claude-sonnet" timeout_seconds = 60 max_retries = 2 [logging] level = "info" # 命中区域日志单独输出,便于和光标调试对照 cursor_trace = true再给settings.json,适合编辑器插件读取:
{ "taotoken": { "baseUrl": "https://taotoken.net/api", "apiKey": "YOUR_TAOTOKEN_KEY", "defaultModel": "claude-sonnet", "timeout": 60000, "retries": 2 }, "cursor": { "hotzoneTrace": true, "logFile": "./logs/cursor_hit.log" } }两个文件里的base_url/baseUrl都指向https://taotoken.net/api,Key 用同一个。cursor_trace和hotzoneTrace是我加的自定义开关,用来把光标命中日志和 AI 请求日志分开,排查时不会互相干扰。
配置写完后,先别急着编译 MFC 程序,用一条命令验证通道是否通。下面进入验证环节。
4. 验证请求与 OnSetCursor 调用链
4.1 先验证 API 通道
用 curl 发一条最小请求,确认 Key 和基址可用:
curl -X POST "https://taotoken.net/api/v1/messages" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_TAOTOKEN_KEY" \ -d '{ "model": "claude-sonnet", "max_tokens": 64, "messages": [{"role": "user", "content": "ping"}] }'返回里带content字段就说明通道正常。如果返回 401,检查 Key 是否复制完整;返回 404,检查基址是否漏了/api。
4.2 OnSetCursor 的调用链
现在回到 MFC。系统在鼠标移动时会走这样一条链:鼠标消息进入窗口 → 系统做命中测试 → 向窗口发送WM_SETCURSOR→ MFC 把它映射到CWnd::OnSetCursor。默认实现会调用DefWindowProc,最终用类光标或箭头。我们重写它,就能按热区返回IDC_CROSS。
关键函数有三个:AfxGetApp()->LoadCursor(IDC_CROSS)加载光标资源,SetCursor(...)立即设置,return TRUE告诉系统「我已经处理了,别再走默认逻辑」。如果返回FALSE,系统会继续用默认光标覆盖你的设置,这是最常见的坑。
下面是一份可编译的OnSetCursor实现,覆盖按钮热区和一块自绘区域:
// CTestDlg.h 中声明 // afx_msg BOOL OnSetCursor(CWnd* pWnd, UINT nHitTest, UINT message); BOOL CTestDlg::OnSetCursor(CWnd* pWnd, UINT nHitTest, UINT message) { CString strHotInfo; HCURSOR hCross = AfxGetApp()->LoadCursor(IDC_CROSS); // 情况一:命中标准按钮控件 if (pWnd != nullptr && pWnd != this) { switch (pWnd->GetDlgCtrlID()) { case IDOK: ::SetCursor(hCross); strHotInfo = "当前热区为 OK 按钮"; break; case IDCANCEL: ::SetCursor(hCross); strHotInfo = "当前热区为 CANCEL 按钮"; break; default: break; } } // 情况二:命中自绘热区(客户区坐标判断) if (strHotInfo.IsEmpty()) { CPoint pt; ::GetCursorPos(&pt); ScreenToClient(&pt); CRect rcHot(50, 50, 250, 200); // 自定义热区矩形 if (rcHot.PtInRect(pt)) { ::SetCursor(hCross); strHotInfo = "当前热区为自绘区域"; } } // 命中则更新标题栏并拦截默认处理 if (!strHotInfo.IsEmpty()) { SetWindowText(strHotInfo); TRACE("[cursor] %s\n", (LPCTSTR)strHotInfo); return TRUE; } return CWnd::OnSetCursor(pWnd, nHitTest, message); }几个细节值得说。第一,LoadCursor每次调用都会查资源,虽然开销不大,但更稳妥的做法是在OnInitDialog里加载一次存成成员变量,OnSetCursor里直接用。第二,自绘区域用GetCursorPos+ScreenToClient换算坐标,比依赖nHitTest更可控。第三,TRACE只在 Debug 下输出,Release 下会被编译掉,正式版想留日志就换成写文件。
别忘了在消息映射里加上:
BEGIN_MESSAGE_MAP(CTestDlg, CDialogEx) ON_WM_SETCURSOR() END_MESSAGE_MAP()漏了这行,OnSetCursor根本不会被调用,光标永远是箭头。
4.3 编译运行后的验证动作
编译运行程序,把鼠标从窗口空白处慢慢移向 OK 按钮。预期现象是:进入按钮范围时光标变成十字,标题栏显示「当前热区为 OK 按钮」,Debug 输出窗口打印[cursor] 当前热区为 OK 按钮。移到自绘区域同理。移出热区后光标恢复箭头,标题栏不再更新。
如果标题栏变了但光标没变,多半是SetCursor用了成员函数版本而当前窗口不是活动窗口,改成::SetCursor全局版本即可。如果光标闪一下就变回箭头,检查是不是返回了FALSE。
5. 本篇常见错排查
光标不切换,始终是箭头。先确认消息映射里有ON_WM_SETCURSOR(),再确认函数签名完全一致:BOOL OnSetCursor(CWnd* pWnd, UINT nHitTest, UINT message)。签名差一个参数,映射就失效。
光标切换后立刻被覆盖。这是返回FALSE的典型症状。命中热区后必须return TRUE,否则系统会用默认光标覆盖你刚设置的。
LoadCursor返回 NULL。说明IDC_CROSS资源不存在。检查资源视图里有没有 ID 为IDC_CROSS的光标资源,或者改用系统预定义光标::LoadCursor(NULL, IDC_CROSS)。
自绘区域判断不准。多半是坐标系搞混了。GetCursorPos给的是屏幕坐标,必须ScreenToClient转成客户区坐标再和矩形比较。如果窗口有滚动条,还要考虑滚动偏移。
日志里命中区域刷屏。OnSetCursor调用极频繁,TRACE会刷满输出窗口。可以在打印前加一个「区域是否变化」的判断,只在切换时输出一次。
API 请求返回 401。回到config.toml和settings.json,确认 Key 没有多余空格,base_url是https://taotoken.net/api而不是别的路径。重新生成 Key 后记得同步更新两个文件。
6. 把通道和光标逻辑接起来
光标热区调通之后,下一步是让 AI 工具链真正参与进来。如果你主要做接入和排障,先去 API Keys 页面确认 Key 状态,再对照接入文档核对字段:https://taotoken.net/api-keys 、https://taotoken.net/doc 。想快速验证模型是否正常响应,用模型对话入口发一条消息即可:https://taotoken.net/chat 。如果你打算长期用 AI 辅助编码、跑 Agent 任务,Coding Plan 会更合适:https://taotoken.net/coding-plan 。
回到代码本身,一个实用的小技巧:把OnSetCursor里的热区矩形抽成配置项,从settings.json的cursor段读取,这样改热区不用重新编译。配合hotzoneTrace开关,调试时开日志、发布时关日志,一套代码两种行为。光标资源也建议在OnInitDialog里预加载成成员变量,OnSetCursor里只做判断和SetCursor,把每次鼠标移动的开销压到最低。