//! 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, pub insertion: Option, pub pointer: Option, 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, } 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 { 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) -> std::ops::Range { 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, 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, delta: f32) -> f32 { scroll_offset(tabs, width, Some(scroll_offset(tabs, width, start) + delta)) } pub fn scroll_start(tabs: &[Tab], width: f32, start: Option, direction: i8) -> Option { 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, 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, tabs: &[Tab], width: f32) -> Vec> { 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, 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, tabs: &[Tab], width: f32, point: Point, ) -> Option { 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, tabs: &[Tab], width: f32, id: u64, point: Point, ) -> Option { 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, 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, 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, list: &mut DisplayList, tabs: &[Tab], width: f32, scale: f32, _pointer: Option, 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![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 ); } }