use retroglyph_core::color::{Color, Style};
use retroglyph_core::grid::{Rect, Size};
use retroglyph_core::text::width;
use super::strip::Strip;
use super::{InteractiveWidget, MinSize, Widget};
use crate::Surface;
use crate::align::Align;
use crate::draw::fill_rect;
use crate::interact::Density;
use crate::interact::Response;
use crate::interact::Sense;
use crate::text::draw_clipped;
use crate::theme::Theme;
#[derive(Clone, Copy, Debug)]
pub struct Tabs<'a> {
titles: &'a [&'a str],
selected: Option<usize>,
style: Style,
selected_style: Style,
column_spacing: u16,
divider: Option<char>,
}
impl<'a> Tabs<'a> {
#[must_use]
pub fn new(titles: &'a [&'a str]) -> Self {
Self {
titles,
selected: None,
style: Style::new(),
selected_style: Style::new(),
column_spacing: 1,
divider: None,
}
.theme(Theme::DARK)
}
#[must_use]
pub const fn select(mut self, index: Option<usize>) -> Self {
self.selected = index;
self
}
#[must_use]
pub const fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
#[must_use]
pub const fn selected_style(mut self, style: Style) -> Self {
self.selected_style = style;
self
}
#[must_use]
pub const fn column_spacing(mut self, spacing: u16) -> Self {
self.column_spacing = spacing;
self
}
#[must_use]
pub const fn divider(mut self, divider: Option<char>) -> Self {
self.divider = divider;
self
}
#[must_use]
pub fn theme(self, theme: Theme) -> Self {
self.theme_on(theme, theme.panel_bg)
}
#[must_use]
pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
self.style = Style::new().fg(theme.dim).bg(bg);
self.selected_style = Style::new().fg(theme.bg).bg(theme.accent);
self
}
}
impl Tabs<'_> {
fn draw(&self, surface: &mut Surface<'_>, selected: Option<usize>) {
let area = surface.area();
if area.width() == 0 || area.height() == 0 {
return;
}
let strip = Strip::new(self.titles, self.column_spacing);
for (index, abs_x, text_width) in strip.columns(area) {
let x = abs_x - area.left();
let title = self.titles[index];
let style = if Some(index) == selected {
self.selected_style
} else {
self.style
};
if Some(index) == selected && text_width > 0 {
fill_rect(
surface,
Rect::new(x, 0, text_width, 1),
' ',
Style::new().bg(style.background()),
);
}
let _ = draw_clipped(surface, (x, 0), text_width, title, Align::Left, style);
if let Some(divider) = self.divider
&& index + 1 < self.titles.len()
{
let mid = x + text_width + self.column_spacing / 2;
if mid < area.width() {
surface.put((mid, 0), divider, Style::new());
}
}
}
}
fn tab_at(&self, area: Rect, pos: retroglyph_core::grid::Pos) -> Option<usize> {
Strip::new(self.titles, self.column_spacing).index_at(area, pos)
}
}
impl MinSize for Tabs<'_> {
fn min_size(&self, density: Density) -> Size {
let mut content_width = 0u16;
for (index, &title) in self.titles.iter().enumerate() {
content_width = content_width.saturating_add(width(title));
if index + 1 < self.titles.len() {
content_width = content_width.saturating_add(self.column_spacing);
}
}
Size::new(content_width, 1).max(density.min_target_size())
}
}
impl Widget for Tabs<'_> {
fn render(&self, surface: &mut Surface<'_>) {
self.draw(surface, self.selected);
}
}
impl<Id> InteractiveWidget<Id> for Tabs<'_> {
type State = usize;
fn sense(&self) -> Sense {
Sense::click() | Sense::HOVER
}
fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State, response: Response<Id>) {
let area = surface.area();
if response.clicked()
&& let Some(pos) = response.pointer_pos()
&& let Some(index) = self.tab_at(area, pos)
{
*state = index;
}
self.draw(surface, Some(*state));
}
}
#[cfg(test)]
mod tests {
use alloc::vec::Vec;
use retroglyph_core::grid::{Grid, HasSize as _, Pos};
use super::*;
#[test]
fn draws_every_title_left_to_right() {
let area = Rect::new(0, 0, 20, 1);
let titles = ["One", "Two"];
let mut grid = Grid::new(20, 1);
let tabs = Tabs::new(&titles);
Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(0, 0)].glyph(), 'O');
assert_eq!(grid[Pos::new(4, 0)].glyph(), 'T');
}
#[test]
fn highlights_the_selected_tab() {
let area = Rect::new(0, 0, 20, 1);
let titles = ["One", "Two"];
let mut grid = Grid::new(20, 1);
let tabs = Tabs::new(&titles).select(Some(1));
Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
let selected_style = grid[Pos::new(4, 0)].style();
let unselected_style = grid[Pos::new(0, 0)].style();
assert_ne!(selected_style.foreground(), unselected_style.foreground());
assert_ne!(selected_style.background(), unselected_style.background());
}
#[test]
fn nothing_highlighted_when_unselected() {
let area = Rect::new(0, 0, 20, 1);
let titles = ["One", "Two"];
let mut grid = Grid::new(20, 1);
let tabs = Tabs::new(&titles);
Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
let bg0 = grid[Pos::new(0, 0)].style().background();
let bg1 = grid[Pos::new(4, 0)].style().background();
assert_eq!(bg0, bg1);
}
#[test]
fn column_spacing_can_be_overridden() {
let area = Rect::new(0, 0, 20, 1);
let titles = ["A", "B"];
let mut grid = Grid::new(20, 1);
let tabs = Tabs::new(&titles).column_spacing(3);
Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
assert_eq!(grid[Pos::new(4, 0)].glyph(), 'B');
}
#[test]
fn divider_renders_between_tabs_when_set() {
let area = Rect::new(0, 0, 20, 1);
let titles = ["A", "B"];
let mut grid = Grid::new(20, 1);
let tabs = Tabs::new(&titles).column_spacing(3).divider(Some('|'));
Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(2, 0)].glyph(), '|');
}
#[test]
fn no_divider_by_default() {
let area = Rect::new(0, 0, 20, 1);
let titles = ["A", "B"];
let mut grid = Grid::new(20, 1);
let tabs = Tabs::new(&titles);
Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(1, 0)].glyph(), ' ');
}
#[test]
fn stops_drawing_past_the_area_width_without_panicking() {
let area = Rect::new(0, 0, 4, 1);
let titles = ["Alpha", "Bravo", "Charlie"];
let mut grid = Grid::new(4, 1);
let tabs = Tabs::new(&titles);
Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
}
#[test]
fn style_can_be_overridden() {
use retroglyph_core::color::Color;
let area = Rect::new(0, 0, 20, 1);
let titles = ["One"];
let custom = Style::new().fg(Color::RED);
let mut grid = Grid::new(20, 1);
let tabs = Tabs::new(&titles).style(custom);
Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Color::RED);
}
#[test]
fn selected_style_can_be_overridden() {
use retroglyph_core::color::Color;
let area = Rect::new(0, 0, 20, 1);
let titles = ["One"];
let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
let mut grid = Grid::new(20, 1);
let tabs = Tabs::new(&titles).selected_style(custom).select(Some(0));
Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Color::GREEN);
assert_eq!(grid[Pos::new(0, 0)].style().background(), Color::BLUE);
}
#[test]
fn zero_width_is_a_no_op() {
let area = Rect::new(0, 0, 0, 1);
let titles = ["One"];
let mut grid = Grid::new(1, 1);
let tabs = Tabs::new(&titles);
Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
}
#[test]
fn theme_maps_named_roles_onto_style_and_selected_style() {
let area = Rect::new(0, 0, 20, 1);
let titles = ["One", "Two"];
let mut grid = Grid::new(20, 1);
let tabs = Tabs::new(&titles).theme(Theme::DARK).select(Some(1));
Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.dim);
assert_eq!(
grid[Pos::new(0, 0)].style().background(),
Theme::DARK.panel_bg
);
assert_eq!(grid[Pos::new(4, 0)].style().foreground(), Theme::DARK.bg);
assert_eq!(
grid[Pos::new(4, 0)].style().background(),
Theme::DARK.accent
);
}
#[test]
fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
use retroglyph_core::color::Color;
let area = Rect::new(0, 0, 20, 1);
let titles = ["One"];
let mut grid = Grid::new(20, 1);
let tabs = Tabs::new(&titles)
.theme_on(Theme::DARK, Color::Default)
.select(Some(0));
Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.bg);
assert_eq!(
grid[Pos::new(0, 0)].style().background(),
Theme::DARK.accent
);
}
#[test]
fn click_selects_the_tab_under_the_pointer() {
let area = Rect::new(0, 0, 20, 1);
let titles = ["One", "Two"];
let tabs = Tabs::new(&titles);
let mut state = 0usize;
let response: Response<()> = Response {
hovered: true,
clicked: true,
pointer_pos: Some(Pos::new(4, 0)), ..Response::default()
};
let mut grid = Grid::new(20, 1);
InteractiveWidget::render(
&tabs,
&mut Surface::new(&mut grid, area, 0),
&mut state,
response,
);
assert_eq!(state, 1);
}
#[test]
fn click_past_the_last_tab_selects_nothing() {
let area = Rect::new(0, 0, 20, 1);
let titles = ["One", "Two"];
let tabs = Tabs::new(&titles);
let mut state = 0usize;
let response: Response<()> = Response {
hovered: true,
clicked: true,
pointer_pos: Some(Pos::new(10, 0)), ..Response::default()
};
let mut grid = Grid::new(20, 1);
InteractiveWidget::render(
&tabs,
&mut Surface::new(&mut grid, area, 0),
&mut state,
response,
);
assert_eq!(state, 0); }
#[test]
fn tabs_wide_title_loses_half_its_characters() {
let area = Rect::new(0, 0, 20, 1);
let titles = ["設定", "次"];
let mut grid = Grid::new(20, 1);
let tabs = Tabs::new(&titles);
Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(0, 0)].glyph(), '設');
assert_eq!(grid[Pos::new(2, 0)].glyph(), '定');
assert_eq!(grid[Pos::new(5, 0)].glyph(), '次');
}
#[test]
fn tabs_wide_title_does_not_overlap_the_next_tab() {
let area = Rect::new(0, 0, 20, 1);
let titles = ["設定", "次"];
let tabs = Tabs::new(&titles);
let columns: Vec<_> = Strip::new(&titles, tabs.column_spacing)
.columns(area)
.collect();
assert_eq!(columns[0], (0, 0, 4));
assert_eq!(columns[1].1, 5);
}
#[test]
fn min_size_sums_title_widths_and_spacing() {
let titles = ["One", "Two"];
let size = Tabs::new(&titles).min_size(Density::Mouse);
assert_eq!(size.width(), 7);
assert_eq!(size.height(), 1);
}
#[test]
fn min_size_floors_a_narrow_strip_at_the_density_minimum() {
let titles = ["A"];
let size = Tabs::new(&titles).min_size(Density::Touch);
assert_eq!(size, Density::Touch.min_target_size());
}
#[test]
fn min_size_respects_custom_column_spacing() {
let titles = ["A", "B"];
let size = Tabs::new(&titles)
.column_spacing(3)
.min_size(Density::Mouse);
assert_eq!(size.width(), 6);
}
}