3 Commits

Author SHA1 Message Date
Codex 81341fab2c docs: 收口阶段 1 输入系统实现与验证记录 2026-10-07 20:46:20 +08:00
Codex c23b32bb40 fix(test): 校准 KEY 用例 Tooltip 延时与日志轮转 2026-10-07 20:44:31 +08:00
Codex c57ddaf74f feat(input): 完成阶段 1 输入采集与规范化 2026-10-07 20:42:50 +08:00
16 changed files with 1972 additions and 24 deletions
+3 -3
View File
@@ -55,15 +55,15 @@
<!-- Public and internal headers are kept in this project so all consumers use one implementation. -->
<ItemGroup>
<ClInclude Include="include\StellarX\Button.h" /><ClInclude Include="include\StellarX\Canvas.h" /><ClInclude Include="include\StellarX\Control.h" />
<ClInclude Include="include\StellarX\CoreTypes.h" /><ClInclude Include="include\StellarX\Dialog.h" /><ClInclude Include="include\StellarX\Label.h" />
<ClInclude Include="include\StellarX\MessageBox.h" /><ClInclude Include="include\StellarX\StellarX.h" /><ClInclude Include="src\StellarX\internal\SxEventLoopInternal.h" /><ClInclude Include="src\StellarX\internal\SxInputProbeInternal.h" /><ClInclude Include="include\StellarX\SxLog.h" /><ClInclude Include="src\StellarX\internal\SxTextInternal.h" />
<ClInclude Include="include\StellarX\CoreTypes.h" /><ClInclude Include="include\StellarX\Dialog.h" /><ClInclude Include="include\StellarX\InputEvents.h" /><ClInclude Include="include\StellarX\Label.h" />
<ClInclude Include="include\StellarX\MessageBox.h" /><ClInclude Include="include\StellarX\StellarX.h" /><ClInclude Include="src\StellarX\internal\SxEventLoopInternal.h" /><ClInclude Include="src\StellarX\internal\SxInputProbeInternal.h" /><ClInclude Include="src\StellarX\internal\SxNativeInputInternal.h" /><ClInclude Include="include\StellarX\SxLog.h" /><ClInclude Include="src\StellarX\internal\SxTextInternal.h" />
<ClInclude Include="include\StellarX\TabControl.h" /><ClInclude Include="include\StellarX\Table.h" /><ClInclude Include="include\StellarX\TextBox.h" /><ClInclude Include="include\StellarX\Window.h" />
</ItemGroup>
<!-- Framework source files compiled into StellarX.Core.lib. -->
<ItemGroup>
<ClCompile Include="src\StellarX\Button.cpp" /><ClCompile Include="src\StellarX\Canvas.cpp" /><ClCompile Include="src\StellarX\Control.cpp" />
<ClCompile Include="src\StellarX\Dialog.cpp" /><ClCompile Include="src\StellarX\Label.cpp" /><ClCompile Include="src\StellarX\MessageBox.cpp" />
<ClCompile Include="src\StellarX\SxInputProbe.cpp" /><ClCompile Include="src\StellarX\SxLog.cpp" /><ClCompile Include="src\StellarX\TabControl.cpp" /><ClCompile Include="src\StellarX\Table.cpp" />
<ClCompile Include="src\StellarX\SxInputProbe.cpp" /><ClCompile Include="src\StellarX\SxLog.cpp" /><ClCompile Include="src\StellarX\SxNativeInput.cpp" /><ClCompile Include="src\StellarX\TabControl.cpp" /><ClCompile Include="src\StellarX\Table.cpp" />
<ClCompile Include="src\StellarX\TextBox.cpp" /><ClCompile Include="src\StellarX\Window.cpp" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
+1 -3
View File
@@ -150,7 +150,6 @@ int main()
auto configureButton = [&](Button* button, const std::string& tooltip)
{
button->enableTooltip(true);
button->setTooltipDelay(120);
button->setTooltipText(tooltip);
};
@@ -382,7 +381,6 @@ int main()
auto configureTooltip = [](Button* button, const std::string& text)
{
button->enableTooltip(true);
button->setTooltipDelay(120);
button->setTooltipText(text);
};
auto makeTestButton = [&](int x, int y, int width, int height, const std::string& text)
@@ -1044,7 +1042,7 @@ bool gSigned = false; //是否为有符号数
int main()
{
//StellarX::SxLogger::Get().enableConsole(true);
StellarX::SxLogger::Get().enableFile("log.txt",true,1024);
StellarX::SxLogger::Get().enableFile("log.txt", true, 10u * 1024u * 1024u);
StellarX::SxLogger::Get().setMinLevel(StellarX::SxLogLevel::Debug); // Info/Debug/Trace 自己切
StellarX::SxLogger::Get().setLanguage(StellarX::SxLogLanguage::ZhCN); // ZhCN / EnUS
+2
View File
@@ -185,6 +185,8 @@ public:
// 绘制入口。实现应只在 dirty/show 条件满足时写屏,并在完成后清理 dirty。
virtual void draw() = 0;
// 事件入口。返回 true 表示事件已消费;若事件造成视觉变化,应设置 eventVisualChanged 并请求重绘。
// 阶段 1 兼容过渡:EX_MOUSE 永久保留在此入口;EX_KEY 暂时仍按旧路径下发以保持行为,
// 计划在阶段 3 KeyboardRouter 一次性换轨后停止下发。派生控件不要再新增对 EX_KEY 的依赖。
virtual bool handleEvent(const ExMessage& msg) = 0;
// 轻量清理接口:仅用于“没有机会收到本次 WM_MOUSEMOVE”的兄弟分支,
// 清掉 hover / tooltip / 临时按下态等鼠标瞬时状态。
+124
View File
@@ -0,0 +1,124 @@
/*******************************************************************************
* StellarX native keyboard, text, IME, and activation event model.
* Stage 1 only captures, normalizes, queues, and observes these events.
* Focus, routing, and control behavior are intentionally out of scope.
******************************************************************************/
#pragma once
#include <windows.h>
#include <cstdint>
#include <string>
#include <variant>
namespace StellarX
{
enum class KeyAction : std::uint8_t
{
Down,
Up
};
// Snapshot modifiers in WndProc; delayed consumers must not reread global state.
struct KeyModifiers
{
bool leftControl = false;
bool rightControl = false;
bool leftShift = false;
bool rightShift = false;
bool leftAlt = false;
bool rightAlt = false;
bool control() const noexcept { return leftControl || rightControl; }
bool shift() const noexcept { return leftShift || rightShift; }
bool alt() const noexcept { return leftAlt || rightAlt; }
};
struct KeyEvent
{
KeyAction action = KeyAction::Down;
UINT virtualKey = 0; // Raw WndProc VK; VK_PROCESSKEY is never rewritten.
UINT physicalVirtualKey = 0; // Left/right-normalized modifier VK.
UINT scanCode = 0;
std::uint16_t repeatCount = 0;
bool extended = false;
bool wasDown = false;
bool altContext = false; // Raw lParam bit 29; modifiers.alt() is the semantic snapshot.
bool systemKey = false;
// Snapshot of the standard IME lifecycle axis at capture time.
// It never overrides the per-key ownership expressed by VK_PROCESSKEY.
bool imeComposingAtCapture = false;
KeyModifiers modifiers{};
bool isAutoRepeat() const noexcept
{
return action == KeyAction::Down && wasDown;
}
bool isImeOwnedAtCapture() const noexcept
{
// Per-key ownership and IME lifecycle are intentionally independent axes.
// A normal key captured during a standard composition remains a normal key;
// routing may suppress it because of the lifecycle snapshot, but must not
// rewrite its ownership.
return virtualKey == VK_PROCESSKEY;
}
};
enum class TextSource : std::uint8_t
{
ImeResult,
UnicodeChar,
UnicodeScalar,
AnsiCharFallback,
Clipboard
};
struct TextInputEvent
{
std::wstring text;
TextSource source = TextSource::UnicodeChar;
std::uint64_t compositionId = 0; // Zero for non-IME input.
};
enum class CompositionStage : std::uint8_t
{
Start,
Update,
Commit,
Cancel,
End
};
struct CompositionEvent
{
CompositionStage stage = CompositionStage::Start;
std::uint64_t compositionId = 0;
std::wstring compositionText;
std::uint32_t caretOffset = 0;
};
struct WindowActivationEvent
{
bool keyboardFocusActive = false;
HWND otherWindow = nullptr;
};
using NativeInputPayload = std::variant<
KeyEvent,
TextInputEvent,
CompositionEvent,
WindowActivationEvent>;
struct NativeInputEvent
{
std::uint64_t sequence = 0;
DWORD messageTime = 0;
HWND hwnd = nullptr;
NativeInputPayload payload{};
};
}
+1
View File
@@ -30,6 +30,7 @@
#pragma once
#include "CoreTypes.h"
#include "InputEvents.h"
#include "SxLog.h"
#include "Control.h"
#include"Canvas.h"
+19
View File
@@ -17,14 +17,26 @@
#include "Control.h"
#include <atomic>
#include <cstdint>
#include <string>
#include <vector>
#include <memory>
#include <windows.h>
class Window;
namespace StellarX::Internal
{
class NativeInputContext;
struct NativeInputRuntimeSnapshot;
NativeInputRuntimeSnapshot inspectWindowNativeInputForTesting(const ::Window& window) noexcept;
}
class Window
{
friend class Dialog;
friend StellarX::Internal::NativeInputRuntimeSnapshot
StellarX::Internal::inspectWindowNativeInputForTesting(const Window& window) noexcept;
// —— 尺寸状态 ——(绘制尺寸与待应用尺寸分离;收口时一次性更新)
int width; // 当前有效宽(已应用到画布/控件的客户区宽)
@@ -46,7 +58,14 @@ class Window
// EasyX 的 WndProc 与宿主事件循环分属不同线程;只有 WndProc 完成 WM_NCDESTROY
// 转发后才发布 false,避免宿主提前析构仍在执行 WndProcThunk 的 Window 对象。
std::atomic<bool> nativeWindowAlive{ false };
// 阶段 1 只建立采集、规范化与跨线程队列;宿主安全点只观测排空,不做业务路由。
std::unique_ptr<StellarX::Internal::NativeInputContext> nativeInputContext;
bool nativeInputFailureReported = false;
std::uint64_t nativeInputRejectedTextReported = 0;
std::uint64_t nativeInputImeQueryFailuresReported = 0;
std::uint64_t nativeInputDiscardedFragmentsReported = 0;
bool hasLiveNativeWindow() const noexcept;
bool drainNativeInputAtSafePoint();
static LRESULT CALLBACK WndProcThunk(HWND h, UINT m, WPARAM w, LPARAM l); // 静态过程分发到 this
// —— 绘制相关 ——(是否使用合成双缓冲、窗口标题、背景等)
+65 -2
View File
@@ -1,5 +1,9 @@
# 外部 UI 驱动:启动确定性 fixture,注入真实鼠标输入,并保存像素与状态诊断。
param([int]$Repeat = 1)
param(
[int]$Repeat = 1,
[ValidateSet('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'input-shadow')]
[string[]]$OnlyScenario = @()
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
@@ -163,6 +167,21 @@ function Click-Mouse([IntPtr]$h, [int]$x, [int]$y) {
Start-Sleep -Milliseconds 250
}
function Post-KeyWave([IntPtr]$h, [int]$pairCount = 1000) {
# F24 is outside normal text entry and was already proven to cross both the
# WndProc and EasyX EX_KEY observation planes during stage 0B.
$keyDownLParam = [IntPtr]([long]0x00760001)
$keyUpLParam = [IntPtr]([long]0xC0760001)
for ($index = 0; $index -lt $pairCount; $index++) {
if (-not [StellarXUiNative]::PostMessage($h, 0x0100, [IntPtr]0x87, $keyDownLParam)) {
throw "Failed to post F24 key-down at pair $index"
}
if (-not [StellarXUiNative]::PostMessage($h, 0x0101, [IntPtr]0x87, $keyUpLParam)) {
throw "Failed to post F24 key-up at pair $index"
}
}
}
function Capture-Client([IntPtr]$h, [string]$path) {
# 只保存客户区,排除窗口边框;返回 Bitmap 供调用方继续读取关键像素。
$rect = [StellarXUiNative]::GetClientRectangle($h)
@@ -243,7 +262,8 @@ function Wait-ClientRgb([IntPtr]$h, [int]$x, [int]$y, [int]$r, [int]$g, [int]$b,
$savedCursor = New-Object StellarXUiNative+POINT
# UI 测试会抢占前台并移动鼠标,外层 finally 必须恢复用户原来的光标位置。
[void][StellarXUiNative]::GetCursorPos([ref]$savedCursor)
$scenarios = @('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog')
$allScenarios = @('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'input-shadow')
$scenarios = if ($OnlyScenario.Count -gt 0) { @($OnlyScenario) } else { $allScenarios }
$sessionRoot = Join-Path $resultRoot ((Get-Date -Format 'yyyyMMdd-HHmmss-fff') + "-$PID")
try {
@@ -280,6 +300,49 @@ try {
Click-Mouse $h 240 185
$result = Wait-TrueResultProperty $resultFile 'lowerClosed'
}
'input-shadow' {
$expectedKeyEvents = 4000
# Keep every burst below the observed EasyX internal queue ceiling
# (63 usable entries in this environment).
# This measures sustained draining rather than overflow behavior of a
# third-party queue that the framework cannot resize.
foreach ($wave in 1..50) {
Post-KeyWave $h 20
Start-Sleep -Milliseconds 50
}
Start-Sleep -Milliseconds 250
$process.Refresh()
$memoryAfterWarmup = $process.PrivateMemorySize64
foreach ($wave in 1..50) {
Post-KeyWave $h 20
Start-Sleep -Milliseconds 50
}
Start-Sleep -Milliseconds 250
$process.Refresh()
$memoryAfterSecondWave = $process.PrivateMemorySize64
Click-Mouse $h 100 61
$result = Wait-Result $resultFile
if ($result.legacyKeyCount -ne $expectedKeyEvents) { throw "legacy EX_KEY count mismatch: $($result.legacyKeyCount)" }
if ($result.legacyKeyDownCount -ne ($expectedKeyEvents / 2) -or
$result.legacyKeyUpCount -ne ($expectedKeyEvents / 2)) {
throw "legacy EX_KEY action mismatch: down=$($result.legacyKeyDownCount) up=$($result.legacyKeyUpCount)"
}
if ($result.keyEvents -ne $expectedKeyEvents) { throw "normalized key count mismatch: $($result.keyEvents)" }
if ($result.pushed -ne $result.popped -or $result.currentDepth -ne 0) { throw 'native input queue did not drain completely' }
if (-not $result.sequenceStrict -or $result.faulted) { throw 'native input queue reported ordering or fail-stop failure' }
if ($result.rejectedTextSequences -ne 0 -or $result.imeQueryFailures -ne 0 -or
$result.discardedDecoderFragments -ne 0) {
throw 'key-only input shadow reported a text normalization anomaly'
}
$memoryGrowth = $memoryAfterSecondWave - $memoryAfterWarmup
if ($memoryGrowth -gt 16MB) { throw "input shadow memory grew after warmup: $memoryGrowth bytes" }
@{
expectedKeyEvents = $expectedKeyEvents
memoryAfterWarmup = $memoryAfterWarmup
memoryAfterSecondWave = $memoryAfterSecondWave
memoryGrowth = $memoryGrowth
} | ConvertTo-Json | Set-Content -Encoding UTF8 (Join-Path $runDir 'input-shadow-memory.json')
}
}
$bitmap = Capture-Client $h (Join-Path $runDir 'screen.png')
if ($scenario -eq 'ownership' -or $scenario -eq 'geometry') {
+14
View File
@@ -196,6 +196,13 @@ void Dialog::Show()
while (show)
{
if (!hWnd.drainNativeInputAtSafePoint())
{
show = false;
hWnd.dialogOpen = false;
StellarX::Internal::inputProbeFlushAtSafePoint(hWnd.getHwnd());
break;
}
if (!hWnd.hasLiveNativeWindow())
{
show = false;
@@ -246,6 +253,13 @@ void Dialog::Show()
processedMessages = static_cast<int>(batch.processed);
StellarX::Internal::inputProbeHostBatchEnd(
hWnd.getHwnd(), probeBatch, batch.processed, batch.stopped);
if (!hWnd.drainNativeInputAtSafePoint())
{
show = false;
hWnd.dialogOpen = false;
StellarX::Internal::inputProbeFlushAtSafePoint(hWnd.getHwnd());
break;
}
if (!hWnd.hasLiveNativeWindow())
{
show = false;
+975
View File
@@ -0,0 +1,975 @@
#include "SxNativeInputInternal.h"
#include <imm.h>
#include <algorithm>
#include <array>
#include <cwchar>
#include <new>
#include <optional>
#include <string>
#include <type_traits>
#include <utility>
#pragma comment(lib, "imm32.lib")
namespace StellarX::Internal
{
namespace
{
std::atomic<std::uint64_t> nextNativeInputSequence{ 0 };
static_assert(std::is_nothrow_move_constructible_v<TextInputEvent>,
"TextInputEvent must transfer its wstring without allocating during queue publication.");
static_assert(std::is_nothrow_move_constructible_v<CompositionEvent>,
"CompositionEvent must transfer its wstring without allocating during queue publication.");
static_assert(std::is_nothrow_move_constructible_v<NativeInputEvent>,
"NativeInputEvent must be nothrow move constructible.");
static_assert(std::is_nothrow_move_assignable_v<NativeInputEvent>,
"NativeInputEvent must be nothrow move assignable.");
static_assert(sizeof(wchar_t) == 2, "The Windows input path requires UTF-16 wchar_t.");
bool isHighSurrogate(wchar_t value) noexcept
{
return value >= 0xD800 && value <= 0xDBFF;
}
bool isLowSurrogate(wchar_t value) noexcept
{
return value >= 0xDC00 && value <= 0xDFFF;
}
bool isValidUtf16(const std::wstring& text) noexcept
{
for (std::size_t index = 0; index < text.size(); ++index)
{
const wchar_t current = text[index];
if (isHighSurrogate(current))
{
if (index + 1 >= text.size() || !isLowSurrogate(text[index + 1]))
return false;
++index;
}
else if (isLowSurrogate(current))
{
return false;
}
}
return true;
}
UINT codePageForLayout(HKL layout) noexcept
{
if (layout)
{
wchar_t buffer[16]{};
const LANGID language = LOWORD(reinterpret_cast<ULONG_PTR>(layout));
const LCID locale = MAKELCID(language, SORT_DEFAULT);
if (GetLocaleInfoW(locale, LOCALE_IDEFAULTANSICODEPAGE,
buffer, static_cast<int>(std::size(buffer))) > 0)
{
wchar_t* end = nullptr;
const unsigned long value = std::wcstoul(buffer, &end, 10);
if (end != buffer && value > 0 && value <= (std::numeric_limits<UINT>::max)())
return static_cast<UINT>(value);
}
}
return GetACP();
}
KeyModifierSnapshot captureKeyModifiers() noexcept
{
auto isDown = [](int virtualKey) noexcept
{
return (GetKeyState(virtualKey) & 0x8000) != 0;
};
KeyModifierSnapshot snapshot;
snapshot.leftControl = isDown(VK_LCONTROL);
snapshot.rightControl = isDown(VK_RCONTROL);
snapshot.leftShift = isDown(VK_LSHIFT);
snapshot.rightShift = isDown(VK_RSHIFT);
snapshot.leftAlt = isDown(VK_LMENU);
snapshot.rightAlt = isDown(VK_RMENU);
return snapshot;
}
UINT physicalVirtualKey(UINT virtualKey, UINT scanCode, bool extended) noexcept
{
switch (virtualKey)
{
case VK_SHIFT:
return scanCode == 0x36 ? VK_RSHIFT : VK_LSHIFT;
case VK_CONTROL:
return extended ? VK_RCONTROL : VK_LCONTROL;
case VK_MENU:
return extended ? VK_RMENU : VK_LMENU;
default:
return virtualKey;
}
}
bool queryCompositionString(HIMC context, DWORD index, std::wstring& text)
{
const LONG byteCount = ImmGetCompositionStringW(context, index, nullptr, 0);
if (byteCount < 0 || (byteCount % static_cast<LONG>(sizeof(wchar_t))) != 0)
return false;
const std::size_t unitCount = static_cast<std::size_t>(byteCount) / sizeof(wchar_t);
text.assign(unitCount, L'\0');
if (byteCount == 0)
return true;
const LONG copied = ImmGetCompositionStringW(context, index, text.data(), byteCount);
return copied == byteCount;
}
bool convertAnsiStrict(UINT codePage, const unsigned char* bytes,
std::size_t byteCount, std::wstring& text)
{
if (!bytes || byteCount == 0 || byteCount > 2)
return false;
wchar_t buffer[2]{};
const int converted = MultiByteToWideChar(codePage, MB_ERR_INVALID_CHARS,
reinterpret_cast<const char*>(bytes), static_cast<int>(byteCount),
buffer, static_cast<int>(std::size(buffer)));
if (converted <= 0)
return false;
text.assign(buffer, buffer + converted);
return isValidUtf16(text);
}
}
NativeInputQueuePushResult NativeInputQueue::push(NativeInputEvent&& event) noexcept
{
const NativeInputQueuePushResult forced = forcedPushResult_.exchange(
NativeInputQueuePushResult::Queued, std::memory_order_acq_rel);
if (forced != NativeInputQueuePushResult::Queued)
return forced;
try
{
std::lock_guard<std::mutex> lock(mutex_);
events_.emplace_back(std::move(event));
++pushed_;
maximumDepth_ = (std::max)(maximumDepth_, events_.size());
return NativeInputQueuePushResult::Queued;
}
catch (const std::bad_alloc&)
{
return NativeInputQueuePushResult::AllocationFailure;
}
catch (...)
{
return NativeInputQueuePushResult::UnexpectedFailure;
}
}
NativeInputQueuePopResult NativeInputQueue::tryPop(NativeInputEvent& event) noexcept
{
try
{
std::lock_guard<std::mutex> lock(mutex_);
if (events_.empty())
return NativeInputQueuePopResult::Empty;
event = std::move(events_.front());
events_.pop_front();
++popped_;
return NativeInputQueuePopResult::Popped;
}
catch (...)
{
return NativeInputQueuePopResult::UnexpectedFailure;
}
}
NativeInputQueueStats NativeInputQueue::stats() const noexcept
{
try
{
std::lock_guard<std::mutex> lock(mutex_);
NativeInputQueueStats snapshot;
snapshot.pushed = pushed_;
snapshot.popped = popped_;
snapshot.currentDepth = events_.size();
snapshot.maximumDepth = maximumDepth_;
return snapshot;
}
catch (...)
{
return {};
}
}
void NativeInputQueue::forceNextPushResultForTesting(NativeInputQueuePushResult result) noexcept
{
forcedPushResult_.store(result, std::memory_order_release);
}
KeyEvent makeKeyEvent(UINT message, WPARAM wParam, LPARAM lParam,
const KeyModifierSnapshot& modifierSnapshot, bool imeLifecycleActive) noexcept
{
const auto raw = static_cast<std::uint32_t>(static_cast<ULONG_PTR>(lParam));
KeyEvent event;
event.action = message == WM_KEYUP || message == WM_SYSKEYUP
? KeyAction::Up : KeyAction::Down;
event.virtualKey = static_cast<UINT>(wParam);
event.repeatCount = static_cast<std::uint16_t>(raw & 0xFFFFu);
event.scanCode = (raw >> 16) & 0xFFu;
event.extended = (raw & (1u << 24)) != 0;
event.altContext = (raw & (1u << 29)) != 0;
event.wasDown = (raw & (1u << 30)) != 0;
event.systemKey = message == WM_SYSKEYDOWN || message == WM_SYSKEYUP;
event.imeComposingAtCapture = imeLifecycleActive;
event.physicalVirtualKey = physicalVirtualKey(event.virtualKey, event.scanCode, event.extended);
event.modifiers.leftControl = modifierSnapshot.leftControl;
event.modifiers.rightControl = modifierSnapshot.rightControl;
event.modifiers.leftShift = modifierSnapshot.leftShift;
event.modifiers.rightShift = modifierSnapshot.rightShift;
event.modifiers.leftAlt = modifierSnapshot.leftAlt;
event.modifiers.rightAlt = modifierSnapshot.rightAlt;
return event;
}
struct NativeInputContext::Impl
{
enum class EchoDisposition : std::uint8_t
{
NotEcho,
Suppress
};
struct EchoState
{
bool active = false;
bool windowUnicode = false;
std::uint64_t compositionId = 0;
std::wstring utf16;
std::string ansi;
std::size_t imeCharMatched = 0;
std::size_t charMatched = 0;
};
explicit Impl(HWND window)
: hwnd(window), windowUnicode(window && IsWindowUnicode(window) != FALSE)
{
DWORD processId = 0;
windowThreadId = window ? GetWindowThreadProcessId(window, &processId) : GetCurrentThreadId();
keyboardLayout = GetKeyboardLayout(windowThreadId);
ansiCodePage = codePageForLayout(keyboardLayout);
}
HWND hwnd = nullptr;
bool windowUnicode = false;
DWORD windowThreadId = 0;
HKL keyboardLayout = nullptr;
UINT ansiCodePage = CP_ACP;
std::optional<unsigned char> pendingLead;
std::optional<wchar_t> pendingHighSurrogate;
bool imeLifecycleActive = false;
bool compositionCommitted = false;
bool compositionCancelled = false;
std::uint64_t nextCompositionId = 0;
std::uint64_t activeCompositionId = 0;
EchoState echo;
NativeInputQueue queue;
NativeInputObservationStats observations;
std::atomic<bool> faultClaimed{ false };
std::atomic<bool> faultReady{ false };
NativeInputFaultSnapshot faultSnapshot{};
std::atomic<std::uint64_t> rejectedTextSequences{ 0 };
std::atomic<std::uint64_t> imeQueryFailures{ 0 };
std::atomic<std::uint64_t> discardedDecoderFragments{ 0 };
bool hasFault() const noexcept
{
return faultReady.load(std::memory_order_acquire);
}
void publishFault(NativeInputFaultCode code, std::uint64_t sequence,
UINT message, HWND window) noexcept
{
bool expected = false;
if (!faultClaimed.compare_exchange_strong(expected, true,
std::memory_order_acq_rel, std::memory_order_acquire))
return;
faultSnapshot.code = code;
faultSnapshot.sequence = sequence;
faultSnapshot.message = message;
faultSnapshot.hwnd = window;
faultReady.store(true, std::memory_order_release);
}
void clearTextDecoderState() noexcept
{
if (pendingLead || pendingHighSurrogate)
discardedDecoderFragments.fetch_add(1, std::memory_order_relaxed);
pendingLead.reset();
pendingHighSurrogate.reset();
}
void clearEcho() noexcept
{
echo = {};
}
void snapshotKeyboardLayout(HKL layout) noexcept
{
const HKL actual = layout ? layout : GetKeyboardLayout(0);
const UINT actualCodePage = codePageForLayout(actual);
if (actual != keyboardLayout || actualCodePage != ansiCodePage)
{
if (pendingLead)
discardedDecoderFragments.fetch_add(1, std::memory_order_relaxed);
pendingLead.reset();
keyboardLayout = actual;
ansiCodePage = actualCodePage;
}
}
std::uint64_t allocateCompositionId() noexcept
{
do
{
++nextCompositionId;
} while (nextCompositionId == 0);
activeCompositionId = nextCompositionId;
return activeCompositionId;
}
template <typename Payload>
bool enqueue(HWND window, UINT message, Payload&& payload) noexcept
{
if (hasFault())
return false;
NativeInputEvent event;
event.sequence = nextNativeInputSequence.fetch_add(1, std::memory_order_relaxed) + 1;
event.messageTime = static_cast<DWORD>(GetMessageTime());
event.hwnd = window;
event.payload = std::forward<Payload>(payload);
const std::uint64_t sequence = event.sequence;
const NativeInputQueuePushResult result = queue.push(std::move(event));
if (result == NativeInputQueuePushResult::Queued)
return true;
publishFault(result == NativeInputQueuePushResult::AllocationFailure
? NativeInputFaultCode::OutOfMemory : NativeInputFaultCode::QueueFailure,
sequence, message, window);
return false;
}
bool ensureComposition(HWND window, UINT message)
{
if (imeLifecycleActive)
return true;
imeLifecycleActive = true;
compositionCommitted = false;
compositionCancelled = false;
allocateCompositionId();
CompositionEvent start;
start.stage = CompositionStage::Start;
start.compositionId = activeCompositionId;
return enqueue(window, message, std::move(start));
}
void beginEcho(const std::wstring& text, std::uint64_t compositionId)
{
clearEcho();
if (text.empty())
return;
echo.active = true;
echo.windowUnicode = windowUnicode;
echo.compositionId = compositionId;
echo.utf16 = text;
if (windowUnicode)
return;
const bool supportsDefaultTracking = ansiCodePage != CP_UTF7 && ansiCodePage != CP_UTF8;
const DWORD conversionFlags = supportsDefaultTracking ? WC_NO_BEST_FIT_CHARS : 0;
BOOL usedDefault = FALSE;
BOOL* usedDefaultPointer = supportsDefaultTracking ? &usedDefault : nullptr;
const int required = WideCharToMultiByte(ansiCodePage, conversionFlags,
text.data(), static_cast<int>(text.size()), nullptr, 0, nullptr, usedDefaultPointer);
if (required <= 0)
{
// Without a deterministic expected byte sequence there is no safe
// content-based suppression rule. End correlation on the first echo.
echo.ansi.clear();
return;
}
echo.ansi.assign(static_cast<std::size_t>(required), '\0');
usedDefault = FALSE;
const int converted = WideCharToMultiByte(ansiCodePage, conversionFlags,
text.data(), static_cast<int>(text.size()), echo.ansi.data(), required,
nullptr, usedDefaultPointer);
if (converted != required)
echo.ansi.clear();
}
bool matchBytes(const unsigned char* bytes, std::size_t count,
std::size_t& matched) const noexcept
{
if (matched + count > echo.ansi.size())
return false;
for (std::size_t index = 0; index < count; ++index)
{
if (static_cast<unsigned char>(echo.ansi[matched + index]) != bytes[index])
return false;
}
matched += count;
return true;
}
bool matchUtf16(wchar_t value, std::size_t& matched) const noexcept
{
if (matched >= echo.utf16.size() || echo.utf16[matched] != value)
return false;
++matched;
return true;
}
EchoDisposition correlateEcho(UINT message, WPARAM wParam) noexcept
{
if (!echo.active || (message != WM_IME_CHAR && message != WM_CHAR))
return EchoDisposition::NotEcho;
std::size_t& matched = message == WM_IME_CHAR
? echo.imeCharMatched : echo.charMatched;
bool matches = false;
if (echo.windowUnicode)
{
matches = matchUtf16(static_cast<wchar_t>(wParam), matched);
}
else if (message == WM_IME_CHAR)
{
unsigned char bytes[2]{};
std::size_t count = 1;
if ((wParam & 0xFF00u) != 0)
{
bytes[0] = static_cast<unsigned char>((wParam >> 8) & 0xFFu);
bytes[1] = static_cast<unsigned char>(wParam & 0xFFu);
count = 2;
}
else
{
bytes[0] = static_cast<unsigned char>(wParam & 0xFFu);
}
matches = matchBytes(bytes, count, matched);
}
else
{
const unsigned char byte = static_cast<unsigned char>(wParam & 0xFFu);
matches = matchBytes(&byte, 1, matched);
}
if (matches)
return EchoDisposition::Suppress;
// Only an adjacent, unit-for-unit match is an echo. End correlation on
// mismatch so later equal user input is never removed by content alone.
clearEcho();
return EchoDisposition::NotEcho;
}
std::optional<std::wstring> decodeUnicodeUnit(wchar_t value)
{
if (pendingHighSurrogate)
{
const wchar_t high = *pendingHighSurrogate;
pendingHighSurrogate.reset();
if (isLowSurrogate(value))
{
std::wstring text;
text.push_back(high);
text.push_back(value);
return text;
}
rejectedTextSequences.fetch_add(1, std::memory_order_relaxed);
}
if (isHighSurrogate(value))
{
pendingHighSurrogate = value;
return std::nullopt;
}
if (isLowSurrogate(value))
{
rejectedTextSequences.fetch_add(1, std::memory_order_relaxed);
return std::nullopt;
}
return std::wstring(1, value);
}
std::optional<std::wstring> decodeAnsiChar(unsigned char byte)
{
if (pendingLead)
{
const unsigned char bytes[2]{ *pendingLead, byte };
pendingLead.reset();
std::wstring text;
if (!convertAnsiStrict(ansiCodePage, bytes, 2, text))
{
rejectedTextSequences.fetch_add(1, std::memory_order_relaxed);
return std::nullopt;
}
return text;
}
if (IsDBCSLeadByteEx(ansiCodePage, byte) != FALSE)
{
pendingLead = byte;
return std::nullopt;
}
std::wstring text;
if (!convertAnsiStrict(ansiCodePage, &byte, 1, text))
{
rejectedTextSequences.fetch_add(1, std::memory_order_relaxed);
return std::nullopt;
}
return text;
}
std::optional<std::wstring> decodeImeChar(WPARAM wParam)
{
if (windowUnicode)
return decodeUnicodeUnit(static_cast<wchar_t>(wParam));
unsigned char bytes[2]{};
std::size_t count = 1;
if ((wParam & 0xFF00u) != 0)
{
bytes[0] = static_cast<unsigned char>((wParam >> 8) & 0xFFu);
bytes[1] = static_cast<unsigned char>(wParam & 0xFFu);
count = 2;
}
else
{
bytes[0] = static_cast<unsigned char>(wParam & 0xFFu);
}
std::wstring text;
if (!convertAnsiStrict(ansiCodePage, bytes, count, text))
{
rejectedTextSequences.fetch_add(1, std::memory_order_relaxed);
return std::nullopt;
}
return text;
}
bool collectKey(HWND window, UINT message, WPARAM wParam, LPARAM lParam) noexcept
{
if (echo.active)
clearEcho();
KeyEvent event = makeKeyEvent(message, wParam, lParam,
captureKeyModifiers(), imeLifecycleActive);
return enqueue(window, message, std::move(event));
}
bool collectText(HWND window, UINT message, WPARAM wParam)
{
if (correlateEcho(message, wParam) == EchoDisposition::Suppress)
return true;
std::optional<std::wstring> decoded;
TextSource source = TextSource::AnsiCharFallback;
if (message == WM_IME_CHAR)
{
decoded = decodeImeChar(wParam);
source = windowUnicode ? TextSource::UnicodeChar : TextSource::AnsiCharFallback;
}
else if (windowUnicode)
{
decoded = decodeUnicodeUnit(static_cast<wchar_t>(wParam));
source = TextSource::UnicodeChar;
}
else
{
if (wParam > 0xFFu)
{
rejectedTextSequences.fetch_add(1, std::memory_order_relaxed);
return true;
}
decoded = decodeAnsiChar(static_cast<unsigned char>(wParam & 0xFFu));
}
if (!decoded || decoded->empty())
return true;
TextInputEvent event;
event.text = std::move(*decoded);
event.source = source;
event.compositionId = message == WM_IME_CHAR ? activeCompositionId : 0;
return enqueue(window, message, std::move(event));
}
bool collectImeComposition(HWND window, LPARAM lParam)
{
if (!ensureComposition(window, WM_IME_COMPOSITION))
return false;
const DWORD flags = static_cast<DWORD>(lParam);
const bool hasCompositionString = (flags & GCS_COMPSTR) != 0;
const bool hasResultString = (flags & GCS_RESULTSTR) != 0;
const DWORD anyGcsMask = GCS_COMPREADSTR | GCS_COMPREADATTR | GCS_COMPREADCLAUSE |
GCS_COMPSTR | GCS_COMPATTR | GCS_COMPCLAUSE | GCS_CURSORPOS | GCS_DELTASTART |
GCS_RESULTREADSTR | GCS_RESULTREADCLAUSE | GCS_RESULTSTR | GCS_RESULTCLAUSE;
HIMC context = nullptr;
if (hasCompositionString || hasResultString)
context = ImmGetContext(window);
bool success = true;
if ((hasCompositionString || hasResultString) && !context)
imeQueryFailures.fetch_add(1, std::memory_order_relaxed);
if (hasCompositionString && context)
{
std::wstring composition;
if (!queryCompositionString(context, GCS_COMPSTR, composition))
{
imeQueryFailures.fetch_add(1, std::memory_order_relaxed);
}
else if (!isValidUtf16(composition))
{
rejectedTextSequences.fetch_add(1, std::memory_order_relaxed);
}
else
{
CompositionEvent update;
update.stage = CompositionStage::Update;
update.compositionId = activeCompositionId;
update.compositionText = std::move(composition);
const LONG caret = ImmGetCompositionStringW(context, GCS_CURSORPOS, nullptr, 0);
update.caretOffset = caret >= 0 ? static_cast<std::uint32_t>(caret) : 0;
success = enqueue(window, WM_IME_COMPOSITION, std::move(update)) && success;
}
}
if (hasResultString && context)
{
std::wstring result;
if (!queryCompositionString(context, GCS_RESULTSTR, result))
{
imeQueryFailures.fetch_add(1, std::memory_order_relaxed);
}
else if (!isValidUtf16(result))
{
rejectedTextSequences.fetch_add(1, std::memory_order_relaxed);
}
else
{
compositionCommitted = true;
CompositionEvent commit;
commit.stage = CompositionStage::Commit;
commit.compositionId = activeCompositionId;
success = enqueue(window, WM_IME_COMPOSITION, std::move(commit)) && success;
beginEcho(result, activeCompositionId);
if (!result.empty())
{
TextInputEvent text;
text.text = std::move(result);
text.source = TextSource::ImeResult;
text.compositionId = activeCompositionId;
success = enqueue(window, WM_IME_COMPOSITION, std::move(text)) && success;
}
}
}
if (context)
ImmReleaseContext(window, context);
if ((flags & anyGcsMask) == 0 && !compositionCommitted && !compositionCancelled)
{
compositionCancelled = true;
CompositionEvent cancel;
cancel.stage = CompositionStage::Cancel;
cancel.compositionId = activeCompositionId;
success = enqueue(window, WM_IME_COMPOSITION, std::move(cancel)) && success;
}
return success;
}
NativeInputCollectResult collect(HWND window, UINT message, WPARAM wParam, LPARAM lParam)
{
NativeInputCollectResult result;
if (message == WM_UNICHAR && wParam == UNICODE_NOCHAR)
{
result.handledSynchronously = true;
result.result = TRUE;
return result;
}
if (hasFault())
return result;
switch (message)
{
case WM_KEYDOWN:
case WM_KEYUP:
case WM_SYSKEYDOWN:
case WM_SYSKEYUP:
collectKey(window, message, wParam, lParam);
break;
case WM_CHAR:
case WM_IME_CHAR:
collectText(window, message, wParam);
break;
case WM_UNICHAR:
{
const std::uint32_t scalar = static_cast<std::uint32_t>(wParam);
if (scalar > 0x10FFFFu || (scalar >= 0xD800u && scalar <= 0xDFFFu))
{
rejectedTextSequences.fetch_add(1, std::memory_order_relaxed);
break;
}
TextInputEvent text;
if (scalar <= 0xFFFFu)
{
text.text.assign(1, static_cast<wchar_t>(scalar));
}
else
{
const std::uint32_t value = scalar - 0x10000u;
text.text.push_back(static_cast<wchar_t>(0xD800u + (value >> 10)));
text.text.push_back(static_cast<wchar_t>(0xDC00u + (value & 0x3FFu)));
}
text.source = TextSource::UnicodeScalar;
if (enqueue(window, message, std::move(text)))
{
result.handledSynchronously = true;
result.result = 0;
}
break;
}
case WM_IME_STARTCOMPOSITION:
clearTextDecoderState();
clearEcho();
if (imeLifecycleActive)
{
if (!compositionCommitted && !compositionCancelled)
{
CompositionEvent cancel;
cancel.stage = CompositionStage::Cancel;
cancel.compositionId = activeCompositionId;
enqueue(window, message, std::move(cancel));
}
CompositionEvent end;
end.stage = CompositionStage::End;
end.compositionId = activeCompositionId;
enqueue(window, message, std::move(end));
}
imeLifecycleActive = false;
ensureComposition(window, message);
break;
case WM_IME_COMPOSITION:
collectImeComposition(window, lParam);
break;
case WM_IME_ENDCOMPOSITION:
if (imeLifecycleActive)
{
if (!compositionCommitted && !compositionCancelled)
{
compositionCancelled = true;
CompositionEvent cancel;
cancel.stage = CompositionStage::Cancel;
cancel.compositionId = activeCompositionId;
enqueue(window, message, std::move(cancel));
}
CompositionEvent end;
end.stage = CompositionStage::End;
end.compositionId = activeCompositionId;
enqueue(window, message, std::move(end));
}
imeLifecycleActive = false;
compositionCommitted = false;
compositionCancelled = false;
activeCompositionId = 0;
clearTextDecoderState();
// Do not clear result correlation before forwarding END to the old
// window procedure. Some IMEs generate nested or immediately-following
// WM_IME_CHAR/WM_CHAR echoes while END is being processed. The next
// physical key, composition, focus transition, language change, or a
// mismatching character closes this short-lived echo window.
break;
case WM_SETFOCUS:
clearTextDecoderState();
clearEcho();
snapshotKeyboardLayout(nullptr);
{
WindowActivationEvent activation;
activation.keyboardFocusActive = true;
activation.otherWindow = reinterpret_cast<HWND>(wParam);
enqueue(window, message, std::move(activation));
}
break;
case WM_KILLFOCUS:
clearTextDecoderState();
clearEcho();
{
WindowActivationEvent activation;
activation.keyboardFocusActive = false;
activation.otherWindow = reinterpret_cast<HWND>(wParam);
enqueue(window, message, std::move(activation));
}
break;
case WM_INPUTLANGCHANGE:
if (pendingLead)
discardedDecoderFragments.fetch_add(1, std::memory_order_relaxed);
pendingLead.reset();
clearEcho();
snapshotKeyboardLayout(reinterpret_cast<HKL>(lParam));
break;
default:
break;
}
return result;
}
};
NativeInputContext::NativeInputContext(HWND hwnd)
: impl_(std::make_unique<Impl>(hwnd))
{
}
NativeInputContext::~NativeInputContext() = default;
NativeInputCollectResult NativeInputContext::collect(
HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) noexcept
{
if (!impl_)
return {};
try
{
return impl_->collect(hwnd, message, wParam, lParam);
}
catch (const std::bad_alloc&)
{
impl_->publishFault(NativeInputFaultCode::OutOfMemory, 0, message, hwnd);
}
catch (...)
{
impl_->publishFault(NativeInputFaultCode::UnexpectedException, 0, message, hwnd);
}
return {};
}
std::size_t NativeInputContext::drainObserved(std::size_t limit) noexcept
{
if (!impl_ || limit == 0)
return 0;
std::size_t drained = 0;
while (drained < limit)
{
NativeInputEvent event;
const NativeInputQueuePopResult result = impl_->queue.tryPop(event);
if (result == NativeInputQueuePopResult::Empty)
break;
if (result == NativeInputQueuePopResult::UnexpectedFailure)
{
impl_->publishFault(NativeInputFaultCode::QueueFailure, 0, 0, impl_->hwnd);
break;
}
if (impl_->observations.lastSequence != 0 &&
event.sequence <= impl_->observations.lastSequence)
{
impl_->observations.sequenceStrict = false;
impl_->publishFault(NativeInputFaultCode::SequenceViolation,
event.sequence, 0, event.hwnd);
break;
}
impl_->observations.lastSequence = event.sequence;
++impl_->observations.observed;
if (std::holds_alternative<KeyEvent>(event.payload))
++impl_->observations.keyEvents;
else if (std::holds_alternative<TextInputEvent>(event.payload))
++impl_->observations.textEvents;
else if (std::holds_alternative<CompositionEvent>(event.payload))
++impl_->observations.compositionEvents;
else if (std::holds_alternative<WindowActivationEvent>(event.payload))
++impl_->observations.activationEvents;
++drained;
}
return drained;
}
bool NativeInputContext::hasFault() const noexcept
{
return impl_ && impl_->hasFault();
}
NativeInputFaultSnapshot NativeInputContext::fault() const noexcept
{
if (!impl_ || !impl_->faultReady.load(std::memory_order_acquire))
return {};
return impl_->faultSnapshot;
}
NativeInputQueueStats NativeInputContext::queueStats() const noexcept
{
return impl_ ? impl_->queue.stats() : NativeInputQueueStats{};
}
NativeInputObservationStats NativeInputContext::observationStats() const noexcept
{
return impl_ ? impl_->observations : NativeInputObservationStats{};
}
NativeInputNormalizationStats NativeInputContext::normalizationStats() const noexcept
{
NativeInputNormalizationStats snapshot;
if (!impl_)
return snapshot;
snapshot.rejectedTextSequences = impl_->rejectedTextSequences.load(std::memory_order_relaxed);
snapshot.imeQueryFailures = impl_->imeQueryFailures.load(std::memory_order_relaxed);
snapshot.discardedDecoderFragments = impl_->discardedDecoderFragments.load(std::memory_order_relaxed);
return snapshot;
}
bool NativeInputContext::tryPopForTesting(NativeInputEvent& event) noexcept
{
return impl_ && impl_->queue.tryPop(event) == NativeInputQueuePopResult::Popped;
}
void NativeInputContext::forceNextQueuePushResultForTesting(
NativeInputQueuePushResult result) noexcept
{
if (impl_)
impl_->queue.forceNextPushResultForTesting(result);
}
void NativeInputContext::setAnsiCodePageForTesting(UINT codePage) noexcept
{
if (impl_)
{
impl_->pendingLead.reset();
impl_->ansiCodePage = codePage;
impl_->windowUnicode = false;
}
}
void NativeInputContext::seedEchoForTesting(
const std::wstring& text, std::uint64_t compositionId)
{
if (impl_)
impl_->beginEcho(text, compositionId);
}
}
+79 -1
View File
@@ -3,8 +3,10 @@
#include"SxLog.h"
#include "SxEventLoopInternal.h"
#include "SxInputProbeInternal.h"
#include "SxNativeInputInternal.h"
#include <easyx.h>
#include <algorithm>
#include <cstdint>
// 可能频繁出现且对调试信息干扰较大的消息(例如鼠标移动),
// 可以在日志输出时特殊处理以减少干扰。
static bool SxIsNoisyMsg(UINT m)
@@ -673,6 +675,58 @@ bool Window::hasLiveNativeWindow() const noexcept
return hWnd != nullptr && IsWindow(hWnd) != FALSE;
}
bool Window::drainNativeInputAtSafePoint()
{
if (!nativeInputContext)
return true;
// 逐条移动并观测,既不预取整批事件,也不进入焦点或控件路由。
nativeInputContext->drainObserved(StellarX::Internal::MessageBatchLimit);
const auto normalization = nativeInputContext->normalizationStats();
if (normalization.rejectedTextSequences != nativeInputRejectedTextReported ||
normalization.imeQueryFailures != nativeInputImeQueryFailuresReported ||
normalization.discardedDecoderFragments != nativeInputDiscardedFragmentsReported)
{
nativeInputRejectedTextReported = normalization.rejectedTextSequences;
nativeInputImeQueryFailuresReported = normalization.imeQueryFailures;
nativeInputDiscardedFragmentsReported = normalization.discardedDecoderFragments;
SX_LOGW("Input")
<< SX_T("原生输入规范化异常累计:rejected=", "Native input normalization anomalies: rejected=")
<< normalization.rejectedTextSequences
<< " imeQueryFailures=" << normalization.imeQueryFailures
<< " discardedFragments=" << normalization.discardedDecoderFragments;
}
if (!nativeInputContext->hasFault())
return true;
if (!nativeInputFailureReported)
{
nativeInputFailureReported = true;
const auto fault = nativeInputContext->fault();
SX_LOGE("Input")
<< SX_T("原生输入子系统进入 fail-stop:code=", "Native input subsystem entered fail-stop: code=")
<< static_cast<unsigned>(fault.code)
<< " sequence=" << fault.sequence
<< " message=" << fault.message
<< " hwnd=" << reinterpret_cast<std::uintptr_t>(fault.hwnd);
}
return false;
}
StellarX::Internal::NativeInputRuntimeSnapshot
StellarX::Internal::inspectWindowNativeInputForTesting(const ::Window& window) noexcept
{
NativeInputRuntimeSnapshot snapshot;
if (!window.nativeInputContext)
return snapshot;
snapshot.queue = window.nativeInputContext->queueStats();
snapshot.observations = window.nativeInputContext->observationStats();
snapshot.normalization = window.nativeInputContext->normalizationStats();
snapshot.faulted = window.nativeInputContext->hasFault();
snapshot.fault = window.nativeInputContext->fault();
return snapshot;
}
Window::~Window()
{
// 先销毁控件树,再关闭图形环境,避免控件析构时访问已关闭的 EasyX 上下文。
@@ -710,12 +764,20 @@ LRESULT CALLBACK Window::WndProcThunk(HWND h, UINT m, WPARAM w, LPARAM l)
return DefWindowProc(h, m, w, l);
}
const auto probeCall = StellarX::Internal::inputProbeWndProcEnter(h, m, w, l);
const auto nativeInputResult = self->nativeInputContext
? self->nativeInputContext->collect(h, m, w, l)
: StellarX::Internal::NativeInputCollectResult{};
if (probeCall.observeAndConsume)
{
// 阶段 0B 的唯一消费出口:仅诊断构建、有效单规则和匹配基线下可达。
// 默认构建的 token 恒为 false,不改变生产窗口过程行为。
return StellarX::Internal::inputProbeWndProcLeave(probeCall, 0, false);
}
if (nativeInputResult.handledSynchronously)
{
return StellarX::Internal::inputProbeWndProcLeave(
probeCall, nativeInputResult.result, false);
}
if (m == WM_CLOSE)
{
// EasyX 的旧窗口过程可能在 WM_CLOSE 内直接终止进程,不保证返回到 leave、
@@ -841,6 +903,8 @@ void Window::draw()
// 子类化:让我们的 WndProcThunk 接管窗口消息(仅执行一次)
if (!procHooked)
{
if (!nativeInputContext)
nativeInputContext = std::make_unique<StellarX::Internal::NativeInputContext>(hWnd);
SetWindowLongPtr(hWnd, GWLP_USERDATA, (LONG_PTR)this);
StellarX::Internal::inputProbeAttach(hWnd);
oldWndProc = (WNDPROC)SetWindowLongPtr(hWnd, GWLP_WNDPROC, (LONG_PTR)&Window::WndProcThunk);
@@ -881,6 +945,8 @@ void Window::draw(std::string imagePath)
if (!procHooked)
{
if (!nativeInputContext)
nativeInputContext = std::make_unique<StellarX::Internal::NativeInputContext>(hWnd);
SetWindowLongPtr(hWnd, GWLP_USERDATA, (LONG_PTR)this);
StellarX::Internal::inputProbeAttach(hWnd);
oldWndProc = (WNDPROC)SetWindowLongPtr(hWnd, GWLP_WNDPROC, (LONG_PTR)&Window::WndProcThunk);
@@ -933,11 +999,18 @@ int Window::runEventLoop()
// 不再引入额外 pendingResize 等状态,避免分叉导致状态不一致。
while (true)
{
if (nativeInputContext && nativeInputContext->hasFault())
{
drainNativeInputAtSafePoint();
StellarX::Internal::inputProbeDetach(hWnd);
return StellarX::Internal::NativeInputFailureExitCode;
}
if (!hasLiveNativeWindow())
{
const bool inputHealthy = drainNativeInputAtSafePoint();
// 宿主循环已经确定退出;在 Window/EasyX 进一步改写句柄状态前结束探测会话。
StellarX::Internal::inputProbeDetach(hWnd);
return 0;
return inputHealthy ? 0 : StellarX::Internal::NativeInputFailureExitCode;
}
// drainMessageBatch 将一轮消息限制在固定上限;批次结束后统一处理 resize、tick 和重绘。
@@ -1059,6 +1132,11 @@ int Window::runEventLoop()
return true;
});
StellarX::Internal::inputProbeHostBatchEnd(hWnd, probeBatch, batch.processed, batch.stopped);
if (!drainNativeInputAtSafePoint())
{
StellarX::Internal::inputProbeDetach(hWnd);
return StellarX::Internal::NativeInputFailureExitCode;
}
if (batch.stopped || !hasLiveNativeWindow())
{
// batch.stopped(典型为 WM_CLOSE)是宿主线程最后一个可靠安全点。
@@ -0,0 +1,161 @@
#pragma once
#include "InputEvents.h"
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <deque>
#include <limits>
#include <memory>
#include <mutex>
class Window;
namespace StellarX::Internal
{
enum class NativeInputQueuePushResult : std::uint8_t
{
Queued,
AllocationFailure,
UnexpectedFailure
};
enum class NativeInputQueuePopResult : std::uint8_t
{
Popped,
Empty,
UnexpectedFailure
};
struct NativeInputQueueStats
{
std::uint64_t pushed = 0;
std::uint64_t popped = 0;
std::size_t currentDepth = 0;
std::size_t maximumDepth = 0;
};
// Correctness-first cross-thread SPSC queue. The mutex provides happens-before.
class NativeInputQueue
{
public:
NativeInputQueue() = default;
NativeInputQueue(const NativeInputQueue&) = delete;
NativeInputQueue& operator=(const NativeInputQueue&) = delete;
NativeInputQueuePushResult push(NativeInputEvent&& event) noexcept;
NativeInputQueuePopResult tryPop(NativeInputEvent& event) noexcept;
NativeInputQueueStats stats() const noexcept;
// Deterministic fail-stop injection for unit tests only.
void forceNextPushResultForTesting(NativeInputQueuePushResult result) noexcept;
private:
mutable std::mutex mutex_;
std::deque<NativeInputEvent> events_;
std::uint64_t pushed_ = 0;
std::uint64_t popped_ = 0;
std::size_t maximumDepth_ = 0;
std::atomic<NativeInputQueuePushResult> forcedPushResult_{ NativeInputQueuePushResult::Queued };
};
enum class NativeInputFaultCode : std::uint8_t
{
None,
OutOfMemory,
QueueFailure,
SequenceViolation,
UnexpectedException
};
struct NativeInputFaultSnapshot
{
NativeInputFaultCode code = NativeInputFaultCode::None;
std::uint64_t sequence = 0;
UINT message = 0;
HWND hwnd = nullptr;
};
struct NativeInputObservationStats
{
std::uint64_t observed = 0;
std::uint64_t keyEvents = 0;
std::uint64_t textEvents = 0;
std::uint64_t compositionEvents = 0;
std::uint64_t activationEvents = 0;
std::uint64_t lastSequence = 0;
bool sequenceStrict = true;
};
// Producer-side normalization anomalies are explicit diagnostics. They do
// not become replacement text and are never silently counted as success.
struct NativeInputNormalizationStats
{
std::uint64_t rejectedTextSequences = 0;
std::uint64_t imeQueryFailures = 0;
std::uint64_t discardedDecoderFragments = 0;
};
struct NativeInputRuntimeSnapshot
{
NativeInputQueueStats queue{};
NativeInputObservationStats observations{};
NativeInputNormalizationStats normalization{};
NativeInputFaultSnapshot fault{};
bool faulted = false;
};
struct NativeInputCollectResult
{
bool handledSynchronously = false;
LRESULT result = 0;
};
struct KeyModifierSnapshot
{
bool leftControl = false;
bool rightControl = false;
bool leftShift = false;
bool rightShift = false;
bool leftAlt = false;
bool rightAlt = false;
};
KeyEvent makeKeyEvent(UINT message, WPARAM wParam, LPARAM lParam,
const KeyModifierSnapshot& modifiers, bool imeLifecycleActive) noexcept;
// One context per Window: WndProc produces and the innermost host loop consumes.
class NativeInputContext
{
public:
explicit NativeInputContext(HWND hwnd);
~NativeInputContext();
NativeInputContext(const NativeInputContext&) = delete;
NativeInputContext& operator=(const NativeInputContext&) = delete;
NativeInputCollectResult collect(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) noexcept;
std::size_t drainObserved(std::size_t limit) noexcept;
bool hasFault() const noexcept;
NativeInputFaultSnapshot fault() const noexcept;
NativeInputQueueStats queueStats() const noexcept;
NativeInputObservationStats observationStats() const noexcept;
NativeInputNormalizationStats normalizationStats() const noexcept;
// Test-only observation seams; none participates in production routing.
bool tryPopForTesting(NativeInputEvent& event) noexcept;
void forceNextQueuePushResultForTesting(NativeInputQueuePushResult result) noexcept;
void setAnsiCodePageForTesting(UINT codePage) noexcept;
void seedEchoForTesting(const std::wstring& text, std::uint64_t compositionId);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
// Internal fixture seam. It exposes counters only and never returns queued text.
NativeInputRuntimeSnapshot inspectWindowNativeInputForTesting(const ::Window& window) noexcept;
constexpr int NativeInputFailureExitCode = -2;
}
+303
View File
@@ -1,11 +1,14 @@
#include "CppUnitTest.h"
#include "StellarX.h"
#include "SxEventLoopInternal.h"
#include "SxNativeInputInternal.h"
#include "SxTextInternal.h"
#include <atomic>
#include <filesystem>
#include <fstream>
#include <string>
#include <thread>
#include <vector>
using namespace Microsoft::VisualStudio::CppUnitTestFramework;
@@ -312,6 +315,306 @@ namespace StellarXNativeTests
}
};
// Stage 1 only validates capture-time snapshots, normalization, queue ordering,
// and fail-stop. No test in this class performs focus or control routing.
TEST_CLASS(NativeInputTests)
{
public:
TEST_METHOD(KeyLParamAndModifierSnapshotArePreserved)
{
StellarX::Internal::KeyModifierSnapshot modifiers;
modifiers.leftControl = true;
modifiers.rightAlt = true;
const LPARAM lParam = static_cast<LPARAM>(3u | (0x1Du << 16) |
(1u << 24) | (1u << 29) | (1u << 30));
const auto event = StellarX::Internal::makeKeyEvent(
WM_SYSKEYDOWN, VK_CONTROL, lParam, modifiers, true);
Assert::IsTrue(event.action == StellarX::KeyAction::Down);
Assert::AreEqual<UINT>(VK_CONTROL, event.virtualKey);
Assert::AreEqual<UINT>(VK_RCONTROL, event.physicalVirtualKey);
Assert::AreEqual<UINT>(0x1D, event.scanCode);
Assert::AreEqual<std::uint16_t>(3, event.repeatCount);
Assert::IsTrue(event.extended);
Assert::IsTrue(event.wasDown);
Assert::IsTrue(event.altContext);
Assert::IsTrue(event.systemKey);
Assert::IsTrue(event.imeComposingAtCapture);
Assert::IsTrue(event.modifiers.leftControl);
Assert::IsTrue(event.modifiers.rightAlt);
Assert::IsTrue(event.isAutoRepeat());
}
TEST_METHOD(ProcessKeyOwnsOnlyItsKeyAndDoesNotStartComposition)
{
StellarX::Internal::NativeInputContext context(nullptr);
context.collect(nullptr, WM_KEYDOWN, VK_PROCESSKEY, 1);
StellarX::NativeInputEvent event;
Assert::IsTrue(context.tryPopForTesting(event));
const auto* key = std::get_if<StellarX::KeyEvent>(&event.payload);
Assert::IsNotNull(key);
Assert::AreEqual<UINT>(VK_PROCESSKEY, key->virtualKey);
Assert::IsFalse(key->imeComposingAtCapture);
Assert::IsTrue(key->isImeOwnedAtCapture());
Assert::IsFalse(context.tryPopForTesting(event));
context.collect(nullptr, WM_KEYDOWN, 'A', 1);
Assert::IsTrue(context.tryPopForTesting(event));
key = std::get_if<StellarX::KeyEvent>(&event.payload);
Assert::IsNotNull(key);
Assert::IsFalse(key->imeComposingAtCapture);
Assert::IsFalse(key->isImeOwnedAtCapture());
}
TEST_METHOD(CompositionLifecycleIsCapturedIndependentlyFromKeyOwnership)
{
StellarX::Internal::NativeInputContext context(nullptr);
context.collect(nullptr, WM_IME_STARTCOMPOSITION, 0, 0);
context.collect(nullptr, WM_KEYDOWN, 'A', 1);
context.collect(nullptr, WM_IME_ENDCOMPOSITION, 0, 0);
StellarX::NativeInputEvent event;
Assert::IsTrue(context.tryPopForTesting(event));
const auto* start = std::get_if<StellarX::CompositionEvent>(&event.payload);
Assert::IsNotNull(start);
Assert::IsTrue(start->stage == StellarX::CompositionStage::Start);
Assert::AreNotEqual<std::uint64_t>(0, start->compositionId);
const std::uint64_t compositionId = start->compositionId;
Assert::IsTrue(context.tryPopForTesting(event));
const auto* key = std::get_if<StellarX::KeyEvent>(&event.payload);
Assert::IsNotNull(key);
Assert::IsTrue(key->imeComposingAtCapture);
Assert::IsFalse(key->isImeOwnedAtCapture());
Assert::IsTrue(context.tryPopForTesting(event));
const auto* cancel = std::get_if<StellarX::CompositionEvent>(&event.payload);
Assert::IsNotNull(cancel);
Assert::IsTrue(cancel->stage == StellarX::CompositionStage::Cancel);
Assert::AreEqual(compositionId, cancel->compositionId);
Assert::IsTrue(context.tryPopForTesting(event));
const auto* end = std::get_if<StellarX::CompositionEvent>(&event.payload);
Assert::IsNotNull(end);
Assert::IsTrue(end->stage == StellarX::CompositionStage::End);
Assert::AreEqual(compositionId, end->compositionId);
Assert::IsFalse(context.tryPopForTesting(event));
}
TEST_METHOD(AnsiDbcsLeadAndTrailBecomeOneUtf16Event)
{
StellarX::Internal::NativeInputContext context(nullptr);
context.setAnsiCodePageForTesting(936);
context.collect(nullptr, WM_CHAR, 0xD6, 1);
StellarX::NativeInputEvent event;
Assert::IsFalse(context.tryPopForTesting(event));
context.collect(nullptr, WM_CHAR, 0xD0, 1);
Assert::IsTrue(context.tryPopForTesting(event));
const auto* text = std::get_if<StellarX::TextInputEvent>(&event.payload);
Assert::IsNotNull(text);
Assert::AreEqual<std::size_t>(1, text->text.size());
Assert::AreEqual<wchar_t>(static_cast<wchar_t>(0x4E2D), text->text[0]);
Assert::IsTrue(text->source == StellarX::TextSource::AnsiCharFallback);
Assert::AreEqual<std::uint64_t>(0, text->compositionId);
}
TEST_METHOD(UnicodeScalarProducesSurrogatePairAndConsumesMessage)
{
StellarX::Internal::NativeInputContext context(nullptr);
const auto query = context.collect(nullptr, WM_UNICHAR, UNICODE_NOCHAR, 0);
Assert::IsTrue(query.handledSynchronously);
Assert::AreEqual<LRESULT>(TRUE, query.result);
const auto actual = context.collect(nullptr, WM_UNICHAR, 0x1F600, 0);
Assert::IsTrue(actual.handledSynchronously);
Assert::AreEqual<LRESULT>(0, actual.result);
StellarX::NativeInputEvent event;
Assert::IsTrue(context.tryPopForTesting(event));
const auto* text = std::get_if<StellarX::TextInputEvent>(&event.payload);
Assert::IsNotNull(text);
Assert::AreEqual<std::size_t>(2, text->text.size());
Assert::AreEqual<wchar_t>(static_cast<wchar_t>(0xD83D), text->text[0]);
Assert::AreEqual<wchar_t>(static_cast<wchar_t>(0xDE00), text->text[1]);
Assert::IsTrue(text->source == StellarX::TextSource::UnicodeScalar);
}
TEST_METHOD(InvalidUnicodeScalarIsRejectedAndCounted)
{
StellarX::Internal::NativeInputContext context(nullptr);
const auto result = context.collect(nullptr, WM_UNICHAR, 0x110000, 0);
Assert::IsFalse(result.handledSynchronously);
StellarX::NativeInputEvent event;
Assert::IsFalse(context.tryPopForTesting(event));
Assert::AreEqual<std::uint64_t>(1,
context.normalizationStats().rejectedTextSequences);
}
TEST_METHOD(ImeResultEchoChannelsAreSuppressedWithoutContentWideDedup)
{
StellarX::Internal::NativeInputContext context(nullptr);
context.setAnsiCodePageForTesting(936);
const std::wstring committed(1, static_cast<wchar_t>(0x4E2D));
context.seedEchoForTesting(committed, 7);
context.collect(nullptr, WM_IME_CHAR, 0xD6D0, 0);
context.collect(nullptr, WM_CHAR, 0xD6, 0);
context.collect(nullptr, WM_CHAR, 0xD0, 0);
StellarX::NativeInputEvent event;
Assert::IsFalse(context.tryPopForTesting(event));
// A new physical key is an echo boundary. Equal later content is real input.
context.collect(nullptr, WM_KEYDOWN, 'A', 1);
context.collect(nullptr, WM_CHAR, 0xD6, 0);
context.collect(nullptr, WM_CHAR, 0xD0, 0);
Assert::IsTrue(context.tryPopForTesting(event));
Assert::IsNotNull(std::get_if<StellarX::KeyEvent>(&event.payload));
Assert::IsTrue(context.tryPopForTesting(event));
const auto* text = std::get_if<StellarX::TextInputEvent>(&event.payload);
Assert::IsNotNull(text);
Assert::AreEqual(committed, text->text);
Assert::IsFalse(context.tryPopForTesting(event));
}
TEST_METHOD(ImeEndDoesNotDiscardImmediatelyFollowingResultEcho)
{
StellarX::Internal::NativeInputContext context(nullptr);
context.setAnsiCodePageForTesting(936);
const std::wstring committed(1, static_cast<wchar_t>(0x4E2D));
context.seedEchoForTesting(committed, 9);
context.collect(nullptr, WM_IME_ENDCOMPOSITION, 0, 0);
context.collect(nullptr, WM_IME_CHAR, 0xD6D0, 0);
StellarX::NativeInputEvent event;
Assert::IsFalse(context.tryPopForTesting(event));
}
TEST_METHOD(UnrepresentableAnsiEchoDoesNotSuppressFollowingText)
{
StellarX::Internal::NativeInputContext context(nullptr);
context.setAnsiCodePageForTesting(936);
std::wstring committed;
committed.push_back(static_cast<wchar_t>(0xD83D));
committed.push_back(static_cast<wchar_t>(0xDE00));
BOOL usedDefault = FALSE;
const int required = WideCharToMultiByte(936, WC_NO_BEST_FIT_CHARS,
committed.data(), static_cast<int>(committed.size()), nullptr, 0,
nullptr, &usedDefault);
Assert::IsTrue(required > 0);
Assert::IsTrue(usedDefault != FALSE);
std::string replacement(static_cast<std::size_t>(required), '\0');
usedDefault = FALSE;
Assert::AreEqual(required, WideCharToMultiByte(936, WC_NO_BEST_FIT_CHARS,
committed.data(), static_cast<int>(committed.size()), replacement.data(),
required, nullptr, &usedDefault));
context.seedEchoForTesting(committed, 11);
for (const unsigned char byte : replacement)
context.collect(nullptr, WM_CHAR, byte, 0);
context.collect(nullptr, WM_CHAR, 'A', 0);
StellarX::NativeInputEvent event;
Assert::IsTrue(context.tryPopForTesting(event));
const auto* text = std::get_if<StellarX::TextInputEvent>(&event.payload);
Assert::IsNotNull(text);
Assert::AreEqual(std::wstring(L"A"), text->text);
Assert::IsFalse(context.tryPopForTesting(event));
}
TEST_METHOD(QueuePreservesStrictOrderAcrossProducerAndConsumerThreads)
{
constexpr std::uint64_t eventCount = 20000;
StellarX::Internal::NativeInputQueue queue;
std::atomic<bool> producerDone{ false };
std::atomic<bool> failed{ false };
std::thread producer([&]() {
for (std::uint64_t sequence = 1; sequence <= eventCount; ++sequence)
{
StellarX::NativeInputEvent event;
event.sequence = sequence;
event.payload = StellarX::KeyEvent{};
if (queue.push(std::move(event)) !=
StellarX::Internal::NativeInputQueuePushResult::Queued)
{
failed.store(true, std::memory_order_release);
break;
}
}
producerDone.store(true, std::memory_order_release);
});
std::uint64_t expected = 1;
while (!producerDone.load(std::memory_order_acquire) || expected <= eventCount)
{
StellarX::NativeInputEvent event;
const auto result = queue.tryPop(event);
if (result == StellarX::Internal::NativeInputQueuePopResult::Popped)
{
if (event.sequence != expected)
failed.store(true, std::memory_order_release);
++expected;
}
else if (result == StellarX::Internal::NativeInputQueuePopResult::UnexpectedFailure)
{
failed.store(true, std::memory_order_release);
break;
}
else
{
if (producerDone.load(std::memory_order_acquire) &&
(expected > eventCount || failed.load(std::memory_order_acquire)))
break;
std::this_thread::yield();
}
}
producer.join();
Assert::IsFalse(failed.load(std::memory_order_acquire));
Assert::AreEqual(eventCount + 1, expected);
const auto stats = queue.stats();
Assert::AreEqual(eventCount, stats.pushed);
Assert::AreEqual(eventCount, stats.popped);
Assert::AreEqual<std::size_t>(0, stats.currentDepth);
Assert::IsTrue(stats.maximumDepth > 0);
}
TEST_METHOD(AllocationFailureBecomesObservableFailStop)
{
StellarX::Internal::NativeInputContext context(nullptr);
context.forceNextQueuePushResultForTesting(
StellarX::Internal::NativeInputQueuePushResult::AllocationFailure);
context.collect(nullptr, WM_KEYDOWN, 'A', 1);
Assert::IsTrue(context.hasFault());
const auto fault = context.fault();
Assert::IsTrue(fault.code == StellarX::Internal::NativeInputFaultCode::OutOfMemory);
Assert::AreEqual<UINT>(WM_KEYDOWN, fault.message);
StellarX::NativeInputEvent event;
Assert::IsFalse(context.tryPopForTesting(event));
}
TEST_METHOD(ObservationDrainIsStrictAndLeavesNoBacklog)
{
StellarX::Internal::NativeInputContext context(nullptr);
context.collect(nullptr, WM_KEYDOWN, 'A', 1);
context.collect(nullptr, WM_KEYUP, 'A', static_cast<LPARAM>(1u << 31));
context.collect(nullptr, WM_CHAR, 'a', 1);
Assert::AreEqual<std::size_t>(3, context.drainObserved(16));
const auto observations = context.observationStats();
Assert::AreEqual<std::uint64_t>(3, observations.observed);
Assert::AreEqual<std::uint64_t>(2, observations.keyEvents);
Assert::AreEqual<std::uint64_t>(1, observations.textEvents);
Assert::IsTrue(observations.sequenceStrict);
Assert::AreEqual<std::size_t>(0, context.queueStats().currentDepth);
Assert::IsFalse(context.hasFault());
}
};
// 验证文件 sink 的原子切换、失败回退和轮换过程不会丢失日志行。
TEST_CLASS(LoggingTests)
{
+57
View File
@@ -1,5 +1,7 @@
#include "StellarX.h"
#include "SxNativeInputInternal.h"
#include <array>
#include <filesystem>
#include <fstream>
@@ -28,6 +30,32 @@ namespace
// 统一按钮颜色,使 UI 回归可以区分背景、悬停和点击后的绘制结果。
return std::make_unique<Button>(x, y, w, h, text, ClickColor, ButtonColor, HoverColor);
}
class KeyboardObservationControl final : public Control
{
public:
KeyboardObservationControl() : Control(0, 0, 1, 1) { id = "KeyboardObservation"; }
void draw() override { dirty = false; }
bool handleEvent(const ExMessage& message) override
{
if (message.message == WM_KEYDOWN || message.message == WM_SYSKEYDOWN)
{
++keyEventCount;
++keyDownCount;
}
else if (message.message == WM_KEYUP || message.message == WM_SYSKEYUP)
{
++keyEventCount;
++keyUpCount;
}
return false;
}
bool model() const override { return false; }
std::uint64_t keyEventCount = 0;
std::uint64_t keyDownCount = 0;
std::uint64_t keyUpCount = 0;
};
}
int main(int argc, char** argv)
@@ -134,6 +162,35 @@ int main(int argc, char** argv)
window.addControl(std::move(caret));
}
}
else if (scenario == "input-shadow")
{
// Stage 1 keeps legacy EX_KEY delivery while the normalized queue is only
// observed and discarded at host safe points.
auto observer = std::make_unique<KeyboardObservationControl>();
KeyboardObservationControl* observerRaw = observer.get();
window.addControl(std::move(observer));
auto verify = MakeButton(20, 40, 160, 42, "verify input shadow");
verify->setOnClickListener([&, observerRaw]() {
const auto snapshot = StellarX::Internal::inspectWindowNativeInputForTesting(window);
WriteResult(resultDir, scenario,
std::string("{\"legacyKeyCount\":") + std::to_string(observerRaw->keyEventCount) +
",\"legacyKeyDownCount\":" + std::to_string(observerRaw->keyDownCount) +
",\"legacyKeyUpCount\":" + std::to_string(observerRaw->keyUpCount) +
",\"pushed\":" + std::to_string(snapshot.queue.pushed) +
",\"popped\":" + std::to_string(snapshot.queue.popped) +
",\"currentDepth\":" + std::to_string(snapshot.queue.currentDepth) +
",\"maximumDepth\":" + std::to_string(snapshot.queue.maximumDepth) +
",\"observed\":" + std::to_string(snapshot.observations.observed) +
",\"keyEvents\":" + std::to_string(snapshot.observations.keyEvents) +
",\"rejectedTextSequences\":" + std::to_string(snapshot.normalization.rejectedTextSequences) +
",\"imeQueryFailures\":" + std::to_string(snapshot.normalization.imeQueryFailures) +
",\"discardedDecoderFragments\":" + std::to_string(snapshot.normalization.discardedDecoderFragments) +
",\"sequenceStrict\":" + (snapshot.observations.sequenceStrict ? "true" : "false") +
",\"faulted\":" + (snapshot.faulted ? "true" : "false") + "}");
});
window.addControl(std::move(verify));
}
else if (scenario == "native-close")
{
// 只创建顶层窗口,由 UI 驱动发送原生关闭命令并验证宿主循环自行退出。
+26
View File
@@ -112,6 +112,32 @@ $msbuild = 'D:\vs2022\MSBuild\Current\Bin\MSBuild.exe'
- 自动复验:2026-09-30 使用 Windows PowerShell 5.1 执行 `NonUI`,四种配置均为 15/15 通过,KEY1 至 KEY6 编译链接成功。
- 说明:该结论代表本轮人工回归通过,不等同于对所有运行环境和未覆盖路径作无缺陷保证。
### 阶段 1 输入地基人工回归(2026-10-07,已完成)
本轮阶段 1 保留旧 `EX_KEY` 业务旁路,人工目标是确认“采集与观察队列接入后,现有交互和真实 IME 行为没有改变”。自动测试不能替代以下操作:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-input-probe-manual.ps1 -ImeName MicrosoftPinyin
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-input-probe-manual.ps1 -ImeName Sogou
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-key.ps1
```
- 微软拼音与搜狗分别完成固定非敏感测试串的候选、提交、重复字符、Escape 取消、数字选词和输入布局切换;候选 UI、输入响应和关闭流程不得卡顿或重复提交。
- 输入探测摘要必须 `sequenceStrict=True`、`textRawWParamLeaks=0`、结果/回声关系符合对应 IME 实际行为,并正常观察到关闭;探测器显式记账的诊断丢失不应被隐藏。
- KEY1–6 按上表执行;重点确认一次按键/点击只触发一次,阶段 1 兼容旁路没有造成双触发。
- 完成后,`Module-20261007-0005` 才可记为“自动与人工兼容性回归通过”。
#### 2026-10-07 执行结果
- 用户按固定清单分别完成微软拼音与搜狗真实输入,并完成 KEY1–6 人工交互;当前快照未发现卡顿、重复提交、双触发或既有控件行为退化。
- 微软拼音有效证据位于 `TestResults/InputProbe/manual-20261007-192828-293-MicrosoftPinyin`:`sequenceStrict=True`、`recordsLost=0`、`textRawWParamLeaks=0`,3 次正式结果均形成 `WM_IME_CHAR:matched`,关闭与 `session_end` 完整。
- 搜狗有效证据位于 `TestResults/InputProbe/manual-20261007-193100-289-Sogou`:`sequenceStrict=True`、`recordsLost=0`、`textRawWParamLeaks=0`,3 次正式结果均形成 `WM_IME_CHAR:matched`,关闭与 `session_end` 完整。搜狗本轮只出现 3 组标准生命周期,再次证明生命周期不能替代原始 `VK_PROCESSKEY` 的单键归属快照。
- 两轮均未观察到 `WM_INPUTLANGCHANGE`;这与当前系统同一 HKL 下切换 TSF 输入配置文件的既有证据一致,不把“用户执行过切换”和“窗口收到 HKL 变化消息”混写成同一结论。
- 微软拼音候选框在无文本焦点叶子、无 caret 锚点的输入探测夹具中落到系统回退位置。阶段 1 不实现 FocusManager、TextBox 或 `ImmSetCandidateWindow`,因此该现象作为后续集成边界保留,不把本轮采集通过误写成候选框定位通过;定位能力仍以阶段 0C 证据为准。
- 人工回归发现 KEY5/KEY6 曾把 Tooltip 延时覆盖为 `120 ms`,不是框架默认计时错误;现已移除覆盖,使人工用例使用 Button 默认 `1000 ms`。自动化 Tooltip fixture 继续使用独立的 `200 ms` 测试延时。
- KEY2 是 KEY1–6 中唯一启用文件日志的场景;轮转阈值由 `1024` 字节调整为 `10 * 1024 * 1024` 字节(10 MiB)。轮转在完整日志行写完后检查,单文件允许最多超出最后一行的长度。
- 调整后执行正式 `NonUI`:Debug x64、x64 ASan、Release x64、Debug Win32 均为 27/27,KEY1–6 全部重新编译链接成功。
### 失败留证
- 记录 KEY 编号、窗口尺寸、操作顺序和实际现象。
@@ -7,7 +7,7 @@
- 模块 ID:Module-20261001-0001
- 模块名称:通用输入采集、焦点与键盘路由系统
- 状态:设计中(v0.13;0C 当前可用环境基线通过,0B 阶段 1 前置基线已关闭;微软拼音 Forward/Consume 有效,搜狗 Consume 请求按 `ime_condition_not_met` fail-open 并形成 `MustForward`;阶段 1 尚未开始)
- 状态:实现中(v0.14;0C/0B 前置基线已关闭;阶段 1 输入采集、规范化与观察队列已落地并通过自动回归,待真实 IME/KEY1–6 人工回归;焦点与键盘路由尚未开始)
- 类型:新增模块 / 架构演进
- 所属系统 / 子系统:StellarX Core / Window / Control / Input / Focus / TextBox
- 版本 / 分支:`master`,阶段 0A 实现基线 `c218c25`
@@ -37,7 +37,7 @@
- v1 不自绘 IME 拼音组合串和候选列表,继续使用系统 IME UI。
- v1 不重写现有全部鼠标采集链;现有 EasyX 鼠标消息只在框架入口规范化为 `PointerEvent`。
- v1 不承诺完整 Unicode grapheme cluster 编辑,只保证不拆分 UTF-16 代理对。
- 本设计稿不代表框架代码已经实现,也不构成运行验证结果。
- 本稿中的阶段 1 已有实现记录和自动验证证据;阶段 2 至阶段 6 仍是设计目标,不应被误读为已经实现。
## 模块边界
@@ -162,8 +162,10 @@ Button / TextBox / Table / TabControl / Dialog
### 3. 关键不变量
1. NativeInputQueue 是键盘、文本和 IME 的唯一业务输入源。
2. EasyX `EX_KEY` 只排空和观察,不得再次触发业务路由。
以下是阶段 3 换轨后的完成态不变量;阶段 1–2 的兼容过渡例外仅限“旧 EX_KEY 继续业务下发、新队列只观察”,不得被扩展成长期双业务源:
1. 阶段 3 启用后,NativeInputQueue 是键盘、文本和 IME 的唯一业务输入源。
2. 阶段 3 启用后,EasyX `EX_KEY` 只排空和观察,不得再次触发业务路由。
3. 同步窗口协议不得进入异步输入队列。
4. 异步路由只能阻止框架默认动作,不能倒过来改变已经结束的 WndProc 返回值。
5. 队列、焦点、Tab 顺序、路由路径和待删除列表长期只保存 `ControlHandle`。
@@ -1034,13 +1036,15 @@ struct ProbeConsumeRule
#### 阶段 1:InputCollector
- 输入事件结构和 NativeInputQueue。
- 按已确认双线程模型实现 SPSC 语义的跨线程发布;正确性版本先用短临界区互斥队列。
- `sequence` 使用 atomic 分配,队列同步独立提供 happens-before。
- KeyEvent 完整解析。
- Unicode/ANSI 解码。
- IME 状态机。
- EasyX `EX_KEY` 排空但不业务路由。
- 已实现 `NativeInputEvent / KeyEvent / TextInputEvent / CompositionEvent / WindowActivationEvent` 和每 Window 的 `NativeInputContext`。
- 按已确认双线程模型使用 `mutex + deque` 实现跨线程 SPSC 语义;`sequence` 由 atomic 分配,队列互斥独立提供 happens-before。
- 已实现 Key lParam 完整解析、左右修饰键快照、Unicode/ANSI/CP936 状态解码、IME 生命周期、`compositionId` 与结果回声去重。
- WndProc 捕获时同时快照原始 VK 和 `imeComposingAtCapture`;二者是正交轴,只有原始 `VK_PROCESSKEY` 表示当前单键归 IME。
- 阶段 1 采用行为保持过渡:新队列只在宿主安全点观测排空,旧 `EX_KEY -> Control::handleEvent()` 暂时保留;阶段 3 才把 `EX_KEY` 改为纯排空,并让 NativeInputQueue 成为唯一键盘业务源。
- `Control::handleEvent()` 的 `EX_MOUSE` 永久保留;仅 `EX_KEY` 旧入口进入弃用期,派生控件不得新增对该路径的依赖。
- 队列分配失败或内部异常由无分配 POD 故障快照发布,WndProc 不抛异常;宿主安全点显式 fail-stop 并以 `-2` 退出,不允许静默丢输入后继续运行。
- 非法 UTF-16/ANSI 序列、IMM 查询失败和被边界清理的半截解码状态使用无原文累计计数,并在宿主安全点按变化输出警告。
- 阶段 1 不实现 FocusManager、KeyboardRouter、生产消费白名单、Button/TextBox 键盘行为或 TextBox 自绘。
#### 阶段 2:FocusManager
@@ -1108,17 +1112,23 @@ struct ProbeConsumeRule
28. IME 位置必须在焦点/caret/anchor 变化时立即预设,并在组合生命周期消息到达时再次校准;不得假定候选 UI 出现前一定会收到 `WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION`。
29. 原始 `VK_PROCESSKEY` 一律视为 IME 所有:WndProc 层采集并转发,阶段 1 不向 Button、Dialog、快捷键和焦点导航路由;不得依赖 `imeComposingAtCapture` 单独判断搜狗候选态。
30. 0B 没有建立跨 IME 生产消费白名单;阶段 1 从默认转发基线开始,后续任何窄消费规则都必须有独立逐键证据。
31. 阶段 1 只建输入地基、不切换业务轨道:旧 `EX_KEY` 继续进入 `Control::handleEvent()`,新队列只观测排空;阶段 3 一次性切换,`EX_MOUSE` 不受影响。
32. 单键 IME 所有权只由采集时的原始 `VK_PROCESSKEY` 表达;`imeComposingAtCapture` 只记录标准生命周期,两者不得合并为一个持久“组合中”状态。
33. `mutex + deque` 在正常资源条件下不得丢输入;分配失败或队列异常必须 fail-stop,不能捕获后假装成功。
34. `WM_IME_ENDCOMPOSITION` 不得在转发旧窗口过程之前清空结果回声;END 处理中嵌套或紧随其后的匹配 `WM_IME_CHAR/WM_CHAR` 仍须抑制重复入队。
35. 无效文本序列、IMM 查询失败和残留半截解码状态必须以不含原文的计数显式诊断,不得写入替代文本或静默记为成功。
## 实现与影响
- 关键实现点:
- 阶段 0A 探测器已经进入框架;0C 独立视觉探测工程已实现;FocusManager、生产 NativeInputQueue 和 TextBox 改造尚未开始。
- 独立 EasyX 探测已经推翻 v0.4 的单线程假设,v0.5 据此补入跨线程队列约束;v0.6 根据微软拼音/搜狗真实日志锁定结果串与回声关系,并补入输入布局 request/change 分层;v0.7 根据专用短测区分 TSF 配置文件切换与跨语言 HKL,并增加重新获焦快照约束;v0.8 根据 0C 自动证据锁定宽字符绘制、批次进入顺序与当前 DPI 坐标规则;v0.9 根据搜狗真实失败与修复复验锁定 IME 位置双时机规则;v0.13 根据 0B 微软拼音 Consume 与搜狗运行时 fail-open 证据锁定默认转发、`VK_PROCESSKEY` 路由抑制和无生产消费白名单基线。
- 阶段 0A 探测器和 0C 独立视觉探测已进入仓库;阶段 1 的生产 NativeInputQueue、输入事件结构、采集规范化、IME 状态与观察排空已实现。FocusManager、KeyboardRouter 和 TextBox 改造尚未开始。
- 独立 EasyX 探测已经推翻 v0.4 的单线程假设,v0.5 据此补入跨线程队列约束;v0.6 根据微软拼音/搜狗真实日志锁定结果串与回声关系,并补入输入布局 request/change 分层;v0.7 根据专用短测区分 TSF 配置文件切换与跨语言 HKL,并增加重新获焦快照约束;v0.8 根据 0C 自动证据锁定宽字符绘制、批次进入顺序与当前 DPI 坐标规则;v0.9 根据搜狗真实失败与修复复验锁定 IME 位置双时机规则;v0.13 根据 0B 证据锁定默认转发与无生产消费白名单;v0.14 根据阶段 1 实现审查锁定 EX_KEY 兼容过渡、两轴快照、fail-stop、END 回声边界和显式异常计数。
- 涉及文件 / 类 / 函数:
- 预计涉及 `Window`、`Control`、`Canvas`、`Button`、`TextBox`、`Table`、`TabControl`、`Dialog`。
- 实际文件和接口必须在阶段 0 与实现前只读检查后确定。
- 兼容性影响:
- 设计目标是保留现有鼠标行为和公共文本 API 的兼容入口。
- 阶段 1 不改变现有 `EX_KEY` 业务交付;阶段 3 停止向 `Control::handleEvent()` 下发 `EX_KEY` 时属于明确的派生控件迁移点,`EX_MOUSE` 永久保留。
- 输入链路和 TextBox 内部模型将发生架构级变化,必须分阶段接入。
- 性能影响:
- 输入事件会增加规范化和句柄解析开销,但不允许在 WndProc 中执行阻塞 I/O。
@@ -1126,13 +1136,13 @@ struct ProbeConsumeRule
- 风险点:
- EasyX 双线程、ANSI 窗口和当前微软拼音/搜狗回声顺序已确认,但其他 IME、窗口 A/W 属性和系统版本仍可能产生不同顺序。
- 模态重入和控件删除若破坏单 drainer/句柄不变量,可能产生难复现的顺序或生命周期问题。
- 该文档是设计基线,不应被误读为已实现或已验证能力。
- 阶段 1 的自动验证与人工回归必须分开记账;尚未执行的真实 IME/KEY1–6 人工操作不能由单元测试替代。
## 测试与验证
- 测试范围:
- 设计评审覆盖输入采集、同步消息、Unicode、IME、焦点、复合控件、按键归属、TextBox 和模态重入。
- 独立线程归属探测、0A 构建、固定消息 smoke、微软拼音/搜狗真实输入链路测试、当前可用环境的 0C 基线,以及 0B 的 F24 KeyDown/KeyUp 自动 A/B、baseline fail-open、微软拼音 Forward/Consume 和搜狗运行时 fail-open 门槛均已完成;生产输入队列和焦点系统尚未开始。
- 独立线程归属探测、0A、0C、0B 门槛均已完成;阶段 1 已加入逻辑测试与真实 EasyX `input-shadow` 场景,焦点系统尚未开始。
- 验证步骤:
1. 先按关联规格实现阶段 0A 被动探测并取得消息链路证据。
@@ -1161,6 +1171,7 @@ struct ProbeConsumeRule
- 2026-10-07:微软拼音 Forward/Consume 有效复验均通过;Forward 对应 EasyX 键出现一次,Consume 后 EasyX 键消失,位置链分别为 18/18 与 7/7,均零丢失、无孤立定位记录。
- 2026-10-07:搜狗候选态没有向目标 HWND 暴露 START/COMPOSITION/END 生命周期;最终 Consume 请求以 `ime_condition_not_met` 正确 fail-open 为 Forward,规则零命中、零消费、EasyX 键出现一次,结构 verdict 为 `MustForward`。
- 2026-10-07:最终 Debug x64 单元测试 15/15、UI fixture 1 轮、F24 KeyDown A/B 新运行通过;既有 KeyUp 与 baseline mismatch 证据经当前分析器回放保持有效。阶段 1 以默认转发且无消费白名单为起点。
- 2026-10-07:阶段 1 自动回归通过:Debug x64、x64 ASan、Release x64、Debug Win32 均为 27/27,KEY1–6 全部编译/链接成功;完整 UI fixture 曾一轮通过,最终源码的独立 `input-shadow` 再次通过,旧 EX_KEY 与新队列均完整观察 4000/4000 条事件,新队列最终深度 0、顺序严格、内存增长 0、无 fault/规范化异常;PowerShell 5.1/7 AST、工程 XML 和 `git diff --check` 通过。最终完整 UI 的两次追加复跑分别在 `geometry/ownership` 鼠标点击检查点超时,均未进入输入场景,按既有桌面焦点抖动保留失败证据,不冒充最终全套连续通过。真实 IME 与 KEY1–6 交互仍待人工确认。
- 已知限制 / 遗留问题:
- §5.3 当前明确不建立生产消费白名单;若后续需要窄消费规则,仍必须补对应消息、虚拟键、IME 和环境的独立 0B 证据。
- §7.3 的微软拼音/搜狗回声状态机已有 0A 证据,其他 IME 仍需保持可诊断回退。
@@ -0,0 +1,116 @@
# 新增功能模块 / 模块重构
> 本记录描述通用输入系统阶段 1 的实际实现边界与验证证据。它不代表焦点、键盘路由或 TextBox 自绘已经完成。
## 基本信息
- 模块 ID:Module-20261007-0005
- 模块名称:阶段 1 输入采集、规范化与跨线程观察队列
- 状态:实现完成,自动回归与真实 IME / KEY1–6 人工兼容性回归通过
- 类型:新增模块 / 架构演进
- 所属系统 / 子系统:StellarX Core / Window / Native Input
- 版本 / 分支:`master`,未提交工作区
- 环境:Windows + Visual Studio 2022 + MSVC v143 + EasyX + MBCS 工程配置
- 负责人:Codex 协作实现
## 背景与目标
- 阶段 0 已确认 EasyX HWND/WndProc 和宿主 `peekmessage` 分属两个线程,当前窗口是 ANSI、中文布局为 CP936。
- 阶段 1 只建立“WndProc 采集与规范化 → 跨线程队列 → 宿主安全点观察排空”的地基,不实现 FocusManager、KeyboardRouter、消费白名单或控件键盘行为。
- 为保持现有行为,本阶段继续把 EasyX `EX_KEY` 交给旧 `Control::handleEvent()`;真正换轨留到阶段 3。
## 实现边界
### 已实现
- 公共事件模型:`KeyEvent`、`TextInputEvent`、`CompositionEvent`、`WindowActivationEvent`、`NativeInputEvent`。
- 每 Window `NativeInputContext` 和正确性优先的 `mutex + deque` 跨线程队列。
- 全局 atomic sequence、队列 pushed/popped/depth/maxDepth 统计和宿主观察统计。
- Key lParam 的 repeat、scan code、extended、alt context、previous state、system key 解析,以及左右 Ctrl/Shift/Alt 快照。
- 原始 `VK_PROCESSKEY` 单键所有权与 `imeComposingAtCapture` 生命周期快照的两轴模型。
- Unicode `WM_CHAR` 代理对、`WM_UNICHAR` UTF-32、ANSI/CP936 DBCS lead/trail 解码。
- `WM_IME_STARTCOMPOSITION/COMPOSITION/ENDCOMPOSITION` 状态机、缺失 START 时的惰性 `compositionId`。
- `GCS_RESULTSTR` 唯一正式提交,以及 `WM_IME_CHAR/WM_CHAR` 独立游标的邻接回声去重。
- `WM_SETFOCUS/WM_KILLFOCUS` 激活事件和重新获焦时的键盘布局快照。
- 主循环与模态循环在安全点观察排空;阶段 1 不调用控件路由。
- WndProc 分配失败/异常的无分配故障快照;宿主安全点 fail-stop,返回码为 `-2`。
- 非法文本序列、IMM 查询失败、残留半截解码状态的无原文累计诊断。
### 明确未实现
- FocusManager、FocusScope、Tab 顺序和焦点回退。
- KeyboardRouter、祖先冒泡、Dialog 默认动作和 `VK_PROCESSKEY` 下游抑制。
- 生产键盘消费白名单。
- Button、Table、TabControl、Dialog 的键盘接入。
- TextBox UTF-16 编辑内核、自绘、选区、剪贴板和 IME caret 定位。
- 将旧 `EX_KEY` 从 `Control::handleEvent()` 中移除。
## 关键契约
1. WndProc 线程只做状态快照、规范化和短临界区入队;不执行用户回调、控件 mutation 或文件 I/O。
2. 原始 `VK_PROCESSKEY` 只表示当前单键归 IME,不启动持久组合态,也不分配 `compositionId`。
3. 标准 IME 生命周期只由 START/COMPOSITION/END 维护;两轴均在采集时快照,延迟消费者不得重查全局输入状态。
4. `GCS_RESULTSTR` 只产生一次 `TextInputEvent`;紧邻且完整匹配的 IME/字符回声只观察,不重复入队。
5. END 转发旧窗口过程前不清回声,避免漏掉 END 处理中的嵌套或紧随回声。
6. 正常资源条件下队列不得丢输入;分配失败或队列异常必须显式 fail-stop。
7. 阶段 1 保留旧 EX_KEY 业务旁路;阶段 3 才把 EX_KEY 改为纯排空并启用新路由。EX_MOUSE 永久保留。
## 实现文件
- `include/StellarX/InputEvents.h`
- `src/StellarX/internal/SxNativeInputInternal.h`
- `src/StellarX/SxNativeInput.cpp`
- `include/StellarX/Window.h`
- `src/StellarX/Window.cpp`
- `src/StellarX/Dialog.cpp`
- `tests/StellarXTests.cpp`
- `tests/UiFixture.cpp`
- `scripts/run-ui-tests.ps1`
## 自动验证
### 逻辑测试
- Key lParam 和左右修饰键快照。
- `VK_PROCESSKEY` 不启动组合,且普通键在生命周期内不被改写为 IME 所有。
- IME 生命周期、Cancel/End 和 `compositionId`。
- CP936 lead/trail 合并。
- `WM_UNICHAR` 能力查询、UTF-32 代理对和非法 scalar 计数。
- GCS 结果回声双通道去重,以及 END 后紧随回声保持。
- CP936 不可表示结果只抑制实际替代字节回声,不误吞随后无物理键前导的程序化文本。
- 20,000 事件生产/消费跨线程严格顺序。
- 注入式分配失败 fail-stop。
- 宿主观察排空无积压。
### 真实 EasyX `input-shadow`
- 总计注入 4,000 条 F24 KeyDown/KeyUp,分为低于 EasyX 旧队列有效容量的持续小波次。
- 同时验证旧 `EX_KEY -> handleEvent()` 与新 NativeInputQueue;两面均必须完整看到 2,000 Down + 2,000 Up。
- 新队列必须 `pushed == popped`、depth 归零、sequence 严格、无 fault、无文本规范化异常。
- 两轮负载之间检查私有内存不出现线性增长,并验证窗口仍能响应鼠标点击。
- 脚本新增 `-OnlyScenario input-shadow`,默认不传参时仍执行完整 UI 套件。
## 当前证据
- Debug x64、x64 ASan、Release x64、Debug Win32 均为 27/27;KEY1–6 全部编译/链接成功。
- 完整 UI fixture 曾一轮通过;最终源码单独复跑 `input-shadow` 通过:旧面 4000/4000,新面 4000/4000,最大新队列深度 40,最终深度 0,内存增长 0,顺序严格且无故障。
- 最终完整 UI 的两次追加复跑分别在 `geometry`、`ownership` 的鼠标点击检查点超时,均未进入 `input-shadow`。该结果按既有桌面焦点/SendInput 抖动保留,不写成最终全套连续通过;阶段 1 核心输入场景已有最终源码独立证据。
- PowerShell 5.1 与 Codex 运行时 PowerShell 7.6.5 AST、`StellarX.Core.vcxproj` XML、`git diff --check` 均通过。
- 2026-10-07 微软拼音有效会话 `manual-20261007-192828-293-MicrosoftPinyin` 与搜狗有效会话 `manual-20261007-193100-289-Sogou` 均为 sequence 严格、零丢失、零原文泄漏、3 次正式结果与 `WM_IME_CHAR` 回声完整匹配,并观察到正常关闭和 `session_end`。
- 用户完成 KEY1–6 人工交互,当前快照未发现卡顿、重复提交、双触发或既有行为退化。人工用例中 KEY5/KEY6 的 `120 ms` Tooltip 覆盖已移除,恢复验证框架默认 `1000 ms`;KEY2 日志轮转阈值由 1 KiB 调整为 10 MiB。
- 上述用例调整后,正式 `NonUI` 再次确认四种单元测试配置均为 27/27,KEY1–6 全部编译链接成功。
- 输入探测夹具没有文本焦点叶子和 caret 锚点,候选框使用系统回退位置不属于阶段 1 失败;本记录不据此宣称 FocusManager、TextBox 或候选框定位已经集成。
## 风险与后续
- 当前微软拼音/搜狗 + ANSI EasyX + CP936 的结果/回声顺序有阶段 0 真实证据;其他 IME、窗口 A/W 属性和系统版本仍需保持可诊断回退。
- EasyX 旧 `EX_KEY` 队列在当前环境呈现约 63 条有效容量;压力测试必须使用持续小波次验证排空,不能把第三方瞬时溢出误判为新队列丢失。
- 阶段 3 是明确兼容性切换点:派生控件必须从 `handleEvent(EX_KEY)` 迁移到新的 routed key 接口;届时再补 `开发记录/MIGRATION.md` 条目。
- 阶段 1 的自动验证和用户人工兼容性回归已经收口;下一步可进入阶段 2 FocusManager 设计/实现,但仍不得提前混入阶段 3 KeyboardRouter 或阶段 5 TextBox 自绘。
## 关联记录
- [`Module-20261001-0001-通用输入焦点与键盘路由系统设计.md`](./Module-20261001-0001-通用输入焦点与键盘路由系统设计.md)
- [`Module-20261001-0002-阶段0A输入链路被动探测规格.md`](./Module-20261001-0002-阶段0A输入链路被动探测规格.md)
- [`Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md`](./Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md)
- [`Module-20261001-0004-阶段0B原生键盘消息隔离消费实验规格.md`](./Module-20261001-0004-阶段0B原生键盘消息隔离消费实验规格.md)