211 lines
10 KiB
Rust
211 lines
10 KiB
Rust
//! 宿主管理工具和页面列表的唯一侧栏。展开时占据页面左侧空间,折叠后只保留展开按钮区域。
|
||
//! 页面标题和身份来自发布帧;实际切换/关闭通过带版本的事件进入运行时,不在绘制时修改业务对象。
|
||
//!
|
||
//! 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<Target<Action>>,
|
||
// 只滚动普通页面列表,顶部工具入口、折叠按钮和底部工作区标签保持固定。
|
||
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)));
|
||
}
|
||
}
|