diff --git a/.github/workflows/qt-cpp.yml b/.github/workflows/qt-cpp.yml index 86dbec2..127c939 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 --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_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 ` @@ -104,6 +104,7 @@ jobs: 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 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-paste-bin/Release --executable qt_paste_test.exe - name: Preserve runtime file identities without binaries if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -124,14 +125,19 @@ jobs: build/qt-cpp/qt-selection-bin/Release/runtime-manifest.json build/qt-cpp/qt-clipboard-bin/Release/runtime-manifest.json 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)$' --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_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 env: TERMFORGE_QT_QPA_PLATFORM: windows run: python src/qt/run_smoke.py build/qt-cpp/qt-clipboard-bin/Release/qt_clipboard_system_test.exe --hosted-windows-copy + - name: Run hosted Windows QPA product-reader paste + env: + TERMFORGE_QT_QPA_PLATFORM: windows + run: python src/qt/run_smoke.py build/qt-cpp/qt-clipboard-bin/Release/qt_clipboard_system_test.exe --hosted-windows-paste # Keep this configuration independent from the default/system-backend and # Alacritty reference jobs. SDKs, runtime folders and raw logs are not # publication artifacts; this job does not produce a release package. diff --git a/.github/workflows/qt-hosted-paste-multiline.yml b/.github/workflows/qt-hosted-paste-multiline.yml new file mode 100644 index 0000000..012880e --- /dev/null +++ b/.github/workflows/qt-hosted-paste-multiline.yml @@ -0,0 +1,46 @@ +# Opt-in single hosted Windows QPA run for the #324 multiline paste recovery. +# The default qt-cpp.yml lane keeps the historical single-line hosted scope; the +# multiline real-CF dialog branch only executes with TERMFORGE_QT_HOSTED_MULTILINE=1. +# Attempt 3 consumed the recovery quota on 2026-11-12 (run 43101420107 / job +# 130793595825, passed); the trigger is dispatch-only so later pushes of result +# docs or trace cleanups cannot re-execute the real hosted path. +name: Qt hosted Windows multiline paste recovery + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + hosted-multiline-paste: + runs-on: windows-2025 + timeout-minutes: 20 + env: + PYTHONUTF8: '1' + TERMFORGE_QT_HOSTED_MULTILINE: '1' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.12' + - name: Verify preparation failure contracts + run: python -m unittest tools.test_prepare_qt_build tools.test_prepare_msvc_runtime tools.test_msvc_runtime -v + - name: Prepare manifest-pinned Qt qtbase, Microsoft ConPTY and CRT + run: python -m tools.prepare_qt_build + - name: Configure explicit Qt product and test entries + run: | + $dependencyRoot = Join-Path $env:GITHUB_WORKSPACE 'local/qt-build-deps' + cmake -S . -B build/qt-cpp -G 'Visual Studio 18 2026' -A x64 ` + -DTERMFORGE_BUILD_QT_SHELL=ON -DBUILD_TESTING=ON ` + -DTERMFORGE_BUILD_CANDIDATE_ADAPTER=OFF ` + "-DPython3_EXECUTABLE=$((Get-Command python).Source)" ` + "-DQt6_DIR=$dependencyRoot/qt/lib/cmake/Qt6" ` + "-DTERMFORGE_CONPTY_BACKEND_DIR=$dependencyRoot/conpty" ` + "-DTERMFORGE_MSVC_RUNTIME_DIR=$dependencyRoot/crt" + - name: Build Qt clipboard system test + run: cmake --build build/qt-cpp --config Release --target qt_clipboard_system_test --parallel 2 + - name: Run hosted Windows QPA multiline paste recovery + env: + TERMFORGE_QT_QPA_PLATFORM: windows + run: python src/qt/run_smoke.py build/qt-cpp/qt-clipboard-bin/Release/qt_clipboard_system_test.exe --hosted-windows-paste diff --git a/CMakeLists.txt b/CMakeLists.txt index 3041a40..1081060 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -845,6 +845,20 @@ if(TERMFORGE_BUILD_QT_SHELL) "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$") set_tests_properties(qt_selection_migration PROPERTIES TIMEOUT 40 ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + add_executable(qt_paste_test src/qt/paste_test.cpp) + set_target_properties(qt_paste_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qt-paste-bin/$") + termforge_qt_entry(qt_paste_test) + target_link_libraries(qt_paste_test PRIVATE Qt6::Test) + add_custom_command(TARGET qt_paste_test POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "$" + "$/platforms/" + VERBATIM) + add_test(NAME qt_paste COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$") + set_tests_properties(qt_paste PROPERTIES TIMEOUT 45 + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") add_executable(qt_find_test src/qt/find_test.cpp) set_target_properties(qt_find_test PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qt-find-bin/$") diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index 9124d1e..46d8ae2 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -25,6 +25,7 @@ | 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 有界系统粘贴入口(#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 完成 | | 正式 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 验收 | | 正式 Qt / 固定 ConPTY 八会话文本输入目标隔离与生命周期(#30 切片) | [入口、矩阵、时序与实测](../experiments/shell/results/2026-11-08-qt-eight-session-isolation/README.md):新增 `qt_eight_session`,通过公开 `+`/标签切换/关闭入口创建 8 个真实固定 ConPTY child(+1 替代页),每会话独立 PID/nonce/接收文件;硬编码字面预期核对 ASCII/CJK/补充平面/结合标与无修饰 Home 只进入活动目标,非创建顺序 `[4,0,7,2,5,1,6,3]` 切换,8×120 行受控后台输出全部排空(queued 0),关闭非活动/活动标签后存活目标继续精确接收,替代页首输入无待发单元泄漏,父窗关闭 6 会话 `cleanup_verified/active_processes_after=0`、status 0;Qt 正式路径 CTest 12/12,核心 42/42 | 仅 offscreen 合成 Qt 事件、固定 8 会话/80×24/1 替代页,`presented_done=null`;`SessionTabs` 当前无会话上限,未定义/未测试第 9 个会话行为;不覆盖真实键盘、原生 IME、系统剪贴板、真实 DPI、完整 Copy→Paste→IME、八会话以上或性能;#30/#155/#7 不关闭 | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index da80a49..ec165a5 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);不含选词/选行/物理呈现/性能阈值);历史滚动条已接真实 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)),翻页键历史与粘贴回底未迁移;八会话目标隔离与生命周期已用公开 `+`/标签切换/关闭入口和每会话独立 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-06-qt-system-paste/README.md b/experiments/shell/results/2026-11-06-qt-system-paste/README.md new file mode 100644 index 0000000..fcb980c --- /dev/null +++ b/experiments/shell/results/2026-11-06-qt-system-paste/README.md @@ -0,0 +1,31 @@ +# 正式 Qt:有界系统粘贴入口 + +本切片把已冻结的 R1 粘贴合同接入正式 Qt 正常主屏:`Ctrl+Shift+V`、`Shift+Insert` 与实际终端 Paste 上下文菜单;默认 Windows 读取器读取 `CF_UNICODETEXT`,通过现有有界 ConPTY 输入队列发送给发起会话。复用现有 UTF-16/UTF-8 容量、CR 归一化、多行确认、目标绑定和错误反馈策略;不新增 bracketed paste、备用屏/TUI 粘贴、自动逐行、Find 或字素规则。 + +## 实现 + +- `ClipboardReader`/`SystemClipboardReader`:Windows `OpenClipboard` 短重试、1 Mi UTF-16 上限、缺失终止符/空/失败分开;读取在线程外,结果回投 Qt。 +- `TerminalWidget`:三个公开入口;异步读取→`prepare_paste`(既有 CR 归一化/容量/多行确认)→分块 1024 B 发送;输入队列拒绝时保留同一 chunk 重试,不确认、不重复;绑定发起会话 child PID,目标关闭/变化即失败;第二入口在读取/传输中报 busy;空/失败/取消/超限/无有效目标有不同 notice。 +- 多行使用真实 `QMessageBox` Paste/Cancel;确认后恢复窗口/终端焦点。`Ctrl+C`、普通 `Insert`、复制与 IME 行为不变;既有 `qt_unicode_input` 的“未实现粘贴快捷键”断言更新为“已实现入口且不落成 Insert 字节”,并禁用 reader 避免本机 CF。 + +## 入口与结果矩阵 + +| 层 | 入口 | 结果 | +|---|---|---| +| 本地 fake reader + 真实 child 输入记录 | Ctrl+Shift+V 单行、Shift+Insert 多行取消、真实对话框确认(role 选择点击 Paste,无 auto-confirm 捷径)、实际 Paste 菜单中文/NFD、busy、目标关闭、双会话切换、读取中关闭、新会话隔离 | `qt_paste` 1/1;Qt 正式路径 14/14;fake reader,未触个人 CF | +| hosted Windows QPA 默认 reader + 真实控件 HWND | Ctrl+Shift+V、实际 Paste 菜单、Shift+Insert;真实 CF;自有 holder 占用失败/释放后第二次动作 | run `42183487961` / job `127778260596`:`keyboard_cf/menu_cf/shift_insert_cf/occupied_failure/release_success=true`,child 字节核对;`qpa=windows`、默认 owner | +| 同上 | 多行取消/确认 | run `43101420107` / job `130793595825`:**通过**(恢复尝试 3,额度 1)——真实 Ctrl+Shift+V 取消零字节、`PASTE_CONFIRM accepted=0`;真实 Paste 菜单确认 `accepted=1` 后 `PASTE_TRANSFER total=5`(`L1\rL2` CRLF→CR 恰好一次),`multiline_scope=hosted-real-cf`;占用失败/释放后第二次动作保留。详见[尝试账本](attempt-ledger.md) | + +本地 `qt_paste` 的 child 把收到的每个字节写入文件,不执行粘贴文本;每步以动作前文件内容为基线,按精确字节增量核对单行一次、多行取消零增量、确认一次。hosted 场景的独立 child 同样只记录固定合成输入,不执行粘贴内容。 + +## RED/GREEN 与限制 + +- 开发中先发现既有 `qt_unicode_input` 仍断言粘贴快捷键“未实现”,更新该测试并禁用其 reader,使其不会触碰个人 CF;这是测试合同随入口实现更新,不是产品回归。 +- 多行 hosted 失败的根因已离线证明并修复:产品把 `QMessageBox::exec()`(返回被点击标准按钮值,实测 accept=2/escape=3)错当 `QDialog::DialogCode` 比较,确认恒被判为拒绝;修复按 `clickedButton()` 的 role 判定。本地 `qt_paste` 以真实对话框(role 选择点击、无 auto-confirm)验证取消零字节/确认 CR 归一化一次;hosted 真实 CF 组合由唯一一次授权 run `43101420107`/job `130793595825` 闭合(恢复尝试 3,额度 1)。 +- 本地 Qt 正式路径 14/14;hosted 真实 CF 三入口、多行取消/确认与占用/释放全部通过(run `42183487961` 与 `43101420107` 分列)。第二会话切换/关闭复用只在本地 fake reader 场景覆盖关闭目标,未在 hosted 组合验证。 +- 旧三次 hosted 多行失败保留为历史,不删除、不称 flaky;恢复尝试 3 成功后故障闭合,过程与证据见[尝试账本](attempt-ledger.md)。 +- 不覆盖 bracketed paste、备用屏/TUI 粘贴、自动逐行、Find、完整文字/选择、物理呈现/DPI/性能。系统 CF 只在该一次性 hosted Windows QPA 运行内验证;本地/offscreen 缓存不是 CF 证据。 + +## 身份 + +源码/构建/二进制/运行身份见 [validation.json](validation.json);hosted run/job 为 `42183487961`/`127778260596`(单行+占用/释放)与 `43101420107`/`130793595825`(恢复尝试 3 多行,opt-in 单次)。旧 B/CD、192.168.3.210 校准/v2、A/B/C、旧六阶段失败、#27/#29/#30/#35/#7 状态不变。 diff --git a/experiments/shell/results/2026-11-06-qt-system-paste/attempt-ledger.md b/experiments/shell/results/2026-11-06-qt-system-paste/attempt-ledger.md new file mode 100644 index 0000000..31b843a --- /dev/null +++ b/experiments/shell/results/2026-11-06-qt-system-paste/attempt-ledger.md @@ -0,0 +1,84 @@ +# 多行 hosted 粘贴失败账本(未闭合) + +## 首个失败 + +- head `eecf21f`,run `42181178347` / job `127770923069`(Windows QPA、默认 reader、真实 CF)。 +- 动作:先做三次单行公开入口,再写 CF `L1\r\nL2`,经 Ctrl+Shift+V 进入真实多行 `QMessageBox`,测试端点击 Cancel 后重新发起并点击 Paste。 +- 实得:child 文件仍只有前三次单行内容: + `5053542de4b8ade696872065cc815053542d4d454e555053542d494e53455254` + (`PST-中文 éPST-MENUPST-INSERT`);`notice=Paste cancelled`;随后 `paste child bytes differ`,exit 1、`timed_out=false`、Job `active=0`。预期 `baseline + L1\rL2` 未出现。 + +## 已执行尝试(同故障恢复次数) + +| 尝试 | head | run / job | 结果 | +|---|---|---|---| +| 首个失败 | `eecf21f` | `42181178347` / `127770923069` | 失败:无 L1/L2,notice cancelled | +| 修复后重试 1 | `bc6a5af` | `42181721249` / `127772638312` | 仍失败;该修复为窗口 activate/raise/focus + 测试确认改用产品 trigger | +| 诊断重试(同故障,不新增额度) | `5ea8e44` | `42182236823` / `127774271059` | 仍失败;实际仍为三条单行,notice cancelled;调试显示多行 read 均 finish,但无传输/child 字节 | +| 删除多行 hosted 分支后的单行回归 | `10df02e` | `42182751375` / `127775920833` | 通过,但 `multiline_confirm=false`,不能算多行成功 | +| 交付前巡检 | `9b48627` | `42184032136` / `127780005871` | 通过;sample 明示 `multiline_cancel=false`/`multiline_confirm=false` | +| 恢复尝试 3(授权额度 1) | `b2536fe` | `43101420107` / `130793595825` | **通过**:`multiline_cancel=true`、`multiline_confirm=true`、`multiline_scope=hosted-real-cf`,sample 全项真;日志见下 | + +结论:该多行故障已经历一次修复后重试仍失败,既有恢复额度已使用;本轮不再启动相同 hosted 多行重试,也没有把单行/本地 fake 成功或删除失败分支当作修复。恢复尝试 3 按根因修复(exec 返回值语义)在唯一一次授权 hosted run 中通过,故障闭合。 + +## 离线诊断(事实与假设分开) + +事实: + +- 5ea8e44 的 hosted 日志中,多行两次动作均出现 `PASTE_INIT reading=0` 与 `PASTE_FINISH status=0`(读取成功),但没有 child 的 `L1`/`L2` 字节,最终 notice 仍为 cancelled。 +- 当时测试 helper `click_paste_dialog` 在找不到文字按钮时直接 `box->reject()`,且没有记录点击结果;因此无法证明对话框被接受,也无法证明产品 transfer 一定启动。 +- 本地 fake reader 的多行取消/确认通过,但 product reader 是真实 CF、目标是真实 child,不能据此消除 hosted 失败。 + +假设(未证明,不得当结论): + +1. 测试 helper 未命中 `Paste` 按钮而走 reject,导致“确认”实际是取消;这是夹具候选原因。 +2. 对话框接受后,焦点/状态使 `finish_paste_read` 未启动 transfer;这是产品候选原因。 +3. 真实 CF 内容/生命周期与 fake reader 不同导致读取后未进入确认;需要插桩定位。 + +## 根因定位(尝试 3 恢复轮内离线完成,已证明) + +事实(本地离屏复现,未触碰个人 CF/桌面): + +- 独立探针([dialog_probe.cpp](dialog_probe.cpp),offscreen,无剪贴板)构造与产品完全一致的 `QMessageBox`:点击 `Paste`(AcceptRole)后 `box.exec()` 返回 `2`,Escape/Cancel 返回 `3`;`QDialog::Accepted=1`、`Rejected=0`。即 `QMessageBox::exec()` 返回被点击按钮的标准按钮值,**不是** `QDialog::DialogCode`。 +- 产品 `finish_paste_read` 原为 `confirmed = box.exec() == QDialog::Accepted`,对多行确认恒为假 → 对话框被真实接受也仍走 “Paste cancelled”、不启动 transfer。键鼠四种交互(含 `QPushButton::click()` 直连)全部复现,排除夹具点击/焦点/坐标因素。 +- 修复:按 `box.clickedButton()` 的 `buttonRole() == AcceptRole` 判定;探针与本地 `qt_paste`(真实对话框、role 选择、无 auto-confirm)验证通过,取消 `accepted=0` 零字节、确认 `accepted=1` + transfer(`total=3`,即 `A\rB`)。 +- 因此前两次 hosted 失败的产品侧解释确定:读取成功 → 对话框真实出现 → 用户确认被代码误判为拒绝。旧假设 1(夹具未命中)不是根因;旧假设 2/3 由该 bug 统一解释。 + +结论:根因是产品对 `QMessageBox::exec()` 返回值的语义误判;hosted 真实 CF 与 windows QPA 组合仍待唯一一次授权 run 实测闭合(见下)。 + + +## 最小修复方案(待授权的一次 hosted 重试) + +- 恢复 hosted 多行分支,但先给测试 helper 记录 `clicked button text/role/exec result`,并在产品 `finish_paste_read`/transfer 启动处记录状态与 target PID;不删除断言、不改为直接业务调用。 +- 若记录显示按钮未命中,修 helper 按 AcceptRole/RejectRole 选择按钮并重跑;若产品未启动 transfer,按目标绑定合同修最小路径。此修复只允许一次 hosted 重试,并记录唯一 run/job 与结果。 +- 在获得该一次恢复额度或明确环境阻塞决定前,不声明多行 hosted 通过;本 PR 仅保留单行三入口 + 占用/释放通过,多行保持历史失败/未闭合。 + +## 恢复尝试 3(预写计划,运行前授权额度 = 1,运行后回填) + +授权与边界: + +- 旧表内 `bc6a5af`/`5ea8e44` 两次修复/诊断重试保留计数(retryCount=2 不重置);本次为第 3 次 hosted 多行执行,也是本轮唯一且最后一次授权额度。 +- 唯一执行路径:opt-in workflow `qt-hosted-paste-multiline.yml`(push 限定本 PR 分支 + 路径过滤);`qt-cpp.yml` 的默认 hosted paste 步骤通过测试内 `TERMFORGE_QT_HOSTED_MULTILINE` 门控保持历史单行范围,不执行多行。预期恰好 1 个 run / 1 个 job。 +- 停止条件(有界,预写):上述唯一 run 无论结论如何,首失败即停止;不重跑、不自动派生诊断 run、不以另一 CI 副本重复同一 hosted 失败路径。若基础设施在 paste 步骤之前失败,同样记为未通过并给出解除条件,不补跑。 +- 入口合同:取消动作走真实公开入口 `Ctrl+Shift+V`;确认动作走真实公开入口 Paste 上下文菜单;默认产品 CF reader、真实 `QMessageBox`、真实独立 child 字节记录全部保留;不调用 `setPasteDialogAutoConfirmForTest`、不对产品做业务方法级伪造接受。 +- 断言不弱化:取消后 child 字节 == 动作前基线(零增量);确认后 child 字节 == 基线 + `L1\rL2`(CRLF 归一化为 CR,恰好一次);占用失败/释放后第二次动作的最终字节增量不变;三入口中文/NFD、忙/取消/关闭通知分列不变。 + +插桩与判据(本次 run 的唯一证据来源): + +- helper 侧(测试夹具):`dialog_helper: box= buttons=N`;每个按钮 `button='' role=`;`click '' via role|text`;`click sent`;未找到/未命中分别记录并 reject(保留原合同,但留下记录)。 +- 产品侧(临时痕迹,本轮后移除):`PASTE_FINISH status=%d`(与 5ea8e44 同名以便对照)、`PASTE_PREP confirmation_required=%d newlines=%zu target=%llu current=%llu`、`PASTE_CONFIRM accepted=%d`、`PASTE_TRANSFER state=%d total=%zu target=%llu`。 +- 判别矩阵: + - `dialog_helper: click ... via role` + `click sent` + `PASTE_CONFIRM accepted=1` + `PASTE_TRANSFER state=...` + child 字节正确 ⇒ 修复成功,hosted 多行通过。 + - `click via role` + `click sent` + `PASTE_CONFIRM accepted=0` ⇒ 点击到达但对话框仍拒绝(产品/平台侧候选),需新的离线证据,不重跑。 + - 无 `click sent` 且出现 `no QMessageBox after 250 polls` ⇒ 对话框未成为 activeModal(平台侧候选),需新的离线证据,不重跑。 + - `PASTE_CONFIRM accepted=1` + `PASTE_TRANSFER state=1`(sending)但 child 无字节 ⇒ 发送/传输侧候选,按目标绑定合同离线修,不重跑。 + - 任何 `no match ... rejecting` ⇒ 夹具选择失败(角色缺失),属于夹具 bug,记录后修复需新额度。 + +结果(运行后回填): + +- 唯一 run `43101420107` / 唯一 job `130793595825`(opt-in workflow、windows-2025 QPA、`TERMFORGE_QT_HOSTED_MULTILINE=1`):**通过**,exit 0、`timed_out=false`、`job_after.active=0`(资源全回收)、sample `multiline_cancel=true`/`multiline_confirm=true`/`multiline_scope=hosted-real-cf"`,三入口与占用/释放全项真。 +- 关键日志(21:16:08Z):取消动作 `dialog_helper: box=activeModal buttons=2` → `button='Paste' role=0` / `button='Cancel' role=1` → `click 'Cancel' via role` → `click sent` → `PASTE_CONFIRM accepted=0`,child 字节 == 基线(零增量断言通过)。确认动作(真实 Paste 菜单入口)`click 'Paste' via role` → `click sent` → `PASTE_CONFIRM accepted=1` → `PASTE_TRANSFER state=0 total=5 target=2960`(`L1\rL2` 恰好 5 字节,CRLF→CR、恰好一次,wait_file 断言通过);占用动作 `PASTE_FINISH status=2` → `Paste failed: OpenClipboard: 5`;释放后 `PST-OWN` 再成功(`total=7`)。 +- 执行顺序核对:单次 push 仅触发该 workflow 一次;`qt-cpp.yml` 的 push/PR 双触发在本轮全部保持门控关闭(单行范围),未重复执行真实 hosted 多行路径。 +- 判定矩阵落在第一行:修复成功,hosted 多行确认闭合。根因(exec 返回值语义误判)与修复与本次 hosted 实得一致。 +- 运行 head 为 `b2536fe`(含临时 PASTE_* 痕迹);结果回填后提交移除痕迹并把该 workflow 触发改为 workflow_dispatch-only,行为代码与运行 head 完全一致,差异仅诊断输出。 + diff --git a/experiments/shell/results/2026-11-06-qt-system-paste/dialog_probe.cpp b/experiments/shell/results/2026-11-06-qt-system-paste/dialog_probe.cpp new file mode 100644 index 0000000..faf1923 --- /dev/null +++ b/experiments/shell/results/2026-11-06-qt-system-paste/dialog_probe.cpp @@ -0,0 +1,92 @@ +// Offline QMessageBox interaction probe (no clipboard, offscreen only). +#include +#include +#include +#include +#include +#include +#include +#include + +static QMessageBox* g_box = nullptr; +static int g_result = -99; + +static void dump(const char* tag) { + std::fprintf(stderr, "probe[%s]: activeModal=%p box=%p focus=%p default=%p\n", tag, + (void*)QApplication::activeModalWidget(), (void*)g_box, + (void*)QApplication::focusWidget(), + (void*)(g_box ? g_box->defaultButton() : nullptr)); + if (!g_box) return; + for (auto* button : g_box->buttons()) { + const QPoint global = button->mapToGlobal(button->rect().center()); + std::fprintf(stderr, "probe[%s]: button='%s' role=%d rect=(%d,%d %dx%d) centerGlobal=(%d,%d)\n", + tag, button->text().toUtf8().constData(), + static_cast(g_box->buttonRole(button)), + button->x(), button->y(), button->width(), button->height(), + global.x(), global.y()); + } +} + +static int run_scenario(int mode) { + g_box = new QMessageBox(QMessageBox::Question, QStringLiteral("Paste"), + QStringLiteral("Paste multi-line text into this terminal?"), + QMessageBox::NoButton, nullptr); + QAbstractButton* accept = g_box->addButton(QStringLiteral("Paste"), QMessageBox::AcceptRole); + g_box->addButton(QStringLiteral("Cancel"), QMessageBox::RejectRole); + g_box->setDefaultButton(qobject_cast(accept)); + QTimer::singleShot(300, [mode] { + dump("before"); + if (mode == 0) { + QTest::mouseClick(g_box->buttons().first(), Qt::LeftButton); + std::fprintf(stderr, "probe: mouseClick sent\n"); + } else if (mode == 1) { + QTest::keyClick(g_box, Qt::Key_Return); + std::fprintf(stderr, "probe: keyClick Return sent\n"); + } else if (mode == 2) { + QTest::keyClick(static_cast(g_box->defaultButton()), Qt::Key_Return); + std::fprintf(stderr, "probe: keyClick default Return sent\n"); + } else { + qobject_cast(g_box->buttons().first())->click(); + std::fprintf(stderr, "probe: direct click() used\n"); + } + dump("after"); + }); + const int result = g_box->exec(); + g_result = result; + QAbstractButton* clicked = g_box->clickedButton(); + const int role = clicked ? static_cast(g_box->buttonRole(clicked)) : -99; + const bool confirmed = clicked && g_box->buttonRole(clicked) == QMessageBox::AcceptRole; + std::fprintf(stderr, "probe: exec result=%d clicked='%s' role=%d confirmed=%d\n", result, + clicked ? clicked->text().toUtf8().constData() : "(null)", role, confirmed ? 1 : 0); + delete g_box; + g_box = nullptr; + return confirmed ? 1 : 0; +} + +int main(int argc, char** argv) { + QApplication app(argc, argv); + int accepted = 0, rejected = 0; + for (int mode = 0; mode < 4; ++mode) { + const int result = run_scenario(mode); + if (result == 1) ++accepted; else ++rejected; + } + // Mode 5: Escape must reject. + { + g_box = new QMessageBox(QMessageBox::Question, QStringLiteral("Paste"), + QStringLiteral("Paste multi-line text into this terminal?"), + QMessageBox::NoButton, nullptr); + QAbstractButton* accept = g_box->addButton(QStringLiteral("Paste"), QMessageBox::AcceptRole); + g_box->addButton(QStringLiteral("Cancel"), QMessageBox::RejectRole); + g_box->setDefaultButton(qobject_cast(accept)); + QTimer::singleShot(300, [] { QTest::keyClick(static_cast(g_box), Qt::Key_Escape); }); + const int result = g_box->exec(); + QAbstractButton* clicked = g_box->clickedButton(); + const bool confirmed = clicked && g_box->buttonRole(clicked) == QMessageBox::AcceptRole; + std::fprintf(stderr, "probe: escape exec result=%d clicked='%s' confirmed=%d\n", result, + clicked ? clicked->text().toUtf8().constData() : "(null)", confirmed ? 1 : 0); + if (confirmed) ++accepted; else ++rejected; + delete g_box; g_box = nullptr; + } + std::fprintf(stderr, "probe: confirmed=%d rejected=%d\n", accepted, rejected); + return accepted == 4 && rejected == 1 ? 0 : 1; +} diff --git a/experiments/shell/results/2026-11-06-qt-system-paste/validation.json b/experiments/shell/results/2026-11-06-qt-system-paste/validation.json new file mode 100644 index 0000000..5d20017 --- /dev/null +++ b/experiments/shell/results/2026-11-06-qt-system-paste/validation.json @@ -0,0 +1,122 @@ +{ + "schema": "termforge-qt-system-paste-validation-v1", + "baseline_main": "859e59538fed671070d3833033ac738502f97d95", + "source_commit": "1cde247", + "documentation_note": "Implementation fix is 1cde247; hosted recovery harness f99f693; this validation is a docs/results follow-up on top of the main merge 99aae95.", + "source_hash_note": "Hashes are SHA-256 of committed LF Git blob content at the result-follow-up head; Windows may materialize CRLF while the blob identity stays this value.", + "source_files_sha256": { + "src/qt/clipboard_reader.hpp": "9c923eb646fc176900484af1e4607cae37c7211548f092746a19683edbb9fd66", + "src/qt/terminal_widget.hpp": "de2a5460568835a7687da8d250d719954631dedacf6602ac653cbb3122c0d60c", + "src/qt/paste_fixture.hpp": "d6c203eb9f2aa04513831ec2a18412c900bcf0233adf625e1b1ec90331f56ffb", + "src/qt/paste_test.cpp": "ed0d4731150446214b7467d33980e4d25c2438c777a1b1fda130dee65264b8b5", + "src/qt/clipboard_system_test.cpp": "bdbc445f0f83505fea2da851fa9c76ba8e54e851905df9fa56fabf3c9f018f41", + "src/qt/unicode_input_test.cpp": "f6867c5d2006ed6b75dc0a3693f58d0a0b0022896e87bb7cbe55739881f842c2", + "src/qt/run_smoke.py": "74d4c882bb859e8c85bace5e6db9741af7128008ca04df7a97da22c76d870692", + "CMakeLists.txt": "566e8a2fe059bd38d134816da36167d3e91e69256b4fb741be90cf249fddeb8d", + ".github/workflows/qt-cpp.yml": "f484e496de90ef0d579ff2d4f12fda3dafbf570b80a4eaaa8e64f63213ce739e", + ".github/workflows/qt-hosted-paste-multiline.yml": "c66498518377a168f9dc93d44444a26dfc185ceb433f459fbb8e66d58c010858" + }, + "environment": { + "os": "Windows 11 x64 build 26200", + "generator": "Visual Studio 17 2022", + "compiler": "MSVC 19.42.34433.0", + "qt": "6.10.3 offscreen (local) / Windows QPA (hosted)", + "conpty": "1.25.260930003", + "crt": "14.51.36247.0", + "binary_sha256": "1fd339af0c1294e4ad8c8da71ff768a468112fe4b80afddbee813ebb426e9b3c", + "binary_sha256_note": "local qt_paste_test.exe Release at the result-follow-up head (trace-free); the hosted attempt-3 binary was built on the runner and is identified by run 43101420107" + }, + "entries": { + "keyboard": "Ctrl+Shift+V", + "shift_insert": "Shift+Insert", + "menu": "actual QMenu Paste action clicked with QTest mouse", + "reader": "SystemClipboardReader CF_UNICODETEXT; fake reader locally", + "target": "initiating SessionModel child PID" + }, + "local": { + "qt_paste": { + "passed": true, + "seconds": 1.8, + "multiline_confirm_fixture": "real QMessageBox; role-selected Paste button mouse click; no auto-confirm" + }, + "qt_formal_path_regression": { + "passed": 14, + "failed": 0, + "seconds": 35.0 + }, + "checks": [ + "keyboard single-line once", + "Shift+Insert multiline cancel sends zero", + "real-dialog confirm sends normalized CR once", + "actual menu Unicode + NFD", + "busy rejects second entry", + "closed target rejected", + "system_cf_touched=false", + "offscreen_only=true", + "focus switch stays with origin", + "close during read sends nothing to old/new session", + "second session independent" + ], + "child": "real child records received bytes to a file; never executes the pasted text" + }, + "root_cause": { + "status": "proven offline, closed by hosted attempt 3", + "fact": "QMessageBox::exec() returns the clicked standard button value (measured: accept=2, escape=3), not QDialog::DialogCode; the product compared it against QDialog::Accepted(1), so a real accept was always recorded as rejected and no transfer started.", + "repro": "dialog_probe.cpp probe plus strengthened qt_paste, offscreen, no clipboard access", + "fix": "classify the user choice via box.clickedButton() buttonRole() == QMessageBox::AcceptRole" + }, + "hosted_windows_qpa": { + "run_id": "42183487961", + "job_id": "127778260596", + "status": "passed", + "qpa": "windows", + "product_default_owner_verified": true, + "keyboard_cf": true, + "menu_cf": true, + "shift_insert_cf": true, + "child_bytes_verified": true, + "occupied_failure": true, + "release_success": true, + "multiline_confirm": true, + "multiline_cancel": true, + "multiline_scope": "hosted-real-cf via opt-in recovery run 43101420107 / job 130793595825", + "multiline_recovery_attempt_3": { + "authorized_quota": 1, + "prior_attempts_preserved": 2, + "workflow": ".github/workflows/qt-hosted-paste-multiline.yml", + "trigger": "single push-scoped execution on agent/qt-paste/record-hidden-retention-2e7373117ea0; later dispatch-only", + "gate": "TERMFORGE_QT_HOSTED_MULTILINE=1 in the test binary only", + "entries": "cancel via Ctrl+Shift+V, confirm via real Paste menu; default product CF reader", + "fixture": "role-based dialog selection with full stderr logging; no auto-confirm", + "run_head": "b2536fe (with temporary PASTE_* traces; behavior-identical follow-up removes them)", + "run_id": "43101420107", + "job_id": "130793595825", + "status": "passed", + "sample": "multiline_cancel=true,multiline_confirm=true,multiline_scope=hosted-real-cf,keyboard_cf=true,menu_cf=true,shift_insert_cf=true,occupied_failure=true,release_success=true,qpa=windows,product_default_owner_verified=true", + "log_evidence": "cancel: click 'Cancel' via role -> PASTE_CONFIRM accepted=0, child bytes unchanged; confirm: click 'Paste' via role -> accepted=1 -> PASTE_TRANSFER total=5 (L1\\rL2 CRLF->CR once); occupied: PASTE_FINISH status=2 -> OpenClipboard: 5; release: PST-OWN total=7; exit 0, job_after.active=0" + }, + "description": "Real CF content written by the test, read by the default product reader through the real Windows QPA widget; each public entry appended exactly the expected child bytes. A real holder process caused an explicit failure; after release a second explicit action succeeded. The multiline dialog branch ran once in the opt-in recovery workflow and passed: real Ctrl+Shift+V cancel sent zero bytes with an explicit cancellation, real Paste menu confirm transferred CRLF normalized to CR exactly once.", + "multiline_failure": { + "first_head": "eecf21f", + "first_run_id": "42181178347", + "first_job_id": "127770923069", + "retry_head": "bc6a5af/5ea8e44", + "retry_run_id": "42182236823", + "retry_job_id": "127774271059", + "actual_hex": "5053542de4b8ade696872065cc815053542d4d454e555053542d494e53455254", + "notice": "Paste cancelled", + "exit_code": 1, + "timed_out": false, + "job_active": 0, + "recovery_status": "superseded: root cause proven offline (QMessageBox::exec return semantics) and closed by attempt 3 run 43101420107 / job 130793595825", + "ledger": "attempt-ledger.md" + } + }, + "limitations": [ + "Attempt 3 consumed the single recovery quota and passed; no further hosted multiline run is authorized or needed for this failure.", + "Second-session switch/close reuse is covered only by the local fake-reader scenario and is not combined with hosted real CF.", + "No bracketed paste, alternate-screen/TUI paste policy, automatic line-by-line send, Find, word/line selection, physical presentation/DPI or performance.", + "Child byte recording is transport/child-observed input, not an execution or rendering oracle; no system clipboard is touched locally.", + "Old B/CD addressing, remote VM calibration/v2, A/B/C, old six-stage failure and #27/#29/#30/#35/#7 remain unchanged." + ] +} \ No newline at end of file diff --git a/src/qt/README.md b/src/qt/README.md index 47c8af6..7295658 100644 --- a/src/qt/README.md +++ b/src/qt/README.md @@ -8,7 +8,7 @@ 正式路径的尺寸事务改用 `Screen::resize_preserving`:变窄时保留源 Cell、行身份与软续接,不立即销毁右侧正文。绘制消费者按当前宽度生成独立显示投影页(`HistoryViewport::project`),协议光标只被翻译为显示光标,供光标下划线、IME 候选位置与预编辑预览使用;选择/命中继续消费同一份 `ViewMapping`,并用页面 layout revision 拒绝过期页。不支持的保留 Cell 或无法映射的光标回退到物理视图页,避免隐藏输出。当前验证与仍存差异见[固定结果](../../experiments/shell/results/2026-11-03-qt-retained-display/README.md):只缩宽的显示重排与保留行合同已通过真实 child/固定 ConPTY 核对;旧六阶段 native-sync 诊断仍失败并保留,原生重排网格不被当作产品寻址 oracle。2026-11-04 又补[隐藏帧七阶段正式路径离屏证据](../../experiments/shell/results/2026-11-04-qt-hidden-retention/README.md):主屏正文→缩窄→1049h→仅缩高→恢复→1049l→恢复宽度的保留正文、行身份、显示页/光标/命中通过 58 项检查;child 原生网格仅记录,不是保留文本 oracle。同时缩宽+缩高的原版视口/滚动与后续 CUP 仍缺独立证据,物理呈现/DPI、完整 reflow 和历史/选择/Find 用户流程未验收。 -终端可在无修饰、无组合文本时用左键正向或反向拖选,普通点击清除旧选区。每个 widget 独立持有 `TextSelection`,命中与高亮经 `HistoryViewport` 显示快照中的 `ViewMapping` 映射到文档位置;只装饰显示副本,不修改核心文字或样式。部分列选区只投影到相应列,避免整行高亮。该接线也覆盖保留历史/软续接/显示重排页上的选区源位置迁移(内部提取与高亮投影,见[固定结果](../../experiments/shell/results/2026-11-06-qt-selection-migration/README.md));已接入 Ctrl+Shift+C 与实际 Copy 菜单的有界 CF_UNICODETEXT 写入(失败/空/失效/容量分开报告;hosted Windows CI 已用独立 CF 读回分别验证 offscreen 注入 owner 与 Windows QPA 默认 writer/真实控件 HWND 的键盘/实际菜单/空/占用/释放/淘汰,见[系统复制结果](../../experiments/shell/results/2026-11-06-qt-system-copy/README.md));不包含滚动条/翻页键历史浏览、粘贴、选词/选行或 Find;[固定实现与验证边界](../../experiments/shell/results/2026-11-01-r2-core-input/README.md)与完整 #29/#30 出口分开。 +终端可在无修饰、无组合文本时用左键正向或反向拖选,普通点击清除旧选区。每个 widget 独立持有 `TextSelection`,命中与高亮经 `HistoryViewport` 显示快照中的 `ViewMapping` 映射到文档位置;只装饰显示副本,不修改核心文字或样式。部分列选区只投影到相应列,避免整行高亮。该接线也覆盖保留历史/软续接/显示重排页上的选区源位置迁移(内部提取与高亮投影,见[固定结果](../../experiments/shell/results/2026-11-06-qt-selection-migration/README.md));已接入 Ctrl+Shift+C 与实际 Copy 菜单的有界 CF_UNICODETEXT 写入(失败/空/失效/容量分开报告;hosted Windows CI 已用独立 CF 读回分别验证 offscreen 注入 owner 与 Windows QPA 默认 writer/真实控件 HWND 的键盘/实际菜单/空/占用/释放/淘汰,见[系统复制结果](../../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));不包含滚动条/翻页键历史浏览、选词/选行或 Find;[固定实现与验证边界](../../experiments/shell/results/2026-11-01-r2-core-input/README.md)与完整 #29/#30 出口分开。 标签栏的 `+` 创建同类型 cmd,点击标签选择输入目标,关闭按钮只取消对应会话;关闭父窗口取消所有会话。后台标签继续独立排空输出,已关闭标签完成自有进程回收后释放。成功启动的最后会话结束时沿用原最小入口的应用退出路径;有失败启动页时保留该页错误供关闭。当前标签外观、默认动作和多会话退出汇总仅为这批迁移入口的工程合同,未作 WindTerm 完整标签行为对比。 diff --git a/src/qt/clipboard_reader.hpp b/src/qt/clipboard_reader.hpp new file mode 100644 index 0000000..d21048f --- /dev/null +++ b/src/qt/clipboard_reader.hpp @@ -0,0 +1,74 @@ +// Bounded CF_UNICODETEXT reader for the formal Qt paste entry. +#pragma once +#include +#include +#include +#include +#ifdef Q_OS_WIN +#include +#endif + +namespace termforge::qt { + +struct ClipboardRead { + enum class Status { ok, empty, failure }; + Status status = Status::failure; + std::u16string text; + std::string error; +}; + +class ClipboardReader { +public: + virtual ~ClipboardReader() = default; + virtual ClipboardRead read(quintptr owner = 0) = 0; +}; + +class SystemClipboardReader final : public ClipboardReader { +public: + ClipboardRead read(quintptr owner) override { +#ifdef Q_OS_WIN + HWND window = owner ? reinterpret_cast(owner) : nullptr; + bool opened = false; + for (int attempt = 0; attempt < 5 && !opened; ++attempt) { + if (OpenClipboard(window)) { + opened = true; + break; + } + const DWORD error = GetLastError(); + if (error != ERROR_ACCESS_DENIED) + return {ClipboardRead::Status::failure, {}, "OpenClipboard: " + std::to_string(error)}; + Sleep(20 * static_cast(attempt + 1)); + } + if (!opened) return {ClipboardRead::Status::failure, {}, "OpenClipboard: " + std::to_string(GetLastError())}; + struct Close { ~Close() { CloseClipboard(); } } close; + if (!IsClipboardFormatAvailable(CF_UNICODETEXT)) + return {ClipboardRead::Status::failure, {}, "Unicode text unavailable"}; + HANDLE memory = GetClipboardData(CF_UNICODETEXT); + if (!memory) return {ClipboardRead::Status::failure, {}, "GetClipboardData: " + std::to_string(GetLastError())}; + const SIZE_T allocation = GlobalSize(memory); + if (allocation > 2 * 1024 * 1024 + 64) + return {ClipboardRead::Status::failure, {}, "clipboard allocation exceeds limit"}; + const auto* text = static_cast(GlobalLock(memory)); + if (!text) return {ClipboardRead::Status::failure, {}, "GlobalLock: " + std::to_string(GetLastError())}; + struct Unlock { HANDLE memory; ~Unlock() { GlobalUnlock(memory); } } unlock{memory}; + const size_t units = allocation / sizeof(char16_t); + size_t length = 0; + while (length < units && text[length]) ++length; + if (length == units) return {ClipboardRead::Status::failure, {}, "clipboard text lacks bounded terminator"}; + if (length == 0) return {ClipboardRead::Status::empty, {}, {}}; + return {ClipboardRead::Status::ok, std::u16string(text, length), {}}; +#else + try { + const QString value = QGuiApplication::clipboard()->text(); + if (value.isEmpty()) return {ClipboardRead::Status::empty, {}, {}}; + if (value.size() > 1024 * 1024) + return {ClipboardRead::Status::failure, {}, "clipboard UTF-16 limit"}; + return {ClipboardRead::Status::ok, value.toStdU16String(), {}}; + } catch (const std::exception& error) { + return {ClipboardRead::Status::failure, {}, error.what()}; + } +#endif + } +}; + +} // namespace termforge::qt diff --git a/src/qt/clipboard_system_test.cpp b/src/qt/clipboard_system_test.cpp index 089c017..4ae5a45 100644 --- a/src/qt/clipboard_system_test.cpp +++ b/src/qt/clipboard_system_test.cpp @@ -2,9 +2,12 @@ // Locally this executable returns a bounded skip without touching the user CF. #include "terminal_widget.hpp" #include "selection_fixture.hpp" +#include "paste_fixture.hpp" #include #include #include +#include +#include #include #include #include @@ -17,12 +20,14 @@ #include #include #include +#include #include using termforge::qt::ClipboardWrite; using termforge::qt::SessionModel; using termforge::qt::TerminalWidget; using termforge::qt::test::selection_child; +using termforge::qt::test::paste_child; namespace { void require(bool condition, const std::string& message) { @@ -148,6 +153,174 @@ void await(Predicate predicate, const SessionModel& model, QElapsedTimer& lifecy } require(predicate(), message); } +void menu_paste(TerminalWidget& terminal) { + QTimer::singleShot(0, [] { + auto* popup = qobject_cast(QApplication::activePopupWidget()); + require(popup != nullptr, "Paste context menu missing"); + const auto actions = popup->actions(); + require(!actions.empty() && actions.back()->text() == QStringLiteral("Paste"), "Paste action missing"); + QTest::mouseClick(popup, Qt::LeftButton, Qt::NoModifier, popup->actionGeometry(actions.back()).center()); + }); + QContextMenuEvent event(QContextMenuEvent::Mouse, QPoint(20, 20), terminal.mapToGlobal(QPoint(20, 20))); + QApplication::sendEvent(&terminal, &event); +} +// Hosted dialog driver: select the button by its QMessageBox role first (the +// product assigns explicit roles), falling back to the label only as a +// diagnostic. Every decision is logged so a rejected dialog can be attributed +// to the fixture instead of being silently treated as a user cancellation. +void click_paste_dialog(const char* label, QMessageBox::ButtonRole role) { + auto polls = std::make_shared(0); + auto* timer = new QTimer(qApp); + timer->setInterval(20); + QObject::connect(timer, &QTimer::timeout, [timer, label, role, polls] { + ++*polls; + auto* box = qobject_cast(QApplication::activeModalWidget()); + const char* found = "activeModal"; + if (!box) { + for (auto* widget : QApplication::topLevelWidgets()) { + if (auto* candidate = qobject_cast(widget)) { box = candidate; break; } + } + found = "topLevelScan"; + } + if (!box) { + if (*polls >= 250) { + std::fprintf(stderr, "dialog_helper: no QMessageBox after %d polls\n", *polls); + timer->stop(); + } + return; + } + timer->stop(); + std::fprintf(stderr, "dialog_helper: box=%s buttons=%d\n", found, static_cast(box->buttons().size())); + for (auto* button : box->buttons()) + std::fprintf(stderr, "dialog_helper: button='%s' role=%d\n", + button->text().toUtf8().constData(), static_cast(box->buttonRole(button))); + QAbstractButton* chosen = nullptr; + const char* matched = ""; + for (auto* button : box->buttons()) + if (box->buttonRole(button) == role) { chosen = button; matched = "role"; break; } + if (!chosen) + for (auto* button : box->buttons()) + if (button->text() == QString::fromLatin1(label)) { chosen = button; matched = "text"; break; } + if (!chosen) { + std::fprintf(stderr, "dialog_helper: no match label='%s' role=%d; rejecting\n", label, + static_cast(role)); + box->reject(); + return; + } + std::fprintf(stderr, "dialog_helper: click '%s' via %s\n", chosen->text().toUtf8().constData(), matched); + QTest::mouseClick(chosen, Qt::LeftButton); + std::fprintf(stderr, "dialog_helper: click sent\n"); + }); + timer->start(); +} +std::string read_input_file(const QString& path) { + QFile file(path); require(file.open(QIODevice::ReadOnly), "paste input file unavailable"); + return file.readAll().toStdString(); +} +void hosted_windows_paste() { +#ifdef Q_OS_WIN + QElapsedTimer lifecycle; lifecycle.start(); + QTemporaryDir workspace; + require(workspace.isValid(), "paste workspace unavailable"); + wchar_t executable[32768]{}; + const DWORD length = GetModuleFileNameW(nullptr, executable, 32768); + require(length && length < 32768, "paste executable unavailable"); + const QString input_path = workspace.path() + "/paste-input.bin"; + SessionModel model; QWidget window; TerminalWidget terminal(model, &window); + window.resize(1200, 900); window.show(); terminal.setFocus(); + require(model.start(executable, L"--paste-child \"" + input_path.toStdWString() + L"\"", + workspace.path().toStdWString()), "paste child did not start"); + await([&] { return termforge::project_line(model.screen().visible_lines()[0]).text.find("PASTE_READY") != std::string::npos; }, + model, lifecycle, "paste child prompt missing"); + require(terminal.hasFocus(), "paste terminal lost focus"); + const HWND hwnd = reinterpret_cast(terminal.winId()); + require(hwnd != nullptr, "Windows QPA widget has no native HWND"); + auto wait_notice = [&](const QString& text) { + QElapsedTimer phase; phase.start(); + while (terminal.paste_notice() != text && phase.elapsed() < 5000) QTest::qWait(10); + require(terminal.paste_notice() == text, "paste notice differs: " + terminal.paste_notice().toStdString()); + }; + auto wait_file = [&](const std::string& expected) { + QElapsedTimer phase; phase.start(); + while (read_input_file(input_path) != expected && phase.elapsed() < 5000) QTest::qWait(10); + require(read_input_file(input_path) == expected, "paste child bytes differ"); + }; + + // Keyboard single-line, immediate. + write_cf(hwnd, u"PST-中文 e\u0301"); + QTest::keyClick(&terminal, Qt::Key_V, Qt::ControlModifier | Qt::ShiftModifier); + wait_file("PST-中文 e\xcc\x81"); + wait_notice(QStringLiteral("Pasted")); + + // Actual Paste menu, single line. + write_cf(hwnd, u"PST-MENU"); + menu_paste(terminal); + wait_file("PST-中文 e\xcc\x81PST-MENU"); + wait_notice(QStringLiteral("Pasted")); + + // Shift+Insert single line. + write_cf(hwnd, u"PST-INSERT"); + QTest::keyClick(&terminal, Qt::Key_Insert, Qt::ShiftModifier); + wait_file("PST-中文 e\xcc\x81PST-MENUPST-INSERT"); + wait_notice(QStringLiteral("Pasted")); + + // Multiline cancel/confirm runs only in the opt-in single hosted recovery + // workflow (TERMFORGE_QT_HOSTED_MULTILINE=1); the default CI lane keeps the + // historical single-line scope and the local fake-reader coverage. + const std::string baseline = read_input_file(input_path); + const bool multiline = qgetenv("TERMFORGE_QT_HOSTED_MULTILINE") == "1"; + if (multiline) { + // Real Ctrl+Shift+V entry; the real dialog must reject and send nothing. + write_cf(hwnd, u"L1\r\nL2"); + click_paste_dialog("Cancel", QMessageBox::RejectRole); + QTest::keyClick(&terminal, Qt::Key_V, Qt::ControlModifier | Qt::ShiftModifier); + wait_notice(QStringLiteral("Paste cancelled")); + require(read_input_file(input_path) == baseline, "cancelled multiline paste sent bytes"); + // Real Paste menu entry; the real dialog accept must transfer CRLF + // normalized to CR exactly once into the independent child. + write_cf(hwnd, u"L1\r\nL2"); + click_paste_dialog("Paste", QMessageBox::AcceptRole); + menu_paste(terminal); + wait_file(baseline + "L1\rL2"); + wait_notice(QStringLiteral("Pasted")); + } + + // A real owner-held clipboard fails cleanly; after release an explicit + // second action succeeds with the intended CF text. + const QString ready = workspace.path() + "/paste-hold-ready"; + const QString release = workspace.path() + "/paste-hold-release"; + write_cf(hwnd, u"PST-OWN"); + QProcess holder; + holder.start(QString::fromWCharArray(executable), {"--clipboard-holder", ready, release}); + require(holder.waitForStarted(5000), "paste clipboard holder did not start"); + for (int i = 0; i < 100 && !QFile::exists(ready); ++i) QTest::qWait(20); + require(QFile::exists(ready), "paste clipboard holder did not acquire CF"); + QTest::keyClick(&terminal, Qt::Key_V, Qt::ControlModifier | Qt::ShiftModifier); + wait_notice(QStringLiteral("Paste failed: OpenClipboard: 5")); + { + QFile release_file(release); + require(release_file.open(QIODevice::WriteOnly), "paste release file unavailable"); + release_file.write("release"); release_file.close(); + } + require(holder.waitForFinished(5000), "paste clipboard holder did not release"); + write_cf(hwnd, u"PST-OWN"); + QTest::keyClick(&terminal, Qt::Key_V, Qt::ControlModifier | Qt::ShiftModifier); + wait_file(baseline + (multiline ? "L1\rL2" : "") + "PST-OWN"); + + require(model.send(std::string(1, '\x03')), "paste child quit rejected"); + await([&] { return model.finished(); }, model, lifecycle, "paste child did not exit"); + require(model.exit_code() == 37 && model.cleanup_verified(), "paste child cleanup was not verified"); + std::cout << "{\"schema\":\"termforge-qt-clipboard-paste-v1\",\"status\":\"passed\"," + "\"keyboard_cf\":true,\"menu_cf\":true,\"shift_insert_cf\":true," + "\"multiline_cancel\":" << (multiline ? "true" : "false") + << ",\"multiline_confirm\":" << (multiline ? "true" : "false") + << ",\"multiline_scope\":\"" << (multiline ? "hosted-real-cf" : "local-fake-reader-only") << "\"," + "\"occupied_failure\":true,\"release_success\":true," + "\"qpa\":\"windows\",\"product_default_owner_verified\":true}\n"; +#else + throw std::runtime_error("hosted Windows paste requires Windows"); +#endif +} void menu_copy(TerminalWidget& terminal) { QTimer::singleShot(0, [] { auto* popup = qobject_cast(QApplication::activePopupWidget()); @@ -170,11 +343,16 @@ int main(int argc, char** argv) { if (argc == 2 && std::string_view(argv[1]) == "--selection-child") { try { return selection_child(); } catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 3; } } + if (argc == 3 && std::string_view(argv[1]) == "--paste-child") { + try { return paste_child(QString::fromLocal8Bit(argv[2]).toStdWString()); } + catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 3; } + } const std::string_view mode = argc == 2 ? std::string_view(argv[1]) : std::string_view{}; const bool injected_owner = mode == "--hosted-copy"; const bool windows_qpa = mode == "--hosted-windows-copy"; - if (!injected_owner && !windows_qpa) { - std::cerr << "usage: qt_clipboard_system_test --hosted-copy|--hosted-windows-copy|--selection-child|--clipboard-holder \n"; + const bool windows_paste = mode == "--hosted-windows-paste"; + if (!injected_owner && !windows_qpa && !windows_paste) { + std::cerr << "usage: qt_clipboard_system_test --hosted-copy|--hosted-windows-copy|--hosted-windows-paste|--selection-child|--paste-child |--clipboard-holder \n"; return 2; } if (!hosted()) { @@ -191,6 +369,10 @@ int main(int argc, char** argv) { QApplication app(argc, argv); QApplication::setQuitOnLastWindowClosed(false); try { + if (windows_paste) { + hosted_windows_paste(); + return 0; + } QElapsedTimer lifecycle; lifecycle.start(); QTemporaryDir workspace; require(workspace.isValid(), "workspace unavailable"); diff --git a/src/qt/paste_fixture.hpp b/src/qt/paste_fixture.hpp new file mode 100644 index 0000000..2c50c01 --- /dev/null +++ b/src/qt/paste_fixture.hpp @@ -0,0 +1,48 @@ +// Test-only child that records received input bytes unchanged. It never +// interprets or executes pasted text. +#pragma once +#include "../../experiments/conpty/session.hpp" +#include +#include +#include +#include +#include +#include + +namespace termforge::qt::test { + +inline int paste_child(const std::wstring& path) { + BOOL owned = FALSE; + if (!IsProcessInJob(GetCurrentProcess(), nullptr, &owned) || !owned) + throw std::runtime_error("paste child requires its owned Session Job"); + tf_conpty::Handle input(CreateFileW(L"CONIN$", GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, 0, nullptr)); + tf_conpty::Handle output(CreateFileW(L"CONOUT$", GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, 0, nullptr)); + if (input.get() == INVALID_HANDLE_VALUE || output.get() == INVALID_HANDLE_VALUE) + tf_conpty::fail("CreateFile(paste child console)"); + if (!SetConsoleMode(input.get(), ENABLE_VIRTUAL_TERMINAL_INPUT) || + !SetConsoleCP(CP_UTF8) || !SetConsoleOutputCP(CP_UTF8) || + !SetConsoleMode(output.get(), ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING)) + tf_conpty::fail("SetConsoleMode(paste child)"); + std::ofstream file(path, std::ios::binary | std::ios::trunc); + if (!file) tf_conpty::fail("paste child input file"); + const char ready[] = "PASTE_READY\r\n"; + DWORD written = 0; + if (!WriteFile(output.get(), ready, sizeof(ready) - 1, &written, nullptr) || + written != sizeof(ready) - 1) + tf_conpty::fail("WriteFile(paste child prompt)"); + for (;;) { + char byte = 0; DWORD count = 0; + if (!ReadFile(input.get(), &byte, 1, &count, nullptr) || count != 1) + tf_conpty::fail("ReadFile(paste child input)"); + if (static_cast(byte) == 0x03) { + file.flush(); + return 37; + } + file.put(byte); + file.flush(); + } +} + +} // namespace termforge::qt::test diff --git a/src/qt/paste_test.cpp b/src/qt/paste_test.cpp new file mode 100644 index 0000000..c8b2f7f --- /dev/null +++ b/src/qt/paste_test.cpp @@ -0,0 +1,241 @@ +// Local, non-CF paste-entry regression with a fake reader and a real child that +// records received bytes without executing them. +#include "terminal_widget.hpp" +#include "paste_fixture.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using termforge::qt::ClipboardRead; +using termforge::qt::ClipboardReader; +using termforge::qt::SessionModel; +using termforge::qt::TerminalWidget; +using termforge::qt::test::paste_child; + +namespace { +void require(bool condition, const std::string& message) { + if (!condition) throw std::runtime_error(message); +} +class FakeReader final : public ClipboardReader { +public: + ClipboardRead value; + int delay_ms = 0; + ClipboardRead read(quintptr) override { + if (delay_ms > 0) std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms)); + return value; + } +}; +std::string read_file(const QString& path) { + QFile file(path); require(file.open(QIODevice::ReadOnly), "input file unavailable"); + return file.readAll().toStdString(); +} +template +void await(Predicate predicate, const 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()); + if (model.finished() && !predicate()) throw std::runtime_error(std::string("child ended before: ") + message); + } + require(predicate(), message); +} +void click_dialog(const char* label, QMessageBox::ButtonRole role) { + auto polls = std::make_shared(0); + auto* timer = new QTimer(qApp); + timer->setInterval(20); + QObject::connect(timer, &QTimer::timeout, [timer, label, role, polls] { + ++*polls; + auto* box = qobject_cast(QApplication::activeModalWidget()); + if (!box) { + if (*polls >= 250) { + std::cerr << "dialog_helper: no QMessageBox after " << *polls << " polls\n"; + timer->stop(); + } + return; + } + timer->stop(); + for (auto* button : box->buttons()) + std::cerr << "dialog_helper: button='" << button->text().toStdString() << "' role=" + << static_cast(box->buttonRole(button)) << "\n"; + QAbstractButton* chosen = nullptr; + for (auto* button : box->buttons()) + if (box->buttonRole(button) == role) { chosen = button; break; } + if (!chosen) { + std::cerr << "dialog_helper: no button for role=" << static_cast(role) << "; rejecting\n"; + box->reject(); + return; + } + std::cerr << "dialog_helper: click '" << chosen->text().toStdString() << "' role=" + << static_cast(role) << "\n"; + QTest::mouseClick(chosen, Qt::LeftButton); + std::cerr << "dialog_helper: click sent\n"; + }); + timer->start(); +} +void open_menu(TerminalWidget& terminal) { + QTimer::singleShot(200, [] { + auto* popup = qobject_cast(QApplication::activePopupWidget()); + require(popup != nullptr, "terminal menu did not appear"); + for (auto* action : popup->actions()) { + if (action->text() == QStringLiteral("Paste")) { + QTest::mouseClick(popup, Qt::LeftButton, Qt::NoModifier, popup->actionGeometry(action).center()); + return; + } + } + throw std::runtime_error("Paste menu action missing"); + }); + QContextMenuEvent event(QContextMenuEvent::Mouse, QPoint(20, 20), terminal.mapToGlobal(QPoint(20, 20))); + QApplication::sendEvent(&terminal, &event); +} +} // namespace + +int main(int argc, char** argv) { + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); + if (argc == 3 && std::string_view(argv[1]) == "--paste-child") { + try { return paste_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_paste_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 << "paste 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(), "paste workspace unavailable"); + wchar_t executable[32768]{}; + const DWORD length = GetModuleFileNameW(nullptr, executable, 32768); + require(length && length < 32768, "paste executable unavailable"); + const QString child_input = workspace.path() + "/child-input.bin"; + const QString second_input = workspace.path() + "/second-input.bin"; + SessionModel model; QWidget window; TerminalWidget terminal(model, &window); + window.resize(1200, 900); window.show(); terminal.setFocus(); + require(model.start(executable, L"--paste-child \"" + child_input.toStdWString() + L"\"", + workspace.path().toStdWString()), "paste child did not start"); + await([&] { return termforge::project_line(model.screen().visible_lines()[0]).text.find("PASTE_READY") != std::string::npos; }, + model, lifecycle, "paste child prompt missing"); + auto reader = std::make_unique(); + FakeReader* fake = reader.get(); + terminal.setClipboardReaderForTest(std::move(reader)); + require(terminal.hasFocus(), "paste terminal lacks focus"); + SessionModel second; QWidget second_window; TerminalWidget second_terminal(second, &second_window); + second_window.resize(1200, 900); second_window.show(); + require(second.start(executable, L"--paste-child \"" + second_input.toStdWString() + L"\"", + workspace.path().toStdWString()), "second paste child did not start"); + await([&] { return termforge::project_line(second.screen().visible_lines()[0]).text.find("PASTE_READY") != std::string::npos; }, + second, lifecycle, "second paste prompt missing"); + auto second_reader = std::make_unique(); + FakeReader* second_fake = second_reader.get(); + second_terminal.setClipboardReaderForTest(std::move(second_reader)); + window.activateWindow(); + terminal.setFocus(); + QTest::qWait(20); + require(terminal.hasFocus(), "first paste terminal did not regain focus"); + + // Keyboard single-line: immediate, exactly once, no confirmation. + fake->value = {ClipboardRead::Status::ok, u"TF-PASTE", {}}; + QTest::keyClick(&terminal, Qt::Key_V, Qt::ControlModifier | Qt::ShiftModifier); + await([&] { return read_file(child_input) == "TF-PASTE"; }, model, lifecycle, "single-line paste bytes differ"); + require(terminal.paste_notice() == QStringLiteral("Pasted"), "single-line paste did not report success"); + + // Shift+Insert multiline: dialog cancel sends nothing; confirm through the + // real dialog sends normalized CR once (no auto-confirm shortcut). + const std::string baseline = read_file(child_input); + fake->value = {ClipboardRead::Status::ok, u"A\r\nB", {}}; + click_dialog("Cancel", QMessageBox::RejectRole); + QTest::keyClick(&terminal, Qt::Key_Insert, Qt::ShiftModifier); + await([&] { return terminal.paste_notice() == QStringLiteral("Paste cancelled"); }, + model, lifecycle, "cancel dialog did not report cancellation"); + require(read_file(child_input) == baseline, "cancel sent child bytes"); + // Offscreen modal teardown does not reliably return focus; use the + // product paste trigger as the entry, but confirm through the real + // QMessageBox exactly like the hosted Windows QPA recovery run. + fake->value = {ClipboardRead::Status::ok, u"A\r\nB", {}}; + click_dialog("Paste", QMessageBox::AcceptRole); + terminal.initiate_paste_for_test(); + await([&] { return read_file(child_input) == baseline + "A\rB"; }, + model, lifecycle, "confirmed multiline paste bytes differ"); + require(read_file(child_input) == baseline + "A\rB", "multiline paste was duplicated or not CR-normalized"); + require(terminal.paste_notice() == QStringLiteral("Pasted"), "confirmed dialog did not report success"); + + // Actual Paste context-menu action, Unicode + NFD, immediate single line. + fake->value = {ClipboardRead::Status::ok, u"中文 e\u0301", {}}; + open_menu(terminal); + await([&] { return read_file(child_input) == baseline + "A\rB中文 e\xcc\x81"; }, + model, lifecycle, "menu paste bytes differ"); + + // Busy: a second entry while the fake read is in flight is rejected. + fake->delay_ms = 250; + terminal.initiate_paste_for_test(); + QTest::qWait(30); + terminal.initiate_paste_for_test(); + require(terminal.paste_notice() == QStringLiteral("Paste busy"), "second paste did not report busy"); + QTest::qWait(350); + require(terminal.paste_notice() == QStringLiteral("Pasted"), "first blocked paste did not finish"); + + // Focus/tab switch: the in-flight task stays with the initiating session. + const std::string switch_baseline = read_file(child_input); + { + fake->delay_ms = 120; + fake->value = {ClipboardRead::Status::ok, u"ISO-SWITCH", {}}; + terminal.initiate_paste_for_test(); + second_window.activateWindow(); + second_terminal.setFocus(); + QTest::qWait(30); + QElapsedTimer phase; phase.start(); + while (read_file(child_input) != switch_baseline + "ISO-SWITCH" && phase.elapsed() < 3000) QTest::qWait(10); + require(read_file(child_input) == switch_baseline + "ISO-SWITCH", "switched-target paste did not stay with its origin"); + require(read_file(second_input).empty(), "paste leaked into the second session after focus switch"); + } + // Close during the asynchronous read: the old task must not reach a new session. + const std::string close_baseline = read_file(child_input); + { + fake->delay_ms = 300; + fake->value = {ClipboardRead::Status::ok, u"ISO-CLOSED", {}}; + terminal.initiate_paste_for_test(); + QTest::qWait(30); + model.close(); + await([&] { return model.finished(); }, model, lifecycle, "paste target did not close"); + QTest::qWait(400); + require(read_file(child_input) == close_baseline, "closed-target paste reached the old child"); + require(read_file(second_input).empty(), "closed-target paste reached the new session"); + } + // New session remains independent and usable. + second_fake->value = {ClipboardRead::Status::ok, u"ISO-SECOND", {}}; + second_window.activateWindow(); second_terminal.setFocus(); QTest::qWait(20); + QTest::keyClick(&second_terminal, Qt::Key_V, Qt::ControlModifier | Qt::ShiftModifier); + QElapsedTimer second_phase; second_phase.start(); + while (read_file(second_input) != "ISO-SECOND" && second_phase.elapsed() < 3000) QTest::qWait(10); + require(read_file(second_input) == "ISO-SECOND", "second session paste did not work independently"); + require(model.finished(), "closed first session unexpectedly restarted"); + require(second.send(std::string(1, '\x03')), "second paste child quit rejected"); + await([&] { return second.finished(); }, second, lifecycle, "second paste child did not exit"); + require(second.exit_code() == 37 && second.cleanup_verified(), "second paste child cleanup was not verified"); + std::cout << "{\"schema\":\"termforge-qt-paste-v1\",\"status\":\"passed\"," + "\"keyboard_single\":true,\"shift_insert_cancel_confirm\":true," + "\"menu_unicode\":true,\"busy\":true,\"closed_target\":true," + "\"switch_isolation\":true,\"close_during_read\":true,\"new_session_isolated\":true," + "\"system_cf_touched\":false,\"offscreen_only\":true,\"presented_done\":null}\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "Qt paste: " << error.what() << '\n'; + return 1; + } +} diff --git a/src/qt/terminal_widget.hpp b/src/qt/terminal_widget.hpp index 4c0a625..60d9fef 100644 --- a/src/qt/terminal_widget.hpp +++ b/src/qt/terminal_widget.hpp @@ -3,6 +3,8 @@ #include "terminal_session.hpp" #include "clipboard_writer.hpp" +#include "clipboard_reader.hpp" +#include "../../experiments/shell/paste.hpp" #include "../../experiments/core/display_cursor.hpp" #include "../../experiments/core/history_view.hpp" #include "../../experiments/core/text_selection.hpp" @@ -21,9 +23,15 @@ #include #include #include -#include +#include \n#include #include #include +#include +#include +#include +#include +#include +#include #include #include #include @@ -41,6 +49,7 @@ #include #include #include +#include namespace termforge::qt { @@ -84,8 +93,16 @@ class TerminalWidget final : public QWidget { int find_interval_ms_ = 0; QTimer find_timer_; std::unique_ptr clipboard_; + std::unique_ptr clipboard_reader_; ClipboardWrite last_copy_; QString copy_notice_; + QString paste_notice_; + std::thread paste_read_thread_; + bool paste_reading_ = false; + std::unique_ptr paste_transfer_; + std::optional paste_dialog_auto_confirm_; + std::string paste_pending_chunk_; + unsigned long long paste_target_ = 0; static constexpr int margin = 8; int cell_width_ = 9; int cell_height_ = 19; @@ -208,6 +225,157 @@ class TerminalWidget final : public QWidget { selection_.set(document, {*selection_anchor_, *position}); update(); } + void set_paste_notice(const QString& text) { + paste_notice_ = text; + update(); + } + void finish_paste_read(ClipboardRead result) { + paste_reading_ = false; + if (paste_read_thread_.joinable()) paste_read_thread_.join(); + if (result.status == ClipboardRead::Status::empty) { + set_paste_notice(QStringLiteral("Paste: clipboard empty")); + return; + } + if (result.status == ClipboardRead::Status::failure) { + set_paste_notice(QStringLiteral("Paste failed: ") + QString::fromStdString(result.error)); + return; + } + if (!model_.running() || model_.resize_state().child_pid != paste_target_) { + set_paste_notice(QStringLiteral("Paste failed: target session changed or closed")); + paste_target_ = 0; + return; + } + tf_shell::PreparedPaste prepared; + try { + prepared = tf_shell::prepare_paste(result.text); + } catch (const std::exception& error) { + set_paste_notice(QStringLiteral("Paste failed: ") + QString::fromUtf8(error.what())); + return; + } + bool confirmed = true; + if (prepared.confirmation_required && paste_dialog_auto_confirm_) { + confirmed = *paste_dialog_auto_confirm_; + } else if (prepared.confirmation_required) { + QMessageBox box(QMessageBox::Question, QStringLiteral("Paste"), + QStringLiteral("Paste multi-line text into this terminal?"), + QMessageBox::NoButton, this); + QAbstractButton* accept = box.addButton(QStringLiteral("Paste"), QMessageBox::AcceptRole); + box.addButton(QStringLiteral("Cancel"), QMessageBox::RejectRole); + box.setDefaultButton(qobject_cast(accept)); + box.exec(); + // QMessageBox::exec() returns the clicked standard button, not a + // QDialog::DialogCode, so classify the real user choice by the + // clicked button's role instead of comparing against QDialog codes. + QAbstractButton* clicked = box.clickedButton(); + confirmed = clicked && box.buttonRole(clicked) == QMessageBox::AcceptRole; + if (QWidget* top = window()) { + top->activateWindow(); + top->raise(); + } + setFocus(Qt::OtherFocusReason); + QApplication::processEvents(); + if (!confirmed) { + set_paste_notice(QStringLiteral("Paste cancelled")); + return; + } + } + if (!model_.running()) { + set_paste_notice(QStringLiteral("Paste failed: no active terminal")); + return; + } + if (!paste_target_ || model_.resize_state().child_pid != paste_target_) { + set_paste_notice(QStringLiteral("Paste failed: target session changed or closed")); + return; + } + try { + paste_transfer_ = std::make_unique(paste_target_, std::move(prepared), confirmed); + } catch (const std::exception& error) { + set_paste_notice(QStringLiteral("Paste failed: ") + QString::fromUtf8(error.what())); + return; + } + if (paste_transfer_->progress().state == tf_shell::PasteState::completed) { + set_paste_notice(QStringLiteral("Pasted")); + paste_transfer_.reset(); + return; + } + QMetaObject::invokeMethod(this, [this] { pump_paste(); }, Qt::QueuedConnection); + } + void pump_paste() { + if (!paste_transfer_) return; + if (!model_.running() || model_.resize_state().child_pid != paste_target_) { + paste_transfer_->fail("target session changed or closed"); + set_paste_notice(QStringLiteral("Paste failed: target session changed or closed")); + paste_transfer_.reset(); + paste_pending_chunk_.clear(); + return; + } + std::string chunk = paste_pending_chunk_; + if (chunk.empty()) { + chunk = paste_transfer_->take_chunk(); + if (chunk.empty()) { + const auto state = paste_transfer_->progress().state; + if (state == tf_shell::PasteState::completed) set_paste_notice(QStringLiteral("Pasted")); + else if (state == tf_shell::PasteState::cancelled) set_paste_notice(QStringLiteral("Paste cancelled")); + else if (state == tf_shell::PasteState::failed) + set_paste_notice(QStringLiteral("Paste failed: ") + QString::fromStdString(paste_transfer_->progress().error)); + paste_transfer_.reset(); + return; + } + } + if (!model_.send(chunk)) { + // Keep the same chunk: a rejected submission was not accepted and + // must not be acknowledged or duplicated. + paste_pending_chunk_ = std::move(chunk); + QTimer::singleShot(10, this, [this] { pump_paste(); }); + return; + } + paste_pending_chunk_.clear(); + paste_transfer_->acknowledge_written(chunk.size()); + paste_transfer_->finish_chunk(); + if (tf_shell::paste_terminal(paste_transfer_->progress().state)) { + const auto state = paste_transfer_->progress().state; + if (state == tf_shell::PasteState::completed) set_paste_notice(QStringLiteral("Pasted")); + else if (state == tf_shell::PasteState::cancelled) set_paste_notice(QStringLiteral("Paste cancelled")); + else set_paste_notice(QStringLiteral("Paste failed: ") + + QString::fromStdString(paste_transfer_->progress().error)); + paste_transfer_.reset(); + return; + } + QTimer::singleShot(0, this, [this] { pump_paste(); }); + } + void initiate_paste() { + if (paste_reading_ || paste_transfer_) { + set_paste_notice(QStringLiteral("Paste busy")); + return; + } + if (!model_.running()) { + set_paste_notice(QStringLiteral("Paste failed: no active terminal")); + return; + } + paste_target_ = model_.resize_state().child_pid; + if (!paste_target_) { + set_paste_notice(QStringLiteral("Paste failed: no active terminal")); + return; + } + paste_reading_ = true; + if (paste_read_thread_.joinable()) paste_read_thread_.join(); + const quintptr owner = static_cast(winId()); + ClipboardReader* reader = clipboard_reader_.get(); + QPointer self(this); + paste_read_thread_ = std::thread([reader, owner, self] { + ClipboardRead value = reader ? reader->read(owner) : ClipboardRead{ + ClipboardRead::Status::failure, {}, "no clipboard reader"}; + QMetaObject::invokeMethod(qApp, [self, value = std::move(value)] { + if (self) self->finish_paste_read(std::move(value)); + }, Qt::QueuedConnection); + }); + } + void cancel_paste() { + if (paste_transfer_) { + paste_transfer_->cancel(); + paste_pending_chunk_.clear(); + } + } ClipboardWrite copy_selection_to_clipboard() { const TextDocument document(model_.screen()); const auto copied = selection_.copy(document, 4 * 1024 * 1024); @@ -247,8 +415,11 @@ class TerminalWidget final : public QWidget { QMenu menu(this); QAction* copy = menu.addAction(QStringLiteral("Copy")); copy->setEnabled(selection_.range().has_value()); + QAction* paste = menu.addAction(QStringLiteral("Paste")); + paste->setEnabled(model_.running() && !paste_reading_ && !paste_transfer_); QAction* chosen = menu.exec(event->globalPos()); if (chosen == copy) copy_selection_to_clipboard(); + else if (chosen == paste) initiate_paste(); event->accept(); } void mousePressEvent(QMouseEvent* event) override { @@ -365,6 +536,13 @@ class TerminalWidget final : public QWidget { event->key() == Qt::Key_F) { open_find(); event->accept(); return; } + if (event->modifiers() == (Qt::ControlModifier | Qt::ShiftModifier) && + event->key() == Qt::Key_V) { + initiate_paste(); event->accept(); return; + } + if (event->modifiers() == Qt::ShiftModifier && event->key() == Qt::Key_Insert) { + initiate_paste(); event->accept(); return; + } if (event->modifiers() == (Qt::ControlModifier | Qt::ShiftModifier) && event->key() == Qt::Key_C) { copy_selection_to_clipboard(); @@ -799,6 +977,7 @@ class TerminalWidget final : public QWidget { QStringLiteral("Session exited: %1").arg(model_.exit_code()) : QStringLiteral("Terminal %1 x %2").arg(screen.width()).arg(screen.height()); if (!copy_notice_.isEmpty()) status = copy_notice_ + QStringLiteral(" | ") + status; + if (!paste_notice_.isEmpty()) status = paste_notice_ + QStringLiteral(" | ") + status; painter.drawText(QRect(margin, height() - margin * 2 - cell_height_, width() - margin * 2, cell_height_ + margin), Qt::AlignLeft | Qt::AlignVCenter | Qt::TextSingleLine, status); @@ -823,6 +1002,7 @@ class TerminalWidget final : public QWidget { resize(sizeHint()); grid_ready_ = true; clipboard_ = std::make_unique(*this); + clipboard_reader_ = std::make_unique(); history_scrollbar_ = new QScrollBar(Qt::Vertical, this); history_scrollbar_->setObjectName(QStringLiteral("historyScrollbar")); scrollbar_extent_ = style()->pixelMetric(QStyle::PM_ScrollBarExtent); @@ -839,7 +1019,10 @@ class TerminalWidget final : public QWidget { build_find_panel(); model_.on_changed = [this] { refresh_session(); }; } - ~TerminalWidget() override { model_.on_changed = {}; } + ~TerminalWidget() override { + model_.on_changed = {}; + if (paste_read_thread_.joinable()) paste_read_thread_.join(); + } void refresh_session() { if (!model_.running()) keyboard_.reset(); const TextDocument document(model_.screen()); @@ -880,6 +1063,10 @@ class TerminalWidget final : public QWidget { return selection_.copy(TextDocument(model_.screen()), maximum_bytes); } ClipboardWrite copy_to_clipboard() { return copy_selection_to_clipboard(); } + void initiate_paste_for_test() { initiate_paste(); } + void setClipboardReaderForTest(std::unique_ptr reader) { clipboard_reader_ = std::move(reader); } + void setPasteDialogAutoConfirmForTest(std::optional value) { paste_dialog_auto_confirm_ = value; } + QString paste_notice() const { return paste_notice_; } void open_find_for_test() { open_find(); } void close_find_for_test() { close_find(); } void set_find_query_for_test(const QString& text) { if (find_query_) find_query_->setText(text); } diff --git a/src/qt/unicode_input_test.cpp b/src/qt/unicode_input_test.cpp index 1f1a077..f5bf07e 100644 --- a/src/qt/unicode_input_test.cpp +++ b/src/qt/unicode_input_test.cpp @@ -178,9 +178,14 @@ static QJsonObject navigation_keys(const QString& receiver, const QString& direc QTest::keyClick(&terminal, static_cast(key)); // Modified navigation belongs to separate terminal/UI contracts. In // particular, Shift+Insert must not become an Insert byte sequence here. + // Shift+Insert now belongs to the implemented paste entry, not to the + // terminal key table. Disable the system reader so this local test never + // touches a personal clipboard; the shortcut must be accepted without + // becoming an Insert byte sequence. + terminal.setClipboardReaderForTest(nullptr); QKeyEvent paste_shortcut(QEvent::KeyPress, Qt::Key_Insert, Qt::ShiftModifier); paste_shortcut.setAccepted(false); QApplication::sendEvent(&terminal, &paste_shortcut); - require(!paste_shortcut.isAccepted(), "unimplemented paste shortcut became terminal input"); + require(paste_shortcut.isAccepted(), "paste shortcut fell through as terminal input"); // Navigation ends a pending UTF-16 unit without coupling the next character // to it. Verify at the independent receiver, not through the encoder itself. require(text_event(terminal, QString(QChar(0xd83d))), "navigation pending high was rejected");