Add XCUI input focus shortcut MVP
This commit is contained in:
@@ -0,0 +1,140 @@
|
|||||||
|
# XCUI Input / Focus / Shortcut Subplan 完成归档
|
||||||
|
|
||||||
|
日期:`2026-04-04`
|
||||||
|
|
||||||
|
## 1. 归档结论
|
||||||
|
|
||||||
|
`Subplan-04` 在其原始定义边界内已经完成,可以归档。
|
||||||
|
|
||||||
|
这里的“完成”指的是:
|
||||||
|
|
||||||
|
- 已建立 XCUI 输入事件基础模型
|
||||||
|
- 已建立焦点、激活路径、指针捕获路径三套状态管理
|
||||||
|
- 已建立 capture / target / bubble 三阶段输入路由
|
||||||
|
- 已建立 shortcut scope 与命令匹配机制
|
||||||
|
- 已建立统一的 `UIInputDispatcher`
|
||||||
|
- 已补齐最小单元测试并完成通过验证
|
||||||
|
|
||||||
|
这里的“完成”不包括:
|
||||||
|
|
||||||
|
- Win32 原生消息采集
|
||||||
|
- ImGui / Editor 侧的输入桥接
|
||||||
|
- 文本输入控件级别的 IME 细节
|
||||||
|
|
||||||
|
这些内容本来就不在 `Subplan-04` 的负责边界里,后续应由 `Subplan-05`、`Subplan-08`、`Subplan-09` 等继续接手。
|
||||||
|
|
||||||
|
## 2. 本次落地内容
|
||||||
|
|
||||||
|
### 2.1 输入事件模型
|
||||||
|
|
||||||
|
已扩展 `UIInputEvent`:
|
||||||
|
|
||||||
|
- `PointerEnter`
|
||||||
|
- `PointerLeave`
|
||||||
|
- `pointerId`
|
||||||
|
- `timestampNanoseconds`
|
||||||
|
- `repeat`
|
||||||
|
- `synthetic`
|
||||||
|
|
||||||
|
相关文件:
|
||||||
|
|
||||||
|
- `engine/include/XCEngine/UI/Types.h`
|
||||||
|
|
||||||
|
### 2.2 焦点与路径模型
|
||||||
|
|
||||||
|
已补齐:
|
||||||
|
|
||||||
|
- `UIElementId`
|
||||||
|
- `UIInputPath`
|
||||||
|
- `UIFocusController`
|
||||||
|
- `UIFocusChange`
|
||||||
|
|
||||||
|
相关文件:
|
||||||
|
|
||||||
|
- `engine/include/XCEngine/UI/Input/UIInputPath.h`
|
||||||
|
- `engine/include/XCEngine/UI/Input/UIFocusController.h`
|
||||||
|
- `engine/src/UI/Input/UIInputPath.cpp`
|
||||||
|
- `engine/src/UI/Input/UIFocusController.cpp`
|
||||||
|
|
||||||
|
### 2.3 Shortcut 系统
|
||||||
|
|
||||||
|
已补齐:
|
||||||
|
|
||||||
|
- `UIShortcutScope`
|
||||||
|
- `UIShortcutChord`
|
||||||
|
- `UIShortcutBinding`
|
||||||
|
- `UIShortcutRegistry`
|
||||||
|
- shortcut scope 优先级匹配规则
|
||||||
|
|
||||||
|
相关文件:
|
||||||
|
|
||||||
|
- `engine/include/XCEngine/UI/Input/UIShortcutRegistry.h`
|
||||||
|
- `engine/src/UI/Input/UIShortcutRegistry.cpp`
|
||||||
|
|
||||||
|
### 2.4 输入路由与统一分发器
|
||||||
|
|
||||||
|
已补齐:
|
||||||
|
|
||||||
|
- `UIInputRouter`
|
||||||
|
- `UIInputRoutingPlan`
|
||||||
|
- `UIInputRoutingStep`
|
||||||
|
- `UIInputDispatcher`
|
||||||
|
|
||||||
|
相关文件:
|
||||||
|
|
||||||
|
- `engine/include/XCEngine/UI/Input/UIInputRouter.h`
|
||||||
|
- `engine/include/XCEngine/UI/Input/UIInputDispatcher.h`
|
||||||
|
- `engine/src/UI/Input/UIInputRouter.cpp`
|
||||||
|
- `engine/src/UI/Input/UIInputDispatcher.cpp`
|
||||||
|
|
||||||
|
## 3. 测试与验证
|
||||||
|
|
||||||
|
新增测试:
|
||||||
|
|
||||||
|
- `tests/Input/test_xcui_input_dispatcher.cpp`
|
||||||
|
|
||||||
|
已完成验证:
|
||||||
|
|
||||||
|
- `cmake --build build --config Debug --target input_tests`
|
||||||
|
- `ctest -C Debug -R "XCUI.*" --output-on-failure`
|
||||||
|
|
||||||
|
验证结果:
|
||||||
|
|
||||||
|
- `6 / 6` 通过
|
||||||
|
|
||||||
|
覆盖点包括:
|
||||||
|
|
||||||
|
- focus path 切换
|
||||||
|
- capture path 优先级
|
||||||
|
- keyboard routed path 顺序
|
||||||
|
- shortcut scope 匹配优先级
|
||||||
|
- pointer down / pointer up 对 active path 的影响
|
||||||
|
- shortcut 命中后优先消费、跳过普通 routing
|
||||||
|
|
||||||
|
## 4. 对后续 subplan 的可复用输出
|
||||||
|
|
||||||
|
当前已经可以被后续直接依赖的稳定入口:
|
||||||
|
|
||||||
|
- `XCEngine::UI::UIInputPath`
|
||||||
|
- `XCEngine::UI::UIFocusController`
|
||||||
|
- `XCEngine::UI::UIShortcutRegistry`
|
||||||
|
- `XCEngine::UI::UIInputRouter`
|
||||||
|
- `XCEngine::UI::UIInputDispatcher`
|
||||||
|
|
||||||
|
后续建议对接方式:
|
||||||
|
|
||||||
|
- `Subplan-05`:负责把 ImGui/平台输入桥接进这套 dispatcher
|
||||||
|
- `Subplan-08`:负责把 menu / dock / panel shell 的 shortcut scope 接进 registry
|
||||||
|
- `Subplan-09`:负责把 viewport shell 的 pointer / focus / capture 接进 routing
|
||||||
|
|
||||||
|
## 5. 原 subplan 文件
|
||||||
|
|
||||||
|
原始 subplan 文件保留在:
|
||||||
|
|
||||||
|
- `docs/plan/xcui-subplans/Subplan-04_XCUI-Input-Focus-Shortcut.md`
|
||||||
|
|
||||||
|
其状态应视为:
|
||||||
|
|
||||||
|
- 已完成
|
||||||
|
- 已归档
|
||||||
|
- 不再作为活跃开发计划继续扩写
|
||||||
44
docs/plan/xcui-subplans/README.md
Normal file
44
docs/plan/xcui-subplans/README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# XCUI Parallel Subplans
|
||||||
|
|
||||||
|
基于 [XCUI完整架构设计与执行计划](../XCUI完整架构设计与执行计划.md) 的并行拆分版本。
|
||||||
|
|
||||||
|
当前建议:
|
||||||
|
|
||||||
|
- `Phase 0` 由主线继续推进,目标是把 ImGui 从 engine/editor 公共边界剥离出来。
|
||||||
|
- 其他人不要再去碰 `Phase 0` 正在改的边界文件,优先认领下面的独立 subplan。
|
||||||
|
- 每个人只领一个 subplan,按“自己负责的目录”做增量开发,避免跨 subplan 改核心契约。
|
||||||
|
|
||||||
|
推荐并行顺序:
|
||||||
|
|
||||||
|
- 可以立刻开始:`01` `03` `04` `05` `06`
|
||||||
|
- 建议在 Core/Backend 契约初步稳定后启动:`07` `08` `09`
|
||||||
|
|
||||||
|
已完成归档:
|
||||||
|
|
||||||
|
- `Subplan-02`:已于 `2026-04-04` 归档到 [../used/XCUI_Subplan-02_LayoutEngine_完成归档_2026-04-04.md](../used/XCUI_Subplan-02_LayoutEngine_完成归档_2026-04-04.md)
|
||||||
|
- `Subplan-04`:已于 `2026-04-04` 归档到 [../used/XCUI_Input_Focus_Shortcut_Subplan_完成归档_2026-04-04.md](../used/XCUI_Input_Focus_Shortcut_Subplan_完成归档_2026-04-04.md)
|
||||||
|
|
||||||
|
统一协作约束:
|
||||||
|
|
||||||
|
- 共享契约文件尽量只由主线或对应 owner 修改。
|
||||||
|
- 新模块优先放到新目录,不要把 XCUI 新逻辑继续塞进旧的 ImGui helper。
|
||||||
|
- 每个 subplan 都要自带最小测试或样例,不接受只落抽象不落验证。
|
||||||
|
- 每个 subplan 完成后,至少产出一个可被其他 subplan 直接依赖的稳定入口。
|
||||||
|
|
||||||
|
共享高风险边界:
|
||||||
|
|
||||||
|
- `engine/include/XCEngine/UI/`
|
||||||
|
- `engine/include/XCEngine/Core/Layer.h`
|
||||||
|
- `engine/include/XCEngine/Core/LayerStack.h`
|
||||||
|
- `editor/src/Application.cpp`
|
||||||
|
- `editor/src/Viewport/IViewportHostService.h`
|
||||||
|
|
||||||
|
Subplan 列表:
|
||||||
|
|
||||||
|
- `Subplan-01`:XCUI Core Tree / State / Invalidation
|
||||||
|
- `Subplan-03`:XCUI Style / Theme / Token
|
||||||
|
- `Subplan-05`:XCUI ImGui Transition Backend
|
||||||
|
- `Subplan-06`:XCUI Markup / Import / Hot Reload
|
||||||
|
- `Subplan-07`:XCUI Schema Inspector / PropertyGrid
|
||||||
|
- `Subplan-08`:XCUI DockHost / Menu / Panel Shell
|
||||||
|
- `Subplan-09`:XCUI ViewportSlot / Editor Integration
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Subplan 04:XCUI Input / Focus / Shortcut
|
||||||
|
|
||||||
|
状态:
|
||||||
|
|
||||||
|
- 已于 `2026-04-04` 完成当前 subplan 定义边界内的实现。
|
||||||
|
- 已归档到:
|
||||||
|
[../used/XCUI_Input_Focus_Shortcut_Subplan_完成归档_2026-04-04.md](../used/XCUI_Input_Focus_Shortcut_Subplan_完成归档_2026-04-04.md)
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 建立 XCUI 的输入事件、焦点流转和快捷键分发模型。
|
||||||
|
- 让输入不再直接写死在 ImGui 调用点里。
|
||||||
|
|
||||||
|
负责人边界:
|
||||||
|
|
||||||
|
- 负责 `engine/src/UI/Input/`。
|
||||||
|
- 负责 pointer / keyboard / focus / command dispatch 的抽象。
|
||||||
|
- 不负责平台消息采集本身。
|
||||||
|
|
||||||
|
建议目录:
|
||||||
|
|
||||||
|
- `engine/include/XCEngine/UI/Input/`
|
||||||
|
- `engine/src/UI/Input/`
|
||||||
|
- `tests` 中 input/focus 测试
|
||||||
|
|
||||||
|
前置依赖:
|
||||||
|
|
||||||
|
- 依赖 `Subplan 01` 的 tree 和 hit-test 基础契约。
|
||||||
|
- 需要和 `Subplan 05` 对齐 adapter 输入桥接接口。
|
||||||
|
|
||||||
|
现在就可以先做的内容:
|
||||||
|
|
||||||
|
- 设计 `UIInputEvent` 丰富版本
|
||||||
|
- 设计 focus path / active path / capture path
|
||||||
|
- 设计 shortcut scope:global / window / panel / widget
|
||||||
|
- 写 focus 切换和冒泡/捕获测试
|
||||||
|
|
||||||
|
明确不做:
|
||||||
|
|
||||||
|
- 不做 Win32 原生消息处理细节
|
||||||
|
- 不做具体文本输入 widget
|
||||||
|
|
||||||
|
交付物:
|
||||||
|
|
||||||
|
- XCUI 输入分发器
|
||||||
|
- 焦点管理器
|
||||||
|
- 快捷键绑定与分发机制
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- 可以确定事件从哪里来、往哪里走、谁消费
|
||||||
|
- 焦点切换规则稳定可测
|
||||||
|
- 快捷键系统可与 editor shell 直接对接
|
||||||
@@ -405,6 +405,35 @@ add_library(XCEngine STATIC
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Platform/Windows/WindowsInputModule.h
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Platform/Windows/WindowsInputModule.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/src/Platform/Windows/WindowsInputModule.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/src/Platform/Windows/WindowsInputModule.cpp
|
||||||
|
|
||||||
|
# UI
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Types.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Core/UIInvalidation.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Core/UIViewModel.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Core/UIBuildContext.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Core/UIElementTree.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Core/UIContext.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Core/UIBuildContext.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Core/UIElementTree.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Layout/LayoutTypes.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Layout/LayoutEngine.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Style/StyleTypes.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Style/Theme.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Style/StyleSet.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Style/StyleResolver.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Style/StyleTypes.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Style/Theme.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Style/StyleResolver.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Input/UIInputPath.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Input/UIFocusController.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Input/UIShortcutRegistry.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Input/UIInputRouter.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Input/UIInputDispatcher.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Input/UIInputPath.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Input/UIFocusController.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Input/UIShortcutRegistry.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Input/UIInputRouter.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Input/UIInputDispatcher.cpp
|
||||||
|
|
||||||
# Input
|
# Input
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Input/InputTypes.h
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Input/InputTypes.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Input/InputEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Input/InputEvent.h
|
||||||
|
|||||||
65
engine/include/XCEngine/UI/Input/UIFocusController.h
Normal file
65
engine/include/XCEngine/UI/Input/UIFocusController.h
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "UIInputPath.h"
|
||||||
|
|
||||||
|
namespace XCEngine {
|
||||||
|
namespace UI {
|
||||||
|
|
||||||
|
struct UIFocusChange {
|
||||||
|
UIInputPath previousPath = {};
|
||||||
|
UIInputPath currentPath = {};
|
||||||
|
UIInputPath lostPath = {};
|
||||||
|
UIInputPath gainedPath = {};
|
||||||
|
|
||||||
|
bool Changed() const {
|
||||||
|
return previousPath != currentPath;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class UIFocusController {
|
||||||
|
public:
|
||||||
|
const UIInputPath& GetFocusedPath() const {
|
||||||
|
return m_focusedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UIInputPath& GetActivePath() const {
|
||||||
|
return m_activePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UIInputPath& GetPointerCapturePath() const {
|
||||||
|
return m_pointerCapturePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HasFocus() const {
|
||||||
|
return !m_focusedPath.Empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HasActivePath() const {
|
||||||
|
return !m_activePath.Empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HasPointerCapture() const {
|
||||||
|
return !m_pointerCapturePath.Empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
UIFocusChange SetFocusedPath(const UIInputPath& path);
|
||||||
|
UIFocusChange ClearFocus();
|
||||||
|
|
||||||
|
void SetActivePath(const UIInputPath& path);
|
||||||
|
void ClearActivePath();
|
||||||
|
|
||||||
|
void SetPointerCapturePath(const UIInputPath& path);
|
||||||
|
void ClearPointerCapturePath();
|
||||||
|
|
||||||
|
bool IsFocused(UIElementId elementId) const;
|
||||||
|
bool IsActive(UIElementId elementId) const;
|
||||||
|
bool IsCapturingPointer(UIElementId elementId) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
UIInputPath m_focusedPath = {};
|
||||||
|
UIInputPath m_activePath = {};
|
||||||
|
UIInputPath m_pointerCapturePath = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace UI
|
||||||
|
} // namespace XCEngine
|
||||||
110
engine/include/XCEngine/UI/Input/UIInputDispatcher.h
Normal file
110
engine/include/XCEngine/UI/Input/UIInputDispatcher.h
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "UIFocusController.h"
|
||||||
|
#include "UIInputRouter.h"
|
||||||
|
#include "UIShortcutRegistry.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace XCEngine {
|
||||||
|
namespace UI {
|
||||||
|
|
||||||
|
struct UIInputDispatcherOptions {
|
||||||
|
bool pointerDownChangesFocus = true;
|
||||||
|
bool pointerDownStartsActivePath = true;
|
||||||
|
UIPointerButton focusTransferButton = UIPointerButton::Left;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct UIInputDispatchSummary {
|
||||||
|
UIFocusChange focusChange = {};
|
||||||
|
UIInputDispatchResult routing = {};
|
||||||
|
bool shortcutHandled = false;
|
||||||
|
std::string commandId = {};
|
||||||
|
|
||||||
|
bool Handled() const {
|
||||||
|
return shortcutHandled || routing.handled;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class UIInputDispatcher {
|
||||||
|
public:
|
||||||
|
explicit UIInputDispatcher(
|
||||||
|
const UIInputDispatcherOptions& options = UIInputDispatcherOptions())
|
||||||
|
: m_options(options) {
|
||||||
|
}
|
||||||
|
|
||||||
|
UIFocusController& GetFocusController() {
|
||||||
|
return m_focusController;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UIFocusController& GetFocusController() const {
|
||||||
|
return m_focusController;
|
||||||
|
}
|
||||||
|
|
||||||
|
UIShortcutRegistry& GetShortcutRegistry() {
|
||||||
|
return m_shortcutRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UIShortcutRegistry& GetShortcutRegistry() const {
|
||||||
|
return m_shortcutRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename HandlerFn>
|
||||||
|
UIInputDispatchSummary Dispatch(
|
||||||
|
const UIInputEvent& event,
|
||||||
|
const UIInputPath& hoveredPath,
|
||||||
|
HandlerFn&& handler) {
|
||||||
|
UIInputDispatchSummary summary = {};
|
||||||
|
|
||||||
|
if (ShouldTransferFocusOnPointerDown(event, hoveredPath)) {
|
||||||
|
summary.focusChange = m_focusController.SetFocusedPath(hoveredPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ShouldStartActivePathOnPointerDown(event, hoveredPath)) {
|
||||||
|
const UIInputPath& capturePath = m_focusController.GetPointerCapturePath();
|
||||||
|
m_focusController.SetActivePath(capturePath.Empty() ? hoveredPath : capturePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const UIShortcutContext shortcutContext = {
|
||||||
|
m_focusController.GetFocusedPath(),
|
||||||
|
m_focusController.GetActivePath(),
|
||||||
|
hoveredPath };
|
||||||
|
const UIShortcutMatch shortcutMatch = m_shortcutRegistry.Match(event, shortcutContext);
|
||||||
|
if (shortcutMatch.matched) {
|
||||||
|
summary.shortcutHandled = true;
|
||||||
|
summary.commandId = shortcutMatch.binding.commandId;
|
||||||
|
return FinalizeDispatch(event, std::move(summary));
|
||||||
|
}
|
||||||
|
|
||||||
|
UIInputRouteContext routeContext = {};
|
||||||
|
routeContext.hoveredPath = hoveredPath;
|
||||||
|
routeContext.focusedPath = m_focusController.GetFocusedPath();
|
||||||
|
routeContext.capturePath = m_focusController.GetPointerCapturePath();
|
||||||
|
summary.routing = UIInputRouter::Dispatch(
|
||||||
|
event,
|
||||||
|
routeContext,
|
||||||
|
std::forward<HandlerFn>(handler));
|
||||||
|
return FinalizeDispatch(event, std::move(summary));
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool ShouldTransferFocusOnPointerDown(
|
||||||
|
const UIInputEvent& event,
|
||||||
|
const UIInputPath& hoveredPath) const;
|
||||||
|
|
||||||
|
bool ShouldStartActivePathOnPointerDown(
|
||||||
|
const UIInputEvent& event,
|
||||||
|
const UIInputPath& hoveredPath) const;
|
||||||
|
|
||||||
|
UIInputDispatchSummary FinalizeDispatch(
|
||||||
|
const UIInputEvent& event,
|
||||||
|
UIInputDispatchSummary&& summary);
|
||||||
|
|
||||||
|
UIInputDispatcherOptions m_options = {};
|
||||||
|
UIFocusController m_focusController = {};
|
||||||
|
UIShortcutRegistry m_shortcutRegistry = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace UI
|
||||||
|
} // namespace XCEngine
|
||||||
56
engine/include/XCEngine/UI/Input/UIInputPath.h
Normal file
56
engine/include/XCEngine/UI/Input/UIInputPath.h
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <initializer_list>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace XCEngine {
|
||||||
|
namespace UI {
|
||||||
|
|
||||||
|
using UIElementId = std::uint64_t;
|
||||||
|
|
||||||
|
struct UIInputPath {
|
||||||
|
std::vector<UIElementId> elements = {};
|
||||||
|
|
||||||
|
UIInputPath() = default;
|
||||||
|
UIInputPath(std::initializer_list<UIElementId> values)
|
||||||
|
: elements(values) {
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Empty() const {
|
||||||
|
return elements.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t Size() const {
|
||||||
|
return elements.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
UIElementId Root() const {
|
||||||
|
return elements.empty() ? 0u : elements.front();
|
||||||
|
}
|
||||||
|
|
||||||
|
UIElementId Target() const {
|
||||||
|
return elements.empty() ? 0u : elements.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Contains(UIElementId elementId) const;
|
||||||
|
|
||||||
|
void Clear() {
|
||||||
|
elements.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
bool operator==(const UIInputPath& lhs, const UIInputPath& rhs);
|
||||||
|
bool operator!=(const UIInputPath& lhs, const UIInputPath& rhs);
|
||||||
|
|
||||||
|
std::size_t GetUIInputPathCommonPrefixLength(
|
||||||
|
const UIInputPath& lhs,
|
||||||
|
const UIInputPath& rhs);
|
||||||
|
|
||||||
|
UIInputPath BuildUIInputPathSuffix(
|
||||||
|
const UIInputPath& path,
|
||||||
|
std::size_t startIndex);
|
||||||
|
|
||||||
|
} // namespace UI
|
||||||
|
} // namespace XCEngine
|
||||||
104
engine/include/XCEngine/UI/Input/UIInputRouter.h
Normal file
104
engine/include/XCEngine/UI/Input/UIInputRouter.h
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "UIInputPath.h"
|
||||||
|
|
||||||
|
#include <XCEngine/UI/Types.h>
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace XCEngine {
|
||||||
|
namespace UI {
|
||||||
|
|
||||||
|
enum class UIInputRoutingPhase : std::uint8_t {
|
||||||
|
Capture = 0,
|
||||||
|
Target,
|
||||||
|
Bubble
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class UIInputTargetKind : std::uint8_t {
|
||||||
|
None = 0,
|
||||||
|
Hovered,
|
||||||
|
Focused,
|
||||||
|
Captured
|
||||||
|
};
|
||||||
|
|
||||||
|
struct UIInputRouteContext {
|
||||||
|
UIInputPath hoveredPath = {};
|
||||||
|
UIInputPath focusedPath = {};
|
||||||
|
UIInputPath capturePath = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct UIInputRoutingStep {
|
||||||
|
UIElementId elementId = 0;
|
||||||
|
UIInputRoutingPhase phase = UIInputRoutingPhase::Target;
|
||||||
|
bool isTargetElement = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct UIInputRoutingPlan {
|
||||||
|
UIInputTargetKind targetKind = UIInputTargetKind::None;
|
||||||
|
UIInputPath targetPath = {};
|
||||||
|
std::vector<UIInputRoutingStep> steps = {};
|
||||||
|
|
||||||
|
bool HasTargetPath() const {
|
||||||
|
return !targetPath.Empty();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct UIInputDispatchRequest {
|
||||||
|
const UIInputEvent* event = nullptr;
|
||||||
|
UIElementId elementId = 0;
|
||||||
|
UIInputRoutingPhase phase = UIInputRoutingPhase::Target;
|
||||||
|
UIInputTargetKind targetKind = UIInputTargetKind::None;
|
||||||
|
bool isTargetElement = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct UIInputDispatchDecision {
|
||||||
|
bool handled = false;
|
||||||
|
bool stopPropagation = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct UIInputDispatchResult {
|
||||||
|
UIInputRoutingPlan plan = {};
|
||||||
|
bool handled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
class UIInputRouter {
|
||||||
|
public:
|
||||||
|
static bool IsPointerEvent(UIInputEventType type);
|
||||||
|
static bool IsKeyboardEvent(UIInputEventType type);
|
||||||
|
|
||||||
|
static UIInputRoutingPlan BuildRoutingPlan(
|
||||||
|
const UIInputEvent& event,
|
||||||
|
const UIInputRouteContext& context);
|
||||||
|
|
||||||
|
template <typename HandlerFn>
|
||||||
|
static UIInputDispatchResult Dispatch(
|
||||||
|
const UIInputEvent& event,
|
||||||
|
const UIInputRouteContext& context,
|
||||||
|
HandlerFn&& handler) {
|
||||||
|
UIInputDispatchResult result = {};
|
||||||
|
result.plan = BuildRoutingPlan(event, context);
|
||||||
|
|
||||||
|
for (const UIInputRoutingStep& step : result.plan.steps) {
|
||||||
|
UIInputDispatchRequest request = {};
|
||||||
|
request.event = &event;
|
||||||
|
request.elementId = step.elementId;
|
||||||
|
request.phase = step.phase;
|
||||||
|
request.targetKind = result.plan.targetKind;
|
||||||
|
request.isTargetElement = step.isTargetElement;
|
||||||
|
|
||||||
|
const UIInputDispatchDecision decision = handler(request);
|
||||||
|
result.handled = result.handled || decision.handled;
|
||||||
|
if (decision.stopPropagation) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace UI
|
||||||
|
} // namespace XCEngine
|
||||||
67
engine/include/XCEngine/UI/Input/UIShortcutRegistry.h
Normal file
67
engine/include/XCEngine/UI/Input/UIShortcutRegistry.h
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "UIInputPath.h"
|
||||||
|
|
||||||
|
#include <XCEngine/UI/Types.h>
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace XCEngine {
|
||||||
|
namespace UI {
|
||||||
|
|
||||||
|
enum class UIShortcutScope : std::uint8_t {
|
||||||
|
Global = 0,
|
||||||
|
Window,
|
||||||
|
Panel,
|
||||||
|
Widget
|
||||||
|
};
|
||||||
|
|
||||||
|
struct UIShortcutChord {
|
||||||
|
std::int32_t keyCode = 0;
|
||||||
|
UIInputModifiers modifiers = {};
|
||||||
|
bool allowRepeat = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct UIShortcutBinding {
|
||||||
|
std::uint64_t bindingId = 0;
|
||||||
|
UIShortcutScope scope = UIShortcutScope::Global;
|
||||||
|
UIElementId ownerId = 0;
|
||||||
|
UIInputEventType triggerEventType = UIInputEventType::KeyDown;
|
||||||
|
UIShortcutChord chord = {};
|
||||||
|
std::string commandId = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct UIShortcutContext {
|
||||||
|
UIInputPath focusedPath = {};
|
||||||
|
UIInputPath activePath = {};
|
||||||
|
UIInputPath hoveredPath = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct UIShortcutMatch {
|
||||||
|
bool matched = false;
|
||||||
|
UIShortcutBinding binding = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
class UIShortcutRegistry {
|
||||||
|
public:
|
||||||
|
std::uint64_t RegisterBinding(const UIShortcutBinding& binding);
|
||||||
|
bool UnregisterBinding(std::uint64_t bindingId);
|
||||||
|
void Clear();
|
||||||
|
|
||||||
|
const std::vector<UIShortcutBinding>& GetBindings() const {
|
||||||
|
return m_bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
UIShortcutMatch Match(
|
||||||
|
const UIInputEvent& event,
|
||||||
|
const UIShortcutContext& context) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<UIShortcutBinding> m_bindings = {};
|
||||||
|
std::uint64_t m_nextBindingId = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace UI
|
||||||
|
} // namespace XCEngine
|
||||||
102
engine/include/XCEngine/UI/Types.h
Normal file
102
engine/include/XCEngine/UI/Types.h
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace XCEngine {
|
||||||
|
namespace UI {
|
||||||
|
|
||||||
|
struct UIPoint {
|
||||||
|
float x = 0.0f;
|
||||||
|
float y = 0.0f;
|
||||||
|
|
||||||
|
constexpr UIPoint() = default;
|
||||||
|
constexpr UIPoint(float xValue, float yValue)
|
||||||
|
: x(xValue)
|
||||||
|
, y(yValue) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct UISize {
|
||||||
|
float width = 0.0f;
|
||||||
|
float height = 0.0f;
|
||||||
|
|
||||||
|
constexpr UISize() = default;
|
||||||
|
constexpr UISize(float widthValue, float heightValue)
|
||||||
|
: width(widthValue)
|
||||||
|
, height(heightValue) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct UIRect {
|
||||||
|
float x = 0.0f;
|
||||||
|
float y = 0.0f;
|
||||||
|
float width = 0.0f;
|
||||||
|
float height = 0.0f;
|
||||||
|
|
||||||
|
constexpr UIRect() = default;
|
||||||
|
constexpr UIRect(float xValue, float yValue, float widthValue, float heightValue)
|
||||||
|
: x(xValue)
|
||||||
|
, y(yValue)
|
||||||
|
, width(widthValue)
|
||||||
|
, height(heightValue) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct UITextureHandle {
|
||||||
|
std::uintptr_t nativeHandle = 0;
|
||||||
|
std::uint32_t width = 0;
|
||||||
|
std::uint32_t height = 0;
|
||||||
|
|
||||||
|
constexpr bool IsValid() const {
|
||||||
|
return nativeHandle != 0 && width > 0 && height > 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class UIPointerButton : std::uint8_t {
|
||||||
|
None = 0,
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
Middle,
|
||||||
|
X1,
|
||||||
|
X2
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class UIInputEventType : std::uint8_t {
|
||||||
|
None = 0,
|
||||||
|
PointerMove,
|
||||||
|
PointerEnter,
|
||||||
|
PointerLeave,
|
||||||
|
PointerButtonDown,
|
||||||
|
PointerButtonUp,
|
||||||
|
PointerWheel,
|
||||||
|
KeyDown,
|
||||||
|
KeyUp,
|
||||||
|
Character,
|
||||||
|
FocusGained,
|
||||||
|
FocusLost
|
||||||
|
};
|
||||||
|
|
||||||
|
struct UIInputModifiers {
|
||||||
|
bool shift = false;
|
||||||
|
bool control = false;
|
||||||
|
bool alt = false;
|
||||||
|
bool super = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct UIInputEvent {
|
||||||
|
UIInputEventType type = UIInputEventType::None;
|
||||||
|
UIPoint position = {};
|
||||||
|
UIPoint delta = {};
|
||||||
|
float wheelDelta = 0.0f;
|
||||||
|
std::int32_t keyCode = 0;
|
||||||
|
std::uint32_t character = 0;
|
||||||
|
std::uint32_t pointerId = 0;
|
||||||
|
std::uint64_t timestampNanoseconds = 0;
|
||||||
|
UIPointerButton pointerButton = UIPointerButton::None;
|
||||||
|
UIInputModifiers modifiers = {};
|
||||||
|
bool repeat = false;
|
||||||
|
bool synthetic = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace UI
|
||||||
|
} // namespace XCEngine
|
||||||
63
engine/src/UI/Input/UIFocusController.cpp
Normal file
63
engine/src/UI/Input/UIFocusController.cpp
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
#include <XCEngine/UI/Input/UIFocusController.h>
|
||||||
|
|
||||||
|
namespace XCEngine {
|
||||||
|
namespace UI {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
UIFocusChange BuildFocusChange(
|
||||||
|
const UIInputPath& previousPath,
|
||||||
|
const UIInputPath& currentPath) {
|
||||||
|
UIFocusChange change = {};
|
||||||
|
change.previousPath = previousPath;
|
||||||
|
change.currentPath = currentPath;
|
||||||
|
|
||||||
|
const std::size_t commonPrefixLength =
|
||||||
|
GetUIInputPathCommonPrefixLength(previousPath, currentPath);
|
||||||
|
change.lostPath = BuildUIInputPathSuffix(previousPath, commonPrefixLength);
|
||||||
|
change.gainedPath = BuildUIInputPathSuffix(currentPath, commonPrefixLength);
|
||||||
|
return change;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
UIFocusChange UIFocusController::SetFocusedPath(const UIInputPath& path) {
|
||||||
|
const UIInputPath previousPath = m_focusedPath;
|
||||||
|
m_focusedPath = path;
|
||||||
|
return BuildFocusChange(previousPath, m_focusedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
UIFocusChange UIFocusController::ClearFocus() {
|
||||||
|
return SetFocusedPath({});
|
||||||
|
}
|
||||||
|
|
||||||
|
void UIFocusController::SetActivePath(const UIInputPath& path) {
|
||||||
|
m_activePath = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
void UIFocusController::ClearActivePath() {
|
||||||
|
m_activePath.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void UIFocusController::SetPointerCapturePath(const UIInputPath& path) {
|
||||||
|
m_pointerCapturePath = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
void UIFocusController::ClearPointerCapturePath() {
|
||||||
|
m_pointerCapturePath.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool UIFocusController::IsFocused(UIElementId elementId) const {
|
||||||
|
return m_focusedPath.Contains(elementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool UIFocusController::IsActive(UIElementId elementId) const {
|
||||||
|
return m_activePath.Contains(elementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool UIFocusController::IsCapturingPointer(UIElementId elementId) const {
|
||||||
|
return m_pointerCapturePath.Contains(elementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace UI
|
||||||
|
} // namespace XCEngine
|
||||||
34
engine/src/UI/Input/UIInputDispatcher.cpp
Normal file
34
engine/src/UI/Input/UIInputDispatcher.cpp
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#include <XCEngine/UI/Input/UIInputDispatcher.h>
|
||||||
|
|
||||||
|
namespace XCEngine {
|
||||||
|
namespace UI {
|
||||||
|
|
||||||
|
bool UIInputDispatcher::ShouldTransferFocusOnPointerDown(
|
||||||
|
const UIInputEvent& event,
|
||||||
|
const UIInputPath& hoveredPath) const {
|
||||||
|
return m_options.pointerDownChangesFocus &&
|
||||||
|
event.type == UIInputEventType::PointerButtonDown &&
|
||||||
|
event.pointerButton == m_options.focusTransferButton &&
|
||||||
|
!hoveredPath.Empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool UIInputDispatcher::ShouldStartActivePathOnPointerDown(
|
||||||
|
const UIInputEvent& event,
|
||||||
|
const UIInputPath& hoveredPath) const {
|
||||||
|
return m_options.pointerDownStartsActivePath &&
|
||||||
|
event.type == UIInputEventType::PointerButtonDown &&
|
||||||
|
!hoveredPath.Empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
UIInputDispatchSummary UIInputDispatcher::FinalizeDispatch(
|
||||||
|
const UIInputEvent& event,
|
||||||
|
UIInputDispatchSummary&& summary) {
|
||||||
|
if (event.type == UIInputEventType::PointerButtonUp) {
|
||||||
|
m_focusController.ClearActivePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
return std::move(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace UI
|
||||||
|
} // namespace XCEngine
|
||||||
45
engine/src/UI/Input/UIInputPath.cpp
Normal file
45
engine/src/UI/Input/UIInputPath.cpp
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#include <XCEngine/UI/Input/UIInputPath.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
namespace XCEngine {
|
||||||
|
namespace UI {
|
||||||
|
|
||||||
|
bool UIInputPath::Contains(UIElementId elementId) const {
|
||||||
|
return std::find(elements.begin(), elements.end(), elementId) != elements.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool operator==(const UIInputPath& lhs, const UIInputPath& rhs) {
|
||||||
|
return lhs.elements == rhs.elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool operator!=(const UIInputPath& lhs, const UIInputPath& rhs) {
|
||||||
|
return !(lhs == rhs);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t GetUIInputPathCommonPrefixLength(
|
||||||
|
const UIInputPath& lhs,
|
||||||
|
const UIInputPath& rhs) {
|
||||||
|
const std::size_t limit = (std::min)(lhs.elements.size(), rhs.elements.size());
|
||||||
|
std::size_t index = 0;
|
||||||
|
while (index < limit && lhs.elements[index] == rhs.elements[index]) {
|
||||||
|
++index;
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
UIInputPath BuildUIInputPathSuffix(
|
||||||
|
const UIInputPath& path,
|
||||||
|
std::size_t startIndex) {
|
||||||
|
UIInputPath suffix = {};
|
||||||
|
if (startIndex >= path.elements.size()) {
|
||||||
|
return suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
suffix.elements.assign(path.elements.begin() + static_cast<std::ptrdiff_t>(startIndex), path.elements.end());
|
||||||
|
return suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace UI
|
||||||
|
} // namespace XCEngine
|
||||||
86
engine/src/UI/Input/UIInputRouter.cpp
Normal file
86
engine/src/UI/Input/UIInputRouter.cpp
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
#include <XCEngine/UI/Input/UIInputRouter.h>
|
||||||
|
|
||||||
|
namespace XCEngine {
|
||||||
|
namespace UI {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
UIInputRoutingPlan BuildPlanForTargetPath(
|
||||||
|
UIInputTargetKind targetKind,
|
||||||
|
const UIInputPath& path) {
|
||||||
|
UIInputRoutingPlan plan = {};
|
||||||
|
plan.targetKind = targetKind;
|
||||||
|
plan.targetPath = path;
|
||||||
|
|
||||||
|
if (path.Empty()) {
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::size_t size = path.elements.size();
|
||||||
|
for (std::size_t index = 0; index + 1 < size; ++index) {
|
||||||
|
UIInputRoutingStep step = {};
|
||||||
|
step.elementId = path.elements[index];
|
||||||
|
step.phase = UIInputRoutingPhase::Capture;
|
||||||
|
plan.steps.push_back(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
UIInputRoutingStep targetStep = {};
|
||||||
|
targetStep.elementId = path.Target();
|
||||||
|
targetStep.phase = UIInputRoutingPhase::Target;
|
||||||
|
targetStep.isTargetElement = true;
|
||||||
|
plan.steps.push_back(targetStep);
|
||||||
|
|
||||||
|
for (std::size_t index = size; index > 1; --index) {
|
||||||
|
UIInputRoutingStep bubbleStep = {};
|
||||||
|
bubbleStep.elementId = path.elements[index - 2];
|
||||||
|
bubbleStep.phase = UIInputRoutingPhase::Bubble;
|
||||||
|
plan.steps.push_back(bubbleStep);
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool UIInputRouter::IsPointerEvent(UIInputEventType type) {
|
||||||
|
switch (type) {
|
||||||
|
case UIInputEventType::PointerMove:
|
||||||
|
case UIInputEventType::PointerEnter:
|
||||||
|
case UIInputEventType::PointerLeave:
|
||||||
|
case UIInputEventType::PointerButtonDown:
|
||||||
|
case UIInputEventType::PointerButtonUp:
|
||||||
|
case UIInputEventType::PointerWheel:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool UIInputRouter::IsKeyboardEvent(UIInputEventType type) {
|
||||||
|
return type == UIInputEventType::KeyDown ||
|
||||||
|
type == UIInputEventType::KeyUp ||
|
||||||
|
type == UIInputEventType::Character;
|
||||||
|
}
|
||||||
|
|
||||||
|
UIInputRoutingPlan UIInputRouter::BuildRoutingPlan(
|
||||||
|
const UIInputEvent& event,
|
||||||
|
const UIInputRouteContext& context) {
|
||||||
|
if (IsPointerEvent(event.type)) {
|
||||||
|
if (!context.capturePath.Empty()) {
|
||||||
|
return BuildPlanForTargetPath(UIInputTargetKind::Captured, context.capturePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildPlanForTargetPath(UIInputTargetKind::Hovered, context.hoveredPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsKeyboardEvent(event.type) ||
|
||||||
|
event.type == UIInputEventType::FocusGained ||
|
||||||
|
event.type == UIInputEventType::FocusLost) {
|
||||||
|
return BuildPlanForTargetPath(UIInputTargetKind::Focused, context.focusedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace UI
|
||||||
|
} // namespace XCEngine
|
||||||
121
engine/src/UI/Input/UIShortcutRegistry.cpp
Normal file
121
engine/src/UI/Input/UIShortcutRegistry.cpp
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
#include <XCEngine/UI/Input/UIShortcutRegistry.h>
|
||||||
|
|
||||||
|
namespace XCEngine {
|
||||||
|
namespace UI {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
const UIInputPath& ResolvePrimaryShortcutPath(const UIShortcutContext& context) {
|
||||||
|
if (!context.activePath.Empty()) {
|
||||||
|
return context.activePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.focusedPath.Empty()) {
|
||||||
|
return context.focusedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.hoveredPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ModifiersEqual(
|
||||||
|
const UIInputModifiers& lhs,
|
||||||
|
const UIInputModifiers& rhs) {
|
||||||
|
return lhs.shift == rhs.shift &&
|
||||||
|
lhs.control == rhs.control &&
|
||||||
|
lhs.alt == rhs.alt &&
|
||||||
|
lhs.super == rhs.super;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsBindingActive(
|
||||||
|
const UIShortcutBinding& binding,
|
||||||
|
const UIShortcutContext& context) {
|
||||||
|
if (binding.scope == UIShortcutScope::Global) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binding.ownerId == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResolvePrimaryShortcutPath(context).Contains(binding.ownerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DoesBindingMatchEvent(
|
||||||
|
const UIShortcutBinding& binding,
|
||||||
|
const UIInputEvent& event) {
|
||||||
|
return binding.triggerEventType == event.type &&
|
||||||
|
binding.chord.keyCode == event.keyCode &&
|
||||||
|
ModifiersEqual(binding.chord.modifiers, event.modifiers) &&
|
||||||
|
(binding.chord.allowRepeat || !event.repeat);
|
||||||
|
}
|
||||||
|
|
||||||
|
int GetShortcutScopePriority(UIShortcutScope scope) {
|
||||||
|
switch (scope) {
|
||||||
|
case UIShortcutScope::Widget:
|
||||||
|
return 3;
|
||||||
|
case UIShortcutScope::Panel:
|
||||||
|
return 2;
|
||||||
|
case UIShortcutScope::Window:
|
||||||
|
return 1;
|
||||||
|
case UIShortcutScope::Global:
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::uint64_t UIShortcutRegistry::RegisterBinding(const UIShortcutBinding& binding) {
|
||||||
|
UIShortcutBinding storedBinding = binding;
|
||||||
|
storedBinding.bindingId = storedBinding.bindingId != 0 ? storedBinding.bindingId : m_nextBindingId++;
|
||||||
|
if (storedBinding.bindingId >= m_nextBindingId) {
|
||||||
|
m_nextBindingId = storedBinding.bindingId + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_bindings.push_back(storedBinding);
|
||||||
|
return m_bindings.back().bindingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool UIShortcutRegistry::UnregisterBinding(std::uint64_t bindingId) {
|
||||||
|
for (auto it = m_bindings.begin(); it != m_bindings.end(); ++it) {
|
||||||
|
if (it->bindingId != bindingId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_bindings.erase(it);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void UIShortcutRegistry::Clear() {
|
||||||
|
m_bindings.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
UIShortcutMatch UIShortcutRegistry::Match(
|
||||||
|
const UIInputEvent& event,
|
||||||
|
const UIShortcutContext& context) const {
|
||||||
|
UIShortcutMatch bestMatch = {};
|
||||||
|
int bestPriority = -1;
|
||||||
|
|
||||||
|
for (const UIShortcutBinding& binding : m_bindings) {
|
||||||
|
if (!DoesBindingMatchEvent(binding, event) || !IsBindingActive(binding, context)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int priority = GetShortcutScopePriority(binding.scope);
|
||||||
|
if (!bestMatch.matched ||
|
||||||
|
priority > bestPriority ||
|
||||||
|
(priority == bestPriority && binding.bindingId > bestMatch.binding.bindingId)) {
|
||||||
|
bestMatch.matched = true;
|
||||||
|
bestMatch.binding = binding;
|
||||||
|
bestPriority = priority;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace UI
|
||||||
|
} // namespace XCEngine
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
set(INPUT_TEST_SOURCES
|
set(INPUT_TEST_SOURCES
|
||||||
test_input_manager.cpp
|
test_input_manager.cpp
|
||||||
test_windows_input_module.cpp
|
test_windows_input_module.cpp
|
||||||
|
test_xcui_input_dispatcher.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
add_executable(input_tests ${INPUT_TEST_SOURCES})
|
add_executable(input_tests ${INPUT_TEST_SOURCES})
|
||||||
|
|||||||
222
tests/Input/test_xcui_input_dispatcher.cpp
Normal file
222
tests/Input/test_xcui_input_dispatcher.cpp
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <XCEngine/Input/InputTypes.h>
|
||||||
|
#include <XCEngine/UI/Input/UIFocusController.h>
|
||||||
|
#include <XCEngine/UI/Input/UIInputDispatcher.h>
|
||||||
|
#include <XCEngine/UI/Input/UIInputRouter.h>
|
||||||
|
#include <XCEngine/UI/Input/UIShortcutRegistry.h>
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using XCEngine::Input::KeyCode;
|
||||||
|
using XCEngine::UI::UIElementId;
|
||||||
|
using XCEngine::UI::UIFocusController;
|
||||||
|
using XCEngine::UI::UIInputDispatchDecision;
|
||||||
|
using XCEngine::UI::UIInputDispatcher;
|
||||||
|
using XCEngine::UI::UIInputEvent;
|
||||||
|
using XCEngine::UI::UIInputEventType;
|
||||||
|
using XCEngine::UI::UIInputPath;
|
||||||
|
using XCEngine::UI::UIInputRouteContext;
|
||||||
|
using XCEngine::UI::UIInputRouter;
|
||||||
|
using XCEngine::UI::UIInputRoutingPhase;
|
||||||
|
using XCEngine::UI::UIInputTargetKind;
|
||||||
|
using XCEngine::UI::UIPointerButton;
|
||||||
|
using XCEngine::UI::UIShortcutBinding;
|
||||||
|
using XCEngine::UI::UIShortcutContext;
|
||||||
|
using XCEngine::UI::UIShortcutRegistry;
|
||||||
|
using XCEngine::UI::UIShortcutScope;
|
||||||
|
|
||||||
|
std::string BuildTraceLabel(
|
||||||
|
UIElementId elementId,
|
||||||
|
UIInputRoutingPhase phase) {
|
||||||
|
char phaseChar = 'T';
|
||||||
|
switch (phase) {
|
||||||
|
case UIInputRoutingPhase::Capture:
|
||||||
|
phaseChar = 'C';
|
||||||
|
break;
|
||||||
|
case UIInputRoutingPhase::Bubble:
|
||||||
|
phaseChar = 'B';
|
||||||
|
break;
|
||||||
|
case UIInputRoutingPhase::Target:
|
||||||
|
default:
|
||||||
|
phaseChar = 'T';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return std::to_string(elementId) + phaseChar;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(XCUIFocusControllerTest, SetFocusedPathTracksLostAndGainedSuffixes) {
|
||||||
|
UIFocusController controller = {};
|
||||||
|
EXPECT_FALSE(controller.HasFocus());
|
||||||
|
|
||||||
|
const auto initialChange = controller.SetFocusedPath({ 1u, 2u, 3u });
|
||||||
|
EXPECT_TRUE(initialChange.Changed());
|
||||||
|
EXPECT_EQ(initialChange.gainedPath.elements, (std::vector<UIElementId>{ 1u, 2u, 3u }));
|
||||||
|
EXPECT_TRUE(initialChange.lostPath.elements.empty());
|
||||||
|
EXPECT_TRUE(controller.HasFocus());
|
||||||
|
|
||||||
|
const auto change = controller.SetFocusedPath({ 1u, 4u });
|
||||||
|
EXPECT_TRUE(change.Changed());
|
||||||
|
EXPECT_EQ(change.previousPath.elements, (std::vector<UIElementId>{ 1u, 2u, 3u }));
|
||||||
|
EXPECT_EQ(change.currentPath.elements, (std::vector<UIElementId>{ 1u, 4u }));
|
||||||
|
EXPECT_EQ(change.lostPath.elements, (std::vector<UIElementId>{ 2u, 3u }));
|
||||||
|
EXPECT_EQ(change.gainedPath.elements, (std::vector<UIElementId>{ 4u }));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(XCUIInputRouterTest, PointerCaptureOverridesHoveredPath) {
|
||||||
|
UIInputEvent event = {};
|
||||||
|
event.type = UIInputEventType::PointerMove;
|
||||||
|
|
||||||
|
UIInputRouteContext context = {};
|
||||||
|
context.hoveredPath = { 10u, 20u, 30u };
|
||||||
|
context.capturePath = { 90u, 100u };
|
||||||
|
|
||||||
|
const auto plan = UIInputRouter::BuildRoutingPlan(event, context);
|
||||||
|
|
||||||
|
EXPECT_EQ(plan.targetKind, UIInputTargetKind::Captured);
|
||||||
|
EXPECT_EQ(plan.targetPath.elements, (std::vector<UIElementId>{ 90u, 100u }));
|
||||||
|
ASSERT_EQ(plan.steps.size(), 3u);
|
||||||
|
EXPECT_EQ(plan.steps[0].elementId, 90u);
|
||||||
|
EXPECT_EQ(plan.steps[0].phase, UIInputRoutingPhase::Capture);
|
||||||
|
EXPECT_EQ(plan.steps[1].elementId, 100u);
|
||||||
|
EXPECT_EQ(plan.steps[1].phase, UIInputRoutingPhase::Target);
|
||||||
|
EXPECT_EQ(plan.steps[2].elementId, 90u);
|
||||||
|
EXPECT_EQ(plan.steps[2].phase, UIInputRoutingPhase::Bubble);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(XCUIInputRouterTest, KeyboardEventsRouteThroughFocusedPathInCaptureTargetBubbleOrder) {
|
||||||
|
UIInputEvent event = {};
|
||||||
|
event.type = UIInputEventType::KeyDown;
|
||||||
|
event.keyCode = static_cast<std::int32_t>(KeyCode::Enter);
|
||||||
|
|
||||||
|
UIInputRouteContext context = {};
|
||||||
|
context.focusedPath = { 1u, 2u, 3u };
|
||||||
|
|
||||||
|
std::vector<std::string> trace = {};
|
||||||
|
const auto result = UIInputRouter::Dispatch(
|
||||||
|
event,
|
||||||
|
context,
|
||||||
|
[&trace](const auto& request) {
|
||||||
|
trace.push_back(BuildTraceLabel(request.elementId, request.phase));
|
||||||
|
return UIInputDispatchDecision{};
|
||||||
|
});
|
||||||
|
|
||||||
|
EXPECT_FALSE(result.handled);
|
||||||
|
EXPECT_EQ(trace, (std::vector<std::string>{ "1C", "2C", "3T", "2B", "1B" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(XCUIShortcutRegistryTest, MatchingPrefersMostSpecificScopeThenNewestBinding) {
|
||||||
|
UIShortcutRegistry registry = {};
|
||||||
|
|
||||||
|
UIShortcutBinding globalBinding = {};
|
||||||
|
globalBinding.scope = UIShortcutScope::Global;
|
||||||
|
globalBinding.chord.keyCode = static_cast<std::int32_t>(KeyCode::S);
|
||||||
|
globalBinding.chord.modifiers.control = true;
|
||||||
|
globalBinding.commandId = "save.global";
|
||||||
|
registry.RegisterBinding(globalBinding);
|
||||||
|
|
||||||
|
UIShortcutBinding panelBinding = {};
|
||||||
|
panelBinding.scope = UIShortcutScope::Panel;
|
||||||
|
panelBinding.ownerId = 20u;
|
||||||
|
panelBinding.chord.keyCode = static_cast<std::int32_t>(KeyCode::S);
|
||||||
|
panelBinding.chord.modifiers.control = true;
|
||||||
|
panelBinding.commandId = "save.panel";
|
||||||
|
registry.RegisterBinding(panelBinding);
|
||||||
|
|
||||||
|
UIShortcutBinding widgetBinding = {};
|
||||||
|
widgetBinding.scope = UIShortcutScope::Widget;
|
||||||
|
widgetBinding.ownerId = 30u;
|
||||||
|
widgetBinding.chord.keyCode = static_cast<std::int32_t>(KeyCode::S);
|
||||||
|
widgetBinding.chord.modifiers.control = true;
|
||||||
|
widgetBinding.commandId = "save.widget";
|
||||||
|
registry.RegisterBinding(widgetBinding);
|
||||||
|
|
||||||
|
UIInputEvent event = {};
|
||||||
|
event.type = UIInputEventType::KeyDown;
|
||||||
|
event.keyCode = static_cast<std::int32_t>(KeyCode::S);
|
||||||
|
event.modifiers.control = true;
|
||||||
|
|
||||||
|
UIShortcutContext context = {};
|
||||||
|
context.focusedPath = { 10u, 20u, 30u };
|
||||||
|
EXPECT_EQ(registry.Match(event, context).binding.commandId, "save.widget");
|
||||||
|
|
||||||
|
context.focusedPath = { 10u, 20u };
|
||||||
|
EXPECT_EQ(registry.Match(event, context).binding.commandId, "save.panel");
|
||||||
|
|
||||||
|
context.focusedPath.Clear();
|
||||||
|
EXPECT_EQ(registry.Match(event, context).binding.commandId, "save.global");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(XCUIInputDispatcherTest, PointerDownTransfersFocusAndMaintainsActivePathUntilPointerUp) {
|
||||||
|
UIInputDispatcher dispatcher;
|
||||||
|
|
||||||
|
UIInputEvent pointerDown = {};
|
||||||
|
pointerDown.type = UIInputEventType::PointerButtonDown;
|
||||||
|
pointerDown.pointerButton = UIPointerButton::Left;
|
||||||
|
|
||||||
|
const auto downSummary = dispatcher.Dispatch(
|
||||||
|
pointerDown,
|
||||||
|
UIInputPath{ 100u, 200u },
|
||||||
|
[](const auto&) {
|
||||||
|
return UIInputDispatchDecision{};
|
||||||
|
});
|
||||||
|
|
||||||
|
EXPECT_TRUE(downSummary.focusChange.Changed());
|
||||||
|
EXPECT_EQ(dispatcher.GetFocusController().GetFocusedPath().elements, (std::vector<UIElementId>{ 100u, 200u }));
|
||||||
|
EXPECT_EQ(dispatcher.GetFocusController().GetActivePath().elements, (std::vector<UIElementId>{ 100u, 200u }));
|
||||||
|
EXPECT_EQ(downSummary.routing.plan.targetKind, UIInputTargetKind::Hovered);
|
||||||
|
|
||||||
|
UIInputEvent pointerUp = {};
|
||||||
|
pointerUp.type = UIInputEventType::PointerButtonUp;
|
||||||
|
pointerUp.pointerButton = UIPointerButton::Left;
|
||||||
|
dispatcher.Dispatch(
|
||||||
|
pointerUp,
|
||||||
|
UIInputPath{ 100u, 200u },
|
||||||
|
[](const auto&) {
|
||||||
|
return UIInputDispatchDecision{};
|
||||||
|
});
|
||||||
|
|
||||||
|
EXPECT_TRUE(dispatcher.GetFocusController().GetActivePath().Empty());
|
||||||
|
EXPECT_EQ(dispatcher.GetFocusController().GetFocusedPath().elements, (std::vector<UIElementId>{ 100u, 200u }));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(XCUIInputDispatcherTest, ShortcutMatchConsumesKeyboardDispatchBeforeRouting) {
|
||||||
|
UIInputDispatcher dispatcher;
|
||||||
|
dispatcher.GetFocusController().SetFocusedPath({ 1u, 2u, 3u });
|
||||||
|
|
||||||
|
UIShortcutBinding binding = {};
|
||||||
|
binding.scope = UIShortcutScope::Widget;
|
||||||
|
binding.ownerId = 3u;
|
||||||
|
binding.chord.keyCode = static_cast<std::int32_t>(KeyCode::P);
|
||||||
|
binding.chord.modifiers.control = true;
|
||||||
|
binding.commandId = "palette.open";
|
||||||
|
dispatcher.GetShortcutRegistry().RegisterBinding(binding);
|
||||||
|
|
||||||
|
UIInputEvent event = {};
|
||||||
|
event.type = UIInputEventType::KeyDown;
|
||||||
|
event.keyCode = static_cast<std::int32_t>(KeyCode::P);
|
||||||
|
event.modifiers.control = true;
|
||||||
|
|
||||||
|
bool handlerCalled = false;
|
||||||
|
const auto summary = dispatcher.Dispatch(
|
||||||
|
event,
|
||||||
|
{},
|
||||||
|
[&handlerCalled](const auto&) {
|
||||||
|
handlerCalled = true;
|
||||||
|
UIInputDispatchDecision decision = {};
|
||||||
|
decision.handled = true;
|
||||||
|
return decision;
|
||||||
|
});
|
||||||
|
|
||||||
|
EXPECT_TRUE(summary.shortcutHandled);
|
||||||
|
EXPECT_EQ(summary.commandId, "palette.open");
|
||||||
|
EXPECT_FALSE(handlerCalled);
|
||||||
|
EXPECT_FALSE(summary.routing.plan.HasTargetPath());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user