//! 宿主管理工具和页面列表的唯一侧栏。展开时占据页面左侧空间,折叠后只保留展开按钮区域。 //! 页面标题和身份来自发布帧;实际切换/关闭通过带版本的事件进入运行时,不在绘制时修改业务对象。 //! //! 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; 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)] /// 侧栏释放激活后要执行的管理动作,绘制本身只登记它。 pub enum Action { Toggle, NewPage, Tool(&'static str), Activate(u64), Close(u64), } /// 侧栏开合、控件交互和本帧目标集合,决定实际占用页面宽度。 pub struct State { pub open: bool, pub interaction: Interaction, pub targets: Vec>, // 只滚动普通页面列表,顶部工具入口、折叠按钮和底部工作区标签保持固定。 pub scroll: f32, } impl Default for State { /// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。 fn default() -> Self { Self { open: true, interaction: Interaction::default(), targets: Vec::new(), scroll: 0.0, } } } impl State { /// 返回展开侧栏占据的逻辑宽度,折叠时页面收回这部分空间。 pub fn left(&self) -> f32 { if self.open { WIDTH } else { 0.0 } } /// 判断指针是否落入宿主侧栏/展开按钮区域,保护页面输入边界。 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) } } /// 绘制工具入口、页面列表和展开控制,同时登记稳定的交互目标。 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. 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("+ 新建页面"), ); 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)); for (index, tab) in pages.into_iter().enumerate() { 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), ); ui.button( Rect::new(WIDTH - 40.0, y, 28.0, 32.0), Action::Close(tab.id), Button::new("×"), ); } 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); 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))); } }