From 58ad294aea7d01388e83720b70e467b4b55d82f5 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:20:29 +0800 Subject: [PATCH 01/31] test(qt): prepare IME literal-commit receiver red case (refs #155) --- CMakeLists.txt | 18 ++++ src/qt/ime_input_test.cpp | 176 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 src/qt/ime_input_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 9029f0ad..1c4584fe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -427,6 +427,24 @@ if(TERMFORGE_BUILD_QT_SHELL) "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$") set_tests_properties(qt_unicode_input PROPERTIES TIMEOUT 35 ENVIRONMENT "QT_QPA_PLATFORM=offscreen;TERMFORGE_TEST_ARTIFACTS=${PROJECT_BINARY_DIR}/test-artifacts") + add_executable(qt_ime_input_test src/qt/ime_input_test.cpp) + set_target_properties(qt_ime_input_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qt-ime-bin/$") + termforge_qt_entry(qt_ime_input_test) + target_link_libraries(qt_ime_input_test PRIVATE Qt6::Test) + add_dependencies(qt_ime_input_test shell_input_client) + add_custom_command(TARGET qt_ime_input_test POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "$" + "$/platforms/" + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "$" "$/" + VERBATIM) + add_test(NAME qt_ime_input COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$") + set_tests_properties(qt_ime_input PROPERTIES TIMEOUT 35 + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;TERMFORGE_TEST_ARTIFACTS=${PROJECT_BINARY_DIR}/test-artifacts") + add_executable(qt_cpp_resize_smoke src/qt/resize_smoke.cpp) set_target_properties(qt_cpp_resize_smoke PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qt-resize-bin/$") diff --git a/src/qt/ime_input_test.cpp b/src/qt/ime_input_test.cpp new file mode 100644 index 00000000..99d6621d --- /dev/null +++ b/src/qt/ime_input_test.cpp @@ -0,0 +1,176 @@ +// Public Qt IME events and queries, own rendering, and an independent receiver. +#include "terminal_widget.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using termforge::qt::SessionModel; +using termforge::qt::TerminalWidget; + +static void require(bool value, const char* message) { + if (!value) throw std::runtime_error(message); +} +template static void await(Predicate predicate, const char* message) { + QElapsedTimer timer; timer.start(); + while (!predicate() && timer.elapsed() < 5000) { + QApplication::processEvents(QEventLoop::AllEvents, 20); + QTest::qWait(5); + } + require(predicate(), message); +} +static bool has_text(const termforge::Screen& screen, std::string_view needle) { + for (const auto& row : screen.lines) { + std::string text; + for (const auto& cell : row) if (cell.width) text += cell.text; + if (text.find(needle) != std::string::npos) return true; + } + return false; +} +static QByteArray received(const QString& path) { + QFile file(path); + require(file.open(QIODevice::ReadOnly), "IME receiver output is missing"); + return file.readAll(); +} +static void finish(SessionModel& model) { + // EOT terminates this receiver protocol; it is not a product Ctrl-D action. + require(model.send(std::string(1, '\x04')), "IME receiver EOT was rejected"); + await([&] { return model.finished(); }, "IME receiver did not finish"); + require(model.natural_exit() && model.exit_code() == 0 && model.output_eof() && + model.cleanup_verified() && model.active_processes_after() == 0, + "IME receiver did not exit naturally with verified cleanup"); +} +static QImage rendered(QWidget& widget) { + QImage image(widget.size(), QImage::Format_ARGB32); + image.fill(Qt::transparent); + widget.render(&image); + return image; +} +static QString image_sha(const QImage& image) { + const QByteArray bytes(reinterpret_cast(image.constBits()), image.sizeInBytes()); + return QString::fromLatin1(QCryptographicHash::hash(bytes, QCryptographicHash::Sha256).toHex()); +} +static QJsonObject query(QWidget& widget) { + QInputMethodQueryEvent request(Qt::ImEnabled | Qt::ImCursorRectangle | Qt::ImFont | + Qt::ImSurroundingText | Qt::ImCurrentSelection); + QApplication::sendEvent(&widget, &request); + const auto rectangle = request.value(Qt::ImCursorRectangle); + const auto rect = rectangle.toRectF(); + return {{"enabled_valid", request.value(Qt::ImEnabled).isValid()}, + {"enabled", request.value(Qt::ImEnabled).toBool()}, + {"cursor_valid", rectangle.isValid()}, + {"cursor_rect", QJsonArray{rect.x(), rect.y(), rect.width(), rect.height()}}, + {"surrounding_text", request.value(Qt::ImSurroundingText).toString()}, + {"current_selection", request.value(Qt::ImCurrentSelection).toString()}}; +} +static QJsonObject literal_ime_commit(const QString& receiver, const QString& directory) { + const QString path = directory + QStringLiteral("/ime-commit.bin"); + SessionModel model; + TerminalWidget terminal(model); + terminal.show(); + QApplication::setActiveWindow(&terminal); + terminal.setFocus(); + QApplication::processEvents(); + require(terminal.hasFocus(), "IME event target has no Qt focus"); + require(model.start(receiver.toStdWString(), + (QStringLiteral("\"") + path + QLatin1Char('"')).toStdWString(), + directory.toStdWString()), "IME receiver could not start"); + await([&] { return has_text(model.screen(), "TF_KEY_READY"); }, "IME receiver did not become ready"); + QTest::keyClicks(&terminal, QStringLiteral("L")); + const auto baseline = rendered(terminal); + QTextCharFormat format; + format.setForeground(QColor(255, 240, 0)); + format.setBackground(QColor(0, 40, 160)); + format.setFontUnderline(true); + const QList attributes{ + {QInputMethodEvent::TextFormat, 0, 8, format}, + {QInputMethodEvent::Cursor, 4, 1, QColor(0, 255, 255)} + }; + QInputMethodEvent preedit(QStringLiteral("zhongwen"), attributes); + preedit.setAccepted(false); + QApplication::sendEvent(&terminal, &preedit); + QApplication::processEvents(); + const auto composing = rendered(terminal); + qsizetype changed_pixels = 0; + require(baseline.size() == composing.size(), "preedit changed widget extent"); + for (int y = 0; y < baseline.height(); ++y) + for (int x = 0; x < baseline.width(); ++x) + if (baseline.pixel(x, y) != composing.pixel(x, y)) ++changed_pixels; + const auto properties = query(terminal); + + // IME commit is literal Unicode. In particular BS must stay U+0008, not DEL. + const auto text = QString::fromUtf16(u"\u4e2d\u6587e\u0301\U0001f600") + + QChar(8) + QChar(0); + QInputMethodEvent commit; + commit.setCommitString(text); + commit.setAccepted(false); + QApplication::sendEvent(&terminal, &commit); + QTest::keyClicks(&terminal, QStringLiteral("R")); + finish(model); + const auto actual = received(path); + const auto expected = QByteArray::fromHex("4ce4b8ade6968765cc81f09f9880080052"); + const QJsonObject observation{ + {"schema", "termforge-qt-ime-commit-observation-v1"}, + {"preedit_accepted", preedit.isAccepted()}, {"commit_accepted", commit.isAccepted()}, + {"properties_during_preedit", properties}, {"baseline_sha256", image_sha(baseline)}, + {"preedit_sha256", image_sha(composing)}, {"preedit_changed_pixels", static_cast(changed_pixels)}, + {"received_hex", QString::fromLatin1(actual.toHex())}, + {"expected_hex", QString::fromLatin1(expected.toHex())}, + {"receiver_exit_code", static_cast(model.exit_code())}, + {"receiver_natural_exit", model.natural_exit()}, + {"receiver_cleanup_verified", model.cleanup_verified()}, + {"actual_os_ime", false}, {"presented_done", QJsonValue()} + }; + QFile saved(directory + QStringLiteral("/observation.json")); + require(saved.open(QIODevice::WriteOnly) && saved.write(QJsonDocument(observation).toJson()) > 0, + "IME observation could not be retained"); + require(baseline.save(directory + QStringLiteral("/baseline.png")) && + composing.save(directory + QStringLiteral("/preedit.png")), "own render evidence could not be retained"); + std::cerr << "ime observation=" << QJsonDocument(observation).toJson(QJsonDocument::Compact).constData() << '\n'; + require(actual == expected, "Qt IME literal commit did not reach the receiver as exact UTF-8"); + return observation; +} +int main(int argc, char** argv) { + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); + _set_abort_behavior(0, _WRITE_ABORT_MSG | _CALL_REPORTFAULT); + 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 << "Qt IME test requires its owned Job supervisor\n"; return 2; + } + qInstallMessageHandler([](QtMsgType, const QMessageLogContext&, const QString& message) { + std::cerr << message.toStdString() << '\n' << std::flush; + }); + QApplication app(argc, argv); + QApplication::setQuitOnLastWindowClosed(false); + try { + const auto artifacts = qEnvironmentVariable("TERMFORGE_TEST_ARTIFACTS"); + if (!artifacts.isEmpty()) require(QDir().mkpath(artifacts), "IME artifact directory could not be created"); + QTemporaryDir workspace(artifacts.isEmpty() ? QString() : artifacts + QStringLiteral("/qt-ime-XXXXXX")); + require(workspace.isValid(), "owned IME receiver workspace could not be created"); + if (!artifacts.isEmpty()) workspace.setAutoRemove(false); + const auto receiver = QCoreApplication::applicationDirPath() + QStringLiteral("/shell_input_client.exe"); + require(QFile::exists(receiver), "independent IME receiver is missing"); + const QJsonObject result{{"schema", "termforge-qt-ime-input-v1"}, + {"cases", QJsonArray{literal_ime_commit(receiver, workspace.path())}}, + {"actual_os_ime", false}, {"presented_done", QJsonValue()}}; + std::cout << QJsonDocument(result).toJson(QJsonDocument::Compact).constData() << '\n'; + return 0; + } catch (const std::exception& error) { + std::cerr << "Qt IME input: " << error.what() << '\n'; return 1; + } +} + From 761bb2c23ee5cb5d38fefc4f5fb2e7fe102a8711 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:24:25 +0800 Subject: [PATCH 02/31] docs(qt): fix IME event, literal commit and native validation boundaries (refs #155) --- docs/research/qt-ime-integration-plan.md | 50 ++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/research/qt-ime-integration-plan.md diff --git a/docs/research/qt-ime-integration-plan.md b/docs/research/qt-ime-integration-plan.md new file mode 100644 index 00000000..f1da005e --- /dev/null +++ b/docs/research/qt-ime-integration-plan.md @@ -0,0 +1,50 @@ +# Qt IME 基础接线计划(#155) + +状态:**test-only 准备,尚未编译或执行**。依据 [#155](https://github.com/JavaZeroo/termforge/issues/155)、[ADR-0008](../adr/0008-cpp-core-qt-widgets.md) 和 [R2 合同](../milestones/R2-local-terminal.md)。基点 main a0aed8a643a0a99b09e16c7836e5a4e3ad26daed 已包含 #147 的 Qt 键盘文本;旧原件保持不变。本页是本切片技术边界,不宣告现有能力或验收通过。 + +## 已知事实与首个反例 + +当前 TerminalWidget 明确关闭 WA_InputMethodEnabled,没有重写 inputMethodEvent / inputMethodQuery。Qt 6.10 的 [QWidget 合同](https://doc.qt.io/qt-6.10/qwidget.html#inputMethodEvent)说明默认事件处理拒绝输入法事件;这只是源码和接口依据,**尚不是本切片的运行观察**。 + +test-only 源 58ad294aea7d01388e83720b70e467b4b55d82f5 注册 qt_ime_input,使用现有固定 ConPTY、run_smoke.py OwnedJob/GO 和独立 shell_input_client: + +1. 等接收端独立输出 TF_KEY_READY,以公开 Qt 按键发送 L。 +2. 发送带 TextFormat / Cursor 属性的公开 preedit zhongwen,保存前后实际自有 QImage、哈希、差异像素数和 query 值;这些诊断尚不作为首个失败前置。 +3. 发送一次 IME commit:中文、NFD、U+1F600、字面 U+0008 和 U+0000,再用公开按键发送 R。 +4. 接收端通过自己的 ReadConsoleW / Windows UTF-8 转换导出文件。完整字面预期为 **4ce4b8ade6968765cc81f09f9880080052(17B)**;preedit 不属于该字节流。只由 Session 发送该夹具的 EOT,待接收端自然结束及回收后,先保存观察再断言全文。 +5. 当前实现会丢失 commit 是**待验证假设**;不提前把 WA_InputMethodEnabled 或图像检查失败写成“已取得独立接收红例”。 + +未延长原合同:阶段 gate5秒,既有监督器 communicate25秒、超时后5秒等待,CTest35秒;后续 drain 和 finally 清理各有原实现边界。它们不组成已证明的整程硬时限,更不是性能门槛。原件写入独立自有目录;图像仅属本项目控件离屏绘制,不是原版资源或物理呈现。 + +## 最小实现形状 + +只修改 Qt widget 的输入法与绘制接线,必要时复用 Model 的错误呈现通道增加单一 reject_input(reason) 领域入口。Parser/Screen 不保存 preedit,也不把终端屏幕当成可编辑输入行;ConPTY 和有界 InputQueue 的职责不变,不引入新组件、字体或测试钩子。 + +- **字面提交。** preedit 和 commit 各沿旧 ImeContext 的256个UTF-16单位工程上限,先严格校验完整 UTF-16,再按字面 UTF-8 编码。复用旧 ime.hpp 的 WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS) 语义,不调用会将 Backspace 转成 DEL 的 KeyboardEncoder.character。整个结果只调用一次有界 send,拒绝不发送前缀。该上限和非法输入策略属于当前 TermForge 工程政策,尚无 WindTerm 对照。 +- **独立状态。** IME 不把自己的代理项交给 #147 的键盘编码器;无效或被拒绝的 IME 事件不改变已经持有的键盘半个标量。焦点/隐藏/关闭/不可用目标继续清理两种状态。旧 Win32 接线也是将 ImeMessage.committed 独立发送,不能因共享入口而套键盘控制码政策。 +- **组合显示。** widget 只持有有界 preedit、格式范围和组合光标。按 [QInputMethodEvent 合同](https://doc.qt.io/qt-6.10/qinputmethodevent.html)至少实现 TextFormat 的前景/背景/下划线及 Cursor 位置/可见性/颜色,用 Qt 自有文字布局绘制,裁剪于终端可用区域。错误 UTF-16、越界/冲突属性明确拒绝并取消,不静默截断。 +- **周边编辑请求。** 当前没有应用输入行/重转换合同,replacementStart或replacementLength非零明确拒绝,不向 child 注入猜测的退格/光标序列。周边文本和选择 query 不暴露屏幕正文作可编辑文本;计划返回空内容/索引或不支持值,并在验证中保留实际返回。 +- **候选锚点。** ImEnabled 按实际目标可用性、焦点和可见性回答。ImCursorRectangle / ImAnchorRectangle、ImFont 使用当前 widget 逻辑坐标、模型光标和实际文字布局;output、resize、字体/DPR及焦点变化调用 [QInputMethod.update](https://doc.qt.io/qt-6.10/qinputmethod.html#update) 通知。Qt 逻辑矩形与 native 物理矩形分列,不将原 Win32 的12×26网格认定为 Qt 已等价。 +- **取消与重入。** empty preedit 更新可取消组合但不发送文本。焦点转移先清理本地状态并依赖 Qt 自动重置输入上下文;若因本目标错误主动 reset,只在它仍是当前焦点时调用并先清理本地状态,不取消新目标或操作共享 OS 输入服务。 +- **一次性发送。** 每次 commit 事件只在本 handler 发送一次,不再交给默认编辑处理或同时从 key text 重发。相同内容的另一次输入事件仍是新输入,不能用“内容去重”吞掉用户有意重复;真实 Windows 提交/字符事件是否重复由 native 证据核对。组合期间普通按键文本不能绕过 IME 进入 child。 + +Qt 接口要求是实现依据,未知 WindTerm 行为仍未知;没有因为文档或测试通过而批准重要行为差异。 + +## 后续红绿与停止条件 + +按 tracer bullet 接续,不先写完整假想测试矩阵: + +| 顺序 | 公开动作和独立预期 | +|---|---| +| 首片 | 先编译/冻结/执行上面的17B接收红例一次,再实现字面提交使其通过;源、EXE、SDK/ConPTY/CRT、stdin GO、stdout/stderr、receiver和Job独立保留 | +| 组合 | 实际 QImage 的 preedit/格式/光标与取消后恢复,原始接收端证明 preedit 未发送;普通文本属性和 Cursor 显隐用可判读颜色/区域反例 | +| 拒绝 | 256/257单位、非法 UTF-16、replacement、无目标和实际发送失败;不通过改变等待或加生产队列钩子取绿。现容量 guard 的独立测试与 Qt 真实拒绝现场分列 | +| 上下文 | focus/hide/两标签/关闭/自然结束/失败目标;前后接收文件、查询、组合图像和回收均可核对,不根据空文件声称自然退出 | +| 几何 | output、resize、字体变化后的公开 query 和自有渲染;记录逻辑矩形/网格关系,不冒充跨DPI或实际上屏 | +| 完整配置 | 合入最新 main,复验原Qt键盘/cmd/resize,冻结源码/依赖/运行目录,运行一次必要完整矩阵并分开Qt和其他诊断、实际skip与旧失败 | +| 原生 | 源码和包/计划审查后,仅root在隔离Win11固定Microsoft拼音执行真实输入→Qt组合/结果→child字节,记录候选/焦点/清理;未满足则#155不能按完整原生条件结项 | + +本次准备阶段只提交源码和计划,**编译/Qt执行/full/native均为0**。先等 root 的互斥负载租约;不改个人输入布局/桌面/剪贴板,不直接接管 Qt HWND 的 IMM,不终止 ctfmon 等共享服务,不执行未审核的 guest 写入。后续实际失败保留,不自动重试取绿或扩大原期限。 + +完整 Find/rename、同次八会话、CF、真实跨DPI、所有字素/glyph/fallback、物理呈现及性能仍由 #30/#31/R2 接续;只观察到合成 Qt 事件不能关闭真实固定拼音条件。 + From 905d4b1fba7359dd7339a83cbaac26b055e084e2 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:32:14 +0800 Subject: [PATCH 03/31] feat(qt): send IME commits as bounded literal UTF-8 (refs #155) --- src/qt/terminal_session.hpp | 4 ++++ src/qt/terminal_widget.hpp | 32 +++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/qt/terminal_session.hpp b/src/qt/terminal_session.hpp index 610b928f..1a208479 100644 --- a/src/qt/terminal_session.hpp +++ b/src/qt/terminal_session.hpp @@ -231,6 +231,10 @@ class SessionModel final : public QObject { notify(); return succeeded; } + bool reject_input(const QString& reason) { + remember_error(reason); + notify(); return false; + } bool send(const std::string& bytes) { if (!running() || !input_) { remember_error(QStringLiteral("Terminal input is unavailable")); diff --git a/src/qt/terminal_widget.hpp b/src/qt/terminal_widget.hpp index aa05babe..174ffc45 100644 --- a/src/qt/terminal_widget.hpp +++ b/src/qt/terminal_widget.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -120,6 +121,35 @@ class TerminalWidget final : public QWidget { event->accept(); } else event->ignore(); } + static QByteArray literal_ime_text(const QString& text) { + if (text.size() > 256) throw std::runtime_error("IME text exceeds 256 UTF-16 units"); + for (qsizetype index = 0; index < text.size(); ++index) { + const auto unit = text.at(index); + if (unit.isHighSurrogate()) { + if (++index == text.size() || !text.at(index).isLowSurrogate()) + throw std::runtime_error("IME text contains invalid UTF-16"); + } else if (unit.isLowSurrogate()) throw std::runtime_error("IME text contains invalid UTF-16"); + } + // toUtf8 is literal only after complete UTF-16 validation; keyboard + // conventions such as Backspace -> DEL must never rewrite a commit. + return text.toUtf8(); + } + void inputMethodEvent(QInputMethodEvent* event) override { + if (!isVisible() || !hasFocus() || !model_.running()) { + model_.reject_input(QStringLiteral("Terminal input method target is unavailable")); + event->ignore(); return; + } + try { + if (event->replacementStart() || event->replacementLength()) + throw std::runtime_error("IME surrounding-text replacement is unsupported"); + const auto bytes = literal_ime_text(event->commitString()); + if (bytes.isEmpty() || model_.send(bytes)) event->accept(); + else event->ignore(); + } catch (const std::exception& error) { + model_.reject_input(QString::fromUtf8(error.what())); + event->ignore(); + } + } void paintEvent(QPaintEvent*) override { QPainter painter(this); painter.fillRect(rect(), colour(-1, false)); @@ -171,7 +201,7 @@ class TerminalWidget final : public QWidget { fixed.setStyleHint(QFont::TypeWriter); fixed.setPointSize(11); setFont(fixed); refresh_metrics(); setFocusPolicy(Qt::StrongFocus); - setAttribute(Qt::WA_InputMethodEnabled, false); + setAttribute(Qt::WA_InputMethodEnabled, true); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); resize(sizeHint()); grid_ready_ = true; From 245eca646dca4e74fed12dc18f87339cce649f35 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:33:57 +0800 Subject: [PATCH 04/31] test(qt): require observable IME preedit rendering (refs #155) --- src/qt/ime_input_test.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/qt/ime_input_test.cpp b/src/qt/ime_input_test.cpp index 99d6621d..d5a4750c 100644 --- a/src/qt/ime_input_test.cpp +++ b/src/qt/ime_input_test.cpp @@ -140,6 +140,7 @@ static QJsonObject literal_ime_commit(const QString& receiver, const QString& di composing.save(directory + QStringLiteral("/preedit.png")), "own render evidence could not be retained"); std::cerr << "ime observation=" << QJsonDocument(observation).toJson(QJsonDocument::Compact).constData() << '\n'; require(actual == expected, "Qt IME literal commit did not reach the receiver as exact UTF-8"); + require(changed_pixels > 0, "Qt IME preedit did not produce any rendered ink"); return observation; } int main(int argc, char** argv) { From 4c1127613776a11565225808b0e7daff88d8b8e1 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:37:24 +0800 Subject: [PATCH 05/31] feat(qt): render bounded preedit and expose current IME geometry (refs #155) --- src/qt/terminal_widget.hpp | 171 ++++++++++++++++++++++++++++++++++--- 1 file changed, 159 insertions(+), 12 deletions(-) diff --git a/src/qt/terminal_widget.hpp b/src/qt/terminal_widget.hpp index 174ffc45..ca7aa2a0 100644 --- a/src/qt/terminal_widget.hpp +++ b/src/qt/terminal_widget.hpp @@ -10,6 +10,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -22,10 +25,74 @@ namespace termforge::qt { class TerminalWidget final : public QWidget { SessionModel& model_; tf_shell::KeyboardEncoder keyboard_; + struct ImeInputError : std::runtime_error { using std::runtime_error::runtime_error; }; + struct Composition { + QString text; + QList formats; + int cursor = 0; + bool cursor_visible = true; + QColor cursor_colour; + } composition_; + bool resetting_input_method_ = false; static constexpr int margin = 8; int cell_width_ = 9; int cell_height_ = 19; bool grid_ready_ = false; + bool accepts_ime() const { return isEnabled() && isVisible() && hasFocus() && model_.running(); } + QRectF terminal_clip() const { + return QRectF(margin, margin, (std::max)(0, width() - margin * 2), + (std::max)(0, height() - margin * 3 - cell_height_)); + } + QRectF terminal_cursor() const { + const auto& screen = model_.screen(); + return QRectF(margin + screen.column * cell_width_, margin + screen.row * cell_height_, + cell_width_, cell_height_); + } + QPointF composition_origin() const { + auto origin = terminal_cursor().topLeft(); + origin.ry() += (cell_height_ - QFontMetricsF(font()).height()) / 2; + return origin; + } + void layout_composition(QTextLayout& layout) const { + layout.setFormats(composition_.formats); + QTextOption option; option.setWrapMode(QTextOption::NoWrap); + layout.setTextOption(option); + layout.beginLayout(); + qreal y = 0; + for (;;) { + auto line = layout.createLine(); + if (!line.isValid()) break; + line.setLineWidth((std::max)(qreal(1), terminal_clip().right() - composition_origin().x())); + line.setPosition(QPointF(0, y)); y += line.height(); + } + layout.endLayout(); + } + QRectF ime_cursor() const { + if (composition_.text.isEmpty()) return terminal_cursor(); + QTextLayout layout(composition_.text, font(), this); layout_composition(layout); + auto line = layout.lineForTextPosition(composition_.cursor); + if (!line.isValid()) return terminal_cursor(); + const auto origin = composition_origin(); + return QRectF(origin.x() + line.cursorToX(composition_.cursor), origin.y() + line.y(), 1, line.height()); + } + void update_input_method() { + if (isVisible() && hasFocus()) { + if (auto* method = QGuiApplication::inputMethod()) + method->update(Qt::ImQueryInput | Qt::ImCursorRectangle | Qt::ImAnchorRectangle | + Qt::ImFont | Qt::ImEnabled | Qt::ImInputItemClipRectangle); + } + } + void clear_composition(bool reset_provider = false) { + composition_ = {}; + // Focus changes already reset Qt's context. Reset only our still-focused + // target on a rejected transaction, never the editor that took focus. + if (reset_provider && hasFocus() && isVisible() && !resetting_input_method_) { + resetting_input_method_ = true; + if (auto* method = QGuiApplication::inputMethod()) method->reset(); + resetting_input_method_ = false; + } + update(); update_input_method(); + } void refresh_metrics() { const QFontMetricsF metrics(font()); cell_width_ = (std::max)(1, qCeil(metrics.horizontalAdvance(QLatin1Char('M')))); @@ -39,16 +106,19 @@ class TerminalWidget final : public QWidget { void resizeEvent(QResizeEvent* event) override { QWidget::resizeEvent(event); synchronize_grid(); + update_input_method(); } void showEvent(QShowEvent* event) override { QWidget::showEvent(event); synchronize_grid(); + update_input_method(); } void changeEvent(QEvent* event) override { QWidget::changeEvent(event); if (grid_ready_ && (event->type() == QEvent::FontChange || event->type() == QEvent::ApplicationFontChange)) { refresh_metrics(); synchronize_grid(); + update_input_method(); } } static QColor colour(int index, bool foreground) { @@ -62,12 +132,15 @@ class TerminalWidget final : public QWidget { } void closeEvent(QCloseEvent* event) override { keyboard_.reset(); + clear_composition(); model_.close(); QWidget::closeEvent(event); } bool event(QEvent* event) override { - if (event->type() == QEvent::FocusOut || event->type() == QEvent::Hide) + if (event->type() == QEvent::FocusOut || event->type() == QEvent::Hide) { keyboard_.reset(); + clear_composition(); + } // QWidget normally consumes Tab for focus traversal before keyPressEvent. if (event->type() == QEvent::KeyPress && static_cast(event)->key() == Qt::Key_Tab) { keyPressEvent(static_cast(event)); return event->isAccepted(); @@ -76,7 +149,9 @@ class TerminalWidget final : public QWidget { if (grid_ready_ && event->type() == QEvent::DevicePixelRatioChange) { refresh_metrics(); synchronize_grid(); + update_input_method(); } + if (event->type() == QEvent::FocusIn || event->type() == QEvent::EnabledChange) update_input_method(); return handled; } void keyPressEvent(QKeyEvent* event) override { @@ -89,6 +164,9 @@ class TerminalWidget final : public QWidget { model_.send(std::string{}); event->ignore(); return; } + // The input context owns composing keystrokes; they must not become a + // second text path into the child while preedit is still active. + if (!composition_.text.isEmpty()) { QWidget::keyPressEvent(event); return; } std::string bytes; auto next = keyboard_; bool character_text = false; @@ -122,34 +200,90 @@ class TerminalWidget final : public QWidget { } else event->ignore(); } static QByteArray literal_ime_text(const QString& text) { - if (text.size() > 256) throw std::runtime_error("IME text exceeds 256 UTF-16 units"); + if (text.size() > 256) throw ImeInputError("IME text exceeds 256 UTF-16 units"); for (qsizetype index = 0; index < text.size(); ++index) { const auto unit = text.at(index); if (unit.isHighSurrogate()) { if (++index == text.size() || !text.at(index).isLowSurrogate()) - throw std::runtime_error("IME text contains invalid UTF-16"); - } else if (unit.isLowSurrogate()) throw std::runtime_error("IME text contains invalid UTF-16"); + throw ImeInputError("IME text contains invalid UTF-16"); + } else if (unit.isLowSurrogate()) throw ImeInputError("IME text contains invalid UTF-16"); } // toUtf8 is literal only after complete UTF-16 validation; keyboard // conventions such as Backspace -> DEL must never rewrite a commit. return text.toUtf8(); } + static bool scalar_boundary(const QString& text, int position) { + return position >= 0 && position <= text.size() && + (position == 0 || position == text.size() || !text.at(position).isLowSurrogate()); + } + static Composition composition_for(const QInputMethodEvent& event) { + Composition next; + next.text = event.preeditString(); + (void)literal_ime_text(next.text); + next.cursor = static_cast(next.text.size()); + bool cursor_seen = false; + std::array formatted{}; + for (const auto& attribute : event.attributes()) { + if (attribute.type == QInputMethodEvent::TextFormat) { + if (attribute.start < 0 || attribute.length < 0 || attribute.start > next.text.size() || + attribute.length > next.text.size() - attribute.start || + !scalar_boundary(next.text, attribute.start) || + !scalar_boundary(next.text, attribute.start + attribute.length) || + !attribute.value.canConvert()) + throw ImeInputError("IME text format range is invalid"); + const auto value = attribute.value.value(); + if (!value.isCharFormat()) throw ImeInputError("IME text format type is invalid"); + for (int index = attribute.start; index < attribute.start + attribute.length; ++index) { + if (formatted[static_cast(index)]) throw ImeInputError("IME text formats overlap"); + formatted[static_cast(index)] = true; + } + next.formats.append({attribute.start, attribute.length, value.toCharFormat()}); + } else if (attribute.type == QInputMethodEvent::Cursor) { + if (cursor_seen || attribute.length < 0 || !scalar_boundary(next.text, attribute.start)) + throw ImeInputError("IME cursor attribute is invalid"); + cursor_seen = true; next.cursor = attribute.start; next.cursor_visible = attribute.length != 0; + if (attribute.value.canConvert()) next.cursor_colour = attribute.value.value(); + } else if (attribute.type == QInputMethodEvent::Selection && !attribute.start && !attribute.length) { + // There is no editable surrounding document or selection. + } else throw ImeInputError("IME attribute is unsupported"); + } + return next; + } void inputMethodEvent(QInputMethodEvent* event) override { - if (!isVisible() || !hasFocus() || !model_.running()) { + if (resetting_input_method_) { event->ignore(); return; } + if (!accepts_ime()) { + clear_composition(); model_.reject_input(QStringLiteral("Terminal input method target is unavailable")); event->ignore(); return; } try { if (event->replacementStart() || event->replacementLength()) - throw std::runtime_error("IME surrounding-text replacement is unsupported"); + throw ImeInputError("IME surrounding-text replacement is unsupported"); const auto bytes = literal_ime_text(event->commitString()); - if (bytes.isEmpty() || model_.send(bytes)) event->accept(); - else event->ignore(); - } catch (const std::exception& error) { + auto next = composition_for(*event); + if (bytes.isEmpty() || model_.send(bytes)) { + composition_ = std::move(next); + event->accept(); update(); update_input_method(); + } else { clear_composition(true); event->ignore(); } + } catch (const ImeInputError& error) { + clear_composition(true); model_.reject_input(QString::fromUtf8(error.what())); event->ignore(); } } + void paint_composition(QPainter& painter) const { + if (composition_.text.isEmpty() || !accepts_ime()) return; + QTextLayout layout(composition_.text, font(), this); layout_composition(layout); + const auto origin = composition_origin(); + painter.save(); painter.setClipRect(terminal_clip()); + painter.fillRect(layout.boundingRect().translated(origin), colour(-1, false)); + painter.setPen(colour(-1, true)); layout.draw(&painter, origin); + if (composition_.cursor_visible) { + painter.setPen(composition_.cursor_colour.isValid() ? composition_.cursor_colour : colour(-1, true)); + layout.drawCursor(&painter, origin, composition_.cursor, 1); + } + painter.restore(); + } void paintEvent(QPaintEvent*) override { QPainter painter(this); painter.fillRect(rect(), colour(-1, false)); @@ -175,10 +309,11 @@ class TerminalWidget final : public QWidget { painter.restore(); } } - if (model_.running()) { + if (model_.running() && composition_.text.isEmpty()) { painter.fillRect(QRect(margin + screen.column * cell_width_, margin + (screen.row + 1) * cell_height_ - 2, cell_width_, 2), colour(-1, true)); } + paint_composition(painter); painter.setFont(base_font); painter.setPen(model_.error().isEmpty() ? colour(8, true) : colour(9, true)); const QString status = !model_.error().isEmpty() ? model_.error() : model_.finished() ? @@ -189,6 +324,18 @@ class TerminalWidget final : public QWidget { Qt::AlignLeft | Qt::AlignVCenter | Qt::TextSingleLine, status); } public: + QVariant inputMethodQuery(Qt::InputMethodQuery query) const override { + switch (query) { + case Qt::ImEnabled: return accepts_ime(); + case Qt::ImCursorRectangle: case Qt::ImAnchorRectangle: return ime_cursor(); + case Qt::ImInputItemClipRectangle: return terminal_clip(); + case Qt::ImFont: return font(); + case Qt::ImCursorPosition: case Qt::ImAnchorPosition: case Qt::ImAbsolutePosition: return 0; + case Qt::ImSurroundingText: case Qt::ImCurrentSelection: + case Qt::ImTextBeforeCursor: case Qt::ImTextAfterCursor: return QString{}; + default: return QVariant{}; + } + } explicit TerminalWidget(SessionModel& model, QWidget* parent = nullptr) : QWidget(parent), model_(model) { // Qt owns this one process-local registration until application exit. // The font comes from the project's independent OFL asset bundle. @@ -209,8 +356,8 @@ class TerminalWidget final : public QWidget { } ~TerminalWidget() override { model_.on_changed = {}; } void refresh_session() { - if (!model_.running()) keyboard_.reset(); - update(); + if (!model_.running()) { keyboard_.reset(); clear_composition(); } + else { update(); update_input_method(); } } QSize sizeHint() const override { return {margin * 2 + SessionModel::initial_columns * cell_width_, From b6b704aa9bbf6856c318329a14af65d05607da3d Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:43:00 +0800 Subject: [PATCH 06/31] test(qt): verify IME attributes, cancellation and literal boundaries (refs #155) --- src/qt/ime_input_test.cpp | 94 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/src/qt/ime_input_test.cpp b/src/qt/ime_input_test.cpp index d5a4750c..10fe5638 100644 --- a/src/qt/ime_input_test.cpp +++ b/src/qt/ime_input_test.cpp @@ -62,6 +62,31 @@ static QString image_sha(const QImage& image) { const QByteArray bytes(reinterpret_cast(image.constBits()), image.sizeInBytes()); return QString::fromLatin1(QCryptographicHash::hash(bytes, QCryptographicHash::Sha256).toHex()); } +static qsizetype coloured_pixels(const QImage& image, const QColor& colour) { + qsizetype count = 0; + for (int y = 0; y < image.height(); ++y) + for (int x = 0; x < image.width(); ++x) + if (image.pixelColor(x, y) == colour) ++count; + return count; +} +static bool ime_event(QWidget& target, const QString& committed, const QString& preedit = {}, + const QList& attributes = {}, + int replacement_start = 0, int replacement_length = 0) { + QInputMethodEvent event(preedit, attributes); + event.setCommitString(committed, replacement_start, replacement_length); + event.setAccepted(false); QApplication::sendEvent(&target, &event); + QApplication::processEvents(); + return event.isAccepted(); +} +static bool key_text(QWidget& target, const QString& text) { + QKeyEvent event(QEvent::KeyPress, Qt::Key_unknown, Qt::NoModifier, text); + event.setAccepted(false); QApplication::sendEvent(&target, &event); + return event.isAccepted(); +} +static void focus(TerminalWidget& terminal) { + terminal.show(); QApplication::setActiveWindow(terminal.window()); terminal.setFocus(); + QApplication::processEvents(); require(terminal.hasFocus(), "IME target has no Qt focus"); +} static QJsonObject query(QWidget& widget) { QInputMethodQueryEvent request(Qt::ImEnabled | Qt::ImCursorRectangle | Qt::ImFont | Qt::ImSurroundingText | Qt::ImCurrentSelection); @@ -109,6 +134,20 @@ static QJsonObject literal_ime_commit(const QString& receiver, const QString& di for (int x = 0; x < baseline.width(); ++x) if (baseline.pixel(x, y) != composing.pixel(x, y)) ++changed_pixels; const auto properties = query(terminal); + const auto background_pixels = coloured_pixels(composing, QColor(0, 40, 160)); + const auto foreground_pixels = coloured_pixels(composing, QColor(255, 240, 0)); + const auto cursor_pixels = coloured_pixels(composing, QColor(0, 255, 255)); + auto hidden_attributes = attributes; + hidden_attributes[1].length = 0; + require(ime_event(terminal, {}, QStringLiteral("zhongwen"), hidden_attributes), "hidden preedit cursor rejected"); + const auto hidden_cursor = rendered(terminal); + auto plain_attributes = hidden_attributes; + auto plain_format = format; plain_format.setFontUnderline(false); + plain_attributes[0].value = plain_format; + require(ime_event(terminal, {}, QStringLiteral("zhongwen"), plain_attributes), "plain preedit rejected"); + const auto plain = rendered(terminal); + require(ime_event(terminal, {}), "empty composition cancel rejected"); + const bool cancelled_render_restored = rendered(terminal) == baseline; // IME commit is literal Unicode. In particular BS must stay U+0008, not DEL. const auto text = QString::fromUtf16(u"\u4e2d\u6587e\u0301\U0001f600") + @@ -126,6 +165,12 @@ static QJsonObject literal_ime_commit(const QString& receiver, const QString& di {"preedit_accepted", preedit.isAccepted()}, {"commit_accepted", commit.isAccepted()}, {"properties_during_preedit", properties}, {"baseline_sha256", image_sha(baseline)}, {"preedit_sha256", image_sha(composing)}, {"preedit_changed_pixels", static_cast(changed_pixels)}, + {"background_pixels", static_cast(background_pixels)}, + {"foreground_pixels", static_cast(foreground_pixels)}, + {"cursor_pixels", static_cast(cursor_pixels)}, + {"hidden_cursor_pixels", static_cast(coloured_pixels(hidden_cursor, QColor(0, 255, 255)))}, + {"underline_changes_render", hidden_cursor != plain}, + {"cancelled_render_restored", cancelled_render_restored}, {"received_hex", QString::fromLatin1(actual.toHex())}, {"expected_hex", QString::fromLatin1(expected.toHex())}, {"receiver_exit_code", static_cast(model.exit_code())}, @@ -141,8 +186,54 @@ static QJsonObject literal_ime_commit(const QString& receiver, const QString& di std::cerr << "ime observation=" << QJsonDocument(observation).toJson(QJsonDocument::Compact).constData() << '\n'; require(actual == expected, "Qt IME literal commit did not reach the receiver as exact UTF-8"); require(changed_pixels > 0, "Qt IME preedit did not produce any rendered ink"); + require(background_pixels > 0 && foreground_pixels > 0 && cursor_pixels > 0, + "preedit text format or cursor colour did not reach rendering"); + require(!coloured_pixels(hidden_cursor, QColor(0, 255, 255)) && hidden_cursor != plain, + "preedit cursor visibility or underline attribute was ignored"); + require(cancelled_render_restored, "cancelled preedit left stale rendered text"); return observation; } +static QJsonObject limits_and_literal_boundaries(const QString& receiver, const QString& directory) { + const QString path = directory + QStringLiteral("/ime-limits.bin"); + SessionModel model; TerminalWidget terminal(model); focus(terminal); + require(!ime_event(terminal, QStringLiteral("NO_TARGET")), "unstarted IME target accepted commit"); + require(model.start(receiver.toStdWString(), (QStringLiteral("\"") + path + QLatin1Char('"')).toStdWString(), + directory.toStdWString()), "IME limits receiver could not start"); + await([&] { return has_text(model.screen(), "TF_KEY_READY"); }, "IME limits receiver was not ready"); + require(key_text(terminal, QStringLiteral("A")), "IME limits prefix was rejected"); + require(ime_event(terminal, QString(256, QChar(0x0800))), "256-unit IME commit was rejected"); + require(!ime_event(terminal, QString(257, QChar(0x0800))), "257-unit IME commit was accepted"); + require(!ime_event(terminal, {}, QString(257, QLatin1Char('x'))), "257-unit preedit was accepted"); + require(key_text(terminal, QString(QChar(0xd83d))), "held keyboard high was rejected"); + require(!ime_event(terminal, QString(QChar(0xd800))), "invalid IME UTF-16 was accepted"); + require(key_text(terminal, QString(QChar(0xde00))), "keyboard continuation failed after invalid IME"); + require(key_text(terminal, QString(QChar(0xd83d))), "second held keyboard high was rejected"); + require(ime_event(terminal, QString::fromUtf16(u"\u4e2d")), "literal IME commit was rejected"); + require(key_text(terminal, QString(QChar(0xde00))), "literal IME altered independent keyboard state"); + require(!ime_event(terminal, QStringLiteral("BAD"), {}, {}, -1, 1), "surrounding replacement was accepted"); + require(!ime_event(terminal, {}, QString(QChar(0xdc00))), "invalid preedit UTF-16 was accepted"); + QTextCharFormat format; format.setFontUnderline(true); + require(!ime_event(terminal, QStringLiteral("BAD"), QStringLiteral("x"), + {{QInputMethodEvent::TextFormat, 2, 1, format}}), "out-of-range IME format was accepted"); + // Equal text in distinct input events is intentional input, not a duplicate + // to discard by content. Each transaction must reach the receiver once. + require(ime_event(terminal, QStringLiteral("Z")) && ime_event(terminal, QStringLiteral("Z")) && + key_text(terminal, QStringLiteral("Z")), "distinct repeated text was deduplicated"); + require(key_text(terminal, QStringLiteral("R")), "IME limits suffix was rejected"); + finish(model); + QByteArray expected("A"); + for (int index = 0; index < 256; ++index) expected += QByteArray::fromHex("e0a080"); + expected += QByteArray::fromHex("f09f9880e4b8adf09f98805a5a5a52"); + const auto actual = received(path); + require(actual == expected, "IME bounds/rejection rewrote bytes or corrupted keyboard state"); + require(!ime_event(terminal, QStringLiteral("AFTER")), "ended IME target accepted commit"); + require(!query(terminal).value("enabled").toBool() && !model.error().isEmpty(), + "unavailable IME query or rejection feedback is missing"); + return {{"case", "limits-literal-keyboard-isolation"}, {"received_bytes", actual.size()}, + {"received_sha256", QString::fromLatin1(QCryptographicHash::hash(actual, QCryptographicHash::Sha256).toHex())}, + {"accepted_utf16_units", 256}, {"rejected_utf16_units", 257}, + {"literal_ime_kept_keyboard_state", true}, {"receiver_exit_code", 0}}; +} int main(int argc, char** argv) { SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); _set_abort_behavior(0, _WRITE_ABORT_MSG | _CALL_REPORTFAULT); @@ -166,7 +257,8 @@ int main(int argc, char** argv) { const auto receiver = QCoreApplication::applicationDirPath() + QStringLiteral("/shell_input_client.exe"); require(QFile::exists(receiver), "independent IME receiver is missing"); const QJsonObject result{{"schema", "termforge-qt-ime-input-v1"}, - {"cases", QJsonArray{literal_ime_commit(receiver, workspace.path())}}, + {"cases", QJsonArray{literal_ime_commit(receiver, workspace.path()), + limits_and_literal_boundaries(receiver, workspace.path())}}, {"actual_os_ime", false}, {"presented_done", QJsonValue()}}; std::cout << QJsonDocument(result).toJson(QJsonDocument::Compact).constData() << '\n'; return 0; From 13c6abccd2a93d854acfabc57d7b8e13185a354b Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:45:17 +0800 Subject: [PATCH 07/31] fix(qt): preserve explicit IME cursor colour on raster devices (refs #155) --- src/qt/terminal_widget.hpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/qt/terminal_widget.hpp b/src/qt/terminal_widget.hpp index ca7aa2a0..e7340c5c 100644 --- a/src/qt/terminal_widget.hpp +++ b/src/qt/terminal_widget.hpp @@ -279,8 +279,10 @@ class TerminalWidget final : public QWidget { painter.fillRect(layout.boundingRect().translated(origin), colour(-1, false)); painter.setPen(colour(-1, true)); layout.draw(&painter, origin); if (composition_.cursor_visible) { - painter.setPen(composition_.cursor_colour.isValid() ? composition_.cursor_colour : colour(-1, true)); - layout.drawCursor(&painter, origin, composition_.cursor, 1); + // QTextLayout's cursor can invert raster destinations. An explicit + // IME colour must instead fill the same rectangle exposed by query. + painter.fillRect(ime_cursor(), composition_.cursor_colour.isValid() ? + composition_.cursor_colour : colour(-1, true)); } painter.restore(); } From 07c5b054f6e67a46b33b43fd5d64ea2e0bb288ef Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:49:11 +0800 Subject: [PATCH 08/31] test(qt): keep IME composition isolated through focus and lifecycle (refs #155) --- src/qt/ime_input_test.cpp | 75 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/src/qt/ime_input_test.cpp b/src/qt/ime_input_test.cpp index 10fe5638..2361d81b 100644 --- a/src/qt/ime_input_test.cpp +++ b/src/qt/ime_input_test.cpp @@ -1,5 +1,6 @@ // Public Qt IME events and queries, own rendering, and an independent receiver. #include "terminal_widget.hpp" +#include "session_tabs.hpp" #include #include #include @@ -12,6 +13,8 @@ #include #include #include +#include +#include #include #include #include @@ -234,6 +237,75 @@ static QJsonObject limits_and_literal_boundaries(const QString& receiver, const {"accepted_utf16_units", 256}, {"rejected_utf16_units", 257}, {"literal_ime_kept_keyboard_state", true}, {"receiver_exit_code", 0}}; } +static QJsonObject context_isolation(const QString& receiver, const QString& directory) { + const auto argument = [&](const QString& name) { + return (QStringLiteral("\"") + directory + QLatin1Char('/') + name + QLatin1Char('"')).toStdWString(); + }; + termforge::qt::SessionTabs window(receiver.toStdWString(), argument(QStringLiteral("ime-tab-a.bin")), directory.toStdWString()); + window.show(); QApplication::setActiveWindow(&window); QApplication::processEvents(); + auto* first = window.current_session(); + require(first != nullptr, "first IME target is missing"); + await([&] { return has_text(first->model().screen(), "TF_KEY_READY"); }, "first IME receiver was not ready"); + const auto base_query = query(first->terminal()).value("cursor_rect").toArray(); + require(key_text(first->terminal(), QStringLiteral("A")), "first IME prefix was rejected"); + require(ime_event(first->terminal(), {}, QStringLiteral("focus")), "first preedit was rejected"); + require(!key_text(first->terminal(), QStringLiteral("LEAK")), "composing key text bypassed input context"); + QLineEdit other; other.show(); QApplication::setActiveWindow(&other); other.setFocus(); QApplication::processEvents(); + require(other.hasFocus() && !first->terminal().hasFocus(), "IME focus did not move to another widget"); + require(!ime_event(first->terminal(), QStringLiteral("UNFOCUSED")), "unfocused IME target accepted late commit"); + focus(first->terminal()); + require(query(first->terminal()).value("cursor_rect").toArray() == base_query, + "focus return revived cancelled preedit cursor"); + require(ime_event(first->terminal(), QString::fromUtf16(u"\u7532")), "first IME commit was rejected"); + require(ime_event(first->terminal(), {}, QStringLiteral("hidden")), "hide preedit was rejected"); + first->terminal().hide(); QApplication::processEvents(); + require(!ime_event(first->terminal(), QStringLiteral("HIDDEN")), "hidden IME target accepted late commit"); + focus(first->terminal()); + require(query(first->terminal()).value("cursor_rect").toArray() == base_query, + "show revived hidden preedit cursor"); + require(ime_event(first->terminal(), {}, QStringLiteral("tab")), "tab preedit was rejected"); + auto* second = window.new_session(receiver.toStdWString(), argument(QStringLiteral("ime-tab-b.bin")), directory.toStdWString()); + require(second && second != first, "second IME target is missing"); + await([&] { return has_text(second->model().screen(), "TF_KEY_READY"); }, "second IME receiver was not ready"); + require(!ime_event(first->terminal(), QStringLiteral("WRONG_TAB")), "old tab accepted IME commit"); + require(ime_event(second->terminal(), {}, QStringLiteral("second")) && + ime_event(second->terminal(), QString::fromUtf16(u"\u4e59")), "second IME transaction was rejected"); + auto* bar = window.findChild(); + require(bar && bar->count() == 2, "IME tab bar is missing"); + QTest::mouseClick(bar, Qt::LeftButton, Qt::NoModifier, bar->tabRect(0).center()); QApplication::processEvents(); + require(query(first->terminal()).value("cursor_rect").toArray() == base_query, + "tab return revived another composition"); + require(ime_event(first->terminal(), QString::fromUtf16(u"\u4e19")), "first IME target did not resume"); + finish(second->model()); + auto* closing = window.new_session(receiver.toStdWString(), argument(QStringLiteral("ime-tab-close.bin")), directory.toStdWString()); + require(closing != nullptr, "closing IME target is missing"); + await([&] { return has_text(closing->model().screen(), "TF_KEY_READY"); }, "closing IME receiver was not ready"); + require(ime_event(closing->terminal(), {}, QStringLiteral("close")), "closing preedit was rejected"); + closing->terminal().close(); + require(!ime_event(closing->terminal(), QStringLiteral("CLOSED")), "closed IME target accepted text"); + await([&] { return closing->model().finished(); }, "closed IME receiver did not finish"); + require(closing->model().cleanup_verified() && closing->model().active_processes_after() == 0 && + !closing->model().natural_exit(), "closed IME receiver was not verified cancellation"); + QTest::mouseClick(bar, Qt::LeftButton, Qt::NoModifier, bar->tabRect(0).center()); QApplication::processEvents(); + require(ime_event(first->terminal(), QString::fromUtf16(u"\u4e01")), "surviving IME target did not accept text"); + finish(first->model()); + const auto a = received(directory + QStringLiteral("/ime-tab-a.bin")); + const auto b = received(directory + QStringLiteral("/ime-tab-b.bin")); + const auto cancelled = received(directory + QStringLiteral("/ime-tab-close.bin")); + require(a == QByteArray::fromHex("41e794b2e4b899e4b881") && b == QByteArray::fromHex("e4b999") && cancelled.isEmpty(), + "IME preedit/cancelled/hidden text leaked between receiver targets"); + window.close(); + SessionModel failed; TerminalWidget failure(failed); focus(failure); + require(!failed.start((directory + QStringLiteral("/missing-ime-receiver.exe")).toStdWString(), L""), + "missing IME receiver unexpectedly started"); + await([&] { return failed.finished(); }, "failed IME target cleanup did not finish"); + require(!ime_event(failure, QStringLiteral("FAIL")) && !failed.error().isEmpty() && + failed.cleanup_verified() && failed.active_processes_after() == 0, + "failed IME target accepted input or lost cleanup/error"); + return {{"case", "focus-hide-tab-close-failure"}, {"first_hex", QString::fromLatin1(a.toHex())}, + {"second_hex", QString::fromLatin1(b.toHex())}, {"cancelled_bytes", 0}, + {"natural_receivers", 2}, {"cancelled_receivers", 1}, {"failed_target_verified", true}}; +} int main(int argc, char** argv) { SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); _set_abort_behavior(0, _WRITE_ABORT_MSG | _CALL_REPORTFAULT); @@ -258,7 +330,8 @@ int main(int argc, char** argv) { require(QFile::exists(receiver), "independent IME receiver is missing"); const QJsonObject result{{"schema", "termforge-qt-ime-input-v1"}, {"cases", QJsonArray{literal_ime_commit(receiver, workspace.path()), - limits_and_literal_boundaries(receiver, workspace.path())}}, + limits_and_literal_boundaries(receiver, workspace.path()), + context_isolation(receiver, workspace.path())}}, {"actual_os_ime", false}, {"presented_done", QJsonValue()}}; std::cout << QJsonDocument(result).toJson(QJsonDocument::Compact).constData() << '\n'; return 0; From 5c5ec66cfd1c08b9bfc1ee9a2d4d348f1c604e2b Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:52:12 +0800 Subject: [PATCH 09/31] test(qt): follow real console output and font changes during preedit (refs #155) --- src/qt/ime_input_test.cpp | 66 ++++++++++++++++++++++++++- src/qt/ime_receiver_fixture.hpp | 80 +++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 src/qt/ime_receiver_fixture.hpp diff --git a/src/qt/ime_input_test.cpp b/src/qt/ime_input_test.cpp index 2361d81b..c55680de 100644 --- a/src/qt/ime_input_test.cpp +++ b/src/qt/ime_input_test.cpp @@ -1,6 +1,7 @@ // Public Qt IME events and queries, own rendering, and an independent receiver. #include "terminal_widget.hpp" #include "session_tabs.hpp" +#include "ime_receiver_fixture.hpp" #include #include #include @@ -306,9 +307,71 @@ static QJsonObject context_isolation(const QString& receiver, const QString& dir {"second_hex", QString::fromLatin1(b.toHex())}, {"cancelled_bytes", 0}, {"natural_receivers", 2}, {"cancelled_receivers", 1}, {"failed_target_verified", true}}; } +static QRectF public_cursor(QWidget& target) { + QInputMethodQueryEvent request(Qt::ImCursorRectangle); + QApplication::sendEvent(&target, &request); + require(request.value(Qt::ImCursorRectangle).isValid(), "IME cursor query is unavailable"); + return request.value(Qt::ImCursorRectangle).toRectF(); +} +static QJsonArray rectangle(const QRectF& value) { + return {value.x(), value.y(), value.width(), value.height()}; +} +static QJsonObject output_and_geometry(const QString& directory) { + termforge::qt::ime_fixture::ControlledReceiver receiver(directory + QStringLiteral("/ime-output.bin")); + SessionModel model; TerminalWidget terminal(model); focus(terminal); + require(model.start(QCoreApplication::applicationFilePath().toStdWString(), receiver.arguments(), directory.toStdWString()), + "controlled IME receiver could not start"); + await([&] { return receiver.ready() && has_text(model.screen(), "TF_QT_IME_READY"); }, + "controlled IME receiver was not ready"); + auto* method = QGuiApplication::inputMethod(); require(method != nullptr, "Qt input method interface is missing"); + QJsonArray changed_rectangles; + QObject listener; + QObject::connect(method, &QInputMethod::cursorRectangleChanged, &listener, [&] { + changed_rectangles.append(rectangle(method->cursorRectangle())); + }); + require(ime_event(terminal, {}, QStringLiteral("live"), {{QInputMethodEvent::Cursor, 4, 1, QColor(0, 255, 255)}}), + "output preedit was rejected"); + const auto before = public_cursor(terminal); + const auto events_before_output = changed_rectangles.size(); + receiver.output(); + await([&] { return has_text(model.screen(), "TF_QT_IME_OUTPUT") && model.screen().row == 5 && model.screen().column == 12; }, + "independent child output did not reach the terminal cursor"); + const auto after_output = public_cursor(terminal); + require(after_output.x() > before.x() && after_output.y() > before.y(), + "IME anchor did not follow independent child output"); + require(changed_rectangles.size() > events_before_output, + "output did not notify the public Qt input-method cursor signal"); + require(coloured_pixels(rendered(terminal), QColor(0, 255, 255)) > 0, + "output lost the active preedit cursor"); + terminal.resize(terminal.width() + 72, terminal.height() + 44); QApplication::processEvents(); + const auto after_resize = public_cursor(terminal); + require(coloured_pixels(rendered(terminal), QColor(0, 255, 255)) > 0, + "resize lost the active preedit cursor"); + auto larger = terminal.font(); larger.setPointSize(18); terminal.setFont(larger); QApplication::processEvents(); + const auto after_font = public_cursor(terminal); + require(after_font.height() > after_resize.height() && + coloured_pixels(rendered(terminal), QColor(0, 255, 255)) > 0, + "font change did not update active preedit geometry"); + require(ime_event(terminal, QStringLiteral("OUT")), "output test IME commit was rejected"); + await([&] { return received(receiver.path) == QByteArray("OUT"); }, "controlled receiver did not receive the literal commit"); + receiver.stop(); await([&] { return model.finished(); }, "controlled IME receiver did not finish"); + require(model.natural_exit() && model.exit_code() == 0 && model.cleanup_verified() && + model.active_processes_after() == 0 && model.output_eof(), "controlled receiver cleanup was not natural and verified"); + require(!query(terminal).value("enabled").toBool(), "finished controlled target still enabled IME"); + return {{"case", "independent-output-resize-font-query"}, {"received_hex", "4f5554"}, + {"before_output", rectangle(before)}, {"after_output", rectangle(after_output)}, + {"after_resize", rectangle(after_resize)}, {"after_font", rectangle(after_font)}, + {"public_cursor_rectangle_changes", changed_rectangles}, {"receiver_exit_code", 0}}; +} int main(int argc, char** argv) { SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); _set_abort_behavior(0, _WRITE_ABORT_MSG | _CALL_REPORTFAULT); + if (argc == 4 && std::string_view(argv[1]) == "--ime-output-child") { + BOOL child_owned = FALSE; + if (!IsProcessInJob(GetCurrentProcess(), nullptr, &child_owned) || !child_owned) return 2; + QCoreApplication child(argc, argv); + return termforge::qt::ime_fixture::child(child.arguments().at(2), child.arguments().at(3)); + } 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" || @@ -331,7 +394,8 @@ int main(int argc, char** argv) { const QJsonObject result{{"schema", "termforge-qt-ime-input-v1"}, {"cases", QJsonArray{literal_ime_commit(receiver, workspace.path()), limits_and_literal_boundaries(receiver, workspace.path()), - context_isolation(receiver, workspace.path())}}, + context_isolation(receiver, workspace.path()), + output_and_geometry(workspace.path())}}, {"actual_os_ime", false}, {"presented_done", QJsonValue()}}; std::cout << QJsonDocument(result).toJson(QJsonDocument::Compact).constData() << '\n'; return 0; diff --git a/src/qt/ime_receiver_fixture.hpp b/src/qt/ime_receiver_fixture.hpp new file mode 100644 index 00000000..2e070ef1 --- /dev/null +++ b/src/qt/ime_receiver_fixture.hpp @@ -0,0 +1,80 @@ +// Test-only console source/capture; control uses owned events, never input text. +#pragma once +#include "../../experiments/shell/raw_console_receiver.hpp" +#include +#include +#include + +namespace termforge::qt::ime_fixture { +inline void check(bool value, const char* message) { + if (!value) throw std::runtime_error(message); +} +class ControlledReceiver { + std::wstring prefix_; + tf_conpty::Handle ready_, output_, stop_; + static tf_conpty::Handle make(const std::wstring& name) { + const HANDLE value = CreateEventW(nullptr, TRUE, FALSE, name.c_str()); + check(value != nullptr, "IME receiver control event creation failed"); + return tf_conpty::Handle(value); + } +public: + explicit ControlledReceiver(const QString& path) : path(path) { + prefix_ = L"Local\\TermForgeQtIme-" + QUuid::createUuid().toString(QUuid::WithoutBraces).toStdWString(); + ready_ = make(prefix_ + L"-ready"); + output_ = make(prefix_ + L"-output"); + stop_ = make(prefix_ + L"-stop"); + } + QString path; + std::wstring arguments() const { + return L"--ime-output-child \"" + path.toStdWString() + L"\" \"" + prefix_ + L"\""; + } + bool ready() const { return WaitForSingleObject(ready_.get(), 0) == WAIT_OBJECT_0; } + void output() { check(SetEvent(output_.get()) != FALSE, "IME receiver output signal failed"); } + void stop() { check(SetEvent(stop_.get()) != FALSE, "IME receiver stop signal failed"); } +}; +inline int child(const QString& path, const QString& event_prefix) { + try { + BOOL owned = FALSE; + check(IsProcessInJob(GetCurrentProcess(), nullptr, &owned) && owned, "IME receiver requires an owned Job"); + const auto prefix = event_prefix.toStdWString(); + check(prefix.rfind(L"Local\\TermForgeQtIme-", 0) == 0 && prefix.size() < 180, + "IME receiver event identity is invalid"); + check(SetConsoleCP(CP_UTF8) && SetConsoleOutputCP(CP_UTF8) && + SetConsoleMode(GetStdHandle(STD_INPUT_HANDLE), ENABLE_VIRTUAL_TERMINAL_INPUT), + "IME receiver raw console mode failed"); + const auto open = [&](const wchar_t* suffix) { + const auto value = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, (prefix + suffix).c_str()); + check(value != nullptr, "IME receiver control event is missing"); + return tf_conpty::Handle(value); + }; + auto ready = open(L"-ready"), output = open(L"-output"), stop = open(L"-stop"); + tf_shell_test::RawConsoleReceiver capture(std::filesystem::path(path.toStdWString())); + const auto write = [](const char* bytes, DWORD size) { + DWORD count = 0; + check(WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), bytes, size, &count, nullptr) && count == size, + "IME receiver output write failed"); + }; + constexpr char initial[] = "TF_QT_IME_READY\r\n"; + write(initial, sizeof(initial) - 1); + check(SetEvent(ready.get()) != FALSE, "IME receiver ready signal failed"); + bool emitted = false; + const auto deadline = GetTickCount64() + 20000; + for (;;) { + const HANDLE controls[]{stop.get(), output.get()}; + const auto state = WaitForMultipleObjects(emitted ? 1 : 2, controls, FALSE, 50); + if (state == WAIT_OBJECT_0) break; + if (state == WAIT_OBJECT_0 + 1 && !emitted) { + constexpr char body[] = "\x1b[2J\x1b[HTF_QT_IME_OUTPUT\r\n\x1b[6;13H"; + write(body, sizeof(body) - 1); emitted = true; + } else check(state == WAIT_TIMEOUT, "IME receiver control wait failed"); + check(GetTickCount64() < deadline, "IME receiver control deadline expired"); + } + capture.finish(); + return 0; + } catch (const std::exception& error) { + std::fprintf(stderr, "Qt IME controlled receiver: %s\n", error.what()); + return 18; + } +} +} + From 933f365810e1523742044dc83b1acb06f0a592c7 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:52:40 +0800 Subject: [PATCH 10/31] fix(test): initialize owned IME control handles without assignment (refs #155) --- src/qt/ime_receiver_fixture.hpp | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/qt/ime_receiver_fixture.hpp b/src/qt/ime_receiver_fixture.hpp index 2e070ef1..67d4425a 100644 --- a/src/qt/ime_receiver_fixture.hpp +++ b/src/qt/ime_receiver_fixture.hpp @@ -18,12 +18,10 @@ class ControlledReceiver { return tf_conpty::Handle(value); } public: - explicit ControlledReceiver(const QString& path) : path(path) { - prefix_ = L"Local\\TermForgeQtIme-" + QUuid::createUuid().toString(QUuid::WithoutBraces).toStdWString(); - ready_ = make(prefix_ + L"-ready"); - output_ = make(prefix_ + L"-output"); - stop_ = make(prefix_ + L"-stop"); - } + explicit ControlledReceiver(const QString& path) : + prefix_(L"Local\\TermForgeQtIme-" + QUuid::createUuid().toString(QUuid::WithoutBraces).toStdWString()), + ready_(make(prefix_ + L"-ready")), output_(make(prefix_ + L"-output")), + stop_(make(prefix_ + L"-stop")), path(path) {} QString path; std::wstring arguments() const { return L"--ime-output-child \"" + path.toStdWString() + L"\" \"" + prefix_ + L"\""; From 2ac8f8f68cbcada51c3984eab19fcc4d0689540e Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:59:22 +0800 Subject: [PATCH 11/31] test(qt): prepare native IME event observer without synthetic input (refs #155) --- src/qt/ime_input_test.cpp | 3 + src/qt/ime_native_observer.hpp | 207 +++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 src/qt/ime_native_observer.hpp diff --git a/src/qt/ime_input_test.cpp b/src/qt/ime_input_test.cpp index c55680de..45c07c9d 100644 --- a/src/qt/ime_input_test.cpp +++ b/src/qt/ime_input_test.cpp @@ -2,6 +2,7 @@ #include "terminal_widget.hpp" #include "session_tabs.hpp" #include "ime_receiver_fixture.hpp" +#include "ime_native_observer.hpp" #include #include #include @@ -384,6 +385,8 @@ int main(int argc, char** argv) { QApplication app(argc, argv); QApplication::setQuitOnLastWindowClosed(false); try { + if (app.arguments().size() == 3 && app.arguments().at(1) == QStringLiteral("--native-ime-observer")) + return termforge::qt::ime_native::run(app, app.arguments().at(2)); const auto artifacts = qEnvironmentVariable("TERMFORGE_TEST_ARTIFACTS"); if (!artifacts.isEmpty()) require(QDir().mkpath(artifacts), "IME artifact directory could not be created"); QTemporaryDir workspace(artifacts.isEmpty() ? QString() : artifacts + QStringLiteral("/qt-ime-XXXXXX")); diff --git a/src/qt/ime_native_observer.hpp b/src/qt/ime_native_observer.hpp new file mode 100644 index 00000000..1c9887b5 --- /dev/null +++ b/src/qt/ime_native_observer.hpp @@ -0,0 +1,207 @@ +// Diagnostic-only native entry: observe Qt events from an external OS driver. +#pragma once +#include "session_tabs.hpp" +#include "ime_receiver_fixture.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace termforge::qt::ime_native { +inline void check(bool value, const char* message) { + if (!value) throw std::runtime_error(message); +} +inline void save_json(const QString& path, const QJsonObject& value) { + QSaveFile file(path); + check(file.open(QIODevice::WriteOnly) && file.write(QJsonDocument(value).toJson()) > 0 && file.commit(), + "native IME evidence write failed"); +} +inline QJsonArray rect(const QRectF& value) { + return {value.x(), value.y(), value.width(), value.height()}; +} +inline QString units(const QString& value) { + QByteArray bytes; + for (const auto unit : value) { + bytes.append(static_cast(unit.unicode() & 255)); + bytes.append(static_cast(unit.unicode() >> 8)); + } + return QString::fromLatin1(bytes.toHex()); +} +class Observation final : public QObject { + QPointer page_; + QString directory_, label_; + QJsonArray events_; + bool evidence_gap_ = false, write_failed_ = false; + QJsonObject target() const { + if (!page_) return {{"target_alive", false}}; + const auto& model = page_->model(); + const auto& terminal = page_->terminal(); + return {{"target_alive", true}, {"visible", terminal.isVisible()}, {"has_focus", terminal.hasFocus()}, + {"ime_enabled", terminal.inputMethodQuery(Qt::ImEnabled).toBool()}, + {"cursor_rectangle_logical", rect(terminal.inputMethodQuery(Qt::ImCursorRectangle).toRectF())}, + {"grid_columns", model.screen().width()}, {"grid_rows", model.screen().height()}, + {"terminal_column", model.screen().column}, {"terminal_row", model.screen().row}, + {"requested_widget_font_family", terminal.font().family()}, + {"requested_widget_font_points", terminal.font().pointSizeF()}, + {"running", model.running()}, {"finished", model.finished()}, + {"natural_exit", model.natural_exit()}, {"cleanup_verified", model.cleanup_verified()}, + {"error", model.error()}}; + } + void write() { + try { + save_json(directory_ + QLatin1Char('/') + label_ + QStringLiteral("-events.json"), + {{"schema", "termforge-qt-ime-native-events-v1"}, {"target", label_}, + {"synthetic_qt_input_events_sent", 0}, + {"event_filter_phase", "before widget handler"}, + {"spontaneous_flag_is_not_origin_proof", true}, + {"evidence_gap", evidence_gap_}, {"events", events_}, {"current", target()}}); + } catch (...) { write_failed_ = true; } + } + void append(QJsonObject event) { + if (events_.size() >= 128) { evidence_gap_ = true; write(); return; } + event.insert("sequence", events_.size()); + events_.append(event); write(); + } +protected: + bool eventFilter(QObject*, QEvent* event) override { + if (event->type() == QEvent::InputMethod) { + const auto* input = static_cast(event); + QJsonObject row{{"kind", "input-method"}, {"preedit_units", input->preeditString().size()}, + {"commit_units", input->commitString().size()}, + {"replacement_start", input->replacementStart()}, {"replacement_length", input->replacementLength()}, + {"spontaneous", event->spontaneous()}}; + if (input->preeditString().size() > 256 || input->commitString().size() > 256 || + input->attributes().size() > 64) evidence_gap_ = true; + else { + row.insert("preedit_utf16le_hex", units(input->preeditString())); + row.insert("commit_utf16le_hex", units(input->commitString())); + QJsonArray attributes; + for (const auto& attribute : input->attributes()) { + QJsonObject item{{"type", static_cast(attribute.type)}, {"start", attribute.start}, + {"length", attribute.length}}; + if (attribute.type == QInputMethodEvent::Cursor && attribute.value.canConvert()) + item.insert("colour", attribute.value.value().name(QColor::HexArgb)); + if (attribute.type == QInputMethodEvent::TextFormat && attribute.value.canConvert()) { + const auto format = attribute.value.value().toCharFormat(); + item.insert("foreground", format.foreground().color().name(QColor::HexArgb)); + item.insert("background", format.background().color().name(QColor::HexArgb)); + item.insert("underline", format.fontUnderline()); + } + attributes.append(item); + } + row.insert("attributes", attributes); + } + const auto sequence = events_.size(); + append(row); + // A queued frame can include newer events. Record that explicitly; + // never label a coalesced image as this event's exact rendered state. + QTimer::singleShot(0, this, [this, sequence] { + if (!page_) return; + QImage image(page_->terminal().size(), QImage::Format_ARGB32); + image.fill(Qt::transparent); page_->terminal().render(&image); + const auto name = label_ + QStringLiteral("-frame-%1.png").arg(sequence); + if (!image.save(directory_ + QLatin1Char('/') + name)) write_failed_ = true; + append({{"kind", "queued-frame"}, {"input_sequence", sequence}, + {"events_seen_before_frame", events_.size()}, {"image", name}, {"state", target()}}); + }); + } else if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease) { + const auto* key = static_cast(event); + QJsonObject row{{"kind", event->type() == QEvent::KeyPress ? "key-press" : "key-release"}, + {"key", key->key()}, {"native_virtual_key", static_cast(key->nativeVirtualKey())}, + {"native_scan_code", static_cast(key->nativeScanCode())}, + {"spontaneous", event->spontaneous()}}; + if (key->text().size() > 256) evidence_gap_ = true; + else row.insert("text_utf16le_hex", units(key->text())); + append(row); + } else if (event->type() == QEvent::FocusIn || event->type() == QEvent::FocusOut || + event->type() == QEvent::Hide || event->type() == QEvent::Close || + event->type() == QEvent::Resize || event->type() == QEvent::FontChange) { + append({{"kind", "widget-event"}, {"type", static_cast(event->type())}, {"before_handler", target()}}); + } + return false; + } +public: + Observation(SessionPage* page, const QString& directory, const QString& label, QObject* parent) : + QObject(parent), page_(page), directory_(directory), label_(label) { + check(page != nullptr, "native IME page is missing"); + page->terminal().installEventFilter(this); write(); + } + bool valid() const { return !evidence_gap_ && !write_failed_; } + QJsonObject snapshot() { write(); return target(); } +}; +inline int run(QApplication& app, const QString& directory) { + check(QGuiApplication::platformName() == QStringLiteral("windows"), + "native IME observer requires the windows platform plugin"); + check(!QFileInfo::exists(directory) && QDir().mkpath(directory), "native IME output directory must be new"); + ime_fixture::ControlledReceiver a(directory + QStringLiteral("/native-a.bin")); + ime_fixture::ControlledReceiver b(directory + QStringLiteral("/native-b.bin")); + SessionTabs window(QCoreApplication::applicationFilePath().toStdWString(), a.arguments(), directory.toStdWString()); + QPointer first = window.current_session(); + QPointer second = window.new_session(QCoreApplication::applicationFilePath().toStdWString(), + b.arguments(), directory.toStdWString()); + window.setWindowTitle(QStringLiteral("TermForge Qt IME native observer")); + window.resize(960, 640); window.show(); + auto* tabs = window.findChild(); check(tabs != nullptr, "native IME tab bar is missing"); + tabs->setCurrentIndex(0); first->terminal().setFocus(); + Observation first_events(first, directory, QStringLiteral("a"), &window); + Observation second_events(second, directory, QStringLiteral("b"), &window); + bool ready_written = false, finish_requested = false, failed = false; + QString failure; + QElapsedTimer elapsed; elapsed.start(); + QObject lifetime; + QTimer timer; + const auto state = [&] { + return QJsonObject{{"schema", "termforge-qt-ime-native-observer-v1"}, + {"qt_platform", QGuiApplication::platformName()}, + {"synthetic_qt_input_events_sent", 0}, {"external_os_input_required", true}, + {"native_os_ime_acceptance", QJsonValue()}, {"presented_done", QJsonValue()}, + {"ready", ready_written}, {"finish_requested", finish_requested}, + {"failed", failed}, {"failure", failure}, {"elapsed_ms", elapsed.elapsed()}, + {"a", first_events.snapshot()}, {"b", second_events.snapshot()}}; + }; + QObject::connect(&app, &QCoreApplication::aboutToQuit, &lifetime, [&] { + save_json(directory + QStringLiteral("/about-to-quit.json"), state()); + }); + QObject::connect(&timer, &QTimer::timeout, &lifetime, [&] { + try { + if (!ready_written && first && second && a.ready() && b.ready()) { + ready_written = true; + save_json(directory + QStringLiteral("/ready.json"), state()); + } + if (!finish_requested && QFileInfo::exists(directory + QStringLiteral("/output.request"))) a.output(); + if (!finish_requested && QFileInfo::exists(directory + QStringLiteral("/font.request")) && first) { + auto font = first->terminal().font(); + if (font.pointSize() != 18) { font.setPointSize(18); first->terminal().setFont(font); } + } + if (!finish_requested && QFileInfo::exists(directory + QStringLiteral("/finish.request"))) { + finish_requested = true; a.stop(); b.stop(); + } + if (!first_events.valid() || !second_events.valid()) { + failed = true; failure = QStringLiteral("native observer evidence gap"); window.close(); + } else if (elapsed.elapsed() > 20000 && !finish_requested) { + failed = true; failure = QStringLiteral("native observer execution deadline"); window.close(); + } + } catch (const std::exception& error) { + failed = true; failure = QString::fromUtf8(error.what()); window.close(); + } + }); + timer.start(50); + const auto result = app.exec(); + const bool complete = !failed && ready_written && finish_requested && first && second && + first->model().natural_exit() && second->model().natural_exit() && + first->model().cleanup_verified() && second->model().cleanup_verified(); + auto final = state(); final.insert("application_exit_code", result); final.insert("observer_complete", complete); + save_json(directory + QStringLiteral("/complete.json"), final); + std::cout << QJsonDocument(final).toJson(QJsonDocument::Compact).constData() << '\n'; + return complete && result == 0 ? 0 : 1; +} +} + From d4387ab3ad601ff38b02408b086e65ef237fae2c Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:01:55 +0800 Subject: [PATCH 12/31] test(qt): expose no-op IME attribute overflow and composing interrupt loss (refs #155) --- src/qt/ime_input_test.cpp | 49 +++++++++++++++++++++++++++++++++- src/qt/ime_native_observer.hpp | 3 +++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/qt/ime_input_test.cpp b/src/qt/ime_input_test.cpp index 45c07c9d..75f4b65d 100644 --- a/src/qt/ime_input_test.cpp +++ b/src/qt/ime_input_test.cpp @@ -364,6 +364,52 @@ static QJsonObject output_and_geometry(const QString& directory) { {"after_resize", rectangle(after_resize)}, {"after_font", rectangle(after_font)}, {"public_cursor_rectangle_changes", changed_rectangles}, {"receiver_exit_code", 0}}; } +static QJsonObject attribute_capacity_and_interrupt(const QString& receiver, const QString& directory) { + const auto path = directory + QStringLiteral("/ime-guards.bin"); + SessionModel model; TerminalWidget terminal(model); focus(terminal); + require(model.start(receiver.toStdWString(), (QStringLiteral("\"") + path + QLatin1Char('"')).toStdWString(), + directory.toStdWString()), "IME guard receiver could not start"); + await([&] { return has_text(model.screen(), "TF_KEY_READY"); }, "IME guard receiver was not ready"); + require(key_text(terminal, QStringLiteral("L")), "guard prefix was rejected"); + QTextCharFormat format; format.setFontUnderline(true); + QList normal; + for (int index = 0; index < 256; ++index) normal.append({QInputMethodEvent::TextFormat, index, 1, format}); + normal.append({QInputMethodEvent::Cursor, 128, 1, QColor(0, 255, 255)}); + normal.append({QInputMethodEvent::Selection, 0, 0, QVariant()}); + const bool normal_accepted = ime_event(terminal, {}, QString(256, QLatin1Char('x')), normal); + ime_event(terminal, {}); + QList formats, selections; + for (int index = 0; index < 4096; ++index) { + formats.append({QInputMethodEvent::TextFormat, 0, 0, format}); + selections.append({QInputMethodEvent::Selection, 0, 0, QVariant()}); + } + const bool formats_accepted = ime_event(terminal, QStringLiteral("BAD"), QStringLiteral("x"), formats); + ime_event(terminal, {}); + const bool selections_accepted = ime_event(terminal, QStringLiteral("SEL_BAD"), QStringLiteral("x"), selections); + ime_event(terminal, {}); + require(ime_event(terminal, {}, QStringLiteral("ctrl")), "interrupt preedit was rejected"); + QKeyEvent interrupt(QEvent::KeyPress, Qt::Key_C, Qt::ControlModifier, QString(QChar(3))); + interrupt.setAccepted(false); QApplication::sendEvent(&terminal, &interrupt); + ime_event(terminal, {}); + require(key_text(terminal, QStringLiteral("R")), "guard suffix was rejected"); + finish(model); + const auto actual = received(path); + const QJsonObject observation{{"case", "attribute-capacity-and-composing-interrupt"}, + {"normal_attribute_count", 258}, {"normal_accepted", normal_accepted}, + {"noop_attribute_count", 4096}, {"noop_formats_accepted", formats_accepted}, + {"noop_selections_accepted", selections_accepted}, {"composing_ctrl_c_accepted", interrupt.isAccepted()}, + {"received_hex", QString::fromLatin1(actual.toHex())}, {"expected_hex", "4c0352"}, + {"receiver_natural_exit", model.natural_exit()}, {"receiver_cleanup_verified", model.cleanup_verified()}}; + QFile saved(directory + QStringLiteral("/guards-observation.json")); + require(saved.open(QIODevice::WriteOnly) && saved.write(QJsonDocument(observation).toJson()) > 0, + "IME guard observation could not be retained"); + std::cerr << "ime guards=" << QJsonDocument(observation).toJson(QJsonDocument::Compact).constData() << '\n'; + require(normal_accepted && !formats_accepted && !selections_accepted, + "IME no-op attribute list was not rejected without weakening normal formats"); + require(interrupt.isAccepted() && actual == QByteArray::fromHex("4c0352"), + "composing IME swallowed Ctrl-C or leaked rejected transaction bytes"); + return observation; +} int main(int argc, char** argv) { SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); _set_abort_behavior(0, _WRITE_ABORT_MSG | _CALL_REPORTFAULT); @@ -398,7 +444,8 @@ int main(int argc, char** argv) { {"cases", QJsonArray{literal_ime_commit(receiver, workspace.path()), limits_and_literal_boundaries(receiver, workspace.path()), context_isolation(receiver, workspace.path()), - output_and_geometry(workspace.path())}}, + output_and_geometry(workspace.path()), + attribute_capacity_and_interrupt(receiver, workspace.path())}}, {"actual_os_ime", false}, {"presented_done", QJsonValue()}}; std::cout << QJsonDocument(result).toJson(QJsonDocument::Compact).constData() << '\n'; return 0; diff --git a/src/qt/ime_native_observer.hpp b/src/qt/ime_native_observer.hpp index 1c9887b5..4a7e96a2 100644 --- a/src/qt/ime_native_observer.hpp +++ b/src/qt/ime_native_observer.hpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #include #include @@ -14,6 +16,7 @@ #include #include #include +#include namespace termforge::qt::ime_native { inline void check(bool value, const char* message) { From 9d9246a8d520f148247e2447c776f921a4ba8f47 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:03:30 +0800 Subject: [PATCH 13/31] fix(qt): bound no-op IME attribute work before accepting commits (refs #155) --- src/qt/terminal_widget.hpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/qt/terminal_widget.hpp b/src/qt/terminal_widget.hpp index e7340c5c..ab4f6cce 100644 --- a/src/qt/terminal_widget.hpp +++ b/src/qt/terminal_widget.hpp @@ -35,6 +35,10 @@ class TerminalWidget final : public QWidget { } composition_; bool resetting_input_method_ = false; static constexpr int margin = 8; + static constexpr int maximum_ime_units = 256; + // At most one nonempty format per unit, one cursor and one selection. + // No-op attributes share this budget rather than growing work or storage. + static constexpr int maximum_ime_attributes = maximum_ime_units + 2; int cell_width_ = 9; int cell_height_ = 19; bool grid_ready_ = false; @@ -200,7 +204,7 @@ class TerminalWidget final : public QWidget { } else event->ignore(); } static QByteArray literal_ime_text(const QString& text) { - if (text.size() > 256) throw ImeInputError("IME text exceeds 256 UTF-16 units"); + if (text.size() > maximum_ime_units) throw ImeInputError("IME text exceeds 256 UTF-16 units"); for (qsizetype index = 0; index < text.size(); ++index) { const auto unit = text.at(index); if (unit.isHighSurrogate()) { @@ -217,12 +221,14 @@ class TerminalWidget final : public QWidget { (position == 0 || position == text.size() || !text.at(position).isLowSurrogate()); } static Composition composition_for(const QInputMethodEvent& event) { + if (event.attributes().size() > maximum_ime_attributes) + throw ImeInputError("IME attributes exceed the bounded format budget"); Composition next; next.text = event.preeditString(); (void)literal_ime_text(next.text); next.cursor = static_cast(next.text.size()); bool cursor_seen = false; - std::array formatted{}; + std::array formatted{}; for (const auto& attribute : event.attributes()) { if (attribute.type == QInputMethodEvent::TextFormat) { if (attribute.start < 0 || attribute.length < 0 || attribute.start > next.text.size() || @@ -233,6 +239,7 @@ class TerminalWidget final : public QWidget { throw ImeInputError("IME text format range is invalid"); const auto value = attribute.value.value(); if (!value.isCharFormat()) throw ImeInputError("IME text format type is invalid"); + if (!attribute.length) continue; for (int index = attribute.start; index < attribute.start + attribute.length; ++index) { if (formatted[static_cast(index)]) throw ImeInputError("IME text formats overlap"); formatted[static_cast(index)] = true; From 4667d12de76e445d8ea64f728843e6f9edb70d96 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:03:32 +0800 Subject: [PATCH 14/31] fix(qt): preserve the supported interrupt during composition (refs #155) --- src/qt/ime_native_observer.hpp | 2 +- src/qt/terminal_widget.hpp | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/qt/ime_native_observer.hpp b/src/qt/ime_native_observer.hpp index 4a7e96a2..5c091819 100644 --- a/src/qt/ime_native_observer.hpp +++ b/src/qt/ime_native_observer.hpp @@ -82,7 +82,7 @@ class Observation final : public QObject { {"replacement_start", input->replacementStart()}, {"replacement_length", input->replacementLength()}, {"spontaneous", event->spontaneous()}}; if (input->preeditString().size() > 256 || input->commitString().size() > 256 || - input->attributes().size() > 64) evidence_gap_ = true; + input->attributes().size() > 258) evidence_gap_ = true; else { row.insert("preedit_utf16le_hex", units(input->preeditString())); row.insert("commit_utf16le_hex", units(input->commitString())); diff --git a/src/qt/terminal_widget.hpp b/src/qt/terminal_widget.hpp index ab4f6cce..11aede1b 100644 --- a/src/qt/terminal_widget.hpp +++ b/src/qt/terminal_widget.hpp @@ -170,11 +170,12 @@ class TerminalWidget final : public QWidget { } // The input context owns composing keystrokes; they must not become a // second text path into the child while preedit is still active. - if (!composition_.text.isEmpty()) { QWidget::keyPressEvent(event); return; } + const bool interrupt = event->modifiers() == Qt::ControlModifier && event->key() == Qt::Key_C; + if (!composition_.text.isEmpty() && !interrupt) { QWidget::keyPressEvent(event); return; } std::string bytes; auto next = keyboard_; bool character_text = false; - if (event->modifiers() == Qt::ControlModifier && event->key() == Qt::Key_C) bytes = std::string(1, '\x03'); + if (interrupt) bytes = std::string(1, '\x03'); else if (event->modifiers().testFlag(Qt::ControlModifier) || event->modifiers().testFlag(Qt::AltModifier)) { QWidget::keyPressEvent(event); return; } else { From 16878ab7de361e81c9d6c3c0afa570b9f215fdc9 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:06:01 +0800 Subject: [PATCH 15/31] test(qt): bind native readiness, coalescing and verified exit states (refs #155) --- src/qt/ime_native_observer.hpp | 37 +++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/qt/ime_native_observer.hpp b/src/qt/ime_native_observer.hpp index 5c091819..f8fea22e 100644 --- a/src/qt/ime_native_observer.hpp +++ b/src/qt/ime_native_observer.hpp @@ -108,18 +108,24 @@ class Observation final : public QObject { // never label a coalesced image as this event's exact rendered state. QTimer::singleShot(0, this, [this, sequence] { if (!page_) return; + bool coalesced_input = false; + for (qsizetype index = sequence + 1; index < events_.size(); ++index) + if (events_.at(index).toObject().value("kind").toString() == QStringLiteral("input-method")) + coalesced_input = true; QImage image(page_->terminal().size(), QImage::Format_ARGB32); image.fill(Qt::transparent); page_->terminal().render(&image); const auto name = label_ + QStringLiteral("-frame-%1.png").arg(sequence); if (!image.save(directory_ + QLatin1Char('/') + name)) write_failed_ = true; append({{"kind", "queued-frame"}, {"input_sequence", sequence}, - {"events_seen_before_frame", events_.size()}, {"image", name}, {"state", target()}}); + {"events_seen_before_frame", events_.size()}, {"coalesced_input_updates", coalesced_input}, + {"image", name}, {"state", target()}}); }); } else if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease) { const auto* key = static_cast(event); QJsonObject row{{"kind", event->type() == QEvent::KeyPress ? "key-press" : "key-release"}, {"key", key->key()}, {"native_virtual_key", static_cast(key->nativeVirtualKey())}, {"native_scan_code", static_cast(key->nativeScanCode())}, + {"modifiers", key->modifiers().toInt()}, {"auto_repeat", key->isAutoRepeat()}, {"spontaneous", event->spontaneous()}}; if (key->text().size() > 256) evidence_gap_ = true; else row.insert("text_utf16le_hex", units(key->text())); @@ -140,6 +146,15 @@ class Observation final : public QObject { bool valid() const { return !evidence_gap_ && !write_failed_; } QJsonObject snapshot() { write(); return target(); } }; +inline bool ready_text(const QPointer& page) { + if (!page || !page->model().running()) return false; + for (const auto& row : page->model().screen().lines) { + std::string text; + for (const auto& cell : row) if (cell.width) text += cell.text; + if (text.find("TF_QT_IME_READY") != std::string::npos) return true; + } + return false; +} inline int run(QApplication& app, const QString& directory) { check(QGuiApplication::platformName() == QStringLiteral("windows"), "native IME observer requires the windows platform plugin"); @@ -156,7 +171,7 @@ inline int run(QApplication& app, const QString& directory) { tabs->setCurrentIndex(0); first->terminal().setFocus(); Observation first_events(first, directory, QStringLiteral("a"), &window); Observation second_events(second, directory, QStringLiteral("b"), &window); - bool ready_written = false, finish_requested = false, failed = false; + bool ready_written = false, finish_requested = false, output_requested = false, failed = false; QString failure; QElapsedTimer elapsed; elapsed.start(); QObject lifetime; @@ -171,15 +186,18 @@ inline int run(QApplication& app, const QString& directory) { {"a", first_events.snapshot()}, {"b", second_events.snapshot()}}; }; QObject::connect(&app, &QCoreApplication::aboutToQuit, &lifetime, [&] { - save_json(directory + QStringLiteral("/about-to-quit.json"), state()); + try { save_json(directory + QStringLiteral("/about-to-quit.json"), state()); } + catch (const std::exception& error) { failed = true; failure = QString::fromUtf8(error.what()); } }); QObject::connect(&timer, &QTimer::timeout, &lifetime, [&] { try { - if (!ready_written && first && second && a.ready() && b.ready()) { + if (!ready_written && a.ready() && b.ready() && ready_text(first) && ready_text(second)) { ready_written = true; save_json(directory + QStringLiteral("/ready.json"), state()); } - if (!finish_requested && QFileInfo::exists(directory + QStringLiteral("/output.request"))) a.output(); + if (!finish_requested && !output_requested && QFileInfo::exists(directory + QStringLiteral("/output.request"))) { + output_requested = true; a.output(); + } if (!finish_requested && QFileInfo::exists(directory + QStringLiteral("/font.request")) && first) { auto font = first->terminal().font(); if (font.pointSize() != 18) { font.setPointSize(18); first->terminal().setFont(font); } @@ -198,10 +216,15 @@ inline int run(QApplication& app, const QString& directory) { }); timer.start(50); const auto result = app.exec(); + auto final = state(); const bool complete = !failed && ready_written && finish_requested && first && second && first->model().natural_exit() && second->model().natural_exit() && - first->model().cleanup_verified() && second->model().cleanup_verified(); - auto final = state(); final.insert("application_exit_code", result); final.insert("observer_complete", complete); + first->model().exit_code() == 0 && second->model().exit_code() == 0 && + first->model().output_eof() && second->model().output_eof() && + first->model().active_processes_after() == 0 && second->model().active_processes_after() == 0 && + first->model().cleanup_verified() && second->model().cleanup_verified() && + first_events.valid() && second_events.valid(); + final.insert("application_exit_code", result); final.insert("observer_complete", complete); save_json(directory + QStringLiteral("/complete.json"), final); std::cout << QJsonDocument(final).toJson(QJsonDocument::Compact).constData() << '\n'; return complete && result == 0 ? 0 : 1; From 1a9612f097dc4c7123ec3b180b154d09a349d3a3 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:08:47 +0800 Subject: [PATCH 16/31] test(qt): expose native driver geometry and interrupt event lineage (refs #155) --- src/qt/ime_native_observer.hpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/qt/ime_native_observer.hpp b/src/qt/ime_native_observer.hpp index f8fea22e..397ee77c 100644 --- a/src/qt/ime_native_observer.hpp +++ b/src/qt/ime_native_observer.hpp @@ -120,11 +120,15 @@ class Observation final : public QObject { {"events_seen_before_frame", events_.size()}, {"coalesced_input_updates", coalesced_input}, {"image", name}, {"state", target()}}); }); - } else if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease) { + } else if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease || + event->type() == QEvent::ShortcutOverride) { const auto* key = static_cast(event); - QJsonObject row{{"kind", event->type() == QEvent::KeyPress ? "key-press" : "key-release"}, + const auto kind = event->type() == QEvent::ShortcutOverride ? "shortcut-override" : + event->type() == QEvent::KeyPress ? "key-press" : "key-release"; + QJsonObject row{{"kind", kind}, {"key", key->key()}, {"native_virtual_key", static_cast(key->nativeVirtualKey())}, {"native_scan_code", static_cast(key->nativeScanCode())}, + {"native_modifiers", static_cast(key->nativeModifiers())}, {"modifiers", key->modifiers().toInt()}, {"auto_repeat", key->isAutoRepeat()}, {"spontaneous", event->spontaneous()}}; if (key->text().size() > 256) evidence_gap_ = true; @@ -177,8 +181,15 @@ inline int run(QApplication& app, const QString& directory) { QObject lifetime; QTimer timer; const auto state = [&] { + QJsonArray tab_rectangles; + for (int index = 0; index < tabs->count(); ++index) + tab_rectangles.append(rect(QRectF(tabs->tabRect(index).translated(tabs->mapTo(&window, QPoint()))))); return QJsonObject{{"schema", "termforge-qt-ime-native-observer-v1"}, {"qt_platform", QGuiApplication::platformName()}, + {"input_method_locale", QGuiApplication::inputMethod()->locale().name()}, + {"window_device_pixel_ratio", window.devicePixelRatioF()}, + {"window_client_rectangle_logical", rect(window.rect())}, + {"current_tab_index", tabs->currentIndex()}, {"tab_rectangles_window_logical", tab_rectangles}, {"synthetic_qt_input_events_sent", 0}, {"external_os_input_required", true}, {"native_os_ime_acceptance", QJsonValue()}, {"presented_done", QJsonValue()}, {"ready", ready_written}, {"finish_requested", finish_requested}, From c64208f5b8a2899eef6dcbdfa6045b31c5b42a01 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:14:18 +0800 Subject: [PATCH 17/31] test(qt): separate the declared native IME budget from offscreen tests (refs #155) --- .github/workflows/qt-cpp.yml | 7 +++++-- src/qt/ime_input_test.cpp | 7 +++++-- src/qt/ime_native_observer.hpp | 8 +++++--- src/qt/ime_receiver_fixture.hpp | 16 +++++++++++----- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/.github/workflows/qt-cpp.yml b/.github/workflows/qt-cpp.yml index cf5d64d4..4858dd25 100644 --- a/.github/workflows/qt-cpp.yml +++ b/.github/workflows/qt-cpp.yml @@ -42,7 +42,7 @@ jobs: "-DTERMFORGE_CONPTY_BACKEND_DIR=$dependencyRoot/conpty" ` "-DTERMFORGE_MSVC_RUNTIME_DIR=$dependencyRoot/crt" - name: Build Qt product and independent smoke - run: cmake --build build/qt-cpp --config Release --target termforge_qt_shell qt_cpp_shell_smoke qt_cpp_resize_smoke qt_unicode_input_test --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_ime_input_test --parallel 2 - name: Record complete path-free runtime manifests run: | python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-bin/Release --executable termforge_qt_shell.exe @@ -53,6 +53,8 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-unicode-bin/Release --executable qt_unicode_input_test.exe if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-ime-bin/Release --executable qt_ime_input_test.exe + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Preserve runtime file identities without binaries if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -65,8 +67,9 @@ jobs: build/qt-cpp/qt-smoke-bin/Release/runtime-manifest.json build/qt-cpp/qt-resize-bin/Release/runtime-manifest.json build/qt-cpp/qt-unicode-bin/Release/runtime-manifest.json + build/qt-cpp/qt-ime-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)$' --no-tests=error --verbose + run: ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_(unicode|ime)_input)$' --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/src/qt/ime_input_test.cpp b/src/qt/ime_input_test.cpp index 75f4b65d..3e6fed87 100644 --- a/src/qt/ime_input_test.cpp +++ b/src/qt/ime_input_test.cpp @@ -413,11 +413,14 @@ static QJsonObject attribute_capacity_and_interrupt(const QString& receiver, con int main(int argc, char** argv) { SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); _set_abort_behavior(0, _WRITE_ABORT_MSG | _CALL_REPORTFAULT); - if (argc == 4 && std::string_view(argv[1]) == "--ime-output-child") { + if (argc == 5 && std::string_view(argv[1]) == "--ime-output-child") { BOOL child_owned = FALSE; if (!IsProcessInJob(GetCurrentProcess(), nullptr, &child_owned) || !child_owned) return 2; QCoreApplication child(argc, argv); - return termforge::qt::ime_fixture::child(child.arguments().at(2), child.arguments().at(3)); + bool valid_budget = false; + const auto budget = child.arguments().at(4).toUInt(&valid_budget); + if (!valid_budget || budget < 1000 || budget > 60000) return 2; + return termforge::qt::ime_fixture::child(child.arguments().at(2), child.arguments().at(3), budget); } char gate[3]{}; DWORD count = 0; BOOL owned = FALSE; if (!ReadFile(GetStdHandle(STD_INPUT_HANDLE), gate, sizeof(gate), &count, nullptr) || diff --git a/src/qt/ime_native_observer.hpp b/src/qt/ime_native_observer.hpp index 397ee77c..e2baf174 100644 --- a/src/qt/ime_native_observer.hpp +++ b/src/qt/ime_native_observer.hpp @@ -71,6 +71,7 @@ class Observation final : public QObject { void append(QJsonObject event) { if (events_.size() >= 128) { evidence_gap_ = true; write(); return; } event.insert("sequence", events_.size()); + event.insert("observation_tick_ms", static_cast(GetTickCount64())); events_.append(event); write(); } protected: @@ -163,8 +164,8 @@ inline int run(QApplication& app, const QString& directory) { check(QGuiApplication::platformName() == QStringLiteral("windows"), "native IME observer requires the windows platform plugin"); check(!QFileInfo::exists(directory) && QDir().mkpath(directory), "native IME output directory must be new"); - ime_fixture::ControlledReceiver a(directory + QStringLiteral("/native-a.bin")); - ime_fixture::ControlledReceiver b(directory + QStringLiteral("/native-b.bin")); + ime_fixture::ControlledReceiver a(directory + QStringLiteral("/native-a.bin"), 50000); + ime_fixture::ControlledReceiver b(directory + QStringLiteral("/native-b.bin"), 50000); SessionTabs window(QCoreApplication::applicationFilePath().toStdWString(), a.arguments(), directory.toStdWString()); QPointer first = window.current_session(); QPointer second = window.new_session(QCoreApplication::applicationFilePath().toStdWString(), @@ -191,6 +192,7 @@ inline int run(QApplication& app, const QString& directory) { {"window_client_rectangle_logical", rect(window.rect())}, {"current_tab_index", tabs->currentIndex()}, {"tab_rectangles_window_logical", tab_rectangles}, {"synthetic_qt_input_events_sent", 0}, {"external_os_input_required", true}, + {"observer_budget_ms", 45000}, {"receiver_budget_ms", 50000}, {"native_os_ime_acceptance", QJsonValue()}, {"presented_done", QJsonValue()}, {"ready", ready_written}, {"finish_requested", finish_requested}, {"failed", failed}, {"failure", failure}, {"elapsed_ms", elapsed.elapsed()}, @@ -218,7 +220,7 @@ inline int run(QApplication& app, const QString& directory) { } if (!first_events.valid() || !second_events.valid()) { failed = true; failure = QStringLiteral("native observer evidence gap"); window.close(); - } else if (elapsed.elapsed() > 20000 && !finish_requested) { + } else if (elapsed.elapsed() > 45000 && !finish_requested) { failed = true; failure = QStringLiteral("native observer execution deadline"); window.close(); } } catch (const std::exception& error) { diff --git a/src/qt/ime_receiver_fixture.hpp b/src/qt/ime_receiver_fixture.hpp index 67d4425a..eacc5940 100644 --- a/src/qt/ime_receiver_fixture.hpp +++ b/src/qt/ime_receiver_fixture.hpp @@ -4,6 +4,7 @@ #include #include #include +#include namespace termforge::qt::ime_fixture { inline void check(bool value, const char* message) { @@ -11,6 +12,7 @@ inline void check(bool value, const char* message) { } class ControlledReceiver { std::wstring prefix_; + unsigned lifetime_ms_; tf_conpty::Handle ready_, output_, stop_; static tf_conpty::Handle make(const std::wstring& name) { const HANDLE value = CreateEventW(nullptr, TRUE, FALSE, name.c_str()); @@ -18,22 +20,26 @@ class ControlledReceiver { return tf_conpty::Handle(value); } public: - explicit ControlledReceiver(const QString& path) : + explicit ControlledReceiver(const QString& path, unsigned lifetime_ms = 20000) : prefix_(L"Local\\TermForgeQtIme-" + QUuid::createUuid().toString(QUuid::WithoutBraces).toStdWString()), + lifetime_ms_(lifetime_ms), ready_(make(prefix_ + L"-ready")), output_(make(prefix_ + L"-output")), - stop_(make(prefix_ + L"-stop")), path(path) {} + stop_(make(prefix_ + L"-stop")), path(path) { + check(lifetime_ms_ >= 1000 && lifetime_ms_ <= 60000, "IME receiver lifetime is invalid"); + } QString path; std::wstring arguments() const { - return L"--ime-output-child \"" + path.toStdWString() + L"\" \"" + prefix_ + L"\""; + return L"--ime-output-child \"" + path.toStdWString() + L"\" \"" + prefix_ + L"\" " + std::to_wstring(lifetime_ms_); } bool ready() const { return WaitForSingleObject(ready_.get(), 0) == WAIT_OBJECT_0; } void output() { check(SetEvent(output_.get()) != FALSE, "IME receiver output signal failed"); } void stop() { check(SetEvent(stop_.get()) != FALSE, "IME receiver stop signal failed"); } }; -inline int child(const QString& path, const QString& event_prefix) { +inline int child(const QString& path, const QString& event_prefix, unsigned lifetime_ms) { try { BOOL owned = FALSE; check(IsProcessInJob(GetCurrentProcess(), nullptr, &owned) && owned, "IME receiver requires an owned Job"); + check(lifetime_ms >= 1000 && lifetime_ms <= 60000, "IME receiver lifetime is invalid"); const auto prefix = event_prefix.toStdWString(); check(prefix.rfind(L"Local\\TermForgeQtIme-", 0) == 0 && prefix.size() < 180, "IME receiver event identity is invalid"); @@ -56,7 +62,7 @@ inline int child(const QString& path, const QString& event_prefix) { write(initial, sizeof(initial) - 1); check(SetEvent(ready.get()) != FALSE, "IME receiver ready signal failed"); bool emitted = false; - const auto deadline = GetTickCount64() + 20000; + const auto deadline = GetTickCount64() + lifetime_ms; for (;;) { const HANDLE controls[]{stop.get(), output.get()}; const auto state = WaitForMultipleObjects(emitted ? 1 : 2, controls, FALSE, 50); From 04f946db3e2269769f919148ab3ad7add2d12bb4 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:15:28 +0800 Subject: [PATCH 18/31] docs(qt): declare native Pinyin N1 origin, target, byte and cleanup checks (refs #155) --- .../qt-ime-native-observation-plan.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/research/qt-ime-native-observation-plan.md diff --git a/docs/research/qt-ime-native-observation-plan.md b/docs/research/qt-ime-native-observation-plan.md new file mode 100644 index 00000000..94420be8 --- /dev/null +++ b/docs/research/qt-ime-native-observation-plan.md @@ -0,0 +1,46 @@ +# Qt IME 原生观察 N1(#155,待执行) + +状态:**提案/待root快审,实际native运行次数0,完整CTest次数0**。它只接续 #155,不改变 [R2](../milestones/R2-local-terminal.md)、原版优先规则或性能容差。公开事件 focused 原件位于本切片私有工作目录;只在源码、运行包和本计划经审查后由 root 独占隔离 Windows 11 VM 执行。 + +## 固定入口与来源 + +入口为 qt_ime_input_test.exe --native-ime-observer 。从既有 stdin 精确 GO+LF、OwnedJob 检查之后创建 QApplication;native分支立即进入观察器,不调用默认测试数组的 QInputMethodEvent/QKeyEvent/QTest 注入函数。它只接受 windows 平台插件,拒绝 offscreen。原25秒 communicate、35秒CTest与已运行的20秒controlled-child默认保持不变。 + +N1 使用现有正式 SessionTabs 预建两个目标,各有自己的固定ConPTY和独立 ReadConsoleW 接收端,分别保存 native-a.bin / native-b.bin。child由同一EXE的 --ime-output-child 分支运行;先检查已有Job归属,不创建Qt GUI或接管输入法。输入文字与控制事件独立,停止前须由root确认预期原始字节已落盘。 + +N1 源码配置单独固定为 observer45秒、controlled child50秒;建议root自有监督55秒、清理5秒、交互请求70秒。上述均是本次原生诊断的待审核执行预算,不是性能门槛或旧合同的延长。root须把实际外层脚本、期限、stdin、源/EXE/完整运行时清单/ZIP哈希与唯一输出路径绑定后才启动。最大实际原生运行 **1次**,失败/超时不自动重试、不替换原件。编译失败、尚未启动和真正执行失败分别计数。 + +运行目录的manifest列40个非清单文件;加runtime-manifest.json为41文件包,以实际清单为准,不为了凑“40”删文件。Qt6.10.3、固定ConPTY1.25.260930003、app-local CRT14.51保持已有身份。最终完整回归须在原生观察及必要修正之后,对最终源码只执行必要一次。 + +## root执行前预检 + +- 只使用自有VM/交互session及新建空结果子目录,不改个人Windows的显示、布局、输入法或剪贴板;不终止ctfmon等共享服务,不直接接管Qt HWND的IMM context。连接、账户、PID/HWND与原始图像仅私有保存。 +- 在实际目标输入线程核对HKL、语言0804、启用的Microsoft拼音provider/profile及系统版本。既有 HostedPinyin 的身份为 provider 81D4E9C9-1D3B-41BC-9E6C-4B40BF79E35E / profile FA550B04-5AD7-411F-A5AC-CA038EC515D7;只能复用身份和已有OS驱动方法,不能伪造hosted环境绕过旧runner守卫。实际VM身份和版本仍须独立记录。 +- 原生窗口标题固定 TermForge Qt IME native observer。ready.json 仅在两个child控制ready与两个模型真正读到TF_QT_IME_READY后产生;包含Qt平台、locale、实际焦点、DPR、逻辑tab矩形和查询。locale/HKL本身不证明转换模式或IME已工作。 +- root保留根句柄,核对它属于精确自有Job并在GO前验证;锁定实际窗口PID/线程/前台/焦点后才SendInput。外部驱动仅使用虚拟键或scan code(含实际鼠标tab点击),**禁止KEYEVENTF_UNICODE、WM_IME_COMPOSITION伪造或手造Qt输入事件**。这是synthetic OS input,不是物理键盘。 +- root驱动脚本、实际provider选择与Chinese转换状态、每步发出的Win32输入和观测条件先冻结。本文件不猜测未知provider状态,也不把“看起来启用了中文”当成已取证。 + +## 预声明动作与判据 + +| 动作 | 原生证据和预期 | +|---|---| +| A组合开始,尚不提交 | 实际OS虚拟键进入固定拼音,a-events.json取得非空preedit和TextFormat/Cursor;A/B raw保持空。root必须关联真实候选窗口、当前目标/焦点和Qt锚点,不能由自有QImage冒充候选窗或实际上屏 | +| OS Escape取消 | Qt组合清理、候选/焦点变化可追溯;未提交文字不出现在raw中。至少保留一个只组合/取消而自然结束的B空文件,区别于强制取消进程 | +| A用固定拼音输入nihao并确认 | 观察真实provider产生的commit UTF16,独立A接收精确“你好”UTF8 e4bda0e5a5bd一次;B不增加。两个相同的独立commit仍应各自发送一次,不按内容或时间去重 | +| A再组合,真实Ctrl-C与随后Escape | 记录ShortcutOverride/KeyPress/KeyRelease的真实键、modifiers、scan/virtual key以及IME取消/结果。若Ctrl-C确实到达widget,沿已有中断合同只产生一次03;若provider未交给widget,只记录实际链与未知,不虚构已测试03或原版一致。Escape作为单独取消动作保留,不据它回填Ctrl-C效果 | +| 实际tab切换到B/返回A | 使用ready中逻辑tab矩形和实际DPI/Win32客户区变换定位,核对真实Qt焦点与每目标事件/raw,不把未完成组合串入另一个目标。B可仅做组合+Escape而不commit,以保留取消空文件 | +| 组合期间输出/窗口尺寸/字体 | 可用output.request触发独立child输出、root对自有native窗口的实际resize、font.request触发明确诊断字体API动作;记录Qt query/公开cursor信号、实际候选窗与焦点。字体API不是OS按键,不声称原Win32的12×26或目标glyph已等价 | +| 收尾 | root先核对已声明raw及事件,再创建finish.request;两个child通过独立控制事件结束,等待natural exit0、EOF、inner Job0和cleanup verified;root核对根正常退出、外Job0、空请求队列及实际回收。强杀/超时/缺失证据不能称通过 | + +候选窗口可能属于OS输入服务,不以“PID等于产品进程”代替关联;root应保存实际类/owner或UIA关系、provider身份、焦点和几何变化。Qt逻辑矩形与Win32物理矩形分列,不自行设未经确认的像素容差或从相同DPI推导不同DPI验收。无法可靠识别候选或真实输入来源就保留缺口,不用模拟补齐。 + +## 观察器能证明和不能证明的事 + +a-events.json / b-events.json 是widget处理前的公开事件观察,不把进入时accepted标志解释为处理成功;commit由独立raw校验。spontaneous标志不是OS来源证明,需外部Win32驱动与native分支无合成输入的来源链。queued-frame记录input sequence、后续事件数和coalesced_input_updates,合并帧不冒充该事件精确瞬时状态。 + +原始IME文本最多256单位、属性最多258、每目标事件最多128;超过会显式标evidence_gap并失败收尾,不截断后称完整。自有QImage只表示产品控件渲染,presented_done保持null。observer_complete/CLI0仅表示观察器与两接收端正常完成;native_os_ime_acceptance保持null,真正IME功能结论由root对来源、candidate/焦点、事件、原始接收及回收独立审核。 + +当前258属性guard由256单位最多256个非空格式范围+Cursor+Selection推导;零length格式不占格式存储,过量no-op提前拒绝。它与256单位/非法UTF16拒绝都是TermForge工程边界,非WindTerm已对照。Ctrl-C显式分支不被composition普通文本guard吞掉,provider实际取消政策仍待这次原生观察。 + +若本次发现OS时序/焦点/commit差异,先保留失败、定位并明确修正,再对新最终源做完整回归;不得先full再默认为不同源码仍已验收。原版未知、完整Find/rename/同次八会话、CF、所有字素/glyph/fallback、真实跨DPI和性能仍由#30/#31/R2接续。 + From 0db417b4efe06b20f9bb35149ebc6c9fc813d66e Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:17:21 +0800 Subject: [PATCH 19/31] docs(qt): separate initial IME proposal from current focused and native scope (refs #155) --- docs/research/qt-ime-integration-plan.md | 2 +- src/qt/terminal_widget.hpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/research/qt-ime-integration-plan.md b/docs/research/qt-ime-integration-plan.md index f1da005e..bdc745cb 100644 --- a/docs/research/qt-ime-integration-plan.md +++ b/docs/research/qt-ime-integration-plan.md @@ -1,6 +1,6 @@ # Qt IME 基础接线计划(#155) -状态:**test-only 准备,尚未编译或执行**。依据 [#155](https://github.com/JavaZeroo/termforge/issues/155)、[ADR-0008](../adr/0008-cpp-core-qt-widgets.md) 和 [R2 合同](../milestones/R2-local-terminal.md)。基点 main a0aed8a643a0a99b09e16c7836e5a4e3ad26daed 已包含 #147 的 Qt 键盘文本;旧原件保持不变。本页是本切片技术边界,不宣告现有能力或验收通过。 +状态:**初始计划历史记录**,描述源761bb2c执行前的test-only准备;下文“当前”仅指当时基线,不是最新实现状态。实施/实测由 [#155](https://github.com/JavaZeroo/termforge/issues/155)维护,后续原生计划见 [N1](qt-ime-native-observation-plan.md)。依据 [ADR-0008](../adr/0008-cpp-core-qt-widgets.md) 和 [R2 合同](../milestones/R2-local-terminal.md)。初始基点 main a0aed8a643a0a99b09e16c7836e5a4e3ad26daed 已包含 #147 的 Qt 键盘文本;旧原件保持不变。本页不宣告最新能力或验收通过。 ## 已知事实与首个反例 diff --git a/src/qt/terminal_widget.hpp b/src/qt/terminal_widget.hpp index 11aede1b..e48e161f 100644 --- a/src/qt/terminal_widget.hpp +++ b/src/qt/terminal_widget.hpp @@ -168,8 +168,8 @@ class TerminalWidget final : public QWidget { model_.send(std::string{}); event->ignore(); return; } - // The input context owns composing keystrokes; they must not become a - // second text path into the child while preedit is still active. + // The input context owns composing text; it must not become a second + // text path. The already-supported interrupt remains explicit below. const bool interrupt = event->modifiers() == Qt::ControlModifier && event->key() == Qt::Key_C; if (!composition_.text.isEmpty() && !interrupt) { QWidget::keyPressEvent(event); return; } std::string bytes; From 9db13ce84aa7b09907e93ed279d6fc0d5f70236d Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:31:06 +0800 Subject: [PATCH 20/31] test(qt): reproduce actual observer normal and cancellation lifecycle stalls (refs #155) --- CMakeLists.txt | 6 ++ src/qt/ime_input_test.cpp | 3 + src/qt/ime_native_observer.hpp | 12 ++- src/qt/run_ime_observer_lifecycle.py | 128 +++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 src/qt/run_ime_observer_lifecycle.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 59f76fee..bdf2b3a3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -457,6 +457,12 @@ if(TERMFORGE_BUILD_QT_SHELL) "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$") set_tests_properties(qt_ime_input PROPERTIES TIMEOUT 35 ENVIRONMENT "QT_QPA_PLATFORM=offscreen;TERMFORGE_TEST_ARTIFACTS=${PROJECT_BINARY_DIR}/test-artifacts") + foreach(scenario normal cancel) + add_test(NAME qt_ime_observer_lifecycle_${scenario} COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/src/qt/run_ime_observer_lifecycle.py" "$" "${scenario}") + set_tests_properties(qt_ime_observer_lifecycle_${scenario} PROPERTIES TIMEOUT 35 + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;TERMFORGE_TEST_ARTIFACTS=${PROJECT_BINARY_DIR}/test-artifacts") + endforeach() add_executable(qt_cpp_resize_smoke src/qt/resize_smoke.cpp) set_target_properties(qt_cpp_resize_smoke PROPERTIES diff --git a/src/qt/ime_input_test.cpp b/src/qt/ime_input_test.cpp index 3e6fed87..28746703 100644 --- a/src/qt/ime_input_test.cpp +++ b/src/qt/ime_input_test.cpp @@ -436,6 +436,9 @@ int main(int argc, char** argv) { try { if (app.arguments().size() == 3 && app.arguments().at(1) == QStringLiteral("--native-ime-observer")) return termforge::qt::ime_native::run(app, app.arguments().at(2)); + if (app.arguments().size() == 3 && app.arguments().at(1) == QStringLiteral("--offscreen-ime-observer-lifecycle")) + return termforge::qt::ime_native::run(app, app.arguments().at(2), + termforge::qt::ime_native::Purpose::OffscreenLifecycle); const auto artifacts = qEnvironmentVariable("TERMFORGE_TEST_ARTIFACTS"); if (!artifacts.isEmpty()) require(QDir().mkpath(artifacts), "IME artifact directory could not be created"); QTemporaryDir workspace(artifacts.isEmpty() ? QString() : artifacts + QStringLiteral("/qt-ime-XXXXXX")); diff --git a/src/qt/ime_native_observer.hpp b/src/qt/ime_native_observer.hpp index e2baf174..818bdf37 100644 --- a/src/qt/ime_native_observer.hpp +++ b/src/qt/ime_native_observer.hpp @@ -160,9 +160,11 @@ inline bool ready_text(const QPointer& page) { } return false; } -inline int run(QApplication& app, const QString& directory) { - check(QGuiApplication::platformName() == QStringLiteral("windows"), - "native IME observer requires the windows platform plugin"); +enum class Purpose { NativeIme, OffscreenLifecycle }; +inline int run(QApplication& app, const QString& directory, Purpose purpose = Purpose::NativeIme) { + const bool native = purpose == Purpose::NativeIme; + check(QGuiApplication::platformName() == (native ? QStringLiteral("windows") : QStringLiteral("offscreen")), + "IME observer platform does not match its explicit purpose"); check(!QFileInfo::exists(directory) && QDir().mkpath(directory), "native IME output directory must be new"); ime_fixture::ControlledReceiver a(directory + QStringLiteral("/native-a.bin"), 50000); ime_fixture::ControlledReceiver b(directory + QStringLiteral("/native-b.bin"), 50000); @@ -187,6 +189,7 @@ inline int run(QApplication& app, const QString& directory) { tab_rectangles.append(rect(QRectF(tabs->tabRect(index).translated(tabs->mapTo(&window, QPoint()))))); return QJsonObject{{"schema", "termforge-qt-ime-native-observer-v1"}, {"qt_platform", QGuiApplication::platformName()}, + {"purpose", native ? "native-os-ime" : "offscreen-lifecycle-only"}, {"input_method_locale", QGuiApplication::inputMethod()->locale().name()}, {"window_device_pixel_ratio", window.devicePixelRatioF()}, {"window_client_rectangle_logical", rect(window.rect())}, @@ -218,6 +221,9 @@ inline int run(QApplication& app, const QString& directory) { if (!finish_requested && QFileInfo::exists(directory + QStringLiteral("/finish.request"))) { finish_requested = true; a.stop(); b.stop(); } + if (QFileInfo::exists(directory + QStringLiteral("/cancel.request"))) { + failed = true; failure = QStringLiteral("native observer cancelled by controller"); window.close(); + } if (!first_events.valid() || !second_events.valid()) { failed = true; failure = QStringLiteral("native observer evidence gap"); window.close(); } else if (elapsed.elapsed() > 45000 && !finish_requested) { diff --git a/src/qt/run_ime_observer_lifecycle.py b/src/qt/run_ime_observer_lifecycle.py new file mode 100644 index 00000000..d1c43f39 --- /dev/null +++ b/src/qt/run_ime_observer_lifecycle.py @@ -0,0 +1,128 @@ +"""Exercise the actual observer event loop and receivers, never OS IME input.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from experiments.conpty.run import OwnedJob + + +def main(): + binary = Path(sys.argv[1]).resolve(strict=True) + scenario = sys.argv[2] + if scenario not in ("normal", "cancel"): + raise ValueError("Expected normal or cancel lifecycle scenario") + artifacts = Path(os.environ["TERMFORGE_TEST_ARTIFACTS"]) + artifacts.mkdir(parents=True, exist_ok=True) + directory = Path(tempfile.mkdtemp(prefix="qt-ime-lifecycle-" + scenario + "-", dir=artifacts)) + app_directory = directory / "observer" # The observer itself must create this. + result = {"schema": "termforge-qt-ime-observer-lifecycle-v1", "scenario": scenario, + "purpose": "offscreen-lifecycle-only", "actual_os_ime": False, + "status": "failed", "execution_budget_seconds": 25, "cleanup_budget_seconds": 5, + "directory": str(directory), "ready_observed": False, "marker_created": False, + "timed_out": False, "forced_cleanup": False, "cleanup_errors": []} + job, process, assigned = OwnedJob(), None, False + started = time.monotonic() + stdout, stderr = b"", b"" + try: + environment = os.environ.copy() + environment["QT_QPA_PLATFORM"] = "offscreen" + environment["QT_QPA_FONTDIR"] = str(binary.parent / "assets/fonts") + process = subprocess.Popen([str(binary), "--offscreen-ime-observer-lifecycle", str(app_directory)], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env=environment, creationflags=subprocess.CREATE_NO_WINDOW) + job.assign(process.pid) + assigned = True + result["root_pid"] = process.pid + process.stdin.write(b"GO\n") + process.stdin.close() + process.stdin = None + deadline = started + 25 + while process.poll() is None and time.monotonic() < deadline: + ready_path = app_directory / "ready.json" + if not result["marker_created"] and ready_path.exists(): + ready = json.loads(ready_path.read_text(encoding="utf-8")) + if not (ready["ready"] and ready["qt_platform"] == "offscreen" and + ready["purpose"] == "offscreen-lifecycle-only" and + ready["a"]["running"] and ready["b"]["running"]): + raise RuntimeError("Actual two-receiver observer readiness is incomplete") + result["ready_observed"] = True + marker = app_directory / ("finish.request" if scenario == "normal" else "cancel.request") + marker.write_bytes(b"") + result["marker_created"] = True + result["marker_elapsed_ms"] = round((time.monotonic() - started) * 1000) + time.sleep(0.01) + if process.poll() is None: + result["timed_out"] = True + result["job_at_timeout"] = job.accounting() + raise TimeoutError("Observer did not exit within the unchanged 25 second execution budget") + stdout, stderr = process.communicate(timeout=max(0.001, deadline - time.monotonic())) + result["exit_code"] = process.returncode + result["job_after_natural_exit"] = job.accounting() + complete = json.loads((app_directory / "complete.json").read_text(encoding="utf-8")) + result["complete"] = complete + if not result["ready_observed"] or not result["marker_created"]: + raise RuntimeError("Lifecycle exited before the declared control action") + if complete["purpose"] != "offscreen-lifecycle-only" or complete["qt_platform"] != "offscreen": + raise RuntimeError("Lifecycle evidence was confused with native OS IME") + if scenario == "normal": + if process.returncode != 0 or not complete["observer_complete"] or complete["failed"]: + raise RuntimeError("Normal observer completion did not report a successful application exit") + if not all(complete[label]["natural_exit"] and complete[label]["cleanup_verified"] + for label in ("a", "b")): + raise RuntimeError("Normal completion lacks both actual receiver exits") + else: + if process.returncode != 1 or complete["observer_complete"] or not complete["failed"]: + raise RuntimeError("Cancelled observer did not return explicit failure") + if complete["failure"] != "native observer cancelled by controller": + raise RuntimeError("Cancelled observer lost the original failure reason") + if not all(complete[label]["finished"] and complete[label]["cleanup_verified"] + for label in ("a", "b")): + raise RuntimeError("Cancelled observer did not complete both session cleanups") + if result["job_after_natural_exit"]["active"]: + raise RuntimeError("Observer root exited while owned descendants were still active") + if json.loads(stdout.decode("utf-8")) != complete: + raise RuntimeError("Observer complete file differs from its actual stdout result") + result["status"] = "passed" + except Exception as error: + result["error"] = str(error) + finally: + result["execution_elapsed_ms"] = round((time.monotonic() - started) * 1000) + cleanup_deadline = time.monotonic() + 5 + try: + if process is not None: + remaining = job.accounting() if assigned else None + if process.poll() is None or (remaining and remaining["active"]): + result["forced_cleanup"] = True + if assigned: + job.terminate() + else: + process.kill() + stdout, stderr = process.communicate(timeout=max(0.001, cleanup_deadline - time.monotonic())) + result["exit_code"] = process.returncode + result["job_after_cleanup"] = job.drain(timeout=max(0, cleanup_deadline - time.monotonic())) + if result["job_after_cleanup"]["active"]: + raise RuntimeError("Observer lifecycle left processes in its exact owned Job") + except Exception as error: + result["cleanup_errors"].append(str(error)) + finally: + job.close() + (directory / "stdout.log").write_bytes(stdout) + (directory / "stderr.log").write_bytes(stderr) + result["complete_exists"] = (app_directory / "complete.json").exists() + result["receiver_files"] = {path.name: path.stat().st_size + for path in app_directory.glob("native-*.bin*")} + if result["forced_cleanup"] or result["cleanup_errors"]: + result["status"] = "failed" + (directory / "supervisor.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + print(json.dumps(result), file=sys.stdout if result["status"] == "passed" else sys.stderr) + return 0 if result["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From cfed9060364dd89cc88e559558e4c1eacdae0f41 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:34:14 +0800 Subject: [PATCH 21/31] fix(qt): exit observer after session retirement and retain absolute deadline (refs #155) --- src/qt/ime_native_observer.hpp | 34 +++++++++++++++++++++++----- src/qt/run_ime_observer_lifecycle.py | 2 ++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/qt/ime_native_observer.hpp b/src/qt/ime_native_observer.hpp index 818bdf37..582699e3 100644 --- a/src/qt/ime_native_observer.hpp +++ b/src/qt/ime_native_observer.hpp @@ -179,6 +179,8 @@ inline int run(QApplication& app, const QString& directory, Purpose purpose = Pu Observation first_events(first, directory, QStringLiteral("a"), &window); Observation second_events(second, directory, QStringLiteral("b"), &window); bool ready_written = false, finish_requested = false, output_requested = false, failed = false; + bool close_requested = false, deadline_expired = false, sessions_completed = false; + int sessions_exit_code = -1; QString failure; QElapsedTimer elapsed; elapsed.start(); QObject lifetime; @@ -199,8 +201,24 @@ inline int run(QApplication& app, const QString& directory, Purpose purpose = Pu {"native_os_ime_acceptance", QJsonValue()}, {"presented_done", QJsonValue()}, {"ready", ready_written}, {"finish_requested", finish_requested}, {"failed", failed}, {"failure", failure}, {"elapsed_ms", elapsed.elapsed()}, + {"close_requested", close_requested}, {"deadline_expired", deadline_expired}, + {"sessions_completed", sessions_completed}, {"sessions_exit_code", sessions_exit_code}, {"a", first_events.snapshot()}, {"b", second_events.snapshot()}}; }; + const auto fail_and_close = [&](const QString& reason) { + if (!failed) { failed = true; failure = reason; } + if (!close_requested) { close_requested = true; window.close(); } + }; + // SessionTabs owns asynchronous session retirement. A hidden/closed window + // does not stop this QApplication: exit only after that actual completion, + // with the absolute observer deadline as a final event-loop bound. + window.on_all_exited = [&](int code) { + sessions_completed = true; sessions_exit_code = code; + if (!finish_requested && !failed) { + failed = true; failure = QStringLiteral("native observer sessions ended before finish request"); + } + app.exit(failed || code != 0 ? 1 : 0); + }; QObject::connect(&app, &QCoreApplication::aboutToQuit, &lifetime, [&] { try { save_json(directory + QStringLiteral("/about-to-quit.json"), state()); } catch (const std::exception& error) { failed = true; failure = QString::fromUtf8(error.what()); } @@ -222,21 +240,25 @@ inline int run(QApplication& app, const QString& directory, Purpose purpose = Pu finish_requested = true; a.stop(); b.stop(); } if (QFileInfo::exists(directory + QStringLiteral("/cancel.request"))) { - failed = true; failure = QStringLiteral("native observer cancelled by controller"); window.close(); + fail_and_close(QStringLiteral("native observer cancelled by controller")); } if (!first_events.valid() || !second_events.valid()) { - failed = true; failure = QStringLiteral("native observer evidence gap"); window.close(); - } else if (elapsed.elapsed() > 45000 && !finish_requested) { - failed = true; failure = QStringLiteral("native observer execution deadline"); window.close(); + fail_and_close(QStringLiteral("native observer evidence gap")); + } + if (elapsed.elapsed() > 45000) { + deadline_expired = true; + fail_and_close(QStringLiteral("native observer execution deadline")); + app.exit(1); } } catch (const std::exception& error) { - failed = true; failure = QString::fromUtf8(error.what()); window.close(); + fail_and_close(QString::fromUtf8(error.what())); } }); timer.start(50); const auto result = app.exec(); auto final = state(); - const bool complete = !failed && ready_written && finish_requested && first && second && + const bool complete = !failed && !deadline_expired && sessions_completed && sessions_exit_code == 0 && + ready_written && finish_requested && first && second && first->model().natural_exit() && second->model().natural_exit() && first->model().exit_code() == 0 && second->model().exit_code() == 0 && first->model().output_eof() && second->model().output_eof() && diff --git a/src/qt/run_ime_observer_lifecycle.py b/src/qt/run_ime_observer_lifecycle.py index d1c43f39..900c6641 100644 --- a/src/qt/run_ime_observer_lifecycle.py +++ b/src/qt/run_ime_observer_lifecycle.py @@ -70,6 +70,8 @@ def main(): raise RuntimeError("Lifecycle exited before the declared control action") if complete["purpose"] != "offscreen-lifecycle-only" or complete["qt_platform"] != "offscreen": raise RuntimeError("Lifecycle evidence was confused with native OS IME") + if not complete["sessions_completed"] or complete["deadline_expired"]: + raise RuntimeError("Actual session completion callback did not end the event loop") if scenario == "normal": if process.returncode != 0 or not complete["observer_complete"] or complete["failed"]: raise RuntimeError("Normal observer completion did not report a successful application exit") From 3beba23a7522b0defd7da13ba5ae374b1126121a Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:34:41 +0800 Subject: [PATCH 22/31] fix(qt): check observer deadline before fallible evidence operations (refs #155) --- src/qt/ime_native_observer.hpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/qt/ime_native_observer.hpp b/src/qt/ime_native_observer.hpp index 582699e3..6313f5c3 100644 --- a/src/qt/ime_native_observer.hpp +++ b/src/qt/ime_native_observer.hpp @@ -225,6 +225,14 @@ inline int run(QApplication& app, const QString& directory, Purpose purpose = Pu }); QObject::connect(&timer, &QTimer::timeout, &lifetime, [&] { try { + // Check before any evidence I/O: a repeated write/control error + // must not prevent this absolute event-loop deadline from firing. + if (elapsed.elapsed() > 45000) { + deadline_expired = true; + fail_and_close(QStringLiteral("native observer execution deadline")); + app.exit(1); + return; + } if (!ready_written && a.ready() && b.ready() && ready_text(first) && ready_text(second)) { ready_written = true; save_json(directory + QStringLiteral("/ready.json"), state()); @@ -245,11 +253,6 @@ inline int run(QApplication& app, const QString& directory, Purpose purpose = Pu if (!first_events.valid() || !second_events.valid()) { fail_and_close(QStringLiteral("native observer evidence gap")); } - if (elapsed.elapsed() > 45000) { - deadline_expired = true; - fail_and_close(QStringLiteral("native observer execution deadline")); - app.exit(1); - } } catch (const std::exception& error) { fail_and_close(QString::fromUtf8(error.what())); } From 05bc52fcb7d97fc4011978b6359c5bc9e3ccf62b Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:36:19 +0800 Subject: [PATCH 23/31] test(qt): observe actual GUI-thread profile and separate requested candidate forms (refs #155) --- .github/workflows/qt-cpp.yml | 2 +- CMakeLists.txt | 4 +- src/qt/ime_gui_profile_observer.hpp | 90 ++++++++++++++++++++++++++++ src/qt/ime_native_observer.hpp | 6 ++ src/qt/run_ime_observer_lifecycle.py | 2 + 5 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 src/qt/ime_gui_profile_observer.hpp diff --git a/.github/workflows/qt-cpp.yml b/.github/workflows/qt-cpp.yml index 4858dd25..41361345 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-ime-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|ime)_input)$' --no-tests=error --verbose + run: ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_(unicode|ime)_input|qt_ime_observer_lifecycle_(normal|cancel))$' --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 bdf2b3a3..76f0a8a8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -444,7 +444,9 @@ if(TERMFORGE_BUILD_QT_SHELL) set_target_properties(qt_ime_input_test PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qt-ime-bin/$") termforge_qt_entry(qt_ime_input_test) - target_link_libraries(qt_ime_input_test PRIVATE Qt6::Test) + # Read-only native diagnostics use Windows SDK COM/TSF/IMM APIs only in + # this test executable; the product entry keeps its existing dependencies. + target_link_libraries(qt_ime_input_test PRIVATE Qt6::Test imm32 ole32 uuid) add_dependencies(qt_ime_input_test shell_input_client) add_custom_command(TARGET qt_ime_input_test POST_BUILD COMMAND "${CMAKE_COMMAND}" -E copy_if_different diff --git a/src/qt/ime_gui_profile_observer.hpp b/src/qt/ime_gui_profile_observer.hpp new file mode 100644 index 00000000..27bcd25c --- /dev/null +++ b/src/qt/ime_gui_profile_observer.hpp @@ -0,0 +1,90 @@ +// Test-only, read-only state from the Qt GUI thread. Never activate a provider +// or change/associate Qt's input context on behalf of the external OS driver. +#pragma once +#include +#include +#include +#include +#include +#include + +namespace termforge::qt::ime_native { +class GuiThreadProfile { + bool enabled_, uninitialize_ = false; + DWORD thread_ = GetCurrentThreadId(); + HRESULT initialized_ = E_PENDING, created_ = E_PENDING; + ITfInputProcessorProfileMgr* profiles_ = nullptr; + static QString guid(REFGUID value) { + wchar_t text[40]{}; + return StringFromGUID2(value, text, 40) ? QString::fromWCharArray(text) : QString(); + } +public: + explicit GuiThreadProfile(bool enabled) : enabled_(enabled) { + if (!enabled_) return; + initialized_ = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + uninitialize_ = SUCCEEDED(initialized_); // Includes our S_FALSE reference. + if (SUCCEEDED(initialized_) || initialized_ == RPC_E_CHANGED_MODE) + created_ = CoCreateInstance(CLSID_TF_InputProcessorProfiles, nullptr, CLSCTX_INPROC_SERVER, + IID_ITfInputProcessorProfileMgr, reinterpret_cast(&profiles_)); + } + ~GuiThreadProfile() { + if (profiles_) profiles_->Release(); + if (uninitialize_) CoUninitialize(); + } + GuiThreadProfile(const GuiThreadProfile&) = delete; + GuiThreadProfile& operator=(const GuiThreadProfile&) = delete; + QJsonObject snapshot() const { + QJsonObject result{{"scope", "observer Qt GUI thread; read-only"}, {"enabled", enabled_}, + {"process_id", static_cast(GetCurrentProcessId())}, + {"thread_id", static_cast(thread_)}, + {"sample_tick_ms", static_cast(GetTickCount64())}, + {"provider_activation_attempts", 0}, {"input_context_mutations", 0}}; + if (!enabled_) return result; + if (thread_ != GetCurrentThreadId()) throw std::runtime_error("IME profile queried from a different thread"); + result.insert("com_initialize_hresult", static_cast(initialized_)); + result.insert("own_com_initialize_reference", uninitialize_); + result.insert("profile_manager_hresult", static_cast(created_)); + result.insert("thread_hkl", QString::number(reinterpret_cast(GetKeyboardLayout(0)), 16)); + TF_INPUTPROCESSORPROFILE profile{}; + const auto status = profiles_ ? profiles_->GetActiveProfile(GUID_TFCAT_TIP_KEYBOARD, &profile) : created_; + result.insert("get_active_profile_hresult", static_cast(status)); + if (status == S_OK) { + result.insert("profile", QJsonObject{{"type", static_cast(profile.dwProfileType)}, + {"language_id", profile.langid}, {"clsid", guid(profile.clsid)}, {"guid", guid(profile.guidProfile)}, + {"category", guid(profile.catid)}, {"flags", static_cast(profile.dwFlags)}, + {"capabilities", static_cast(profile.dwCaps)}, + {"hkl", QString::number(reinterpret_cast(profile.hkl), 16)}, + {"substitute_hkl", QString::number(reinterpret_cast(profile.hklSubstitute), 16)}}); + } else result.insert("profile", QJsonValue()); + const auto focus = GetFocus(); + DWORD focus_pid = 0; + const auto focus_thread = focus ? GetWindowThreadProcessId(focus, &focus_pid) : 0; + result.insert("focus_hwnd", QString::number(reinterpret_cast(focus), 16)); + result.insert("focus_process_id", static_cast(focus_pid)); + result.insert("focus_thread_id", static_cast(focus_thread)); + const auto context = focus && focus_pid == GetCurrentProcessId() && focus_thread == thread_ ? ImmGetContext(focus) : nullptr; + result.insert("focused_context_available", context != nullptr); + if (context) { + DWORD conversion = 0, sentence = 0; + const bool queried = ImmGetConversionStatus(context, &conversion, &sentence) != FALSE; + result.insert("ime_open", ImmGetOpenStatus(context) != FALSE); + result.insert("conversion_query_succeeded", queried); + if (queried) { + result.insert("conversion_mode", static_cast(conversion)); + result.insert("sentence_mode", static_cast(sentence)); + } + CANDIDATEFORM requested{}; + const bool candidate_queried = ImmGetCandidateWindow(context, 0, &requested) != FALSE; + result.insert("requested_candidate_form_query_succeeded", candidate_queried); + result.insert("requested_form_is_not_visible_window", true); + if (candidate_queried) + result.insert("requested_candidate_form", QJsonObject{{"style", static_cast(requested.dwStyle)}, + {"x", requested.ptCurrentPos.x}, {"y", requested.ptCurrentPos.y}, + {"left", requested.rcArea.left}, {"top", requested.rcArea.top}, + {"right", requested.rcArea.right}, {"bottom", requested.rcArea.bottom}}); + result.insert("context_released", ImmReleaseContext(focus, context) != FALSE); + } + return result; + } +}; +} diff --git a/src/qt/ime_native_observer.hpp b/src/qt/ime_native_observer.hpp index 6313f5c3..d2e1d8b9 100644 --- a/src/qt/ime_native_observer.hpp +++ b/src/qt/ime_native_observer.hpp @@ -2,6 +2,7 @@ #pragma once #include "session_tabs.hpp" #include "ime_receiver_fixture.hpp" +#include "ime_gui_profile_observer.hpp" #include #include #include @@ -56,6 +57,8 @@ class Observation final : public QObject { {"requested_widget_font_points", terminal.font().pointSizeF()}, {"running", model.running()}, {"finished", model.finished()}, {"natural_exit", model.natural_exit()}, {"cleanup_verified", model.cleanup_verified()}, + {"exit_code", model.exit_code()}, {"output_eof", model.output_eof()}, + {"active_processes_after", model.active_processes_after()}, {"error", model.error()}}; } void write() { @@ -166,6 +169,7 @@ inline int run(QApplication& app, const QString& directory, Purpose purpose = Pu check(QGuiApplication::platformName() == (native ? QStringLiteral("windows") : QStringLiteral("offscreen")), "IME observer platform does not match its explicit purpose"); check(!QFileInfo::exists(directory) && QDir().mkpath(directory), "native IME output directory must be new"); + GuiThreadProfile gui_profile(native); ime_fixture::ControlledReceiver a(directory + QStringLiteral("/native-a.bin"), 50000); ime_fixture::ControlledReceiver b(directory + QStringLiteral("/native-b.bin"), 50000); SessionTabs window(QCoreApplication::applicationFilePath().toStdWString(), a.arguments(), directory.toStdWString()); @@ -192,6 +196,7 @@ inline int run(QApplication& app, const QString& directory, Purpose purpose = Pu return QJsonObject{{"schema", "termforge-qt-ime-native-observer-v1"}, {"qt_platform", QGuiApplication::platformName()}, {"purpose", native ? "native-os-ime" : "offscreen-lifecycle-only"}, + {"gui_thread_profile", gui_profile.snapshot()}, {"input_method_locale", QGuiApplication::inputMethod()->locale().name()}, {"window_device_pixel_ratio", window.devicePixelRatioF()}, {"window_client_rectangle_logical", rect(window.rect())}, @@ -237,6 +242,7 @@ inline int run(QApplication& app, const QString& directory, Purpose purpose = Pu ready_written = true; save_json(directory + QStringLiteral("/ready.json"), state()); } + if (native) save_json(directory + QStringLiteral("/native-state.json"), state()); if (!finish_requested && !output_requested && QFileInfo::exists(directory + QStringLiteral("/output.request"))) { output_requested = true; a.output(); } diff --git a/src/qt/run_ime_observer_lifecycle.py b/src/qt/run_ime_observer_lifecycle.py index 900c6641..9e9c9c66 100644 --- a/src/qt/run_ime_observer_lifecycle.py +++ b/src/qt/run_ime_observer_lifecycle.py @@ -76,6 +76,8 @@ def main(): if process.returncode != 0 or not complete["observer_complete"] or complete["failed"]: raise RuntimeError("Normal observer completion did not report a successful application exit") if not all(complete[label]["natural_exit"] and complete[label]["cleanup_verified"] + and complete[label]["exit_code"] == 0 and complete[label]["output_eof"] + and complete[label]["active_processes_after"] == 0 for label in ("a", "b")): raise RuntimeError("Normal completion lacks both actual receiver exits") else: From 627094be42c79ccd61a81a946fae6720bf9edb09 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:36:44 +0800 Subject: [PATCH 24/31] fix(qt): serialize Win32 diagnostic integer types explicitly (refs #155) --- src/qt/ime_gui_profile_observer.hpp | 6 +++--- src/qt/ime_native_observer.hpp | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/qt/ime_gui_profile_observer.hpp b/src/qt/ime_gui_profile_observer.hpp index 27bcd25c..765b3547 100644 --- a/src/qt/ime_gui_profile_observer.hpp +++ b/src/qt/ime_gui_profile_observer.hpp @@ -79,9 +79,9 @@ class GuiThreadProfile { result.insert("requested_form_is_not_visible_window", true); if (candidate_queried) result.insert("requested_candidate_form", QJsonObject{{"style", static_cast(requested.dwStyle)}, - {"x", requested.ptCurrentPos.x}, {"y", requested.ptCurrentPos.y}, - {"left", requested.rcArea.left}, {"top", requested.rcArea.top}, - {"right", requested.rcArea.right}, {"bottom", requested.rcArea.bottom}}); + {"x", static_cast(requested.ptCurrentPos.x)}, {"y", static_cast(requested.ptCurrentPos.y)}, + {"left", static_cast(requested.rcArea.left)}, {"top", static_cast(requested.rcArea.top)}, + {"right", static_cast(requested.rcArea.right)}, {"bottom", static_cast(requested.rcArea.bottom)}}); result.insert("context_released", ImmReleaseContext(focus, context) != FALSE); } return result; diff --git a/src/qt/ime_native_observer.hpp b/src/qt/ime_native_observer.hpp index d2e1d8b9..d5d886f0 100644 --- a/src/qt/ime_native_observer.hpp +++ b/src/qt/ime_native_observer.hpp @@ -57,8 +57,8 @@ class Observation final : public QObject { {"requested_widget_font_points", terminal.font().pointSizeF()}, {"running", model.running()}, {"finished", model.finished()}, {"natural_exit", model.natural_exit()}, {"cleanup_verified", model.cleanup_verified()}, - {"exit_code", model.exit_code()}, {"output_eof", model.output_eof()}, - {"active_processes_after", model.active_processes_after()}, + {"exit_code", static_cast(model.exit_code())}, {"output_eof", model.output_eof()}, + {"active_processes_after", static_cast(model.active_processes_after())}, {"error", model.error()}}; } void write() { From 194101a9bc2bf1fe22cba2479677b7f3bcf30a49 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:38:17 +0800 Subject: [PATCH 25/31] docs(qt): bind native profile scope and bounded observer retirement contract (refs #155) --- docs/research/qt-ime-native-observation-plan.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/research/qt-ime-native-observation-plan.md b/docs/research/qt-ime-native-observation-plan.md index 94420be8..92c55d9a 100644 --- a/docs/research/qt-ime-native-observation-plan.md +++ b/docs/research/qt-ime-native-observation-plan.md @@ -4,7 +4,7 @@ ## 固定入口与来源 -入口为 qt_ime_input_test.exe --native-ime-observer 。从既有 stdin 精确 GO+LF、OwnedJob 检查之后创建 QApplication;native分支立即进入观察器,不调用默认测试数组的 QInputMethodEvent/QKeyEvent/QTest 注入函数。它只接受 windows 平台插件,拒绝 offscreen。原25秒 communicate、35秒CTest与已运行的20秒controlled-child默认保持不变。 +入口为 qt_ime_input_test.exe --native-ime-observer 。从既有 stdin 精确 GO+LF、OwnedJob 检查之后创建 QApplication;native分支立即进入观察器,不调用默认测试数组的 QInputMethodEvent/QKeyEvent/QTest 注入函数。它只接受 windows 平台插件,拒绝 offscreen。显式 --offscreen-ime-observer-lifecycle 复用同一个观察器、实际SessionTabs/ConPTY与两个接收端,仅检查正常/取消应用生命周期,绝不替代原生IME。两个独立lifecycle CTest各保留25秒supervisor、5秒共享清理及35秒CTest上限;旧focused的25/35秒和20秒controlled-child默认保持不变。 N1 使用现有正式 SessionTabs 预建两个目标,各有自己的固定ConPTY和独立 ReadConsoleW 接收端,分别保存 native-a.bin / native-b.bin。child由同一EXE的 --ime-output-child 分支运行;先检查已有Job归属,不创建Qt GUI或接管输入法。输入文字与控制事件独立,停止前须由root确认预期原始字节已落盘。 @@ -18,6 +18,8 @@ N1 源码配置单独固定为 observer45秒、controlled child50秒;建议roo - 在实际目标输入线程核对HKL、语言0804、启用的Microsoft拼音provider/profile及系统版本。既有 HostedPinyin 的身份为 provider 81D4E9C9-1D3B-41BC-9E6C-4B40BF79E35E / profile FA550B04-5AD7-411F-A5AC-CA038EC515D7;只能复用身份和已有OS驱动方法,不能伪造hosted环境绕过旧runner守卫。实际VM身份和版本仍须独立记录。 - 原生窗口标题固定 TermForge Qt IME native observer。ready.json 仅在两个child控制ready与两个模型真正读到TF_QT_IME_READY后产生;包含Qt平台、locale、实际焦点、DPR、逻辑tab矩形和查询。locale/HKL本身不证明转换模式或IME已工作。 - root保留根句柄,核对它属于精确自有Job并在GO前验证;锁定实际窗口PID/线程/前台/焦点后才SendInput。外部驱动仅使用虚拟键或scan code(含实际鼠标tab点击),**禁止KEYEVENTF_UNICODE、WM_IME_COMPOSITION伪造或手造Qt输入事件**。这是synthetic OS input,不是物理键盘。 +- native-state.json每次50ms观察器poll在实际Qt GUI线程只读采样GetActiveProfile(GUID_TFCAT_TIP_KEYBOARD)、该线程HKL、Win32焦点及两个Qt目标状态。记录HRESULT、profile type/language/CLSID/GUID/flags和tick;S_FALSE/失败/过期采样不作已激活证明。只借用并Release该线程实际焦点窗口的HIMC读取open/conversion/requested candidate form,不创建、关联、更换或设置context。固定拼音、语言0804、active flag、实际焦点/当前tab、HKL及中文open/native转换状态须同时匹配,缺项fail-before-input,不用controller profile或已安装名单补齐。 +- 只读诊断在测试EXE新增Windows SDK imm32/ole32/uuid链接,正式产品入口依赖不变。CoInitializeEx仅S_OK/S_FALSE持有并配对释放自身引用;RPC_E_CHANGED_MODE沿当前已初始化apartment查询但不CoUninitialize Qt的引用;不创建/激活ThreadMgr或ActivateProfile。 - root驱动脚本、实际provider选择与Chinese转换状态、每步发出的Win32输入和观测条件先冻结。本文件不猜测未知provider状态,也不把“看起来启用了中文”当成已取证。 ## 预声明动作与判据 @@ -30,7 +32,7 @@ N1 源码配置单独固定为 observer45秒、controlled child50秒;建议roo | A再组合,真实Ctrl-C与随后Escape | 记录ShortcutOverride/KeyPress/KeyRelease的真实键、modifiers、scan/virtual key以及IME取消/结果。若Ctrl-C确实到达widget,沿已有中断合同只产生一次03;若provider未交给widget,只记录实际链与未知,不虚构已测试03或原版一致。Escape作为单独取消动作保留,不据它回填Ctrl-C效果 | | 实际tab切换到B/返回A | 使用ready中逻辑tab矩形和实际DPI/Win32客户区变换定位,核对真实Qt焦点与每目标事件/raw,不把未完成组合串入另一个目标。B可仅做组合+Escape而不commit,以保留取消空文件 | | 组合期间输出/窗口尺寸/字体 | 可用output.request触发独立child输出、root对自有native窗口的实际resize、font.request触发明确诊断字体API动作;记录Qt query/公开cursor信号、实际候选窗与焦点。字体API不是OS按键,不声称原Win32的12×26或目标glyph已等价 | -| 收尾 | root先核对已声明raw及事件,再创建finish.request;两个child通过独立控制事件结束,等待natural exit0、EOF、inner Job0和cleanup verified;root核对根正常退出、外Job0、空请求队列及实际回收。强杀/超时/缺失证据不能称通过 | +| 收尾 | root先核对已声明raw及事件,再创建finish.request;两个child通过独立控制事件结束,SessionTabs实际完成回调结束Qt事件循环,再生成complete.json。正常要求natural exit0、EOF、inner Job0和cleanup verified;root核对根正常退出、外Job0、空请求队列及实际回收。cancel.request只代表诊断取消,close/cleanup完成后CLI1/observer_complete=false;45秒绝对期限在finish后仍有效,期限先于证据I/O检查,达到后退出失败,不无界等callback。强杀/超时/缺失证据不能称通过 | 候选窗口可能属于OS输入服务,不以“PID等于产品进程”代替关联;root应保存实际类/owner或UIA关系、provider身份、焦点和几何变化。Qt逻辑矩形与Win32物理矩形分列,不自行设未经确认的像素容差或从相同DPI推导不同DPI验收。无法可靠识别候选或真实输入来源就保留缺口,不用模拟补齐。 @@ -42,5 +44,7 @@ a-events.json / b-events.json 是widget处理前的公开事件观察,不把 当前258属性guard由256单位最多256个非空格式范围+Cursor+Selection推导;零length格式不占格式存储,过量no-op提前拒绝。它与256单位/非法UTF16拒绝都是TermForge工程边界,非WindTerm已对照。Ctrl-C显式分支不被composition普通文本guard吞掉,provider实际取消政策仍待这次原生观察。 +只读API语义依据:[GetActiveProfile](https://learn.microsoft.com/en-us/windows/win32/api/msctf/nf-msctf-itfinputprocessorprofilemgr-getactiveprofile)、[TF_INPUTPROCESSORPROFILE](https://learn.microsoft.com/en-us/windows/win32/api/msctf/ns-msctf-tf_inputprocessorprofile)、[CoInitializeEx](https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-coinitializeex)、[CoUninitialize](https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-couninitialize)。API查询不等于真实候选可见或物理键盘来源,仍须外部链证据。 + 若本次发现OS时序/焦点/commit差异,先保留失败、定位并明确修正,再对新最终源做完整回归;不得先full再默认为不同源码仍已验收。原版未知、完整Find/rename/同次八会话、CF、所有字素/glyph/fallback、真实跨DPI和性能仍由#30/#31/R2接续。 From 0deb19bf4da73a1cc0e82f398925c540da7edb55 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:53:19 +0800 Subject: [PATCH 26/31] test(qt): expose terminal geometry in native observer window coordinates (refs #155) --- src/qt/ime_native_observer.hpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/qt/ime_native_observer.hpp b/src/qt/ime_native_observer.hpp index d5d886f0..9a2441db 100644 --- a/src/qt/ime_native_observer.hpp +++ b/src/qt/ime_native_observer.hpp @@ -48,9 +48,12 @@ class Observation final : public QObject { if (!page_) return {{"target_alive", false}}; const auto& model = page_->model(); const auto& terminal = page_->terminal(); + const auto origin = terminal.mapTo(terminal.window(), QPoint()); return {{"target_alive", true}, {"visible", terminal.isVisible()}, {"has_focus", terminal.hasFocus()}, {"ime_enabled", terminal.inputMethodQuery(Qt::ImEnabled).toBool()}, {"cursor_rectangle_logical", rect(terminal.inputMethodQuery(Qt::ImCursorRectangle).toRectF())}, + {"terminal_origin_window_logical", QJsonArray{origin.x(), origin.y()}}, + {"terminal_size_logical", QJsonArray{terminal.width(), terminal.height()}}, {"grid_columns", model.screen().width()}, {"grid_rows", model.screen().height()}, {"terminal_column", model.screen().column}, {"terminal_row", model.screen().row}, {"requested_widget_font_family", terminal.font().family()}, From c6d10335e4cf76e4e1b0a66815aaa93d82297dee Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:36:56 +0800 Subject: [PATCH 27/31] fix(qt): preserve first IME observer evidence write failure --- CMakeLists.txt | 7 + .../qt-ime-native-observation-plan.md | 4 + src/qt/ime_evidence_write_test.cpp | 133 ++++++++++++++++++ src/qt/ime_native_observer.hpp | 104 +++++++++++--- 4 files changed, 232 insertions(+), 16 deletions(-) create mode 100644 src/qt/ime_evidence_write_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1ac1398f..9c7e9b16 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -471,6 +471,13 @@ if(TERMFORGE_BUILD_QT_SHELL) "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$") set_tests_properties(qt_ime_input PROPERTIES TIMEOUT 35 ENVIRONMENT "QT_QPA_PLATFORM=offscreen;TERMFORGE_TEST_ARTIFACTS=${PROJECT_BINARY_DIR}/test-artifacts") + add_executable(qt_ime_evidence_write_test src/qt/ime_evidence_write_test.cpp) + set_target_properties(qt_ime_evidence_write_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qt-ime-evidence-bin/$") + termforge_qt_entry(qt_ime_evidence_write_test) + target_link_libraries(qt_ime_evidence_write_test PRIVATE imm32 ole32 uuid) + add_test(NAME qt_ime_evidence_write COMMAND qt_ime_evidence_write_test) + set_tests_properties(qt_ime_evidence_write PROPERTIES TIMEOUT 10) foreach(scenario normal cancel) add_test(NAME qt_ime_observer_lifecycle_${scenario} COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/src/qt/run_ime_observer_lifecycle.py" "$" "${scenario}") diff --git a/docs/research/qt-ime-native-observation-plan.md b/docs/research/qt-ime-native-observation-plan.md index 92c55d9a..da4dcf72 100644 --- a/docs/research/qt-ime-native-observation-plan.md +++ b/docs/research/qt-ime-native-observation-plan.md @@ -42,6 +42,10 @@ a-events.json / b-events.json 是widget处理前的公开事件观察,不把 原始IME文本最多256单位、属性最多258、每目标事件最多128;超过会显式标evidence_gap并失败收尾,不截断后称完整。自有QImage只表示产品控件渲染,presented_done保持null。observer_complete/CLI0仅表示观察器与两接收端正常完成;native_os_ime_acceptance保持null,真正IME功能结论由root对来源、candidate/焦点、事件、原始接收及回收独立审核。 +留证失败仍立即停止,不重试或忽略。首个失败的 `evidence_write_failure` 保留 target、JSON open/write/commit 或 PNG save 操作、仅本地 basename、Qt FileError/errorString、JSON 预期/实际 bytes 或 PNG writer error;后续成功或失败不覆盖首错。JSON 必须一次写入完整序列化长度。最终 state 在两个目标 snapshot 后读取该上下文,成功写出的 about-to-quit/complete 带首错;complete 文件自身写失败时,结构化 stdout 保留同一上下文并返回失败,不能声称该文件存在。错误字符串删除私有目录,不公开原生截图或路径。 + +`qt_ime_evidence_write` 为独立 console seam:实际 QSaveFile 正常/open/无 DELETE sharing 的目标句柄造成的 commit 失败,受控短写/-1 不重试不 commit,PNG writer 失败,以及 sticky/final JSON 传播。它不创建 GUI、receiver、IME 输入或 VM。共享锁用例只证明该失败能被诊断;PS reader 的共享模式与原生观察失败之间的因果仍是待验证 hypothesis,不据本地合成反例认定。 + 当前258属性guard由256单位最多256个非空格式范围+Cursor+Selection推导;零length格式不占格式存储,过量no-op提前拒绝。它与256单位/非法UTF16拒绝都是TermForge工程边界,非WindTerm已对照。Ctrl-C显式分支不被composition普通文本guard吞掉,provider实际取消政策仍待这次原生观察。 只读API语义依据:[GetActiveProfile](https://learn.microsoft.com/en-us/windows/win32/api/msctf/nf-msctf-itfinputprocessorprofilemgr-getactiveprofile)、[TF_INPUTPROCESSORPROFILE](https://learn.microsoft.com/en-us/windows/win32/api/msctf/ns-msctf-tf_inputprocessorprofile)、[CoInitializeEx](https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-coinitializeex)、[CoUninitialize](https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-couninitialize)。API查询不等于真实候选可见或物理键盘来源,仍须外部链证据。 diff --git a/src/qt/ime_evidence_write_test.cpp b/src/qt/ime_evidence_write_test.cpp new file mode 100644 index 00000000..25909559 --- /dev/null +++ b/src/qt/ime_evidence_write_test.cpp @@ -0,0 +1,133 @@ +// Console-only evidence transactions: no windows, receivers or input APIs. +#include "ime_native_observer.hpp" +#include +#include +#include + +using namespace termforge::qt::ime_native; +namespace { +void require(bool value, const char* message) { if (!value) throw std::runtime_error(message); } +QJsonObject context(const EvidenceWriteFailure& failure) { + require(failure.snapshot().isObject(), "Expected a structured evidence failure"); + return failure.snapshot().toObject(); +} +void identity(const QJsonObject& row, const char* operation, const char* basename, + const QString& private_directory) { + require(row.value("target") == QStringLiteral("a") && row.value("operation") == QLatin1String(operation) && + row.value("basename") == QLatin1String(basename), "Failure identity differs"); + const auto error = row.value("qt_error_string").toString(); + require(!error.isEmpty() && !error.contains(private_directory, Qt::CaseInsensitive) && + !error.contains(QDir::toNativeSeparators(private_directory), Qt::CaseInsensitive), + "Failure error is empty or exposes the private directory"); +} +template void expect_failure(Action action) { + bool failed = false; + try { action(); } catch (const std::runtime_error&) { failed = true; } + require(failed, "Evidence transaction unexpectedly succeeded"); +} +struct ShortFile { + qint64 result; + int commits = 0, writes = 0; + bool open(QIODevice::OpenMode) { return true; } + qint64 write(const QByteArray&) { ++writes; return result; } + bool commit() { ++commits; return true; } + QFileDevice::FileError error() const { return QFileDevice::WriteError; } + QString errorString() const { return QStringLiteral("controlled short write"); } +}; +struct LockedFile { + HANDLE handle; + explicit LockedFile(const QString& path) : handle(CreateFileW( + reinterpret_cast(path.utf16()), GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)) { + require(handle != INVALID_HANDLE_VALUE, "Could not retain the controlled sharing lock"); + } + ~LockedFile() { CloseHandle(handle); } +}; +} +int main(int argc, char** argv) { + QCoreApplication application(argc, argv); + try { + QTemporaryDir temporary; + require(temporary.isValid(), "Controlled evidence directory is unavailable"); + const auto directory = temporary.path(), path = directory + QStringLiteral("/a-events.json"); + const QJsonObject original{{"synthetic", true}}; + QJsonArray cases; + EvidenceWriteFailure good; + save_json(path, original, good, QStringLiteral("a")); + require(good.snapshot().isNull(), "Success created a failure context"); + cases.append("json-success"); + + EvidenceWriteFailure missing; + expect_failure([&] { save_json(directory + QStringLiteral("/missing/a-events.json"), original, + missing, QStringLiteral("a")); }); + const auto open = context(missing); + identity(open, "json-open", "a-events.json", directory); + require(open.value("qt_file_error").toInt() != 0 && open.value("actual_bytes").toInteger() == 0 && + open.value("expected_bytes").toInteger() > 0, "Open failure details differ"); + cases.append("json-open"); + + for (const qint64 written : {qint64(3), qint64(-1)}) { + ShortFile file{written}; EvidenceWriteFailure short_write; + expect_failure([&] { detail::save_json_file(file, path, QByteArray("1234"), short_write, QStringLiteral("a")); }); + const auto row = context(short_write); + identity(row, "json-write", "a-events.json", directory); + require(row.value("expected_bytes").toInteger() == 4 && row.value("actual_bytes").toInteger() == written && + file.writes == 1 && file.commits == 0, "Incomplete write was retried or committed"); + cases.append(written < 0 ? "json-write-error" : "json-short-write"); + } + + EvidenceWriteFailure locked; + { + LockedFile lock(path); + expect_failure([&] { save_json(path, {{"replacement", true}}, locked, QStringLiteral("a")); }); + } + const auto commit = context(locked); + identity(commit, "json-commit", "a-events.json", directory); + require(commit.value("qt_file_error").toInt() != 0 && commit.value("expected_bytes").toInteger() > 0 && + commit.value("expected_bytes") == commit.value("actual_bytes"), "Commit failure lost the complete write facts"); + QFile retained(path); require(retained.open(QIODevice::ReadOnly), "Could not inspect the retained original"); + require(QJsonDocument::fromJson(retained.readAll()).object() == original, "Failed replacement changed the original JSON"); + retained.close(); cases.append("json-commit-sharing-lock"); + + QImage image(2, 2, QImage::Format_ARGB32); image.fill(Qt::black); + EvidenceWriteFailure png_failure; + require(!save_png(directory + QStringLiteral("/missing/a-frame-0.png"), image, png_failure, QStringLiteral("a")), + "PNG failure unexpectedly succeeded"); + const auto png = context(png_failure); + identity(png, "png-save", "a-frame-0.png", directory); + require(png.value("png_writer_error").toInt() != 0, "PNG writer error was not retained"); + cases.append("png-save"); + + save_json(path, original, locked, QStringLiteral("a")); + require(!save_png(directory + QStringLiteral("/missing/later.png"), image, locked, QStringLiteral("b")), + "Later controlled failure unexpectedly succeeded"); + require(context(locked) == commit, "A later success or failure replaced the first failure"); + for (const auto* name : {"about-to-quit.json", "complete.json"}) { + const auto final_path = directory + QLatin1Char('/') + QLatin1String(name); + save_json(final_path, {{"evidence_write_failure", locked.snapshot()}}, locked); + QFile final_file(final_path); require(final_file.open(QIODevice::ReadOnly), "Could not inspect the synthetic final JSON"); + require(QJsonDocument::fromJson(final_file.readAll()).object().value("evidence_write_failure").toObject() == commit, + "Synthetic final JSON lost the sticky failure"); + } + cases.append("sticky-first-failure-and-final-json"); + const auto final_path = directory + QStringLiteral("/complete.json"); + EvidenceWriteFailure final_failure; + QJsonObject final{{"failed", false}, {"failure", ""}, {"observer_complete", true}}; + { + LockedFile lock(final_path); + require(!save_complete_json(final_path, final, final_failure), "A failed final write reported success"); + } + require(final.value("failed").toBool() && !final.value("observer_complete").toBool() && + final.value("evidence_write_failure") == final_failure.snapshot() && + context(final_failure).value("basename") == QStringLiteral("complete.json") && + context(final_failure).value("operation") == QStringLiteral("json-commit"), + "The structured final fallback lost its own failure"); + cases.append("complete-write-failure-structured-fallback"); + const QJsonObject report{{"status", "passed"}, {"cases", cases}, {"sharing_lock_failure", commit}, + {"native_gui", false}, {"ime_input", false}, {"vm", false}}; + std::cout << QJsonDocument(report).toJson(QJsonDocument::Compact).constData() << '\n'; + return 0; + } catch (const std::exception& error) { + std::cerr << "Evidence diagnostic test failed: " << error.what() << '\n'; return 1; + } +} diff --git a/src/qt/ime_native_observer.hpp b/src/qt/ime_native_observer.hpp index 9a2441db..d6075db7 100644 --- a/src/qt/ime_native_observer.hpp +++ b/src/qt/ime_native_observer.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -23,10 +24,65 @@ namespace termforge::qt::ime_native { inline void check(bool value, const char* message) { if (!value) throw std::runtime_error(message); } -inline void save_json(const QString& path, const QJsonObject& value) { +class EvidenceWriteFailure final { + QJsonObject first_; +public: + void remember(const QJsonObject& context) { if (first_.isEmpty()) first_ = context; } + QJsonValue snapshot() const { return first_.isEmpty() ? QJsonValue() : QJsonValue(first_); } +}; +inline QString evidence_error(const QString& path, QString error) { + const auto directory = QFileInfo(path).absolutePath(); + for (const auto& private_part : {QFileInfo(path).absoluteFilePath(), path, + QDir::toNativeSeparators(directory), directory}) + if (private_part.size() > 1) error.replace(private_part, QStringLiteral(""), Qt::CaseInsensitive); + return error; +} +namespace detail { +// The same single-write transaction accepts a short-writing test device; it +// never fills a partial write or retries an unsuccessful atomic replacement. +template +void save_json_file(File& file, const QString& path, const QByteArray& bytes, + EvidenceWriteFailure& failure, const QString& target) { + qint64 written = 0; + const auto stop = [&](const QString& operation) { + failure.remember({{"target", target}, {"operation", operation}, + {"basename", QFileInfo(path).fileName()}, + {"qt_file_error", static_cast(file.error())}, + {"qt_error_string", evidence_error(path, file.errorString())}, + {"expected_bytes", static_cast(bytes.size())}, {"actual_bytes", written}}); + throw std::runtime_error("native IME evidence write failed"); + }; + if (!file.open(QIODevice::WriteOnly)) stop(QStringLiteral("json-open")); + written = file.write(bytes); + if (written != bytes.size()) stop(QStringLiteral("json-write")); + if (!file.commit()) stop(QStringLiteral("json-commit")); +} +} // namespace detail +inline void save_json(const QString& path, const QJsonObject& value, + EvidenceWriteFailure& failure, const QString& target = QStringLiteral("observer")) { QSaveFile file(path); - check(file.open(QIODevice::WriteOnly) && file.write(QJsonDocument(value).toJson()) > 0 && file.commit(), - "native IME evidence write failed"); + detail::save_json_file(file, path, QJsonDocument(value).toJson(), failure, target); +} +inline bool save_png(const QString& path, const QImage& image, + EvidenceWriteFailure& failure, const QString& target) { + QImageWriter writer(path, "png"); + if (writer.write(image)) return true; + failure.remember({{"target", target}, {"operation", "png-save"}, + {"basename", QFileInfo(path).fileName()}, + {"png_writer_error", static_cast(writer.error())}, + {"qt_error_string", evidence_error(path, writer.errorString())}}); + return false; +} +inline bool save_complete_json(const QString& path, QJsonObject& final, EvidenceWriteFailure& failure) { + try { save_json(path, final, failure); return true; } + catch (const std::exception& error) { + // A failed final file cannot contain its own failure. The caller emits + // this same structured result to stdout and must return failure. + final.insert("failed", true); final.insert("observer_complete", false); + if (final.value("failure").toString().isEmpty()) final.insert("failure", QString::fromUtf8(error.what())); + final.insert("evidence_write_failure", failure.snapshot()); + return false; + } } inline QJsonArray rect(const QRectF& value) { return {value.x(), value.y(), value.width(), value.height()}; @@ -44,6 +100,7 @@ class Observation final : public QObject { QString directory_, label_; QJsonArray events_; bool evidence_gap_ = false, write_failed_ = false; + EvidenceWriteFailure& write_failure_; QJsonObject target() const { if (!page_) return {{"target_alive", false}}; const auto& model = page_->model(); @@ -71,8 +128,17 @@ class Observation final : public QObject { {"synthetic_qt_input_events_sent", 0}, {"event_filter_phase", "before widget handler"}, {"spontaneous_flag_is_not_origin_proof", true}, - {"evidence_gap", evidence_gap_}, {"events", events_}, {"current", target()}}); - } catch (...) { write_failed_ = true; } + {"evidence_gap", evidence_gap_}, {"write_failed", write_failed_}, + {"evidence_write_failure", write_failure_.snapshot()}, + {"events", events_}, {"current", target()}}, write_failure_, label_); + } catch (...) { + write_failed_ = true; + // A non-file exception must also leave a bounded, path-free record. + write_failure_.remember({{"target", label_}, {"operation", "json-prepare"}, + {"basename", label_ + QStringLiteral("-events.json")}, + {"qt_file_error", QJsonValue()}, {"qt_error_string", QJsonValue()}, + {"exception", "Exception outside a QSaveFile result"}}); + } } void append(QJsonObject event) { if (events_.size() >= 128) { evidence_gap_ = true; write(); return; } @@ -122,7 +188,7 @@ class Observation final : public QObject { QImage image(page_->terminal().size(), QImage::Format_ARGB32); image.fill(Qt::transparent); page_->terminal().render(&image); const auto name = label_ + QStringLiteral("-frame-%1.png").arg(sequence); - if (!image.save(directory_ + QLatin1Char('/') + name)) write_failed_ = true; + if (!save_png(directory_ + QLatin1Char('/') + name, image, write_failure_, label_)) write_failed_ = true; append({{"kind", "queued-frame"}, {"input_sequence", sequence}, {"events_seen_before_frame", events_.size()}, {"coalesced_input_updates", coalesced_input}, {"image", name}, {"state", target()}}); @@ -149,8 +215,9 @@ class Observation final : public QObject { return false; } public: - Observation(SessionPage* page, const QString& directory, const QString& label, QObject* parent) : - QObject(parent), page_(page), directory_(directory), label_(label) { + Observation(SessionPage* page, const QString& directory, const QString& label, QObject* parent, + EvidenceWriteFailure& write_failure) : + QObject(parent), page_(page), directory_(directory), label_(label), write_failure_(write_failure) { check(page != nullptr, "native IME page is missing"); page->terminal().installEventFilter(this); write(); } @@ -183,8 +250,9 @@ inline int run(QApplication& app, const QString& directory, Purpose purpose = Pu window.resize(960, 640); window.show(); auto* tabs = window.findChild(); check(tabs != nullptr, "native IME tab bar is missing"); tabs->setCurrentIndex(0); first->terminal().setFocus(); - Observation first_events(first, directory, QStringLiteral("a"), &window); - Observation second_events(second, directory, QStringLiteral("b"), &window); + EvidenceWriteFailure write_failure; + Observation first_events(first, directory, QStringLiteral("a"), &window, write_failure); + Observation second_events(second, directory, QStringLiteral("b"), &window, write_failure); bool ready_written = false, finish_requested = false, output_requested = false, failed = false; bool close_requested = false, deadline_expired = false, sessions_completed = false; int sessions_exit_code = -1; @@ -193,6 +261,9 @@ inline int run(QApplication& app, const QString& directory, Purpose purpose = Pu QObject lifetime; QTimer timer; const auto state = [&] { + // snapshot() writes evidence itself. Collect its possible first failure + // before reading the shared sticky context for this state/final report. + const auto a_state = first_events.snapshot(), b_state = second_events.snapshot(); QJsonArray tab_rectangles; for (int index = 0; index < tabs->count(); ++index) tab_rectangles.append(rect(QRectF(tabs->tabRect(index).translated(tabs->mapTo(&window, QPoint()))))); @@ -211,7 +282,8 @@ inline int run(QApplication& app, const QString& directory, Purpose purpose = Pu {"failed", failed}, {"failure", failure}, {"elapsed_ms", elapsed.elapsed()}, {"close_requested", close_requested}, {"deadline_expired", deadline_expired}, {"sessions_completed", sessions_completed}, {"sessions_exit_code", sessions_exit_code}, - {"a", first_events.snapshot()}, {"b", second_events.snapshot()}}; + {"a", a_state}, {"b", b_state}, + {"evidence_write_failure", write_failure.snapshot()}}; }; const auto fail_and_close = [&](const QString& reason) { if (!failed) { failed = true; failure = reason; } @@ -228,7 +300,7 @@ inline int run(QApplication& app, const QString& directory, Purpose purpose = Pu app.exit(failed || code != 0 ? 1 : 0); }; QObject::connect(&app, &QCoreApplication::aboutToQuit, &lifetime, [&] { - try { save_json(directory + QStringLiteral("/about-to-quit.json"), state()); } + try { save_json(directory + QStringLiteral("/about-to-quit.json"), state(), write_failure); } catch (const std::exception& error) { failed = true; failure = QString::fromUtf8(error.what()); } }); QObject::connect(&timer, &QTimer::timeout, &lifetime, [&] { @@ -243,9 +315,9 @@ inline int run(QApplication& app, const QString& directory, Purpose purpose = Pu } if (!ready_written && a.ready() && b.ready() && ready_text(first) && ready_text(second)) { ready_written = true; - save_json(directory + QStringLiteral("/ready.json"), state()); + save_json(directory + QStringLiteral("/ready.json"), state(), write_failure); } - if (native) save_json(directory + QStringLiteral("/native-state.json"), state()); + if (native) save_json(directory + QStringLiteral("/native-state.json"), state(), write_failure); if (!finish_requested && !output_requested && QFileInfo::exists(directory + QStringLiteral("/output.request"))) { output_requested = true; a.output(); } @@ -269,7 +341,7 @@ inline int run(QApplication& app, const QString& directory, Purpose purpose = Pu timer.start(50); const auto result = app.exec(); auto final = state(); - const bool complete = !failed && !deadline_expired && sessions_completed && sessions_exit_code == 0 && + bool complete = !failed && !deadline_expired && sessions_completed && sessions_exit_code == 0 && ready_written && finish_requested && first && second && first->model().natural_exit() && second->model().natural_exit() && first->model().exit_code() == 0 && second->model().exit_code() == 0 && @@ -278,7 +350,7 @@ inline int run(QApplication& app, const QString& directory, Purpose purpose = Pu first->model().cleanup_verified() && second->model().cleanup_verified() && first_events.valid() && second_events.valid(); final.insert("application_exit_code", result); final.insert("observer_complete", complete); - save_json(directory + QStringLiteral("/complete.json"), final); + if (!save_complete_json(directory + QStringLiteral("/complete.json"), final, write_failure)) complete = false; std::cout << QJsonDocument(final).toJson(QJsonDocument::Compact).constData() << '\n'; return complete && result == 0 ? 0 : 1; } From 47e6a9b04d231f4477cb4fec6c41cc4dc0f8c0f2 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:00:38 +0800 Subject: [PATCH 28/31] ci(qt): run IME evidence write diagnostics --- .github/workflows/qt-cpp.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/qt-cpp.yml b/.github/workflows/qt-cpp.yml index 41361345..99991bdd 100644 --- a/.github/workflows/qt-cpp.yml +++ b/.github/workflows/qt-cpp.yml @@ -42,7 +42,7 @@ jobs: "-DTERMFORGE_CONPTY_BACKEND_DIR=$dependencyRoot/conpty" ` "-DTERMFORGE_MSVC_RUNTIME_DIR=$dependencyRoot/crt" - name: Build Qt product and independent smoke - run: cmake --build build/qt-cpp --config Release --target termforge_qt_shell qt_cpp_shell_smoke qt_cpp_resize_smoke qt_unicode_input_test qt_ime_input_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_ime_input_test qt_ime_evidence_write_test --parallel 2 - name: Record complete path-free runtime manifests run: | python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-bin/Release --executable termforge_qt_shell.exe @@ -69,7 +69,7 @@ jobs: build/qt-cpp/qt-unicode-bin/Release/runtime-manifest.json build/qt-cpp/qt-ime-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|ime)_input|qt_ime_observer_lifecycle_(normal|cancel))$' --no-tests=error --verbose + run: ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_(unicode|ime)_input|qt_ime_observer_lifecycle_(normal|cancel)|qt_ime_evidence_write)$' --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. From 27ec32a1b8f49b70fbc43b1b5210137dcc04d69d Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:52:50 +0800 Subject: [PATCH 29/31] fix(qt): publish immutable native IME observation snapshots --- CMakeLists.txt | 2 +- .../qt-ime-native-observation-plan.md | 6 + src/qt/ime_evidence_write_test.cpp | 123 ++++++++++++++++++ src/qt/ime_native_observer.hpp | 120 +++++++++++++++-- 4 files changed, 241 insertions(+), 10 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9c7e9b16..de6f84c5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -477,7 +477,7 @@ if(TERMFORGE_BUILD_QT_SHELL) termforge_qt_entry(qt_ime_evidence_write_test) target_link_libraries(qt_ime_evidence_write_test PRIVATE imm32 ole32 uuid) add_test(NAME qt_ime_evidence_write COMMAND qt_ime_evidence_write_test) - set_tests_properties(qt_ime_evidence_write PROPERTIES TIMEOUT 10) + set_tests_properties(qt_ime_evidence_write PROPERTIES TIMEOUT 60) foreach(scenario normal cancel) add_test(NAME qt_ime_observer_lifecycle_${scenario} COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/src/qt/run_ime_observer_lifecycle.py" "$" "${scenario}") diff --git a/docs/research/qt-ime-native-observation-plan.md b/docs/research/qt-ime-native-observation-plan.md index da4dcf72..43908fee 100644 --- a/docs/research/qt-ime-native-observation-plan.md +++ b/docs/research/qt-ime-native-observation-plan.md @@ -46,6 +46,12 @@ a-events.json / b-events.json 是widget处理前的公开事件观察,不把 `qt_ime_evidence_write` 为独立 console seam:实际 QSaveFile 正常/open/无 DELETE sharing 的目标句柄造成的 commit 失败,受控短写/-1 不重试不 commit,PNG writer 失败,以及 sticky/final JSON 传播。它不创建 GUI、receiver、IME 输入或 VM。共享锁用例只证明该失败能被诊断;PS reader 的共享模式与原生观察失败之间的因果仍是待验证 hypothesis,不据本地合成反例认定。 +native 每次 50ms poll 成功采样并保存原 legacy state 后,另发布 `observer/snapshots/state-00000001.json` 起的不可变 packet,不维护 mutable latest。固定六字段为 schema=`termforge-qt-ime-snapshot-v1`、generation(文件名整数)、process_id(实际进程)、state、a_events_json、b_events_json;后两项是当次实际 Observation 事件对象的 UTF-8 JSON 文本,不声称等于异步 legacy 文件。所有原 JSON/PNG、ready/complete、输入与 45s 期限保留,正式核心和 IME 控件不改。 + +每代目标必须新:QSaveFile 先提交到独占临时目录,再以同目录非覆盖 move 发布最终名,失败即停止且首错 sticky,不重试、不覆盖既有代。工程上限为单 packet 1MiB、1024 代、累计 16MiB(按最终编码 bytes);越界显式失败,不截断或改判通过。这些是诊断资源上限,不是产品性能/验收门槛。console focused 覆盖持上一代 R/W/Delete reader 时发布新代、原字节不变、六字段及文本精确绑定、既有目标与三个边界拒绝和首错保留;原四项 focused CTest 继续执行。 + +app.exec 返回后的原终态采样另发布一次 final packet,同样计入上述预算;complete 的 `immutable_snapshot_generation` 精确绑定该代,reader 在退出后用该代复核原事件 oracle。若已有 queued-frame 回调未结束或原事件证据无效,不补捕获/输入或延长期限,final packet 拒绝发布;发布失败令 generation 为 null、整体 CLI 失败并保留首错,不能用旧 last-timer 代冒充完整终态。原 complete/raw 条件继续保留。 + 当前258属性guard由256单位最多256个非空格式范围+Cursor+Selection推导;零length格式不占格式存储,过量no-op提前拒绝。它与256单位/非法UTF16拒绝都是TermForge工程边界,非WindTerm已对照。Ctrl-C显式分支不被composition普通文本guard吞掉,provider实际取消政策仍待这次原生观察。 只读API语义依据:[GetActiveProfile](https://learn.microsoft.com/en-us/windows/win32/api/msctf/nf-msctf-itfinputprocessorprofilemgr-getactiveprofile)、[TF_INPUTPROCESSORPROFILE](https://learn.microsoft.com/en-us/windows/win32/api/msctf/ns-msctf-tf_inputprocessorprofile)、[CoInitializeEx](https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-coinitializeex)、[CoUninitialize](https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-couninitialize)。API查询不等于真实候选可见或物理键盘来源,仍须外部链证据。 diff --git a/src/qt/ime_evidence_write_test.cpp b/src/qt/ime_evidence_write_test.cpp index 25909559..1f33cf8e 100644 --- a/src/qt/ime_evidence_write_test.cpp +++ b/src/qt/ime_evidence_write_test.cpp @@ -43,6 +43,128 @@ struct LockedFile { } ~LockedFile() { CloseHandle(handle); } }; +struct SharedReader { + HANDLE handle; + explicit SharedReader(const QString& path) : handle(CreateFileW( + reinterpret_cast(path.utf16()), GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)) { + require(handle != INVALID_HANDLE_VALUE, "Could not retain the controlled R/W/Delete reader"); + } + ~SharedReader() { CloseHandle(handle); } +}; +QByteArray file_bytes(const QString& path) { + QFile file(path); require(file.open(QIODevice::ReadOnly), "Could not read the synthetic immutable packet"); + return file.readAll(); +} +QString snapshot_path(const QString& directory, int generation) { + return directory + QStringLiteral("/snapshots/state-%1.json").arg(generation, 8, 10, QLatin1Char('0')); +} +QJsonObject events(const QString& target) { + return {{"schema", "termforge-qt-ime-native-events-v1"}, {"target", target}, {"evidence_gap", false}, + {"events", QJsonArray{QJsonObject{{"kind", "input-method"}, {"sequence", 3}, {"preedit_utf16le_hex", "604f7d59"}}}}}; +} +void snapshot_tests(const QString& directory, QJsonArray& cases) { + const auto a = events(QStringLiteral("a")), b = events(QStringLiteral("b")); + const QJsonObject state{{"schema", "termforge-qt-ime-native-observer-v1"}, {"elapsed_ms", 50}, + {"cursor_rectangle_logical", QJsonArray{62, 31.1171875, 1, 20}}}; + const auto normal = directory + QStringLiteral("/immutable-normal"); + EvidenceWriteFailure failure; ImmutableSnapshots producer(normal, failure); + producer.publish(state, a, b); + const auto first = snapshot_path(normal, 1); + const auto first_bytes = file_bytes(first); + { + SharedReader held(first); + producer.publish(state, a, b); + require(file_bytes(first) == first_bytes, "Publishing a new name changed the held old generation"); + } + const auto packet = QJsonDocument::fromJson(file_bytes(snapshot_path(normal, 2))).object(); + require(packet.size() == 6 && packet.value("schema") == QStringLiteral("termforge-qt-ime-snapshot-v1") && + packet.value("generation").toInteger() == 2 && packet.value("process_id").toInteger() == QCoreApplication::applicationPid() && + packet.value("state").toObject() == state && + packet.value("a_events_json").toString().toUtf8() == QJsonDocument(a).toJson() && + packet.value("b_events_json").toString().toUtf8() == QJsonDocument(b).toJson(), + "Packet schema, sequence, PID, range or exact event JSON text differs"); + require(failure.snapshot().isNull() && producer.published_count() == 2, "Successful generations recorded a failure"); + cases.append("immutable-new-name-with-held-RWD-reader"); + + EvidenceWriteFailure collision; ImmutableSnapshots conflicting(normal, collision); + expect_failure([&] { conflicting.publish(state, a, b); }); + require(context(collision).value("operation") == QStringLiteral("snapshot-existing-target") && file_bytes(first) == first_bytes, + "Existing generation was replaced or its refusal was lost"); + const auto first_error = collision.snapshot(); + expect_failure([&] { conflicting.publish({}, {}, {}); }); + require(collision.snapshot() == first_error && conflicting.published_count() == 0, "Stopped publisher lost its first failure"); + cases.append("immutable-existing-target-and-sticky-stop"); + + const auto calibration = directory + QStringLiteral("/calibration"); + EvidenceWriteFailure calibration_failure; ImmutableSnapshots calibrate(calibration, calibration_failure); + calibrate.publish({{"padding", ""}}, {}, {}); + const qint64 base_bytes = file_bytes(snapshot_path(calibration, 1)).size(); + const auto exact = directory + QStringLiteral("/packet-exact"); + EvidenceWriteFailure exact_failure; ImmutableSnapshots exact_packet(exact, exact_failure); + exact_packet.publish({{"padding", QString(ImmutableSnapshots::maximum_packet_bytes - base_bytes, QLatin1Char('x'))}}, {}, {}); + require(file_bytes(snapshot_path(exact, 1)).size() == ImmutableSnapshots::maximum_packet_bytes, + "Exact 1MiB packet was rejected or truncated"); + cases.append("immutable-exact-packet-byte-bound"); + const auto too_large = directory + QStringLiteral("/packet-over"); + EvidenceWriteFailure over_failure; ImmutableSnapshots over_packet(too_large, over_failure); + over_packet.publish({}, {}, {}); + const auto over_first = file_bytes(snapshot_path(too_large, 1)); + expect_failure([&] { over_packet.publish({{"padding", QString(ImmutableSnapshots::maximum_packet_bytes - base_bytes + 1, QLatin1Char('x'))}}, {}, {}); }); + require(context(over_failure).value("operation") == QStringLiteral("snapshot-packet-bound") && + !QFileInfo::exists(snapshot_path(too_large, 2)) && file_bytes(snapshot_path(too_large, 1)) == over_first, + "Oversize packet was published or changed existing evidence"); + cases.append("immutable-packet-byte-bound-refusal"); + + const auto total = directory + QStringLiteral("/packet-total"); + EvidenceWriteFailure total_failure; ImmutableSnapshots total_packets(total, total_failure); + for (int generation = 1; generation <= 16; ++generation) { + const auto digits = QString::number(generation).size(); + total_packets.publish({{"padding", QString(ImmutableSnapshots::maximum_packet_bytes - base_bytes - digits + 1, QLatin1Char('x'))}}, {}, {}); + require(file_bytes(snapshot_path(total, generation)).size() == ImmutableSnapshots::maximum_packet_bytes, + "Aggregate boundary fixture is not exactly 1MiB per packet"); + } + const auto total_first = file_bytes(snapshot_path(total, 1)), total_last = file_bytes(snapshot_path(total, 16)); + require(total_packets.saved_bytes() == ImmutableSnapshots::maximum_saved_bytes, "Exact 16MiB aggregate was rejected"); + expect_failure([&] { total_packets.publish({}, {}, {}); }); + require(context(total_failure).value("operation") == QStringLiteral("snapshot-total-bound") && + !QFileInfo::exists(snapshot_path(total, 17)) && file_bytes(snapshot_path(total, 1)) == total_first && + file_bytes(snapshot_path(total, 16)) == total_last, "Aggregate refusal changed a published generation"); + cases.append("immutable-total-byte-bound-refusal"); + + const auto count = directory + QStringLiteral("/packet-count"); + EvidenceWriteFailure count_failure; ImmutableSnapshots count_packets(count, count_failure); + for (int generation = 1; generation <= ImmutableSnapshots::maximum_count; ++generation) count_packets.publish({}, {}, {}); + const auto count_first = file_bytes(snapshot_path(count, 1)), count_last = file_bytes(snapshot_path(count, 1024)); + expect_failure([&] { count_packets.publish({}, {}, {}); }); + require(context(count_failure).value("operation") == QStringLiteral("snapshot-count-bound") && count_packets.published_count() == 1024 && + QDir(count + QStringLiteral("/snapshots")).entryList({QStringLiteral("state-*.json")}, QDir::Files).size() == 1024 && + !QFileInfo::exists(snapshot_path(count, 1025)) && file_bytes(snapshot_path(count, 1)) == count_first && + file_bytes(snapshot_path(count, 1024)) == count_last, "Count refusal changed a published generation"); + cases.append("immutable-count-bound-refusal"); + + const auto ending = directory + QStringLiteral("/packet-final"); + EvidenceWriteFailure final_failure; ImmutableSnapshots final_packets(ending, final_failure); + final_packets.publish(state, a, b); + auto final_state = state; + require(publish_final_snapshot(final_packets, final_state, a, b, 0, 0, final_failure) && + final_state.value("immutable_snapshot_generation").toInteger() == 2 && + QJsonDocument::fromJson(file_bytes(snapshot_path(ending, 2))).object().value("state").toObject() == state, + "Complete metadata is not bound to the actual final packet"); + cases.append("immutable-final-generation-binding"); + auto pending_state = state; + require(!publish_final_snapshot(final_packets, pending_state, a, b, 1, 0, final_failure) && + pending_state.value("failed").toBool() && pending_state.value("immutable_snapshot_generation").isNull() && + context(final_failure).value("operation") == QStringLiteral("snapshot-final-incomplete-events") && + !QFileInfo::exists(snapshot_path(ending, 3)), "Pending queued events were passed off as a final packet"); + cases.append("immutable-pending-final-frame-refusal"); + auto failed_state = state; + require(!publish_final_snapshot(count_packets, failed_state, a, b, 0, 0, count_failure) && + failed_state.value("failed").toBool() && failed_state.value("immutable_snapshot_generation").isNull() && + failed_state.value("evidence_write_failure") == count_failure.snapshot(), + "Final publication failure reused an old generation or hid its context"); + cases.append("immutable-final-publication-failure-refusal"); +} } int main(int argc, char** argv) { QCoreApplication application(argc, argv); @@ -123,6 +245,7 @@ int main(int argc, char** argv) { context(final_failure).value("operation") == QStringLiteral("json-commit"), "The structured final fallback lost its own failure"); cases.append("complete-write-failure-structured-fallback"); + snapshot_tests(directory, cases); const QJsonObject report{{"status", "passed"}, {"cases", cases}, {"sharing_lock_failure", commit}, {"native_gui", false}, {"ime_input", false}, {"vm", false}}; std::cout << QJsonDocument(report).toJson(QJsonDocument::Compact).constData() << '\n'; diff --git a/src/qt/ime_native_observer.hpp b/src/qt/ime_native_observer.hpp index d6075db7..37fbb2d6 100644 --- a/src/qt/ime_native_observer.hpp +++ b/src/qt/ime_native_observer.hpp @@ -16,8 +16,10 @@ #include #include #include +#include #include #include +#include #include namespace termforge::qt::ime_native { @@ -84,6 +86,92 @@ inline bool save_complete_json(const QString& path, QJsonObject& final, Evidence return false; } } +class ImmutableSnapshots final { + QString directory_; + EvidenceWriteFailure& failure_; + qint64 generation_ = 0, saved_bytes_ = 0; + bool stopped_ = false; + [[noreturn]] void stop(const QString& operation, const QString& basename, qint64 bytes, + const QString& error, QJsonValue native_error = {}) { + stopped_ = true; + failure_.remember({{"target", "snapshot"}, {"operation", operation}, {"basename", basename}, + {"generation", generation_ + 1}, {"packet_bytes", bytes}, + {"saved_packet_bytes", saved_bytes_}, {"published_count", generation_}, + {"qt_file_error", QJsonValue()}, {"qt_error_string", error}, + {"native_error", native_error}}); + throw std::runtime_error("native IME immutable snapshot publication failed"); + } +public: + static constexpr qint64 maximum_packet_bytes = 1024 * 1024; + static constexpr qint64 maximum_count = 1024; + static constexpr qint64 maximum_saved_bytes = 16 * 1024 * 1024; + ImmutableSnapshots(const QString& observer_directory, EvidenceWriteFailure& failure) : + directory_(observer_directory + QStringLiteral("/snapshots")), failure_(failure) {} + QString next_basename() const { + return QStringLiteral("state-%1.json").arg(generation_ + 1, 8, 10, QLatin1Char('0')); + } + qint64 published_count() const { return generation_; } + qint64 saved_bytes() const { return saved_bytes_; } + void publish(const QJsonObject& state, const QJsonObject& a_events, const QJsonObject& b_events) { + const auto basename = next_basename(); + if (stopped_) stop(QStringLiteral("snapshot-stopped"), basename, 0, QStringLiteral("Publisher already stopped")); + if (generation_ >= maximum_count) + stop(QStringLiteral("snapshot-count-bound"), basename, 0, QStringLiteral("Snapshot count bound exceeded")); + const QJsonObject packet{{"schema", "termforge-qt-ime-snapshot-v1"}, {"generation", generation_ + 1}, + {"process_id", QCoreApplication::applicationPid()}, {"state", state}, + {"a_events_json", QString::fromUtf8(QJsonDocument(a_events).toJson())}, + {"b_events_json", QString::fromUtf8(QJsonDocument(b_events).toJson())}}; + const auto bytes = QJsonDocument(packet).toJson(); + if (bytes.size() > maximum_packet_bytes) + stop(QStringLiteral("snapshot-packet-bound"), basename, bytes.size(), QStringLiteral("Snapshot packet byte bound exceeded")); + if (bytes.size() > maximum_saved_bytes - saved_bytes_) + stop(QStringLiteral("snapshot-total-bound"), basename, bytes.size(), QStringLiteral("Snapshot aggregate byte bound exceeded")); + const auto final_path = directory_ + QLatin1Char('/') + basename; + if (QFileInfo::exists(final_path)) + stop(QStringLiteral("snapshot-existing-target"), basename, bytes.size(), QStringLiteral("Snapshot target must be new")); + if (!QDir().mkpath(directory_)) + stop(QStringLiteral("snapshot-directory"), basename, bytes.size(), QStringLiteral("Snapshot directory creation failed")); + // Commit a complete private file first. The final non-replacing move + // atomically refuses collisions, including a target created meanwhile. + QTemporaryDir staging(directory_ + QStringLiteral("/.pending-XXXXXX")); + if (!staging.isValid()) + stop(QStringLiteral("snapshot-staging"), basename, bytes.size(), evidence_error(final_path, staging.errorString())); + const auto pending = staging.path() + QStringLiteral("/packet.json"); + try { + QSaveFile file(pending); + detail::save_json_file(file, pending, bytes, failure_, QStringLiteral("snapshot")); + } catch (...) { stopped_ = true; throw; } + const auto native_pending = QDir::toNativeSeparators(pending), native_final = QDir::toNativeSeparators(final_path); + if (!MoveFileW(reinterpret_cast(native_pending.utf16()), + reinterpret_cast(native_final.utf16()))) { + const auto error = GetLastError(); + stop(QStringLiteral("snapshot-publish"), basename, bytes.size(), + QStringLiteral("Non-replacing snapshot move failed"), static_cast(error)); + } + ++generation_; saved_bytes_ += bytes.size(); + } +}; +inline bool publish_final_snapshot(ImmutableSnapshots& snapshots, QJsonObject& final, + const QJsonObject& a_events, const QJsonObject& b_events, + qsizetype a_pending, qsizetype b_pending, EvidenceWriteFailure& failure) { + try { + if (a_pending || b_pending || a_events.value("evidence_gap").toBool() || b_events.value("evidence_gap").toBool() || + a_events.value("write_failed").toBool() || b_events.value("write_failed").toBool()) { + failure.remember({{"target", "snapshot"}, {"operation", "snapshot-final-incomplete-events"}, + {"basename", snapshots.next_basename()}, {"a_pending_frames", a_pending}, + {"b_pending_frames", b_pending}}); + throw std::runtime_error("native IME final snapshot has incomplete event evidence"); + } + snapshots.publish(final, a_events, b_events); + final.insert("immutable_snapshot_generation", snapshots.published_count()); + return true; + } catch (const std::exception& error) { + final.insert("failed", true); final.insert("immutable_snapshot_generation", QJsonValue()); + if (final.value("failure").toString().isEmpty()) final.insert("failure", QString::fromUtf8(error.what())); + final.insert("evidence_write_failure", failure.snapshot()); + return false; + } +} inline QJsonArray rect(const QRectF& value) { return {value.x(), value.y(), value.width(), value.height()}; } @@ -100,6 +188,7 @@ class Observation final : public QObject { QString directory_, label_; QJsonArray events_; bool evidence_gap_ = false, write_failed_ = false; + qsizetype pending_frames_ = 0; EvidenceWriteFailure& write_failure_; QJsonObject target() const { if (!page_) return {{"target_alive", false}}; @@ -124,13 +213,7 @@ class Observation final : public QObject { void write() { try { save_json(directory_ + QLatin1Char('/') + label_ + QStringLiteral("-events.json"), - {{"schema", "termforge-qt-ime-native-events-v1"}, {"target", label_}, - {"synthetic_qt_input_events_sent", 0}, - {"event_filter_phase", "before widget handler"}, - {"spontaneous_flag_is_not_origin_proof", true}, - {"evidence_gap", evidence_gap_}, {"write_failed", write_failed_}, - {"evidence_write_failure", write_failure_.snapshot()}, - {"events", events_}, {"current", target()}}, write_failure_, label_); + event_document(), write_failure_, label_); } catch (...) { write_failed_ = true; // A non-file exception must also leave a bounded, path-free record. @@ -179,7 +262,9 @@ class Observation final : public QObject { append(row); // A queued frame can include newer events. Record that explicitly; // never label a coalesced image as this event's exact rendered state. + ++pending_frames_; QTimer::singleShot(0, this, [this, sequence] { + const auto finished = qScopeGuard([this] { --pending_frames_; }); if (!page_) return; bool coalesced_input = false; for (qsizetype index = sequence + 1; index < events_.size(); ++index) @@ -222,6 +307,14 @@ class Observation final : public QObject { page->terminal().installEventFilter(this); write(); } bool valid() const { return !evidence_gap_ && !write_failed_; } + qsizetype pending_frames() const { return pending_frames_; } + QJsonObject event_document() const { + return {{"schema", "termforge-qt-ime-native-events-v1"}, {"target", label_}, + {"synthetic_qt_input_events_sent", 0}, {"event_filter_phase", "before widget handler"}, + {"spontaneous_flag_is_not_origin_proof", true}, {"evidence_gap", evidence_gap_}, + {"write_failed", write_failed_}, {"evidence_write_failure", write_failure_.snapshot()}, + {"events", events_}, {"current", target()}}; + } QJsonObject snapshot() { write(); return target(); } }; inline bool ready_text(const QPointer& page) { @@ -253,6 +346,7 @@ inline int run(QApplication& app, const QString& directory, Purpose purpose = Pu EvidenceWriteFailure write_failure; Observation first_events(first, directory, QStringLiteral("a"), &window, write_failure); Observation second_events(second, directory, QStringLiteral("b"), &window, write_failure); + ImmutableSnapshots snapshots(directory, write_failure); bool ready_written = false, finish_requested = false, output_requested = false, failed = false; bool close_requested = false, deadline_expired = false, sessions_completed = false; int sessions_exit_code = -1; @@ -317,7 +411,12 @@ inline int run(QApplication& app, const QString& directory, Purpose purpose = Pu ready_written = true; save_json(directory + QStringLiteral("/ready.json"), state(), write_failure); } - if (native) save_json(directory + QStringLiteral("/native-state.json"), state(), write_failure); + if (native) { + const auto sampled = state(); + save_json(directory + QStringLiteral("/native-state.json"), sampled, write_failure); + if (!failed && first_events.valid() && second_events.valid()) + snapshots.publish(sampled, first_events.event_document(), second_events.event_document()); + } if (!finish_requested && !output_requested && QFileInfo::exists(directory + QStringLiteral("/output.request"))) { output_requested = true; a.output(); } @@ -341,7 +440,10 @@ inline int run(QApplication& app, const QString& directory, Purpose purpose = Pu timer.start(50); const auto result = app.exec(); auto final = state(); - bool complete = !failed && !deadline_expired && sessions_completed && sessions_exit_code == 0 && + const bool final_packet_complete = !native || publish_final_snapshot(snapshots, final, + first_events.event_document(), second_events.event_document(), first_events.pending_frames(), + second_events.pending_frames(), write_failure); + bool complete = final_packet_complete && !failed && !deadline_expired && sessions_completed && sessions_exit_code == 0 && ready_written && finish_requested && first && second && first->model().natural_exit() && second->model().natural_exit() && first->model().exit_code() == 0 && second->model().exit_code() == 0 && From 59d0662eafe6dd42cd8184fea176e23827727ec5 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:12:40 +0800 Subject: [PATCH 30/31] docs(qt): clarify same-volume immutable snapshot publication --- docs/research/qt-ime-native-observation-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/research/qt-ime-native-observation-plan.md b/docs/research/qt-ime-native-observation-plan.md index 43908fee..675e1f2e 100644 --- a/docs/research/qt-ime-native-observation-plan.md +++ b/docs/research/qt-ime-native-observation-plan.md @@ -48,7 +48,7 @@ a-events.json / b-events.json 是widget处理前的公开事件观察,不把 native 每次 50ms poll 成功采样并保存原 legacy state 后,另发布 `observer/snapshots/state-00000001.json` 起的不可变 packet,不维护 mutable latest。固定六字段为 schema=`termforge-qt-ime-snapshot-v1`、generation(文件名整数)、process_id(实际进程)、state、a_events_json、b_events_json;后两项是当次实际 Observation 事件对象的 UTF-8 JSON 文本,不声称等于异步 legacy 文件。所有原 JSON/PNG、ready/complete、输入与 45s 期限保留,正式核心和 IME 控件不改。 -每代目标必须新:QSaveFile 先提交到独占临时目录,再以同目录非覆盖 move 发布最终名,失败即停止且首错 sticky,不重试、不覆盖既有代。工程上限为单 packet 1MiB、1024 代、累计 16MiB(按最终编码 bytes);越界显式失败,不截断或改判通过。这些是诊断资源上限,不是产品性能/验收门槛。console focused 覆盖持上一代 R/W/Delete reader 时发布新代、原字节不变、六字段及文本精确绑定、既有目标与三个边界拒绝和首错保留;原四项 focused CTest 继续执行。 +每代目标必须新:QSaveFile 先提交到独占临时目录,再以同卷非覆盖 move 发布最终名,失败即停止且首错 sticky,不重试、不覆盖既有代。工程上限为单 packet 1MiB、1024 代、累计 16MiB(按最终编码 bytes);越界显式失败,不截断或改判通过。这些是诊断资源上限,不是产品性能/验收门槛。console focused 覆盖持上一代 R/W/Delete reader 时发布新代、原字节不变、六字段及文本精确绑定、既有目标与三个边界拒绝和首错保留;原四项 focused CTest 继续执行。 app.exec 返回后的原终态采样另发布一次 final packet,同样计入上述预算;complete 的 `immutable_snapshot_generation` 精确绑定该代,reader 在退出后用该代复核原事件 oracle。若已有 queued-frame 回调未结束或原事件证据无效,不补捕获/输入或延长期限,final packet 拒绝发布;发布失败令 generation 为 null、整体 CLI 失败并保留首错,不能用旧 last-timer 代冒充完整终态。原 complete/raw 条件继续保留。 From 5fab30b1a27781ae05d2d312018b319ffa4d367c Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:16:41 +0800 Subject: [PATCH 31/31] docs: clarify IME protocol status and index row-order evidence --- docs/EVIDENCE.md | 1 + docs/research/qt-ime-native-observation-plan.md | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index 484daba3..0fa6449d 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -8,6 +8,7 @@ | 配置 | 证据定位 | |---|---| +| 稳定行身份与文档顺序(隔离实验) | [精确定位/顺序与18项定位成本](../experiments/core/results/2026-10-29-line-order-r164/README.md)、[独立静态Search成本](../experiments/core/results/2026-10-29-static-search-r164/README.md):原caller反例、两Adapter正确性、共享宿主样本及原失败分列;生产核心未因实验改变,完整增量Search/结构通知/UI与RSS仍待验,不构成永久容器或WindTerm性能验收 | | 自主 C++ 主屏 ASCII DECAWM | [有限合同与红绿](../experiments/core/results/2026-10-29-cpp-decawm/README.md)、[固定验证身份](../experiments/core/results/2026-10-29-cpp-decawm/validation.json):抽取原版14–18阶段79B,strict/recovery各235个字面前缀分块用例;初次定向8/9的缺构建依赖失败、补建后的core单项31测试通过分列。跨1049/宽字/resize、origin/margin、GUI/固定ConPTY运输和完整TUI均不由本项验收 | | 自主 C++ 限定 1049 协议 | [固定结果](../experiments/core/results/2026-10-29-cpp-1049/README.md)、[验证身份](../experiments/core/results/2026-10-29-cpp-1049/validation.json):文字/光标/颜色及 pending-wrap、重复进入与读者失效;55项中Qt1项分列,13条内部skip及旧负例判据失败保留。未验收非颜色属性/全网格背景、其他模式、真实TUI/IME/DPI、呈现或性能 | | 自主 C++ 双缓冲存储与文档 epoch 基础 | [固定结果](../experiments/core/results/2026-10-29-buffer-state-r2/README.md)、[验证身份](../experiments/core/results/2026-10-29-buffer-state-r2/validation.json):拥有状态、共享 ID、隐藏帧 resize 回滚与跨缓冲句柄失效;Qt/candidate 关闭配置的完整 50 项及 12 条内部 skip 分列。未接 private CSI,不是原版模式、Qt 产品路线或完整 #27 验收 | diff --git a/docs/research/qt-ime-native-observation-plan.md b/docs/research/qt-ime-native-observation-plan.md index 675e1f2e..719dced7 100644 --- a/docs/research/qt-ime-native-observation-plan.md +++ b/docs/research/qt-ime-native-observation-plan.md @@ -1,6 +1,6 @@ -# Qt IME 原生观察 N1(#155,待执行) +# Qt IME 原生观察协议(#155) -状态:**提案/待root快审,实际native运行次数0,完整CTest次数0**。它只接续 #155,不改变 [R2](../milestones/R2-local-terminal.md)、原版优先规则或性能容差。公开事件 focused 原件位于本切片私有工作目录;只在源码、运行包和本计划经审查后由 root 独占隔离 Windows 11 VM 执行。 +本页维护观察协议、资源上限与执行前置;各次尝试的源码/运行包、失败和验收状态由 [#155](https://github.com/JavaZeroo/termforge/issues/155)维护,N1等名称仅标识历史尝试。本文不构成执行授权或原生通过结论,也不改变 [R2](../milestones/R2-local-terminal.md)、原版优先规则或性能容差。公开事件与原生观察各自留证;每次由 root 在源码、运行包和协议经审查后冻结独立租约,独占隔离 Windows 11 VM 执行。 ## 固定入口与来源