Files
endorphin-tessera-gui/src/tabs.rs
T
codex-bot 7cd7ec2c11
Verify Tessera GUI / verify (push) Failing after 7m40s
Support stable insertion and managed tab placement
2026-10-22 11:29:14 +08:00

276 lines
8.8 KiB
Rust
Raw 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};
pub const HEIGHT: f32 = 36.0;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tab {
pub id: u64,
pub title: String,
pub active: bool,
}
pub fn height(tabs: &[Tab]) -> f32 {
if tabs.is_empty() { 0.0 } else { HEIGHT }
}
struct Item<'a> {
tab: &'a Tab,
rect: Rect,
navigation: Option<&'static str>,
}
fn items(tabs: &[Tab], width: f32) -> Vec<Item<'_>> {
if tabs.is_empty() || width <= 0.0 {
return Vec::new();
}
let capacity = ((width - 64.0) / 160.0).floor().max(1.0) as usize;
let overflow = tabs.len() > capacity;
let count = if overflow { capacity } else { tabs.len() };
let active = tabs.iter().position(|tab| tab.active).unwrap_or(0);
let start = if overflow {
active.saturating_sub(count / 2).min(tabs.len() - count)
} else {
0
};
let margin = if overflow { 32.0 } else { 0.0 };
let tab_width = ((width - margin * 2.0) / count as f32).min(220.0);
let mut result = Vec::new();
if overflow && start > 0 {
result.push(Item {
tab: &tabs[start - 1],
rect: Rect::new(0.0, 0.0, 32.0, HEIGHT),
navigation: Some("‹"),
});
}
for (index, tab) in tabs[start..start + count].iter().enumerate() {
result.push(Item {
tab,
rect: Rect::new(margin + index as f32 * tab_width, 0.0, tab_width, HEIGHT),
navigation: None,
});
}
if overflow && start + count < tabs.len() {
result.push(Item {
tab: &tabs[start + count],
rect: Rect::new(width - 32.0, 0.0, 32.0, HEIGHT),
navigation: Some("›"),
});
}
result
}
fn close_rect(rect: Rect) -> Rect {
Rect::new(rect.x + rect.width - 27.0, 5.0, 24.0, 26.0)
}
pub fn hit(tabs: &[Tab], width: f32, point: Point) -> Option<(u64, bool)> {
if point.x < 0.0 || point.x >= width || point.y < 0.0 || point.y >= height(tabs) {
return None;
}
items(tabs, width)
.into_iter()
.find(|item| item.rect.contains(point))
.map(|item| {
(
item.tab.id,
item.navigation.is_none() && close_rect(item.rect).contains(point),
)
})
}
/// Gap index in the complete published order, including overflow-hidden tabs.
pub fn insertion_index(tabs: &[Tab], width: f32, point: Point) -> Option<usize> {
if point.x < 0.0 || point.x >= width || point.y < 0.0 || point.y >= HEIGHT {
return None;
}
if tabs.is_empty() {
return Some(0);
}
let visible: Vec<_> = items(tabs, width)
.into_iter()
.filter(|item| item.navigation.is_none())
.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(tabs: &[Tab], width: f32, id: u64, point: Point) -> Option<Point> {
let item = items(tabs, width)
.into_iter()
.find(|item| item.tab.id == id && item.navigation.is_none() && item.rect.contains(point))?;
(!close_rect(item.rect).contains(point)).then(|| Point::new(point.x - item.rect.x, point.y))
}
pub fn draw_insertion(list: &mut DisplayList, tabs: &[Tab], width: f32, index: usize, scale: f32) {
let visible: Vec<_> = items(tabs, width)
.into_iter()
.filter(|item| item.navigation.is_none())
.collect();
let x = if tabs.is_empty() {
Some(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 draw(list: &mut DisplayList, tabs: &[Tab], width: f32, scale: f32) {
if tabs.is_empty() {
return;
}
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: Color::rgb(230, 232, 236),
});
for item in items(tabs, width) {
if item.tab.active && item.navigation.is_none() {
list.push(DrawCommand::FillRoundedRect {
rect: scaled(item.rect),
radius: 0.0,
color: Color::rgb(250, 250, 249),
});
list.push(DrawCommand::FillRoundedRect {
rect: scaled(Rect::new(item.rect.x, HEIGHT - 2.0, item.rect.width, 2.0)),
radius: 0.0,
color: Color::rgb(79, 111, 219),
});
}
let title = item.navigation.unwrap_or(&item.tab.title);
let text_rect = Rect::new(
item.rect.x + 10.0,
4.0,
(item.rect.width
- if item.navigation.is_some() {
12.0
} else {
42.0
})
.max(0.0),
28.0,
);
list.push(DrawCommand::PushClip(scaled(text_rect)));
list.push(DrawCommand::Text {
origin: Point::new((item.rect.x + 10.0) * scale, 8.0 * scale),
value: title.into(),
style: TextStyle::ui(1).scaled(scale),
color: Color::rgb(36, 36, 40),
});
list.push(DrawCommand::PopClip);
if item.navigation.is_none() {
list.push(DrawCommand::Text {
origin: Point::new((item.rect.x + item.rect.width - 22.0) * scale, 8.0 * scale),
value: "×".into(),
style: TextStyle::ui(1).scaled(scale),
color: Color::rgb(80, 82, 88),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn insertion_gaps_share_visible_tab_geometry_and_exclude_close_and_arrow_drags() {
let tabs: Vec<_> = (1..=4)
.map(|id| Tab {
id,
title: id.to_string(),
active: id == 1,
})
.collect();
assert_eq!(
insertion_index(&tabs, 1000.0, Point::new(10.0, 18.0)),
Some(0)
);
assert_eq!(
insertion_index(&tabs, 1000.0, Point::new(215.0, 18.0)),
Some(1)
);
assert_eq!(
insertion_index(&tabs, 1000.0, Point::new(990.0, 18.0)),
Some(4)
);
assert!(drag_anchor(&tabs, 1000.0, 1, Point::new(200.0, 18.0)).is_none());
assert!(drag_anchor(&tabs, 1000.0, 1, Point::new(25.0, 18.0)).is_some());
assert_eq!(
insertion_index(&tabs, 1000.0, Point::new(20.0, HEIGHT)),
None
);
assert!(drag_anchor(&tabs, 400.0, 3, Point::new(390.0, 18.0)).is_none());
assert_eq!(insertion_index(&[], 720.0, Point::new(20.0, 18.0)), Some(0));
}
#[test]
fn overflow_keeps_active_page_visible_and_navigation_reaches_other_pages() {
let tabs: Vec<_> = (1..=64)
.map(|id| Tab {
id,
title: format!("Page {id}"),
active: id == 32,
})
.collect();
let visible = items(&tabs, 720.0);
assert!(
visible
.iter()
.any(|item| item.tab.active && item.navigation.is_none())
);
for item in &visible {
let point = Point::new(item.rect.x + 12.0, 18.0);
assert_eq!(hit(&tabs, 720.0, point), Some((item.tab.id, false)));
}
let tab = visible.iter().find(|item| item.tab.active).unwrap();
assert_eq!(
hit(
&tabs,
720.0,
Point::new(tab.rect.x + tab.rect.width - 15.0, 18.0)
),
Some((32, true))
);
assert_eq!(hit(&tabs, 720.0, Point::new(50.0, HEIGHT)), None);
}
}