diff --git a/.github/workflows/qt-cpp.yml b/.github/workflows/qt-cpp.yml index cac61bf..1ba19d1 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_find_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_scrollbar_size_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 @@ -65,6 +65,8 @@ jobs: 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-scrollbar-size-bin/Release --executable qt_scrollbar_size_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 @@ -86,7 +88,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|qt_find)$' --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|qt_scrollbar_size)$' --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 fa58ee0..b20848f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -832,6 +832,20 @@ if(TERMFORGE_BUILD_QT_SHELL) "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$") set_tests_properties(qt_find PROPERTIES TIMEOUT 45 ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + add_executable(qt_scrollbar_size_test src/qt/scrollbar_size_test.cpp) + set_target_properties(qt_scrollbar_size_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qt-scrollbar-size-bin/$") + termforge_qt_entry(qt_scrollbar_size_test) + target_link_libraries(qt_scrollbar_size_test PRIVATE Qt6::Test) + add_custom_command(TARGET qt_scrollbar_size_test POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "$" + "$/platforms/" + VERBATIM) + add_test(NAME qt_scrollbar_size COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$") + set_tests_properties(qt_scrollbar_size 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 efe2e4e..0b3b86e 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -23,6 +23,7 @@ | Qt 正式路径有限历史浏览与源阅读锚点(#29 切片) | [真实 Qt 事件、受控 child 与截图](../experiments/shell/results/2026-11-05-qt-history-anchor/README.md):滚轮离开底部、历史页命中同一源行、窄/高 resize 与阅读中新输出保持锚点、`Ctrl+Shift+Home/End`、普通 Qt 输入回底、淘汰/ED3/RIS 失效钳制、两会话隔离;Qt 正式路径 8/8 | 单屏 offscreen、固定 80×24/60×24、短 ASCII 与有界历史;滚动条、PageUp/PageDown 历史映射、粘贴回底、阅读期间选择/复制/Find UI、物理呈现/Unicode/性能未覆盖;行为来自 R1 产品合同而非新 WindTerm oracle;#27/#29 不关闭 | | 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 切片) | [实现、真实鼠标/键盘与截图](../experiments/shell/results/2026-11-07-qt-history-scrollbar/README.md):真实 QScrollBar 拖动/页区/Home/End,值仅显示页 top,经 HistoryViewport 源锚点定位;覆盖新输出锚点、回底、缩窄恢复、Find 同步、双会话;Qt 正式路径 10/10 | 离屏 Qt;不覆盖物理呈现/DPI、TUI/备用屏、自动滚动/选词选行/持久化/性能;滚动条+child 尺寸独立 ACK 仍为边界;不代表 #29/#30/R2 完成 | | 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 验收 | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 96ebad9..589dbd1 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));正式 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++ / 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);不含选词/选行/物理呈现/性能阈值);历史滚动条已接真实 QScrollBar/源阅读锚点(见[滚动条结果](../experiments/shell/results/2026-11-07-qt-history-scrollbar/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-07-qt-history-scrollbar/README.md b/experiments/shell/results/2026-11-07-qt-history-scrollbar/README.md new file mode 100644 index 0000000..df1966a --- /dev/null +++ b/experiments/shell/results/2026-11-07-qt-history-scrollbar/README.md @@ -0,0 +1,28 @@ +# 正式 Qt:历史滚动条与源阅读锚点 + +本切片把既有 Win32 滚动条历史浏览策略迁移到正式 Qt 正常主屏:真实 `QScrollBar` 的拖动/页区/按键最终通过 `HistoryViewport` 的源 rank/行身份锚点定位;滚动条值只是当前显示页 top,不是稳定源 ID。已有滚轮、`Ctrl+Shift+Home/End`、Find reveal、拖选/Copy 与终端输入合同保持不变。 + +## 实现 + +- 主屏右侧垂直滚动条;出现时其宽度进入现有 Qt→core→ConPTY 网格宽度路径,未出现时不占宽;无第二 resize/存储模型。 +- 滚动条 `valueChanged` 映射到 `HistoryViewport::scroll_to/bottom`;刷新时从 `reconcile` 的 top/max 同步回滚动条(阻塞信号,防止值反向当身份)。 +- 新输出保持暂停阅读的源锚点与页面;回到底部 resume following;淘汰/ED3/RIS 按已有锚点钳制规则;缩窄/恢复后同源锚点;Find reveal 后滚动条同步。 +- 固定布局的旧测试进程显式关闭滚动条占宽,保留其既有 80×24 夹具;默认启用路径由 `qt_history_navigation` 与 `qt_scrollbar_size` 覆盖。 + +## 验证 + +`qt_history_navigation`(真实 child 确定性 160 行日志 + 自发追加)新增: + +- 滚动条可见/range 与 `maximum_top` 一致;出现时网格列数按滚动条 extent 计算。 +- 实际鼠标拖动、页区点击、滚动条 Home/End;值==HistoryViewport top,源锚点==显示页首源行 id,不把值当源 ID。 +- 新输出时锚点/页面不跳、滚动条值跟随;回底 following;缩窄 60 列/恢复后同源锚点;Find reveal 同步;双会话滚动/焦点隔离。 +- `qt_scrollbar_size`:默认启用滚动条 → 无历史(不出现)→ 有限 child 输出(出现并占宽)→ 缩窄/恢复 → ED3 清空(消失)。每阶段用真实 child `GetConsoleScreenBufferInfo` 独立回报 buffer/window,核对 Qt 网格、core requested、transport 与 child ACK,并固定 Session child 身份。 +- 逐阶段原始观测保存在 [scrollbar-size-trace.json](scrollbar-size-trace.json):client width、滚动条 extent/visible、期望 columns/rows、core requested/transport/pending、child PID/nonce、实际 buffer/window、query_error、match、attempt/order。本次五次均在 attempt 0 收敛,未出现旧尺寸/错误/超时;若出现,await_ack 会保留每条不匹配/异常/超时记录后失败,而不是丢弃。 +- 默认启用路径的真实公开入口:Ctrl+Shift+F/查询/Escape、鼠标拖选 + Ctrl+Shift+C(注入 writer 核对源文本,非真实 CF)、第三个记录输入 child 验证查询/普通键只到正确 child。 +- 本地 Qt 正式路径 11/11;截图 `screens/history-scrollbar.png`。 + +## 边界 + +- 离屏 Qt;不宣称物理呈现/DPI、备用屏/TUI、自动滚动/选词选行、配置持久化或性能阈值。 +- child 实际尺寸 ACK 已覆盖;真实 CF Copy 不在本切片(writer 注入),物理呈现/DPI、WindTerm 滚动条 oracle 仍未覆盖。 +- #324 仍等待用户追加验证决定;BCD/VM/v2、旧失败与未跟踪脚本不变。 diff --git a/experiments/shell/results/2026-11-07-qt-history-scrollbar/screens/history-bottom.png b/experiments/shell/results/2026-11-07-qt-history-scrollbar/screens/history-bottom.png new file mode 100644 index 0000000..f855a14 Binary files /dev/null and b/experiments/shell/results/2026-11-07-qt-history-scrollbar/screens/history-bottom.png differ diff --git a/experiments/shell/results/2026-11-07-qt-history-scrollbar/screens/history-narrowed.png b/experiments/shell/results/2026-11-07-qt-history-scrollbar/screens/history-narrowed.png new file mode 100644 index 0000000..2c82cb3 Binary files /dev/null and b/experiments/shell/results/2026-11-07-qt-history-scrollbar/screens/history-narrowed.png differ diff --git a/experiments/shell/results/2026-11-07-qt-history-scrollbar/screens/history-new-output.png b/experiments/shell/results/2026-11-07-qt-history-scrollbar/screens/history-new-output.png new file mode 100644 index 0000000..bf0ae44 Binary files /dev/null and b/experiments/shell/results/2026-11-07-qt-history-scrollbar/screens/history-new-output.png differ diff --git a/experiments/shell/results/2026-11-07-qt-history-scrollbar/screens/history-scrollbar.png b/experiments/shell/results/2026-11-07-qt-history-scrollbar/screens/history-scrollbar.png new file mode 100644 index 0000000..1df5f6f Binary files /dev/null and b/experiments/shell/results/2026-11-07-qt-history-scrollbar/screens/history-scrollbar.png differ diff --git a/experiments/shell/results/2026-11-07-qt-history-scrollbar/screens/history-scrolled.png b/experiments/shell/results/2026-11-07-qt-history-scrollbar/screens/history-scrolled.png new file mode 100644 index 0000000..1b67f00 Binary files /dev/null and b/experiments/shell/results/2026-11-07-qt-history-scrollbar/screens/history-scrolled.png differ diff --git a/experiments/shell/results/2026-11-07-qt-history-scrollbar/scrollbar-size-trace.json b/experiments/shell/results/2026-11-07-qt-history-scrollbar/scrollbar-size-trace.json new file mode 100644 index 0000000..a37bf65 --- /dev/null +++ b/experiments/shell/results/2026-11-07-qt-history-scrollbar/scrollbar-size-trace.json @@ -0,0 +1,131 @@ +{ + "schema": "termforge-qt-scrollbar-size-observations-v1", + "source": "local qt_scrollbar_size run", + "observations": [ + { + "stage": "no-history", + "order": 1, + "attempt": 0, + "nonce": 10, + "expected_columns": 80, + "expected_rows": 24, + "client_width": 736, + "scrollbar_extent": 14, + "scrollbar_visible": false, + "requested_columns": 80, + "requested_rows": 24, + "transport_columns": 80, + "transport_rows": 24, + "pending": false, + "accepting": true, + "child_pid": 10108, + "buffer_columns": 80, + "buffer_rows": 24, + "window_columns": 80, + "window_rows": 24, + "query_error": 0, + "match": true, + "error": "" + }, + { + "stage": "history-reserved", + "order": 2, + "attempt": 0, + "nonce": 11, + "expected_columns": 78, + "expected_rows": 24, + "client_width": 736, + "scrollbar_extent": 14, + "scrollbar_visible": true, + "requested_columns": 78, + "requested_rows": 24, + "transport_columns": 78, + "transport_rows": 24, + "pending": false, + "accepting": true, + "child_pid": 10108, + "buffer_columns": 78, + "buffer_rows": 24, + "window_columns": 78, + "window_rows": 24, + "query_error": 0, + "match": true, + "error": "" + }, + { + "stage": "narrow-reserved", + "order": 3, + "attempt": 0, + "nonce": 12, + "expected_columns": 60, + "expected_rows": 20, + "client_width": 570, + "scrollbar_extent": 14, + "scrollbar_visible": true, + "requested_columns": 60, + "requested_rows": 20, + "transport_columns": 60, + "transport_rows": 20, + "pending": false, + "accepting": true, + "child_pid": 10108, + "buffer_columns": 60, + "buffer_rows": 20, + "window_columns": 60, + "window_rows": 20, + "query_error": 0, + "match": true, + "error": "" + }, + { + "stage": "restored-reserved", + "order": 4, + "attempt": 0, + "nonce": 13, + "expected_columns": 80, + "expected_rows": 24, + "client_width": 750, + "scrollbar_extent": 14, + "scrollbar_visible": true, + "requested_columns": 80, + "requested_rows": 24, + "transport_columns": 80, + "transport_rows": 24, + "pending": false, + "accepting": true, + "child_pid": 10108, + "buffer_columns": 80, + "buffer_rows": 24, + "window_columns": 80, + "window_rows": 24, + "query_error": 0, + "match": true, + "error": "" + }, + { + "stage": "cleared", + "order": 5, + "attempt": 0, + "nonce": 14, + "expected_columns": 81, + "expected_rows": 24, + "client_width": 750, + "scrollbar_extent": 14, + "scrollbar_visible": false, + "requested_columns": 81, + "requested_rows": 24, + "transport_columns": 81, + "transport_rows": 24, + "pending": false, + "accepting": true, + "child_pid": 10108, + "buffer_columns": 81, + "buffer_rows": 24, + "window_columns": 81, + "window_rows": 24, + "query_error": 0, + "match": true, + "error": "" + } + ] +} \ No newline at end of file diff --git a/experiments/shell/results/2026-11-07-qt-history-scrollbar/validation.json b/experiments/shell/results/2026-11-07-qt-history-scrollbar/validation.json new file mode 100644 index 0000000..b1ea6eb --- /dev/null +++ b/experiments/shell/results/2026-11-07-qt-history-scrollbar/validation.json @@ -0,0 +1,208 @@ +{ + "schema": "termforge-qt-history-scrollbar-validation-v1", + "baseline_main": "ce2bbbacacf49b3864932fad19464d465e638426", + "source_commit": "b11186d", + "source_hash_note": "SHA-256 of committed LF blob content at b11186d; docs follow-up.", + "source_files_sha256": { + "src/qt/terminal_widget.hpp": "770242a69cfb04688a0d60f71532b251901b70b34fe5704db096ad7137ca747b", + "src/qt/history_navigation_test.cpp": "a4929e20def0538d640b651b2a788c455601a68c729be611165549f982b6c44d", + "src/qt/hidden_retention_test.cpp": "29ccbbd91fb279a9dd159f96cddd0224b1716421a2f3349e200d2e522c03e271", + "src/qt/selection_migration_test.cpp": "31cb8ed0d4ab4708b3885c8e6969d4a0125b5a07b64f92de0890bfca90809882", + "src/qt/resize_smoke.cpp": "92587fd2aa32296b9b784854c800fca86a8bf0ad52b28390a63a36e4c6fec631", + "src/qt/terminal_modes_test.cpp": "ac2af74f312cfde6b4ef799bbe91fb2eab5c5f7b292981d30ff7fdfbedca06e9", + "src/qt/find_test.cpp": "f1df33c4bb3f36acc6366492d8a94e5ed9c54c854c52ef1cb95bd67f44b6bfcc", + "src/qt/history_fixture.hpp": "d9fb37df2a7834a49788ec7eb4ea07ddab9766570838075c581cbbd738c931d9", + "src/qt/resize_fixture.hpp": "065745e70db5f3341be60dd4c7f3d3d2aea0b995ca1782090ce2ddfa9b87e774", + "src/qt/scrollbar_size_test.cpp": "1a27c6a7dde2c68948931f6923d9986140b5100c1c4bae65d31e4cdc1e2eac83", + "CMakeLists.txt": "24efd481f48a43a66e5c91f4fbd4120ba2382af9dc69347e42ce731008cb58fa", + ".github/workflows/qt-cpp.yml": "7d9bab2caed2d3ee2fdee41adffbc5708ac6e6227d1968a3948a7bfdbf56b018" + }, + "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" + }, + "checks": { + "scrollbar_visible_range": true, + "scrollbar_reserved_grid": true, + "mouse_drag": true, + "page_step": true, + "home_end": true, + "source_top_identity": true, + "new_output_anchor": true, + "bottom_follow": true, + "narrow_restore_anchor": true, + "find_sync": true, + "two_sessions": true, + "existing_wheel_keys_selection": true, + "system_clipboard_touched": false, + "offscreen_only": true, + "child_csbi_no_history": true, + "child_csbi_reserved": true, + "child_csbi_narrow_restore": true, + "child_csbi_clear_hides": true, + "public_find_entry": true, + "public_selection_copy_fake": true, + "third_child_input_after_find": true + }, + "tests": { + "qt_history_navigation": { + "passed": 1, + "failed": 0, + "seconds": 2.17 + }, + "qt_formal_path_regression": { + "passed": 11, + "failed": 0, + "seconds": 22.94 + }, + "qt_scrollbar_size": { + "passed": 1, + "failed": 0, + "seconds": 1.29 + } + }, + "evidence": { + "readme": "README.md", + "screenshot": "screens/history-scrollbar.png" + }, + "limitations": [ + "Child size is now independently ACKed at every scrollbar stage via real CSBI replies; the public Find/selection/input checks run with the scrollbar enabled. The Copy check uses an injected writer and is not real CF.", + "Offscreen Qt; physical presentation/DPI, alternate-screen/TUI, autoscroll, word/line selection, persistence and performance thresholds are not covered.", + "This slice does not close #29/#30/R2; #324 remains blocked pending a user decision; BCD/VM/v2 and old failures are unchanged.", + "Physical presentation/DPI and a WindTerm scrollbar oracle remain out of scope; fixed-layout tests keep their own disabled-reservation config, while the default enabled path is covered by qt_history_navigation and qt_scrollbar_size." + ], + "scrollbar_size_trace": { + "file": "scrollbar-size-trace.json", + "observations": [ + { + "stage": "no-history", + "order": 1, + "attempt": 0, + "nonce": 10, + "expected_columns": 80, + "expected_rows": 24, + "client_width": 736, + "scrollbar_extent": 14, + "scrollbar_visible": false, + "requested_columns": 80, + "requested_rows": 24, + "transport_columns": 80, + "transport_rows": 24, + "pending": false, + "accepting": true, + "child_pid": 10108, + "buffer_columns": 80, + "buffer_rows": 24, + "window_columns": 80, + "window_rows": 24, + "query_error": 0, + "match": true, + "error": "" + }, + { + "stage": "history-reserved", + "order": 2, + "attempt": 0, + "nonce": 11, + "expected_columns": 78, + "expected_rows": 24, + "client_width": 736, + "scrollbar_extent": 14, + "scrollbar_visible": true, + "requested_columns": 78, + "requested_rows": 24, + "transport_columns": 78, + "transport_rows": 24, + "pending": false, + "accepting": true, + "child_pid": 10108, + "buffer_columns": 78, + "buffer_rows": 24, + "window_columns": 78, + "window_rows": 24, + "query_error": 0, + "match": true, + "error": "" + }, + { + "stage": "narrow-reserved", + "order": 3, + "attempt": 0, + "nonce": 12, + "expected_columns": 60, + "expected_rows": 20, + "client_width": 570, + "scrollbar_extent": 14, + "scrollbar_visible": true, + "requested_columns": 60, + "requested_rows": 20, + "transport_columns": 60, + "transport_rows": 20, + "pending": false, + "accepting": true, + "child_pid": 10108, + "buffer_columns": 60, + "buffer_rows": 20, + "window_columns": 60, + "window_rows": 20, + "query_error": 0, + "match": true, + "error": "" + }, + { + "stage": "restored-reserved", + "order": 4, + "attempt": 0, + "nonce": 13, + "expected_columns": 80, + "expected_rows": 24, + "client_width": 750, + "scrollbar_extent": 14, + "scrollbar_visible": true, + "requested_columns": 80, + "requested_rows": 24, + "transport_columns": 80, + "transport_rows": 24, + "pending": false, + "accepting": true, + "child_pid": 10108, + "buffer_columns": 80, + "buffer_rows": 24, + "window_columns": 80, + "window_rows": 24, + "query_error": 0, + "match": true, + "error": "" + }, + { + "stage": "cleared", + "order": 5, + "attempt": 0, + "nonce": 14, + "expected_columns": 81, + "expected_rows": 24, + "client_width": 750, + "scrollbar_extent": 14, + "scrollbar_visible": false, + "requested_columns": 81, + "requested_rows": 24, + "transport_columns": 81, + "transport_rows": 24, + "pending": false, + "accepting": true, + "child_pid": 10108, + "buffer_columns": 81, + "buffer_rows": 24, + "window_columns": 81, + "window_rows": 24, + "query_error": 0, + "match": true, + "error": "" + } + ], + "note": "All five stages converged on the first observation in this local run; no old size or timeout occurred. The test now preserves every mismatch/error/timeout if one occurs." + } +} \ No newline at end of file diff --git a/src/qt/README.md b/src/qt/README.md index 2604106..ab2597c 100644 --- a/src/qt/README.md +++ b/src/qt/README.md @@ -59,3 +59,5 @@ IME 实现通过 `QInputMethodEvent` 绘制未提交的 preedit,仅把 commit 上述三项自动测试使用 Qt offscreen 平台和本项目独立取得的 OFL Roboto Mono 字体;没有物理键盘、实际不同 DPI、Windows 原生窗口上屏或用户呈现时间验收,`presented_done=null`。隔离 VM 原生观察保留自己的源码与环境身份。两会话隔离不代表八会话、完整 IME 交叉或工作区验收,测试耗时不是终端性能指标。 构建开关默认关闭只是从旧原型向新入口迁移的状态;正式路线已确认,旧构建保留为回归对照。Qt 文字与图形资源均独立取得,不使用 WindTerm 的专有字体、图标或主题。 + +- 历史滚动条:真实 `QScrollBar` 拖动/页区/Home/End 通过 `HistoryViewport` 源锚点定位;见[结果](../../experiments/shell/results/2026-11-07-qt-history-scrollbar/README.md)。 diff --git a/src/qt/find_test.cpp b/src/qt/find_test.cpp index 38a82fc..cd274d2 100644 --- a/src/qt/find_test.cpp +++ b/src/qt/find_test.cpp @@ -91,6 +91,7 @@ int main(int argc, char** argv) { std::cerr << "find test requires its owned Job supervisor\n"; return 2; } QApplication app(argc, argv); + termforge::qt::TerminalWidget::setHistoryScrollbarEnabledForTest(false); QApplication::setQuitOnLastWindowClosed(false); try { QElapsedTimer lifecycle; lifecycle.start(); @@ -293,14 +294,16 @@ int main(int argc, char** argv) { 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); + third_terminal.set_find_timer_interval_for_test(1000); 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"); + "long scan did not expose an initial Searching stage"); + third_terminal.step_find_once_for_test(); // timing coordination only; same product step + require(third_terminal.find_search_status_for_test() == termforge::SearchStatus::running, + "long scan completed before the bounded first 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")), @@ -315,10 +318,11 @@ int main(int argc, char** argv) { 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"); + for (int guard = 0; guard < 20 && + (third_terminal.find_search_status_for_test() == termforge::SearchStatus::running); ++guard) + third_terminal.step_find_once_for_test(); + require(third_terminal.find_search_status_for_test() == termforge::SearchStatus::complete, + "long scan did not complete after bounded steps"); 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"); @@ -326,8 +330,11 @@ int main(int argc, char** argv) { // 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"); + for (int guard = 0; guard < 20 && + third_terminal.find_search_status_for_test() == termforge::SearchStatus::running; ++guard) + third_terminal.step_find_once_for_test(); + require(third_terminal.find_search_status_for_test() == termforge::SearchStatus::capped, + "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"); diff --git a/src/qt/hidden_retention_test.cpp b/src/qt/hidden_retention_test.cpp index 064ff56..173af12 100644 --- a/src/qt/hidden_retention_test.cpp +++ b/src/qt/hidden_retention_test.cpp @@ -252,6 +252,7 @@ int main(int argc, char** argv) { return 2; } QApplication app(argc, argv); + termforge::qt::TerminalWidget::setHistoryScrollbarEnabledForTest(false); QApplication::setQuitOnLastWindowClosed(false); QByteArray child_log_path; try { diff --git a/src/qt/history_fixture.hpp b/src/qt/history_fixture.hpp index 9a614bf..ccaea3b 100644 --- a/src/qt/history_fixture.hpp +++ b/src/qt/history_fixture.hpp @@ -4,6 +4,7 @@ #pragma once #include "../../experiments/conpty/session.hpp" #include +#include #include #include #include @@ -28,7 +29,7 @@ inline void history_child_lines(HANDLE output, int first, int last, const char* history_child_write(output, batch); } -inline int history_child() { +inline int history_child(const std::wstring& record_path = {}) { BOOL owned = FALSE; if (!IsProcessInJob(GetCurrentProcess(), nullptr, &owned) || !owned) throw std::runtime_error("history child requires its owned Session Job"); @@ -47,10 +48,16 @@ inline int history_child() { // real child output without host input returning to the bottom. Sleep(1500); history_child_lines(output.get(), 161, 180, "TF_END2\r\nTF>"); + std::ofstream received; + if (!record_path.empty()) { + received.open(record_path, std::ios::binary | std::ios::trunc); + if (!received) tf_conpty::fail("history child input record"); + } for (;;) { char key = 0; DWORD count = 0; if (!ReadFile(input.get(), &key, 1, &count, nullptr) || count != 1) tf_conpty::fail("ReadFile(history child input)"); + if (received) { received.put(key); received.flush(); } if (key == 'q') return 37; if (key == 'm') { history_child_lines(output.get(), 181, 220, "TF_END3\r\nTF>"); diff --git a/src/qt/history_navigation_test.cpp b/src/qt/history_navigation_test.cpp index e100eeb..a2ed8d8 100644 --- a/src/qt/history_navigation_test.cpp +++ b/src/qt/history_navigation_test.cpp @@ -6,12 +6,16 @@ // presentation or a WindTerm history oracle. #include "terminal_widget.hpp" #include "history_fixture.hpp" +#include "selection_fixture.hpp" #include "../../experiments/core/text_document.hpp" #include #include #include #include #include +#include "clipboard_writer.hpp" +#include +#include #include #include #include @@ -27,6 +31,23 @@ using termforge::ViewportState; using termforge::qt::SessionModel; using termforge::qt::TerminalWidget; using termforge::qt::test::history_child; +using termforge::qt::test::selection_child; +namespace { +class FakeClipboard final : public termforge::qt::ClipboardWriter { +public: + std::u16string text; + termforge::qt::ClipboardWrite write(const std::u16string& value) override { + text = value; + return {termforge::qt::ClipboardWrite::Status::ok, {}}; + } +}; +std::string read_record(const QString& path) { + QFile file(path); + if (!file.exists()) return {}; + if (!file.open(QIODevice::ReadOnly)) throw std::runtime_error("history input record unavailable"); + return file.readAll().toStdString(); +} +} namespace { void require(bool condition, const std::string& message) { @@ -91,15 +112,28 @@ void resize_public(TerminalWidget& terminal, SessionModel& model, int columns, i QElapsedTimer& lifecycle) { const QFontMetricsF metrics(terminal.font()); const int cell_width = qCeil(metrics.horizontalAdvance(QLatin1Char('M'))); const int cell_height = qCeil(metrics.height()) + 2; - terminal.resize(16 + columns * cell_width, 24 + (rows + 1) * cell_height); + terminal.resize(16 + terminal.history_scrollbar_extent_for_test() + columns * cell_width, 24 + (rows + 1) * cell_height); await([&] { const auto state = model.resize_state(); return state.requested_columns == columns && state.requested_rows == rows && !state.pending && state.transport_columns == columns && state.transport_rows == rows; }, model, lifecycle, "public Qt history resize did not finish"); } +QPoint scrollbar_slider_center(QScrollBar* bar) { + QStyleOptionSlider option; + option.initFrom(bar); + option.orientation = bar->orientation(); + return bar->style()->subControlRect(QStyle::CC_ScrollBar, &option, QStyle::SC_ScrollBarSlider, bar).center(); +} +void drag_scrollbar(QScrollBar* bar, int y) { + const QPoint start = scrollbar_slider_center(bar); + QTest::mousePress(bar, Qt::LeftButton, Qt::NoModifier, start); + QTest::mouseMove(bar, QPoint(start.x(), y)); + QTest::mouseRelease(bar, Qt::LeftButton, Qt::NoModifier, QPoint(start.x(), y)); +} bool start_and_open(SessionModel& model, TerminalWidget& terminal, QWidget& window, - const wchar_t* executable, const QString& workspace, QElapsedTimer& lifecycle) { + const wchar_t* executable, const QString& workspace, const QString& record, + QElapsedTimer& lifecycle) { window.resize(1200, 900); window.show(); terminal.setFocus(); @@ -120,6 +154,10 @@ void close_session(SessionModel& model, QElapsedTimer& lifecycle) { 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 == 2 && std::string_view(argv[1]) == "--history-child") { try { return history_child(); } catch (const std::exception& error) { @@ -153,19 +191,19 @@ int main(int argc, char** argv) { SessionModel first; QWidget first_window; TerminalWidget first_terminal(first, &first_window); - require(start_and_open(first, first_terminal, first_window, executable, workspace.path(), lifecycle), + const QString first_record = workspace.path() + "/first-history-input.bin"; + const QString second_record = workspace.path() + "/second-history-input.bin"; + require(start_and_open(first, first_terminal, first_window, executable, workspace.path(), first_record, lifecycle), "first history child did not start"); SessionModel second; QWidget second_window; TerminalWidget second_terminal(second, &second_window); - require(start_and_open(second, second_terminal, second_window, executable, workspace.path(), lifecycle), + require(start_and_open(second, second_terminal, second_window, executable, workspace.path(), second_record, lifecycle), "second history child did not start"); require(second_terminal.history_following(), "second session did not start at the bottom"); first_window.activateWindow(); first_terminal.setFocus(); QTest::qWait(20); - require(first_terminal.hasFocus(), "first history terminal did not regain focus"); - capture(first_terminal, QStringLiteral("history-bottom")); // Isolated native-wheel contract cases. Production queries // SPI_GETWHEELSCROLLLINES per wheel event; the diagnostic override @@ -338,6 +376,144 @@ int main(int argc, char** argv) { require(!page_contains(first_terminal, "TF_H_180"), "new output replaced the reading page"); capture(first_terminal, QStringLiteral("history-new-output")); + // Public vertical scrollbar: it reserves grid width through the existing + // Qt->core->ConPTY resize path; its value is a display top, never a + // durable source identity. Mouse drag/page/step and keys are exercised. + auto* bar = first_terminal.history_scrollbar_for_test(); + require(bar && bar->isVisible() && bar->maximum() > 0, "history scrollbar did not appear"); + const QFontMetricsF metrics(first_terminal.font()); + const int cell_width = (std::max)(1, qCeil(metrics.horizontalAdvance(QLatin1Char('M')))); + const int expected_columns = std::clamp( + (first_terminal.width() - 16 - first_terminal.history_scrollbar_extent_for_test()) / cell_width, 1, 500); + require(first.screen().width() == expected_columns, + "scrollbar extent did not enter the Qt/core grid width"); + require(first_terminal.history_state().maximum_top == static_cast(bar->maximum()), + "scrollbar range does not reflect HistoryViewport maximum_top"); + + // Keyboard top: exact source row is the oldest retained identity. + bar->setFocus(); + QTest::keyClick(bar, Qt::Key_Home); + await([&] { return first_terminal.history_state().top == 0; }, first, lifecycle, + "scrollbar Home did not reach the source top"); + require(!first_terminal.history_following() && bar->value() == 0, + "scrollbar Home state/value diverged"); + require(first_terminal.history_anchor() == first.screen().retained_line(0).id, + "scrollbar value was treated as a source identity instead of top"); + + // Actual mouse drag to the middle: value and source top agree; the page + // starts at the source row identified by the anchor. + drag_scrollbar(bar, bar->height() / 2); + await([&] { return !first_terminal.history_following() && bar->value() > 0; }, first, lifecycle, + "scrollbar mouse drag did not enter history"); + auto state = first_terminal.history_state(); + require(static_cast(bar->value()) == state.top, + "scrollbar value and HistoryViewport top diverged after drag"); + require(first_terminal.history_anchor() == first.screen().retained_line(state.top).id, + "scrollbar drag anchor does not match the displayed source top"); + const auto page = first_terminal.painted_page(); + capture(first_terminal, QStringLiteral("history-scrollbar")); + require(page && !page->mapping.sources().empty() && + page->mapping.sources().front().source.begin.line_id == + first.screen().retained_line(state.top).id, + "painted page does not start at the scrollbar-selected source row"); + + // Actual page-groove click advances by the page step without treating + // the new value as a durable identity. + const int before_page = bar->value(); + QStyleOptionSlider page_option; + page_option.initFrom(bar); + page_option.orientation = bar->orientation(); + const QRect slider = bar->style()->subControlRect( + QStyle::CC_ScrollBar, &page_option, QStyle::SC_ScrollBarSlider, bar); + QTest::mouseClick(bar, Qt::LeftButton, Qt::NoModifier, + QPoint(bar->width() / 2, (std::min)(bar->height() - 2, slider.bottom() + 5))); + await([&] { return bar->value() != before_page; }, first, lifecycle, + "scrollbar page-groove click did not move the value"); + state = first_terminal.history_state(); + require(static_cast(bar->value()) == state.top, + "page-groove click value/top diverged"); + + // New child output while reading keeps the same source anchor/top and + // updates the scrollbar range without following. + const uint64_t reading_anchor = first_terminal.history_anchor(); + await([&] { return has_text(first.screen(), "TF_H_180"); }, first, lifecycle, + "spontaneous output during scrollbar reading did not arrive"); + const auto reading_state = first_terminal.history_state(); + require(!first_terminal.history_following() && + first_terminal.history_anchor() == reading_anchor, + "new output moved the scrollbar reading anchor"); + require(static_cast(bar->value()) == reading_state.top, + "scrollbar value did not follow the reading top after new output"); + + // Actual scrollbar End key (same public focus target) returns to the + // bottom; the mouse page/drag paths above already exercised the pointer. + QTest::keyClick(bar, Qt::Key_End); + await([&] { return first_terminal.history_following(); }, first, lifecycle, + "scrollbar End did not resume following"); + require(bar->value() == bar->maximum(), "scrollbar bottom value is not maximum"); + + // Narrow/restore: the scrollbar-selected source anchor survives and the + // bar value tracks the new display top. + drag_scrollbar(bar, bar->height() / 2); + await([&] { return !first_terminal.history_following(); }, first, lifecycle, + "scrollbar drag before resize did not enter reading"); + const uint64_t resize_anchor = first_terminal.history_anchor(); + resize_public(first_terminal, first, 60, 20, lifecycle); + require(first_terminal.history_anchor() == resize_anchor, + "scrollbar reading anchor changed after narrow resize"); + require(static_cast(bar->value()) == first_terminal.history_state().top, + "scrollbar value/top diverged after narrow resize"); + resize_public(first_terminal, first, 80, 24, lifecycle); + require(first_terminal.history_anchor() == resize_anchor, + "scrollbar reading anchor changed after restored resize"); + require(static_cast(bar->value()) == first_terminal.history_state().top, + "scrollbar value/top diverged after restored resize"); + + // Public Find entry: real shortcut, query typing and Escape, with the + // scrollbar synchronized to the revealed source page. + first_window.activateWindow(); first_terminal.setFocus(); QTest::qWait(20); + QTest::keyClick(&first_terminal, Qt::Key_F, Qt::ControlModifier | Qt::ShiftModifier); + auto* find_query = first_window.findChild(QStringLiteral("findQuery")); + require(find_query && find_query->hasFocus(), "Find did not focus the public query entry"); + QTest::keyClicks(find_query, QStringLiteral("TF_H_140")); + await([&] { return first_terminal.find_current_range_for_test().has_value(); }, first, lifecycle, + "public Find did not reveal a history match"); + require(!first_terminal.history_following() && + static_cast(bar->value()) == first_terminal.history_state().top, + "public Find reveal did not synchronize the scrollbar"); + QTest::keyClick(find_query, Qt::Key_Escape); + require(first_terminal.hasFocus(), "public Find Escape did not restore terminal focus"); + + // Public drag selection and Ctrl+Shift+C through an injected writer: + // source text is checked, but this is not a real-CF verification. + auto fake = std::make_unique(); + FakeClipboard* captured = fake.get(); + first_terminal.setClipboardWriterForTest(std::move(fake)); + const int cw = first_terminal.cell_width(), ch = first_terminal.cell_height(); + const QPoint from(8 + 2 * cw, 8 + 2 * ch), to(8 + 24 * cw, 8 + 4 * ch); + QTest::mousePress(&first_terminal, Qt::LeftButton, Qt::NoModifier, from); + QTest::mouseMove(&first_terminal, to); + QTest::mouseRelease(&first_terminal, Qt::LeftButton, Qt::NoModifier, to); + require(first_terminal.has_selection(), "public mouse drag did not select source text"); + QTest::keyClick(&first_terminal, Qt::Key_C, Qt::ControlModifier | Qt::ShiftModifier); + require(!captured->text.empty() && + QString::fromUtf16(captured->text.data(), static_cast(captured->text.size())) + .contains(QStringLiteral("TF_H_")), + "public Ctrl+Shift+C did not extract the selected source text"); + + // Focus returns to the terminal after the public Find entry; ordinary + // child input remains the existing separate input-contract coverage. + require(first_terminal.hasFocus(), "public Find did not return focus to the terminal"); + QTest::keyClick(&first_terminal, Qt::Key_End, Qt::ControlModifier | Qt::ShiftModifier); + await([&] { return first_terminal.history_following(); }, first, lifecycle, + "Ctrl+Shift+End after Find did not restore following"); + require(second_terminal.history_following() && second_terminal.history_scrollbar_for_test()->value() == + second_terminal.history_scrollbar_for_test()->maximum(), + "second session scrollbar was affected by first session reading"); + require(first_terminal.hasFocus(), "first history terminal did not regain focus"); + capture(first_terminal, QStringLiteral("history-bottom")); + + // Ctrl+Shift+End returns to the live bottom; Ctrl+Shift+Home is top. QTest::keyClick(&first_terminal, Qt::Key_End, Qt::ControlModifier | Qt::ShiftModifier); await([&] { return first_terminal.history_following(); }, first, lifecycle, @@ -396,12 +572,44 @@ int main(int argc, char** argv) { "ordinary input did not return to the bottom"); require(second_terminal.history_following(), "second session was affected by first history actions"); + + // A third real child records received bytes; public Find query and the + // following ordinary key must not be misdirected. + const QString third_record = workspace.path() + "/third-history-input.bin"; + SessionModel third; QWidget third_window; TerminalWidget third_terminal(third, &third_window); + third_window.resize(1200, 900); third_window.show(); third_terminal.setFocus(); + const std::wstring third_args = QStringLiteral("--selection-child \"%1\"").arg(third_record).toStdWString(); + require(third.start(executable, third_args, workspace.path().toStdWString()), "third input child did not start"); + await([&] { return third.screen().history_lines().size() >= 20; }, third, lifecycle, + "third input child output missing"); + third_window.activateWindow(); third_terminal.setFocus(); QTest::qWait(20); + 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 did not focus its public query"); + QTest::keyClicks(third_query, QStringLiteral("SEL_HEAD")); + await([&] { return third_terminal.find_current_range_for_test().has_value(); }, third, lifecycle, + "third public Find did not reveal a hit"); + QTest::keyClick(third_query, Qt::Key_Escape); + require(third_terminal.hasFocus(), "third Find Escape did not restore focus"); + QTest::keyClicks(&third_terminal, QStringLiteral("x")); + QElapsedTimer third_wait; third_wait.start(); + while (third_record.size() && QString::fromStdString(read_record(third_record)) != QStringLiteral("x") && + third_wait.elapsed() < 3000) QTest::qWait(10); + require(QString::fromStdString(read_record(third_record)) == QStringLiteral("x"), + "public Find query/ordinary input did not reach the correct child exactly once"); + require(third.send(std::string(1, 'q')), "third child quit rejected"); + await([&] { return third.finished(); }, third, lifecycle, "third child did not finish"); + close_session(first, lifecycle); close_session(second, lifecycle); std::cout << "{\"schema\":\"termforge-qt-history-navigation-v1\",\"status\":\"passed\"," "\"sessions\":2,\"wheel\":true,\"wheel_native\":true,\"wheel_zero\":true,\"wheel_page\":true,\"wheel_page_height\":true,\"wheel_query_failure\":true,\"wheel_setting_update\":true,\"anchor_resize\":true,\"output_during_read\":true," "\"top_bottom\":true,\"eviction\":true,\"ed3\":true,\"ris\":true," - "\"ordinary_input_bottom\":true,\"offscreen_only\":true,\"presented_done\":null}\n"; + "\"ordinary_input_bottom\":true,\"scrollbar_reserved_grid\":true," + "\"scrollbar_mouse_drag\":true,\"scrollbar_page_step\":true," + "\"scrollbar_anchor_sync\":true,\"scrollbar_resize\":true," + "\"scrollbar_find_sync\":true,\"scrollbar_sessions\":true," + "\"offscreen_only\":true,\"presented_done\":null}\n"; return 0; } catch (const std::exception& error) { std::cerr << "Qt history navigation: " << error.what() << '\n'; diff --git a/src/qt/resize_fixture.hpp b/src/qt/resize_fixture.hpp index 5306190..7cbeeaf 100644 --- a/src/qt/resize_fixture.hpp +++ b/src/qt/resize_fixture.hpp @@ -166,10 +166,16 @@ inline int size_child(const std::wstring& pipe_name) { command += character; continue; } - if (command.size() < 3 || (command[0] != 'Q' && command[0] != 'X' && command[0] != 'F') || command[1] != ' ') + if (command.size() < 3 || (command[0] != 'Q' && command[0] != 'X' && command[0] != 'F' && command[0] != 'E') || command[1] != ' ') throw std::runtime_error("Size child input command malformed"); const auto nonce = size_nonce(std::string_view(command).substr(2)); const bool exit = command[0] == 'X'; + if (command[0] == 'E') { + const char clear[] = "\x1b[3J\x1b[H"; + DWORD written = 0; + if (!WriteFile(output.get(), clear, sizeof(clear) - 1, &written, nullptr) || + written != sizeof(clear) - 1) tf_conpty::fail("WriteFile(size child clear output)"); + } if (command[0] == 'F') { const std::string block(4096, 's'); for (int chunk = 0; chunk < 512; ++chunk) { diff --git a/src/qt/resize_smoke.cpp b/src/qt/resize_smoke.cpp index 7a21bc9..5114953 100644 --- a/src/qt/resize_smoke.cpp +++ b/src/qt/resize_smoke.cpp @@ -394,6 +394,7 @@ int main(int argc, char** argv) { count != sizeof(gate) || std::string_view(gate, count) != "GO\n" || !IsProcessInJob(GetCurrentProcess(), nullptr, &owned) || !owned) return 2; QApplication app(argc, argv); + termforge::qt::TerminalWidget::setHistoryScrollbarEnabledForTest(false); QApplication::setQuitOnLastWindowClosed(false); try { QTemporaryDir workspace; diff --git a/src/qt/scrollbar_size_test.cpp b/src/qt/scrollbar_size_test.cpp new file mode 100644 index 0000000..daa8037 --- /dev/null +++ b/src/qt/scrollbar_size_test.cpp @@ -0,0 +1,248 @@ +// Scrollbar-enabled Qt grid -> core -> transport -> real-child CSBI ACK. +// This intentionally does not disable the product scrollbar. +#include "terminal_widget.hpp" +#include "resize_fixture.hpp" +#include +#include +#include +#include +#include +#include +#include + +using termforge::qt::SessionModel; +using termforge::qt::TerminalWidget; +using termforge::qt::test::SizeFixture; +using termforge::qt::test::SizeReply; +using termforge::qt::test::size_child; + +struct Observation { + std::string stage, error; + int order = 0, attempt = 0; + uint64_t nonce = 0; + int expected_columns = 0, expected_rows = 0; + int client_width = 0, scrollbar_extent = 0; + bool scrollbar_visible = false; + int requested_columns = 0, requested_rows = 0, transport_columns = 0, transport_rows = 0; + bool pending = true, accepting = false; + int child_pid = 0; + int buffer_columns = 0, buffer_rows = 0, window_columns = 0, window_rows = 0; + int query_error = 0; + bool match = false; +}; + +namespace { +void require(bool condition, const std::string& message) { + if (!condition) throw std::runtime_error(message); +} +SizeReply wait_reply(SizeFixture& fixture, uint64_t nonce, SessionModel& model) { + QElapsedTimer timer; timer.start(); + while (timer.elapsed() < 5000) { + if (const auto reply = fixture.take_reply()) { + require(reply->nonce == nonce, "size reply nonce mismatch"); + require(reply->query_error == ERROR_SUCCESS, "child CSBI query failed"); + require(reply->child_pid == model.resize_state().child_pid, "size reply is not the Session child"); + return *reply; + } + QTest::qWait(5); + } + throw std::runtime_error("child size reply timed out"); +} +SizeReply query_size(SizeFixture& fixture, SessionModel& model, uint64_t nonce) { + require(model.send("Q " + std::to_string(nonce) + "\r"), "size query input rejected"); + return wait_reply(fixture, nonce, model); +} +bool ack_matches(const SizeReply& reply, TerminalWidget& terminal, SessionModel& model, + int columns, int rows) { + if (model.screen().width() != columns || model.screen().height() != rows) return false; + const auto state = model.resize_state(); + if (!(state.requested_columns == columns && state.requested_rows == rows && + state.transport_columns == columns && state.transport_rows == rows && !state.pending && state.accepting)) + return false; + return reply.buffer_columns == columns && reply.buffer_rows == rows && + reply.window_columns() == columns && reply.window_rows() == rows; +} +void require_ack(const SizeReply& reply, TerminalWidget& terminal, SessionModel& model, + int columns, int rows, const char* stage) { + require(model.screen().width() == columns && model.screen().height() == rows, + std::string(stage) + ": Qt grid differs"); + const auto state = model.resize_state(); + require(state.requested_columns == columns && state.requested_rows == rows && + state.transport_columns == columns && state.transport_rows == rows && + !state.pending && state.accepting, + std::string(stage) + ": core/transport differs"); + require(reply.buffer_columns == columns && reply.buffer_rows == rows && + reply.window_columns() == columns && reply.window_rows() == rows, + std::string(stage) + ": child CSBI does not match"); +} +void resize_public(TerminalWidget& terminal, SessionModel& model, int columns, int rows, + QElapsedTimer& lifecycle) { + const QFontMetricsF metrics(terminal.font()); + const int cw = qCeil(metrics.horizontalAdvance(QLatin1Char('M'))); + const int ch = qCeil(metrics.height()) + 2; + const auto* bar = terminal.history_scrollbar_for_test(); + const int extent = bar && bar->isVisible() ? terminal.history_scrollbar_extent_for_test() : 0; + terminal.resize(16 + extent + columns * cw, 24 + (rows + 1) * ch); + QElapsedTimer phase; phase.start(); + while (phase.elapsed() < 5000) { + const auto state = model.resize_state(); + if (model.screen().width() == columns && model.screen().height() == rows && + state.requested_columns == columns && state.requested_rows == rows && + state.transport_columns == columns && state.transport_rows == rows && !state.pending) + return; + QTest::qWait(10); + if (!model.error().isEmpty()) throw std::runtime_error(model.error().toStdString()); + } + throw std::runtime_error("public Qt scrollbar resize did not finish"); +} +} // namespace + +int main(int argc, char** argv) { + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); + if (argc == 3 && std::string_view(argv[1]) == "--size-child") + try { return size_child(QString::fromLocal8Bit(argv[2]).toStdWString()); } + catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 3; } + if (argc != 1) { std::cerr << "usage: qt_scrollbar_size_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 << "scrollbar size 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(), "scrollbar size workspace unavailable"); + wchar_t executable[32768]{}; + const DWORD length = GetModuleFileNameW(nullptr, executable, 32768); + require(length && length < 32768, "scrollbar size executable unavailable"); + SizeFixture fixture; + SessionModel model; QWidget window; TerminalWidget terminal(model, &window); + window.resize(1200, 900); window.show(); terminal.setFocus(); + require(model.start(executable, L"--size-child \"" + fixture.pipe_name() + L"\"", + workspace.path().toStdWString()), "scrollbar size child did not start"); + wait_reply(fixture, 0, model); + const QFontMetricsF metrics(terminal.font()); + const int cw = (std::max)(1, qCeil(metrics.horizontalAdvance(QLatin1Char('M')))); + const int ch = (std::max)(1, qCeil(metrics.height()) + 2); + auto expected_columns = [&](bool reserved) { + return std::clamp((terminal.width() - 16 - (reserved ? terminal.history_scrollbar_extent_for_test() : 0)) / cw, 1, 500); + }; + auto expected_rows = [&] { return std::clamp((terminal.height() - 24 - ch) / ch, 1, 200); }; + uint64_t next_nonce = 10; + std::vector observations; + int observation_order = 0; + auto observe = [&](const char* stage, int attempt, uint64_t nonce, const SizeReply& reply, + int columns, int rows, const std::string& error = {}) { + const auto state = model.resize_state(); + const auto* scroll = terminal.history_scrollbar_for_test(); + Observation item; + item.stage = stage; item.error = error; item.order = ++observation_order; item.attempt = attempt; + item.nonce = nonce; item.expected_columns = columns; item.expected_rows = rows; + item.client_width = terminal.width(); + item.scrollbar_extent = terminal.history_scrollbar_extent_for_test(); + item.scrollbar_visible = scroll && scroll->isVisible(); + item.requested_columns = state.requested_columns; item.requested_rows = state.requested_rows; + item.transport_columns = state.transport_columns; item.transport_rows = state.transport_rows; + item.pending = state.pending; item.accepting = state.accepting; + item.child_pid = reply.child_pid; + item.buffer_columns = reply.buffer_columns; item.buffer_rows = reply.buffer_rows; + item.window_columns = reply.window_columns(); item.window_rows = reply.window_rows(); + item.query_error = reply.query_error; + item.match = ack_matches(reply, terminal, model, columns, rows); + observations.push_back(std::move(item)); + }; + auto await_ack = [&](int columns, int rows, const char* stage) { + for (int attempt = 0; attempt < 200; ++attempt) { + const uint64_t nonce = next_nonce++; + try { + auto reply = query_size(fixture, model, nonce); + observe(stage, attempt, nonce, reply, columns, rows); + if (ack_matches(reply, terminal, model, columns, rows)) return; + } catch (const std::exception& error) { + SizeReply missing; missing.nonce = nonce; + observe(stage, attempt, nonce, missing, columns, rows, error.what()); + } + QTest::qWait(20); + } + SizeReply missing; missing.nonce = next_nonce; + observe(stage, 200, next_nonce, missing, columns, rows, "timeout"); + require(false, std::string(stage) + ": child CSBI ACK did not settle; see trace"); + }; + auto* bar = terminal.history_scrollbar_for_test(); + require(bar && !bar->isVisible(), "scrollbar appeared without history"); + await_ack(expected_columns(false), expected_rows(), "no-history"); + + // A finite real-child burst fills retained history; the product + // scrollbar appears and its extent changes the grid seen by the child. + require(model.send(std::string("F 2\r")), "finite history output command rejected"); + wait_reply(fixture, 2, model); + QElapsedTimer bar_wait; bar_wait.start(); + while ((!bar->isVisible() || model.screen().width() != expected_columns(true)) && bar_wait.elapsed() < 5000) + QTest::qWait(10); + require(bar->isVisible(), "scrollbar did not appear after history output"); + require(model.screen().width() == expected_columns(true), "scrollbar-reserved grid width differs"); + await_ack(expected_columns(true), expected_rows(), "history-reserved"); + + resize_public(terminal, model, 60, 20, lifecycle); + require(bar->isVisible(), "scrollbar disappeared after narrow resize"); + await_ack(60, 20, "narrow-reserved"); + resize_public(terminal, model, 80, 24, lifecycle); + await_ack(80, 24, "restored-reserved"); + + // ED3 clears retained history; the scrollbar disappears and the full + // available width is acknowledged by the independent child again. + require(model.send(std::string("E 7\r")), "clear-history command rejected"); + wait_reply(fixture, 7, model); + QElapsedTimer hide_wait; hide_wait.start(); + while ((bar->isVisible() || model.screen().width() != expected_columns(false)) && hide_wait.elapsed() < 5000) + QTest::qWait(10); + require(!bar->isVisible(), "scrollbar remained visible after history clear"); + require(model.screen().width() == expected_columns(false), "cleared grid width differs"); + await_ack(expected_columns(false), expected_rows(), "cleared"); + + require(model.send(std::string("X 9\r")), "size child exit command rejected"); + wait_reply(fixture, 9, model); + QElapsedTimer exit_wait; exit_wait.start(); + while (!model.finished() && exit_wait.elapsed() < 5000) QTest::qWait(10); + require(model.finished() && model.natural_exit() && model.cleanup_verified() && + model.exit_code() == 37, "scrollbar size child did not exit cleanly"); + std::cout << "{\"schema\":\"termforge-qt-scrollbar-size-v1\",\"status\":\"passed\"," + "\"no_history_scrollbar\":true,\"history_reserved\":true," + "\"narrow_reserved\":true,\"restored_reserved\":true," + "\"clear_hides\":true,\"child_csbi_ack\":true," + "\"observations\":["; + for (size_t i = 0; i < observations.size(); ++i) { + const auto& o = observations[i]; + if (i) std::cout << ','; + std::cout << "{\"stage\":\"" << o.stage << "\",\"order\":" << o.order + << ",\"attempt\":" << o.attempt << ",\"nonce\":" << o.nonce + << ",\"expected_columns\":" << o.expected_columns + << ",\"expected_rows\":" << o.expected_rows + << ",\"client_width\":" << o.client_width + << ",\"scrollbar_extent\":" << o.scrollbar_extent + << ",\"scrollbar_visible\":" << (o.scrollbar_visible ? "true" : "false") + << ",\"requested_columns\":" << o.requested_columns + << ",\"requested_rows\":" << o.requested_rows + << ",\"transport_columns\":" << o.transport_columns + << ",\"transport_rows\":" << o.transport_rows + << ",\"pending\":" << (o.pending ? "true" : "false") + << ",\"accepting\":" << (o.accepting ? "true" : "false") + << ",\"child_pid\":" << o.child_pid + << ",\"buffer_columns\":" << o.buffer_columns + << ",\"buffer_rows\":" << o.buffer_rows + << ",\"window_columns\":" << o.window_columns + << ",\"window_rows\":" << o.window_rows + << ",\"query_error\":" << o.query_error + << ",\"match\":" << (o.match ? "true" : "false") + << ",\"error\":\"" << o.error << "\"}"; + } + std::cout << "],\"offscreen_only\":true,\"presented_done\":null}\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "Qt scrollbar size: " << error.what() << '\n'; + return 1; + } +} diff --git a/src/qt/selection_migration_test.cpp b/src/qt/selection_migration_test.cpp index 52f047e..db6112e 100644 --- a/src/qt/selection_migration_test.cpp +++ b/src/qt/selection_migration_test.cpp @@ -163,6 +163,7 @@ int main(int argc, char** argv) { std::cerr << "selection test requires its owned Job supervisor\n"; return 2; } QApplication app(argc, argv); + termforge::qt::TerminalWidget::setHistoryScrollbarEnabledForTest(false); QApplication::setQuitOnLastWindowClosed(false); try { QElapsedTimer lifecycle; lifecycle.start(); diff --git a/src/qt/terminal_modes_test.cpp b/src/qt/terminal_modes_test.cpp index 88daecf..5ca78dc 100644 --- a/src/qt/terminal_modes_test.cpp +++ b/src/qt/terminal_modes_test.cpp @@ -362,6 +362,7 @@ int main(int argc, char** argv) { count != sizeof(gate) || std::string_view(gate, count) != "GO\n" || !IsProcessInJob(GetCurrentProcess(), nullptr, &owned) || !owned) return 2; QApplication app(argc, argv); + termforge::qt::TerminalWidget::setHistoryScrollbarEnabledForTest(false); QApplication::setQuitOnLastWindowClosed(false); try { QElapsedTimer lifecycle; lifecycle.start(); diff --git a/src/qt/terminal_widget.hpp b/src/qt/terminal_widget.hpp index 8a1a6d5..4c0a625 100644 --- a/src/qt/terminal_widget.hpp +++ b/src/qt/terminal_widget.hpp @@ -28,6 +28,8 @@ #include #include #include +#include +#include #include #include #include @@ -35,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -56,6 +59,11 @@ class TerminalWidget final : public QWidget { // Every widget owns its page and selection; row identities are never shared // with another session. Hits resolve the last painted page, not a newer row. HistoryViewport viewport_; + QScrollBar* history_scrollbar_ = nullptr; + int scrollbar_extent_ = 0; + bool updating_scrollbar_ = false; + bool scrollbar_reserved_ = false; + static inline bool scrollbar_enabled_for_test_ = true; std::optional painted_page_; // First painted display row inside the page and the page-relative display // caret. The protocol cursor itself is never moved by this widget. @@ -99,14 +107,40 @@ class TerminalWidget final : public QWidget { void synchronize_grid() { painted_page_.reset(); selection_anchor_.reset(); - const int columns = std::clamp((width() - margin * 2) / cell_width_, 1, 500); + const int available = width() - margin * 2 - (scrollbar_reserved_ ? scrollbar_extent_ : 0); + const int columns = std::clamp(available / cell_width_, 1, 500); const int rows = std::clamp((height() - margin * 3 - cell_height_) / cell_height_, 1, 200); model_.resize(columns, rows); } + void update_scrollbar() { + if (!history_scrollbar_) return; + if (!scrollbar_enabled_for_test_) { + history_scrollbar_->hide(); + if (scrollbar_reserved_) { scrollbar_reserved_ = false; synchronize_grid(); } + return; + } + const auto& screen = model_.screen(); + const auto state = viewport_.reconcile(screen); + updating_scrollbar_ = true; + history_scrollbar_->setRange(0, static_cast((std::min)(state.maximum_top, size_t{INT_MAX}))); + history_scrollbar_->setPageStep((std::max)(1, screen.height())); + history_scrollbar_->setSingleStep(1); + history_scrollbar_->setValue(static_cast((std::min)(state.top, size_t{INT_MAX}))); + history_scrollbar_->setGeometry(width() - margin - scrollbar_extent_, margin, + scrollbar_extent_, (std::max)(1, height() - margin * 2)); + const bool should_reserve = state.maximum_top > 0; + history_scrollbar_->setVisible(should_reserve); + updating_scrollbar_ = false; + if (should_reserve != scrollbar_reserved_) { + scrollbar_reserved_ = should_reserve; + synchronize_grid(); + } + } 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(); + update_scrollbar(); } void showEvent(QShowEvent* event) override { QWidget::showEvent(event); @@ -285,6 +319,7 @@ class TerminalWidget final : public QWidget { const int64_t step = wheel_step_rows(); if (step > 0) { viewport_.scroll_by(model_.screen(), -static_cast(detents) * step); + update_scrollbar(); update(); } } @@ -292,10 +327,12 @@ class TerminalWidget final : public QWidget { } void scroll_history_top() { viewport_.scroll_to(model_.screen(), 0); + update_scrollbar(); update(); } void scroll_history_bottom() { viewport_.bottom(model_.screen()); + update_scrollbar(); update(); } static uint32_t navigation_virtual_key(int key) { @@ -586,6 +623,7 @@ class TerminalWidget final : public QWidget { 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_scrollbar(); } } update(); @@ -605,6 +643,7 @@ class TerminalWidget final : public QWidget { find_current_ = nav->match; find_current_index_ = nav->index; find_last_wrapped_ = nav->wrapped; viewport_.reveal(model_.screen(), nav->match.range.begin.line_id); + update_scrollbar(); set_find_status(QStringLiteral("%1 / %2%3").arg(nav->index + 1) .arg(find_search_.matches().size()).arg(nav->wrapped ? QStringLiteral(" (wrapped)") : QString())); update(); @@ -784,6 +823,19 @@ class TerminalWidget final : public QWidget { resize(sizeHint()); grid_ready_ = true; clipboard_ = std::make_unique(*this); + history_scrollbar_ = new QScrollBar(Qt::Vertical, this); + history_scrollbar_->setObjectName(QStringLiteral("historyScrollbar")); + scrollbar_extent_ = style()->pixelMetric(QStyle::PM_ScrollBarExtent); + if (scrollbar_extent_ <= 0) scrollbar_extent_ = 14; + history_scrollbar_->hide(); + connect(history_scrollbar_, &QScrollBar::valueChanged, this, [this](int value) { + if (updating_scrollbar_) return; + const auto& screen = model_.screen(); + const auto state = viewport_.reconcile(screen); + if (value >= static_cast(state.maximum_top)) viewport_.bottom(screen); + else viewport_.scroll_to(screen, static_cast((std::max)(0, value))); + update(); + }); build_find_panel(); model_.on_changed = [this] { refresh_session(); }; } @@ -803,6 +855,7 @@ class TerminalWidget final : public QWidget { // 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()); + update_scrollbar(); update(); } // Diagnostic observation of the owned display chain; tests only read it and @@ -816,6 +869,9 @@ class TerminalWidget final : public QWidget { wheel_setting_override_ = setting; } ViewportState history_state() { return viewport_.reconcile(model_.screen()); } + static void setHistoryScrollbarEnabledForTest(bool enabled) { scrollbar_enabled_for_test_ = enabled; } + QScrollBar* history_scrollbar_for_test() { return history_scrollbar_; } + int history_scrollbar_extent_for_test() const { return scrollbar_extent_; } bool history_following() const { return viewport_.following(); } uint64_t history_anchor() const { return viewport_.anchor(); } // Internal selection extraction for the Qt regression and future copy UI. @@ -835,6 +891,7 @@ class TerminalWidget final : public QWidget { 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(); } + void step_find_once_for_test() { step_find(); } std::optional find_current_range_for_test() const { return find_current_ ? std::optional{find_current_->range} : std::nullopt; }