
1. 从一次「查找按钮点了没反应」说起QTextCursor 是 Qt 文本系统里真正干活的角色它既是一个位置指针也是一段可带选区的范围。你在 QTextEdit 里看到的闪烁光标、鼠标拖选的高亮、CtrlF 跳转的定位底层都是 QTextCursor 在动。这篇面向的是正在写 Qt 桌面编辑器、需要给文本查找加上高亮、循环定位、大小写匹配的开发者尤其是那种「按钮点了没反应」「第二次查找把上次高亮也撤了」的典型场景。我见过太多查找实现是直接把QTextDocument::find的结果mergeCharFormat一遍第一次能用第二次点就乱套要么旧高亮没清要么 undo 栈被污染要么光标停在末尾再也找不到。根因通常不在 find 本身而在于没有把「查找状态」和「文档编辑状态」分开管理。QTextCursor 的查找是只读遍历但mergeCharFormat是写操作两者混在一个 undo block 里回退逻辑就会互相打架。所以这篇不只给你一段能跑的查找代码还会把 TaoToken 统一 Key 的接入骨架一起铺好——因为很多编辑器项目后面要接 AI 补全、语义搜索Key 管理如果一开始就散落在各处后面重构成本极高。下面从环境准备、配置骨架、可复制代码到排障一步步来。2. TaoToken 前置统一 Key 与配置骨架TaoToken 是一个统一的大模型 API 接入层把不同模型的调用收敛到一套 Key 和一套 OpenAI 兼容接口上。对 Qt 编辑器项目来说它的价值在于你不需要在客户端里硬编码多家厂商的 Key也不用为每个模型写一套请求适配。官网入口在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end API 基址是 https://taotoken.net/api 注意 API 地址不带任何查询参数。接入前先在控制台创建 Key地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 创建完到 Key 管理页复制页面在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。这个 Key 就是后面配置文件里要填的东西建议单独放一个不提交到 git 的本地文件。配置骨架我习惯用两层一层是项目级的config.toml管模型、超时、重试这类不敏感参数一层是用户级的settings.json管 Key 和本地偏好。这样团队协作时config.toml可以进版本库settings.json走.gitignore。下面两份都是可直接复制的骨架。config.toml放在项目根目录# config.toml —— 项目级配置可提交到版本库 [api] base_url https://taotoken.net/api timeout_ms 30000 max_retries 2 [model] default claude-sonnet fallback gpt-4o-mini [editor] # 查找相关默认值供 QTextCursor 查找逻辑读取 case_sensitive_default false whole_word_default false highlight_color #FFD54Fsettings.json放在用户配置目录比如~/.config/your-editor/settings.json{ taotoken: { api_key: sk-替换成你在控制台创建的Key, base_url: https://taotoken.net/api }, editor: { last_search: , wrap_around: true, highlight_all: true } }注意settings.json里只放 Key不要放任何模型参数模型参数统一走config.toml避免两处配置打架。Key 泄露时只需轮换这一个文件。如果你后面要做长期编码辅助或 Agent 类功能可以了解下 Coding Plan入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 它适合把编辑器里的补全、重构请求统一走一个额度池。模型对话调试入口在 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。3. 可复制配置QTextCursor 查找的完整实现先说清楚 QTextCursor 查找的三个关键点。第一QTextDocument::find返回的 cursor 是「命中选区」它自带 anchor 和 position可以直接拿来mergeCharFormat。第二查找方向由FindFlags控制FindBackward反向FindCaseSensitively区分大小写FindWholeWords全词匹配。第三循环定位要靠手动判断atEnd()或atStart()find 本身不会自动绕回。下面这段是查找按钮的槽函数做了三件事清理上次高亮、遍历所有命中并着色、记录命中数量。注意清理高亮用的是「重新设置格式」而不是undo()这样不会污染 undo 栈。// finddialog.cpp void FindDialog::onFindClicked() { const QString keyword ui-lineEdit-text(); if (keyword.isEmpty()) { QMessageBox::information(this, tr(提示), tr(请输入要查找的内容)); return; } QTextDocument *doc ui-textEdit-document(); // 1. 清理上一次的高亮把整篇文档的字符格式重置为默认 QTextCursor clearCursor(doc); clearCursor.select(QTextCursor::Document); QTextCharFormat plain; plain.setBackground(Qt::transparent); plain.setForeground(Qt::black); clearCursor.mergeCharFormat(plain); // 2. 组装查找标志 QTextDocument::FindFlags flags; if (ui-caseCheck-isChecked()) flags | QTextDocument::FindCaseSensitively; if (ui-wholeWordCheck-isChecked()) flags | QTextDocument::FindWholeWords; // 3. 遍历所有命中并着色 QTextCharFormat highlight; highlight.setBackground(QColor(#FFD54F)); highlight.setForeground(Qt::black); QTextCursor cursor(doc); int hitCount 0; while (!cursor.isNull() !cursor.atEnd()) { cursor doc-find(keyword, cursor, flags); if (cursor.isNull()) break; cursor.mergeCharFormat(highlight); hitCount; } if (hitCount 0) { QMessageBox::information(this, tr(查找), tr(未找到匹配内容)); } else { ui-statusLabel-setText(tr(命中 %1 处).arg(hitCount)); } }循环定位下一个/上一个单独抽一个函数核心是判断边界后重置 cursor 位置。这里用QTextCursor::Start和QTextCursor::End做绕回// 查找下一个支持循环 void FindDialog::findNext(const QString keyword, bool backward) { QTextDocument *doc ui-textEdit-document(); QTextDocument::FindFlags flags; if (ui-caseCheck-isChecked()) flags | QTextDocument::FindCaseSensitively; if (backward) flags | QTextDocument::FindBackward; QTextCursor cursor ui-textEdit-textCursor(); // 边界判断到末尾/开头时绕回 if (!backward cursor.atEnd()) cursor.movePosition(QTextCursor::Start); if (backward cursor.atStart()) cursor.movePosition(QTextCursor::End); QTextCursor hit doc-find(keyword, cursor, flags); if (hit.isNull()) { // 绕回后再试一次 cursor.movePosition(backward ? QTextCursor::End : QTextCursor::Start); hit doc-find(keyword, cursor, flags); } if (!hit.isNull()) { ui-textEdit-setTextCursor(hit); ui-textEdit-ensureCursorVisible(); } }跨段落定位要注意一点find默认会跨段落搜索但如果你在段落内用QTextCursor::BlockUnderCursor限制范围就会漏掉跨段命中。所以查找范围始终用整篇 document不要手动限制 block。4. 验证请求命中、跨段落与大小写写完代码要验证三件事我按顺序说动作和预期结果。第一基础命中。在 QTextEdit 里输入三行文本比如hello world、hello qt、world hello查找框输入hello点查找。预期状态栏显示「命中 3 处」三处背景变黄。如果只命中 1 处检查循环里cursor doc-find(...)是否在mergeCharFormat之后没有推进——find 返回的 cursor 已经指向命中末尾下一轮从它继续是对的但如果你手动movePosition就会跳字。第二跨段落定位。输入两段第一段结尾是find第二段开头是me查找findme这种跨段词是找不到的因为段落分隔符会打断。但查找find应该能在第一段命中。真正要验证的是光标停在第一段命中处按「下一个」能跳到第二段里的另一个find。如果跳不过去多半是atEnd()判断把跨段情况误判了。第三大小写匹配。输入Qt和qt不勾选区分大小写时查找qt应命中 2 处勾选后只命中 1 处。这里有个坑FindCaseSensitively只影响 find不影响你已经着色的旧高亮所以每次切换勾选状态都要先清理再重查否则会看到「旧高亮还在」的假象。如果你要验证 TaoToken 的 Key 是否配通可以用模型对话页发一条测试消息入口在 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。返回正常就说明 Key 和 base_url 没问题编辑器里的 AI 功能可以接着接。5. 本篇常见错排查查找第二次失效最常见原因是用了document-undo()清理高亮。undo 会把上一次的mergeCharFormat连同用户的其他编辑一起回退状态就乱了。正确做法是像上面那样用select(Document)加mergeCharFormat重置格式不碰 undo 栈。高亮颜色不生效QTextCharFormat的setBackground在某些样式表下会被 QTextEdit 的 palette 覆盖。检查是否给 QTextEdit 设了background-color的 QSS如果有高亮要用setBackground加setForeground双设或者改用QTextEdit::ExtraSelection方案。循环定位跳不回开头atEnd()在文档末尾返回 true但如果你在 find 之后没更新 textCursor判断的就是旧位置。每次 findNext 开头都要QTextCursor cursor ui-textEdit-textCursor();重新取。跨段落命中丢失如果你在 find 前调用了cursor.movePosition(QTextCursor::BlockUnderCursor, KeepAnchor)之类限制范围跨段就搜不到。查找范围保持整篇 document。Key 读取失败settings.json路径写错或 JSON 格式有尾逗号QJsonDocument 解析会静默返回空。加一行qDebug() settingsPath;确认路径再用在线 JSON 校验器过一遍。base_url 带错API 地址是https://taotoken.net/api不要在后面拼/v1或加查询参数拼接逻辑统一在代码里做配置文件只存基址。6. 把 Key 和查找逻辑收进一个入口查找功能本身不依赖网络但编辑器一旦要加 AI 补全、语义搜索Key 的读取入口就得和查找逻辑一样集中。我的做法是写一个AppConfig单例启动时读config.toml和settings.json查找模块和 AI 模块都从它取参数不各自读文件。这样换 Key、调超时只改一处。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有完整的请求示例和错误码说明。Key 管理在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 控制台在 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。长期做编码辅助的话Coding Plan 在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite Claude Code 相关接入在 https://taotoken.net/claude-code?utm_sourcetaotoken_aicg_blog_endutm_contentclaude-codeutm_campaignrewrite 。最后留一个我踩过的坑QTextCursor 的mergeCharFormat在文档很大时几万行会明显卡顿因为每次 merge 都触发一次布局。优化办法是先把所有命中位置收集到QVectorQTextCursor再用editBlock包起来批量 merge实测万行文档从 800ms 降到 120ms 左右。查找逻辑和 Key 管理一样集中、批量、别散着写后面加功能才不痛苦。