diff --git a/.github/workflows/qt-cpp.yml b/.github/workflows/qt-cpp.yml index f76add4..cac61bf 100644 --- a/.github/workflows/qt-cpp.yml +++ b/.github/workflows/qt-cpp.yml @@ -42,7 +42,7 @@ jobs: "-DTERMFORGE_CONPTY_BACKEND_DIR=$dependencyRoot/conpty" ` "-DTERMFORGE_MSVC_RUNTIME_DIR=$dependencyRoot/crt" - name: Build Qt product and independent smoke - run: cmake --build build/qt-cpp --config Release --target termforge_qt_shell qt_cpp_shell_smoke qt_cpp_resize_smoke qt_unicode_input_test qt_unicode_render_test qt_terminal_modes_test qt_hidden_retention_test qt_history_navigation_test qt_selection_migration_test qt_clipboard_system_test --parallel 2 + run: cmake --build build/qt-cpp --config Release --target termforge_qt_shell qt_cpp_shell_smoke qt_cpp_resize_smoke qt_unicode_input_test qt_unicode_render_test qt_terminal_modes_test qt_hidden_retention_test qt_history_navigation_test qt_selection_migration_test qt_find_test qt_clipboard_system_test --parallel 2 - name: Record complete path-free runtime manifests run: | python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-bin/Release --executable termforge_qt_shell.exe @@ -63,6 +63,8 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-selection-bin/Release --executable qt_selection_migration_test.exe if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-find-bin/Release --executable qt_find_test.exe + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-clipboard-bin/Release --executable qt_clipboard_system_test.exe if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Preserve runtime file identities without binaries @@ -84,7 +86,7 @@ jobs: build/qt-cpp/qt-selection-bin/Release/runtime-manifest.json build/qt-cpp/qt-clipboard-bin/Release/runtime-manifest.json - name: Run Qt offscreen smoke in its owned Job - run: ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_unicode_render|qt_terminal_modes(_retained)?|qt_hidden_retention|qt_history_navigation|qt_selection_migration)$' --no-tests=error --verbose + run: ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_unicode_render|qt_terminal_modes(_retained)?|qt_hidden_retention|qt_history_navigation|qt_selection_migration|qt_find)$' --no-tests=error --verbose - name: Run hosted-only Qt system clipboard copy run: python src/qt/run_smoke.py build/qt-cpp/qt-clipboard-bin/Release/qt_clipboard_system_test.exe --hosted-copy - name: Run hosted Windows QPA product-writer clipboard copy diff --git a/CMakeLists.txt b/CMakeLists.txt index c0f5cb9..fa58ee0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -818,6 +818,20 @@ if(TERMFORGE_BUILD_QT_SHELL) "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$") set_tests_properties(qt_selection_migration PROPERTIES TIMEOUT 40 ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + add_executable(qt_find_test src/qt/find_test.cpp) + set_target_properties(qt_find_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qt-find-bin/$") + termforge_qt_entry(qt_find_test) + target_link_libraries(qt_find_test PRIVATE Qt6::Test) + add_custom_command(TARGET qt_find_test POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "$" + "$/platforms/" + VERBATIM) + add_test(NAME qt_find COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$") + set_tests_properties(qt_find PROPERTIES TIMEOUT 45 + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") add_executable(qt_clipboard_system_test src/qt/clipboard_system_test.cpp) set_target_properties(qt_clipboard_system_test PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qt-clipboard-bin/$") diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index e4de541..efe2e4e 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -24,6 +24,7 @@ | Qt 正式路径恢复 Windows 原生滚轮设置(#29 收尾) | [原生合同、注入回归与边界](../experiments/shell/results/2026-11-05-qt-wheel-native/README.md):每个真实滚轮事件读取 `SPI_GETWHEELSCROLLLINES`,0 禁用、正整数行数、`WHEEL_PAGESCROLL` 页步长=可见高度−1 至少1,查询失败回退3行;注入覆盖原生/0/3/页标记/半 detent/页高 24→20/查询失败/设置更新,真实 Qt+受控 child 历史与锚点回归 8/8 | 仅 Windows 11 x64 offscreen Qt 6.10.3;不修改宿主设置;页步长为 R1 产品合同非 WindTerm oracle;查询失败分支由注入验证而非真实策略变更;滚动条/翻页键/粘贴/呈现/DPI/Unicode/性能未覆盖;#317 待决、#27/#29 不关闭 | | Qt 历史/重排页选择源位置迁移(#29 切片) | [真实 Qt 鼠标/历史浏览、受控 child 与截图](../experiments/shell/results/2026-11-06-qt-selection-migration/README.md):反向跨软续行、部分列+硬行边界、60×20 重排/恢复、浏览页变化、无关输出、真实淘汰/ED3/RIS 失效、双会话隔离;字面文本与高亮列数独立核对,Qt 正式路径 9/9 | 单屏 offscreen、固定短 ASCII/单一软续行;宽字/结合字选择列、系统剪贴板/复制 UI、粘贴、选词选行、自动滚动、Find、物理呈现/DPI/其他尺寸/性能未覆盖;内部提取≠系统剪贴板;旧失败/A/B/C/#27/#29 状态不变 | | Qt 有界系统复制入口(#29/#30 切片) | [实现、fake 入口回归与 hosted 阻塞](../experiments/shell/results/2026-11-06-qt-system-copy/README.md):Ctrl+Shift+C/实际 Copy 菜单接入 CF_UNICODETEXT,空/失效/容量/失败分列;本地 fake writer 入口测试通过,Qt 正式路径 9/9;hosted-only CF 入口通过(QPA/offscreen 分层) | **系统 CF 已在一次性 GitHub-hosted Windows run `42160062253` 分层验证**:offscreen 注入 owner 与 Windows QPA 默认 writer/真实控件 HWND 均独立读回键盘/实际菜单/空 sentinel/占用失败/释放后成功/淘汰;首次 offscreen winId 非 HWND 的 `OpenClipboard:1400` 只修正测试 owner,不改产品;宽字/结合字复制列、占用重试、粘贴、Find、物理呈现/性能未覆盖;不把 offscreen 缓存当 CF;旧 B/CD/A/B/C/#27/#29/#30 状态不变 | +| Qt 有界字面量 Find(#29 切片) | [实现、本地确定性 child 与截图](../experiments/shell/results/2026-11-06-qt-find/README.md):Ctrl+Shift+F/查询/Previous/Next/Close,复用 TextSearch 的 4096 字节/1024 结果/字面量/ASCII case folding;源位置经 ViewMapping 在正常主屏与历史页高亮导航;本地 Qt 正式路径 10/10(qt_find 1/1) | 固定 ASCII 样例、离屏 Qt;中文/NFD 只声明字面标量匹配,不宣称字素/glyph 等价;不含选词/选行、滚动条/翻页键历史、TUI/备用屏 Find、物理呈现/DPI/性能阈值;不代表 #29/#30/R2 完成 | | 正式 Qt / 固定 ConPTY 固定 Unicode 样本贯通呈现与独立接收(#30 切片) | [链路、样本与实测](../experiments/shell/results/2026-11-04-qt-unicode-presentation/README.md):新增 `qt_unicode_render`,5 条固定样本经公开 Qt 事件→真实 child `ReadConsoleW` 精确接收→同字节 CR→CRLF 回显→核心解析→`TerminalWidget` 离屏抓图;receiver/echo SHA-256 与源字节逐字节一致,CJK 实测 fallback `Alibaba PuHuiTi`、emoji `Segoe UI Emoji`(advance 21>span 18 但无裁剪参考墨迹 3..17 未越格),结合标 `marks=1`,第二条空会话 0 字节且无样本文字;Qt 正式路径 6/6 | U+200D/U+FE0F 仍被 recovery parser 替换为 U+FFFD(只记录,不批准新聚类/宽度策略);offscreen + 宿主 fallback 依赖、`presented_done=null`,不覆盖物理呈现/原生 IME/真实 DPI/完整字素或 #30/#155 验收 | | 隐藏主屏纯高度变化的正文保留(#27 工程合同) | [RED/GREEN、命令与边界](../experiments/core/results/2026-11-04-hidden-height-retention/README.md):修复前 `resize_hidden_height_test` 证明隐藏 live/历史被按未变宽度裁剪并丢失保留尾;`resize_preserving` 改为只在宽度实际变化时适配隐藏帧后,三场景通过并注册 `retained_resize_hidden_height_core`;组合回放 20/20、事务检查不变,正式 Qt CTest 5/5 | 工程不变量;原版对照见下一行。2026-11-04 用户确认保留完整正文、接受原版返回裁剪差异,工程身份不变;固定七阶段正式 Qt 路径已离屏核对(见下方正式路径行),物理呈现/DPI 未覆盖。未覆盖备用屏后续输出、保存光标交叉、Unicode、历史淘汰、Qt 物理呈现/DPI 或性能;#27/#29 不关闭 | | 正式 Qt / 固定 ConPTY 隐藏帧七阶段完整正文(#27/#29 正式路径切片) | [场景、阶段与显示/命中](../experiments/shell/results/2026-11-04-qt-hidden-retention/README.md):真实 child 写入 91×20 固定 75 B 正文→公开 Qt 缩窄 61→`1049h`→仅高度 20→14→20→`1049l`→恢复宽度 91;58 检查 0 失败,2 次运行除 `child_pid` 外一致;阶段 1/2/6/7 逻辑正文 SHA `0e683ff0…` 相同,保留行 id `12`、91-Cell 源行与历史 5 行前缀保持,显示页/光标/命中均映射同一源身份;Qt 正式路径 7/7(8.99 s)、核心 41/41(23.05 s) | 仅 offscreen 固定样本;非物理呈现(`presented_done=null`)、DPI、IME 或性能验收;child 原生行单独记录,不作为保留文本 oracle;原严格失败、补充审计与 `semantic_acceptance=false` 不改写;#27/#29/#30/#7 不关闭 | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index c988297..96ebad9 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -23,7 +23,7 @@ | 新路线迁移入口 | 当前状态与边界 | 目标差距 | 路标 | |---|---|---|---| -| 自主 C++ / Qt / 固定微软 ConPTY | **实现中,未验收**:[显式 Qt 入口](../src/qt/README.md)提供默认 80×24、随可用区域变化的 cmd 标签、基本 ASCII 与[限定 Unicode 文本字节](../experiments/shell/results/2026-10-29-qt-unicode-input/README.md)、16 色及各会话独立有界队列、[输入法组合/提交/取消(事件级 + 固定拼音验证)](../research/ui/results/2026-10-30-qt-ime-fixed-pinyin/README.md),并接入[无修饰导航键与基础鼠标正反拖选高亮](../experiments/shell/results/2026-11-01-r2-core-input/README.md);[基础动态尺寸验证](../experiments/shell/results/2026-10-29-qt-resize/README.md)分离窗口/核心/transport 与真实 child,覆盖极小、重复、隐藏页和关闭交叉,并复验原标签算术及事件循环回收。678 原生极窄失败保留;0ff 仅修订字面子区域/顶层实际区域夹具,另有[冻结包一次隔离原生成功](../experiments/shell/results/2026-10-29-qt-resize/native-validation.json):实际顶层区域、core/transport/child 一致,窗口仅 DPI 96。旧尺寸结果保留各自源码;[Unicode 联合源码的一次完整61项](../experiments/shell/results/2026-10-29-qt-unicode-input/validation.json)分开 Qt3项与其余58项既有/独立诊断,13条内部skip保留。正式尺寸事务已改走显式保留入口并绘制独立显示投影/显示光标:[只缩宽的保留行契约](../experiments/shell/results/2026-11-03-qt-retained-display/README.md)在真实 child/固定 ConPTY 核对 12/12 检查,Qt 正式路径 CTest 5/5;旧六阶段 native-sync 失败保留并归类为原生重排网格与保留行合同的坐标差。Qt 已接入有限历史浏览:滚轮与 `Ctrl+Shift+Home/End` 通过同一 `HistoryViewport` 源锚点浏览保留历史,窄/高 resize、新输出、淘汰/ED3/RIS 下的锚点规则由真实 Qt 事件和受控 child 回归核对([固定结果](../experiments/shell/results/2026-11-05-qt-history-anchor/README.md));Windows 滚轮直接读取 `SPI_GETWHEELSCROLLLINES`,恢复 0 禁用、正整数行数与 `WHEEL_PAGESCROLL` 页步长,查询失败按 R1 回退 3 行([原生滚轮结果](../experiments/shell/results/2026-11-05-qt-wheel-native/README.md));历史/软续接/显示重排页的选区源位置迁移已端到端覆盖(正反/部分列/跨软硬行、重排/浏览/无关输出、淘汰/ED3/RIS 失效、双会话隔离;内部提取非系统剪贴板,见[固定结果](../experiments/shell/results/2026-11-06-qt-selection-migration/README.md));系统复制入口已接入 Ctrl+Shift+C/实际 Copy 菜单的有界 CF_UNICODETEXT 写入,并在 hosted Windows CI 以独立 CF 读回验证键盘/实际菜单/空选区/占用失败与释放后成功/淘汰 sentinel(见[系统复制结果](../experiments/shell/results/2026-11-06-qt-system-copy/README.md));滚动条/翻页键历史与粘贴回底未迁移 | 分屏、其他 shell、工作区恢复、同时缩宽+缩高的原版视口/滚动与后续 CUP 证据(已有固定**写后端点**样本:一次动作 91×20→61×14 后执行 CUP 7;2+DQ,观测 scroll 5、DSR 7;4、62 字符行;无 pre-write 原版正文,裁剪归因未知;见[双轴结果](../research/ui/results/2026-11-04-resize-dual-axis-reference/README.md))、完整 reflow、Qt 滚动条/翻页键历史与自动滚动、选词/选行与系统复制/粘贴、Find、完整 Unicode 字素/绘制、候选窗与真实 IME 交叉(已有固定拼音;[固定时间点对照](research/qt-ime-candidate-panel-reference-2026-11-01.md)中输出后两侧都保持旧位置、resize 后延后点都更新;字体阶段原版未触发,面板身份未证明,且对照 Qt 源为历史 5fab30b)、真实 DPI、应用兼容与性能仍需实现或迁移;完整 WindTerm 标签/尺寸行为对照未完成;当前不是可发布开发包 | R2 | +| 自主 C++ / Qt / 固定微软 ConPTY | **实现中,未验收**:[显式 Qt 入口](../src/qt/README.md)提供默认 80×24、随可用区域变化的 cmd 标签、基本 ASCII 与[限定 Unicode 文本字节](../experiments/shell/results/2026-10-29-qt-unicode-input/README.md)、16 色及各会话独立有界队列、[输入法组合/提交/取消(事件级 + 固定拼音验证)](../research/ui/results/2026-10-30-qt-ime-fixed-pinyin/README.md),并接入[无修饰导航键与基础鼠标正反拖选高亮](../experiments/shell/results/2026-11-01-r2-core-input/README.md);[基础动态尺寸验证](../experiments/shell/results/2026-10-29-qt-resize/README.md)分离窗口/核心/transport 与真实 child,覆盖极小、重复、隐藏页和关闭交叉,并复验原标签算术及事件循环回收。678 原生极窄失败保留;0ff 仅修订字面子区域/顶层实际区域夹具,另有[冻结包一次隔离原生成功](../experiments/shell/results/2026-10-29-qt-resize/native-validation.json):实际顶层区域、core/transport/child 一致,窗口仅 DPI 96。旧尺寸结果保留各自源码;[Unicode 联合源码的一次完整61项](../experiments/shell/results/2026-10-29-qt-unicode-input/validation.json)分开 Qt3项与其余58项既有/独立诊断,13条内部skip保留。正式尺寸事务已改走显式保留入口并绘制独立显示投影/显示光标:[只缩宽的保留行契约](../experiments/shell/results/2026-11-03-qt-retained-display/README.md)在真实 child/固定 ConPTY 核对 12/12 检查,Qt 正式路径 CTest 5/5;旧六阶段 native-sync 失败保留并归类为原生重排网格与保留行合同的坐标差。Qt 已接入有限历史浏览:滚轮与 `Ctrl+Shift+Home/End` 通过同一 `HistoryViewport` 源锚点浏览保留历史,窄/高 resize、新输出、淘汰/ED3/RIS 下的锚点规则由真实 Qt 事件和受控 child 回归核对([固定结果](../experiments/shell/results/2026-11-05-qt-history-anchor/README.md));Windows 滚轮直接读取 `SPI_GETWHEELSCROLLLINES`,恢复 0 禁用、正整数行数与 `WHEEL_PAGESCROLL` 页步长,查询失败按 R1 回退 3 行([原生滚轮结果](../experiments/shell/results/2026-11-05-qt-wheel-native/README.md));历史/软续接/显示重排页的选区源位置迁移已端到端覆盖(正反/部分列/跨软硬行、重排/浏览/无关输出、淘汰/ED3/RIS 失效、双会话隔离;内部提取非系统剪贴板,见[固定结果](../experiments/shell/results/2026-11-06-qt-selection-migration/README.md));系统复制入口已接入 Ctrl+Shift+C/实际 Copy 菜单的有界 CF_UNICODETEXT 写入,并在 hosted Windows CI 以独立 CF 读回验证键盘/实际菜单/空选区/占用失败与释放后成功/淘汰 sentinel(见[系统复制结果](../experiments/shell/results/2026-11-06-qt-system-copy/README.md));正式 Qt Find 已接 Ctrl+Shift+F/查询/上一项/下一项/关闭,复用 TextSearch 的 4096 字节/1024 结果/字面量/ASCII case folding,并通过源位置+ViewMapping 在正常主屏与历史页高亮导航(本地确定性 child 10/10,见[Find 结果](../experiments/shell/results/2026-11-06-qt-find/README.md);不含选词/选行/物理呈现/性能阈值);滚动条/翻页键历史与粘贴回底未迁移 | 分屏、其他 shell、工作区恢复、同时缩宽+缩高的原版视口/滚动与后续 CUP 证据(已有固定**写后端点**样本:一次动作 91×20→61×14 后执行 CUP 7;2+DQ,观测 scroll 5、DSR 7;4、62 字符行;无 pre-write 原版正文,裁剪归因未知;见[双轴结果](../research/ui/results/2026-11-04-resize-dual-axis-reference/README.md))、完整 reflow、Qt 滚动条/翻页键历史与自动滚动、选词/选行与系统复制/粘贴、Find、完整 Unicode 字素/绘制、候选窗与真实 IME 交叉(已有固定拼音;[固定时间点对照](research/qt-ime-candidate-panel-reference-2026-11-01.md)中输出后两侧都保持旧位置、resize 后延后点都更新;字体阶段原版未触发,面板身份未证明,且对照 Qt 源为历史 5fab30b)、真实 DPI、应用兼容与性能仍需实现或迁移;完整 WindTerm 标签/尺寸行为对照未完成;当前不是可发布开发包 | R2 | 自主 C++ 核心已具备双缓冲与限定 `?1049`、DECAWM、DECOM、DECCKM/DECTCEM、每屏 DECSTBM 边距、字符/行插删、ED3/RIS、DSR 6n 和部分 SGR 状态。依据分别见 [1049](../experiments/core/results/2026-10-29-cpp-1049/README.md)、[DECAWM](../experiments/core/results/2026-10-29-cpp-decawm/README.md)及[支持表](../experiments/core/README.md)。[原版每屏 origin 对照](../research/ui/results/2026-11-02-mode-state-reference/README.md)确认备用屏进入时关闭 origin、主屏返回恢复自身状态及固定样本的 margin 下界;核心已改为[每屏保存 origin](../experiments/core/results/2026-11-02-origin-frame/README.md)。区域 LF、IL/DL 后的精确行序,以及[区域自动换行续接、RIS/ED3 历史结构发布](../experiments/core/results/2026-11-02-r27-mode-cross/README.md)已有修正。完整网格/样式、resize 和运输交叉仍由 #27/#29 验收,不能据此声明完整 TUI 通过。 diff --git a/experiments/shell/results/2026-11-06-qt-find/README.md b/experiments/shell/results/2026-11-06-qt-find/README.md new file mode 100644 index 0000000..86cda44 --- /dev/null +++ b/experiments/shell/results/2026-11-06-qt-find/README.md @@ -0,0 +1,41 @@ +# 正式 Qt:有界字面量 Find(正常主屏 + 历史显示页) + +本切片把既有 `TextSearch` 的有界字面量搜索接入正式 Qt:`Ctrl+Shift+F` 打开当前会话 Find 面板,复用 4096 UTF-8 字节查询上限、1024 结果上限、字面量/无规范化/ASCII case folding、循环/截断/持续扫描策略;命中保存源 `TextPosition`,通过 `HistoryViewport` 与当前 `ViewMapping` 导航和高亮,不保存显示行号。 + +## 实现 + +- `TerminalWidget`:Find 面板含查询框、Previous/Next/Close、Match case 和状态行;`Ctrl+Shift+F` 打开并聚焦,Escape 关闭并恢复终端焦点。 +- 搜索按 `TextSearch::step` 的 4096 单位预算分片,计时器继续;空/超限/无命中/截断/失效状态来自既有合同。 +- 输出变化调用 `TextSearch::notify_change`,重扫/失效;当前命中在 `TextDocument` 上以源位置 live-validate,再经当前显示页 `ViewMapping` 投影;`reveal` 定位源行,历史/当前页均可用。 +- 查询只在 Find 面板内处理,不作为终端输入;复制仍走已合入的源选择/系统 Copy。 + +## 验证 + +- `qt_find` 1/1,Qt 正式路径 10/10;two real selection children with independent input recording:multiple `SEL_N_` matches 的顺序/index、双向 wrap、精确源文本/列与显示宽度、历史 reveal、缩窄/扩大同源迁移、淘汰不复活、RIS 旧 epoch 失效;两个会话查询隔离,查询零泄漏到 child,Escape 后 `Z` 恰好一次只到正确 child;空查询、实际 Match case、4097-byte/中文/NFD 超限拒绝、Return/F3/Shift+F3。未触个人剪贴板/桌面。 +- **1024 截断/有限循环已通过真实入口**:第 3 个真实 child 输出 52 行 `X...ZZ`(52 命中,>4096 字节扫描)+ 26 行 × 40 个 `AB`(1040 命中);正式查询框输入 `ZZ` 在 25ms 测试节拍下保留 Searching/部分命中,公开 Next 不越出已扫描前缀;输入 `AB` 达到 1024 capped、提示截断,首尾 Previous/Next wrap 有限(1023↔0)。未改产品上限/预算/注入文档。 +- 截图:[find-panel.png](find-panel.png)(同一真实 Qt 测试窗口,历史命中态;离屏,不代表物理 DPI/呈现)。 +- 当前 head CI 与源码/构建身份见 [validation.json](validation.json);本切片不代表 #29/#30/R2 完成。 + +## 边界 + +- 固定样例/ASCII 字面量与离屏 Qt;中文/NFD 只声明字面标量匹配,不宣称字素/glyph/宽度等价。 +- 不覆盖选词/选行、滚动条/翻页键历史、备用屏/TUI Find、物理呈现/DPI、完整 Unicode 字素或性能阈值;不新增数值性能验收。 +- 1024 截断现在由真实 child/查询入口覆盖;固定 child 仍受 100 行历史 + 24 live 行容量约束,本次负载 78 行(52 ZZ + 26 AB)在容量内。 +- 旧 B/CD、192.168.3.210 校准/v2、A/B/C、旧六阶段失败、#27/#29/#30/#35/#7 状态不变。 + + +## 原派单缺口 → 证据 + +状态:五项均为**已闭合**;无待完成项(边界外能力仍不宣称)。 + +| 原要求 | 本次证据 | +|---|---| +| 多命中准确顺序/循环/当前页与历史页 | 多个 `SEL_N_` 顺序/index、双向 wrap、历史 reveal、精确源文本/列和显示宽度 | +| 缩窄扩大同源迁移、写入/淘汰失效、旧 epoch | resize 前后源 range 不变;淘汰后无复活;RIS 后旧位置拒绝、新 epoch 可查 | +| 双会话隔离、Escape 后输入只到正确 child | 两个真实 child + 独立输入文件;查询零泄漏,Escape 后 `Z` 恰好一次到目标 | +| 空查询、大小写、1024 截断/有限循环、4096 字节边界 | 空查询/Match case/Return/F3/Shift+F3;真实键盘 4096 合法/4097 拒绝,中文/NFD 经 Qt IME commit 合法与超限;真实 child 1040 命中达到 1024 capped,首尾有限 wrap;>4096 扫描保留 Searching/部分命中且不越界 | +| 公开入口而非测试 setter 替代 | 首次查询、空查询、大小写、ASCII 键盘超限、中文/NFD IME commit、Return/F3 均经真实查询框;setter 仅用于失效/前置观测 | + +## 身份 + +源码/测试/构建身份见 [validation.json](validation.json);截图为本机同一 Qt 测试窗口的离屏历史命中态。 diff --git a/experiments/shell/results/2026-11-06-qt-find/find-panel.png b/experiments/shell/results/2026-11-06-qt-find/find-panel.png new file mode 100644 index 0000000..8ddf1ee Binary files /dev/null and b/experiments/shell/results/2026-11-06-qt-find/find-panel.png differ diff --git a/experiments/shell/results/2026-11-06-qt-find/validation.json b/experiments/shell/results/2026-11-06-qt-find/validation.json new file mode 100644 index 0000000..15c796f --- /dev/null +++ b/experiments/shell/results/2026-11-06-qt-find/validation.json @@ -0,0 +1,87 @@ +{ + "schema": "termforge-qt-find-validation-v1", + "baseline_main": "3b063e3c8f6c57cd01a0c5339c2145969b92aa86", + "source_commit": "ea6425a", + "source_hash_note": "Hashes are SHA-256 of committed LF Git blob content at ea6425a; this validation is a docs-only follow-up.", + "source_files_sha256": { + "src/qt/terminal_widget.hpp": "90ea07333992b7e7f380e17ae2fc8b647a74c4f0bbfdb511d7a5d17e0b7976fa", + "src/qt/find_test.cpp": "25fc9922d7e007fe8b65860079c59dd9d0de6e257e4f4b930ee205f41330b265", + "CMakeLists.txt": "93c6977a6978c061bdf675eac3bcf3b61ee3b775f9454676f86c0930bf3ab2d9", + ".github/workflows/qt-cpp.yml": "e1a0f4c0496a02bb2434e71befcaaf0a114808629950d10c59f3ff29fbbefded", + "src/qt/selection_fixture.hpp": "1f44bbed1358442febacca5f9ee89134508cd8bf75fb6e67a863f9351d07e222" + }, + "environment": { + "os": "Windows 11 x64 build 26200", + "generator": "Visual Studio 17 2022", + "compiler": "MSVC 19.42.34433.0", + "qt": "6.10.3 offscreen", + "conpty": "1.25.260930003", + "crt": "14.51.36247.0" + }, + "entries": { + "shortcut": "Ctrl+Shift+F actual Qt key event", + "query": "QLineEdit in current-session Find panel", + "navigation": "actual Previous/Next buttons and Return/Shift+Return", + "close": "Escape or Close restores terminal focus" + }, + "checks": { + "shortcut_open": true, + "query_focus": true, + "literal_source_hit": true, + "history_reveal": true, + "painted_highlight_projection": true, + "previous_next": true, + "no_match": true, + "escape_focus": true, + "query_limit_4096": true, + "query_sent_to_child": false, + "system_clipboard_touched": false, + "offscreen_only": true, + "multiple_matches_order": true, + "exact_painted_width": true, + "wrap_previous_next": true, + "resize_source_migration": true, + "eviction_no_resurrection": true, + "epoch_invalidation": true, + "two_real_sessions": true, + "query_zero_leak_independent_input": true, + "escape_input_once_correct_child": true, + "empty_query": true, + "case_toggle": true, + "limit_ascii_4097_rejected": true, + "limit_chinese_bytes_rejected": true, + "limit_nfd_bytes_rejected": true, + "return_f3_shift_f3": true, + "real_1024_cap_via_child": true, + "cap_notice_and_finite_wrap": true, + "long_scan_searching_partial_hits": true, + "partial_navigation_no_unscanned_wrap": true, + "real_keyboard_limit_text": true, + "real_ime_commit_chinese_nfd": true, + "legal_and_over_limit_preserved": true + }, + "tests": { + "qt_find": { + "passed": 1, + "failed": 0, + "seconds": 7.38 + }, + "qt_formal_path_regression": { + "passed": 10, + "failed": 0, + "seconds": 20.6 + } + }, + "evidence": { + "readme": "README.md", + "screenshot": "find-panel.png", + "fixture": "deterministic selection child (SEL_SOFT_ source hit; query not sent as terminal input)" + }, + "limitations": [ + "The 1024 cap and finite wrap are now exercised through the real Qt query entry and a bounded real child (26 rows x 40 non-overlapping AB hits = 1040); a 25 ms test-only pacing observes a >4096-byte scan in progress with partial hits, without changing product budgets.", + "Fixed ASCII literal sample and offscreen Qt; Chinese/NFD only claims literal scalar matching.", + "No word/line selection, scrollbar/page-key history, alternate-screen/TUI Find, physical presentation/DPI, full Unicode graphemes or performance thresholds.", + "This slice does not close #29/#30/R2; old B/CD, remote VM calibration/v2, A/B/C and old six-stage failures remain unchanged.", + "Windows/line capacity used here: 80 columns, 40 AB hits per row, 26 rows = 1040 hits; 52 ZZ rows (4160 bytes) plus 26 AB rows remain within the 100-row history + live rows. No injected document or product cap change." + ] +} \ No newline at end of file diff --git a/src/qt/find_test.cpp b/src/qt/find_test.cpp new file mode 100644 index 0000000..38a82fc --- /dev/null +++ b/src/qt/find_test.cpp @@ -0,0 +1,366 @@ +// Formal Qt Find acceptance: public shortcut/query, multiple source-ordered +// matches, wrap, history/current-page highlight and scroll, resize migration, +// invalidation/eviction/epoch, two real sessions and actual child input bytes. +#include "terminal_widget.hpp" +#include "selection_fixture.hpp" +#include "../../experiments/core/text_document.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using termforge::TextDocument; +using termforge::qt::SessionModel; +using termforge::qt::TerminalWidget; +using termforge::qt::test::selection_child; + +namespace { +void require(bool condition, const std::string& message) { + if (!condition) throw std::runtime_error(message); +} +std::string read_input(const QString& path) { + QFile file(path); + if (!file.exists()) return {}; + require(file.open(QIODevice::ReadOnly), "find input record unavailable"); + return file.readAll().toStdString(); +} +std::string text_at(SessionModel& model, uint64_t line_id) { + const TextDocument document(model.screen()); + const auto index = document.index_of(line_id); + require(index.has_value(), "find hit line is not in the document"); + return document.line(*index).projection.text; +} +void await(const std::function& predicate, SessionModel& model, QElapsedTimer& lifecycle, const char* message) { + QElapsedTimer phase; phase.start(); + while (!predicate() && phase.elapsed() < 10000 && lifecycle.elapsed() < 30000) { + QTest::qWait(10); + if (!model.error().isEmpty()) throw std::runtime_error(model.error().toStdString()); + } + require(predicate(), message); +} +void await_find_done(TerminalWidget& terminal, SessionModel& model, QElapsedTimer& lifecycle, const char* message) { + await([&] { return !terminal.find_status_for_test().startsWith(QStringLiteral("Searching")); }, + model, lifecycle, message); +} +size_t line_rank(SessionModel& model, uint64_t id) { + const TextDocument document(model.screen()); + return document.index_of(id).value_or(std::numeric_limits::max()); +} +void clear_query(QLineEdit* query) { + query->setFocus(); + QTest::keyClick(query, Qt::Key_A, Qt::ControlModifier); + QTest::keyClick(query, Qt::Key_Backspace); +} +void type_ascii(QLineEdit* query, const QString& text) { + clear_query(query); + QTest::keyClicks(query, text); +} +void commit_ime(QLineEdit* query, const QString& text) { + clear_query(query); + QInputMethodEvent event; + event.setCommitString(text); + QApplication::sendEvent(query, &event); +} +void start_child(SessionModel& model, TerminalWidget& terminal, QWidget& window, + const std::wstring& executable, const QString& record, const std::wstring& working, const char* message) { + const QString args = QStringLiteral("--selection-child \"%1\"").arg(record); + require(model.start(executable, args.toStdWString(), working), message); + window.show(); terminal.setFocus(); +} +} // namespace + +int main(int argc, char** argv) { + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); + if (argc >= 2 && std::string_view(argv[1]) == "--selection-child") { + try { return selection_child(argc == 3 ? QString::fromLocal8Bit(argv[2]).toStdWString() : std::wstring{}); } + catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 3; } + } + if (argc != 1) { std::cerr << "usage: qt_find_test\n"; return 2; } + char gate[3]{}; DWORD count = 0; BOOL owned = FALSE; + if (!ReadFile(GetStdHandle(STD_INPUT_HANDLE), gate, sizeof(gate), &count, nullptr) || + count != sizeof(gate) || std::string_view(gate, count) != "GO\n" || + !IsProcessInJob(GetCurrentProcess(), nullptr, &owned) || !owned) { + std::cerr << "find test requires its owned Job supervisor\n"; return 2; + } + QApplication app(argc, argv); + QApplication::setQuitOnLastWindowClosed(false); + try { + QElapsedTimer lifecycle; lifecycle.start(); + QTemporaryDir workspace; + require(workspace.isValid(), "find workspace unavailable"); + wchar_t executable[32768]{}; + const DWORD length = GetModuleFileNameW(nullptr, executable, 32768); + require(length && length < 32768, "find executable unavailable"); + const QString first_input = workspace.path() + "/first-input.bin"; + const QString second_input = workspace.path() + "/second-input.bin"; + + SessionModel first; QWidget first_window; TerminalWidget terminal(first, &first_window); + first_window.resize(1200, 900); + start_child(first, terminal, first_window, executable, first_input, + workspace.path().toStdWString(), "find first child did not start"); + QTest::qWait(700); + require(first.running(), "find first child exited before search"); + + SessionModel second; QWidget second_window; TerminalWidget second_terminal(second, &second_window); + second_window.resize(1200, 900); + start_child(second, second_terminal, second_window, executable, second_input, + workspace.path().toStdWString(), "find second child did not start"); + QTest::qWait(700); + require(second.running(), "find second child exited before search"); + + first_window.activateWindow(); terminal.setFocus(); QTest::qWait(30); + QTest::keyClick(&terminal, Qt::Key_F, Qt::ControlModifier | Qt::ShiftModifier); + require(terminal.find_visible_for_test(), "Ctrl+Shift+F did not open Find"); + auto* first_query = first_window.findChild(QStringLiteral("findQuery")); + require(first_query && first_query->hasFocus(), "Find query did not take focus"); + QTest::keyClicks(first_query, QStringLiteral("SEL_N_")); + await_find_done(terminal, first, lifecycle, "multi-match Find did not complete"); + const size_t total = terminal.find_match_count_for_test(); + require(total > 4, "Find did not return multiple matches"); + auto range = terminal.find_current_range_for_test(); + require(range.has_value(), "Find did not select a current match"); + auto* prev_button = first_window.findChild(QStringLiteral("findPrevious")); + for (int guard = 0; guard < static_cast(total) + 2 && terminal.find_current_index_for_test() != 0; ++guard) + QTest::mouseClick(prev_button, Qt::LeftButton); + require(terminal.find_current_index_for_test() == 0, "could not position at the first match"); + range = terminal.find_current_range_for_test(); + require(range.has_value(), "first match lost after positioning"); + + size_t expected_index = 0; + uint64_t prior_rank = line_rank(first, range->begin.line_id); + for (int step = 0; step < 3; ++step) { + auto* next_button = first_window.findChild(QStringLiteral("findNext")); + require(next_button, "Find Next button missing"); + QTest::mouseClick(next_button, Qt::LeftButton); + require(terminal.find_current_index_for_test() == ++expected_index, "Next index is not sequential"); + range = terminal.find_current_range_for_test(); + require(range.has_value(), "Next lost the current match"); + const std::string line = text_at(first, range->begin.line_id); + require(line.substr(range->begin.column, range->end.column - range->begin.column) == "SEL_N_", + "Next hit does not point at the expected source literal"); + const uint64_t rank = line_rank(first, range->begin.line_id); + require(rank > prior_rank, "Next hit is not in document order"); + prior_rank = rank; + } + auto spans = terminal.find_spans_for_test(); + require(!spans.empty(), "current hit has no painted projection"); + size_t projected = 0; + for (const auto& span : spans) projected += span.column_end - span.column_begin; + require(projected == range->end.column - range->begin.column, + "painted highlight width does not match the source hit"); + require(!terminal.history_following(), "history hit did not leave the live bottom"); + first_window.grab().save(QStringLiteral("find-panel.png")); + + for (int guard = 0; guard < static_cast(total) + 2 && terminal.find_current_index_for_test() != 0; ++guard) + QTest::mouseClick(first_window.findChild(QStringLiteral("findPrevious")), Qt::LeftButton); + require(terminal.find_current_index_for_test() == 0, "could not return to the first hit"); + QTest::mouseClick(first_window.findChild(QStringLiteral("findPrevious")), Qt::LeftButton); + require(terminal.find_last_wrapped_for_test(), "Previous from the first hit did not wrap"); + require(terminal.find_current_index_for_test() == total - 1, "wrapped Previous index is not the last hit"); + auto* next_button = first_window.findChild(QStringLiteral("findNext")); + QTest::mouseClick(next_button, Qt::LeftButton); + require(terminal.find_last_wrapped_for_test(), "Next from the last hit did not wrap"); + require(terminal.find_current_index_for_test() == 0, "wrapped Next index is not the first hit"); + + QTest::keyClick(first_query, Qt::Key_Return); + require(terminal.find_current_index_for_test() == 1, "Return did not move to Next"); + QTest::keyClick(first_query, Qt::Key_F3); + require(terminal.find_current_index_for_test() == 2, "F3 did not move to Next"); + QTest::keyClick(first_query, Qt::Key_F3, Qt::ShiftModifier); + require(terminal.find_current_index_for_test() == 1, "Shift+F3 did not move to Previous"); + + const auto before_resize = *terminal.find_current_range_for_test(); + const std::string before_text = text_at(first, before_resize.begin.line_id); + first_window.resize(760, 720); QTest::qWait(80); + const auto narrow = terminal.find_current_range_for_test(); + require(narrow && narrow->begin == before_resize.begin && narrow->end == before_resize.end, + "narrow resize changed the source hit"); + first_window.resize(1200, 900); QTest::qWait(80); + const auto restored = terminal.find_current_range_for_test(); + require(restored && restored->begin == before_resize.begin && restored->end == before_resize.end, + "restored resize changed the source hit"); + require(text_at(first, before_resize.begin.line_id) == before_text, "resize changed the hit line text"); + require(!terminal.find_spans_for_test().empty(), "resized hit has no projection"); + + QTest::keyClick(first_query, Qt::Key_A, Qt::ControlModifier); + QTest::keyClick(first_query, Qt::Key_Backspace); + require(terminal.find_match_count_for_test() == 0, "empty query retained matches"); + require(terminal.find_status_for_test() == QStringLiteral("No query"), "empty query lacks explicit status"); + + type_ascii(first_query, QStringLiteral("sel_head")); + await_find_done(terminal, first, lifecycle, "case-sensitive no-match did not finish"); + require(terminal.find_match_count_for_test() == 0, "case-sensitive query unexpectedly matched"); + auto* match_case = first_window.findChild(QStringLiteral("findCase")); + require(match_case && match_case->isChecked(), "Match case is not the explicit default"); + QTest::mouseClick(match_case, Qt::LeftButton); + await([&] { return terminal.find_match_count_for_test() > 0; }, first, lifecycle, + "unchecked Match case did not find the folded hit"); + QTest::mouseClick(match_case, Qt::LeftButton); + + // Real edit path: exactly 4096 ASCII bytes is legal, one more is rejected + // with the full input preserved and an explicit status. + type_ascii(first_query, QString(4096, QLatin1Char('x'))); + require(terminal.find_query_text_for_test().size() == 4096, "4096-byte legal query was changed"); + require(!terminal.find_status_for_test().contains(QStringLiteral("4096")), + "legal 4096-byte query was rejected"); + QTest::keyClicks(first_query, QStringLiteral("x")); + require(terminal.find_query_text_for_test().size() == 4097, "4097-byte query was truncated"); + require(terminal.find_status_for_test().contains(QStringLiteral("4096")), + "4097-byte query did not report the existing limit"); + + // Chinese/NFD input uses Qt input-method commit events (not physical IME). + commit_ime(first_query, QString(1365, QChar(0x4e2d))); // 4095 UTF-8 bytes + require(terminal.find_query_text_for_test().size() == 1365, "legal Chinese query was changed"); + require(!terminal.find_status_for_test().contains(QStringLiteral("4096")), + "legal 4095-byte Chinese query was rejected"); + commit_ime(first_query, QString(1366, QChar(0x4e2d))); // 4098 bytes + require(terminal.find_query_text_for_test().size() == 1366, "over-limit Chinese query was truncated"); + require(terminal.find_status_for_test().contains(QStringLiteral("4096")), + "over-limit Chinese query did not report the limit"); + commit_ime(first_query, QString(QString::fromUtf8("e\xcc\x81")).repeated(2049)); + require(terminal.find_query_text_for_test() == QString(QString::fromUtf8("e\xcc\x81")).repeated(2049), + "over-limit NFD query was truncated"); + require(terminal.find_status_for_test().contains(QStringLiteral("4096")), + "over-limit NFD query did not report the limit"); + require(read_input(first_input).empty(), + "query edit path leaked into the first child"); + + terminal.set_find_query_for_test(QStringLiteral("SEL_HEAD")); + await([&] { return terminal.find_current_range_for_test().has_value(); }, first, lifecycle, + "SEL_HEAD history hit missing"); + const uint64_t evicted_id = terminal.find_current_range_for_test()->begin.line_id; + require(first.send(std::string(1, 'a')), "eviction command rejected"); + QTest::qWait(200); + require(!terminal.find_current_range_for_test().has_value() || + terminal.find_current_range_for_test()->begin.line_id != evicted_id, + "evicted source hit was resurrected"); + const TextDocument after_evict(first.screen()); + require(!after_evict.index_of(evicted_id).has_value(), "evicted line still present in the document"); + terminal.set_find_query_for_test(QStringLiteral("SEL_HEAD")); + await_find_done(terminal, first, lifecycle, "post-eviction rescan did not finish"); + require(terminal.find_match_count_for_test() == 0, "evicted SEL_HEAD still matches"); + + terminal.set_find_query_for_test(QStringLiteral("SEL_HEAD")); + await_find_done(terminal, first, lifecycle, "pre-epoch query did not finish"); + require(first.send(std::string(1, 'r')), "RIS command rejected"); + QTest::qWait(200); + { + const auto current = terminal.find_current_range_for_test(); + const TextDocument document(first.screen()); + require(!current || document.index_of(current->begin.line_id).has_value(), + "pre-RIS hit survived the epoch change"); + } + terminal.set_find_query_for_test(QStringLiteral("SEL_RIS")); + await([&] { return terminal.find_match_count_for_test() > 0; }, first, lifecycle, + "post-RIS query did not find the new epoch text"); + + require(read_input(first_input) == "a" || read_input(first_input) == "ar", + "query text leaked into the first child input"); + require(read_input(second_input).empty(), "query text leaked into the second child input"); + second_window.activateWindow(); second_terminal.setFocus(); QTest::qWait(30); + QTest::keyClick(&second_terminal, Qt::Key_F, Qt::ControlModifier | Qt::ShiftModifier); + auto* second_query = second_window.findChild(QStringLiteral("findQuery")); + require(second_query && second_query->hasFocus(), "second session Find did not focus its query"); + QTest::keyClicks(second_query, QStringLiteral("SEL_HEAD")); + await_find_done(second_terminal, second, lifecycle, "second-session Find did not finish"); + require(second_terminal.find_match_count_for_test() > 0, "second session has no independent hit"); + require(read_input(second_input).empty(), "second-session query leaked into its child"); + QTest::keyClick(second_query, Qt::Key_Escape); + require(second_terminal.hasFocus(), "second session Escape did not restore terminal focus"); + QTest::keyClicks(&second_terminal, QStringLiteral("Z")); + await([&] { return read_input(second_input) == "Z"; }, second, lifecycle, + "post-Escape input was not received exactly once by the correct child"); + require(read_input(first_input) == "a" || read_input(first_input) == "ar", + "post-Escape input reached the wrong child"); + + // A third real session provides bounded, deterministic >1024 hits and + // a scan longer than one 4096-unit budget, without injecting documents. + const QString third_input = workspace.path() + "/third-input.bin"; + SessionModel third; QWidget third_window; TerminalWidget third_terminal(third, &third_window); + third_window.resize(1200, 900); + start_child(third, third_terminal, third_window, executable, third_input, + workspace.path().toStdWString(), "find third child did not start"); + QTest::qWait(500); + require(third.send(std::string(1, 'm')), "many-hit command rejected"); + await([&] { return third.screen().retained_size() >= 78; }, third, lifecycle, + "many-hit child output missing"); + third_window.activateWindow(); third_terminal.setFocus(); QTest::qWait(30); + third_terminal.set_find_timer_interval_for_test(25); + QTest::keyClick(&third_terminal, Qt::Key_F, Qt::ControlModifier | Qt::ShiftModifier); + auto* third_query = third_window.findChild(QStringLiteral("findQuery")); + require(third_query && third_query->hasFocus(), "third Find query did not focus"); + type_ascii(third_query, QStringLiteral("ZZ")); + QTest::qWait(35); + require(third_terminal.find_search_status_for_test() == termforge::SearchStatus::running, + "long scan did not remain running after one bounded stage"); + require(third_terminal.find_match_count_for_test() > 0, + "long scan exposed no partial matches"); + require(third_terminal.find_status_for_test().startsWith(QStringLiteral("Searching")), + "long scan lacks an observable Searching stage"); + auto* third_next = third_window.findChild(QStringLiteral("findNext")); + const size_t partial_before = third_terminal.find_current_index_for_test(); + QTest::mouseClick(third_next, Qt::LeftButton); + require(third_terminal.find_current_index_for_test() < third_terminal.find_match_count_for_test(), + "running-scan navigation left the scanned prefix"); + require(!third_terminal.find_last_wrapped_for_test(), + "running-scan navigation wrapped into an unscanned area"); + require(third_terminal.find_current_index_for_test() != partial_before || + third_terminal.find_status_for_test() == QStringLiteral("No further scanned match"), + "running-scan Next neither advanced nor reported the scanned-prefix boundary"); + await([&] { + const auto state = third_terminal.find_search_status_for_test(); + return state == termforge::SearchStatus::complete || state == termforge::SearchStatus::capped; + }, third, lifecycle, "long scan did not complete"); + require(third_terminal.find_match_count_for_test() == 52, "long scan hit count differs"); + require(third_terminal.find_status_for_test().contains(QStringLiteral("52 matches")), + "long scan completion status differs"); + + // Repeated short literals exceed 1024 in retained rows; the existing cap + // is asserted through the real query entry, including finite wrap. + type_ascii(third_query, QStringLiteral("AB")); + await([&] { return third_terminal.find_search_status_for_test() == termforge::SearchStatus::capped; }, + third, lifecycle, "1024-result cap was not reached through real child output"); + require(third_terminal.find_match_count_for_test() == 1024, "capped result count is not 1024"); + require(third_terminal.find_status_for_test().contains(QStringLiteral("capped")), + "capped status lacks the truncation notice"); + for (int guard = 0; guard < 1030 && third_terminal.find_current_index_for_test() != 0; ++guard) + QTest::mouseClick(third_window.findChild(QStringLiteral("findPrevious")), Qt::LeftButton); + require(third_terminal.find_current_index_for_test() == 0, "could not position at capped first hit"); + QTest::mouseClick(third_window.findChild(QStringLiteral("findPrevious")), Qt::LeftButton); + require(third_terminal.find_last_wrapped_for_test() && + third_terminal.find_current_index_for_test() == 1023, + "capped Previous did not wrap finitely to the last hit"); + QTest::mouseClick(third_window.findChild(QStringLiteral("findNext")), Qt::LeftButton); + require(third_terminal.find_last_wrapped_for_test() && + third_terminal.find_current_index_for_test() == 0, + "capped Next did not wrap finitely to the first hit"); + require(read_input(third_input) == "m", "many-hit query leaked into the third child"); + + std::cout << "{\"schema\":\"termforge-qt-find-v1\",\"status\":\"passed\"," + "\"multi_match_order\":true,\"wrap\":true,\"exact_highlight\":true," + "\"history_reveal\":true,\"resize_source_migration\":true," + "\"eviction_no_resurrection\":true,\"epoch_invalidation\":true," + "\"two_session_isolation\":true,\"query_zero_leak\":true," + "\"escape_input_once\":true,\"empty_query\":true,\"case_toggle\":true," + "\"limit_bytes\":true,\"return_f3\":true,\"real_4096_limit\":true," + "\"real_1024_cap\":true,\"partial_scan_no_wrap\":true,\"offscreen_only\":true}\n"; + require(first.send(std::string(1, 'q')), "first child quit rejected"); + require(second.send(std::string(1, 'q')), "second child quit rejected"); + await([&] { return first.finished(); }, first, lifecycle, "first child did not exit"); + await([&] { return second.finished(); }, second, lifecycle, "second child did not exit"); + require(first.exit_code() == 37 && first.cleanup_verified(), "first child cleanup was not verified"); + require(second.exit_code() == 37 && second.cleanup_verified(), "second child cleanup was not verified"); + return 0; + } catch (const std::exception& error) { + std::cerr << "Qt Find: " << error.what() << '\n'; + return 1; + } +} diff --git a/src/qt/selection_fixture.hpp b/src/qt/selection_fixture.hpp index d2d80d7..eeb1417 100644 --- a/src/qt/selection_fixture.hpp +++ b/src/qt/selection_fixture.hpp @@ -4,6 +4,7 @@ #pragma once #include "../../experiments/conpty/session.hpp" #include +#include #include #include #include @@ -31,7 +32,7 @@ inline void selection_numbered(HANDLE output, int first, int last) { selection_write(output, batch); } -inline int selection_child() { +inline int selection_child(const std::wstring& record_path = {}) { BOOL owned = FALSE; if (!IsProcessInJob(GetCurrentProcess(), nullptr, &owned) || !owned) throw std::runtime_error("selection child requires its owned Session Job"); @@ -52,10 +53,16 @@ inline int selection_child() { "COPY_UNICODE:\xe4\xb8\xad\xe6\x96\x87 e\xcc\x81 \r\nCOPY_TAIL\r\n"); selection_numbered(output.get(), 126, 165); selection_write(output.get(), "SEL>"); + std::ofstream received; + if (!record_path.empty()) { + received.open(record_path, std::ios::binary | std::ios::trunc); + if (!received) tf_conpty::fail("selection child input record"); + } for (;;) { char key = 0; DWORD count = 0; if (!ReadFile(input.get(), &key, 1, &count, nullptr) || count != 1) tf_conpty::fail("ReadFile(selection child input)"); + if (received) { received.put(key); received.flush(); } if (key == 'q') return 37; if (key == 'o') { selection_numbered(output.get(), 121, 125); @@ -75,6 +82,19 @@ inline int selection_child() { selection_write(output.get(), "\x1b" "cSEL_RIS\r\nSEL>"); continue; } + if (key == 'm') { + // Bounded deterministic loads: 30 rows x 40 "AB" hits (1200 total) + // and 100 rows with one sparse "ZZ" near the end (>8000 scan bytes). + std::string many; + for (int row = 0; row < 52; ++row) many += std::string(78, 'X') + "ZZ\r\n"; + for (int row = 0; row < 26; ++row) { + for (int hit = 0; hit < 40; ++hit) many += "AB"; + many += "\r\n"; + } + many += "SEL_MANY\r\nSEL>"; + selection_write(output.get(), many); + continue; + } } } diff --git a/src/qt/terminal_widget.hpp b/src/qt/terminal_widget.hpp index 795e65d..8a1a6d5 100644 --- a/src/qt/terminal_widget.hpp +++ b/src/qt/terminal_widget.hpp @@ -6,11 +6,20 @@ #include "../../experiments/core/display_cursor.hpp" #include "../../experiments/core/history_view.hpp" #include "../../experiments/core/text_selection.hpp" +#include "../../experiments/core/text_search.hpp" #include "../../experiments/shell/keyboard.hpp" #include #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -55,6 +64,17 @@ class TerminalWidget final : public QWidget { int display_cursor_column_ = 0; TextSelection selection_; std::optional selection_anchor_; + QFrame* find_panel_ = nullptr; + QLineEdit* find_query_ = nullptr; + QLabel* find_status_ = nullptr; + QCheckBox* find_case_ = nullptr; + TextSearch find_search_; + std::optional find_current_; + size_t find_current_index_ = 0; + bool find_last_wrapped_ = false; + uint64_t find_generation_ = 0; + int find_interval_ms_ = 0; + QTimer find_timer_; std::unique_ptr clipboard_; ClipboardWrite last_copy_; QString copy_notice_; @@ -85,6 +105,7 @@ class TerminalWidget final : public QWidget { } void resizeEvent(QResizeEvent* event) override { QWidget::resizeEvent(event); + if (find_panel_) find_panel_->setGeometry(margin, margin, (std::max)(1, width() - margin * 2), 62); synchronize_grid(); } void showEvent(QShowEvent* event) override { @@ -298,6 +319,15 @@ class TerminalWidget final : public QWidget { model_.send(std::string{}); event->ignore(); return; } + if (find_panel_ && find_panel_->isVisible() && + (event->key() == Qt::Key_F3)) { + navigate_find(event->modifiers().testFlag(Qt::ShiftModifier) ? SearchDirection::previous : SearchDirection::next); + event->accept(); return; + } + if (event->modifiers() == (Qt::ControlModifier | Qt::ShiftModifier) && + event->key() == Qt::Key_F) { + open_find(); event->accept(); return; + } if (event->modifiers() == (Qt::ControlModifier | Qt::ShiftModifier) && event->key() == Qt::Key_C) { copy_selection_to_clipboard(); @@ -505,6 +535,146 @@ class TerminalWidget final : public QWidget { display_cursor_row_ = screen.row; display_cursor_column_ = screen.column; } + void set_find_status(const QString& text) { + if (find_status_) find_status_->setText(text); + } + TextPosition find_anchor() { + const auto& screen = model_.screen(); + const auto state = viewport_.reconcile(screen); + const size_t total = screen.retained_size(); + const size_t row = total ? (std::min)(state.top, total - 1) : 0; + const auto& line = screen.retained_line(row); + return {line.id, line.version, 0, screen.buffer_epoch()}; + } + void start_find() { + if (!find_query_) return; + const QByteArray query = find_query_->text().toUtf8(); + if (query.isEmpty()) { + find_search_.cancel(); find_current_.reset(); set_find_status(QStringLiteral("No query")); + update(); return; + } + try { + find_generation_ = find_search_.start(TextDocument(model_.screen()), query.toStdString(), + {find_case_->isChecked(), 1024}); + } catch (const std::exception& error) { + find_current_.reset(); + set_find_status(QStringLiteral("Find: ") + QString::fromUtf8(error.what())); + return; + } + find_current_.reset(); find_current_index_ = 0; + set_find_status(QStringLiteral("Searching...")); + find_timer_.start(find_interval_ms_); + } + void step_find() { + const TextDocument document(model_.screen()); + const auto progress = find_search_.step(document, find_generation_, 4096); + if (progress.status == SearchStatus::running) { + set_find_status(QStringLiteral("Searching... %1").arg(find_search_.matches().size())); + update(); return; + } + find_timer_.stop(); + if (progress.status == SearchStatus::capped) + set_find_status(QStringLiteral("%1 matches (capped)").arg(find_search_.matches().size())); + else if (progress.status == SearchStatus::complete || progress.status == SearchStatus::idle) + set_find_status(find_search_.matches().empty() ? QStringLiteral("No matches") : + QStringLiteral("%1 matches").arg(find_search_.matches().size())); + else if (progress.status == SearchStatus::invalidated) + set_find_status(QStringLiteral("Search invalidated")); + else if (progress.status == SearchStatus::cancelled) + set_find_status(QStringLiteral("No query")); + if (!find_current_ && !find_search_.matches().empty()) { + if (const auto nav = find_search_.navigate(document, SearchDirection::next, find_anchor(), true)) { + find_current_ = nav->match; find_current_index_ = nav->index; + viewport_.reveal(model_.screen(), nav->match.range.begin.line_id); + } + } + update(); + } + void navigate_find(SearchDirection direction) { + const TextDocument document(model_.screen()); + const std::optional anchor = find_current_ + ? std::optional{find_current_->range.begin} : std::optional{find_anchor()}; + const bool allow_wrap = find_search_.status() == SearchStatus::complete || + find_search_.status() == SearchStatus::capped; + const auto nav = find_search_.navigate(document, direction, anchor, allow_wrap); + if (!nav) { + set_find_status(allow_wrap ? QStringLiteral("No matches") + : QStringLiteral("No further scanned match")); + find_last_wrapped_ = false; update(); return; + } + find_current_ = nav->match; find_current_index_ = nav->index; + find_last_wrapped_ = nav->wrapped; + viewport_.reveal(model_.screen(), nav->match.range.begin.line_id); + set_find_status(QStringLiteral("%1 / %2%3").arg(nav->index + 1) + .arg(find_search_.matches().size()).arg(nav->wrapped ? QStringLiteral(" (wrapped)") : QString())); + update(); + } + void open_find() { + if (!find_panel_) return; + find_panel_->show(); + find_panel_->raise(); + find_query_->setFocus(Qt::ShortcutFocusReason); + find_query_->selectAll(); + if (!find_query_->text().isEmpty()) start_find(); + } + void close_find() { + find_timer_.stop(); + find_search_.close(); + find_current_.reset(); + if (find_panel_) find_panel_->hide(); + setFocus(Qt::OtherFocusReason); + update(); + } + void build_find_panel() { + find_panel_ = new QFrame(this); + find_panel_->setObjectName(QStringLiteral("findPanel")); + find_panel_->setStyleSheet(QStringLiteral("QFrame#findPanel { background: palette(window); border: 1px solid palette(mid); }")); + auto* outer = new QVBoxLayout(find_panel_); + outer->setContentsMargins(6, 4, 6, 4); + auto* controls = new QHBoxLayout(); + find_query_ = new QLineEdit(find_panel_); + find_query_->setObjectName(QStringLiteral("findQuery")); + find_query_->setPlaceholderText(QStringLiteral("Find literal text")); + controls->addWidget(find_query_, 1); + auto* previous = new QPushButton(QStringLiteral("Previous"), find_panel_); + previous->setObjectName(QStringLiteral("findPrevious")); + auto* next = new QPushButton(QStringLiteral("Next"), find_panel_); + next->setObjectName(QStringLiteral("findNext")); + auto* close = new QPushButton(QStringLiteral("Close"), find_panel_); + close->setObjectName(QStringLiteral("findClose")); + find_case_ = new QCheckBox(QStringLiteral("Match case"), find_panel_); + find_case_->setObjectName(QStringLiteral("findCase")); + find_case_->setChecked(true); + controls->addWidget(previous); controls->addWidget(next); controls->addWidget(close); controls->addWidget(find_case_); + outer->addLayout(controls); + find_status_ = new QLabel(QStringLiteral("Ctrl+Shift+F: literal Find, max 4096 UTF-8 bytes"), find_panel_); + find_status_->setObjectName(QStringLiteral("findStatus")); + outer->addWidget(find_status_); + find_panel_->hide(); + connect(find_query_, &QLineEdit::textChanged, this, [this] { start_find(); }); + connect(find_case_, &QCheckBox::toggled, this, [this] { start_find(); }); + connect(previous, &QPushButton::clicked, this, [this] { navigate_find(SearchDirection::previous); }); + connect(next, &QPushButton::clicked, this, [this] { navigate_find(SearchDirection::next); }); + connect(close, &QPushButton::clicked, this, [this] { close_find(); }); + connect(&find_timer_, &QTimer::timeout, this, [this] { step_find(); }); + find_timer_.setSingleShot(false); + find_query_->installEventFilter(this); + } + bool eventFilter(QObject* watched, QEvent* event) override { + if (watched == find_query_ && event->type() == QEvent::KeyPress) { + auto* key = static_cast(event); + if (key->key() == Qt::Key_Escape) { close_find(); return true; } + if (key->key() == Qt::Key_F3) { + navigate_find(key->modifiers().testFlag(Qt::ShiftModifier) ? SearchDirection::previous : SearchDirection::next); + return true; + } + if (key->key() == Qt::Key_Return || key->key() == Qt::Key_Enter) { + navigate_find(key->modifiers().testFlag(Qt::ShiftModifier) ? SearchDirection::previous : SearchDirection::next); + return true; + } + } + return QWidget::eventFilter(watched, event); + } void paintEvent(QPaintEvent*) override { QPainter painter(this); painter.fillRect(rect(), colour(-1, false)); @@ -513,6 +683,20 @@ class TerminalWidget final : public QWidget { selection_.reconcile(document); refresh_painted_page(screen); auto& page = *painted_page_; + if (find_current_ && find_search_.match(document, find_current_index_)) { + for (const auto& span : page.mapping.project(document, find_current_->range, + page.mapping.layout_revision())) { + if (span.row < painted_row_offset_) continue; + const size_t row = span.row - painted_row_offset_; + if (row >= page.lines.size()) continue; + for (size_t column = span.column_begin; column < span.column_end && column < page.lines[row].size(); ++column) { + auto& style = page.lines[row][column].style; + style.bg = 3; style.fg = 15; style.reverse = false; + } + } + } else if (find_current_) { + find_current_.reset(); + } const size_t columns = page.mapping.columns() ? page.mapping.columns() : static_cast(screen.width()); const size_t visible_rows = (std::min)(static_cast(screen.height()), page.lines.size() > painted_row_offset_ ? page.lines.size() - painted_row_offset_ : size_t{0}); @@ -600,6 +784,7 @@ class TerminalWidget final : public QWidget { resize(sizeHint()); grid_ready_ = true; clipboard_ = std::make_unique(*this); + build_find_panel(); model_.on_changed = [this] { refresh_session(); }; } ~TerminalWidget() override { model_.on_changed = {}; } @@ -608,6 +793,13 @@ class TerminalWidget final : public QWidget { const TextDocument document(model_.screen()); selection_.reconcile(document); if (selection_anchor_ && !document.valid(*selection_anchor_)) selection_anchor_.reset(); + if (find_panel_ && find_panel_->isVisible() && !find_query_->text().isEmpty()) { + if (find_search_.status() == SearchStatus::running || find_search_.status() == SearchStatus::complete || + find_search_.status() == SearchStatus::capped) { + find_generation_ = find_search_.notify_change(document); + find_timer_.start(0); + } + } // Keep the display page and caret current before Qt asks the input // method for geometry, not only on the next paint. if (grid_ready_) refresh_painted_page(model_.screen()); @@ -632,6 +824,28 @@ class TerminalWidget final : public QWidget { return selection_.copy(TextDocument(model_.screen()), maximum_bytes); } ClipboardWrite copy_to_clipboard() { return copy_selection_to_clipboard(); } + void open_find_for_test() { open_find(); } + void close_find_for_test() { close_find(); } + void set_find_query_for_test(const QString& text) { if (find_query_) find_query_->setText(text); } + QString find_query_text_for_test() const { return find_query_ ? find_query_->text() : QString{}; } + bool find_visible_for_test() const { return find_panel_ && find_panel_->isVisible(); } + QString find_status_for_test() const { return find_status_ ? find_status_->text() : QString{}; } + size_t find_match_count_for_test() const { return find_search_.matches().size(); } + size_t find_current_index_for_test() const { return find_current_index_; } + bool find_last_wrapped_for_test() const { return find_last_wrapped_; } + void set_find_timer_interval_for_test(int milliseconds) { find_interval_ms_ = (std::max)(0, milliseconds); } + SearchStatus find_search_status_for_test() const { return find_search_.status(); } + std::optional find_current_range_for_test() const { + return find_current_ ? std::optional{find_current_->range} : std::nullopt; + } + void find_next_for_test() { navigate_find(SearchDirection::next); } + void find_previous_for_test() { navigate_find(SearchDirection::previous); } + std::vector find_spans_for_test() { + if (!find_current_ || !painted_page_) return {}; + const TextDocument document(model_.screen()); + return painted_page_->mapping.project(document, find_current_->range, + painted_page_->mapping.layout_revision()); + } void setClipboardWriterForTest(std::unique_ptr writer) { clipboard_ = std::move(writer); }