276 lines
8.8 KiB
Rust
276 lines
8.8 KiB
Rust
//! 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);
|
||
}
|
||
}
|