Files
endorphin-tessera-gui/src/shell/sidebar.rs

187 lines
7.8 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 宿主管理工具和页面列表的唯一侧栏。展开时占据页面左侧空间,折叠后只保留展开按钮区域。
//! 页面标题和身份来自发布帧;实际切换/关闭通过带版本的事件进入运行时,不在绘制时修改业务对象。
//!
//! 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<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 {
// 展开时整个侧栏横向带均拦截页面输入,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)));
}
}