172 lines
6.0 KiB
Rust
172 lines
6.0 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;
|
||
pub const HEADER: f32 = 40.0;
|
||
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<Target<Action>>,
|
||
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 {
|
||
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 {
|
||
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.
|
||
if y < area.y || y + 32.0 > area.y + area.height {
|
||
continue;
|
||
}
|
||
ui.button(
|
||
Rect::new(12.0, y, WIDTH - 56.0, 32.0),
|
||
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]
|
||
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]);
|
||
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)));
|
||
}
|
||
}
|