use crate::components::keyboard; use iced::widget::button; use iced::widget::scrollable::Direction; use iced::widget::text::Shaping; use iced::widget::tooltip::Position; use iced::widget::{Space, container, row, scrollable, text, tooltip}; use iced::{Background, Border, Color, Element, Font, Length, Theme}; use bds_core::i18n::UiLocale; use crate::app::Message; use crate::components::inputs; use crate::i18n::t; use crate::state::tabs::Tab; /// tabs.allium config: tab_min_width=100, tab_max_width=160 /// In a scrollable tab bar, tabs use the max width since they never need to shrink. const TAB_WIDTH: f32 = 160.0; const CHAT_TITLE_MAX_LEN: usize = 18; /// Maximum characters for tab titles. /// TAB_WIDTH (160) minus padding (16) minus close button + spacing (~28) /// leaves ~116px. At ~6.8px per char (12px proportional font) ≈ 17 chars. const TAB_TITLE_MAX_LEN: usize = 17; /// Tab bar background. fn bar_style(_theme: &Theme) -> container::Style { container::Style { background: Some(Background::Color(inputs::SIDEBAR_BG)), border: Border { color: inputs::BORDER_COLOR, width: 0.0, radius: 0.0.into(), }, ..container::Style::default() } } /// Active tab style. fn tab_active(_theme: &Theme, _status: button::Status) -> button::Style { button::Style { background: Some(Background::Color(inputs::APP_BG)), text_color: Color::WHITE, border: Border { color: Color::from_rgb(0.30, 0.55, 0.90), width: 0.0, radius: 6.0.into(), }, ..button::Style::default() } } /// Inactive tab style. fn tab_inactive(_theme: &Theme, status: button::Status) -> button::Style { let bg = match status { button::Status::Hovered => inputs::ELEVATED_BG, _ => inputs::SIDEBAR_BG, }; button::Style { background: Some(Background::Color(bg)), text_color: Color::from_rgb(0.60, 0.60, 0.65), border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 6.0.into(), }, ..button::Style::default() } } /// Close button on tab. fn close_style(_theme: &Theme, status: button::Status) -> button::Style { let color = match status { button::Status::Hovered => Color::WHITE, _ => Color::from_rgb(0.45, 0.45, 0.50), }; button::Style { background: Some(Background::Color(Color::TRANSPARENT)), text_color: color, border: Border::default(), ..button::Style::default() } } /// Truncate chat title per tabs.allium: chat_title_max_length = 18. fn truncate_chat_title(title: &str) -> String { if title.chars().count() > CHAT_TITLE_MAX_LEN { let truncated: String = title.chars().take(CHAT_TITLE_MAX_LEN).collect(); format!("{truncated}...") } else { title.to_string() } } /// Truncate a tab title to fit within the tab's available text area. /// Uses "…" (Unicode ellipsis) as the truncation indicator. fn truncate_tab_title(title: &str) -> String { if title.chars().count() > TAB_TITLE_MAX_LEN { let truncated: String = title .chars() .take(TAB_TITLE_MAX_LEN.saturating_sub(1)) .collect(); format!("{truncated}\u{2026}") } else { title.to_string() } } /// Build tooltip text per tabs.allium: /// Base: tab title. If transient: append " (Preview)". If dirty: append " * Modified". fn build_tooltip_text(tab: &Tab, locale: UiLocale) -> String { let mut tip = tab.title.clone(); if tab.is_transient { tip.push_str(" ("); tip.push_str(&t(locale, "tabBar.preview")); tip.push(')'); } if tab.is_dirty { tip.push_str(" * "); tip.push_str(&t(locale, "tabBar.modified")); } tip } pub fn view(tabs: &[Tab], active_tab: Option<&str>, locale: UiLocale) -> Element<'static, Message> { // Per tabs.allium: "Hidden when no tabs exist." if tabs.is_empty() { return Space::new().height(0).into(); } let tab_buttons: Vec> = tabs .iter() .map(|tab| { let is_active = active_tab == Some(tab.id.as_str()); let tab_id = tab.id.clone(); let close_id = tab.id.clone(); // Per tabs.allium: chat titles are JS-truncated to 18 chars + "..." // All other titles are truncated to fit the tab's text area. let display_title = if tab.tab_type == crate::state::tabs::TabType::Chat { truncate_chat_title(&tab.title) } else { truncate_tab_title(&tab.title) }; // Per tabs.allium: transient tabs show italic title let title_label = if tab.is_transient { text(display_title) .size(12) .shaping(Shaping::Advanced) .font(Font { style: iced::font::Style::Italic, ..Font::DEFAULT }) .wrapping(iced::widget::text::Wrapping::None) } else { text(display_title) .size(12) .shaping(Shaping::Advanced) .wrapping(iced::widget::text::Wrapping::None) }; // Per tabs.allium DirtyIndicator: dot for dirty post tabs let dirty_indicator = if tab.is_dirty { text(" \u{25CF}") .size(10) .shaping(Shaping::Advanced) .color(Color::from_rgb(0.90, 0.70, 0.30)) } else { text("").size(10) }; // Title + dirty indicator clipped to enforce ellipsis-like truncation let title_area = container( row![title_label, dirty_indicator] .spacing(2) .align_y(iced::Alignment::Center), ) .width(Length::Fill) .clip(true); // Keep the tooltip around non-interactive title content so it does // not hide the tab and close controls from widget operations. let tooltip_text = build_tooltip_text(tab, locale); let title_area = tooltip( title_area, text(tooltip_text).size(11).shaping(Shaping::Advanced), Position::Bottom, ) .gap(4) .style(inputs::tooltip_style); let label = row![ title_area, keyboard::button(text("\u{2715}").size(10).shaping(Shaping::Advanced)) .on_press(Message::CloseTab(close_id)) .padding(2) .style(close_style), ] .spacing(4) .align_y(iced::Alignment::Center); // tabs.allium: tab_min_width=100, tab_max_width=160 keyboard::button(label) .on_press(Message::SelectTab(tab_id)) .padding([6, 8]) .width(Length::Fixed(TAB_WIDTH)) .style(if is_active { tab_active } else { tab_inactive }) .into() }) .collect(); // tabs.allium: horizontal strip with overflow scroll let tab_row = iced::widget::Row::with_children(tab_buttons) .spacing(1) .height(Length::Fixed(35.0)); let scrollable_tabs = scrollable(tab_row) .direction(Direction::Horizontal( scrollable::Scrollbar::new().width(0).scroller_width(0), )) .width(Length::Fill) .height(Length::Fixed(35.0)); container(scrollable_tabs) .width(Length::Fill) .height(Length::Fixed(35.0)) .style(bar_style) .into() } #[cfg(test)] mod tests { use super::*; #[test] fn truncate_chat_title_short() { assert_eq!(truncate_chat_title("Hello"), "Hello"); } #[test] fn truncate_chat_title_exact_18() { let title: String = "a".repeat(18); assert_eq!(truncate_chat_title(&title), title); } #[test] fn truncate_chat_title_over_18() { let title: String = "a".repeat(25); let expected = format!("{}...", "a".repeat(18)); assert_eq!(truncate_chat_title(&title), expected); } #[test] fn truncate_tab_title_short() { assert_eq!(truncate_tab_title("Settings"), "Settings"); } #[test] fn truncate_tab_title_exact_limit() { let title: String = "a".repeat(TAB_TITLE_MAX_LEN); assert_eq!(truncate_tab_title(&title), title); } #[test] fn truncate_tab_title_over_limit() { let title: String = "a".repeat(30); let expected = format!("{}\u{2026}", "a".repeat(TAB_TITLE_MAX_LEN - 1)); assert_eq!(truncate_tab_title(&title), expected); } }