//! 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> { 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 { 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 { 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); } }