use crate::core::{Color, Font, Point, Rect};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
pub struct TabPage {
pub title: String,
pub content: Option<Box<dyn Widget>>,
pub icon: Option<String>,
}
pub struct TabView {
base: BaseWidget,
tabs: Vec<TabPage>,
selected_index: usize,
pub tab_changed: Signal1<usize>,
}
impl TabView {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::TabView, geometry, "TabView"),
tabs: Vec::new(),
selected_index: 0,
tab_changed: Signal1::new(),
}
}
pub fn add_tab(
&mut self,
title: impl Into<String>,
content: Option<Box<dyn Widget>>,
icon: Option<impl Into<String>>,
) {
let was_empty = self.tabs.is_empty();
self.tabs.push(TabPage { title: title.into(), content, icon: icon.map(|i| i.into()) });
if was_empty {
self.set_current_index(0);
}
self.base.request_redraw();
}
pub fn remove_tab(&mut self, index: usize) {
if index >= self.tabs.len() {
return;
}
self.tabs.remove(index);
if self.tabs.is_empty() {
self.selected_index = 0;
} else if self.selected_index >= self.tabs.len() {
self.selected_index = self.tabs.len() - 1;
}
self.base.request_redraw();
}
pub fn tab_count(&self) -> usize {
self.tabs.len()
}
pub fn clear_tabs(&mut self) {
self.tabs.clear();
self.selected_index = 0;
self.base.request_redraw();
}
pub fn set_current_index(&mut self, index: usize) {
if self.tabs.is_empty() {
return;
}
let clamped = index.min(self.tabs.len() - 1);
if self.selected_index != clamped {
self.selected_index = clamped;
self.tab_changed.emit(clamped);
self.base.request_redraw();
}
}
pub fn current_index(&self) -> usize {
self.selected_index
}
pub fn tabs(&self) -> &[TabPage] {
&self.tabs
}
pub fn tabs_mut(&mut self) -> &mut Vec<TabPage> {
&mut self.tabs
}
}
impl Widget for TabView {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
}
impl Draw for TabView {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
let tab_bar_height: u32 = 40;
let tab_bar_rect = Rect::new(rect.x, rect.y, rect.width, tab_bar_height);
let content_y = rect.y + tab_bar_height as i32;
let content_rect =
Rect::new(rect.x, content_y, rect.width, rect.height.saturating_sub(tab_bar_height));
context.fill_rect(tab_bar_rect, Color::rgba(245, 245, 245, 255));
if self.tabs.is_empty() {
context.fill_rect(content_rect, Color::WHITE);
return;
}
let tab_count = self.tabs.len() as u32;
let tab_width = rect.width / tab_count.max(1);
let font = Font::simple("sans-serif", 12.0);
for i in 0..self.tabs.len() {
let tab_x = rect.x + (i as u32 * tab_width) as i32;
let tab_rect = Rect::new(tab_x, rect.y, tab_width, tab_bar_height);
let is_selected = i == self.selected_index;
let bg_color = if is_selected { Color::WHITE } else { Color::rgba(235, 235, 235, 255) };
context.fill_rect(tab_rect, bg_color);
if is_selected {
let indicator_rect =
Rect::new(tab_x, rect.y + tab_bar_height as i32 - 3, tab_width, 3);
context.fill_rect(indicator_rect, Color::rgba(52, 120, 246, 255));
}
let tab = &self.tabs[i];
let display_text = if let Some(ref icon_name) = tab.icon {
format!("{} {}", icon_name, tab.title)
} else {
tab.title.clone()
};
let text_color = if is_selected {
Color::rgba(52, 120, 246, 255)
} else {
Color::rgba(80, 80, 80, 255)
};
let metrics = context.measure_text(&display_text, &font);
let text_x = tab_x + (tab_width as i32 - metrics.width as i32) / 2;
let text_y = rect.y
+ (tab_bar_height as i32 - metrics.height as i32) / 2
+ metrics.ascent as i32;
context.draw_text(
Point::new(text_x.max(tab_x), text_y),
&display_text,
&font,
text_color,
);
}
let separator_rect = Rect::new(rect.x, rect.y + tab_bar_height as i32 - 1, rect.width, 1);
context.fill_rect(separator_rect, Color::rgba(200, 200, 200, 255));
context.fill_rect(content_rect, Color::WHITE);
}
}
impl EventHandler for TabView {
fn handle_event(&mut self, event: &Event) {
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button } => {
if *button == 1 && !self.tabs.is_empty() {
let rect = self.geometry();
let tab_bar_height: u32 = 40;
if pos.y >= rect.y && pos.y < rect.y + tab_bar_height as i32 {
let tab_count = self.tabs.len() as u32;
let tab_width = rect.width / tab_count.max(1);
let relative_x = (pos.x - rect.x) as u32;
let clicked_index = (relative_x / tab_width) as usize;
if clicked_index < self.tabs.len() {
self.set_current_index(clicked_index);
}
}
}
}
_ => {
self.base.handle_event(event);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Point;
use crate::widget::svg::render_to_svg;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
fn make_tab_view() -> TabView {
TabView::new(Rect::new(0, 0, 300, 400))
}
#[test]
fn tab_view_default_state() {
let tv = make_tab_view();
assert_eq!(tv.tab_count(), 0);
assert_eq!(tv.current_index(), 0);
assert_eq!(tv.kind(), WidgetKind::TabView);
}
#[test]
fn tab_view_add_and_select() {
let mut tv = make_tab_view();
tv.add_tab("Tab 1", None, None::<&str>);
tv.add_tab("Tab 2", None, None::<&str>);
assert_eq!(tv.tab_count(), 2);
assert_eq!(tv.current_index(), 0);
tv.set_current_index(1);
assert_eq!(tv.current_index(), 1);
}
#[test]
fn tab_view_signal_emits() {
let mut tv = make_tab_view();
tv.add_tab("First", None, None::<&str>);
tv.add_tab("Second", None, None::<&str>);
let captured = Arc::new(AtomicUsize::new(usize::MAX));
tv.tab_changed.connect({
let captured = Arc::clone(&captured);
move |val: Arc<usize>| {
captured.store(*val, Ordering::SeqCst);
}
});
tv.set_current_index(1);
assert_eq!(captured.load(Ordering::SeqCst), 1);
}
#[test]
fn tab_view_remove_tab() {
let mut tv = make_tab_view();
tv.add_tab("A", None, None::<&str>);
tv.add_tab("B", None, None::<&str>);
tv.add_tab("C", None, None::<&str>);
tv.set_current_index(2);
tv.remove_tab(2);
assert_eq!(tv.tab_count(), 2);
assert_eq!(tv.current_index(), 1);
}
#[test]
fn tab_view_clear_tabs() {
let mut tv = make_tab_view();
tv.add_tab("X", None, None::<&str>);
tv.add_tab("Y", None, None::<&str>);
tv.clear_tabs();
assert_eq!(tv.tab_count(), 0);
assert_eq!(tv.current_index(), 0);
}
#[test]
fn tab_view_mouse_click_switches_tab() {
let mut tv = make_tab_view();
tv.add_tab("Foo", None, None::<&str>);
tv.add_tab("Bar", None, None::<&str>);
tv.handle_event(&Event::MousePress { pos: Point::new(160, 20), button: 1 });
assert_eq!(tv.current_index(), 1);
}
#[test]
fn tab_view_svg_output() {
let mut tv = make_tab_view();
tv.add_tab("One", None, None::<&str>);
tv.add_tab("Two", None, None::<&str>);
let svg = render_to_svg(&mut tv);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
}
#[test]
fn tab_view_set_current_index_noop_when_empty() {
let mut tv = make_tab_view();
tv.set_current_index(5); assert_eq!(tv.current_index(), 0);
}
}