Files
endorphin-tessera-gui/src/shell/tabs.rs
2026-10-25 21:25:43 +08:00

680 lines
21 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.
//! Immutable tab chrome. IDs are session-local projections, never heap handles.
use crate::{
display_list::{Color, DisplayList, DrawCommand},
geometry::{Point, Rect},
text::TextStyle,
};
use serde::{Deserialize, Serialize};
mod hover;
mod motion;
pub use motion::{Motion, Visual};
// Native traffic lights and all tab controls share this row's center line.
pub const HEIGHT: f32 = 40.0;
pub const INSET: f32 = 8.0;
const NEW_TAB_SPACE: f32 = 44.0;
const MIN_WIDTH: f32 = 88.0;
pub const RAIL: f32 = 56.0;
pub const CONTROLS: f32 = if cfg!(target_os = "macos") { 86.0 } else { 0.0 };
pub const LEADING: f32 = CONTROLS + 12.0;
#[derive(Clone, Default)]
pub struct Chrome {
pub offset: Option<f32>,
pub insertion: Option<usize>,
pub pointer: Option<Point>,
pub visual: Visual,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tab {
pub id: u64,
pub title: String,
pub active: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub developer_tool: Option<String>,
}
pub fn height(tabs: &[Tab]) -> f32 {
let _ = tabs;
HEIGHT
}
/// Scroll offsets are logical pixels, never a discrete page index.
fn viewport(width: f32) -> Rect {
Rect::new(
LEADING,
0.0,
(width - NEW_TAB_SPACE - LEADING).max(0.0),
HEIGHT,
)
}
fn tab_width(tabs: &[Tab], width: f32) -> f32 {
(viewport(width).width / tabs.len().max(1) as f32)
.clamp(MIN_WIDTH, 208.0)
.min(viewport(width).width)
}
fn scroll_offset(tabs: &[Tab], width: f32, start: Option<f32>) -> f32 {
let tab_width = tab_width(tabs, width);
let available = viewport(width).width;
let maximum = (tabs.len() as f32 * tab_width - available).max(0.0);
let active = tabs.iter().position(|tab| tab.active).unwrap_or(0);
start
.unwrap_or(((active + 1) as f32 * tab_width - available).max(0.0))
.clamp(0.0, maximum)
}
#[cfg(test)]
fn visible_range(tabs: &[Tab], width: f32, start: Option<f32>) -> std::ops::Range<usize> {
let tab_width = tab_width(tabs, width);
if tab_width <= 0.0 {
return 0..0;
}
let offset = scroll_offset(tabs, width, start);
(offset / tab_width).floor() as usize
..(((offset + viewport(width).width) / tab_width).ceil() as usize).min(tabs.len())
}
/// Invisible drag edge zones only; ordinary clicks never scroll or activate another tab.
pub fn edge_direction(tabs: &[Tab], width: f32, start: Option<f32>, point: Point) -> i8 {
if !viewport(width).contains(point) || point.y >= HEIGHT {
return 0;
}
let offset = scroll_offset(tabs, width, start);
let maximum = (tabs.len() as f32 * tab_width(tabs, width) - viewport(width).width).max(0.0);
if point.x < LEADING + 24.0 && offset > 0.0 {
-1
} else if point.x > width - NEW_TAB_SPACE - 24.0 && offset < maximum {
1
} else {
0
}
}
pub fn wheel_offset(tabs: &[Tab], width: f32, start: Option<f32>, delta: f32) -> f32 {
scroll_offset(tabs, width, Some(scroll_offset(tabs, width, start) + delta))
}
pub fn scroll_start(tabs: &[Tab], width: f32, start: Option<f32>, direction: i8) -> Option<f32> {
let current = scroll_offset(tabs, width, start);
let next = wheel_offset(tabs, width, Some(current), direction as f32 * MIN_WIDTH);
(next != current).then_some(next)
}
struct Item<'a> {
tab: &'a Tab,
rect: Rect,
}
/// Retain all IDs, including clipped tabs, for continuous movement through the viewport.
fn tab_rects(start: Option<f32>, tabs: &[Tab], width: f32) -> Vec<(u64, Rect)> {
let tab_width = tab_width(tabs, width);
let offset = scroll_offset(tabs, width, start);
tabs.iter()
.enumerate()
.map(|(index, tab)| {
(
tab.id,
Rect::new(
LEADING + index as f32 * tab_width - offset,
0.0,
tab_width,
HEIGHT,
),
)
})
.collect()
}
fn items(start: Option<f32>, tabs: &[Tab], width: f32) -> Vec<Item<'_>> {
let viewport = viewport(width);
tabs.iter()
.zip(tab_rects(start, tabs, width))
.filter(|(_, (_, rect))| {
rect.x + rect.width > viewport.x && rect.x < viewport.x + viewport.width
})
.map(|(tab, (_, rect))| Item { tab, rect })
.collect()
}
fn pill_rect(rect: Rect) -> Rect {
Rect::new(rect.x + 2.0, 4.0, (rect.width - 4.0).max(0.0), HEIGHT - 8.0)
}
fn close_rect(rect: Rect) -> Rect {
Rect::new(
rect.x + rect.width - 25.0,
(HEIGHT - 18.0) / 2.0,
18.0,
18.0,
)
}
pub fn hit(start: Option<f32>, tabs: &[Tab], width: f32, point: Point) -> Option<(u64, bool)> {
if point.x < LEADING
|| point.x >= width - NEW_TAB_SPACE
|| point.y < 0.0
|| point.y >= height(tabs)
{
return None;
}
items(start, tabs, width)
.into_iter()
.find(|item| pill_rect(item.rect).contains(point))
.map(|item| (item.tab.id, close_rect(item.rect).contains(point)))
}
/// Gap index in the complete published order, including overflow-hidden tabs.
pub fn insertion_index(
start: Option<f32>,
tabs: &[Tab],
width: f32,
point: Point,
) -> Option<usize> {
if point.x < LEADING || point.x >= width - NEW_TAB_SPACE || point.y < 0.0 || point.y >= HEIGHT {
return None;
}
if tabs.is_empty() {
return Some(0);
}
let visible: Vec<_> = items(start, tabs, width).into_iter().collect();
for item in &visible {
if point.x < item.rect.x + item.rect.width / 2.0 {
return tabs.iter().position(|tab| tab.id == item.tab.id);
}
}
visible
.last()
.and_then(|last| tabs.iter().position(|tab| tab.id == last.tab.id))
.map(|index| index + 1)
}
pub fn drag_anchor(
start: Option<f32>,
tabs: &[Tab],
width: f32,
id: u64,
point: Point,
) -> Option<Point> {
if !viewport(width).contains(point) {
return None;
}
let item = items(start, tabs, width)
.into_iter()
.find(|item| item.tab.id == id && pill_rect(item.rect).contains(point))?;
(!close_rect(item.rect).contains(point)).then(|| Point::new(point.x - item.rect.x, point.y))
}
pub fn draw_insertion(
start: Option<f32>,
list: &mut DisplayList,
tabs: &[Tab],
width: f32,
index: usize,
scale: f32,
) {
let visible: Vec<_> = items(start, tabs, width).into_iter().collect();
let x = if tabs.is_empty() {
Some(LEADING + 8.0)
} else {
visible
.iter()
.find(|item| tabs.get(index).is_some_and(|tab| tab.id == item.tab.id))
.map(|item| item.rect.x)
.or_else(|| {
visible
.last()
.filter(|item| {
tabs.iter().position(|tab| tab.id == item.tab.id) == index.checked_sub(1)
})
.map(|item| item.rect.x + item.rect.width)
})
};
if let Some(x) = x {
list.push(DrawCommand::FillRoundedRect {
rect: Rect::new(
x.clamp(1.5, (width - 1.5).max(1.5)) * scale - 1.5 * scale,
3.0 * scale,
3.0 * scale,
(HEIGHT - 6.0) * scale,
),
radius: scale,
color: Color::rgb(56, 95, 220),
});
}
}
pub fn plus_rect(start: Option<f32>, tabs: &[Tab], width: f32) -> Rect {
if tabs.len() as f32 * tab_width(tabs, width) > viewport(width).width {
return Rect::new(width - 34.0, (HEIGHT - 28.0) / 2.0, 24.0, 28.0);
}
let end = items(start, tabs, width)
.iter()
.map(|item| item.rect.x + item.rect.width)
.fold(LEADING, f32::max);
Rect::new(
(end + 4.0).min(width - 34.0),
(HEIGHT - 28.0) / 2.0,
24.0,
28.0,
)
}
fn tab_style() -> TextStyle {
TextStyle {
font_size: 13.0,
line_height: 18.0,
..TextStyle::ui(1)
}
}
pub fn draw(
_start: Option<f32>,
list: &mut DisplayList,
tabs: &[Tab],
width: f32,
scale: f32,
_pointer: Option<Point>,
visual: &Visual,
) {
let scaled = |rect: Rect| {
Rect::new(
rect.x * scale,
rect.y * scale,
rect.width * scale,
rect.height * scale,
)
};
list.push(DrawCommand::FillRoundedRect {
rect: scaled(Rect::new(0.0, 0.0, width, HEIGHT)),
radius: 0.0,
color: crate::system_ui::theme::BAR,
});
let plus = visual.plus;
if visual.plus_hover > 0.0 {
list.push(DrawCommand::FillRoundedRect {
rect: scaled(plus),
radius: 6.0 * scale,
color: crate::system_ui::theme::mix(
crate::system_ui::theme::BAR,
crate::system_ui::theme::TEXT,
0.06 * visual.plus_hover,
),
});
}
list.push(DrawCommand::Text {
origin: Point::new(
(plus.x + (plus.width - crate::text::measure("+", tab_style()).width) / 2.0) * scale,
((HEIGHT - tab_style().line_height) / 2.0) * scale,
),
value: "+".into(),
style: tab_style().scaled(scale),
color: crate::system_ui::theme::MUTED,
});
let mut paint_items: Vec<_> = tabs
.iter()
.map(|tab| Item {
tab,
rect: visual.rect(tab.id, Rect::default()),
})
.collect();
paint_items.extend(
visual
.exiting
.iter()
.map(|(tab, rect)| Item { tab, rect: *rect }),
);
for item in paint_items {
{
if item.rect.width <= 0.0
|| item.rect.x + item.rect.width <= visual.viewport.x
|| item.rect.x >= visual.viewport.x + visual.viewport.width
{
continue;
}
list.push(DrawCommand::PushClip(scaled(visual.viewport)));
list.push(DrawCommand::PushClip(scaled(pill_rect(item.rect))));
}
let pill = scaled(pill_rect(item.rect));
let active = item.tab.active;
let hover = visual.hover(item.tab.id);
let background = if active {
crate::system_ui::theme::BG
} else {
crate::system_ui::theme::mix(
crate::system_ui::theme::BAR,
crate::system_ui::theme::TEXT,
0.06 * hover,
)
};
if active || hover > 0.0 {
list.push(DrawCommand::FillRoundedRect {
rect: pill,
radius: 6.0 * scale,
color: background,
});
}
{
// Match the close button with an 18px slot and 7px edge inset.
let icon = "◇";
list.push(DrawCommand::Text {
origin: Point::new(
(item.rect.x
+ 7.0
+ (18.0 - crate::text::measure(icon, tab_style()).width) / 2.0)
* scale,
((HEIGHT - tab_style().line_height) / 2.0) * scale,
),
value: icon.into(),
style: tab_style().scaled(scale),
color: crate::system_ui::theme::MUTED,
});
}
let title = &item.tab.title;
let text_rect = Rect::new(
item.rect.x + 30.0,
(HEIGHT - tab_style().line_height) / 2.0,
(item.rect.width - 58.0).max(0.0),
tab_style().line_height,
);
list.push(DrawCommand::PushClip(scaled(text_rect)));
list.push(DrawCommand::Text {
origin: Point::new(
(item.rect.x + 30.0) * scale,
((HEIGHT - tab_style().line_height) / 2.0) * scale,
),
value: title.into(),
style: tab_style().scaled(scale),
color: if active {
crate::system_ui::theme::TEXT
} else {
crate::system_ui::theme::MUTED
},
});
list.push(DrawCommand::PopClip);
if item.rect.width >= 44.0 {
let close_hover = visual.close_hover(item.tab.id);
if close_hover > 0.0 {
list.push(DrawCommand::FillRoundedRect {
rect: scaled(close_rect(item.rect)),
radius: 5.0 * scale,
color: crate::system_ui::theme::mix(
background,
crate::system_ui::theme::mix(
crate::system_ui::theme::BAR,
crate::system_ui::theme::TEXT,
0.12,
),
close_hover,
),
});
}
list.push(DrawCommand::Text {
origin: Point::new(
(close_rect(item.rect).x
+ (close_rect(item.rect).width
- crate::text::measure("×", tab_style()).width)
/ 2.0)
* scale,
((HEIGHT - tab_style().line_height) / 2.0) * scale,
),
value: "×".into(),
style: tab_style().scaled(scale),
color: crate::system_ui::theme::MUTED,
});
}
{
list.push(DrawCommand::PopClip);
list.push(DrawCommand::PopClip);
}
}
// Quiet boundaries only between two idle tabs; the active/hovered pill owns its edges.
list.push(DrawCommand::PushClip(scaled(visual.viewport)));
for pair in tabs.windows(2) {
let left = visual.rect(pair[0].id, Rect::default());
let hover = visual.hover(pair[0].id).max(visual.hover(pair[1].id));
if !pair[0].active && !pair[1].active && hover < 1.0 {
list.push(DrawCommand::FillRoundedRect {
rect: scaled(Rect::new(
left.x + left.width - 0.5,
(HEIGHT - 12.0) / 2.0,
1.0,
12.0,
)),
radius: 0.5 * scale,
color: crate::system_ui::theme::mix(
crate::system_ui::theme::BAR,
crate::system_ui::theme::TEXT,
0.18 * (1.0 - hover),
),
});
}
}
list.push(DrawCommand::PopClip);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn insertion_gaps_share_visible_tab_geometry_and_exclude_close_drags() {
let tabs: Vec<_> = (1..=4)
.map(|id| Tab {
id,
title: id.to_string(),
developer_tool: None,
active: id == 1,
})
.collect();
assert_eq!(
insertion_index(
None,
&tabs,
LEADING + NEW_TAB_SPACE + 1000.0,
Point::new(LEADING + 10.0, 18.0)
),
Some(0)
);
assert_eq!(
insertion_index(
None,
&tabs,
LEADING + NEW_TAB_SPACE + 1000.0,
Point::new(LEADING + 215.0, 18.0)
),
Some(1)
);
assert_eq!(
insertion_index(
None,
&tabs,
LEADING + NEW_TAB_SPACE + 1000.0,
Point::new(LEADING + 990.0, 18.0)
),
Some(4)
);
assert!(
drag_anchor(
None,
&tabs,
LEADING + NEW_TAB_SPACE + 1000.0,
1,
Point::new(LEADING + 190.0, 18.0)
)
.is_none()
);
assert!(
drag_anchor(
None,
&tabs,
LEADING + NEW_TAB_SPACE + 1000.0,
1,
Point::new(LEADING + 25.0, 18.0)
)
.is_some()
);
assert_eq!(
insertion_index(
None,
&tabs,
LEADING + NEW_TAB_SPACE + 1000.0,
Point::new(LEADING + 20.0, HEIGHT)
),
None
);
assert!(
drag_anchor(
None,
&tabs,
LEADING + NEW_TAB_SPACE + 400.0,
3,
Point::new(LEADING + 390.0, 18.0)
)
.is_none()
);
assert_eq!(
insertion_index(
None,
&[],
LEADING + NEW_TAB_SPACE + 720.0,
Point::new(LEADING + 20.0, 18.0)
),
Some(0)
);
}
#[test]
fn overflow_keeps_active_page_visible_without_navigation_buttons() {
let tabs: Vec<_> = (1..=64)
.map(|id| Tab {
id,
title: format!("Page {id}"),
developer_tool: None,
active: id == 32,
})
.collect();
let visible = items(None, &tabs, LEADING + NEW_TAB_SPACE + 720.0);
assert!(visible.iter().any(|item| item.tab.active));
for item in &visible {
let point = Point::new((item.rect.x + 12.0).max(LEADING + 3.0), 18.0);
assert_eq!(
hit(None, &tabs, LEADING + NEW_TAB_SPACE + 720.0, point),
Some((item.tab.id, close_rect(item.rect).contains(point)))
);
}
let tab = visible.iter().find(|item| item.tab.active).unwrap();
assert_eq!(
hit(
None,
&tabs,
LEADING + NEW_TAB_SPACE + 720.0,
Point::new(tab.rect.x + tab.rect.width - 15.0, 18.0)
),
Some((32, true))
);
assert_eq!(
hit(
None,
&tabs,
LEADING + NEW_TAB_SPACE + 720.0,
Point::new(LEADING + 50.0, HEIGHT)
),
None
);
}
#[test]
fn drag_viewport_reaches_hidden_ends_without_changing_active_page() {
let tabs: Vec<_> = (1..=64)
.map(|id| Tab {
id,
title: id.to_string(),
developer_tool: None,
active: id == 32,
})
.collect();
let width = LEADING + NEW_TAB_SPACE + 720.0;
let mut start = None;
while let Some(next) = scroll_start(&tabs, width, start, 1) {
start = Some(next);
}
assert_eq!(visible_range(&tabs, width, start), 55..64);
assert_eq!(
edge_direction(&tabs, width, start, Point::new(LEADING + 710.0, 18.0)),
0
);
assert_eq!(
insertion_index(start, &tabs, width, Point::new(LEADING + 710.0, 18.0)),
Some(64)
);
while let Some(next) = scroll_start(&tabs, width, start, -1) {
start = Some(next);
}
assert_eq!(visible_range(&tabs, width, start), 0..9);
assert_eq!(
edge_direction(&tabs, width, start, Point::new(LEADING + 10.0, 18.0)),
0
);
assert_eq!(
insertion_index(start, &tabs, width, Point::new(LEADING + 10.0, 18.0)),
Some(0)
);
assert_eq!(
tabs.iter()
.filter(|tab| tab.active)
.map(|tab| tab.id)
.collect::<Vec<_>>(),
vec![32]
);
assert!(items(None, &tabs, width).iter().any(|item| item.tab.active));
}
#[test]
fn scrolled_hit_and_gap_geometry_survive_resize_and_list_shrink() {
let tabs: Vec<_> = (1..=16)
.map(|id| Tab {
id,
title: id.to_string(),
developer_tool: None,
active: id == 1,
})
.collect();
let start = Some(4.0 * MIN_WIDTH);
assert_eq!(
hit(
start,
&tabs,
LEADING + NEW_TAB_SPACE + 720.0,
Point::new(LEADING + 40.0, 18.0)
),
Some((5, false))
);
assert_eq!(
insertion_index(
start,
&tabs,
LEADING + NEW_TAB_SPACE + 720.0,
Point::new(LEADING + 40.0, 18.0)
),
Some(4)
);
assert_eq!(
visible_range(&tabs[..3], LEADING + NEW_TAB_SPACE + 720.0, start),
0..3
);
assert_eq!(
visible_range(&tabs, LEADING + NEW_TAB_SPACE + 1920.0, start),
0..16
);
assert_eq!(
edge_direction(
&tabs,
LEADING + NEW_TAB_SPACE + 720.0,
start,
Point::new(LEADING + 710.0, HEIGHT)
),
0
);
assert_eq!(
edge_direction(
&tabs,
LEADING + NEW_TAB_SPACE + 720.0,
start,
Point::new(LEADING + 725.0, 18.0)
),
0
);
}
}