Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69c26a35a2 | |||
| 255aba688f |
@@ -55,14 +55,14 @@
|
|||||||
<!-- Public and internal headers are kept in this project so all consumers use one implementation. -->
|
<!-- Public and internal headers are kept in this project so all consumers use one implementation. -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ClInclude Include="include\StellarX\Button.h" /><ClInclude Include="include\StellarX\Canvas.h" /><ClInclude Include="include\StellarX\Control.h" />
|
<ClInclude Include="include\StellarX\Button.h" /><ClInclude Include="include\StellarX\Canvas.h" /><ClInclude Include="include\StellarX\Control.h" />
|
||||||
<ClInclude Include="include\StellarX\CoreTypes.h" /><ClInclude Include="include\StellarX\Dialog.h" /><ClInclude Include="include\StellarX\InputEvents.h" /><ClInclude Include="include\StellarX\Label.h" />
|
<ClInclude Include="include\StellarX\CoreTypes.h" /><ClInclude Include="include\StellarX\Dialog.h" /><ClInclude Include="include\StellarX\FocusManager.h" /><ClInclude Include="include\StellarX\InputEvents.h" /><ClInclude Include="include\StellarX\Label.h" />
|
||||||
<ClInclude Include="include\StellarX\MessageBox.h" /><ClInclude Include="include\StellarX\StellarX.h" /><ClInclude Include="src\StellarX\internal\SxEventLoopInternal.h" /><ClInclude Include="src\StellarX\internal\SxInputProbeInternal.h" /><ClInclude Include="src\StellarX\internal\SxNativeInputInternal.h" /><ClInclude Include="include\StellarX\SxLog.h" /><ClInclude Include="src\StellarX\internal\SxTextInternal.h" />
|
<ClInclude Include="include\StellarX\MessageBox.h" /><ClInclude Include="include\StellarX\StellarX.h" /><ClInclude Include="src\StellarX\internal\SxEventLoopInternal.h" /><ClInclude Include="src\StellarX\internal\SxInputProbeInternal.h" /><ClInclude Include="src\StellarX\internal\SxNativeInputInternal.h" /><ClInclude Include="include\StellarX\SxLog.h" /><ClInclude Include="src\StellarX\internal\SxTextInternal.h" />
|
||||||
<ClInclude Include="include\StellarX\TabControl.h" /><ClInclude Include="include\StellarX\Table.h" /><ClInclude Include="include\StellarX\TextBox.h" /><ClInclude Include="include\StellarX\Window.h" />
|
<ClInclude Include="include\StellarX\TabControl.h" /><ClInclude Include="include\StellarX\Table.h" /><ClInclude Include="include\StellarX\TextBox.h" /><ClInclude Include="include\StellarX\Window.h" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<!-- Framework source files compiled into StellarX.Core.lib. -->
|
<!-- Framework source files compiled into StellarX.Core.lib. -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ClCompile Include="src\StellarX\Button.cpp" /><ClCompile Include="src\StellarX\Canvas.cpp" /><ClCompile Include="src\StellarX\Control.cpp" />
|
<ClCompile Include="src\StellarX\Button.cpp" /><ClCompile Include="src\StellarX\Canvas.cpp" /><ClCompile Include="src\StellarX\Control.cpp" />
|
||||||
<ClCompile Include="src\StellarX\Dialog.cpp" /><ClCompile Include="src\StellarX\Label.cpp" /><ClCompile Include="src\StellarX\MessageBox.cpp" />
|
<ClCompile Include="src\StellarX\Dialog.cpp" /><ClCompile Include="src\StellarX\FocusManager.cpp" /><ClCompile Include="src\StellarX\Label.cpp" /><ClCompile Include="src\StellarX\MessageBox.cpp" />
|
||||||
<ClCompile Include="src\StellarX\SxInputProbe.cpp" /><ClCompile Include="src\StellarX\SxLog.cpp" /><ClCompile Include="src\StellarX\SxNativeInput.cpp" /><ClCompile Include="src\StellarX\TabControl.cpp" /><ClCompile Include="src\StellarX\Table.cpp" />
|
<ClCompile Include="src\StellarX\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>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<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\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" />
|
||||||
|
|||||||
@@ -45,6 +45,9 @@
|
|||||||
<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\MessageBox.h">
|
<ClInclude Include="include\StellarX\MessageBox.h">
|
||||||
<Filter>头文件</Filter>
|
<Filter>头文件</Filter>
|
||||||
</ClInclude>
|
</ClInclude>
|
||||||
|
|||||||
@@ -191,6 +191,11 @@ 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;
|
||||||
|
}
|
||||||
private:
|
private:
|
||||||
// 初始化按钮内部状态、颜色和 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);
|
||||||
|
|||||||
@@ -118,6 +118,12 @@ 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;
|
||||||
// 进入 Canvas 子树 mutation transaction;会向父容器/Window 传播事务深度。
|
// 进入 Canvas 子树 mutation transaction;会向父容器/Window 传播事务深度。
|
||||||
void beginMutationScope() override;
|
void beginMutationScope() override;
|
||||||
// 离开 Canvas 子树 mutation transaction;最外层结束时提交子控件队列。
|
// 离开 Canvas 子树 mutation transaction;最外层结束时提交子控件队列。
|
||||||
|
|||||||
@@ -36,10 +36,17 @@
|
|||||||
|
|
||||||
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 +59,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; // 布局模式
|
||||||
@@ -228,6 +238,15 @@ public:
|
|||||||
std::string getId() const { return id; }
|
std::string getId() const { return id; }
|
||||||
// 返回稳定句柄而非对象地址;调用方不得据此推断控件当前仍在容器中。
|
// 返回稳定句柄而非对象地址;调用方不得据此推断控件当前仍在容器中。
|
||||||
StellarX::ControlHandle getHandle() const noexcept { return handle; }
|
StellarX::ControlHandle getHandle() const noexcept { return handle; }
|
||||||
|
// 申请逻辑焦点。回调重入时请求会进入 FocusManager 当前回合的单槽 pending。
|
||||||
|
StellarX::FocusChangeResult requestFocus(
|
||||||
|
StellarX::FocusReason reason = StellarX::FocusReason::Programmatic);
|
||||||
|
// 设置焦点入口策略;修改后若当前焦点不再有效,会立即进入焦点回退。
|
||||||
|
void setFocusPolicy(StellarX::FocusPolicy policy);
|
||||||
|
StellarX::FocusPolicy getFocusPolicy() const noexcept { return focusPolicy; }
|
||||||
|
void setTabIndex(std::int32_t value);
|
||||||
|
std::int32_t getTabIndex() const noexcept { return tabIndex; }
|
||||||
|
bool hasLogicalFocus() const noexcept { return logicalFocused; }
|
||||||
// 检查控件当前是否处于待重绘状态。
|
// 检查控件当前是否处于待重绘状态。
|
||||||
bool isDirty() { return dirty; }
|
bool isDirty() { return dirty; }
|
||||||
//获取控件最近一次事件处理是否引发了视觉变化
|
//获取控件最近一次事件处理是否引发了视觉变化
|
||||||
@@ -280,6 +299,19 @@ 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);
|
||||||
|
// 各控件动态提供 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 传播,使整棵树共享同一提交边界。
|
||||||
|
|||||||
@@ -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
|
||||||
* @功能描述: 用来定义控件填充图案的枚举类
|
* @功能描述: 用来定义控件填充图案的枚举类
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* FocusManager.h - per-Window logical focus manager.
|
||||||
|
******************************************************************************/
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreTypes.h"
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
class Window;
|
||||||
|
|
||||||
|
namespace StellarX
|
||||||
|
{
|
||||||
|
class FocusManager
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "CoreTypes.h"
|
#include "CoreTypes.h"
|
||||||
|
#include "FocusManager.h"
|
||||||
#include "InputEvents.h"
|
#include "InputEvents.h"
|
||||||
#include "SxLog.h"
|
#include "SxLog.h"
|
||||||
#include "Control.h"
|
#include "Control.h"
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ 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;
|
||||||
// 将回调期间加入的页签和页内控件在外层事务结束时一次性提交。
|
// 将回调期间加入的页签和页内控件在外层事务结束时一次性提交。
|
||||||
void flushPendingMutations() override;
|
void flushPendingMutations() override;
|
||||||
// 外层统一解算后,TabControl 需要同步刷新其内部页签栏和页面区。
|
// 外层统一解算后,TabControl 需要同步刷新其内部页签栏和页面区。
|
||||||
|
|||||||
@@ -186,4 +186,11 @@ 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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "Control.h"
|
#include "Control.h"
|
||||||
|
#include "FocusManager.h"
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -32,9 +33,15 @@ namespace StellarX::Internal
|
|||||||
NativeInputRuntimeSnapshot inspectWindowNativeInputForTesting(const ::Window& window) noexcept;
|
NativeInputRuntimeSnapshot inspectWindowNativeInputForTesting(const ::Window& window) noexcept;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace StellarX
|
||||||
|
{
|
||||||
|
class FocusManager;
|
||||||
|
}
|
||||||
|
|
||||||
class Window
|
class Window
|
||||||
{
|
{
|
||||||
friend class Dialog;
|
friend class Dialog;
|
||||||
|
friend class StellarX::FocusManager;
|
||||||
friend StellarX::Internal::NativeInputRuntimeSnapshot
|
friend StellarX::Internal::NativeInputRuntimeSnapshot
|
||||||
StellarX::Internal::inspectWindowNativeInputForTesting(const Window& window) noexcept;
|
StellarX::Internal::inspectWindowNativeInputForTesting(const Window& window) noexcept;
|
||||||
|
|
||||||
@@ -78,6 +85,7 @@ class Window
|
|||||||
// —— 控件/对话框 ——(容器内的普通控件与非模态对话框)
|
// —— 控件/对话框 ——(容器内的普通控件与非模态对话框)
|
||||||
std::vector<std::unique_ptr<Control>> controls; // 普通顶层控件;绘制顺序也决定层级顺序
|
std::vector<std::unique_ptr<Control>> controls; // 普通顶层控件;绘制顺序也决定层级顺序
|
||||||
std::vector<std::unique_ptr<Control>> dialogs; // 非模态对话框;始终位于普通控件之上
|
std::vector<std::unique_ptr<Control>> dialogs; // 非模态对话框;始终位于普通控件之上
|
||||||
|
std::unique_ptr<StellarX::FocusManager> focusManager; // Window 内逻辑焦点唯一所有者
|
||||||
// 顶层控件的结构变更与事件分发解耦,防止回调直接改动正在遍历的 controls。
|
// 顶层控件的结构变更与事件分发解耦,防止回调直接改动正在遍历的 controls。
|
||||||
// add/remove/clear 在事件、绘制和回调期间只入队;最外层 mutation scope 结束后再提交。
|
// add/remove/clear 在事件、绘制和回调期间只入队;最外层 mutation scope 结束后再提交。
|
||||||
enum class ControlMutationKind { Add, Remove, Clear };
|
enum class ControlMutationKind { Add, Remove, Clear };
|
||||||
@@ -141,6 +149,9 @@ public:
|
|||||||
const Control* findControl(StellarX::ControlHandle handle) const;
|
const Control* findControl(StellarX::ControlHandle handle) const;
|
||||||
// 返回当前已提交控件的句柄快照;调用方不得据此推断控件之后仍然存在。
|
// 返回当前已提交控件的句柄快照;调用方不得据此推断控件之后仍然存在。
|
||||||
std::vector<StellarX::ControlHandle> getControlHandles() const;
|
std::vector<StellarX::ControlHandle> getControlHandles() const;
|
||||||
|
// 获取当前 Window 的通用逻辑焦点管理器。
|
||||||
|
StellarX::FocusManager& getFocusManager();
|
||||||
|
const StellarX::FocusManager& getFocusManager() const;
|
||||||
// 添加非模态对话框;Window 接管生命周期并在事件安全点清理关闭对象。
|
// 添加非模态对话框;Window 接管生命周期并在事件安全点清理关闭对象。
|
||||||
void addDialog(std::unique_ptr<Control> dialogs);
|
void addDialog(std::unique_ptr<Control> dialogs);
|
||||||
//非模态对话框去重
|
//非模态对话框去重
|
||||||
@@ -184,6 +195,13 @@ 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);
|
||||||
// 提交 Window 顶层控件队列;只允许在最外层事务安全点调用。
|
// 提交 Window 顶层控件队列;只允许在最外层事务安全点调用。
|
||||||
void flushPendingControlMutations();
|
void flushPendingControlMutations();
|
||||||
// 自顶向下提交 Window、Canvas、TabControl 等容器中累积的 pending mutation。
|
// 自顶向下提交 Window、Canvas、TabControl 等容器中累积的 pending mutation。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 外部 UI 驱动:启动确定性 fixture,注入真实鼠标输入,并保存像素与状态诊断。
|
# 外部 UI 驱动:启动确定性 fixture,注入真实鼠标输入,并保存像素与状态诊断。
|
||||||
param(
|
param(
|
||||||
[int]$Repeat = 1,
|
[int]$Repeat = 1,
|
||||||
[ValidateSet('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'input-shadow')]
|
[ValidateSet('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'focus', 'input-shadow')]
|
||||||
[string[]]$OnlyScenario = @()
|
[string[]]$OnlyScenario = @()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -243,6 +243,27 @@ 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 Wait-ClientRgb([IntPtr]$h, [int]$x, [int]$y, [int]$r, [int]$g, [int]$b,
|
function Wait-ClientRgb([IntPtr]$h, [int]$x, [int]$y, [int]$r, [int]$g, [int]$b,
|
||||||
[string]$capturePath, [int]$timeoutMs = 15000) {
|
[string]$capturePath, [int]$timeoutMs = 15000) {
|
||||||
# 某些绘制在消息批次尾才完成,因此对目标像素进行有界轮询而不是固定猜测延时。
|
# 某些绘制在消息批次尾才完成,因此对目标像素进行有界轮询而不是固定猜测延时。
|
||||||
@@ -262,7 +283,7 @@ function Wait-ClientRgb([IntPtr]$h, [int]$x, [int]$y, [int]$r, [int]$g, [int]$b,
|
|||||||
$savedCursor = New-Object StellarXUiNative+POINT
|
$savedCursor = New-Object StellarXUiNative+POINT
|
||||||
# UI 测试会抢占前台并移动鼠标,外层 finally 必须恢复用户原来的光标位置。
|
# UI 测试会抢占前台并移动鼠标,外层 finally 必须恢复用户原来的光标位置。
|
||||||
[void][StellarXUiNative]::GetCursorPos([ref]$savedCursor)
|
[void][StellarXUiNative]::GetCursorPos([ref]$savedCursor)
|
||||||
$allScenarios = @('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'input-shadow')
|
$allScenarios = @('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'focus', 'input-shadow')
|
||||||
$scenarios = if ($OnlyScenario.Count -gt 0) { @($OnlyScenario) } else { $allScenarios }
|
$scenarios = if ($OnlyScenario.Count -gt 0) { @($OnlyScenario) } else { $allScenarios }
|
||||||
$sessionRoot = Join-Path $resultRoot ((Get-Date -Format 'yyyyMMdd-HHmmss-fff') + "-$PID")
|
$sessionRoot = Join-Path $resultRoot ((Get-Date -Format 'yyyyMMdd-HHmmss-fff') + "-$PID")
|
||||||
|
|
||||||
@@ -288,6 +309,28 @@ 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' {
|
||||||
|
$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'
|
||||||
|
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() }
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -285,6 +287,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 +348,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;
|
||||||
@@ -566,6 +576,7 @@ void Button::setbuttonMode(StellarX::ButtonMode mode)
|
|||||||
//取值范围参考 buttMode的枚举注释
|
//取值范围参考 buttMode的枚举注释
|
||||||
this->mode = mode;
|
this->mode = mode;
|
||||||
dirty = true; // 标记需要重绘
|
dirty = true; // 标记需要重绘
|
||||||
|
notifyFocusSubtreeStateChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
void Button::setROUND_RECTANGLEwidth(int width)
|
void Button::setROUND_RECTANGLEwidth(int width)
|
||||||
|
|||||||
@@ -451,9 +451,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 +464,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 +499,34 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
void Canvas::setShape(StellarX::ControlShape shape)
|
void Canvas::setShape(StellarX::ControlShape shape)
|
||||||
{
|
{
|
||||||
switch (shape)
|
switch (shape)
|
||||||
@@ -550,6 +588,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)
|
||||||
|
|||||||
@@ -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,89 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Control::notifyFocusSubtreeStateChanged()
|
||||||
|
{
|
||||||
|
if (auto* host = getHostWindow())
|
||||||
|
host->getFocusManager().notifySubtreeStateChanged(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Control::onWindowResize()
|
void Control::onWindowResize()
|
||||||
|
|||||||
@@ -299,6 +299,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();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,451 @@
|
|||||||
|
#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, ¤t) != 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -351,6 +351,43 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -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,49 @@ 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))
|
||||||
|
return true;
|
||||||
|
if (nextButton && nextButton->applyFocusState(target, focused, reason))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
RECT Table::getManagedRepaintCoverageRect() const
|
RECT Table::getManagedRepaintCoverageRect() const
|
||||||
{
|
{
|
||||||
// coverage 包含表体、页码和分页按钮的实际绘制范围,而不只包含表格本体。
|
// coverage 包含表体、页码和分页按钮的实际绘制范围,而不只包含表格本体。
|
||||||
@@ -749,6 +794,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 +811,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 +836,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 +857,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 +918,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 +931,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ void Window::flushPendingMutationsInTree()
|
|||||||
if (control) control->flushPendingMutations();
|
if (control) control->flushPendingMutations();
|
||||||
for (auto& dialog : dialogs)
|
for (auto& dialog : dialogs)
|
||||||
if (dialog) dialog->flushPendingMutations();
|
if (dialog) dialog->flushPendingMutations();
|
||||||
|
if (focusManager)
|
||||||
|
focusManager->onMutationCommitted();
|
||||||
}
|
}
|
||||||
|
|
||||||
void Window::markAllControlsDirty()
|
void Window::markAllControlsDirty()
|
||||||
@@ -647,6 +649,7 @@ Window::Window(int w, int h, int mode)
|
|||||||
localwidth = minClientW = pendingW = width = w;
|
localwidth = minClientW = pendingW = width = w;
|
||||||
localheight = minClientH = pendingH = height = h;
|
localheight = minClientH = pendingH = height = h;
|
||||||
windowMode = mode;
|
windowMode = mode;
|
||||||
|
focusManager = std::make_unique<StellarX::FocusManager>(*this);
|
||||||
}
|
}
|
||||||
|
|
||||||
Window::Window(int w, int h, int mode, COLORREF bk)
|
Window::Window(int w, int h, int mode, COLORREF bk)
|
||||||
@@ -655,6 +658,7 @@ Window::Window(int w, int h, int mode, COLORREF bk)
|
|||||||
localheight = minClientH = pendingH = height = h;
|
localheight = minClientH = pendingH = height = h;
|
||||||
windowMode = mode;
|
windowMode = mode;
|
||||||
wBkcolor = bk;
|
wBkcolor = bk;
|
||||||
|
focusManager = std::make_unique<StellarX::FocusManager>(*this);
|
||||||
}
|
}
|
||||||
|
|
||||||
Window::Window(int w, int h, int mode, COLORREF bk, std::string title)
|
Window::Window(int w, int h, int mode, COLORREF bk, std::string title)
|
||||||
@@ -664,6 +668,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
|
||||||
@@ -1382,6 +1387,8 @@ bool Window::removeControl(StellarX::ControlHandle handle)
|
|||||||
if (!exists)
|
if (!exists)
|
||||||
return false;
|
return false;
|
||||||
pendingControlMutations.push_back({ ControlMutationKind::Remove, handle, nullptr });
|
pendingControlMutations.push_back({ ControlMutationKind::Remove, handle, nullptr });
|
||||||
|
if (focusManager)
|
||||||
|
focusManager->notifySubtreePendingRemoval(handle);
|
||||||
if (!isMutationTransactionActive())
|
if (!isMutationTransactionActive())
|
||||||
flushPendingMutationsInTree();
|
flushPendingMutationsInTree();
|
||||||
return true;
|
return true;
|
||||||
@@ -1390,6 +1397,14 @@ bool Window::removeControl(StellarX::ControlHandle handle)
|
|||||||
void Window::clearControls()
|
void Window::clearControls()
|
||||||
{
|
{
|
||||||
pendingControlMutations.push_back({ ControlMutationKind::Clear, {}, nullptr });
|
pendingControlMutations.push_back({ ControlMutationKind::Clear, {}, nullptr });
|
||||||
|
if (focusManager)
|
||||||
|
{
|
||||||
|
std::vector<StellarX::ControlHandle> removedHandles;
|
||||||
|
removedHandles.reserve(controls.size());
|
||||||
|
for (const auto& control : controls)
|
||||||
|
if (control) removedHandles.push_back(control->getHandle());
|
||||||
|
focusManager->notifySubtreesPendingRemoval(removedHandles);
|
||||||
|
}
|
||||||
if (!isMutationTransactionActive())
|
if (!isMutationTransactionActive())
|
||||||
flushPendingMutationsInTree();
|
flushPendingMutationsInTree();
|
||||||
}
|
}
|
||||||
@@ -1417,6 +1432,45 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
void Window::addDialog(std::unique_ptr<Control> dlg)
|
void Window::addDialog(std::unique_ptr<Control> dlg)
|
||||||
{
|
{
|
||||||
// 新增非模态对话框:管理顺序决定事件优先级(顶层从后往前)
|
// 新增非模态对话框:管理顺序决定事件优先级(顶层从后往前)
|
||||||
|
|||||||
@@ -25,6 +25,38 @@ namespace
|
|||||||
bool model() const override { return false; }
|
bool model() const override { return false; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
class FocusTestControl final : public Control
|
||||||
|
{
|
||||||
|
bool focusEnabled = true;
|
||||||
|
std::function<void(bool, StellarX::FocusReason)> focusCallback;
|
||||||
|
public:
|
||||||
|
explicit FocusTestControl(int tabOrder = 0) : Control(0, 0, 10, 10)
|
||||||
|
{
|
||||||
|
id = "FocusTest";
|
||||||
|
focusPolicy = StellarX::FocusPolicy::Programmatic |
|
||||||
|
StellarX::FocusPolicy::Pointer | StellarX::FocusPolicy::Tab;
|
||||||
|
tabIndex = tabOrder;
|
||||||
|
}
|
||||||
|
void draw() override { dirty = false; }
|
||||||
|
bool handleEvent(const ExMessage&) override { return false; }
|
||||||
|
bool model() const override { return false; }
|
||||||
|
void setFocusEnabledForTest(bool value)
|
||||||
|
{
|
||||||
|
focusEnabled = value;
|
||||||
|
notifyFocusSubtreeStateChanged();
|
||||||
|
}
|
||||||
|
void setFocusCallback(std::function<void(bool, StellarX::FocusReason)> callback)
|
||||||
|
{
|
||||||
|
focusCallback = std::move(callback);
|
||||||
|
}
|
||||||
|
protected:
|
||||||
|
bool isFocusEnabled() const override { return focusEnabled; }
|
||||||
|
void onLogicalFocusChanged(bool focused, StellarX::FocusReason reason) override
|
||||||
|
{
|
||||||
|
if (focusCallback) focusCallback(focused, reason);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
std::filesystem::path MakeTempLogPath(const char* name)
|
std::filesystem::path MakeTempLogPath(const char* name)
|
||||||
{
|
{
|
||||||
// 使用进程号隔离并行测试产生的日志路径;测试结束后由用例主动清理。
|
// 使用进程号隔离并行测试产生的日志路径;测试结束后由用例主动清理。
|
||||||
@@ -169,6 +201,352 @@ namespace StellarXNativeTests
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
TEST_CLASS(FocusManagerTests)
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
TEST_METHOD(NestedControlResolvesByFocusTargetId)
|
||||||
|
{
|
||||||
|
Window window(200, 120, 1);
|
||||||
|
auto canvas = std::make_unique<Canvas>(0, 0, 100, 100);
|
||||||
|
auto child = std::make_unique<FocusTestControl>();
|
||||||
|
FocusTestControl* childRaw = child.get();
|
||||||
|
const auto childHandle = canvas->addControl(std::move(child));
|
||||||
|
window.addControl(std::move(canvas));
|
||||||
|
|
||||||
|
const auto result = window.getFocusManager().requestFocus({ childHandle, 0 });
|
||||||
|
Assert::AreEqual(static_cast<int>(StellarX::FocusError::None),
|
||||||
|
static_cast<int>(result.error));
|
||||||
|
Assert::IsTrue(childRaw->hasLogicalFocus());
|
||||||
|
Assert::AreEqual(childHandle.value,
|
||||||
|
window.getFocusManager().focusedTarget().owner.value);
|
||||||
|
|
||||||
|
const auto stops = window.getFocusManager().collectTabStops();
|
||||||
|
Assert::AreEqual<size_t>(1, stops.size());
|
||||||
|
Assert::AreEqual(childHandle.value, stops.front().id.owner.value);
|
||||||
|
Assert::AreEqual<std::uint32_t>(0, stops.front().id.subNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_METHOD(PointerDownMovesLogicalFocusWithoutKeyboardRouting)
|
||||||
|
{
|
||||||
|
Window window(240, 120, 1);
|
||||||
|
auto button = std::make_unique<Button>(10, 10, 80, 30, "button");
|
||||||
|
Button* buttonRaw = button.get();
|
||||||
|
window.addControl(std::move(button));
|
||||||
|
auto text = std::make_unique<TextBox>(110, 10, 100, 30, "text");
|
||||||
|
TextBox* textRaw = text.get();
|
||||||
|
window.addControl(std::move(text));
|
||||||
|
|
||||||
|
ExMessage down{}; down.message = WM_LBUTTONDOWN; down.x = 20; down.y = 20;
|
||||||
|
Assert::IsTrue(buttonRaw->handleEvent(down));
|
||||||
|
Assert::IsTrue(buttonRaw->hasLogicalFocus());
|
||||||
|
|
||||||
|
down.x = 120;
|
||||||
|
Assert::IsTrue(textRaw->handleEvent(down));
|
||||||
|
Assert::IsFalse(buttonRaw->hasLogicalFocus());
|
||||||
|
Assert::IsTrue(textRaw->hasLogicalFocus());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_METHOD(FocusLostCallbackCanDeleteNewTarget)
|
||||||
|
{
|
||||||
|
Window window(200, 120, 1);
|
||||||
|
auto oldControl = std::make_unique<FocusTestControl>();
|
||||||
|
FocusTestControl* oldRaw = oldControl.get();
|
||||||
|
const auto oldHandle = window.addControl(std::move(oldControl));
|
||||||
|
auto newControl = std::make_unique<FocusTestControl>();
|
||||||
|
const auto newHandle = window.addControl(std::move(newControl));
|
||||||
|
|
||||||
|
window.getFocusManager().requestFocus({ oldHandle, 0 });
|
||||||
|
oldRaw->setFocusCallback([&](bool focused, StellarX::FocusReason)
|
||||||
|
{
|
||||||
|
if (!focused) Assert::IsTrue(window.removeControl(newHandle));
|
||||||
|
});
|
||||||
|
const auto result = window.getFocusManager().requestFocus({ newHandle, 0 });
|
||||||
|
|
||||||
|
Assert::AreEqual(static_cast<int>(StellarX::FocusError::InvalidTarget),
|
||||||
|
static_cast<int>(result.error));
|
||||||
|
Assert::IsFalse(static_cast<bool>(window.getFocusManager().focusedTarget()));
|
||||||
|
Assert::IsNull(window.findControl(newHandle));
|
||||||
|
Assert::IsFalse(oldRaw->hasLogicalFocus());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_METHOD(FocusLostCallbackForcesFreshEnabledSnapshot)
|
||||||
|
{
|
||||||
|
Window window(200, 120, 1);
|
||||||
|
auto oldControl = std::make_unique<FocusTestControl>();
|
||||||
|
FocusTestControl* oldRaw = oldControl.get();
|
||||||
|
const auto oldHandle = window.addControl(std::move(oldControl));
|
||||||
|
auto newControl = std::make_unique<FocusTestControl>();
|
||||||
|
FocusTestControl* newRaw = newControl.get();
|
||||||
|
const auto newHandle = window.addControl(std::move(newControl));
|
||||||
|
|
||||||
|
window.getFocusManager().requestFocus({ oldHandle, 0 });
|
||||||
|
oldRaw->setFocusCallback([&](bool focused, StellarX::FocusReason)
|
||||||
|
{
|
||||||
|
if (!focused) newRaw->setFocusEnabledForTest(false);
|
||||||
|
});
|
||||||
|
const auto result = window.getFocusManager().requestFocus({ newHandle, 0 });
|
||||||
|
|
||||||
|
Assert::AreEqual(static_cast<int>(StellarX::FocusError::Disabled),
|
||||||
|
static_cast<int>(result.error));
|
||||||
|
Assert::IsFalse(newRaw->hasLogicalFocus());
|
||||||
|
Assert::IsFalse(static_cast<bool>(window.getFocusManager().focusedTarget()));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_METHOD(FocusGainedCallbackCanDeleteItselfAndFallsBack)
|
||||||
|
{
|
||||||
|
Window window(200, 120, 1);
|
||||||
|
auto fallback = std::make_unique<FocusTestControl>();
|
||||||
|
FocusTestControl* fallbackRaw = fallback.get();
|
||||||
|
const auto fallbackHandle = window.addControl(std::move(fallback));
|
||||||
|
auto selfDeleting = std::make_unique<FocusTestControl>();
|
||||||
|
FocusTestControl* deletingRaw = selfDeleting.get();
|
||||||
|
const auto deletingHandle = window.addControl(std::move(selfDeleting));
|
||||||
|
|
||||||
|
window.getFocusManager().requestFocus({ fallbackHandle, 0 });
|
||||||
|
deletingRaw->setFocusCallback([&](bool focused, StellarX::FocusReason)
|
||||||
|
{
|
||||||
|
if (focused) Assert::IsTrue(window.removeControl(deletingHandle));
|
||||||
|
});
|
||||||
|
const auto result = window.getFocusManager().requestFocus({ deletingHandle, 0 });
|
||||||
|
|
||||||
|
Assert::AreEqual(static_cast<int>(StellarX::FocusError::None),
|
||||||
|
static_cast<int>(result.error));
|
||||||
|
Assert::AreEqual(fallbackHandle.value,
|
||||||
|
window.getFocusManager().focusedTarget().owner.value);
|
||||||
|
Assert::IsTrue(fallbackRaw->hasLogicalFocus());
|
||||||
|
Assert::IsNull(window.findControl(deletingHandle));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_METHOD(ReentrantPendingIsSingleSlotAndDoesNotReviveOlderRequest)
|
||||||
|
{
|
||||||
|
Window window(300, 120, 1);
|
||||||
|
auto oldControl = std::make_unique<FocusTestControl>();
|
||||||
|
FocusTestControl* oldRaw = oldControl.get();
|
||||||
|
const auto oldHandle = window.addControl(std::move(oldControl));
|
||||||
|
auto trigger = std::make_unique<FocusTestControl>();
|
||||||
|
const auto triggerHandle = window.addControl(std::move(trigger));
|
||||||
|
auto olderPending = std::make_unique<FocusTestControl>();
|
||||||
|
FocusTestControl* olderRaw = olderPending.get();
|
||||||
|
const auto olderHandle = window.addControl(std::move(olderPending));
|
||||||
|
auto latestPending = std::make_unique<FocusTestControl>();
|
||||||
|
const auto latestHandle = window.addControl(std::move(latestPending));
|
||||||
|
|
||||||
|
window.getFocusManager().requestFocus({ oldHandle, 0 });
|
||||||
|
oldRaw->setFocusCallback([&](bool focused, StellarX::FocusReason)
|
||||||
|
{
|
||||||
|
if (focused) return;
|
||||||
|
window.getFocusManager().requestFocus({ olderHandle, 0 });
|
||||||
|
window.getFocusManager().requestFocus({ latestHandle, 0 });
|
||||||
|
Assert::IsTrue(window.removeControl(latestHandle));
|
||||||
|
});
|
||||||
|
window.getFocusManager().requestFocus({ triggerHandle, 0 });
|
||||||
|
|
||||||
|
Assert::AreEqual(triggerHandle.value,
|
||||||
|
window.getFocusManager().focusedTarget().owner.value);
|
||||||
|
Assert::IsFalse(olderRaw->hasLogicalFocus());
|
||||||
|
Assert::IsNull(window.findControl(latestHandle));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_METHOD(FocusCallbackCanPushScopeAndPopRestoresOuterFocus)
|
||||||
|
{
|
||||||
|
Window window(300, 160, 1);
|
||||||
|
auto scopeRoot = std::make_unique<Canvas>(0, 40, 200, 100);
|
||||||
|
const auto scopeRootHandle = scopeRoot->getHandle();
|
||||||
|
auto scopedChild = std::make_unique<FocusTestControl>();
|
||||||
|
FocusTestControl* childRaw = scopedChild.get();
|
||||||
|
const auto childHandle = scopeRoot->addControl(std::move(scopedChild));
|
||||||
|
window.addControl(std::move(scopeRoot));
|
||||||
|
|
||||||
|
auto outer = std::make_unique<FocusTestControl>();
|
||||||
|
FocusTestControl* outerRaw = outer.get();
|
||||||
|
const auto outerHandle = window.addControl(std::move(outer));
|
||||||
|
StellarX::FocusScopeId pushed{};
|
||||||
|
bool didPush = false;
|
||||||
|
outerRaw->setFocusCallback([&](bool focused, StellarX::FocusReason)
|
||||||
|
{
|
||||||
|
if (focused && !didPush)
|
||||||
|
{
|
||||||
|
didPush = true;
|
||||||
|
pushed = window.getFocusManager().pushScope(
|
||||||
|
{ scopeRootHandle, 0 }, { childHandle, 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.getFocusManager().requestFocus({ outerHandle, 0 });
|
||||||
|
Assert::IsTrue(static_cast<bool>(pushed));
|
||||||
|
Assert::AreEqual(childHandle.value,
|
||||||
|
window.getFocusManager().focusedTarget().owner.value);
|
||||||
|
Assert::IsTrue(childRaw->hasLogicalFocus());
|
||||||
|
Assert::IsTrue(window.getFocusManager().popScope(pushed));
|
||||||
|
Assert::AreEqual(outerHandle.value,
|
||||||
|
window.getFocusManager().focusedTarget().owner.value);
|
||||||
|
Assert::IsTrue(outerRaw->hasLogicalFocus());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_METHOD(FocusLostCallbackScopeRestoresTheActualOldTarget)
|
||||||
|
{
|
||||||
|
Window window(360, 180, 1);
|
||||||
|
auto unrelated = std::make_unique<FocusTestControl>();
|
||||||
|
const auto unrelatedHandle = window.addControl(std::move(unrelated));
|
||||||
|
auto oldControl = std::make_unique<FocusTestControl>();
|
||||||
|
FocusTestControl* oldRaw = oldControl.get();
|
||||||
|
const auto oldHandle = window.addControl(std::move(oldControl));
|
||||||
|
auto requested = std::make_unique<FocusTestControl>();
|
||||||
|
const auto requestedHandle = window.addControl(std::move(requested));
|
||||||
|
|
||||||
|
auto scopeRoot = std::make_unique<Canvas>(0, 40, 200, 100);
|
||||||
|
const auto scopeRootHandle = scopeRoot->getHandle();
|
||||||
|
auto scopedChild = std::make_unique<FocusTestControl>();
|
||||||
|
const auto childHandle = scopeRoot->addControl(std::move(scopedChild));
|
||||||
|
window.addControl(std::move(scopeRoot));
|
||||||
|
|
||||||
|
StellarX::FocusScopeId pushed{};
|
||||||
|
oldRaw->setFocusCallback([&](bool focused, StellarX::FocusReason)
|
||||||
|
{
|
||||||
|
if (!focused && !pushed)
|
||||||
|
pushed = window.getFocusManager().pushScope(
|
||||||
|
{ scopeRootHandle, 0 }, { childHandle, 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
window.getFocusManager().requestFocus({ oldHandle, 0 });
|
||||||
|
window.getFocusManager().requestFocus({ requestedHandle, 0 });
|
||||||
|
Assert::IsTrue(static_cast<bool>(pushed));
|
||||||
|
Assert::AreEqual(childHandle.value,
|
||||||
|
window.getFocusManager().focusedTarget().owner.value);
|
||||||
|
Assert::IsTrue(window.getFocusManager().popScope(pushed));
|
||||||
|
Assert::AreEqual(oldHandle.value,
|
||||||
|
window.getFocusManager().focusedTarget().owner.value);
|
||||||
|
Assert::AreNotEqual(unrelatedHandle.value,
|
||||||
|
window.getFocusManager().focusedTarget().owner.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_METHOD(ReentrantFocusCycleStopsAtExplicitLimit)
|
||||||
|
{
|
||||||
|
Window window(200, 120, 1);
|
||||||
|
auto first = std::make_unique<FocusTestControl>();
|
||||||
|
FocusTestControl* firstRaw = first.get();
|
||||||
|
const auto firstHandle = window.addControl(std::move(first));
|
||||||
|
auto second = std::make_unique<FocusTestControl>();
|
||||||
|
FocusTestControl* secondRaw = second.get();
|
||||||
|
const auto secondHandle = window.addControl(std::move(second));
|
||||||
|
|
||||||
|
firstRaw->setFocusCallback([&](bool focused, StellarX::FocusReason)
|
||||||
|
{
|
||||||
|
if (focused) window.getFocusManager().requestFocus({ secondHandle, 0 });
|
||||||
|
});
|
||||||
|
secondRaw->setFocusCallback([&](bool focused, StellarX::FocusReason)
|
||||||
|
{
|
||||||
|
if (focused) window.getFocusManager().requestFocus({ firstHandle, 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const auto result = window.getFocusManager().requestFocus({ firstHandle, 0 });
|
||||||
|
Assert::AreEqual(static_cast<int>(StellarX::FocusError::ReentrancyLimit),
|
||||||
|
static_cast<int>(result.error));
|
||||||
|
Assert::AreEqual(static_cast<int>(StellarX::FocusError::ReentrancyLimit),
|
||||||
|
static_cast<int>(window.getFocusManager().getLastError()));
|
||||||
|
Assert::IsTrue(static_cast<bool>(window.getFocusManager().focusedTarget()));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_METHOD(ActiveRemovalUsesNextTabStopFallback)
|
||||||
|
{
|
||||||
|
Window window(240, 120, 1);
|
||||||
|
auto first = std::make_unique<FocusTestControl>(0);
|
||||||
|
const auto firstHandle = window.addControl(std::move(first));
|
||||||
|
auto second = std::make_unique<FocusTestControl>(1);
|
||||||
|
const auto secondHandle = window.addControl(std::move(second));
|
||||||
|
auto third = std::make_unique<FocusTestControl>(2);
|
||||||
|
FocusTestControl* thirdRaw = third.get();
|
||||||
|
const auto thirdHandle = window.addControl(std::move(third));
|
||||||
|
|
||||||
|
window.getFocusManager().requestFocus({ secondHandle, 0 });
|
||||||
|
Assert::IsTrue(window.removeControl(secondHandle));
|
||||||
|
Assert::AreEqual(thirdHandle.value,
|
||||||
|
window.getFocusManager().focusedTarget().owner.value);
|
||||||
|
Assert::IsTrue(thirdRaw->hasLogicalFocus());
|
||||||
|
Assert::IsNotNull(window.findControl(firstHandle));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_METHOD(ClearInvalidatesWholeBatchBeforeChoosingFallback)
|
||||||
|
{
|
||||||
|
Window window(240, 120, 1);
|
||||||
|
auto first = std::make_unique<FocusTestControl>();
|
||||||
|
const auto firstHandle = window.addControl(std::move(first));
|
||||||
|
int unexpectedGains = 0;
|
||||||
|
auto second = std::make_unique<FocusTestControl>();
|
||||||
|
second->setFocusCallback([&](bool focused, StellarX::FocusReason)
|
||||||
|
{ if (focused) ++unexpectedGains; });
|
||||||
|
window.addControl(std::move(second));
|
||||||
|
auto third = std::make_unique<FocusTestControl>();
|
||||||
|
third->setFocusCallback([&](bool focused, StellarX::FocusReason)
|
||||||
|
{ if (focused) ++unexpectedGains; });
|
||||||
|
window.addControl(std::move(third));
|
||||||
|
|
||||||
|
window.getFocusManager().requestFocus({ firstHandle, 0 });
|
||||||
|
window.clearControls();
|
||||||
|
|
||||||
|
Assert::AreEqual(0, unexpectedGains);
|
||||||
|
Assert::IsFalse(static_cast<bool>(window.getFocusManager().focusedTarget()));
|
||||||
|
Assert::AreEqual<size_t>(0, window.getControlHandles().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_METHOD(TabControlExposesRealButtonHandleWithoutSyntheticSubNode)
|
||||||
|
{
|
||||||
|
Window window(300, 200, 1);
|
||||||
|
auto tabs = std::make_unique<TabControl>(0, 0, 300, 200);
|
||||||
|
auto tabButton = std::make_unique<Button>(0, 0, 80, 24, "tab");
|
||||||
|
const auto tabHandle = tabButton->getHandle();
|
||||||
|
tabs->addTab(std::move(tabButton), std::make_unique<Canvas>(0, 24, 300, 176));
|
||||||
|
window.addControl(std::move(tabs));
|
||||||
|
|
||||||
|
const auto stops = window.getFocusManager().collectTabStops();
|
||||||
|
auto found = std::find_if(stops.begin(), stops.end(), [&](const auto& item)
|
||||||
|
{ return item.id.owner == tabHandle; });
|
||||||
|
Assert::IsTrue(found != stops.end());
|
||||||
|
Assert::AreEqual<std::uint32_t>(0, found->id.subNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_METHOD(TabControlExcludesInactivePageFromFocusTree)
|
||||||
|
{
|
||||||
|
Window window(320, 220, 1);
|
||||||
|
auto tabs = std::make_unique<TabControl>(0, 0, 300, 200);
|
||||||
|
|
||||||
|
auto firstPage = std::make_unique<Canvas>(0, 24, 300, 176);
|
||||||
|
Canvas* firstPageRaw = firstPage.get();
|
||||||
|
auto firstChild = std::make_unique<FocusTestControl>();
|
||||||
|
const auto firstChildHandle = firstPage->addControl(std::move(firstChild));
|
||||||
|
tabs->addTab(std::make_unique<Button>(0, 0, 80, 24, "first"),
|
||||||
|
std::move(firstPage));
|
||||||
|
|
||||||
|
auto secondPage = std::make_unique<Canvas>(0, 24, 300, 176);
|
||||||
|
Canvas* secondPageRaw = secondPage.get();
|
||||||
|
auto secondChild = std::make_unique<FocusTestControl>();
|
||||||
|
const auto secondChildHandle = secondPage->addControl(std::move(secondChild));
|
||||||
|
tabs->addTab(std::make_unique<Button>(80, 0, 80, 24, "second"),
|
||||||
|
std::move(secondPage));
|
||||||
|
window.addControl(std::move(tabs));
|
||||||
|
|
||||||
|
auto contains = [&](StellarX::ControlHandle handle)
|
||||||
|
{
|
||||||
|
const auto stops = window.getFocusManager().collectTabStops();
|
||||||
|
return std::any_of(stops.begin(), stops.end(), [&](const auto& item)
|
||||||
|
{ return item.id.owner == handle; });
|
||||||
|
};
|
||||||
|
|
||||||
|
// addTab keeps pages hidden until the first real draw. Drive visibility directly here so
|
||||||
|
// this non-GDI unit isolates the focus-tree filter rather than TabControl rendering.
|
||||||
|
firstPageRaw->setIsVisible(true);
|
||||||
|
Assert::IsTrue(contains(firstChildHandle));
|
||||||
|
Assert::IsFalse(contains(secondChildHandle));
|
||||||
|
window.getFocusManager().requestFocus({ firstChildHandle, 0 });
|
||||||
|
firstPageRaw->setIsVisible(false);
|
||||||
|
secondPageRaw->setIsVisible(true);
|
||||||
|
Assert::IsFalse(contains(firstChildHandle));
|
||||||
|
Assert::IsTrue(contains(secondChildHandle));
|
||||||
|
Assert::AreNotEqual(firstChildHandle.value,
|
||||||
|
window.getFocusManager().focusedTarget().owner.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 验证 MBCS 文本所有权、空页签边界、提示延时和密码掩码等纯逻辑行为。
|
// 验证 MBCS 文本所有权、空页签边界、提示延时和密码掩码等纯逻辑行为。
|
||||||
TEST_CLASS(TextAndTabTests)
|
TEST_CLASS(TextAndTabTests)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -251,6 +251,65 @@ int main(int argc, char** argv)
|
|||||||
});
|
});
|
||||||
window.addControl(std::move(resize));
|
window.addControl(std::move(resize));
|
||||||
}
|
}
|
||||||
|
else if (scenario == "focus")
|
||||||
|
{
|
||||||
|
// Stage 2 integration: real focus visuals, Table child eligibility, and active Tab page filtering.
|
||||||
|
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));
|
||||||
|
|
||||||
|
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")
|
||||||
{
|
{
|
||||||
// 先叠加两个非模态对话框,再只关闭上层,验证下层恢复和交互顺序。
|
// 先叠加两个非模态对话框,再只关闭上层,验证下层恢复和交互顺序。
|
||||||
|
|||||||
@@ -2,6 +2,23 @@
|
|||||||
|
|
||||||
本次变更包含不兼容的所有权与文本样式 API 调整。GUI 仍限定在主线程,字符集仍为 MBCS。
|
本次变更包含不兼容的所有权与文本样式 API 调整。GUI 仍限定在主线程,字符集仍为 MBCS。
|
||||||
|
|
||||||
|
## 阶段 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()`;复合控件应覆盖焦点递归协议,但不得长期保存解析得到的裸指针。
|
||||||
|
- 本阶段没有移除 `Control::handleEvent()`,也没有停止旧 `EX_KEY` 业务交付;键盘迁移仍在阶段 3。
|
||||||
|
|
||||||
## Window 与 Canvas 所有权
|
## Window 与 Canvas 所有权
|
||||||
|
|
||||||
不再公开可变的 `std::vector<std::unique_ptr<Control>>`。调用方必须保存稳定句柄,而不是保存容器迭代器或假设控件地址永久有效。
|
不再公开可变的 `std::vector<std::unique_ptr<Control>>`。调用方必须保存稳定句柄,而不是保存容器迭代器或假设控件地址永久有效。
|
||||||
|
|||||||
+14
-1
@@ -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 八类真实窗口场景。
|
||||||
- `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,8 @@ Windows MSVC AddressSanitizer 不支持 LeakSanitizer,因此脚本使用 `dete
|
|||||||
- Tooltip 单调时钟 tick、禁用和移出清理。
|
- Tooltip 单调时钟 tick、禁用和移出清理。
|
||||||
- 1000 条消息的 128 条批处理上限、空闲休眠和关闭顺序。
|
- 1000 条消息的 128 条批处理上限、空闲休眠和关闭顺序。
|
||||||
- 日志配置原子切换与滚动行完整性。
|
- 日志配置原子切换与滚动行完整性。
|
||||||
|
- FocusTargetId 递归解析、焦点回调重入、单槽 pending、64 轮活性保护、作用域恢复和 pending removal 回退。
|
||||||
|
- TabControl 真实页签句柄与非活动页面焦点过滤。
|
||||||
|
|
||||||
UI/像素测试覆盖:
|
UI/像素测试覆盖:
|
||||||
|
|
||||||
@@ -53,6 +55,8 @@ UI/像素测试覆盖:
|
|||||||
- 同名页签唯一选中、唯一页面可见。
|
- 同名页签唯一选中、唯一页面可见。
|
||||||
- 极窄 Table 的最小宽度和 coverage 外像素不变量。
|
- 极窄 Table 的最小宽度和 coverage 外像素不变量。
|
||||||
- 两层异步 Dialog 先后关闭、下层可见且可继续交互。
|
- 两层异步 Dialog 先后关闭、下层可见且可继续交互。
|
||||||
|
- Button/TextBox 焦点框、Table 分页 Button 动态资格、TabControl 活动页焦点树和 `subNode == 0`。
|
||||||
|
- 阶段 1 新队列与旧 `EX_KEY` 旁路的 input-shadow 顺序、排空与内存稳定性。
|
||||||
|
|
||||||
## 诊断产物
|
## 诊断产物
|
||||||
|
|
||||||
@@ -138,6 +142,15 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-key.ps1
|
|||||||
- KEY2 是 KEY1–6 中唯一启用文件日志的场景;轮转阈值由 `1024` 字节调整为 `10 * 1024 * 1024` 字节(10 MiB)。轮转在完整日志行写完后检查,单文件允许最多超出最后一行的长度。
|
- KEY2 是 KEY1–6 中唯一启用文件日志的场景;轮转阈值由 `1024` 字节调整为 `10 * 1024 * 1024` 字节(10 MiB)。轮转在完整日志行写完后检查,单文件允许最多超出最后一行的长度。
|
||||||
- 调整后执行正式 `NonUI`:Debug x64、x64 ASan、Release x64、Debug Win32 均为 27/27,KEY1–6 全部重新编译链接成功。
|
- 调整后执行正式 `NonUI`:Debug x64、x64 ASan、Release x64、Debug Win32 均为 27/27,KEY1–6 全部重新编译链接成功。
|
||||||
|
|
||||||
|
### 阶段 2 通用焦点自动回归(2026-10-08)
|
||||||
|
|
||||||
|
- `FocusManagerTests` 覆盖嵌套解析、PointerDown、回调删除/禁用目标、FocusGained 自删、单槽 latest-wins、FocusLost/FocusGained 中 pushScope、64 轮上限、active removal、clear 批量失效、真实页签句柄和非活动页面过滤。
|
||||||
|
- 正式 `NonUI`:Debug x64、x64 ASan、Release x64、Debug Win32 均为 40/40;KEY1–KEY6 全部编译/链接成功。
|
||||||
|
- 最终源码的独立 `focus` UI 连续 3 轮通过:TextBox 初始焦点框、Button 点击焦点框、第一页 Table 仅 next 分页 Button 进入 Tab stop、活动页后代存在、非活动页后代缺席、全部现有目标 `subNode == 0`。
|
||||||
|
- 针对旧 `tooltip` 像素等待和 `geometry` 点击结果超时,以阶段 1 基线 `81341fa` 和阶段 2 工作树执行受控 A/B:62/62 个结构化样本通过,geometry、tooltip 两端各累计 8/8,共有七场景两端各 3/3;双方未出现失败断言或明显耗时劣化。
|
||||||
|
- 当前阶段 2 随后通过正式完整 UI 三轮,八场景共 24/24。结论仅排除阶段 2 稳定引入的隐性回归;此前超时定性为间歇性桌面 fixture / 输入时序波动,仍保留残余偶发波动的可能,不表述为“证明 UI 永不波动”。
|
||||||
|
- 阶段 2 的实现与回归归因门槛均已关闭,可以进入阶段 3。阶段 2 不迁移键盘业务,不需要重复真实 IME 提交矩阵;阶段 1 的 IME/KEY1–6 人工基线继续有效,阶段 3 仍需明确 Tab 导航和 `EX_KEY` 原子换轨测试。
|
||||||
|
|
||||||
### 失败留证
|
### 失败留证
|
||||||
|
|
||||||
- 记录 KEY 编号、窗口尺寸、操作顺序和实际现象。
|
- 记录 KEY 编号、窗口尺寸、操作顺序和实际现象。
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
- 模块 ID:Module-20261001-0001
|
- 模块 ID:Module-20261001-0001
|
||||||
- 模块名称:通用输入采集、焦点与键盘路由系统
|
- 模块名称:通用输入采集、焦点与键盘路由系统
|
||||||
- 状态:实现中(v0.14;0C/0B 前置基线已关闭;阶段 1 输入采集、规范化与观察队列已落地并通过自动回归,待真实 IME/KEY1–6 人工回归;焦点与键盘路由尚未开始)
|
- 状态:实现中(v0.16;0C/0B 前置基线、阶段 1 输入地基及阶段 2 实现与回归归因均已关闭;可以进入阶段 3,键盘换轨尚未开始)
|
||||||
- 类型:新增模块 / 架构演进
|
- 类型:新增模块 / 架构演进
|
||||||
- 所属系统 / 子系统:StellarX Core / Window / Control / Input / Focus / TextBox
|
- 所属系统 / 子系统:StellarX Core / Window / Control / Input / Focus / TextBox
|
||||||
- 版本 / 分支:`master`,阶段 0A 实现基线 `c218c25`
|
- 版本 / 分支:`master`,阶段 0A 实现基线 `c218c25`
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
- v1 不自绘 IME 拼音组合串和候选列表,继续使用系统 IME UI。
|
- v1 不自绘 IME 拼音组合串和候选列表,继续使用系统 IME UI。
|
||||||
- v1 不重写现有全部鼠标采集链;现有 EasyX 鼠标消息只在框架入口规范化为 `PointerEvent`。
|
- v1 不重写现有全部鼠标采集链;现有 EasyX 鼠标消息只在框架入口规范化为 `PointerEvent`。
|
||||||
- v1 不承诺完整 Unicode grapheme cluster 编辑,只保证不拆分 UTF-16 代理对。
|
- v1 不承诺完整 Unicode grapheme cluster 编辑,只保证不拆分 UTF-16 代理对。
|
||||||
- 本稿中的阶段 1 已有实现记录和自动验证证据;阶段 2 至阶段 6 仍是设计目标,不应被误读为已经实现。
|
- 本稿中的阶段 1 与阶段 2 已有实现记录和自动验证证据;阶段 3 至阶段 6 仍是设计目标,不应被误读为已经实现。
|
||||||
|
|
||||||
## 模块边界
|
## 模块边界
|
||||||
|
|
||||||
@@ -566,15 +566,23 @@ DPI 规则:
|
|||||||
#### 10.1 焦点能力
|
#### 10.1 焦点能力
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
enum class FocusPolicy
|
enum class FocusPolicy : std::uint8_t
|
||||||
{
|
{
|
||||||
None,
|
None = 0,
|
||||||
Programmatic,
|
Programmatic = 1u << 0,
|
||||||
Click,
|
Pointer = 1u << 1,
|
||||||
TabAndClick
|
Tab = 1u << 2
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FocusTargetId
|
||||||
|
{
|
||||||
|
ControlHandle owner;
|
||||||
|
std::uint32_t subNode = 0;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`FocusTargetId` 是 FocusManager 唯一长期身份。阶段 2 审计确认 Table 分页按钮与 TabControl 页签按钮都是真实 `Button`,所以现有焦点节点全部使用 `{buttonHandle, 0}`;非零 `subNode` 只预留给未来确实没有独立 `Control` 的轻量节点,不得为现有 Button 制造第二套身份。
|
||||||
|
|
||||||
| 控件 | 焦点角色 |
|
| 控件 | 焦点角色 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Label | 不可聚焦 |
|
| Label | 不可聚焦 |
|
||||||
@@ -586,20 +594,28 @@ enum class FocusPolicy
|
|||||||
| Dialog | 焦点作用域 |
|
| Dialog | 焦点作用域 |
|
||||||
| Window | 根焦点作用域 |
|
| Window | 根焦点作用域 |
|
||||||
|
|
||||||
#### 10.2 `ControlHandle`
|
#### 10.2 身份与瞬时快照协议
|
||||||
|
|
||||||
|
当前 `ControlHandle` 是进程内单调递增且不复用的 `uint64_t`,`0` 表示无效;不存在 slot/generation 复用模型。焦点树长期只保存 `FocusTargetId`,解析时临时取得 `FocusTargetSnapshot`:
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
struct ControlHandle
|
struct FocusTargetSnapshot
|
||||||
{
|
{
|
||||||
WindowId owner;
|
FocusTargetId id;
|
||||||
uint32_t slot;
|
FocusTargetId logicalParent;
|
||||||
uint32_t generation;
|
FocusScopeId scope;
|
||||||
|
FocusPolicy policy;
|
||||||
|
bool visible;
|
||||||
|
bool enabled;
|
||||||
|
bool focusable;
|
||||||
|
bool tabStop;
|
||||||
|
std::int32_t tabIndex;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
- slot 重用必须递增 generation。
|
- `Window::findControl()` 只查直接子级,不能承担焦点解析;FocusManager 使用独立递归协议遍历 Canvas、Table 和 TabControl。
|
||||||
- 失效句柄解析为空。
|
- `enabled` 由控件协议动态给出,不假定基类存在统一 enabled 字段;例如 Button 从 `ButtonMode::DISABLED` 推导。
|
||||||
- `collectTabStops()` 返回 `ControlHandle`。
|
- 快照布尔值只代表采集时刻。任何 FocusLost/FocusGained 用户回调结束后必须重新解析并重新拉取全部字段。
|
||||||
- `Control*` 只允许作为当前函数内立即使用的借用视图,不得跨事件、回调、mutation transaction 或模态调用保存。
|
- `Control*` 只允许作为当前函数内立即使用的借用视图,不得跨事件、回调、mutation transaction 或模态调用保存。
|
||||||
|
|
||||||
#### 10.3 焦点有效条件
|
#### 10.3 焦点有效条件
|
||||||
@@ -618,19 +634,23 @@ struct ControlHandle
|
|||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
FocusChangeResult requestFocus(
|
FocusChangeResult requestFocus(
|
||||||
ControlHandle target,
|
FocusTargetId target,
|
||||||
FocusReason reason);
|
FocusReason reason);
|
||||||
```
|
```
|
||||||
|
|
||||||
顺序:
|
切换在宿主线程的 mutation transaction 中执行:
|
||||||
|
|
||||||
1. 验证目标。
|
1. 重新解析并验证目标。
|
||||||
2. 保存旧句柄。
|
2. 保存旧 `FocusTargetId`,临时清空 active。
|
||||||
3. 更新 FocusManager。
|
3. 向旧目标发送 `FocusLost`;回调可删除控件、切页、push scope 或再次请求焦点。
|
||||||
4. 向旧目标发送 `FocusLost`。
|
4. 回调后重新拉取目标的完整快照;目标失效时保持无焦点,不复活旧目标。
|
||||||
5. 向新目标发送 `FocusGained`。
|
5. 提交新 active,再发送 `FocusGained`。
|
||||||
6. 使相关区域失效并重绘。
|
6. 回调后再次验证 active;active 失效才进入独立的失效回退路径。
|
||||||
7. 新目标为 TextBox 时更新 IME 锚点。
|
7. 使旧/新控件所属托管区域失效并重绘。
|
||||||
|
|
||||||
|
回调内的 `requestFocus()` 不递归切换,只覆盖当前回合的单槽 pending。回合末只验证最终槽位;最终请求失效就丢弃,不恢复被覆盖请求。一次外层调用最多执行 64 轮,超限返回 `FocusError::ReentrancyLimit`、清空 pending,并保留最后一个已提交且仍有效的 active。
|
||||||
|
|
||||||
|
`pushScope()` 若发生在 FocusLost 回调中,恢复点取该回调对应的旧目标;若发生在 FocusGained 回调中,恢复点取新目标。不能因为切换中间态 `activeTarget` 暂时为空而丢失外层恢复身份。
|
||||||
|
|
||||||
#### 10.5 焦点失效回退
|
#### 10.5 焦点失效回退
|
||||||
|
|
||||||
@@ -645,6 +665,8 @@ FocusChangeResult requestFocus(
|
|||||||
|
|
||||||
控件进入 pending removal 前,必须先让焦点系统中的对应句柄失效。
|
控件进入 pending removal 前,必须先让焦点系统中的对应句柄失效。
|
||||||
|
|
||||||
|
pending 请求失效与 active 焦点失效是两个入口:pending 失效只丢弃;active 失效才执行上述回退。`clearControls()` 必须先批量标记整批子树失效,再选择后备,禁止焦点落到同批即将删除的兄弟控件。
|
||||||
|
|
||||||
#### 10.6 Dialog 作用域
|
#### 10.6 Dialog 作用域
|
||||||
|
|
||||||
Dialog 打开:
|
Dialog 打开:
|
||||||
@@ -661,6 +683,8 @@ Dialog 关闭:
|
|||||||
3. 验证并恢复原焦点。
|
3. 验证并恢复原焦点。
|
||||||
4. 原焦点失效时执行外层回退。
|
4. 原焦点失效时执行外层回退。
|
||||||
|
|
||||||
|
阶段 2 只实现并测试 `pushScope/popScope` 机制,使用测试作用域验证重入与恢复;真实 Dialog 的打开/关闭接入仍留阶段 4,避免在焦点地基阶段提前改变模态行为。
|
||||||
|
|
||||||
### 11. Tab 顺序与复合控件
|
### 11. Tab 顺序与复合控件
|
||||||
|
|
||||||
#### 11.1 Tab 顺序
|
#### 11.1 Tab 顺序
|
||||||
@@ -1048,11 +1072,14 @@ struct ProbeConsumeRule
|
|||||||
|
|
||||||
#### 阶段 2:FocusManager
|
#### 阶段 2:FocusManager
|
||||||
|
|
||||||
- ControlHandle。
|
- 已实现 `FocusTargetId`、瞬时快照协议和递归焦点树解析。
|
||||||
- 焦点申请与回退。
|
- 已实现每 Window `FocusManager`、焦点申请、失效回退、单槽重入与 64 轮活性保护。
|
||||||
- Window/Dialog FocusScope。
|
- 已实现 FocusScope 机制与恢复身份;真实 Dialog 接入留阶段 4。
|
||||||
- PointerDown 申请焦点。
|
- Button/TextBox PointerDown 申请焦点并绘制焦点框。
|
||||||
- mutation 生命周期。
|
- Table 暴露真实分页 Button;TabControl 暴露真实页签 Button 和活动页面树。
|
||||||
|
- pending removal 入队时逻辑失效,并与 mutation transaction 同步收口。
|
||||||
|
- 阶段 1 基线与阶段 2 工作树受控 A/B 为 62/62,通过后当前阶段 2 正式完整 UI 三轮 24/24;未发现阶段 2 相关失败或耗时劣化,阶段 2 实现与回归归因门槛均已关闭。
|
||||||
|
- 不实现 Tab 键导航、按键路由、Table 键盘翻页、页签 roving-tab-stop 或 TextBox 编辑内核。
|
||||||
|
|
||||||
#### 阶段 3:KeyboardRouter
|
#### 阶段 3:KeyboardRouter
|
||||||
|
|
||||||
@@ -1060,6 +1087,8 @@ struct ProbeConsumeRule
|
|||||||
- 句柄路由路径。
|
- 句柄路由路径。
|
||||||
- 冒泡和框架默认动作。
|
- 冒泡和框架默认动作。
|
||||||
- Button 和 Dialog 键盘行为。
|
- Button 和 Dialog 键盘行为。
|
||||||
|
- Table 分页 Button 仅在首次 draw 后 materialize;首次 draw 前“无可遍历项”表示焦点树尚未就绪,不得缓存或解释为该 Table 永久没有 Tab stop。
|
||||||
|
- Button 键盘按压态必须与阶段 2 的最小焦点框叠加绘制,不得用按压态覆盖或抹除键盘焦点反馈。
|
||||||
|
|
||||||
#### 阶段 4:复合控件
|
#### 阶段 4:复合控件
|
||||||
|
|
||||||
@@ -1117,17 +1146,26 @@ struct ProbeConsumeRule
|
|||||||
33. `mutex + deque` 在正常资源条件下不得丢输入;分配失败或队列异常必须 fail-stop,不能捕获后假装成功。
|
33. `mutex + deque` 在正常资源条件下不得丢输入;分配失败或队列异常必须 fail-stop,不能捕获后假装成功。
|
||||||
34. `WM_IME_ENDCOMPOSITION` 不得在转发旧窗口过程之前清空结果回声;END 处理中嵌套或紧随其后的匹配 `WM_IME_CHAR/WM_CHAR` 仍须抑制重复入队。
|
34. `WM_IME_ENDCOMPOSITION` 不得在转发旧窗口过程之前清空结果回声;END 处理中嵌套或紧随其后的匹配 `WM_IME_CHAR/WM_CHAR` 仍须抑制重复入队。
|
||||||
35. 无效文本序列、IMM 查询失败和残留半截解码状态必须以不含原文的计数显式诊断,不得写入替代文本或静默记为成功。
|
35. 无效文本序列、IMM 查询失败和残留半截解码状态必须以不含原文的计数显式诊断,不得写入替代文本或静默记为成功。
|
||||||
|
36. 焦点长期身份统一为 `FocusTargetId`;现有真实 Control 节点一律使用 `subNode == 0`,Table 分页和 TabControl 页签不得建立合成身份。
|
||||||
|
37. 当前 `ControlHandle` 在进程内单调分配且不复用,不采用 slot/generation 草案;焦点解析使用独立递归快照协议。
|
||||||
|
38. 焦点回调重入使用当前事务回合内的单槽 latest-wins;最终槽位失效只丢弃,不复活旧请求。
|
||||||
|
39. 焦点切换最多 64 轮;超限以 `FocusError::ReentrancyLimit` 明确失败,并保留最后一个仍有效的已提交焦点。
|
||||||
|
40. removal 请求入队时立即让对应焦点子树逻辑失效;批量 clear 必须先使整批失效,再执行 active 回退。
|
||||||
|
41. 阶段 2 只建立 FocusScope 机制;真实 Dialog 作用域接入、Tab 导航和键盘路由不得提前混入。
|
||||||
|
42. 阶段 3 的 Tab 遍历必须区分 Table 分页 Button“首次 draw 前尚未 materialize”与“永久无停靠项”,不得把 draw 前空快照固化为长期结论。
|
||||||
|
43. 阶段 3 的 Button 键盘按压视觉必须叠加在最小焦点框之上,焦点框不得因按压态切换而消失。
|
||||||
|
|
||||||
## 实现与影响
|
## 实现与影响
|
||||||
|
|
||||||
- 关键实现点:
|
- 关键实现点:
|
||||||
- 阶段 0A 探测器和 0C 独立视觉探测已进入仓库;阶段 1 的生产 NativeInputQueue、输入事件结构、采集规范化、IME 状态与观察排空已实现。FocusManager、KeyboardRouter 和 TextBox 改造尚未开始。
|
- 阶段 0A 探测器和 0C 独立视觉探测已进入仓库;阶段 1 的 NativeInputQueue 与输入规范化已经实现;阶段 2 的 FocusManager、递归焦点协议、PointerDown 聚焦、失效回退和作用域机制已经实现。KeyboardRouter 与 TextBox 编辑内核尚未开始。
|
||||||
- 独立 EasyX 探测已经推翻 v0.4 的单线程假设,v0.5 据此补入跨线程队列约束;v0.6 根据微软拼音/搜狗真实日志锁定结果串与回声关系,并补入输入布局 request/change 分层;v0.7 根据专用短测区分 TSF 配置文件切换与跨语言 HKL,并增加重新获焦快照约束;v0.8 根据 0C 自动证据锁定宽字符绘制、批次进入顺序与当前 DPI 坐标规则;v0.9 根据搜狗真实失败与修复复验锁定 IME 位置双时机规则;v0.13 根据 0B 证据锁定默认转发与无生产消费白名单;v0.14 根据阶段 1 实现审查锁定 EX_KEY 兼容过渡、两轴快照、fail-stop、END 回声边界和显式异常计数。
|
- 独立 EasyX 探测已经推翻 v0.4 的单线程假设,v0.5 据此补入跨线程队列约束;v0.6 根据微软拼音/搜狗真实日志锁定结果串与回声关系,并补入输入布局 request/change 分层;v0.7 根据专用短测区分 TSF 配置文件切换与跨语言 HKL,并增加重新获焦快照约束;v0.8 根据 0C 自动证据锁定宽字符绘制、批次进入顺序与当前 DPI 坐标规则;v0.9 根据搜狗真实失败与修复复验锁定 IME 位置双时机规则;v0.13 根据 0B 证据锁定默认转发与无生产消费白名单;v0.14 根据阶段 1 实现审查锁定 EX_KEY 兼容过渡、两轴快照、fail-stop、END 回声边界和显式异常计数;v0.15 根据阶段 2 审计与实现锁定 FocusTargetId、瞬时快照、单槽重入、64 轮活性保护和 pending removal 即时失效;v0.16 根据受控 A/B 和正式完整 UI 三轮结果关闭阶段 2 回归归因门槛,并锁定 Table 首次 draw 生命周期与 Button 按压/焦点视觉叠加约束。
|
||||||
- 涉及文件 / 类 / 函数:
|
- 涉及文件 / 类 / 函数:
|
||||||
- 预计涉及 `Window`、`Control`、`Canvas`、`Button`、`TextBox`、`Table`、`TabControl`、`Dialog`。
|
- 预计涉及 `Window`、`Control`、`Canvas`、`Button`、`TextBox`、`Table`、`TabControl`、`Dialog`。
|
||||||
- 实际文件和接口必须在阶段 0 与实现前只读检查后确定。
|
- 实际文件和接口必须在阶段 0 与实现前只读检查后确定。
|
||||||
- 兼容性影响:
|
- 兼容性影响:
|
||||||
- 设计目标是保留现有鼠标行为和公共文本 API 的兼容入口。
|
- 设计目标是保留现有鼠标行为和公共文本 API 的兼容入口。
|
||||||
|
- 阶段 2 向 `Control` 增加焦点协议虚接口,现有二进制派生控件需要重新编译;源码接口没有删除,源码分发用户按正常流程重编译即可。
|
||||||
- 阶段 1 不改变现有 `EX_KEY` 业务交付;阶段 3 停止向 `Control::handleEvent()` 下发 `EX_KEY` 时属于明确的派生控件迁移点,`EX_MOUSE` 永久保留。
|
- 阶段 1 不改变现有 `EX_KEY` 业务交付;阶段 3 停止向 `Control::handleEvent()` 下发 `EX_KEY` 时属于明确的派生控件迁移点,`EX_MOUSE` 永久保留。
|
||||||
- 输入链路和 TextBox 内部模型将发生架构级变化,必须分阶段接入。
|
- 输入链路和 TextBox 内部模型将发生架构级变化,必须分阶段接入。
|
||||||
- 性能影响:
|
- 性能影响:
|
||||||
@@ -1142,7 +1180,7 @@ struct ProbeConsumeRule
|
|||||||
|
|
||||||
- 测试范围:
|
- 测试范围:
|
||||||
- 设计评审覆盖输入采集、同步消息、Unicode、IME、焦点、复合控件、按键归属、TextBox 和模态重入。
|
- 设计评审覆盖输入采集、同步消息、Unicode、IME、焦点、复合控件、按键归属、TextBox 和模态重入。
|
||||||
- 独立线程归属探测、0A、0C、0B 门槛均已完成;阶段 1 已加入逻辑测试与真实 EasyX `input-shadow` 场景,焦点系统尚未开始。
|
- 独立线程归属探测、0A、0C、0B 门槛均已完成;阶段 1 已加入逻辑测试与真实 EasyX `input-shadow` 场景;阶段 2 已加入焦点事务单测和真实 EasyX `focus` 场景,并以 62/62 受控 A/B 和 24/24 正式完整 UI 关闭回归归因门槛。
|
||||||
- 验证步骤:
|
- 验证步骤:
|
||||||
|
|
||||||
1. 先按关联规格实现阶段 0A 被动探测并取得消息链路证据。
|
1. 先按关联规格实现阶段 0A 被动探测并取得消息链路证据。
|
||||||
@@ -1172,6 +1210,7 @@ struct ProbeConsumeRule
|
|||||||
- 2026-10-07:搜狗候选态没有向目标 HWND 暴露 START/COMPOSITION/END 生命周期;最终 Consume 请求以 `ime_condition_not_met` 正确 fail-open 为 Forward,规则零命中、零消费、EasyX 键出现一次,结构 verdict 为 `MustForward`。
|
- 2026-10-07:搜狗候选态没有向目标 HWND 暴露 START/COMPOSITION/END 生命周期;最终 Consume 请求以 `ime_condition_not_met` 正确 fail-open 为 Forward,规则零命中、零消费、EasyX 键出现一次,结构 verdict 为 `MustForward`。
|
||||||
- 2026-10-07:最终 Debug x64 单元测试 15/15、UI fixture 1 轮、F24 KeyDown A/B 新运行通过;既有 KeyUp 与 baseline mismatch 证据经当前分析器回放保持有效。阶段 1 以默认转发且无消费白名单为起点。
|
- 2026-10-07:最终 Debug x64 单元测试 15/15、UI fixture 1 轮、F24 KeyDown A/B 新运行通过;既有 KeyUp 与 baseline mismatch 证据经当前分析器回放保持有效。阶段 1 以默认转发且无消费白名单为起点。
|
||||||
- 2026-10-07:阶段 1 自动回归通过:Debug x64、x64 ASan、Release x64、Debug Win32 均为 27/27,KEY1–6 全部编译/链接成功;完整 UI fixture 曾一轮通过,最终源码的独立 `input-shadow` 再次通过,旧 EX_KEY 与新队列均完整观察 4000/4000 条事件,新队列最终深度 0、顺序严格、内存增长 0、无 fault/规范化异常;PowerShell 5.1/7 AST、工程 XML 和 `git diff --check` 通过。最终完整 UI 的两次追加复跑分别在 `geometry/ownership` 鼠标点击检查点超时,均未进入输入场景,按既有桌面焦点抖动保留失败证据,不冒充最终全套连续通过。真实 IME 与 KEY1–6 交互仍待人工确认。
|
- 2026-10-07:阶段 1 自动回归通过:Debug x64、x64 ASan、Release x64、Debug Win32 均为 27/27,KEY1–6 全部编译/链接成功;完整 UI fixture 曾一轮通过,最终源码的独立 `input-shadow` 再次通过,旧 EX_KEY 与新队列均完整观察 4000/4000 条事件,新队列最终深度 0、顺序严格、内存增长 0、无 fault/规范化异常;PowerShell 5.1/7 AST、工程 XML 和 `git diff --check` 通过。最终完整 UI 的两次追加复跑分别在 `geometry/ownership` 鼠标点击检查点超时,均未进入输入场景,按既有桌面焦点抖动保留失败证据,不冒充最终全套连续通过。真实 IME 与 KEY1–6 交互仍待人工确认。
|
||||||
|
- 2026-10-08:阶段 2 FocusManager 完成实现审查;Debug x64、x64 ASan、Release x64、Debug Win32 均为 40/40,KEY1–6 全部编译/链接成功。最终源码的独立 `focus` UI 连续 3 轮通过。针对随后出现的 Tooltip 像素等待和 geometry 点击结果超时,以阶段 1 基线 `81341fa` 与阶段 2 工作树执行受控 A/B,62/62 个结构化样本全部通过;当前阶段 2 正式完整 UI 三轮八场景共 24/24 通过,未发现阶段 2 相关失败或耗时劣化。此前超时定性为间歇性桌面 fixture / 输入时序波动,仍保留残余偶发波动可能,不把关闭门槛表述为证明 UI 永不波动。
|
||||||
- 已知限制 / 遗留问题:
|
- 已知限制 / 遗留问题:
|
||||||
- §5.3 当前明确不建立生产消费白名单;若后续需要窄消费规则,仍必须补对应消息、虚拟键、IME 和环境的独立 0B 证据。
|
- §5.3 当前明确不建立生产消费白名单;若后续需要窄消费规则,仍必须补对应消息、虚拟键、IME 和环境的独立 0B 证据。
|
||||||
- §7.3 的微软拼音/搜狗回声状态机已有 0A 证据,其他 IME 仍需保持可诊断回退。
|
- §7.3 的微软拼音/搜狗回声状态机已有 0A 证据,其他 IME 仍需保持可诊断回退。
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user