feat(input): 完成阶段 1 输入采集与规范化

This commit is contained in:
Codex
2026-10-07 20:42:50 +08:00
parent 18caf91633
commit c57ddaf74f
12 changed files with 1803 additions and 6 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" />
+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 驱动发送原生关闭命令并验证宿主循环自行退出。