Files
endorphin-tessera-gui/src/shell/system_ui/interaction.rs
T

285 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 即时绘制控件的跨帧交互状态:稳定 WidgetId、悬停、按下捕获、键盘焦点和释放激活。
//! 控件用语义 key 标识,而不是屏幕位置或行号。释放时必须命中原启用目标;在外释放不激活,移回后释放仍可激活。
//!
use crate::geometry::{Point, Rect};
use std::hash::{Hash, Hasher};
/// Stable across frames, resizing and text changes. Use semantic keys, never row indices.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
/// 由语义 key 生成的稳定控件身份,跨帧移动和文字变化不应改变它。
pub struct WidgetId(u64);
impl WidgetId {
/// 对调用方语义 key 求哈希,生成不随控件位置或标签变化的跨帧 WidgetId;相同 key 会得到同一身份。
// widgets 的按钮按 Action 生成身份,拖动控件由 Painter 显式传语义 key;同帧重建几何不应重置身份。
pub fn new(key: impl Hash) -> Self {
// 语义 key 哈希为会话内身份;不依赖布局坐标,但调用方仍需避免重复 key,哈希并非全局唯一保证。
let mut hash = std::collections::hash_map::DefaultHasher::new();
key.hash(&mut hash);
Self(hash.finish())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
/// 分隔条和滚动条的操作方向。
pub enum Axis {
Horizontal,
Vertical,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
/// 管理控件角色,决定焦点资格和系统光标。
pub enum Role {
Button,
Tab,
Toggle,
TextField,
Row,
Swatch,
Splitter(Axis),
Scrollbar(Axis),
}
impl Role {
/// 判断控件角色是否可以接受键盘焦点。
// Interaction::press 和 focus_next 共用资格规则,只有允许键盘导航的角色能通过这两个入口得到焦点。
fn focusable(self) -> bool {
// 行用于指针选择,分隔条/滚动条用于拖动,这三类不加入键盘 Tab 焦点循环。
!matches!(self, Self::Splitter(_) | Self::Scrollbar(_) | Self::Row)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
/// 宿主控件请求的系统光标种类。
pub enum Cursor {
Default,
Pointer,
Text,
ResizeHorizontal,
ResizeVertical,
Grab,
Disabled,
}
#[derive(Clone, Debug)]
/// 本帧可命中的管理控件,绑定几何、角色、启用条件与可选 action。
pub struct Target<A> {
// 对应跨帧保留的焦点/捕获键,矩形可每帧变化;ID 本身不携带屏幕坐标。
pub id: WidgetId,
// 本帧逻辑命中矩形,通常与绘制一致,行/分隔条可显式用不同的绘制与交互范围。
pub rect: Rect,
// 决定焦点资格、指针光标和 TextField 的按下处理例外;不会直接执行 action。
pub role: Role,
// 禁用仍参与几何命中以遮挡下层控件,但不能被 press 捕获、Tab 聚焦或释放激活。
pub enabled: bool,
// 激活时克隆交给宿主处理;分隔条/滚动条没有点击动作,拖动参数由上层布局另存。
pub action: Option<A>,
}
#[derive(Clone, Copy, Debug)]
/// 绘制使用的组合交互状态,避免按钮自己重新推断焦点/捕获。
pub struct VisualState {
// enabled 且当前悬停身份匹配;与 selected 独立。
pub hovered: bool,
// 键盘捕获匹配或鼠标捕获且仍悬停;指针拖出只取消外观,不立即清掉捕获。
pub pressed: bool,
// 仅键盘来源焦点显示此状态;鼠标聚焦也能保存 focused 身份,但不会显示键盘焦点圈。
pub focused: bool,
// 由当前页面或工具数据决定的选中态,不从 hover/press 推断,禁用也不自动清除此数据状态。
pub selected: bool,
pub enabled: bool,
}
/// Retained input state for an immediate-mode frame. Actions fire on release inside
/// the same enabled control, so dragging away cancels a click.
#[derive(Clone, Debug, Default)]
/// 即时绘制之外保留的输入状态,确保按下与释放跨帧仍匹配同一控件。
pub struct Interaction {
// 最近逻辑指针位置;None 时 hit 不命中,即使仍保留 pointer_active 拖动捕获。
pointer: Option<Point>,
hovered: Option<WidgetId>,
// 按下时捕获的控件;即使指针移动到别处,也保留原身份直到释放或取消。
pointer_active: Option<WidgetId>,
// 键盘按下时的焦点身份;释放时再次匹配,避免焦点已变却激活新控件。
key_active: Option<WidgetId>,
// 文本或按钮焦点身份与键盘来源标记分开;鼠标也能给可聚焦目标 focused,但不显示键盘焦点圈。
focused: Option<WidgetId>,
// 区分键盘导航和鼠标获得焦点,visual 仅为键盘来源显示焦点外观。
keyboard_focus: bool,
}
impl Interaction {
/// 按逆绘制顺序选择指针处的管理控件,避免重叠控件同时激活。
// app、工具与其他交互方法共用,targets 追加顺序对应绘制层级,末尾目标优先。
pub fn hit<'a, A>(&self, targets: &'a [Target<A>]) -> Option<&'a Target<A>> {
// 无指针位置直接 None,pointer_left 后不会继续用上一次窗口坐标重新命中。
let p = self.pointer?;
// 几何命中包含禁用控件,因此上层禁用项会挡住下层项;是否允许操作由 press/cursor 分别判断。
targets.iter().rev().find(|t| t.rect.contains(p))
}
/// 更新指针与悬停目标,但不在移动时直接触发点击。
// 原生指针移动先更新真实 Interaction,Ui 在下一帧克隆它查询外观;移动本身不改捕获或焦点。
pub fn pointer_moved<A>(&mut self, p: Point, targets: &[Target<A>]) {
self.pointer = Some(p);
self.hovered = self.hit(targets).map(|t| t.id);
}
/// 清除离开窗口后的指针/悬停状态。
// app/DeveloperToolsState 的离开路径调用,仅清指针与 hover,外层决定是否进一步取消操作。
pub fn pointer_left(&mut self) {
// 离开窗口只清位置和悬停,保留捕获身份供外层拖动流程使用;完整失焦由 blur 处理。
self.pointer = None;
self.hovered = None;
}
/// 捕获启用控件的稳定身份,并按角色更新键盘焦点。
// app 侧栏和 DeveloperToolsState 按下调用,克隆目标让上层按角色决定拖动或文本焦点,不跨调用借用 targets。
pub fn press<A: Clone>(&mut self, targets: &[Target<A>]) -> Option<Target<A>> {
// 鼠标按下撤销旧键盘激活并关闭键盘焦点外观;点空白或不可聚焦角色会清除 focused。
self.key_active = None;
self.keyboard_focus = false;
// 先取最上层几何目标再按 enabled 过滤,不能因它被禁用而穿透到下层启用控件。
let target = self.hit(targets).filter(|t| t.enabled).cloned();
self.pointer_active = target.as_ref().map(|t| t.id);
// 按角色更新焦点:空白、禁用或不可聚焦角色令焦点清空,捕获身份与焦点可不同。
self.focused = target.as_ref().filter(|t| t.role.focusable()).map(|t| t.id);
target
}
/// 只有释放时仍处于原启用目标内才返回 action,拖出后释放不触发。
// 侧栏和工具鼠标抬起调用,必须匹配按下 ID;TextField 已在按下阶段处理定位,不再抬起激活。
pub fn release<A: Clone>(&mut self, targets: &[Target<A>]) -> Option<A> {
// take 先消费捕获,保证同一次按下最多激活一次;TextField 不走按钮 release action。
let active = self.pointer_active.take()?;
self.hit(targets)
.filter(|t| t.enabled && t.id == active && t.role != Role::TextField)
.and_then(|t| t.action.clone())
}
/// 撤销当前操作的后续交付资格,并执行对应取消清理。
// 侧栏折叠或焦点导航前调用,清未交付按压但保留指针、悬停和 focused;完整失焦另走 blur。
pub fn cancel(&mut self) {
self.pointer_active = None;
self.key_active = None;
}
/// 撤销失焦后的键盘/指针激活状态,防止回到窗口时补发旧操作。
// 窗口失焦或工具切走时调用,组合清捕获、焦点、键盘外观和指针位置,防止回来补发旧操作。
pub fn blur(&mut self) {
self.cancel();
self.focused = None;
self.keyboard_focus = false;
self.pointer_left();
}
/// 用本帧目标集合校正跨帧焦点和捕获,已消失控件不保留可激活身份。
// 每帧 Ui 目标重建后由 sidebar::draw 或 developer_tools::build 调用,返回外观相关身份是否变化供上层决定重绘。
pub fn reconcile<A>(&mut self, targets: &[Target<A>]) -> bool {
// 保存更新前四种身份,末尾与更新后比较;键盘来源标记本函数不修改。
let before = (
self.hovered,
self.pointer_active,
self.focused,
self.key_active,
);
// 重建 UI 后必须仍存在且启用才保留焦点/捕获;hover 则依据当前指针重新命中,包含禁用项。
let exists = |id| targets.iter().any(|t| t.id == id && t.enabled);
self.focused = self.focused.filter(|id| exists(*id));
self.pointer_active = self.pointer_active.filter(|id| exists(*id));
self.key_active = self.key_active.filter(|id| exists(*id));
self.hovered = self.hit(targets).map(|t| t.id);
before
!= (
self.hovered,
self.pointer_active,
self.focused,
self.key_active,
)
}
/// 返回当前仍持有焦点的稳定控件身份。
// key_press/key_release 与工具键盘路由共用,只有当前集合仍有相同启用 ID 才返回目标。
pub fn focused<'a, A>(&self, targets: &'a [Target<A>]) -> Option<&'a Target<A>> {
targets
.iter()
.find(|t| Some(t.id) == self.focused && t.enabled)
}
/// 沿启用且可聚焦的目标循环导航,支持正向与反向遍历。
// app/工具处理 Tab 导航时调用,返回目标克隆供外层映射文本焦点,顺序按本帧登记而非屏幕坐标排序。
pub fn focus_next<A: Clone>(&mut self, targets: &[Target<A>], back: bool) -> Option<Target<A>> {
// 键盘导航先取消未完成按压,避免切换焦点后补发旧激活;候选顺序沿本帧目标顺序。
self.cancel();
self.keyboard_focus = true;
// 只收集 enabled 且 focusable 目标,禁用项和纯拖动区域不会占 Tab 一站。
let candidates = targets
.iter()
.filter(|t| t.enabled && t.role.focusable())
.collect::<Vec<_>>();
// 空集合清焦点并返回,后面的 len-1 与取模依赖这里已确保非空。
if candidates.is_empty() {
self.focused = None;
return None;
}
// 旧焦点可能已隐藏或禁用,找不到时按从首/末开始,而非沿用旧数组下标。
let current = candidates.iter().position(|t| Some(t.id) == self.focused);
// 有当前焦点时环绕前进/后退;无有效焦点时正向选首项、反向选末项。
let index = match current {
Some(i) if back => (i + candidates.len() - 1) % candidates.len(),
Some(i) => (i + 1) % candidates.len(),
None if back => candidates.len() - 1,
None => 0,
};
// 把焦点写成语义身份而不是候选位置,下一帧重排后仍可校验同一控件。
let target = candidates[index];
self.focused = Some(target.id);
Some(target.clone())
}
/// 为当前焦点控件记录键盘激活,后续释放仍须匹配目标。
// Enter/Space 按下调用,记录当前启用非文本框焦点,重复按下是否抑制由上层事件路由决定。
pub fn key_press<A>(&mut self, targets: &[Target<A>]) {
self.key_active = self
.focused(targets)
.filter(|t| t.role != Role::TextField)
.map(|t| t.id);
}
/// 结束键盘激活并按有效控件身份返回操作。
// Enter/Space 抬起调用,先消费旧捕获,再核对当前启用焦点与 ID,相同才返回 action。
pub fn key_release<A: Clone>(&mut self, targets: &[Target<A>]) -> Option<A> {
// 只有释放时仍聚焦相同启用目标才返回动作;take 防止重复 key-up 重复激活。
let active = self.key_active.take()?;
self.focused(targets)
.filter(|t| t.id == active)
.and_then(|t| t.action.clone())
}
/// 组合悬停、按下、焦点、选中和启用状态,供控件选择正确样式。
// Ui::register 在绘制控件时查询,selected/enabled 由本帧数据传入,其余身份来自跨帧 Interaction。
pub fn visual(&self, id: WidgetId, selected: bool, enabled: bool) -> VisualState {
// 禁用控件不显示交互状态;指针按压需仍悬停才显示 pressed,拖出后保留捕获但取消按下外观。
// hovered 只在启用时成立;键盘按下不要求鼠标悬停,鼠标按下外观则须仍停在原捕获目标。
let hovered = enabled && self.hovered == Some(id);
VisualState {
hovered,
pressed: enabled
&& (self.key_active == Some(id) || (hovered && self.pointer_active == Some(id))),
focused: enabled && self.keyboard_focus && self.focused == Some(id),
selected,
enabled,
}
}
/// 返回本次拖动/按压捕获的控件,移动过程中不随悬停目标跳变。
// widgets 的 splitter/scrollbar 用它保持拖出后的按压颜色;普通按钮不使用这个外观例外。
pub fn captured(&self, id: WidgetId) -> bool {
self.pointer_active == Some(id)
}
/// 按命中角色选择系统光标,例如文本、分隔条或可点击按钮。
// DeveloperToolsState 把结果映射为 winit 原生光标,当前捕获的拖动角色优先于新悬停目标。
pub fn cursor<A>(&self, targets: &[Target<A>]) -> Cursor {
// 拖动分隔条/滚动条时优先沿用捕获目标的光标,避免指针跨过其他控件导致拖动光标跳变。
let target = self
.pointer_active
.and_then(|id| {
targets.iter().find(|t| {
t.id == id && matches!(t.role, Role::Splitter(_) | Role::Scrollbar(_))
})
})
.or_else(|| self.hit(targets));
// 命中禁用项返回禁止图标,无命中返回默认;其他角色按文本、调整、抓取或可点击分别映射。
match target {
Some(t) if !t.enabled => Cursor::Disabled,
Some(t) => match t.role {
Role::TextField => Cursor::Text,
Role::Splitter(Axis::Horizontal) => Cursor::ResizeHorizontal,
Role::Splitter(Axis::Vertical) => Cursor::ResizeVertical,
Role::Scrollbar(_) => Cursor::Grab,
_ => Cursor::Pointer,
},
None => Cursor::Default,
}
}
}