feat(focus): 完成阶段 2 通用焦点系统

This commit is contained in:
Codex
2026-10-08 18:37:34 +08:00
parent 81341fab2c
commit 255aba688f
25 changed files with 1533 additions and 5 deletions
+2 -2
View File
@@ -55,14 +55,14 @@
<!-- Public and internal headers are kept in this project so all consumers use one implementation. -->
<ItemGroup>
<ClInclude Include="include\StellarX\Button.h" /><ClInclude Include="include\StellarX\Canvas.h" /><ClInclude Include="include\StellarX\Control.h" />
<ClInclude Include="include\StellarX\CoreTypes.h" /><ClInclude Include="include\StellarX\Dialog.h" /><ClInclude Include="include\StellarX\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\TabControl.h" /><ClInclude Include="include\StellarX\Table.h" /><ClInclude Include="include\StellarX\TextBox.h" /><ClInclude Include="include\StellarX\Window.h" />
</ItemGroup>
<!-- Framework source files compiled into StellarX.Core.lib. -->
<ItemGroup>
<ClCompile Include="src\StellarX\Button.cpp" /><ClCompile Include="src\StellarX\Canvas.cpp" /><ClCompile Include="src\StellarX\Control.cpp" />
<ClCompile Include="src\StellarX\Dialog.cpp" /><ClCompile Include="src\StellarX\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\TextBox.cpp" /><ClCompile Include="src\StellarX\Window.cpp" />
</ItemGroup>
+1
View File
@@ -24,6 +24,7 @@
<ClInclude Include="include\StellarX\Control.h" />
<ClInclude Include="include\StellarX\CoreTypes.h" />
<ClInclude Include="include\StellarX\Dialog.h" />
<ClInclude Include="include\StellarX\FocusManager.h" />
<ClInclude Include="include\StellarX\Label.h" />
<ClInclude Include="include\StellarX\MessageBox.h" />
<ClInclude Include="include\StellarX\StellarX.h" />
+3
View File
@@ -45,6 +45,9 @@
<ClInclude Include="include\StellarX\Dialog.h">
<Filter>头文件</Filter>
</ClInclude>
<ClInclude Include="include\StellarX\FocusManager.h">
<Filter>头文件</Filter>
</ClInclude>
<ClInclude Include="include\StellarX\MessageBox.h">
<Filter>头文件</Filter>
</ClInclude>
+5
View File
@@ -191,6 +191,11 @@ public:
void setTooltipText(const std::string& s) { tipTextClick = s; tipUserOverride = true; }
// 设置 TOGGLE 模式开/关两种状态下的 Tooltip 文本。
void setTooltipTextsForToggle(const std::string& onText, const std::string& offText);
protected:
bool isFocusEnabled() const override
{
return mode != StellarX::ButtonMode::DISABLED;
}
private:
// 初始化按钮内部状态、颜色和 Tooltip 默认样式
void initButton(const std::string text, StellarX::ButtonMode mode, StellarX::ControlShape shape, COLORREF ct, COLORREF cf, COLORREF ch);
+6
View File
@@ -118,6 +118,12 @@ public:
// 托管收口阶段执行 Canvas 的真正重绘
void commitManagedRepaint() override;
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 传播事务深度。
void beginMutationScope() override;
// 离开 Canvas 子树 mutation transaction;最外层结束时提交子控件队列。
+32
View File
@@ -36,10 +36,17 @@
class Window;
namespace StellarX
{
class FocusManager;
}
class Control
{
friend class Window;
friend class Canvas;
friend class Table;
friend class TabControl;
protected:
inline static std::atomic<std::uint64_t> nextHandleValue{ 1 };
// 句柄随控件构造生成,容器只通过句柄排队删除,避免遍历期间直接持有失效指针。
@@ -52,6 +59,9 @@ protected:
bool dirty = true; // 是否重绘
bool show = true; // 是否显示
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; // 布局模式
@@ -228,6 +238,15 @@ public:
std::string getId() const { return id; }
// 返回稳定句柄而非对象地址;调用方不得据此推断控件当前仍在容器中。
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; }
//获取控件最近一次事件处理是否引发了视觉变化
@@ -280,6 +299,19 @@ public:
// - 仅用于少数需要把当前结果固化为新设计基线的场景
void commitCurrentGeometryAsDesignRect();
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 期间进入事务;具体容器在最外层结束时提交队列。
MutationScope enterMutationScope() { return MutationScope(this); }
// 进入子树结构变更事务。默认向父容器或宿主 Window 传播,使整棵树共享同一提交边界。
+105
View File
@@ -64,6 +64,111 @@ namespace StellarX
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
* @功能描述: 用来定义控件填充图案的枚举类
+100
View File
@@ -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();
};
}
+1
View File
@@ -30,6 +30,7 @@
#pragma once
#include "CoreTypes.h"
#include "FocusManager.h"
#include "InputEvents.h"
#include "SxLog.h"
#include "Control.h"
+6
View File
@@ -111,6 +111,12 @@ public:
bool canCommitManagedPartialRepaint() const override; // 判断当前 TabControl 是否可安全做局部提交
void commitManagedRepaint() override; // 托管收口阶段执行 TabControl 的真正重绘
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;
// 外层统一解算后,TabControl 需要同步刷新其内部页签栏和页面区。
+7
View File
@@ -186,4 +186,11 @@ public:
int getTableWidth() 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;
};
+18
View File
@@ -16,6 +16,7 @@
#pragma once
#include "Control.h"
#include "FocusManager.h"
#include <atomic>
#include <cstdint>
#include <string>
@@ -32,9 +33,15 @@ namespace StellarX::Internal
NativeInputRuntimeSnapshot inspectWindowNativeInputForTesting(const ::Window& window) noexcept;
}
namespace StellarX
{
class FocusManager;
}
class Window
{
friend class Dialog;
friend class StellarX::FocusManager;
friend StellarX::Internal::NativeInputRuntimeSnapshot
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>> dialogs; // 非模态对话框;始终位于普通控件之上
std::unique_ptr<StellarX::FocusManager> focusManager; // Window 内逻辑焦点唯一所有者
// 顶层控件的结构变更与事件分发解耦,防止回调直接改动正在遍历的 controls。
// add/remove/clear 在事件、绘制和回调期间只入队;最外层 mutation scope 结束后再提交。
enum class ControlMutationKind { Add, Remove, Clear };
@@ -141,6 +149,9 @@ public:
const Control* findControl(StellarX::ControlHandle handle) const;
// 返回当前已提交控件的句柄快照;调用方不得据此推断控件之后仍然存在。
std::vector<StellarX::ControlHandle> getControlHandles() const;
// 获取当前 Window 的通用逻辑焦点管理器。
StellarX::FocusManager& getFocusManager();
const StellarX::FocusManager& getFocusManager() const;
// 添加非模态对话框;Window 接管生命周期并在事件安全点清理关闭对象。
void addDialog(std::unique_ptr<Control> dialogs);
//非模态对话框去重
@@ -184,6 +195,13 @@ public:
// 请求下一安全点执行整场景重绘;用于 Dialog 关闭、全局覆盖关系变化等无法安全局部修复的场景。
void requestFullSceneRedraw();
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 顶层控件队列;只允许在最外层事务安全点调用。
void flushPendingControlMutations();
// 自顶向下提交 Window、Canvas、TabControl 等容器中累积的 pending mutation。
+45 -2
View File
@@ -1,7 +1,7 @@
# 外部 UI 驱动:启动确定性 fixture,注入真实鼠标输入,并保存像素与状态诊断。
param(
[int]$Repeat = 1,
[ValidateSet('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'input-shadow')]
[ValidateSet('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'focus', 'input-shadow')]
[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,
[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
# UI 测试会抢占前台并移动鼠标,外层 finally 必须恢复用户原来的光标位置。
[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 }
$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') }
'tab' { Click-Mouse $h 230 35; Click-Mouse $h 395 238; $result = Wait-Result $resultFile; if ($result.active -ne 1 -or $result.firstSelected -or -not $result.secondSelected -or $result.firstPageVisible -or -not $result.secondPageVisible) { throw 'tab uniqueness result failed' } }
'table' { Click-Mouse $h 395 268; $result = Wait-Result $resultFile; if ($result.width -le 40 -or $result.coverageRight -le $result.coverageLeft -or $result.coverageBottom -le $result.coverageTop) { throw 'table minimum width or coverage failed' } }
'focus' {
$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' {
Click-Mouse $h 90 40
Start-Sleep -Milliseconds 350
+11
View File
@@ -166,6 +166,8 @@ void Button::initButton(const std::string text, StellarX::ButtonMode mode, Stell
this->buttonHoverColor = ch;
this->click = false;
this->hover = false;
this->focusPolicy = StellarX::FocusPolicy::Programmatic |
StellarX::FocusPolicy::Pointer | StellarX::FocusPolicy::Tab;
// === Tooltip 默认:文本=按钮文本;白底黑字;不透明;用当前按钮字体样式 ===
tipTextClick = tipTextOn = tipTextOff = this->text;
@@ -285,6 +287,13 @@ void Button::draw()
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();//恢复默认字体样式和颜色
dirty = false; //标记按钮不需要重绘
@@ -339,6 +348,7 @@ bool Button::handleEvent(const ExMessage& msg)
// 处理鼠标点击事件
if (msg.message == WM_LBUTTONDOWN && hover && mode != StellarX::ButtonMode::DISABLED)
{
requestFocus(StellarX::FocusReason::Pointer);
if (mode == StellarX::ButtonMode::NORMAL)
{
click = true;
@@ -566,6 +576,7 @@ void Button::setbuttonMode(StellarX::ButtonMode mode)
//取值范围参考 buttMode的枚举注释
this->mode = mode;
dirty = true; // 标记需要重绘
notifyFocusSubtreeStateChanged();
}
void Button::setROUND_RECTANGLEwidth(int width)
+41
View File
@@ -451,9 +451,11 @@ bool Canvas::removeControl(StellarX::ControlHandle handle)
if (!applyingControlMutations && (mutationScopeDepth > 0 || (host && host->isMutationTransactionActive())))
{
pendingControlMutations.push_back({ ControlMutationKind::Remove, handle, nullptr });
if (host) host->getFocusManager().notifySubtreePendingRemoval(handle);
return true;
}
pendingControlMutations.push_back({ ControlMutationKind::Remove, handle, nullptr });
if (host) host->getFocusManager().notifySubtreePendingRemoval(handle);
flushPendingMutations();
return true;
}
@@ -462,6 +464,14 @@ void Canvas::clearControls()
{
Window* host = getHostWindow();
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()))
flushPendingMutations();
}
@@ -489,6 +499,34 @@ std::vector<StellarX::ControlHandle> Canvas::getControlHandles() const
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)
{
switch (shape)
@@ -550,6 +588,9 @@ void Canvas::setIsVisible(bool visible)
}
if (!visible)
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)
+85
View File
@@ -1,4 +1,5 @@
#include "Control.h"
#include "FocusManager.h"
#include "SxLog.h"
#include<assert.h>
#include "Window.h"
@@ -201,6 +202,7 @@ void Control::setIsVisible(bool show)
{
// 隐藏:擦除自己在屏幕上的内容,并释放快照
discardBackground();
notifyFocusSubtreeStateChanged();
return;
}
@@ -208,6 +210,89 @@ void Control::setIsVisible(bool show)
// 仅向上标脏,让事件收口阶段由容器统一重绘。
if (parent)
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()
+1
View File
@@ -299,6 +299,7 @@ void Dialog::Close()
show = false;
dirty = true;
pendingCleanup = true; // 只标记需要清理,不立即执行
notifyFocusSubtreeStateChanged();
hWnd.dialogClose = true;
hWnd.requestFullSceneRedraw();
+451
View File
@@ -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, &current) != FocusError::None)
continue;
if (tabStopsOnly && !current.tabStop)
continue;
filtered.push_back(current);
}
std::stable_sort(filtered.begin(), filtered.end(), [](const auto& left, const auto& right)
{ return left.tabIndex < right.tabIndex; });
return filtered;
}
std::vector<FocusTargetSnapshot> FocusManager::collectTabStops() const
{
return collectOrderedTargets(true);
}
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();
}
}
+37
View File
@@ -351,6 +351,43 @@ bool TabControl::clearTransientMouseState()
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)
{
if (!button || !page)
+52
View File
@@ -262,6 +262,7 @@ void Table::initButton()
if (currentPage > 1)
{
--currentPage;
notifyFocusSubtreeStateChanged();
SX_LOGI("Table")
<< SX_T("翻页:id=", "page change: id=") << id
<< " " << oldPage << "->" << currentPage
@@ -279,6 +280,7 @@ void Table::initButton()
if (currentPage < totalPages)
{
++currentPage;
notifyFocusSubtreeStateChanged();
SX_LOGI("Table")
<< SX_T("翻页:id=", "page change: id=") << id
<< " " << oldPage << "->" << currentPage
@@ -661,6 +663,49 @@ bool Table::clearTransientMouseState()
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
{
// coverage 包含表体、页码和分页按钮的实际绘制范围,而不只包含表格本体。
@@ -749,6 +794,7 @@ void Table::setHeaders(std::initializer_list<std::string> headers)
isNeedDrawHeaders = true; // 标记需要重新绘制表头
isNeedButtonAndPageNum = true;
dirty = true;
notifyFocusSubtreeStateChanged();
}
@@ -765,6 +811,7 @@ void Table::setData(std::vector<std::string> data)
isNeedCellSize = true;
isNeedButtonAndPageNum = true;
dirty = true;
notifyFocusSubtreeStateChanged();
SX_LOGI("Table")
<< SX_T("新增Data:id=", "appendRow: id=") << id
@@ -789,6 +836,7 @@ void Table::setData(std::initializer_list<std::vector<std::string>> data)
isNeedCellSize = true; // 标记需要重新计算单元格尺寸
isNeedButtonAndPageNum = true;
dirty = true;
notifyFocusSubtreeStateChanged();
SX_LOGI("Table")
<< SX_T("新增多行Data:id=", "appendRows: id=") << id
<< SX_T(" 新增行数=", " addedRows=") << addedRows
@@ -809,12 +857,14 @@ void Table::setRowsPerPage(int rows)
isNeedCellSize = true; // 标记需要重新计算单元格尺寸
isNeedButtonAndPageNum = true;
dirty = true;
notifyFocusSubtreeStateChanged();
}
void Table::showPageButton(bool isShow)
{
this->isShowPageButton = isShow;
this->dirty = true;
notifyFocusSubtreeStateChanged();
}
void Table::setTableBorder(COLORREF color)
@@ -868,6 +918,7 @@ void Table::clearHeaders()
isNeedDrawHeaders = true; // 标记需要重新绘制表头
isNeedButtonAndPageNum = true;// 标记需要重新计算翻页按钮和页码信息
dirty = true;
notifyFocusSubtreeStateChanged();
SX_LOGI("Table") << SX_T("清除表头:id=","clearHeaders: id=" )<< id;
}
@@ -880,6 +931,7 @@ void Table::clearData()
isNeedCellSize = true; // 标记需要重新计算单元格尺寸
isNeedButtonAndPageNum = true;// 标记需要重新计算翻页按钮和页码信息
dirty = true;
notifyFocusSubtreeStateChanged();
SX_LOGI("Table") << SX_T("清除表格数据:id=","clearData: id=") << id;
}
+16 -1
View File
@@ -94,6 +94,8 @@ TextBox::TextBox(int x, int y, int width, int height, std::string text, StellarX
// - 垂直方向当前实现保持固定尺寸,不在本轮引入随高度变化的视觉自适应
this->layoutCapability.allowStretchX = true;
this->layoutCapability.allowStretchY = false;
this->focusPolicy = StellarX::FocusPolicy::Programmatic |
StellarX::FocusPolicy::Pointer | StellarX::FocusPolicy::Tab;
}
void TextBox::draw()
@@ -169,6 +171,12 @@ void TextBox::draw()
outtextxy(x + 10, (y + (height - text_height) / 2), LPCTSTR(displayText.c_str()));
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();
dirty = false; //标记不需要重绘
}
@@ -177,10 +185,10 @@ void TextBox::draw()
bool TextBox::handleEvent(const ExMessage& msg)
{
if (!show) return false;
auto mutationScope = enterMutationScope();
resetEventVisualChanged();
bool hover = false;
bool oldClick = click;
bool consume = false;
const bool isMouseMessage =
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)
{
click = true;
@@ -257,6 +271,7 @@ void TextBox::setMode(StellarX::TextBoxmode mode)
{
this->mode = mode;
this->dirty = true;
notifyFocusSubtreeStateChanged();
}
void TextBox::setMaxCharLen(size_t len)
+54
View File
@@ -138,6 +138,8 @@ void Window::flushPendingMutationsInTree()
if (control) control->flushPendingMutations();
for (auto& dialog : dialogs)
if (dialog) dialog->flushPendingMutations();
if (focusManager)
focusManager->onMutationCommitted();
}
void Window::markAllControlsDirty()
@@ -647,6 +649,7 @@ Window::Window(int w, int h, int mode)
localwidth = minClientW = pendingW = width = w;
localheight = minClientH = pendingH = height = h;
windowMode = mode;
focusManager = std::make_unique<StellarX::FocusManager>(*this);
}
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;
windowMode = mode;
wBkcolor = bk;
focusManager = std::make_unique<StellarX::FocusManager>(*this);
}
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;
wBkcolor = bk;
headline = std::move(title);
focusManager = std::make_unique<StellarX::FocusManager>(*this);
}
bool Window::hasLiveNativeWindow() const noexcept
@@ -1382,6 +1387,8 @@ bool Window::removeControl(StellarX::ControlHandle handle)
if (!exists)
return false;
pendingControlMutations.push_back({ ControlMutationKind::Remove, handle, nullptr });
if (focusManager)
focusManager->notifySubtreePendingRemoval(handle);
if (!isMutationTransactionActive())
flushPendingMutationsInTree();
return true;
@@ -1390,6 +1397,14 @@ bool Window::removeControl(StellarX::ControlHandle handle)
void Window::clearControls()
{
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())
flushPendingMutationsInTree();
}
@@ -1417,6 +1432,45 @@ std::vector<StellarX::ControlHandle> Window::getControlHandles() const
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)
{
// 新增非模态对话框:管理顺序决定事件优先级(顶层从后往前)
+378
View File
@@ -25,6 +25,38 @@ namespace
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)
{
// 使用进程号隔离并行测试产生的日志路径;测试结束后由用例主动清理。
@@ -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 文本所有权、空页签边界、提示延时和密码掩码等纯逻辑行为。
TEST_CLASS(TextAndTabTests)
{
+59
View File
@@ -251,6 +251,65 @@ int main(int argc, char** argv)
});
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")
{
// 先叠加两个非模态对话框,再只关闭上层,验证下层恢复和交互顺序。
+17
View File
@@ -2,6 +2,23 @@
本次变更包含不兼容的所有权与文本样式 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 所有权
不再公开可变的 `std::vector<std::unique_ptr<Control>>`。调用方必须保存稳定句柄,而不是保存容器迭代器或假设控件地址永久有效。