4 Commits

Author SHA1 Message Date
Codex dd88a80b8c docs: 收口阶段3键盘路由与焦点回归结论 2026-10-10 00:06:41 +08:00
Codex 29df994f09 feat(input): 完成阶段3键盘路由与焦点重绘收口 2026-10-10 00:05:23 +08:00
Codex 69c26a35a2 docs: 收口阶段 2 焦点系统与回归结论 2026-10-08 18:38:14 +08:00
Codex 255aba688f feat(focus): 完成阶段 2 通用焦点系统 2026-10-08 18:37:34 +08:00
41 changed files with 4607 additions and 139 deletions
+4 -4
View File
@@ -55,15 +55,15 @@
<!-- Public and internal headers are kept in this project so all consumers use one implementation. --> <!-- Public and internal headers are kept in this project so all consumers use one implementation. -->
<ItemGroup> <ItemGroup>
<ClInclude Include="include\StellarX\Button.h" /><ClInclude Include="include\StellarX\Canvas.h" /><ClInclude Include="include\StellarX\Control.h" /> <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\InputEvents.h" /><ClInclude Include="include\StellarX\Label.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\SxNativeInputInternal.h" /><ClInclude Include="include\StellarX\SxLog.h" /><ClInclude Include="src\StellarX\internal\SxTextInternal.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" /> <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> </ItemGroup>
<!-- Framework source files compiled into StellarX.Core.lib. --> <!-- Framework source files compiled into StellarX.Core.lib. -->
<ItemGroup> <ItemGroup>
<ClCompile Include="src\StellarX\Button.cpp" /><ClCompile Include="src\StellarX\Canvas.cpp" /><ClCompile Include="src\StellarX\Control.cpp" /> <ClCompile Include="src\StellarX\Button.cpp" /><ClCompile Include="src\StellarX\Canvas.cpp" /><ClCompile Include="src\StellarX\Control.cpp" />
<ClCompile Include="src\StellarX\Dialog.cpp" /><ClCompile Include="src\StellarX\Label.cpp" /><ClCompile Include="src\StellarX\MessageBox.cpp" /> <ClCompile Include="src\StellarX\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" /> <ClCompile Include="src\StellarX\TextBox.cpp" /><ClCompile Include="src\StellarX\Window.cpp" />
</ItemGroup> </ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
+3
View File
@@ -24,6 +24,9 @@
<ClInclude Include="include\StellarX\Control.h" /> <ClInclude Include="include\StellarX\Control.h" />
<ClInclude Include="include\StellarX\CoreTypes.h" /> <ClInclude Include="include\StellarX\CoreTypes.h" />
<ClInclude Include="include\StellarX\Dialog.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\Label.h" />
<ClInclude Include="include\StellarX\MessageBox.h" /> <ClInclude Include="include\StellarX\MessageBox.h" />
<ClInclude Include="include\StellarX\StellarX.h" /> <ClInclude Include="include\StellarX\StellarX.h" />
+9
View File
@@ -45,6 +45,15 @@
<ClInclude Include="include\StellarX\Dialog.h"> <ClInclude Include="include\StellarX\Dialog.h">
<Filter>头文件</Filter> <Filter>头文件</Filter>
</ClInclude> </ClInclude>
<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"> <ClInclude Include="include\StellarX\MessageBox.h">
<Filter>头文件</Filter> <Filter>头文件</Filter>
</ClInclude> </ClInclude>
+20 -1
View File
@@ -32,6 +32,9 @@ class Button : public Control
std::string text; // 按钮上的文字 std::string text; // 按钮上的文字
bool click; // 是否被点击 bool click; // 是否被点击
bool hover; // 是否被悬停 bool hover; // 是否被悬停
// 独立于 click:NORMAL 的 click 是鼠标瞬时态,TOGGLE 的 click 是持久态;
// 键盘 Space 的按下/释放生命周期不能复用它们。
bool keyboardArmed = false;
std::string cutText; // 切割后的文本 std::string cutText; // 切割后的文本
bool needCutText = true; // 是否需要切割文本 bool needCutText = true; // 是否需要切割文本
@@ -104,6 +107,8 @@ class Button : public Control
void draw() override; void draw() override;
// 处理按钮点击、悬停、切换状态和 Tooltip 触发。 // 处理按钮点击、悬停、切换状态和 Tooltip 触发。
bool handleEvent(const ExMessage& msg) override; bool handleEvent(const ExMessage& msg) override;
// 阶段 3 Routed 生产路径:Space 采用按下/释放语义,Enter 首次按下即激活。
StellarX::KeyRouteResult handleKeyEvent(StellarX::RoutedKeyEvent& event) override;
// 由窗口时钟推进 Tooltip 到期状态,即使没有新的鼠标消息也能触发显示。 // 由窗口时钟推进 Tooltip 到期状态,即使没有新的鼠标消息也能触发显示。
void tick(ULONGLONG nowMs) override; void tick(ULONGLONG nowMs) override;
// 清理按钮的鼠标瞬时状态;用于父容器在 WM_MOUSEMOVE 短路后补做 hover/tooltip 收口。 // 清理按钮的鼠标瞬时状态;用于父容器在 WM_MOUSEMOVE 短路后补做 hover/tooltip 收口。
@@ -121,7 +126,9 @@ class Button : public Control
// 设置 TOGGLE 模式下切换到关闭状态时的回调 // 设置 TOGGLE 模式下切换到关闭状态时的回调
void setOnToggleOffListener(std::function<void()> callback); void setOnToggleOffListener(std::function<void()> callback);
// 设置按钮模式(NORMAL / TOGGLE / DISABLED) // 设置按钮模式(NORMAL / TOGGLE / DISABLED)
void setbuttonMode(StellarX::ButtonMode mode); void setbuttonMode(StellarX::ButtonMode newMode);
// 隐藏会同步取消尚未释放的键盘按压态。
void setIsVisible(bool visible) override;
// 设置圆角矩形的圆角宽度 // 设置圆角矩形的圆角宽度
void setROUND_RECTANGLEwidth(int width); void setROUND_RECTANGLEwidth(int width);
// 设置圆角矩形的圆角高度 // 设置圆角矩形的圆角高度
@@ -191,7 +198,19 @@ public:
void setTooltipText(const std::string& s) { tipTextClick = s; tipUserOverride = true; } void setTooltipText(const std::string& s) { tipTextClick = s; tipUserOverride = true; }
// 设置 TOGGLE 模式开/关两种状态下的 Tooltip 文本。 // 设置 TOGGLE 模式开/关两种状态下的 Tooltip 文本。
void setTooltipTextsForToggle(const std::string& onText, const std::string& offText); void setTooltipTextsForToggle(const std::string& onText, const std::string& offText);
protected:
bool isFocusEnabled() const override
{
return mode != StellarX::ButtonMode::DISABLED;
}
void onLogicalFocusChanged(bool focused, StellarX::FocusReason reason) override;
void onKeyboardRoutingSuspended() override;
private: private:
// 返回状态是否发生变化;requestVisual=false 用于焦点/可见性/模式路径,
// 这些调用方已经负责整体失效或重绘。
bool setKeyboardArmed(bool armed, bool requestVisual);
// 执行一次 Button 命令。调用用户回调后不再访问成员。
void activateFromKeyboard();
// 初始化按钮内部状态、颜色和 Tooltip 默认样式 // 初始化按钮内部状态、颜色和 Tooltip 默认样式
void initButton(const std::string text, StellarX::ButtonMode mode, StellarX::ControlShape shape, COLORREF ct, COLORREF cf, COLORREF ch); void initButton(const std::string text, StellarX::ButtonMode mode, StellarX::ControlShape shape, COLORREF ct, COLORREF cf, COLORREF ch);
// 判断鼠标是否在圆形按钮内 // 判断鼠标是否在圆形按钮内
+10
View File
@@ -118,6 +118,16 @@ public:
// 托管收口阶段执行 Canvas 的真正重绘 // 托管收口阶段执行 Canvas 的真正重绘
void commitManagedRepaint() override; void commitManagedRepaint() override;
protected: protected:
bool queryFocusTargetSnapshot(const StellarX::FocusTargetId& target,
StellarX::FocusTargetSnapshot& snapshot) const override;
void collectFocusTargetSnapshots(
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 传播事务深度。 // 进入 Canvas 子树 mutation transaction;会向父容器/Window 传播事务深度。
void beginMutationScope() override; void beginMutationScope() override;
// 离开 Canvas 子树 mutation transaction;最外层结束时提交子控件队列。 // 离开 Canvas 子树 mutation transaction;最外层结束时提交子控件队列。
+47 -2
View File
@@ -33,13 +33,21 @@
#include <functional> #include <functional>
#include <atomic> #include <atomic>
#include "CoreTypes.h" #include "CoreTypes.h"
#include "KeyboardRouting.h"
class Window; class Window;
namespace StellarX
{
class FocusManager;
}
class Control class Control
{ {
friend class Window; friend class Window;
friend class Canvas; friend class Canvas;
friend class Table;
friend class TabControl;
protected: protected:
inline static std::atomic<std::uint64_t> nextHandleValue{ 1 }; inline static std::atomic<std::uint64_t> nextHandleValue{ 1 };
// 句柄随控件构造生成,容器只通过句柄排队删除,避免遍历期间直接持有失效指针。 // 句柄随控件构造生成,容器只通过句柄排队删除,避免遍历期间直接持有失效指针。
@@ -52,6 +60,9 @@ protected:
bool dirty = true; // 是否重绘 bool dirty = true; // 是否重绘
bool show = true; // 是否显示 bool show = true; // 是否显示
bool eventVisualChanged = false; // 最近一次 handleEvent 是否真的引发了视觉变化(用于上层判断是否需要登记重绘) bool eventVisualChanged = false; // 最近一次 handleEvent 是否真的引发了视觉变化(用于上层判断是否需要登记重绘)
StellarX::FocusPolicy focusPolicy = StellarX::FocusPolicy::None; // 逻辑焦点入口能力
std::int32_t tabIndex = 0; // 同一作用域内的显式 Tab 顺序;相同时保持树顺序
bool logicalFocused = false; // 仅由 FocusManager 提交,控件不得自行切换
/* == 布局模式 == */ /* == 布局模式 == */
StellarX::LayoutMode layoutMode = StellarX::LayoutMode::Fixed; // 布局模式 StellarX::LayoutMode layoutMode = StellarX::LayoutMode::Fixed; // 布局模式
@@ -185,9 +196,12 @@ public:
// 绘制入口。实现应只在 dirty/show 条件满足时写屏,并在完成后清理 dirty。 // 绘制入口。实现应只在 dirty/show 条件满足时写屏,并在完成后清理 dirty。
virtual void draw() = 0; virtual void draw() = 0;
// 事件入口。返回 true 表示事件已消费;若事件造成视觉变化,应设置 eventVisualChanged 并请求重绘。 // 事件入口。返回 true 表示事件已消费;若事件造成视觉变化,应设置 eventVisualChanged 并请求重绘。
// 阶段 1 兼容过渡:EX_MOUSE 永久保留在此入口;EX_KEY 暂时仍按旧路径下发以保持行为, // EX_MOUSE 永久保留在此入口;阶段 3 起生产事件循环不再向这里下发 EX_KEY。
// 计划在阶段 3 KeyboardRouter 一次性换轨后停止下发。派生控件不要再新增对 EX_KEY 的依赖。 // 派生控件的键盘逻辑应实现 handleKeyEvent(),旧 EX_KEY 覆盖不会再被调用。
virtual bool handleEvent(const ExMessage& msg) = 0; virtual bool handleEvent(const ExMessage& msg) = 0;
// 阶段 3 定向键盘入口。默认空实现保证现有派生控件无需立即修改;
// KeyboardRouter 在句柄解析与 mutation 边界内逐节点调用。
virtual StellarX::KeyRouteResult handleKeyEvent(StellarX::RoutedKeyEvent&) { return {}; }
// 轻量清理接口:仅用于“没有机会收到本次 WM_MOUSEMOVE”的兄弟分支, // 轻量清理接口:仅用于“没有机会收到本次 WM_MOUSEMOVE”的兄弟分支,
// 清掉 hover / tooltip / 临时按下态等鼠标瞬时状态。 // 清掉 hover / tooltip / 临时按下态等鼠标瞬时状态。
// 该接口不是事件分发入口,不参与新的命中判断,也不允许回写设计基线。 // 该接口不是事件分发入口,不参与新的命中判断,也不允许回写设计基线。
@@ -228,6 +242,15 @@ public:
std::string getId() const { return id; } std::string getId() const { return id; }
// 返回稳定句柄而非对象地址;调用方不得据此推断控件当前仍在容器中。 // 返回稳定句柄而非对象地址;调用方不得据此推断控件当前仍在容器中。
StellarX::ControlHandle getHandle() const noexcept { return handle; } StellarX::ControlHandle getHandle() const noexcept { return handle; }
// 申请逻辑焦点。回调重入时请求会进入 FocusManager 当前回合的单槽 pending。
StellarX::FocusChangeResult requestFocus(
StellarX::FocusReason reason = StellarX::FocusReason::Programmatic);
// 设置焦点入口策略;修改后若当前焦点不再有效,会立即进入焦点回退。
void setFocusPolicy(StellarX::FocusPolicy policy);
StellarX::FocusPolicy getFocusPolicy() const noexcept { return focusPolicy; }
void setTabIndex(std::int32_t value);
std::int32_t getTabIndex() const noexcept { return tabIndex; }
bool hasLogicalFocus() const noexcept { return logicalFocused; }
// 检查控件当前是否处于待重绘状态。 // 检查控件当前是否处于待重绘状态。
bool isDirty() { return dirty; } bool isDirty() { return dirty; }
//获取控件最近一次事件处理是否引发了视觉变化 //获取控件最近一次事件处理是否引发了视觉变化
@@ -280,6 +303,28 @@ public:
// - 仅用于少数需要把当前结果固化为新设计基线的场景 // - 仅用于少数需要把当前结果固化为新设计基线的场景
void commitCurrentGeometryAsDesignRect(); void commitCurrentGeometryAsDesignRect();
protected: protected:
// 焦点参与协议:默认节点只解析 {本控件句柄, 0};复合控件递归转发给真实子 Control。
virtual bool queryFocusTargetSnapshot(const StellarX::FocusTargetId& target,
StellarX::FocusTargetSnapshot& snapshot) const;
virtual void collectFocusTargetSnapshots(
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; }
// 派生控件可响应焦点变化;回调允许安全地请求焦点或排队修改控件树。
virtual void onLogicalFocusChanged(bool, StellarX::FocusReason) {}
bool isEffectivelyVisible() const;
void notifyFocusSubtreeStateChanged();
// 容器和窗口在事件、绘制、tick 期间进入事务;具体容器在最外层结束时提交队列。 // 容器和窗口在事件、绘制、tick 期间进入事务;具体容器在最外层结束时提交队列。
MutationScope enterMutationScope() { return MutationScope(this); } MutationScope enterMutationScope() { return MutationScope(this); }
// 进入子树结构变更事务。默认向父容器或宿主 Window 传播,使整棵树共享同一提交边界。 // 进入子树结构变更事务。默认向父容器或宿主 Window 传播,使整棵树共享同一提交边界。
+105
View File
@@ -64,6 +64,111 @@ namespace StellarX
bool operator!=(const ControlHandle& other) const noexcept { return value != other.value; } bool operator!=(const ControlHandle& other) const noexcept { return value != other.value; }
}; };
/**
* @结构体名称: FocusTargetId
* @功能描述: 表示一个逻辑焦点参与节点的稳定身份
*
* @详细说明:
* owner 指向拥有该焦点节点的 Control。当前阶段所有焦点节点都是真实 Control,
* 因此 subNode 固定为 0;非 0 值预留给未来没有独立 Control 对象的轻量节点。
* ControlHandle 在进程内单调分配且不复用,因此本结构不额外复制 generation。
*/
struct FocusTargetId
{
ControlHandle owner{};
std::uint32_t subNode = 0;
explicit operator bool() const noexcept { return static_cast<bool>(owner); }
bool operator==(const FocusTargetId& other) const noexcept
{
return owner == other.owner && subNode == other.subNode;
}
bool operator!=(const FocusTargetId& other) const noexcept { return !(*this == other); }
};
// 标识一个 Window 内的逻辑焦点作用域。0 表示无效,根作用域由 FocusManager 创建。
struct FocusScopeId
{
std::uint64_t value = 0;
explicit operator bool() const noexcept { return value != 0; }
bool operator==(const FocusScopeId& other) const noexcept { return value == other.value; }
bool operator!=(const FocusScopeId& other) const noexcept { return value != other.value; }
};
// 控件声明自己允许通过哪些入口获得逻辑焦点。
enum class FocusPolicy : std::uint8_t
{
None = 0,
Programmatic = 1u << 0,
Pointer = 1u << 1,
Tab = 1u << 2
};
constexpr FocusPolicy operator|(FocusPolicy left, FocusPolicy right) noexcept
{
return static_cast<FocusPolicy>(static_cast<std::uint8_t>(left) |
static_cast<std::uint8_t>(right));
}
constexpr FocusPolicy operator&(FocusPolicy left, FocusPolicy right) noexcept
{
return static_cast<FocusPolicy>(static_cast<std::uint8_t>(left) &
static_cast<std::uint8_t>(right));
}
constexpr bool hasFocusPolicy(FocusPolicy value, FocusPolicy flag) noexcept
{
return (value & flag) != FocusPolicy::None;
}
enum class FocusReason : std::uint8_t
{
Programmatic,
Pointer,
TabTraversal,
ScopeActivation,
ScopeRestore,
InvalidationFallback
};
// FocusManager 的可断言结果;ReentrancyLimit 是独立错误,不依赖日志判断。
enum class FocusError : std::uint8_t
{
None,
InvalidTarget,
NotFocusable,
NotVisible,
Disabled,
OutsideActiveScope,
InvalidScope,
ReentrancyLimit
};
struct FocusChangeResult
{
bool changed = false;
FocusError error = FocusError::None;
FocusTargetId focused{};
};
/**
* 焦点参与协议的瞬时快照。
* 所有布尔值只对采集时刻有效;任何用户回调结束后都必须重新解析并拉取新快照。
*/
struct FocusTargetSnapshot
{
FocusTargetId id{};
FocusTargetId logicalParent{};
FocusScopeId scope{};
FocusPolicy policy = FocusPolicy::None;
bool visible = false;
bool enabled = false;
bool focusable = false;
bool tabStop = false;
std::int32_t tabIndex = 0;
};
/** /**
* @枚举类名称: FillStyle * @枚举类名称: FillStyle
* @功能描述: 用来定义控件填充图案的枚举类 * @功能描述: 用来定义控件填充图案的枚举类
+109
View File
@@ -0,0 +1,109 @@
/*******************************************************************************
* FocusManager.h - per-Window logical focus manager.
******************************************************************************/
#pragma once
#include "CoreTypes.h"
#include <cstddef>
#include <cstdint>
#include <vector>
class Window;
namespace StellarX
{
namespace Internal
{
class KeyboardRouter;
}
class FocusManager
{
friend class Internal::KeyboardRouter;
struct FocusRequest
{
FocusTargetId target{};
FocusReason reason = FocusReason::Programmatic;
};
struct ScopeEntry
{
FocusScopeId id{};
FocusTargetId root{};
FocusTargetId initialFocus{};
FocusTargetId restoreFocus{};
};
::Window* window = nullptr;
FocusTargetId activeTarget{};
// Stable identity of the target whose focus callback is currently running.
// pushScope() uses it because activeTarget is intentionally empty during FocusLost.
FocusTargetId callbackFocusContext{};
bool transitionActive = false;
bool pendingPresent = false;
FocusRequest pending{};
FocusError lastError = FocusError::None;
std::vector<ScopeEntry> scopes;
std::vector<ControlHandle> invalidatedSubtrees;
std::uint64_t nextScopeValue = 2;
static constexpr std::size_t MaxReentrantRounds = 64;
bool querySnapshot(FocusTargetId target, FocusTargetSnapshot& snapshot) const;
bool isUnderInvalidatedSubtree(FocusTargetId target) const;
bool isWithinSubtree(FocusTargetId target, FocusTargetId root) const;
bool isWithinActiveScope(FocusTargetId target) const;
FocusError validateTarget(FocusTargetId target, FocusReason reason,
FocusTargetSnapshot* snapshot = nullptr) const;
FocusError validateCurrentTarget(FocusTargetId target,
FocusTargetSnapshot* snapshot = nullptr) const;
FocusPolicy requiredPolicy(FocusReason reason) const noexcept;
FocusChangeResult performTransition(FocusTargetId target, FocusReason reason);
FocusTargetId findInvalidationFallback(FocusTargetId invalidTarget) const;
std::vector<FocusTargetSnapshot> collectOrderedTargets(bool tabStopsOnly) const;
void queueReentrantRequest(FocusTargetId target, FocusReason reason) noexcept;
void normalizeInvalidScopes();
public:
explicit FocusManager(::Window& owner);
~FocusManager() = default;
FocusManager(const FocusManager&) = delete;
FocusManager& operator=(const FocusManager&) = delete;
// Normal calls switch immediately. Reentrant calls replace the per-round pending slot.
FocusChangeResult requestFocus(FocusTargetId target,
FocusReason reason = FocusReason::Programmatic);
FocusChangeResult clearFocus(FocusReason reason = FocusReason::Programmatic)
{
return requestFocus({}, reason);
}
FocusTargetId focusedTarget() const noexcept { return activeTarget; }
bool hasFocus(FocusTargetId target) const noexcept
{
return static_cast<bool>(target) && activeTarget == target;
}
FocusError getLastError() const noexcept { return lastError; }
FocusScopeId rootScope() const noexcept;
FocusScopeId activeScope() const noexcept;
// 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 = {});
bool popScope(FocusScopeId scope);
// Called when removal is queued, before physical destruction at mutation flush.
void notifySubtreePendingRemoval(ControlHandle rootHandle);
void notifySubtreesPendingRemoval(const std::vector<ControlHandle>& rootHandles);
// Revalidates active focus after visibility, enabled-state, or policy changes.
void notifySubtreeStateChanged(ControlHandle rootHandle);
// Clears invalidation markers after the outer mutation flush.
void onMutationCommitted();
};
}
+14 -2
View File
@@ -1,7 +1,8 @@
/******************************************************************************* /*******************************************************************************
* StellarX native keyboard, text, IME, and activation event model. * StellarX native keyboard, text, IME, and activation event model.
* Stage 1 only captures, normalizes, queues, and observes these events. * Stage 1 introduced capture, normalization, queuing, and observation.
* Focus, routing, and control behavior are intentionally out of scope. * After the Stage 3 atomic cutover, normalized KeyEvent routing is the
* production business path; EasyX EX_KEY remains a drain-only observation.
******************************************************************************/ ******************************************************************************/
#pragma once #pragma once
@@ -108,6 +109,16 @@ namespace StellarX
HWND otherWindow = nullptr; 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< using NativeInputPayload = std::variant<
KeyEvent, KeyEvent,
TextInputEvent, TextInputEvent,
@@ -119,6 +130,7 @@ namespace StellarX
std::uint64_t sequence = 0; std::uint64_t sequence = 0;
DWORD messageTime = 0; DWORD messageTime = 0;
HWND hwnd = nullptr; HWND hwnd = nullptr;
NativeInputActivationSnapshot activationAtCapture{};
NativeInputPayload payload{}; NativeInputPayload payload{};
}; };
} }
+34
View File
@@ -0,0 +1,34 @@
/*******************************************************************************
* StellarX routed keyboard event contract.
* Stage 3 defines the public control seam and internal bubbling contract.
* Production routing uses this contract after the slice-6 atomic cutover.
******************************************************************************/
#pragma once
#include "InputEvents.h"
#include "CoreTypes.h"
namespace StellarX
{
// A normalized key plus the stable identities used while it bubbles through
// the focus path. The router updates currentTarget before each control call;
// controls must not retain either identity as a raw pointer.
struct RoutedKeyEvent
{
std::uint64_t sequence = 0;
DWORD messageTime = 0;
HWND hwnd = nullptr;
KeyEvent key{};
FocusTargetId originalTarget{};
FocusTargetId currentTarget{};
};
// Stopping the control bubble and suppressing a later framework default are
// independent decisions. Neither result can change the completed WndProc
// return value.
struct KeyRouteResult
{
bool stopBubble = false;
bool preventFrameworkDefault = false;
};
}
+4 -1
View File
@@ -22,7 +22,8 @@
* 只需包含此文件即可使用框架所有功能。 * 只需包含此文件即可使用框架所有功能。
* 示例: #include "StellarX.h" * 示例: #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 * Button.h / Label.h / TextBox.h / Table.h
* Dialog.h / MessageBox.h / TabControl.h * Dialog.h / MessageBox.h / TabControl.h
******************************************************************************/ ******************************************************************************/
@@ -30,7 +31,9 @@
#pragma once #pragma once
#include "CoreTypes.h" #include "CoreTypes.h"
#include "FocusManager.h"
#include "InputEvents.h" #include "InputEvents.h"
#include "KeyboardRouting.h"
#include "SxLog.h" #include "SxLog.h"
#include "Control.h" #include "Control.h"
#include"Canvas.h" #include"Canvas.h"
+10
View File
@@ -111,6 +111,16 @@ public:
bool canCommitManagedPartialRepaint() const override; // 判断当前 TabControl 是否可安全做局部提交 bool canCommitManagedPartialRepaint() const override; // 判断当前 TabControl 是否可安全做局部提交
void commitManagedRepaint() override; // 托管收口阶段执行 TabControl 的真正重绘 void commitManagedRepaint() override; // 托管收口阶段执行 TabControl 的真正重绘
protected: protected:
bool queryFocusTargetSnapshot(const StellarX::FocusTargetId& target,
StellarX::FocusTargetSnapshot& snapshot) const override;
void collectFocusTargetSnapshots(
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; void flushPendingMutations() override;
// 外层统一解算后,TabControl 需要同步刷新其内部页签栏和页面区。 // 外层统一解算后,TabControl 需要同步刷新其内部页签栏和页面区。
+11
View File
@@ -186,4 +186,15 @@ public:
int getTableWidth() const; int getTableWidth() const;
// 获取表格当前运行态高度。 // 获取表格当前运行态高度。
int getTableHeight() const; int getTableHeight() const;
protected:
bool queryFocusTargetSnapshot(const StellarX::FocusTargetId& target,
StellarX::FocusTargetSnapshot& snapshot) const override;
void collectFocusTargetSnapshots(
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;
}; };
+47 -2
View File
@@ -16,6 +16,7 @@
#pragma once #pragma once
#include "Control.h" #include "Control.h"
#include "FocusManager.h"
#include <atomic> #include <atomic>
#include <cstdint> #include <cstdint>
#include <string> #include <string>
@@ -28,15 +29,34 @@ class Window;
namespace StellarX::Internal namespace StellarX::Internal
{ {
class NativeInputContext; class NativeInputContext;
class KeyboardRouter;
struct NativeInputRuntimeSnapshot; struct NativeInputRuntimeSnapshot;
enum class KeyboardPipelineMode : std::uint8_t;
NativeInputRuntimeSnapshot inspectWindowNativeInputForTesting(const ::Window& window) noexcept; 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
{
class FocusManager;
} }
class Window class Window
{ {
friend class Dialog; friend class Dialog;
friend class StellarX::FocusManager;
friend StellarX::Internal::NativeInputRuntimeSnapshot friend StellarX::Internal::NativeInputRuntimeSnapshot
StellarX::Internal::inspectWindowNativeInputForTesting(const Window& window) noexcept; 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; // 当前有效宽(已应用到画布/控件的客户区宽) int width; // 当前有效宽(已应用到画布/控件的客户区宽)
@@ -58,14 +78,22 @@ class Window
// EasyX 的 WndProc 与宿主事件循环分属不同线程;只有 WndProc 完成 WM_NCDESTROY // EasyX 的 WndProc 与宿主事件循环分属不同线程;只有 WndProc 完成 WM_NCDESTROY
// 转发后才发布 false,避免宿主提前析构仍在执行 WndProcThunk 的 Window 对象。 // 转发后才发布 false,避免宿主提前析构仍在执行 WndProcThunk 的 Window 对象。
std::atomic<bool> nativeWindowAlive{ false }; std::atomic<bool> nativeWindowAlive{ false };
// 阶段 1 只建立采集、规范化与跨线程队列;宿主安全点只观测排空,不做业务路由。 // Routed 生产模式在宿主安全点逐条交给 KeyboardRouter;Legacy 仅供切换基线测试。
std::unique_ptr<StellarX::Internal::NativeInputContext> nativeInputContext; std::unique_ptr<StellarX::Internal::NativeInputContext> nativeInputContext;
bool nativeInputFailureReported = false; bool nativeInputFailureReported = false;
std::uint64_t nativeInputRejectedTextReported = 0; std::uint64_t nativeInputRejectedTextReported = 0;
std::uint64_t nativeInputImeQueryFailuresReported = 0; std::uint64_t nativeInputImeQueryFailuresReported = 0;
std::uint64_t nativeInputDiscardedFragmentsReported = 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 hasLiveNativeWindow() const noexcept;
bool drainNativeInputAtSafePoint(); bool drainNativeInputAtSafePoint(bool modalBusinessSuppressed = false);
static LRESULT CALLBACK WndProcThunk(HWND h, UINT m, WPARAM w, LPARAM l); // 静态过程分发到 this static LRESULT CALLBACK WndProcThunk(HWND h, UINT m, WPARAM w, LPARAM l); // 静态过程分发到 this
// —— 绘制相关 ——(是否使用合成双缓冲、窗口标题、背景等) // —— 绘制相关 ——(是否使用合成双缓冲、窗口标题、背景等)
@@ -78,6 +106,7 @@ class Window
// —— 控件/对话框 ——(容器内的普通控件与非模态对话框) // —— 控件/对话框 ——(容器内的普通控件与非模态对话框)
std::vector<std::unique_ptr<Control>> controls; // 普通顶层控件;绘制顺序也决定层级顺序 std::vector<std::unique_ptr<Control>> controls; // 普通顶层控件;绘制顺序也决定层级顺序
std::vector<std::unique_ptr<Control>> dialogs; // 非模态对话框;始终位于普通控件之上 std::vector<std::unique_ptr<Control>> dialogs; // 非模态对话框;始终位于普通控件之上
std::unique_ptr<StellarX::FocusManager> focusManager; // Window 内逻辑焦点唯一所有者
// 顶层控件的结构变更与事件分发解耦,防止回调直接改动正在遍历的 controls。 // 顶层控件的结构变更与事件分发解耦,防止回调直接改动正在遍历的 controls。
// add/remove/clear 在事件、绘制和回调期间只入队;最外层 mutation scope 结束后再提交。 // add/remove/clear 在事件、绘制和回调期间只入队;最外层 mutation scope 结束后再提交。
enum class ControlMutationKind { Add, Remove, Clear }; enum class ControlMutationKind { Add, Remove, Clear };
@@ -141,6 +170,9 @@ public:
const Control* findControl(StellarX::ControlHandle handle) const; const Control* findControl(StellarX::ControlHandle handle) const;
// 返回当前已提交控件的句柄快照;调用方不得据此推断控件之后仍然存在。 // 返回当前已提交控件的句柄快照;调用方不得据此推断控件之后仍然存在。
std::vector<StellarX::ControlHandle> getControlHandles() const; std::vector<StellarX::ControlHandle> getControlHandles() const;
// 获取当前 Window 的通用逻辑焦点管理器。
StellarX::FocusManager& getFocusManager();
const StellarX::FocusManager& getFocusManager() const;
// 添加非模态对话框;Window 接管生命周期并在事件安全点清理关闭对象。 // 添加非模态对话框;Window 接管生命周期并在事件安全点清理关闭对象。
void addDialog(std::unique_ptr<Control> dialogs); void addDialog(std::unique_ptr<Control> dialogs);
//非模态对话框去重 //非模态对话框去重
@@ -184,6 +216,19 @@ public:
// 请求下一安全点执行整场景重绘;用于 Dialog 关闭、全局覆盖关系变化等无法安全局部修复的场景。 // 请求下一安全点执行整场景重绘;用于 Dialog 关闭、全局覆盖关系变化等无法安全局部修复的场景。
void requestFullSceneRedraw(); void requestFullSceneRedraw();
private: private:
// FocusManager 专用的递归焦点参与协议;不复用只查直接子级的 findControl()。
bool queryFocusTargetSnapshot(const StellarX::FocusTargetId& target,
StellarX::FocusTargetSnapshot& snapshot) const;
void collectFocusTargetSnapshots(
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 顶层控件队列;只允许在最外层事务安全点调用。 // 提交 Window 顶层控件队列;只允许在最外层事务安全点调用。
void flushPendingControlMutations(); void flushPendingControlMutations();
// 自顶向下提交 Window、Canvas、TabControl 等容器中累积的 pending mutation。 // 自顶向下提交 Window、Canvas、TabControl 等容器中累积的 pending mutation。
+198 -9
View File
@@ -1,14 +1,27 @@
# 外部 UI 驱动:启动确定性 fixture,注入真实鼠标输入,并保存像素与状态诊断。 # 外部 UI 驱动:启动确定性 fixture,注入真实鼠标输入,并保存像素与状态诊断。
param( param(
[int]$Repeat = 1, [int]$Repeat = 1,
[ValidateSet('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'input-shadow')] [ValidateSet('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'focus', 'input-shadow', 'keyboard-routing')]
[string[]]$OnlyScenario = @() [string[]]$OnlyScenario = @(),
[string]$FixturePath = '',
[string]$ResultRoot = '',
[ValidateSet('Legacy', 'Routed')]
[string]$InputPipelineExpectation = 'Routed'
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot $root = Split-Path -Parent $PSScriptRoot
$fixture = Join-Path $root 'build\x64\Debug\StellarX.UiFixture.exe' $fixture = if ($FixturePath) {
$resultRoot = Join-Path $root 'TestResults\ui' $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 New-Item -ItemType Directory -Force -Path $resultRoot | Out-Null
Add-Type -AssemblyName System.Drawing 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) { function Capture-Client([IntPtr]$h, [string]$path) {
# 只保存客户区,排除窗口边框;返回 Bitmap 供调用方继续读取关键像素。 # 只保存客户区,排除窗口边框;返回 Bitmap 供调用方继续读取关键像素。
$rect = [StellarXUiNative]::GetClientRectangle($h) $rect = [StellarXUiNative]::GetClientRectangle($h)
@@ -243,6 +270,38 @@ function Assert-NotRgb($pixel, [int]$r, [int]$g, [int]$b, [string]$message) {
} }
} }
function Assert-RegionContainsRgb([Drawing.Bitmap]$bitmap, [int]$left, [int]$top,
[int]$right, [int]$bottom, [int]$r, [int]$g, [int]$b, [string]$message) {
for ($y = $top; $y -lt $bottom; $y++) {
for ($x = $left; $x -lt $right; $x++) {
$pixel = $bitmap.GetPixel($x, $y)
if ($pixel.R -eq $r -and $pixel.G -eq $g -and $pixel.B -eq $b) { return }
}
}
throw $message
}
function Assert-RegionsDiffer([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()) { return }
}
}
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, function Wait-ClientRgb([IntPtr]$h, [int]$x, [int]$y, [int]$r, [int]$g, [int]$b,
[string]$capturePath, [int]$timeoutMs = 15000) { [string]$capturePath, [int]$timeoutMs = 15000) {
# 某些绘制在消息批次尾才完成,因此对目标像素进行有界轮询而不是固定猜测延时。 # 某些绘制在消息批次尾才完成,因此对目标像素进行有界轮询而不是固定猜测延时。
@@ -262,9 +321,10 @@ function Wait-ClientRgb([IntPtr]$h, [int]$x, [int]$y, [int]$r, [int]$g, [int]$b,
$savedCursor = New-Object StellarXUiNative+POINT $savedCursor = New-Object StellarXUiNative+POINT
# UI 测试会抢占前台并移动鼠标,外层 finally 必须恢复用户原来的光标位置。 # UI 测试会抢占前台并移动鼠标,外层 finally 必须恢复用户原来的光标位置。
[void][StellarXUiNative]::GetCursorPos([ref]$savedCursor) [void][StellarXUiNative]::GetCursorPos([ref]$savedCursor)
$allScenarios = @('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'input-shadow') $allScenarios = @('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'focus', 'input-shadow', 'keyboard-routing')
$scenarios = if ($OnlyScenario.Count -gt 0) { @($OnlyScenario) } else { $allScenarios } $scenarios = if ($OnlyScenario.Count -gt 0) { @($OnlyScenario) } else { $allScenarios }
$sessionRoot = Join-Path $resultRoot ((Get-Date -Format 'yyyyMMdd-HHmmss-fff') + "-$PID") $sessionRoot = Join-Path $resultRoot ((Get-Date -Format 'yyyyMMdd-HHmmss-fff') + "-$PID")
$scenarioTimings = [Collections.Generic.List[object]]::new()
try { try {
# 场景按固定顺序串行执行,避免多个 EasyX 图形上下文同时争用桌面。 # 场景按固定顺序串行执行,避免多个 EasyX 图形上下文同时争用桌面。
@@ -275,6 +335,7 @@ try {
$resultFile = Join-Path $runDir "$scenario.json" $resultFile = Join-Path $runDir "$scenario.json"
$stdoutFile = Join-Path $runDir 'fixture.stdout.log' $stdoutFile = Join-Path $runDir 'fixture.stdout.log'
$stderrFile = Join-Path $runDir 'fixture.stderr.log' $stderrFile = Join-Path $runDir 'fixture.stderr.log'
$scenarioStopwatch = [Diagnostics.Stopwatch]::StartNew()
$process = Start-Process -FilePath $fixture -ArgumentList @($scenario, $runDir) -PassThru ` $process = Start-Process -FilePath $fixture -ArgumentList @($scenario, $runDir) -PassThru `
-RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile
$h = [IntPtr]::Zero $h = [IntPtr]::Zero
@@ -288,6 +349,87 @@ try {
'tooltip' { Move-Mouse $h 80 40; Wait-ClientRgb $h 200 61 255 255 255 (Join-Path $runDir 'tooltip-wait.png') } 'tooltip' { Move-Mouse $h 80 40; Wait-ClientRgb $h 200 61 255 255 255 (Join-Path $runDir 'tooltip-wait.png') }
'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' } } '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' } } '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')
try {
Assert-RegionContainsRgb $buttonFocus 20 20 140 60 32 32 32 'Button focus frame was not rendered'
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
$result = Wait-Result $resultFile
if (-not $result.buttonFocusObserved -or $result.tableTabStops -ne 1 -or
-not $result.activePageChildPresent -or $result.inactivePageChildPresent -or
-not $result.allSubNodesZero) {
throw 'focus tree integration result failed'
}
}
'dialog' { 'dialog' {
Click-Mouse $h 90 40 Click-Mouse $h 90 40
Start-Sleep -Milliseconds 350 Start-Sleep -Milliseconds 350
@@ -322,10 +464,29 @@ try {
$memoryAfterSecondWave = $process.PrivateMemorySize64 $memoryAfterSecondWave = $process.PrivateMemorySize64
Click-Mouse $h 100 61 Click-Mouse $h 100 61
$result = Wait-Result $resultFile $result = Wait-Result $resultFile
if ($result.legacyKeyCount -ne $expectedKeyEvents) { throw "legacy EX_KEY count mismatch: $($result.legacyKeyCount)" } if ($InputPipelineExpectation -eq 'Legacy') {
if ($result.legacyKeyDownCount -ne ($expectedKeyEvents / 2) -or if ($result.legacyKeyCount -ne $expectedKeyEvents) { throw "legacy EX_KEY count mismatch: $($result.legacyKeyCount)" }
$result.legacyKeyUpCount -ne ($expectedKeyEvents / 2)) { if ($result.legacyKeyDownCount -ne ($expectedKeyEvents / 2) -or
throw "legacy EX_KEY action mismatch: down=$($result.legacyKeyDownCount) up=$($result.legacyKeyUpCount)" $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.keyEvents -ne $expectedKeyEvents) { throw "normalized key count mismatch: $($result.keyEvents)" }
if ($result.pushed -ne $result.popped -or $result.currentDepth -ne 0) { throw 'native input queue did not drain completely' } if ($result.pushed -ne $result.popped -or $result.currentDepth -ne 0) { throw 'native input queue did not drain completely' }
@@ -338,11 +499,24 @@ try {
if ($memoryGrowth -gt 16MB) { throw "input shadow memory grew after warmup: $memoryGrowth bytes" } if ($memoryGrowth -gt 16MB) { throw "input shadow memory grew after warmup: $memoryGrowth bytes" }
@{ @{
expectedKeyEvents = $expectedKeyEvents expectedKeyEvents = $expectedKeyEvents
inputPipelineExpectation = $InputPipelineExpectation
memoryAfterWarmup = $memoryAfterWarmup memoryAfterWarmup = $memoryAfterWarmup
memoryAfterSecondWave = $memoryAfterSecondWave memoryAfterSecondWave = $memoryAfterSecondWave
memoryGrowth = $memoryGrowth memoryGrowth = $memoryGrowth
} | ConvertTo-Json | Set-Content -Encoding UTF8 (Join-Path $runDir 'input-shadow-memory.json') } | 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') $bitmap = Capture-Client $h (Join-Path $runDir 'screen.png')
if ($scenario -eq 'ownership' -or $scenario -eq 'geometry') { if ($scenario -eq 'ownership' -or $scenario -eq 'geometry') {
@@ -374,6 +548,13 @@ try {
Assert-Rgb ($bitmap.GetPixel(240, 150)) 30 50 70 'dialog pixels remained after both dialogs closed' Assert-Rgb ($bitmap.GetPixel(240, 150)) 30 50 70 'dialog pixels remained after both dialogs closed'
} }
$bitmap.Dispose() $bitmap.Dispose()
$scenarioStopwatch.Stop()
$scenarioTimings.Add([pscustomobject]@{
iteration = $iteration
scenario = $scenario
passed = $true
durationMs = $scenarioStopwatch.ElapsedMilliseconds
})
} }
catch { catch {
# 失败时保留驱动错误、fixture 输出和客户区截图,便于定位像素或状态问题。 # 失败时保留驱动错误、fixture 输出和客户区截图,便于定位像素或状态问题。
@@ -405,6 +586,14 @@ try {
Assert-NativeCloseExitsProcess $fixture ` Assert-NativeCloseExitsProcess $fixture `
(Join-Path $sessionRoot "run-$iteration-modal-native-close") 'modal-close' (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 { finally {
[void][StellarXUiNative]::SetCursorPos($savedCursor.X, $savedCursor.Y) [void][StellarXUiNative]::SetCursorPos($savedCursor.X, $savedCursor.Y)
+27 -8
View File
@@ -10,10 +10,6 @@
# 统一测试入口:负责构建指定矩阵,并把所有诊断结果集中到 TestResults。 # 统一测试入口:负责构建指定矩阵,并把所有诊断结果集中到 TestResults。
$ErrorActionPreference = 'Stop' $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 $root = Split-Path -Parent $PSScriptRoot
$msbuild = if ($env:STELLARX_MSBUILD) { $env:STELLARX_MSBUILD } else { 'D:\vs2022\MSBuild\Current\Bin\MSBuild.exe' } $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' } $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 $msbuild)) { throw "MSBuild not found: $msbuild" }
if (-not (Test-Path -LiteralPath $vstest)) { throw "VSTest not found: $vstest" } 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) { function Build-Project([string]$project, [string]$config, [string]$arch, [bool]$asan = $false) {
# ASan 使用 Rebuild,确保运行库和插桩设置不会沿用上一种构建配置。 # ASan 使用 Rebuild,确保运行库和插桩设置不会沿用上一种构建配置。
$asanValue = if ($asan) { 'true' } else { 'false' } $asanValue = if ($asan) { 'true' } else { 'false' }
$target = if ($asan) { 'Rebuild' } else { 'Build' } $target = if ($asan) { 'Rebuild' } else { 'Build' }
& $msbuild $project /m /t:$target /p:Configuration=$config /p:Platform=$arch /p:EnableAsan=$asanValue /v:minimal Invoke-StellarXMSBuild @($project, '/m', "/t:$target", "/p:Configuration=$config",
if ($LASTEXITCODE -ne 0) { throw "Build failed: $project ($config|$arch)" } "/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) { function Run-Unit([string]$config, [string]$arch, [bool]$asan = $false) {
@@ -61,8 +79,9 @@ function Build-KeyScenarios {
# KEY1-KEY6 是应用现有主路径的编译/链接冒烟,不在这里模拟鼠标交互。 # KEY1-KEY6 是应用现有主路径的编译/链接冒烟,不在这里模拟鼠标交互。
$project = Join-Path $root 'imGui-easyX.vcxproj' $project = Join-Path $root 'imGui-easyX.vcxproj'
foreach ($key in 1..6) { foreach ($key in 1..6) {
& $msbuild $project /m /t:Build /p:Configuration=Debug /p:Platform=x64 /p:KeyScenario=$key /p:EnableAsan=false /v:minimal Invoke-StellarXMSBuild @($project, '/m', '/t:Build', '/p:Configuration=Debug',
if ($LASTEXITCODE -ne 0) { throw "KEY$key build failed" } '/p:Platform=x64', "/p:KeyScenario=$key", '/p:EnableAsan=false', '/v:minimal')
if ($lastMsBuildExitCode -ne 0) { throw "KEY$key build failed" }
} }
} }
+132 -4
View File
@@ -166,6 +166,8 @@ void Button::initButton(const std::string text, StellarX::ButtonMode mode, Stell
this->buttonHoverColor = ch; this->buttonHoverColor = ch;
this->click = false; this->click = false;
this->hover = false; this->hover = false;
this->focusPolicy = StellarX::FocusPolicy::Programmatic |
StellarX::FocusPolicy::Pointer | StellarX::FocusPolicy::Tab;
// === Tooltip 默认:文本=按钮文本;白底黑字;不透明;用当前按钮字体样式 === // === Tooltip 默认:文本=按钮文本;白底黑字;不透明;用当前按钮字体样式 ===
tipTextClick = tipTextOn = tipTextOff = this->text; tipTextClick = tipTextOn = tipTextOff = this->text;
@@ -194,8 +196,10 @@ void Button::draw()
} }
else else
{ {
// 点击状态优先级最高,然后是悬停状态,最后是默认状态 // 鼠标/Toggle 的 click 与键盘 Space armed 相互独立,只在绘制层合并。
COLORREF col = click ? buttonTrueColor : (hover ? buttonHoverColor : buttonFalseColor); // 焦点框仍在按钮本体之后绘制,不会被按压色覆盖。
COLORREF col = (click || keyboardArmed)
? buttonTrueColor : (hover ? buttonHoverColor : buttonFalseColor);
setfillcolor(col); setfillcolor(col);
} }
// //
@@ -285,6 +289,13 @@ void Button::draw()
break; break;
} }
if (logicalFocused && width > 6 && height > 6)
{
setlinecolor(RGB(32, 32, 32));
setlinestyle(PS_DOT, 1);
rectangle(x + 3, y + 3, x + width - 3, y + height - 3);
}
restoreStyle();//恢复默认字体样式和颜色 restoreStyle();//恢复默认字体样式和颜色
dirty = false; //标记按钮不需要重绘 dirty = false; //标记按钮不需要重绘
@@ -339,6 +350,7 @@ bool Button::handleEvent(const ExMessage& msg)
// 处理鼠标点击事件 // 处理鼠标点击事件
if (msg.message == WM_LBUTTONDOWN && hover && mode != StellarX::ButtonMode::DISABLED) if (msg.message == WM_LBUTTONDOWN && hover && mode != StellarX::ButtonMode::DISABLED)
{ {
requestFocus(StellarX::FocusReason::Pointer);
if (mode == StellarX::ButtonMode::NORMAL) if (mode == StellarX::ButtonMode::NORMAL)
{ {
click = true; click = true;
@@ -449,6 +461,101 @@ bool Button::handleEvent(const ExMessage& msg)
return consume; 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) void Button::tick(ULONGLONG nowMs)
{ {
// tick 只处理已经命中的按钮;到期后显示 Tooltip,并把重绘交给父容器统一提交。 // tick 只处理已经命中的按钮;到期后显示 Tooltip,并把重绘交给父容器统一提交。
@@ -561,11 +668,32 @@ void Button::setOnToggleOffListener(std::function<void()> callback)
this->onToggleOffCallback = std::move(callback); this->onToggleOffCallback = std::move(callback);
} }
void Button::setbuttonMode(StellarX::ButtonMode mode) void Button::setbuttonMode(StellarX::ButtonMode newMode)
{ {
//取值范围参考 buttMode的枚举注释 //取值范围参考 buttMode的枚举注释
this->mode = mode; if (this->mode != newMode)
setKeyboardArmed(false, false);
this->mode = newMode;
dirty = true; // 标记需要重绘 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) void Button::setROUND_RECTANGLEwidth(int width)
+86 -11
View File
@@ -351,6 +351,20 @@ bool Canvas::handleEvent(const ExMessage& msg)
} }
} }
// Canvas 背景本身也是一个明确的指针命中面:子控件均未处理、且左键
// 按在当前 Canvas 矩形内时,清除逻辑焦点并停止继续向更低层兄弟穿透。
// 模态 Dialog 继承自 Canvas,但阶段 4 接入真实 FocusScope 之前仍需保留
// 底层逻辑焦点,因此这里只对非模态 Canvas/非模态 Dialog 生效。
const bool insideCanvas =
msg.x >= x && msg.x <= x + width &&
msg.y >= y && msg.y <= y + height;
if (!consumed && msg.message == WM_LBUTTONDOWN && insideCanvas && !model())
{
if (Window* host = getHostWindow())
host->getFocusManager().clearFocus(StellarX::FocusReason::Pointer);
consumed = true;
}
if (firstConsumer && !SxIsNoisyMsg(msg.message)) if (firstConsumer && !SxIsNoisyMsg(msg.message))
{ {
SX_LOGD("Event") << SX_T("Canvas 消耗消息: ","Canvas consumed: ") SX_LOGD("Event") << SX_T("Canvas 消耗消息: ","Canvas consumed: ")
@@ -451,9 +465,11 @@ bool Canvas::removeControl(StellarX::ControlHandle handle)
if (!applyingControlMutations && (mutationScopeDepth > 0 || (host && host->isMutationTransactionActive()))) if (!applyingControlMutations && (mutationScopeDepth > 0 || (host && host->isMutationTransactionActive())))
{ {
pendingControlMutations.push_back({ ControlMutationKind::Remove, handle, nullptr }); pendingControlMutations.push_back({ ControlMutationKind::Remove, handle, nullptr });
if (host) host->getFocusManager().notifySubtreePendingRemoval(handle);
return true; return true;
} }
pendingControlMutations.push_back({ ControlMutationKind::Remove, handle, nullptr }); pendingControlMutations.push_back({ ControlMutationKind::Remove, handle, nullptr });
if (host) host->getFocusManager().notifySubtreePendingRemoval(handle);
flushPendingMutations(); flushPendingMutations();
return true; return true;
} }
@@ -462,6 +478,14 @@ void Canvas::clearControls()
{ {
Window* host = getHostWindow(); Window* host = getHostWindow();
pendingControlMutations.push_back({ ControlMutationKind::Clear, {}, nullptr }); pendingControlMutations.push_back({ ControlMutationKind::Clear, {}, nullptr });
if (host)
{
std::vector<StellarX::ControlHandle> removedHandles;
removedHandles.reserve(controls.size());
for (const auto& control : controls)
if (control) removedHandles.push_back(control->getHandle());
host->getFocusManager().notifySubtreesPendingRemoval(removedHandles);
}
if (!applyingControlMutations && mutationScopeDepth == 0 && (!host || !host->isMutationTransactionActive())) if (!applyingControlMutations && mutationScopeDepth == 0 && (!host || !host->isMutationTransactionActive()))
flushPendingMutations(); flushPendingMutations();
} }
@@ -489,6 +513,54 @@ std::vector<StellarX::ControlHandle> Canvas::getControlHandles() const
return handles; return handles;
} }
bool Canvas::queryFocusTargetSnapshot(const StellarX::FocusTargetId& target,
StellarX::FocusTargetSnapshot& snapshot) const
{
if (Control::queryFocusTargetSnapshot(target, snapshot))
return true;
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
if (*it && (*it)->queryFocusTargetSnapshot(target, snapshot)) return true;
return false;
}
void Canvas::collectFocusTargetSnapshots(
std::vector<StellarX::FocusTargetSnapshot>& snapshots) const
{
Control::collectFocusTargetSnapshots(snapshots);
for (const auto& control : controls)
if (control) control->collectFocusTargetSnapshots(snapshots);
}
bool Canvas::applyFocusState(const StellarX::FocusTargetId& target, bool focused,
StellarX::FocusReason reason)
{
if (Control::applyFocusState(target, focused, reason))
return true;
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
if (*it && (*it)->applyFocusState(target, focused, reason)) return true;
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) void Canvas::setShape(StellarX::ControlShape shape)
{ {
switch (shape) switch (shape)
@@ -550,6 +622,9 @@ void Canvas::setIsVisible(bool visible)
} }
if (!visible) if (!visible)
discardBackground(); discardBackground();
// Canvas overrides Control::setIsVisible to propagate visibility to descendants,
// so it must also preserve the base focus-invalidation contract after the subtree settles.
notifyFocusSubtreeStateChanged();
} }
void Canvas::setDirty(bool dirty) void Canvas::setDirty(bool dirty)
@@ -666,20 +741,20 @@ void Canvas::requestRepaint(Control* parent)
if (!child->IsVisible()) if (!child->IsVisible())
continue; 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); 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; return;
+106
View File
@@ -1,4 +1,5 @@
#include "Control.h" #include "Control.h"
#include "FocusManager.h"
#include "SxLog.h" #include "SxLog.h"
#include<assert.h> #include<assert.h>
#include "Window.h" #include "Window.h"
@@ -201,6 +202,7 @@ void Control::setIsVisible(bool show)
{ {
// 隐藏:擦除自己在屏幕上的内容,并释放快照 // 隐藏:擦除自己在屏幕上的内容,并释放快照
discardBackground(); discardBackground();
notifyFocusSubtreeStateChanged();
return; return;
} }
@@ -208,6 +210,110 @@ void Control::setIsVisible(bool show)
// 仅向上标脏,让事件收口阶段由容器统一重绘。 // 仅向上标脏,让事件收口阶段由容器统一重绘。
if (parent) if (parent)
parent->setDirty(true); parent->setDirty(true);
notifyFocusSubtreeStateChanged();
}
StellarX::FocusChangeResult Control::requestFocus(StellarX::FocusReason reason)
{
if (auto* host = getHostWindow())
return host->getFocusManager().requestFocus({ handle, 0 }, reason);
return { false, StellarX::FocusError::InvalidTarget, {} };
}
void Control::setFocusPolicy(StellarX::FocusPolicy policy)
{
if (focusPolicy == policy)
return;
focusPolicy = policy;
notifyFocusSubtreeStateChanged();
}
void Control::setTabIndex(std::int32_t value)
{
if (tabIndex == value)
return;
tabIndex = value;
notifyFocusSubtreeStateChanged();
}
bool Control::isEffectivelyVisible() const
{
const Control* cursor = this;
while (cursor)
{
if (!cursor->show)
return false;
cursor = cursor->parent;
}
return true;
}
bool Control::queryFocusTargetSnapshot(const StellarX::FocusTargetId& target,
StellarX::FocusTargetSnapshot& snapshot) const
{
if (target.owner != handle || target.subNode != 0)
return false;
snapshot = {};
snapshot.id = target;
if (parent)
snapshot.logicalParent = { parent->getHandle(), 0 };
snapshot.policy = focusPolicy;
snapshot.visible = isEffectivelyVisible();
snapshot.enabled = isFocusEnabled();
snapshot.focusable = focusPolicy != StellarX::FocusPolicy::None;
snapshot.tabStop = StellarX::hasFocusPolicy(focusPolicy, StellarX::FocusPolicy::Tab);
snapshot.tabIndex = tabIndex;
return true;
}
void Control::collectFocusTargetSnapshots(
std::vector<StellarX::FocusTargetSnapshot>& snapshots) const
{
StellarX::FocusTargetSnapshot snapshot;
if (queryFocusTargetSnapshot({ handle, 0 }, snapshot) && snapshot.focusable)
snapshots.push_back(snapshot);
}
bool Control::applyFocusState(const StellarX::FocusTargetId& target, bool focused,
StellarX::FocusReason reason)
{
if (target.owner != handle || target.subNode != 0)
return false;
if (logicalFocused == focused)
return true;
logicalFocused = focused;
dirty = true;
requestRepaint(parent);
// 这是本函数最后一次访问 this;回调可以排队删除当前控件。
onLogicalFocusChanged(focused, reason);
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())
host->getFocusManager().notifySubtreeStateChanged(handle);
} }
void Control::onWindowResize() void Control::onWindowResize()
+25 -3
View File
@@ -2,6 +2,7 @@
#include "SxLog.h" #include "SxLog.h"
#include "SxEventLoopInternal.h" #include "SxEventLoopInternal.h"
#include "SxInputProbeInternal.h" #include "SxInputProbeInternal.h"
#include "SxKeyboardRouterInternal.h"
Dialog::Dialog(Window& h, std::string text, std::string message, StellarX::MessageBoxType type, bool modal) 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) : Canvas(), message(message), type(type), modal(modal), hWnd(h), titleText(text)
@@ -174,6 +175,17 @@ void Dialog::Show()
if (modal) 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()) if (!hWnd.hasLiveNativeWindow())
{ {
@@ -196,7 +208,7 @@ void Dialog::Show()
while (show) while (show)
{ {
if (!hWnd.drainNativeInputAtSafePoint()) if (!hWnd.drainNativeInputAtSafePoint(true))
{ {
show = false; show = false;
hWnd.dialogOpen = false; hWnd.dialogOpen = false;
@@ -247,13 +259,22 @@ void Dialog::Show()
[](ExMessage& next) { return peekmessage(&next, EX_MOUSE | EX_KEY); }, [](ExMessage& next) { return peekmessage(&next, EX_MOUSE | EX_KEY); },
[&](const ExMessage& next) { [&](const ExMessage& next) {
StellarX::Internal::inputProbeObserveEasyXMessage(hWnd.getHwnd(), probeBatch, 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; return show;
}); });
processedMessages = static_cast<int>(batch.processed); processedMessages = static_cast<int>(batch.processed);
StellarX::Internal::inputProbeHostBatchEnd( StellarX::Internal::inputProbeHostBatchEnd(
hWnd.getHwnd(), probeBatch, batch.processed, batch.stopped); hWnd.getHwnd(), probeBatch, batch.processed, batch.stopped);
if (!hWnd.drainNativeInputAtSafePoint()) if (!hWnd.drainNativeInputAtSafePoint(true))
{ {
show = false; show = false;
hWnd.dialogOpen = false; hWnd.dialogOpen = false;
@@ -299,6 +320,7 @@ void Dialog::Close()
show = false; show = false;
dirty = true; dirty = true;
pendingCleanup = true; // 只标记需要清理,不立即执行 pendingCleanup = true; // 只标记需要清理,不立即执行
notifyFocusSubtreeStateChanged();
hWnd.dialogClose = true; hWnd.dialogClose = true;
hWnd.requestFullSceneRedraw(); hWnd.requestFullSceneRedraw();
+479
View File
@@ -0,0 +1,479 @@
#include "FocusManager.h"
#include "SxLog.h"
#include "Window.h"
#include <algorithm>
namespace
{
class WindowMutationGuard
{
Window* window = nullptr;
public:
explicit WindowMutationGuard(Window* owner) : window(owner)
{
if (window) window->beginMutationScope();
}
~WindowMutationGuard()
{
if (window) window->endMutationScope();
}
};
class FocusCallbackContextGuard
{
StellarX::FocusTargetId& slot;
public:
FocusCallbackContextGuard(StellarX::FocusTargetId& targetSlot,
StellarX::FocusTargetId target) : slot(targetSlot)
{
slot = target;
}
~FocusCallbackContextGuard()
{
slot = {};
}
};
}
namespace StellarX
{
FocusManager::FocusManager(::Window& owner) : window(&owner)
{
ScopeEntry root;
root.id.value = 1;
scopes.push_back(root);
}
FocusScopeId FocusManager::rootScope() const noexcept
{
return scopes.empty() ? FocusScopeId{} : scopes.front().id;
}
FocusScopeId FocusManager::activeScope() const noexcept
{
return scopes.empty() ? FocusScopeId{} : scopes.back().id;
}
bool FocusManager::querySnapshot(FocusTargetId target, FocusTargetSnapshot& snapshot) const
{
if (!window || !target)
return false;
if (!window->queryFocusTargetSnapshot(target, snapshot))
return false;
snapshot.scope = rootScope();
for (auto it = scopes.rbegin(); it != scopes.rend(); ++it)
{
if (it->root && isWithinSubtree(target, it->root))
{
snapshot.scope = it->id;
break;
}
}
return true;
}
bool FocusManager::isWithinSubtree(FocusTargetId target, FocusTargetId root) const
{
if (!target || !root)
return false;
FocusTargetId cursor = target;
for (std::size_t guard = 0; cursor && guard < MaxReentrantRounds * 4; ++guard)
{
if (cursor == root)
return true;
FocusTargetSnapshot snapshot;
if (!window || !window->queryFocusTargetSnapshot(cursor, snapshot))
return false;
if (snapshot.logicalParent == cursor)
return false;
cursor = snapshot.logicalParent;
}
return false;
}
bool FocusManager::isUnderInvalidatedSubtree(FocusTargetId target) const
{
for (const ControlHandle root : invalidatedSubtrees)
{
if (isWithinSubtree(target, FocusTargetId{ root, 0 }))
return true;
}
return false;
}
bool FocusManager::isWithinActiveScope(FocusTargetId target) const
{
if (scopes.empty() || !scopes.back().root)
return true;
return isWithinSubtree(target, scopes.back().root);
}
FocusPolicy FocusManager::requiredPolicy(FocusReason reason) const noexcept
{
switch (reason)
{
case FocusReason::Pointer:
return FocusPolicy::Pointer;
case FocusReason::TabTraversal:
case FocusReason::InvalidationFallback:
return FocusPolicy::Tab;
case FocusReason::Programmatic:
case FocusReason::ScopeActivation:
case FocusReason::ScopeRestore:
default:
return FocusPolicy::Programmatic;
}
}
FocusError FocusManager::validateCurrentTarget(FocusTargetId target,
FocusTargetSnapshot* result) const
{
if (!target)
return FocusError::InvalidTarget;
FocusTargetSnapshot snapshot;
if (!querySnapshot(target, snapshot) || isUnderInvalidatedSubtree(target))
return FocusError::InvalidTarget;
if (!snapshot.focusable)
return FocusError::NotFocusable;
if (!snapshot.visible)
return FocusError::NotVisible;
if (!snapshot.enabled)
return FocusError::Disabled;
if (!isWithinActiveScope(target))
return FocusError::OutsideActiveScope;
if (result) *result = snapshot;
return FocusError::None;
}
FocusError FocusManager::validateTarget(FocusTargetId target, FocusReason reason,
FocusTargetSnapshot* result) const
{
const FocusError currentError = validateCurrentTarget(target, result);
if (currentError != FocusError::None)
return currentError;
FocusTargetSnapshot snapshot;
if (!result)
{
if (!querySnapshot(target, snapshot))
return FocusError::InvalidTarget;
result = &snapshot;
}
if (!hasFocusPolicy(result->policy, requiredPolicy(reason)))
return FocusError::NotFocusable;
return FocusError::None;
}
void FocusManager::queueReentrantRequest(FocusTargetId target, FocusReason reason) noexcept
{
pending.target = target;
pending.reason = reason;
pendingPresent = true;
}
FocusChangeResult FocusManager::performTransition(FocusTargetId target, FocusReason reason)
{
FocusChangeResult result;
result.focused = activeTarget;
if (target)
{
result.error = validateTarget(target, reason);
if (result.error != FocusError::None)
return result;
}
if (target == activeTarget)
return result;
const FocusTargetId oldTarget = activeTarget;
activeTarget = {};
if (oldTarget)
{
FocusCallbackContextGuard callbackContext(callbackFocusContext, oldTarget);
window->applyFocusState(oldTarget, false, reason);
}
// onFocusLost may delete, hide, or disable the new target. Pull a fresh snapshot.
if (target)
{
result.error = validateTarget(target, reason);
if (result.error != FocusError::None)
{
result.changed = static_cast<bool>(oldTarget);
result.focused = activeTarget;
return result;
}
activeTarget = target;
FocusCallbackContextGuard callbackContext(callbackFocusContext, target);
window->applyFocusState(target, true, reason);
}
result.changed = oldTarget != activeTarget;
result.focused = activeTarget;
return result;
}
std::vector<FocusTargetSnapshot> FocusManager::collectOrderedTargets(bool tabStopsOnly) const
{
std::vector<FocusTargetSnapshot> snapshots;
if (!window)
return snapshots;
window->collectFocusTargetSnapshots(snapshots);
std::vector<FocusTargetSnapshot> filtered;
filtered.reserve(snapshots.size());
for (auto snapshot : snapshots)
{
FocusTargetSnapshot current;
if (validateCurrentTarget(snapshot.id, &current) != FocusError::None)
continue;
if (tabStopsOnly && !current.tabStop)
continue;
filtered.push_back(current);
}
std::stable_sort(filtered.begin(), filtered.end(), [](const auto& left, const auto& right)
{ return left.tabIndex < right.tabIndex; });
return filtered;
}
std::vector<FocusTargetSnapshot> FocusManager::collectTabStops() const
{
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;
if (window) window->collectFocusTargetSnapshots(all);
std::stable_sort(all.begin(), all.end(), [](const auto& left, const auto& right)
{ return left.tabIndex < right.tabIndex; });
auto invalidIt = std::find_if(all.begin(), all.end(), [&](const auto& item)
{ return item.id == invalidTarget; });
const std::size_t invalidIndex = invalidIt == all.end()
? 0 : static_cast<std::size_t>(std::distance(all.begin(), invalidIt));
for (std::size_t i = invalidIndex + (invalidIt == all.end() ? 0 : 1); i < all.size(); ++i)
{
if (validateTarget(all[i].id, FocusReason::InvalidationFallback) == FocusError::None)
return all[i].id;
}
for (std::size_t i = invalidIndex; i > 0; --i)
{
if (validateTarget(all[i - 1].id, FocusReason::InvalidationFallback) == FocusError::None)
return all[i - 1].id;
}
if (!scopes.empty() && scopes.back().initialFocus &&
validateTarget(scopes.back().initialFocus, FocusReason::ScopeActivation) == FocusError::None)
return scopes.back().initialFocus;
return {};
}
FocusChangeResult FocusManager::requestFocus(FocusTargetId target, FocusReason reason)
{
if (transitionActive)
{
queueReentrantRequest(target, reason);
return { false, FocusError::None, activeTarget };
}
WindowMutationGuard mutation(window);
transitionActive = true;
lastError = FocusError::None;
FocusRequest request{ target, reason };
FocusChangeResult aggregate{ false, FocusError::None, activeTarget };
bool continueTransition = true;
std::size_t round = 0;
while (continueTransition && round < MaxReentrantRounds)
{
++round;
pendingPresent = false;
const FocusChangeResult step = performTransition(request.target, request.reason);
aggregate.changed = aggregate.changed || step.changed;
if (round == 1 && step.error != FocusError::None)
aggregate.error = step.error;
const bool activeStillValid = !activeTarget ||
validateCurrentTarget(activeTarget) == FocusError::None;
bool selectedNext = false;
if (pendingPresent)
{
const FocusRequest latest = pending;
pendingPresent = false;
// Single-slot latest-wins: validate only the final request; never revive older requests.
if (!latest.target || validateTarget(latest.target, latest.reason) == FocusError::None)
{
request = latest;
selectedNext = true;
}
}
if (!selectedNext && !activeStillValid)
{
const FocusTargetId invalid = activeTarget;
request = { findInvalidationFallback(invalid), FocusReason::InvalidationFallback };
selectedNext = true;
}
continueTransition = selectedNext;
}
if (continueTransition)
{
pendingPresent = false;
lastError = FocusError::ReentrancyLimit;
aggregate.error = FocusError::ReentrancyLimit;
SX_LOGE("Focus") << "focus reentrancy limit reached: rounds=" << MaxReentrantRounds;
}
else if (aggregate.error != FocusError::None)
{
lastError = aggregate.error;
}
transitionActive = false;
aggregate.focused = activeTarget;
return aggregate;
}
FocusScopeId FocusManager::pushScope(FocusTargetId scopeRoot, FocusTargetId initialFocus)
{
FocusTargetSnapshot rootSnapshot;
if (!scopeRoot || !querySnapshot(scopeRoot, rootSnapshot) || isUnderInvalidatedSubtree(scopeRoot))
{
lastError = FocusError::InvalidScope;
return {};
}
ScopeEntry entry;
entry.id.value = nextScopeValue++;
entry.root = scopeRoot;
entry.initialFocus = initialFocus;
entry.restoreFocus = callbackFocusContext ? callbackFocusContext : activeTarget;
scopes.push_back(entry);
FocusTargetId target = initialFocus;
if (!target || validateTarget(target, FocusReason::ScopeActivation) != FocusError::None)
{
const auto stops = collectTabStops();
target = stops.empty() ? FocusTargetId{} : stops.front().id;
}
requestFocus(target, FocusReason::ScopeActivation);
return entry.id;
}
bool FocusManager::popScope(FocusScopeId scope)
{
if (scopes.size() <= 1 || scopes.back().id != scope)
{
lastError = FocusError::InvalidScope;
return false;
}
const FocusTargetId restore = scopes.back().restoreFocus;
scopes.pop_back();
if (restore && validateTarget(restore, FocusReason::ScopeRestore) == FocusError::None)
requestFocus(restore, FocusReason::ScopeRestore);
else
{
const auto stops = collectTabStops();
requestFocus(stops.empty() ? FocusTargetId{} : stops.front().id,
FocusReason::ScopeRestore);
}
return true;
}
void FocusManager::normalizeInvalidScopes()
{
while (scopes.size() > 1)
{
FocusTargetSnapshot snapshot;
if (querySnapshot(scopes.back().root, snapshot) &&
!isUnderInvalidatedSubtree(scopes.back().root))
break;
scopes.pop_back();
}
}
void FocusManager::notifySubtreePendingRemoval(ControlHandle rootHandle)
{
if (!rootHandle)
return;
notifySubtreesPendingRemoval({ rootHandle });
}
void FocusManager::notifySubtreesPendingRemoval(const std::vector<ControlHandle>& rootHandles)
{
for (const ControlHandle rootHandle : rootHandles)
{
if (!rootHandle)
continue;
if (std::find(invalidatedSubtrees.begin(), invalidatedSubtrees.end(), rootHandle) == invalidatedSubtrees.end())
invalidatedSubtrees.push_back(rootHandle);
}
normalizeInvalidScopes();
bool activeRemoved = false;
for (const ControlHandle rootHandle : rootHandles)
{
if (rootHandle && activeTarget &&
isWithinSubtree(activeTarget, FocusTargetId{ rootHandle, 0 }))
{
activeRemoved = true;
break;
}
}
if (!activeRemoved)
return;
if (transitionActive)
return;
const FocusTargetId invalid = activeTarget;
requestFocus(findInvalidationFallback(invalid), FocusReason::InvalidationFallback);
}
void FocusManager::notifySubtreeStateChanged(ControlHandle rootHandle)
{
if (!rootHandle || !activeTarget ||
!isWithinSubtree(activeTarget, FocusTargetId{ rootHandle, 0 }))
return;
if (validateCurrentTarget(activeTarget) == FocusError::None || transitionActive)
return;
const FocusTargetId invalid = activeTarget;
requestFocus(findInvalidationFallback(invalid), FocusReason::InvalidationFallback);
}
void FocusManager::onMutationCommitted()
{
invalidatedSubtrees.clear();
normalizeInvalidScopes();
}
}
+238
View File
@@ -0,0 +1,238 @@
#include "internal/SxKeyboardRouterInternal.h"
#include "FocusManager.h"
#include "Window.h"
#include <algorithm>
#include <array>
#include <variant>
namespace
{
constexpr std::size_t MaxKeyboardRouteDepth = 256;
using RoutePath = std::array<StellarX::FocusTargetId, MaxKeyboardRouteDepth>;
class ManagedKeyboardDispatchGuard
{
Window& window;
bool& managedDispatchActive;
bool previousManagedDispatchActive = false;
public:
ManagedKeyboardDispatchGuard(Window& owner, bool& dispatchFlag)
: window(owner), managedDispatchActive(dispatchFlag),
previousManagedDispatchActive(dispatchFlag)
{
window.beginMutationScope();
managedDispatchActive = true;
}
~ManagedKeyboardDispatchGuard()
{
// Restore the outer state before leaving the mutation scope. Only the
// true outermost event may commit pending mutations.
managedDispatchActive = previousManagedDispatchActive;
window.endMutationScope();
}
};
}
namespace StellarX::Internal
{
bool KeyboardRouter::buildOriginalPath(FocusManager& focus,
FocusTargetId originalTarget, FocusTargetId* path,
std::size_t capacity, std::size_t& pathLength)
{
pathLength = 0;
FocusTargetId cursor = originalTarget;
while (cursor && pathLength < capacity)
{
if (std::find(path, path + pathLength, cursor) != path + pathLength)
return false;
FocusTargetSnapshot snapshot;
if (!focus.querySnapshot(cursor, snapshot) ||
focus.isUnderInvalidatedSubtree(cursor))
return false;
path[pathLength++] = cursor;
cursor = snapshot.logicalParent;
}
return !cursor;
}
KeyboardRouteDispatchResult KeyboardRouter::suspendFocusedPath(::Window& window)
{
KeyboardRouteDispatchResult outcome;
ManagedKeyboardDispatchGuard dispatchGuard(window, window.managedDispatchActive);
FocusManager& focus = window.getFocusManager();
const FocusTargetId originalTarget = focus.focusedTarget();
outcome.originalTarget = originalTarget;
outcome.hadFocusedTarget = static_cast<bool>(originalTarget);
if (!originalTarget)
{
outcome.pathComplete = true;
return outcome;
}
RoutePath path{};
std::size_t pathLength = 0;
if (!buildOriginalPath(focus, originalTarget, path.data(), path.size(), pathLength))
return outcome;
outcome.pathComplete = true;
outcome.pathLength = pathLength;
for (std::size_t index = 0; index < pathLength; ++index)
{
const FocusTargetId target = path[index];
FocusTargetSnapshot freshSnapshot;
if (!focus.querySnapshot(target, freshSnapshot) ||
focus.isUnderInvalidatedSubtree(target))
{
++outcome.skippedTargets;
continue;
}
if (!window.dispatchKeyboardSuspensionToTarget(target))
{
++outcome.skippedTargets;
continue;
}
++outcome.deliveredTargets;
outcome.focusedPathSuspended = true;
}
return outcome;
}
KeyboardRouteDispatchResult KeyboardRouter::route(
::Window& window, const NativeInputEvent& nativeEvent,
const KeyboardRoutingEnvironment& environment)
{
if (const auto* activation =
std::get_if<WindowActivationEvent>(&nativeEvent.payload))
{
KeyboardRouteDispatchResult outcome;
outcome.activationEvent = true;
if (!activation->keyboardFocusActive)
{
outcome = suspendFocusedPath(window);
outcome.activationEvent = true;
}
else
{
FocusManager& focus = window.getFocusManager();
const FocusTargetId focused = focus.focusedTarget();
outcome.originalTarget = focused;
outcome.hadFocusedTarget = static_cast<bool>(focused);
if (focused)
focus.notifySubtreeStateChanged(focused.owner);
}
return outcome;
}
KeyboardRouteDispatchResult outcome;
const auto* key = std::get_if<KeyEvent>(&nativeEvent.payload);
if (!key)
return outcome;
outcome.keyEvent = true;
outcome.suppressedByModal = environment.modalBusinessSuppressed;
outcome.suppressedByInactiveWindow =
!nativeEvent.activationAtCapture.keyboardFocusActive ||
!environment.currentActivation.keyboardFocusActive;
outcome.suppressedByStaleActivation =
nativeEvent.activationAtCapture.generation == 0 ||
environment.currentActivation.generation == 0 ||
nativeEvent.activationAtCapture.generation !=
environment.currentActivation.generation;
outcome.suppressedByIme =
key->isImeOwnedAtCapture() || key->imeComposingAtCapture;
outcome.suppressedBySystemKey =
key->systemKey || key->altContext || key->modifiers.alt();
if (outcome.suppressedByModal || outcome.suppressedByInactiveWindow ||
outcome.suppressedByStaleActivation || outcome.suppressedByIme ||
outcome.suppressedBySystemKey)
return outcome;
const bool isTabTraversal = key->action == KeyAction::Down &&
key->virtualKey == VK_TAB && !key->modifiers.control();
ManagedKeyboardDispatchGuard dispatchGuard(window, window.managedDispatchActive);
FocusManager& focus = window.getFocusManager();
const FocusTargetId originalTarget = focus.focusedTarget();
outcome.originalTarget = originalTarget;
outcome.hadFocusedTarget = static_cast<bool>(originalTarget);
if (!originalTarget)
{
outcome.pathComplete = true;
if (isTabTraversal)
{
outcome.tabTraversalAttempted = true;
const FocusChangeResult focusResult =
focus.advanceTabFocus(key->modifiers.shift());
outcome.tabFocusChanged = focusResult.changed;
outcome.tabFocusedTarget = focusResult.focused;
}
return outcome;
}
// The path stores stable identities only. Focus/tree changes made by a
// callback affect the next event, not this event's original path.
RoutePath path{};
std::size_t pathLength = 0;
if (!buildOriginalPath(focus, originalTarget, path.data(), path.size(), pathLength))
return outcome;
outcome.pathComplete = true;
outcome.pathLength = pathLength;
RoutedKeyEvent event;
event.sequence = nativeEvent.sequence;
event.messageTime = nativeEvent.messageTime;
event.hwnd = nativeEvent.hwnd;
event.key = *key;
event.originalTarget = originalTarget;
for (std::size_t index = 0; index < pathLength; ++index)
{
const FocusTargetId target = path[index];
// Earlier callbacks may remove later nodes. Resolve every identity again
// and reject pending-removal subtrees; no borrowed pointer survives.
FocusTargetSnapshot freshSnapshot;
if (!focus.querySnapshot(target, freshSnapshot) ||
focus.isUnderInvalidatedSubtree(target))
{
++outcome.skippedTargets;
continue;
}
KeyRouteResult result;
if (!window.dispatchKeyEventToTarget(target, event, result))
{
++outcome.skippedTargets;
continue;
}
++outcome.deliveredTargets;
outcome.frameworkDefaultPrevented =
outcome.frameworkDefaultPrevented || result.preventFrameworkDefault;
if (result.stopBubble)
{
outcome.bubbleStopped = true;
break;
}
}
// Tab is a framework default, not a leaf-control command. It runs only
// after the original focus path has completed, so a control or container
// may independently stop bubbling and/or suppress traversal. The focus
// manager pulls a fresh tree here; no pre-callback snapshot is reused.
if (isTabTraversal && !outcome.frameworkDefaultPrevented)
{
outcome.tabTraversalAttempted = true;
const FocusChangeResult focusResult =
focus.advanceTabFocus(key->modifiers.shift());
outcome.tabFocusChanged = focusResult.changed;
outcome.tabFocusedTarget = focusResult.focused;
}
return outcome;
}
}
+106 -24
View File
@@ -18,6 +18,40 @@ namespace StellarX::Internal
namespace namespace
{ {
std::atomic<std::uint64_t> nextNativeInputSequence{ 0 }; 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>, static_assert(std::is_nothrow_move_constructible_v<TextInputEvent>,
"TextInputEvent must transfer its wstring without allocating during queue publication."); "TextInputEvent must transfer its wstring without allocating during queue publication.");
@@ -252,7 +286,8 @@ namespace StellarX::Internal
}; };
explicit Impl(HWND window) explicit Impl(HWND window)
: hwnd(window), windowUnicode(window && IsWindowUnicode(window) != FALSE) : hwnd(window), windowUnicode(window && IsWindowUnicode(window) != FALSE),
activationState(initialActivationState(window))
{ {
DWORD processId = 0; DWORD processId = 0;
windowThreadId = window ? GetWindowThreadProcessId(window, &processId) : GetCurrentThreadId(); windowThreadId = window ? GetWindowThreadProcessId(window, &processId) : GetCurrentThreadId();
@@ -274,6 +309,7 @@ namespace StellarX::Internal
std::uint64_t nextCompositionId = 0; std::uint64_t nextCompositionId = 0;
std::uint64_t activeCompositionId = 0; std::uint64_t activeCompositionId = 0;
EchoState echo; EchoState echo;
std::atomic<std::uint64_t> activationState;
NativeInputQueue queue; NativeInputQueue queue;
NativeInputObservationStats observations; NativeInputObservationStats observations;
@@ -318,6 +354,30 @@ namespace StellarX::Internal
echo = {}; 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 void snapshotKeyboardLayout(HKL layout) noexcept
{ {
const HKL actual = layout ? layout : GetKeyboardLayout(0); 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.sequence = nextNativeInputSequence.fetch_add(1, std::memory_order_relaxed) + 1;
event.messageTime = static_cast<DWORD>(GetMessageTime()); event.messageTime = static_cast<DWORD>(GetMessageTime());
event.hwnd = window; event.hwnd = window;
event.activationAtCapture = activationSnapshot();
event.payload = std::forward<Payload>(payload); event.payload = std::forward<Payload>(payload);
const std::uint64_t sequence = event.sequence; const std::uint64_t sequence = event.sequence;
const NativeInputQueuePushResult result = queue.push(std::move(event)); const NativeInputQueuePushResult result = queue.push(std::move(event));
@@ -806,6 +867,7 @@ namespace StellarX::Internal
break; break;
case WM_SETFOCUS: case WM_SETFOCUS:
publishActivation(true);
clearTextDecoderState(); clearTextDecoderState();
clearEcho(); clearEcho();
snapshotKeyboardLayout(nullptr); snapshotKeyboardLayout(nullptr);
@@ -818,6 +880,7 @@ namespace StellarX::Internal
break; break;
case WM_KILLFOCUS: case WM_KILLFOCUS:
publishActivation(false);
clearTextDecoderState(); clearTextDecoderState();
clearEcho(); clearEcho();
{ {
@@ -870,6 +933,42 @@ namespace StellarX::Internal
return {}; 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 std::size_t NativeInputContext::drainObserved(std::size_t limit) noexcept
{ {
if (!impl_ || limit == 0) if (!impl_ || limit == 0)
@@ -879,33 +978,11 @@ namespace StellarX::Internal
while (drained < limit) while (drained < limit)
{ {
NativeInputEvent event; NativeInputEvent event;
const NativeInputQueuePopResult result = impl_->queue.tryPop(event); const NativeInputQueuePopResult result = tryPopObserved(event);
if (result == NativeInputQueuePopResult::Empty) if (result == NativeInputQueuePopResult::Empty)
break; break;
if (result == NativeInputQueuePopResult::UnexpectedFailure) if (result == NativeInputQueuePopResult::UnexpectedFailure)
{
impl_->publishFault(NativeInputFaultCode::QueueFailure, 0, 0, impl_->hwnd);
break; 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; ++drained;
} }
return drained; return drained;
@@ -933,6 +1010,11 @@ namespace StellarX::Internal
return impl_ ? impl_->observations : NativeInputObservationStats{}; return impl_ ? impl_->observations : NativeInputObservationStats{};
} }
NativeInputActivationSnapshot NativeInputContext::activationSnapshot() const noexcept
{
return impl_ ? impl_->activationSnapshot() : NativeInputActivationSnapshot{};
}
NativeInputNormalizationStats NativeInputContext::normalizationStats() const noexcept NativeInputNormalizationStats NativeInputContext::normalizationStats() const noexcept
{ {
NativeInputNormalizationStats snapshot; NativeInputNormalizationStats snapshot;
+63
View File
@@ -351,6 +351,69 @@ bool TabControl::clearTransientMouseState()
return changed; return changed;
} }
bool TabControl::queryFocusTargetSnapshot(const StellarX::FocusTargetId& target,
StellarX::FocusTargetSnapshot& snapshot) const
{
if (Canvas::queryFocusTargetSnapshot(target, snapshot))
return true;
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
{
if (it->first && it->first->queryFocusTargetSnapshot(target, snapshot)) return true;
if (it->second && it->second->queryFocusTargetSnapshot(target, snapshot)) return true;
}
return false;
}
void TabControl::collectFocusTargetSnapshots(
std::vector<StellarX::FocusTargetSnapshot>& snapshots) const
{
Canvas::collectFocusTargetSnapshots(snapshots);
for (const auto& tab : controls)
{
if (tab.first) tab.first->collectFocusTargetSnapshots(snapshots);
if (tab.second) tab.second->collectFocusTargetSnapshots(snapshots);
}
}
bool TabControl::applyFocusState(const StellarX::FocusTargetId& target, bool focused,
StellarX::FocusReason reason)
{
if (Canvas::applyFocusState(target, focused, reason))
return true;
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
{
if (it->first && it->first->applyFocusState(target, focused, reason)) return true;
if (it->second && it->second->applyFocusState(target, focused, reason)) return true;
}
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) size_t TabControl::addTab(std::unique_ptr<Button> button, std::unique_ptr<Canvas> page)
{ {
if (!button || !page) if (!button || !page)
+88
View File
@@ -262,6 +262,7 @@ void Table::initButton()
if (currentPage > 1) if (currentPage > 1)
{ {
--currentPage; --currentPage;
notifyFocusSubtreeStateChanged();
SX_LOGI("Table") SX_LOGI("Table")
<< SX_T("翻页:id=", "page change: id=") << id << SX_T("翻页:id=", "page change: id=") << id
<< " " << oldPage << "->" << currentPage << " " << oldPage << "->" << currentPage
@@ -279,6 +280,7 @@ void Table::initButton()
if (currentPage < totalPages) if (currentPage < totalPages)
{ {
++currentPage; ++currentPage;
notifyFocusSubtreeStateChanged();
SX_LOGI("Table") SX_LOGI("Table")
<< SX_T("翻页:id=", "page change: id=") << id << SX_T("翻页:id=", "page change: id=") << id
<< " " << oldPage << "->" << currentPage << " " << oldPage << "->" << currentPage
@@ -661,6 +663,85 @@ bool Table::clearTransientMouseState()
return changed; return changed;
} }
bool Table::queryFocusTargetSnapshot(const StellarX::FocusTargetId& target,
StellarX::FocusTargetSnapshot& snapshot) const
{
if (Control::queryFocusTargetSnapshot(target, snapshot))
return true;
if (prevButton && prevButton->queryFocusTargetSnapshot(target, snapshot))
{
snapshot.visible = snapshot.visible && isShowPageButton;
snapshot.enabled = snapshot.enabled && currentPage > 1;
return true;
}
if (nextButton && nextButton->queryFocusTargetSnapshot(target, snapshot))
{
snapshot.visible = snapshot.visible && isShowPageButton;
snapshot.enabled = snapshot.enabled && currentPage < totalPages;
return true;
}
return false;
}
void Table::collectFocusTargetSnapshots(
std::vector<StellarX::FocusTargetSnapshot>& snapshots) const
{
Control::collectFocusTargetSnapshots(snapshots);
StellarX::FocusTargetSnapshot snapshot;
if (prevButton && queryFocusTargetSnapshot({ prevButton->getHandle(), 0 }, snapshot))
snapshots.push_back(snapshot);
if (nextButton && queryFocusTargetSnapshot({ nextButton->getHandle(), 0 }, snapshot))
snapshots.push_back(snapshot);
}
bool Table::applyFocusState(const StellarX::FocusTargetId& target, bool focused,
StellarX::FocusReason reason)
{
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;
}
RECT Table::getManagedRepaintCoverageRect() const RECT Table::getManagedRepaintCoverageRect() const
{ {
// coverage 包含表体、页码和分页按钮的实际绘制范围,而不只包含表格本体。 // coverage 包含表体、页码和分页按钮的实际绘制范围,而不只包含表格本体。
@@ -749,6 +830,7 @@ void Table::setHeaders(std::initializer_list<std::string> headers)
isNeedDrawHeaders = true; // 标记需要重新绘制表头 isNeedDrawHeaders = true; // 标记需要重新绘制表头
isNeedButtonAndPageNum = true; isNeedButtonAndPageNum = true;
dirty = true; dirty = true;
notifyFocusSubtreeStateChanged();
} }
@@ -765,6 +847,7 @@ void Table::setData(std::vector<std::string> data)
isNeedCellSize = true; isNeedCellSize = true;
isNeedButtonAndPageNum = true; isNeedButtonAndPageNum = true;
dirty = true; dirty = true;
notifyFocusSubtreeStateChanged();
SX_LOGI("Table") SX_LOGI("Table")
<< SX_T("新增Data:id=", "appendRow: id=") << id << SX_T("新增Data:id=", "appendRow: id=") << id
@@ -789,6 +872,7 @@ void Table::setData(std::initializer_list<std::vector<std::string>> data)
isNeedCellSize = true; // 标记需要重新计算单元格尺寸 isNeedCellSize = true; // 标记需要重新计算单元格尺寸
isNeedButtonAndPageNum = true; isNeedButtonAndPageNum = true;
dirty = true; dirty = true;
notifyFocusSubtreeStateChanged();
SX_LOGI("Table") SX_LOGI("Table")
<< SX_T("新增多行Data:id=", "appendRows: id=") << id << SX_T("新增多行Data:id=", "appendRows: id=") << id
<< SX_T(" 新增行数=", " addedRows=") << addedRows << SX_T(" 新增行数=", " addedRows=") << addedRows
@@ -809,12 +893,14 @@ void Table::setRowsPerPage(int rows)
isNeedCellSize = true; // 标记需要重新计算单元格尺寸 isNeedCellSize = true; // 标记需要重新计算单元格尺寸
isNeedButtonAndPageNum = true; isNeedButtonAndPageNum = true;
dirty = true; dirty = true;
notifyFocusSubtreeStateChanged();
} }
void Table::showPageButton(bool isShow) void Table::showPageButton(bool isShow)
{ {
this->isShowPageButton = isShow; this->isShowPageButton = isShow;
this->dirty = true; this->dirty = true;
notifyFocusSubtreeStateChanged();
} }
void Table::setTableBorder(COLORREF color) void Table::setTableBorder(COLORREF color)
@@ -868,6 +954,7 @@ void Table::clearHeaders()
isNeedDrawHeaders = true; // 标记需要重新绘制表头 isNeedDrawHeaders = true; // 标记需要重新绘制表头
isNeedButtonAndPageNum = true;// 标记需要重新计算翻页按钮和页码信息 isNeedButtonAndPageNum = true;// 标记需要重新计算翻页按钮和页码信息
dirty = true; dirty = true;
notifyFocusSubtreeStateChanged();
SX_LOGI("Table") << SX_T("清除表头:id=","clearHeaders: id=" )<< id; SX_LOGI("Table") << SX_T("清除表头:id=","clearHeaders: id=" )<< id;
} }
@@ -880,6 +967,7 @@ void Table::clearData()
isNeedCellSize = true; // 标记需要重新计算单元格尺寸 isNeedCellSize = true; // 标记需要重新计算单元格尺寸
isNeedButtonAndPageNum = true;// 标记需要重新计算翻页按钮和页码信息 isNeedButtonAndPageNum = true;// 标记需要重新计算翻页按钮和页码信息
dirty = true; dirty = true;
notifyFocusSubtreeStateChanged();
SX_LOGI("Table") << SX_T("清除表格数据:id=","clearData: id=") << id; SX_LOGI("Table") << SX_T("清除表格数据:id=","clearData: id=") << id;
} }
+16 -1
View File
@@ -94,6 +94,8 @@ TextBox::TextBox(int x, int y, int width, int height, std::string text, StellarX
// - 垂直方向当前实现保持固定尺寸,不在本轮引入随高度变化的视觉自适应 // - 垂直方向当前实现保持固定尺寸,不在本轮引入随高度变化的视觉自适应
this->layoutCapability.allowStretchX = true; this->layoutCapability.allowStretchX = true;
this->layoutCapability.allowStretchY = false; this->layoutCapability.allowStretchY = false;
this->focusPolicy = StellarX::FocusPolicy::Programmatic |
StellarX::FocusPolicy::Pointer | StellarX::FocusPolicy::Tab;
} }
void TextBox::draw() void TextBox::draw()
@@ -169,6 +171,12 @@ void TextBox::draw()
outtextxy(x + 10, (y + (height - text_height) / 2), LPCTSTR(displayText.c_str())); outtextxy(x + 10, (y + (height - text_height) / 2), LPCTSTR(displayText.c_str()));
break; break;
} }
if (logicalFocused && width > 6 && height > 6)
{
setlinecolor(RGB(32, 96, 192));
setlinestyle(PS_DOT, 1);
rectangle(x + 3, y + 3, x + width - 3, y + height - 3);
}
restoreStyle(); restoreStyle();
dirty = false; //标记不需要重绘 dirty = false; //标记不需要重绘
} }
@@ -177,10 +185,10 @@ void TextBox::draw()
bool TextBox::handleEvent(const ExMessage& msg) bool TextBox::handleEvent(const ExMessage& msg)
{ {
if (!show) return false; if (!show) return false;
auto mutationScope = enterMutationScope();
resetEventVisualChanged(); resetEventVisualChanged();
bool hover = false; bool hover = false;
bool oldClick = click;
bool consume = false; bool consume = false;
const bool isMouseMessage = const bool isMouseMessage =
msg.message == WM_MOUSEMOVE || msg.message == WM_MOUSEMOVE ||
@@ -202,6 +210,12 @@ bool TextBox::handleEvent(const ExMessage& msg)
} }
} }
if (hover && msg.message == WM_LBUTTONDOWN)
{
requestFocus(StellarX::FocusReason::Pointer);
consume = true;
}
if (hover && msg.message == WM_LBUTTONUP) if (hover && msg.message == WM_LBUTTONUP)
{ {
click = true; click = true;
@@ -257,6 +271,7 @@ void TextBox::setMode(StellarX::TextBoxmode mode)
{ {
this->mode = mode; this->mode = mode;
this->dirty = true; this->dirty = true;
notifyFocusSubtreeStateChanged();
} }
void TextBox::setMaxCharLen(size_t len) void TextBox::setMaxCharLen(size_t len)
+193 -4
View File
@@ -4,6 +4,7 @@
#include "SxEventLoopInternal.h" #include "SxEventLoopInternal.h"
#include "SxInputProbeInternal.h" #include "SxInputProbeInternal.h"
#include "SxNativeInputInternal.h" #include "SxNativeInputInternal.h"
#include "SxKeyboardRouterInternal.h"
#include <easyx.h> #include <easyx.h>
#include <algorithm> #include <algorithm>
#include <cstdint> #include <cstdint>
@@ -47,6 +48,55 @@ static RECT SxUnionRect(const RECT& a, const RECT& b)
return out; 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, static void collectManagedControlOverlays(const std::vector<std::unique_ptr<Control>>& controls,
Control* repaintRoot, const RECT& coverage, std::vector<Control*>& overlays); Control* repaintRoot, const RECT& coverage, std::vector<Control*>& overlays);
@@ -138,6 +188,8 @@ void Window::flushPendingMutationsInTree()
if (control) control->flushPendingMutations(); if (control) control->flushPendingMutations();
for (auto& dialog : dialogs) for (auto& dialog : dialogs)
if (dialog) dialog->flushPendingMutations(); if (dialog) dialog->flushPendingMutations();
if (focusManager)
focusManager->onMutationCommitted();
} }
void Window::markAllControlsDirty() void Window::markAllControlsDirty()
@@ -647,6 +699,7 @@ Window::Window(int w, int h, int mode)
localwidth = minClientW = pendingW = width = w; localwidth = minClientW = pendingW = width = w;
localheight = minClientH = pendingH = height = h; localheight = minClientH = pendingH = height = h;
windowMode = mode; windowMode = mode;
focusManager = std::make_unique<StellarX::FocusManager>(*this);
} }
Window::Window(int w, int h, int mode, COLORREF bk) Window::Window(int w, int h, int mode, COLORREF bk)
@@ -655,6 +708,7 @@ Window::Window(int w, int h, int mode, COLORREF bk)
localheight = minClientH = pendingH = height = h; localheight = minClientH = pendingH = height = h;
windowMode = mode; windowMode = mode;
wBkcolor = bk; wBkcolor = bk;
focusManager = std::make_unique<StellarX::FocusManager>(*this);
} }
Window::Window(int w, int h, int mode, COLORREF bk, std::string title) Window::Window(int w, int h, int mode, COLORREF bk, std::string title)
@@ -664,6 +718,7 @@ Window::Window(int w, int h, int mode, COLORREF bk, std::string title)
windowMode = mode; windowMode = mode;
wBkcolor = bk; wBkcolor = bk;
headline = std::move(title); headline = std::move(title);
focusManager = std::make_unique<StellarX::FocusManager>(*this);
} }
bool Window::hasLiveNativeWindow() const noexcept bool Window::hasLiveNativeWindow() const noexcept
@@ -675,13 +730,40 @@ bool Window::hasLiveNativeWindow() const noexcept
return hWnd != nullptr && IsWindow(hWnd) != FALSE; return hWnd != nullptr && IsWindow(hWnd) != FALSE;
} }
bool Window::drainNativeInputAtSafePoint() bool Window::drainNativeInputAtSafePoint(bool modalBusinessSuppressed)
{ {
if (!nativeInputContext) if (!nativeInputContext)
return true; return true;
// 逐条移动并观测,既不预取整批事件,也不进入焦点或控件路由。 if (StellarX::Internal::keyboardPipelineMode(*this) ==
nativeInputContext->drainObserved(StellarX::Internal::MessageBatchLimit); 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(); const auto normalization = nativeInputContext->normalizationStats();
if (normalization.rejectedTextSequences != nativeInputRejectedTextReported || if (normalization.rejectedTextSequences != nativeInputRejectedTextReported ||
normalization.imeQueryFailures != nativeInputImeQueryFailuresReported || normalization.imeQueryFailures != nativeInputImeQueryFailuresReported ||
@@ -722,6 +804,10 @@ StellarX::Internal::inspectWindowNativeInputForTesting(const ::Window& window) n
snapshot.queue = window.nativeInputContext->queueStats(); snapshot.queue = window.nativeInputContext->queueStats();
snapshot.observations = window.nativeInputContext->observationStats(); snapshot.observations = window.nativeInputContext->observationStats();
snapshot.normalization = window.nativeInputContext->normalizationStats(); snapshot.normalization = window.nativeInputContext->normalizationStats();
snapshot.activation = window.nativeInputContext->activationSnapshot();
snapshot.easyXKeyboardMessagesDrained = window.easyXKeyboardMessagesDrained;
snapshot.legacyEasyXKeyboardBusinessDeliveries =
window.legacyEasyXKeyboardBusinessDeliveries;
snapshot.faulted = window.nativeInputContext->hasFault(); snapshot.faulted = window.nativeInputContext->hasFault();
snapshot.fault = window.nativeInputContext->fault(); snapshot.fault = window.nativeInputContext->fault();
return snapshot; return snapshot;
@@ -778,6 +864,19 @@ LRESULT CALLBACK Window::WndProcThunk(HWND h, UINT m, WPARAM w, LPARAM l)
return StellarX::Internal::inputProbeWndProcLeave( return StellarX::Internal::inputProbeWndProcLeave(
probeCall, nativeInputResult.result, false); 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) if (m == WM_CLOSE)
{ {
// EasyX 的旧窗口过程可能在 WM_CLOSE 内直接终止进程,不保证返回到 leave、 // EasyX 的旧窗口过程可能在 WM_CLOSE 内直接终止进程,不保证返回到 leave、
@@ -994,6 +1093,7 @@ void Window::draw(std::string imagePath)
int Window::runEventLoop() int Window::runEventLoop()
{ {
ExMessage msg; ExMessage msg;
StellarX::Internal::lockKeyboardPipeline(*this);
// 说明:统一使用 needResizeDirty 作为“收口重绘”的唯一标志位 // 说明:统一使用 needResizeDirty 作为“收口重绘”的唯一标志位
// 不再引入额外 pendingResize 等状态,避免分叉导致状态不一致。 // 不再引入额外 pendingResize 等状态,避免分叉导致状态不一致。
@@ -1018,7 +1118,11 @@ int Window::runEventLoop()
const auto batch = StellarX::Internal::drainMessageBatch(msg, const auto batch = StellarX::Internal::drainMessageBatch(msg,
[](ExMessage& next) { return peekmessage(&next, EX_MOUSE | EX_KEY | EX_WINDOW, true); }, [](ExMessage& next) { return peekmessage(&next, EX_MOUSE | EX_KEY | EX_WINDOW, true); },
[&](const ExMessage& next) { [&](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; // 每条消息独立判断,不能把上一条消息的消费状态带入下一条。 bool consume = false; // 每条消息独立判断,不能把上一条消息的消费状态带入下一条。
if (msg.message == WM_CLOSE) if (msg.message == WM_CLOSE)
return false; return false;
@@ -1058,6 +1162,13 @@ int Window::runEventLoop()
return true; 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 期间,控件只改状态并登记重绘 root,不直接画。
managedDispatchActive = true; managedDispatchActive = true;
@@ -1127,6 +1238,15 @@ int Window::runEventLoop()
<< SxMsgName(msg.message) << SxMsgName(msg.message)
<< SX_T(" id=", " id=") << firstConsumer->getId(); << 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; managedDispatchActive = false;
flushPendingMutationsInTree(); flushPendingMutationsInTree();
return true; return true;
@@ -1382,6 +1502,8 @@ bool Window::removeControl(StellarX::ControlHandle handle)
if (!exists) if (!exists)
return false; return false;
pendingControlMutations.push_back({ ControlMutationKind::Remove, handle, nullptr }); pendingControlMutations.push_back({ ControlMutationKind::Remove, handle, nullptr });
if (focusManager)
focusManager->notifySubtreePendingRemoval(handle);
if (!isMutationTransactionActive()) if (!isMutationTransactionActive())
flushPendingMutationsInTree(); flushPendingMutationsInTree();
return true; return true;
@@ -1390,6 +1512,14 @@ bool Window::removeControl(StellarX::ControlHandle handle)
void Window::clearControls() void Window::clearControls()
{ {
pendingControlMutations.push_back({ ControlMutationKind::Clear, {}, nullptr }); pendingControlMutations.push_back({ ControlMutationKind::Clear, {}, nullptr });
if (focusManager)
{
std::vector<StellarX::ControlHandle> removedHandles;
removedHandles.reserve(controls.size());
for (const auto& control : controls)
if (control) removedHandles.push_back(control->getHandle());
focusManager->notifySubtreesPendingRemoval(removedHandles);
}
if (!isMutationTransactionActive()) if (!isMutationTransactionActive())
flushPendingMutationsInTree(); flushPendingMutationsInTree();
} }
@@ -1417,6 +1547,65 @@ std::vector<StellarX::ControlHandle> Window::getControlHandles() const
return handles; return handles;
} }
StellarX::FocusManager& Window::getFocusManager()
{
return *focusManager;
}
const StellarX::FocusManager& Window::getFocusManager() const
{
return *focusManager;
}
bool Window::queryFocusTargetSnapshot(const StellarX::FocusTargetId& target,
StellarX::FocusTargetSnapshot& snapshot) const
{
for (auto it = dialogs.rbegin(); it != dialogs.rend(); ++it)
if (*it && (*it)->queryFocusTargetSnapshot(target, snapshot)) return true;
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
if (*it && (*it)->queryFocusTargetSnapshot(target, snapshot)) return true;
return false;
}
void Window::collectFocusTargetSnapshots(
std::vector<StellarX::FocusTargetSnapshot>& snapshots) const
{
for (const auto& control : controls)
if (control) control->collectFocusTargetSnapshots(snapshots);
for (const auto& dialog : dialogs)
if (dialog) dialog->collectFocusTargetSnapshots(snapshots);
}
bool Window::applyFocusState(const StellarX::FocusTargetId& target, bool focused,
StellarX::FocusReason reason)
{
for (auto it = dialogs.rbegin(); it != dialogs.rend(); ++it)
if (*it && (*it)->applyFocusState(target, focused, reason)) return true;
for (auto it = controls.rbegin(); it != controls.rend(); ++it)
if (*it && (*it)->applyFocusState(target, focused, reason)) return true;
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) void Window::addDialog(std::unique_ptr<Control> dlg)
{ {
// 新增非模态对话框:管理顺序决定事件优先级(顶层从后往前) // 新增非模态对话框:管理顺序决定事件优先级(顶层从后往前)
@@ -1,11 +1,58 @@
#pragma once #pragma once
#include <windows.h>
#include <cstddef> #include <cstddef>
#include <cstdint>
class Window;
// 窗口实现和测试共用的内部事件循环工具。 // 窗口实现和测试共用的内部事件循环工具。
// 本文件不属于 StellarX 公共 API。 // 本文件不属于 StellarX 公共 API。
namespace StellarX::Internal 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; 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{}; NativeInputQueueStats queue{};
NativeInputObservationStats observations{}; NativeInputObservationStats observations{};
NativeInputNormalizationStats normalization{}; NativeInputNormalizationStats normalization{};
NativeInputActivationSnapshot activation{};
NativeInputFaultSnapshot fault{}; NativeInputFaultSnapshot fault{};
std::uint64_t easyXKeyboardMessagesDrained = 0;
std::uint64_t legacyEasyXKeyboardBusinessDeliveries = 0;
bool faulted = false; bool faulted = false;
}; };
@@ -135,6 +138,8 @@ namespace StellarX::Internal
NativeInputContext& operator=(const NativeInputContext&) = delete; NativeInputContext& operator=(const NativeInputContext&) = delete;
NativeInputCollectResult collect(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) noexcept; 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; std::size_t drainObserved(std::size_t limit) noexcept;
bool hasFault() const noexcept; bool hasFault() const noexcept;
@@ -142,6 +147,7 @@ namespace StellarX::Internal
NativeInputQueueStats queueStats() const noexcept; NativeInputQueueStats queueStats() const noexcept;
NativeInputObservationStats observationStats() const noexcept; NativeInputObservationStats observationStats() const noexcept;
NativeInputNormalizationStats normalizationStats() const noexcept; NativeInputNormalizationStats normalizationStats() const noexcept;
NativeInputActivationSnapshot activationSnapshot() const noexcept;
// Test-only observation seams; none participates in production routing. // Test-only observation seams; none participates in production routing.
bool tryPopForTesting(NativeInputEvent& event) noexcept; bool tryPopForTesting(NativeInputEvent& event) noexcept;
File diff suppressed because it is too large Load Diff
+135 -5
View File
@@ -50,11 +50,23 @@ namespace
} }
return false; 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; } bool model() const override { return false; }
std::uint64_t keyEventCount = 0; std::uint64_t keyEventCount = 0;
std::uint64_t keyDownCount = 0; std::uint64_t keyDownCount = 0;
std::uint64_t keyUpCount = 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::string scenario = argc > 1 ? argv[1] : "geometry";
const std::filesystem::path resultDir = argc > 2 ? argv[2] : "TestResults"; const std::filesystem::path resultDir = argc > 2 ? argv[2] : "TestResults";
const bool consumeProbeScenario = scenario == "input-consume-probe"; const bool consumeProbeScenario = scenario == "input-consume-probe";
const int windowWidth = consumeProbeScenario ? 720 : 480; const bool expandedFocusScenario = scenario == "focus";
const int windowHeight = consumeProbeScenario ? 420 : 320; 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); Window window(windowWidth, windowHeight, 1, Background, "StellarX.UI." + scenario);
if (scenario == "ownership") if (scenario == "ownership")
@@ -164,11 +177,13 @@ int main(int argc, char** argv)
} }
else if (scenario == "input-shadow") else if (scenario == "input-shadow")
{ {
// Stage 1 keeps legacy EX_KEY delivery while the normalized queue is only // Stage 3 production routes the normalized queue while EasyX EX_KEY is
// observed and discarded at host safe points. // still drained but never delivered to Control::handleEvent().
auto observer = std::make_unique<KeyboardObservationControl>(); auto observer = std::make_unique<KeyboardObservationControl>();
KeyboardObservationControl* observerRaw = observer.get(); 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"); auto verify = MakeButton(20, 40, 160, 42, "verify input shadow");
verify->setOnClickListener([&, observerRaw]() { verify->setOnClickListener([&, observerRaw]() {
@@ -177,6 +192,13 @@ int main(int argc, char** argv)
std::string("{\"legacyKeyCount\":") + std::to_string(observerRaw->keyEventCount) + std::string("{\"legacyKeyCount\":") + std::to_string(observerRaw->keyEventCount) +
",\"legacyKeyDownCount\":" + std::to_string(observerRaw->keyDownCount) + ",\"legacyKeyDownCount\":" + std::to_string(observerRaw->keyDownCount) +
",\"legacyKeyUpCount\":" + std::to_string(observerRaw->keyUpCount) + ",\"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) + ",\"pushed\":" + std::to_string(snapshot.queue.pushed) +
",\"popped\":" + std::to_string(snapshot.queue.popped) + ",\"popped\":" + std::to_string(snapshot.queue.popped) +
",\"currentDepth\":" + std::to_string(snapshot.queue.currentDepth) + ",\"currentDepth\":" + std::to_string(snapshot.queue.currentDepth) +
@@ -191,6 +213,31 @@ int main(int argc, char** argv)
}); });
window.addControl(std::move(verify)); 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") else if (scenario == "native-close")
{ {
// 只创建顶层窗口,由 UI 驱动发送原生关闭命令并验证宿主循环自行退出。 // 只创建顶层窗口,由 UI 驱动发送原生关闭命令并验证宿主循环自行退出。
@@ -251,6 +298,89 @@ int main(int argc, char** argv)
}); });
window.addControl(std::move(resize)); window.addControl(std::move(resize));
} }
else if (scenario == "focus")
{
// 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;
button->setOnClickListener([&]() { buttonFocusObserved = buttonRaw->hasLogicalFocus(); });
window.addControl(std::move(button));
auto textBox = std::make_unique<TextBox>(20, 80, 160, 36, "focus text");
TextBox* textBoxRaw = textBox.get();
window.addControl(std::move(textBox));
auto table = std::make_unique<Table>(210, 20);
const auto tableHandle = table->getHandle();
table->setHeaders({ "value" });
table->setRowsPerPage(1);
table->setData({ { "one" }, { "two" } });
window.addControl(std::move(table));
auto tabs = std::make_unique<TabControl>(20, 145, 300, 140);
tabs->setActiveIndex(0);
auto firstPage = std::make_unique<Canvas>(0, 0, 300, 110);
auto firstPageButton = MakeButton(10, 40, 110, 30, "active child");
const auto firstPageChildHandle = firstPageButton->getHandle();
firstPage->addControl(std::move(firstPageButton));
tabs->addTab(MakeButton(0, 0, 100, 30, "first"), std::move(firstPage));
auto secondPage = std::make_unique<Canvas>(0, 0, 300, 110);
auto secondPageButton = MakeButton(10, 40, 110, 30, "inactive child");
const auto secondPageChildHandle = secondPageButton->getHandle();
secondPage->addControl(std::move(secondPageButton));
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();
std::size_t tableTabStops = 0;
bool activePageChildPresent = false;
bool inactivePageChildPresent = false;
bool allSubNodesZero = true;
for (const auto& stop : stops)
{
if (stop.logicalParent.owner == tableHandle) ++tableTabStops;
if (stop.id.owner == firstPageChildHandle) activePageChildPresent = true;
if (stop.id.owner == secondPageChildHandle) inactivePageChildPresent = true;
if (stop.id.subNode != 0) allSubNodesZero = false;
}
WriteResult(resultDir, scenario,
std::string("{\"buttonFocusObserved\":") + (buttonFocusObserved ? "true" : "false") +
",\"tableTabStops\":" + std::to_string(tableTabStops) +
",\"activePageChildPresent\":" + (activePageChildPresent ? "true" : "false") +
",\"inactivePageChildPresent\":" + (inactivePageChildPresent ? "true" : "false") +
",\"allSubNodesZero\":" + (allSubNodesZero ? "true" : "false") + "}");
});
window.addControl(std::move(verify));
textBoxRaw->requestFocus();
}
else if (scenario == "dialog") else if (scenario == "dialog")
{ {
// 先叠加两个非模态对话框,再只关闭上层,验证下层恢复和交互顺序。 // 先叠加两个非模态对话框,再只关闭上层,验证下层恢复和交互顺序。
@@ -0,0 +1,54 @@
# BUG-20261009-0001
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
## 基本信息
- ID: BUG-20261009-0001
- 标题: Table 内部分页按钮焦点视觉延迟刷新
- 状态:已修复,自动回归通过,KEY1 人工复验待执行
- 严重性:S2
- 优先级:P1
- 模块: Focus / Table / Managed Repaint
- 版本 / 分支: `master`,阶段 3 实现提交 `29df994`
- 环境: Windows + EasyX + Routed 键盘生产路径
- 发现人: 用户 KEY1 人工回归
- 关联 Fix ID: Fix-BUG-20261009-0001
## 问题描述
- 现象: Tab 把逻辑焦点移入 Table 的分页 Button 后,焦点框不会立即出现;焦点离开后,旧焦点框也不会立即消失。只有 hover、翻页或其它原因再次触发表格重绘时,屏幕才与逻辑焦点状态一致。
- 影响范围: Table 内部上一页/下一页 Button 的全部逻辑焦点视觉;逻辑焦点身份和 Tab 顺序本身正确。
- 期望结果: 分页 Button 获焦和失焦都必须在当前输入批次的托管重绘收口中刷新整张 Table,不依赖下一次无关事件。
## 复现信息
- 前置条件: Table 已首次绘制并 materialize 分页 Button,当前页存在可用分页方向。
- 复现步骤:
1. 启动 KEY1。
2. 使用 Tab 把焦点移动到 Table 分页 Button。
3. 不移动鼠标、不触发其它控件,观察分页区。
4. 再按 Tab 离开分页 Button,继续观察分页区。
- 复现概率:人工回归稳定观察到
- 人工证据: 用户于 2026-10-09 的 KEY1 回归描述。
## 根因分析
- Table 分页 Button 是真实 `Button`,但它属于 Table 内部绘制单元,不能作为独立顶层重绘 root 提交。
- `Button::applyFocusState()` 只把内部 Button 自身标脏并请求重绘;Table 没有因此进入 dirty,外层 Canvas 的脏子树判断看不到需要重画的 Table。
- 逻辑焦点状态已更新,但屏幕继续保留上一帧,直到后续事件把 Table 自身标脏。
## 跟踪信息
- 首次发现时间: 2026-10-09
- 最后更新时间: 2026-10-09
- 修复版本: `29df994`
- 验证版本: Debug x64 定向 focus UI 3/3、完整 UI 1/1、四配置 67/67 与 KEY1–KEY6 编译通过
- 关联记录: `Module-20261009-0007`
## 关闭边界
- 自动像素测试已证明分页 Button 焦点框在 Tab 进入时即时出现、离开时即时清除。
- BUG 最终人工关闭仍需用户复验 KEY1;自动通过不替代该人工门槛。
@@ -0,0 +1,55 @@
# BUG-20261009-0002
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
## 基本信息
- ID: BUG-20261009-0002
- 标题: 同父 Canvas 并发脏子树导致上层区域被下层重绘覆盖
- 状态:已修复,自动回归通过,KEY5 人工复验待执行
- 严重性:S2
- 优先级:P1
- 模块: Canvas / Managed Repaint / Overlay / Focus
- 版本 / 分支: `master`,阶段 3 实现提交 `29df994`
- 环境: Windows + EasyX + KEY5 D-1/D-2 同父 overlay
- 发现人: 用户 KEY5 人工回归
- 关联 Fix ID: Fix-BUG-20261009-0002
## 问题描述
- 现象: 焦点从 KEY5 的 D-1 下层按钮转移到 D-2 上层按钮时,下层失焦重绘会覆盖 D-2 的一部分;如果没有后续事件再次触发完整重绘,错误像素会持续存在。
- 影响范围: 同一 Canvas 内发生区域相交的上下层兄弟,并且下层持久内容与上层脏子树在同一批次提交。
- 期望结果: 只要下层本轮持久写屏区域与上层兄弟相交,上层兄弟必须基于当前屏幕重新抓背景并完整恢复,不能只提交自己的局部脏后代。
## 复现信息
- 前置条件: 两个相交 Canvas 按绘制顺序形成下层和上层;二者各有可聚焦 Button。
- 复现步骤:
1. 启动 KEY5。
2. 在 C 米区离开后使焦点进入 D-1 下层按钮。
3. 再把焦点移到 D-2 上层按钮。
4. 不触发额外重绘,观察 D-2 与 D-1 的相交区域。
- 复现概率:用户人工路径可复现
- 人工证据: 用户截图显示 D-1 重绘像素覆盖 D-2 上层区域。
## 根因分析
- `Canvas::requestRepaint()` 依绘制顺序提交脏子树并累计实际 coverage。
- 旧分支优先判断“当前上层兄弟自己是否有脏子树”。当 D-2 的 Button 同时获焦时,代码只提交 D-2 的局部脏子树。
- 此时 D-1 已经把持久像素写进 D-2 区域;仅画 D-2 的 Button 无法恢复 D-2 Canvas 背景和其它未脏内容,因此留下错误覆盖。
## 跟踪信息
- 首次发现时间: 2026-10-09
- 最后更新时间: 2026-10-09
- 修复版本: `29df994`
- 验证版本: 扩展 focus UI 3/3、完整 UI 1/1、四配置 67/67 与 KEY1–KEY6 编译通过
- 关联记录: `BUG-20260415-0005`、`BUG-20260511-0012`、`Module-20261009-0007`
## 关闭边界
- 自动像素测试已覆盖“下层失焦 + 上层获焦同批发生”并断言相交点仍为上层背景色。
- Tooltip 临时 coverage 仍不触发持久快照刷新;完整 tooltip UI 已通过。
- BUG 最终人工关闭仍需用户复验 KEY5 D 区。
@@ -0,0 +1,60 @@
# Fix-BUG-20261009-0001
> 适用场景:记录某个 BUG 的修复方案、影响评估、验证结果与落地信息。
## 关联信息
- Fix ID: Fix-BUG-20261009-0001
- 关联 BUG ID: BUG-20261009-0001
- 修复目标: 让 Table 内部分页 Button 的焦点变化在当前批次即时重绘
- 状态:已完成,自动与 KEY1 人工复验通过
- 负责人: Codex
- 分支 / 版本: `master`,实现提交 `29df994`
## 根因分析
- 根因: 分页 Button 的焦点状态只把 Button 自身标脏,没有把变化提升到负责实际合成的 Table。
- 触发条件: 通过 Tab 或程序化焦点切换让分页 Button 获焦/失焦,且之后没有 hover、翻页等额外 Table 重绘事件。
- 为什么之前没发现: 阶段 2 的 UI 只验证分页 Button 进入焦点树,没有验证内部焦点框的同帧出现与消失。
- 关键证据: 逻辑 Tab 顺序正确,人工观察到后续任意重绘会补出或清掉焦点框。
## 修复方案
- 修复思路: `Table::applyFocusState()` 成功命中内部分页 Button 后,把 Table 本体设为 dirty,并向父容器请求重绘。
- 关键改动:
- 上一页和下一页 Button 采用同一提升规则。
- 保留真实 Button 的 `FocusTargetId`,不创建合成 Table 子节点。
- 新增像素回归:TextBox 后一次 Tab 进入 Table next Button,下一次 Tab 离开;分别断言 Table 区域立即变化并恢复。
- 涉及文件 / 类 / 函数:
- `src/StellarX/Table.cpp`
- `tests/UiFixture.cpp`
- `scripts/run-ui-tests.ps1`
- 影响的 API / 行为: 无公开 API 变化;只修正焦点视觉提交时机。
- 关键约束 / 不变量: Table 分页 Button 仍是 `subNode == 0` 的真实 ControlHandle。
- 回滚点 / 开关: 回退 `Table::applyFocusState()` 的 Table dirty/repaint 提升与对应测试。
## 影响评估
- 影响范围: Table 分页区焦点框、Table 整体托管重绘。
- 兼容性影响:无
- 行为变化:有,焦点视觉从延迟刷新改为当前批次刷新。
- 性能影响:焦点进入/离开分页 Button 时多一次整 Table 重绘;只发生在焦点切换边界。
- 回归风险: Table 重绘可能影响与其相交的上层控件,因此与 overlay 回归共同验证。
## 验证结果
- Debug x64 新增 `CanvasBackgroundPointerDownClearsFocusAndConsumes` 后为 67/67。
- Debug x64、x64 ASan、Release x64、Debug Win32 均为 67/67;KEY1–KEY6 全部编译/链接成功。
- 扩展 `focus` UI 连续 3/3 通过;Table 焦点出现和消失均有独立截图与像素区域断言。
- 完整 UI 1/1 通过,包含 Tooltip、Table、Dialog、input-shadow 和 keyboard-routing。
- 最终 fixture 自动 UI 证据:`TestResults/ui/20261009-223506-755-18932`,九场景 9/9,通过 fixture SHA-256 `F439C0636CD6B979A47C4594BC5AB3728F6342F49602FE43078435011A877793`。
- 人工复验: 用户重新执行 KEY1,Table 分页焦点框即时出现/消失,暂未发现问题。
## 落地信息
- Commit: `29df994`
- PR: 无
- 发布版本: 未发布
- 证据:
- `TestResults/ui/20261009-220625-941-15096`
- `TestResults/ui/20261009-220940-941-31488`
@@ -0,0 +1,62 @@
# Fix-BUG-20261009-0002
> 适用场景:记录某个 BUG 的修复方案、影响评估、验证结果与落地信息。
## 关联信息
- Fix ID: Fix-BUG-20261009-0002
- 关联 BUG ID: BUG-20261009-0002
- 修复目标: 下层持久重绘命中上层兄弟时,优先完整恢复上层而不是只提交其脏子树
- 状态:已完成,自动与 KEY5 人工复验通过
- 负责人: Codex
- 分支 / 版本: `master`,实现提交 `29df994`
## 根因分析
- 根因: `Canvas::requestRepaint()` 把“上层自身有脏子树”放在“下层持久 coverage 命中上层”之前,导致完整 overlay 恢复被局部提交短路。
- 触发条件: 下层与上层相交,且一次焦点事务同时让下层失焦、上层获焦。
- 为什么之前没发现: 既有 overlay 测试主要覆盖只有下层变脏的情况,没有覆盖上下层同批变脏。
- 关键证据: 额外事件触发完整重绘后画面恢复,说明 z-order 和内容本身正确,错误集中在局部提交优先级。
## 修复方案
- 修复思路: 每个上层兄弟在选择提交方式前,先判断本轮累计的下层持久 coverage 是否与其持久/完整 coverage 相交。
- 关键改动:
- `persistentHit` 为真时,无条件作废上层旧快照并完整重画。
- 只有不命中下层持久内容时,才允许继续提交上层自己的局部脏子树。
- Tooltip 等临时 coverage 不进入持久相交条件,继续保持既有临时浮层契约。
- 扩展 focus UI,构造同父上下层 Canvas,并在下层失焦、上层获焦后精确断言交叠点颜色。
- 涉及文件 / 类 / 函数:
- `src/StellarX/Canvas.cpp`
- `tests/UiFixture.cpp`
- `scripts/run-ui-tests.ps1`
- 影响的 API / 行为: 无公开 API 变化;调整托管局部重绘内部优先级。
- 关键约束 / 不变量: 完整 coverage 负责“是否被写到”,持久 coverage 负责“是否需要刷新背景快照”;两者不能重新合并。
- 回滚点 / 开关: 回退 Canvas 分支优先级与同批双脏像素回归。
## 影响评估
- 影响范围: Canvas 内相交兄弟、嵌套 Canvas、焦点框与持久内容的同批重绘。
- 兼容性影响:无
- 行为变化:有,错误覆盖改为按 z-order 完整恢复。
- 性能影响:仅在下层持久写屏区域命中上层时,把上层局部提交升级为完整控件重画。
- 回归风险:
- 若把 Tooltip 临时 coverage 错当持久内容,可能重新引入 Tooltip 被擦除;本修复继续使用独立 persistent coverage,并执行完整 tooltip UI。
- 完整上层重画会刷新其背景快照,因此必须先让下层本轮像素稳定落屏。
## 验证结果
- 扩展 `focus` UI 连续 3/3 通过;同父下层/上层焦点切换后,交叠点精确保持上层背景色 `RGB(200,160,120)`。
- 完整 UI 1/1 通过,`tooltip` 到期显示与移出清除均未回归。
- Debug x64、x64 ASan、Release x64、Debug Win32 均为 67/67;KEY1–KEY6 全部编译/链接成功。
- 最终 fixture 自动 UI 证据:`TestResults/ui/20261009-223506-755-18932`,九场景 9/9,通过 fixture SHA-256 `F439C0636CD6B979A47C4594BC5AB3728F6342F49602FE43078435011A877793`。
- 人工复验: 用户重新执行 KEY5 D 区,D-1 → D-2 未再发生下层重绘覆盖,暂未发现问题。
## 落地信息
- Commit: `29df994`
- PR: 无
- 发布版本: 未发布
- 证据:
- `TestResults/ui/20261009-220625-941-15096`
- `TestResults/ui/20261009-220940-941-31488`
+51
View File
@@ -2,6 +2,57 @@
本次变更包含不兼容的所有权与文本样式 API 调整。GUI 仍限定在主线程,字符集仍为 MBCS。 本次变更包含不兼容的所有权与文本样式 API 调整。GUI 仍限定在主线程,字符集仍为 MBCS。
## 阶段 3 键盘业务入口迁移
阶段 3 已把生产键盘业务源从 EasyX `EX_KEY` 原子切换到 `NativeInputQueue -> KeyboardRouter`。事件循环仍持续请求并排空 `EX_KEY`,避免 EasyX 内部队列累积,但不会再把任何 `EX_KEY` 交给 `Control::handleEvent()`。`EX_MOUSE`、窗口消息和既有鼠标行为不受本次迁移影响。
在 `handleEvent(const ExMessage&)` 中处理键盘的源码派生控件必须迁移到 `handleKeyEvent(RoutedKeyEvent&)`:
```cpp
StellarX::KeyRouteResult MyControl::handleKeyEvent(
StellarX::RoutedKeyEvent& event)
{
if (event.key.action == StellarX::KeyAction::Down &&
event.key.virtualKey == VK_RETURN) {
activate();
return { false, true }; // 允许祖先观察,阻止框架默认动作重复执行
}
return {};
}
```
- `stopBubble` 控制是否继续交给逻辑父节点;`preventFrameworkDefault` 独立控制冒泡结束后的 Tab/Dialog/Window 默认动作。
- 回调可能排队删除当前控件或改变焦点。实现不得在会触发用户回调的命令执行后继续访问自身成员,也不得长期保存 `RoutedKeyEvent` 中的焦点身份解析结果。
- 当前生产路由只向控件交付 `KeyEvent`。`TextInputEvent` 与 `CompositionEvent` 仍在队列中完整观察和计数,TextBox 的正式文本接口留到阶段 5/6。
- 原始 `VK_PROCESSKEY`、采集时处于标准 IME 生命周期的按键,以及阶段 3 未明确拥有的 Alt/System 组合键不会进入控件业务路由。
- `Control` 新增 routed key 与键盘暂停虚接口,再次改变 vtable;所有二进制派生控件必须使用当前头文件和 Core 库重新编译。
- 旧 `handleEvent(EX_KEY)` 不再提供兼容回退。未迁移的第三方控件会失去键盘业务能力,但鼠标入口保持可用。
## Window / Canvas 空白左键与逻辑焦点
Window 背景或非模态 Canvas 空白区域收到未被子控件消费的 `WM_LBUTTONDOWN` 时,现在会清除当前逻辑焦点并消费该指针事件。此行为使空白点击与常见桌面 GUI 一致,也阻止事件继续穿透到更低层的重叠兄弟控件。
- 自定义顶层控件若希望保留焦点,应在自身命中区域内消费该左键消息,或把可交互区域实现为实际子控件。
- Canvas 子控件先按既有从上到下命中顺序获得事件;只有全部子控件未消费且坐标位于 Canvas 内部时,才按空白点击处理。
- 模态 Dialog 在阶段 4 接入真实 FocusScope 前不借空白点击清除底层 Window 的逻辑焦点;后续作用域切换将按 Dialog 契约独立处理。
## 阶段 2 焦点协议与二进制重编译
阶段 2 新增 `FocusManager`、`FocusTargetId`、`FocusScopeId`、`FocusPolicy`、`FocusReason`、`FocusError` 和 `FocusTargetSnapshot`。每个 `Window` 现在拥有一个逻辑焦点管理器:
```cpp
button->setTabIndex(10);
const auto result = button->requestFocus(StellarX::FocusReason::Programmatic);
const auto focused = window.getFocusManager().focusedTarget();
```
`Control` 新增焦点参与虚接口,用于递归快照解析、收集和焦点状态提交。该变化会改变 `Control` 及派生类的 vtable,因此:
- 已编译的第三方派生控件二进制必须使用新头文件重新编译,不能直接与新 Core 静态库混用。
- 源码派生控件无需立刻覆盖新虚接口;默认实现把自身作为焦点节点,默认 `FocusPolicy::None`,正常全量重编译即可。
- 需要参与焦点的自定义控件可调用 `setFocusPolicy()`;复合控件应覆盖焦点递归协议,但不得长期保存解析得到的裸指针。
- 阶段 2 提交当时没有移除 `Control::handleEvent()`,也没有停止旧 `EX_KEY` 业务交付;后续阶段 3 已按上一节完成键盘迁移。
## Window 与 Canvas 所有权 ## Window 与 Canvas 所有权
不再公开可变的 `std::vector<std::unique_ptr<Control>>`。调用方必须保存稳定句柄,而不是保存容器迭代器或假设控件地址永久有效。 不再公开可变的 `std::vector<std::unique_ptr<Control>>`。调用方必须保存稳定句柄,而不是保存容器迭代器或假设控件地址永久有效。
+64 -3
View File
@@ -25,7 +25,7 @@
- `Unit`:构建核心静态库并运行 VS Native Unit Test。 - `Unit`:构建核心静态库并运行 VS Native Unit Test。
- `NonUI`:运行 Debug x64、Debug x64 ASan、Release x64、Debug Win32 和 KEY1 至 KEY6 编译,不启动或操作任何窗口。 - `NonUI`:运行 Debug x64、Debug x64 ASan、Release x64、Debug Win32 和 KEY1 至 KEY6 编译,不启动或操作任何窗口。
- `Render`:运行确定性 EasyX fixture 和关键像素断言;当前与单轮 UI fixture 共用实现。 - `Render`:运行确定性 EasyX fixture 和关键像素断言;当前与单轮 UI fixture 共用实现。
- `UI`:使用 `SendInput` 驱动 ownership、geometry、tooltip、tab、table、dialog 六类真实窗口场景。 - `UI`:使用 `SendInput` 驱动 ownership、geometry、tooltip、tab、table、dialog、focus、input-shadow、keyboard-routing,以及普通/模态原生关闭共十一类真实窗口场景。
- `All`:Debug x64、Debug x64 ASan、Release x64、Debug Win32、KEY1 至 KEY6 编译、UI 连续三轮。 - `All`:Debug x64、Debug x64 ASan、Release x64、Debug Win32、KEY1 至 KEY6 编译、UI 连续三轮。
- `CI`:默认运行 Debug x64、Release x64 和 Debug Win32;设置 `STELLARX_UI_TESTS=1` 后增加单轮 UI。 - `CI`:默认运行 Debug x64、Release x64 和 Debug Win32;设置 `STELLARX_UI_TESTS=1` 后增加单轮 UI。
@@ -44,6 +44,9 @@ Windows MSVC AddressSanitizer 不支持 LeakSanitizer,因此脚本使用 `dete
- Tooltip 单调时钟 tick、禁用和移出清理。 - Tooltip 单调时钟 tick、禁用和移出清理。
- 1000 条消息的 128 条批处理上限、空闲休眠和关闭顺序。 - 1000 条消息的 128 条批处理上限、空闲休眠和关闭顺序。
- 日志配置原子切换与滚动行完整性。 - 日志配置原子切换与滚动行完整性。
- FocusTargetId 递归解析、焦点回调重入、单槽 pending、64 轮活性保护、作用域恢复和 pending removal 回退。
- TabControl 真实页签句柄与非活动页面焦点过滤。
- KeyboardRouter 原始身份路径、叶子到祖先冒泡、逐节点重新解析、独立停止/默认动作位、pending removal 跳过、嵌套 mutation 边界、回调改焦点后的下一事件生效语义,以及激活代次、IME/Alt-System、模态隔离和键盘瞬时状态暂停门控。
UI/像素测试覆盖: UI/像素测试覆盖:
@@ -53,6 +56,9 @@ UI/像素测试覆盖:
- 同名页签唯一选中、唯一页面可见。 - 同名页签唯一选中、唯一页面可见。
- 极窄 Table 的最小宽度和 coverage 外像素不变量。 - 极窄 Table 的最小宽度和 coverage 外像素不变量。
- 两层异步 Dialog 先后关闭、下层可见且可继续交互。 - 两层异步 Dialog 先后关闭、下层可见且可继续交互。
- Button/TextBox 焦点框、Table 分页 Button 动态资格、TabControl 活动页焦点树和 `subNode == 0`。
- 阶段 3 Routed 生产路径的 input-shadow 顺序、EasyX `EX_KEY` 纯排空、旧业务零交付与内存稳定性。
- 生产键盘路由的 Button Space、Tab 换焦与 Button Enter 单次激活。
## 诊断产物 ## 诊断产物
@@ -97,10 +103,10 @@ $msbuild = 'D:\vs2022\MSBuild\Current\Bin\MSBuild.exe'
| 场景 | 操作 | 通过标准 | | 场景 | 操作 | 通过标准 |
|---|---|---| |---|---|---|
| KEY1 | 进入页签 1,点击页内“按钮”重置表格,不移动鼠标立即观察分页区;连续重置三次并执行上一页、下一页;点击外部 `test` 多次激活同一页,再依次切换三个页签 | 重置后的首帧立即完整显示“第1页/共2页”,不依赖 hover 或后续重绘恢复;重复激活不闪烁;Table 超出页面区域的内容在切页后完全清除;无残影或层级错乱 | | KEY1 | 进入页签 1,点击页内“按钮”重置表格,不移动鼠标立即观察分页区;连续重置三次并执行上一页、下一页;用 Tab 进入并离开 Table 分页按钮;点击外部 `test` 多次激活同一页,再依次切换三个页签 | 重置后的首帧立即完整显示“第1页/共2页”,不依赖 hover 或后续重绘恢复;Table 分页按钮的焦点框在当前批次即时出现和消失;重复激活不闪烁;Table 超出页面区域的内容在切页后完全清除;无残影或层级错乱 |
| KEY2 | 拉宽、缩窄、最大化并还原窗口;点击多个位按钮,执行位取反、左移、右移、一键置 0/1 和有符号切换 | 32 位“位号 + 按钮”始终对齐;五个区块铺满且不重叠;十六进制、十进制、上次值、本次值立即同步 | | KEY2 | 拉宽、缩窄、最大化并还原窗口;点击多个位按钮,执行位取反、左移、右移、一键置 0/1 和有符号切换 | 32 位“位号 + 按钮”始终对齐;五个区块铺满且不重叠;十六进制、十进制、上次值、本次值立即同步 |
| KEY4 | 打开非模态对话框,在顶部按钮、页签和分页区快速移动鼠标;再打开模态对话框并拖动窗口,随后关闭 | 遮挡区不发生 Hover/Tooltip 穿透;标题、关闭按钮和底层背景无残影;关闭后底层 Hover 立即恢复 | | KEY4 | 打开非模态对话框,在顶部按钮、页签和分页区快速移动鼠标;再打开模态对话框并拖动窗口,随后关闭 | 遮挡区不发生 Hover/Tooltip 穿透;标题、关闭按钮和底层背景无残影;关闭后底层 Hover 立即恢复 |
| KEY5 | 在三层 Canvas、两个页签及其页内按钮间移动和点击;触发各处 Tooltip;操作 Table 分页按钮并观察粉色浮层 | 深层按钮状态正常;Tooltip 出现和清除完整;页签与页面层级正确;Table 重绘不会擦除或覆盖上层浮层 | | KEY5 | 在三层 Canvas、两个页签及其页内按钮间移动和点击;从 D-1 下层按钮切到 D-2 上层按钮;触发各处 Tooltip;操作 Table 分页按钮并观察粉色浮层 | 深层按钮状态正常;D-1 失焦与 D-2 获焦同批发生时,下层重绘不会覆盖 D-2 上层区域;Tooltip 出现和清除完整;页签与页面层级正确;Table 重绘不会擦除或覆盖上层浮层 |
| KEY6 | 点击圆形和椭圆按钮的内部及外接矩形角落;点击禁用按钮;切换右侧页签并操作页内控件;分别验证 YesNo、RetryCancel、AbortRetryIgnore | 形状外区域不误触发;禁用按钮不改变状态;右侧页签布局稳定;消息框结果正确,非模态关闭后无残留且底层 Hover 恢复 | | KEY6 | 点击圆形和椭圆按钮的内部及外接矩形角落;点击禁用按钮;切换右侧页签并操作页内控件;分别验证 YesNo、RetryCancel、AbortRetryIgnore | 形状外区域不误触发;禁用按钮不改变状态;右侧页签布局稳定;消息框结果正确,非模态关闭后无残留且底层 Hover 恢复 |
| KEY3 | 启动后完成登录页和旧业务页的基本切换 | 能正常进入和切换,无明显布局或交互倒退 | | KEY3 | 启动后完成登录页和旧业务页的基本切换 | 能正常进入和切换,无明显布局或交互倒退 |
@@ -138,6 +144,61 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-key.ps1
- KEY2 是 KEY1–6 中唯一启用文件日志的场景;轮转阈值由 `1024` 字节调整为 `10 * 1024 * 1024` 字节(10 MiB)。轮转在完整日志行写完后检查,单文件允许最多超出最后一行的长度。 - KEY2 是 KEY1–6 中唯一启用文件日志的场景;轮转阈值由 `1024` 字节调整为 `10 * 1024 * 1024` 字节(10 MiB)。轮转在完整日志行写完后检查,单文件允许最多超出最后一行的长度。
- 调整后执行正式 `NonUI`:Debug x64、x64 ASan、Release x64、Debug Win32 均为 27/27,KEY1–6 全部重新编译链接成功。 - 调整后执行正式 `NonUI`:Debug x64、x64 ASan、Release x64、Debug Win32 均为 27/27,KEY1–6 全部重新编译链接成功。
### 阶段 2 通用焦点自动回归(2026-10-08)
- `FocusManagerTests` 覆盖嵌套解析、PointerDown、回调删除/禁用目标、FocusGained 自删、单槽 latest-wins、FocusLost/FocusGained 中 pushScope、64 轮上限、active removal、clear 批量失效、真实页签句柄和非活动页面过滤。
- 正式 `NonUI`:Debug x64、x64 ASan、Release x64、Debug Win32 均为 40/40;KEY1–KEY6 全部编译/链接成功。
- 最终源码的独立 `focus` UI 连续 3 轮通过:TextBox 初始焦点框、Button 点击焦点框、第一页 Table 仅 next 分页 Button 进入 Tab stop、活动页后代存在、非活动页后代缺席、全部现有目标 `subNode == 0`。
- 针对旧 `tooltip` 像素等待和 `geometry` 点击结果超时,以阶段 1 基线 `81341fa` 和阶段 2 工作树执行受控 A/B:62/62 个结构化样本通过,geometry、tooltip 两端各累计 8/8,共有七场景两端各 3/3;双方未出现失败断言或明显耗时劣化。
- 当前阶段 2 随后通过正式完整 UI 三轮,八场景共 24/24。结论仅排除阶段 2 稳定引入的隐性回归;此前超时定性为间歇性桌面 fixture / 输入时序波动,仍保留残余偶发波动的可能,不表述为“证明 UI 永不波动”。
- 阶段 2 的实现与回归归因门槛均已关闭,可以进入阶段 3。阶段 2 不迁移键盘业务,不需要重复真实 IME 提交矩阵;阶段 1 的 IME/KEY1–6 人工基线继续有效,阶段 3 仍需明确 Tab 导航和 `EX_KEY` 原子换轨测试。
### 阶段 3 切片 1–5 自动回归(2026-10-09)
- 切片 1 建立 `RoutedKeyEvent` / `KeyRouteResult`、Control 默认路由接口和启动前固定的 Legacy/Routed 内部门控;切片 2 建立固定容量原始身份路径、逐节点重新解析、冒泡和可嵌套托管 mutation 边界。
- 正式 `NonUI`:Debug x64、x64 ASan、Release x64、Debug Win32 均为 52/52;KEY1–KEY6 全部重新编译链接成功。
- 交互桌面完整 UI 1/1 通过,十个场景证据位于 `TestResults/ui/20261009-010856-164-15856`;其中 `input-shadow` 为旧业务 4000/4000、新队列 4000/4000、最终深度 0、顺序严格且零 fault。
- 首次受限运行在任何 UI 场景完成前因 `MoveMouseToScreen` 返回 Access denied;这是桌面权限导致的未执行,不记作产品失败。随后在交互桌面重新完整执行并通过。
- 切片 3 新增采集侧 active/generation 原子快照、失活/跨代门控、`VK_PROCESSKEY`/组合生命周期/Alt-System 抑制、焦点路径暂停协议和模态期底层隔离。新增三项单测后,正式 `NonUI` 的四种配置均为 55/55,KEY1–KEY6 全部重新编译链接成功。
- 切片 3 最终生产兼容 UI 1/1 通过,证据位于 `TestResults/ui/20261009-013601-150-29780`;`input-shadow` 为旧业务 4000/4000、新队列 4000/4000、最终深度 0、顺序严格且零 fault。该轮 UI 验证 Legacy 默认路径未回归,Routed 门控由单元测试验证。
- 切片 4 在 Routed 测试门控中加入 Tab / Shift+Tab 默认动作:KeyDown 且无 Ctrl 时,在原始焦点路径冒泡完成且未被 `preventFrameworkDefault` 阻止后,重新采集当前作用域焦点树并循环移动;KeyUp、Ctrl+Tab 和切片 3 已抑制的 IME/Alt-System/失活/跨代/模态输入均不触发遍历。五项新增单测覆盖正反向与循环、`stopBubble`/默认动作阻止独立性、回调后新树、空结果不缓存和活动 FocusScope 边界。
- 切片 4 正式 `NonUI`:Debug x64、x64 ASan、Release x64、Debug Win32 均为 60/60;KEY1–KEY6 全部重新编译链接成功。首次完整 UI 在场景开始前因 `MoveMouseToScreen` 返回 Access denied,未进入产品断言;交互桌面重跑 1/1 通过,证据位于 `TestResults/ui/20261009-140434-611-33188`,`input-shadow` 为旧业务 4000/4000、新队列 4000/4000、最终深度 0、顺序严格且零 fault。
- 切片 5 在 Routed 测试门控中为 Button 加入独立 `keyboardArmed`:Space 首次 KeyDown 进入按压态、重复 KeyDown 不重复激活、KeyUp 确认一次;Enter 只在首次 KeyDown 激活;Escape、焦点丢失、模式切换、隐藏、窗口失活和模态隔离会取消 armed。`click` 继续分别承担 NORMAL 鼠标瞬时态与 TOGGLE 持久态,绘制仅以 `click || keyboardArmed` 合并按压色,焦点框仍最后绘制。Button 命令阻止后续框架默认动作但继续冒泡,用户回调后的对象访问由 mutation transaction 保护并禁止成员续用。
- 切片 5 新增五项 Button 键盘单测;正式 `NonUI`:Debug x64、x64 ASan、Release x64、Debug Win32 均为 65/65,KEY1–KEY6 全部重新编译链接成功。完整 UI 1/1 通过,证据位于 `TestResults/ui/20261009-150453-477-26448`;`input-shadow` 为旧业务 4000/4000、新队列 4000/4000、最终深度 0、顺序严格且零 fault。
- 生产默认仍为 `LegacyCompatibility`,切片 4 的 Tab 与切片 5 的 Button 键盘行为都只由显式 Routed 测试门控触发;EX_KEY 生产换轨仍未启用。本轮没有改变 IME 所有权/提交路径,不重复真实 IME 人工矩阵;真实生产换轨与对应人工 IME/KEY 回归门槛留给切片 6。
### 阶段 3 切片 6 生产换轨自动回归(2026-10-09)
- 生产默认已一次性切换为 `Routed`:Window 主循环和 Dialog 模态循环继续排空 EasyX `EX_KEY`,但旧 `Control::handleEvent(EX_KEY)` 业务交付为零;`EX_MOUSE` 保持原路径。Legacy 模式仅保留为事件循环启动前锁定的内部 A/B 测试能力。
- WndProc 对 `WM_GETDLGCODE` 先保留 EasyX 旧窗口过程结果,仅在 Routed 模式的明确普通 Tab 查询中合并 `DLGC_WANTTAB`;没有声明 `DLGC_WANTALLKEYS`、`DLGC_WANTCHARS` 或 `DLGC_WANTARROWS`,System/Alt+Tab 不认领。
- 正式 `NonUI`:Debug x64、x64 ASan、Release x64、Debug Win32 均为 66/66;KEY1–KEY6 全部重新编译/链接成功。
- 换轨前 fixture SHA-256 为 `3F6267B6D5EE85A7A97244C3DAE21DB44CEFBD185F56A38516E5E2F6F6234974`。旧 fixture 与当前 Routed fixture 对共同八场景执行 5 对 5 A/B:Legacy 40/40、Routed 40/40 全部通过。证据位于:
- `TestResults/Stage3CutoverAB/legacy/20261009-152906-951-18728`
- `TestResults/Stage3CutoverAB/legacy-expanded/20261009-153941-779-34800`
- `TestResults/Stage3CutoverAB/routed-formal/20261009-153745-045-33364`
- `TestResults/Stage3CutoverAB/routed-expanded/20261009-154044-849-12588`
- 汇总裁决:`TestResults/Stage3CutoverAB/comparison.json`
- 八个共同场景的 Routed/Legacy 平均耗时差分别为 ownership `+148 ms`、geometry `+4 ms`、tooltip `-19 ms`、tab `+43 ms`、table `+5 ms`、dialog `+21 ms`、focus `+32 ms`、input-shadow `+41 ms`。ownership 的差异来自 Routed 第一次启动单点 `1330 ms`;其余四次为 `531–601 ms`,与 Legacy 的 `517–709 ms` 重叠,因此未观察到稳定耗时劣化。该结论不排除交互桌面的残余偶发波动。
- 当前 Routed fixture 的正式完整 UI 连续两轮通过:九个业务场景共 18/18,普通/模态原生关闭检查每轮均通过。新增 `keyboard-routing` 为 2/2,每轮均得到首 Button Space 激活一次、Tab 聚焦第二 Button、Enter 激活一次。
- 两轮 `input-shadow` 均为 Routed 4000/4000(Down/Up 各 2000)、EasyX `EX_KEY` 排空 4000、旧 EX_KEY 业务交付 0、队列 pushed/popped 4000/4000、最终 depth 0、sequence 严格、零 fault、两轮间私有内存增长 0。该证据证明“新路由是唯一键盘业务源”且 EasyX 队列仍被持续排空。
- 最初受限运行曾在任何产品断言完成前因 `MoveMouseToScreen` 返回 Access denied;授权交互桌面后全部重跑通过。该样本是桌面权限导致的未执行,不记作产品失败。
- 最终补跑在 PowerShell 7 宿主中暴露 `PATH` / `Path` 双环境键:旧 `test.ps1` 的进程内规整没有阻止 MSBuild 创建 `CL.exe` 时抛 `MSB6001`。运行器现只在检测到重复键时通过 `cmd` 子进程删除大写 `PATH`、保留同值 `Path`,不修改调用者环境;修复后 PowerShell 7 与 Windows PowerShell 5.1 均重新执行 Debug x64 66/66 通过。首次失败发生在编译器进程创建前,不记作产品用例失败。
- 本节只关闭自动回归门槛。微软拼音、搜狗和 KEY1–KEY6 的切片 6 人工结果必须在后续小节独立记账;不能用本节自动结果替代。
### 阶段 3 人工换轨反馈与焦点/重绘修复(2026-10-09)
- 用户已分别完成微软拼音与搜狗真实候选输入复验:候选出现/取消和 Space 交互正常,未报告卡顿、重复提交或输入法按键误触发 Button。该结果关闭本轮两种 IME 的人工兼容性项,但不替代 KEY1–KEY6 视觉复测。
- Button 行为符合锁定语义:Space KeyDown 显示按压态、KeyUp 执行一次回调;Enter 在首次 KeyDown 直接执行回调且不进入持续按压态。两者触发时机不同是有意设计,不作为缺陷修改。
- 模态 Dialog 当前不参与 Window 焦点树,Tab 不进入其子树;非模态 Dialog 只有在底层作用域停靠项遍历后才可能进入。该现象属于阶段 4 真实 Dialog FocusScope、默认按钮与 Escape 接入范围,本轮不做临时路由补丁。
- KEY1 发现 Table 分页 Button 的逻辑焦点已切换,但焦点框需等待其它事件才出现或消失。修复将内部分页 Button 的焦点视觉提升为整张 Table 重绘,并新增即时出现/消失的 UI 像素回归。
- Window 与非模态 Canvas 空白左键现在会清除逻辑焦点并消费事件,避免点击空白后焦点框残留或指针继续穿透到底层兄弟;模态 Dialog 在阶段 4 接入 FocusScope 前仍保留底层逻辑焦点。
- KEY5 发现同父 Canvas 的下层和上层分支同时因焦点变化变脏时,下层重绘会覆盖 D-2 上层未变脏区域。修复在下层持久 coverage 命中上层时优先作废上层旧快照并完整恢复上层,而不是只提交上层局部脏子树。
- 四配置单测均为 67/67,KEY1–KEY6 全部编译/链接成功;扩展 `focus` UI 连续 3/3 通过(`TestResults/ui/20261009-220625-941-15096`),同一 fixture 的完整 UI 九场景 9/9 通过(`TestResults/ui/20261009-220940-941-31488`)。
- 移除临时 Button JSON 诊断字段后,用户从项目终端对最终源码重新执行完整自动 UI。证据位于 `TestResults/ui/20261009-223506-755-18932`:当前 fixture SHA-256 为 `F439C0636CD6B979A47C4594BC5AB3728F6342F49602FE43078435011A877793`,与 `run-summary.json` 一致;九个业务场景 9/9 通过,普通/模态原生关闭均通过。
- 自动化期间保留一次沙箱 `SendInput Access denied`、一次 Button 点击未命中和一次 ownership 等待超时证据;随后定向 3/3 与完整 UI 通过,不把这些间歇性桌面输入波动写成从未发生。
- 用户随后定向复测 KEY1 与 KEY5,暂未发现问题:Table 分页焦点框即时出现/消失,Window/Canvas 空白点击可清除焦点,D-1 → D-2 未再出现下层重绘覆盖。
- 阶段 3 的实现、四配置单测、KEY 构建、最终 fixture 自动 UI、微软拼音/搜狗及 KEY1/KEY5 人工门槛均已关闭。该结论不包含阶段 4 的真实 Dialog FocusScope、默认按钮、Escape 或复合控件高级键盘行为。
### 失败留证 ### 失败留证
- 记录 KEY 编号、窗口尺寸、操作顺序和实际现象。 - 记录 KEY 编号、窗口尺寸、操作顺序和实际现象。
@@ -7,10 +7,10 @@
- 模块 ID:Module-20261001-0001 - 模块 ID:Module-20261001-0001
- 模块名称:通用输入采集、焦点与键盘路由系统 - 模块名称:通用输入采集、焦点与键盘路由系统
- 状态:实现中(v0.14;0C/0B 前置基线已关闭;阶段 1 输入采集、规范化与观察队列已落地并通过自动回归,待真实 IME/KEY1–6 人工回归;焦点与键盘路由尚未开始) - 状态:实现中(v0.23;0C/0B 前置基线、阶段 1 输入地基、阶段 2 与阶段 3 六个切片均已实现并完成验收;阶段 4–6 尚未实现)
- 类型:新增模块 / 架构演进 - 类型:新增模块 / 架构演进
- 所属系统 / 子系统:StellarX Core / Window / Control / Input / Focus / TextBox - 所属系统 / 子系统:StellarX Core / Window / Control / Input / Focus / TextBox
- 版本 / 分支:`master`,阶段 0A 实现基线 `c218c25` - 版本 / 分支:`master`,阶段 3 实现提交 `29df994`;阶段 0A 实现基线 `c218c25`
- 环境:Windows + Visual Studio 2022 + MSVC v143 + EasyX + MBCS 工程配置 - 环境:Windows + Visual Studio 2022 + MSVC v143 + EasyX + MBCS 工程配置
- 负责人:Codex 协作设计 - 负责人:Codex 协作设计
@@ -37,7 +37,7 @@
- v1 不自绘 IME 拼音组合串和候选列表,继续使用系统 IME UI。 - v1 不自绘 IME 拼音组合串和候选列表,继续使用系统 IME UI。
- v1 不重写现有全部鼠标采集链;现有 EasyX 鼠标消息只在框架入口规范化为 `PointerEvent`。 - v1 不重写现有全部鼠标采集链;现有 EasyX 鼠标消息只在框架入口规范化为 `PointerEvent`。
- v1 不承诺完整 Unicode grapheme cluster 编辑,只保证不拆分 UTF-16 代理对。 - v1 不承诺完整 Unicode grapheme cluster 编辑,只保证不拆分 UTF-16 代理对。
- 本稿中的阶段 1 已有实现记录和自动验证证据;阶段 2 至阶段 6 仍是设计目标,不应被误读为已经实现。 - 本稿中的阶段 1、阶段 2 与阶段 3 已有实现记录、自动验证和对应人工证据;阶段 3 已关闭验收。阶段 4–6 仍是设计目标,不应被误读为已经实现。
## 模块边界 ## 模块边界
@@ -61,8 +61,8 @@
- IMM32:`ImmGetCompositionStringW`、`ImmSetCompositionWindow`、`ImmSetCandidateWindow`、`ImmNotifyIME`。 - IMM32:`ImmGetCompositionStringW`、`ImmSetCompositionWindow`、`ImmSetCandidateWindow`、`ImmNotifyIME`。
- 宽字符 GDI:`TextOutW` / `DrawTextW` / `GetTextExtentPoint32W`。 - 宽字符 GDI:`TextOutW` / `DrawTextW` / `GetTextExtentPoint32W`。
- 对外能力 / API: - 对外能力 / API:
- 本稿只确定职责和接口形状,公共 API 名称在阶段 0 完成后再落地。 - 阶段 1–3 已落地 `NativeInputEvent`、`FocusManager`、`FocusTargetId`、`RoutedKeyEvent`、`KeyRouteResult` 和 `Control::handleKeyEvent()`;后续阶段不得再把这些完成态写成占位草案。
- 控件通过焦点能力、键盘处理、文本输入和 Tab stop 收集接口接入。 - 控件通过焦点能力、键盘处理和 Tab stop 收集接口接入;正式文本输入接口仍留阶段 5/6。
- 关键数据 / 状态: - 关键数据 / 状态:
- `NativeInputEvent`、`KeyEvent`、`TextInputEvent`、`CompositionEvent`、`WindowActivationEvent`。 - `NativeInputEvent`、`KeyEvent`、`TextInputEvent`、`CompositionEvent`、`WindowActivationEvent`。
- `ControlHandle`、`FocusScope`、逻辑焦点句柄和窗口键盘激活状态。 - `ControlHandle`、`FocusScope`、逻辑焦点句柄和窗口键盘激活状态。
@@ -80,9 +80,9 @@
↓ ↓
阶段 2:通用焦点系统 阶段 2:通用焦点系统
↓ ↓
阶段 3:键盘定向路由 阶段 3:键盘定向路由与 Button 接入
↓ ↓
阶段 4:Button / Table / TabControl / Dialog 接入 阶段 4:Table / TabControl / Dialog 作用域与默认动作接入
↓ ↓
阶段 5:TextBox 编辑内核与自绘 阶段 5:TextBox 编辑内核与自绘
↓ ↓
@@ -162,18 +162,20 @@ Button / TextBox / Table / TabControl / Dialog
### 3. 关键不变量 ### 3. 关键不变量
以下是阶段 3 换轨后的完成态不变量;阶段 1–2 的兼容过渡例外仅限“旧 EX_KEY 继续业务下发、新队列只观察”,不得被扩展成长期双业务源: 以下是阶段 3 换轨后的完成态不变量。阶段 1–2 的“旧 EX_KEY 继续业务下发、新队列只观察”和阶段 3 切片 1–5 的测试门控均为历史过渡;v0.22 已由切片 6 一次性把生产默认切换为 Routed,Legacy 只保留为事件循环启动前锁定的内部 A/B 基线,任何模式下旧、新业务路径都必须互斥:
1. 阶段 3 启用后,NativeInputQueue 是键盘、文本和 IME 的唯一业务输入源。 1. 阶段 3 启用后,NativeInputQueue 是键盘、文本和 IME 的唯一业务输入源。
2. 阶段 3 启用后,EasyX `EX_KEY` 只排空和观察,不得再次触发业务路由。 2. 阶段 3 启用后,EasyX `EX_KEY` 只排空和观察,不得再次触发业务路由。
3. 同步窗口协议不得进入异步输入队列。 3. 同步窗口协议不得进入异步输入队列。
4. 异步路由只能阻止框架默认动作,不能倒过来改变已经结束的 WndProc 返回值。 4. 异步路由只能阻止框架默认动作,不能倒过来改变已经结束的 WndProc 返回值。
5. 队列、焦点、Tab 顺序、路由路径和待删除列表长期只保存 `ControlHandle`。 5. 队列、焦点、Tab 顺序、路由路径和待删除列表长期只保存 `FocusTargetId` / `ControlHandle`,不保存裸 `Control*`。
6. 同一 Window 同时只能由最内层消息循环 drain 输入队列。 6. 同一 Window 同时只能由最内层消息循环 drain 输入队列。
7. 每次只从队列取一条事件,不预取整个批次到局部容器。 7. 每次只从队列取一条事件,不预取整个批次到局部容器。
8. WndProc 只在 EasyX 窗口线程采集;焦点、路由和控件操作只在宿主分发线程执行。 8. WndProc 只在 EasyX 窗口线程采集;焦点、路由和控件操作只在宿主分发线程执行。
9. NativeInputQueue 必须提供跨线程发布/获取的 happens-before,不能使用无锁保护的普通 `std::deque`。 9. NativeInputQueue 必须提供跨线程发布/获取的 happens-before,不能使用无锁保护的普通 `std::deque`。
10. `sequence` 提供全局因果编号,但不能代替队列同步。 10. `sequence` 提供全局因果编号,但不能代替队列同步。
11. 窗口键盘激活态由采集线程发布原子快照;业务路由不能只依赖消费者按顺序处理 `WindowActivationEvent` 得到的布尔值。
12. 模态 Dialog 尚未进入 FocusScope 前,内层循环只排空并观察原生键盘队列,不得把键路由到底层 Window 焦点。
### 4. Win32 语义边界 ### 4. Win32 语义边界
@@ -224,6 +226,13 @@ v1 使用系统 IME UI:
`WM_GETDLGCODE` 不得无条件返回 `DLGC_WANTALLKEYS`,必须结合当前焦点能力、`wParam` 和可选 `MSG*` 选择性声明。 `WM_GETDLGCODE` 不得无条件返回 `DLGC_WANTALLKEYS`,必须结合当前焦点能力、`wParam` 和可选 `MSG*` 选择性声明。
阶段 3 的首个生产接管项只有 Tab:
1. 切片 1–5 的生产路径继续原样返回旧窗口过程结果,不提前改变同步协议。
2. 切片 6 启用生产 KeyboardRouter 后,先调用旧窗口过程,再仅在当前查询明确对应 Tab 时合并 `DLGC_WANTTAB`。
3. 不因阶段 3 已有路由框架就顺手声明 `DLGC_WANTALLKEYS`、`DLGC_WANTARROWS` 或 `DLGC_WANTCHARS`;方向键、文本与复合控件能力在实际接入阶段分别开放。
4. `WM_GETDLGCODE` 仍不进入 NativeInputQueue,也不经过异步 `KeyRouteResult`。
#### 5.2 异步输入消息 #### 5.2 异步输入消息
| 消息 | 规范化结果 | 初始处置 | | 消息 | 规范化结果 | 初始处置 |
@@ -353,7 +362,7 @@ struct WindowActivationEvent
}; };
``` ```
窗口失活时可以保留逻辑焦点句柄,但 `keyboardFocusActive == false` 时不路由键盘。窗口重新激活后先验证句柄,失效则执行焦点回退。 窗口失活时保留逻辑焦点身份,但 `keyboardFocusActive == false` 时暂停键盘、文本和 IME 的业务分发,并单独取消 Button 等控件的键盘瞬时按压态。窗口重新激活后先验证句柄,失效则执行焦点回退。清理按压态和暂停业务路由是两个独立动作,不能用其中一个代替另一个。
#### 6.5 队列外壳 #### 6.5 队列外壳
@@ -364,6 +373,9 @@ struct NativeInputEvent
DWORD messageTime; DWORD messageTime;
HWND hwnd; HWND hwnd;
bool windowActiveAtCapture;
uint64_t windowActivationGeneration;
std::variant< std::variant<
KeyEvent, KeyEvent,
TextInputEvent, TextInputEvent,
@@ -375,6 +387,19 @@ struct NativeInputEvent
键盘、文本和 IME 事件不合并、不静默丢弃。 键盘、文本和 IME 事件不合并、不静默丢弃。
`windowActiveAtCapture` 与 `windowActivationGeneration` 属于整个原生事件的采集时快照,而不是只属于 `KeyEvent`。TextBox 后续接入文本与 IME 时必须使用同一门控,避免失活前积压的文字在重新激活后插入。
每个 `NativeInputContext` 维护一个可原子读取的 `{ keyboardFocusActive, generation }`:
1. 挂接 WndProc 前用 HWND 属主线程的 `GetGUIThreadInfo` 初始化;查询失败时按 inactive 处理,不猜测为 active。
2. `WM_SETFOCUS/WM_KILLFOCUS` 在入队 `WindowActivationEvent` 前发布新快照;激活状态真正变化时递增 generation。
3. 其它原生事件入队时复制当时的 active 与 generation。
4. 宿主路由一条业务事件前重新读取最新原子快照;只有“采集时 active、当前仍 active、generation 相同”三者同时成立才允许路由。
5. 失活后再恢复会产生新 generation,旧激活周期的积压事件即使此时窗口再次 active 也必须丢弃业务交付。
6. 门控以宿主线程紧邻路由前的原子读取为线性化点;不得为追求跨线程绝对同时而在 WndProc 与控件回调之间持有共享阻塞锁。
切片 3 使用一个 64 位原子值打包 active 位与 generation,避免消费者读到撕裂的二元状态;generation `0` 保留为未知/无效快照。首次挂接通过窗口属主线程的 `GetGUIThreadInfo` 判断键盘焦点,查询失败按 inactive 初始化。`WM_SETFOCUS/WM_KILLFOCUS` 先发布新状态,再把携带同一快照的 `WindowActivationEvent` 入队;每条其它原生输入也复制采集时快照。
#### 6.6 跨线程队列模型 #### 6.6 跨线程队列模型
当前拓扑是单生产者/单消费者: 当前拓扑是单生产者/单消费者:
@@ -566,15 +591,23 @@ DPI 规则:
#### 10.1 焦点能力 #### 10.1 焦点能力
```cpp ```cpp
enum class FocusPolicy enum class FocusPolicy : std::uint8_t
{ {
None, None = 0,
Programmatic, Programmatic = 1u << 0,
Click, Pointer = 1u << 1,
TabAndClick Tab = 1u << 2
};
struct FocusTargetId
{
ControlHandle owner;
std::uint32_t subNode = 0;
}; };
``` ```
`FocusTargetId` 是 FocusManager 唯一长期身份。阶段 2 审计确认 Table 分页按钮与 TabControl 页签按钮都是真实 `Button`,所以现有焦点节点全部使用 `{buttonHandle, 0}`;非零 `subNode` 只预留给未来确实没有独立 `Control` 的轻量节点,不得为现有 Button 制造第二套身份。
| 控件 | 焦点角色 | | 控件 | 焦点角色 |
|---|---| |---|---|
| Label | 不可聚焦 | | Label | 不可聚焦 |
@@ -586,20 +619,28 @@ enum class FocusPolicy
| Dialog | 焦点作用域 | | Dialog | 焦点作用域 |
| Window | 根焦点作用域 | | Window | 根焦点作用域 |
#### 10.2 `ControlHandle` #### 10.2 身份与瞬时快照协议
当前 `ControlHandle` 是进程内单调递增且不复用的 `uint64_t`,`0` 表示无效;不存在 slot/generation 复用模型。焦点树长期只保存 `FocusTargetId`,解析时临时取得 `FocusTargetSnapshot`:
```cpp ```cpp
struct ControlHandle struct FocusTargetSnapshot
{ {
WindowId owner; FocusTargetId id;
uint32_t slot; FocusTargetId logicalParent;
uint32_t generation; FocusScopeId scope;
FocusPolicy policy;
bool visible;
bool enabled;
bool focusable;
bool tabStop;
std::int32_t tabIndex;
}; };
``` ```
- slot 重用必须递增 generation。 - `Window::findControl()` 只查直接子级,不能承担焦点解析;FocusManager 使用独立递归协议遍历 Canvas、Table 和 TabControl。
- 失效句柄解析为空。 - `enabled` 由控件协议动态给出,不假定基类存在统一 enabled 字段;例如 Button 从 `ButtonMode::DISABLED` 推导。
- `collectTabStops()` 返回 `ControlHandle`。 - 快照布尔值只代表采集时刻。任何 FocusLost/FocusGained 用户回调结束后必须重新解析并重新拉取全部字段。
- `Control*` 只允许作为当前函数内立即使用的借用视图,不得跨事件、回调、mutation transaction 或模态调用保存。 - `Control*` 只允许作为当前函数内立即使用的借用视图,不得跨事件、回调、mutation transaction 或模态调用保存。
#### 10.3 焦点有效条件 #### 10.3 焦点有效条件
@@ -618,19 +659,23 @@ struct ControlHandle
```cpp ```cpp
FocusChangeResult requestFocus( FocusChangeResult requestFocus(
ControlHandle target, FocusTargetId target,
FocusReason reason); FocusReason reason);
``` ```
顺序: 切换在宿主线程的 mutation transaction 中执行:
1. 验证目标。 1. 重新解析并验证目标。
2. 保存旧句柄。 2. 保存旧 `FocusTargetId`,临时清空 active。
3. 更新 FocusManager。 3. 向旧目标发送 `FocusLost`;回调可删除控件、切页、push scope 或再次请求焦点。
4. 向旧目标发送 `FocusLost`。 4. 回调后重新拉取目标的完整快照;目标失效时保持无焦点,不复活旧目标。
5. 向新目标发送 `FocusGained`。 5. 提交新 active,再发送 `FocusGained`。
6. 使相关区域失效并重绘。 6. 回调后再次验证 active;active 失效才进入独立的失效回退路径。
7. 新目标为 TextBox 时更新 IME 锚点。 7. 使旧/新控件所属托管区域失效并重绘。
回调内的 `requestFocus()` 不递归切换,只覆盖当前回合的单槽 pending。回合末只验证最终槽位;最终请求失效就丢弃,不恢复被覆盖请求。一次外层调用最多执行 64 轮,超限返回 `FocusError::ReentrancyLimit`、清空 pending,并保留最后一个已提交且仍有效的 active。
`pushScope()` 若发生在 FocusLost 回调中,恢复点取该回调对应的旧目标;若发生在 FocusGained 回调中,恢复点取新目标。不能因为切换中间态 `activeTarget` 暂时为空而丢失外层恢复身份。
#### 10.5 焦点失效回退 #### 10.5 焦点失效回退
@@ -645,6 +690,8 @@ FocusChangeResult requestFocus(
控件进入 pending removal 前,必须先让焦点系统中的对应句柄失效。 控件进入 pending removal 前,必须先让焦点系统中的对应句柄失效。
pending 请求失效与 active 焦点失效是两个入口:pending 失效只丢弃;active 失效才执行上述回退。`clearControls()` 必须先批量标记整批子树失效,再选择后备,禁止焦点落到同批即将删除的兄弟控件。
#### 10.6 Dialog 作用域 #### 10.6 Dialog 作用域
Dialog 打开: Dialog 打开:
@@ -661,6 +708,8 @@ Dialog 关闭:
3. 验证并恢复原焦点。 3. 验证并恢复原焦点。
4. 原焦点失效时执行外层回退。 4. 原焦点失效时执行外层回退。
阶段 2 只实现并测试 `pushScope/popScope` 机制,使用测试作用域验证重入与恢复;真实 Dialog 的打开/关闭接入仍留阶段 4,避免在焦点地基阶段提前改变模态行为。
### 11. Tab 顺序与复合控件 ### 11. Tab 顺序与复合控件
#### 11.1 Tab 顺序 #### 11.1 Tab 顺序
@@ -715,13 +764,15 @@ Table 本身不是焦点叶子,通过 `collectTabStops()` 暴露上一页和
```text ```text
取出一条事件 取出一条事件
→ 验证窗口激活状态 → 校验 sequence 并累计观察统计
→ IME 预处理 → 验证采集激活快照与当前激活 generation
→ Tab 导航 → 验证当前是否处于模态隔离态
→ IME / 系统组合键预处理
→ 锁定原始焦点句柄 → 锁定原始焦点句柄
→ 构造句柄路由路径 → 构造句柄路由路径
→ 焦点叶子 → 焦点叶子
→ 父容器冒泡 → 父容器冒泡
→ 若未阻止框架默认动作,以回调后的新焦点树执行 Tab 导航
→ FocusScope 默认动作 → FocusScope 默认动作
→ Window 默认动作 → Window 默认动作
→ 提交当前事件产生的 mutation → 提交当前事件产生的 mutation
@@ -729,6 +780,8 @@ Table 本身不是焦点叶子,通过 `collectTabStops()` 暴露上一页和
`TextInputEvent` 只发送给实现文本输入接口的当前焦点叶子,不冒泡。 `TextInputEvent` 只发送给实现文本输入接口的当前焦点叶子,不冒泡。
阶段 3 只对 `KeyEvent` 建立生产业务路由;`TextInputEvent` 与 `CompositionEvent` 仍完整排空、观察和计数,等阶段 5/6 的 TextBox/IME 接口接入后再交付。原始 `VK_PROCESSKEY`、`imeComposingAtCapture == true` 的键,以及阶段 3 没有明确拥有的 Alt/System 组合键,一律不进入 Tab、Button、Dialog 默认动作或其它框架业务处理。
#### 12.2 路由结果 #### 12.2 路由结果
```cpp ```cpp
@@ -743,6 +796,8 @@ struct KeyRouteResult
- `preventFrameworkDefault`:冒泡结束后不再执行 Dialog/Window 默认动作。 - `preventFrameworkDefault`:冒泡结束后不再执行 Dialog/Window 默认动作。
- 两者均不影响已经完成的原生 WndProc 返回值。 - 两者均不影响已经完成的原生 WndProc 返回值。
切片 4 将 Tab 定义为冒泡后的框架默认动作:原始焦点路径可以通过 `preventFrameworkDefault` 抑制换焦,单独 `stopBubble` 不会隐式抑制默认动作。真正执行 Tab 时重新采集当前活动 FocusScope 的完整停靠点树,不复用冒泡前快照,也不缓存上一按键的空结果。
#### 12.3 路由路径与 mutation #### 12.3 路由路径与 mutation
事件开始时使用句柄构造路径快照: 事件开始时使用句柄构造路径快照:
@@ -753,14 +808,54 @@ FocusedLeaf → Parent → ... → FocusScope → Window
每个节点使用前重新解析。处理过程中删除控件时,无效节点被跳过;当前事件仍使用原始路径,下一事件才使用新焦点和新树结构。 每个节点使用前重新解析。处理过程中删除控件时,无效节点被跳过;当前事件仍使用原始路径,下一事件才使用新焦点和新树结构。
新路由的整个单事件处理必须位于 Window mutation transaction 和托管重绘分发边界内。路由回调可以删除当前/祖先控件、改变焦点或打开模态 Dialog;回调返回后只能使用 `FocusTargetId` 重新解析,不能继续使用回调前取得的裸指针或快照布尔值。
`managedDispatchActive` 的进入/退出必须保留外层状态,不能让内层模态循环或回调重入把外层分发状态提前清成 false。NativeInputQueue 每次只弹出一条事件;路由回调进入模态循环时,外层 drainer 停在当前调用栈,内层循环可以继续逐条排空,返回后外层再继续,禁止预取整批事件到局部容器。
切片 2 的实现使用固定容量 256 的 `FocusTargetId` 路径数组,避免在业务路由中为常规父链动态分配。路径在任何控件回调前完整构造;检测到父链循环、超过深度上限或原始目标已逻辑失效时,整条事件不做部分投递,并通过内部结果的 `pathComplete=false` 暴露给测试/诊断。逐节点调用由 Control/Canvas/Table/TabControl 的受保护递归协议完成,借用的 `Control*` 不离开当前函数调用。
#### 12.4 阶段 3 测试门控与原子换轨
切片 1–5 曾使用内部流水线模式验证新路由;切片 6 完成后的模式为:
```text
Routed(生产默认)
EX_KEY → 纯排空与统计
NativeInputQueue → KeyboardRouter
LegacyCompatibility(仅内部 A/B 基线)
EX_KEY → Control::handleEvent
NativeInputQueue → 只观察排空
```
硬规则:
1. 两种模式互斥,任何窗口在同一时刻只能有一个键盘业务源。
2. 测试门控只能在事件循环开始前设置,不允许运行中切换造成半批次双投递或漏投递。
3. 切片 1–5 的生产默认曾保持 `LegacyCompatibility`;切片 6 已把默认一次性改为 Routed。
4. 普通 Window 与模态 Dialog 的 EasyX 循环必须在同一变更中换轨,不能存在只换其中一路的中间生产状态;当前实现已满足该约束。
5. 换轨后仍继续请求并排空 `EX_KEY`,避免 EasyX 内部队列累积;但该面不再调用任何控件的 `handleEvent()`。
6. `EX_MOUSE` 永久保持既有 `handleEvent()` 路径,不属于本次兼容性切换。
7. Legacy 只允许测试在事件循环锁定前选择;运行中不能切换,生产 API 不暴露该模式。
#### 12.5 窗口失活与模态隔离
- 收到失活事件时,KeyboardRouter 立即通知当前焦点路径清理 keyboard armed 等瞬时状态,但不清除 FocusManager 的逻辑焦点。
- 失活后路由暂停;失活前已入队但尚未 drain 的业务事件由 activation generation 判为 stale。
- 快速失活再恢复时,旧 generation 的事件不得投递到新激活周期。
- 当前模态 `Dialog` 是栈对象,不在 Window 的焦点树中。阶段 3 的模态循环必须继续排空、观察并统计 NativeInputQueue,但在真实 Dialog FocusScope 接入前抑制业务路由,禁止激活底层 Window 控件。
- 阶段 4 接入 Dialog FocusScope、默认按钮和 Escape 后,才把模态期键盘定向交给 Dialog 子树;阶段 3 不复制临时 Dialog 路由。
### 13. 按键归属 ### 13. 按键归属
#### 13.1 Tab #### 13.1 Tab
- IME 组合中交给 IME,不移动焦点。 - IME 组合中交给 IME,不移动焦点。
- 非组合态由 FocusManager 处理。 - 非组合态、无 Ctrl 的 `VK_TAB` KeyDown 在冒泡后由 FocusManager 处理;KeyUp 与 Ctrl+Tab 不触发普通遍历。
- Shift+Tab 反向移动。 - Shift+Tab 反向移动。
- 普通 Button/TextBox 不直接接收 Tab。 - 无焦点时 Tab 进入首项、Shift+Tab 进入末项;到达边界后在当前最上层 FocusScope 内循环。
- 每次按键重新采集并验证停靠点;冒泡回调对可见、可用、删除和作用域状态的修改对同一按键的默认动作立即生效。
- 普通 Button/TextBox 不把 Tab 作为自身命令处理;默认空路由后由框架 Tab 默认动作接管。
#### 13.2 Space #### 13.2 Space
@@ -770,11 +865,13 @@ Button:
首次 KeyDown → armed + pressed 首次 KeyDown → armed + pressed
重复 KeyDown → 忽略 重复 KeyDown → 忽略
KeyUp 且仍聚焦、仍 armed → 触发一次 click KeyUp 且仍聚焦、仍 armed → 触发一次 click
焦点丢失或控件禁用 → 取消 armed 焦点丢失、控件禁用/隐藏、Escape 或窗口失活 → 取消 armed
``` ```
TextBox 不从 KeyEvent 插入空格,只接受 `TextInputEvent(U+0020)`。 TextBox 不从 KeyEvent 插入空格,只接受 `TextInputEvent(U+0020)`。
`keyboardArmed` 必须是 Button 独立状态,不能复用现有 `click`:`click` 在 NORMAL 模式承担鼠标瞬时按压,在 TOGGLE 模式承担持久选中状态,复用会让键盘释放破坏 Toggle 状态或让鼠标/键盘互相误触发。绘制按压色时可使用 `click || keyboardArmed`,但阶段 2 的焦点框仍在按钮本体之后绘制并保持可见。
#### 13.3 Enter #### 13.3 Enter
```text ```text
@@ -1048,24 +1145,31 @@ struct ProbeConsumeRule
#### 阶段 2:FocusManager #### 阶段 2:FocusManager
- ControlHandle。 - 已实现 `FocusTargetId`、瞬时快照协议和递归焦点树解析。
- 焦点申请与回退。 - 已实现每 Window `FocusManager`、焦点申请、失效回退、单槽重入与 64 轮活性保护。
- Window/Dialog FocusScope。 - 已实现 FocusScope 机制与恢复身份;真实 Dialog 接入留阶段 4。
- PointerDown 申请焦点。 - Button/TextBox PointerDown 申请焦点并绘制焦点框。
- mutation 生命周期。 - Table 暴露真实分页 Button;TabControl 暴露真实页签 Button 和活动页面树。
- pending removal 入队时逻辑失效,并与 mutation transaction 同步收口。
- 阶段 1 基线与阶段 2 工作树受控 A/B 为 62/62,通过后当前阶段 2 正式完整 UI 三轮 24/24;未发现阶段 2 相关失败或耗时劣化,阶段 2 实现与回归归因门槛均已关闭。
- 不实现 Tab 键导航、按键路由、Table 键盘翻页、页签 roving-tab-stop 或 TextBox 编辑内核。
#### 阶段 3:KeyboardRouter #### 阶段 3:KeyboardRouter
- Tab 导航。 - 切片 1(已完成):增加 `RoutedKeyEvent` / `KeyRouteResult`、Control 默认空路由接口和内部流水线门控;生产默认仍为旧 EX_KEY。
- 句柄路由路径。 - 切片 2(已完成):实现 `FocusTargetId` 原始路由路径、逐节点重新解析、叶子到祖先冒泡、`stopBubble` / `preventFrameworkDefault` 独立聚合,以及可嵌套的单事件 mutation/托管分发边界;真实 Dialog 默认动作暂不接入。
- 冒泡和框架默认动作。 - 切片 3(已完成):实现 activation generation、失活业务门控、键盘瞬时状态暂停协议、IME/Alt-System 抑制和模态期底层路由隔离;该暂停协议已由切片 5 的 Button `keyboardArmed` 清理路径复用。
- Button 和 Dialog 键盘行为。 - 切片 4(已完成):实现测试门控下的 Tab / Shift+Tab 冒泡后默认动作;每次按键重新获取焦点树,支持当前活动 FocusScope 内正反向循环、无焦点进入首/末项和 `preventFrameworkDefault` 抑制。Table 分页 Button 仅在首次 draw 后 materialize,draw 前“无可遍历项”不缓存或解释为永久无 Tab stop。
- 切片 5(已完成):实现 Button Enter/Space、独立 `keyboardArmed`、Escape/焦点变化/模式切换/隐藏/失活与模态隔离清理,以及按压视觉与焦点框叠加;自动重复不重复激活,Button 命令阻止框架默认动作但继续冒泡,仍只由测试门控触发。
- 切片 6(实现与自动回归已完成):生产默认改为 Routed;普通 Window 与模态 Dialog 的 EX_KEY 同时变为纯排空,NativeInputQueue 成为唯一键盘业务源;同步加入选择性 `WM_GETDLGCODE`、旧业务/排空诊断计数、第三方迁移说明、生产 `keyboard-routing` UI 场景和受控 A/B。真实 IME / KEY1–6 人工换轨回归仍待执行。
- 切片 1–5 不允许新路由与旧 `EX_KEY -> handleEvent()` 同时向生产控件交付;测试窗口进入 Routed 模式时也必须先关闭自己的旧业务交付。
#### 阶段 4:复合控件 #### 阶段 4:复合控件
- Table 翻页 Button。 - Table 翻页 Button。
- TabControl roving tab stop。 - TabControl roving tab stop。
- 活动页面过滤和页签/页面路由隔离。 - 活动页面过滤和页签/页面路由隔离。
- 真实 Dialog FocusScope 生命周期、模态子树路由、默认按钮与 Escape 关闭。
#### 阶段 5:TextBox #### 阶段 5:TextBox
@@ -1090,7 +1194,7 @@ struct ProbeConsumeRule
6. IME 正式提交优先使用 `GCS_RESULTSTR`。 6. IME 正式提交优先使用 `GCS_RESULTSTR`。
7. TextBox 内部使用 UTF-16。 7. TextBox 内部使用 UTF-16。
8. 绘制优先使用宽字符 GDI,不默认降级 CP936。 8. 绘制优先使用宽字符 GDI,不默认降级 CP936。
9. 长期焦点、队列和遍历结果只使用 ControlHandle。 9. 长期焦点、队列和遍历结果只使用 `FocusTargetId` / `ControlHandle`,不保存裸控件指针。
10. 路由结果区分停止冒泡和阻止框架默认动作。 10. 路由结果区分停止冒泡和阻止框架默认动作。
11. TabControl 页签事件绝不传入活动页面。 11. TabControl 页签事件绝不传入活动页面。
12. IME 组合期间暂停框架快捷键、焦点导航和 Dialog 默认动作。 12. IME 组合期间暂停框架快捷键、焦点导航和 Dialog 默认动作。
@@ -1117,18 +1221,45 @@ struct ProbeConsumeRule
33. `mutex + deque` 在正常资源条件下不得丢输入;分配失败或队列异常必须 fail-stop,不能捕获后假装成功。 33. `mutex + deque` 在正常资源条件下不得丢输入;分配失败或队列异常必须 fail-stop,不能捕获后假装成功。
34. `WM_IME_ENDCOMPOSITION` 不得在转发旧窗口过程之前清空结果回声;END 处理中嵌套或紧随其后的匹配 `WM_IME_CHAR/WM_CHAR` 仍须抑制重复入队。 34. `WM_IME_ENDCOMPOSITION` 不得在转发旧窗口过程之前清空结果回声;END 处理中嵌套或紧随其后的匹配 `WM_IME_CHAR/WM_CHAR` 仍须抑制重复入队。
35. 无效文本序列、IMM 查询失败和残留半截解码状态必须以不含原文的计数显式诊断,不得写入替代文本或静默记为成功。 35. 无效文本序列、IMM 查询失败和残留半截解码状态必须以不含原文的计数显式诊断,不得写入替代文本或静默记为成功。
36. 焦点长期身份统一为 `FocusTargetId`;现有真实 Control 节点一律使用 `subNode == 0`,Table 分页和 TabControl 页签不得建立合成身份。
37. 当前 `ControlHandle` 在进程内单调分配且不复用,不采用 slot/generation 草案;焦点解析使用独立递归快照协议。
38. 焦点回调重入使用当前事务回合内的单槽 latest-wins;最终槽位失效只丢弃,不复活旧请求。
39. 焦点切换最多 64 轮;超限以 `FocusError::ReentrancyLimit` 明确失败,并保留最后一个仍有效的已提交焦点。
40. removal 请求入队时立即让对应焦点子树逻辑失效;批量 clear 必须先使整批失效,再执行 active 回退。
41. 阶段 2 只建立 FocusScope 机制;真实 Dialog 作用域接入、Tab 导航和键盘路由不得提前混入。
42. 阶段 3 的 Tab 遍历必须区分 Table 分页 Button“首次 draw 前尚未 materialize”与“永久无停靠项”,不得把 draw 前空快照固化为长期结论。
43. 阶段 3 的 Button 键盘按压视觉必须叠加在最小焦点框之上,焦点框不得因按压态切换而消失。
44. 阶段 3 切片 1–5 的新路由只能通过内部测试门控启用,且测试态的新旧业务交付也必须互斥;切片 6 已按约定一次性切换生产默认。
45. 窗口键盘激活门控使用采集侧原子 `{active, generation}` 与事件采集快照双重校验;只按队列顺序更新消费者布尔值不足以阻止失活前积压键或快速失活/恢复后的跨代投递。
46. 窗口失活时“清理 keyboard armed”和“暂停键盘/文本/IME 业务分发”是两个独立动作;逻辑焦点保留,重新激活后重新验证。
47. 当前栈上模态 Dialog 未进入 Window 焦点树;阶段 3 模态期只排空并抑制业务路由,禁止按键穿透到底层焦点。真实模态子树路由、默认按钮和 Escape 在阶段 4 随 Dialog FocusScope 一起接入。
48. Button 的 keyboard armed 独立于现有 `click`;Space 在 KeyUp 确认,Enter 在首次 KeyDown 确认,焦点丢失、禁用、隐藏、Escape 和窗口失活都会取消 armed。
49. 切片 6 必须在同一提交内同时切换 Window 主循环和 Dialog 模态循环的 EX_KEY 路径;换轨后仍持续排空 EX_KEY,但业务交付计数必须为零,EX_MOUSE 不变。
50. 阶段 3 只对明确的 Tab 查询选择性合并 `DLGC_WANTTAB`;不得无条件返回 `DLGC_WANTALLKEYS`,其它 dialog code 随对应功能接入。
51. `VK_PROCESSKEY`、采集时标准组合态和阶段 3 未明确拥有的 Alt/System 组合键只观察不路由;WndProc 仍按默认策略转发。
52. 新路由必须纳入可嵌套的托管分发与 mutation 边界;回调后重新按身份解析,内层模态循环不得提前清除外层 `managedDispatchActive` 状态。
53. 切片 2 的原始路由路径使用固定容量 256 的身份数组;循环、超深或构造期失效必须在调用任何控件前 fail closed,不允许动态分配失败或畸形父链造成半条路径已投递。
54. Button 的 Enter 与有效 Space 属于叶子命令:返回 `preventFrameworkDefault=true` 防止框架默认动作重复激活,但保持 `stopBubble=false` 允许祖先观察;Escape 只取消本地 armed,不阻止 Dialog/Window 后续默认动作。
55. v0.22 起生产默认固定为 Routed;LegacyCompatibility 只用于事件循环启动前锁定的内部 A/B,不是公开兼容开关,也不得在运行中切换。
56. 阶段 3 只把 `KeyEvent` 交给控件业务路由;`TextInputEvent` 与 `CompositionEvent` 继续完整排空、观察和计数,正式文本交付接口留阶段 5/6。
57. 自动 A/B、生产 UI 与 input-shadow 只能关闭自动换轨门槛;微软拼音、搜狗和 KEY1–6 人工结果必须独立记账,不能由自动测试代替。
58. Window 背景或非模态 Canvas 空白区域收到未被子控件消费的左键按下时,必须清除逻辑焦点并消费该指针事件,禁止继续向更低层兄弟穿透;模态 Dialog 在阶段 4 接入真实 FocusScope 前不借此清除底层逻辑焦点。
59. Table 分页 Button 虽有独立 ControlHandle,但属于 Table 内部绘制单元;其焦点视觉变化必须提升为 Table 重绘边界,不能把内部 Button 当成可独立提交到屏幕的顶层重绘 root。
60. 同父 Canvas 的下层持久 coverage 已写入且命中上层兄弟时,上层完整恢复优先于其局部脏子树提交;必须作废旧快照并重建整个上层兄弟,避免下层像素覆盖上层未变脏区域。
## 实现与影响 ## 实现与影响
- 关键实现点: - 关键实现点:
- 阶段 0A 探测器和 0C 独立视觉探测已进入仓库;阶段 1 的生产 NativeInputQueue、输入事件结构、采集规范化、IME 状态与观察排空已实现。FocusManager、KeyboardRouter 和 TextBox 改造尚未开始。 - 阶段 0A 探测器和 0C 独立视觉探测已进入仓库;阶段 1 的 NativeInputQueue 与输入规范化、阶段 2 的 FocusManager、递归焦点协议、PointerDown 聚焦、失效回退和作用域机制,以及阶段 3 全部六个切片的路由契约、互斥门控、身份路径、逐节点分发、冒泡、嵌套 mutation 边界、activation generation、输入所有权门控、模态隔离、Tab 默认动作、Button Enter/Space 行为和生产 EX_KEY 原子换轨已经实现。TextBox 编辑内核尚未开始。
- 独立 EasyX 探测已经推翻 v0.4 的单线程假设,v0.5 据此补入跨线程队列约束;v0.6 根据微软拼音/搜狗真实日志锁定结果串与回声关系,并补入输入布局 request/change 分层;v0.7 根据专用短测区分 TSF 配置文件切换与跨语言 HKL,并增加重新获焦快照约束;v0.8 根据 0C 自动证据锁定宽字符绘制、批次进入顺序与当前 DPI 坐标规则;v0.9 根据搜狗真实失败与修复复验锁定 IME 位置双时机规则;v0.13 根据 0B 证据锁定默认转发与无生产消费白名单;v0.14 根据阶段 1 实现审查锁定 EX_KEY 兼容过渡、两轴快照、fail-stop、END 回声边界和显式异常计数。 - 独立 EasyX 探测已经推翻 v0.4 的单线程假设,v0.5 据此补入跨线程队列约束;v0.6 根据微软拼音/搜狗真实日志锁定结果串与回声关系,并补入输入布局 request/change 分层;v0.7 根据专用短测区分 TSF 配置文件切换与跨语言 HKL,并增加重新获焦快照约束;v0.8 根据 0C 自动证据锁定宽字符绘制、批次进入顺序与当前 DPI 坐标规则;v0.9 根据搜狗真实失败与修复复验锁定 IME 位置双时机规则;v0.13 根据 0B 证据锁定默认转发与无生产消费白名单;v0.14 根据阶段 1 实现审查锁定 EX_KEY 兼容过渡、两轴快照、fail-stop、END 回声边界和显式异常计数;v0.15 根据阶段 2 审计与实现锁定 FocusTargetId、瞬时快照、单槽重入、64 轮活性保护和 pending removal 即时失效;v0.16 根据受控 A/B 和正式完整 UI 三轮结果关闭阶段 2 回归归因门槛,并锁定 Table 首次 draw 生命周期与 Button 按压/焦点视觉叠加约束;v0.17 根据阶段 3 只读审计锁定测试门控、activation generation、模态隔离、Button 独立 armed、两套 EX_KEY 循环原子换轨和选择性 `WM_GETDLGCODE`;v0.18 根据切片 2 实现锁定固定容量原始身份路径、逐节点重新解析、独立路由结果位与可嵌套托管 mutation 边界;v0.19 根据切片 3 实现锁定原子激活快照、跨代丢弃、IME/Alt-System 抑制、焦点路径暂停协议和模态底层隔离;v0.20 根据切片 4 实现锁定冒泡后 Tab 默认动作、每键新树、当前 FocusScope 循环和空结果不缓存;v0.21 根据切片 5 实现锁定 Button 独立 armed、Space 释放确认、Enter 首次按下确认、自动重复抑制、取消路径和“阻止默认但继续冒泡”的命令语义;v0.22 根据切片 6 实现与 A/B 证据锁定 Routed 生产默认、EX_KEY 纯排空、旧业务零交付、选择性 `DLGC_WANTTAB` 与第三方迁移边界;v0.23 根据 KEY1/KEY5 人工反馈与自动复验锁定 Window/Canvas 空白左键清焦点、Table 内部焦点视觉重绘提升和同父 Canvas 上层完整恢复优先级。
- 涉及文件 / 类 / 函数: - 涉及文件 / 类 / 函数:
- 预计涉及 `Window`、`Control`、`Canvas`、`Button`、`TextBox`、`Table`、`TabControl`、`Dialog`。 - 阶段 3 已涉及 `InputEvents`、`KeyboardRouting`、`NativeInputContext`、`KeyboardRouter`、`FocusManager`、`Window`、`Control`、`Button`、`Dialog`、测试夹具和迁移文档。
- 实际文件和接口必须在阶段 0 与实现前只读检查后确定。 - `Canvas`、`Table`、`TabControl` 在阶段 3 提供既有焦点树、逻辑父关系和递归身份分发协议;高级键盘行为仍留阶段 4。`TextBox` 编辑和文本交付仍留阶段 5/6。
- 兼容性影响: - 兼容性影响:
- 设计目标是保留现有鼠标行为和公共文本 API 的兼容入口。 - 设计目标是保留现有鼠标行为和公共文本 API 的兼容入口。
- 阶段 1 不改变现有 `EX_KEY` 业务交付;阶段 3 停止向 `Control::handleEvent()` 下发 `EX_KEY` 时属于明确的派生控件迁移点,`EX_MOUSE` 永久保留。 - 阶段 2 向 `Control` 增加焦点协议虚接口,现有二进制派生控件需要重新编译;源码接口没有删除,源码分发用户按正常流程重编译即可。
- 阶段 1 没有改变旧 `EX_KEY` 业务交付;阶段 3 现已停止向 `Control::handleEvent()` 下发 `EX_KEY`,这是明确的派生控件迁移点,`EX_MOUSE` 永久保留。
- 阶段 3 已新增 routed key 虚接口并再次改变 `Control` vtable;二进制派生控件必须重新编译,在旧 `handleEvent(EX_KEY)` 中处理键盘的源码派生控件必须迁移到新接口。
- 输入链路和 TextBox 内部模型将发生架构级变化,必须分阶段接入。 - 输入链路和 TextBox 内部模型将发生架构级变化,必须分阶段接入。
- 性能影响: - 性能影响:
- 输入事件会增加规范化和句柄解析开销,但不允许在 WndProc 中执行阻塞 I/O。 - 输入事件会增加规范化和句柄解析开销,但不允许在 WndProc 中执行阻塞 I/O。
@@ -1136,19 +1267,26 @@ struct ProbeConsumeRule
- 风险点: - 风险点:
- EasyX 双线程、ANSI 窗口和当前微软拼音/搜狗回声顺序已确认,但其他 IME、窗口 A/W 属性和系统版本仍可能产生不同顺序。 - EasyX 双线程、ANSI 窗口和当前微软拼音/搜狗回声顺序已确认,但其他 IME、窗口 A/W 属性和系统版本仍可能产生不同顺序。
- 模态重入和控件删除若破坏单 drainer/句柄不变量,可能产生难复现的顺序或生命周期问题。 - 模态重入和控件删除若破坏单 drainer/句柄不变量,可能产生难复现的顺序或生命周期问题。
- 阶段 1 的自动验证与人工回归必须分开记账;尚未执行的真实 IME/KEY1–6 人工操作不能由单元测试替代。 - 激活门控若只保存布尔而没有 generation,会在失活前积压或快速失活/恢复时产生跨激活周期误投递。
- Window 与 Dialog 两套 EX_KEY 循环若没有同一提交切换,会形成局部双投递或模态期旧路径残留。
- 自动验证与人工回归必须分开记账;阶段 3 的微软拼音、搜狗及 KEY1/KEY5 人工门槛已完成,不能用单元测试倒推其它未覆盖环境同样通过。
## 测试与验证 ## 测试与验证
- 测试范围: - 测试范围:
- 设计评审覆盖输入采集、同步消息、Unicode、IME、焦点、复合控件、按键归属、TextBox 和模态重入。 - 设计评审覆盖输入采集、同步消息、Unicode、IME、焦点、复合控件、按键归属、TextBox 和模态重入。
- 独立线程归属探测、0A、0C、0B 门槛均已完成;阶段 1 已加入逻辑测试与真实 EasyX `input-shadow` 场景,焦点系统尚未开始。 - 独立线程归属探测、0A、0C、0B 门槛均已完成;阶段 1 已加入逻辑测试与真实 EasyX `input-shadow` 场景;阶段 2 已加入焦点事务单测和真实 EasyX `focus` 场景,并以 62/62 受控 A/B 和 24/24 正式完整 UI 关闭回归归因门槛。
- 阶段 3 切片 1–5 已在内部测试门控启用的 Routed 模式覆盖路由路径、mutation 重入、activation generation、模态隔离、Tab 与 Button;切片 6 使用保存的换轨前 fixture 与当前 Routed fixture 进行共同场景 A/B,并把生产 input-shadow 改为“新路由完整、EX_KEY 纯排空、旧业务零交付”的断言。
- 切片 2 自动测试必须覆盖叶子到祖先顺序、原始目标稳定、停止冒泡与阻止默认动作相互独立、pending removal 跳过后续节点、嵌套路由不提前提交外层 mutation,以及回调改焦点只影响下一事件。
- 切片 6 的新 input-shadow 已同时证明:WndProc 规范化事件完整到达新路由、EasyX EX_KEY 仍持续被排空、`Control::handleEvent(EX_KEY)` 业务交付为零、队列最终深度为零、序列严格且稳定波次下内存不增长。
- 验证步骤: - 验证步骤:
1. 先按关联规格实现阶段 0A 被动探测并取得消息链路证据。 1. 先按关联规格实现阶段 0A 被动探测并取得消息链路证据。
2. 按阶段 0C 规格完成宽字符 GDI 与 IME 坐标探测,取得绘制与坐标证据。 2. 按阶段 0C 规格完成宽字符 GDI 与 IME 坐标探测,取得绘制与坐标证据。
3. 使用阶段 0B 的逐消息规则验证消费策略,不修改生产默认路径。 3. 使用阶段 0B 的逐消息规则验证消费策略,不修改生产默认路径。
4. 阶段 1 至阶段 6 分别执行逻辑测试、仪器化运行测试和人工输入法/DPI 回归。 4. 阶段 1 至阶段 6 分别执行逻辑测试、仪器化运行测试和人工输入法/DPI 回归。
5. 阶段 3 切片 6 以前禁止把测试门控结果写成生产换轨完成;切片 6 以换轨前提交为基线执行自动 A/B,结构相同的 UI 场景比较行为与耗时,输入场景按新旧不同契约分别断言。
6. 切片 6 完成后执行完整 UI、KEY1–KEY6、微软拼音和搜狗人工回归;真实候选态的 `VK_PROCESSKEY`/组合键不得触发 Button、Tab 或底层 Dialog/Window 默认动作。
- 验证结果: - 验证结果:
- v0.4 设计经过多轮评审,已作为后续探测和实现的基线。 - v0.4 设计经过多轮评审,已作为后续探测和实现的基线。
@@ -1172,10 +1310,22 @@ struct ProbeConsumeRule
- 2026-10-07:搜狗候选态没有向目标 HWND 暴露 START/COMPOSITION/END 生命周期;最终 Consume 请求以 `ime_condition_not_met` 正确 fail-open 为 Forward,规则零命中、零消费、EasyX 键出现一次,结构 verdict 为 `MustForward`。 - 2026-10-07:搜狗候选态没有向目标 HWND 暴露 START/COMPOSITION/END 生命周期;最终 Consume 请求以 `ime_condition_not_met` 正确 fail-open 为 Forward,规则零命中、零消费、EasyX 键出现一次,结构 verdict 为 `MustForward`。
- 2026-10-07:最终 Debug x64 单元测试 15/15、UI fixture 1 轮、F24 KeyDown A/B 新运行通过;既有 KeyUp 与 baseline mismatch 证据经当前分析器回放保持有效。阶段 1 以默认转发且无消费白名单为起点。 - 2026-10-07:最终 Debug x64 单元测试 15/15、UI fixture 1 轮、F24 KeyDown A/B 新运行通过;既有 KeyUp 与 baseline mismatch 证据经当前分析器回放保持有效。阶段 1 以默认转发且无消费白名单为起点。
- 2026-10-07:阶段 1 自动回归通过:Debug x64、x64 ASan、Release x64、Debug Win32 均为 27/27,KEY1–6 全部编译/链接成功;完整 UI fixture 曾一轮通过,最终源码的独立 `input-shadow` 再次通过,旧 EX_KEY 与新队列均完整观察 4000/4000 条事件,新队列最终深度 0、顺序严格、内存增长 0、无 fault/规范化异常;PowerShell 5.1/7 AST、工程 XML 和 `git diff --check` 通过。最终完整 UI 的两次追加复跑分别在 `geometry/ownership` 鼠标点击检查点超时,均未进入输入场景,按既有桌面焦点抖动保留失败证据,不冒充最终全套连续通过。真实 IME 与 KEY1–6 交互仍待人工确认。 - 2026-10-07:阶段 1 自动回归通过:Debug x64、x64 ASan、Release x64、Debug Win32 均为 27/27,KEY1–6 全部编译/链接成功;完整 UI fixture 曾一轮通过,最终源码的独立 `input-shadow` 再次通过,旧 EX_KEY 与新队列均完整观察 4000/4000 条事件,新队列最终深度 0、顺序严格、内存增长 0、无 fault/规范化异常;PowerShell 5.1/7 AST、工程 XML 和 `git diff --check` 通过。最终完整 UI 的两次追加复跑分别在 `geometry/ownership` 鼠标点击检查点超时,均未进入输入场景,按既有桌面焦点抖动保留失败证据,不冒充最终全套连续通过。真实 IME 与 KEY1–6 交互仍待人工确认。
- 2026-10-08:阶段 2 FocusManager 完成实现审查;Debug x64、x64 ASan、Release x64、Debug Win32 均为 40/40,KEY1–6 全部编译/链接成功。最终源码的独立 `focus` UI 连续 3 轮通过。针对随后出现的 Tooltip 像素等待和 geometry 点击结果超时,以阶段 1 基线 `81341fa` 与阶段 2 工作树执行受控 A/B,62/62 个结构化样本全部通过;当前阶段 2 正式完整 UI 三轮八场景共 24/24 通过,未发现阶段 2 相关失败或耗时劣化。此前超时定性为间歇性桌面 fixture / 输入时序波动,仍保留残余偶发波动可能,不把关闭门槛表述为证明 UI 永不波动。
- 2026-10-08:阶段 3 只读审计确认旧 EX_KEY 业务入口同时存在于 Window 主循环和 Dialog 模态循环;`NativeInputContext` 只有顺序观察统计、没有业务 pop/激活代次门控;Button 没有独立键盘 armed;当前栈上模态 Dialog 不在 Window 焦点树;生产 WndProc 尚未实现选择性 `WM_GETDLGCODE`。v0.17 据此完成实现前契约收口,尚未改动代码或宣称阶段 3 已实现。
- 2026-10-08:阶段 3 切片 1 完成:新增 `KeyboardRouting.h`、Control 默认空 `handleKeyEvent()` 与启动前固定的 `LegacyCompatibility/Routed` 内部门控;Window 主循环和 Dialog 模态循环共用同一旧业务交付判定,Routed 测试态只截断 EX_KEY,EX_MOUSE/窗口消息不受影响。生产默认仍为 LegacyCompatibility,未建立 KeyboardRouter、未提前换轨。Debug x64、x64 ASan、Release x64、Debug Win32 均为 46/46,KEY1–KEY6 全部编译链接成功;完整 UI 追加复跑 1/1 通过,独立 input-shadow 记录旧业务 4000/4000、新队列 4000/4000、最终深度 0、严格有序且零 fault。此前完整 UI/定向 focus 各出现一次 `PrintWindow` 全黑抓帧,目标区域 6480/6480 为黑,而同轮后续失败截图显示焦点框正常;保留为 fixture 截图波动,不把失败样本删除或冒充连续通过。
- 2026-10-09:阶段 3 切片 2 完成:新增内部 KeyboardRouter、固定容量原始身份路径、Control/Canvas/Table/TabControl 递归身份分发协议,以及 NativeInputContext 单条“弹出并观察”入口;显式 Routed 测试模式在释放队列锁后逐条路由 KeyEvent,Legacy 生产默认仍只观测新队列并继续由旧 EX_KEY 交付业务。六个新增单测覆盖顺序冒泡、独立结果位、祖先 pending removal、嵌套 mutation/managed-dispatch、回调改焦点和非 KeyEvent 不交付边界。Debug x64、x64 ASan、Release x64、Debug Win32 均为 52/52,KEY1–KEY6 全部编译链接成功;交互桌面完整 UI 1/1 通过,`input-shadow` 保持旧业务 4000/4000、新队列 4000/4000、最终深度 0、顺序严格且零 fault。首次受限环境运行在场景开始前因 `MoveMouseToScreen` 返回 Access denied,未记作产品/UI 失败;获得交互桌面权限后重新完整执行并通过。
- 2026-10-09:阶段 3 切片 3 完成:NativeInputContext 使用单个 64 位原子值维护 active/generation,所有原生输入携带采集时激活快照;路由前以最新原子快照为线性化点,失活、跨代、`VK_PROCESSKEY`、标准组合生命周期内按键、Alt/System 和模态期底层输入均不进入控件回调。失活与进入模态循环会沿原始焦点身份路径调用键盘瞬时状态暂停协议,但保留逻辑焦点;Button 的真实 `keyboardArmed` 仍留切片 5。三项新增单测覆盖原子代次发布、五类业务门控和暂停路径;Debug x64、x64 ASan、Release x64、Debug Win32 均为 55/55,KEY1–KEY6 全部编译链接成功。生产仍默认 LegacyCompatibility;完整 UI 1/1 通过,证据位于 `TestResults/ui/20261009-013601-150-29780`,`input-shadow` 维持旧业务/新队列各 4000/4000、最终深度 0、顺序严格且零 fault。该 UI 证据只证明生产兼容路径未回归,Routed 门控由单元测试验证,不写成生产换轨完成。
- 2026-10-09:阶段 3 切片 4 完成:FocusManager 新增不缓存的当前作用域 Tab 遍历,KeyboardRouter 在原始路径冒泡完成且未被 `preventFrameworkDefault` 阻止后,对无 Ctrl 的 `VK_TAB` KeyDown 执行正向或 Shift 反向循环;KeyUp、Ctrl+Tab 和切片 3 的所有权/激活门控不会误触发。五项新增单测覆盖排序与双向循环、无焦点入口、停止冒泡与默认阻止独立性、回调后新树、空结果不缓存和活动 FocusScope 边界;Debug x64、x64 ASan、Release x64、Debug Win32 均为 60/60,KEY1–KEY6 全部编译链接成功。首次完整 UI 在场景开始前因桌面权限被拒绝,交互桌面重跑 1/1 通过,证据位于 `TestResults/ui/20261009-140434-611-33188`,`input-shadow` 仍为旧业务/新队列各 4000/4000、最终深度 0、顺序严格且零 fault。生产默认保持 LegacyCompatibility,以上只证明测试门控 Tab 与生产兼容路径,未宣称生产换轨。
- 2026-10-09:阶段 3 切片 5 完成:Button 新增独立 `keyboardArmed`,Space 首次 KeyDown 进入按压态、KeyUp 确认一次,Enter 只在首次 KeyDown 激活,自动重复不重复执行命令;Escape、焦点丢失、模式变化、隐藏、窗口失活和模态隔离会取消 armed。NORMAL/TOGGLE 的既有 `click` 语义保持不变,绘制层只合并按压色且焦点框继续最后绘制;命令阻止框架默认动作但继续冒泡,回调删除由托管 mutation 边界保护。五项新增单测后,Debug x64、x64 ASan、Release x64、Debug Win32 均为 65/65,KEY1–KEY6 全部编译链接成功;完整 UI 1/1 通过,证据位于 `TestResults/ui/20261009-150453-477-26448`,`input-shadow` 仍为旧业务/新队列各 4000/4000、最终深度 0、顺序严格且零 fault。生产默认仍为 LegacyCompatibility,本轮不宣称生产换轨。
- 2026-10-09:阶段 3 切片 6 实现与自动回归完成:生产默认改为 Routed,Window 与 Dialog 两套 EasyX 循环同时把 EX_KEY 降为纯排空;新增排空/旧业务交付诊断计数、窄 `DLGC_WANTTAB` 同步应答、生产 `keyboard-routing` UI 场景和迁移说明。Debug x64、x64 ASan、Release x64、Debug Win32 均为 66/66,KEY1–KEY6 全部编译链接成功。换轨前 fixture(SHA-256 `3F6267B6D5EE85A7A97244C3DAE21DB44CEFBD185F56A38516E5E2F6F6234974`)与当前 fixture 的共同八场景执行 5 对 5 A/B,Legacy 40/40、Routed 40/40;唯一较大耗时差来自 Routed 首次 ownership 启动单点,扩展样本后未形成稳定劣化。当前正式 UI 两轮九个业务场景 18/18,普通/模态关闭均通过;`keyboard-routing` 2/2,`input-shadow` 两轮均为 Routed 4000/4000、EasyX 排空 4000、旧业务交付 0、队列深度归零、序列严格、零 fault、内存增长 0。微软拼音、搜狗和 KEY1–6 的换轨后人工回归仍待执行。
- 2026-10-09:最终补跑暴露 PowerShell 7 宿主的 `PATH` / `Path` 双环境键会使 MSBuild 在创建 `CL.exe` 前抛 `MSB6001`。`test.ps1` 现仅在检测到重复键时通过 `cmd` 构建子进程删除大写 `PATH`、保留同值 `Path`,不修改调用者环境;PowerShell 7 与 Windows PowerShell 5.1 均重跑 Debug x64 66/66 通过。该失败未进入产品编译或用例执行,不记作键盘路由回归。
- 2026-10-09:微软拼音与搜狗人工候选输入复验完成;Button Space/Enter 行为符合锁定语义。KEY1/KEY5 随后暴露 Table 分页焦点框延迟、Window/Canvas 空白不清焦点及同父 Canvas 下层重绘覆盖上层三项问题,v0.23 依决策 #58–#60 修复。四配置单测均为 67/67,KEY1–KEY6 全部编译/链接;扩展 focus UI 3/3 与同一 fixture 的完整 UI 9/9 通过。移除临时诊断字段后,最终 fixture(SHA-256 `F439C0636CD6B979A47C4594BC5AB3728F6342F49602FE43078435011A877793`)在 `TestResults/ui/20261009-223506-755-18932` 再次完成九场景 9/9,普通/模态原生关闭均通过;用户定向复测 KEY1/KEY5 暂未发现问题,阶段 3 验收关闭。
- 已知限制 / 遗留问题: - 已知限制 / 遗留问题:
- §5.3 当前明确不建立生产消费白名单;若后续需要窄消费规则,仍必须补对应消息、虚拟键、IME 和环境的独立 0B 证据。 - §5.3 当前明确不建立生产消费白名单;若后续需要窄消费规则,仍必须补对应消息、虚拟键、IME 和环境的独立 0B 证据。
- §7.3 的微软拼音/搜狗回声状态机已有 0A 证据,其他 IME 仍需保持可诊断回退。 - §7.3 的微软拼音/搜狗回声状态机已有 0A 证据,其他 IME 仍需保持可诊断回退。
- 宽字符 GDI 与当前微软拼音/搜狗定位门槛已通过;100%/125% 与跨屏 DPI 仍由阶段 0C 规格作为实机能力缺口承接。 - 宽字符 GDI 与当前微软拼音/搜狗定位门槛已通过;100%/125% 与跨屏 DPI 仍由阶段 0C 规格作为实机能力缺口承接。
- 阶段 3 六个切片、自动回归、微软拼音/搜狗和 KEY1/KEY5 定向人工复测均已完成;阶段 3 验收已关闭。真实 Dialog FocusScope、默认按钮、Escape 与复合控件高级键盘行为仍属阶段 4。
- 阶段 3 只路由 `KeyEvent`;TextBox 的 `TextInputEvent` / `CompositionEvent` 正式交付、编辑内核、caret 和 IME anchor 仍属阶段 5/6。
## 落地信息 ## 落地信息
@@ -1187,6 +1337,7 @@ struct ProbeConsumeRule
- 相关文档: - 相关文档:
- [`Module-20261001-0002-阶段0A输入链路被动探测规格.md`](./Module-20261001-0002-阶段0A输入链路被动探测规格.md) - [`Module-20261001-0002-阶段0A输入链路被动探测规格.md`](./Module-20261001-0002-阶段0A输入链路被动探测规格.md)
- [`Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md`](./Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md) - [`Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md`](./Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md)
- [`Module-20261009-0007-阶段3键盘定向路由与Button接入实现.md`](./Module-20261009-0007-阶段3键盘定向路由与Button接入实现.md)
- [TranslateMessage](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-translatemessage) - [TranslateMessage](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-translatemessage)
- [WM_UNICHAR](https://learn.microsoft.com/en-us/windows/win32/inputdev/wm-unichar) - [WM_UNICHAR](https://learn.microsoft.com/en-us/windows/win32/inputdev/wm-unichar)
- [WM_GETDLGCODE](https://learn.microsoft.com/en-us/windows/win32/dlgbox/wm-getdlgcode) - [WM_GETDLGCODE](https://learn.microsoft.com/en-us/windows/win32/dlgbox/wm-getdlgcode)
@@ -0,0 +1,126 @@
# 新增功能模块 / 模块重构
> 本记录描述通用输入系统阶段 2 的实际实现边界。阶段 2 建立逻辑焦点地基,但不切换键盘业务轨道。
## 基本信息
- 模块 ID:Module-20261008-0006
- 模块名称:阶段 2 通用焦点管理器与复合控件焦点树
- 状态:实现及回归归因均已关闭,可以进入阶段 3
- 类型:新增模块 / 架构演进
- 所属系统 / 子系统:StellarX Core / Window / Control / Focus
- 版本 / 分支:`master`,未提交工作区
- 环境:Windows + Visual Studio 2022 + MSVC v143 + EasyX + MBCS 工程配置
- 负责人:Codex 协作实现
## 背景与目标
- 阶段 1 已建立跨线程输入采集和观察队列,但没有“哪个控件持有逻辑焦点”的统一模型。
- 焦点不能成为 TextBox 私有状态;Button、TextBox、Table 分页 Button、TabControl 页签和活动页面必须进入同一递归焦点树。
- 回调允许删除控件、切页、修改可见性和打开更高作用域,因此焦点切换必须与 mutation transaction、稳定身份和重入保护一起实现。
## 模块边界
### 已实现
- `FocusTargetId { ControlHandle owner, uint32_t subNode }`;阶段 2 现有节点全部 `subNode == 0`。
- 每 Window 一个 `FocusManager`,公开程序化申请、清焦点、查询 active、Tab stop 快照和 FocusScope 机制。
- `Control` 瞬时焦点快照协议:逻辑父级、policy、effective visible、动态 enabled、tabStop 和 tabIndex。
- Window、Canvas、Table、TabControl 的递归解析、收集和焦点状态提交。
- Button/TextBox 默认支持 Programmatic、Pointer、Tab;PointerDown 申请焦点并绘制焦点框。
- Table 复用真实 prev/next Button 句柄,按当前页和总页数动态过滤首尾分页资格。
- TabControl 复用真实页签 Button,非活动页面通过 effective visibility 退出焦点树。
- pending removal 在入队时立即使焦点子树失效;批量 clear 先批量失效再寻找后备。
- FocusScope push/pop、外层焦点恢复和回调中 push 的稳定恢复身份。
### 明确未实现
- Tab/Shift+Tab 键盘遍历、KeyboardRouter 和祖先冒泡。
- `EX_KEY` 业务换轨;阶段 1 的旧 `Control::handleEvent(EX_KEY)` 兼容旁路保持不变。
- Button 的 Enter/Space 键盘语义、Table 键盘翻页、TabControl roving-tab-stop 与方向键切换。
- 真实 Dialog 打开/关闭与 FocusScope 的绑定;阶段 2 只实现并测试作用域机制。
- TextBox UTF-16 编辑内核、caret、选区、剪贴板和 IME anchor。
- 窗口激活态对键盘路由的门控;当前只保留逻辑焦点,待阶段 3 使用阶段 1 的 WindowActivationEvent。
## 关键设计
### 身份与快照
- 当前 `ControlHandle` 为进程内单调递增且不复用的 `uint64_t`,不是 slot/generation 句柄。
- FocusManager 长期只保存 `FocusTargetId`、FocusScope 条目和待失效根句柄,不保存裸指针。
- `Window::findControl()` 只查直接子级,焦点系统使用独立递归协议。
- 快照只在当前调用中有效;每次用户回调之后重新解析并重拉 visible/enabled/focusable/tabStop。
### 重入事务
- 普通 `requestFocus()` 立即在 mutation transaction 内执行。
- 焦点回调中的请求进入单槽 pending,后一次覆盖前一次;回合结束只验证最终请求。
- pending 最终请求失效时丢弃,不恢复被覆盖请求。
- active 焦点失效独立执行“下一个 Tab stop → 上一个 → initialFocus → 无焦点”回退。
- 单次外层切换最多 64 回合;超限返回 `FocusError::ReentrancyLimit`,清空 pending,保留最后一个仍有效的已提交焦点。
- FocusLost 回调中 push scope 时,以正在丢失的旧目标作为恢复点;FocusGained 回调中以新目标作为恢复点。
### mutation 与复合控件
- remove/clear 一经入队,FocusManager 立即把对应子树视为无效,即使对象尚未物理释放。
- `clearControls()` 先收集并批量标记所有现有根,避免回退到同批待删兄弟。
- Canvas 可见性改变在整棵子树状态更新后通知 FocusManager。
- Table 分页 Button 在首次 draw 后创建,真实 UI fixture 验证第一页只有 next 进入 Tab stop。
- TabControl 页签 Button 始终使用真实句柄;只有活动页面的后代进入焦点树。
## 实现文件
- `include/StellarX/FocusManager.h`
- `src/StellarX/FocusManager.cpp`
- `include/StellarX/CoreTypes.h`
- `include/StellarX/Control.h` / `src/StellarX/Control.cpp`
- `include/StellarX/Window.h` / `src/StellarX/Window.cpp`
- `include/StellarX/Canvas.h` / `src/StellarX/Canvas.cpp`
- `Button`、`TextBox`、`Table`、`TabControl`、`Dialog` 对应实现文件
- `tests/StellarXTests.cpp`
- `tests/UiFixture.cpp`
- `scripts/run-ui-tests.ps1`
## 测试与验证
### 逻辑测试
- 嵌套 Canvas 递归解析与稳定目标身份。
- Button/TextBox PointerDown 焦点切换。
- FocusLost 删除/禁用新目标后的完整快照复验。
- FocusGained 删除自身后的 active 回退。
- 单槽 latest-wins 不复活旧请求。
- FocusGained 与 FocusLost 回调中 push scope,pop 后恢复真实外层目标。
- 64 轮 A/B 重入循环返回 `ReentrancyLimit`。
- active removal 下一个 Tab stop 回退和 clear 批量失效。
- TabControl 真实页签句柄、`subNode == 0` 和非活动页面过滤。
### 自动结果
- Debug x64:40/40。
- x64 ASan:40/40,无 sanitizer 报错。
- Release x64:40/40。
- Debug Win32:40/40。
- KEY1–KEY6:全部编译/链接成功。
- 最终源码的独立 `focus` UI 连续 3 轮通过,包含 Button/TextBox 焦点框像素、Table 分页资格、TabControl 活动页过滤和 subNode 检查。
- 初次完整 UI 两次复跑分别在既有 Tooltip 像素等待和 geometry 点击结果等待处超时,因此追加阶段 1 基线 `81341fa` 与阶段 2 工作树的受控 A/B,而不以场景执行顺序直接排除焦点影响。
- 受控 A/B 共 62/62 个结构化样本通过:geometry、tooltip 两端各累计 8/8,共有七场景两端各 3/3;当前阶段 2 正式完整 UI 八场景再执行三轮,共 24/24 通过,未发现阶段 2 相关失败或耗时劣化。
- 裁决为排除阶段 2 稳定引入的隐性回归;此前超时定性为间歇性桌面 fixture / 输入时序波动,关闭阶段门槛不等于证明未来永不发生残余偶发波动。
- A/B 运行器首次把成功进程的 `ExitCode=0` 误读为空值,已修正并重新构建、重跑;原误报保留,并在 `TestResults/Stage2RegressionAB/20261008-120909-362/manifest.json` 中登记。
## 兼容性与风险
- 新增 `Control` 虚接口会改变 vtable;二进制派生控件必须重新编译。StellarX 以源码方式使用时,正常全量重编译即可。
- 没有删除既有公开接口,没有停止 `EX_KEY` 旧入口,阶段 2 不构成键盘迁移点。
- Table 内部按钮仍只在首次 draw 后存在;在 draw 前收集焦点树不会返回它们,这是当前生命周期事实。
- Button/TextBox 焦点框是阶段 2 的最小视觉反馈,最终主题化样式可在后续阶段增强。
- 真实 Dialog scope、窗口激活门控和键盘导航仍未接入,不能仅凭阶段 2 宣称完整键盘可访问性。
## 落地信息
- 关联功能变更 ID:无
- 关联 BUG / Fix:无
- Commit:待提交
- PR:无
- 发布版本:未发布
- 相关文档:[`Module-20261001-0001-通用输入焦点与键盘路由系统设计.md`](./Module-20261001-0001-通用输入焦点与键盘路由系统设计.md)
@@ -0,0 +1,190 @@
# 新增功能模块 / 模块重构
> 本记录描述通用输入系统阶段 3 的实际实现、生产换轨边界与验证证据。阶段 3 完成 KeyEvent 路由和 Button 接入,但不代表 TextBox 文本编辑、真实 Dialog FocusScope 或复合控件高级键盘行为已经完成。
## 基本信息
- 模块 ID:Module-20261009-0007
- 模块名称:阶段 3 键盘定向路由、Button 接入与 EX_KEY 生产换轨
- 状态:已完成;实现、自动回归、微软拼音/搜狗及 KEY1/KEY5 人工门槛全部关闭
- 类型:新增模块 / 架构演进 / 兼容性迁移
- 所属系统 / 子系统:StellarX Core / Window / Native Input / Focus / Keyboard Routing / Button
- 版本 / 分支:`master`,实现提交 `29df994`
- 环境:Windows + Visual Studio 2022 + MSVC v143 + EasyX + MBCS 工程配置
- 负责人:Codex 协作实现
## 背景与目标
- 阶段 1 已建立 WndProc 采集、规范化和跨线程 NativeInputQueue;阶段 2 已建立稳定身份焦点树和 FocusScope 机制。
- 阶段 3 要把 `KeyEvent` 从当前逻辑焦点叶子沿逻辑父链定向冒泡,并接入 Tab、Button Enter/Space、窗口激活与 IME 所有权门控。
- 最终切片必须一次性停止旧 `EX_KEY -> Control::handleEvent()` 生产交付,同时继续排空 EasyX 队列,避免新旧业务源并存或内部队列累积。
## 实现边界
### 已实现
- 公共 `RoutedKeyEvent`、`KeyRouteResult` 和 `Control::handleKeyEvent()` 默认空实现。
- 内部 `KeyboardRouter`:固定容量原始身份路径、逐节点重新解析、叶子到祖先冒泡、独立的停止冒泡与阻止框架默认动作结果位。
- 单事件 mutation transaction 与可嵌套 `managedDispatchActive` 边界;用户回调后不保留裸 `Control*`。
- 采集侧原子 `{ keyboardFocusActive, generation }` 快照;失活、跨激活代次、IME 所有和 Alt/System 输入在业务路由前被抑制。
- 失活和模态入口沿当前焦点路径取消 keyboard armed 等瞬时状态,但保留逻辑焦点。
- Tab / Shift+Tab 冒泡后默认动作:每次按键重新采集活动 FocusScope 的当前焦点树,支持循环、无焦点首/末项与 `preventFrameworkDefault`。
- Button 独立 `keyboardArmed`:Space 首次 KeyDown 按下、KeyUp 确认;Enter 首次 KeyDown 确认;自动重复不重复激活。
- Escape、焦点丢失、模式变化、隐藏、窗口失活和模态隔离取消 armed;按压色与阶段 2 焦点框叠加。
- 生产默认改为 Routed;Window 主循环和 Dialog 模态循环同时继续排空 `EX_KEY`,但不再交给 `handleEvent()`。
- `easyXKeyboardMessagesDrained` 与 `legacyEasyXKeyboardBusinessDeliveries` 诊断计数,用于证明排空持续且旧业务为零。
- `WM_GETDLGCODE` 窄同步分支:先取 EasyX 旧窗口过程结果,只对明确普通 Tab 查询合并 `DLGC_WANTTAB`。
- UI 运行器可指定 fixture、结果根目录和 Legacy/Routed 预期,并输出每场景结构化耗时 `run-summary.json`。
- 生产端到端 `keyboard-routing` 场景覆盖 Space 激活首按钮、Tab 换焦和 Enter 激活第二按钮。
- Window 背景与非模态 Canvas 空白左键清焦点并消费,避免焦点框残留和重叠兄弟穿透。
- Table 内部分页 Button 焦点变化提升为 Table 整体重绘;同父 Canvas 的下层持久 coverage 命中上层时,上层完整恢复优先于局部脏子树提交。
### 明确未实现
- `TextInputEvent` / `CompositionEvent` 向 TextBox 的正式业务交付;当前仍只排空、观察和计数。
- TextBox UTF-16 编辑内核、自绘、caret、选区、剪贴板和 IME anchor。
- Table 方向键/翻页命令、TabControl roving-tab-stop 和方向键切页。
- 真实 Dialog FocusScope、模态 Dialog 子树键盘路由、默认按钮和 Escape 关闭;当前模态循环抑制底层 Window 业务路由。
- `DLGC_WANTALLKEYS`、`DLGC_WANTCHARS`、`DLGC_WANTARROWS`;没有相应功能前不提前声明。
- WndProc 生产消费白名单;阶段 0B 没有给出跨 IME 安全白名单,因此原生按键仍按默认策略采集并转发。
## 关键契约
1. NativeInputQueue 是生产键盘业务源;EasyX `EX_KEY` 仅排空和观察。
2. Routed 与 LegacyCompatibility 互斥。Legacy 仅供测试在事件循环锁定前选择,不能运行中切换,也不是公开兼容开关。
3. 每次只从队列弹出一条事件,释放队列锁后才进入焦点、控件和用户回调;不得预取整批事件。
4. 路由开始时构造原始身份路径,之后逐节点重新解析。早期回调删除的后续节点跳过;回调改变焦点只影响下一事件。
5. `stopBubble` 与 `preventFrameworkDefault` 独立;二者都不能回溯改变已经完成的 WndProc 返回值。
6. 原始 `VK_PROCESSKEY` 是单键 IME 所有权威;`imeComposingAtCapture` 是独立生命周期轴。两者任一命中时,阶段 3 不执行控件、Tab 或默认动作。
7. 激活门控同时比较事件采集快照和当前原子 generation;窗口重新激活不能让旧周期积压键重新生效。
8. Button Space 在 KeyUp 确认,Enter 在首次 KeyDown 确认;命令阻止框架默认动作但继续允许祖先观察。
9. WndProc `WM_GETDLGCODE` 只在 Routed 普通 Tab 查询时增加 `DLGC_WANTTAB`;System/Alt+Tab 不认领。
10. `EX_MOUSE` 永久保留在 `Control::handleEvent()`;本次迁移只切断 `EX_KEY` 业务交付。
## 六个实现切片
### 切片 1:公共契约与互斥门控
- 新增 routed key 公共类型、Control 默认入口和内部 Legacy/Routed 模式。
- Window 与 Dialog 共用旧业务交付判定,测试 Routed 模式从第一天起就先关闭自己的旧 EX_KEY 业务路径,避免双投递。
### 切片 2:身份路由与 mutation
- 固定容量 256 的 `FocusTargetId` 路径;循环、超深或构造期失效时在调用控件前 fail closed。
- Control、Canvas、Table、TabControl 提供递归身份分发;借用指针不逃逸当前调用。
### 切片 3:激活、IME 与模态门控
- NativeInputEvent 携带采集时激活快照。
- `VK_PROCESSKEY`、标准组合生命周期内按键、Alt/System、失活、跨代和模态期底层输入不进入业务回调。
- 失活与模态入口调用焦点路径暂停协议,保留逻辑焦点。
### 切片 4:Tab 默认动作
- 无 Ctrl 的 Tab KeyDown 在冒泡后执行;Shift 反向、边界循环、无焦点进入首/末项。
- 每键重新获取焦点树;Table 分页 Button 在首次 draw 前的空结果不缓存。
### 切片 5:Button 键盘状态机
- `keyboardArmed` 与鼠标/Toggle 的 `click` 分离,只在绘制层合并按压视觉。
- Space/Enter、自动重复、Escape 和全部取消边界均由单测覆盖。
### 切片 6:生产原子换轨
- `Window::routedKeyboardPipeline` 生产默认改为 true;主循环和模态循环同时切断旧 EX_KEY 业务交付。
- 新增窄 `WM_GETDLGCODE` 分支、排空/旧业务计数、生产端到端 UI 场景、迁移说明和换轨前后 A/B。
- `TextInputEvent` 与 `CompositionEvent` 不提前交给控件,避免阶段 3 越界实现 TextBox。
## 实现文件
- `include/StellarX/KeyboardRouting.h`
- `include/StellarX/InputEvents.h`
- `include/StellarX/Control.h` / `src/StellarX/Control.cpp`
- `include/StellarX/FocusManager.h` / `src/StellarX/FocusManager.cpp`
- `include/StellarX/Button.h` / `src/StellarX/Button.cpp`
- `include/StellarX/Window.h` / `src/StellarX/Window.cpp`
- `src/StellarX/KeyboardRouter.cpp`
- `src/StellarX/Dialog.cpp`
- `src/StellarX/SxNativeInput.cpp`
- `src/StellarX/internal/SxKeyboardRouterInternal.h`
- `src/StellarX/internal/SxEventLoopInternal.h`
- `src/StellarX/internal/SxNativeInputInternal.h`
- `Canvas`、`Table`、`TabControl` 对应递归分发实现
- `tests/StellarXTests.cpp`
- `tests/UiFixture.cpp`
- `scripts/run-ui-tests.ps1`
## 自动验证
### 逻辑与构建矩阵
- Debug x64:67/67。
- x64 ASan:67/67,无 sanitizer 报错。
- Release x64:67/67。
- Debug Win32:67/67。
- KEY1–KEY6:全部编译/链接成功。
- 覆盖路由顺序、结果位独立性、删除/改焦点重入、激活代次、IME/Alt/System/模态抑制、Tab 当前树、Button 状态机、Routed 默认/Legacy 锁定和窄 `DLGC_WANTTAB`。
### 换轨前后 UI A/B
- 换轨前 fixture:`TestResults/Stage3CutoverAB/baseline/StellarX.UiFixture.exe`。
- 基线 SHA-256:`3F6267B6D5EE85A7A97244C3DAE21DB44CEFBD185F56A38516E5E2F6F6234974`。
- 共同八场景执行 5 对 5:Legacy 40/40、Routed 40/40。
- 结构化证据:
- `TestResults/Stage3CutoverAB/legacy/20261009-152906-951-18728`
- `TestResults/Stage3CutoverAB/legacy-expanded/20261009-153941-779-34800`
- `TestResults/Stage3CutoverAB/routed-formal/20261009-153745-045-33364`
- `TestResults/Stage3CutoverAB/routed-expanded/20261009-154044-849-12588`
- Routed 首轮 ownership 为 `1330 ms`,其余四次 `531–601 ms`,与 Legacy `517–709 ms` 重叠;其它场景平均差为 `-19 ms` 至 `+43 ms`。未观察到稳定耗时劣化,但仍承认交互桌面存在残余偶发波动。
### Routed 正式 UI
- 连续两轮九个业务场景共 18/18;普通/模态原生关闭检查每轮通过。
- `keyboard-routing` 2/2:首 Button Space 激活一次,Tab 聚焦第二 Button,Enter 激活一次。
- `input-shadow` 两轮一致:
- Routed 4000/4000,Down/Up 各 2000;
- EasyX `EX_KEY` 排空 4000;
- 旧 EX_KEY 业务交付 0;
- pushed/popped 4000/4000、depth 0、maximumDepth 40;
- sequence 严格、零 fault、零规范化异常、内存增长 0。
- 最初受限桌面运行因 `MoveMouseToScreen` 返回 Access denied,未进入产品断言;授权交互桌面后全部重跑,失败样本不计作产品回归。
- PowerShell 7 宿主同时注入 `PATH` / `Path` 时,旧 `test.ps1` 会让 MSBuild 在创建 `CL.exe` 前以 `MSB6001` 失败。运行器已改为只在检测到重复键时通过 `cmd` 子进程删除大写 `PATH`、保留同值 `Path`;不修改调用者环境。修复后 PowerShell 7 与 Windows PowerShell 5.1 的 Debug x64 均为 66/66。
### 人工反馈修复自动复验
- 新增 `CanvasBackgroundPointerDownClearsFocusAndConsumes` 后,四配置单测均为 67/67,KEY1–KEY6 全部编译/链接成功。
- 扩展 `focus` UI 连续 3/3 通过,证据位于 `TestResults/ui/20261009-220625-941-15096`;覆盖 Table 分页焦点框即时出现/消失、Window/Canvas 空白清焦点和 KEY5 同批上下层重绘。
- 同一 fixture 的完整 UI 1/1、九个业务场景 9/9 通过,证据位于 `TestResults/ui/20261009-220940-941-31488`;Tooltip、Dialog、input-shadow 与 keyboard-routing 均通过。
- 移除 UiFixture 中用于定位问题的临时 Button JSON 诊断字段后,用户从项目终端重新执行最终源码完整 UI。证据位于 `TestResults/ui/20261009-223506-755-18932`:fixture SHA-256 为 `F439C0636CD6B979A47C4594BC5AB3728F6342F49602FE43078435011A877793`,与 `run-summary.json` 一致;九个业务场景 9/9 通过,普通/模态原生关闭均通过。
- 自动化过程中曾分别出现一次沙箱 `SendInput Access denied`、一次 Button 点击未命中和一次 ownership 等待超时;失败证据保留。随后定向 3/3 与完整 UI 通过只说明未形成稳定产品回归,不把这些桌面输入波动写成从未发生。
## 兼容性与迁移
- `Control::handleEvent()` 仍保留,但生产循环不再向其交付 `EX_KEY`;第三方源码控件必须把键盘逻辑迁到 `handleKeyEvent()`。
- `EX_MOUSE` 和既有鼠标命中/分发保持不变。
- 新增虚接口再次改变 `Control` vtable;已编译的第三方派生控件必须使用当前头文件和 Core 库重新编译。
- 默认 `handleKeyEvent()` 为空,因此未迁移的第三方控件不会崩溃,但其旧键盘能力不会继续生效。
- 详细迁移示例见 [`../MIGRATION.md`](../MIGRATION.md)。
## 人工门槛与后续
- 微软拼音与搜狗已分别完成真实候选输入复验:候选出现/取消和 Space 交互正常,未报告卡顿、重复提交或输入法按键误触发 Button。
- Button Space/Enter 行为已由人工确认符合锁定语义:Space 按下显示按压态、松开执行;Enter 首次按下立即执行且不显示持续按压态。
- KEY1/KEY5 人工反馈暴露三项问题:Table 分页焦点框延迟、Window/Canvas 空白点击不清焦点、同父 Canvas 下层重绘覆盖 D-2 上层区域;对应实现和自动回归已补齐。
- 模态 Dialog Tab 不可用和非模态作用域顺序属于阶段 4 真实 Dialog FocusScope 范围,本轮不做临时补丁。
- 用户已定向复测 KEY1 与 KEY5,暂未发现问题:Table 分页焦点框即时出现/消失,Window/Canvas 空白点击清焦点,KEY5 D-1 → D-2 未再发生覆盖。
- 阶段 3 验收至此关闭;后续可单独讨论阶段 4 的 Table、TabControl 和真实 Dialog FocusScope 接入,但本记录不提前宣称这些能力已经实现。
## 落地信息
- 关联功能变更 ID:无
- 关联 BUG / Fix:`BUG-20261009-0001` / `Fix-BUG-20261009-0001`;`BUG-20261009-0002` / `Fix-BUG-20261009-0002`
- Commit:`29df994`(阶段 3 键盘路由、生产换轨与焦点重绘收口)
- PR:无
- 发布版本:未发布
- 相关文档:
- [`Module-20261001-0001-通用输入焦点与键盘路由系统设计.md`](./Module-20261001-0001-通用输入焦点与键盘路由系统设计.md)
- [`Module-20261007-0005-阶段1输入采集与规范化实现.md`](./Module-20261007-0005-阶段1输入采集与规范化实现.md)
- [`Module-20261008-0006-阶段2通用焦点管理器实现.md`](./Module-20261008-0006-阶段2通用焦点管理器实现.md)
- [`../MIGRATION.md`](../MIGRATION.md)
- [`../TESTING.md`](../TESTING.md)