diff --git a/.github/workflows/qt-cpp.yml b/.github/workflows/qt-cpp.yml index 8dd5c075..204a4772 100644 --- a/.github/workflows/qt-cpp.yml +++ b/.github/workflows/qt-cpp.yml @@ -69,7 +69,7 @@ jobs: build/qt-cpp/qt-unicode-bin/Release/runtime-manifest.json build/qt-cpp/qt-modes-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_terminal_modes)$' --no-tests=error --verbose + run: ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_terminal_modes(_retained)?)$' --no-tests=error --verbose # 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/CMakeLists.txt b/CMakeLists.txt index 4bdce628..b17e9b37 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -191,6 +191,10 @@ add_executable(resize_reflow_addressing_test experiments/core/resize_reflow_addr target_link_libraries(resize_reflow_addressing_test PRIVATE termforge_core) termforge_warnings(resize_reflow_addressing_test) +add_executable(display_cursor_test experiments/core/display_cursor_test.cpp) +target_link_libraries(display_cursor_test PRIVATE termforge_core) +termforge_warnings(display_cursor_test) + add_executable(resize_edit_test experiments/core/resize_edit_test.cpp) target_link_libraries(resize_edit_test PRIVATE termforge_core) termforge_warnings(resize_edit_test) @@ -451,6 +455,8 @@ add_test(NAME retained_columns_core COMMAND retained_columns_test) add_test(NAME retained_resize_address_core COMMAND resize_address_test --retained-columns) add_test(NAME reflow_addressing_core COMMAND resize_reflow_addressing_test) set_tests_properties(reflow_addressing_core PROPERTIES TIMEOUT 10) +add_test(NAME display_cursor_core COMMAND display_cursor_test) +set_tests_properties(display_cursor_core PROPERTIES TIMEOUT 10) add_test(NAME retained_resize_edit_core COMMAND resize_edit_test) add_test(NAME retained_resize_pending_core COMMAND resize_pending_test) add_test(NAME retained_resize_hidden_core COMMAND resize_hidden_test) @@ -722,6 +728,10 @@ if(TERMFORGE_BUILD_QT_SHELL) "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$") set_tests_properties(qt_terminal_modes PROPERTIES TIMEOUT 35 ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + add_test(NAME qt_terminal_modes_retained COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$" --retained-contract) + set_tests_properties(qt_terminal_modes_retained PROPERTIES TIMEOUT 35 + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") if(TERMFORGE_ENABLE_RESIZE_REPRO) add_test(NAME qt_terminal_modes_resize_repro COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$" --resize) diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index 687cbb03..3602628f 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -17,6 +17,7 @@ | 固定 ConPTY resize 与输出机制 | [1.25.260930003 包身份及 pinned 源码核查](research/conpty-resize-output-2026-11-02.md) | 当前 0x18 是字宽,旧 resize quirk 已删除;signal 成功非完成屏障,内部 reflow 不保证重发整屏,DSR 光标同步不证明正文一致。静态解释不替代同次 Qt 动态证据或原版 oracle;本次未新增运行或改 flags | | 原版 resize 后寻址与自主核心字面诊断 | [独立直接协议样本](../research/ui/results/2026-11-02-resize-address-reference/README.md)、[默认裁剪路径的失败诊断](../experiments/core/results/2026-11-02-resize-address/README.md) | 四例自然追加、CR、LF、CUP 保留动作前观测,文档、DSR、显示坐标分别核对;第五例 EL1 保留原审计失败。默认 `Screen::resize` 历史诊断仅 16/64 阶段通过、退出 1;当时 `EXCLUDE_FROM_ALL`,现参与默认构建但 CTest 只注册另行验证的显式保留入口。旧红不设 `WILL_FAIL`,常规 CI 或另一入口通过不能豁免默认路径差异 | | 缩窄后跨前方显示重排的 CUP/DSR 寻址(#27/#29 显式入口) | [单例原版四阶段严格记录](../research/ui/results/2026-11-03-reflow-addressing-reference/README.md)、[核心字面回放与身份](../experiments/core/results/2026-11-03-reflow-addressing/README.md):`CSI 8;2HQ` 写入文档行 6(保留的缩放前源片段行)而非重排行 5,DSR `8;3`、caret 同口径;窄屏矩形 (155,144) 恢复 (155,126) 证实显示页独立 +1 行重排 | 单例固定 ASCII、`91×20→61×20→91×20`、一个动作、最大一次运行;只补既有样本无法区分的行映射,不含备用屏、高度、Unicode、历史、样式、Qt/固定 ConPTY 或性能;默认 `Screen::resize`/Qt 未切换,#27/#29 不关闭 | +| 正式 Qt / 固定 ConPTY 接入保留事务、显示投影与显示光标 | [契约、身份与差异](../experiments/shell/results/2026-11-03-qt-retained-display/README.md):可达真实 child→固定 ConPTY→core→widget;新 `qt_terminal_modes_retained` 12/12 检查通过,只缩宽后显示页第 8 行与原生重排逐字节一致,B/C/D 保留源位,命中经 layout revision 回到源格;Qt 正式路径 CTest 5/5,核心 36/36(含 `display_cursor_core`) | 旧六阶段 `--resize` 仍 exit 1 且失败信息不变,保留为原生重排网格与保留行合同的坐标差;同时缩宽+缩高的原版视口/滚动与后续 CUP 仍缺独立证据;不覆盖真实呈现/DPI/历史/Find/性能,#27/#29 不关闭 | | 自主 C++ 同 owner 保留列宽事务与有限编辑(#27/#29 独立入口) | [源码、定向红绿与限制](../experiments/core/results/2026-11-02-retained-columns/README.md):`a35be70` 的四个寻址 case 为 64/64 阶段;`fdeaed6` 使打印/ICH/DCH 按协议宽更新目标行,内容长度与 `written` 分离,三例编辑为 64/64 阶段。该固定源码重新构建后核心 22/22、Qt/固定 ConPTY 4/4,各配置身份独立记录 | 默认 resize / Qt 未切换;Qt 回归不执行新保留入口。Q/R 与 ICH/DCH 红绿分别保留,owner 存储及空白长度工程检查没有独立先行 RED。后续光标与隐藏帧修正另列,不继承本轮结果;默认裁剪与 B/CD 失败不豁免 | | 原版活动/隐藏帧与缩放后下一字符 | [6次原版、29阶段](../research/ui/results/2026-11-02-resize-frame-reference/README.md):5次原审计通过、1次隐藏主屏原审计失败后独立离线补充通过;无补跑。文档、DSR与显示分别记录 | 活动备用屏保留正文;隐藏主屏返回的正文和边界不同;4个ON/OFF缩放末列样本支持下一字符覆盖当前位置。原版Document索引差异、私有模式位及完整网格不据此判定 | | 自主 C++ 缩放后的末列输入与隐藏主屏 | [末列输入红绿](../experiments/core/results/2026-11-02-resize-pending/README.md):`9b67886` 的4例44/72→72/72;[隐藏主屏红绿](../experiments/core/results/2026-11-02-resize-hidden/README.md):`afdcf59` 的12/32→32/32,精确比较正文空白长度、源位置、显示与DSR | 定向实现均只走显式保留入口;默认 resize / Qt 尚未接入。隐藏主屏样本不能推出活动帧 margin、单纯隐藏增宽或完整 Unicode 语义;各报告区分固定生产源码、执行后身份与缺失的历史二进制 | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index a58287e3..89d803da 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保留 | 分屏、其他 shell、工作区恢复、完整 resize/reflow、历史浏览/自动滚动、选词/选行与系统复制/粘贴、Find、完整 Unicode 字素/绘制、候选窗与真实 IME 交叉(已有固定拼音;[固定时间点对照](research/qt-ime-candidate-panel-reference-2026-11-01.md)中输出后两侧都保持旧位置、resize 后延后点都更新;字体阶段原版未触发,面板身份未证明,且对照 Qt 源为历史 5fab30b)、真实 DPI、应用兼容与性能仍需实现或迁移;物理裁剪不恢复丢弃正文,完整 WindTerm 标签/尺寸行为对照未完成;当前不是可发布开发包 | R2 | +| 自主 C++ / Qt / 固定微软 ConPTY | **实现中,未验收**:[显式 Qt 入口](../src/qt/README.md)提供默认 80×24、随可用区域变化的 cmd 标签、基本 ASCII 与[限定 Unicode 文本字节](../experiments/shell/results/2026-10-29-qt-unicode-input/README.md)、16 色及各会话独立有界队列、[输入法组合/提交/取消(事件级 + 固定拼音验证)](../research/ui/results/2026-10-30-qt-ime-fixed-pinyin/README.md),并接入[无修饰导航键与基础鼠标正反拖选高亮](../experiments/shell/results/2026-11-01-r2-core-input/README.md);[基础动态尺寸验证](../experiments/shell/results/2026-10-29-qt-resize/README.md)分离窗口/核心/transport 与真实 child,覆盖极小、重复、隐藏页和关闭交叉,并复验原标签算术及事件循环回收。678 原生极窄失败保留;0ff 仅修订字面子区域/顶层实际区域夹具,另有[冻结包一次隔离原生成功](../experiments/shell/results/2026-10-29-qt-resize/native-validation.json):实际顶层区域、core/transport/child 一致,窗口仅 DPI 96。旧尺寸结果保留各自源码;[Unicode 联合源码的一次完整61项](../experiments/shell/results/2026-10-29-qt-unicode-input/validation.json)分开 Qt3项与其余58项既有/独立诊断,13条内部skip保留。正式尺寸事务已改走显式保留入口并绘制独立显示投影/显示光标:[只缩宽的保留行契约](../experiments/shell/results/2026-11-03-qt-retained-display/README.md)在真实 child/固定 ConPTY 核对 12/12 检查,Qt 正式路径 CTest 5/5;旧六阶段 native-sync 失败保留并归类为原生重排网格与保留行合同的坐标差 | 分屏、其他 shell、工作区恢复、同时缩宽+缩高的原版视口/滚动与后续 CUP 证据、完整 reflow、历史浏览/自动滚动、选词/选行与系统复制/粘贴、Find、完整 Unicode 字素/绘制、候选窗与真实 IME 交叉(已有固定拼音;[固定时间点对照](research/qt-ime-candidate-panel-reference-2026-11-01.md)中输出后两侧都保持旧位置、resize 后延后点都更新;字体阶段原版未触发,面板身份未证明,且对照 Qt 源为历史 5fab30b)、真实 DPI、应用兼容与性能仍需实现或迁移;完整 WindTerm 标签/尺寸行为对照未完成;当前不是可发布开发包 | R2 | 自主 C++ 核心已具备双缓冲与限定 `?1049`、DECAWM、DECOM、DECCKM/DECTCEM、每屏 DECSTBM 边距、字符/行插删、ED3/RIS、DSR 6n 和部分 SGR 状态。依据分别见 [1049](../experiments/core/results/2026-10-29-cpp-1049/README.md)、[DECAWM](../experiments/core/results/2026-10-29-cpp-decawm/README.md)及[支持表](../experiments/core/README.md)。[原版每屏 origin 对照](../research/ui/results/2026-11-02-mode-state-reference/README.md)确认备用屏进入时关闭 origin、主屏返回恢复自身状态及固定样本的 margin 下界;核心已改为[每屏保存 origin](../experiments/core/results/2026-11-02-origin-frame/README.md)。区域 LF、IL/DL 后的精确行序,以及[区域自动换行续接、RIS/ED3 历史结构发布](../experiments/core/results/2026-11-02-r27-mode-cross/README.md)已有修正。完整网格/样式、resize 和运输交叉仍由 #27/#29 验收,不能据此声明完整 TUI 通过。 diff --git a/experiments/core/README.md b/experiments/core/README.md index b6529a44..39e25403 100644 --- a/experiments/core/README.md +++ b/experiments/core/README.md @@ -90,10 +90,12 @@ build/bin/Release/receive.exe 512 edge stream 580000 512 input.bin ## 显示投影续接 -显式 `HistoryViewport::begin_projection` / `step_projection` 按每步至多200个源片段生成指定宽度的页面;`ProjectionJob` 独占未完成半行和页面,完成前不发布。源被修改、导入或换屏、真实尺寸变化、新请求及取消会使任务失效;尺寸no-op不打断。它与默认物理`resize`/`snapshot`仍分开,当前Qt未消费这个入口。 +显式 `HistoryViewport::begin_projection` / `step_projection` 按每步至多200个源片段生成指定宽度的页面;`ProjectionJob` 独占未完成半行和页面,完成前不发布。源被修改、导入或换屏、真实尺寸变化、新请求及取消会使任务失效;尺寸no-op不打断。它与默认物理`resize`/`snapshot`仍分开;正式 Qt 目前消费单次 `project` 绘制显示页,分步入口仍未被 Qt 使用。 返回cursor是绑定owner/行身份/version/epoch/order的源锚点,可以定义另一宽度下的局部起点,不代表全局显示行号。静止500片段的分步进展和选择/Find交叉见[固定验证](results/2026-11-02-projection-job/README.md);持续输出下的调度与进展、任意显示位置定位仍未交付。旧单次`project`保留有界窗口语义,部分软行在`source_limit`可能不推进,跨预算任务应使用新分步入口。 +`display_cursor`(`display_cursor.hpp`)把协议光标这一保留源位置翻译为指定投影页上的显示单元,不移动源光标、不创造源文字;`ViewMapping` 暴露 `columns()`/`sources()`/`layout_revision()` 供消费者校验页面。宽度重排、软续接片段、空硬行、尾部 padding 与宽字后的光标位置由 `display_cursor_core` 锁定;Qt 侧光标/IME 与选择命中消费同一页布局 revision,见 [Qt 保留/投影结果](../shell/results/2026-11-03-qt-retained-display/README.md)。 + ## 文字投影与选择 `text_document.hpp` 提供同线程、短生命周期的只读适配;返回行文本与跨度均按值持有,以行身份/版本和物理半开列范围表达位置,不向消费方暴露可变行引用。`text_selection.hpp` 为每个会话独立持有选区,合并真实软折行、以 CRLF 复制硬行边界并保留显式空格;不拆开宽字和有限组合标记。 diff --git a/experiments/core/display_cursor.hpp b/experiments/core/display_cursor.hpp new file mode 100644 index 00000000..60a2f5d3 --- /dev/null +++ b/experiments/core/display_cursor.hpp @@ -0,0 +1,85 @@ +// Source-to-display caret mapping for one projected page. +// +// The protocol cursor is a retained source position (fragment row and column). +// A display page reflows complete source atoms at another width, so the caret +// cell is a different coordinate. This header owns only that conversion; it +// never moves the protocol cursor and never invents source content. +#pragma once +#include "history_view.hpp" +#include + +namespace termforge { + +struct DisplayCursor { + // The page covered the cursor's source row and produced a display cell. + bool inside = false; + // Display cell: zero-based row within the page and column within its width. + size_t row = 0, column = 0; +}; + +inline std::optional display_cursor(const Screen& screen, const ViewSnapshot& page) { + if (screen.row < 0 || screen.row >= screen.height()) return std::nullopt; + const size_t retained = screen.history_lines().size() + static_cast(screen.row); + if (retained >= screen.retained_size()) return std::nullopt; + const auto& line = screen.retained_line(retained); + const auto& spans = page.mapping.sources(); + const size_t columns = page.mapping.columns(); + if (!columns) return std::nullopt; + size_t column = static_cast((std::max)(screen.column, 0)); + column = (std::min)(column, line.size()); + const ViewSourceSpan* first = nullptr; + const ViewSourceSpan* last = nullptr; + for (const auto& span : spans) { + if (span.source.begin.line_id != line.id || span.source.begin.version != line.version) continue; + if (!first) first = &span; + last = &span; + } + if (!first) return std::nullopt; + for (const auto& span : spans) { + if (span.source.begin.line_id != line.id || span.source.begin.version != line.version) continue; + if (span.source.begin.column > column) continue; + if (column < span.source.end.column) { + DisplayCursor result; + result.inside = true; + result.row = span.row; + const auto source_width = span.source.end.column - span.source.begin.column; + const auto display_width = span.column_end - span.column_begin; + // A wide or otherwise indivisible display atom keeps its leading + // edge; one-to-one cells preserve the column offset. + result.column = display_width == source_width + ? span.column_begin + (column - span.source.begin.column) + : span.column_begin; + return result; + } + if (column == span.source.end.column) { + // The caret sits immediately after this atom: inside the span when + // another span continues on the same display row, otherwise at its + // trailing edge. A trailing edge at the display row width continues + // on the next display row without claiming a source identity there. + DisplayCursor result; + result.inside = true; + result.row = span.row; + result.column = span.column_end; + if (result.column >= columns) { + result.row += result.column / columns; + result.column %= columns; + } + return result; + } + } + // Cursor in untouched trailing padding: the display keeps the same cell + // offsets after the last published atom on that fragment's final row. + DisplayCursor result; + result.inside = true; + result.row = last->row; + result.column = last->column_end + (column - last->source.end.column); + if (result.column >= columns) { + // Synthetic padding ends at the display row edge; the caret continues + // on the next display row without claiming a source identity there. + result.row += result.column / columns; + result.column %= columns; + } + return result; +} + +} // namespace termforge diff --git a/experiments/core/display_cursor_test.cpp b/experiments/core/display_cursor_test.cpp new file mode 100644 index 00000000..956c4a0f --- /dev/null +++ b/experiments/core/display_cursor_test.cpp @@ -0,0 +1,111 @@ +// Literal display-caret mapping for the frozen post-reflow sample. +// +// The protocol cursor stays a retained source position (see +// research/ui/results/2026-11-03-reflow-addressing-reference). The display page +// reflows the same atoms at another width, and the caret cell must follow that +// page without moving the protocol cursor or inventing source content. +#include "terminal.hpp" +#include "history_view.hpp" +#include "display_cursor.hpp" +#include +#include +#include +#include + +using namespace termforge; + +struct Checks { + std::string context; + size_t failures = 0, assertions = 0; + + void expect(bool condition, const std::string& message) { + ++assertions; + if (condition) return; + ++failures; + std::cerr << context << ": " << message << '\n'; + } +}; + +static std::string initial_body() { + std::string body = "\x1b[?6l\x1b[?7h\x1b[r\x1b[0m\x1b[2J"; + body += "\x1b[2;1H0123456789"; + body += "\x1b[3;1HANCHOR"; + body += "\x1b[5;1H" + std::string(122, 'r') + "v"; + body += "\x1b[7;1H5\x1b[8;1H6\x1b[9;1H7\x1b[10;1H8\x1b[11;1H9\x1b[12;1H"; + return body; +} + +static ProjectionPage project_page(Screen& screen, HistoryViewport& viewport, size_t columns, size_t rows) { + return viewport.project(screen, columns, rows, {0, 0}, screen.retained_size()); +} + +int main() { + Checks checks; + Parser parser(20, 91, 0, ParseMode::strict); + HistoryViewport viewport; + parser.feed(initial_body()); + const auto cursor_at = [&](size_t columns, size_t row, size_t column, const char* note) { + const auto page = project_page(parser.screen, viewport, columns, 20); + const auto mapped = display_cursor(parser.screen, page.snapshot); + const std::string actual = mapped + ? std::to_string(mapped->row) + "," + std::to_string(mapped->column) : "none"; + checks.expect(mapped.has_value() && mapped->row == row && mapped->column == column, + std::string("display caret differs at ") + note + " expected " + std::to_string(row) + "," + + std::to_string(column) + " actual " + actual); + }; + + checks.context = "wide"; + cursor_at(91, 11, 0, "initial bottom row"); + cursor_at(91, 11, 0, "repeated projection is stable"); + + checks.context = "narrow"; + parser.screen.resize_columns_preserving(61); + cursor_at(61, 12, 0, "earlier reflow shifts the caret row"); + + checks.context = "narrow-action"; + parser.feed("\x1b[8;2HQ"); + cursor_at(61, 8, 2, "CUP write keeps source row while display reflows"); + + checks.context = "narrow-continuation-fragment"; + parser.feed("\x1b[6;2H"); + cursor_at(61, 5, 31, "cursor on a soft-continuation fragment"); + + checks.context = "narrow-padding"; + parser.feed("\x1b[10;6H"); + cursor_at(61, 10, 5, "caret in trailing padding keeps the column offset"); + + checks.context = "narrow-empty-line"; + parser.feed("\x1b[12;5H"); + cursor_at(61, 12, 4, "caret on an empty hard row keeps the column offset"); + + checks.context = "restore"; + parser.screen.resize_columns_preserving(91); + cursor_at(91, 11, 4, "restored width re-projects the same source row"); + + checks.context = "continued-output"; + parser.feed("Z"); + cursor_at(91, 11, 5, "continued output advances the display caret"); + + // A bounded page that ends before the cursor's source row cannot show it. + checks.context = "truncated-page"; + const auto short_page = project_page(parser.screen, viewport, 91, 2); + checks.expect(!display_cursor(parser.screen, short_page.snapshot).has_value(), + "a page that ends above the source cursor must not invent a caret"); + + // Wide atom: the caret after a two-cell character is the cell after it. + { + Parser wide(4, 20, 0, ParseMode::strict); + HistoryViewport wide_viewport; + wide.feed("\xe4\xb8\xad"); + wide.feed("\x1b[1;3H"); + const auto page = project_page(wide.screen, wide_viewport, 20, 4); + const auto mapped = display_cursor(wide.screen, page.snapshot); + checks.context = "wide-atom"; + checks.expect(mapped.has_value() && mapped->row == 0 && mapped->column == 2, + "caret after a wide atom differs"); + } + + std::cout << "display cursor: " << checks.failures << " failed assertions of " << checks.assertions << '\n'; + return checks.failures ? 1 : 0; +} + diff --git a/experiments/core/view_mapping.hpp b/experiments/core/view_mapping.hpp index d2a787e3..fc79373a 100644 --- a/experiments/core/view_mapping.hpp +++ b/experiments/core/view_mapping.hpp @@ -36,6 +36,10 @@ class ViewMapping { ViewMapping(size_t columns, std::vector sources, uint64_t layout_revision = 0) : columns_(columns), sources_(std::move(sources)), layout_revision_(layout_revision) {} + size_t columns() const { return columns_; } + const std::vector& sources() const { return sources_; } + uint64_t layout_revision() const { return layout_revision_; } + std::optional hit(const TextDocument& document, size_t row, size_t column, ViewAffinity affinity, uint64_t current_layout_revision = 0) const { if (layout_revision_ != current_layout_revision) return std::nullopt; diff --git a/experiments/shell/results/2026-11-03-qt-retained-display/README.md b/experiments/shell/results/2026-11-03-qt-retained-display/README.md new file mode 100644 index 00000000..76d636a0 --- /dev/null +++ b/experiments/shell/results/2026-11-03-qt-retained-display/README.md @@ -0,0 +1,62 @@ +# 正式 Qt / 固定 ConPTY:保留事务 + 独立显示投影 + 显示光标 + +本切片把 #27/#29 的核心保留入口接入**正式 Qt 路径**:`SessionModel::resize` 改用 `Screen::resize_preserving`,`TerminalWidget` 按当前宽度绘制独立显示投影页,并把协议光标翻译为显示光标(IME 候选位置、光标下划线、预编辑预览同源);已有选择/命中继续消费同一份 `ViewMapping`,使用页面布局 revision 拒绝过期页。核心与协议坐标不被显示层修改。 + +原六阶段 B/CD 失败判据保持:显式 `--resize` 诊断仍以 exit 1 失败,失败信息不变。新证据说明该断言比较的是**原生 ConPTY 重排网格**,而 #27/#29 已确认原版 CUP/DSR/光标寻址跟随保留的缩放前行;两者在缩窄+缩高后不是同一坐标层。内容不再被物理裁剪(B/C/D 保留在保留帧/历史),剩余差异是原生网格与保留行合同的坐标差,需要同时缩宽+缩高后的原版对视口/滚动与后续 CUP 的独立观察才能定案;本切片不据此改写旧失败。 + +## 配置与身份 + +- Windows 11 x64、Visual Studio 17 2022 17.12.6、MSVC 19.42.34433.0、Qt 6.10.3 offscreen、随包固定微软 ConPTY 1.25.260930003、app-local CRT 14.51.36247.0;固定后端身份守卫、自有 Job、25 s 监督期限不变。 +- 基线 `7fea912beb8e23cb8e1ef783970475d111dab0ed`(含 #308/#309 的 main)。依赖准备摘要 `local/qt-build-preparation.json`:Qt archive `4db84dee…`,输入清单 qt `2796294f…`、conpty `e75e522a…`、msvc_runtime `154176c8…`;本报告不发布依赖包本体。 +- 实际运行产物与源码 SHA-256 见 [validation.json](validation.json);以下是本工作区定向构建的结果,不是完整工作树冻结清单。 + +## 结果 + +| 入口 | 实际结果 | 口径 | +|---|---|---| +| 新 CTest `qt_terminal_modes_retained`(`--retained-contract`) | **Passed,12/12 契约检查,0 失败**;child 自然退出 37、Job `active0/total4/terminated0` | 真实 child → 固定 ConPTY → SessionModel/TerminalWidget;公开 Qt resize 到 60×24(只缩宽)后由 child 写 `RESIZED` 与 `S6` | +| Qt 正式路径回归 `qt_cpp_shell` / `qt_unicode_input` / `qt_cpp_resize` / `qt_terminal_modes` / `qt_terminal_modes_retained` | **5/5 通过,9.31 s** | 同一 offscreen 配置;`qt_cpp_resize` 覆盖窗口/核心/transport/真实 child 尺寸与隐藏标签 | +| 核心 CTest(含新 `display_cursor_core`) | **36/36 通过,4.51 s** | 新增显示光标契约测试;原 35 项未改 | +| 旧六阶段诊断 `--resize`(显式、不进 CI) | **exit 1,失败保留**:`stage 6 failed: core resize is not synchronized with the literal native B/CD rows` | 与历史失败同一断言;未弱化、未设 `WILL_FAIL`、未用作通过 | + +新入口的可观察事实(来自 `termforge-qt-retained-contract-v1` 记录与 stage 记录): + +- 核心在 60×24 缩宽后保留旧源行:第 7 行第 79 列 `B`、第 9 行第 79 列 `C`、第 10 行第 0 列 `D` 仍在 `visible_lines()`;`history` 为空(只缩宽不裁剪)。 +- 协议光标仍为源坐标 `[0,2]`;显示光标为 `[0,2]`(该位置在当前重排下同格)。 +- 显示页按 60 列重排:第 8 行 = 19 空格 + `B`(与原生第 8 行逐字节一致);第 11 行 = 19 空格 + `CD`;第 12 行 = `RESIZED`。 +- 命中测试:显示页 `(8,19)` 经 `ViewMapping::hit(..., layout_revision)` 返回保留源行 `B` 的 `(line id, column 79)`,即选择/命中与重排后的显示一致。 +- 原生记录分开保留:原生第 8 行同为 19 空格 + `B`;原生第 11 行为 `RESIZED` + 12 空格 + `CD`,而产品按保留行合同把 `RESIZED` 画在显示第 12 行。差别来自 child 的 CUP 指向其**自身重排网格**,不是产品裁剪。 + +## 与旧六阶段失败的关系 + +旧断言把“原生重排网格的 B/CD 行”当作产品应同步的目标。加入 #308(宽高组合保留)与 #309(缩窄后 CUP/DSR 跟随保留源行)后,这一目标与已确认的原版坐标层冲突:原生 ConPTY 在 60×12 后把旧正文压缩进可见 12 行,并按新网格解释后续 CUP;产品保留源行、由显示投影重排,`CUP 12;1` 落在保留第 11 行(显示第 11/12 行取决于高度策略)。本次同一次运行因此得到: + +- 旧 `--resize`:仍 exit 1(`core resize is not synchronized with the literal native B/CD rows`),旧失败身份保留; +- 内容保留:宽度+高度收缩后 B/C/D 在保留帧/历史中可查,不再因 `line.resize` 丢字; +- 显示链:只缩宽时产品显示与原生重排的 B 行逐字节一致,`CD` 行同样重排,`RESIZED` 的差异可归因于 CUP 坐标层与高度策略。 + +**下一步的真实前置**(未在本切片完成):固定原版 WindTerm 2.7.0 在**同时缩宽+缩高**后,视口/滚动如何选择、后续 CUP/DSR 与显示如何对应;有了该证据才能决定六阶段是否应改用保留行合同断言,或把原生网格差异写入已知差异清单。该前置不能靠重跑旧断言或弱化断言取得。 + +## 复现命令 + +```powershell +cmake -S . -B build/qt-cpp -G "Visual Studio 17 2022" -A x64 ` + -DTERMFORGE_BUILD_QT_SHELL=ON -DBUILD_TESTING=ON -DTERMFORGE_BUILD_CANDIDATE_ADAPTER=OFF ` + -DQt6_DIR="/qt/lib/cmake/Qt6" ` + -DTERMFORGE_CONPTY_BACKEND_DIR="/conpty" ` + -DTERMFORGE_MSVC_RUNTIME_DIR="/crt" +cmake --build build/qt-cpp --config Release --target qt_terminal_modes_test qt_cpp_resize_smoke qt_cpp_shell_smoke qt_unicode_input_test --parallel 3 +ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_terminal_modes(_retained)?)$' --output-on-failure +# 旧失败保留(显式,不注册 CI) +python src/qt/run_smoke.py build/qt-cpp/qt-modes-bin/Release/qt_terminal_modes_test.exe --resize +# 新契约记录 +python src/qt/run_smoke.py build/qt-cpp/qt-modes-bin/Release/qt_terminal_modes_test.exe --retained-contract +``` + +## 边界 + +- 单显示器、offscreen、固定 80×24→60×24 缩宽与 60×12 旧诊断;不覆盖真实物理呈现、跨 DPI、历史浏览滚动、系统剪贴板/Find、完整 reflow、备用屏、宽字/结合字在 resize 后的完整交叉或性能。 +- 显示投影对不支持的保留 Cell 与无法映射的光标回退到物理视图页(有界、不隐藏输出);回退路径未做单独原版对照。 +- 本切片不关闭 #27/#29,不声明 WindTerm 等价、性能等价比或 R2 完成;合并由用户/调度者决定。 + + diff --git a/experiments/shell/results/2026-11-03-qt-retained-display/validation.json b/experiments/shell/results/2026-11-03-qt-retained-display/validation.json new file mode 100644 index 00000000..1004ac71 --- /dev/null +++ b/experiments/shell/results/2026-11-03-qt-retained-display/validation.json @@ -0,0 +1,140 @@ +{ + "schema": "termforge-qt-retained-display-validation-v1", + "date": "2026-11-03", + "status": "formal-qt-retained-transaction-and-display-projection-verified", + "main_base_commit": "7fea912beb8e23cb8e1ef783970475d111dab0ed", + "r27_complete": false, + "r29_complete": false, + "old_six_stage_failure_preserved": true, + "native_grid_used_as_addressing_oracle": false, + "hashes_collected_after_runs": true, + "logs_sha256": { + "qt-ctest.log": "1d9a1bf97a71a94f47e79d83c6b5c2b98d6b7fc68fef9f0a71b86e0517809111", + "qt-retained-contract.log": "d93c63db43fa6ead7d772dda9db48af10525a15a43f142951b1f3c13ca7821c5", + "qt-resize-repro.log": "09568c0e89d10f63210030b5d4be70551babd1ab31737597e49dd3e36de9a331", + "core-ctest.log": "694e1b411b95ec74b052d7e4c634757fd5fdf5b068b2d4b8805ba23d39c1400c" + }, + "config": { + "platform": "Windows 11 x64", + "generator": "Visual Studio 17 2022 17.12.6", + "compiler": "MSVC 19.42.34433.0", + "windows_sdk": "10.0.26100.0", + "configuration": "Release", + "qt": "6.10.3 offscreen", + "conpty": "bundled 1.25.260930003", + "msvc_runtime": "14.51.36247.0 app-local", + "candidate_adapter": false + }, + "dependency_preparation": { + "summary_sha256": "187428835c2d200a8b5a94ee341b6a0476bae19a13f8442caad3347cf1a12215", + "qt_archive_sha256": "4db84dee7fe3c558f242bef0a88852613af76580dc6d2b24596479f47004dad7", + "input_manifests": { + "qt": "2796294f254f7fe0370be99d2de8f7745734c7352ac2648b1f8d9a8fab5ad2cd", + "conpty": "e75e522a3355c0a66c6d60cafa02413a83d8757262747f650b77c959c09ed439", + "msvc_runtime": "154176c8b4ae6dd57fce453e2c2002ff484344b39451fa9880b07db4794f3758" + } + }, + "source_files_sha256": { + "CMakeLists.txt": "b3e699147504eaeb02113ec234d622e40ed6fd8d742e2cdf404008d40ba77501", + "experiments/core/view_mapping.hpp": "6e410336c66df0ecc1cba4e539cec4db69ebd475f31c4e7882f1e3d2afd5e186", + "experiments/core/display_cursor.hpp": "99cae039ff02c86dda6ce0e3ac2cc669addba8915de0ea759843edb3a995968e", + "experiments/core/display_cursor_test.cpp": "aa77bfbfe8775e0fda09dd72d16e0c2c5e7a7211a94a5063786e86e6b6efef86", + "src/qt/terminal_session.hpp": "52ef8b763b5cbfcf0727eaf090f618cb07a0f5dde19d187ab6e8deed14016dae", + "src/qt/terminal_widget.hpp": "bbcc23aa9fcc8cf1a6f40606bfa7d4eac44a476a609c5381ae5814b338ba136f", + "src/qt/terminal_modes_test.cpp": "26204e019327fe1825b594219dd819cc348b8a63be8f6d23e248e5f553504d0a", + "src/qt/run_smoke.py": "467387975311879ba94c4329410f7cddd4aaa418143935c498d0e08eca8c1e58", + ".github/workflows/qt-cpp.yml": "38f0cd33ed06c54b168361e55fce3d79e328f606d8a2a646116f031f900b33e3" + }, + "binary_sha256": { + "qt_terminal_modes_test.exe": "3d4bf581d08fe17c3008528896ab612423fe2c69446a0256ad92dd920a76cf6e", + "qt_cpp_resize_smoke.exe": "dbf3753bab0a378662738de86914e5454933604b0b8826d18d17ea8a6c79fbcb", + "qt_cpp_shell_smoke.exe": "7586235af3e9cfca1e40a5359ea3e25723d76e10c9f72cf72b6820a5dce978c5", + "qt_unicode_input_test.exe": "e9b86ba450cb68e1ad0d8d5b2fc78ba51efb02f9fcbef85f1deb4e25503d1b91", + "display_cursor_test.exe": "b1a9edffaea1ec270d107854e16c334ae8b70223265d2f913bbecf69702fed3d" + }, + "runs": { + "qt_formal_path_ctest": { + "command": "ctest -C Release -R ^(qt_cpp_(shell|resize)|qt_unicode_input|qt_terminal_modes(_retained)?)$", + "passed": 5, + "failed": 0, + "seconds": 9.31, + "tests": [ + "qt_cpp_shell", + "qt_unicode_input", + "qt_cpp_resize", + "qt_terminal_modes", + "qt_terminal_modes_retained" + ] + }, + "retained_contract": { + "command": "run_smoke.py qt_terminal_modes_test.exe --retained-contract", + "scenario": "modes-retained-contract", + "exit_code": 0, + "checks": 12, + "failures": 0, + "stages": 6, + "resize": "public Qt widget 80x24 -> 60x24 (width only)", + "child_exit": 37, + "cleanup_verified": true, + "job_after": { + "active": 0, + "total": 4, + "terminated": 0 + }, + "core_cursor": [ + 0, + 2 + ], + "display_caret": [ + 0, + 2 + ], + "native_row_8_hex": "2020202020202020202020202020202020202042", + "display_row_8_hex": "2020202020202020202020202020202020202042", + "native_row_11_hex": "524553495a45442020202020202020202020204344", + "display_row_12_hex": "524553495a4544" + }, + "preserved_six_stage_repro": { + "command": "run_smoke.py qt_terminal_modes_test.exe --resize", + "scenario": "modes-resize-repro", + "exit_code": 1, + "failure": "stage 6 failed: core resize is not synchronized with the literal native B/CD rows", + "job_after": { + "active": 0, + "total": 4, + "terminated": 0 + }, + "note": "Old assertion and failure message unchanged; not registered in CI and not marked WILL_FAIL." + }, + "core_ctest": { + "command": "ctest -C Release -R _core$", + "passed": 36, + "failed": 0, + "seconds": 4.51, + "includes": [ + "display_cursor_core", + "reflow_addressing_core" + ] + } + }, + "product_contract_facts": [ + "Screen::resize_preserving keeps pre-resize source rows: retained B(7,79), C(9,79), D(10,0) remain in visible_lines after the 60-column resize.", + "The protocol cursor stays a retained source position [0,2]; the widget translates it into the display caret [0,2] without moving the source cursor.", + "The widget paints the independent display page: row 8 = 19 spaces + B (byte-identical to the native reflowed row 8), row 11 = 19 spaces + CD, row 12 = RESIZED.", + "ViewMapping::hit with the page layout revision maps display (8,19) back to the retained B source cell (line id of source row 7, column 79).", + "Child/transport/core agree on 60x24 after the public widget resize; requested and transport revisions are equal and no request remains pending." + ], + "known_divergence": { + "native_row_11": "RESIZED + 12 spaces + CD (child CUP targets its own reflowed grid row 11)", + "product_display": "row 11 = 19 spaces + CD; row 12 = RESIZED (product CUP follows the retained source row contract)", + "classification": "coordinate-layer difference between native ConPTY reflow and the confirmed retained-row contract, not physical clipping", + "resolution_requires": "independent WindTerm 2.7.0 observation of simultaneous width+height resize: viewport/scroll selection and subsequent CUP/DSR/display correspondence" + }, + "limitations": [ + "One offscreen single-display configuration, fixed 80x24 -> 60x24 width-only contract and the preserved 60x12 repro; no physical presentation, cross-DPI, history-browsing UI, system clipboard/Find or performance.", + "Display projection falls back to the physical viewport page for unsupported retained cells or an unmappable cursor; the fallback path has no original comparison in this slice.", + "The official WindTerm program was not run in this slice; the retained-row addressing contract comes from the earlier bounded original sample and is not extended to simultaneous width+height resize here.", + "Neither #27 nor #29 is closed; no complete reflow, WindTerm equivalence or R2 acceptance is claimed." + ] +} + diff --git a/src/qt/README.md b/src/qt/README.md index ed7bb8cb..e69a362f 100644 --- a/src/qt/README.md +++ b/src/qt/README.md @@ -4,7 +4,9 @@ 现有 C++ 核心的[能力限制](../../experiments/core/README.md)继续有效。Qt 尚未迁移原 Win32 的完整工作区、分屏、历史浏览/自动滚动、选词/选行、系统复制/粘贴、Find 或会话恢复;完整键盘/IME 交叉、resize/reflow 仍需按目标调查和实现,也不能继承 Alacritty 分支的通过结果。普通方向键与无修饰 Home/End 可随 DECCKM 切换 CSI/SS3,无修饰 Insert/Delete/PageUp/PageDown 复用已有终端键表,另有 F1–F12 编码;当前 Ctrl 字母组合仅接入 Ctrl+C,修饰导航键不被当作普通导航发送,不能据此声明完整编辑器键盘或 bracketed paste。 -网格沿当前 Roboto Mono 字体度量和终端可用区域计算,保留八像素边距与一行状态区,并沿现有 Screen 资源上限限制为 1..500 列、1..200 行。字体变化重新计算度量;隐藏标签在显示/布局后同步。GUI 线程负责物理 Screen resize,现有生命周期 worker 用单个最新请求串行执行 ConPTY resize、wait、cancel 和 close;GUI 不等待 native resize。重复同尺寸不重新初始化屏幕或发起新尺寸操作;关闭、错误与退出停止接受并清除待处理请求,已选取的 native 操作由同一 worker 收尾,不重新激活会话。原物理裁剪不回填历史或恢复被裁掉的正文,目标 reflow 和 WindTerm 内容恢复策略未验收。 +网格沿当前 Roboto Mono 字体度量和终端可用区域计算,保留八像素边距与一行状态区,并沿现有 Screen 资源上限限制为 1..500 列、1..200 行。字体变化重新计算度量;隐藏标签在显示/布局后同步。GUI 线程负责核心尺寸事务,现有生命周期 worker 用单个最新请求串行执行 ConPTY resize、wait、cancel 和 close;GUI 不等待 native resize。重复同尺寸不重新初始化屏幕或发起新尺寸操作;关闭、错误与退出停止接受并清除待处理请求,已选取的 native 操作由同一 worker 收尾,不重新激活会话。 + +正式路径的尺寸事务改用 `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。同时缩宽+缩高的原版视口/滚动与后续 CUP 仍缺独立证据,完整 reflow 和历史/选择/Find 用户流程未验收。 终端可在无修饰、无组合文本时用左键正向或反向拖选,普通点击清除旧选区。每个 widget 独立持有 `TextSelection`,命中与高亮经 `HistoryViewport` 显示快照中的 `ViewMapping` 映射到文档位置;只装饰显示副本,不修改核心文字或样式。部分列选区只投影到相应列,避免整行高亮。该接线是可见网格的基础拖选,不包含历史浏览、系统剪贴板、选词/选行或 Find;[固定实现与验证边界](../../experiments/shell/results/2026-11-01-r2-core-input/README.md)与完整 #29/#30 出口分开。 @@ -23,12 +25,12 @@ cmake -S . -B build/qt-cpp -G "Visual Studio 17 2022" -A x64 ` -DQt6_DIR="/lib/cmake/Qt6" ` -DTERMFORGE_CONPTY_BACKEND_DIR="" ` -DTERMFORGE_MSVC_RUNTIME_DIR="" -cmake --build build/qt-cpp --config Release --target termforge_qt_shell qt_cpp_shell_smoke qt_cpp_resize_smoke qt_unicode_input_test +cmake --build build/qt-cpp --config Release --target termforge_qt_shell qt_cpp_shell_smoke qt_cpp_resize_smoke qt_unicode_input_test qt_terminal_modes_test build/qt-cpp/qt-bin/Release/termforge_qt_shell.exe -ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input)$' --output-on-failure +ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_terminal_modes(_retained)?)$' --output-on-failure ``` -当前主线注册的 Qt CTest 就是上述三项;IME 合成事件检查在 `qt_cpp_shell` 内。历史冻结包的 `qt_ime_input_test.exe` 不属于当前主线构建目标,不用于替代这组验证。 +当前主线注册的 Qt CTest 包括上述四项与 `qt_terminal_modes_retained`(只缩宽的保留/投影契约);IME 合成事件检查在 `qt_cpp_shell` 内。历史冻结包的 `qt_ime_input_test.exe` 不属于当前主线构建目标,不用于替代这组验证。旧六阶段 `--resize` 仍以显式诊断运行并保留失败,不注册进默认 CI。 固定 ConPTY/CRT 输入缺失或篡改时构建明确失败;实际工具集更新到固定 CRT 之外时须重新审查依赖。产品中的 shell 启动失败保留可关闭的错误窗口。随目录保留 Qt 动态运行库、平台插件、`conpty-backend/`、`assets/fonts/`、十个固定 CRT DLL 及其原许可/NOTICE;每次构建重新校验并部署 CRT,不运行安装器、不修改 System32。最终目录用 `tools/msvc_runtime.py manifest` 记录全部文件。当前只是本地构建输出,Qt/第三方的完整分发审计及产品打包尚未完成。 diff --git a/src/qt/terminal_modes_test.cpp b/src/qt/terminal_modes_test.cpp index 204f264c..de0bad24 100644 --- a/src/qt/terminal_modes_test.cpp +++ b/src/qt/terminal_modes_test.cpp @@ -176,6 +176,77 @@ static void check_stage(const ModeReply& reply, termforge::qt::TerminalWidget& t } } +static std::string display_row(const termforge::ViewSnapshot& page, size_t row) { + if (row >= page.lines.size()) return {}; + std::string text; + for (const auto& cell : page.lines[row]) text += cell.text; + while (!text.empty() && text.back() == ' ') text.pop_back(); + return text; +} + +// Formal Qt path contract after a public width-only resize: the core retains +// its pre-resize rows and the widget paints the independent reflowed display +// page. The independent native grid is recorded, not used as the addressing +// oracle: the child's CUP targets its own reflowed grid, while the product +// follows the confirmed retained-row contract. +static size_t retained_contract_checks(const ModeReply& native, termforge::qt::TerminalWidget& terminal, + const termforge::qt::SessionModel& model) { + size_t checks = 0, failures = 0; + const auto check = [&](bool condition, const char* message) { + ++checks; + if (condition) return; + ++failures; + std::cerr << "retained contract failed: " << message << '\n'; + }; + const auto& screen = model.screen(); + const auto state = model.resize_state(); + check(native.info.dwSize.X == 60 && native.info.dwSize.Y == 24 && screen.width() == 60 && screen.height() == 24 && + state.transport_columns == 60 && state.transport_rows == 24 && !state.pending && + state.requested_revision == state.transport_revision, + "child/transport/core resize identity differs"); + check(screen.history_lines().empty() && screen.visible_lines().size() == 24, + "width-only resize changed the retained frame"); + check(termforge::project_line(screen.visible_lines()[7]).text == std::string(79, ' ') + "B" && + termforge::project_line(screen.visible_lines()[9]).text == std::string(79, ' ') + "C" && + termforge::project_line(screen.visible_lines()[10]).text == "D", + "retained source rows lost the literal B/C/D cells"); + check(screen.row == 0 && screen.column == 2, "protocol cursor left its retained source position"); + terminal.grab(); + const auto& page = terminal.painted_page(); + check(page.has_value() && terminal.painted_row_offset() == 0, "widget painted no display page"); + if (page) { + check(display_row(*page, 7).empty() && display_row(*page, 8) == std::string(19, ' ') + "B" && + display_row(*page, 9).empty() && display_row(*page, 10).empty(), + "display rows 7..10 do not match the reflowed retained B row"); + check(display_row(*page, 11) == std::string(19, ' ') + "CD", + "display row 11 does not reflow the retained CD logical line"); + check(display_row(*page, 12) == "RESIZED", + "CUP write did not follow the retained source row in the display page"); + check(terminal.display_caret_row() == 0 && terminal.display_caret_column() == 2, + "display caret differs from the retained source cursor"); + const auto hit = page->mapping.hit(termforge::TextDocument(screen), 8, 19, + termforge::ViewAffinity::leading, page->mapping.layout_revision()); + check(hit.has_value() && hit->line_id == screen.visible_lines()[7].id && hit->column == 79, + "display hit-test did not return the retained B source cell"); + } + // Independent native observation, kept separate and never used as the + // product addressing oracle. + check(native_row(native, 7).empty() && native_row(native, 8) == std::string(19, ' ') + "B" && + native_row(native, 9).empty() && native_row(native, 10).empty(), + "independent native reflow observation differs from its literal rows"); + check(native_row(native, 11) == "RESIZED" + std::string(12, ' ') + "CD", + "independent native row did not keep its own CUP write with the old tail"); + std::cerr << "{\"schema\":\"termforge-qt-retained-contract-v1\",\"checks\":" << checks + << ",\"failures\":" << failures << ",\"core_cursor\":[" << screen.row << ',' << screen.column + << "],\"display_caret\":[" << terminal.display_caret_row() << ',' << terminal.display_caret_column() + << "],\"native_row_8_hex\":\"" << hex(native_row(native, 8)) + << "\",\"display_row_8_hex\":\"" << (page ? hex(display_row(*page, 8)) : std::string()) + << "\",\"native_row_11_hex\":\"" << hex(native_row(native, 11)) + << "\",\"display_row_12_hex\":\"" << (page ? hex(display_row(*page, 12)) : std::string()) + << "\",\"presented_done\":null}\n"; + return failures; +} + // Opt-in, one-run failure diagnostic using the same owned child and fixed // backend. It captures actual VT from the transport's existing public probe // interface; this is a separate direct-transport run, never a byte claim about @@ -250,14 +321,15 @@ int main(int argc, char** argv) { catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 3; } } const bool resize_repro = argc == 2 && std::string_view(argv[1]) == "--resize"; - if (argc != 1 && !resize_repro) { - std::cerr << "usage: qt_terminal_modes_test [--resize]\n"; return 2; + const bool retained_contract = argc == 2 && std::string_view(argv[1]) == "--retained-contract"; + if (argc != 1 && !resize_repro && !retained_contract) { + std::cerr << "usage: qt_terminal_modes_test [--resize|--retained-contract]\n"; return 2; } const bool capture_transport = qEnvironmentVariableIsSet("TERMFORGE_MODES_TRANSPORT_DIAGNOSTIC"); if (capture_transport && !resize_repro) { std::cerr << "transport diagnostic requires the explicit --resize entry\n"; return 2; } - const unsigned final_stage = resize_repro ? 6u : 5u; + const unsigned final_stage = (resize_repro || retained_contract) ? 6u : 5u; 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" || @@ -284,15 +356,21 @@ int main(int argc, char** argv) { require(initial.info.dwSize.X == 80 && initial.info.dwSize.Y == 24, "mode child did not start at the literal 80x24 geometry"); size_t failures = 0; + size_t retained_failures = 0; for (unsigned stage = 1; stage <= final_stage; ++stage) { if (stage == 6) { const QFontMetricsF metrics(terminal.font()); const int width = qCeil(metrics.horizontalAdvance(QLatin1Char('M'))); const int height = qCeil(metrics.height()) + 2; - terminal.resize(16 + 60 * width, 24 + 13 * height); + // The explicit repro keeps the original 60x12 simultaneous + // width/height change; the retained contract entry keeps the + // frame height so the reflowed display region is unambiguous. + const int target_columns = 60, target_rows = retained_contract ? 24 : 12; + terminal.resize(16 + target_columns * width, 24 + (target_rows + 1) * height); await([&] { const auto state = model.resize_state(); - return state.requested_columns == 60 && state.requested_rows == 12 && - !state.pending && state.transport_columns == 60 && state.transport_rows == 12; }, + return state.requested_columns == target_columns && state.requested_rows == target_rows && + !state.pending && state.transport_columns == target_columns && + state.transport_rows == target_rows; }, model, lifecycle, "public Qt resize did not finish the bounded native request"); } require(terminal.hasFocus(), "public mode key target lost focus"); @@ -305,7 +383,10 @@ int main(int argc, char** argv) { model, lifecycle, "source stage fence did not reach the core through ConPTY"); } catch (...) { record(reply, model.screen()); throw; } record(reply, model.screen()); - check_stage(reply, terminal, model, failures); + if (retained_contract && stage == final_stage) + retained_failures = retained_contract_checks(reply, terminal, model); + else + check_stage(reply, terminal, model, failures); } QTest::keyClicks(&terminal, QStringLiteral("x")); await([&] { return model.finished(); }, model, lifecycle, "mode child did not exit within its fixed deadline"); @@ -315,13 +396,14 @@ int main(int argc, char** argv) { if (capture_transport) transport_diagnostic(executable, workspace.path().toStdWString(), lifecycle); std::cout << "{\"schema\":\"termforge-qt-terminal-modes-v1\",\"scenario\":\"" - << (resize_repro ? "modes-resize-repro" : "modes") + << (retained_contract ? "modes-retained-contract" : resize_repro ? "modes-resize-repro" : "modes") << "\",\"stages\":" << final_stage << ",\"resize_exercised\":" - << (resize_repro ? "true" : "false") << ",\"literal_failures\":" - << failures << ",\"output_bytes\":" << model.received_bytes() + << (resize_repro || retained_contract ? "true" : "false") << ",\"literal_failures\":" + << failures << ",\"retained_contract_failures\":" << retained_failures + << ",\"output_bytes\":" << model.received_bytes() << ",\"child_exit\":37,\"cleanup_verified\":true,\"raw_transport_bytes_observed\":false," "\"presented_done\":null}\n"; - return failures ? 1 : 0; + return (failures || retained_failures) ? 1 : 0; } catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; } diff --git a/src/qt/terminal_session.hpp b/src/qt/terminal_session.hpp index e75d0d1d..06da722f 100644 --- a/src/qt/terminal_session.hpp +++ b/src/qt/terminal_session.hpp @@ -269,7 +269,10 @@ class SessionModel final : public QObject { std::lock_guard lock(control_mutex_); if (started_ && !accepting_resize_) return false; if (columns == parser_.screen.width() && rows == parser_.screen.height()) return true; - parser_.screen.resize(rows, columns); + // Formal product entry: keep the retained source frame and let the + // display projection reflow it. The protocol cursor/DSR stay in + // retained (pre-resize) coordinates; the widget maps the caret. + parser_.screen.resize_preserving(rows, columns); ++requested_revision_; if (started_) pending_resize_ = ResizeRequest{static_cast(columns), static_cast(rows), requested_revision_}; diff --git a/src/qt/terminal_widget.hpp b/src/qt/terminal_widget.hpp index 3f63a28a..46fe6270 100644 --- a/src/qt/terminal_widget.hpp +++ b/src/qt/terminal_widget.hpp @@ -2,6 +2,7 @@ #pragma once #include "terminal_session.hpp" +#include "../../experiments/core/display_cursor.hpp" #include "../../experiments/core/history_view.hpp" #include "../../experiments/core/text_selection.hpp" #include "../../experiments/shell/keyboard.hpp" @@ -31,6 +32,11 @@ class TerminalWidget final : public QWidget { // with another session. Hits resolve the last painted page, not a newer row. HistoryViewport viewport_; std::optional painted_page_; + // First painted display row inside the page and the page-relative display + // caret. The protocol cursor itself is never moved by this widget. + size_t painted_row_offset_ = 0; + int display_cursor_row_ = -1; + int display_cursor_column_ = 0; TextSelection selection_; std::optional selection_anchor_; static constexpr int margin = 8; @@ -104,12 +110,14 @@ class TerminalWidget final : public QWidget { } std::optional selection_position(QPointF point, bool nearest) const { if (!painted_page_ || painted_page_->lines.empty()) return std::nullopt; - const int row = std::clamp(qFloor((point.y() - margin) / cell_height_), - 0, static_cast(painted_page_->lines.size()) - 1); + const int visible = static_cast(painted_page_->lines.size() - painted_row_offset_); + if (visible <= 0) return std::nullopt; + const int row = std::clamp(qFloor((point.y() - margin) / cell_height_), 0, visible - 1); const int column = std::clamp(qFloor((point.x() - margin + (nearest ? cell_width_ / 2.0 : 0.0)) / - cell_width_), 0, model_.screen().width()); + cell_width_), 0, static_cast(painted_page_->mapping.columns())); return painted_page_->mapping.hit(TextDocument(model_.screen()), - static_cast(row), static_cast(column), ViewAffinity::leading); + painted_row_offset_ + static_cast(row), static_cast(column), + ViewAffinity::leading, painted_page_->mapping.layout_revision()); } void extend_selection(QPointF point) { const TextDocument document(model_.screen()); @@ -236,7 +244,9 @@ class TerminalWidget final : public QWidget { QVariant inputMethodQuery(Qt::InputMethodQuery query) const override { if (query == Qt::ImCursorRectangle && grid_ready_) { const auto& screen = model_.screen(); - return QRect(margin + screen.column * cell_width_, margin + screen.row * cell_height_, cell_width_, cell_height_); + const int row = display_cursor_row_ >= 0 ? display_cursor_row_ : screen.row; + const int column = display_cursor_row_ >= 0 ? display_cursor_column_ : screen.column; + return QRect(margin + column * cell_width_, margin + row * cell_height_, cell_width_, cell_height_); } if (query == Qt::ImSurroundingText) return QString(); if (query == Qt::ImCurrentSelection) return QString(); @@ -253,12 +263,14 @@ class TerminalWidget final : public QWidget { if (stream == nullptr) return; const QByteArray bytes = text.toUtf8(); const auto& screen = model_.screen(); + const int row = display_cursor_row_ >= 0 ? display_cursor_row_ : screen.row; + const int column = display_cursor_row_ >= 0 ? display_cursor_column_ : screen.column; const QPoint origin = mapToGlobal(QPoint(0, 0)); std::fprintf(stream, "{\"kind\":\"%s\",\"bytes\":%d,\"utf8\":\"", kind, static_cast(bytes.size())); for (const char ch : bytes) std::fprintf(stream, "%02x", static_cast(ch)); std::fprintf(stream, "\",\"row\":%d,\"column\":%d,\"caret_x\":%d,\"caret_y\":%d,\"caret_w\":%d,\"caret_h\":%d,\"origin_x\":%d,\"origin_y\":%d}\n", - screen.row, screen.column, - margin + screen.column * cell_width_, margin + screen.row * cell_height_, + row, column, + margin + column * cell_width_, margin + row * cell_height_, cell_width_, cell_height_, origin.x(), origin.y()); std::fclose(stream); } @@ -280,36 +292,80 @@ class TerminalWidget final : public QWidget { } event->accept(); } + // The formal Qt path keeps the retained source transaction and paints its + // independent display projection; only the caret is translated and the + // protocol cursor/source cells are never moved here. A frame that cannot be + // projected falls back to the physical viewport page instead of hiding + // output, so unsupported retained atoms keep the previous visibility. + void refresh_painted_page(const Screen& screen) { + painted_page_.reset(); + painted_row_offset_ = 0; + display_cursor_row_ = -1; + display_cursor_column_ = 0; + const size_t rows = static_cast(screen.height()); + const size_t total = screen.retained_size(); + const size_t live_top = screen.history_lines().size(); + if (rows >= 1 && rows <= 200 && total > live_top) { + // A soft continuation crossing the live-frame boundary needs its + // logical-line start for a faithful reflow; the projection window + // is bounded to 200 source rows. + size_t start = live_top; + while (start > 0 && live_top - start < 199 && + continues_into(screen.retained_line(start - 1), screen.retained_line(start))) --start; + if (total - start <= 200) { + try { + auto page = viewport_.project(screen, static_cast(screen.width()), 200, {start, 0}, total); + if (const auto caret = display_cursor(screen, page.snapshot)) { + const size_t first = caret->row >= rows ? caret->row - rows + 1 : 0; + painted_page_ = std::move(page.snapshot); + painted_row_offset_ = first; + display_cursor_row_ = static_cast(caret->row - first); + display_cursor_column_ = static_cast(caret->column); + return; + } + } catch (const std::exception&) { + // Unsupported retained atoms keep the physical page below. + } + } + } + painted_page_ = viewport_.snapshot(screen); + display_cursor_row_ = screen.row; + display_cursor_column_ = screen.column; + } void paintEvent(QPaintEvent*) override { QPainter painter(this); painter.fillRect(rect(), colour(-1, false)); const auto& screen = model_.screen(); const TextDocument document(screen); selection_.reconcile(document); - painted_page_ = viewport_.snapshot(screen); + refresh_painted_page(screen); auto& page = *painted_page_; - if (selection_.range()) for (const auto& span : page.mapping.project(document, *selection_.range())) { - for (size_t column = span.column_begin; column < span.column_end; ++column) { + const size_t columns = page.mapping.columns() ? page.mapping.columns() : static_cast(screen.width()); + const size_t visible_rows = (std::min)(static_cast(screen.height()), + page.lines.size() > painted_row_offset_ ? page.lines.size() - painted_row_offset_ : size_t{0}); + if (selection_.range()) for (const auto& span : page.mapping.project(document, *selection_.range(), page.mapping.layout_revision())) { + if (span.row < painted_row_offset_ || span.row - painted_row_offset_ >= visible_rows) continue; + for (size_t column = span.column_begin; column < span.column_end && column < columns; ++column) { // Only the owned display copy is decorated, never Screen cells. auto& style = page.lines[span.row][column].style; style.bg = 4; style.fg = 15; style.reverse = false; } } const auto base_font = font(); - for (int row = 0; row < screen.height(); ++row) { - const auto& line = page.lines[static_cast(row)]; - for (int column = 0; column < screen.width(); ++column) { - const auto& cell = line[static_cast(column)]; - const QRectF area(margin + column * cell_width_, margin + row * cell_height_, cell_width_, cell_height_); + for (size_t row = 0; row < visible_rows; ++row) { + const auto& line = page.lines[painted_row_offset_ + row]; + for (size_t column = 0; column < columns; ++column) { + const auto& cell = line[column]; + const QRectF area(margin + static_cast(column) * cell_width_, margin + static_cast(row) * cell_height_, cell_width_, cell_height_); // Reverse video swaps the cell's own foreground and background. painter.fillRect(area, colour(cell.style.reverse ? cell.style.fg : cell.style.bg, false)); } - for (int column = 0; column < screen.width(); ++column) { - const auto& cell = line[static_cast(column)]; + for (size_t column = 0; column < columns; ++column) { + const auto& cell = line[column]; if (!cell.width || cell.text.empty() || cell.style.hidden) continue; auto cell_font = base_font; cell_font.setBold(cell.style.bold); painter.setFont(cell_font); painter.setPen(colour(cell.style.reverse ? cell.style.bg : cell.style.fg, true)); - const QRectF area(margin + column * cell_width_, margin + row * cell_height_, + const QRectF area(margin + static_cast(column) * cell_width_, margin + static_cast(row) * cell_height_, cell_width_ * cell.width, cell_height_); painter.save(); painter.setClipRect(area); painter.drawText(area, Qt::AlignLeft | Qt::AlignVCenter | Qt::TextSingleLine, @@ -320,17 +376,20 @@ class TerminalWidget final : public QWidget { painter.restore(); } } - if (model_.running() && model_.cursor_visible()) { - painter.fillRect(QRect(margin + screen.column * cell_width_, margin + (screen.row + 1) * cell_height_ - 2, + const bool caret_drawn = display_cursor_row_ >= 0 && display_cursor_row_ < static_cast(visible_rows) && + display_cursor_column_ >= 0 && display_cursor_column_ < static_cast(columns); + if (model_.running() && model_.cursor_visible() && caret_drawn) { + painter.fillRect(QRect(margin + display_cursor_column_ * cell_width_, + margin + (display_cursor_row_ + 1) * cell_height_ - 2, cell_width_, 2), colour(-1, true)); } - if (!preedit_.isEmpty()) { + if (!preedit_.isEmpty() && caret_drawn) { // Compose in place at the caret; the committed text is what reaches // the session, this preview is presentation only. // Measure the preview instead of counting characters: a wide or // combining preedit is not "one cell per QChar". const QFontMetricsF compose_metrics(base_font); - const QRectF area(margin + screen.column * cell_width_, margin + screen.row * cell_height_, + const QRectF area(margin + display_cursor_column_ * cell_width_, margin + display_cursor_row_ * cell_height_, compose_metrics.horizontalAdvance(preedit_), static_cast(cell_height_)); auto compose_font = base_font; compose_font.setUnderline(true); @@ -374,8 +433,17 @@ class TerminalWidget final : public QWidget { const TextDocument document(model_.screen()); selection_.reconcile(document); if (selection_anchor_ && !document.valid(*selection_anchor_)) selection_anchor_.reset(); + // Keep the display page and caret current before Qt asks the input + // method for geometry, not only on the next paint. + if (grid_ready_) refresh_painted_page(model_.screen()); update(); } + // Diagnostic observation of the owned display chain; tests only read it and + // never mutate the painted page or the protocol cursor. + const std::optional& painted_page() const { return painted_page_; } + size_t painted_row_offset() const { return painted_row_offset_; } + int display_caret_row() const { return display_cursor_row_; } + int display_caret_column() const { return display_cursor_column_; } QSize sizeHint() const override { return {margin * 2 + SessionModel::initial_columns * cell_width_, margin * 3 + (SessionModel::initial_rows + 1) * cell_height_};