feat(input): 完成阶段3键盘路由与焦点重绘收口
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
// 判断鼠标是否在圆形按钮内
|
||||
|
||||
@@ -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;最外层结束时提交子控件队列。
|
||||
|
||||
@@ -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; }
|
||||
// 派生控件可响应焦点变化;回调允许安全地请求焦点或排队修改控件树。
|
||||
|
||||
@@ -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 = {});
|
||||
|
||||
@@ -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{};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 需要同步刷新其内部页签栏和页面区。
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user