From 97b281d43db4e1ef5b5d173c0afa697a4eef6ca7 Mon Sep 17 00:00:00 2001 From: xiepengfei Date: Tue, 27 Oct 2026 11:59:14 +0800 Subject: [PATCH] fix: sync find highlight to render page on first next click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Root cause: clicking next/prev as the first find action of a session never registers the keyword (m_keywordForSearchAll stays empty), so Window::handleFindKeyword took the clear branch and never pushed highlightSearch to the rendered MarkdownView - the match scrolled into view but carried no highlight; pressing Enter first registers the keyword, which is why "Enter then click" works but "click first" loses the highlight (verified on the Qt 6.8.0 build of BUG-380705) 2. Fix: handleFindKeyword now registers the current keyword and always calls highlightKeywordInView, matching the Enter path (handleUpdateSearchKeyword) 3. Test: Window UT asserts click-first pushes highlightSearch to the injected renderer with the right keyword and advancing match index; EditWrapper UT adds Enter/click path-parity and LivePreview-then- ReadView flow scenarios for the scroll sync regression Log: fix find highlight missing when clicking next before enter Influence: 1. In read-only markdown view, press Ctrl+F and click Next directly without pressing Enter, verify the match is highlighted and centered 2. Test the same flow in edit mode and live preview, verify no regression 3. Verify render-page highlight follows subsequent next/prev clicks fix: 修复直接点击"下一个"时渲染页无查找高亮 1. 根因:会话内首个查找动作若直接点击"下一个/上一个"(未经回车路径 注册查找词),m_keywordForSearchAll为空使handleFindKeyword走清空 分支,渲染页收不到highlightSearch推送——匹配滚入视口却无任何高亮; 回车路径会注册查找词,故"先回车再点击"正常、"直接点击"丢高亮 (在BUG-380705修复版 Qt 6.8.0 构建上实测确认) 2. 方案:handleFindKeyword注册当前查找词并无条件调用 highlightKeywordInView,与回车路径行为对齐 3. 测试:Window单测断言点击先行也会向注入渲染器推送highlightSearch 且命中序号随点击推进;EditWrapper单测补充回车/点击路径一致性与 LivePreview进只读视图流程的滚动同步回归场景 Log: 修复只读Markdown视图下直接点击"下一个"渲染页无高亮的问题 Influence: 1. 在只读Markdown视图下Ctrl+F后不按回车、直接点击"下一个",验证匹配 内容带高亮且居中显示 2. 在编辑模式与实时预览下验证相同流程无回归 3. 验证渲染页高亮随后续点击"下一个/上一个"同步刷新 PMS: BUG-380705 --- src/widgets/window.cpp | 14 +-- tests/editor_wrapper/test_editwrapper.cpp | 108 +++++++++++++++++----- tests/widgets_ut/test_window.cpp | 56 ++++++++++- 3 files changed, 147 insertions(+), 31 deletions(-) diff --git a/src/widgets/window.cpp b/src/widgets/window.cpp index e6935445..46b26158 100644 --- a/src/widgets/window.cpp +++ b/src/widgets/window.cpp @@ -3654,14 +3654,16 @@ void Window::handleFindKeyword(const QString &keyword, bool state) m_keywordForSearch = keyword; wrapper->textEditor()->saveMarkStatus(); wrapper->textEditor()->updateCursorKeywordSelection(m_keywordForSearch, state); + // 点击"下一个/上一个"可能是本会话首个查找动作(未经过回车路径 + // handleUpdateSearchKeyword 注册查找词)。若此时按不等分支清空 + // m_keywordForSearchAll,渲染页(MarkdownView)将收不到 + // highlightSearch 推送,只读视图下命中处无任何高亮(BUG-380705)。 + // 对齐回车路径的注册行为:查找词变更时注册当前词并同步高亮。 if (QString::compare(m_keywordForSearch, m_keywordForSearchAll, Qt::CaseInsensitive) != 0) { - qCDebug(appLog) << "m_keywordForSearchAll is not equal to m_keywordForSearch, clear it"; - m_keywordForSearchAll.clear(); - wrapper->textEditor()->clearFindMatchSelections(); - } else { - qCDebug(appLog) << "m_keywordForSearchAll is equal to m_keywordForSearch, highlight it"; - wrapper->textEditor()->highlightKeywordInView(m_keywordForSearchAll); + qCDebug(appLog) << "m_keywordForSearchAll is not equal to m_keywordForSearch, register current keyword"; + m_keywordForSearchAll = m_keywordForSearch; } + wrapper->textEditor()->highlightKeywordInView(m_keywordForSearchAll); wrapper->textEditor()->markAllKeywordInView(); diff --git a/tests/editor_wrapper/test_editwrapper.cpp b/tests/editor_wrapper/test_editwrapper.cpp index 6f9673bd..79539265 100644 --- a/tests/editor_wrapper/test_editwrapper.cpp +++ b/tests/editor_wrapper/test_editwrapper.cpp @@ -194,6 +194,7 @@ class RecordingRenderer : public IMarkdownRenderer { { ++scrollCalls; lastLine = line; + scrollLog << QString::number(line, 'f', 1); } void highlightSearch(const QString &keyword, int currentIndex, bool caseSensitive) override { @@ -215,6 +216,7 @@ class RecordingRenderer : public IMarkdownRenderer { int lastMaxWidth = -1; bool lastCenter = false; double lastLine = -1.0; + QStringList scrollLog; QString lastSearchKeyword; int lastSearchIndex = -2; bool lastSearchCaseSensitive = false; @@ -644,17 +646,22 @@ TEST_F(EditWrapperTest, PlainTextEmpty_DefaultTrue_AfterInsertFalse) // 断言(修复后期望):1) 渲染器收到 scrollToLine; // 2) 同步行号与匹配行距离 ≤ 半屏(居中语义),保证渲染页顶部对齐后匹配仍在屏内 // ============================================================================ -static void v6705FindNextScenario(EditWrapper *wrapper, RecordingRenderer *renderer, - int winW, int winH, const char *tag) +// 真实应用时序变体:flow 控制进入只读视图的路径 +// flow=0: Edit → 直接 ReadView(编辑页全程全宽) +// flow=1: Edit → LivePreview(编辑页半宽布局)→ ReadView(保留半宽陈旧几何) +// flow=2: 同 flow=1,但切 ReadView 后再把窗口放大(隐藏页几何完全陈旧) +static void v6705Setup(EditWrapper *wrapper, RecordingRenderer *renderer, + int winW, int winH, int flow) { renderer->ready = true; wrapper->setMarkdownRendererForTest(renderer); - // 长文档:500 行,needle 只在第 400 行(0 基块号 399) QString text; for (int i = 1; i <= 500; ++i) - text += (i == 400 ? QString("line %1 needle here\n").arg(i) - : QString("line %1 plain text content\n").arg(i)); + text += (i % 20 == 0 ? QString("# heading %1\n").arg(i) + : QString("plain line %1 lorem ipsum dolor sit amet consectetur\n").arg(i)) + + ((i == 150 || i == 300 || i == 450) ? QString("line %1 needlexx target here\n").arg(i) + : QString()); QTemporaryDir dir; const QString mdPath = dir.filePath("v6705.md"); @@ -667,32 +674,62 @@ static void v6705FindNextScenario(EditWrapper *wrapper, RecordingRenderer *rende wrapper->updatePath(mdPath, mdPath); wrapper->textEditor()->setPlainText(text); - // 真实应用时序:编辑页先以真实尺寸布局(Edit 模式可见),再切 ReadView 隐藏。 - // 未 show 的 widget 不派发 Resize 事件,隐藏页几何不会更新,必须先 show 顶层容器。 - if (QWidget *top = wrapper->parentWidget() ? wrapper->parentWidget() : wrapper) - top->resize(winW, winH); + QWidget *top = wrapper->parentWidget() ? wrapper->parentWidget() : wrapper; + top->resize(winW, winH); wrapper->resize(winW, winH); - if (wrapper->parentWidget()) - wrapper->parentWidget()->show(); - else - wrapper->show(); + top->show(); QApplication::processEvents(); wrapper->updateMarkdownRecognition(QStringLiteral("v6705.md"), QString()); + if (flow >= 1) + ASSERT_EQ(wrapper->viewMode(), ViewMode::LivePreview); wrapper->setViewMode(ViewMode::ReadView); ASSERT_EQ(wrapper->viewMode(), ViewMode::ReadView); + if (flow == 2) { + // 只读视图下放大窗口:隐藏的编辑页收不到 Resize 事件,几何停留在旧尺寸 + top->resize(winW + 500, winH + 300); + wrapper->resize(winW + 500, winH + 300); + QApplication::processEvents(); + } +} + +static void v6705FindNextScenario(EditWrapper *wrapper, RecordingRenderer *renderer, + int winW, int winH, const char *tag, int path, int flow = 0) +{ + v6705Setup(wrapper, renderer, winW, winH, flow); TextEdit *ed = wrapper->textEditor(); - ASSERT_EQ(ed->toPlainText().split('\n').size(), 501); // 500 行 + 末尾空串 ed->verticalScrollBar()->setValue(0); QApplication::processEvents(); renderer->scrollCalls = 0; renderer->lastLine = -1.0; - - // Act:真实查找路径(Window::handleFindKeyword → updateCursorKeywordSelection) - ed->updateCursorKeywordSelection(QStringLiteral("needle"), true); - QApplication::processEvents(); + renderer->scrollLog.clear(); + + // Act:三种真实路径 + // direct: updateCursorKeywordSelection 直调(两路径共享的核心) + // enter : 回车键路径 Window::handleUpdateSearchKeyword → TextEdit::highlightKeyword + // click : "下一个"按钮路径 Window::handleFindKeyword 的完整调用序列 + if (path == 0) { + ed->updateCursorKeywordSelection(QStringLiteral("needle"), true); + } else if (path == 1) { + ed->highlightKeyword(QStringLiteral("needle"), 0, Qt::CaseSensitive); + } else { + ed->saveMarkStatus(); + ed->updateCursorKeywordSelection(QStringLiteral("needle"), true); + // Window::handleFindKeyword 修复后(BUG-380705):无论 m_keywordForSearchAll + // 是否已注册,均注册当前词并调用 highlightKeywordInView(向渲染页推送 + // highlightSearch),此处对齐该调用序列 + ed->highlightKeywordInView(QStringLiteral("needle")); + ed->markAllKeywordInView(); + ed->renderAllSelections(); + ed->restoreMarkStatus(); + ed->updateLeftAreaWidget(); + wrapper->OnUpdateHighlighter(); + } + // QueuedConnection 的滚动条 lambda(OnUpdateHighlighter/高亮刷新)需跑满事件循环 + for (int i = 0; i < 3; ++i) + QApplication::processEvents(); const int matchBlock = ed->textCursor().blockNumber(); const int lineHeight = ed->cursorRect().height(); @@ -700,9 +737,11 @@ static void v6705FindNextScenario(EditWrapper *wrapper, RecordingRenderer *rende const int visibleLines = lineHeight > 0 ? viewportH / lineHeight : 0; const double dist = matchBlock - renderer->lastLine; - qInfo("[%s] win=%dx%d scrollCalls=%d lastLine=%.1f matchBlock=%d vbarValue=%d " - "topVisibleBlock=%d viewportH=%d lineHeight=%d visibleLines=%d matchToSyncTopDist=%.1f", - tag, winW, winH, renderer->scrollCalls, renderer->lastLine, matchBlock, + qInfo("[%s] win=%dx%d scrollCalls=%d scrollLog=[%s] lastLine=%.1f matchBlock=%d " + "vbarValue=%d topVisibleBlock=%d viewportH=%d lineHeight=%d visibleLines=%d " + "matchToSyncTopDist=%.1f", + tag, winW, winH, renderer->scrollCalls, + qPrintable(renderer->scrollLog.join(",")), renderer->lastLine, matchBlock, ed->verticalScrollBar()->value(), ed->topVisibleBlockNumber(), viewportH, lineHeight, visibleLines, dist); @@ -714,12 +753,35 @@ static void v6705FindNextScenario(EditWrapper *wrapper, RecordingRenderer *rende TEST_F(EditWrapperTest, V6705_ReadViewFindNext_SyncsRendererNearMatch_Maximized) { - v6705FindNextScenario(m_wrapper, &m_renderer, 1920, 1040, "maximized"); + v6705FindNextScenario(m_wrapper, &m_renderer, 1920, 1040, "maximized-direct", 0); } TEST_F(EditWrapperTest, V6705_ReadViewFindNext_SyncsRendererNearMatch_Normal) { - v6705FindNextScenario(m_wrapper, &m_renderer, 900, 600, "normal"); + v6705FindNextScenario(m_wrapper, &m_renderer, 900, 600, "normal-direct", 0); +} + +TEST_F(EditWrapperTest, V6705_ReadViewFindNext_EnterPath_Maximized) +{ + v6705FindNextScenario(m_wrapper, &m_renderer, 1920, 1040, "maximized-ENTER", 1); +} + +TEST_F(EditWrapperTest, V6705_ReadViewFindNext_ClickPath_Maximized) +{ + v6705FindNextScenario(m_wrapper, &m_renderer, 1920, 1040, "maximized-CLICK", 2); +} + +// 真实应用流程复刻:md 打开先进 LivePreview(编辑页半宽布局),再切只读视图。 +// 隐藏的编辑页保留 LivePreview 时代的半宽几何(行折叠更密)。 +TEST_F(EditWrapperTest, V6705_Flow1_LivePreviewThenReadView_Maximized_Click) +{ + v6705FindNextScenario(m_wrapper, &m_renderer, 1920, 1040, "flow1-LPthenRV-max-CLICK", 2, 1); +} + +// 真实应用流程复刻:普通窗口进只读视图后再最大化(隐藏编辑页几何完全陈旧)。 +TEST_F(EditWrapperTest, V6705_Flow2_MaximizeAfterReadView_Click) +{ + v6705FindNextScenario(m_wrapper, &m_renderer, 1000, 600, "flow2-maxAfterRV-CLICK", 2, 2); } // ============================================================================ diff --git a/tests/widgets_ut/test_window.cpp b/tests/widgets_ut/test_window.cpp index 16cd8932..54288092 100644 --- a/tests/widgets_ut/test_window.cpp +++ b/tests/widgets_ut/test_window.cpp @@ -61,6 +61,7 @@ #include "controls/jumplinebar.h" #include "editor/editwrapper.h" #include "editor/dtextedit.h" +#include "editor/markdown/imarkdownrenderer.h" #include "common/settings.h" #include "common/utils.h" #include "common/iflytek_ai_assistant.h" @@ -1646,11 +1647,11 @@ TEST_F(WindowTest, HandleFindKeywords_NextPrev_UpdateState) // Assert: 关键词记录 EXPECT_EQ(m_win->getKeywordForSearch(), QString("alpha")); - // Act: 向前查找(与 all 不同 → 清空 all 分支) + // Act: 向前查找(与 all 不同 → 注册当前词分支,BUG-380705:与回车路径对齐) m_win->m_keywordForSearchAll = QStringLiteral("other"); m_win->handleFindPrevSearchKeyword(QStringLiteral("beta")); EXPECT_EQ(m_win->getKeywordForSearch(), QString("beta")); - EXPECT_TRUE(m_win->m_keywordForSearchAll.isEmpty()); + EXPECT_EQ(m_win->m_keywordForSearchAll, QString("beta")); } TEST_F(WindowTest, HandleFindKeyword_MatchAllBranch_HighlightsAll) @@ -1667,6 +1668,57 @@ TEST_F(WindowTest, HandleFindKeyword_MatchAllBranch_HighlightsAll) EXPECT_EQ(m_win->m_keywordForSearchAll, QString("x")); } +// BUG-380705 残留缺陷回归:点击"下一个"作为会话内首个查找动作(未经回车注册 +// 查找词)时,渲染页必须收到 highlightSearch 推送,否则只读视图下命中处无高亮。 +// 记录型渲染器桩:仅记录 highlightSearch 入参。 +namespace { +class V6705RecordingRenderer : public IMarkdownRenderer { +public: + bool isReady() const override { return true; } + void setMarkdown(const QString &) override {} + void setMode(int) override {} + void applyTheme(const QVariantMap &) override {} + void setLayout(int, bool) override {} + void scrollToLine(double) override {} + void highlightSearch(const QString &keyword, int currentIndex, bool caseSensitive) override + { + ++calls; + lastKeyword = keyword; + lastIndex = currentIndex; + lastCaseSensitive = caseSensitive; + } + int calls = 0; + QString lastKeyword; + int lastIndex = -2; + bool lastCaseSensitive = false; +}; +} // namespace + +TEST_F(WindowTest, HandleFindKeyword_ClickFirst_PushesRendererHighlight) +{ + // Arrange: 全新查找会话(m_keywordForSearchAll 为空),直接点击"下一个" + const QString path = addFileTab(QStringLiteral("v6705.txt"), QByteArray("aa bb aa bb aa\n")); + EditWrapper *wrapper = m_win->currentWrapper(); + ASSERT_NE(wrapper, nullptr); + V6705RecordingRenderer renderer; + wrapper->setMarkdownRendererForTest(&renderer); + + // Act: 首个动作即点击下一个(未经过回车路径注册) + m_win->handleFindNextSearchKeyword(QStringLiteral("aa")); + + // Assert: 查找词已注册(滚动联动高亮可用) + EXPECT_EQ(m_win->getKeywordForSearchAll(), QString("aa")); + // 渲染页收到 highlightSearch 推送(修复前该路径从不推送 → 渲染页无高亮) + EXPECT_GE(renderer.calls, 1); + EXPECT_EQ(renderer.lastKeyword, QString("aa")); + // 当前命中为首个(0 基序号) + EXPECT_EQ(renderer.lastIndex, 0); + + // Act 2: 再次点击下一个 → 当前命中推进到第二个 + m_win->handleFindNextSearchKeyword(QStringLiteral("aa")); + EXPECT_EQ(renderer.lastIndex, 1); +} + TEST_F(WindowTest, HandleReplaceAll_ReplacesDocumentText) { // Arrange