9 Commits

53 changed files with 9134 additions and 185 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\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="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\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" />
+1 -3
View File
@@ -150,7 +150,6 @@ int main()
auto configureButton = [&](Button* button, const std::string& tooltip) auto configureButton = [&](Button* button, const std::string& tooltip)
{ {
button->enableTooltip(true); button->enableTooltip(true);
button->setTooltipDelay(120);
button->setTooltipText(tooltip); button->setTooltipText(tooltip);
}; };
@@ -382,7 +381,6 @@ int main()
auto configureTooltip = [](Button* button, const std::string& text) auto configureTooltip = [](Button* button, const std::string& text)
{ {
button->enableTooltip(true); button->enableTooltip(true);
button->setTooltipDelay(120);
button->setTooltipText(text); button->setTooltipText(text);
}; };
auto makeTestButton = [&](int x, int y, int width, int height, const std::string& text) auto makeTestButton = [&](int x, int y, int width, int height, const std::string& text)
@@ -1044,7 +1042,7 @@ bool gSigned = false; //是否为有符号数
int main() int main()
{ {
//StellarX::SxLogger::Get().enableConsole(true); //StellarX::SxLogger::Get().enableConsole(true);
StellarX::SxLogger::Get().enableFile("log.txt",true,1024); StellarX::SxLogger::Get().enableFile("log.txt", true, 10u * 1024u * 1024u);
StellarX::SxLogger::Get().setMinLevel(StellarX::SxLogLevel::Debug); // Info/Debug/Trace 自己切 StellarX::SxLogger::Get().setMinLevel(StellarX::SxLogLevel::Debug); // Info/Debug/Trace 自己切
StellarX::SxLogger::Get().setLanguage(StellarX::SxLogLanguage::ZhCN); // ZhCN / EnUS StellarX::SxLogger::Get().setLanguage(StellarX::SxLogLanguage::ZhCN); // ZhCN / EnUS
+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
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,7 +196,12 @@ public:
// 绘制入口。实现应只在 dirty/show 条件满足时写屏,并在完成后清理 dirty。 // 绘制入口。实现应只在 dirty/show 条件满足时写屏,并在完成后清理 dirty。
virtual void draw() = 0; virtual void draw() = 0;
// 事件入口。返回 true 表示事件已消费;若事件造成视觉变化,应设置 eventVisualChanged 并请求重绘。 // 事件入口。返回 true 表示事件已消费;若事件造成视觉变化,应设置 eventVisualChanged 并请求重绘。
// EX_MOUSE 永久保留在此入口;阶段 3 起生产事件循环不再向这里下发 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 / 临时按下态等鼠标瞬时状态。
// 该接口不是事件分发入口,不参与新的命中判断,也不允许回写设计基线。 // 该接口不是事件分发入口,不参与新的命中判断,也不允许回写设计基线。
@@ -226,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; }
//获取控件最近一次事件处理是否引发了视觉变化 //获取控件最近一次事件处理是否引发了视觉变化
@@ -278,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();
};
}
+136
View File
@@ -0,0 +1,136 @@
/*******************************************************************************
* StellarX native keyboard, text, IME, and activation event model.
* Stage 1 introduced capture, normalization, queuing, and observation.
* After the Stage 3 atomic cutover, normalized KeyEvent routing is the
* production business path; EasyX EX_KEY remains a drain-only observation.
******************************************************************************/
#pragma once
#include <windows.h>
#include <cstdint>
#include <string>
#include <variant>
namespace StellarX
{
enum class KeyAction : std::uint8_t
{
Down,
Up
};
// Snapshot modifiers in WndProc; delayed consumers must not reread global state.
struct KeyModifiers
{
bool leftControl = false;
bool rightControl = false;
bool leftShift = false;
bool rightShift = false;
bool leftAlt = false;
bool rightAlt = false;
bool control() const noexcept { return leftControl || rightControl; }
bool shift() const noexcept { return leftShift || rightShift; }
bool alt() const noexcept { return leftAlt || rightAlt; }
};
struct KeyEvent
{
KeyAction action = KeyAction::Down;
UINT virtualKey = 0; // Raw WndProc VK; VK_PROCESSKEY is never rewritten.
UINT physicalVirtualKey = 0; // Left/right-normalized modifier VK.
UINT scanCode = 0;
std::uint16_t repeatCount = 0;
bool extended = false;
bool wasDown = false;
bool altContext = false; // Raw lParam bit 29; modifiers.alt() is the semantic snapshot.
bool systemKey = false;
// Snapshot of the standard IME lifecycle axis at capture time.
// It never overrides the per-key ownership expressed by VK_PROCESSKEY.
bool imeComposingAtCapture = false;
KeyModifiers modifiers{};
bool isAutoRepeat() const noexcept
{
return action == KeyAction::Down && wasDown;
}
bool isImeOwnedAtCapture() const noexcept
{
// Per-key ownership and IME lifecycle are intentionally independent axes.
// A normal key captured during a standard composition remains a normal key;
// routing may suppress it because of the lifecycle snapshot, but must not
// rewrite its ownership.
return virtualKey == VK_PROCESSKEY;
}
};
enum class TextSource : std::uint8_t
{
ImeResult,
UnicodeChar,
UnicodeScalar,
AnsiCharFallback,
Clipboard
};
struct TextInputEvent
{
std::wstring text;
TextSource source = TextSource::UnicodeChar;
std::uint64_t compositionId = 0; // Zero for non-IME input.
};
enum class CompositionStage : std::uint8_t
{
Start,
Update,
Commit,
Cancel,
End
};
struct CompositionEvent
{
CompositionStage stage = CompositionStage::Start;
std::uint64_t compositionId = 0;
std::wstring compositionText;
std::uint32_t caretOffset = 0;
};
struct WindowActivationEvent
{
bool keyboardFocusActive = false;
HWND otherWindow = nullptr;
};
// Atomic producer snapshot copied into every native input event. Generation
// changes whenever keyboard focus active state changes, so a delayed event
// cannot cross a deactivate/reactivate boundary merely because the window is
// active again when the host thread eventually drains it.
struct NativeInputActivationSnapshot
{
bool keyboardFocusActive = false;
std::uint64_t generation = 0;
};
using NativeInputPayload = std::variant<
KeyEvent,
TextInputEvent,
CompositionEvent,
WindowActivationEvent>;
struct NativeInputEvent
{
std::uint64_t sequence = 0;
DWORD messageTime = 0;
HWND hwnd = nullptr;
NativeInputActivationSnapshot activationAtCapture{};
NativeInputPayload payload{};
};
}
+34
View File
@@ -0,0 +1,34 @@
/*******************************************************************************
* StellarX routed keyboard event contract.
* Stage 3 defines the public control seam and internal bubbling contract.
* Production routing uses this contract after the slice-6 atomic cutover.
******************************************************************************/
#pragma once
#include "InputEvents.h"
#include "CoreTypes.h"
namespace StellarX
{
// A normalized key plus the stable identities used while it bubbles through
// the focus path. The router updates currentTarget before each control call;
// controls must not retain either identity as a raw pointer.
struct RoutedKeyEvent
{
std::uint64_t sequence = 0;
DWORD messageTime = 0;
HWND hwnd = nullptr;
KeyEvent key{};
FocusTargetId originalTarget{};
FocusTargetId currentTarget{};
};
// Stopping the control bubble and suppressing a later framework default are
// independent decisions. Neither result can change the completed WndProc
// return value.
struct KeyRouteResult
{
bool stopBubble = false;
bool preventFrameworkDefault = false;
};
}
+5 -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,6 +31,9 @@
#pragma once #pragma once
#include "CoreTypes.h" #include "CoreTypes.h"
#include "FocusManager.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;
}; };
+64
View File
@@ -16,15 +16,47 @@
#pragma once #pragma once
#include "Control.h" #include "Control.h"
#include "FocusManager.h"
#include <atomic> #include <atomic>
#include <cstdint>
#include <string> #include <string>
#include <vector> #include <vector>
#include <memory> #include <memory>
#include <windows.h> #include <windows.h>
class Window;
namespace StellarX::Internal
{
class NativeInputContext;
class KeyboardRouter;
struct NativeInputRuntimeSnapshot;
enum class KeyboardPipelineMode : std::uint8_t;
NativeInputRuntimeSnapshot inspectWindowNativeInputForTesting(const ::Window& window) noexcept;
bool configureKeyboardPipelineForTesting(
::Window& window, KeyboardPipelineMode mode) noexcept;
KeyboardPipelineMode keyboardPipelineMode(
const ::Window& window) noexcept;
void lockKeyboardPipeline(::Window& window) noexcept;
}
namespace StellarX
{
class FocusManager;
}
class Window class Window
{ {
friend class Dialog; friend class Dialog;
friend class StellarX::FocusManager;
friend StellarX::Internal::NativeInputRuntimeSnapshot
StellarX::Internal::inspectWindowNativeInputForTesting(const Window& window) noexcept;
friend bool StellarX::Internal::configureKeyboardPipelineForTesting(
Window& window, StellarX::Internal::KeyboardPipelineMode mode) noexcept;
friend StellarX::Internal::KeyboardPipelineMode
StellarX::Internal::keyboardPipelineMode(const Window& window) noexcept;
friend void StellarX::Internal::lockKeyboardPipeline(Window& window) noexcept;
friend class StellarX::Internal::KeyboardRouter;
// —— 尺寸状态 ——(绘制尺寸与待应用尺寸分离;收口时一次性更新) // —— 尺寸状态 ——(绘制尺寸与待应用尺寸分离;收口时一次性更新)
int width; // 当前有效宽(已应用到画布/控件的客户区宽) int width; // 当前有效宽(已应用到画布/控件的客户区宽)
@@ -46,7 +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 };
// Routed 生产模式在宿主安全点逐条交给 KeyboardRouter;Legacy 仅供切换基线测试。
std::unique_ptr<StellarX::Internal::NativeInputContext> nativeInputContext;
bool nativeInputFailureReported = false;
std::uint64_t nativeInputRejectedTextReported = 0;
std::uint64_t nativeInputImeQueryFailuresReported = 0;
std::uint64_t nativeInputDiscardedFragmentsReported = 0;
// Stage 3 slice 6 production default. WndProc also reads this for the
// synchronous WM_GETDLGCODE answer, so the cross-thread snapshot is atomic.
std::atomic<bool> routedKeyboardPipeline{ true };
bool keyboardPipelineLocked = false;
// Host-thread diagnostics: EX_KEY must continue draining after cutover while
// legacy business delivery remains exactly zero.
std::uint64_t easyXKeyboardMessagesDrained = 0;
std::uint64_t legacyEasyXKeyboardBusinessDeliveries = 0;
bool hasLiveNativeWindow() const noexcept; bool hasLiveNativeWindow() const noexcept;
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
// —— 绘制相关 ——(是否使用合成双缓冲、窗口标题、背景等) // —— 绘制相关 ——(是否使用合成双缓冲、窗口标题、背景等)
@@ -59,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 };
@@ -122,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);
//非模态对话框去重 //非模态对话框去重
@@ -165,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。
+210 -4
View File
@@ -1,4 +1,4 @@
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$Path [string]$Path
) )
@@ -39,7 +39,183 @@ $textLeaks = @($records | Where-Object {
$_.messageCategory -eq 'text' -and $_.wParamHex -ne $null $_.messageCategory -eq 'text' -and $_.wParamHex -ne $null
}) })
$lost = @($records | Where-Object recordType -eq 'records_lost') $lost = @($records | Where-Object recordType -eq 'records_lost')
$lostTotal = 0
foreach ($lostRecord in $lost) {
if ($null -ne $lostRecord.lostCount) {
$lostTotal += [int]$lostRecord.lostCount
}
else {
$lostTotal++
}
}
$attach = $records | Where-Object recordType -eq 'window_attach' | Select-Object -First 1 $attach = $records | Where-Object recordType -eq 'window_attach' | Select-Object -First 1
$closeObserved = @($records | Where-Object messageName -eq 'WM_CLOSE').Count -gt 0
$sessionEndObserved = @($records | Where-Object recordType -eq 'session_end').Count -gt 0
$experimentConfigs = @($records | Where-Object recordType -eq 'experiment_config')
if ($experimentConfigs.Count -gt 1) {
throw "More than one experiment_config record was found: $($experimentConfigs.Count)"
}
$experimentConfig = $experimentConfigs | Select-Object -First 1
$experimentTargetEnters = @()
$experimentTargetLeaves = @()
$experimentEnters = @()
$experimentLeaves = @()
$matchingEasyX = @()
$easyxVisibility = $null
$experimentValidity = 'not_configured'
$imePositionAttempts = @()
$imePositionSuccesses = @()
$imePositionResultRecords = @()
$imePositionOrphans = @()
$runtimeFailOpenEnters = @()
$runtimeFailOpenLeaves = @()
$runtimeFailOpenReason = $null
if ($experimentConfig) {
$schemaVersions = @($records | Select-Object -ExpandProperty schemaVersion -Unique)
$experimentTargetEnters = @($records | Where-Object {
$_.recordType -eq 'wndproc_enter' -and
$_.experimentId -eq $experimentConfig.experimentId -and
$_.ruleId -eq $experimentConfig.ruleId
})
$targetCallIds = @($experimentTargetEnters | Select-Object -ExpandProperty wndProcCallId)
$experimentTargetLeaves = @($records | Where-Object {
$_.recordType -eq 'wndproc_leave' -and
$targetCallIds -contains $_.wndProcCallId
})
$runtimeFailOpenEnters = @($experimentTargetEnters | Where-Object failOpenApplied -eq $true)
$runtimeFailOpenCallIds = @($runtimeFailOpenEnters | Select-Object -ExpandProperty wndProcCallId)
$runtimeFailOpenLeaves = @($experimentTargetLeaves | Where-Object {
$runtimeFailOpenCallIds -contains $_.wndProcCallId -and $_.failOpenApplied -eq $true
})
$runtimeFailOpenRecords = @($runtimeFailOpenEnters) + @($runtimeFailOpenLeaves)
$runtimeFailOpenReasons = @($runtimeFailOpenRecords |
Select-Object -ExpandProperty failOpenReason -Unique)
if ($runtimeFailOpenReasons.Count -eq 1) {
$runtimeFailOpenReason = [string]$runtimeFailOpenReasons[0]
}
$experimentEnters = @($experimentTargetEnters | Where-Object ruleMatched -eq $true)
$matchedCallIds = @($experimentEnters | Select-Object -ExpandProperty wndProcCallId)
$experimentLeaves = @($records | Where-Object {
$_.recordType -eq 'wndproc_leave' -and
$matchedCallIds -contains $_.wndProcCallId
})
$easyXMessageType = if ([string]$experimentConfig.targetMessage -match 'UP$') {
'WM_KEYUP'
}
else {
'WM_KEYDOWN'
}
$targetScanSpecified = $null -ne $experimentConfig.targetScanCode
$matchingEasyX = @($records | Where-Object {
$_.recordType -eq 'easyx_key' -and
$_.easyxMessageType -eq $easyXMessageType -and
[int]$_.easyxVkCode -eq [int]$experimentConfig.targetVirtualKey -and
(-not $targetScanSpecified -or
[int]$_.easyxScanCode -eq [int]$experimentConfig.targetScanCode)
})
$imePositionResultRecords = @($records | Where-Object recordType -eq 'ime_position')
# 兼容本修复前把定位结果直接挂在 wndproc_enter 上的 schema v2 日志。
$legacyImePositionAttempts = @($records | Where-Object {
$_.recordType -eq 'wndproc_enter' -and $_.imePositionAttempted -eq $true
})
$imePositionAttempts = @($imePositionResultRecords) + @($legacyImePositionAttempts)
$imePositionSuccesses = @($imePositionAttempts | Where-Object {
$_.imePositionContextAvailable -eq $true -and
$_.compositionPositionSucceeded -eq $true -and
$_.candidatePositionSucceeded -eq $true
})
$sequenceLookup = @{}
foreach ($entry in $records) { $sequenceLookup[[string]$entry.sequence] = $true }
$imePositionOrphans = @($imePositionResultRecords | Where-Object {
$null -eq $_.parentSequence -or
-not $sequenceLookup.ContainsKey([string]$_.parentSequence) -or
[uint64]$_.parentSequence -ge [uint64]$_.sequence
})
$easyxVisibility = if ($matchingEasyX.Count -eq 0) {
'absent'
}
elseif ($matchingEasyX.Count -eq 1) {
'present_once'
}
else {
'present_multiple'
}
if ($schemaVersions.Count -ne 1 -or [int]$schemaVersions[0] -ne 2) {
$experimentValidity = 'invalid_schema'
}
elseif (-not $sequenceStrict) {
$experimentValidity = 'inconclusive_sequence'
}
elseif ($textLeaks.Count -gt 0) {
$experimentValidity = 'inconclusive_text_leak'
}
elseif ($lostTotal -gt 0) {
$experimentValidity = 'inconclusive_capture_loss'
}
elseif ($imePositionOrphans.Count -gt 0) {
$experimentValidity = 'inconclusive_ime_position_link'
}
elseif (-not $closeObserved) {
$experimentValidity = 'inconclusive_close_missing'
}
elseif (-not $sessionEndObserved) {
$experimentValidity = 'inconclusive_session_end_missing'
}
elseif (-not $experimentConfig.configurationValid) {
$experimentValidity = 'invalid_configuration'
}
elseif (-not $experimentConfig.ruleEligible) {
$experimentValidity = 'ineligible_fail_open'
}
elseif ([string]$experimentConfig.inputSource -eq 'Manual' -and
(-not $experimentConfig.imePositionConfigured -or $imePositionSuccesses.Count -eq 0)) {
$experimentValidity = 'inconclusive_ime_position'
}
elseif ($experimentTargetEnters.Count -ne [int]$experimentConfig.maxMatches) {
$experimentValidity = 'unexpected_target_count'
}
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and
$experimentEnters.Count -eq 0 -and
$runtimeFailOpenEnters.Count -eq [int]$experimentConfig.maxMatches -and
$runtimeFailOpenLeaves.Count -eq $runtimeFailOpenEnters.Count -and
$runtimeFailOpenReasons.Count -eq 1 -and
-not [string]::IsNullOrWhiteSpace($runtimeFailOpenReason) -and
@($runtimeFailOpenLeaves | Where-Object {
$_.actualAction -ne 'ObserveAndForward' -or $_.forwarded -ne $true
}).Count -eq 0) {
$experimentValidity = 'runtime_fail_open'
}
elseif ($experimentEnters.Count -ne [int]$experimentConfig.maxMatches) {
$experimentValidity = 'unexpected_match_count'
}
elseif ($experimentLeaves.Count -ne $experimentEnters.Count) {
$experimentValidity = 'missing_leave_record'
}
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and
@($experimentLeaves | Where-Object actualAction -ne 'ObserveAndConsume').Count -gt 0) {
$experimentValidity = 'consume_action_mismatch'
}
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and
@($experimentLeaves | Where-Object forwarded -eq $true).Count -gt 0) {
$experimentValidity = 'consume_was_forwarded'
}
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndConsume' -and
@($experimentLeaves | Where-Object forwardResultHex -ne '0x0000000000000000').Count -gt 0) {
$experimentValidity = 'consume_result_nonzero'
}
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndForward' -and
@($experimentLeaves | Where-Object actualAction -ne 'ObserveAndForward').Count -gt 0) {
$experimentValidity = 'forward_action_mismatch'
}
elseif ([string]$experimentConfig.requestedAction -eq 'ObserveAndForward' -and
@($experimentLeaves | Where-Object forwarded -ne $true).Count -gt 0) {
$experimentValidity = 'forward_was_not_forwarded'
}
else {
$experimentValidity = 'valid'
}
}
$correlationCounts = [ordered]@{} $correlationCounts = [ordered]@{}
foreach ($entry in $echoSummaries) { foreach ($entry in $echoSummaries) {
@@ -51,8 +227,10 @@ foreach ($entry in $echoSummaries) {
[pscustomobject]@{ [pscustomobject]@{
file = $file.FullName file = $file.FullName
recordCount = $records.Count recordCount = $records.Count
schemaVersions = @($records | Select-Object -ExpandProperty schemaVersion -Unique)
sequenceStrict = $sequenceStrict sequenceStrict = $sequenceStrict
recordsLost = $lost.Count recordsLost = $lostTotal
lossRecordCount = $lost.Count
textRawWParamLeaks = $textLeaks.Count textRawWParamLeaks = $textLeaks.Count
isWindowUnicode = $attach.isWindowUnicode isWindowUnicode = $attach.isWindowUnicode
ansiCodePage = $attach.ansiCodePage ansiCodePage = $attach.ansiCodePage
@@ -72,6 +250,34 @@ foreach ($entry in $echoSummaries) {
successfulScalarCounts = @($successfulResults | Select-Object -ExpandProperty scalarCount) successfulScalarCounts = @($successfulResults | Select-Object -ExpandProperty scalarCount)
digestPresentCount = @($successfulResults | Where-Object sessionDigest -ne $null).Count digestPresentCount = @($successfulResults | Where-Object sessionDigest -ne $null).Count
correlationCounts = [pscustomobject]$correlationCounts correlationCounts = [pscustomobject]$correlationCounts
closeObserved = @($records | Where-Object messageName -eq 'WM_CLOSE').Count -gt 0 closeObserved = $closeObserved
sessionEndObserved = @($records | Where-Object recordType -eq 'session_end').Count -gt 0 sessionEndObserved = $sessionEndObserved
experimentConfigured = $null -ne $experimentConfig
experimentId = if ($experimentConfig) { $experimentConfig.experimentId } else { $null }
ruleId = if ($experimentConfig) { $experimentConfig.ruleId } else { $null }
ruleEligible = if ($experimentConfig) { [bool]$experimentConfig.ruleEligible } else { $null }
experimentFailOpenReason = if ($experimentConfig) { $experimentConfig.failOpenReason } else { $null }
runtimeFailOpenReason = $runtimeFailOpenReason
requestedAction = if ($experimentConfig) { $experimentConfig.requestedAction } else { $null }
targetMessage = if ($experimentConfig) { $experimentConfig.targetMessage } else { $null }
targetVirtualKey = if ($experimentConfig) { $experimentConfig.targetVirtualKey } else { $null }
targetScanCode = if ($experimentConfig) { $experimentConfig.targetScanCode } else { $null }
baselineId = if ($experimentConfig) { $experimentConfig.baselineId } else { $null }
targetObservationCount = $experimentTargetEnters.Count
targetLeaveCount = $experimentTargetLeaves.Count
failOpenObservationCount = @($experimentTargetEnters | Where-Object failOpenApplied -eq $true).Count
failOpenForwardedCount = @($experimentTargetLeaves | Where-Object {
$_.failOpenApplied -eq $true -and $_.forwarded -eq $true
}).Count
ruleMatchCount = $experimentEnters.Count
consumedMatchCount = @($experimentEnters | Where-Object actualAction -eq 'ObserveAndConsume').Count
forwardedMatchCount = @($experimentLeaves | Where-Object forwarded -eq $true).Count
matchingEasyXKeyCount = $matchingEasyX.Count
easyxVisibility = $easyxVisibility
imePositionAttemptCount = $imePositionAttempts.Count
imePositionSuccessCount = $imePositionSuccesses.Count
imePositionReady = $imePositionSuccesses.Count -gt 0
imePositionResultRecordCount = $imePositionResultRecords.Count
imePositionOrphanCount = $imePositionOrphans.Count
experimentValidity = $experimentValidity
} }
@@ -0,0 +1,41 @@
param(
[Parameter(Mandatory = $true)]
[ValidateSet('MicrosoftPinyin', 'Sogou')]
[string]$ImeName,
[Parameter(Mandatory = $true)]
[ValidateSet('ObserveAndForward', 'ObserveAndConsume')]
[string]$Action,
[ValidateSet('WM_KEYDOWN', 'WM_KEYUP', 'WM_SYSKEYDOWN', 'WM_SYSKEYUP')]
[string]$Message = 'WM_KEYDOWN',
[ValidateRange(1, 255)]
[int]$VirtualKey = 229,
[ValidateRange(-1, 255)]
[int]$ScanCode = 1,
[string]$OperatorKeyLabel = 'Escape',
[ValidateSet('IdleOnly', 'ComposingOnly')]
[string]$ImeCondition = 'ComposingOnly',
[ValidateSet('none', 'ime_condition_not_met')]
[string]$ExpectedFailOpenReason = 'none',
[string]$BaselineSet,
[switch]$ConfirmImeIdentity
)
$ErrorActionPreference = 'Stop'
$runner = Join-Path $PSScriptRoot 'run-input-consume-probe.ps1'
$arguments = @{
Action = $Action
Message = $Message
VirtualKey = $VirtualKey
ScanCode = $ScanCode
OperatorKeyLabel = $OperatorKeyLabel
InputSource = 'Manual'
ImeCondition = $ImeCondition
ImeName = $ImeName
ConfirmImeIdentity = [bool]$ConfirmImeIdentity
ExpectedFailOpenReason = $ExpectedFailOpenReason
}
if ($BaselineSet) {
$arguments.BaselineSet = $BaselineSet
}
& $runner @arguments
+576
View File
@@ -0,0 +1,576 @@
param(
[ValidateSet('Both', 'ObserveAndForward', 'ObserveAndConsume')]
[string]$Action = 'Both',
[ValidateSet('WM_KEYDOWN', 'WM_KEYUP', 'WM_SYSKEYDOWN', 'WM_SYSKEYUP')]
[string]$Message = 'WM_KEYDOWN',
[ValidateRange(1, 255)]
[int]$VirtualKey = 0x87,
[ValidateRange(-1, 255)]
[int]$ScanCode = -1,
[string]$OperatorKeyLabel,
[string]$BaselineSet,
[ValidateRange(1, 65535)]
[int]$ExpectedAnsiCodePage = 936,
[string]$RuleBaselineId,
[ValidateSet('none', 'baseline_id_mismatch', 'ime_condition_not_met')]
[string]$ExpectedFailOpenReason = 'none',
[ValidateSet('SendInput', 'Manual')]
[string]$InputSource = 'SendInput',
[ValidateSet('IdleOnly', 'ComposingOnly')]
[string]$ImeCondition = 'IdleOnly',
[ValidateSet('unspecified', 'MicrosoftPinyin', 'Sogou')]
[string]$ImeName = 'unspecified',
[switch]$ConfirmImeIdentity
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$project = Join-Path $root 'tests\StellarX.UiFixture.vcxproj'
$fixture = Join-Path $root 'build\x64\Debug\StellarX.UiFixture.exe'
$msbuild = if ($env:STELLARX_MSBUILD) { $env:STELLARX_MSBUILD } else { 'D:\vs2022\MSBuild\Current\Bin\MSBuild.exe' }
$expectedBaselineId = 'sha256:5C544D9FEC9C1A2324A7AF3F593A1A34110100644BAF5071E777302387E3C6C5'
if (-not (Test-Path -LiteralPath $msbuild)) { throw "MSBuild not found: $msbuild" }
if ($ExpectedFailOpenReason -ne 'none' -and $Action -ne 'ObserveAndConsume') {
throw 'A fail-open negative run must request only ObserveAndConsume'
}
if ($ExpectedFailOpenReason -eq 'baseline_id_mismatch' -and -not $RuleBaselineId) {
throw 'A baseline mismatch run must pass -RuleBaselineId with an intentionally different value'
}
if ($InputSource -eq 'SendInput' -and $ImeCondition -ne 'IdleOnly') {
throw 'Automatic SendInput runs are limited to IdleOnly; Composition requires real manual IME input'
}
if ($InputSource -eq 'Manual' -and $Action -eq 'Both') {
throw 'Manual runs must select one action so each process contains exactly one operator exercise'
}
if ($InputSource -eq 'Manual' -and ($ImeName -eq 'unspecified' -or -not $ConfirmImeIdentity)) {
throw 'Manual runs require -ImeName and the explicit -ConfirmImeIdentity switch'
}
if ($ExpectedFailOpenReason -eq 'baseline_id_mismatch' -and $InputSource -ne 'SendInput') {
throw 'The baseline mismatch negative check uses the controlled SendInput path'
}
if ($ExpectedFailOpenReason -eq 'ime_condition_not_met' -and
($InputSource -ne 'Manual' -or $ImeCondition -ne 'ComposingOnly')) {
throw 'The IME condition runtime fail-open check requires Manual + ComposingOnly'
}
$effectiveScanCode = if ($ScanCode -lt 0) { 256 } else { $ScanCode }
$operatorKeyDisplay = if ([string]::IsNullOrWhiteSpace($OperatorKeyLabel)) {
"VK=$VirtualKey"
}
else {
$OperatorKeyLabel.Trim()
}
function Get-Sha256Text([string]$text) {
$sha = [Security.Cryptography.SHA256]::Create()
try {
$bytes = [Text.Encoding]::UTF8.GetBytes($text)
return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-', '')
}
finally {
$sha.Dispose()
}
}
function Resolve-BaselineSet([string]$requestedPath) {
if ($requestedPath) {
return (Resolve-Path -LiteralPath $requestedPath).Path
}
$candidates = @(Get-ChildItem -LiteralPath (Join-Path $root 'TestResults\VisualProbe') `
-Filter 'baseline-set-v1.json' -File -Recurse -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending)
foreach ($candidate in $candidates) {
try {
$candidateValue = Get-Content -Raw -LiteralPath $candidate.FullName | ConvertFrom-Json
if ([string]$candidateValue.combinedBaselineId -eq $expectedBaselineId) {
return $candidate.FullName
}
}
catch {
# 无效历史产物不参与当前基线选择,继续寻找下一个可解析候选。
continue
}
}
throw 'No matching 0C baseline-set-v1.json was found; pass -BaselineSet explicitly'
}
function Read-VerifiedBaseline([string]$path) {
$resolvedPath = (Resolve-Path -LiteralPath $path).Path
$sidecarPath = $resolvedPath + '.sha256'
if (-not (Test-Path -LiteralPath $sidecarPath)) { throw "Baseline sidecar missing: $sidecarPath" }
$actualBaselineHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedPath).Hash
$sidecarHash = ((Get-Content -Raw -LiteralPath $sidecarPath).Trim() -split '\s+')[0].ToUpperInvariant()
if ($actualBaselineHash -ne $sidecarHash) { throw 'Baseline-set SHA-256 does not match its sidecar' }
$baseline = Get-Content -Raw -LiteralPath $resolvedPath | ConvertFrom-Json
if ([int]$baseline.visualProbeBaselineSchemaVersion -ne 1) { throw 'Unsupported visual baseline schema' }
if ([string]$baseline.automatedStatus -ne 'pass') { throw '0C baseline automatedStatus is not pass' }
$directory = Split-Path -Parent $resolvedPath
$drawingPath = Join-Path $directory ([string]$baseline.drawingBaselineManifest)
$coordinatePath = Join-Path $directory ([string]$baseline.coordinateBaselineManifest)
if (-not (Test-Path -LiteralPath $drawingPath)) { throw "Drawing baseline missing: $drawingPath" }
if (-not (Test-Path -LiteralPath $coordinatePath)) { throw "Coordinate baseline missing: $coordinatePath" }
$drawingHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $drawingPath).Hash
$coordinateHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $coordinatePath).Hash
if ($drawingHash -ne [string]$baseline.drawingBaselineSha256) { throw 'Drawing baseline SHA-256 mismatch' }
if ($coordinateHash -ne [string]$baseline.coordinateBaselineSha256) { throw 'Coordinate baseline SHA-256 mismatch' }
$combined = 'sha256:' + (Get-Sha256Text "$drawingHash`:$coordinateHash")
if ($combined -ne [string]$baseline.combinedBaselineId) { throw 'Combined baseline ID mismatch' }
if ($combined -ne $expectedBaselineId) { throw "Unsupported 0C baseline ID: $combined" }
$coordinate = Get-Content -Raw -LiteralPath $coordinatePath | ConvertFrom-Json
$awarenessMap = @{ unaware = 0; system = 1; 'per-monitor' = 2; 'per-monitor-v2' = 2 }
$awarenessName = [string]$baseline.dpiAwareness
if (-not $awarenessMap.ContainsKey($awarenessName)) {
throw "Unsupported DPI awareness in baseline: $awarenessName"
}
[pscustomobject]@{
path = $resolvedPath
sha256 = $actualBaselineHash
combinedBaselineId = $combined
dpiForWindow = [int]$baseline.dpiForWindow
dpiAwareness = [int]$awarenessMap[$awarenessName]
windowUnicode = [bool]$coordinate.environment.windowUnicode
drawingSha256 = $drawingHash
coordinateSha256 = $coordinateHash
}
}
# Windows PowerShell 5.1 能精确收口宿主注入的 PATH/Path 重复键;只影响当前 runner。
$savedPath = $env:Path
[Environment]::SetEnvironmentVariable('PATH', $null, [EnvironmentVariableTarget]::Process)
[Environment]::SetEnvironmentVariable('Path', $savedPath, [EnvironmentVariableTarget]::Process)
$baselineInfo = Read-VerifiedBaseline (Resolve-BaselineSet $BaselineSet)
& $msbuild $project /m /t:Build /p:Configuration=Debug /p:Platform=x64 `
/p:StellarXInputProbe=true /v:minimal
if ($LASTEXITCODE -ne 0) { throw 'Input consume probe fixture build failed' }
if (-not (Test-Path -LiteralPath $fixture)) { throw "Fixture not found: $fixture" }
Add-Type @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
public static class StellarXInputConsumeNative {
[StructLayout(LayoutKind.Sequential)] private struct POINT { public int X; public int Y; }
[StructLayout(LayoutKind.Sequential)] private struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
[StructLayout(LayoutKind.Sequential)] private struct MOUSEINPUT {
public int Dx; public int Dy; public uint MouseData; public uint Flags; public uint Time; public UIntPtr ExtraInfo;
}
[StructLayout(LayoutKind.Sequential)] private struct KEYBDINPUT {
public ushort Vk; public ushort Scan; public uint Flags; public uint Time; public UIntPtr ExtraInfo;
}
[StructLayout(LayoutKind.Explicit)] private struct INPUTUNION {
[FieldOffset(0)] public MOUSEINPUT Mouse;
[FieldOffset(0)] public KEYBDINPUT Keyboard;
}
[StructLayout(LayoutKind.Sequential)] private struct INPUT {
public uint Type; public INPUTUNION Data;
}
[DllImport("user32.dll", CharSet=CharSet.Unicode)] private static extern IntPtr FindWindowW(string className, string title);
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] private static extern bool BringWindowToTop(IntPtr h);
[DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr h, int command);
[DllImport("user32.dll", SetLastError=true)] private static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags);
[DllImport("user32.dll", SetLastError=true)] private static extern bool GetWindowRect(IntPtr h, out RECT rect);
[DllImport("user32.dll")] private static extern bool GetCursorPos(out POINT point);
[DllImport("user32.dll")] private static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] private static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extraInfo);
[DllImport("user32.dll", SetLastError=true)] private static extern bool AttachThreadInput(uint attach, uint attachTo, bool value);
[DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId();
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h, uint message, IntPtr w, IntPtr l);
[DllImport("user32.dll", SetLastError=true)] private static extern uint GetWindowThreadProcessId(IntPtr h, out uint processId);
[DllImport("user32.dll", SetLastError=true)] private static extern uint SendInput(uint count, INPUT[] inputs, int size);
public static IntPtr FindWindowByTitle(string title) { return FindWindowW(null, title); }
public static uint GetWindowProcessId(IntPtr h) {
uint processId;
if (GetWindowThreadProcessId(h, out processId) == 0 || processId == 0) throw new Win32Exception();
return processId;
}
public static bool ActivateWindow(IntPtr h) {
uint ignored;
uint currentThread = GetCurrentThreadId();
uint targetThread = GetWindowThreadProcessId(h, out ignored);
IntPtr foreground = GetForegroundWindow();
uint foregroundThread = foreground == IntPtr.Zero ? 0 : GetWindowThreadProcessId(foreground, out ignored);
bool attachedForeground = foregroundThread != 0 && foregroundThread != currentThread &&
AttachThreadInput(currentThread, foregroundThread, true);
bool attachedTarget = targetThread != 0 && targetThread != currentThread && targetThread != foregroundThread &&
AttachThreadInput(currentThread, targetThread, true);
try {
ShowWindow(h, 9);
BringWindowToTop(h);
SetForegroundWindow(h);
return GetForegroundWindow() == h;
}
finally {
if (attachedTarget) AttachThreadInput(currentThread, targetThread, false);
if (attachedForeground) AttachThreadInput(currentThread, foregroundThread, false);
}
}
public static long ClickWindowForActivation(IntPtr h) {
RECT rect;
POINT original;
if (!GetWindowRect(h, out rect) || !GetCursorPos(out original)) throw new Win32Exception();
const uint noMoveNoSize = 0x0001 | 0x0002;
SetWindowPos(h, new IntPtr(-1), 0, 0, 0, 0, noMoveNoSize);
int x = rect.Left + Math.Max(16, (rect.Right - rect.Left) / 2);
int y = rect.Top + Math.Max(40, (rect.Bottom - rect.Top) / 2);
try {
if (!SetCursorPos(x, y)) throw new Win32Exception();
mouse_event(0x0002, 0, 0, 0, UIntPtr.Zero);
mouse_event(0x0004, 0, 0, 0, UIntPtr.Zero);
int outsideX = rect.Left > 48 ? rect.Left - 32 : rect.Right + 32;
int outsideY = rect.Top > 48 ? rect.Top - 32 : rect.Bottom + 32;
SetCursorPos(outsideX, outsideY);
}
finally {
SetWindowPos(h, new IntPtr(-2), 0, 0, 0, 0, noMoveNoSize);
}
return ((long)original.X << 32) | (uint)original.Y;
}
public static void RestoreCursorPosition(long packed) {
SetCursorPos((int)(packed >> 32), (int)(packed & 0xffffffffL));
}
private static void SendKey(ushort virtualKey, bool up) {
var inputs = new INPUT[1];
inputs[0].Type = 1; inputs[0].Data.Keyboard.Vk = virtualKey;
if (up) inputs[0].Data.Keyboard.Flags = 0x0002;
if (SendInput(1, inputs, Marshal.SizeOf(typeof(INPUT))) != 1) throw new Win32Exception();
}
public static void SendKeyDown(ushort virtualKey) { SendKey(virtualKey, false); }
public static void SendKeyUp(ushort virtualKey) { SendKey(virtualKey, true); }
}
'@
function Wait-Window([string]$title, [int]$timeoutMs = 15000) {
$watch = [Diagnostics.Stopwatch]::StartNew()
while ($watch.ElapsedMilliseconds -lt $timeoutMs) {
$window = [StellarXInputConsumeNative]::FindWindowByTitle($title)
if ($window -ne [IntPtr]::Zero) {
$fixturePid = [StellarXInputConsumeNative]::GetWindowProcessId($window)
try {
$fixtureProcess = [Diagnostics.Process]::GetProcessById([int]$fixturePid)
return [pscustomobject]@{
window = $window
process = $fixtureProcess
}
}
catch {
# 窗口可能正处于销毁边界;继续等待同标题的有效新实例。
$fixtureProcess = $null
}
}
Start-Sleep -Milliseconds 50
}
throw "Window not found: $title"
}
function Invoke-ProbeRun([string]$requestedAction, [string]$sessionRoot, [string]$experimentId) {
$actionSlug = if ($requestedAction -eq 'ObserveAndForward') { 'forward' } else { 'consume' }
$runDirectory = Join-Path $sessionRoot $actionSlug
$fixtureDirectory = Join-Path $runDirectory 'fixture'
New-Item -ItemType Directory -Force -Path $fixtureDirectory | Out-Null
$scanSlug = if ($ScanCode -lt 0) { 'scan-any' } else { "scan-$ScanCode" }
$ruleId = "$($Message.ToLowerInvariant())-vk$VirtualKey-$scanSlug-$($ImeCondition.ToLowerInvariant())-$actionSlug"
$windowUnicode = if ($baselineInfo.windowUnicode) { '1' } else { '0' }
$effectiveRuleBaselineId = if ($RuleBaselineId) { $RuleBaselineId } else { $baselineInfo.combinedBaselineId }
$launchParts = @(
'set "STELLARX_INPUT_PROBE=1"',
'set "STELLARX_INPUT_PROBE_0B=1"',
"set `"STELLARX_INPUT_PROBE_DIR=$runDirectory`"",
"set `"STELLARX_INPUT_PROBE_0B_EXPERIMENT_ID=$experimentId`"",
"set `"STELLARX_INPUT_PROBE_0B_RULE_ID=$ruleId`"",
"set `"STELLARX_INPUT_PROBE_0B_ACTION=$requestedAction`"",
"set `"STELLARX_INPUT_PROBE_0B_MESSAGE=$Message`"",
"set `"STELLARX_INPUT_PROBE_0B_VIRTUAL_KEY=$VirtualKey`"",
"set `"STELLARX_INPUT_PROBE_0B_SCAN_CODE=$effectiveScanCode`"",
"set `"STELLARX_INPUT_PROBE_0B_IME_CONDITION=$ImeCondition`"",
'set "STELLARX_INPUT_PROBE_0B_MAX_MATCHES=1"',
"set `"STELLARX_INPUT_PROBE_0B_INPUT_SOURCE=$InputSource`"",
"set `"STELLARX_INPUT_PROBE_0B_BASELINE_ID=$effectiveRuleBaselineId`"",
"set `"STELLARX_INPUT_PROBE_0B_EXPECT_WINDOW_UNICODE=$windowUnicode`"",
"set `"STELLARX_INPUT_PROBE_0B_EXPECT_ANSI_CODE_PAGE=$ExpectedAnsiCodePage`"",
"set `"STELLARX_INPUT_PROBE_0B_EXPECT_DPI_FOR_WINDOW=$($baselineInfo.dpiForWindow)`"",
"set `"STELLARX_INPUT_PROBE_0B_EXPECT_DPI_AWARENESS=$($baselineInfo.dpiAwareness)`""
)
if ($InputSource -eq 'Manual') {
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_IDENTITY_CONFIRMED=1"'
$launchParts += "set `"STELLARX_INPUT_PROBE_0B_IME_NAME=$ImeName`""
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_CARET_X=225"'
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_CARET_Y=282"'
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_LEFT=200"'
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_TOP=255"'
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_RIGHT=680"'
$launchParts += 'set "STELLARX_INPUT_PROBE_0B_IME_LINE_BOTTOM=284"'
}
$launchParts += ('"{0}" input-consume-probe "{1}"' -f $fixture, $fixtureDirectory)
$launchCommand = $launchParts -join ' & '
$title = 'StellarX.UI.input-consume-probe'
if ([StellarXInputConsumeNative]::FindWindowByTitle($title) -ne [IntPtr]::Zero) {
throw "A stale fixture window is already open: $title"
}
$launcherProcess = Start-Process -FilePath $env:ComSpec -ArgumentList @('/d', '/c', $launchCommand) -PassThru
$window = [IntPtr]::Zero
$fixtureProcess = $null
$savedCursorPosition = $null
$keyDownSent = $false
$keyUpSent = $false
try {
$windowState = Wait-Window $title
$window = $windowState.window
$fixtureProcess = $windowState.process
if ($InputSource -eq 'Manual') {
Write-Host "Manual 0B window ready: $ImeName / $ImeCondition / $requestedAction"
Write-Host 'Complete the printed checklist in the EasyX window, then close it.'
[void]$fixtureProcess.WaitForExit()
}
else {
Start-Sleep -Milliseconds 400
$windowShell = New-Object -ComObject WScript.Shell
[void]$windowShell.AppActivate($fixtureProcess.Id)
Start-Sleep -Milliseconds 400
if ([StellarXInputConsumeNative]::GetForegroundWindow() -ne $window) {
[void][StellarXInputConsumeNative]::ActivateWindow($window)
Start-Sleep -Milliseconds 200
}
if ([StellarXInputConsumeNative]::GetForegroundWindow() -ne $window) {
$savedCursorPosition = [StellarXInputConsumeNative]::ClickWindowForActivation($window)
Start-Sleep -Milliseconds 300
}
if ([StellarXInputConsumeNative]::GetForegroundWindow() -ne $window) {
throw 'Fixture did not become the foreground window'
}
[StellarXInputConsumeNative]::SendKeyDown([uint16]$VirtualKey)
$keyDownSent = $true
Start-Sleep -Milliseconds 250
[StellarXInputConsumeNative]::SendKeyUp([uint16]$VirtualKey)
$keyUpSent = $true
Start-Sleep -Milliseconds 800
if (-not [StellarXInputConsumeNative]::PostMessage($window, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)) {
throw 'Failed to post WM_CLOSE to the input consume fixture'
}
if (-not $fixtureProcess.WaitForExit(10000)) { throw 'Fixture did not exit after WM_CLOSE' }
}
}
finally {
if ($keyDownSent -and -not $keyUpSent) {
try { [StellarXInputConsumeNative]::SendKeyUp([uint16]$VirtualKey) }
catch { Write-Verbose 'Failed to release synthetic key during cleanup' }
}
if ($fixtureProcess) { $fixtureProcess.Refresh() }
if ($fixtureProcess -and -not $fixtureProcess.HasExited -and $window -ne [IntPtr]::Zero) {
[void][StellarXInputConsumeNative]::PostMessage(
$window, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)
[void]$fixtureProcess.WaitForExit(3000)
$fixtureProcess.Refresh()
}
if ($fixtureProcess -and -not $fixtureProcess.HasExited) {
Stop-Process -Id $fixtureProcess.Id -Force -ErrorAction SilentlyContinue
}
$launcherProcess.Refresh()
if (-not $launcherProcess.HasExited) {
[void]$launcherProcess.WaitForExit(3000)
$launcherProcess.Refresh()
}
if (-not $launcherProcess.HasExited) {
Stop-Process -Id $launcherProcess.Id -Force -ErrorAction SilentlyContinue
}
if ($null -ne $savedCursorPosition) {
[StellarXInputConsumeNative]::RestoreCursorPosition([long]$savedCursorPosition)
}
}
$summary = & (Join-Path $PSScriptRoot 'analyze-input-probe.ps1') -Path $runDirectory
if ($ExpectedFailOpenReason -eq 'none') {
if ($summary.experimentValidity -ne 'valid') {
throw "Invalid $requestedAction experiment: $($summary.experimentValidity)"
}
}
elseif ($ExpectedFailOpenReason -eq 'baseline_id_mismatch') {
$failOpenValid =
$summary.experimentValidity -eq 'ineligible_fail_open' -and
$summary.experimentFailOpenReason -eq $ExpectedFailOpenReason -and
$summary.targetObservationCount -eq 1 -and
$summary.targetLeaveCount -eq 1 -and
$summary.failOpenObservationCount -eq 1 -and
$summary.failOpenForwardedCount -eq 1 -and
$summary.ruleMatchCount -eq 0
if (-not $failOpenValid) {
throw "Invalid fail-open experiment: validity=$($summary.experimentValidity), reason=$($summary.experimentFailOpenReason)"
}
}
else {
$failOpenValid =
$summary.experimentValidity -eq 'runtime_fail_open' -and
$summary.runtimeFailOpenReason -eq $ExpectedFailOpenReason -and
$summary.targetObservationCount -eq 1 -and
$summary.targetLeaveCount -eq 1 -and
$summary.failOpenObservationCount -eq 1 -and
$summary.failOpenForwardedCount -eq 1 -and
$summary.ruleMatchCount -eq 0 -and
$summary.consumedMatchCount -eq 0 -and
$summary.matchingEasyXKeyCount -eq 1
if (-not $failOpenValid) {
throw "Invalid runtime fail-open experiment: validity=$($summary.experimentValidity), reason=$($summary.runtimeFailOpenReason)"
}
}
$actualFailOpenReason = if ($summary.experimentFailOpenReason -and
$summary.experimentFailOpenReason -ne 'none') {
$summary.experimentFailOpenReason
}
else {
$summary.runtimeFailOpenReason
}
[pscustomobject][ordered]@{
action = $requestedAction
runDirectory = $runDirectory
file = $summary.file
fixtureProcessId = if ($fixtureProcess) { $fixtureProcess.Id } else { $null }
sequenceStrict = $summary.sequenceStrict
recordsLost = $summary.recordsLost
ruleMatchCount = $summary.ruleMatchCount
consumedMatchCount = $summary.consumedMatchCount
forwardedMatchCount = $summary.forwardedMatchCount
matchingEasyXKeyCount = $summary.matchingEasyXKeyCount
easyxVisibility = $summary.easyxVisibility
imePositionAttemptCount = $summary.imePositionAttemptCount
imePositionSuccessCount = $summary.imePositionSuccessCount
imePositionReady = $summary.imePositionReady
imePositionResultRecordCount = $summary.imePositionResultRecordCount
imePositionOrphanCount = $summary.imePositionOrphanCount
closeObserved = $summary.closeObserved
sessionEndObserved = $summary.sessionEndObserved
expectedFailOpenReason = $ExpectedFailOpenReason
actualFailOpenReason = $actualFailOpenReason
experimentFailOpenReason = $summary.experimentFailOpenReason
runtimeFailOpenReason = $summary.runtimeFailOpenReason
targetObservationCount = $summary.targetObservationCount
targetLeaveCount = $summary.targetLeaveCount
failOpenObservationCount = $summary.failOpenObservationCount
failOpenForwardedCount = $summary.failOpenForwardedCount
experimentValidity = $summary.experimentValidity
}
}
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
$comparisonId = [Guid]::NewGuid().ToString()
$sessionRoot = Join-Path $root "TestResults\InputProbe\0B\$stamp-$comparisonId"
New-Item -ItemType Directory -Force -Path $sessionRoot | Out-Null
$actions = if ($Action -eq 'Both') {
@('ObserveAndForward', 'ObserveAndConsume')
}
else {
@($Action)
}
$results = @()
if ($InputSource -eq 'Manual') {
Write-Host "Manual IME consume probe: $ImeName"
$scanDescription = if ($ScanCode -lt 0) { 'any scan code' } else { "scanCode=$ScanCode" }
Write-Host " Rule: $Message VK=$VirtualKey, $scanDescription, $ImeCondition, $Action"
Write-Host ' 1. Confirm the selected input method really matches -ImeName, then click the EasyX window.'
if ($ImeCondition -eq 'ComposingOnly') {
Write-Host ' 2. Type only ceshi until the composition/candidate UI is visible.'
Write-Host " 3. Press $operatorKeyDisplay exactly once while composition is active; release it normally."
}
else {
Write-Host " 2. Before starting composition, press $operatorKeyDisplay exactly once; release it normally."
Write-Host ' 3. Then type only ceshi to confirm ordinary candidate behavior still works.'
}
Write-Host ' 4. Check candidate, commit/cancel behavior and position against the accepted 0C baseline.'
Write-Host ' 5. Close the EasyX window. The runner will validate privacy-safe structural evidence.'
}
foreach ($requestedAction in $actions) {
Write-Host "Running 0B $requestedAction $Message VK=$VirtualKey scan=$effectiveScanCode"
$results += Invoke-ProbeRun $requestedAction $sessionRoot $comparisonId
}
$forward = $results | Where-Object action -eq 'ObserveAndForward' | Select-Object -First 1
$consume = $results | Where-Object action -eq 'ObserveAndConsume' | Select-Object -First 1
$allRunsComplete = @($results | Where-Object {
-not $_.sequenceStrict -or
$_.recordsLost -gt 0 -or
-not $_.closeObserved -or
-not $_.sessionEndObserved
}).Count -eq 0
$comparisonStatus = if (-not $allRunsComplete) {
'inconclusive'
}
elseif ($ExpectedFailOpenReason -ne 'none' -and @($results | Where-Object {
$_.actualFailOpenReason -ne $ExpectedFailOpenReason -or
$_.failOpenObservationCount -ne 1 -or
$_.failOpenForwardedCount -ne 1
}).Count -gt 0) {
'inconclusive'
}
elseif ($Action -eq 'Both' -and (-not $forward -or -not $consume)) {
'inconclusive'
}
elseif ($Action -eq 'Both' -and $forward.matchingEasyXKeyCount -lt 1) {
'inconclusive'
}
else {
'pass'
}
$observedRelation = if (-not $consume) {
'not_measured'
}
elseif ($consume.experimentValidity -eq 'runtime_fail_open') {
'easyx_visible_after_fail_open_forward'
}
elseif ($consume.easyxVisibility -eq 'absent') {
'easyx_suppressed_by_wndproc_consume'
}
elseif ($consume.easyxVisibility -eq 'present_once' -or $consume.easyxVisibility -eq 'present_multiple') {
'easyx_still_visible_after_wndproc_consume'
}
else {
'inconclusive'
}
$comparison = [pscustomobject][ordered]@{
inputConsumeProbeSchemaVersion = 1
comparisonId = $comparisonId
createdAtUtc = [DateTime]::UtcNow.ToString('o')
targetMessage = $Message
targetVirtualKey = $VirtualKey
targetScanCode = if ($ScanCode -lt 0) { $null } else { $ScanCode }
inputSource = $InputSource
imeCondition = $ImeCondition
imeName = if ($InputSource -eq 'Manual') { $ImeName } else { $null }
imeIdentityConfirmed = $InputSource -eq 'Manual' -and $ConfirmImeIdentity
expectedFailOpenReason = $ExpectedFailOpenReason
baselineSet = $baselineInfo.path
baselineSetSha256 = $baselineInfo.sha256
combinedBaselineId = $baselineInfo.combinedBaselineId
drawingBaselineSha256 = $baselineInfo.drawingSha256
coordinateBaselineSha256 = $baselineInfo.coordinateSha256
automatedStatus = $comparisonStatus
observedRelation = $observedRelation
finalVerdict = if ($ExpectedFailOpenReason -eq 'ime_condition_not_met') {
'MustForward'
}
else {
'Inconclusive'
}
finalVerdictReason = if ($ExpectedFailOpenReason -eq 'ime_condition_not_met') {
'The candidate UI was visible, but the HWND exposed no reliable composing state; the consume request fail-opened to Forward.'
}
elseif ($InputSource -eq 'Manual') {
'Structural capture only; operator review of IME behavior and 0C position evidence is still required.'
}
else {
'Core EX_KEY relation only; the required message and IME matrix is not complete.'
}
runs = @($results)
}
$comparisonPath = Join-Path $sessionRoot 'comparison-v1.json'
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
[IO.File]::WriteAllText($comparisonPath,
($comparison | ConvertTo-Json -Depth 20) + [Environment]::NewLine, $utf8Bom)
$comparisonHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $comparisonPath).Hash
$comparison | Format-List
Write-Host "Comparison: $comparisonPath"
Write-Host "Comparison SHA-256: $comparisonHash"
if ($comparisonStatus -ne 'pass') { throw '0B automatic comparison is inconclusive' }
+256 -4
View File
@@ -1,10 +1,27 @@
# 外部 UI 驱动:启动确定性 fixture,注入真实鼠标输入,并保存像素与状态诊断。 # 外部 UI 驱动:启动确定性 fixture,注入真实鼠标输入,并保存像素与状态诊断。
param([int]$Repeat = 1) param(
[int]$Repeat = 1,
[ValidateSet('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'focus', 'input-shadow', 'keyboard-routing')]
[string[]]$OnlyScenario = @(),
[string]$FixturePath = '',
[string]$ResultRoot = '',
[ValidateSet('Legacy', 'Routed')]
[string]$InputPipelineExpectation = 'Routed'
)
$ErrorActionPreference = 'Stop' $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
@@ -163,6 +180,35 @@ function Click-Mouse([IntPtr]$h, [int]$x, [int]$y) {
Start-Sleep -Milliseconds 250 Start-Sleep -Milliseconds 250
} }
function Post-KeyWave([IntPtr]$h, [int]$pairCount = 1000) {
# F24 is outside normal text entry and was already proven to cross both the
# WndProc and EasyX EX_KEY observation planes during stage 0B.
$keyDownLParam = [IntPtr]([long]0x00760001)
$keyUpLParam = [IntPtr]([long]0xC0760001)
for ($index = 0; $index -lt $pairCount; $index++) {
if (-not [StellarXUiNative]::PostMessage($h, 0x0100, [IntPtr]0x87, $keyDownLParam)) {
throw "Failed to post F24 key-down at pair $index"
}
if (-not [StellarXUiNative]::PostMessage($h, 0x0101, [IntPtr]0x87, $keyUpLParam)) {
throw "Failed to post F24 key-up at pair $index"
}
}
}
function 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)
@@ -224,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) {
# 某些绘制在消息批次尾才完成,因此对目标像素进行有界轮询而不是固定猜测延时。 # 某些绘制在消息批次尾才完成,因此对目标像素进行有界轮询而不是固定猜测延时。
@@ -243,8 +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)
$scenarios = @('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog') $allScenarios = @('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'focus', 'input-shadow', 'keyboard-routing')
$scenarios = if ($OnlyScenario.Count -gt 0) { @($OnlyScenario) } else { $allScenarios }
$sessionRoot = Join-Path $resultRoot ((Get-Date -Format 'yyyyMMdd-HHmmss-fff') + "-$PID") $sessionRoot = Join-Path $resultRoot ((Get-Date -Format 'yyyyMMdd-HHmmss-fff') + "-$PID")
$scenarioTimings = [Collections.Generic.List[object]]::new()
try { try {
# 场景按固定顺序串行执行,避免多个 EasyX 图形上下文同时争用桌面。 # 场景按固定顺序串行执行,避免多个 EasyX 图形上下文同时争用桌面。
@@ -255,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
@@ -268,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
@@ -280,6 +442,81 @@ try {
Click-Mouse $h 240 185 Click-Mouse $h 240 185
$result = Wait-TrueResultProperty $resultFile 'lowerClosed' $result = Wait-TrueResultProperty $resultFile 'lowerClosed'
} }
'input-shadow' {
$expectedKeyEvents = 4000
# Keep every burst below the observed EasyX internal queue ceiling
# (63 usable entries in this environment).
# This measures sustained draining rather than overflow behavior of a
# third-party queue that the framework cannot resize.
foreach ($wave in 1..50) {
Post-KeyWave $h 20
Start-Sleep -Milliseconds 50
}
Start-Sleep -Milliseconds 250
$process.Refresh()
$memoryAfterWarmup = $process.PrivateMemorySize64
foreach ($wave in 1..50) {
Post-KeyWave $h 20
Start-Sleep -Milliseconds 50
}
Start-Sleep -Milliseconds 250
$process.Refresh()
$memoryAfterSecondWave = $process.PrivateMemorySize64
Click-Mouse $h 100 61
$result = Wait-Result $resultFile
if ($InputPipelineExpectation -eq 'Legacy') {
if ($result.legacyKeyCount -ne $expectedKeyEvents) { throw "legacy EX_KEY count mismatch: $($result.legacyKeyCount)" }
if ($result.legacyKeyDownCount -ne ($expectedKeyEvents / 2) -or
$result.legacyKeyUpCount -ne ($expectedKeyEvents / 2)) {
throw "legacy EX_KEY action mismatch: down=$($result.legacyKeyDownCount) up=$($result.legacyKeyUpCount)"
}
}
else {
if ($result.legacyKeyCount -ne 0 -or $result.legacyKeyDownCount -ne 0 -or
$result.legacyKeyUpCount -ne 0) {
throw "legacy EX_KEY business path was not disabled: total=$($result.legacyKeyCount)"
}
if ($result.routedKeyCount -ne $expectedKeyEvents -or
$result.routedKeyDownCount -ne ($expectedKeyEvents / 2) -or
$result.routedKeyUpCount -ne ($expectedKeyEvents / 2)) {
throw "routed key delivery mismatch: total=$($result.routedKeyCount) down=$($result.routedKeyDownCount) up=$($result.routedKeyUpCount)"
}
if ($result.easyXKeyboardMessagesDrained -ne $expectedKeyEvents) {
throw "EasyX EX_KEY drain mismatch: $($result.easyXKeyboardMessagesDrained)"
}
if ($result.legacyEasyXKeyboardBusinessDeliveries -ne 0) {
throw "legacy EX_KEY business delivery count was not zero: $($result.legacyEasyXKeyboardBusinessDeliveries)"
}
}
if ($result.keyEvents -ne $expectedKeyEvents) { throw "normalized key count mismatch: $($result.keyEvents)" }
if ($result.pushed -ne $result.popped -or $result.currentDepth -ne 0) { throw 'native input queue did not drain completely' }
if (-not $result.sequenceStrict -or $result.faulted) { throw 'native input queue reported ordering or fail-stop failure' }
if ($result.rejectedTextSequences -ne 0 -or $result.imeQueryFailures -ne 0 -or
$result.discardedDecoderFragments -ne 0) {
throw 'key-only input shadow reported a text normalization anomaly'
}
$memoryGrowth = $memoryAfterSecondWave - $memoryAfterWarmup
if ($memoryGrowth -gt 16MB) { throw "input shadow memory grew after warmup: $memoryGrowth bytes" }
@{
expectedKeyEvents = $expectedKeyEvents
inputPipelineExpectation = $InputPipelineExpectation
memoryAfterWarmup = $memoryAfterWarmup
memoryAfterSecondWave = $memoryAfterSecondWave
memoryGrowth = $memoryGrowth
} | ConvertTo-Json | Set-Content -Encoding UTF8 (Join-Path $runDir 'input-shadow-memory.json')
}
'keyboard-routing' {
# Space confirms on KeyUp, Tab is the framework default,
# and Enter confirms on first KeyDown.
Post-KeyPair $h 0x20 0x39
Post-KeyPair $h 0x09 0x0F
Post-KeyPair $h 0x0D 0x1C
$result = Wait-Result $resultFile
if ($result.firstClicks -ne 1 -or $result.secondClicks -ne 1 -or
-not $result.secondFocused) {
throw 'production keyboard route did not activate and traverse exactly once'
}
}
} }
$bitmap = Capture-Client $h (Join-Path $runDir 'screen.png') $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') {
@@ -311,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 输出和客户区截图,便于定位像素或状态问题。
@@ -342,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)
+85 -10
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()) // 下层持久像素与当前上层兄弟相交时,必须优先完整重建上层兄弟。
{ // 即便上层自己的某个后代也同时变脏,也不能只提交那条脏子树;
commitManagedChild(child, SxCanvasOverlayRedrawMode::None); // 否则下层刚写入的像素仍会覆盖上层背景和未变脏的兄弟内容。
} const bool persistentHit = hasPaintCoverage && hasPersistentCoverage &&
else if (hasPaintCoverage && SxCanvasRectsIntersect(child->getManagedRepaintCoverageRect(), paintCoverage)) SxCanvasRectsIntersect(child->getManagedRepaintCoverageRect(), paintCoverage) &&
{
// 位于本次累计 coverage 上方、且发生相交的兄弟控件,需要补画回最上层。
// 但只有下层“持久内容”影响到它时,才允许作废并重新抓背景快照;
// 如果只是被 Tooltip 等临时浮层覆盖,则跳过兄弟补画,避免透明控件回贴旧快照擦掉 Tooltip。
const bool persistentHit = hasPersistentCoverage &&
SxCanvasRectsIntersect(child->getManagedRepaintPersistentCoverageRect(), persistentCoverage); SxCanvasRectsIntersect(child->getManagedRepaintPersistentCoverageRect(), persistentCoverage);
if (persistentHit) if (persistentHit)
{
commitManagedChild(child, SxCanvasOverlayRedrawMode::RefreshSnapshot); commitManagedChild(child, SxCanvasOverlayRedrawMode::RefreshSnapshot);
} }
else if (child->hasManagedDirtySubtree())
{
commitManagedChild(child, SxCanvasOverlayRedrawMode::None);
}
} }
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()
+36
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,6 +208,13 @@ void Dialog::Show()
while (show) while (show)
{ {
if (!hWnd.drainNativeInputAtSafePoint(true))
{
show = false;
hWnd.dialogOpen = false;
StellarX::Internal::inputProbeFlushAtSafePoint(hWnd.getHwnd());
break;
}
if (!hWnd.hasLiveNativeWindow()) if (!hWnd.hasLiveNativeWindow())
{ {
show = false; show = false;
@@ -240,12 +259,28 @@ 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);
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); 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(true))
{
show = false;
hWnd.dialogOpen = false;
StellarX::Internal::inputProbeFlushAtSafePoint(hWnd.getHwnd());
break;
}
if (!hWnd.hasLiveNativeWindow()) if (!hWnd.hasLiveNativeWindow())
{ {
show = false; show = false;
@@ -285,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;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+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)
+286 -3
View File
@@ -3,8 +3,11 @@
#include"SxLog.h" #include"SxLog.h"
#include "SxEventLoopInternal.h" #include "SxEventLoopInternal.h"
#include "SxInputProbeInternal.h" #include "SxInputProbeInternal.h"
#include "SxNativeInputInternal.h"
#include "SxKeyboardRouterInternal.h"
#include <easyx.h> #include <easyx.h>
#include <algorithm> #include <algorithm>
#include <cstdint>
// 可能频繁出现且对调试信息干扰较大的消息(例如鼠标移动), // 可能频繁出现且对调试信息干扰较大的消息(例如鼠标移动),
// 可以在日志输出时特殊处理以减少干扰。 // 可以在日志输出时特殊处理以减少干扰。
static bool SxIsNoisyMsg(UINT m) static bool SxIsNoisyMsg(UINT m)
@@ -45,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);
@@ -136,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()
@@ -645,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)
@@ -653,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)
@@ -662,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
@@ -673,6 +730,89 @@ bool Window::hasLiveNativeWindow() const noexcept
return hWnd != nullptr && IsWindow(hWnd) != FALSE; return hWnd != nullptr && IsWindow(hWnd) != FALSE;
} }
bool Window::drainNativeInputAtSafePoint(bool modalBusinessSuppressed)
{
if (!nativeInputContext)
return true;
if (StellarX::Internal::keyboardPipelineMode(*this) ==
StellarX::Internal::KeyboardPipelineMode::LegacyCompatibility)
{
// 兼容模式只观测排空;旧 EX_KEY 仍是唯一业务路径。
nativeInputContext->drainObserved(StellarX::Internal::MessageBatchLimit);
}
else
{
// Routed 是切片 6 后的生产路径。一次弹出一条,释放队列锁后再进入
// 焦点/控件回调,禁止把整个批次预取到局部容器。
std::size_t drained = 0;
while (drained < StellarX::Internal::MessageBatchLimit)
{
StellarX::NativeInputEvent event;
const auto popResult = nativeInputContext->tryPopObserved(event);
if (popResult == StellarX::Internal::NativeInputQueuePopResult::Empty)
break;
if (popResult == StellarX::Internal::NativeInputQueuePopResult::UnexpectedFailure)
break;
StellarX::Internal::KeyboardRoutingEnvironment environment;
// This acquire load is the routing gate's linearization point. A later
// WndProc focus transition may affect the next event without sharing a
// blocking lock with control callbacks.
environment.currentActivation = nativeInputContext->activationSnapshot();
environment.modalBusinessSuppressed = modalBusinessSuppressed;
StellarX::Internal::KeyboardRouter::route(*this, event, environment);
++drained;
}
}
const auto normalization = nativeInputContext->normalizationStats();
if (normalization.rejectedTextSequences != nativeInputRejectedTextReported ||
normalization.imeQueryFailures != nativeInputImeQueryFailuresReported ||
normalization.discardedDecoderFragments != nativeInputDiscardedFragmentsReported)
{
nativeInputRejectedTextReported = normalization.rejectedTextSequences;
nativeInputImeQueryFailuresReported = normalization.imeQueryFailures;
nativeInputDiscardedFragmentsReported = normalization.discardedDecoderFragments;
SX_LOGW("Input")
<< SX_T("原生输入规范化异常累计:rejected=", "Native input normalization anomalies: rejected=")
<< normalization.rejectedTextSequences
<< " imeQueryFailures=" << normalization.imeQueryFailures
<< " discardedFragments=" << normalization.discardedDecoderFragments;
}
if (!nativeInputContext->hasFault())
return true;
if (!nativeInputFailureReported)
{
nativeInputFailureReported = true;
const auto fault = nativeInputContext->fault();
SX_LOGE("Input")
<< SX_T("原生输入子系统进入 fail-stop:code=", "Native input subsystem entered fail-stop: code=")
<< static_cast<unsigned>(fault.code)
<< " sequence=" << fault.sequence
<< " message=" << fault.message
<< " hwnd=" << reinterpret_cast<std::uintptr_t>(fault.hwnd);
}
return false;
}
StellarX::Internal::NativeInputRuntimeSnapshot
StellarX::Internal::inspectWindowNativeInputForTesting(const ::Window& window) noexcept
{
NativeInputRuntimeSnapshot snapshot;
if (!window.nativeInputContext)
return snapshot;
snapshot.queue = window.nativeInputContext->queueStats();
snapshot.observations = window.nativeInputContext->observationStats();
snapshot.normalization = window.nativeInputContext->normalizationStats();
snapshot.activation = window.nativeInputContext->activationSnapshot();
snapshot.easyXKeyboardMessagesDrained = window.easyXKeyboardMessagesDrained;
snapshot.legacyEasyXKeyboardBusinessDeliveries =
window.legacyEasyXKeyboardBusinessDeliveries;
snapshot.faulted = window.nativeInputContext->hasFault();
snapshot.fault = window.nativeInputContext->fault();
return snapshot;
}
Window::~Window() Window::~Window()
{ {
// 先销毁控件树,再关闭图形环境,避免控件析构时访问已关闭的 EasyX 上下文。 // 先销毁控件树,再关闭图形环境,避免控件析构时访问已关闭的 EasyX 上下文。
@@ -710,6 +850,40 @@ LRESULT CALLBACK Window::WndProcThunk(HWND h, UINT m, WPARAM w, LPARAM l)
return DefWindowProc(h, m, w, l); return DefWindowProc(h, m, w, l);
} }
const auto probeCall = StellarX::Internal::inputProbeWndProcEnter(h, m, w, l); const auto probeCall = StellarX::Internal::inputProbeWndProcEnter(h, m, w, l);
const auto nativeInputResult = self->nativeInputContext
? self->nativeInputContext->collect(h, m, w, l)
: StellarX::Internal::NativeInputCollectResult{};
if (probeCall.observeAndConsume)
{
// 阶段 0B 的唯一消费出口:仅诊断构建、有效单规则和匹配基线下可达。
// 默认构建的 token 恒为 false,不改变生产窗口过程行为。
return StellarX::Internal::inputProbeWndProcLeave(probeCall, 0, false);
}
if (nativeInputResult.handledSynchronously)
{
return StellarX::Internal::inputProbeWndProcLeave(
probeCall, nativeInputResult.result, false);
}
if (m == WM_GETDLGCODE)
{
// The old EasyX procedure owns the baseline answer. StellarX only adds
// the one capability already implemented by the production router.
const bool forwardedToOldProc = self->oldWndProc != nullptr;
LRESULT result = forwardedToOldProc
? CallWindowProc(self->oldWndProc, h, m, w, l)
: DefWindowProc(h, m, w, l);
if (StellarX::Internal::shouldAddTabDialogCode(*self, w, l))
result |= DLGC_WANTTAB;
return StellarX::Internal::inputProbeWndProcLeave(
probeCall, result, forwardedToOldProc);
}
if (m == WM_CLOSE)
{
// EasyX 的旧窗口过程可能在 WM_CLOSE 内直接终止进程,不保证返回到 leave、
// WM_NCDESTROY 或宿主循环。这里是诊断会话最后一个可证明到达的耐久边界。
// 默认构建中 inputProbeDetach 为空操作,不改变生产关闭路径。
StellarX::Internal::inputProbeDetach(h);
}
// 关键点①:禁止系统擦背景,避免和我们自己的清屏/双缓冲打架造成闪烁 // 关键点①:禁止系统擦背景,避免和我们自己的清屏/双缓冲打架造成闪烁
if (m == WM_ERASEBKGND) if (m == WM_ERASEBKGND)
@@ -828,6 +1002,8 @@ void Window::draw()
// 子类化:让我们的 WndProcThunk 接管窗口消息(仅执行一次) // 子类化:让我们的 WndProcThunk 接管窗口消息(仅执行一次)
if (!procHooked) if (!procHooked)
{ {
if (!nativeInputContext)
nativeInputContext = std::make_unique<StellarX::Internal::NativeInputContext>(hWnd);
SetWindowLongPtr(hWnd, GWLP_USERDATA, (LONG_PTR)this); SetWindowLongPtr(hWnd, GWLP_USERDATA, (LONG_PTR)this);
StellarX::Internal::inputProbeAttach(hWnd); StellarX::Internal::inputProbeAttach(hWnd);
oldWndProc = (WNDPROC)SetWindowLongPtr(hWnd, GWLP_WNDPROC, (LONG_PTR)&Window::WndProcThunk); oldWndProc = (WNDPROC)SetWindowLongPtr(hWnd, GWLP_WNDPROC, (LONG_PTR)&Window::WndProcThunk);
@@ -868,6 +1044,8 @@ void Window::draw(std::string imagePath)
if (!procHooked) if (!procHooked)
{ {
if (!nativeInputContext)
nativeInputContext = std::make_unique<StellarX::Internal::NativeInputContext>(hWnd);
SetWindowLongPtr(hWnd, GWLP_USERDATA, (LONG_PTR)this); SetWindowLongPtr(hWnd, GWLP_USERDATA, (LONG_PTR)this);
StellarX::Internal::inputProbeAttach(hWnd); StellarX::Internal::inputProbeAttach(hWnd);
oldWndProc = (WNDPROC)SetWindowLongPtr(hWnd, GWLP_WNDPROC, (LONG_PTR)&Window::WndProcThunk); oldWndProc = (WNDPROC)SetWindowLongPtr(hWnd, GWLP_WNDPROC, (LONG_PTR)&Window::WndProcThunk);
@@ -915,15 +1093,24 @@ void Window::draw(std::string imagePath)
int Window::runEventLoop() int Window::runEventLoop()
{ {
ExMessage msg; ExMessage msg;
StellarX::Internal::lockKeyboardPipeline(*this);
// 说明:统一使用 needResizeDirty 作为“收口重绘”的唯一标志位 // 说明:统一使用 needResizeDirty 作为“收口重绘”的唯一标志位
// 不再引入额外 pendingResize 等状态,避免分叉导致状态不一致。 // 不再引入额外 pendingResize 等状态,避免分叉导致状态不一致。
while (true) while (true)
{ {
if (nativeInputContext && nativeInputContext->hasFault())
{
drainNativeInputAtSafePoint();
StellarX::Internal::inputProbeDetach(hWnd);
return StellarX::Internal::NativeInputFailureExitCode;
}
if (!hasLiveNativeWindow()) if (!hasLiveNativeWindow())
{ {
StellarX::Internal::inputProbeFlushAtSafePoint(hWnd); const bool inputHealthy = drainNativeInputAtSafePoint();
return 0; // 宿主循环已经确定退出;在 Window/EasyX 进一步改写句柄状态前结束探测会话。
StellarX::Internal::inputProbeDetach(hWnd);
return inputHealthy ? 0 : StellarX::Internal::NativeInputFailureExitCode;
} }
// drainMessageBatch 将一轮消息限制在固定上限;批次结束后统一处理 resize、tick 和重绘。 // drainMessageBatch 将一轮消息限制在固定上限;批次结束后统一处理 resize、tick 和重绘。
@@ -932,6 +1119,10 @@ int Window::runEventLoop()
[](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;
@@ -971,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;
@@ -1040,14 +1238,30 @@ 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;
}); });
StellarX::Internal::inputProbeHostBatchEnd(hWnd, probeBatch, batch.processed, batch.stopped); StellarX::Internal::inputProbeHostBatchEnd(hWnd, probeBatch, batch.processed, batch.stopped);
if (!drainNativeInputAtSafePoint())
{
StellarX::Internal::inputProbeDetach(hWnd);
return StellarX::Internal::NativeInputFailureExitCode;
}
if (batch.stopped || !hasLiveNativeWindow()) if (batch.stopped || !hasLiveNativeWindow())
{ {
StellarX::Internal::inputProbeFlushAtSafePoint(hWnd); // batch.stopped(典型为 WM_CLOSE)是宿主线程最后一个可靠安全点。
// detach 会落下 window_detach/session_end;默认构建中该调用为空操作。
StellarX::Internal::inputProbeDetach(hWnd);
return 0; return 0;
} }
@@ -1288,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;
@@ -1296,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();
} }
@@ -1323,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;
@@ -17,6 +17,12 @@ namespace StellarX::Internal
UINT message = 0; UINT message = 0;
bool depthCounted = false; bool depthCounted = false;
bool recorded = false; bool recorded = false;
bool experimentTagged = false;
bool ruleMatched = false;
bool observeAndConsume = false;
bool failOpenApplied = false;
std::uint8_t failOpenReason = 0;
std::uint32_t matchOrdinal = 0;
}; };
struct InputProbeBatchToken struct InputProbeBatchToken
@@ -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);
};
}
@@ -0,0 +1,167 @@
#pragma once
#include "InputEvents.h"
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <deque>
#include <limits>
#include <memory>
#include <mutex>
class Window;
namespace StellarX::Internal
{
enum class NativeInputQueuePushResult : std::uint8_t
{
Queued,
AllocationFailure,
UnexpectedFailure
};
enum class NativeInputQueuePopResult : std::uint8_t
{
Popped,
Empty,
UnexpectedFailure
};
struct NativeInputQueueStats
{
std::uint64_t pushed = 0;
std::uint64_t popped = 0;
std::size_t currentDepth = 0;
std::size_t maximumDepth = 0;
};
// Correctness-first cross-thread SPSC queue. The mutex provides happens-before.
class NativeInputQueue
{
public:
NativeInputQueue() = default;
NativeInputQueue(const NativeInputQueue&) = delete;
NativeInputQueue& operator=(const NativeInputQueue&) = delete;
NativeInputQueuePushResult push(NativeInputEvent&& event) noexcept;
NativeInputQueuePopResult tryPop(NativeInputEvent& event) noexcept;
NativeInputQueueStats stats() const noexcept;
// Deterministic fail-stop injection for unit tests only.
void forceNextPushResultForTesting(NativeInputQueuePushResult result) noexcept;
private:
mutable std::mutex mutex_;
std::deque<NativeInputEvent> events_;
std::uint64_t pushed_ = 0;
std::uint64_t popped_ = 0;
std::size_t maximumDepth_ = 0;
std::atomic<NativeInputQueuePushResult> forcedPushResult_{ NativeInputQueuePushResult::Queued };
};
enum class NativeInputFaultCode : std::uint8_t
{
None,
OutOfMemory,
QueueFailure,
SequenceViolation,
UnexpectedException
};
struct NativeInputFaultSnapshot
{
NativeInputFaultCode code = NativeInputFaultCode::None;
std::uint64_t sequence = 0;
UINT message = 0;
HWND hwnd = nullptr;
};
struct NativeInputObservationStats
{
std::uint64_t observed = 0;
std::uint64_t keyEvents = 0;
std::uint64_t textEvents = 0;
std::uint64_t compositionEvents = 0;
std::uint64_t activationEvents = 0;
std::uint64_t lastSequence = 0;
bool sequenceStrict = true;
};
// Producer-side normalization anomalies are explicit diagnostics. They do
// not become replacement text and are never silently counted as success.
struct NativeInputNormalizationStats
{
std::uint64_t rejectedTextSequences = 0;
std::uint64_t imeQueryFailures = 0;
std::uint64_t discardedDecoderFragments = 0;
};
struct NativeInputRuntimeSnapshot
{
NativeInputQueueStats queue{};
NativeInputObservationStats observations{};
NativeInputNormalizationStats normalization{};
NativeInputActivationSnapshot activation{};
NativeInputFaultSnapshot fault{};
std::uint64_t easyXKeyboardMessagesDrained = 0;
std::uint64_t legacyEasyXKeyboardBusinessDeliveries = 0;
bool faulted = false;
};
struct NativeInputCollectResult
{
bool handledSynchronously = false;
LRESULT result = 0;
};
struct KeyModifierSnapshot
{
bool leftControl = false;
bool rightControl = false;
bool leftShift = false;
bool rightShift = false;
bool leftAlt = false;
bool rightAlt = false;
};
KeyEvent makeKeyEvent(UINT message, WPARAM wParam, LPARAM lParam,
const KeyModifierSnapshot& modifiers, bool imeLifecycleActive) noexcept;
// One context per Window: WndProc produces and the innermost host loop consumes.
class NativeInputContext
{
public:
explicit NativeInputContext(HWND hwnd);
~NativeInputContext();
NativeInputContext(const NativeInputContext&) = delete;
NativeInputContext& operator=(const NativeInputContext&) = delete;
NativeInputCollectResult collect(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) noexcept;
// Pop one event and update the stage-1 ordering/type observations.
NativeInputQueuePopResult tryPopObserved(NativeInputEvent& event) noexcept;
std::size_t drainObserved(std::size_t limit) noexcept;
bool hasFault() const noexcept;
NativeInputFaultSnapshot fault() const noexcept;
NativeInputQueueStats queueStats() const noexcept;
NativeInputObservationStats observationStats() const noexcept;
NativeInputNormalizationStats normalizationStats() const noexcept;
NativeInputActivationSnapshot activationSnapshot() const noexcept;
// Test-only observation seams; none participates in production routing.
bool tryPopForTesting(NativeInputEvent& event) noexcept;
void forceNextQueuePushResultForTesting(NativeInputQueuePushResult result) noexcept;
void setAnsiCodePageForTesting(UINT codePage) noexcept;
void seedEchoForTesting(const std::wstring& text, std::uint64_t compositionId);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
// Internal fixture seam. It exposes counters only and never returns queued text.
NativeInputRuntimeSnapshot inspectWindowNativeInputForTesting(const ::Window& window) noexcept;
constexpr int NativeInputFailureExitCode = -2;
}
File diff suppressed because it is too large Load Diff
+216 -3
View File
@@ -1,5 +1,7 @@
#include "StellarX.h" #include "StellarX.h"
#include "SxNativeInputInternal.h"
#include <array> #include <array>
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
@@ -28,6 +30,44 @@ namespace
// 统一按钮颜色,使 UI 回归可以区分背景、悬停和点击后的绘制结果。 // 统一按钮颜色,使 UI 回归可以区分背景、悬停和点击后的绘制结果。
return std::make_unique<Button>(x, y, w, h, text, ClickColor, ButtonColor, HoverColor); return std::make_unique<Button>(x, y, w, h, text, ClickColor, ButtonColor, HoverColor);
} }
class KeyboardObservationControl final : public Control
{
public:
KeyboardObservationControl() : Control(0, 0, 1, 1) { id = "KeyboardObservation"; }
void draw() override { dirty = false; }
bool handleEvent(const ExMessage& message) override
{
if (message.message == WM_KEYDOWN || message.message == WM_SYSKEYDOWN)
{
++keyEventCount;
++keyDownCount;
}
else if (message.message == WM_KEYUP || message.message == WM_SYSKEYUP)
{
++keyEventCount;
++keyUpCount;
}
return false;
}
StellarX::KeyRouteResult handleKeyEvent(StellarX::RoutedKeyEvent& event) override
{
++routedKeyEventCount;
if (event.key.action == StellarX::KeyAction::Down)
++routedKeyDownCount;
else
++routedKeyUpCount;
return {};
}
bool model() const override { return false; }
std::uint64_t keyEventCount = 0;
std::uint64_t keyDownCount = 0;
std::uint64_t keyUpCount = 0;
std::uint64_t routedKeyEventCount = 0;
std::uint64_t routedKeyDownCount = 0;
std::uint64_t routedKeyUpCount = 0;
};
} }
int main(int argc, char** argv) int main(int argc, char** argv)
@@ -35,7 +75,11 @@ int main(int argc, char** argv)
// fixture 每次只运行一个确定性场景,并用唯一标题让驱动脚本精确绑定 HWND。 // fixture 每次只运行一个确定性场景,并用唯一标题让驱动脚本精确绑定 HWND。
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";
Window window(480, 320, 1, Background, "StellarX.UI." + scenario); const bool consumeProbeScenario = scenario == "input-consume-probe";
const bool expandedFocusScenario = scenario == "focus";
const int windowWidth = consumeProbeScenario ? 720 : (expandedFocusScenario ? 800 : 480);
const int windowHeight = consumeProbeScenario ? 420 : (expandedFocusScenario ? 520 : 320);
Window window(windowWidth, windowHeight, 1, Background, "StellarX.UI." + scenario);
if (scenario == "ownership") if (scenario == "ownership")
{ {
@@ -89,10 +133,19 @@ int main(int argc, char** argv)
button->setTooltipText("stationary tip"); button->setTooltipText("stationary tip");
window.addControl(std::move(button)); window.addControl(std::move(button));
} }
else if (scenario == "input-probe") else if (scenario == "input-probe" || scenario == "input-consume-probe")
{ {
// 该场景只提供一个可聚焦的 EasyX 顶层窗口,不让控件消费或改写键盘/IME 消息。 // 该场景只提供一个可聚焦的 EasyX 顶层窗口,不让控件消费或改写键盘/IME 消息。
const std::array<std::string, 5> instructions{ const bool consumeProbe = scenario == "input-consume-probe";
const std::array<std::string, 5> instructions = consumeProbe
? std::array<std::string, 5>{
"StellarX stage 0B isolated consume probe",
"This window is driven by the bounded diagnostic runner.",
"Only one message, key and action rule is active.",
"No control receives business keyboard routing.",
"The runner will close this window after capture."
}
: std::array<std::string, 5>{
"StellarX input probe - manual IME run", "StellarX input probe - manual IME run",
"1. Click this window, then switch to the requested IME.", "1. Click this window, then switch to the requested IME.",
"2. Type only the agreed non-sensitive test strings.", "2. Type only the agreed non-sensitive test strings.",
@@ -107,6 +160,83 @@ int main(int argc, char** argv)
window.addControl(std::move(label)); window.addControl(std::move(label));
y += 42; y += 42;
} }
if (consumeProbe)
{
// 与 runner 传给 IMM32 的固定客户区坐标完全一致:行区 (200,255)-(680,284),
// caret bottom 为 (225,282)。仅用于人工确认候选框没有落回桌面边缘。
auto anchorLabel = std::make_unique<Label>(20, 260, "IME anchor:", WHITE, Background);
anchorLabel->setTextdisap(true);
window.addControl(std::move(anchorLabel));
auto lineRegion = std::make_unique<Canvas>(200, 255, 480, 29);
lineRegion->setCanvasBkColor(RGB(45, 70, 95));
window.addControl(std::move(lineRegion));
auto caret = std::make_unique<Canvas>(224, 258, 2, 24);
caret->setCanvasBkColor(HoverColor);
window.addControl(std::move(caret));
}
}
else if (scenario == "input-shadow")
{
// Stage 3 production routes the normalized queue while EasyX EX_KEY is
// still drained but never delivered to Control::handleEvent().
auto observer = std::make_unique<KeyboardObservationControl>();
KeyboardObservationControl* observerRaw = observer.get();
observerRaw->setFocusPolicy(StellarX::FocusPolicy::Programmatic);
const auto observerHandle = window.addControl(std::move(observer));
window.getFocusManager().requestFocus({ observerHandle, 0 });
auto verify = MakeButton(20, 40, 160, 42, "verify input shadow");
verify->setOnClickListener([&, observerRaw]() {
const auto snapshot = StellarX::Internal::inspectWindowNativeInputForTesting(window);
WriteResult(resultDir, scenario,
std::string("{\"legacyKeyCount\":") + std::to_string(observerRaw->keyEventCount) +
",\"legacyKeyDownCount\":" + std::to_string(observerRaw->keyDownCount) +
",\"legacyKeyUpCount\":" + std::to_string(observerRaw->keyUpCount) +
",\"routedKeyCount\":" + std::to_string(observerRaw->routedKeyEventCount) +
",\"routedKeyDownCount\":" + std::to_string(observerRaw->routedKeyDownCount) +
",\"routedKeyUpCount\":" + std::to_string(observerRaw->routedKeyUpCount) +
",\"easyXKeyboardMessagesDrained\":" +
std::to_string(snapshot.easyXKeyboardMessagesDrained) +
",\"legacyEasyXKeyboardBusinessDeliveries\":" +
std::to_string(snapshot.legacyEasyXKeyboardBusinessDeliveries) +
",\"pushed\":" + std::to_string(snapshot.queue.pushed) +
",\"popped\":" + std::to_string(snapshot.queue.popped) +
",\"currentDepth\":" + std::to_string(snapshot.queue.currentDepth) +
",\"maximumDepth\":" + std::to_string(snapshot.queue.maximumDepth) +
",\"observed\":" + std::to_string(snapshot.observations.observed) +
",\"keyEvents\":" + std::to_string(snapshot.observations.keyEvents) +
",\"rejectedTextSequences\":" + std::to_string(snapshot.normalization.rejectedTextSequences) +
",\"imeQueryFailures\":" + std::to_string(snapshot.normalization.imeQueryFailures) +
",\"discardedDecoderFragments\":" + std::to_string(snapshot.normalization.discardedDecoderFragments) +
",\"sequenceStrict\":" + (snapshot.observations.sequenceStrict ? "true" : "false") +
",\"faulted\":" + (snapshot.faulted ? "true" : "false") + "}");
});
window.addControl(std::move(verify));
}
else if (scenario == "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")
{ {
@@ -168,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,57 @@
# BUG-20261006-0002
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
## 基本信息
- ID: BUG-20261006-0002
- 标题: 阶段 0B 夹具缺少 IME 锚点、组合态 Escape 规则无法命中且采集存在锁竞争丢失
- 状态:已修复并完成微软拼音与搜狗人工验证
- 严重性:S2
- 优先级:P0
- 模块: 阶段 0B / InputProbe / UiFixture / IMM32
- 版本 / 分支: `master`,0B 开发中未提交改动
- 环境: Windows 11、EasyX 25.9.10、ANSI HWND、CP936、DPI-unaware 96 DPI、微软拼音与搜狗人工探测
- 发现人: 用户真实 IME 人工探测 + 日志复核
- 关联 Fix ID: Fix-BUG-20261006-0002
## 问题描述
- 现象 1: `input-consume-probe` 窗口中开始中文组合后,微软拼音和搜狗候选 UI 都出现在桌面左下角,而不是探测窗口内。
- 现象 2: 人工按一次 Escape 后,配置为 `WM_KEYDOWN + VK_ESCAPE + ComposingOnly` 的规则命中数为 0。
- 现象 3: 本轮日志出现 3 条 `wndproc_lock_contention`,`recordsLost=3`,分析结果为 `inconclusive_capture_loss`。
- 影响范围: 0B-3 的真实 IME A/B 无法形成有效证据;如果误把该运行当成消费行为结果,可能把夹具缺陷错误归因于 IME 或 WndProc 消费策略。
- 期望结果: 夹具使用与 0C 同源的客户区 anchor 定位候选 UI;规则匹配 WndProc 实际可观察的原生键三元组;探测器自身不因 flush 锁竞争丢记录。
## 复现信息
- 前置条件: 运行 0B Composition 人工探针,选择微软拼音或搜狗。
- 复现步骤:
1. 点击 `StellarX.UI.input-consume-probe`,输入固定非敏感串 `ceshi`。
2. 观察候选 UI 位于桌面左下角。
3. 在组合态按一次 Escape 并关闭窗口。
4. 分析 JSONL 中的原生键和 `records_lost`。
- 复现概率:旧夹具在本轮稳定复现
- 证据目录: `TestResults/InputProbe/0B/20261006-140720-218-96b7392e-548a-44cf-9343-5d1449bf73fa`
- 人工截图: `manual-evidence/composition-candidate-position-wrong.png`
- 截图 SHA-256: `61E4A04CEE329B13BB22C919DC233A07ABF3EF6C298E64FB7B6E70F6D60DE0A3`
## 初步分析
- 候选位置根因: 0B 夹具没有调用 `ImmSetCompositionWindow` 或 `ImmSetCandidateWindow`,却要求操作者按 0C 基线判断位置;两套环境实际上不可比。
- 规则根因: 组合态按 Escape 时,WndProc 记录为 `WM_KEYDOWN + VK_PROCESSKEY(229) + scanCode 1`;IME 结束后才出现 `WM_KEYUP + VK_ESCAPE(27)`。旧规则错误假设 KeyDown 仍保留逻辑 VK。
- API 时机边界: 微软文档说明 `ImmGetVirtualKey` 只能在应用调用 `TranslateMessage` 前恢复原 VK。当前钩子已经在 EasyX WndProc,不能可靠恢复该键,只能记录真实原生值。
- 采集丢失根因: 宿主 safe-point 在状态锁内复制整个记录环,WndProc 使用 `try-lock`,争用时直接记丢失;探测器自身制造了实验失效条件。
- 证据边界: 失败运行混有多次组合,且存在采集丢失,只能证明夹具缺陷,不能区分输入法差异,也不能判断 Consume 是否安全。
## 跟踪信息
- 首次发现时间: 2026-10-06 14:07
- 最后更新时间: 2026-10-07
- 修复版本: 待提交
- 验证版本: Debug x64 探测构建、默认单元测试 15/15、UI fixture 1 轮、F24 自动 A/B、微软拼音 Forward/Consume 与搜狗运行时 fail-open 人工验证通过
- 参考: <https://learn.microsoft.com/windows/win32/api/immdev/nf-immdev-immgetvirtualkey>
- 后续关联: 固定锚点实现最初在状态锁内调用 IMM32 setter,引发 WndProc 重入自锁;详见 `BUG-20261006-0003`。
- 关闭边界: 搜狗候选态不暴露标准 Composition 生命周期,Consume 请求必须以 `ime_condition_not_met` fail-open;这是 `MustForward` 的输入法兼容结论,不是本 BUG 的残留夹具缺陷。
@@ -0,0 +1,60 @@
# BUG-20261006-0003
> 适用场景:记录问题本身,不展开完整修复方案。修复内容写入对应的 Fix 文档。
## 基本信息
- ID: BUG-20261006-0003
- 标题: 阶段 0B 持锁调用 IMM32 定位导致 WndProc 重入死锁
- 状态:已修复(微软拼音 Forward/Consume 与搜狗 fail-open 人工运行均保持响应)
- 严重性:S1
- 优先级:P0
- 模块: 阶段 0B / InputProbe / WndProc / IMM32
- 版本 / 分支: `master`,0B 开发中未提交改动
- 环境: Windows 11、EasyX 25.9.10、ANSI HWND、CP936、DPI-unaware 96 DPI、微软拼音与搜狗人工探测
- 发现人: 用户真实 IME 人工探测
- 关联 Fix ID: Fix-BUG-20261006-0003
## 问题描述
- 现象: 启动微软拼音 `ObserveAndForward` 人工探测后,EasyX 窗口卡顿并进入“未响应”,无法继续输入。
- 影响范围: 0B-3 人工 IME A/B 无法执行;该现象由诊断夹具自身造成,不能作为微软拼音或 Forward 行为异常的证据。
- 期望结果: IMM32 定位不得持有探测状态锁;同步重入的 WndProc 消息仍应被严格有序记录,窗口保持可交互。
## 复现信息
- 前置条件: 启用 0B 人工 Composition 规则和固定 IME caret/line rect。
- 复现步骤:
1. 启动微软拼音 `ObserveAndForward` 人工探针。
2. 激活 EasyX 窗口并尝试中文输入。
3. 窗口出现卡顿,随后无法输入并显示“未响应”。
- 复现概率:本轮稳定复现
- 证据目录: `TestResults/InputProbe/0B/20261006-231604-636-4bc775a1-a3f8-41b9-b466-1cce740bc195/forward`
- 日志边界: 已耐久写入 268 条记录,`sequenceStrict=true`、`recordsLost=0`,但没有 `WM_CLOSE/session_end`;最后一批记录完整结束于普通 Space KeyUp。死锁发生点很可能位于下一次定位调用且早于新记录入队,因此日志不能直接给出阻塞栈。
## 根因分析
- `inputProbeWndProcEnter` 持有不可重入的 `ProbeState::lock` 时调用 `ImmGetContext`、`ImmSetCompositionWindow` 和 `ImmSetCandidateWindow`。
- IMM32 定位 setter 可以同步重入同一窗口过程;重入后的探测入口再次请求同一把 SRW 独占锁,当前线程因此自锁。
- 直接把 setter 移到锁外仍不充分:重入消息如果再次满足定位触发条件,会递归发起新的定位调用,需要独立的线程内重入抑制。
- 本问题由 `BUG-20261006-0002` 的固定锚点修复实现引入,不是 EasyX、微软拼音或消息消费策略本身的缺陷。
## 跟踪信息
- 首次发现时间: 2026-10-06 23:16
- 最后更新时间: 2026-10-07
- 修复版本: 待提交
- 验证版本: Debug x64 探测构建、默认单元测试 15/15、UI fixture 1 轮、微软拼音 Forward/Consume 与搜狗 fail-open 真实复验通过
- 关联记录: `BUG-20261006-0002`、`Fix-BUG-20261006-0002`
## 关闭证据
- 有效运行: `TestResults/InputProbe/0B/20261006-234544-007-603391f8-3a14-4c90-ab41-4d891ceb8f46/forward`
- 结构结果: `experimentValidity=valid`、`sequenceStrict=true`、`recordsLost=0`、规则唯一命中并 Forward、`imePosition=18/18`、`imePositionOrphanCount=0`、`WM_CLOSE/session_end` 完整。
- 人工结果: 窗口保持响应并可正常输入;组合串和候选 UI 位于固定锚点附近,Escape 流程完成后窗口正常关闭。
- 截图: `manual-evidence/microsoft-pinyin-forward-composition-position-pass.png`
- 截图 SHA-256: `1447E9F1AAD2C90A787AADC2594C78AE596336FC6D569EA1A22C69B2FCA5C3D5`
- 补充证据: 搜狗最终 fail-open 运行中窗口保持响应,定位 3/3、无孤立结果、零丢失且关闭完整;见 `TestResults/InputProbe/0B/20261007-033454-763-56a2630b-bf2a-4431-b501-38f6ff977dfe/consume`。
- 关闭边界: 本 BUG 只关闭“持锁定位导致重入死锁”;搜狗未暴露 Composition 生命周期所形成的 `MustForward` 结论归 0B 规格,不属于死锁残留。
@@ -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,74 @@
# Fix-BUG-20261006-0002
> 适用场景:记录某个 BUG 的修复方案、影响评估、验证结果与落地信息。
## 关联信息
- Fix ID: Fix-BUG-20261006-0002
- 关联 BUG ID: BUG-20261006-0002
- 修复目标: 让阶段 0B 的真实 IME 实验具备可比较的位置基线、可命中的原生键规则和不因状态锁竞争主动丢记录的采集路径
- 状态:验证完成
- 负责人: Codex
- 分支 / 版本: `master`,未提交工作区
## 根因分析
- 根因 1: 0B 复用了只显示说明文字的 UiFixture 场景,没有把 0C 已验证的 caret point/line rect 写入当前 HWND 的 IME context。
- 根因 2: 规则按操作者语义配置为 `VK_ESCAPE`,但 IME 在 WndProc 前已处理组合态 KeyDown,窗口实际收到 `VK_PROCESSKEY`;scan code 1 才保留了本轮 Escape 的物理区分信息。
- 根因 3: 单环形缓冲在 safe-point 持锁复制,WndProc 为避免等待使用 `TryAcquireSRWLockExclusive`;宿主复制期间到达的原生消息会被主动丢弃。
## 修复方案
- 修复思路: 保持 0B 动作枚举只有 Forward/Consume 两值,仅修正诊断输入、位置基线和采集完整性,不新增生产路由行为。
- 关键改动:
- 0B 人工 runner 固定窗口为 `720×420`,传入客户区 caret `(225,282)` 与 line rect `(200,255)-(680,284)`。
- UiFixture 绘制与上述数值一致的 line region 和 caret marker,便于操作者判断候选 UI 是否仍落在窗口外。
- 在 `WM_SETFOCUS`、`WM_IME_SETCONTEXT`、`WM_IME_STARTCOMPOSITION`、`WM_IME_COMPOSITION` 观察点准备定位请求;锁外调用 `CFS_POINT` composition setter 与 `CFS_EXCLUDE` candidate setter,通过独立 `ime_position` 记录保存 context、两个 API 结果和坐标。
- Composition 请求 Consume 时,如果尚无一次两个 setter 同时成功的证据,立即 fail-open 为 `ime_position_unavailable`。
- 规则新增可选 `scanCode`。自动 F24 仍使用 any;人工 Escape 默认使用 `WM_KEYDOWN + VK_PROCESSKEY(229) + scanCode 1 + ComposingOnly`,界面指令仍要求操作者按 Escape。
- 分析器在指定 scan code 时同时过滤 WndProc 与 EasyX `EX_KEY`,并输出位置尝试/成功计数和 `imePositionReady`。
- 记录缓冲改为两个固定数组;safe-point 在锁内只交换 active buffer 和重置索引,文件写入在锁外完成。WndProc 使用极短阻塞临界区,不再因 `try-lock` 失败丢弃记录。
- 涉及文件 / 类 / 函数:
- `src/StellarX/SxInputProbe.cpp`
- `tests/UiFixture.cpp`
- `scripts/run-input-consume-probe.ps1`
- `scripts/run-input-consume-probe-manual.ps1`
- `scripts/analyze-input-probe.ps1`
- 影响的 API / 行为: 仅 `STELLARX_ENABLE_INPUT_PROBE` 且显式开启 0B 的诊断路径;公共 API 与默认生产窗口行为不变。
- 回滚点 / 开关: 不设置 `STELLARX_INPUT_PROBE_0B` 时不装载规则、不调用固定位置逻辑;默认构建不包含探测实现。
## 影响评估
- 正确性: scan code 消除同一 `VK_PROCESSKEY` 下字母、方向、Escape 等多类组合键的歧义;固定位置成功门槛防止把夹具定位失败算成消费副作用。
- 并发: 双缓冲消除宿主批量复制对 WndProc 的长时间占锁;文件写锁仍串行化多个 flush,状态锁序保持“文件写锁 → 状态锁”。
- 性能: WndProc 每条被观察消息增加一次短阻塞状态锁;Composition 生命周期增加有限的 IMM32 setter。两者只存在于诊断构建。
- 风险:
- 某些 IME 即使 setter 返回成功也可能自行调整候选 UI,因此仍需人工视觉确认。
- WndProc 阻塞临界区要求 safe-point 严禁在状态锁内做文件 I/O 或整批复制;后续维护若破坏此不变量可能重新引入卡顿。
- 首次人工复验发现持状态锁调用 IMM32 setter 会因同步重入 WndProc 自锁;已拆分为“锁内请求 → 锁外调用 → 锁内结果”并增加线程内递归定位保护,详见 `Fix-BUG-20261006-0003`。
- `VK_PROCESSKEY + scanCode 1` 是当前环境观察到的原生 Escape 形态,不得未经新证据外推到全部 IME/键盘布局。
## 验证结果
- 已完成:
- Debug x64 探测构建完成编译与链接;仅有 EasyX 头文件既有 C4201 警告。
- 失败截图已复制到对应忽略运行目录并记录 SHA-256。
- 默认 Debug x64 单元测试 15/15 通过。
- Windows PowerShell 5.1.22000.2538 与 PowerShell 7.6.5 对三份 0B 脚本均为 0 个 AST 解析错误。
- `git diff --check` 通过,仅输出仓库换行规范提示。
- 新分析器重放既有有效 F24 KeyDown A/B:Forward 仍为 `valid/present_once`,Consume 仍为 `valid/absent`,兼容未携带 `targetScanCode` 的既有 schema v2 日志。
- `BUG-20261006-0003` 修复后的微软拼音 Forward 人工复验为 `valid`:Escape 原生规则唯一命中并转发,18/18 次定位成功、零孤立结果、零丢失,人工截图确认候选 UI 位于固定锚点附近。
- 微软拼音 Consume 有效重跑为 `valid`:Escape 唯一命中并消费,旧过程未调用,对应 EasyX 键消失;7/7 次定位成功、零孤立结果、零丢失且无提交结果。
- 搜狗 Forward 运行确认候选 UI 可见且位置正常,但目标 HWND 未出现 START/COMPOSITION/END;Escape 到达时 tracked state 仍为 Idle,`ComposingOnly` 零命中,定位 6/6、零丢失、收口完整。
- 搜狗最终 Consume 请求按 `ime_condition_not_met` 正确运行时 fail-open:`experimentValidity=runtime_fail_open`、零消费、实际 Forward 一次、EasyX 键出现一次、定位 3/3、零丢失,最终 verdict 为 `MustForward`。
- 2026-10-07 默认 Debug x64 单元测试 15/15、UI fixture 1 轮和 F24 KeyDown A/B 新运行通过;既有 KeyUp 与 baseline mismatch 证据经当前分析器回放保持有效。
- 当前工具环境限制: 自动 F24 runner 已完成构建并打开夹具,但 Codex 非交互工具宿主无法把窗口切到前台,`ClickWindowForActivation` 在发键前失败;该运行没有形成新的 A/B 结论,不记为代码失败,也不覆盖既有有效证据。
- 首次微软拼音复验边界: 旧定位实现导致窗口未响应,失败运行保留于 `TestResults/InputProbe/0B/20261006-231604-636-4bc775a1-a3f8-41b9-b466-1cce740bc195/forward`;该运行只证明诊断死锁,不用于评价 Forward 或微软拼音。
- 验证边界: 自动 `SendInput` 不能替代真实 IME;搜狗不能被要求“规则唯一命中”,因为缺少可证明 Composition 状态时正确行为就是零命中并 fail-open。该 Fix 以固定定位链、原生键观察、零丢失和明确 `MustForward` 结论关闭,不把运行时 Forward 伪装成 Consume。
## 落地信息
- Commit: 待验证与用户确认后提交
- PR:无
- 发布版本:未发布
- 备注: 本修复不进入阶段 1,不实现 FocusManager、生产 InputCollector 或键盘路由。
@@ -0,0 +1,54 @@
# Fix-BUG-20261006-0003
> 适用场景:记录某个 BUG 的修复方案、影响评估、验证结果与落地信息。
## 关联信息
- Fix ID: Fix-BUG-20261006-0003
- 关联 BUG ID: BUG-20261006-0003
- 修复目标: 消除 0B 固定 IME 定位路径的 WndProc 重入自锁,同时保持日志严格序号和可审计关联
- 状态:验证通过(微软拼音 Forward/Consume 与搜狗运行时 fail-open)
- 负责人: Codex
- 分支 / 版本: `master`,未提交工作区
## 修复方案
- 将定位路径拆成三个阶段:
1. 锁内解析并写入原始 `wndproc_enter`,更新 IME/规则状态,复制不可变的定位请求。
2. 锁外调用 `ImmGetContext`、`ImmSetCompositionWindow`、`ImmSetCandidateWindow` 和 `ImmReleaseContext`。
3. 再次加锁校验探测仍启用、HWND 与 `hwndInstanceId` 未变化,更新 `imePositionReady` 和成功计数,并写入独立 `ime_position` 结果记录。
- `ime_position.parentSequence` 指向触发它的 `wndproc_enter.sequence`;结果记录只在 IMM32 调用返回后分配序号。这样 setter 同步重入产生的消息自然位于父 enter 与结果记录之间,不会因“预留低序号、事后补写”破坏 `sequenceStrict`。
- 使用 WndProc 线程局部的 `imePositionApiCallActive` 抑制定位 API 调用期间的递归定位;重入消息仍正常采集、转发和记录,只是不再次调用定位 setter。
- 分析器同时兼容旧版挂在 `wndproc_enter` 上的位置结果与新的 `ime_position` 记录,并校验新记录的 `parentSequence` 存在且严格早于结果记录。
## 影响评估
- 正确性: 移除持锁 IMM32 setter,避免 SRW 自锁;独立结果记录保留调用结果、坐标、触发消息与父序号。
- 并发: `ProbeState::lock` 只保护状态快照与结果提交,不跨越任何定位 setter;完成阶段会拒绝已经 detach 或 HWND 实例变化的过期结果。
- 顺序: 父 enter 先入缓冲,重入消息随后入缓冲,最终结果记录最后入缓冲;全局序号与实际可观察顺序一致。
- 行为边界: 仅影响 `STELLARX_ENABLE_INPUT_PROBE` 且显式开启 0B 人工定位的诊断路径;默认生产构建、公共 API 与正常消息返回策略不变。
- 风险: 不同 IME 仍可能自行调整候选 UI;setter 成功只证明调用链成立,最终位置仍需人工视觉确认。
## 验证结果
- 已完成:
- Debug x64 探测构建完成编译与链接;仅有 EasyX 头文件既有 C4201 警告。
- 旧失败日志重放仍为 268 条严格递增记录、零丢失,并明确判为缺少关闭收口的无效实验。
- 默认 Debug x64 单元测试 15/15 通过。
- Windows PowerShell 5.1 与 PowerShell 7.6.5 对三份 0B 脚本均为 0 个 AST 解析错误。
- `git diff --check` 通过,仅输出仓库换行规范提示。
- 微软拼音 Forward 人工复验通过:窗口保持响应,组合串/候选 UI 位于固定锚点;结构日志为 1142 条严格递增记录、零丢失、规则唯一命中并 Forward、18/18 次定位成功、0 个孤立定位结果、关闭收口完整。
- 有效运行: `TestResults/InputProbe/0B/20261006-234544-007-603391f8-3a14-4c90-ab41-4d891ceb8f46/forward`。
- 人工截图: `manual-evidence/microsoft-pinyin-forward-composition-position-pass.png`,SHA-256 `1447E9F1AAD2C90A787AADC2594C78AE596336FC6D569EA1A22C69B2FCA5C3D5`。
- 微软拼音 Consume 同样保持响应,7/7 次定位成功且 Escape 唯一命中并被消费;有效运行位于 `TestResults/InputProbe/0B/20261007-000152-550-4c83bcda-12e8-4f4e-b616-1d1fe1f56a27/consume`。
- 搜狗 Forward 运行保持响应、定位 6/6;最终 Consume 请求因 Composition 条件不可证明而正确 fail-open,运行仍保持响应、定位 3/3、无孤立结果、零丢失并完整关闭。最终有效目录为 `TestResults/InputProbe/0B/20261007-033454-763-56a2630b-bf2a-4431-b501-38f6ff977dfe/consume`。
- 2026-10-07 默认 Debug x64 单元测试 15/15、UI fixture 1 轮和当前分析器回放通过。
## 落地信息
- Commit: 待验证与用户确认后提交
- PR:无
- 发布版本:未发布
- 备注: 本修复不进入阶段 1,不实现 FocusManager、生产 InputCollector 或键盘路由。
@@ -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>>`。调用方必须保存稳定句柄,而不是保存容器迭代器或假设控件地址永久有效。
+90 -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 | 启动后完成登录页和旧业务页的基本切换 | 能正常进入和切换,无明显布局或交互倒退 |
@@ -112,6 +118,87 @@ $msbuild = 'D:\vs2022\MSBuild\Current\Bin\MSBuild.exe'
- 自动复验:2026-09-30 使用 Windows PowerShell 5.1 执行 `NonUI`,四种配置均为 15/15 通过,KEY1 至 KEY6 编译链接成功。 - 自动复验:2026-09-30 使用 Windows PowerShell 5.1 执行 `NonUI`,四种配置均为 15/15 通过,KEY1 至 KEY6 编译链接成功。
- 说明:该结论代表本轮人工回归通过,不等同于对所有运行环境和未覆盖路径作无缺陷保证。 - 说明:该结论代表本轮人工回归通过,不等同于对所有运行环境和未覆盖路径作无缺陷保证。
### 阶段 1 输入地基人工回归(2026-10-07,已完成)
本轮阶段 1 保留旧 `EX_KEY` 业务旁路,人工目标是确认“采集与观察队列接入后,现有交互和真实 IME 行为没有改变”。自动测试不能替代以下操作:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-input-probe-manual.ps1 -ImeName MicrosoftPinyin
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-input-probe-manual.ps1 -ImeName Sogou
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-key.ps1
```
- 微软拼音与搜狗分别完成固定非敏感测试串的候选、提交、重复字符、Escape 取消、数字选词和输入布局切换;候选 UI、输入响应和关闭流程不得卡顿或重复提交。
- 输入探测摘要必须 `sequenceStrict=True`、`textRawWParamLeaks=0`、结果/回声关系符合对应 IME 实际行为,并正常观察到关闭;探测器显式记账的诊断丢失不应被隐藏。
- KEY1–6 按上表执行;重点确认一次按键/点击只触发一次,阶段 1 兼容旁路没有造成双触发。
- 完成后,`Module-20261007-0005` 才可记为“自动与人工兼容性回归通过”。
#### 2026-10-07 执行结果
- 用户按固定清单分别完成微软拼音与搜狗真实输入,并完成 KEY1–6 人工交互;当前快照未发现卡顿、重复提交、双触发或既有控件行为退化。
- 微软拼音有效证据位于 `TestResults/InputProbe/manual-20261007-192828-293-MicrosoftPinyin`:`sequenceStrict=True`、`recordsLost=0`、`textRawWParamLeaks=0`,3 次正式结果均形成 `WM_IME_CHAR:matched`,关闭与 `session_end` 完整。
- 搜狗有效证据位于 `TestResults/InputProbe/manual-20261007-193100-289-Sogou`:`sequenceStrict=True`、`recordsLost=0`、`textRawWParamLeaks=0`,3 次正式结果均形成 `WM_IME_CHAR:matched`,关闭与 `session_end` 完整。搜狗本轮只出现 3 组标准生命周期,再次证明生命周期不能替代原始 `VK_PROCESSKEY` 的单键归属快照。
- 两轮均未观察到 `WM_INPUTLANGCHANGE`;这与当前系统同一 HKL 下切换 TSF 输入配置文件的既有证据一致,不把“用户执行过切换”和“窗口收到 HKL 变化消息”混写成同一结论。
- 微软拼音候选框在无文本焦点叶子、无 caret 锚点的输入探测夹具中落到系统回退位置。阶段 1 不实现 FocusManager、TextBox 或 `ImmSetCandidateWindow`,因此该现象作为后续集成边界保留,不把本轮采集通过误写成候选框定位通过;定位能力仍以阶段 0C 证据为准。
- 人工回归发现 KEY5/KEY6 曾把 Tooltip 延时覆盖为 `120 ms`,不是框架默认计时错误;现已移除覆盖,使人工用例使用 Button 默认 `1000 ms`。自动化 Tooltip fixture 继续使用独立的 `200 ms` 测试延时。
- KEY2 是 KEY1–6 中唯一启用文件日志的场景;轮转阈值由 `1024` 字节调整为 `10 * 1024 * 1024` 字节(10 MiB)。轮转在完整日志行写完后检查,单文件允许最多超出最后一行的长度。
- 调整后执行正式 `NonUI`:Debug x64、x64 ASan、Release x64、Debug Win32 均为 27/27,KEY1–6 全部重新编译链接成功。
### 阶段 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.9 IME 位置双时机修订;0C 当前可用环境基线通过,下一步进入 0B,阶段 1 尚未开始) - 状态:实现中(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 与阶段 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 编辑内核与自绘
↓ ↓
@@ -126,6 +126,14 @@
- 搜狗真实运行证明候选 UI 可能已经出现,但 EasyX HWND 尚未收到探测器原先依赖的 `WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION`。因此位置设置不能只挂在 IME 生命周期消息上;必须在焦点/caret/anchor 变化时预设,并在生命周期消息到达时再校准。 - 搜狗真实运行证明候选 UI 可能已经出现,但 EasyX HWND 尚未收到探测器原先依赖的 `WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION`。因此位置设置不能只挂在 IME 生命周期消息上;必须在焦点/caret/anchor 变化时预设,并在生命周期消息到达时再校准。
- 修复后的搜狗运行中,F1/F2/F3 的 anchor 预设全部成功,F3 生命周期二次校准成功;F3 提交后回到 F1 的截图与初始 F1 裁剪字节一致。当前 96 DPI 虚拟化视图下,微软拼音与搜狗 0C-3 均通过人工视觉验收。 - 修复后的搜狗运行中,F1/F2/F3 的 anchor 预设全部成功,F3 生命周期二次校准成功;F3 提交后回到 F1 的截图与初始 F1 裁剪字节一致。当前 96 DPI 虚拟化视图下,微软拼音与搜狗 0C-3 均通过人工视觉验收。
2026-10-06 的 0B 首轮 Composition 人工运行进一步确认:
- 操作者按 Escape 时,组合态 WndProc 观察到的是 `WM_KEYDOWN + VK_PROCESSKEY(229) + scanCode 1`,组合结束后的 KeyUp 才是 `VK_ESCAPE(27)`;因此原生采集规则必须允许用 message、VK 与 scan code 三元组精确描述,不能把 WndProc 层的 `VK_PROCESSKEY` 伪装成已恢复的逻辑 Escape。
- `ImmGetVirtualKey` 只允许应用在自身消息循环调用 `TranslateMessage` 前恢复原 VK;当前钩子已经位于 EasyX WndProc,不能依赖该 API 补救时机。
- 0B 夹具必须自己引用 0C 已验证的客户区 caret/line rect,并在焦点/IME context 与组合生命周期双时机设置组合窗和候选窗位置;否则“候选框异常”只证明夹具缺少定位,不能证明消费动作破坏 IME。
- 宿主安全点复制整批记录会与 WndProc 争用状态锁。0B 修正为双固定缓冲、锁内常数时间交换、锁外写盘;诊断采集不再用 `try-lock` 丢弃 WndProc 观察。
- 固定 IME 定位 setter 可能同步重入 WndProc。任何探测或生产输入实现都不得持有状态/输入队列锁调用 IMM32 setter;0B 使用“锁内提交父 enter 和不可变请求 → 锁外 IMM32 → 锁内校验会话并提交 `ime_position` 结果”的三阶段协议,并以线程局部标志阻止递归定位。
### 2. 分层架构 ### 2. 分层架构
```text ```text
@@ -154,16 +162,20 @@ Button / TextBox / Table / TabControl / Dialog
### 3. 关键不变量 ### 3. 关键不变量
1. NativeInputQueue 是键盘、文本和 IME 的唯一业务输入源。 以下是阶段 3 换轨后的完成态不变量。阶段 1–2 的“旧 EX_KEY 继续业务下发、新队列只观察”和阶段 3 切片 1–5 的测试门控均为历史过渡;v0.22 已由切片 6 一次性把生产默认切换为 Routed,Legacy 只保留为事件循环启动前锁定的内部 A/B 基线,任何模式下旧、新业务路径都必须互斥:
2. EasyX `EX_KEY` 只排空和观察,不得再次触发业务路由。
1. 阶段 3 启用后,NativeInputQueue 是键盘、文本和 IME 的唯一业务输入源。
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 语义边界
@@ -214,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 异步输入消息
| 消息 | 规范化结果 | 初始处置 | | 消息 | 规范化结果 | 初始处置 |
@@ -250,7 +269,7 @@ IME 消息:采集 + 转发
- 非组合态 Button 的 Enter/Space。 - 非组合态 Button 的 Enter/Space。
- 框架明确拥有的 Ctrl 快捷键。 - 框架明确拥有的 Ctrl 快捷键。
硬规则:`imeComposingAtCapture == true` 的键不能进入消费白名单。 硬规则:`imeComposingAtCapture == true` 或原始 `virtualKey == VK_PROCESSKEY` 的键不能进入消费白名单,也不得进入 Button、Dialog、快捷键或焦点导航等业务路由。后者不依赖标准 Composition 生命周期,因为搜狗实测已证明候选 UI 可见时目标 HWND 仍可能保持 `imeState=idle`。
### 6. 输入事件模型 ### 6. 输入事件模型
@@ -343,7 +362,7 @@ struct WindowActivationEvent
}; };
``` ```
窗口失活时可以保留逻辑焦点句柄,但 `keyboardFocusActive == false` 时不路由键盘。窗口重新激活后先验证句柄,失效则执行焦点回退。 窗口失活时保留逻辑焦点身份,但 `keyboardFocusActive == false` 时暂停键盘、文本和 IME 的业务分发,并单独取消 Button 等控件的键盘瞬时按压态。窗口重新激活后先验证句柄,失效则执行焦点回退。清理按压态和暂停业务路由是两个独立动作,不能用其中一个代替另一个。
#### 6.5 队列外壳 #### 6.5 队列外壳
@@ -354,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,
@@ -365,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 跨线程队列模型
当前拓扑是单生产者/单消费者: 当前拓扑是单生产者/单消费者:
@@ -389,7 +424,7 @@ EasyX HWND/WndProc 线程
6. 若改为 lock-free SPSC,生产者写完 slot 后以 release 发布写索引,消费者以 acquire 读取;回收索引反向使用同等内存序。 6. 若改为 lock-free SPSC,生产者写完 slot 后以 release 发布写索引,消费者以 acquire 读取;回收索引反向使用同等内存序。
7. 全局 `sequence` 使用 atomic 分配,但事件可见性由互斥锁或 release/acquire 队列协议保证。 7. 全局 `sequence` 使用 atomic 分配,但事件可见性由互斥锁或 release/acquire 队列协议保证。
探测器使用的“固定环形缓冲 + 尝试锁 + 允许 `records_lost`”只适用于诊断日志,不能照搬为生产输入队列;生产输入事件比探测记录具有更强的不丢失要求。 探测器使用的固定双缓冲和 `records_lost` 失效判定只适用于诊断日志,不能照搬为生产输入队列;生产输入事件比探测记录具有更强的不丢失要求。诊断路径同样禁止持锁调用旧窗口过程、IMM32 setter、用户回调或文件 I/O。
### 7. Unicode 和文字来源 ### 7. Unicode 和文字来源
@@ -556,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 | 不可聚焦 |
@@ -576,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 焦点有效条件
@@ -608,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 焦点失效回退
@@ -635,6 +690,8 @@ FocusChangeResult requestFocus(
控件进入 pending removal 前,必须先让焦点系统中的对应句柄失效。 控件进入 pending removal 前,必须先让焦点系统中的对应句柄失效。
pending 请求失效与 active 焦点失效是两个入口:pending 失效只丢弃;active 失效才执行上述回退。`clearControls()` 必须先批量标记整批子树失效,再选择后备,禁止焦点落到同批即将删除的兄弟控件。
#### 10.6 Dialog 作用域 #### 10.6 Dialog 作用域
Dialog 打开: Dialog 打开:
@@ -651,6 +708,8 @@ Dialog 关闭:
3. 验证并恢复原焦点。 3. 验证并恢复原焦点。
4. 原焦点失效时执行外层回退。 4. 原焦点失效时执行外层回退。
阶段 2 只实现并测试 `pushScope/popScope` 机制,使用测试作用域验证重入与恢复;真实 Dialog 的打开/关闭接入仍留阶段 4,避免在焦点地基阶段提前改变模态行为。
### 11. Tab 顺序与复合控件 ### 11. Tab 顺序与复合控件
#### 11.1 Tab 顺序 #### 11.1 Tab 顺序
@@ -705,13 +764,15 @@ Table 本身不是焦点叶子,通过 `collectTabStops()` 暴露上一页和
```text ```text
取出一条事件 取出一条事件
→ 验证窗口激活状态 → 校验 sequence 并累计观察统计
→ IME 预处理 → 验证采集激活快照与当前激活 generation
→ Tab 导航 → 验证当前是否处于模态隔离态
→ IME / 系统组合键预处理
→ 锁定原始焦点句柄 → 锁定原始焦点句柄
→ 构造句柄路由路径 → 构造句柄路由路径
→ 焦点叶子 → 焦点叶子
→ 父容器冒泡 → 父容器冒泡
→ 若未阻止框架默认动作,以回调后的新焦点树执行 Tab 导航
→ FocusScope 默认动作 → FocusScope 默认动作
→ Window 默认动作 → Window 默认动作
→ 提交当前事件产生的 mutation → 提交当前事件产生的 mutation
@@ -719,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
@@ -733,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
事件开始时使用句柄构造路径快照: 事件开始时使用句柄构造路径快照:
@@ -743,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
@@ -760,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
@@ -987,17 +1094,18 @@ struct DispatchState
- 覆盖 100%、125%、150% 和跨屏不同 DPI。 - 覆盖 100%、125%、150% 和跨屏不同 DPI。
- 采集 `COMPOSITIONFORM` / `CANDIDATEFORM` 定位证据。 - 采集 `COMPOSITIONFORM` / `CANDIDATEFORM` 定位证据。
- 详细规格见 [`Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md`](./Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md)。 - 详细规格见 [`Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md`](./Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md)。
- 当前状态:0C-1 自动门槛通过;0C-2 当前 96/144 DPI 自动门槛通过;微软拼音与搜狗三个 anchor 已取得真实视觉证据,搜狗提交后 stale-position 回归通过。当前可用环境的 0C 基线关闭,100%/125%/跨屏作为实机能力缺口继续记录,下一步进入 0B。 - 当前状态:0C-1 自动门槛通过;0C-2 当前 96/144 DPI 自动门槛通过;微软拼音与搜狗三个 anchor 已取得真实视觉证据,搜狗提交后 stale-position 回归通过。当前可用环境的 0C 基线已关闭,100%/125%/跨屏作为实机能力缺口继续记录;后续 0B 阶段 1 前置基线也已关闭。
#### 阶段 0B:隔离消费实验 #### 阶段 0B:隔离消费实验
使用测试专用规则: 使用测试专用规则;详细规格见 [`Module-20261001-0004-阶段0B原生键盘消息隔离消费实验规格.md`](./Module-20261001-0004-阶段0B原生键盘消息隔离消费实验规格.md):
```cpp ```cpp
struct ProbeConsumeRule struct ProbeConsumeRule
{ {
UINT message; UINT message;
std::optional<UINT> virtualKey; std::optional<UINT> virtualKey;
std::optional<std::uint8_t> scanCode;
ImeStateCondition imeCondition; // Any / IdleOnly / ComposingOnly ImeStateCondition imeCondition; // Any / IdleOnly / ComposingOnly
ProbeAction action; // ObserveAndForward / ObserveAndConsume ProbeAction action; // ObserveAndForward / ObserveAndConsume
}; };
@@ -1007,37 +1115,61 @@ struct ProbeConsumeRule
- 日志携带实验 ID 和规则 ID。 - 日志携带实验 ID 和规则 ID。
- 同步窗口协议禁止进入规则表。 - 同步窗口协议禁止进入规则表。
- 正式构建默认没有任何消费规则。 - 正式构建默认没有任何消费规则。
- 动作枚举严格保持 `ObserveAndForward / ObserveAndConsume` 两值;“转发但抑制下游业务路由”属于阶段 1,不是 0B 的第三种 WndProc 动作。
- 实验结论使用 `SafeToConsume / MustForward / Inconclusive`,与动作枚举分离。
- 自动 A/B 必须单独回答:WndProc 消费目标 `WM_KEYDOWN` 后,宿主 `peekmessage(EX_KEY)` 是否仍返回对应虚拟键。
- 配置错误、规则模糊、IME 状态不可靠、出现记录丢失或运行环境与 0C baseline 不一致时一律 fail-open,实际动作退回观察并转发。
- 当前 F24 实测:`WM_KEYDOWN/UP` 的 Forward 对照均产生一次对应 `EX_KEY`,Consume 后对应 `EX_KEY` 均消失;错误 baseline 请求 Consume 时正确退回 Forward。该结论只证明 F24 的两观察面关系,不自动外推为生产消费白名单。
- 自动首键由 F8 改为 F24:当前中文 IME 环境下 F8 的 `SendInput` KeyDown 未到达目标 WndProc,截断层级尚未确认,不能形成有效 A/B 对照。
- 0B 自动证据现已强制要求严格序列、零丢失、真实 fixture PID 退出、`WM_CLOSE` 与 `session_end` 完整;宿主 flush 和 WndProc 关闭收口使用独立文件写锁串行。
- Composition 人工规则按真实原生观察匹配 `WM_KEYDOWN + VK_PROCESSKEY(229) + scanCode 1`,操作者动作仍为 Escape;规则不尝试在 WndProc 中恢复逻辑键。
- 0B 人工夹具使用固定客户区 anchor,并记录 `ImmGetContext` 与两个 setter 的分项结果;位置链未成功时请求 Consume 必须 fail-open。
- 0B 日志缓冲采用双固定缓冲,安全点只在状态锁内交换 buffer;文件序列化在锁外完成,避免把探测器自身锁竞争误报成输入链丢失。
- 0B 固定 IME 定位采用三阶段拆锁协议,定位结果写独立 `ime_position` 记录并以 `parentSequence` 关联父 enter;setter 同步重入的消息允许正常采集,但不得递归发起定位。
- 修复后微软拼音 Forward 人工运行保持响应,Escape 原生规则唯一命中并转发,定位结果 18/18 成功且无孤立父序号;截图确认组合串/候选 UI 位于固定锚点附近。该结论只关闭诊断死锁和 Forward 对照,不代替 Consume 与搜狗矩阵。
- 微软拼音 Consume 重跑中 Escape 原生规则唯一命中并返回 0,旧窗口过程未被调用,对应 EasyX `EX_KEY=absent`;定位 7/7 成功、零丢失且没有产生提交结果。与 Forward 的 `EX_KEY=present_once` 对照表明当前受控键在 WndProc 消费后不会再进入 EasyX 键面,但生产白名单仍需搜狗矩阵和最终 0B 评审。
- 搜狗候选态实测没有向目标 HWND 发送 START/COMPOSITION/END 生命周期;Escape KeyDown 仍为 `VK_PROCESSKEY + scanCode 1`,但 tracked IME state 保持 Idle。最终 Consume 请求按 `ime_condition_not_met` 运行时 fail-open 为 Forward,EasyX 键可见一次,结论为 `MustForward` 而不是真正 Consume。
- 阶段 1 采用默认 capture-and-forward,不建立生产消费白名单;所有 `VK_PROCESSKEY` 和已确认组合态键只供输入法/文本规范化,不进入控件业务路由。微软拼音单点 Consume 成功只作为“WndProc return 0 会阻断 EasyX 键面”的机制证据。
#### 阶段 1:InputCollector #### 阶段 1:InputCollector
- 输入事件结构和 NativeInputQueue。 - 已实现 `NativeInputEvent / KeyEvent / TextInputEvent / CompositionEvent / WindowActivationEvent` 和每 Window 的 `NativeInputContext`。
- 按已确认双线程模型实现 SPSC 语义的跨线程发布;正确性版本先用短临界区互斥队列。 - 按已确认双线程模型使用 `mutex + deque` 实现跨线程 SPSC 语义;`sequence` 由 atomic 分配,队列互斥独立提供 happens-before。
- `sequence` 使用 atomic 分配,队列同步独立提供 happens-before。 - 已实现 Key lParam 完整解析、左右修饰键快照、Unicode/ANSI/CP936 状态解码、IME 生命周期、`compositionId` 与结果回声去重。
- KeyEvent 完整解析。 - WndProc 捕获时同时快照原始 VK 和 `imeComposingAtCapture`;二者是正交轴,只有原始 `VK_PROCESSKEY` 表示当前单键归 IME。
- Unicode/ANSI 解码。 - 阶段 1 采用行为保持过渡:新队列只在宿主安全点观测排空,旧 `EX_KEY -> Control::handleEvent()` 暂时保留;阶段 3 才把 `EX_KEY` 改为纯排空,并让 NativeInputQueue 成为唯一键盘业务源。
- IME 状态机。 - `Control::handleEvent()` 的 `EX_MOUSE` 永久保留;仅 `EX_KEY` 旧入口进入弃用期,派生控件不得新增对该路径的依赖。
- EasyX `EX_KEY` 排空但不业务路由。 - 队列分配失败或内部异常由无分配 POD 故障快照发布,WndProc 不抛异常;宿主安全点显式 fail-stop 并以 `-2` 退出,不允许静默丢输入后继续运行。
- 非法 UTF-16/ANSI 序列、IMM 查询失败和被边界清理的半截解码状态使用无原文累计计数,并在宿主安全点按变化输出警告。
- 阶段 1 不实现 FocusManager、KeyboardRouter、生产消费白名单、Button/TextBox 键盘行为或 TextBox 自绘。
#### 阶段 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
@@ -1062,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 默认动作。
@@ -1082,17 +1214,52 @@ struct ProbeConsumeRule
26. EasyX 批次必须从主画面进入和退出;工作图像只在批次内部切换,并通过显式合成上屏。 26. EasyX 批次必须从主画面进入和退出;工作图像只在批次内部切换,并通过显式合成上屏。
27. 在已验证的 DPI-unaware 96 与 Per-Monitor-V2 144 环境中,EasyX 绘制坐标可直接作为 HWND 客户区坐标;不得额外乘 DPI 比例。 27. 在已验证的 DPI-unaware 96 与 Per-Monitor-V2 144 环境中,EasyX 绘制坐标可直接作为 HWND 客户区坐标;不得额外乘 DPI 比例。
28. IME 位置必须在焦点/caret/anchor 变化时立即预设,并在组合生命周期消息到达时再次校准;不得假定候选 UI 出现前一定会收到 `WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION`。 28. IME 位置必须在焦点/caret/anchor 变化时立即预设,并在组合生命周期消息到达时再次校准;不得假定候选 UI 出现前一定会收到 `WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION`。
29. 原始 `VK_PROCESSKEY` 一律视为 IME 所有:WndProc 层采集并转发,阶段 1 不向 Button、Dialog、快捷键和焦点导航路由;不得依赖 `imeComposingAtCapture` 单独判断搜狗候选态。
30. 0B 没有建立跨 IME 生产消费白名单;阶段 1 从默认转发基线开始,后续任何窄消费规则都必须有独立逐键证据。
31. 阶段 1 只建输入地基、不切换业务轨道:旧 `EX_KEY` 继续进入 `Control::handleEvent()`,新队列只观测排空;阶段 3 一次性切换,`EX_MOUSE` 不受影响。
32. 单键 IME 所有权只由采集时的原始 `VK_PROCESSKEY` 表达;`imeComposingAtCapture` 只记录标准生命周期,两者不得合并为一个持久“组合中”状态。
33. `mutex + deque` 在正常资源条件下不得丢输入;分配失败或队列异常必须 fail-stop,不能捕获后假装成功。
34. `WM_IME_ENDCOMPOSITION` 不得在转发旧窗口过程之前清空结果回声;END 处理中嵌套或紧随其后的匹配 `WM_IME_CHAR/WM_CHAR` 仍须抑制重复入队。
35. 无效文本序列、IMM 查询失败和残留半截解码状态必须以不含原文的计数显式诊断,不得写入替代文本或静默记为成功。
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 独立视觉探测工程已实现;FocusManager、生产 NativeInputQueue 和 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 位置双时机规则。 - 独立 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 的兼容入口。
- 阶段 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。
@@ -1100,19 +1267,26 @@ struct ProbeConsumeRule
- 风险点: - 风险点:
- EasyX 双线程、ANSI 窗口和当前微软拼音/搜狗回声顺序已确认,但其他 IME、窗口 A/W 属性和系统版本仍可能产生不同顺序。 - EasyX 双线程、ANSI 窗口和当前微软拼音/搜狗回声顺序已确认,但其他 IME、窗口 A/W 属性和系统版本仍可能产生不同顺序。
- 模态重入和控件删除若破坏单 drainer/句柄不变量,可能产生难复现的顺序或生命周期问题。 - 模态重入和控件删除若破坏单 drainer/句柄不变量,可能产生难复现的顺序或生命周期问题。
- 该文档是设计基线,不应被误读为已实现或已验证能力。 - 激活门控若只保存布尔而没有 generation,会在失活前积压或快速失活/恢复时产生跨激活周期误投递。
- Window 与 Dialog 两套 EX_KEY 循环若没有同一提交切换,会形成局部双投递或模态期旧路径残留。
- 自动验证与人工回归必须分开记账;阶段 3 的微软拼音、搜狗及 KEY1/KEY5 人工门槛已完成,不能用单元测试倒推其它未覆盖环境同样通过。
## 测试与验证 ## 测试与验证
- 测试范围: - 测试范围:
- 设计评审覆盖输入采集、同步消息、Unicode、IME、焦点、复合控件、按键归属、TextBox 和模态重入。 - 设计评审覆盖输入采集、同步消息、Unicode、IME、焦点、复合控件、按键归属、TextBox 和模态重入。
- 独立线程归属探测、0A 构建、固定消息 smoke、微软拼音/搜狗真实输入链路测试以及当前可用环境的 0C 基线均已完成;0B 消费实验、生产输入队列和焦点系统尚未开始。 - 独立线程归属探测、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 设计经过多轮评审,已作为后续探测和实现的基线。
@@ -1129,28 +1303,48 @@ struct ProbeConsumeRule
- 2026-10-05:微软拼音三个 anchor 自动 IMM 调用和人工位置观察通过;第二轮虽然脚本参数为 `Sogou`,操作者确认实际未切换输入法,因此没有把它误记为搜狗证据。 - 2026-10-05:微软拼音三个 anchor 自动 IMM 调用和人工位置观察通过;第二轮虽然脚本参数为 `Sogou`,操作者确认实际未切换输入法,因此没有把它误记为搜狗证据。
- 2026-10-05 23:51:搜狗首次真实运行按预期门槛失败,F1/F2 未发生 IMM 定位调用,F3 为 2/2;截图确认候选框先位于窗口外,之后沿用 F3 旧位置。 - 2026-10-05 23:51:搜狗首次真实运行按预期门槛失败,F1/F2 未发生 IMM 定位调用,F3 为 2/2;截图确认候选框先位于窗口外,之后沿用 F3 旧位置。
- 2026-10-06:v0.9 将 IME 位置刷新改为“焦点/caret/anchor 变化立即预设 + IME 生命周期再次校准”;探测事件新增触发来源与分项计数,并完成三平台构建及真实搜狗回归。 - 2026-10-06:v0.9 将 IME 位置刷新改为“焦点/caret/anchor 变化立即预设 + IME 生命周期再次校准”;探测事件新增触发来源与分项计数,并完成三平台构建及真实搜狗回归。
- 2026-10-06:搜狗修复后运行自动与人工状态均为 `pass`;三个 anchor 均按请求定位,F3 提交后回到 F1 的截图与初始 F1 裁剪字节一致。当前可用环境 0C-3 关闭,下一步进入 0B。 - 2026-10-06:搜狗修复后运行自动与人工状态均为 `pass`;三个 anchor 均按请求定位,F3 提交后回到 F1 的截图与初始 F1 裁剪字节一致。当前可用环境 0C-3 关闭,随后进入 0B。
- 2026-10-06:0B F24 `WM_KEYDOWN/UP` 自动 A/B 均通过;Forward 对应 `EX_KEY=present_once`,Consume 对应 `EX_KEY=absent`。错误 baseline 的请求 Consume 正确 fail-open 为 Forward。真实 IME 人工矩阵未完成,因此阶段 1 仍不启动。
- 2026-10-06:0B Composition 首轮人工运行无效:夹具未设置 IME 位置,Escape 规则错误匹配 `VK_ESCAPE` 而实际 KeyDown 为 `VK_PROCESSKEY + scanCode 1`,且出现 3 条锁竞争丢失。已实现固定 IMM32 锚点、原生三元组规则与双缓冲采集修正;微软拼音/搜狗 A/B 需重新执行。
- 2026-10-07:微软拼音 Forward/Consume 有效复验均通过;Forward 对应 EasyX 键出现一次,Consume 后 EasyX 键消失,位置链分别为 18/18 与 7/7,均零丢失、无孤立定位记录。
- 2026-10-07:搜狗候选态没有向目标 HWND 暴露 START/COMPOSITION/END 生命周期;最终 Consume 请求以 `ime_condition_not_met` 正确 fail-open 为 Forward,规则零命中、零消费、EasyX 键出现一次,结构 verdict 为 `MustForward`。
- 2026-10-07:最终 Debug x64 单元测试 15/15、UI fixture 1 轮、F24 KeyDown A/B 新运行通过;既有 KeyUp 与 baseline mismatch 证据经当前分析器回放保持有效。阶段 1 以默认转发且无消费白名单为起点。
- 2026-10-07:阶段 1 自动回归通过:Debug x64、x64 ASan、Release x64、Debug Win32 均为 27/27,KEY1–6 全部编译/链接成功;完整 UI fixture 曾一轮通过,最终源码的独立 `input-shadow` 再次通过,旧 EX_KEY 与新队列均完整观察 4000/4000 条事件,新队列最终深度 0、顺序严格、内存增长 0、无 fault/规范化异常;PowerShell 5.1/7 AST、工程 XML 和 `git diff --check` 通过。最终完整 UI 的两次追加复跑分别在 `geometry/ownership` 鼠标点击检查点超时,均未进入输入场景,按既有桌面焦点抖动保留失败证据,不冒充最终全套连续通过。真实 IME 与 KEY1–6 交互仍待人工确认。
- 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 的最终消费白名单必须等待阶段 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。
## 落地信息 ## 落地信息
- 关联功能变更 ID:无 - 关联功能变更 ID:无
- 关联 BUG / Fix:无 - 关联 BUG / Fix:`BUG-20261006-0001` / `Fix-BUG-20261006-0001`;`BUG-20261006-0002` / `Fix-BUG-20261006-0002`
- Commit:`c218c25`(阶段 0A 诊断实现与线程归属复现);`c0eca1d`(探测暴露的原生窗口关闭修复);`43cf4ac`(阶段 0C 视觉与 IME 坐标探测) - Commit:`c218c25`(阶段 0A 诊断实现与线程归属复现);`c0eca1d`(探测暴露的原生窗口关闭修复);`43cf4ac`(阶段 0C 视觉与 IME 坐标探测)
- PR:无 - PR:无
- 发布版本:未发布 - 发布版本:未发布
- 相关文档: - 相关文档:
- [`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)
- [WM_IME_SETCONTEXT](https://learn.microsoft.com/en-us/windows/win32/intl/wm-ime-setcontext) - [WM_IME_SETCONTEXT](https://learn.microsoft.com/en-us/windows/win32/intl/wm-ime-setcontext)
- [WM_IME_COMPOSITION](https://learn.microsoft.com/en-us/windows/win32/intl/wm-ime-composition) - [WM_IME_COMPOSITION](https://learn.microsoft.com/en-us/windows/win32/intl/wm-ime-composition)
- [ImmGetCompositionStringW](https://learn.microsoft.com/en-us/windows/win32/api/imm/nf-imm-immgetcompositionstringw) - [ImmGetCompositionStringW](https://learn.microsoft.com/en-us/windows/win32/api/imm/nf-imm-immgetcompositionstringw)
- [ImmGetVirtualKey](https://learn.microsoft.com/en-us/windows/win32/api/immdev/nf-immdev-immgetvirtualkey)
- [ImmNotifyIME](https://learn.microsoft.com/en-us/windows/win32/api/immdev/nf-immdev-immnotifyime) - [ImmNotifyIME](https://learn.microsoft.com/en-us/windows/win32/api/immdev/nf-immdev-immnotifyime)
- [COMPOSITIONFORM](https://learn.microsoft.com/en-us/windows/win32/api/imm/ns-imm-compositionform) - [COMPOSITIONFORM](https://learn.microsoft.com/en-us/windows/win32/api/imm/ns-imm-compositionform)
- [CANDIDATEFORM](https://learn.microsoft.com/en-us/windows/win32/api/imm/ns-imm-candidateform) - [CANDIDATEFORM](https://learn.microsoft.com/en-us/windows/win32/api/imm/ns-imm-candidateform)
@@ -0,0 +1,628 @@
# 阶段 0B 原生键盘消息隔离消费实验规格
## 基本信息
- 模块 ID:Module-20261001-0004
- 模块名称:阶段 0B 原生键盘消息隔离消费实验规格
- 状态:0B 阶段 1 前置基线已关闭;微软拼音 Forward/Consume 有效,搜狗 Consume 请求按 `ime_condition_not_met` 运行时 fail-open 并形成 `MustForward` 结论;未建立生产消费白名单
- 类型:新增模块 / 诊断基础设施
- 所属系统 / 子系统:StellarX Core / Window / InputProbe / EasyX Event Loop
- 版本 / 分支:`master`,0C 收口基线 `e514a7d`
- 环境:Windows + Visual Studio 2022 + MSVC v143 + EasyX 25.9.10
- 负责人:Codex 协作设计
- 阶段定位:阶段 0 的消费行为隔离实验;执行顺序为 0A → 0C → 0B → 阶段 1。
## 背景与目标
### 背景
阶段 0A 已确认:
- EasyX HWND/WndProc 与宿主 `peekmessage` 循环分属两个线程。
- 固定键在 WndProc 与 EasyX `EX_KEY` 两个观察面均可出现。
- 当前 EasyX 窗口为 ANSI,活动代码页为 CP936。
- 0A 只观察并转发,没有验证 WndProc `return 0` 后 EasyX 事件面是否仍然产生相应 `EX_KEY`。
阶段 0C 已确认:
- 宽字符 GDI 绘制链可用。
- 当前 DPI-unaware 96 与 Per-Monitor-V2 144 坐标基线可用。
- 微软拼音与搜狗候选框定位链通过当前环境人工验收。
- 0B 可以引用可哈希的绘制/坐标基线判断环境是否可比。
阶段 1 的 InputCollector 必须决定每类消息采用 `capture-and-forward` 还是 `capture-and-consume`。如果跳过 0B,这个决定只能依赖推测;FocusManager 又依赖阶段 1 的队列和事件模型,因此 0B 是阶段 1 的硬前置,不与阶段 1 或 FocusManager 并行开发。
### 目标
0B 只回答一个问题:
> 对指定原生键盘消息,在现有 WndProc 层观察后不调用旧窗口过程、直接返回 0,是否安全;该动作是否阻断 EasyX 宿主侧对应的 `EX_KEY`,以及是否破坏 EasyX/IME 的既有行为。
具体目标:
1. 对照 `ObserveAndForward` 与 `ObserveAndConsume` 两种且仅两种 WndProc 动作。
2. 单次只改变消息类型、虚拟键、IME 状态和动作中的一个变量。
3. 单独测量消费 `WM_KEYDOWN` 后真实宿主 `peekmessage(EX_KEY)` 是否仍返回对应事件。
4. 分开记录 KeyDown/KeyUp,禁止把成对输入合并成一个模糊结论。
5. 在微软拼音与搜狗的 Idle/Composition 场景中复验候选、提交、取消和定位行为。
6. 输出阶段 1 可引用的消息级结论及证据,不直接实现生产 InputCollector 或下游路由抑制。
### 非目标
- 不实现 InputCollector、NativeInputQueue、FocusManager、KeyboardRouter 或 TextBox。
- 不决定 Button/Dialog 是否接收已转发消息。
- 不把“转发给旧窗口过程但不再路由给控件”作为第三种 WndProc 动作。
- 不在 0B 中新增 `TranslateMessage`、`DispatchMessage` 或额外 `peekmessage`。
- 不修改 EasyX、输入法、系统键盘布局或用户输入配置。
- 不把自动合成输入等同于真实键盘或真实 IME 验收。
## 分层边界
### WndProc 动作:严格两值
```cpp
enum class ProbeAction
{
ObserveAndForward,
ObserveAndConsume
};
```
- `ObserveAndForward`:完成观察后调用旧窗口过程,原样返回旧过程的 `LRESULT`。
- `ObserveAndConsume`:完成观察后不调用旧窗口过程,直接返回当前实验规定的消费值;首期仅允许异步键盘消息,消费值固定为 0。
- 不允许 `ForwardButSuppressRouting`、`ObserveOnly` 等第三种动作进入枚举。
“消息必须转发给 IME,但阶段 1 不应把它路由给 Button/Dialog”属于阶段 1 的队列和路由决策,不属于 0B 的 WndProc 动作。
### 实验结论:三值
```cpp
enum class ExperimentVerdict
{
SafeToConsume,
MustForward,
Inconclusive
};
```
- `SafeToConsume`:同环境对照有效,消费动作确实命中,自动与所需人工门槛均通过,且没有破坏 EasyX/IME 行为。
- `MustForward`:消费导致输入丢失、IME/候选异常、系统行为异常,或无法达到阶段 1 所需语义。
- `Inconclusive`:环境、规则、时序、证据或输入法状态存在任何不确定。
动作枚举和结论枚举不可混用。`Inconclusive` 不是第三种动作;运行时仍必须 fail-open 到 `ObserveAndForward`。
## 不变量
1. 生产默认路径永远没有消费规则。
2. 0B 必须同时满足编译期开关、0A 运行开关和独立 0B 运行开关才可装载规则。
3. 每个进程只允许一条规则;每轮实验只允许一个目标消息/虚拟键/IME 条件组合。
4. 规则未命中时调用旧窗口过程并原样返回。
5. 规则配置、环境、IME 状态或日志完整性存在不确定时调用旧窗口过程并原样返回。
6. 同步窗口管理消息禁止进入消费规则表。
7. WndProc 线程不写文件、不等待宿主线程、不调用新的消息泵 API。
8. 0B 复用 0A 的跨线程序列、缓冲、flush 和隐私边界,不另建不可比较的日志格式。
9. `sequence` 只描述探测器观察顺序,不伪装成 Windows 队列序号。
10. 任何 `records_lost` 都使相关消费结论降级为 `Inconclusive`。
## 规则模型
```cpp
struct ProbeConsumeRule
{
std::string experimentId;
std::string ruleId;
UINT message;
std::optional<UINT> virtualKey;
std::optional<std::uint8_t> scanCode;
ImeStateCondition imeCondition; // Any / IdleOnly / ComposingOnly
ProbeAction action; // ObserveAndForward / ObserveAndConsume
std::uint32_t maxMatches;
std::string baselineId;
InputSource inputSource; // SendInput / Manual
};
```
首期规则只接受:
```text
WM_KEYDOWN
WM_KEYUP
WM_SYSKEYDOWN
WM_SYSKEYUP
```
原因是首个核心问题是 WndProc 物理键消息与 EasyX `EX_KEY` 的关系。`WM_CHAR/WM_SYSCHAR/WM_IME_CHAR` 涉及 ANSI/DBCS、隐私和 IME 回声状态机,在首期键消息结论稳定后再按同一 schema 扩展,不与物理键首轮混测。
下列消息永远不得加入 0B 消费规则:
```text
WM_GETDLGCODE
WM_IME_SETCONTEXT
WM_UNICHAR + UNICODE_NOCHAR
WM_SETCURSOR
WM_MOUSEACTIVATE
WM_INPUTLANGCHANGEREQUEST
WM_INPUTLANGCHANGE
WM_CLOSE / WM_DESTROY / WM_NCDESTROY
```
### 规则配置
诊断构建通过以下环境字段装载唯一规则:
```text
STELLARX_INPUT_PROBE=1
STELLARX_INPUT_PROBE_0B=1
STELLARX_INPUT_PROBE_0B_EXPERIMENT_ID=<安全标识>
STELLARX_INPUT_PROBE_0B_RULE_ID=<安全标识>
STELLARX_INPUT_PROBE_0B_ACTION=ObserveAndForward|ObserveAndConsume
STELLARX_INPUT_PROBE_0B_MESSAGE=WM_KEYDOWN|WM_KEYUP|WM_SYSKEYDOWN|WM_SYSKEYUP
STELLARX_INPUT_PROBE_0B_VIRTUAL_KEY=<十进制或 0x 十六进制>
STELLARX_INPUT_PROBE_0B_SCAN_CODE=<0..255;256 表示任意>
STELLARX_INPUT_PROBE_0B_IME_CONDITION=Any|IdleOnly|ComposingOnly
STELLARX_INPUT_PROBE_0B_MAX_MATCHES=<正整数,小上限>
STELLARX_INPUT_PROBE_0B_INPUT_SOURCE=SendInput|Manual
STELLARX_INPUT_PROBE_0B_BASELINE_ID=<0C combinedBaselineId>
STELLARX_INPUT_PROBE_0B_EXPECT_WINDOW_UNICODE=0|1
STELLARX_INPUT_PROBE_0B_EXPECT_ANSI_CODE_PAGE=<正整数>
STELLARX_INPUT_PROBE_0B_EXPECT_DPI_FOR_WINDOW=<正整数>
STELLARX_INPUT_PROBE_0B_EXPECT_DPI_AWARENESS=<枚举整数>
```
Composition 人工实验还使用固定、非文本的客户区锚点配置:
```text
STELLARX_INPUT_PROBE_0B_IME_CARET_X/Y
STELLARX_INPUT_PROBE_0B_IME_LINE_LEFT/TOP/RIGHT/BOTTOM
```
当前夹具固定为 `720×420`,caret point 为 `(225,282)`,排除矩形为
`(200,255)-(680,284)`。探测器在焦点/IME context 建立以及组合生命周期中重复调用
`ImmSetCompositionWindow(CFS_POINT)` 与
`ImmSetCandidateWindow(CFS_EXCLUDE)`;两个 setter 至少一次同时成功,才允许把
Composition 消费实验视为位置基线可用。
`experimentId`、`ruleId` 只允许 ASCII 字母、数字、点、下划线和连字符;不得把用户输入或候选文字写入标识字段。
## 0C 基线引用契约
0B runner 必须读取 0C 的 `baseline-set-v1.json`,并在启动夹具前验证:
1. baseline sidecar SHA-256 与文件一致。
2. `drawingBaselineManifest` 和 `coordinateBaselineManifest` 存在。
3. 两份 manifest 的 SHA-256 与 baseline 内记录一致。
4. 用 `drawingSha256 + ':' + coordinateSha256` 重新计算的组合值等于 `combinedBaselineId`。
5. `automatedStatus=pass`。
6. DPI awareness、DPI、窗口 A/W 属性与本轮期望一致。
当前可用基线为:
```text
drawingBaselineSha256 BCD0E3DD5D90C5D6F892F9127D81FD124C1DC7D619260B236DBCB8E53CBEC9CA
coordinateBaselineSha256 35D15BF2DD54D9A57B1F8470C8415025F1BAE6CE98D4628F13B64AB1EC381000
combinedBaselineId sha256:5C544D9FEC9C1A2324A7AF3F593A1A34110100644BAF5071E777302387E3C6C5
dpiAwareness unaware
dpiForWindow 96
windowUnicode false
ansiCodePage 936(来自 0A 同环境证据)
```
0C 基线不自动证明实际 TSF 输入配置文件身份。Composition 人工实验还必须记录操作者确认的实际输入法名称和身份来源;脚本参数只能表示预期名称。
## fail-open 硬规则
出现以下任一情况时,规则不得消费,实际动作必须退回 `ObserveAndForward`:
1. 配置缺失、无法解析、超长或出现未知枚举值。
2. 规则消息不在首期允许列表。
3. 虚拟键越界、`maxMatches` 为 0 或超过上限。
4. 同一进程出现多条规则或同一事件存在模糊匹配。
5. baseline ID、DPI、DPI awareness、窗口 A/W 属性或代码页不匹配。
6. Composition 实验的实际输入法身份未经操作者确认。
7. IME 状态条件无法由当前证据可靠确认。
8. Composition 消费实验尚未成功设置固定组合窗和候选窗位置。
9. 已发生缓冲溢出、采集丢失或序列不完整。
10. 规则命中次数已经达到 `maxMatches`。
11. 目标 HWND、窗口实例或线程归属与会话 attach 记录不一致。
12. 任意同步窗口管理消息或未识别消息。
fail-open 必须在 WndProc 决策前完成;不能先消费、事后再把结果标记为 `Inconclusive`。
## 日志 schema
0B 是 0A schema 的可加性 v2,不新建平行日志:
- 被动 0A 会话仍可写 schema v1。
- 启用 0B 时会话写 schema v2。
- v2 保留 v1 全部公共字段和 record type,并新增可选实验字段。
新增 `experiment_config` 记录:
| 字段 | 说明 |
|---|---|
| `experimentId/ruleId` | 本轮实验与唯一规则标识 |
| `requestedAction` | 两值动作之一 |
| `targetMessage/targetVirtualKey/targetScanCode` | 唯一原生目标;scan code 可为 `null` 表示任意 |
| `imeCondition` | `any/idle_only/composing_only` |
| `maxMatches` | 最大命中次数 |
| `inputSource` | `SendInput/manual` |
| `baselineId` | 引用的 0C 组合基线 |
| `configurationValid` | 配置是否合法 |
| `baselineMatched` | 运行环境是否匹配 |
| `ruleEligible` | 是否允许进入匹配 |
| `failOpenReason` | 结构化原因;无问题为 `none` |
| `imePositionConfigured/imeCaretPoint/imeLineRect` | Composition 人工实验的固定 IMM32 客户区锚点 |
目标 `wndproc_enter` 增加:
```text
experimentId
ruleId
ruleMatched
matchOrdinal
requestedAction
actualAction
failOpenApplied
failOpenReason
```
对应 `wndproc_leave` 增加相同关联字段,并继续保留:
```text
wndProcCallId
forwarded
forwardResultHex
```
`easyx_key` 增加 `experimentId/ruleId` 会话标签,但不伪造与 WndProc 的直接 ID 关联。跨线程关联由受控实验协议、目标消息、虚拟键、方向、scan code、QPC 区间和序列窗口共同完成。
IMM32 定位结果使用独立 `ime_position` 记录:
| 字段 | 说明 |
|---|---|
| `parentSequence` | 触发定位请求且已经入队的 `wndproc_enter.sequence` |
| `wndProcCallId` | 触发定位的窗口过程调用 |
| `triggerMessage` | `WM_SETFOCUS/WM_IME_SETCONTEXT/WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION` |
| `imePositionContextAvailable` | 是否取得当前 HWND 的 HIMC |
| `compositionPositionSucceeded/candidatePositionSucceeded` | 两个 setter 的独立结果 |
| `imeCaretPoint/imeLineRect` | 本次使用的不可变客户区坐标快照 |
`ime_position` 必须在 IMM32 调用完成后再分配序号,禁止预留低序号后跨锁补写;setter 同步重入产生的消息应自然排在父 enter 与结果记录之间。分析器必须校验 `parentSequence` 存在且小于结果序号。
## WndProc 执行语义
```text
WndProc enter
→ 0A 解析并记录原始消息
→ 0B 校验规则、基线、IME 条件、丢失状态和命中上限
→ 实际动作 ObserveAndForward
→ CallWindowProc(oldWndProc)
→ leave(forwarded=true, 原样 result)
→ 实际动作 ObserveAndConsume
→ 不调用 oldWndProc
→ leave(forwarded=false, result=0)
→ return 0
```
只有成功写入目标 enter 记录且决策证据完整时才允许消费。采集器使用两个固定记录缓冲:安全点在锁内只交换 active buffer、清空新 active 的索引,文件序列化在锁外完成;WndProc 使用极短的阻塞内存临界区,不再因 `try-lock` 失败主动丢弃观察。缓冲覆盖等任何实际丢失仍必须写 `records_lost`,并使实验失效。
固定 IME 定位严格执行三阶段协议:锁内提交父 enter 并复制请求,锁外调用全部定位相关 IMM32 API,再次加锁校验 `enabled + attachedHwnd + hwndInstanceId` 后提交结果。禁止在状态锁内调用 `ImmGetContext`、两个 setter 或 `ImmReleaseContext`。定位 API 调用期间使用 WndProc 线程局部标志阻止重入消息递归发起定位;重入消息本身仍按正常路径观察和转发。
## EX_KEY 核心测量
### 必答问题
> 在 WndProc 对目标 `WM_KEYDOWN` 执行 `ObserveAndConsume` 后,宿主线程现有的 `peekmessage(&msg, EX_KEY, true)` 是否仍返回同一虚拟键的 KeyDown?
该问题必须作为独立结果字段,而不是隐藏在总通过状态中:
```text
easyxVisibility = present_once | present_multiple | absent | inconclusive
```
解释:
- `absent`:WndProc 消费阻断了 EasyX 键盘面;阶段 1可以评估以采集队列作为唯一业务输入源。
- `present_once/present_multiple`:`return 0` 不足以消除 EasyX 事件;阶段 1 必须显式排空或忽略 EasyX 键盘事件,不能依赖消费避免双触发。
- `inconclusive`:对照缺失、额外输入污染、关联不唯一或日志丢失。
### 受控关联协议
每轮自动实验:
1. 启动独立进程和唯一 `experimentId`。
2. 等待 HWND、探测 attach 和空闲窗口稳定。
3. 在启用目标输入前排空历史事件;探针不得为实验新增第二个 `peekmessage`。
4. 只用一次 `SendInput` 产生固定虚拟键的 Down/Up。
5. 规则只匹配其中一个方向,另一个方向作为普通转发事件。
6. 等待宿主安全点 flush 后关闭窗口。
7. 使用目标 message、vk、可选 scan code、方向和受控时间窗关联 WndProc 与 `easyx_key`;指定 scan code 时,WndProc 和 EasyX 两个观察面都必须匹配该值。
8. 对照组和消费组分别使用新进程,禁止在同一进程热切换 action。
自动注入标记为 `inputSource=SendInput`。`PostMessage` 只可用于结构烟测,不能作为物理键/IME 行为结论。
## 实验矩阵
### 0B-1:核心自动 A/B
第一门槛固定为 Idle 状态的 F24:
| 组 | 消息 | VK | 动作 | 目标 |
|---|---|---:|---|---|
| Control | `WM_KEYDOWN` | `VK_F24` | Forward | 证明对照能同时看到 WndProc 和 `EX_KEY` |
| Experiment | `WM_KEYDOWN` | `VK_F24` | Consume | 判断消费后对应 `EX_KEY` 是否仍出现 |
| Control | `WM_KEYUP` | `VK_F24` | Forward | 建立 KeyUp 对照 |
| Experiment | `WM_KEYUP` | `VK_F24` | Consume | 判断 KeyUp 是否独立受影响 |
最初候选 F8 在当前中文 IME 环境中不适合作为自动门槛:`SendInput` 的 F8 KeyDown 没有到达目标 WndProc,仅观察到 KeyUp。当前证据没有确认它在哪一层被截断,也不据此推断 EasyX 消费关系;因此固定键改为当前环境中可稳定通过 WndProc 与 `EX_KEY` 两个观察面的 F24。
#### 0B-1 当前自动结论(2026-10-06)
| 目标 | Forward `EX_KEY` | Consume `EX_KEY` | 自动状态 | 当前结论 |
|---|---|---|---|---|
| `WM_KEYDOWN + VK_F24 + IdleOnly` | `present_once` | `absent` | `pass` | WndProc `return 0` 会阻断对应 EasyX KeyDown |
| `WM_KEYUP + VK_F24 + IdleOnly` | `present_once` | `absent` | `pass` | WndProc `return 0` 会阻断对应 EasyX KeyUp |
| 错误 baseline + 请求 Consume | `present_once`(实际 Forward) | 不适用 | `pass` | `baseline_id_mismatch` 正确 fail-open,规则零命中、零消费 |
三轮有效证据均满足:`sequenceStrict=true`、`recordsLost=0`、目标观察唯一、`closeObserved=true`、`sessionEndObserved=true`。证据如下(`TestResults` 为本地忽略目录,不进入源码提交):
```text
KeyDown A/B
TestResults/InputProbe/0B/20261006-103302-713-f22cbc3c-4d54-4da6-a3da-9b32cd0aeb43/comparison-v1.json
SHA-256 55B2160CF84594D653B5BF4B71A8DA8E9E2FF9795EB88BC16BA0F61E94DCD440
KeyUp A/B
TestResults/InputProbe/0B/20261006-103644-778-f4086f15-fbd7-4328-ac4c-fda378ef4594/comparison-v1.json
SHA-256 7A1381E8E6B13B3808754863DCD88B38B07D8E79630614A8ACB75649DE780194
baseline mismatch fail-open
TestResults/InputProbe/0B/20261006-103714-598-d0f4b1f1-dc03-47da-a039-ca2cb7523f7b/comparison-v1.json
SHA-256 A69A3646ECBA1FCACE921B15DB6A82FCD27F4E9B1747767DA500310250D7D520
```
上述证据只回答 F24 的 WndProc/`EX_KEY` 关系,尚不足以把任何生产消息判为 `SafeToConsume`;普通键、导航键、快捷键和真实 IME 人工矩阵完成前,最终 verdict 仍为 `Inconclusive`。
随后再扩展到:
- 普通字母键。
- Tab、方向键、Enter、Escape、Space。
- Ctrl 组合键。
- `WM_SYSKEYDOWN/UP`,但不得用会改变系统状态或关闭窗口的组合。
### 0B-2:自动稳定性
每轮同时验证:
- 进程、HWND 和宿主循环正常存活。
- 鼠标、窗口关闭和 resize 路径未受规则影响。
- enter/leave 配对符合实际动作。
- `sequence` 严格递增。
- 无原始文字泄露。
- 无未记账丢失。
- 对照与消费运行引用相同 0C baseline ID。
- runner 必须通过 `GetWindowThreadProcessId` 跟踪真实 UiFixture 进程;`cmd /c` 仅是环境变量启动包装,不能作为退出或超时判断对象。
- EasyX 旧窗口过程可能在 `WM_CLOSE` 内直接终止进程。探测构建须在转发 `WM_CLOSE` 前完成最后一次耐久 detach,写入 `window_detach/session_end`。
- 宿主 flush 与 WndProc 关闭收口必须由独立文件写锁串行,统一采用“文件写锁 → 状态锁”的锁序,禁止跨线程快照乱序落盘。
- 安全点不得在状态锁内复制或写出整批记录;双固定缓冲的交换必须为常数时间,避免 WndProc 因宿主 flush 占锁而丢失观察。
- 不得在状态锁内调用可能同步重入窗口过程的 IMM32 setter;`ime_position` 父序号关联失败时实验必须判为 `inconclusive_ime_position_link`。
### 0B-3:真实 IME 人工 A/B
微软拼音和搜狗分别测试,Idle 与 Composition 分开;每次只启用一个规则。固定非敏感测试串沿用 `ceshi`。
至少覆盖:
- 组合开始前的普通字母键。
- 候选态方向键和数字选择键。
- Enter 提交。
- Escape 取消。
- Space 选择候选。
- Ctrl 快捷键。
- 候选框位置是否仍符合 0C 三 anchor 基线。
- 提交后切换 anchor 是否出现 stale position。
Composition 条件只在日志确实观察到 composing 状态时匹配;如果某 IME 已显示候选 UI 但没有向目标 HWND 提供足够生命周期证据,规则必须 fail-open,不能按 Idle 误消费。单次证据不足仍为 `Inconclusive`;如果受控人工运行已经同时证明候选 UI 存在、目标键到达且 Composition 条件不可可靠确认,则该消息/IME 组合形成 `MustForward`:生产路径必须转发给旧窗口过程,并在阶段 1 抑制其进入 Button、Dialog 和焦点导航等业务路由。
#### 0B-3 首轮夹具发现(2026-10-06)
首轮微软拼音与搜狗测试不能作为正式 A/B 证据,原因不是两个输入法都定位失败,而是夹具本身存在三项诊断缺陷:
1. `input-consume-probe` 未调用两个 IMM32 定位 setter,候选框落在桌面左下角;这与已通过的 0C 坐标链不一致。
2. 操作者按 Escape 时,组合态 WndProc 实际看到的是
`WM_KEYDOWN + VK_PROCESSKEY(229) + scanCode 1`;`WM_KEYUP` 才恢复为
`VK_ESCAPE(27)`。因此原规则 `WM_KEYDOWN + VK_ESCAPE` 不可能命中。
3. 运行出现 3 条 `wndproc_lock_contention`,`recordsLost=3`,按本规格必须判为
`inconclusive_capture_loss`。
微软文档说明,IME 处理后会把虚拟键改为 `VK_PROCESSKEY`;应用只有在自己的消息循环中、调用 `TranslateMessage` 之前才能用 `ImmGetVirtualKey` 恢复原 VK。StellarX 当前钩子位于 EasyX 的 WndProc,时机已经晚于该边界,因此 0B 不伪造“逻辑 Escape”,而是让规则匹配实际可观察的原生三元组;操作者指令仍写“按 Escape”。参考:<https://learn.microsoft.com/windows/win32/api/immdev/nf-immdev-immgetvirtualkey>。
无效运行保留为夹具缺陷证据:
```text
TestResults/InputProbe/0B/20261006-140720-218-96b7392e-548a-44cf-9343-5d1449bf73fa
manual-evidence/composition-candidate-position-wrong.png
SHA-256 61E4A04CEE329B13BB22C919DC233A07ABF3EF6C298E64FB7B6E70F6D60DE0A3
```
该截图只证明候选框位于夹具窗口之外;运行混入多次组合且存在记录丢失,不用于区分微软拼音与搜狗,也不用于判断消费是否安全。
#### 0B-3 首次修复复验发现(2026-10-06)
首次微软拼音 Forward 复验出现窗口未响应和无法输入。根因是定位实现持有不可重入的 SRW 状态锁时调用两个 IMM32 setter;setter 同步重入 WndProc 后再次获取同一把锁,形成线程自锁。失败运行保留于:
```text
TestResults/InputProbe/0B/20261006-231604-636-4bc775a1-a3f8-41b9-b466-1cce740bc195/forward
```
该日志耐久保存 268 条严格递增、零丢失记录,但没有关闭收口;卡死发生在下一次定位进入且早于新记录入队是结合调用链得出的推断,不把它表述为日志直接证明的栈。修复拆成锁内请求、锁外 IMM32、锁内结果,并增加递归定位保护;详见 `BUG-20261006-0003` / `Fix-BUG-20261006-0003`。
修复后的微软拼音 Forward 运行已经通过:
```text
TestResults/InputProbe/0B/20261006-234544-007-603391f8-3a14-4c90-ab41-4d891ceb8f46/forward
comparison SHA-256 7CBEA9089E69BE921829BE49FC74B3F75E6BDAB8BB6DD252F4D4F1D6338AA456C
```
- `experimentValidity=valid`、`sequenceStrict=true`、`recordsLost=0`。
- Escape 原生规则唯一命中,`ObserveAndForward` leave 完整,对应 EasyX 键可见一次。
- `imePositionAttemptCount=18`、`imePositionSuccessCount=18`、`imePositionOrphanCount=0`。
- 组合开始/结束各 2 次,GCS 结果 1 次,关闭和 `session_end` 完整。
- 人工截图 `manual-evidence/microsoft-pinyin-forward-composition-position-pass.png` 确认候选 UI 位于固定锚点附近;SHA-256 `1447E9F1AAD2C90A787AADC2594C78AE596336FC6D569EA1A22C69B2FCA5C3D5`。
该 Forward 运行关闭 `BUG-20261006-0003`;随后继续执行微软拼音 Consume 与搜狗运行,以形成消息消费 verdict。
微软拼音 Consume 第一次操作只完成了候选提交,没有按 Escape。日志中目标 scan code 1 计数为 0,runner 正确判为 `unexpected_target_count`;该运行只保留位置/响应证据,不计入 Consume 结论:
```text
TestResults/InputProbe/0B/20261006-235044-603-9c996ff5-f85b-4127-aac0-68d9f0b89908/consume
manual-evidence/microsoft-pinyin-consume-position-only-no-escape.png
SHA-256 B7B218BEDD343A8E2023A6FE18FC678E5488CB17B1C857D46A254EC06F9095FB
```
按“候选显示时只按一次 Escape”重跑后的微软拼音 Consume 有效:
```text
TestResults/InputProbe/0B/20261007-000152-550-4c83bcda-12e8-4f4e-b616-1d1fe1f56a27/consume
comparison SHA-256 BDB2E202EE7B050D74E6989208D7A63C2D74766292BC4410F5020213787CB698
```
- `experimentValidity=valid`、915 条记录严格递增、`recordsLost=0`。
- Escape 目标唯一命中,`consumedMatchCount=1`、`forwardedMatchCount=0`。
- 对应 EasyX 键为 `absent`,结构关系为 `easyx_suppressed_by_wndproc_consume`。
- `imePosition=7/7`、`imePositionOrphanCount=0`,关闭与 `session_end` 完整。
- `successfulResultCount=0`,与本轮执行取消而非提交一致;操作者完成候选消失检查后关闭窗口,未报告位置或响应异常。
微软拼音 A/B 当前对照为:Forward 时目标键进入 EasyX 一次,Consume 时目标键在 EasyX 面消失;两轮 IME 定位与窗口响应均正常。该结论仍限定于微软拼音和当前 ANSI/CP936、96 DPI 基线。
#### 0B-3 搜狗生命周期缺失与 MustForward 结论(2026-10-07)
搜狗 Forward 受控运行中,候选 UI 实际可见且位置正常,但目标 HWND 没有收到 `WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION/WM_IME_ENDCOMPOSITION`。操作者按 Escape 时仍观察到唯一的 `WM_KEYDOWN + VK_PROCESSKEY(229) + scanCode 1`,探测状态为 `idle`,因此 `ComposingOnly` 规则没有命中:
```text
TestResults/InputProbe/0B/20261007-000604-592-4ddb4314-769e-4c4a-b3e8-696bd0489956/forward
JSONL SHA-256 539BE9BC378EB86825A1040D2A39BF7A1FD3C7F7C07966B67BBC753EB7F83608
```
- 1181 条记录严格递增、`recordsLost=0`、定位 6/6、无孤立定位结果、关闭收口完整。
- `experimentValidity=unexpected_match_count` 准确表示“请求的 ComposingOnly 条件不可证明”,不表示候选 UI 或 Forward 行为异常。
- 对应 EasyX 键为 `present_once`;该轮不得改写为 `IdleOnly` 后强行消费。
最终轮显式请求 `ObserveAndConsume`,并把预期运行时退让原因固定为 `ime_condition_not_met`:
```text
TestResults/InputProbe/0B/20261007-033454-763-56a2630b-bf2a-4431-b501-38f6ff977dfe/consume
comparison SHA-256 0FD8E46493A6D84FD8C0BAD55D22CDB55B48F13BE94EB2831D39E14B7993FBEC
```
- `experimentValidity=runtime_fail_open`,目标观察唯一、规则命中 0 次、消费 0 次。
- enter/leave 的唯一原因均为 `ime_condition_not_met`,实际动作退回 `ObserveAndForward`,旧窗口过程被调用。
- `failOpenForwardedCount=1`,对应 EasyX 键为 `present_once`,结构关系为 `easyx_visible_after_fail_open_forward`。
- 582 条记录严格递增、`recordsLost=0`、定位 3/3、`imePositionOrphanCount=0`、关闭与 `session_end` 完整。
- 最终 verdict 为 `MustForward`。这里的“有效”是指 fail-open 契约得到验证,不是搜狗真实执行过 Consume。
阶段 1 因此采用保守且跨 IME 一致的基线:原生键默认采集并转发;`VK_PROCESSKEY` 和已确认组合态键不得路由到 Button、Dialog、快捷键或焦点导航。微软拼音的单点 Consume 成功只证明 WndProc 消费会阻断对应 EasyX 键面,不足以建立生产消费白名单。
### 0B-4:结论回写
每个消息/虚拟键/IME 状态组合必须输出:
```text
controlSessionId
consumeSessionId
baselineId
easyxVisibility
imeBehavior
windowBehavior
verdict
evidenceHashes
knownScope
```
只有完成所需自动和人工矩阵的组合才可判定 `SafeToConsume`;其余保持 `Inconclusive` 或因明确破坏判定 `MustForward`。
## 验收门槛
自动运行有效必须同时满足:
1. `experiment_config.ruleEligible=true`。
2. 正常 A/B 的目标规则恰好命中预期次数;预期运行时 fail-open 的目标观察次数必须符合 `maxMatches`、规则命中数必须为 0。
3. Forward 组对应 leave 为 `forwarded=true`。
4. 真正 Consume 组对应 leave 为 `forwarded=false` 且返回 0;预期 fail-open 组必须为 `actualAction=ObserveAndForward`、`forwarded=true`。
5. Forward 对照组至少出现一次对应 `easyx_key`。
6. Consume 或运行时 fail-open 组明确得到 `present_once/present_multiple/absent` 之一。
7. `sequence` 严格递增,enter/leave 可配对。
8. `records_lost=0`,无缓冲溢出或锁竞争丢失。
9. 原生关闭与 `session_end` 完整。
10. 两组 baseline ID 和关键环境字段一致。
11. 运行时 fail-open 的 enter/leave 原因必须唯一且一致,分析结果必须为 `runtime_fail_open`;原因不明或半边错配时仍为 `Inconclusive`。
人工 IME 运行有效还要求:
1. 操作者明确确认实际输入法身份。
2. 使用固定非敏感输入。
3. 记录候选、提交、取消是否正常。
4. 位置判断引用相同环境的 0C 基线。
5. 日志至少有一次 `ImmGetContext`、composition setter 与 candidate setter 同时成功,且 caret/line rect 等于固定夹具锚点。
6. 自动失败不能被人工通过覆盖。
## 隐私与安全
- 沿用 0A:文字只记来源、长度、结构和会话摘要,不记原文。
- 0B 配置不允许携带用户输入文本。
- 自动测试使用 F24 等非文字键作为第一门槛;当前环境不使用 KeyDown 未到达目标 WndProc 的 F8。
- 人工 IME 只使用约定的固定非敏感测试串。
- 截图只包含探测窗口和候选 UI,不包含其他应用。
- 无论探测错误、日志错误还是环境错误,默认行为都是转发。
## 实现与影响
- 已复用并实现:
- `src/StellarX/SxInputProbe.cpp`
- `src/StellarX/internal/SxInputProbeInternal.h`
- `src/StellarX/Window.cpp`
- `tests/UiFixture.cpp`
- `scripts/analyze-input-probe.ps1`
- 已新增:
- `scripts/run-input-consume-probe.ps1`:自动 A/B、单规则人工模式、0C baseline 校验、真实进程跟踪和 comparison 输出。
- `scripts/run-input-consume-probe-manual.ps1`:真实 IME 人工单规则入口和固定 `ceshi` checklist。
- 分析摘要中的实验有效性、`easyxVisibility`、关闭完整性和 fail-open 证据字段。
- 可选 scan code 精确规则、固定 IME caret/line rect、IMM32 定位调用及位置成功计数。
- 双固定记录缓冲;安全点常数时间交换、锁外文件写入,取消 WndProc `try-lock` 丢记录路径。
- IMM32 定位三阶段拆锁、线程内递归定位保护、独立 `ime_position` 结果记录与 `parentSequence` 校验。
- 不修改公共 API。
- 不新增生产焦点或键盘路由行为。
- 探测关闭时不产生文件、线程、定时器或消息返回差异。
## 测试与验证
- F24 `WM_KEYDOWN/UP` 的 Forward/Consume 自动 A/B 已完成,均直接报告消费后 `EX_KEY=absent`。
- 错误 baseline 的请求 Consume 负向实验已完成,实际动作正确 fail-open 为 Forward。
- 默认 Debug x64 构建与现有单元测试通过(15/15);探测开关构建已由三轮最终自动实验覆盖。
- 三份 PowerShell 脚本在 Windows PowerShell 5.1.22000.2538 与 PowerShell 7.6.5 下均为零解析错误;`git diff --check` 无空白错误,仅有仓库换行规范提示。
- 0B-3 重入死锁修复后的微软拼音 Forward/Consume 已通过;搜狗候选态未暴露可证明的 Composition 生命周期,最终 Consume 请求按 `ime_condition_not_met` 正确 fail-open 为 Forward,形成 `MustForward` 结论。
- 2026-10-07 最终回归:Debug x64 单元测试 15/15、UI fixture 1 轮通过;F24 KeyDown A/B 新运行通过(comparison SHA-256 `1C1E4EDA1251A460AC250AE57F4F8A5FC8AFE4A9C0EDA87C731BA3C6301FB6FF`),既有 KeyUp 与 baseline mismatch 证据使用当前分析器回放仍分别为 `valid` 与 `ineligible_fail_open`。
- 阶段 1 可以按“默认采集并转发、EasyX `EX_KEY` 只排空、`VK_PROCESSKEY` 与已确认组合态键不进入业务路由”的基线开始;任何生产消费白名单仍需新的逐键证据。
- 构建至少覆盖 Debug x64 探测开关开启、默认关闭构建和现有 15 个单元测试。
- PowerShell 脚本必须同时通过 Windows PowerShell 5.1 与可用的 PowerShell 7 解析检查。
## 已知限制
- 自动 `SendInput` 不等同于物理键盘。
- 当前中文 IME 环境下,自动 F8 KeyDown 未到达目标 WndProc;截断层级尚未确认,因此 F8 运行只作为“门槛键选择失败”记录,不作为消费行为证据。
- 相同中文 HKL 下的 TSF 输入配置文件不能只靠 HKL 自动区分。
- 当前 0C 基线没有覆盖 100%、125% 和跨屏;不同环境的 0B IME 位置结论必须为 `Inconclusive`。
- 首期不消费文字和 IME 消息,只先回答物理键与 EasyX `EX_KEY` 的核心关系。
- WndProc 层只能可靠记录 `VK_PROCESSKEY` 与 scan code,不能在 EasyX 已调用 `TranslateMessage` 后再依赖 `ImmGetVirtualKey` 恢复原始逻辑键。
- 搜狗候选 UI 可见时可能不向目标 HWND 发送标准 Composition 生命周期;不得把 `imeState=idle` 推断为“当前键不属于 IME”。
## 落地信息
- 关联功能变更 ID:无
- 关联 BUG / Fix:`BUG-20261006-0002` / `Fix-BUG-20261006-0002`;`BUG-20261006-0003` / `Fix-BUG-20261006-0003`
- Commit:待用户确认后提交
- PR:无
- 发布版本:未发布
- 相关文档:
- [`Module-20261001-0001-通用输入焦点与键盘路由系统设计.md`](./Module-20261001-0001-通用输入焦点与键盘路由系统设计.md)
- [`Module-20261001-0002-阶段0A输入链路被动探测规格.md`](./Module-20261001-0002-阶段0A输入链路被动探测规格.md)
- [`Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md`](./Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md)
@@ -0,0 +1,116 @@
# 新增功能模块 / 模块重构
> 本记录描述通用输入系统阶段 1 的实际实现边界与验证证据。它不代表焦点、键盘路由或 TextBox 自绘已经完成。
## 基本信息
- 模块 ID:Module-20261007-0005
- 模块名称:阶段 1 输入采集、规范化与跨线程观察队列
- 状态:实现完成,自动回归与真实 IME / KEY1–6 人工兼容性回归通过
- 类型:新增模块 / 架构演进
- 所属系统 / 子系统:StellarX Core / Window / Native Input
- 版本 / 分支:`master`,未提交工作区
- 环境:Windows + Visual Studio 2022 + MSVC v143 + EasyX + MBCS 工程配置
- 负责人:Codex 协作实现
## 背景与目标
- 阶段 0 已确认 EasyX HWND/WndProc 和宿主 `peekmessage` 分属两个线程,当前窗口是 ANSI、中文布局为 CP936。
- 阶段 1 只建立“WndProc 采集与规范化 → 跨线程队列 → 宿主安全点观察排空”的地基,不实现 FocusManager、KeyboardRouter、消费白名单或控件键盘行为。
- 为保持现有行为,本阶段继续把 EasyX `EX_KEY` 交给旧 `Control::handleEvent()`;真正换轨留到阶段 3。
## 实现边界
### 已实现
- 公共事件模型:`KeyEvent`、`TextInputEvent`、`CompositionEvent`、`WindowActivationEvent`、`NativeInputEvent`。
- 每 Window `NativeInputContext` 和正确性优先的 `mutex + deque` 跨线程队列。
- 全局 atomic sequence、队列 pushed/popped/depth/maxDepth 统计和宿主观察统计。
- Key lParam 的 repeat、scan code、extended、alt context、previous state、system key 解析,以及左右 Ctrl/Shift/Alt 快照。
- 原始 `VK_PROCESSKEY` 单键所有权与 `imeComposingAtCapture` 生命周期快照的两轴模型。
- Unicode `WM_CHAR` 代理对、`WM_UNICHAR` UTF-32、ANSI/CP936 DBCS lead/trail 解码。
- `WM_IME_STARTCOMPOSITION/COMPOSITION/ENDCOMPOSITION` 状态机、缺失 START 时的惰性 `compositionId`。
- `GCS_RESULTSTR` 唯一正式提交,以及 `WM_IME_CHAR/WM_CHAR` 独立游标的邻接回声去重。
- `WM_SETFOCUS/WM_KILLFOCUS` 激活事件和重新获焦时的键盘布局快照。
- 主循环与模态循环在安全点观察排空;阶段 1 不调用控件路由。
- WndProc 分配失败/异常的无分配故障快照;宿主安全点 fail-stop,返回码为 `-2`。
- 非法文本序列、IMM 查询失败、残留半截解码状态的无原文累计诊断。
### 明确未实现
- FocusManager、FocusScope、Tab 顺序和焦点回退。
- KeyboardRouter、祖先冒泡、Dialog 默认动作和 `VK_PROCESSKEY` 下游抑制。
- 生产键盘消费白名单。
- Button、Table、TabControl、Dialog 的键盘接入。
- TextBox UTF-16 编辑内核、自绘、选区、剪贴板和 IME caret 定位。
- 将旧 `EX_KEY` 从 `Control::handleEvent()` 中移除。
## 关键契约
1. WndProc 线程只做状态快照、规范化和短临界区入队;不执行用户回调、控件 mutation 或文件 I/O。
2. 原始 `VK_PROCESSKEY` 只表示当前单键归 IME,不启动持久组合态,也不分配 `compositionId`。
3. 标准 IME 生命周期只由 START/COMPOSITION/END 维护;两轴均在采集时快照,延迟消费者不得重查全局输入状态。
4. `GCS_RESULTSTR` 只产生一次 `TextInputEvent`;紧邻且完整匹配的 IME/字符回声只观察,不重复入队。
5. END 转发旧窗口过程前不清回声,避免漏掉 END 处理中的嵌套或紧随回声。
6. 正常资源条件下队列不得丢输入;分配失败或队列异常必须显式 fail-stop。
7. 阶段 1 保留旧 EX_KEY 业务旁路;阶段 3 才把 EX_KEY 改为纯排空并启用新路由。EX_MOUSE 永久保留。
## 实现文件
- `include/StellarX/InputEvents.h`
- `src/StellarX/internal/SxNativeInputInternal.h`
- `src/StellarX/SxNativeInput.cpp`
- `include/StellarX/Window.h`
- `src/StellarX/Window.cpp`
- `src/StellarX/Dialog.cpp`
- `tests/StellarXTests.cpp`
- `tests/UiFixture.cpp`
- `scripts/run-ui-tests.ps1`
## 自动验证
### 逻辑测试
- Key lParam 和左右修饰键快照。
- `VK_PROCESSKEY` 不启动组合,且普通键在生命周期内不被改写为 IME 所有。
- IME 生命周期、Cancel/End 和 `compositionId`。
- CP936 lead/trail 合并。
- `WM_UNICHAR` 能力查询、UTF-32 代理对和非法 scalar 计数。
- GCS 结果回声双通道去重,以及 END 后紧随回声保持。
- CP936 不可表示结果只抑制实际替代字节回声,不误吞随后无物理键前导的程序化文本。
- 20,000 事件生产/消费跨线程严格顺序。
- 注入式分配失败 fail-stop。
- 宿主观察排空无积压。
### 真实 EasyX `input-shadow`
- 总计注入 4,000 条 F24 KeyDown/KeyUp,分为低于 EasyX 旧队列有效容量的持续小波次。
- 同时验证旧 `EX_KEY -> handleEvent()` 与新 NativeInputQueue;两面均必须完整看到 2,000 Down + 2,000 Up。
- 新队列必须 `pushed == popped`、depth 归零、sequence 严格、无 fault、无文本规范化异常。
- 两轮负载之间检查私有内存不出现线性增长,并验证窗口仍能响应鼠标点击。
- 脚本新增 `-OnlyScenario input-shadow`,默认不传参时仍执行完整 UI 套件。
## 当前证据
- Debug x64、x64 ASan、Release x64、Debug Win32 均为 27/27;KEY1–6 全部编译/链接成功。
- 完整 UI fixture 曾一轮通过;最终源码单独复跑 `input-shadow` 通过:旧面 4000/4000,新面 4000/4000,最大新队列深度 40,最终深度 0,内存增长 0,顺序严格且无故障。
- 最终完整 UI 的两次追加复跑分别在 `geometry`、`ownership` 的鼠标点击检查点超时,均未进入 `input-shadow`。该结果按既有桌面焦点/SendInput 抖动保留,不写成最终全套连续通过;阶段 1 核心输入场景已有最终源码独立证据。
- PowerShell 5.1 与 Codex 运行时 PowerShell 7.6.5 AST、`StellarX.Core.vcxproj` XML、`git diff --check` 均通过。
- 2026-10-07 微软拼音有效会话 `manual-20261007-192828-293-MicrosoftPinyin` 与搜狗有效会话 `manual-20261007-193100-289-Sogou` 均为 sequence 严格、零丢失、零原文泄漏、3 次正式结果与 `WM_IME_CHAR` 回声完整匹配,并观察到正常关闭和 `session_end`。
- 用户完成 KEY1–6 人工交互,当前快照未发现卡顿、重复提交、双触发或既有行为退化。人工用例中 KEY5/KEY6 的 `120 ms` Tooltip 覆盖已移除,恢复验证框架默认 `1000 ms`;KEY2 日志轮转阈值由 1 KiB 调整为 10 MiB。
- 上述用例调整后,正式 `NonUI` 再次确认四种单元测试配置均为 27/27,KEY1–6 全部编译链接成功。
- 输入探测夹具没有文本焦点叶子和 caret 锚点,候选框使用系统回退位置不属于阶段 1 失败;本记录不据此宣称 FocusManager、TextBox 或候选框定位已经集成。
## 风险与后续
- 当前微软拼音/搜狗 + ANSI EasyX + CP936 的结果/回声顺序有阶段 0 真实证据;其他 IME、窗口 A/W 属性和系统版本仍需保持可诊断回退。
- EasyX 旧 `EX_KEY` 队列在当前环境呈现约 63 条有效容量;压力测试必须使用持续小波次验证排空,不能把第三方瞬时溢出误判为新队列丢失。
- 阶段 3 是明确兼容性切换点:派生控件必须从 `handleEvent(EX_KEY)` 迁移到新的 routed key 接口;届时再补 `开发记录/MIGRATION.md` 条目。
- 阶段 1 的自动验证和用户人工兼容性回归已经收口;下一步可进入阶段 2 FocusManager 设计/实现,但仍不得提前混入阶段 3 KeyboardRouter 或阶段 5 TextBox 自绘。
## 关联记录
- [`Module-20261001-0001-通用输入焦点与键盘路由系统设计.md`](./Module-20261001-0001-通用输入焦点与键盘路由系统设计.md)
- [`Module-20261001-0002-阶段0A输入链路被动探测规格.md`](./Module-20261001-0002-阶段0A输入链路被动探测规格.md)
- [`Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md`](./Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md)
- [`Module-20261001-0004-阶段0B原生键盘消息隔离消费实验规格.md`](./Module-20261001-0004-阶段0B原生键盘消息隔离消费实验规格.md)
@@ -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)