From 611ca705c8e9fa70203303062de10003cc45c382 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Sat, 4 Apr 2026 18:55:20 +0800 Subject: [PATCH] Add XCUI input focus shortcut MVP --- ...us_Shortcut_Subplan_完成归档_2026-04-04.md | 140 +++++++++++ docs/plan/xcui-subplans/README.md | 44 ++++ .../Subplan-04_XCUI-Input-Focus-Shortcut.md | 53 +++++ engine/CMakeLists.txt | 29 +++ .../XCEngine/UI/Input/UIFocusController.h | 65 +++++ .../XCEngine/UI/Input/UIInputDispatcher.h | 110 +++++++++ .../include/XCEngine/UI/Input/UIInputPath.h | 56 +++++ .../include/XCEngine/UI/Input/UIInputRouter.h | 104 ++++++++ .../XCEngine/UI/Input/UIShortcutRegistry.h | 67 ++++++ engine/include/XCEngine/UI/Types.h | 102 ++++++++ engine/src/UI/Input/UIFocusController.cpp | 63 +++++ engine/src/UI/Input/UIInputDispatcher.cpp | 34 +++ engine/src/UI/Input/UIInputPath.cpp | 45 ++++ engine/src/UI/Input/UIInputRouter.cpp | 86 +++++++ engine/src/UI/Input/UIShortcutRegistry.cpp | 121 ++++++++++ tests/Input/CMakeLists.txt | 1 + tests/Input/test_xcui_input_dispatcher.cpp | 222 ++++++++++++++++++ 17 files changed, 1342 insertions(+) create mode 100644 docs/plan/used/XCUI_Input_Focus_Shortcut_Subplan_完成归档_2026-04-04.md create mode 100644 docs/plan/xcui-subplans/README.md create mode 100644 docs/plan/xcui-subplans/Subplan-04_XCUI-Input-Focus-Shortcut.md create mode 100644 engine/include/XCEngine/UI/Input/UIFocusController.h create mode 100644 engine/include/XCEngine/UI/Input/UIInputDispatcher.h create mode 100644 engine/include/XCEngine/UI/Input/UIInputPath.h create mode 100644 engine/include/XCEngine/UI/Input/UIInputRouter.h create mode 100644 engine/include/XCEngine/UI/Input/UIShortcutRegistry.h create mode 100644 engine/include/XCEngine/UI/Types.h create mode 100644 engine/src/UI/Input/UIFocusController.cpp create mode 100644 engine/src/UI/Input/UIInputDispatcher.cpp create mode 100644 engine/src/UI/Input/UIInputPath.cpp create mode 100644 engine/src/UI/Input/UIInputRouter.cpp create mode 100644 engine/src/UI/Input/UIShortcutRegistry.cpp create mode 100644 tests/Input/test_xcui_input_dispatcher.cpp diff --git a/docs/plan/used/XCUI_Input_Focus_Shortcut_Subplan_完成归档_2026-04-04.md b/docs/plan/used/XCUI_Input_Focus_Shortcut_Subplan_完成归档_2026-04-04.md new file mode 100644 index 00000000..ce76dc47 --- /dev/null +++ b/docs/plan/used/XCUI_Input_Focus_Shortcut_Subplan_完成归档_2026-04-04.md @@ -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` + +其状态应视为: + +- 已完成 +- 已归档 +- 不再作为活跃开发计划继续扩写 diff --git a/docs/plan/xcui-subplans/README.md b/docs/plan/xcui-subplans/README.md new file mode 100644 index 00000000..d460f5ff --- /dev/null +++ b/docs/plan/xcui-subplans/README.md @@ -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 diff --git a/docs/plan/xcui-subplans/Subplan-04_XCUI-Input-Focus-Shortcut.md b/docs/plan/xcui-subplans/Subplan-04_XCUI-Input-Focus-Shortcut.md new file mode 100644 index 00000000..ab3dc121 --- /dev/null +++ b/docs/plan/xcui-subplans/Subplan-04_XCUI-Input-Focus-Shortcut.md @@ -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 直接对接 diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 53fe8a3e..0142ac3a 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -405,6 +405,35 @@ add_library(XCEngine STATIC ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Platform/Windows/WindowsInputModule.h ${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 ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Input/InputTypes.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Input/InputEvent.h diff --git a/engine/include/XCEngine/UI/Input/UIFocusController.h b/engine/include/XCEngine/UI/Input/UIFocusController.h new file mode 100644 index 00000000..7d115578 --- /dev/null +++ b/engine/include/XCEngine/UI/Input/UIFocusController.h @@ -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 diff --git a/engine/include/XCEngine/UI/Input/UIInputDispatcher.h b/engine/include/XCEngine/UI/Input/UIInputDispatcher.h new file mode 100644 index 00000000..c56e18ab --- /dev/null +++ b/engine/include/XCEngine/UI/Input/UIInputDispatcher.h @@ -0,0 +1,110 @@ +#pragma once + +#include "UIFocusController.h" +#include "UIInputRouter.h" +#include "UIShortcutRegistry.h" + +#include +#include + +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 + 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(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 diff --git a/engine/include/XCEngine/UI/Input/UIInputPath.h b/engine/include/XCEngine/UI/Input/UIInputPath.h new file mode 100644 index 00000000..077ef816 --- /dev/null +++ b/engine/include/XCEngine/UI/Input/UIInputPath.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include +#include + +namespace XCEngine { +namespace UI { + +using UIElementId = std::uint64_t; + +struct UIInputPath { + std::vector elements = {}; + + UIInputPath() = default; + UIInputPath(std::initializer_list 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 diff --git a/engine/include/XCEngine/UI/Input/UIInputRouter.h b/engine/include/XCEngine/UI/Input/UIInputRouter.h new file mode 100644 index 00000000..239b95d2 --- /dev/null +++ b/engine/include/XCEngine/UI/Input/UIInputRouter.h @@ -0,0 +1,104 @@ +#pragma once + +#include "UIInputPath.h" + +#include + +#include +#include +#include + +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 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 + 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 diff --git a/engine/include/XCEngine/UI/Input/UIShortcutRegistry.h b/engine/include/XCEngine/UI/Input/UIShortcutRegistry.h new file mode 100644 index 00000000..a683e698 --- /dev/null +++ b/engine/include/XCEngine/UI/Input/UIShortcutRegistry.h @@ -0,0 +1,67 @@ +#pragma once + +#include "UIInputPath.h" + +#include + +#include +#include +#include + +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& GetBindings() const { + return m_bindings; + } + + UIShortcutMatch Match( + const UIInputEvent& event, + const UIShortcutContext& context) const; + +private: + std::vector m_bindings = {}; + std::uint64_t m_nextBindingId = 1; +}; + +} // namespace UI +} // namespace XCEngine diff --git a/engine/include/XCEngine/UI/Types.h b/engine/include/XCEngine/UI/Types.h new file mode 100644 index 00000000..7024c667 --- /dev/null +++ b/engine/include/XCEngine/UI/Types.h @@ -0,0 +1,102 @@ +#pragma once + +#include + +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 diff --git a/engine/src/UI/Input/UIFocusController.cpp b/engine/src/UI/Input/UIFocusController.cpp new file mode 100644 index 00000000..1cabe139 --- /dev/null +++ b/engine/src/UI/Input/UIFocusController.cpp @@ -0,0 +1,63 @@ +#include + +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 diff --git a/engine/src/UI/Input/UIInputDispatcher.cpp b/engine/src/UI/Input/UIInputDispatcher.cpp new file mode 100644 index 00000000..ce6da490 --- /dev/null +++ b/engine/src/UI/Input/UIInputDispatcher.cpp @@ -0,0 +1,34 @@ +#include + +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 diff --git a/engine/src/UI/Input/UIInputPath.cpp b/engine/src/UI/Input/UIInputPath.cpp new file mode 100644 index 00000000..19fbd8a2 --- /dev/null +++ b/engine/src/UI/Input/UIInputPath.cpp @@ -0,0 +1,45 @@ +#include + +#include +#include + +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(startIndex), path.elements.end()); + return suffix; +} + +} // namespace UI +} // namespace XCEngine diff --git a/engine/src/UI/Input/UIInputRouter.cpp b/engine/src/UI/Input/UIInputRouter.cpp new file mode 100644 index 00000000..a88a55ea --- /dev/null +++ b/engine/src/UI/Input/UIInputRouter.cpp @@ -0,0 +1,86 @@ +#include + +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 diff --git a/engine/src/UI/Input/UIShortcutRegistry.cpp b/engine/src/UI/Input/UIShortcutRegistry.cpp new file mode 100644 index 00000000..96e97094 --- /dev/null +++ b/engine/src/UI/Input/UIShortcutRegistry.cpp @@ -0,0 +1,121 @@ +#include + +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 diff --git a/tests/Input/CMakeLists.txt b/tests/Input/CMakeLists.txt index 1f65986c..d0c2bcde 100644 --- a/tests/Input/CMakeLists.txt +++ b/tests/Input/CMakeLists.txt @@ -5,6 +5,7 @@ set(INPUT_TEST_SOURCES test_input_manager.cpp test_windows_input_module.cpp + test_xcui_input_dispatcher.cpp ) add_executable(input_tests ${INPUT_TEST_SOURCES}) diff --git a/tests/Input/test_xcui_input_dispatcher.cpp b/tests/Input/test_xcui_input_dispatcher.cpp new file mode 100644 index 00000000..3951996c --- /dev/null +++ b/tests/Input/test_xcui_input_dispatcher.cpp @@ -0,0 +1,222 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +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{ 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{ 1u, 2u, 3u })); + EXPECT_EQ(change.currentPath.elements, (std::vector{ 1u, 4u })); + EXPECT_EQ(change.lostPath.elements, (std::vector{ 2u, 3u })); + EXPECT_EQ(change.gainedPath.elements, (std::vector{ 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{ 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(KeyCode::Enter); + + UIInputRouteContext context = {}; + context.focusedPath = { 1u, 2u, 3u }; + + std::vector 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{ "1C", "2C", "3T", "2B", "1B" })); +} + +TEST(XCUIShortcutRegistryTest, MatchingPrefersMostSpecificScopeThenNewestBinding) { + UIShortcutRegistry registry = {}; + + UIShortcutBinding globalBinding = {}; + globalBinding.scope = UIShortcutScope::Global; + globalBinding.chord.keyCode = static_cast(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(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(KeyCode::S); + widgetBinding.chord.modifiers.control = true; + widgetBinding.commandId = "save.widget"; + registry.RegisterBinding(widgetBinding); + + UIInputEvent event = {}; + event.type = UIInputEventType::KeyDown; + event.keyCode = static_cast(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{ 100u, 200u })); + EXPECT_EQ(dispatcher.GetFocusController().GetActivePath().elements, (std::vector{ 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{ 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(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(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()); +}