feat(input): 完成阶段3键盘路由与焦点重绘收口

This commit is contained in:
Codex
2026-10-10 00:05:23 +08:00
parent 69c26a35a2
commit 29df994f09
30 changed files with 2227 additions and 80 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\FocusManager.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\CoreTypes.h" /><ClInclude Include="include\StellarX\Dialog.h" /><ClInclude Include="include\StellarX\FocusManager.h" /><ClInclude Include="include\StellarX\InputEvents.h" /><ClInclude Include="include\StellarX\KeyboardRouting.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\SxKeyboardRouterInternal.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\FocusManager.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\SxNativeInput.cpp" /><ClCompile Include="src\StellarX\TabControl.cpp" /><ClCompile Include="src\StellarX\Table.cpp" />
<ClCompile Include="src\StellarX\KeyboardRouter.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
@@ -25,6 +25,8 @@
<ClInclude Include="include\StellarX\CoreTypes.h" />
<ClInclude Include="include\StellarX\Dialog.h" />
<ClInclude Include="include\StellarX\FocusManager.h" />
<ClInclude Include="include\StellarX\InputEvents.h" />
<ClInclude Include="include\StellarX\KeyboardRouting.h" />
<ClInclude Include="include\StellarX\Label.h" />
<ClInclude Include="include\StellarX\MessageBox.h" />
<ClInclude Include="include\StellarX\StellarX.h" />
+6
View File
@@ -48,6 +48,12 @@
<ClInclude Include="include\StellarX\FocusManager.h">
<Filter>头文件</Filter>
</ClInclude>
<ClInclude Include="include\StellarX\InputEvents.h">
<Filter>头文件</Filter>
</ClInclude>
<ClInclude Include="include\StellarX\KeyboardRouting.h">
<Filter>头文件</Filter>
</ClInclude>
<ClInclude Include="include\StellarX\MessageBox.h">
<Filter>头文件</Filter>
</ClInclude>
+15 -1
View File
@@ -32,6 +32,9 @@ class Button : public Control
std::string text; // 按钮上的文字
bool click; // 是否被点击
bool hover; // 是否被悬停
// 独立于 click:NORMAL 的 click 是鼠标瞬时态,TOGGLE 的 click 是持久态;
// 键盘 Space 的按下/释放生命周期不能复用它们。
bool keyboardArmed = false;
std::string cutText; // 切割后的文本
bool needCutText = true; // 是否需要切割文本
@@ -104,6 +107,8 @@ class Button : public Control
void draw() override;
// 处理按钮点击、悬停、切换状态和 Tooltip 触发。
bool handleEvent(const ExMessage& msg) override;
// 阶段 3 Routed 生产路径:Space 采用按下/释放语义,Enter 首次按下即激活。
StellarX::KeyRouteResult handleKeyEvent(StellarX::RoutedKeyEvent& event) override;
// 由窗口时钟推进 Tooltip 到期状态,即使没有新的鼠标消息也能触发显示。
void tick(ULONGLONG nowMs) override;
// 清理按钮的鼠标瞬时状态;用于父容器在 WM_MOUSEMOVE 短路后补做 hover/tooltip 收口。
@@ -121,7 +126,9 @@ class Button : public Control
// 设置 TOGGLE 模式下切换到关闭状态时的回调
void setOnToggleOffListener(std::function<void()> callback);
// 设置按钮模式(NORMAL / TOGGLE / DISABLED)
void setbuttonMode(StellarX::ButtonMode mode);
void setbuttonMode(StellarX::ButtonMode newMode);
// 隐藏会同步取消尚未释放的键盘按压态。
void setIsVisible(bool visible) override;
// 设置圆角矩形的圆角宽度
void setROUND_RECTANGLEwidth(int width);
// 设置圆角矩形的圆角高度
@@ -196,7 +203,14 @@ protected:
{
return mode != StellarX::ButtonMode::DISABLED;
}
void onLogicalFocusChanged(bool focused, StellarX::FocusReason reason) override;
void onKeyboardRoutingSuspended() override;
private:
// 返回状态是否发生变化;requestVisual=false 用于焦点/可见性/模式路径,
// 这些调用方已经负责整体失效或重绘。
bool setKeyboardArmed(bool armed, bool requestVisual);
// 执行一次 Button 命令。调用用户回调后不再访问成员。
void activateFromKeyboard();
// 初始化按钮内部状态、颜色和 Tooltip 默认样式
void initButton(const std::string text, StellarX::ButtonMode mode, StellarX::ControlShape shape, COLORREF ct, COLORREF cf, COLORREF ch);
// 判断鼠标是否在圆形按钮内
+4
View File
@@ -124,6 +124,10 @@ protected:
std::vector<StellarX::FocusTargetSnapshot>& snapshots) const override;
bool applyFocusState(const StellarX::FocusTargetId& target, bool focused,
StellarX::FocusReason reason) override;
bool dispatchKeyEventToTarget(const StellarX::FocusTargetId& target,
StellarX::RoutedKeyEvent& event, StellarX::KeyRouteResult& result) override;
bool dispatchKeyboardSuspensionToTarget(
const StellarX::FocusTargetId& target) override;
// 进入 Canvas 子树 mutation transaction;会向父容器/Window 传播事务深度。
void beginMutationScope() override;
// 离开 Canvas 子树 mutation transaction;最外层结束时提交子控件队列。
+15 -2
View File
@@ -33,6 +33,7 @@
#include <functional>
#include <atomic>
#include "CoreTypes.h"
#include "KeyboardRouting.h"
class Window;
@@ -195,9 +196,12 @@ public:
// 绘制入口。实现应只在 dirty/show 条件满足时写屏,并在完成后清理 dirty。
virtual void draw() = 0;
// 事件入口。返回 true 表示事件已消费;若事件造成视觉变化,应设置 eventVisualChanged 并请求重绘。
// 阶段 1 兼容过渡:EX_MOUSE 永久保留在此入口;EX_KEY 暂时仍按旧路径下发以保持行为,
// 计划在阶段 3 KeyboardRouter 一次性换轨后停止下发。派生控件不要再新增对 EX_KEY 的依赖。
// EX_MOUSE 永久保留在此入口;阶段 3 起生产事件循环不再向这里下发 EX_KEY。
// 派生控件的键盘逻辑应实现 handleKeyEvent(),旧 EX_KEY 覆盖不会再被调用。
virtual bool handleEvent(const ExMessage& msg) = 0;
// 阶段 3 定向键盘入口。默认空实现保证现有派生控件无需立即修改;
// KeyboardRouter 在句柄解析与 mutation 边界内逐节点调用。
virtual StellarX::KeyRouteResult handleKeyEvent(StellarX::RoutedKeyEvent&) { return {}; }
// 轻量清理接口:仅用于“没有机会收到本次 WM_MOUSEMOVE”的兄弟分支,
// 清掉 hover / tooltip / 临时按下态等鼠标瞬时状态。
// 该接口不是事件分发入口,不参与新的命中判断,也不允许回写设计基线。
@@ -306,6 +310,15 @@ protected:
std::vector<StellarX::FocusTargetSnapshot>& snapshots) const;
virtual bool applyFocusState(const StellarX::FocusTargetId& target, bool focused,
StellarX::FocusReason reason);
// 键盘路由按稳定身份逐节点重新解析;返回 true 仅表示本次找到并调用了目标。
// 实现不得把解析得到的 Control* 暴露到当前调用之外,回调返回后也不得再次访问目标。
virtual bool dispatchKeyEventToTarget(const StellarX::FocusTargetId& target,
StellarX::RoutedKeyEvent& event, StellarX::KeyRouteResult& result);
// 失活或模态隔离时按稳定身份取消键盘瞬时交互;Button 将在切片 5
// 覆盖 onKeyboardRoutingSuspended() 清理独立 keyboard armed 状态。
virtual bool dispatchKeyboardSuspensionToTarget(
const StellarX::FocusTargetId& target);
virtual void onKeyboardRoutingSuspended() {}
// 各控件动态提供 enabled 语义;Button 会根据 DISABLED 模式覆盖。
virtual bool isFocusEnabled() const { return true; }
// 派生控件可响应焦点变化;回调允许安全地请求焦点或排队修改控件树。
+9
View File
@@ -12,8 +12,14 @@ class Window;
namespace StellarX
{
namespace Internal
{
class KeyboardRouter;
}
class FocusManager
{
friend class Internal::KeyboardRouter;
struct FocusRequest
{
FocusTargetId target{};
@@ -84,6 +90,9 @@ namespace StellarX
// Valid Tab stops in the active scope, ordered by tabIndex and stable tree order.
std::vector<FocusTargetSnapshot> collectTabStops() const;
// Rebuilds the current scope's Tab order for every call, then moves with
// wrap-around. No traversal snapshot is retained across key events.
FocusChangeResult advanceTabFocus(bool reverse);
// Stage 2 provides scope mechanics; real Dialog integration remains in stage 4.
FocusScopeId pushScope(FocusTargetId scopeRoot, FocusTargetId initialFocus = {});
+14 -2
View File
@@ -1,7 +1,8 @@
/*******************************************************************************
* 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.
* Stage 1 introduced capture, normalization, queuing, and observation.
* After the Stage 3 atomic cutover, normalized KeyEvent routing is the
* production business path; EasyX EX_KEY remains a drain-only observation.
******************************************************************************/
#pragma once
@@ -108,6 +109,16 @@ namespace StellarX
HWND otherWindow = nullptr;
};
// Atomic producer snapshot copied into every native input event. Generation
// changes whenever keyboard focus active state changes, so a delayed event
// cannot cross a deactivate/reactivate boundary merely because the window is
// active again when the host thread eventually drains it.
struct NativeInputActivationSnapshot
{
bool keyboardFocusActive = false;
std::uint64_t generation = 0;
};
using NativeInputPayload = std::variant<
KeyEvent,
TextInputEvent,
@@ -119,6 +130,7 @@ namespace StellarX
std::uint64_t sequence = 0;
DWORD messageTime = 0;
HWND hwnd = nullptr;
NativeInputActivationSnapshot activationAtCapture{};
NativeInputPayload payload{};
};
}
+34
View File
@@ -0,0 +1,34 @@
/*******************************************************************************
* StellarX routed keyboard event contract.
* Stage 3 defines the public control seam and internal bubbling contract.
* Production routing uses this contract after the slice-6 atomic cutover.
******************************************************************************/
#pragma once
#include "InputEvents.h"
#include "CoreTypes.h"
namespace StellarX
{
// A normalized key plus the stable identities used while it bubbles through
// the focus path. The router updates currentTarget before each control call;
// controls must not retain either identity as a raw pointer.
struct RoutedKeyEvent
{
std::uint64_t sequence = 0;
DWORD messageTime = 0;
HWND hwnd = nullptr;
KeyEvent key{};
FocusTargetId originalTarget{};
FocusTargetId currentTarget{};
};
// Stopping the control bubble and suppressing a later framework default are
// independent decisions. Neither result can change the completed WndProc
// return value.
struct KeyRouteResult
{
bool stopBubble = false;
bool preventFrameworkDefault = false;
};
}
+3 -1
View File
@@ -22,7 +22,8 @@
* 只需包含此文件即可使用框架所有功能。
* 示例: #include "StellarX.h"
* @包含模块:
* CoreTypes.h / SxLog.h / Control.h / Canvas.h / Window.h
* CoreTypes.h / InputEvents.h / KeyboardRouting.h / SxLog.h
* Control.h / Canvas.h / Window.h
* Button.h / Label.h / TextBox.h / Table.h
* Dialog.h / MessageBox.h / TabControl.h
******************************************************************************/
@@ -32,6 +33,7 @@
#include "CoreTypes.h"
#include "FocusManager.h"
#include "InputEvents.h"
#include "KeyboardRouting.h"
#include "SxLog.h"
#include "Control.h"
#include"Canvas.h"
+4
View File
@@ -117,6 +117,10 @@ protected:
std::vector<StellarX::FocusTargetSnapshot>& snapshots) const override;
bool applyFocusState(const StellarX::FocusTargetId& target, bool focused,
StellarX::FocusReason reason) override;
bool dispatchKeyEventToTarget(const StellarX::FocusTargetId& target,
StellarX::RoutedKeyEvent& event, StellarX::KeyRouteResult& result) override;
bool dispatchKeyboardSuspensionToTarget(
const StellarX::FocusTargetId& target) override;
// 将回调期间加入的页签和页内控件在外层事务结束时一次性提交。
void flushPendingMutations() override;
// 外层统一解算后,TabControl 需要同步刷新其内部页签栏和页面区。
+4
View File
@@ -193,4 +193,8 @@ protected:
std::vector<StellarX::FocusTargetSnapshot>& snapshots) const override;
bool applyFocusState(const StellarX::FocusTargetId& target, bool focused,
StellarX::FocusReason reason) override;
bool dispatchKeyEventToTarget(const StellarX::FocusTargetId& target,
StellarX::RoutedKeyEvent& event, StellarX::KeyRouteResult& result) override;
bool dispatchKeyboardSuspensionToTarget(
const StellarX::FocusTargetId& target) override;
};
+29 -2
View File
@@ -29,8 +29,15 @@ class Window;
namespace StellarX::Internal
{
class NativeInputContext;
class KeyboardRouter;
struct NativeInputRuntimeSnapshot;
enum class KeyboardPipelineMode : std::uint8_t;
NativeInputRuntimeSnapshot inspectWindowNativeInputForTesting(const ::Window& window) noexcept;
bool configureKeyboardPipelineForTesting(
::Window& window, KeyboardPipelineMode mode) noexcept;
KeyboardPipelineMode keyboardPipelineMode(
const ::Window& window) noexcept;
void lockKeyboardPipeline(::Window& window) noexcept;
}
namespace StellarX
@@ -44,6 +51,12 @@ class Window
friend class StellarX::FocusManager;
friend StellarX::Internal::NativeInputRuntimeSnapshot
StellarX::Internal::inspectWindowNativeInputForTesting(const Window& window) noexcept;
friend bool StellarX::Internal::configureKeyboardPipelineForTesting(
Window& window, StellarX::Internal::KeyboardPipelineMode mode) noexcept;
friend StellarX::Internal::KeyboardPipelineMode
StellarX::Internal::keyboardPipelineMode(const Window& window) noexcept;
friend void StellarX::Internal::lockKeyboardPipeline(Window& window) noexcept;
friend class StellarX::Internal::KeyboardRouter;
// —— 尺寸状态 ——(绘制尺寸与待应用尺寸分离;收口时一次性更新)
int width; // 当前有效宽(已应用到画布/控件的客户区宽)
@@ -65,14 +78,22 @@ class Window
// EasyX 的 WndProc 与宿主事件循环分属不同线程;只有 WndProc 完成 WM_NCDESTROY
// 转发后才发布 false,避免宿主提前析构仍在执行 WndProcThunk 的 Window 对象。
std::atomic<bool> nativeWindowAlive{ false };
// 阶段 1 只建立采集、规范化与跨线程队列;宿主安全点只观测排空,不做业务路由。
// Routed 生产模式在宿主安全点逐条交给 KeyboardRouter;Legacy 仅供切换基线测试。
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;
// Stage 3 slice 6 production default. WndProc also reads this for the
// synchronous WM_GETDLGCODE answer, so the cross-thread snapshot is atomic.
std::atomic<bool> routedKeyboardPipeline{ true };
bool keyboardPipelineLocked = false;
// Host-thread diagnostics: EX_KEY must continue draining after cutover while
// legacy business delivery remains exactly zero.
std::uint64_t easyXKeyboardMessagesDrained = 0;
std::uint64_t legacyEasyXKeyboardBusinessDeliveries = 0;
bool hasLiveNativeWindow() const noexcept;
bool drainNativeInputAtSafePoint();
bool drainNativeInputAtSafePoint(bool modalBusinessSuppressed = false);
static LRESULT CALLBACK WndProcThunk(HWND h, UINT m, WPARAM w, LPARAM l); // 静态过程分发到 this
// —— 绘制相关 ——(是否使用合成双缓冲、窗口标题、背景等)
@@ -202,6 +223,12 @@ private:
std::vector<StellarX::FocusTargetSnapshot>& snapshots) const;
bool applyFocusState(const StellarX::FocusTargetId& target, bool focused,
StellarX::FocusReason reason);
// KeyboardRouter 专用:在当前调用栈内按稳定身份重新解析并立即投递,
// 不把借用的 Control* 传出本函数。
bool dispatchKeyEventToTarget(const StellarX::FocusTargetId& target,
StellarX::RoutedKeyEvent& event, StellarX::KeyRouteResult& result);
bool dispatchKeyboardSuspensionToTarget(
const StellarX::FocusTargetId& target);
// 提交 Window 顶层控件队列;只允许在最外层事务安全点调用。
void flushPendingControlMutations();
// 自顶向下提交 Window、Canvas、TabControl 等容器中累积的 pending mutation。
+155 -9
View File
@@ -1,14 +1,27 @@
# 外部 UI 驱动:启动确定性 fixture,注入真实鼠标输入,并保存像素与状态诊断。
param(
[int]$Repeat = 1,
[ValidateSet('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'focus', 'input-shadow')]
[string[]]$OnlyScenario = @()
[ValidateSet('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'focus', 'input-shadow', 'keyboard-routing')]
[string[]]$OnlyScenario = @(),
[string]$FixturePath = '',
[string]$ResultRoot = '',
[ValidateSet('Legacy', 'Routed')]
[string]$InputPipelineExpectation = 'Routed'
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$fixture = Join-Path $root 'build\x64\Debug\StellarX.UiFixture.exe'
$resultRoot = Join-Path $root 'TestResults\ui'
$fixture = if ($FixturePath) {
$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($FixturePath)
} else {
Join-Path $root 'build\x64\Debug\StellarX.UiFixture.exe'
}
$resultRoot = if ($ResultRoot) {
$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ResultRoot)
} else {
Join-Path $root 'TestResults\ui'
}
if (-not (Test-Path -LiteralPath $fixture -PathType Leaf)) { throw "UI fixture not found: $fixture" }
New-Item -ItemType Directory -Force -Path $resultRoot | Out-Null
Add-Type -AssemblyName System.Drawing
@@ -182,6 +195,20 @@ function Post-KeyWave([IntPtr]$h, [int]$pairCount = 1000) {
}
}
function Post-KeyPair([IntPtr]$h, [int]$virtualKey, [int]$scanCode) {
# Post the same Win32 down/up shape captured from a real keyboard. This keeps
# the test local to the fixture HWND while traversing its actual WndProc.
$down = [IntPtr]([long](1 -bor ($scanCode -shl 16)))
$up = [IntPtr]([long](0xC0000001L -bor ($scanCode -shl 16)))
if (-not [StellarXUiNative]::PostMessage($h, 0x0100, [IntPtr]$virtualKey, $down)) {
throw "Failed to post key-down for VK=$virtualKey"
}
if (-not [StellarXUiNative]::PostMessage($h, 0x0101, [IntPtr]$virtualKey, $up)) {
throw "Failed to post key-up for VK=$virtualKey"
}
Start-Sleep -Milliseconds 120
}
function Capture-Client([IntPtr]$h, [string]$path) {
# 只保存客户区,排除窗口边框;返回 Bitmap 供调用方继续读取关键像素。
$rect = [StellarXUiNative]::GetClientRectangle($h)
@@ -264,6 +291,17 @@ function Assert-RegionsDiffer([Drawing.Bitmap]$before, [Drawing.Bitmap]$after,
throw $message
}
function Assert-RegionsEqual([Drawing.Bitmap]$before, [Drawing.Bitmap]$after,
[int]$left, [int]$top, [int]$right, [int]$bottom, [string]$message) {
for ($y = $top; $y -lt $bottom; $y++) {
for ($x = $left; $x -lt $right; $x++) {
if ($before.GetPixel($x, $y).ToArgb() -ne $after.GetPixel($x, $y).ToArgb()) {
throw "$message first difference=($x,$y)"
}
}
}
}
function Wait-ClientRgb([IntPtr]$h, [int]$x, [int]$y, [int]$r, [int]$g, [int]$b,
[string]$capturePath, [int]$timeoutMs = 15000) {
# 某些绘制在消息批次尾才完成,因此对目标像素进行有界轮询而不是固定猜测延时。
@@ -283,9 +321,10 @@ 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)
$allScenarios = @('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'focus', 'input-shadow')
$allScenarios = @('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'focus', 'input-shadow', 'keyboard-routing')
$scenarios = if ($OnlyScenario.Count -gt 0) { @($OnlyScenario) } else { $allScenarios }
$sessionRoot = Join-Path $resultRoot ((Get-Date -Format 'yyyyMMdd-HHmmss-fff') + "-$PID")
$scenarioTimings = [Collections.Generic.List[object]]::new()
try {
# 场景按固定顺序串行执行,避免多个 EasyX 图形上下文同时争用桌面。
@@ -296,6 +335,7 @@ try {
$resultFile = Join-Path $runDir "$scenario.json"
$stdoutFile = Join-Path $runDir 'fixture.stdout.log'
$stderrFile = Join-Path $runDir 'fixture.stderr.log'
$scenarioStopwatch = [Diagnostics.Stopwatch]::StartNew()
$process = Start-Process -FilePath $fixture -ArgumentList @($scenario, $runDir) -PassThru `
-RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile
$h = [IntPtr]::Zero
@@ -310,9 +350,29 @@ try {
'tab' { Click-Mouse $h 230 35; Click-Mouse $h 395 238; $result = Wait-Result $resultFile; if ($result.active -ne 1 -or $result.firstSelected -or -not $result.secondSelected -or $result.firstPageVisible -or -not $result.secondPageVisible) { throw 'tab uniqueness result failed' } }
'table' { Click-Mouse $h 395 268; $result = Wait-Result $resultFile; if ($result.width -le 40 -or $result.coverageRight -le $result.coverageLeft -or $result.coverageBottom -le $result.coverageTop) { throw 'table minimum width or coverage failed' } }
'focus' {
# 先把鼠标移到固定空白处,排除进入场景时的随机 hover 像素。
Move-Mouse $h 780 500
$initialFocus = Capture-Client $h (Join-Path $runDir 'focus-textbox.png')
try {
Assert-RegionContainsRgb $initialFocus 20 78 182 118 32 96 192 'TextBox focus frame was not rendered'
# TextBox 后一站是 Table 当前页唯一可用的 next Button。焦点框
# 必须在同一键盘批次出现;再 Tab 离开后必须同一批次消失。
Post-KeyPair $h 0x09 0x0F
$tableFocused = Capture-Client $h (Join-Path $runDir 'focus-table-on.png')
try {
Assert-RegionsDiffer $initialFocus $tableFocused 200 20 340 145 `
'Table pagination focus frame did not repaint immediately'
}
finally { $tableFocused.Dispose() }
Post-KeyPair $h 0x09 0x0F
$tableBlurred = Capture-Client $h (Join-Path $runDir 'focus-table-off.png')
try {
Assert-RegionsEqual $initialFocus $tableBlurred 200 20 340 145 `
'Table pagination focus frame remained after focus moved away'
}
finally { $tableBlurred.Dispose() }
Click-Mouse $h 80 40
Move-Mouse $h 470 310
$buttonFocus = Capture-Client $h (Join-Path $runDir 'focus-button.png')
@@ -321,6 +381,45 @@ try {
Assert-RegionsDiffer $initialFocus $buttonFocus 20 20 140 60 'Button focus visual did not change after pointer focus'
}
finally { $buttonFocus.Dispose() }
# Canvas 背景和 Window 背景都是明确的焦点清除面。
Click-Mouse $h 560 57
Move-Mouse $h 780 500
$canvasFocused = Capture-Client $h (Join-Path $runDir 'focus-canvas-button.png')
try {
Assert-RegionsDiffer $initialFocus $canvasFocused 518 38 632 76 `
'Canvas child did not receive pointer focus'
}
finally { $canvasFocused.Dispose() }
Click-Mouse $h 730 120
Move-Mouse $h 780 500
$canvasCleared = Capture-Client $h (Join-Path $runDir 'focus-canvas-cleared.png')
try {
Assert-RegionsEqual $initialFocus $canvasCleared 518 38 632 76 `
'Canvas background click did not clear focus visual'
}
finally { $canvasCleared.Dispose() }
Click-Mouse $h 80 40
Move-Mouse $h 780 500
Click-Mouse $h 780 500
$windowCleared = Capture-Client $h (Join-Path $runDir 'focus-window-cleared.png')
try {
Assert-RegionsEqual $initialFocus $windowCleared 18 18 142 62 `
'Window background click did not clear focus visual'
}
finally { $windowCleared.Dispose() }
# 同父 overlay 回归:下层失焦和上层获焦同时发生后,交叠处
# 必须仍是上层 Canvas 背景色,而不是被下层重绘覆盖。
Click-Mouse $h 540 225
Click-Mouse $h 680 295
$overlayFocus = Capture-Client $h (Join-Path $runDir 'focus-overlay-restored.png')
try {
Assert-Rgb ($overlayFocus.GetPixel(610, 245)) 200 160 120 `
'Upper Canvas background was covered by lower focus repaint'
}
finally { $overlayFocus.Dispose() }
}
finally { $initialFocus.Dispose() }
Click-Mouse $h 405 278
@@ -365,10 +464,29 @@ try {
$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 ($InputPipelineExpectation -eq 'Legacy') {
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)"
}
}
else {
if ($result.legacyKeyCount -ne 0 -or $result.legacyKeyDownCount -ne 0 -or
$result.legacyKeyUpCount -ne 0) {
throw "legacy EX_KEY business path was not disabled: total=$($result.legacyKeyCount)"
}
if ($result.routedKeyCount -ne $expectedKeyEvents -or
$result.routedKeyDownCount -ne ($expectedKeyEvents / 2) -or
$result.routedKeyUpCount -ne ($expectedKeyEvents / 2)) {
throw "routed key delivery mismatch: total=$($result.routedKeyCount) down=$($result.routedKeyDownCount) up=$($result.routedKeyUpCount)"
}
if ($result.easyXKeyboardMessagesDrained -ne $expectedKeyEvents) {
throw "EasyX EX_KEY drain mismatch: $($result.easyXKeyboardMessagesDrained)"
}
if ($result.legacyEasyXKeyboardBusinessDeliveries -ne 0) {
throw "legacy EX_KEY business delivery count was not zero: $($result.legacyEasyXKeyboardBusinessDeliveries)"
}
}
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' }
@@ -381,11 +499,24 @@ try {
if ($memoryGrowth -gt 16MB) { throw "input shadow memory grew after warmup: $memoryGrowth bytes" }
@{
expectedKeyEvents = $expectedKeyEvents
inputPipelineExpectation = $InputPipelineExpectation
memoryAfterWarmup = $memoryAfterWarmup
memoryAfterSecondWave = $memoryAfterSecondWave
memoryGrowth = $memoryGrowth
} | ConvertTo-Json | Set-Content -Encoding UTF8 (Join-Path $runDir 'input-shadow-memory.json')
}
'keyboard-routing' {
# Space confirms on KeyUp, Tab is the framework default,
# and Enter confirms on first KeyDown.
Post-KeyPair $h 0x20 0x39
Post-KeyPair $h 0x09 0x0F
Post-KeyPair $h 0x0D 0x1C
$result = Wait-Result $resultFile
if ($result.firstClicks -ne 1 -or $result.secondClicks -ne 1 -or
-not $result.secondFocused) {
throw 'production keyboard route did not activate and traverse exactly once'
}
}
}
$bitmap = Capture-Client $h (Join-Path $runDir 'screen.png')
if ($scenario -eq 'ownership' -or $scenario -eq 'geometry') {
@@ -417,6 +548,13 @@ try {
Assert-Rgb ($bitmap.GetPixel(240, 150)) 30 50 70 'dialog pixels remained after both dialogs closed'
}
$bitmap.Dispose()
$scenarioStopwatch.Stop()
$scenarioTimings.Add([pscustomobject]@{
iteration = $iteration
scenario = $scenario
passed = $true
durationMs = $scenarioStopwatch.ElapsedMilliseconds
})
}
catch {
# 失败时保留驱动错误、fixture 输出和客户区截图,便于定位像素或状态问题。
@@ -448,6 +586,14 @@ try {
Assert-NativeCloseExitsProcess $fixture `
(Join-Path $sessionRoot "run-$iteration-modal-native-close") 'modal-close'
}
@{
fixture = $fixture
fixtureSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $fixture).Hash
inputPipelineExpectation = $InputPipelineExpectation
repeat = $Repeat
scenarios = @($scenarioTimings)
} | ConvertTo-Json -Depth 5 | Set-Content -Encoding UTF8 (Join-Path $sessionRoot 'run-summary.json')
}
finally {
[void][StellarXUiNative]::SetCursorPos($savedCursor.X, $savedCursor.Y)
+27 -8
View File
@@ -10,10 +10,6 @@
# 统一测试入口:负责构建指定矩阵,并把所有诊断结果集中到 TestResults。
$ErrorActionPreference = 'Stop'
# 暂时清空再恢复 PATH,避免 MSBuild/VSTest 被其他工具链中的同名程序劫持。
$savedPath = $env:Path
[Environment]::SetEnvironmentVariable('PATH', $null, [EnvironmentVariableTarget]::Process)
[Environment]::SetEnvironmentVariable('Path', $savedPath, [EnvironmentVariableTarget]::Process)
$root = Split-Path -Parent $PSScriptRoot
$msbuild = if ($env:STELLARX_MSBUILD) { $env:STELLARX_MSBUILD } else { 'D:\vs2022\MSBuild\Current\Bin\MSBuild.exe' }
$vstest = if ($env:STELLARX_VSTEST) { $env:STELLARX_VSTEST } else { 'D:\vs2022\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe' }
@@ -23,12 +19,34 @@ New-Item -ItemType Directory -Force -Path $results | Out-Null
if (-not (Test-Path -LiteralPath $msbuild)) { throw "MSBuild not found: $msbuild" }
if (-not (Test-Path -LiteralPath $vstest)) { throw "VSTest not found: $vstest" }
$pathKeyCount = @(
[Environment]::GetEnvironmentVariables([EnvironmentVariableTarget]::Process).Keys |
Where-Object { [string]$_ -ieq 'Path' }
).Count
$lastMsBuildExitCode = 0
function Invoke-StellarXMSBuild([string[]]$arguments) {
if ($pathKeyCount -gt 1) {
# PowerShell 7 宿主可能同时注入 PATH/Path。MSBuild 本身能启动,但在创建
# CL.exe 时会因重复环境键抛 MSB6001。通过 cmd 只删除大写 PATH,保留
# 同值的 Path;该规整仅作用于本次构建子进程,不改调用者环境。
$quotedArguments = @($arguments | ForEach-Object { '"' + $_ + '"' })
$buildCommand = 'set "PATH=" & "' + $msbuild + '" ' + ($quotedArguments -join ' ')
& $env:ComSpec /d /c $buildCommand
}
else {
& $msbuild @arguments
}
$script:lastMsBuildExitCode = $LASTEXITCODE
}
function Build-Project([string]$project, [string]$config, [string]$arch, [bool]$asan = $false) {
# ASan 使用 Rebuild,确保运行库和插桩设置不会沿用上一种构建配置。
$asanValue = if ($asan) { 'true' } else { 'false' }
$target = if ($asan) { 'Rebuild' } else { 'Build' }
& $msbuild $project /m /t:$target /p:Configuration=$config /p:Platform=$arch /p:EnableAsan=$asanValue /v:minimal
if ($LASTEXITCODE -ne 0) { throw "Build failed: $project ($config|$arch)" }
Invoke-StellarXMSBuild @($project, '/m', "/t:$target", "/p:Configuration=$config",
"/p:Platform=$arch", "/p:EnableAsan=$asanValue", '/v:minimal')
if ($lastMsBuildExitCode -ne 0) { throw "Build failed: $project ($config|$arch)" }
}
function Run-Unit([string]$config, [string]$arch, [bool]$asan = $false) {
@@ -61,8 +79,9 @@ function Build-KeyScenarios {
# KEY1-KEY6 是应用现有主路径的编译/链接冒烟,不在这里模拟鼠标交互。
$project = Join-Path $root 'imGui-easyX.vcxproj'
foreach ($key in 1..6) {
& $msbuild $project /m /t:Build /p:Configuration=Debug /p:Platform=x64 /p:KeyScenario=$key /p:EnableAsan=false /v:minimal
if ($LASTEXITCODE -ne 0) { throw "KEY$key build failed" }
Invoke-StellarXMSBuild @($project, '/m', '/t:Build', '/p:Configuration=Debug',
'/p:Platform=x64', "/p:KeyScenario=$key", '/p:EnableAsan=false', '/v:minimal')
if ($lastMsBuildExitCode -ne 0) { throw "KEY$key build failed" }
}
}
+121 -4
View File
@@ -196,8 +196,10 @@ void Button::draw()
}
else
{
// 点击状态优先级最高,然后是悬停状态,最后是默认状态
COLORREF col = click ? buttonTrueColor : (hover ? buttonHoverColor : buttonFalseColor);
// 鼠标/Toggle 的 click 与键盘 Space armed 相互独立,只在绘制层合并。
// 焦点框仍在按钮本体之后绘制,不会被按压色覆盖。
COLORREF col = (click || keyboardArmed)
? buttonTrueColor : (hover ? buttonHoverColor : buttonFalseColor);
setfillcolor(col);
}
//
@@ -459,6 +461,101 @@ bool Button::handleEvent(const ExMessage& msg)
return consume;
}
StellarX::KeyRouteResult Button::handleKeyEvent(StellarX::RoutedKeyEvent& event)
{
auto mutationScope = enterMutationScope();
const StellarX::KeyEvent& key = event.key;
// FocusManager normally prevents delivery to an ineligible Button. Keep this
// local guard as a fail-closed boundary for direct/custom dispatchers.
if (!show || mode == StellarX::ButtonMode::DISABLED || !logicalFocused)
{
setKeyboardArmed(false, true);
return {};
}
if (key.virtualKey == VK_ESCAPE && key.action == StellarX::KeyAction::Down)
{
// Escape only cancels the local pressed state. It must continue bubbling
// and must not suppress a later Dialog/Window default action.
setKeyboardArmed(false, true);
return {};
}
if (key.virtualKey == VK_SPACE)
{
if (key.action == StellarX::KeyAction::Down)
{
if (!key.isAutoRepeat())
setKeyboardArmed(true, true);
// Space belongs to the focused Button even when this is an ignored
// repeat. Ancestors may still observe it; framework defaults may not.
return { false, true };
}
if (!keyboardArmed)
return {};
setKeyboardArmed(false, true);
activateFromKeyboard();
return { false, true };
}
if (key.virtualKey == VK_RETURN && key.action == StellarX::KeyAction::Down)
{
if (!key.isAutoRepeat())
activateFromKeyboard();
// Enter repeats are owned but deliberately do not retrigger the command.
return { false, true };
}
return {};
}
bool Button::setKeyboardArmed(bool armed, bool requestVisual)
{
if (keyboardArmed == armed)
return false;
keyboardArmed = armed;
dirty = true;
markEventVisualChanged(true);
if (requestVisual)
requestRepaint(parent);
return true;
}
void Button::activateFromKeyboard()
{
if (!show || mode == StellarX::ButtonMode::DISABLED || !logicalFocused)
return;
hideTooltip();
if (mode == StellarX::ButtonMode::NORMAL)
{
SX_LOGI("Button") << "keyboard click: id=" << id
<< " callback=" << (onClickCallback ? "Y" : "N");
// The callback may queue deletion of this Button. Do not access members
// after invoking it.
if (onClickCallback) onClickCallback();
return;
}
click = !click;
dirty = true;
markEventVisualChanged(true);
refreshTooltipTextForState();
requestRepaint(parent);
const bool toggledOn = click;
SX_LOGI("Button") << "keyboard toggle: id=" << id
<< " state=" << (toggledOn ? 1 : 0);
// The selected callback may queue deletion. No member access follows.
if (toggledOn)
{
if (onToggleOnCallback) onToggleOnCallback();
return;
}
if (onToggleOffCallback) onToggleOffCallback();
}
void Button::tick(ULONGLONG nowMs)
{
// tick 只处理已经命中的按钮;到期后显示 Tooltip,并把重绘交给父容器统一提交。
@@ -571,14 +668,34 @@ void Button::setOnToggleOffListener(std::function<void()> callback)
this->onToggleOffCallback = std::move(callback);
}
void Button::setbuttonMode(StellarX::ButtonMode mode)
void Button::setbuttonMode(StellarX::ButtonMode newMode)
{
//取值范围参考 buttMode的枚举注释
this->mode = mode;
if (this->mode != newMode)
setKeyboardArmed(false, false);
this->mode = newMode;
dirty = true; // 标记需要重绘
notifyFocusSubtreeStateChanged();
}
void Button::setIsVisible(bool visible)
{
if (!visible)
setKeyboardArmed(false, false);
Control::setIsVisible(visible);
}
void Button::onLogicalFocusChanged(bool focused, StellarX::FocusReason)
{
if (!focused)
setKeyboardArmed(false, false);
}
void Button::onKeyboardRoutingSuspended()
{
setKeyboardArmed(false, true);
}
void Button::setROUND_RECTANGLEwidth(int width)
{
rouRectangleSize.ROUND_RECTANGLEwidth = width;
+45 -11
View File
@@ -351,6 +351,20 @@ bool Canvas::handleEvent(const ExMessage& msg)
}
}
// Canvas 背景本身也是一个明确的指针命中面:子控件均未处理、且左键
// 按在当前 Canvas 矩形内时,清除逻辑焦点并停止继续向更低层兄弟穿透。
// 模态 Dialog 继承自 Canvas,但阶段 4 接入真实 FocusScope 之前仍需保留
// 底层逻辑焦点,因此这里只对非模态 Canvas/非模态 Dialog 生效。
const bool insideCanvas =
msg.x >= x && msg.x <= x + width &&
msg.y >= y && msg.y <= y + height;
if (!consumed && msg.message == WM_LBUTTONDOWN && insideCanvas && !model())
{
if (Window* host = getHostWindow())
host->getFocusManager().clearFocus(StellarX::FocusReason::Pointer);
consumed = true;
}
if (firstConsumer && !SxIsNoisyMsg(msg.message))
{
SX_LOGD("Event") << SX_T("Canvas 消耗消息: ","Canvas consumed: ")
@@ -527,6 +541,26 @@ bool Canvas::applyFocusState(const StellarX::FocusTargetId& target, bool focused
return false;
}
bool Canvas::dispatchKeyEventToTarget(const StellarX::FocusTargetId& target,
StellarX::RoutedKeyEvent& event, StellarX::KeyRouteResult& result)
{
if (Control::dispatchKeyEventToTarget(target, event, result))
return true;
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
if (*it && (*it)->dispatchKeyEventToTarget(target, event, result)) return true;
return false;
}
bool Canvas::dispatchKeyboardSuspensionToTarget(
const StellarX::FocusTargetId& target)
{
if (Control::dispatchKeyboardSuspensionToTarget(target))
return true;
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
if (*it && (*it)->dispatchKeyboardSuspensionToTarget(target)) return true;
return false;
}
void Canvas::setShape(StellarX::ControlShape shape)
{
switch (shape)
@@ -707,20 +741,20 @@ void Canvas::requestRepaint(Control* parent)
if (!child->IsVisible())
continue;
if (child->hasManagedDirtySubtree())
// 下层持久像素与当前上层兄弟相交时,必须优先完整重建上层兄弟。
// 即便上层自己的某个后代也同时变脏,也不能只提交那条脏子树;
// 否则下层刚写入的像素仍会覆盖上层背景和未变脏的兄弟内容。
const bool persistentHit = hasPaintCoverage && hasPersistentCoverage &&
SxCanvasRectsIntersect(child->getManagedRepaintCoverageRect(), paintCoverage) &&
SxCanvasRectsIntersect(child->getManagedRepaintPersistentCoverageRect(), persistentCoverage);
if (persistentHit)
{
commitManagedChild(child, SxCanvasOverlayRedrawMode::RefreshSnapshot);
}
else if (child->hasManagedDirtySubtree())
{
commitManagedChild(child, SxCanvasOverlayRedrawMode::None);
}
else if (hasPaintCoverage && SxCanvasRectsIntersect(child->getManagedRepaintCoverageRect(), paintCoverage))
{
// 位于本次累计 coverage 上方、且发生相交的兄弟控件,需要补画回最上层。
// 但只有下层“持久内容”影响到它时,才允许作废并重新抓背景快照;
// 如果只是被 Tooltip 等临时浮层覆盖,则跳过兄弟补画,避免透明控件回贴旧快照擦掉 Tooltip。
const bool persistentHit = hasPersistentCoverage &&
SxCanvasRectsIntersect(child->getManagedRepaintPersistentCoverageRect(), persistentCoverage);
if (persistentHit)
commitManagedChild(child, SxCanvasOverlayRedrawMode::RefreshSnapshot);
}
}
return;
+21
View File
@@ -289,6 +289,27 @@ bool Control::applyFocusState(const StellarX::FocusTargetId& target, bool focuse
return true;
}
bool Control::dispatchKeyEventToTarget(const StellarX::FocusTargetId& target,
StellarX::RoutedKeyEvent& event, StellarX::KeyRouteResult& result)
{
if (target.owner != handle || target.subNode != 0)
return false;
event.currentTarget = target;
// handleKeyEvent() 可排队删除 this;调用返回后不得再次读取任何成员。
result = handleKeyEvent(event);
return true;
}
bool Control::dispatchKeyboardSuspensionToTarget(
const StellarX::FocusTargetId& target)
{
if (target.owner != handle || target.subNode != 0)
return false;
// 回调可排队删除 this;调用后不再读取任何成员。
onKeyboardRoutingSuspended();
return true;
}
void Control::notifyFocusSubtreeStateChanged()
{
if (auto* host = getHostWindow())
+24 -3
View File
@@ -2,6 +2,7 @@
#include "SxLog.h"
#include "SxEventLoopInternal.h"
#include "SxInputProbeInternal.h"
#include "SxKeyboardRouterInternal.h"
Dialog::Dialog(Window& h, std::string text, std::string message, StellarX::MessageBoxType type, bool modal)
: Canvas(), message(message), type(type), modal(modal), hWnd(h), titleText(text)
@@ -174,6 +175,17 @@ void Dialog::Show()
if (modal)
{
// A modal loop is also an event-loop entry point. Freeze the shared
// keyboard pipeline before it starts draining EasyX messages.
StellarX::Internal::lockKeyboardPipeline(hWnd);
if (StellarX::Internal::keyboardPipelineMode(hWnd) ==
StellarX::Internal::KeyboardPipelineMode::Routed)
{
// This stack Dialog is not yet part of the Window focus tree. Cancel
// underlying transient key state once, then keep its logical focus while
// every modal drain suppresses business routing to that tree.
StellarX::Internal::KeyboardRouter::suspendFocusedPath(hWnd);
}
// 模态对话框需要阻塞当前线程直到对话框关闭
if (!hWnd.hasLiveNativeWindow())
{
@@ -196,7 +208,7 @@ void Dialog::Show()
while (show)
{
if (!hWnd.drainNativeInputAtSafePoint())
if (!hWnd.drainNativeInputAtSafePoint(true))
{
show = false;
hWnd.dialogOpen = false;
@@ -247,13 +259,22 @@ void Dialog::Show()
[](ExMessage& next) { return peekmessage(&next, EX_MOUSE | EX_KEY); },
[&](const ExMessage& next) {
StellarX::Internal::inputProbeObserveEasyXMessage(hWnd.getHwnd(), probeBatch, next);
handleEvent(next);
const bool easyXKeyboardMessage =
StellarX::Internal::isEasyXKeyboardMessage(next.message);
if (easyXKeyboardMessage)
++hWnd.easyXKeyboardMessagesDrained;
if (StellarX::Internal::shouldDeliverLegacyEasyXMessage(hWnd, next.message))
{
if (easyXKeyboardMessage)
++hWnd.legacyEasyXKeyboardBusinessDeliveries;
handleEvent(next);
}
return show;
});
processedMessages = static_cast<int>(batch.processed);
StellarX::Internal::inputProbeHostBatchEnd(
hWnd.getHwnd(), probeBatch, batch.processed, batch.stopped);
if (!hWnd.drainNativeInputAtSafePoint())
if (!hWnd.drainNativeInputAtSafePoint(true))
{
show = false;
hWnd.dialogOpen = false;
+28
View File
@@ -242,6 +242,34 @@ namespace StellarX
return collectOrderedTargets(true);
}
FocusChangeResult FocusManager::advanceTabFocus(bool reverse)
{
// The focus tree is intentionally rebuilt for each Tab key. Controls such
// as Table materialize real Button children only after their first draw,
// while callbacks may hide, disable, remove, or add stops between events.
const auto stops = collectTabStops();
if (stops.empty())
return { false, FocusError::None, activeTarget };
const auto current = std::find_if(stops.begin(), stops.end(),
[&](const FocusTargetSnapshot& item) { return item.id == activeTarget; });
FocusTargetId target;
if (current == stops.end())
{
target = reverse ? stops.back().id : stops.front().id;
}
else if (reverse)
{
target = current == stops.begin() ? stops.back().id : (current - 1)->id;
}
else
{
const auto next = current + 1;
target = next == stops.end() ? stops.front().id : next->id;
}
return requestFocus(target, FocusReason::TabTraversal);
}
FocusTargetId FocusManager::findInvalidationFallback(FocusTargetId invalidTarget) const
{
std::vector<FocusTargetSnapshot> all;
+238
View File
@@ -0,0 +1,238 @@
#include "internal/SxKeyboardRouterInternal.h"
#include "FocusManager.h"
#include "Window.h"
#include <algorithm>
#include <array>
#include <variant>
namespace
{
constexpr std::size_t MaxKeyboardRouteDepth = 256;
using RoutePath = std::array<StellarX::FocusTargetId, MaxKeyboardRouteDepth>;
class ManagedKeyboardDispatchGuard
{
Window& window;
bool& managedDispatchActive;
bool previousManagedDispatchActive = false;
public:
ManagedKeyboardDispatchGuard(Window& owner, bool& dispatchFlag)
: window(owner), managedDispatchActive(dispatchFlag),
previousManagedDispatchActive(dispatchFlag)
{
window.beginMutationScope();
managedDispatchActive = true;
}
~ManagedKeyboardDispatchGuard()
{
// Restore the outer state before leaving the mutation scope. Only the
// true outermost event may commit pending mutations.
managedDispatchActive = previousManagedDispatchActive;
window.endMutationScope();
}
};
}
namespace StellarX::Internal
{
bool KeyboardRouter::buildOriginalPath(FocusManager& focus,
FocusTargetId originalTarget, FocusTargetId* path,
std::size_t capacity, std::size_t& pathLength)
{
pathLength = 0;
FocusTargetId cursor = originalTarget;
while (cursor && pathLength < capacity)
{
if (std::find(path, path + pathLength, cursor) != path + pathLength)
return false;
FocusTargetSnapshot snapshot;
if (!focus.querySnapshot(cursor, snapshot) ||
focus.isUnderInvalidatedSubtree(cursor))
return false;
path[pathLength++] = cursor;
cursor = snapshot.logicalParent;
}
return !cursor;
}
KeyboardRouteDispatchResult KeyboardRouter::suspendFocusedPath(::Window& window)
{
KeyboardRouteDispatchResult outcome;
ManagedKeyboardDispatchGuard dispatchGuard(window, window.managedDispatchActive);
FocusManager& focus = window.getFocusManager();
const FocusTargetId originalTarget = focus.focusedTarget();
outcome.originalTarget = originalTarget;
outcome.hadFocusedTarget = static_cast<bool>(originalTarget);
if (!originalTarget)
{
outcome.pathComplete = true;
return outcome;
}
RoutePath path{};
std::size_t pathLength = 0;
if (!buildOriginalPath(focus, originalTarget, path.data(), path.size(), pathLength))
return outcome;
outcome.pathComplete = true;
outcome.pathLength = pathLength;
for (std::size_t index = 0; index < pathLength; ++index)
{
const FocusTargetId target = path[index];
FocusTargetSnapshot freshSnapshot;
if (!focus.querySnapshot(target, freshSnapshot) ||
focus.isUnderInvalidatedSubtree(target))
{
++outcome.skippedTargets;
continue;
}
if (!window.dispatchKeyboardSuspensionToTarget(target))
{
++outcome.skippedTargets;
continue;
}
++outcome.deliveredTargets;
outcome.focusedPathSuspended = true;
}
return outcome;
}
KeyboardRouteDispatchResult KeyboardRouter::route(
::Window& window, const NativeInputEvent& nativeEvent,
const KeyboardRoutingEnvironment& environment)
{
if (const auto* activation =
std::get_if<WindowActivationEvent>(&nativeEvent.payload))
{
KeyboardRouteDispatchResult outcome;
outcome.activationEvent = true;
if (!activation->keyboardFocusActive)
{
outcome = suspendFocusedPath(window);
outcome.activationEvent = true;
}
else
{
FocusManager& focus = window.getFocusManager();
const FocusTargetId focused = focus.focusedTarget();
outcome.originalTarget = focused;
outcome.hadFocusedTarget = static_cast<bool>(focused);
if (focused)
focus.notifySubtreeStateChanged(focused.owner);
}
return outcome;
}
KeyboardRouteDispatchResult outcome;
const auto* key = std::get_if<KeyEvent>(&nativeEvent.payload);
if (!key)
return outcome;
outcome.keyEvent = true;
outcome.suppressedByModal = environment.modalBusinessSuppressed;
outcome.suppressedByInactiveWindow =
!nativeEvent.activationAtCapture.keyboardFocusActive ||
!environment.currentActivation.keyboardFocusActive;
outcome.suppressedByStaleActivation =
nativeEvent.activationAtCapture.generation == 0 ||
environment.currentActivation.generation == 0 ||
nativeEvent.activationAtCapture.generation !=
environment.currentActivation.generation;
outcome.suppressedByIme =
key->isImeOwnedAtCapture() || key->imeComposingAtCapture;
outcome.suppressedBySystemKey =
key->systemKey || key->altContext || key->modifiers.alt();
if (outcome.suppressedByModal || outcome.suppressedByInactiveWindow ||
outcome.suppressedByStaleActivation || outcome.suppressedByIme ||
outcome.suppressedBySystemKey)
return outcome;
const bool isTabTraversal = key->action == KeyAction::Down &&
key->virtualKey == VK_TAB && !key->modifiers.control();
ManagedKeyboardDispatchGuard dispatchGuard(window, window.managedDispatchActive);
FocusManager& focus = window.getFocusManager();
const FocusTargetId originalTarget = focus.focusedTarget();
outcome.originalTarget = originalTarget;
outcome.hadFocusedTarget = static_cast<bool>(originalTarget);
if (!originalTarget)
{
outcome.pathComplete = true;
if (isTabTraversal)
{
outcome.tabTraversalAttempted = true;
const FocusChangeResult focusResult =
focus.advanceTabFocus(key->modifiers.shift());
outcome.tabFocusChanged = focusResult.changed;
outcome.tabFocusedTarget = focusResult.focused;
}
return outcome;
}
// The path stores stable identities only. Focus/tree changes made by a
// callback affect the next event, not this event's original path.
RoutePath path{};
std::size_t pathLength = 0;
if (!buildOriginalPath(focus, originalTarget, path.data(), path.size(), pathLength))
return outcome;
outcome.pathComplete = true;
outcome.pathLength = pathLength;
RoutedKeyEvent event;
event.sequence = nativeEvent.sequence;
event.messageTime = nativeEvent.messageTime;
event.hwnd = nativeEvent.hwnd;
event.key = *key;
event.originalTarget = originalTarget;
for (std::size_t index = 0; index < pathLength; ++index)
{
const FocusTargetId target = path[index];
// Earlier callbacks may remove later nodes. Resolve every identity again
// and reject pending-removal subtrees; no borrowed pointer survives.
FocusTargetSnapshot freshSnapshot;
if (!focus.querySnapshot(target, freshSnapshot) ||
focus.isUnderInvalidatedSubtree(target))
{
++outcome.skippedTargets;
continue;
}
KeyRouteResult result;
if (!window.dispatchKeyEventToTarget(target, event, result))
{
++outcome.skippedTargets;
continue;
}
++outcome.deliveredTargets;
outcome.frameworkDefaultPrevented =
outcome.frameworkDefaultPrevented || result.preventFrameworkDefault;
if (result.stopBubble)
{
outcome.bubbleStopped = true;
break;
}
}
// Tab is a framework default, not a leaf-control command. It runs only
// after the original focus path has completed, so a control or container
// may independently stop bubbling and/or suppress traversal. The focus
// manager pulls a fresh tree here; no pre-callback snapshot is reused.
if (isTabTraversal && !outcome.frameworkDefaultPrevented)
{
outcome.tabTraversalAttempted = true;
const FocusChangeResult focusResult =
focus.advanceTabFocus(key->modifiers.shift());
outcome.tabFocusChanged = focusResult.changed;
outcome.tabFocusedTarget = focusResult.focused;
}
return outcome;
}
}
+106 -24
View File
@@ -18,6 +18,40 @@ namespace StellarX::Internal
namespace
{
std::atomic<std::uint64_t> nextNativeInputSequence{ 0 };
constexpr std::uint64_t ActivationFlagMask = 1;
constexpr std::uint64_t MaximumActivationGeneration =
(std::numeric_limits<std::uint64_t>::max)() >> 1;
std::uint64_t packActivationSnapshot(
const NativeInputActivationSnapshot& snapshot) noexcept
{
return (snapshot.generation << 1) |
(snapshot.keyboardFocusActive ? ActivationFlagMask : 0);
}
NativeInputActivationSnapshot unpackActivationSnapshot(
std::uint64_t packed) noexcept
{
NativeInputActivationSnapshot snapshot;
snapshot.keyboardFocusActive = (packed & ActivationFlagMask) != 0;
snapshot.generation = packed >> 1;
return snapshot;
}
std::uint64_t initialActivationState(HWND window) noexcept
{
bool active = false;
if (window && IsWindow(window) != FALSE)
{
DWORD processId = 0;
const DWORD threadId = GetWindowThreadProcessId(window, &processId);
GUITHREADINFO info{};
info.cbSize = sizeof(info);
active = threadId != 0 && GetGUIThreadInfo(threadId, &info) != FALSE &&
info.hwndFocus == window;
}
return packActivationSnapshot({ active, 1 });
}
static_assert(std::is_nothrow_move_constructible_v<TextInputEvent>,
"TextInputEvent must transfer its wstring without allocating during queue publication.");
@@ -252,7 +286,8 @@ namespace StellarX::Internal
};
explicit Impl(HWND window)
: hwnd(window), windowUnicode(window && IsWindowUnicode(window) != FALSE)
: hwnd(window), windowUnicode(window && IsWindowUnicode(window) != FALSE),
activationState(initialActivationState(window))
{
DWORD processId = 0;
windowThreadId = window ? GetWindowThreadProcessId(window, &processId) : GetCurrentThreadId();
@@ -274,6 +309,7 @@ namespace StellarX::Internal
std::uint64_t nextCompositionId = 0;
std::uint64_t activeCompositionId = 0;
EchoState echo;
std::atomic<std::uint64_t> activationState;
NativeInputQueue queue;
NativeInputObservationStats observations;
@@ -318,6 +354,30 @@ namespace StellarX::Internal
echo = {};
}
NativeInputActivationSnapshot activationSnapshot() const noexcept
{
return unpackActivationSnapshot(
activationState.load(std::memory_order_acquire));
}
NativeInputActivationSnapshot publishActivation(bool active) noexcept
{
std::uint64_t current = activationState.load(std::memory_order_acquire);
for (;;)
{
const NativeInputActivationSnapshot old = unpackActivationSnapshot(current);
if (old.keyboardFocusActive == active)
return old;
const std::uint64_t nextGeneration =
old.generation >= MaximumActivationGeneration ? 1 : old.generation + 1;
const NativeInputActivationSnapshot next{ active, nextGeneration };
const std::uint64_t desired = packActivationSnapshot(next);
if (activationState.compare_exchange_weak(current, desired,
std::memory_order_acq_rel, std::memory_order_acquire))
return next;
}
}
void snapshotKeyboardLayout(HKL layout) noexcept
{
const HKL actual = layout ? layout : GetKeyboardLayout(0);
@@ -352,6 +412,7 @@ namespace StellarX::Internal
event.sequence = nextNativeInputSequence.fetch_add(1, std::memory_order_relaxed) + 1;
event.messageTime = static_cast<DWORD>(GetMessageTime());
event.hwnd = window;
event.activationAtCapture = activationSnapshot();
event.payload = std::forward<Payload>(payload);
const std::uint64_t sequence = event.sequence;
const NativeInputQueuePushResult result = queue.push(std::move(event));
@@ -806,6 +867,7 @@ namespace StellarX::Internal
break;
case WM_SETFOCUS:
publishActivation(true);
clearTextDecoderState();
clearEcho();
snapshotKeyboardLayout(nullptr);
@@ -818,6 +880,7 @@ namespace StellarX::Internal
break;
case WM_KILLFOCUS:
publishActivation(false);
clearTextDecoderState();
clearEcho();
{
@@ -870,6 +933,42 @@ namespace StellarX::Internal
return {};
}
NativeInputQueuePopResult NativeInputContext::tryPopObserved(
NativeInputEvent& event) noexcept
{
if (!impl_)
return NativeInputQueuePopResult::Empty;
const NativeInputQueuePopResult result = impl_->queue.tryPop(event);
if (result == NativeInputQueuePopResult::UnexpectedFailure)
{
impl_->publishFault(NativeInputFaultCode::QueueFailure, 0, 0, impl_->hwnd);
return result;
}
if (result == NativeInputQueuePopResult::Empty)
return result;
if (impl_->observations.lastSequence != 0 &&
event.sequence <= impl_->observations.lastSequence)
{
impl_->observations.sequenceStrict = false;
impl_->publishFault(NativeInputFaultCode::SequenceViolation,
event.sequence, 0, event.hwnd);
return NativeInputQueuePopResult::UnexpectedFailure;
}
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;
return NativeInputQueuePopResult::Popped;
}
std::size_t NativeInputContext::drainObserved(std::size_t limit) noexcept
{
if (!impl_ || limit == 0)
@@ -879,33 +978,11 @@ namespace StellarX::Internal
while (drained < limit)
{
NativeInputEvent event;
const NativeInputQueuePopResult result = impl_->queue.tryPop(event);
const NativeInputQueuePopResult result = tryPopObserved(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;
@@ -933,6 +1010,11 @@ namespace StellarX::Internal
return impl_ ? impl_->observations : NativeInputObservationStats{};
}
NativeInputActivationSnapshot NativeInputContext::activationSnapshot() const noexcept
{
return impl_ ? impl_->activationSnapshot() : NativeInputActivationSnapshot{};
}
NativeInputNormalizationStats NativeInputContext::normalizationStats() const noexcept
{
NativeInputNormalizationStats snapshot;
+26
View File
@@ -388,6 +388,32 @@ bool TabControl::applyFocusState(const StellarX::FocusTargetId& target, bool foc
return false;
}
bool TabControl::dispatchKeyEventToTarget(const StellarX::FocusTargetId& target,
StellarX::RoutedKeyEvent& event, StellarX::KeyRouteResult& result)
{
if (Canvas::dispatchKeyEventToTarget(target, event, result))
return true;
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
{
if (it->first && it->first->dispatchKeyEventToTarget(target, event, result)) return true;
if (it->second && it->second->dispatchKeyEventToTarget(target, event, result)) return true;
}
return false;
}
bool TabControl::dispatchKeyboardSuspensionToTarget(
const StellarX::FocusTargetId& target)
{
if (Canvas::dispatchKeyboardSuspensionToTarget(target))
return true;
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
{
if (it->first && it->first->dispatchKeyboardSuspensionToTarget(target)) return true;
if (it->second && it->second->dispatchKeyboardSuspensionToTarget(target)) return true;
}
return false;
}
size_t TabControl::addTab(std::unique_ptr<Button> button, std::unique_ptr<Canvas> page)
{
if (!button || !page)
+36
View File
@@ -700,8 +700,44 @@ bool Table::applyFocusState(const StellarX::FocusTargetId& target, bool focused,
if (Control::applyFocusState(target, focused, reason))
return true;
if (prevButton && prevButton->applyFocusState(target, focused, reason))
{
// 分页 Button 是 Table 的内部绘制单元,不能独立提交到屏幕。
// 焦点框出现或消失时必须把变化提升为整张 Table 重绘;否则只有
// Button 自己变脏,外层 Canvas 看不到 Table 的脏子树,焦点视觉会
// 一直等到下一次 hover/翻页等事件才刷新。
dirty = true;
requestRepaint(parent);
return true;
}
if (nextButton && nextButton->applyFocusState(target, focused, reason))
{
dirty = true;
requestRepaint(parent);
return true;
}
return false;
}
bool Table::dispatchKeyEventToTarget(const StellarX::FocusTargetId& target,
StellarX::RoutedKeyEvent& event, StellarX::KeyRouteResult& result)
{
if (Control::dispatchKeyEventToTarget(target, event, result))
return true;
if (prevButton && prevButton->dispatchKeyEventToTarget(target, event, result))
return true;
if (nextButton && nextButton->dispatchKeyEventToTarget(target, event, result))
return true;
return false;
}
bool Table::dispatchKeyboardSuspensionToTarget(
const StellarX::FocusTargetId& target)
{
if (Control::dispatchKeyboardSuspensionToTarget(target))
return true;
if (prevButton && prevButton->dispatchKeyboardSuspensionToTarget(target))
return true;
if (nextButton && nextButton->dispatchKeyboardSuspensionToTarget(target))
return true;
return false;
}
+139 -4
View File
@@ -4,6 +4,7 @@
#include "SxEventLoopInternal.h"
#include "SxInputProbeInternal.h"
#include "SxNativeInputInternal.h"
#include "SxKeyboardRouterInternal.h"
#include <easyx.h>
#include <algorithm>
#include <cstdint>
@@ -47,6 +48,55 @@ static RECT SxUnionRect(const RECT& a, const RECT& b)
return out;
}
namespace StellarX::Internal
{
bool configureKeyboardPipelineForTesting(
::Window& window, KeyboardPipelineMode mode) noexcept
{
if (window.keyboardPipelineLocked)
return false;
window.routedKeyboardPipeline.store(
mode == KeyboardPipelineMode::Routed, std::memory_order_release);
return true;
}
KeyboardPipelineMode keyboardPipelineMode(
const ::Window& window) noexcept
{
return window.routedKeyboardPipeline.load(std::memory_order_acquire)
? KeyboardPipelineMode::Routed
: KeyboardPipelineMode::LegacyCompatibility;
}
void lockKeyboardPipeline(::Window& window) noexcept
{
window.keyboardPipelineLocked = true;
}
bool shouldDeliverLegacyEasyXMessage(
const ::Window& window, UINT message) noexcept
{
return keyboardPipelineMode(window) == KeyboardPipelineMode::LegacyCompatibility ||
!isEasyXKeyboardMessage(message);
}
bool shouldAddTabDialogCode(const ::Window& window,
WPARAM wParam, LPARAM lParam) noexcept
{
if (keyboardPipelineMode(window) != KeyboardPipelineMode::Routed ||
wParam != VK_TAB)
return false;
// A null MSG still carries an explicit virtual-key query in wParam. When
// the dialog manager supplies MSG*, require the normal KeyDown form so an
// Alt+Tab/SystemKey query is never claimed by the framework.
if (lParam == 0)
return true;
const MSG* current = reinterpret_cast<const MSG*>(lParam);
return current->message == WM_KEYDOWN && current->wParam == VK_TAB;
}
}
static void collectManagedControlOverlays(const std::vector<std::unique_ptr<Control>>& controls,
Control* repaintRoot, const RECT& coverage, std::vector<Control*>& overlays);
@@ -680,13 +730,40 @@ bool Window::hasLiveNativeWindow() const noexcept
return hWnd != nullptr && IsWindow(hWnd) != FALSE;
}
bool Window::drainNativeInputAtSafePoint()
bool Window::drainNativeInputAtSafePoint(bool modalBusinessSuppressed)
{
if (!nativeInputContext)
return true;
// 逐条移动并观测,既不预取整批事件,也不进入焦点或控件路由。
nativeInputContext->drainObserved(StellarX::Internal::MessageBatchLimit);
if (StellarX::Internal::keyboardPipelineMode(*this) ==
StellarX::Internal::KeyboardPipelineMode::LegacyCompatibility)
{
// 兼容模式只观测排空;旧 EX_KEY 仍是唯一业务路径。
nativeInputContext->drainObserved(StellarX::Internal::MessageBatchLimit);
}
else
{
// Routed 是切片 6 后的生产路径。一次弹出一条,释放队列锁后再进入
// 焦点/控件回调,禁止把整个批次预取到局部容器。
std::size_t drained = 0;
while (drained < StellarX::Internal::MessageBatchLimit)
{
StellarX::NativeInputEvent event;
const auto popResult = nativeInputContext->tryPopObserved(event);
if (popResult == StellarX::Internal::NativeInputQueuePopResult::Empty)
break;
if (popResult == StellarX::Internal::NativeInputQueuePopResult::UnexpectedFailure)
break;
StellarX::Internal::KeyboardRoutingEnvironment environment;
// This acquire load is the routing gate's linearization point. A later
// WndProc focus transition may affect the next event without sharing a
// blocking lock with control callbacks.
environment.currentActivation = nativeInputContext->activationSnapshot();
environment.modalBusinessSuppressed = modalBusinessSuppressed;
StellarX::Internal::KeyboardRouter::route(*this, event, environment);
++drained;
}
}
const auto normalization = nativeInputContext->normalizationStats();
if (normalization.rejectedTextSequences != nativeInputRejectedTextReported ||
normalization.imeQueryFailures != nativeInputImeQueryFailuresReported ||
@@ -727,6 +804,10 @@ StellarX::Internal::inspectWindowNativeInputForTesting(const ::Window& window) n
snapshot.queue = window.nativeInputContext->queueStats();
snapshot.observations = window.nativeInputContext->observationStats();
snapshot.normalization = window.nativeInputContext->normalizationStats();
snapshot.activation = window.nativeInputContext->activationSnapshot();
snapshot.easyXKeyboardMessagesDrained = window.easyXKeyboardMessagesDrained;
snapshot.legacyEasyXKeyboardBusinessDeliveries =
window.legacyEasyXKeyboardBusinessDeliveries;
snapshot.faulted = window.nativeInputContext->hasFault();
snapshot.fault = window.nativeInputContext->fault();
return snapshot;
@@ -783,6 +864,19 @@ LRESULT CALLBACK Window::WndProcThunk(HWND h, UINT m, WPARAM w, LPARAM l)
return StellarX::Internal::inputProbeWndProcLeave(
probeCall, nativeInputResult.result, false);
}
if (m == WM_GETDLGCODE)
{
// The old EasyX procedure owns the baseline answer. StellarX only adds
// the one capability already implemented by the production router.
const bool forwardedToOldProc = self->oldWndProc != nullptr;
LRESULT result = forwardedToOldProc
? CallWindowProc(self->oldWndProc, h, m, w, l)
: DefWindowProc(h, m, w, l);
if (StellarX::Internal::shouldAddTabDialogCode(*self, w, l))
result |= DLGC_WANTTAB;
return StellarX::Internal::inputProbeWndProcLeave(
probeCall, result, forwardedToOldProc);
}
if (m == WM_CLOSE)
{
// EasyX 的旧窗口过程可能在 WM_CLOSE 内直接终止进程,不保证返回到 leave、
@@ -999,6 +1093,7 @@ void Window::draw(std::string imagePath)
int Window::runEventLoop()
{
ExMessage msg;
StellarX::Internal::lockKeyboardPipeline(*this);
// 说明:统一使用 needResizeDirty 作为“收口重绘”的唯一标志位
// 不再引入额外 pendingResize 等状态,避免分叉导致状态不一致。
@@ -1023,7 +1118,11 @@ int Window::runEventLoop()
const auto batch = StellarX::Internal::drainMessageBatch(msg,
[](ExMessage& next) { return peekmessage(&next, EX_MOUSE | EX_KEY | EX_WINDOW, true); },
[&](const ExMessage& next) {
StellarX::Internal::inputProbeObserveEasyXMessage(hWnd, probeBatch, next);
StellarX::Internal::inputProbeObserveEasyXMessage(hWnd, probeBatch, next);
const bool easyXKeyboardMessage =
StellarX::Internal::isEasyXKeyboardMessage(next.message);
if (easyXKeyboardMessage)
++easyXKeyboardMessagesDrained;
bool consume = false; // 每条消息独立判断,不能把上一条消息的消费状态带入下一条。
if (msg.message == WM_CLOSE)
return false;
@@ -1063,6 +1162,13 @@ int Window::runEventLoop()
return true;
}
// Routed is the Stage 3 production path. EasyX keyboard messages are still
// removed from its internal queue above, but never enter handleEvent().
if (!StellarX::Internal::shouldDeliverLegacyEasyXMessage(*this, msg.message))
return true;
if (easyXKeyboardMessage)
++legacyEasyXKeyboardBusinessDeliveries;
// 输入优先:先给顶层“非模态对话框”,再传给普通控件。
// 在 managedDispatchActive 期间,控件只改状态并登记重绘 root,不直接画。
managedDispatchActive = true;
@@ -1132,6 +1238,15 @@ int Window::runEventLoop()
<< SxMsgName(msg.message)
<< SX_T(" id=", " id=") << firstConsumer->getId();
}
// 没有 Dialog、Canvas 或叶子控件拥有这次左键按下,说明用户点击了
// Window 背景。背景点击清除逻辑焦点;Canvas 自身空白区域在
// Canvas::handleEvent 中完成同一语义并消费消息。
if (!consume && msg.message == WM_LBUTTONDOWN)
{
focusManager->clearFocus(StellarX::FocusReason::Pointer);
consume = true;
}
managedDispatchActive = false;
flushPendingMutationsInTree();
return true;
@@ -1471,6 +1586,26 @@ bool Window::applyFocusState(const StellarX::FocusTargetId& target, bool focused
return false;
}
bool Window::dispatchKeyEventToTarget(const StellarX::FocusTargetId& target,
StellarX::RoutedKeyEvent& event, StellarX::KeyRouteResult& result)
{
for (auto it = dialogs.rbegin(); it != dialogs.rend(); ++it)
if (*it && (*it)->dispatchKeyEventToTarget(target, event, result)) return true;
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
if (*it && (*it)->dispatchKeyEventToTarget(target, event, result)) return true;
return false;
}
bool Window::dispatchKeyboardSuspensionToTarget(
const StellarX::FocusTargetId& target)
{
for (auto it = dialogs.rbegin(); it != dialogs.rend(); ++it)
if (*it && (*it)->dispatchKeyboardSuspensionToTarget(target)) return true;
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
if (*it && (*it)->dispatchKeyboardSuspensionToTarget(target)) return true;
return false;
}
void Window::addDialog(std::unique_ptr<Control> dlg)
{
// 新增非模态对话框:管理顺序决定事件优先级(顶层从后往前)
@@ -1,11 +1,58 @@
#pragma once
#include <windows.h>
#include <cstddef>
#include <cstdint>
class Window;
// 窗口实现和测试共用的内部事件循环工具。
// 本文件不属于 StellarX 公共 API。
namespace StellarX::Internal
{
// Stage 3 slice 6 makes Routed the production default. Tests may still opt a
// Window into LegacyCompatibility before its first event loop starts for
// controlled A/B coverage; once locked, the two business paths cannot overlap.
enum class KeyboardPipelineMode : std::uint8_t
{
LegacyCompatibility,
Routed
};
bool configureKeyboardPipelineForTesting(
::Window& window, KeyboardPipelineMode mode) noexcept;
KeyboardPipelineMode keyboardPipelineMode(
const ::Window& window) noexcept;
void lockKeyboardPipeline(::Window& window) noexcept;
bool shouldDeliverLegacyEasyXMessage(
const ::Window& window, UINT message) noexcept;
// WM_GETDLGCODE is synchronous. Only an explicit normal Tab query in Routed
// mode may add DLGC_WANTTAB; no other dialog-code capability is inferred.
bool shouldAddTabDialogCode(const ::Window& window,
WPARAM wParam, LPARAM lParam) noexcept;
inline bool isEasyXKeyboardMessage(UINT message) noexcept
{
constexpr UINT WmUniChar = 0x0109u;
switch (message)
{
case WM_KEYDOWN:
case WM_KEYUP:
case WM_SYSKEYDOWN:
case WM_SYSKEYUP:
case WM_CHAR:
case WM_SYSCHAR:
case WM_DEADCHAR:
case WM_SYSDEADCHAR:
case WM_IME_CHAR:
case WmUniChar:
return true;
default:
return false;
}
}
// 限制单轮处理量,避免持续的消息流长期占用循环而导致重绘得不到执行。
constexpr std::size_t MessageBatchLimit = 128;
@@ -0,0 +1,58 @@
#pragma once
#include "KeyboardRouting.h"
#include <cstddef>
class Window;
namespace StellarX { class FocusManager; }
namespace StellarX::Internal
{
struct KeyboardRoutingEnvironment
{
NativeInputActivationSnapshot currentActivation{};
bool modalBusinessSuppressed = false;
};
// Observable result for one native event. Slice 3 applies ownership and
// activation gates; slice 4 adds the test-gated Tab framework default.
struct KeyboardRouteDispatchResult
{
bool keyEvent = false;
bool activationEvent = false;
bool hadFocusedTarget = false;
bool pathComplete = false;
bool bubbleStopped = false;
bool frameworkDefaultPrevented = false;
bool focusedPathSuspended = false;
bool suppressedByInactiveWindow = false;
bool suppressedByStaleActivation = false;
bool suppressedByIme = false;
bool suppressedBySystemKey = false;
bool suppressedByModal = false;
bool tabTraversalAttempted = false;
bool tabFocusChanged = false;
std::size_t pathLength = 0;
std::size_t deliveredTargets = 0;
std::size_t skippedTargets = 0;
FocusTargetId originalTarget{};
FocusTargetId tabFocusedTarget{};
};
class KeyboardRouter
{
static bool buildOriginalPath(FocusManager& focus,
FocusTargetId originalTarget, FocusTargetId* path,
std::size_t capacity, std::size_t& pathLength);
public:
// Activation events maintain transient state. Eligible KeyEvents bubble
// through one nestable managed-dispatch and mutation transaction.
static KeyboardRouteDispatchResult route(
::Window& window, const NativeInputEvent& nativeEvent,
const KeyboardRoutingEnvironment& environment);
// Modal entry cancels an already-armed underlying focus path even when no
// activation message is generated. Logical focus is retained.
static KeyboardRouteDispatchResult suspendFocusedPath(::Window& window);
};
}
@@ -102,7 +102,10 @@ namespace StellarX::Internal
NativeInputQueueStats queue{};
NativeInputObservationStats observations{};
NativeInputNormalizationStats normalization{};
NativeInputActivationSnapshot activation{};
NativeInputFaultSnapshot fault{};
std::uint64_t easyXKeyboardMessagesDrained = 0;
std::uint64_t legacyEasyXKeyboardBusinessDeliveries = 0;
bool faulted = false;
};
@@ -135,6 +138,8 @@ namespace StellarX::Internal
NativeInputContext& operator=(const NativeInputContext&) = delete;
NativeInputCollectResult collect(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) noexcept;
// Pop one event and update the stage-1 ordering/type observations.
NativeInputQueuePopResult tryPopObserved(NativeInputEvent& event) noexcept;
std::size_t drainObserved(std::size_t limit) noexcept;
bool hasFault() const noexcept;
@@ -142,6 +147,7 @@ namespace StellarX::Internal
NativeInputQueueStats queueStats() const noexcept;
NativeInputObservationStats observationStats() const noexcept;
NativeInputNormalizationStats normalizationStats() const noexcept;
NativeInputActivationSnapshot activationSnapshot() const noexcept;
// Test-only observation seams; none participates in production routing.
bool tryPopForTesting(NativeInputEvent& event) noexcept;
+931
View File
@@ -1,6 +1,7 @@
#include "CppUnitTest.h"
#include "StellarX.h"
#include "SxEventLoopInternal.h"
#include "SxKeyboardRouterInternal.h"
#include "SxNativeInputInternal.h"
#include "SxTextInternal.h"
@@ -57,6 +58,83 @@ namespace
}
};
class KeyRouteTestControl final : public Control
{
std::function<StellarX::KeyRouteResult(StellarX::RoutedKeyEvent&)> routeCallback;
public:
StellarX::FocusTargetId seenTarget{};
int keyboardSuspensions = 0;
KeyRouteTestControl()
{
id = "KeyRouteTest";
focusPolicy = StellarX::FocusPolicy::Programmatic |
StellarX::FocusPolicy::Pointer | StellarX::FocusPolicy::Tab;
}
void draw() override { dirty = false; }
bool handleEvent(const ExMessage&) override { return false; }
bool model() const override { return false; }
void setRouteCallback(
std::function<StellarX::KeyRouteResult(StellarX::RoutedKeyEvent&)> callback)
{
routeCallback = std::move(callback);
}
StellarX::KeyRouteResult handleKeyEvent(StellarX::RoutedKeyEvent& event) override
{
seenTarget = event.currentTarget;
return routeCallback ? routeCallback(event) : StellarX::KeyRouteResult{ true, true };
}
protected:
void onKeyboardRoutingSuspended() override { ++keyboardSuspensions; }
};
class KeyRouteTestCanvas final : public Canvas
{
std::function<StellarX::KeyRouteResult(StellarX::RoutedKeyEvent&)> routeCallback;
public:
int keyboardSuspensions = 0;
KeyRouteTestCanvas() : Canvas(0, 0, 100, 100) {}
void setRouteCallback(
std::function<StellarX::KeyRouteResult(StellarX::RoutedKeyEvent&)> callback)
{
routeCallback = std::move(callback);
}
StellarX::KeyRouteResult handleKeyEvent(StellarX::RoutedKeyEvent& event) override
{
return routeCallback ? routeCallback(event) : StellarX::KeyRouteResult{};
}
protected:
void onKeyboardRoutingSuspended() override { ++keyboardSuspensions; }
};
StellarX::NativeInputEvent MakeKeyInput(std::uint64_t sequence, UINT virtualKey = VK_SPACE)
{
StellarX::NativeInputEvent native;
native.sequence = sequence;
native.messageTime = static_cast<DWORD>(sequence * 10);
native.activationAtCapture = { true, 1 };
StellarX::KeyEvent key;
key.action = StellarX::KeyAction::Down;
key.virtualKey = virtualKey;
native.payload = key;
return native;
}
StellarX::Internal::KeyboardRoutingEnvironment ActiveKeyboardEnvironment(
std::uint64_t generation = 1)
{
StellarX::Internal::KeyboardRoutingEnvironment environment;
environment.currentActivation = { true, generation };
return environment;
}
StellarX::Internal::KeyboardRouteDispatchResult RouteKeyboard(
Window& window, const StellarX::NativeInputEvent& event,
const StellarX::Internal::KeyboardRoutingEnvironment& environment =
ActiveKeyboardEnvironment())
{
return StellarX::Internal::KeyboardRouter::route(window, event, environment);
}
std::filesystem::path MakeTempLogPath(const char* name)
{
// 使用进程号隔离并行测试产生的日志路径;测试结束后由用例主动清理。
@@ -246,6 +324,28 @@ namespace StellarXNativeTests
Assert::IsTrue(textRaw->hasLogicalFocus());
}
TEST_METHOD(CanvasBackgroundPointerDownClearsFocusAndConsumes)
{
Window window(240, 160, 1);
auto canvas = std::make_unique<Canvas>(20, 20, 160, 120);
Canvas* canvasRaw = canvas.get();
auto button = std::make_unique<Button>(10, 10, 80, 30, "button");
Button* buttonRaw = button.get();
const auto buttonHandle = canvas->addControl(std::move(button));
window.addControl(std::move(canvas));
window.getFocusManager().requestFocus({ buttonHandle, 0 });
Assert::IsTrue(buttonRaw->hasLogicalFocus());
ExMessage down{};
down.message = WM_LBUTTONDOWN;
down.x = 160;
down.y = 120;
Assert::IsTrue(canvasRaw->handleEvent(down));
Assert::IsFalse(buttonRaw->hasLogicalFocus());
Assert::IsFalse(static_cast<bool>(window.getFocusManager().focusedTarget()));
}
TEST_METHOD(FocusLostCallbackCanDeleteNewTarget)
{
Window window(200, 120, 1);
@@ -693,6 +793,790 @@ namespace StellarXNativeTests
}
};
// These contract tests cover routing types, the default Control seam, the
// production Routed default, and the startup-only Legacy A/B gate.
TEST_CLASS(KeyboardRoutingScaffoldTests)
{
public:
TEST_METHOD(DefaultControlKeyRouteDoesNotConsumeOrPreventDefault)
{
FakeControl control;
StellarX::RoutedKeyEvent event;
event.sequence = 17;
event.key.virtualKey = VK_TAB;
event.originalTarget = { control.getHandle(), 0 };
event.currentTarget = event.originalTarget;
const auto result = control.handleKeyEvent(event);
Assert::IsFalse(result.stopBubble);
Assert::IsFalse(result.preventFrameworkDefault);
Assert::AreEqual<std::uint64_t>(17, event.sequence);
Assert::AreEqual<UINT>(VK_TAB, event.key.virtualKey);
Assert::IsTrue(event.currentTarget == event.originalTarget);
}
TEST_METHOD(PipelineDefaultsToRoutedAndCanOptIntoLegacyBeforeLock)
{
Window window(200, 120, 1);
Assert::IsTrue(
StellarX::Internal::keyboardPipelineMode(window) ==
StellarX::Internal::KeyboardPipelineMode::Routed);
Assert::IsTrue(StellarX::Internal::configureKeyboardPipelineForTesting(
window, StellarX::Internal::KeyboardPipelineMode::LegacyCompatibility));
Assert::IsTrue(
StellarX::Internal::keyboardPipelineMode(window) ==
StellarX::Internal::KeyboardPipelineMode::LegacyCompatibility);
}
TEST_METHOD(PipelineCannotChangeAfterEventLoopLock)
{
Window window(200, 120, 1);
Assert::IsTrue(StellarX::Internal::configureKeyboardPipelineForTesting(
window, StellarX::Internal::KeyboardPipelineMode::LegacyCompatibility));
StellarX::Internal::lockKeyboardPipeline(window);
Assert::IsFalse(StellarX::Internal::configureKeyboardPipelineForTesting(
window, StellarX::Internal::KeyboardPipelineMode::Routed));
Assert::IsTrue(
StellarX::Internal::keyboardPipelineMode(window) ==
StellarX::Internal::KeyboardPipelineMode::LegacyCompatibility);
}
TEST_METHOD(DerivedControlCanReturnIndependentRouteDecisions)
{
KeyRouteTestControl control;
StellarX::RoutedKeyEvent event;
event.currentTarget = { control.getHandle(), 0 };
Control& base = control;
const auto result = base.handleKeyEvent(event);
Assert::IsTrue(result.stopBubble);
Assert::IsTrue(result.preventFrameworkDefault);
Assert::IsTrue(control.seenTarget == event.currentTarget);
}
TEST_METHOD(EasyXKeyboardClassifierDoesNotSuppressMouseOrWindowMessages)
{
Assert::IsTrue(StellarX::Internal::isEasyXKeyboardMessage(WM_KEYDOWN));
Assert::IsTrue(StellarX::Internal::isEasyXKeyboardMessage(WM_CHAR));
Assert::IsFalse(StellarX::Internal::isEasyXKeyboardMessage(WM_MOUSEMOVE));
Assert::IsFalse(StellarX::Internal::isEasyXKeyboardMessage(WM_SIZE));
}
TEST_METHOD(ProductionRoutedModeSuppressesOnlyLegacyKeyboardBusinessDelivery)
{
Window window(200, 120, 1);
Assert::IsFalse(StellarX::Internal::shouldDeliverLegacyEasyXMessage(
window, WM_KEYDOWN));
Assert::IsTrue(StellarX::Internal::shouldDeliverLegacyEasyXMessage(
window, WM_MOUSEMOVE));
Assert::IsTrue(StellarX::Internal::shouldDeliverLegacyEasyXMessage(
window, WM_SIZE));
Assert::IsTrue(StellarX::Internal::configureKeyboardPipelineForTesting(
window, StellarX::Internal::KeyboardPipelineMode::LegacyCompatibility));
Assert::IsTrue(StellarX::Internal::shouldDeliverLegacyEasyXMessage(
window, WM_KEYDOWN));
}
TEST_METHOD(DialogCodeClaimsOnlyExplicitNormalTabInRoutedMode)
{
Window window(200, 120, 1);
Assert::IsTrue(StellarX::Internal::shouldAddTabDialogCode(
window, VK_TAB, 0));
Assert::IsFalse(StellarX::Internal::shouldAddTabDialogCode(
window, VK_RETURN, 0));
MSG tabDown{};
tabDown.message = WM_KEYDOWN;
tabDown.wParam = VK_TAB;
Assert::IsTrue(StellarX::Internal::shouldAddTabDialogCode(
window, VK_TAB, reinterpret_cast<LPARAM>(&tabDown)));
MSG systemTab = tabDown;
systemTab.message = WM_SYSKEYDOWN;
Assert::IsFalse(StellarX::Internal::shouldAddTabDialogCode(
window, VK_TAB, reinterpret_cast<LPARAM>(&systemTab)));
MSG mismatched = tabDown;
mismatched.wParam = VK_RETURN;
Assert::IsFalse(StellarX::Internal::shouldAddTabDialogCode(
window, VK_TAB, reinterpret_cast<LPARAM>(&mismatched)));
Assert::IsTrue(StellarX::Internal::configureKeyboardPipelineForTesting(
window, StellarX::Internal::KeyboardPipelineMode::LegacyCompatibility));
Assert::IsFalse(StellarX::Internal::shouldAddTabDialogCode(
window, VK_TAB, 0));
}
};
TEST_CLASS(KeyboardRouterTests)
{
public:
TEST_METHOD(KeyBubblesFromFocusedLeafAlongOriginalIdentityPath)
{
Window window(240, 160, 1);
std::vector<std::uint64_t> delivered;
auto canvas = std::make_unique<KeyRouteTestCanvas>();
KeyRouteTestCanvas* canvasRaw = canvas.get();
const auto canvasHandle = canvas->getHandle();
auto leaf = std::make_unique<KeyRouteTestControl>();
KeyRouteTestControl* leafRaw = leaf.get();
const auto leafHandle = canvas->addControl(std::move(leaf));
leafRaw->setRouteCallback([&](StellarX::RoutedKeyEvent& event)
{
delivered.push_back(event.currentTarget.owner.value);
Assert::AreEqual(leafHandle.value, event.originalTarget.owner.value);
Assert::AreEqual<std::uint64_t>(101, event.sequence);
Assert::AreEqual<UINT>(VK_RETURN, event.key.virtualKey);
return StellarX::KeyRouteResult{};
});
canvasRaw->setRouteCallback([&](StellarX::RoutedKeyEvent& event)
{
delivered.push_back(event.currentTarget.owner.value);
Assert::AreEqual(leafHandle.value, event.originalTarget.owner.value);
return StellarX::KeyRouteResult{};
});
window.addControl(std::move(canvas));
window.getFocusManager().requestFocus({ leafHandle, 0 });
const auto outcome = RouteKeyboard(
window, MakeKeyInput(101, VK_RETURN));
Assert::IsTrue(outcome.keyEvent);
Assert::IsTrue(outcome.pathComplete);
Assert::AreEqual<size_t>(2, outcome.pathLength);
Assert::AreEqual<size_t>(2, outcome.deliveredTargets);
Assert::AreEqual<size_t>(0, outcome.skippedTargets);
Assert::AreEqual<size_t>(2, delivered.size());
Assert::AreEqual(leafHandle.value, delivered[0]);
Assert::AreEqual(canvasHandle.value, delivered[1]);
Assert::IsTrue(leafRaw->seenTarget == StellarX::FocusTargetId{ leafHandle, 0 });
}
TEST_METHOD(StopBubbleAndPreventDefaultRemainIndependent)
{
Window window(240, 160, 1);
int parentCalls = 0;
auto canvas = std::make_unique<KeyRouteTestCanvas>();
KeyRouteTestCanvas* canvasRaw = canvas.get();
auto leaf = std::make_unique<KeyRouteTestControl>();
KeyRouteTestControl* leafRaw = leaf.get();
const auto leafHandle = canvas->addControl(std::move(leaf));
canvasRaw->setRouteCallback([&](StellarX::RoutedKeyEvent&)
{
++parentCalls;
return StellarX::KeyRouteResult{};
});
window.addControl(std::move(canvas));
window.getFocusManager().requestFocus({ leafHandle, 0 });
leafRaw->setRouteCallback([](StellarX::RoutedKeyEvent&)
{ return StellarX::KeyRouteResult{ true, false }; });
const auto stopped = RouteKeyboard(
window, MakeKeyInput(201));
Assert::IsTrue(stopped.bubbleStopped);
Assert::IsFalse(stopped.frameworkDefaultPrevented);
Assert::AreEqual(0, parentCalls);
leafRaw->setRouteCallback([](StellarX::RoutedKeyEvent&)
{ return StellarX::KeyRouteResult{ false, true }; });
const auto prevented = RouteKeyboard(
window, MakeKeyInput(202));
Assert::IsFalse(prevented.bubbleStopped);
Assert::IsTrue(prevented.frameworkDefaultPrevented);
Assert::AreEqual(1, parentCalls);
}
TEST_METHOD(PendingRemovalSkipsLaterAncestorAndCommitsAfterEvent)
{
Window window(240, 160, 1);
int parentCalls = 0;
auto canvas = std::make_unique<KeyRouteTestCanvas>();
KeyRouteTestCanvas* canvasRaw = canvas.get();
const auto canvasHandle = canvas->getHandle();
auto leaf = std::make_unique<KeyRouteTestControl>();
KeyRouteTestControl* leafRaw = leaf.get();
const auto leafHandle = canvas->addControl(std::move(leaf));
canvasRaw->setRouteCallback([&](StellarX::RoutedKeyEvent&)
{
++parentCalls;
return StellarX::KeyRouteResult{};
});
leafRaw->setRouteCallback([&](StellarX::RoutedKeyEvent&)
{
Assert::IsTrue(window.removeControl(canvasHandle));
Assert::IsNotNull(window.findControl(canvasHandle));
return StellarX::KeyRouteResult{};
});
window.addControl(std::move(canvas));
window.getFocusManager().requestFocus({ leafHandle, 0 });
const auto outcome = RouteKeyboard(
window, MakeKeyInput(301));
Assert::AreEqual<size_t>(1, outcome.deliveredTargets);
Assert::AreEqual<size_t>(1, outcome.skippedTargets);
Assert::AreEqual(0, parentCalls);
Assert::IsNull(window.findControl(canvasHandle));
}
TEST_METHOD(NestedRoutePreservesOuterManagedDispatchAndMutationBoundary)
{
Window window(300, 180, 1);
auto canvas = std::make_unique<KeyRouteTestCanvas>();
auto leaf = std::make_unique<KeyRouteTestControl>();
KeyRouteTestControl* leafRaw = leaf.get();
const auto leafHandle = canvas->addControl(std::move(leaf));
window.addControl(std::move(canvas));
auto sibling = std::make_unique<FakeControl>();
const auto siblingHandle = window.addControl(std::move(sibling));
window.getFocusManager().requestFocus({ leafHandle, 0 });
int callbackDepth = 0;
leafRaw->setRouteCallback([&](StellarX::RoutedKeyEvent& event)
{
Assert::IsTrue(window.isManagedDispatchActive());
++callbackDepth;
if (event.sequence == 401)
{
Assert::IsTrue(window.removeControl(siblingHandle));
Assert::IsNotNull(window.findControl(siblingHandle));
const auto nested = RouteKeyboard(
window, MakeKeyInput(402));
Assert::AreEqual<size_t>(2, nested.deliveredTargets);
Assert::IsTrue(window.isManagedDispatchActive());
Assert::IsNotNull(window.findControl(siblingHandle));
}
return StellarX::KeyRouteResult{};
});
const auto outer = RouteKeyboard(
window, MakeKeyInput(401));
Assert::AreEqual<size_t>(2, outer.deliveredTargets);
Assert::AreEqual(2, callbackDepth);
Assert::IsFalse(window.isManagedDispatchActive());
Assert::IsNull(window.findControl(siblingHandle));
}
TEST_METHOD(FocusChangeAffectsNextEventButNotCurrentOriginalPath)
{
Window window(320, 180, 1);
int oldParentCalls = 0;
int replacementCalls = 0;
auto canvas = std::make_unique<KeyRouteTestCanvas>();
KeyRouteTestCanvas* canvasRaw = canvas.get();
auto leaf = std::make_unique<KeyRouteTestControl>();
KeyRouteTestControl* leafRaw = leaf.get();
const auto leafHandle = canvas->addControl(std::move(leaf));
canvasRaw->setRouteCallback([&](StellarX::RoutedKeyEvent&)
{
++oldParentCalls;
return StellarX::KeyRouteResult{};
});
window.addControl(std::move(canvas));
auto replacement = std::make_unique<KeyRouteTestControl>();
KeyRouteTestControl* replacementRaw = replacement.get();
const auto replacementHandle = window.addControl(std::move(replacement));
replacementRaw->setRouteCallback([&](StellarX::RoutedKeyEvent&)
{
++replacementCalls;
return StellarX::KeyRouteResult{};
});
leafRaw->setRouteCallback([&](StellarX::RoutedKeyEvent&)
{
window.getFocusManager().requestFocus({ replacementHandle, 0 });
return StellarX::KeyRouteResult{};
});
window.getFocusManager().requestFocus({ leafHandle, 0 });
const auto first = RouteKeyboard(
window, MakeKeyInput(501));
Assert::AreEqual<size_t>(2, first.deliveredTargets);
Assert::AreEqual(1, oldParentCalls);
Assert::AreEqual(0, replacementCalls);
const auto second = RouteKeyboard(
window, MakeKeyInput(502));
Assert::AreEqual(replacementHandle.value, second.originalTarget.owner.value);
Assert::AreEqual<size_t>(1, second.deliveredTargets);
Assert::AreEqual(1, replacementCalls);
}
TEST_METHOD(ActivationImeSystemAndModalGatesSuppressBusinessDelivery)
{
Window window(220, 140, 1);
int routeCalls = 0;
auto control = std::make_unique<KeyRouteTestControl>();
KeyRouteTestControl* raw = control.get();
const auto handle = window.addControl(std::move(control));
raw->setRouteCallback([&](StellarX::RoutedKeyEvent&)
{
++routeCalls;
return StellarX::KeyRouteResult{};
});
window.getFocusManager().requestFocus({ handle, 0 });
auto inactive = MakeKeyInput(551);
inactive.activationAtCapture = { false, 2 };
auto inactiveEnvironment = ActiveKeyboardEnvironment(2);
inactiveEnvironment.currentActivation.keyboardFocusActive = false;
const auto inactiveResult = RouteKeyboard(window, inactive, inactiveEnvironment);
Assert::IsTrue(inactiveResult.suppressedByInactiveWindow);
auto stale = MakeKeyInput(552);
stale.activationAtCapture = { true, 2 };
const auto staleResult = RouteKeyboard(
window, stale, ActiveKeyboardEnvironment(4));
Assert::IsTrue(staleResult.suppressedByStaleActivation);
auto processKey = MakeKeyInput(553, VK_PROCESSKEY);
const auto processResult = RouteKeyboard(window, processKey);
Assert::IsTrue(processResult.suppressedByIme);
auto composing = MakeKeyInput(554, 'A');
std::get<StellarX::KeyEvent>(composing.payload).imeComposingAtCapture = true;
const auto composingResult = RouteKeyboard(window, composing);
Assert::IsTrue(composingResult.suppressedByIme);
auto systemKey = MakeKeyInput(555, VK_MENU);
std::get<StellarX::KeyEvent>(systemKey.payload).systemKey = true;
const auto systemResult = RouteKeyboard(window, systemKey);
Assert::IsTrue(systemResult.suppressedBySystemKey);
auto modalEnvironment = ActiveKeyboardEnvironment();
modalEnvironment.modalBusinessSuppressed = true;
const auto modalResult = RouteKeyboard(
window, MakeKeyInput(556), modalEnvironment);
Assert::IsTrue(modalResult.suppressedByModal);
Assert::AreEqual(0, routeCalls);
const auto eligible = RouteKeyboard(window, MakeKeyInput(557));
Assert::AreEqual<size_t>(1, eligible.deliveredTargets);
Assert::AreEqual(1, routeCalls);
}
TEST_METHOD(DeactivationSuspendsFocusPathWithoutClearingLogicalFocus)
{
Window window(240, 160, 1);
auto canvas = std::make_unique<KeyRouteTestCanvas>();
KeyRouteTestCanvas* canvasRaw = canvas.get();
auto leaf = std::make_unique<KeyRouteTestControl>();
KeyRouteTestControl* leafRaw = leaf.get();
const auto leafHandle = canvas->addControl(std::move(leaf));
window.addControl(std::move(canvas));
window.getFocusManager().requestFocus({ leafHandle, 0 });
StellarX::NativeInputEvent activation;
activation.sequence = 561;
activation.activationAtCapture = { false, 2 };
StellarX::WindowActivationEvent payload;
payload.keyboardFocusActive = false;
activation.payload = payload;
auto environment = ActiveKeyboardEnvironment(2);
environment.currentActivation.keyboardFocusActive = false;
const auto outcome = RouteKeyboard(window, activation, environment);
Assert::IsTrue(outcome.activationEvent);
Assert::IsTrue(outcome.pathComplete);
Assert::IsTrue(outcome.focusedPathSuspended);
Assert::AreEqual<size_t>(2, outcome.deliveredTargets);
Assert::AreEqual(1, leafRaw->keyboardSuspensions);
Assert::AreEqual(1, canvasRaw->keyboardSuspensions);
Assert::IsTrue(window.getFocusManager().focusedTarget() ==
StellarX::FocusTargetId{ leafHandle, 0 });
const auto modalEntry =
StellarX::Internal::KeyboardRouter::suspendFocusedPath(window);
Assert::AreEqual<size_t>(2, modalEntry.deliveredTargets);
Assert::AreEqual(2, leafRaw->keyboardSuspensions);
Assert::AreEqual(2, canvasRaw->keyboardSuspensions);
}
TEST_METHOD(TabTraversalUsesCurrentOrderAndWrapsInBothDirections)
{
Window window(240, 160, 1);
auto middle = std::make_unique<FocusTestControl>(20);
const auto middleHandle = window.addControl(std::move(middle));
auto first = std::make_unique<FocusTestControl>(10);
const auto firstHandle = window.addControl(std::move(first));
auto last = std::make_unique<FocusTestControl>(30);
const auto lastHandle = window.addControl(std::move(last));
const auto fromEmpty = RouteKeyboard(window, MakeKeyInput(571, VK_TAB));
Assert::IsTrue(fromEmpty.tabTraversalAttempted);
Assert::IsTrue(fromEmpty.tabFocusChanged);
Assert::IsTrue(fromEmpty.tabFocusedTarget ==
StellarX::FocusTargetId{ firstHandle, 0 });
const auto forward = RouteKeyboard(window, MakeKeyInput(572, VK_TAB));
Assert::IsTrue(forward.tabTraversalAttempted);
Assert::IsTrue(window.getFocusManager().focusedTarget() ==
StellarX::FocusTargetId{ middleHandle, 0 });
auto reverseInput = MakeKeyInput(573, VK_TAB);
std::get<StellarX::KeyEvent>(reverseInput.payload).modifiers.leftShift = true;
const auto reverse = RouteKeyboard(window, reverseInput);
Assert::IsTrue(reverse.tabTraversalAttempted);
Assert::IsTrue(window.getFocusManager().focusedTarget() ==
StellarX::FocusTargetId{ firstHandle, 0 });
const auto wrappedReverse = RouteKeyboard(window, reverseInput);
Assert::IsTrue(wrappedReverse.tabTraversalAttempted);
Assert::IsTrue(window.getFocusManager().focusedTarget() ==
StellarX::FocusTargetId{ lastHandle, 0 });
auto keyUp = MakeKeyInput(574, VK_TAB);
std::get<StellarX::KeyEvent>(keyUp.payload).action = StellarX::KeyAction::Up;
const auto keyUpResult = RouteKeyboard(window, keyUp);
Assert::IsFalse(keyUpResult.tabTraversalAttempted);
auto controlTab = MakeKeyInput(575, VK_TAB);
std::get<StellarX::KeyEvent>(controlTab.payload).modifiers.leftControl = true;
const auto controlTabResult = RouteKeyboard(window, controlTab);
Assert::IsFalse(controlTabResult.tabTraversalAttempted);
Assert::IsTrue(window.getFocusManager().focusedTarget() ==
StellarX::FocusTargetId{ lastHandle, 0 });
}
TEST_METHOD(TabDefaultPreventionIsIndependentFromStoppingBubble)
{
Window window(220, 140, 1);
auto first = std::make_unique<KeyRouteTestControl>();
KeyRouteTestControl* firstRaw = first.get();
const auto firstHandle = window.addControl(std::move(first));
auto second = std::make_unique<FocusTestControl>();
const auto secondHandle = window.addControl(std::move(second));
window.getFocusManager().requestFocus({ firstHandle, 0 });
firstRaw->setRouteCallback([](StellarX::RoutedKeyEvent&)
{ return StellarX::KeyRouteResult{ true, false }; });
const auto stoppedOnly = RouteKeyboard(window, MakeKeyInput(581, VK_TAB));
Assert::IsTrue(stoppedOnly.bubbleStopped);
Assert::IsFalse(stoppedOnly.frameworkDefaultPrevented);
Assert::IsTrue(stoppedOnly.tabTraversalAttempted);
Assert::IsTrue(window.getFocusManager().focusedTarget() ==
StellarX::FocusTargetId{ secondHandle, 0 });
window.getFocusManager().requestFocus({ firstHandle, 0 });
firstRaw->setRouteCallback([](StellarX::RoutedKeyEvent&)
{ return StellarX::KeyRouteResult{ false, true }; });
const auto prevented = RouteKeyboard(window, MakeKeyInput(582, VK_TAB));
Assert::IsFalse(prevented.bubbleStopped);
Assert::IsTrue(prevented.frameworkDefaultPrevented);
Assert::IsFalse(prevented.tabTraversalAttempted);
Assert::IsTrue(window.getFocusManager().focusedTarget() ==
StellarX::FocusTargetId{ firstHandle, 0 });
}
TEST_METHOD(TabRebuildsFocusTreeAfterRouteCallbacks)
{
Window window(240, 160, 1);
auto first = std::make_unique<KeyRouteTestControl>();
KeyRouteTestControl* firstRaw = first.get();
first->setTabIndex(0);
const auto firstHandle = window.addControl(std::move(first));
auto hiddenByCallback = std::make_unique<FocusTestControl>(1);
FocusTestControl* hiddenRaw = hiddenByCallback.get();
const auto hiddenHandle = window.addControl(std::move(hiddenByCallback));
auto last = std::make_unique<FocusTestControl>(2);
const auto lastHandle = window.addControl(std::move(last));
window.getFocusManager().requestFocus({ firstHandle, 0 });
firstRaw->setRouteCallback([&](StellarX::RoutedKeyEvent&)
{
hiddenRaw->setIsVisible(false);
return StellarX::KeyRouteResult{};
});
const auto afterHide = RouteKeyboard(window, MakeKeyInput(591, VK_TAB));
Assert::IsTrue(afterHide.tabTraversalAttempted);
Assert::IsTrue(window.getFocusManager().focusedTarget() ==
StellarX::FocusTargetId{ lastHandle, 0 });
hiddenRaw->setIsVisible(true);
auto reverseInput = MakeKeyInput(592, VK_TAB);
std::get<StellarX::KeyEvent>(reverseInput.payload).modifiers.rightShift = true;
const auto afterShow = RouteKeyboard(window, reverseInput);
Assert::IsTrue(afterShow.tabTraversalAttempted);
Assert::IsTrue(window.getFocusManager().focusedTarget() ==
StellarX::FocusTargetId{ hiddenHandle, 0 });
}
TEST_METHOD(EmptyTabResultIsNotCachedWhenStopMaterializesLater)
{
Window window(220, 140, 1);
auto canvas = std::make_unique<Canvas>(0, 0, 100, 100);
Canvas* canvasRaw = canvas.get();
window.addControl(std::move(canvas));
const auto empty = RouteKeyboard(window, MakeKeyInput(601, VK_TAB));
Assert::IsTrue(empty.tabTraversalAttempted);
Assert::IsFalse(empty.tabFocusChanged);
Assert::IsFalse(static_cast<bool>(window.getFocusManager().focusedTarget()));
auto materialized = std::make_unique<FocusTestControl>();
const auto materializedHandle = canvasRaw->addControl(std::move(materialized));
const auto afterMaterialize = RouteKeyboard(window, MakeKeyInput(602, VK_TAB));
Assert::IsTrue(afterMaterialize.tabTraversalAttempted);
Assert::IsTrue(afterMaterialize.tabFocusChanged);
Assert::IsTrue(window.getFocusManager().focusedTarget() ==
StellarX::FocusTargetId{ materializedHandle, 0 });
}
TEST_METHOD(TabTraversalStaysInsideActiveFocusScope)
{
Window window(260, 160, 1);
auto outside = std::make_unique<FocusTestControl>(0);
const auto outsideHandle = window.addControl(std::move(outside));
auto scopeRoot = std::make_unique<Canvas>(0, 0, 120, 100);
const auto scopeHandle = scopeRoot->getHandle();
auto first = std::make_unique<FocusTestControl>(1);
const auto firstHandle = scopeRoot->addControl(std::move(first));
auto second = std::make_unique<FocusTestControl>(2);
const auto secondHandle = scopeRoot->addControl(std::move(second));
window.addControl(std::move(scopeRoot));
const auto scope = window.getFocusManager().pushScope(
{ scopeHandle, 0 }, { firstHandle, 0 });
Assert::IsTrue(static_cast<bool>(scope));
const auto forward = RouteKeyboard(window, MakeKeyInput(611, VK_TAB));
Assert::IsTrue(forward.tabTraversalAttempted);
Assert::IsTrue(window.getFocusManager().focusedTarget() ==
StellarX::FocusTargetId{ secondHandle, 0 });
const auto wrapped = RouteKeyboard(window, MakeKeyInput(612, VK_TAB));
Assert::IsTrue(wrapped.tabTraversalAttempted);
Assert::IsTrue(window.getFocusManager().focusedTarget() ==
StellarX::FocusTargetId{ firstHandle, 0 });
Assert::IsFalse(window.getFocusManager().focusedTarget() ==
StellarX::FocusTargetId{ outsideHandle, 0 });
}
TEST_METHOD(ButtonSpaceArmsOnceAndActivatesOnKeyUp)
{
Window window(220, 140, 1);
int clicks = 0;
auto button = std::make_unique<Button>(10, 10, 90, 30, "space");
Button* raw = button.get();
const auto handle = window.addControl(std::move(button));
raw->setOnClickListener([&]() { ++clicks; });
window.getFocusManager().requestFocus({ handle, 0 });
const auto down = RouteKeyboard(window, MakeKeyInput(621, VK_SPACE));
Assert::IsTrue(down.frameworkDefaultPrevented);
Assert::IsFalse(down.bubbleStopped);
Assert::AreEqual(0, clicks);
Assert::IsFalse(raw->isClicked());
auto repeatInput = MakeKeyInput(622, VK_SPACE);
auto& repeatKey = std::get<StellarX::KeyEvent>(repeatInput.payload);
repeatKey.wasDown = true;
repeatKey.repeatCount = 2;
const auto repeat = RouteKeyboard(window, repeatInput);
Assert::IsTrue(repeat.frameworkDefaultPrevented);
Assert::AreEqual(0, clicks);
auto upInput = MakeKeyInput(623, VK_SPACE);
std::get<StellarX::KeyEvent>(upInput.payload).action = StellarX::KeyAction::Up;
const auto up = RouteKeyboard(window, upInput);
Assert::IsTrue(up.frameworkDefaultPrevented);
Assert::AreEqual(1, clicks);
Assert::IsFalse(raw->isClicked());
const auto duplicateUp = RouteKeyboard(window, upInput);
Assert::IsFalse(duplicateUp.frameworkDefaultPrevented);
Assert::AreEqual(1, clicks);
}
TEST_METHOD(ButtonEnterActivatesOnlyOnInitialKeyDown)
{
Window window(220, 140, 1);
int clicks = 0;
auto button = std::make_unique<Button>(10, 10, 90, 30, "enter");
Button* raw = button.get();
const auto handle = window.addControl(std::move(button));
raw->setOnClickListener([&]() { ++clicks; });
window.getFocusManager().requestFocus({ handle, 0 });
const auto first = RouteKeyboard(window, MakeKeyInput(631, VK_RETURN));
Assert::IsTrue(first.frameworkDefaultPrevented);
Assert::AreEqual(1, clicks);
auto repeatInput = MakeKeyInput(632, VK_RETURN);
std::get<StellarX::KeyEvent>(repeatInput.payload).wasDown = true;
const auto repeat = RouteKeyboard(window, repeatInput);
Assert::IsTrue(repeat.frameworkDefaultPrevented);
Assert::AreEqual(1, clicks);
auto upInput = MakeKeyInput(633, VK_RETURN);
std::get<StellarX::KeyEvent>(upInput.payload).action = StellarX::KeyAction::Up;
const auto up = RouteKeyboard(window, upInput);
Assert::IsFalse(up.frameworkDefaultPrevented);
Assert::AreEqual(1, clicks);
}
TEST_METHOD(ButtonSpaceArmCancelsOnEscapeFocusModeVisibilityAndSuspension)
{
Window window(260, 150, 1);
int clicks = 0;
auto button = std::make_unique<Button>(10, 10, 90, 30, "cancel");
Button* raw = button.get();
const auto handle = window.addControl(std::move(button));
raw->setOnClickListener([&]() { ++clicks; });
auto other = std::make_unique<FocusTestControl>();
const auto otherHandle = window.addControl(std::move(other));
window.getFocusManager().requestFocus({ handle, 0 });
auto arm = [&](std::uint64_t sequence)
{
const auto result = RouteKeyboard(window, MakeKeyInput(sequence, VK_SPACE));
Assert::IsTrue(result.frameworkDefaultPrevented);
};
auto release = [&](std::uint64_t sequence)
{
auto input = MakeKeyInput(sequence, VK_SPACE);
std::get<StellarX::KeyEvent>(input.payload).action = StellarX::KeyAction::Up;
RouteKeyboard(window, input);
};
arm(641);
const auto escape = RouteKeyboard(window, MakeKeyInput(642, VK_ESCAPE));
Assert::IsFalse(escape.frameworkDefaultPrevented);
release(643);
Assert::AreEqual(0, clicks);
arm(644);
window.getFocusManager().requestFocus({ otherHandle, 0 });
window.getFocusManager().requestFocus({ handle, 0 });
release(645);
Assert::AreEqual(0, clicks);
arm(646);
raw->setbuttonMode(StellarX::ButtonMode::DISABLED);
raw->setbuttonMode(StellarX::ButtonMode::NORMAL);
window.getFocusManager().requestFocus({ handle, 0 });
release(647);
Assert::AreEqual(0, clicks);
arm(648);
raw->setIsVisible(false);
raw->setIsVisible(true);
window.getFocusManager().requestFocus({ handle, 0 });
release(649);
Assert::AreEqual(0, clicks);
arm(650);
const auto suspended =
StellarX::Internal::KeyboardRouter::suspendFocusedPath(window);
Assert::IsTrue(suspended.focusedPathSuspended);
release(651);
Assert::AreEqual(0, clicks);
Assert::IsTrue(window.getFocusManager().focusedTarget() ==
StellarX::FocusTargetId{ handle, 0 });
}
TEST_METHOD(ButtonToggleStateRemainsIndependentFromKeyboardArm)
{
Window window(220, 140, 1);
int toggledOn = 0;
int toggledOff = 0;
auto button = std::make_unique<Button>(10, 10, 90, 30, "toggle",
StellarX::ButtonMode::TOGGLE);
Button* raw = button.get();
const auto handle = window.addControl(std::move(button));
raw->setOnToggleOnListener([&]() { ++toggledOn; });
raw->setOnToggleOffListener([&]() { ++toggledOff; });
window.getFocusManager().requestFocus({ handle, 0 });
RouteKeyboard(window, MakeKeyInput(661, VK_SPACE));
Assert::IsFalse(raw->isClicked());
RouteKeyboard(window, MakeKeyInput(662, VK_ESCAPE));
auto upInput = MakeKeyInput(663, VK_SPACE);
std::get<StellarX::KeyEvent>(upInput.payload).action = StellarX::KeyAction::Up;
RouteKeyboard(window, upInput);
Assert::IsFalse(raw->isClicked());
Assert::AreEqual(0, toggledOn);
Assert::AreEqual(0, toggledOff);
RouteKeyboard(window, MakeKeyInput(664, VK_SPACE));
RouteKeyboard(window, upInput);
Assert::IsTrue(raw->isClicked());
Assert::AreEqual(1, toggledOn);
RouteKeyboard(window, MakeKeyInput(665, VK_SPACE));
RouteKeyboard(window, MakeKeyInput(666, VK_ESCAPE));
RouteKeyboard(window, upInput);
Assert::IsTrue(raw->isClicked());
Assert::AreEqual(0, toggledOff);
RouteKeyboard(window, MakeKeyInput(667, VK_RETURN));
Assert::IsFalse(raw->isClicked());
Assert::AreEqual(1, toggledOff);
}
TEST_METHOD(ButtonActivationPreventsDefaultButStillBubblesToAncestors)
{
Window window(240, 160, 1);
int clicks = 0;
int ancestorCalls = 0;
auto canvas = std::make_unique<KeyRouteTestCanvas>();
KeyRouteTestCanvas* canvasRaw = canvas.get();
auto button = std::make_unique<Button>(10, 10, 90, 30, "bubble");
Button* buttonRaw = button.get();
const auto buttonHandle = canvas->addControl(std::move(button));
canvasRaw->setRouteCallback([&](StellarX::RoutedKeyEvent&)
{
++ancestorCalls;
return StellarX::KeyRouteResult{};
});
window.addControl(std::move(canvas));
buttonRaw->setOnClickListener([&]() { ++clicks; });
window.getFocusManager().requestFocus({ buttonHandle, 0 });
const auto down = RouteKeyboard(window, MakeKeyInput(671, VK_SPACE));
auto upInput = MakeKeyInput(672, VK_SPACE);
std::get<StellarX::KeyEvent>(upInput.payload).action = StellarX::KeyAction::Up;
const auto up = RouteKeyboard(window, upInput);
Assert::IsTrue(down.frameworkDefaultPrevented);
Assert::IsTrue(up.frameworkDefaultPrevented);
Assert::IsFalse(down.bubbleStopped);
Assert::IsFalse(up.bubbleStopped);
Assert::AreEqual<size_t>(2, down.deliveredTargets);
Assert::AreEqual<size_t>(2, up.deliveredTargets);
Assert::AreEqual(2, ancestorCalls);
Assert::AreEqual(1, clicks);
}
TEST_METHOD(NonKeyPayloadIsNotDeliveredByKeyboardRouter)
{
Window window(200, 120, 1);
int routeCalls = 0;
auto control = std::make_unique<KeyRouteTestControl>();
KeyRouteTestControl* raw = control.get();
const auto handle = window.addControl(std::move(control));
raw->setRouteCallback([&](StellarX::RoutedKeyEvent&)
{
++routeCalls;
return StellarX::KeyRouteResult{};
});
window.getFocusManager().requestFocus({ handle, 0 });
StellarX::NativeInputEvent native;
native.sequence = 601;
StellarX::TextInputEvent text;
text.text = L"x";
native.payload = std::move(text);
const auto outcome = RouteKeyboard(window, native);
Assert::IsFalse(outcome.keyEvent);
Assert::AreEqual<size_t>(0, outcome.deliveredTargets);
Assert::AreEqual(0, routeCalls);
}
};
// 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)
@@ -780,6 +1664,53 @@ namespace StellarXNativeTests
Assert::IsFalse(context.tryPopForTesting(event));
}
TEST_METHOD(ActivationGenerationIsPublishedBeforeEachCapturedEvent)
{
StellarX::Internal::NativeInputContext context(nullptr);
const auto initial = context.activationSnapshot();
Assert::IsFalse(initial.keyboardFocusActive);
Assert::AreNotEqual<std::uint64_t>(0, initial.generation);
context.collect(nullptr, WM_SETFOCUS, 0, 0);
context.collect(nullptr, WM_KEYDOWN, 'A', 1);
context.collect(nullptr, WM_KILLFOCUS, 0, 0);
context.collect(nullptr, WM_KEYDOWN, 'B', 1);
StellarX::NativeInputEvent event;
Assert::IsTrue(context.tryPopForTesting(event));
const auto* activated =
std::get_if<StellarX::WindowActivationEvent>(&event.payload);
Assert::IsNotNull(activated);
Assert::IsTrue(activated->keyboardFocusActive);
Assert::IsTrue(event.activationAtCapture.keyboardFocusActive);
const std::uint64_t activeGeneration = event.activationAtCapture.generation;
Assert::AreNotEqual(initial.generation, activeGeneration);
Assert::IsTrue(context.tryPopForTesting(event));
Assert::IsNotNull(std::get_if<StellarX::KeyEvent>(&event.payload));
Assert::IsTrue(event.activationAtCapture.keyboardFocusActive);
Assert::AreEqual(activeGeneration, event.activationAtCapture.generation);
Assert::IsTrue(context.tryPopForTesting(event));
const auto* deactivated =
std::get_if<StellarX::WindowActivationEvent>(&event.payload);
Assert::IsNotNull(deactivated);
Assert::IsFalse(deactivated->keyboardFocusActive);
Assert::IsFalse(event.activationAtCapture.keyboardFocusActive);
const std::uint64_t inactiveGeneration = event.activationAtCapture.generation;
Assert::AreNotEqual(activeGeneration, inactiveGeneration);
Assert::IsTrue(context.tryPopForTesting(event));
Assert::IsNotNull(std::get_if<StellarX::KeyEvent>(&event.payload));
Assert::IsFalse(event.activationAtCapture.keyboardFocusActive);
Assert::AreEqual(inactiveGeneration, event.activationAtCapture.generation);
Assert::IsFalse(context.tryPopForTesting(event));
const auto current = context.activationSnapshot();
Assert::IsFalse(current.keyboardFocusActive);
Assert::AreEqual(inactiveGeneration, current.generation);
}
TEST_METHOD(AnsiDbcsLeadAndTrailBecomeOneUtf16Event)
{
StellarX::Internal::NativeInputContext context(nullptr);
+77 -6
View File
@@ -50,11 +50,23 @@ namespace
}
return false;
}
StellarX::KeyRouteResult handleKeyEvent(StellarX::RoutedKeyEvent& event) override
{
++routedKeyEventCount;
if (event.key.action == StellarX::KeyAction::Down)
++routedKeyDownCount;
else
++routedKeyUpCount;
return {};
}
bool model() const override { return false; }
std::uint64_t keyEventCount = 0;
std::uint64_t keyDownCount = 0;
std::uint64_t keyUpCount = 0;
std::uint64_t routedKeyEventCount = 0;
std::uint64_t routedKeyDownCount = 0;
std::uint64_t routedKeyUpCount = 0;
};
}
@@ -64,8 +76,9 @@ int main(int argc, char** argv)
const std::string scenario = argc > 1 ? argv[1] : "geometry";
const std::filesystem::path resultDir = argc > 2 ? argv[2] : "TestResults";
const bool consumeProbeScenario = scenario == "input-consume-probe";
const int windowWidth = consumeProbeScenario ? 720 : 480;
const int windowHeight = consumeProbeScenario ? 420 : 320;
const bool expandedFocusScenario = scenario == "focus";
const int windowWidth = consumeProbeScenario ? 720 : (expandedFocusScenario ? 800 : 480);
const int windowHeight = consumeProbeScenario ? 420 : (expandedFocusScenario ? 520 : 320);
Window window(windowWidth, windowHeight, 1, Background, "StellarX.UI." + scenario);
if (scenario == "ownership")
@@ -164,11 +177,13 @@ int main(int argc, char** argv)
}
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.
// Stage 3 production routes the normalized queue while EasyX EX_KEY is
// still drained but never delivered to Control::handleEvent().
auto observer = std::make_unique<KeyboardObservationControl>();
KeyboardObservationControl* observerRaw = observer.get();
window.addControl(std::move(observer));
observerRaw->setFocusPolicy(StellarX::FocusPolicy::Programmatic);
const auto observerHandle = window.addControl(std::move(observer));
window.getFocusManager().requestFocus({ observerHandle, 0 });
auto verify = MakeButton(20, 40, 160, 42, "verify input shadow");
verify->setOnClickListener([&, observerRaw]() {
@@ -177,6 +192,13 @@ int main(int argc, char** argv)
std::string("{\"legacyKeyCount\":") + std::to_string(observerRaw->keyEventCount) +
",\"legacyKeyDownCount\":" + std::to_string(observerRaw->keyDownCount) +
",\"legacyKeyUpCount\":" + std::to_string(observerRaw->keyUpCount) +
",\"routedKeyCount\":" + std::to_string(observerRaw->routedKeyEventCount) +
",\"routedKeyDownCount\":" + std::to_string(observerRaw->routedKeyDownCount) +
",\"routedKeyUpCount\":" + std::to_string(observerRaw->routedKeyUpCount) +
",\"easyXKeyboardMessagesDrained\":" +
std::to_string(snapshot.easyXKeyboardMessagesDrained) +
",\"legacyEasyXKeyboardBusinessDeliveries\":" +
std::to_string(snapshot.legacyEasyXKeyboardBusinessDeliveries) +
",\"pushed\":" + std::to_string(snapshot.queue.pushed) +
",\"popped\":" + std::to_string(snapshot.queue.popped) +
",\"currentDepth\":" + std::to_string(snapshot.queue.currentDepth) +
@@ -191,6 +213,31 @@ int main(int argc, char** argv)
});
window.addControl(std::move(verify));
}
else if (scenario == "keyboard-routing")
{
// End-to-end production cutover: WndProc -> NativeInputQueue ->
// KeyboardRouter -> Button, with no EX_KEY business fallback.
int firstClicks = 0;
int secondClicks = 0;
auto first = MakeButton(20, 40, 150, 42, "space target");
Button* firstRaw = first.get();
const auto firstHandle = window.addControl(std::move(first));
auto second = MakeButton(210, 40, 150, 42, "enter target");
Button* secondRaw = second.get();
const auto secondHandle = window.addControl(std::move(second));
firstRaw->setOnClickListener([&]() { ++firstClicks; });
secondRaw->setOnClickListener([&, secondHandle]() {
++secondClicks;
const bool secondFocused = window.getFocusManager().focusedTarget() ==
StellarX::FocusTargetId{ secondHandle, 0 };
WriteResult(resultDir, scenario,
std::string("{\"firstClicks\":") + std::to_string(firstClicks) +
",\"secondClicks\":" + std::to_string(secondClicks) +
",\"secondFocused\":" + (secondFocused ? "true" : "false") + "}");
});
window.getFocusManager().requestFocus({ firstHandle, 0 });
}
else if (scenario == "native-close")
{
// 只创建顶层窗口,由 UI 驱动发送原生关闭命令并验证宿主循环自行退出。
@@ -253,7 +300,8 @@ int main(int argc, char** argv)
}
else if (scenario == "focus")
{
// Stage 2 integration: real focus visuals, Table child eligibility, and active Tab page filtering.
// Focus integration: real focus visuals, Table child eligibility, active Tab page
// filtering, background pointer clearing, and same-parent overlay restoration.
auto button = MakeButton(20, 20, 120, 40, "focus button");
Button* buttonRaw = button.get();
bool buttonFocusObserved = false;
@@ -286,6 +334,29 @@ int main(int argc, char** argv)
tabs->addTab(MakeButton(100, 0, 100, 30, "second"), std::move(secondPage));
window.addControl(std::move(tabs));
// Canvas 空白点击必须清焦点。右侧 Canvas 与左侧控件不重叠,便于脚本
// 对焦点框出现/消失做稳定的局部像素比较。
auto focusCanvas = std::make_unique<Canvas>(500, 20, 260, 120);
focusCanvas->setCanvasBkColor(RGB(60, 90, 120));
auto canvasButton = MakeButton(20, 20, 110, 34, "canvas focus");
focusCanvas->addControl(std::move(canvasButton));
window.addControl(std::move(focusCanvas));
// 复现 KEY5 D 区:下层与上层 Canvas 同属一个父 Canvas,且两者相交。
// 从下层 Button 切到上层 Button 时,旧焦点消失和新焦点出现会让两条
// 子树在同一批次变脏;上层必须完整恢复,不能只画自己的脏 Button。
auto overlayRoot = std::make_unique<Canvas>(500, 180, 260, 180);
overlayRoot->setCanvasBkColor(RGB(235, 225, 205));
auto lowerLayer = std::make_unique<Canvas>(10, 20, 170, 110);
lowerLayer->setCanvasBkColor(RGB(90, 120, 150));
lowerLayer->addControl(MakeButton(15, 15, 90, 30, "lower"));
auto upperLayer = std::make_unique<Canvas>(100, 55, 145, 100);
upperLayer->setCanvasBkColor(RGB(200, 160, 120));
upperLayer->addControl(MakeButton(30, 45, 100, 30, "upper"));
overlayRoot->addControl(std::move(lowerLayer));
overlayRoot->addControl(std::move(upperLayer));
window.addControl(std::move(overlayRoot));
auto verify = MakeButton(350, 260, 110, 36, "verify");
verify->setOnClickListener([&, buttonRaw]() {
const auto stops = window.getFocusManager().collectTabStops();