From edf513bff7775de2c6bab1d2e02c28071bb6368e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 20:17:03 +0800 Subject: [PATCH] docs: clarify shell input window and host widget lifecycles --- src/shell/app.rs | 40 ++++++++++++++++++++++++++++++++++ src/shell/native_chrome.rs | 7 ++++++ src/shell/system_ui/widgets.rs | 10 +++++++++ 3 files changed, 57 insertions(+) diff --git a/src/shell/app.rs b/src/shell/app.rs index c6aac49..692ad64 100644 --- a/src/shell/app.rs +++ b/src/shell/app.rs @@ -2,6 +2,10 @@ //! 窗口线程不执行 Endor 业务回调;共享状态通过锁读取,实际 VM 更新由 runtime worker 串行完成。 //! 侧栏、页面与开发工具有各自命中区域。拖动捕获必须保留最初的视口目标,不能随指针经过其他组件而跳转。 //! +//! main 调用 new 后交 winit 事件循环的 run_app 驱动:resumed 建立原生资源,window_event 处理 OS 输入,user_event 接收 worker/工具通知。 +//! DocumentUpdated 先协调子窗口和一次性窗口命令,再更新焦点令牌;RedrawRequested 才读取完整文档生成显示列表并 present。 +//! 主窗口 NamespaceState 是 worker 的共享发布入口,子窗口本地状态只是该窗文档投影;所有窗口的 event_client 仍通向同一个权威队列。 +//! OS WindowId、运行时窗口 resource、VM session、layout_version 和 Mount ID 各有用途,不能只凭屏幕位置或某个数字相同认定同一目标。 use std::{ num::NonZeroU32, sync::{Arc, RwLock}, @@ -207,6 +211,7 @@ impl TesseraApp { submission, ..NamespaceState::default() })); + // 新建的是宿主窗口状态,共享活动日志/控制/输入客户端;不会在此为副窗口建立另一套解释器。 let mut child = Self::with_event_client( namespace, self.activity.clone(), @@ -264,11 +269,13 @@ impl TesseraApp { .and_then(|child| child.window.as_ref()) }; let Some(window) = target else { + // 该批已一次性消费,没有 OS 句柄的目标不会留到以后按同编号重试,避免影响后来创建的资源。 continue; }; // 尺寸使用逻辑单位,桌面位置使用物理屏幕坐标;这些 OS 操作都在原生事件线程执行。 match envelope.command.action { crate::window::Action::Resize { width, height } => { + // 向窗口管理器发逻辑尺寸请求,返回值不当作最终 viewport;批结束后重新读取真实窗口大小。 let _ = window.request_inner_size(LogicalSize::new(width, height)); } crate::window::Action::SetTitle(title) => window.set_title(&title), @@ -389,6 +396,7 @@ impl TesseraApp { // 先使原生资源对应新文档,再执行其窗口命令;随后重建焦点令牌,避免沿用旧布局身份。 self.reconcile_windows(event_loop); self.apply_window_commands(); + // 重新读取的是资源/命令同步后的完整本窗投影,焦点和工具标记来自这份发布 Frame,而非 GUI 自行猜测。 if let Ok(namespace) = self.namespace.read() { self.developer_tools.document_updated(&namespace.document); let frame = &namespace.document.frame; @@ -448,6 +456,7 @@ impl TesseraApp { { self.space_down = event.state == ElementState::Pressed; if !self.space_down { + // Space 释放结束正在进行的整页/局部平移,不能因漏掉鼠标释放而一直把后续移动当作拖动。 self.panning = false; } return; @@ -469,6 +478,7 @@ impl TesseraApp { Key::Named(NamedKey::Enter | NamedKey::Space) ) { if event.state == ElementState::Pressed { + // 侧栏只保存按下捕获,真正动作等下面对应释放确认;不在按下和抬起各激活一次。 self.sidebar.interaction.key_press(&self.sidebar.targets); } else if let Some(action) = self.sidebar.interaction.key_release(&self.sidebar.targets) @@ -479,6 +489,7 @@ impl TesseraApp { return; } } + // 新页快捷键在侧栏键盘路由之后处理;只首次按下发一个工具请求,系统重复不创建多页。 if event.state == ElementState::Pressed && !event.repeat && (self.modifiers.super_key() || self.modifiers.control_key()) @@ -494,6 +505,7 @@ impl TesseraApp { && matches!(&event.logical_key, Key::Character(key) if key.eq_ignore_ascii_case("w")); let cycle = self.modifiers.control_key() && matches!(event.logical_key, Key::Named(NamedKey::Tab)); + // 只有确有关闭/切页动作且能构造当前页事件才消费按键,否则继续页面与工具的普通键盘处理。 if (close || cycle) && self.send_tab_shortcut(close) { return; } @@ -528,6 +540,7 @@ impl TesseraApp { let other_shortcut = self.modifiers.control_key() && self.modifiers.shift_key() && is_i; if is_f12 || mac_shortcut || other_shortcut { if !event.repeat { + // 快捷键只请求打开 Console;实际工具 Page 由所属 VM 创建/激活,窗口线程不直接分配托管对象。 self.open_developer_tool("Console"); } return; @@ -627,6 +640,7 @@ impl TesseraApp { let current = self.namespace.read().ok()?; let frame = ¤t.document.frame; if frame.session == 0 { + // reset/失活后仍可显示旧静态文档,但没有活 VM 身份时不能从它构造可执行点击令牌。 return None; } // 命中的是当前发布帧,事件同时携带 session/layout_version;worker 可拒绝排队期间已过期的输入。 @@ -651,6 +665,7 @@ impl TesseraApp { /// 左键捕获和悬停移动共用,从粗命中出现向祖先寻找 pointer 订阅,不把所有交互目标都当作连续指针接收者。 fn pointer_target(&self) -> Option { let mut input = self.guest_hit()?; + // 这里只沿本窗最新出现树找订阅祖先,保留命中令牌的窗口/会话/版本;执行端仍需再次核验。 let current = self.namespace.read().ok()?; let frame = ¤t.document.frame; let mut mount = frame.mounts.iter().find(|m| m.id == input.mount)?; @@ -679,6 +694,7 @@ impl TesseraApp { self.emit_pointer(input, "pointercancel"); } if let Some(input) = self.pointer_hover.take() { + // 悬停与按下捕获是两份状态,取消捕获后仍须单独告知原悬停目标离开;take 保证重复清理不重发。 self.emit_pointer(input, "pointerleave"); } } @@ -710,6 +726,7 @@ impl TesseraApp { let input = self.namespace.read().ok().and_then(|namespace| { let frame = &namespace.document.frame; if frame.session == 0 || frame.fault.is_some() || frame.tabs.is_empty() { + // 没有活会话、帧已故障或标签列表为空时都不能生成页动作,尤其不能对空列表取模。 return None; } // 无显式 active 时从第一项开始;tabs 已检查非空,后续取模和前向环绕不会除零或下溢。 @@ -750,6 +767,7 @@ impl TesseraApp { // 侧栏鼠标、键盘以及命令+B 调用,UI 侧只调整宿主开合,业务页面操作仍发带版本事件。 fn sidebar_action(&mut self, action: crate::sidebar::Action) { use crate::sidebar::Action; + // Toggle 属于宿主布局状态;页面激活/关闭与工具入口则转换成 worker 请求,两类动作不能混成直接 VM 调用。 match action { Action::Toggle => { self.sidebar.open = !self.sidebar.open; @@ -801,6 +819,7 @@ impl TesseraApp { let Some(point) = self.guest_point() else { return false; }; + // 只借已发布帧选择局部相机,锁外不保留 Mount 引用;pan_viewport 保存的是纯数值出现身份。 let Ok(current) = self.namespace.read() else { return false; }; @@ -817,6 +836,7 @@ impl TesseraApp { if !self.panning && self.guest_covered(self.pointer) { return; } + // 此处先得到页面视图坐标,下面闭包才用当前 Surface.to_world 变成页面世界点;不把侧栏偏移带入相机空间。 let point = Point::new(self.pointer.x - self.sidebar.left(), self.pointer.y); let input = self.namespace.read().ok().and_then(|current| { let frame = ¤t.document.frame; @@ -859,6 +879,7 @@ impl TesseraApp { layout_version: frame.layout_version, mount: viewport.id, kind: if viewport.scene3d_viewport { + // 同一入口按目标能力选择 scene-zoom/orbit/pan,二维 viewport 不使用三维轨道语义。 if kind == "page-zoom" { "scene-zoom" } else if self.orbiting { @@ -933,9 +954,11 @@ impl TesseraApp { // 首次 Tab 按下选下一焦点出现,抬起或系统重复只根据已有 guest_focus 决定是否消费。 if *key == NamedKey::Tab { if event.state == ElementState::Pressed && !event.repeat { + // 每次焦点移动读取本窗发布候选,不在上一次保存的几何里按索引盲目选择下一项。 let next = self.namespace.read().ok().and_then(|namespace| { let frame = &namespace.document.frame; if frame.session == 0 || frame.fault.is_some() { + // 静态保留画面或故障域不能响应焦点移动,返回 None 让调用方按未消费处理。 return None; } // 按发布帧中的挂载顺序遍历可交互目标;Shift+Tab 逆向环绕,尚无焦点时从首/末项进入。 @@ -954,6 +977,7 @@ impl TesseraApp { .as_ref() .and_then(|focus| targets.iter().position(|mount| mount.id == focus.mount)); let index = if self.modifiers.shift_key() { + // 逆序首次进入取末项,已有焦点则向前环绕;正序分支首次进入取首项。 current .map(|i| (i + targets.len() - 1) % targets.len()) .unwrap_or(targets.len() - 1) @@ -988,6 +1012,7 @@ impl TesseraApp { if matches!(key, NamedKey::Enter | NamedKey::Space) && self.guest_focus.is_some() { if event.state == ElementState::Pressed { if !event.repeat { + // 保存首次按下时的键名与版本令牌,系统连发不能覆盖捕获后在释放时制造额外点击。 self.guest_key = self.guest_focus.clone().map(|focus| (*key, focus)); } // 释放会取走捕获,保证不会重复激活;仅与原来按下的 Enter/Space 相同才转换成 click。 @@ -1040,6 +1065,7 @@ impl TesseraApp { let window = Arc::new(event_loop.create_window(attributes)?); #[cfg(target_os = "macos")] crate::native_chrome::align_controls(&window); + // Context 与 Surface 都持 Window Arc;这两个局部资源创建成功后才写入 self,失败不会留下半套可重绘字段。 let context = Context::new(window.clone()).map_err(|error| anyhow::anyhow!("{error}"))?; let surface = Surface::new(&context, window.clone()).map_err(|error| anyhow::anyhow!("{error}"))?; @@ -1096,10 +1122,12 @@ impl TesseraApp { match self.try_redraw() { Ok(()) => { if self.window.is_some() && self.surface.is_some() { + // try_redraw 无原生资源时也可返回 Ok;这里只在资源齐全时清上一次错误,不据此重复发送文档通知。 self.native_error = None; } } Err(error) => { + // 保留窗口资源编号定位错误,文本相同的连续失败不反复投递 RuntimeFailed 刷屏。 let message = format!("Window {} redraw failed: {error:#}", self.resource); if self.native_error.as_ref() != Some(&message) { self.native_error = Some(message.clone()); @@ -1132,6 +1160,7 @@ impl TesseraApp { }; let Ok(namespace) = self.namespace.read() else { + // 取不到可靠发布文档时跳过本次重绘,不用局部旧字段拼一份不一致场景,也不把锁故障转换成 VM 调用。 return Ok(()); }; // 读锁保护一次完整文档取样与场景生成;生成后释放锁,像素写入和 present 不再占用 namespace。 @@ -1198,6 +1227,7 @@ impl ApplicationHandler for TesseraApp { match self.control.wait_shutdown(Duration::from_secs(5)) { Some(outcome) => { if let Some(error) = outcome.error { + // 完成通知只证明 worker 已结束清理,仍须保留其错误供 main 的 exit_result 报告。 self.native_error = Some(error); } } @@ -1245,6 +1275,7 @@ impl ApplicationHandler for TesseraApp { match event { AppEvent::ShutdownComplete { error } => { if let Some(error) = error { + // 与 exiting 的最终等待复用同一错误结果;接收完成事件后才结束 winit 循环,不在此重新清理 VM。 self.native_error = Some(error); } event_loop.exit(); @@ -1307,6 +1338,7 @@ impl ApplicationHandler for TesseraApp { if self.resource == crate::window::MAIN_WINDOW { self.request_exit(); } else { + // 副窗口关闭携带当前本地发布投影的身份,worker 会对照主 Namespace 的权威资源再决定源关闭或故障退役。 let input = self .namespace .read() @@ -1324,6 +1356,7 @@ impl ApplicationHandler for TesseraApp { screen_position: None, }); if let Some(input) = input { + // 只是尝试入队,不立即删除 OS 窗口;实际资源消失由后续文档协调或 Close 命令反映。 self.send_guest(input); } } @@ -1362,6 +1395,7 @@ impl ApplicationHandler for TesseraApp { // 3D 相机拖动超过世界坐标下的 3 单位阈值后转为 orbit,并清除 click 捕获,避免释放时误激活。 if is_3d && (point.x - start[0]).hypot(point.y - start[1]) > 3. { self.orbiting = true; + // 超阈值后才开始相机手势,begin_pan 按当前指针位置选相机;普通点击捕获随后丢弃。 self.panning = self.begin_pan(); self.guest_pressed = None; } @@ -1451,6 +1485,7 @@ impl ApplicationHandler for TesseraApp { { #[cfg(target_os = "macos")] if crate::native_chrome::titlebar_double_click(&window) { + // macOS 原生双击标题栏已消费本次动作,不再叠加一次系统窗口拖动。 return; } let _ = window.drag_window(); @@ -1537,6 +1572,7 @@ impl ApplicationHandler for TesseraApp { || point.is_some_and(|point| n.document.frame.camera_at(point).is_some()) }); if canvas && (self.modifiers.super_key() || self.modifiers.control_key()) { + // 命令+滚轮优先转换为缩放倍率,钳到事件协议范围;返回后不会再把同一次滚轮当内容滚动。 self.page_view("page-zoom", [(dy * 0.005).exp().clamp(0.1, 10.0), 0.0]); window.request_redraw(); return; @@ -1547,11 +1583,13 @@ impl ApplicationHandler for TesseraApp { .and_then(|point| current.document.frame.camera_at(point)) .is_some_and(|mount| mount.scene3d_viewport) }); + // 此处重新取得页面点用于滚动目标,后面 Input 带该点与反缩放后的增量,由 worker 再做几何/能力复核。 let point = self.guest_point(); let input = point.and_then(|point| { self.namespace.read().ok().and_then(|namespace| { let frame = &namespace.document.frame; if frame.session == 0 || frame.fault.is_some() { + // 无活会话或故障帧只保留显示,不能据其旧 ScrollBlock 几何继续产生可执行滚动。 return None; } // 滚动方向取反并撤销整页 zoom,wheel_target 只选至少一轴能移动的最近祖先容器。 @@ -1584,6 +1622,7 @@ impl ApplicationHandler for TesseraApp { self.page_view( "page-pan", if horizontal && dx.abs() < 0.1 { + // 仅几乎没有横向输入时把纵向量映到页面横轴,已有明确横向量则走下面保留双轴的分支。 [-dy, 0.0] } else { [-dx, -dy] @@ -1603,6 +1642,7 @@ impl ApplicationHandler for TesseraApp { WindowEvent::Ime(Ime::Commit(value)) => { self.developer_tools.preedit.clear(); if self.developer_tools.open { + // 已确认的组合文本进入工具当前焦点编辑器,既不作为原生 KeyEvent 重放,也不自动提交到 Console 执行。 self.developer_tools.insert(&value); } window.request_redraw(); diff --git a/src/shell/native_chrome.rs b/src/shell/native_chrome.rs index 0c01df9..4ad3a04 100644 --- a/src/shell/native_chrome.rs +++ b/src/shell/native_chrome.rs @@ -2,6 +2,8 @@ //! 仅在 macOS 编译;Objective-C 交互局限在原生窗口层,不参与 Endor 布局与事件分发。 //! //! Keep AppKit's real window buttons aligned with the custom tab strip. +//! app::create_native/redraw 负责在事件循环线程调用对齐,app::window_event 的标题空白区按下路径调用双击处理。 +//! 两个入口都只临时借用 winit 持有的 NSView/NSWindow;本模块不保存原生指针,也不向 VM 发送伪造标题栏事件。 use objc2_app_kit::{NSApplication, NSView, NSWindowButton, NSWindowStyleMask}; use objc2_foundation::{MainThreadMarker, NSPoint, NSString, NSUserDefaults}; use winit::{ @@ -19,9 +21,11 @@ pub fn titlebar_double_click(window: &Window) -> bool { return false; }; let Ok(handle) = window.window_handle() else { + // 借用句柄失败表示当前无法可靠访问原生视图,不把它当成一次已处理的双击吞掉。 return false; }; let RawWindowHandle::AppKit(handle) = handle.as_raw() else { + // 只允许 AppKit 句柄进入下面 NSView 转型,其他平台句柄不具有这些 Objective-C 对象前提。 return false; }; // SAFETY: called on the event-loop thread during a left-button press, with @@ -74,12 +78,15 @@ pub fn align_controls(window: &Window) { // SAFETY: winit owns this NSView for the lifetime of the borrowed Window. // All AppKit access is on the event-loop thread; no native pointers escape. unsafe { + // 安全前提由调用方保证:事件循环主线程、仍活着的 Window 借用,以及 winit 提供的有效 NSView;这里只短期访问。 let view = &*handle.ns_view.as_ptr().cast::(); let Some(native) = view.window() else { + // 原生视图尚未关联窗口时无需移动控制按钮,不创建替代 NSWindow 来补齐。 return; }; // Fullscreen traffic lights belong to AppKit's separate floating toolbar. if native.styleMask().contains(NSWindowStyleMask::FullScreen) { + // 全屏按钮由 AppKit 独立工具栏管理,内容视图坐标不适合作为它们的位置来源。 return; } // 读取内容视图边界,用与侧栏相同的 HEADER 高度确定标题行中心。 diff --git a/src/shell/system_ui/widgets.rs b/src/shell/system_ui/widgets.rs index d441f70..447b0e1 100644 --- a/src/shell/system_ui/widgets.rs +++ b/src/shell/system_ui/widgets.rs @@ -1,6 +1,9 @@ //! 用统一主题绘制宿主管理控件,并同步注册交互目标。 //! 每个控件的图形、角色、启用状态和 action 一起登记,绘制几何与命中几何保持一致。 //! +//! sidebar 和 developer_tools 的 Painter 为每帧创建 Ui,消费其显示列表与 targets,再由外层 Interaction 处理输入。 +//! 这里的 Button 是宿主绘制描述,不是 Endor 的托管 Button;register 只登记 Action,不调用业务回调或立即执行动作。 +//! 主题与 Interaction 是本轮输入快照;控件重绘不会自行改变焦点、按下捕获或滚动偏移,跨帧状态由外层事件处理维护。 use super::{ Axis, Interaction, Role, Target, VisualState, WidgetId, theme::{self, Theme}, @@ -34,16 +37,19 @@ impl<'a> Button<'a> { } /// 按 builder 风格设置选中状态,返回描述供链式调用。 pub fn selected(mut self, value: bool) -> Self { + // 侧栏/工具构建链传入本轮选择结果,只改这个短期描述;Ui::button 稍后合成视觉状态,不在这里切换页面。 self.selected = value; self } /// 设置启用状态,影响外观和目标的可激活资格。 pub fn enabled(mut self, value: bool) -> Self { + // 调用方按业务资格选择是否启用,后面登记到 Target.enabled;不删除动作身份,便于跨帧 reconcile 清理旧捕获。 self.enabled = value; self } /// 设置控件语义角色,影响焦点资格、光标及 Tab 底线等外观。 pub fn role(mut self, value: Role) -> Self { + // Painter 可为同一按钮原语选择 Tab/Toggle 等角色,后续登记与绘制共用这一描述,不另建一份命中控件。 self.role = value; self } @@ -210,6 +216,7 @@ impl Ui { /// 登记文本输入角色并绘制背景和焦点/悬停描边,不负责实际文本与光标。 // Network Filter 和 Settings 自定义颜色框使用,仅负责框的目标/外观,实际文字与编辑缓冲由工具状态维护。 pub fn text_field(&mut self, rect: Rect, action: A, focused: bool) { + // Action 的稳定哈希同时关联本帧文本框目标和视觉状态;focused 还允许外层真实编辑焦点提供强调外观。 let s = self.register( WidgetId::new(&action), rect, @@ -250,6 +257,7 @@ impl Ui { // 行背景可绘制完整行,但 hit 可裁到可见部分;不要把裁剪区外的行注册成可点击区域。 // Painter 为请求、AST 等有动作的显示行调用,paint 可是完整行,hit 只允许可见片段。 pub fn row(&mut self, paint: Rect, hit: Rect, action: A, selected: bool) { + // 目标登记使用裁剪后的 hit,背景使用 paint;源行可以部分可见,不能用完整 paint 扩大鼠标可点击区域。 let s = self.register( WidgetId::new(&action), hit, @@ -263,6 +271,7 @@ impl Ui { /// 登记色块目标,绘制按压变暗、外圈及选中勾号。 // Settings 调色板调用,颜色来自候选色,selected 来自当前强调色比较;点击只登记主题 Action。 pub fn swatch(&mut self, rect: Rect, action: A, color: Color, selected: bool) { + // 色块 Action 由 Settings 消费,控件只登记/画色;selected 是传入比较结果,不在绘制时改当前主题。 let s = self.register( WidgetId::new(&action), rect, @@ -334,6 +343,7 @@ impl Ui { /// 登记滚动手柄目标并绘制其状态颜色,偏移和拖动倍率由外部维护。 // Painter 在内容裁剪弹出后调用,只有手柄几何和轴进入 Ui,内容偏移及倍率仍由外层状态管理。 pub fn scrollbar(&mut self, rect: Rect, id: WidgetId, axis: Axis) { + // 无点击 Action,外层用 Role::Scrollbar 和显式 WidgetId 选择拖动状态;Ui 不拥有内容长度或偏移计算。 let mut s = self.register(id, rect, Role::Scrollbar(axis), None, false, true); // 拖动捕获时即使指针移到手柄外仍保持强调色,不能套用普通按钮拖出后取消按下外观的规则。 s.pressed |= self.interaction.captured(id);