Files
endorphin-tessera-gui/src/tabs.rs
T
codex-bot 96319b13a3
Verify Tessera GUI / verify (push) Failing after 7m37s
Connect native tab chrome to managed Page methods
2026-10-21 17:29:54 +08:00

180 lines
5.5 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),
)
})
}
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 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);
}
}