//! 宿主管理工具和页面列表的唯一侧栏。展开时占据页面左侧空间,折叠后只保留展开按钮区域。 //! 页面标题和身份来自发布帧;实际切换/关闭通过带版本的事件进入运行时,不在绘制时修改业务对象。 //! //! The only persistent host toolbar. A collapsed sidebar owns just its toggle //! and the native traffic-light safe area; the page owns the rest of the window. use crate::{ display_list::{DisplayList, DrawCommand}, geometry::{Point, Rect}, system_ui::{ Button, Interaction, Target, Ui, theme::{self, Theme}, }, tabs::Tab, }; // 展开侧栏固定逻辑宽度;实际设备像素比例由后续显示列表缩放处理。 pub const WIDTH: f32 = 256.0; // 顶部标题/原生按钮区域高度,app 的拖窗判断和 native_chrome 的系统按钮对齐共同使用。 pub const HEADER: f32 = 40.0; // macOS 标题行预留原生三键安全区域,其他平台无需这段横向留白。 pub const CONTROLS: f32 = if cfg!(target_os = "macos") { 86.0 } else { 0.0 }; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] /// 侧栏释放激活后要执行的管理动作,绘制本身只登记它。 // app::sidebar_action 执行这些已确认动作;页面 ID 来自本窗口的 Tab 投影,不是 VM 对象引用。 pub enum Action { // app 就地切换侧栏占用宽度并发布视口,不向 VM 发送标签事件。 Toggle, // app 通过 open_developer_tool("New tab") 提交新页请求,不在 draw 内创建页面。 NewPage, // 固定工具名称交给同一打开入口,是否复用已有工具页由运行时处理。 Tool(&'static str), // app 将当前窗口标签 ID 放进 Input.mount,并以 tab-activate 交给 worker 校验与切页。 Activate(u64), // 与激活使用相同身份通道,但事件种类为 tab-close,不能按列表行号关闭页面。 Close(u64), } /// 侧栏开合、控件交互和本帧目标集合,决定实际占用页面宽度。 pub struct State { // 仅属宿主 UI 的开合状态;决定 left/covers/draw,不是托管页面中的可响应属性。 pub open: bool, // 跨帧保存悬停、键盘焦点和按下捕获;目标矩形则由每次 draw 重建。 pub interaction: Interaction, // 最近绘制帧的逻辑命中集合,app 的指针及键盘事件使用,与显示列表保持同帧。 pub targets: Vec>, // 只滚动普通页面列表,顶部工具入口、折叠按钮和底部工作区标签保持固定。 pub scroll: f32, } impl Default for State { /// 建立默认展开的侧栏,交互目标等第一次 draw 时按实际页面重建。 // TesseraApp 初始化时创建,默认展开、列表在起点,尚未绘制时没有交互目标。 fn default() -> Self { Self { open: true, interaction: Interaction::default(), targets: Vec::new(), scroll: 0.0, } } } impl State { /// 返回展开侧栏占据的逻辑宽度,折叠时页面收回这部分空间。 // scene 合成页面时用作横向偏移,app 发布布局视口时从窗口逻辑宽度扣除;折叠值为零。 pub fn left(&self) -> f32 { if self.open { WIDTH } else { 0.0 } } /// 判断指针是否落入宿主侧栏/展开按钮区域,保护页面输入边界。 // app 路由页面指针、滚轮和键盘焦点时调用,判断宿主占用区而非只判断是否命中某个按钮。 pub fn covers(&self, point: Point) -> bool { // 展开时整个侧栏横向带均拦截页面输入,y 的窗口有效范围由外层事件入口保证。 // 折叠时只覆盖顶部三键安全区与展开按钮,不继续挡住左侧整列页面。 if self.open { point.x >= 0.0 && point.x < WIDTH } else { Rect::new(0.0, 0.0, CONTROLS + 44.0, HEADER).contains(point) } } /// 绘制工具入口、页面列表和展开控制,同时登记稳定的交互目标。 // scene::build_scene 最后调用以覆盖宿主侧栏;height 为逻辑高度,返回命令由外层再统一乘设备 scale。 pub fn draw( &mut self, tabs: &[Tab], height: f32, accent: crate::display_list::Color, ) -> DisplayList { // 每帧重新生成控件目标与显示列表,保留 Interaction 的稳定身份状态,而不是复用上帧屏幕矩形。 let mut ui = Ui::new(Theme::new(accent), self.interaction.clone()); // 只有展开态铺侧栏背景,折叠态不画整列底色,让业务页面能使用原侧栏区域。 if self.open { ui.fill(Rect::new(0.0, 0.0, WIDTH, height), theme::BAR); } // 展开时按钮靠侧栏右边,折叠时靠原生按钮安全区右侧,给页面释放左边宽度。 let toggle_x = if self.open { WIDTH - 40.0 } else { CONTROLS + 4.0 }; // Draw a sidebar icon with primitives so its shape does not depend on fonts. // 展开按钮无文字标签,下面用描边和竖线组合图标;按钮矩形仍负责稳定 Toggle 命中。 ui.button( Rect::new(toggle_x, 6.0, 30.0, 28.0), Action::Toggle, Button::new(""), ); ui.list.push(DrawCommand::StrokeRoundedRect { rect: Rect::new(toggle_x + 7.0, 13.0, 16.0, 13.0), radius: 2.0, width: 1.0, color: theme::MUTED, }); ui.fill(Rect::new(toggle_x + 12.0, 14.0, 1.0, 11.0), theme::MUTED); if self.open { ui.label(20.0, 61.0, "ENDORPHIN", theme::TEXT, false); ui.button( Rect::new(12.0, 95.0, WIDTH - 24.0, 34.0), Action::NewPage, Button::new("+ 新建页面"), ); // 四个固定工具入口不随页面列表滚动,name 作为 Action 参数,label 只是显示文字。 for (index, (name, label)) in [ ("Console", "Console"), ("Sources", "Sources"), ("Network", "Network"), ("Settings", "Settings"), ] .into_iter() .enumerate() { // 工具入口的选中状态来自已发布标签,而非仅凭最后点击动作推断,避免请求失败后显示错误选中。 let selected = tabs .iter() .any(|tab| tab.active && tab.developer_tool.as_deref() == Some(name)); ui.button( Rect::new(12.0, 147.0 + index as f32 * 34.0, WIDTH - 24.0, 30.0), Action::Tool(name), Button::new(label).selected(selected), ); } ui.label(20.0, 303.0, "页面", theme::MUTED, false); // 普通页面可滚区域从固定工具区下方开始,底部为工作区标签留白;极矮窗口面积钳到零。 let area = Rect::new(8.0, 333.0, WIDTH - 16.0, (height - 383.0).max(0.0)); // 开发工具已由固定入口展示,因此页面列表仅收集普通页,保持原注册表顺序。 let pages: Vec<_> = tabs .iter() .filter(|tab| tab.developer_tool.is_none()) .collect(); // 页面关闭或窗口增高后重新钳制旧偏移,内容不足视口时回零,防止列表留在空白区域。 self.scroll = self .scroll .clamp(0.0, (pages.len() as f32 * 36.0 - area.height).max(0.0)); ui.list.push(DrawCommand::PushClip(area)); // 页面行按注册表顺序排,先减 scroll 得到本帧 y,再检查整行是否可见。 for (index, tab) in pages.into_iter().enumerate() { // 每行步长 36、按钮高度 32,空出的 4 单位间距同样属于滚动内容节奏。 let y = area.y + index as f32 * 36.0 - self.scroll; // Register only fully visible rows: paint clipping must match input. // 当前实现完全跳过部分可见行,绘制与注册都不发生;Clip 仍保护其他原语不溢出列表区域。 if y < area.y || y + 32.0 > area.y + area.height { continue; } ui.button( Rect::new(12.0, y, WIDTH - 56.0, 32.0), // 切换和关闭动作携带稳定页面 ID,滚动或标题变化不会改变动作身份。 Action::Activate(tab.id), Button::new(&tab.title).selected(tab.active), ); // 关闭按钮与整行激活按钮使用不同 Action,避免点关闭同时触发切页。 ui.button( Rect::new(WIDTH - 40.0, y, 28.0, 32.0), Action::Close(tab.id), Button::new("×"), ); } // 列表结束先恢复 clip,底部工作区标签保持固定位置且不继承页面列表裁剪。 ui.list.push(DrawCommand::PopClip); ui.label(20.0, height - 32.0, "本地工作区", theme::MUTED, false); } // 重建目标后校正跨帧捕获/焦点;折叠会移除旧页面和工具目标,不能在释放时激活已隐藏控件。 self.targets = ui.targets; self.interaction.reconcile(&self.targets); ui.list } } #[cfg(test)] mod tests { use super::*; #[test] /// 先展开绘制再折叠重绘,检查交互目标只剩 Toggle、页面左侧让宽归零,其他区域不再拦截指针。 fn collapsing_removes_every_host_target_except_the_toggle() { // 以生产默认展开状态建立初始目标,再折叠重建,验证目标清理而不是只验证绘制列表变短。 let mut sidebar = State::default(); sidebar.draw(&[], 800.0, theme::PALETTE[0]); assert!(sidebar.targets.len() > 1); // 第二次 draw 必须移除旧工具/页面目标,Interaction 也应对新集合重新校正捕获。 sidebar.open = false; sidebar.draw(&[], 800.0, theme::PALETTE[0]); // 折叠后仅保留 Toggle,且页面不再让出左宽;顶部按钮区域之外的指针不被侧栏拦截。 assert_eq!(sidebar.targets.len(), 1); assert_eq!(sidebar.targets[0].action, Some(Action::Toggle)); assert_eq!(sidebar.left(), 0.0); assert!(!sidebar.covers(Point::new(400.0, 10.0))); assert!(!sidebar.covers(Point::new(10.0, 80.0))); } }