//! 即时绘制控件的跨帧交互状态:稳定 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 { // 对应跨帧保留的焦点/捕获键,矩形可每帧变化;ID 本身不携带屏幕坐标。 pub id: WidgetId, // 本帧逻辑命中矩形,通常与绘制一致,行/分隔条可显式用不同的绘制与交互范围。 pub rect: Rect, pub role: Role, pub enabled: bool, // 激活时克隆交给宿主处理;分隔条/滚动条没有点击动作,拖动参数由上层布局另存。 pub action: Option, } #[derive(Clone, Copy, Debug)] /// 绘制使用的组合交互状态,避免按钮自己重新推断焦点/捕获。 pub struct VisualState { pub hovered: bool, pub pressed: bool, 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, hovered: Option, // 按下时捕获的控件;即使指针移动到别处,也保留原身份直到释放或取消。 pointer_active: Option, // 键盘按下时的焦点身份;释放时再次匹配,避免焦点已变却激活新控件。 key_active: Option, // 文本或按钮焦点身份与键盘来源标记分开;鼠标也能给可聚焦目标 focused,但不显示键盘焦点圈。 focused: Option, // 区分键盘导航和鼠标获得焦点,visual 仅为键盘来源显示焦点外观。 keyboard_focus: bool, } impl Interaction { /// 按逆绘制顺序选择指针处的管理控件,避免重叠控件同时激活。 // app、工具与其他交互方法共用,targets 追加顺序对应绘制层级,末尾目标优先。 pub fn hit<'a, A>(&self, targets: &'a [Target]) -> Option<&'a Target> { // 无指针位置直接 None,pointer_left 后不会继续用上一次窗口坐标重新命中。 let p = self.pointer?; // 几何命中包含禁用控件,因此上层禁用项会挡住下层项;是否允许操作由 press/cursor 分别判断。 targets.iter().rev().find(|t| t.rect.contains(p)) } /// 更新指针与悬停目标,但不在移动时直接触发点击。 // 原生指针移动先更新真实 Interaction,Ui 在下一帧克隆它查询外观;移动本身不改捕获或焦点。 pub fn pointer_moved(&mut self, p: Point, targets: &[Target]) { 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(&mut self, targets: &[Target]) -> Option> { // 鼠标按下撤销旧键盘激活并关闭键盘焦点外观;点空白或不可聚焦角色会清除 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(&mut self, targets: &[Target]) -> Option { // 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(&mut self, targets: &[Target]) -> 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]) -> Option<&'a Target> { targets .iter() .find(|t| Some(t.id) == self.focused && t.enabled) } /// 沿启用且可聚焦的目标循环导航,支持正向与反向遍历。 // app/工具处理 Tab 导航时调用,返回目标克隆供外层映射文本焦点,顺序按本帧登记而非屏幕坐标排序。 pub fn focus_next(&mut self, targets: &[Target], back: bool) -> Option> { // 键盘导航先取消未完成按压,避免切换焦点后补发旧激活;候选顺序沿本帧目标顺序。 self.cancel(); self.keyboard_focus = true; // 只收集 enabled 且 focusable 目标,禁用项和纯拖动区域不会占 Tab 一站。 let candidates = targets .iter() .filter(|t| t.enabled && t.role.focusable()) .collect::>(); // 空集合清焦点并返回,后面的 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(&mut self, targets: &[Target]) { self.key_active = self .focused(targets) .filter(|t| t.role != Role::TextField) .map(|t| t.id); } /// 结束键盘激活并按有效控件身份返回操作。 // Enter/Space 抬起调用,先消费旧捕获,再核对当前启用焦点与 ID,相同才返回 action。 pub fn key_release(&mut self, targets: &[Target]) -> Option { // 只有释放时仍聚焦相同启用目标才返回动作;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(&self, targets: &[Target]) -> 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, } } }