diff --git a/.github/workflows/qt-cpp.yml b/.github/workflows/qt-cpp.yml index 127c939..1950304 100644 --- a/.github/workflows/qt-cpp.yml +++ b/.github/workflows/qt-cpp.yml @@ -49,7 +49,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_eight_session_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 qt_query_reply_test qt_paste_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_eight_session_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_autoscroll_test qt_clipboard_system_test qt_query_reply_test qt_paste_test --parallel 2 - name: Stage the frozen Qt product and verify every file identity run: | python -m tools.stage_qt_product stage ` @@ -100,6 +100,8 @@ jobs: 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-autoscroll-bin/Release --executable qt_autoscroll_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 } python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-query-reply-bin/Release --executable qt_query_reply_test.exe @@ -127,7 +129,7 @@ jobs: build/qt-cpp/qt-query-reply-bin/Release/runtime-manifest.json build/qt-cpp/qt-paste-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_eight_session|qt_terminal_modes(_retained)?|qt_hidden_retention|qt_history_navigation|qt_selection_migration|qt_find|qt_scrollbar_size|qt_query_reply|qt_paste)$' --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_eight_session|qt_terminal_modes(_retained)?|qt_hidden_retention|qt_history_navigation|qt_selection_migration|qt_find|qt_scrollbar_size|qt_autoscroll|qt_query_reply|qt_paste)$' --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 1081060..28b6aef 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -873,6 +873,35 @@ 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_autoscroll_test src/qt/autoscroll_test.cpp) + set_target_properties(qt_autoscroll_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qt-autoscroll-bin/$") + termforge_qt_entry(qt_autoscroll_test) + target_link_libraries(qt_autoscroll_test PRIVATE Qt6::Test) + add_custom_command(TARGET qt_autoscroll_test POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "$" + "$/platforms/" + VERBATIM) + add_test(NAME qt_autoscroll COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$") + set_tests_properties(qt_autoscroll PROPERTIES TIMEOUT 45 + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + # Opt-in single-run literal oracle for the #327 recovery protocol v2. It is + # built on demand but deliberately NOT registered with add_test(): the + # literal run consumes a separately authorized one-shot budget, so CTest, + # push and PR runs must never execute it. Invoke it manually through + # run_smoke.py with --literal-v2 and an evidence path. + add_executable(qt_autoscroll_literal_test src/qt/autoscroll_literal_test.cpp) + set_target_properties(qt_autoscroll_literal_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qt-autoscroll-literal-bin/$") + termforge_qt_entry(qt_autoscroll_literal_test) + target_link_libraries(qt_autoscroll_literal_test PRIVATE Qt6::Test) + add_custom_command(TARGET qt_autoscroll_literal_test POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "$" + "$/platforms/" + VERBATIM) 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/$") diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index 46d8ae2..30c6ef6 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 切片) | [实现、真实鼠标/键盘与截图](../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 切片) | [实现、策略映射与真实 child 结果](../experiments/shell/results/2026-11-07-qt-selection-autoscroll/README.md):指针拖出可见上/下边缘时每 tick 滚动 1 源行并扩展选区;拖回/松开/失焦/隐藏/resize/关闭停止;高亮/Copy/滚动条同源;双会话隔离;本地 Qt 12/12 | 离屏;Copy 为注入 writer 非真实 CF;不含选词/选行/矩形/TUI/持久化/性能阈值;不关闭 #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 有界系统粘贴入口(#29/#30 切片) | [实现、本地 child 字节回归与 hosted Windows QPA CF](../experiments/shell/results/2026-11-06-qt-system-paste/README.md):Ctrl+Shift+V/Shift+Insert/实际 Paste 菜单接默认 CF 读取器、分块发送、目标绑定、多行确认;本地 fake reader + 真实 child 1/1、Qt 10/10(含双会话切换/读取中关闭/新会话隔离);hosted Windows QPA run `42183487961`/job `127778260596` 三入口/真实占用失败/释放后成功通过 | hosted 多行确认保留为历史失败(首次 `eecf21f`、修复后 `5ea8e44` 均未收到 `L1\rL2`;恢复额度已用尽,未再 hosted 重试,见[尝试账本](../experiments/shell/results/2026-11-06-qt-system-paste/attempt-ledger.md));不覆盖 bracketed paste/TUI/自动逐行/Find/物理呈现/DPI/性能;系统 CF 仅该一次性 hosted 运行;#27/#29/#30/#35/#7 不关闭 | | 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 完成 | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index ec165a5..d6b6849 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));有界系统粘贴已接 Ctrl+Shift+V/Shift+Insert/实际 Paste 菜单与默认 CF 读取器,hosted Windows QPA 验证三入口/占用/释放,多行确认仅本地 fake reader(见[系统粘贴结果](../experiments/shell/results/2026-11-06-qt-system-paste/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)),翻页键历史与粘贴回底未迁移;八会话目标隔离与生命周期已用公开 `+`/标签切换/关闭入口和每会话独立 PID/nonce/接收文件有限验证([固定结果](../experiments/shell/results/2026-11-08-qt-eight-session-isolation/README.md)):8 个真实固定 ConPTY child、非创建顺序切换、固定 120 行后台输出排空、关闭非活动/活动标签后存活目标输入、替代页不继承待发单元、父窗关闭回收 | 分屏、其他 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 标签/尺寸行为对照未完成;八会话仅固定 8 会话/80×24/离屏合成事件/有界后台输出,未定义或测试第 9 个会话行为,真实键盘/原生 IME/系统剪贴板未验收;当前不是可发布开发包 | 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));有界系统粘贴已接 Ctrl+Shift+V/Shift+Insert/实际 Paste 菜单与默认 CF 读取器,hosted Windows QPA 验证三入口/占用/释放,多行确认仅本地 fake reader(见[系统粘贴结果](../experiments/shell/results/2026-11-06-qt-system-paste/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));跨历史拖选有界自动滚动已接 Qt 定时器/HistoryViewport(见[自动滚动结果](../experiments/shell/results/2026-11-07-qt-selection-autoscroll/README.md)),翻页键历史与粘贴回底未迁移;八会话目标隔离与生命周期已用公开 `+`/标签切换/关闭入口和每会话独立 PID/nonce/接收文件有限验证([固定结果](../experiments/shell/results/2026-11-08-qt-eight-session-isolation/README.md)):8 个真实固定 ConPTY child、非创建顺序切换、固定 120 行后台输出排空、关闭非活动/活动标签后存活目标输入、替代页不继承待发单元、父窗关闭回收 | 分屏、其他 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 标签/尺寸行为对照未完成;八会话仅固定 8 会话/80×24/离屏合成事件/有界后台输出,未定义或测试第 9 个会话行为,真实键盘/原生 IME/系统剪贴板未验收;当前不是可发布开发包 | 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-selection-autoscroll/README.md b/experiments/shell/results/2026-11-07-qt-selection-autoscroll/README.md new file mode 100644 index 0000000..2fe2f0f --- /dev/null +++ b/experiments/shell/results/2026-11-07-qt-selection-autoscroll/README.md @@ -0,0 +1,28 @@ +# 正式 Qt:跨历史拖选的有界自动滚动 + +本切片把既有 Win32 R1-03“稳定行列选区、拖出可见区有界自动滚动”合同迁移到正式 Qt 正常主屏,复用现有 Screen owner/行身份/rank/epoch、`HistoryViewport`、`ViewMapping` 与 `TextSelection`;不新增历史存储或选择模型。 + +## 策略(Win32 → Qt 映射) + +- Win32 `selection_autoscroll`:仅在 selecting、未关闭、仍持有 capture 时,按指针是否在可见 extent 上/下决定方向;每个 timer tick `viewport.scroll_by(screen, ±1)`,滚动成功后再 `selection_move`,因此每 tick 最多一行。 +- Qt:公开鼠标左键按下开始选区;拖出可见网格上/下边缘启动 Qt 定时器(40ms,项目策略;无性能验收含义),每 tick `HistoryViewport::scroll_by(±1)` 并把选区端点重扩展到边缘源位置;拖回/松开/失焦/隐藏/UngrabMouse/无关 resize/关闭会话停止。 +- 滚动条/阅读锚点、painted 高亮、Copy 全部使用同一 `TextSelection` 源范围;无第二历史或 resize 模型。默认滚动条启用。 + +## 验证 + +`qt_autoscroll`(真实固定 ConPTY child、两会话、默认滚动条、注入 writer): + +- 默认 40ms 从底部按住拖到上方:每 tick 一行;以独立字面 oracle 比较完整 Copy 文本(非 contains);高亮 span 源行/列逐项一致;反向跨页选择。 +- 正向拖到下方:`top` 增加量 == 新增 tick 数,同字面 oracle 比较 Copy/高亮。 +- 拖回、活跃边缘直接松开:定时器停止;观察窗内 top/选区不再变化。 +- 活跃边缘失焦、关闭目标会话:定时器停止、top 稳定、无后台选区变化。 +- 两 child 自然退出 37、自有 Job 清理通过;复制使用注入 writer,非真实 CF。 + +## 未执行 / 阻塞 + +- soft/hard 混合及宽字/结合标的跨页自动滚动源列;协调缩窄/恢复;持续追加/淘汰/ED3/RIS 与活跃 autoscroll 的联合行为;活跃期间 Find/滚动条交叉。当前 12/12 不替代这些。 + +## 证据边界 + +- 离屏 Qt;物理呈现/DPI 未验证;不包含选词/选行、矩形选择、备用屏/TUI、配置持久化或新性能阈值。 +- 本切片不关闭 #29/#30/R2;#324 仍等待用户决定;BCD/v2、旧失败与未跟踪文件不变。 diff --git a/experiments/shell/results/2026-11-07-qt-selection-autoscroll/literal-oracle-diagnosis.md b/experiments/shell/results/2026-11-07-qt-selection-autoscroll/literal-oracle-diagnosis.md new file mode 100644 index 0000000..bef36c7 --- /dev/null +++ b/experiments/shell/results/2026-11-07-qt-selection-autoscroll/literal-oracle-diagnosis.md @@ -0,0 +1,30 @@ +# #327 独立字面 oracle 失败(up-reverse)离线诊断结论 + +故障指纹:`pr327-literal-oracle-up-reverse`。本文件只记录离线诊断结论;旧 5 次失败/4 次重跑账本见 `offline-ledger.md`,历史失败与产物保持不变。本诊断未运行被测程序,全部依据为已保全的原始产物、补丁脚本与 ac5dd4f 源码。 + +## 证据基线(已保全) + +- `build/auto-debug.out`(749 B,SHA-256 `93e6824a…6e21`):`copy_debug label=up-reverse expected_len=88 actual_len=170`;expected = `TF_H_135`…`TF_H_143`(9 行含 CRLF);actual = `TF_H_143`…`TF_H_159`(17 行,末尾带 CRLF)。 +- `build/autoscroll-literal.py`(SHA-256 `3ef26b7e…9374`):把 `check_exact("up-reverse")` 改为 `check_exact("up-reverse", literal_range(up_top_line - ticks, up_start_line))`;`up_top_line`/`up_start_line` 取自 up 阶段前 `screen.visible_lines()` 的第 0/4 行;`ticks` 为 up 阶段后读出的计时器 tick 数。 +- `build/fix-autoscroll-literal.py`(SHA-256 `eba4a95b…18f7`)与 `build/debug-autoscroll-copy.py`(SHA-256 `97c9784e…0c75`):仅去重声明与追加失败打印,未改变场景结构。 +- ac5dd4f `src/qt/autoscroll_test.cpp`:up 拖选阶段(119–131 行)之后、 `check_exact("up-reverse")`(179 行)之前,存在**向下拖选阶段**(132–140 行:移到网格下方边缘外并 qWait(120),产生 down 自动滚动)。 + +## 机制级根因(观察 → 推断) + +观察: + +1. 提交场景在 up 阶段之后、检查之前**故意执行了一个 down 阶段**。down 拖动把活动端点拉到 anchor 之下,选区方向翻转,归一化后范围变为 `[anchor 行, 底部行+down ticks]`。 +2. 字面 oracle 的期望公式 `literal_range(up_top_line - ticks, up_start_line)` **只建模 up 阶段**(端点在 top 行、anchor 在按下随行),完全没有表达中间的 down 阶段。 +3. actual 的起点 `TF_H_143` 恰等于 `up_start_line`(anchor 行),终点 `TF_H_159` 在 anchor 之下——这正是"端点越过 anchor 后范围翻转为 `[anchor, 下方]`"的特征签名。170 B = 17 行全文 + 每个非末行 CRLF + 末行 CRLF(half-open 终点 `(160, col0)`)。 +4. 同一源码在 ac5dd4f 的内部一致性 oracle(expected 从 actual range 推导)下通过;CI 10/10 与该内部一致性绿色不覆盖此构造错误。 + +推断(高支持度):**失败是字面 oracle(夹具)的构造错误,不是产品缺陷。** 期望公式在错误的场景点(down 阶段之后)断言了只适用于 up 阶段的状态;actual 与提交场景的既定语义(anchor 固定、端点跟随边缘映射、范围归一化、copy 按 half-open 拼接)完全一致。旧 oracle 的 `literal_range` 同时忽略列裁剪(按全文生成期望),是第二个夹具不准确处,但被主错误掩盖。 + +未验证/不排除:旧失败运行从未隔离出"仅 up 阶段"的观测,因此不能凭旧产物对 up 阶段本身的产品行为下任何结论(无论正面还是负面)。该隔离正是 v2 最小路径的目的。 + +## 结论与修复 + +- 产品代码零改动:既定 `HistoryViewport`/`ViewMapping`/`TextSelection` 与源身份保持不变;没有发现需要修的产品缺陷。 +- 夹具修复:以 `recovery-protocol-v2.md` 替代旧独立 oracle——固定字面预期(`TF_H_100` / `TF_H_099\r\nTF_H_100`),公开定位到 painted row0=`TF_H_100`,非滚动 sanity 先行,恰好 1 个默认 40ms tick 并在首个有效 `QScrollBar::valueChanged` 槽内公开 Release,release 后 200ms 不得有第二 tick。预期只来自固定字面,不从 actual range 生成。 +- 旧账本不变:5 次失败、4 次重跑、一次恢复额度已耗尽的事实保留;v2 单次授权见 PR #327 comment 6717182670。 +- v2 入口为 `src/qt/autoscroll_literal_test.cpp`(目标 `qt_autoscroll_literal_test`):**不注册 CTest**,仅在显式 `--literal-v2` + Job 主管下手动运行一次,防止 CTest/push/PR 双跑消耗额度;普通离线回归仍是原 `qt_autoscroll` CTest。 diff --git a/experiments/shell/results/2026-11-07-qt-selection-autoscroll/literal-v2-run-1.json b/experiments/shell/results/2026-11-07-qt-selection-autoscroll/literal-v2-run-1.json new file mode 100644 index 0000000..d2419b5 --- /dev/null +++ b/experiments/shell/results/2026-11-07-qt-selection-autoscroll/literal-v2-run-1.json @@ -0,0 +1,5 @@ +{ + "error": "literal autoscroll fixture did not reach 160 lines", + "schema": "termforge-qt-selection-autoscroll-literal-v2", + "status": "failed" +} diff --git a/experiments/shell/results/2026-11-07-qt-selection-autoscroll/offline-ledger.md b/experiments/shell/results/2026-11-07-qt-selection-autoscroll/offline-ledger.md new file mode 100644 index 0000000..7f3415a --- /dev/null +++ b/experiments/shell/results/2026-11-07-qt-selection-autoscroll/offline-ledger.md @@ -0,0 +1,73 @@ +# #327 独立 oracle 失败:离线尝试账本与诊断 + +## 故障指纹 + +`pr327-literal-oracle-up-reverse`:独立字面 oracle 在 `up-reverse` 阶段失败;所有失败输出均含 `up-reverse: copied source text differs from literal oracle`。**未证明为产品缺陷,也未证明仅为夹具问题。** + +## 尝试账本(只记录已发生的执行,不再重跑) + +| 顺序 | 时间 (UTC) | 动作 | 结果 | +|---|---|---|---| +| 1 | 13:09:49 | 首次运行独立 oracle | 失败,result seq22186 | +| 2 | 13:10:01 | 修改起点为列 0 后重跑(恢复 1) | 失败,seq22198 | +| 3 | 13:10:20 | 增加 copy debug 打印后重跑(实际重跑 2) | 失败,seq22210 | +| 4 | 13:10:36 | 标为读取 debug 但实际再次运行 run_smoke 并写 `build/auto-debug.out`(实际重跑 3) | 失败,seq22217 | +| 5 | 13:10:48 | restore 源码但未重建,运行旧二进制(实际重跑 4) | 失败,seq22224 | +| 6 | 13:10:59 | 重建已回退的 ac5dd4f;旧内部一致性测试恢复通过 | 通过,但**不是**独立 oracle 成功,不解除本故障 | + +结论:首次失败后已有 4 次实际重跑,授权的一次恢复已耗尽;不得通过改名、修改期望、回退源码或新一轮 heartbeat 重置。本轮仅离线诊断,未运行被测程序。 + +## 保全的原始产物 + +- `build/auto-debug.out`,749 bytes,SHA-256 `93e6824a89e6dff40756900205f338f37ad9c8319a32fd6771bea8555f886e21`。 +- `build/autoscroll-literal.py`,SHA-256 `3ef26b7e8d213432a8ba94cad5c45f0325a6ea70f9c5d96b970c058f70849374`。 +- `build/fix-autoscroll-literal.py`,SHA-256 `eba4a95b892160c73df33e3cd26df6855120ff9378fef21728e8ab62102a18f7`。 +- `build/debug-autoscroll-copy.py`,SHA-256 `97c9784e64e1c0ae829f8cd1090935000480b5104a9633b5868141657fec0c75`。 +- 失败期间的完整 `autoscroll_test.cpp`/`terminal_widget.hpp` 快照**未保留**(工作树已回退到 ac5dd4f);不根据补丁脚本反推或补造完整失败源码。当前工作树与 ac5dd4f 一致。 +- 只使用合成 fixture 和脱敏片段;不发布整份私有会话。 + +## 观测 + +`auto-debug.out`: + +- `expected_len=88`,`actual_len=170`。 +- expected = `TF_H_135` … `TF_H_143`(9 行,含 CRLF)。 +- actual = `TF_H_143` … `TF_H_159`(17 行,含 CRLF)。 +- 日志中行间空行是 CRLF 在文本输出的显示结果,不代表空源行。 + +## 静态分析(观察 / 假设 / 未验证) + +观察: + +- 产品鼠标选区的端点由 `TerminalWidget::selection_position` 从 `painted_page_->mapping` 取源位置;该页可带 `painted_row_offset_`(底部 200 行 projection 的 caret 偏移,见 `terminal_widget.hpp` 中 `painted_row_offset_ = first`)。 +- 失败测试的期望构造使用了 `screen.visible_lines()` 的行号(在失败文件已不保留,补丁脚本显示该方向),而不是 painted page 的显示起点;两者在底部历史 projection 时不一定相同。 +- 历史 fixture 在启动后 1500ms 自发追加 161–180;拖选/采样期间可能发生追加,`HistoryViewport::reconcile` 可能改变 top/页面偏移。 +- 自动滚动 timer 在方向首次置位时启动并清零 tick 计数;测试在 qWait 后读取 tick 数,存在采样窗口。 + +假设(按支持度): + +1. **高**:期望的显示行/源行映射用错坐标(screen.visible 行 vs painted page 偏移),导致 expected 起点 135、actual 起点 143 的 8 行偏移;端点按 tick 计算也继承偏移。 +2. **中**:自发追加/异步 viewport reconcile 改变了采样时的 top/锚点,使实际选中范围扩展到 159。 +3. **中**:tick 采样窗口/自动滚动启动时刻与 expected 使用的 ticks 不一致,导致范围长度 9 vs 17。 +4. **未验证**:产品端点在边缘/页面偏移下确实选错源行。没有证据支持或排除;不能据此改产品。 + +以上都不能在没有一次受控重跑的情况下定论;不得用现有失败反推产品缺陷。 + +## 可审批的恢复计划(未执行,需额外授权) + +- 冻结输入:固定 1200×900 窗口,等待 initial 1–180 与 prompt 完全 settled,阶段内禁止异步追加;使用已知固定行列表作为唯一字面来源。 +- 冻结视图:先用公开滚动条/键盘把页面停在已知源行 `TF_H_100`,校验显示 row0 字面文本;选择起点固定为该 row0、column0,仅执行 **1 次** 向上自动滚动(1 tick),期望 range 为 `TF_H_099`+CRLF+`TF_H_100`,Copy 完整字面比较;不做多 tick、不做宽字/软行混合,先验证最小路径。 +- 先做 no-autoscroll 单行 Copy sanity(同一起点/终点),再执行上述 1-tick 路径;两者都保存原始 expected/actual。 +- 运行一次,通过出口:Copy 字节==固定字面、实际源 range 端点对应显示 `TF_H_099`/`TF_H_100`、painted 高亮列与 range 一致;失败出口:立即停止并归档,不再重跑,给出具体原因/解除条件。 +- 在该最小路径通过后,另立计划逐步加入软/硬行、宽字/结合标、缩窄/恢复、追加/淘汰/ED3/RIS、Find/滚动条、正确 child 输入;每一步先冻结输入,禁止用旧单独测试替代联合行为。 + +## 当前状态 + +- PR #327 保持 blocked,不合并;当前 head `ac5dd4f` 的既有 10/10 CI 与本地 12/12 只属于已有内部一致性覆盖。 +- 未执行:独立 oracle 通过、软/宽/结合标、协调缩窄/恢复、追加/淘汰/ED3/RIS 联合、Find/滚动条联合、公开输入正确 child 独立接收。 +- 未动 #324/BCD/v2/个人 CF/桌面/未跟踪脚本;未触发 Codex review。 + + +## 替代提案 + +本文件的旧一次运行提案已被 `recovery-protocol-v2.md` 替代;v2 采用 row0 字面 TF_H_100 起步与默认 40ms 公开 scrollbar valueChanged 首 tick 释放,具体见 PR #327 comment 6717182670。旧提案与旧账本保留,不修改历史失败。 diff --git a/experiments/shell/results/2026-11-07-qt-selection-autoscroll/recovery-protocol-v2.md b/experiments/shell/results/2026-11-07-qt-selection-autoscroll/recovery-protocol-v2.md new file mode 100644 index 0000000..caabbd4 --- /dev/null +++ b/experiments/shell/results/2026-11-07-qt-selection-autoscroll/recovery-protocol-v2.md @@ -0,0 +1,43 @@ +# #327 恢复协议 v2(离线提案,未执行,待用户授权) + +基线:PR327 head `ac5dd4fb5de5e6b22302c1ed32bfd6483cc4fe77`;main `8506c6c` 仅参考。旧账本/失败产物不变,现有 5 次失败、4 次重跑与一次恢复额度已耗尽。本文件不运行、不构建、不改产品。 + +## 1. 固定输入与起点 + +- fixture:`history_fixture.hpp` 的确定性 child。输出为硬 CRLF 物理行 `TF_H_001` … `TF_H_160`,随后 `TF_END`、prompt;启动 1500ms 后自发追加 `TF_H_161` … `TF_H_180`、`TF_END2/3`。每行是独立硬行,无软续行,文本长度 8,不含宽字/结合标。 +- settled 条件:等待画面出现 `TF_H_180`、`TF_END3` 与 prompt,且之后至少一个完整观察窗(例如 500ms)无新输出/无保留行变化;记录当时的 retained_size、top、anchor、滚动条 range/value。此后拖选期间不得有追加。 +- 通过公开键盘 `Ctrl+Shift+Home` 到历史最早保留行,再通过**公开滚动条单步/页操作**向下移动,直到 painted page 的 row0 文本恰为 `TF_H_100`(每次移动都记录滚动条 value/row0 字面)。若越过或找不到该行,协议失败,不选其他行替代。 +- 此时屏幕 page top source = `TF_H_100`,row1 = `TF_H_101`;源行均为硬行。只读记录来自 painted page 文本;independent expected 不来自 actual selection range。 + +## 2. 非滚动 sanity(公开鼠标) + +- 鼠标左键在 row0、`TF_H_100` 末尾按下:`x = margin + 8*cell_width`,`y = margin + 1`(选择命中 nearest 规则得到 column=8,即行尾 half-open 端点);按下事件 `MouseButtonPress`, LeftButton, buttons=Left。 +- 在同一 row0 的 column0 释放:`x=margin, y=margin+1`,`MouseButtonRelease`, LeftButton, buttons=NoButton。无移动/无 timer。 +- 预期:range 为 `TF_H_100` 的 col0..8;Copy 注入 writer 的完整文本恰为 UTF-8 `TF_H_100`(8 字节,无 CRLF,因为 begin/end 在同一行且终列=行宽,offset+1==saved size)。hex `54465f485f313030`。若有 CRLF 或额外行即失败。 + +## 3. 指定自动滚动路径(默认 40ms,公开事件) + +- 重新定位到 page top=`TF_H_100`(同上),记录滚动条 value `v0`、top、painted row0 字面。 +- `MouseButtonPress` 在 row0、column8(`TF_H_100` 末尾):x=`margin+8*cw`, y=`margin+1`,LeftButton,buttons=Left。 +- 立即 `MouseMove` 到可见网格上方外部:x=`margin`(column0),y=`-20`,buttons=Left,button=NoButton。该移动同时把 selection endpoint 设为 edge column0;timer 在 40ms 路径启动。 +- 观察并记录滚动条 `valueChanged` 序列与时间:从 `v0` 到 `v0-1`(即 top 从 `TF_H_100` 到 `TF_H_099`)是**第一次有效 tick**。在该信号槽内立即发送 `MouseButtonRelease` 到 `(margin,-20)`,LeftButton,buttons=NoButton。这样下一次 40ms tick 尚未发生。 +- 记录:首次移动时间、第一次 valueChanged 时间、release 时间、信号序列、release 后 200ms 内是否还有 valueChanged/top/selection 变化。若出现第二个 valueChanged 或 top 100→99→98,协议失败(不是恰好一次有效 tick)。 +- 通过出口(全部满足): + - valueChanged trace 只有一次有效变化 `v0 -> v0-1`;release 后观察窗无进一步滚动。 + - actual source range:begin = `TF_H_099` col0,end = `TF_H_100` col8;不得用 range 生成 expected,expected 来自固定字面。 + - painted highlight:page row0 是 begin 行 `TF_H_099` 的 0..8 列,row1 是 end 行 `TF_H_100` 的 0..8 列;只读 span 映射到固定字面行,不混 `visible_lines` 与 painted 坐标。 + - Copy 完整文本:按 `TextSelection::copy` 的 half-open/包含性,begin `TF_H_099` col0、end `TF_H_100` col8,且两行均硬行:复制 line099 全文 + CRLF + line100 全文,**无末尾 CRLF**。 + - 字面:`TF_H_099\r\nTF_H_100` + - UTF-8 hex:`54465f485f3039390d0a54465f485f313030` + - 长度 18 字节。 +- 失败出口:任一字面、range、highlight、tick trace、settled 条件不符即停,保存原始事件/信号/截图/文本;不再重跑,不修改期望,不用内部 range 反推。 + +## 4. 单次授权边界 + +一次授权只覆盖:一次 settled 定位 + 一次非滚动 sanity + 一次指定 1-tick 自动滚动路径(可含必需的失败诊断记录,但同一路径不重跑)。首失败即停;成功也不代表原交叉矩阵(软/硬、宽/结合标、缩窄/恢复、追加/淘汰/ED3/RIS、Find/滚动条、正确 child 输入)完成,也不关闭 #29/#30/R2。后续集成/回归与新故障计数另行授权,不因本次通过扩大额度。 + +## 5. 已知不确定/需用户决定 + +- 公开滚动条单步是否能稳定走到 row0=`TF_H_100`(可能需要页操作;每次移动需记录)。若不能精确定位,按失败出口停止,不换行替代。 +- 滚动条 `valueChanged` 在 40ms timer 下是否能作为唯一 tick 观察信号;若平台合并/延迟信号,按失败出口记录“无法判定恰好一次”,不调慢 timer、不直接调用滚动函数。 +- 实际 child 行身份/PID、构建产物身份、窗口尺寸、cell 尺寸需在运行时记录;本提案不预填运行观察。 diff --git a/experiments/shell/results/2026-11-07-qt-selection-autoscroll/v2-run-ledger.md b/experiments/shell/results/2026-11-07-qt-selection-autoscroll/v2-run-ledger.md new file mode 100644 index 0000000..a7195a2 --- /dev/null +++ b/experiments/shell/results/2026-11-07-qt-selection-autoscroll/v2-run-ledger.md @@ -0,0 +1,22 @@ +# #327 v2 字面恢复:单次执行账本 + +授权:PR #327 comment 6717182670 v2 协议;预算 1 次执行。本账本只追加,不修改。 + +## 执行 1(2026-11-12,已消耗) + +- 场景身份:`qt_autoscroll_literal_test.exe --literal-v2`(opt-in 单次入口,未注册 CTest)。 +- 源码/构建身份:branch head `8d01e9a324a1d707ef7fb010feedbcc73cd63bd6`(merge `742b399` + 本测试提交);`src/qt/autoscroll_literal_test.cpp` SHA-256 `04328457e66218cdaafb0e14dc236d858910d778866e08757344b6b8d9dccf6b`;`src/qt/terminal_widget.hpp`(合并 #324 后)SHA-256 `cf5ac6d3e2bdfd5028058241657346705dfe5e95ffac23bf9487ae6867ffa815`;二进制 SHA-256 `3207a523b9a94b940fd748b88386d4039ace0c6fc43ca97d3e4d3fe6e32fe1e0`。 +- 入口:run_smoke.py(offscreen、Job 主管、GO 门);未调慢 timer、未直接调用内部滚动函数、未换 oracle。 +- 结果:**失败**,`literal autoscroll fixture did not reach 160 lines`(8s 阶段上限)。supervisor JSON:`status=failed, exit_code=1, job_after{active:0,total:4,terminated:0}`,无遗留进程。 +- 原始产物:`literal-v2-run-1.json`(证据文件,仅含 error/schema/status);`build/literal-v2-run-1.supervisor.log`(supervisor 原始输出)。 +- child PID:未到达记录点(失败发生在 settled 记录之前);Job 会计 total=4。 +- 协议路径消耗:settled 定位 0 次、非滚动 sanity 0 次、1-tick 路径 0 次——失败发生在三者之前的夹具活性门。 + +## 根因(离线确诊,无需重跑) + +测试活性门 `history_lines().size() >= 160` 在任何几何下都不可达:fixture 首批写 160 行 + `TF_END` + prompt(prompt 为 live 行),屏幕高 H(24–45 行)时历史行数 = 161−H ≤ 137;自发追加(161–180 + `TF_END2`)后 = 182−H ≤ 158 < 160;只有公开 `'m'` 批(181–220)后历史才超过 160——而 `'m'` 的发送被该门死锁。即:测试夹具前置条件写死,非产品行为、非 v2 协议路径失败。提交版 `autoscroll_test.cpp` 用 `>= 100`(首批后 ≈116–137 可达)正因同一算术。 + +## 解除条件(待授权方决定) + +- 修复:把初始活性门改为 `>= 100`(与提交版一致);settled 仍由 `TF_END`→`TF_END2`→`'m'`→`TF_END3`→500ms 安静窗逐字锁定,行数门仅作活性证明。改动只触测试源码,不触产品/协议/期望。 +- 然后由授权方明确:执行 1 未消耗任何协议路径(settled/sanity/tick 均为 0 次),是否批准一次修正后的执行。未获批准前不再运行。 diff --git a/experiments/shell/results/2026-11-07-qt-selection-autoscroll/validation.json b/experiments/shell/results/2026-11-07-qt-selection-autoscroll/validation.json new file mode 100644 index 0000000..5f7afed --- /dev/null +++ b/experiments/shell/results/2026-11-07-qt-selection-autoscroll/validation.json @@ -0,0 +1,76 @@ +{ + "schema": "termforge-qt-selection-autoscroll-validation-v1", + "baseline_main": "0dae679283817eb77ef0d3e63fcbc051e6babc81", + "source_commit": "71e08e1", + "source_hash_note": "SHA-256 of committed LF blobs at 71e08e1; docs follow-up.", + "source_files_sha256": { + "src/qt/terminal_widget.hpp": "d62e6c261f897a9eef02002f931c7aad28e81bfaedb2e7a4c555080894153d3c", + "src/qt/autoscroll_test.cpp": "b8b1025838b19ccc487733c9c4dace3b46dadc4cd01e51bb9157a67f249ee58d", + "CMakeLists.txt": "73a315611909ad686a8e6f0ede15103a96d49579c101dce5e089c28124aefde4", + ".github/workflows/qt-cpp.yml": "9cfe91b25b06a0329970d798968d5f5eae0261c1b8fe9ebed9d6e0b536faed66" + }, + "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" + }, + "policy": { + "timer_ms": 40, + "scroll_per_tick_rows": 1, + "stop": [ + "drag_back_inside", + "mouse_release", + "focus_loss", + "hide", + "ungrab", + "resize", + "session_close" + ], + "default_scrollbar": "enabled" + }, + "checks": { + "bounded_up": true, + "bounded_down": true, + "copy_source_text": true, + "drag_back_stop": true, + "release_stop": true, + "focus_loss_stop": true, + "two_sessions": true, + "child_cleanup": true, + "injected_writer_not_real_cf": true, + "offscreen_only": true, + "exact_source_oracle": true, + "exact_highlight": true, + "active_edge_release": true, + "default_40ms_path": true, + "active_focus_loss": true, + "active_session_close": true + }, + "tests": { + "qt_autoscroll": { + "passed": 1, + "failed": 0, + "seconds": 2.78 + }, + "qt_formal_path_regression": { + "passed": 12, + "failed": 0, + "seconds": 50.63 + } + }, + "limitations": [ + "Offscreen Qt; physical presentation/DPI is not covered.", + "Copy uses an injected writer, not real system CF.", + "No word/line/rect selection, alternate-screen/TUI, persistence or performance threshold.", + "Does not close #29/#30/R2; #324 remains blocked.", + "Not executed: soft/wide/combining, coordinated narrow/restore, append/eviction/ED3/RIS and Find/scrollbar crossings with an active autoscroll timer. These are not covered by the 12/12 result." + ], + "not_executed": [ + "soft/hard mixed and wide/combining autoscroll source columns", + "narrow/restore while autoscroll active", + "continuous append/eviction/ED3/RIS combined with active autoscroll", + "Find/scrollbar cross while actively auto-scrolling" + ] +} \ No newline at end of file diff --git a/src/qt/autoscroll_literal_test.cpp b/src/qt/autoscroll_literal_test.cpp new file mode 100644 index 0000000..7143bdf --- /dev/null +++ b/src/qt/autoscroll_literal_test.cpp @@ -0,0 +1,334 @@ +// Opt-in single-run literal oracle for #327 recovery protocol v2. This binary +// is deliberately NOT registered in CTest: the bounded literal run consumes a +// separately authorized budget, so CTest/push/PR must never trigger it. It +// verifies, with fixed literal expectations (never derived from the actual +// selection range), the minimal public path: settle, public Ctrl+Shift+Home + +// public scrollbar single-steps to painted row0 == TF_H_100, a non-scroll +// single-line copy sanity, then exactly one default-40ms auto-scroll tick +// released inside the first valid QScrollBar::valueChanged. +#include "terminal_widget.hpp" +#include "history_fixture.hpp" +#include "../../experiments/core/text_document.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using termforge::qt::SessionModel; +using termforge::qt::TerminalWidget; +using termforge::qt::test::history_child; +using termforge::TextDocument; +using termforge::TextRange; + +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, {}}; + } +}; +void require(bool condition, const std::string& message) { + if (!condition) throw std::runtime_error(message); +} +void await(const std::function& predicate, SessionModel& model, QElapsedTimer& lifecycle, const char* message) { + QElapsedTimer phase; phase.start(); + while (!predicate() && phase.elapsed() < 8000 && lifecycle.elapsed() < 25000) { + QTest::qWait(10); + if (!model.error().isEmpty()) throw std::runtime_error(model.error().toStdString()); + } + require(predicate(), message); +} +void send_mouse(TerminalWidget& terminal, QEvent::Type type, QPointF pos, Qt::MouseButton button, Qt::MouseButtons buttons) { + QMouseEvent event(type, pos, pos, button, buttons, Qt::NoModifier); + QApplication::sendEvent(&terminal, &event); +} +// Literal text of one painted display row. The grab() forces a synchronous +// paint so painted_page_ is current even without an event-loop pass (the same +// read pattern the history navigation test uses offscreen). Read-only: the +// painted page is the same projection the mouse hit-testing consumes; +// visible_lines coordinates are never mixed in. +std::string painted_row_text(TerminalWidget& terminal, size_t row) { + terminal.grab(); + const auto& page = terminal.painted_page(); + if (!page.has_value() || row >= page->lines.size()) return {}; + return termforge::project_line(page->lines[row]).text; +} +// Translate a range endpoint's source identity to its current literal line +// text. This is used to CHECK the actual endpoints against the fixed literal +// expectation, never to build the expectation. +std::string endpoint_line_text(SessionModel& model, const TextRange& range, bool begin) { + const TextDocument document(model.screen()); + const auto index = document.index_of(begin ? range.begin.line_id : range.end.line_id); + if (!index.has_value()) return {}; + return document.line(*index).projection.text; +} +std::string hex_bytes(const std::string& bytes) { + static const char* digits = "0123456789abcdef"; + std::string out; + for (const unsigned char byte : bytes) { out += digits[byte >> 4]; out += digits[byte & 15]; } + return out; +} +bool tail_has_line(SessionModel& model, const std::string& literal) { + const auto& screen = model.screen(); + const size_t total = screen.history_lines().size(); + if (total == 0) return false; + const size_t first = total > 30 ? total - 30 : 0; + for (size_t index = first; index < total; ++index) { + if (termforge::project_line(screen.retained_line(index)).text == literal) return true; + } + return false; +} +} // namespace + +int main(int argc, char** argv) { + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); + if (argc == 2 && std::string_view(argv[1]) == "--history-child") { + try { return history_child(); } catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 3; } + } + // Explicit opt-in: without the exact flag the run refuses to start, so a + // plain invocation or a CTest registration can never spend the budget. + if (argc != 2 || std::string_view(argv[1]) != "--literal-v2") { + std::cerr << "usage: qt_autoscroll_literal_test --literal-v2\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 << "literal autoscroll test requires its owned Job supervisor\n"; return 2; + } + QApplication app(argc, argv); + QApplication::setQuitOnLastWindowClosed(false); + QJsonObject evidence; + evidence["schema"] = "termforge-qt-selection-autoscroll-literal-v2"; + evidence["status"] = "failed"; + try { + QElapsedTimer lifecycle; lifecycle.start(); + QTemporaryDir workspace; + require(workspace.isValid(), "literal autoscroll workspace unavailable"); + wchar_t executable[32768]{}; + const DWORD length = GetModuleFileNameW(nullptr, executable, 32768); + require(length && length < 32768, "literal autoscroll executable unavailable"); + SessionModel model; QWidget window; TerminalWidget terminal(model, &window); + window.resize(1200, 900); window.show(); terminal.setFocus(); + require(model.start(executable, L"--history-child", workspace.path().toStdWString()), + "literal autoscroll child did not start"); + await([&] { return model.screen().history_lines().size() >= 160; }, model, lifecycle, + "literal autoscroll fixture did not reach 160 lines"); + await([&] { return tail_has_line(model, "TF_END"); }, model, lifecycle, + "literal autoscroll fixture missed TF_END"); + // The settled contract waits for the spontaneous 161-180 append + // (TF_END2), then one public 'm' batch (181-220 + TF_END3), then a + // full quiet observation window with no further output. + await([&] { return tail_has_line(model, "TF_END2"); }, model, lifecycle, + "literal autoscroll fixture missed the spontaneous TF_END2 append"); + require(model.send(std::string(1, 'm')), "literal autoscroll 'm' batch rejected"); + await([&] { return tail_has_line(model, "TF_END3"); }, model, lifecycle, + "literal autoscroll fixture missed the TF_END3 batch"); + const size_t quiet_lines = model.screen().history_lines().size(); + QTest::qWait(500); + require(model.error().isEmpty() && model.screen().history_lines().size() == quiet_lines, + "literal autoscroll fixture was not settled after 500ms quiet window"); + const auto settled = terminal.history_state(); + QScrollBar* scrollbar = terminal.history_scrollbar_for_test(); + require(scrollbar != nullptr, "literal autoscroll scrollbar missing"); + QJsonObject settle_json; + settle_json["retained_size"] = QString::number(settled.total); + settle_json["history_lines"] = QString::number(quiet_lines); + settle_json["top"] = QString::number(settled.top); + settle_json["maximum_top"] = QString::number(settled.maximum_top); + settle_json["anchor"] = QString::number(settled.anchor); + settle_json["following"] = settled.following; + settle_json["scrollbar_value"] = scrollbar->value(); + settle_json["scrollbar_maximum"] = scrollbar->maximum(); + settle_json["child_pid"] = QString::number(static_cast(model.resize_state().child_pid)); + evidence["settled"] = settle_json; + + // Public keyboard to the earliest retained row, then public scrollbar + // single steps until the painted page row0 is literally TF_H_100. + QTest::keyClick(&terminal, Qt::Key_Home, Qt::ControlModifier | Qt::ShiftModifier); + QTest::qWait(30); + const int margin = TerminalWidget::content_margin(); + const int cw = terminal.cell_width(), ch = terminal.cell_height(); + evidence["cell_width"] = cw; evidence["cell_height"] = ch; + evidence["screen_height"] = model.screen().height(); + const std::string start_row0 = painted_row_text(terminal, 0); + evidence["home_row0"] = QString::fromStdString(start_row0); + QJsonArray steps; + size_t step_count = 0; + for (; step_count < 400; ++step_count) { + const std::string row0 = painted_row_text(terminal, 0); + if (row0 == "TF_H_100") break; + require(row0.size() == 8 && row0.compare(0, 5, "TF_H_") == 0, + "positioning row0 is not a TF_H_ literal: " + hex_bytes(row0)); + const int number = std::stoi(row0.substr(6, 3)); + require(number < 100, "positioning overshot TF_H_100 at row0=" + row0); + scrollbar->triggerAction(QAbstractSlider::SliderSingleStepAdd); + QJsonObject step; + step["value"] = scrollbar->value(); + step["row0"] = QString::fromStdString(row0); + steps.append(step); + } + require(step_count < 400, "public scrollbar steps could not reach TF_H_100"); + evidence["position_steps"] = steps; + evidence["position_step_count"] = QString::number(step_count); + // Independent cross-check: the document rank of the fixed literal + // TF_H_100 must equal the viewport top and scrollbar value. The rank + // comes from the source document, not from the painted page or the + // selection range. + size_t target_top = 0; + { + const TextDocument document(model.screen()); + bool found = false; + for (size_t index = 0; index < document.size(); ++index) { + if (document.line(index).projection.text == "TF_H_100") { + target_top = index; found = true; break; + } + } + require(found, "document has no TF_H_100 source line"); + } + evidence["target_top"] = QString::number(target_top); + const auto positioned = terminal.history_state(); + require(positioned.top == target_top && scrollbar->value() == static_cast(target_top), + "positioned top/value is not the TF_H_100 document rank"); + require(painted_row_text(terminal, 0) == "TF_H_100" && painted_row_text(terminal, 1) == "TF_H_101", + "positioned painted rows are not TF_H_100/TF_H_101"); + + // Non-scroll sanity: public mouse press at row0/column8 (line end, + // half-open), release at row0/column0; Copy must be the 8 literal + // bytes TF_H_100 with no CRLF. + auto fake = std::make_unique(); + FakeClipboard* captured = fake.get(); + terminal.setClipboardWriterForTest(std::move(fake)); + send_mouse(terminal, QEvent::MouseButtonPress, QPointF(margin + 8 * cw, margin + 1), Qt::LeftButton, Qt::LeftButton); + send_mouse(terminal, QEvent::MouseButtonRelease, QPointF(margin, margin + 1), Qt::LeftButton, Qt::NoButton); + require(terminal.has_selection(), "sanity drag left no selection"); + const auto sanity_range = terminal.selection_range_for_test(); + require(sanity_range.has_value(), "sanity selection range missing"); + require(endpoint_line_text(model, *sanity_range, true) == "TF_H_100" && + endpoint_line_text(model, *sanity_range, false) == "TF_H_100" && + sanity_range->begin.column == 0 && sanity_range->end.column == 8, + "sanity range is not TF_H_100 columns 0..8"); + captured->text.clear(); + QTest::keyClick(&terminal, Qt::Key_C, Qt::ControlModifier | Qt::ShiftModifier); + const std::string sanity_copy = QString::fromUtf16(captured->text.data(), static_cast(captured->text.size())).toStdString(); + evidence["sanity_copy_hex"] = QString::fromStdString(hex_bytes(sanity_copy)); + require(sanity_copy == "TF_H_100", "sanity copy is not the 8 literal bytes TF_H_100"); + + // One bounded up tick at the default 40ms interval. The release is + // sent publicly inside the first valid valueChanged slot, so a second + // tick must never arrive. No internal scroll function is called and + // the timer interval is untouched. + const int v0 = scrollbar->value(); + require(v0 == static_cast(target_top) && painted_row_text(terminal, 0) == "TF_H_100", "pre-drag state drifted"); + QJsonArray values; + QElapsedTimer drag_clock; drag_clock.start(); + qint64 first_value_ms = -1, release_ms = -1; + size_t top_at_release = 0; + bool released = false; + QMetaObject::Connection monitor = QObject::connect(scrollbar, &QScrollBar::valueChanged, terminal.parentWidget(), + [&](int value) { + QJsonObject entry; + entry["value"] = value; + entry["ms"] = static_cast(drag_clock.elapsed()); + values.append(entry); + if (released) return; + released = true; + first_value_ms = drag_clock.elapsed(); + send_mouse(terminal, QEvent::MouseButtonRelease, QPointF(margin, -20), Qt::LeftButton, Qt::NoButton); + release_ms = drag_clock.elapsed(); + top_at_release = terminal.history_state().top; + }); + send_mouse(terminal, QEvent::MouseButtonPress, QPointF(margin + 8 * cw, margin + 1), Qt::LeftButton, Qt::LeftButton); + send_mouse(terminal, QEvent::MouseMove, QPointF(margin, -20), Qt::NoButton, Qt::LeftButton); + const qint64 move_ms = drag_clock.elapsed(); + QElapsedTimer release_wait; release_wait.start(); + while (!released && release_wait.elapsed() < 3000) QTest::qWait(5); + require(released, "no valid valueChanged arrived within 3000ms of the edge drag"); + require(values.size() == 1, "a second valueChanged arrived before the release could land"); + const size_t top_after_tick = terminal.history_state().top; + QTest::qWait(200); + const size_t top_final = terminal.history_state().top; + QObject::disconnect(monitor); + QJsonObject tick_json; + tick_json["v0"] = v0; + tick_json["move_ms"] = static_cast(move_ms); + tick_json["first_value_ms"] = static_cast(first_value_ms); + tick_json["release_ms"] = static_cast(release_ms); + tick_json["ticks"] = terminal.selection_autoscroll_ticks_for_test(); + tick_json["top_at_release"] = QString::number(top_at_release); + tick_json["top_after_200ms"] = QString::number(top_final); + tick_json["post_release_value_changes"] = static_cast(values.size()) - 1; + evidence["tick"] = tick_json; + evidence["value_trace"] = values; + require(values.size() == 1 && values[0].toObject()["value"].toInt() == v0 - 1, + "value trace is not exactly one valid v0 -> v0-1 tick"); + require(top_after_tick == target_top - 1 && top_final == target_top - 1 && top_at_release == target_top - 1, + "top did not move exactly one row to the TF_H_099 rank and stay"); + require(terminal.selection_autoscroll_ticks_for_test() == 1, + "auto-scroll tick count is not exactly one"); + require(!terminal.selection_autoscroll_active_for_test(), "auto-scroll timer still active after release"); + + // Literal painted rows and half-open highlight spans after the tick. + require(painted_row_text(terminal, 0) == "TF_H_099" && painted_row_text(terminal, 1) == "TF_H_100", + "post-tick painted rows are not TF_H_099/TF_H_100"); + const auto range = terminal.selection_range_for_test(); + require(range.has_value(), "post-tick selection range missing"); + require(endpoint_line_text(model, *range, true) == "TF_H_099" && + endpoint_line_text(model, *range, false) == "TF_H_100" && + range->begin.column == 0 && range->end.column == 8, + "actual range is not begin TF_H_099 col0 / end TF_H_100 col8"); + const auto spans = terminal.selection_spans(); + require(spans.size() == 2 && spans[0].row == 0 && spans[0].column_begin == 0 && spans[0].column_end == 8 && + spans[1].row == 1 && spans[1].column_begin == 0 && spans[1].column_end == 8, + "painted highlight is not row0 0..8 and row1 0..8"); + captured->text.clear(); + QTest::keyClick(&terminal, Qt::Key_C, Qt::ControlModifier | Qt::ShiftModifier); + const std::string copied = QString::fromUtf16(captured->text.data(), static_cast(captured->text.size())).toStdString(); + evidence["copy_hex"] = QString::fromStdString(hex_bytes(copied)); + evidence["copy_length"] = static_cast(copied.size()); + // Fixed literal oracle: TF_H_099 + CRLF + TF_H_100, 18 bytes, no + // trailing CRLF. Never derived from the actual range. + require(copied == "TF_H_099\r\nTF_H_100", "copied source text differs from the fixed literal oracle"); + + require(model.send(std::string(1, 'q')), "literal autoscroll child quit rejected"); + await([&] { return model.finished(); }, model, lifecycle, "literal autoscroll child did not finish"); + require(model.exit_code() == 37 && model.cleanup_verified(), "literal autoscroll child cleanup failed"); + evidence["status"] = "passed"; + evidence["bounded_one_tick"] = true; + evidence["copy_source"] = true; + evidence["offscreen_only"] = true; + const QByteArray payload = QJsonDocument(evidence).toJson(QJsonDocument::Compact); + if (const char* path = std::getenv("TERMFORGE_AUTOSCROLL_LITERAL_EVIDENCE")) { + QFile file(QString::fromLocal8Bit(path)); + if (file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + file.write(QJsonDocument(evidence).toJson(QJsonDocument::Indented)); + file.close(); + } + } + std::cout << payload.constData() << '\n'; + return 0; + } catch (const std::exception& error) { + evidence["error"] = QString::fromStdString(error.what()); + if (const char* path = std::getenv("TERMFORGE_AUTOSCROLL_LITERAL_EVIDENCE")) { + QFile file(QString::fromLocal8Bit(path)); + if (file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + file.write(QJsonDocument(evidence).toJson(QJsonDocument::Indented)); + file.close(); + } + } + std::cerr << "Qt selection autoscroll literal v2: " << error.what() << '\n'; + return 1; + } +} diff --git a/src/qt/autoscroll_test.cpp b/src/qt/autoscroll_test.cpp new file mode 100644 index 0000000..7683c1d --- /dev/null +++ b/src/qt/autoscroll_test.cpp @@ -0,0 +1,264 @@ +// Formal Qt bounded selection auto-scroll across retained history. Uses a real +// fixed-ConPTY child, public mouse/focus/keys, default scrollbar, and an injected +// writer for source-text comparison (not real CF). +#include "terminal_widget.hpp" +#include "history_fixture.hpp" +#include "../../experiments/core/text_document.hpp" +#include +#include +#include +#include +#include +#include +#include + +using termforge::qt::SessionModel; +using termforge::qt::TerminalWidget; +using termforge::qt::test::history_child; +using termforge::TextDocument; +using termforge::TextRange; +using termforge::ViewSourceSpan; + +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, {}}; + } +}; +void require(bool condition, const std::string& message) { + if (!condition) throw std::runtime_error(message); +} +void await(const std::function& predicate, SessionModel& model, QElapsedTimer& lifecycle, const char* message) { + QElapsedTimer phase; phase.start(); + while (!predicate() && phase.elapsed() < 8000 && lifecycle.elapsed() < 25000) { + QTest::qWait(10); + if (!model.error().isEmpty()) throw std::runtime_error(model.error().toStdString()); + } + require(predicate(), message); +} +void send_mouse(TerminalWidget& terminal, QEvent::Type type, QPointF pos, Qt::MouseButton button, Qt::MouseButtons buttons) { + QMouseEvent event(type, pos, pos, button, buttons, Qt::NoModifier); + QApplication::sendEvent(&terminal, &event); +} +std::string expected_selection_text(SessionModel& model, const TextRange& range) { + const TextDocument document(model.screen()); + const auto begin = document.index_of(range.begin.line_id); + const auto end = document.index_of(range.end.line_id); + require(begin && end && *begin <= *end, "selection range is not in document order"); + std::string expected; + for (size_t index = *begin; index <= *end; ++index) { + const auto line = document.line(index); + const size_t first = index == *begin ? range.begin.column : 0; + const size_t last = index == *end ? range.end.column : line.projection.text.size(); + require(first <= last && last <= line.projection.text.size(), "selection columns are out of range"); + expected += line.projection.text.substr(first, last - first); + if (index != *end && !line.joins_next) expected += "\r\n"; + } + return expected; +} +std::map expected_widths(SessionModel& model, const TextRange& range) { + const TextDocument document(model.screen()); + const auto begin = document.index_of(range.begin.line_id); + const auto end = document.index_of(range.end.line_id); + require(begin && end && *begin <= *end, "selection range is not in document order"); + std::map widths; + for (size_t index = *begin; index <= *end; ++index) { + const auto line = document.line(index); + const size_t first = index == *begin ? range.begin.column : 0; + const size_t last = index == *end ? range.end.column : line.projection.text.size(); + widths[document.meta(index).id] = last - first; + } + return widths; +} +void start_history(SessionModel& model, TerminalWidget& terminal, QWidget& window, const wchar_t* executable, + const QString& workspace, QElapsedTimer& lifecycle, const char* message) { + window.resize(1200, 900); window.show(); terminal.setFocus(); + require(model.start(executable, L"--history-child", workspace.toStdWString()), message); + await([&] { return model.screen().history_lines().size() >= 100; }, model, lifecycle, message); +} +} // namespace + +int main(int argc, char** argv) { + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); + if (argc == 2 && std::string_view(argv[1]) == "--history-child") { + try { return history_child(); } catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 3; } + } + if (argc != 1) { std::cerr << "usage: qt_autoscroll_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 << "autoscroll 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(), "autoscroll workspace unavailable"); + wchar_t executable[32768]{}; + const DWORD length = GetModuleFileNameW(nullptr, executable, 32768); + require(length && length < 32768, "autoscroll executable unavailable"); + SessionModel first; QWidget first_window; TerminalWidget terminal(first, &first_window); + start_history(first, terminal, first_window, executable, workspace.path(), lifecycle, + "first autoscroll child did not start"); + SessionModel second; QWidget second_window; TerminalWidget second_terminal(second, &second_window); + start_history(second, second_terminal, second_window, executable, workspace.path(), lifecycle, + "second autoscroll child did not start"); + first_window.activateWindow(); terminal.setFocus(); QTest::qWait(30); + const int cw = terminal.cell_width(), ch = terminal.cell_height(); + const auto before = terminal.history_state(); + require(before.maximum_top > 10, "autoscroll fixture has insufficient history"); + require(terminal.history_following(), "autoscroll fixture did not start at the bottom"); + + // Press inside the visible grid and drag above the widget: one bounded + // history row per timer tick, expanding selection toward older source. + send_mouse(terminal, QEvent::MouseButtonPress, QPointF(8 + 3 * cw, 8 + 4 * ch), Qt::LeftButton, Qt::LeftButton); + send_mouse(terminal, QEvent::MouseMove, QPointF(8 + 3 * cw, -20), Qt::NoButton, Qt::LeftButton); + await([&] { return terminal.selection_autoscroll_active_for_test(); }, first, lifecycle, + "drag above the grid did not start auto-scroll"); + QTest::qWait(180); + require(terminal.selection_autoscroll_active_for_test(), "up auto-scroll stopped while still held"); + const auto scrolled = terminal.history_state(); + const int ticks = terminal.selection_autoscroll_ticks_for_test(); + require(ticks > 0 && before.maximum_top - scrolled.top == static_cast(ticks), + "up auto-scroll was not bounded to one row per timer tick"); + require(terminal.has_selection(), "auto-scroll did not extend a selection"); + const auto up_state = terminal.history_state(); + const int up_ticks = terminal.selection_autoscroll_ticks_for_test(); + send_mouse(terminal, QEvent::MouseMove, + QPointF(8 + 3 * cw, 8 + static_cast(first.screen().height()) * ch + 20), + Qt::NoButton, Qt::LeftButton); + QTest::qWait(120); + const auto down_state = terminal.history_state(); + const int down_ticks = terminal.selection_autoscroll_ticks_for_test(); + require(down_state.top > up_state.top && + down_state.top - up_state.top == static_cast(down_ticks - up_ticks), + "down auto-scroll was not bounded to one row per timer tick"); + auto fake = std::make_unique(); + FakeClipboard* captured = fake.get(); + terminal.setClipboardWriterForTest(std::move(fake)); + auto check_exact = [&](const char* label) { + const auto range = terminal.selection_range_for_test(); + require(range.has_value(), std::string(label) + ": selection range missing"); + const std::string expected = expected_selection_text(first, *range); + captured->text.clear(); + QTest::keyClick(&terminal, Qt::Key_C, Qt::ControlModifier | Qt::ShiftModifier); + const QString copied = QString::fromUtf16(captured->text.data(), static_cast(captured->text.size())); + require(copied.toStdString() == expected, std::string(label) + ": copied source text differs from literal oracle"); + std::map actual; + const auto spans = terminal.selection_spans(); + const auto page = terminal.painted_page(); + require(!spans.empty(), std::string(label) + ": no painted highlight spans"); + require(page.has_value(), std::string(label) + ": no painted page"); + for (const auto& span : spans) { + require(span.column_begin < span.column_end, std::string(label) + ": empty highlight span"); + const ViewSourceSpan* source = nullptr; + for (const auto& candidate : page->mapping.sources()) { + if (candidate.row != span.row) continue; + if (span.column_begin >= candidate.column_end || span.column_end <= candidate.column_begin) continue; + source = &candidate; + break; + } + require(source != nullptr, std::string(label) + ": highlight row has no source span"); + require(source->source.begin.line_id == source->source.end.line_id, + std::string(label) + ": highlight crosses a source line unexpectedly"); + actual[source->source.begin.line_id] += span.column_end - span.column_begin; + } + const auto expected_widths_by_line = expected_widths(first, *range); + for (const auto& item : actual) { + const auto expected_width = expected_widths_by_line.find(item.first); + require(expected_width != expected_widths_by_line.end() && + expected_width->second == item.second, + std::string(label) + ": highlight width differs from selected source columns"); + } + }; + check_exact("up-reverse"); + + // Moving back into the grid stops; release/focus stop with no further scroll. + send_mouse(terminal, QEvent::MouseMove, QPointF(8 + 3 * cw, 8 + 4 * ch), Qt::NoButton, Qt::LeftButton); + require(!terminal.selection_autoscroll_active_for_test(), "dragging back inside did not stop auto-scroll"); + const size_t stopped_top = terminal.history_state().top; + QTest::qWait(120); + require(terminal.history_state().top == stopped_top, "auto-scroll continued after leaving the edge"); + send_mouse(terminal, QEvent::MouseButtonRelease, QPointF(8 + 3 * cw, 8 + 4 * ch), Qt::LeftButton, Qt::NoButton); + require(!terminal.selection_autoscroll_active_for_test(), "release did not stop auto-scroll"); + + // Forward drag: press at a visible upper row then hold below the edge; + // the exact source range/highlight/copy must match the literal oracle. + send_mouse(terminal, QEvent::MouseButtonPress, QPointF(8 + 3 * cw, 8 + 2 * ch), Qt::LeftButton, Qt::LeftButton); + send_mouse(terminal, QEvent::MouseMove, + QPointF(8 + 3 * cw, 8 + static_cast(first.screen().height()) * ch + 20), + Qt::NoButton, Qt::LeftButton); + await([&] { return terminal.selection_autoscroll_active_for_test(); }, first, lifecycle, + "forward drag did not start auto-scroll"); + QTest::qWait(140); + send_mouse(terminal, QEvent::MouseButtonRelease, + QPointF(8 + 3 * cw, 8 + static_cast(first.screen().height()) * ch + 20), + Qt::LeftButton, Qt::NoButton); + require(!terminal.selection_autoscroll_active_for_test(), "forward release did not stop auto-scroll"); + check_exact("forward"); + + // Active-edge release: release directly while scrolling, with no drag + // back inside first; the timer and top/selection must freeze. + send_mouse(terminal, QEvent::MouseButtonPress, QPointF(8 + 3 * cw, 8 + 4 * ch), Qt::LeftButton, Qt::LeftButton); + send_mouse(terminal, QEvent::MouseMove, QPointF(8 + 3 * cw, -20), Qt::NoButton, Qt::LeftButton); + await([&] { return terminal.selection_autoscroll_active_for_test(); }, first, lifecycle, + "active-release drag did not start auto-scroll"); + QTest::qWait(100); + const size_t release_top = terminal.history_state().top; + send_mouse(terminal, QEvent::MouseButtonRelease, QPointF(8 + 3 * cw, -20), Qt::LeftButton, Qt::NoButton); + require(!terminal.selection_autoscroll_active_for_test(), "active-edge release did not stop auto-scroll"); + QTest::qWait(120); + require(terminal.history_state().top == release_top, + "background timer changed top after active-edge release"); + + // A second drag loses focus while held; scrolling stops and the second + // session remains independent, followed by a clean quit. + send_mouse(terminal, QEvent::MouseButtonPress, QPointF(8 + 3 * cw, 8 + 4 * ch), Qt::LeftButton, Qt::LeftButton); + send_mouse(terminal, QEvent::MouseMove, QPointF(8 + 3 * cw, -20), Qt::NoButton, Qt::LeftButton); + QTest::qWait(80); + require(terminal.selection_autoscroll_active_for_test(), "second drag did not start auto-scroll"); + second_window.activateWindow(); + second_terminal.setFocus(); + QTest::qWait(30); + require(!terminal.selection_autoscroll_active_for_test(), "focus loss did not stop auto-scroll"); + send_mouse(terminal, QEvent::MouseButtonRelease, QPointF(0, 0), Qt::LeftButton, Qt::NoButton); + const size_t focus_top = terminal.history_state().top; + QTest::qWait(120); + require(terminal.history_state().top == focus_top, "auto-scroll continued after focus loss"); + require(second_terminal.history_following() && !second_terminal.has_selection(), + "second session was affected by first auto-scroll/focus"); + + // Closing the second Session while its edge auto-scroll is active stops + // the timer and leaves no background selection mutation. + second_window.activateWindow(); second_terminal.setFocus(); QTest::qWait(20); + send_mouse(second_terminal, QEvent::MouseButtonPress, QPointF(8 + 3 * cw, 8 + 4 * ch), + Qt::LeftButton, Qt::LeftButton); + send_mouse(second_terminal, QEvent::MouseMove, QPointF(8 + 3 * cw, -20), + Qt::NoButton, Qt::LeftButton); + await([&] { return second_terminal.selection_autoscroll_active_for_test(); }, second, lifecycle, + "second-session close drag did not start auto-scroll"); + QTest::qWait(80); + second.close(); + await([&] { return second.finished(); }, second, lifecycle, "closing autoscroll target did not finish"); + require(!second_terminal.selection_autoscroll_active_for_test(), + "closing the target did not stop auto-scroll"); + QTest::qWait(120); + + require(first.send(std::string(1, 'q')), "first autoscroll child quit rejected"); + await([&] { return first.finished(); }, first, lifecycle, "autoscroll child did not finish"); + require(first.exit_code() == 37 && first.cleanup_verified(), "autoscroll child cleanup failed"); + std::cout << "{\"schema\":\"termforge-qt-selection-autoscroll-v1\",\"status\":\"passed\"," + "\"bounded_up\":true,\"bounded_down\":true,\"copy_source\":true," + "\"drag_back_stop\":true,\"release_stop\":true,\"focus_stop\":true," + "\"sessions\":2,\"offscreen_only\":true,\"presented_done\":null}\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "Qt selection autoscroll: " << error.what() << '\n'; + return 1; + } +} diff --git a/src/qt/terminal_widget.hpp b/src/qt/terminal_widget.hpp index 60d9fef..6a77895 100644 --- a/src/qt/terminal_widget.hpp +++ b/src/qt/terminal_widget.hpp @@ -92,6 +92,11 @@ class TerminalWidget final : public QWidget { uint64_t find_generation_ = 0; int find_interval_ms_ = 0; QTimer find_timer_; + QTimer selection_autoscroll_timer_; + bool selecting_ = false; + QPointF selection_pointer_; + int selection_autoscroll_direction_ = 0; + int selection_autoscroll_ticks_ = 0; std::unique_ptr clipboard_; std::unique_ptr clipboard_reader_; ClipboardWrite last_copy_; @@ -122,6 +127,8 @@ class TerminalWidget final : public QWidget { cell_height_ = (std::max)(1, qCeil(metrics.height()) + 2); } void synchronize_grid() { + stop_selection_autoscroll(); + selecting_ = false; painted_page_.reset(); selection_anchor_.reset(); const int available = width() - margin * 2 - (scrollbar_reserved_ ? scrollbar_extent_ : 0); @@ -181,13 +188,19 @@ class TerminalWidget final : public QWidget { } void closeEvent(QCloseEvent* event) override { keyboard_.reset(); + stop_selection_autoscroll(); + selecting_ = false; selection_anchor_.reset(); model_.close(); QWidget::closeEvent(event); } bool event(QEvent* event) override { if (event->type() == QEvent::FocusOut || event->type() == QEvent::Hide || - event->type() == QEvent::UngrabMouse) selection_anchor_.reset(); + event->type() == QEvent::UngrabMouse) { + stop_selection_autoscroll(); + selecting_ = false; + selection_anchor_.reset(); + } if (event->type() == QEvent::FocusOut || event->type() == QEvent::Hide) { keyboard_.reset(); // Losing focus ends any composition: the input context drops it, and @@ -422,13 +435,48 @@ class TerminalWidget final : public QWidget { else if (chosen == paste) initiate_paste(); event->accept(); } + void stop_selection_autoscroll() { + selection_autoscroll_timer_.stop(); + selection_autoscroll_direction_ = 0; + } + void update_selection_autoscroll(QPointF point) { + selection_pointer_ = point; + const int top = margin; + const int bottom = margin + model_.screen().height() * cell_height_; + const int direction = point.y() < top ? -1 : point.y() >= bottom ? 1 : 0; + if (!direction) { stop_selection_autoscroll(); return; } + selection_autoscroll_direction_ = direction; + if (!selection_autoscroll_timer_.isActive()) { + selection_autoscroll_ticks_ = 0; + selection_autoscroll_timer_.start(40); + } + } + void selection_autoscroll_tick() { + if (!selecting_ || !selection_anchor_ || !selection_autoscroll_direction_) { stop_selection_autoscroll(); return; } + const auto& screen = model_.screen(); + const auto before = viewport_.reconcile(screen).top; + viewport_.scroll_by(screen, selection_autoscroll_direction_); + const auto after = viewport_.reconcile(screen).top; + if (after == before) { stop_selection_autoscroll(); return; } + ++selection_autoscroll_ticks_; + refresh_painted_page(screen); + update_scrollbar(); + const int visible_bottom = margin + static_cast(screen.height()) * cell_height_; + const QPointF edge(selection_pointer_.x(), + selection_autoscroll_direction_ < 0 ? margin : visible_bottom - 1); + extend_selection(edge); + update(); + } void mousePressEvent(QMouseEvent* event) override { if (event->button() != Qt::LeftButton || event->modifiers() != Qt::NoModifier || !preedit_.isEmpty()) { QWidget::mousePressEvent(event); return; } setFocus(Qt::MouseFocusReason); + stop_selection_autoscroll(); + selecting_ = false; selection_.clear(); selection_anchor_ = selection_position(event->position(), false); + selecting_ = selection_anchor_.has_value(); update(); event->setAccepted(selection_anchor_.has_value()); } @@ -437,6 +485,7 @@ class TerminalWidget final : public QWidget { QWidget::mouseMoveEvent(event); return; } extend_selection(event->position()); + update_selection_autoscroll(event->position()); event->accept(); } void mouseReleaseEvent(QMouseEvent* event) override { @@ -444,6 +493,8 @@ class TerminalWidget final : public QWidget { QWidget::mouseReleaseEvent(event); return; } extend_selection(event->position()); + stop_selection_autoscroll(); + selecting_ = false; selection_anchor_.reset(); event->accept(); } @@ -1016,6 +1067,7 @@ class TerminalWidget final : public QWidget { else viewport_.scroll_to(screen, static_cast((std::max)(0, value))); update(); }); + connect(&selection_autoscroll_timer_, &QTimer::timeout, this, [this] { selection_autoscroll_tick(); }); build_find_panel(); model_.on_changed = [this] { refresh_session(); }; } @@ -1024,7 +1076,12 @@ class TerminalWidget final : public QWidget { if (paste_read_thread_.joinable()) paste_read_thread_.join(); } void refresh_session() { - if (!model_.running()) keyboard_.reset(); + if (!model_.running()) { + keyboard_.reset(); + stop_selection_autoscroll(); + selecting_ = false; + selection_anchor_.reset(); + } const TextDocument document(model_.screen()); selection_.reconcile(document); if (selection_anchor_ && !document.valid(*selection_anchor_)) selection_anchor_.reset(); @@ -1096,6 +1153,13 @@ class TerminalWidget final : public QWidget { ClipboardWrite last_copy() const { return last_copy_; } QString copy_notice() const { return copy_notice_; } bool has_selection() const { return selection_.range().has_value(); } + std::optional selection_range_for_test() const { return selection_.range(); } + bool selection_autoscroll_active_for_test() const { return selection_autoscroll_timer_.isActive(); } + int selection_autoscroll_direction_for_test() const { return selection_autoscroll_direction_; } + int selection_autoscroll_ticks_for_test() const { return selection_autoscroll_ticks_; } + void set_selection_autoscroll_interval_for_test(int milliseconds) { + selection_autoscroll_timer_.setInterval((std::max)(1, milliseconds)); + } // Read-only projection of the current selection onto the last painted page; // the test checks the highlighted source columns without copying cells. std::vector selection_spans() {