use std::fmt;
use ratatui::{
buffer::Buffer,
layout::{Position, Rect, Size},
style::{Color, Style},
text::Line,
widgets::Widget,
};
use crate::Theme;
use crate::button_shape::{BOTTOM_CAP, TOP_CAP, cap_row, filled_middle, shape_width};
use crate::color::{
DISABLED_DIM, FOCUS_DARKEN, FOCUS_LIGHTEN, HOVER_DARKEN, HOVER_LIGHTEN, darken, dim, lighten,
};
use crate::linear_nav;
use crate::list_core;
use crate::runtime::{
Component, Event, EventCtx, EventResult, KeyCode, KeyEvent, MeasuredComponent, MouseButton,
MouseKind, RenderCtx, Step,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum TabsSize {
#[default]
Small,
Large,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum TabsActivation {
#[default]
Manual,
Automatic,
}
impl TabsSize {
#[must_use]
pub const fn height(self) -> u16 {
match self {
Self::Small => 1,
Self::Large => 3,
}
}
}
const SPACING: u16 = 1;
const LEFT_MARKER: &str = "‹";
const RIGHT_MARKER: &str = "›";
const MARKER_WIDTH: u16 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TabsStyle {
pub foreground: Color,
pub background: Color,
pub focused_foreground: Color,
pub focused_background: Color,
pub hovered_foreground: Color,
pub hovered_background: Color,
pub selected_foreground: Color,
pub selected_background: Color,
pub selected_focused_foreground: Color,
pub selected_focused_background: Color,
pub selected_hovered_foreground: Color,
pub selected_hovered_background: Color,
pub disabled_foreground: Color,
pub disabled_background: Color,
pub selected_disabled_foreground: Color,
pub selected_disabled_background: Color,
}
impl TabsStyle {
#[must_use]
pub const fn fallback() -> Self {
Self {
foreground: Color::Gray,
background: Color::DarkGray,
focused_foreground: Color::White,
focused_background: Color::Gray,
hovered_foreground: Color::White,
hovered_background: Color::Gray,
selected_foreground: Color::Black,
selected_background: Color::Cyan,
selected_focused_foreground: Color::Black,
selected_focused_background: Color::Cyan,
selected_hovered_foreground: Color::Black,
selected_hovered_background: Color::LightCyan,
disabled_foreground: Color::DarkGray,
disabled_background: Color::Reset,
selected_disabled_foreground: Color::DarkGray,
selected_disabled_background: Color::Cyan,
}
}
#[must_use]
pub const fn from_theme(theme: &Theme) -> Self {
Self {
foreground: theme.secondary_foreground,
background: theme.secondary,
focused_foreground: theme.secondary_foreground,
focused_background: lighten(theme.secondary, FOCUS_LIGHTEN),
hovered_foreground: theme.secondary_foreground,
hovered_background: lighten(theme.secondary, HOVER_LIGHTEN),
selected_foreground: theme.primary_foreground,
selected_background: theme.primary,
selected_focused_foreground: theme.primary_foreground,
selected_focused_background: darken(theme.primary, FOCUS_DARKEN),
selected_hovered_foreground: theme.primary_foreground,
selected_hovered_background: darken(theme.primary, HOVER_DARKEN),
disabled_foreground: theme.muted_foreground,
disabled_background: dim(theme.secondary, theme.surface, DISABLED_DIM),
selected_disabled_foreground: theme.muted_foreground,
selected_disabled_background: dim(theme.primary, theme.surface, DISABLED_DIM),
}
}
#[expect(
clippy::fn_params_excessive_bools,
reason = "the four independent tab states; call sites read from named fields"
)]
fn resolve(
&self,
selected: bool,
focused: bool,
hovered: bool,
disabled: bool,
) -> ResolvedTabStyle {
let (foreground, fill) = match (selected, disabled, hovered, focused) {
(true, true, _, _) => (
self.selected_disabled_foreground,
self.selected_disabled_background,
),
(false, true, _, _) => (self.disabled_foreground, self.disabled_background),
(true, false, true, _) => (
self.selected_hovered_foreground,
self.selected_hovered_background,
),
(true, false, false, true) => (
self.selected_focused_foreground,
self.selected_focused_background,
),
(true, false, false, false) => (self.selected_foreground, self.selected_background),
(false, false, true, _) => (self.hovered_foreground, self.hovered_background),
(false, false, false, true) => (self.focused_foreground, self.focused_background),
(false, false, false, false) => (self.foreground, self.background),
};
ResolvedTabStyle { foreground, fill }
}
}
#[derive(Clone, Copy)]
struct ResolvedTabStyle {
foreground: Color,
fill: Color,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TabsWidget<'a> {
labels: &'a [&'a str],
selected_tab: Option<usize>,
focused_tab: Option<usize>,
disabled_tabs: &'a [bool],
focused: bool,
hovered_tab: Option<usize>,
disabled: bool,
size: TabsSize,
style: TabsStyle,
}
impl<'a> TabsWidget<'a> {
#[must_use]
pub const fn new(labels: &'a [&'a str]) -> Self {
Self {
labels,
selected_tab: None,
focused_tab: None,
disabled_tabs: &[],
focused: false,
hovered_tab: None,
disabled: false,
size: TabsSize::Small,
style: TabsStyle::fallback(),
}
}
#[must_use]
pub const fn themed(mut self, theme: &Theme) -> Self {
self.style = TabsStyle::from_theme(theme);
self
}
#[must_use]
pub const fn style(mut self, style: TabsStyle) -> Self {
self.style = style;
self
}
#[must_use]
pub const fn selected_tab(mut self, selected_tab: Option<usize>) -> Self {
self.selected_tab = selected_tab;
self
}
#[must_use]
pub const fn focused_tab(mut self, focused_tab: Option<usize>) -> Self {
self.focused_tab = focused_tab;
self
}
#[must_use]
pub const fn disabled_tabs(mut self, disabled_tabs: &'a [bool]) -> Self {
self.disabled_tabs = disabled_tabs;
self
}
#[must_use]
pub const fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
#[must_use]
pub const fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
#[must_use]
pub const fn hovered_tab(mut self, hovered_tab: Option<usize>) -> Self {
self.hovered_tab = hovered_tab;
self
}
#[must_use]
pub const fn size(mut self, size: TabsSize) -> Self {
self.size = size;
self
}
#[must_use]
pub const fn height(&self) -> u16 {
self.size.height()
}
#[must_use]
pub fn width(&self) -> u16 {
row_width(self.labels.iter().copied())
}
}
fn row_width<'a>(labels: impl ExactSizeIterator<Item = &'a str>) -> u16 {
let gap_count = u16::try_from(labels.len().saturating_sub(1)).unwrap_or(u16::MAX);
labels
.map(tab_width)
.fold(0, u16::saturating_add)
.saturating_add(gap_count.saturating_mul(SPACING))
}
impl Widget for TabsWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height < self.height() {
return;
}
let must_show = self.focused_tab.or(self.selected_tab).unwrap_or(0);
let TabLayout { tabs, left, right } = tab_layout(area, self.labels, self.size, must_show);
for (index, rect) in tabs {
let disabled = self.disabled || self.disabled_tabs.get(index).copied().unwrap_or(false);
let cursor = self.focused_tab == Some(index);
let resolved = self.style.resolve(
self.selected_tab == Some(index),
cursor && self.focused,
self.hovered_tab == Some(index),
disabled,
);
if self.size == TabsSize::Large {
render_tab_cap(rect, resolved.fill, TOP_CAP, buf);
render_tab_cap(
Rect::new(rect.x, rect.y + 2, rect.width, 1),
resolved.fill,
BOTTOM_CAP,
buf,
);
}
Line::from(filled_middle(self.labels[index], rect.width as usize))
.style(Style::default().fg(resolved.foreground).bg(resolved.fill))
.render(
Rect::new(rect.x, rect.y + content_y_offset(self.size), rect.width, 1),
buf,
);
}
for (marker, slot) in [(LEFT_MARKER, left), (RIGHT_MARKER, right)] {
if let Some(rect) = slot {
Line::from(marker)
.style(Style::default().fg(self.style.foreground))
.render(
Rect::new(
rect.x,
rect.y + content_y_offset(self.size),
MARKER_WIDTH,
1,
),
buf,
);
}
}
}
}
fn render_tab_cap(rect: Rect, fill: Color, symbol: &str, buf: &mut Buffer) {
Line::from(cap_row(fill, symbol, rect.width as usize))
.style(Style::default().fg(fill))
.render(rect, buf);
}
const fn content_y_offset(size: TabsSize) -> u16 {
match size {
TabsSize::Small => 0,
TabsSize::Large => 1,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tab<T> {
value: T,
label: String,
disabled: bool,
}
impl<T> Tab<T> {
#[must_use]
pub fn new(value: T, label: impl Into<String>) -> Self {
Self {
value,
label: label.into(),
disabled: false,
}
}
#[must_use]
pub const fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
#[must_use]
pub const fn value(&self) -> &T {
&self.value
}
#[must_use]
pub fn label(&self) -> &str {
&self.label
}
#[must_use]
pub const fn is_disabled(&self) -> bool {
self.disabled
}
}
impl From<&str> for Tab<String> {
fn from(label: &str) -> Self {
Self::new(label.to_owned(), label)
}
}
impl From<String> for Tab<String> {
fn from(label: String) -> Self {
Self::new(label.clone(), label)
}
}
type ReadFn<S, T> = Box<dyn Fn(&S) -> Option<T>>;
type OnChangeFn<T, M> = Box<dyn Fn(T) -> M>;
type StyleFn = Box<dyn Fn(&Theme) -> TabsStyle>;
pub struct Tabs<T, S, M> {
items: Vec<Tab<T>>,
focused_tab: Option<ReadFn<S, T>>,
on_focus_change: Option<OnChangeFn<T, M>>,
selected: Option<ReadFn<S, T>>,
on_select: Option<OnChangeFn<T, M>>,
activation: TabsActivation,
size: TabsSize,
style: Option<StyleFn>,
disabled: bool,
hits: TabLayout,
}
impl<T, S, M> fmt::Debug for Tabs<T, S, M>
where
T: fmt::Debug,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Tabs")
.field("items", &self.items)
.field("focused_tab", &self.focused_tab.is_some())
.field("on_focus_change", &self.on_focus_change.is_some())
.field("selected", &self.selected.is_some())
.field("on_select", &self.on_select.is_some())
.field("activation", &self.activation)
.field("size", &self.size)
.field("style", &self.style.is_some())
.finish_non_exhaustive()
}
}
impl<T, S, M> Tabs<T, S, M> {
#[must_use]
pub fn new(tabs: impl IntoIterator<Item = impl Into<Tab<T>>>) -> Self {
Self {
items: tabs.into_iter().map(Into::into).collect(),
focused_tab: None,
on_focus_change: None,
selected: None,
on_select: None,
activation: TabsActivation::Manual,
size: TabsSize::Small,
style: None,
disabled: false,
hits: TabLayout::default(),
}
}
#[must_use]
pub fn tab_focus(
mut self,
read: impl Fn(&S) -> Option<T> + 'static,
on_change: impl Fn(T) -> M + 'static,
) -> Self {
self.focused_tab = Some(Box::new(read));
self.on_focus_change = Some(Box::new(on_change));
self
}
#[must_use]
pub fn selection(
mut self,
read: impl Fn(&S) -> Option<T> + 'static,
on_select: impl Fn(T) -> M + 'static,
) -> Self {
self.selected = Some(Box::new(read));
self.on_select = Some(Box::new(on_select));
self
}
#[must_use]
pub const fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
#[must_use]
pub const fn activation(mut self, activation: TabsActivation) -> Self {
self.activation = activation;
self
}
#[must_use]
pub const fn size(mut self, size: TabsSize) -> Self {
self.size = size;
self
}
#[must_use]
pub const fn height(&self) -> u16 {
self.size.height()
}
#[must_use]
pub fn width(&self) -> u16 {
row_width(self.items.iter().map(|tab| tab.label.as_str()))
}
#[must_use]
pub fn style(mut self, style: impl Fn(&Theme) -> TabsStyle + 'static) -> Self {
self.style = Some(Box::new(style));
self
}
fn disabled_at(&self, index: usize) -> bool {
self.items.get(index).is_some_and(|tab| tab.disabled)
}
fn tab_at(&self, column: u16, row: u16) -> Option<usize> {
let point = Position { x: column, y: row };
self.hits
.tabs
.iter()
.find(|(_, rect)| rect.contains(point))
.map(|(index, _)| *index)
}
fn marker_at(&self, column: u16, row: u16) -> Option<Step> {
let point = Position { x: column, y: row };
if self.hits.left.is_some_and(|rect| rect.contains(point)) {
Some(Step::Backward)
} else if self.hits.right.is_some_and(|rect| rect.contains(point)) {
Some(Step::Forward)
} else {
None
}
}
}
fn step_direction(key: KeyEvent) -> Option<Step> {
if key.modifiers.alt || key.modifiers.shift {
return None;
}
if key.modifiers.ctrl {
return match key.code {
KeyCode::Char('n') => Some(Step::Forward),
KeyCode::Char('p') => Some(Step::Backward),
_ => None,
};
}
match key.code {
KeyCode::Left | KeyCode::Char('h') => Some(Step::Backward),
KeyCode::Right | KeyCode::Char('l') => Some(Step::Forward),
_ => None,
}
}
impl<T, S, M> Tabs<T, S, M>
where
T: Clone + PartialEq,
{
fn index_of(&self, value: &T) -> Option<usize> {
self.items.iter().position(|tab| &tab.value == value)
}
fn selected_index(&self, state: &S) -> Option<usize> {
let value = (self.selected.as_ref()?)(state)?;
self.index_of(&value)
}
fn cursor_index(&self, state: &S) -> Option<usize> {
match self.activation {
TabsActivation::Automatic => self.selected_index(state),
TabsActivation::Manual => self
.focused_tab
.as_ref()
.and_then(|read| self.index_of(&read(state)?)),
}
}
fn keyboard_enabled(&self) -> bool {
self.selected.is_some()
&& match self.activation {
TabsActivation::Manual => self.focused_tab.is_some(),
TabsActivation::Automatic => true,
}
}
fn select(&self, index: usize) -> EventResult<M> {
match &self.on_select {
Some(on_select) => EventResult::Emit(on_select(self.items[index].value.clone())),
None => EventResult::Ignored,
}
}
fn move_focus(&self, index: usize) -> EventResult<M> {
match &self.on_focus_change {
Some(on_focus_change) => {
EventResult::Emit(on_focus_change(self.items[index].value.clone()))
}
None => EventResult::Ignored,
}
}
fn handle_key(&self, key: KeyEvent, state: &S) -> EventResult<M> {
if !self.keyboard_enabled() {
return EventResult::Ignored;
}
let len = self.items.len();
if linear_nav::first_enabled(len, |index| self.disabled_at(index)).is_none() {
return EventResult::Ignored;
}
let cursor = self.cursor_index(state);
if let Some(direction) = step_direction(key) {
let index = cursor.map_or_else(
|| linear_nav::first_enabled(len, |i| self.disabled_at(i)).expect("checked above"),
|from| linear_nav::step_enabled(len, from, direction, |i| self.disabled_at(i)),
);
return self.apply_target(Some(index), cursor);
}
if linear_nav::has_reserved_modifier(key) {
return EventResult::Ignored;
}
let next = match key.code {
KeyCode::Home => linear_nav::first_enabled(len, |i| self.disabled_at(i)),
KeyCode::End => linear_nav::last_enabled(len, |i| self.disabled_at(i)),
KeyCode::Enter | KeyCode::Char(' ') => {
return match cursor {
Some(index) if self.disabled_at(index) => EventResult::Ignored,
Some(index) => self.select(index),
None => EventResult::Ignored,
};
}
_ => return EventResult::Ignored,
};
self.apply_target(next, cursor)
}
fn apply_target(&self, next: Option<usize>, cursor: Option<usize>) -> EventResult<M> {
match next {
Some(index) if Some(index) != cursor => match self.activation {
TabsActivation::Manual => self.move_focus(index),
TabsActivation::Automatic => self.select(index),
},
Some(_) => EventResult::Consumed,
None => EventResult::Ignored,
}
}
}
impl<T, S, M> Component<S, M> for Tabs<T, S, M>
where
T: Clone + PartialEq,
{
fn prepare(&mut self, _state: &S) {
list_core::assert_unique_values(self.items.iter().map(Tab::value), "Tabs");
}
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, S, M>) {
let area = ctx.area();
let state = ctx.state();
let selected = self.selected_index(state);
let cursor = self.cursor_index(state);
let labels: Vec<&str> = self.items.iter().map(|tab| tab.label.as_str()).collect();
let disabled: Vec<bool> = (0..self.items.len()).map(|i| self.disabled_at(i)).collect();
let must_show = cursor.or(selected).unwrap_or(0);
self.hits = tab_layout(area, &labels, self.size, must_show);
let hovered_tab = ctx.hover_position().and_then(|position| {
self.hits
.tabs
.iter()
.find(|(_, rect)| rect.contains(position))
.map(|(index, _)| *index)
});
let style = match &self.style {
Some(style) => style(ctx.theme),
None => TabsStyle::from_theme(ctx.theme),
};
let widget = TabsWidget::new(&labels)
.selected_tab(selected)
.focused_tab(cursor)
.disabled_tabs(&disabled)
.focused(ctx.focused)
.hovered_tab(hovered_tab)
.disabled(self.disabled)
.size(self.size)
.style(style);
ctx.render_widget(widget, area);
}
fn handle_event(
&mut self,
event: &Event,
state: &S,
_ctx: &mut EventCtx<'_>,
) -> EventResult<M> {
if self.disabled || self.items.is_empty() {
return EventResult::Ignored;
}
match event {
Event::Mouse(mouse) => match mouse.kind {
MouseKind::Down(MouseButton::Left) => match self.tab_at(mouse.column, mouse.row) {
Some(index) if self.disabled_at(index) => EventResult::Consumed,
_ => EventResult::Ignored,
},
MouseKind::Moved => {
if self.keyboard_enabled() && self.tab_at(mouse.column, mouse.row).is_some() {
EventResult::Consumed
} else {
EventResult::Ignored
}
}
MouseKind::Click(MouseButton::Left) => {
if let Some(index) = self.tab_at(mouse.column, mouse.row) {
return if self.disabled_at(index) {
EventResult::Ignored
} else {
self.select(index)
};
}
let Some(direction) = self.marker_at(mouse.column, mouse.row) else {
return EventResult::Ignored;
};
let edge = match direction {
Step::Backward => self.hits.tabs.first(),
Step::Forward => self.hits.tabs.last(),
};
let Some((index, _)) = edge else {
return EventResult::Ignored;
};
let target =
linear_nav::step_enabled(self.items.len(), *index, direction, |index| {
self.disabled_at(index)
});
match self.activation {
TabsActivation::Manual => self.move_focus(target),
TabsActivation::Automatic => self.select(target),
}
}
_ => EventResult::Ignored,
},
Event::Key(key) => self.handle_key(*key, state),
_ => EventResult::Ignored,
}
}
fn interaction_area(&self, area: Rect) -> Rect {
if area.width == 0 || area.height < self.height() {
Rect::default()
} else {
Rect {
height: self.height(),
..area
}
}
}
fn is_focusable(&self, _state: &S) -> bool {
!self.disabled
&& self.keyboard_enabled()
&& linear_nav::first_enabled(self.items.len(), |index| self.disabled_at(index))
.is_some()
}
}
impl<T: Clone + PartialEq, S, M> MeasuredComponent<S, M> for Tabs<T, S, M> {
fn measure(&self) -> Size {
Size::new(self.width(), self.height())
}
}
fn tab_width(label: &str) -> u16 {
shape_width(label)
}
#[derive(Default)]
struct TabLayout {
tabs: Vec<(usize, Rect)>,
left: Option<Rect>,
right: Option<Rect>,
}
#[derive(Debug, Clone, Copy)]
struct TabWindow {
lo: usize,
hi: usize,
width: u16,
}
fn window_width(widths: &[u16], lo: usize, hi: usize) -> u16 {
let mut width: u16 = 0;
for (offset, tab_width) in widths[lo..=hi].iter().enumerate() {
if offset > 0 {
width = width.saturating_add(SPACING);
}
width = width.saturating_add(*tab_width);
}
width
}
fn tab_window(widths: &[u16], must_show: usize, avail: u16) -> TabWindow {
let n = widths.len();
let fits = |lo: usize, hi: usize| {
let mut width = window_width(widths, lo, hi);
if lo > 0 {
width = width.saturating_add(SPACING + MARKER_WIDTH);
}
if hi < n - 1 {
width = width.saturating_add(SPACING + MARKER_WIDTH);
}
width <= avail
};
let (mut lo, mut hi) = (must_show, must_show);
let mut prefer_right = true;
loop {
let grow_right = hi + 1 < n && fits(lo, hi + 1);
let grow_left = lo > 0 && fits(lo - 1, hi);
if prefer_right && grow_right {
hi += 1;
} else if !prefer_right && grow_left {
lo -= 1;
} else if grow_right {
hi += 1;
} else if grow_left {
lo -= 1;
} else {
break;
}
prefer_right = !prefer_right;
}
TabWindow {
lo,
hi,
width: window_width(widths, lo, hi),
}
}
fn place_tab_window(
area: Rect,
widths: &[u16],
size: TabsSize,
must_show: usize,
window: TabWindow,
) -> TabLayout {
let height = size.height();
let n = widths.len();
let mut left_marker = window.lo > 0;
let mut right_marker = window.hi < n - 1;
let mut needed = window.width;
if left_marker {
needed = needed.saturating_add(SPACING + MARKER_WIDTH);
}
if right_marker {
needed = needed.saturating_add(SPACING + MARKER_WIDTH);
}
if needed > area.width {
left_marker = false;
right_marker = false;
}
let end = area.x.saturating_add(area.width);
let mut x = area.x;
let left = left_marker.then(|| {
let rect = Rect::new(x, area.y, MARKER_WIDTH, height);
x = x.saturating_add(MARKER_WIDTH + SPACING);
rect
});
let mut tabs = Vec::with_capacity(window.hi - window.lo + 1);
for (index, &tab_width) in (window.lo..=window.hi).zip(&widths[window.lo..=window.hi]) {
if x >= end {
break;
}
let available = end - x;
let width = if index == must_show {
tab_width.min(available)
} else if tab_width <= available {
tab_width
} else {
break;
};
tabs.push((index, Rect::new(x, area.y, width, height)));
x = x.saturating_add(width).saturating_add(SPACING);
}
let right = (right_marker && x.saturating_add(MARKER_WIDTH) <= end)
.then(|| Rect::new(x, area.y, MARKER_WIDTH, height));
TabLayout { tabs, left, right }
}
fn tab_layout(area: Rect, labels: &[&str], size: TabsSize, must_show: usize) -> TabLayout {
let height = size.height();
if labels.is_empty() || area.width == 0 || area.height < height {
return TabLayout::default();
}
let widths: Vec<u16> = labels.iter().map(|label| tab_width(label)).collect();
let must_show = must_show.min(widths.len() - 1);
let window = tab_window(&widths, must_show, area.width);
place_tab_window(area, &widths, size, must_show, window)
}
#[cfg(test)]
fn tab_rects(area: Rect, labels: &[&str], size: TabsSize) -> Vec<Rect> {
tab_layout(area, labels, size, 0)
.tabs
.into_iter()
.map(|(_, rect)| rect)
.collect()
}
#[cfg(test)]
mod tests {
use std::panic::{AssertUnwindSafe, catch_unwind};
use ratatui::{Terminal, backend::TestBackend};
use super::*;
use crate::runtime::{ChildId, Modifiers, MouseEvent, Ratcn};
use crate::text_width::display_width;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
enum Screen {
#[default]
A,
B,
C,
D,
E,
F,
}
#[derive(Debug, PartialEq, Eq)]
enum Msg {
Focused(Screen),
Selected(Screen),
}
#[derive(Default)]
struct State {
focused: Option<Screen>,
selected: Screen,
disable_b: bool,
}
fn automatic() -> Tabs<Screen, State, Msg> {
Tabs::new([
Tab::new(Screen::A, "A"),
Tab::new(Screen::B, "B").disabled(true),
Tab::new(Screen::C, "C"),
])
.selection(|s: &State| Some(s.selected), Msg::Selected)
.activation(TabsActivation::Automatic)
}
fn automatic_with_tab_focus() -> Tabs<Screen, State, Msg> {
automatic().tab_focus(
|state: &State| Some(state.focused.unwrap_or(state.selected)),
Msg::Focused,
)
}
fn manual() -> Tabs<Screen, State, Msg> {
Tabs::new([
Tab::new(Screen::A, "A"),
Tab::new(Screen::B, "B").disabled(true),
Tab::new(Screen::C, "C"),
])
.tab_focus(
|s: &State| Some(s.focused.unwrap_or(s.selected)),
Msg::Focused,
)
.selection(|s: &State| Some(s.selected), Msg::Selected)
}
fn key(code: KeyCode) -> Event {
Event::Key(KeyEvent::new(code))
}
fn ctrl_key(ch: char) -> Event {
Event::Key(KeyEvent {
code: KeyCode::Char(ch),
modifiers: Modifiers {
ctrl: true,
..Modifiers::NONE
},
})
}
fn mouse(kind: MouseKind, column: u16, row: u16) -> Event {
Event::Mouse(MouseEvent {
kind,
column,
row,
modifiers: Modifiers::NONE,
})
}
fn render_runtime(
ratcn: &mut Ratcn<State, Msg>,
terminal: &mut Terminal<TestBackend>,
state: &State,
fail: bool,
) {
let theme = Theme::default_dark();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("tabs"),
Tabs::new([
Tab::new(Screen::A, "A"),
Tab::new(Screen::B, "B").disabled(state.disable_b),
Tab::new(Screen::C, "C"),
])
.tab_focus(
|state: &State| Some(state.focused.unwrap_or(state.selected)),
Msg::Focused,
)
.selection(|state: &State| Some(state.selected), Msg::Selected),
area,
);
assert!(!fail, "failed pass");
});
})
.expect("draw");
}
#[test]
fn automatic_right_selects_the_next_enabled_tab_directly() {
let mut tabs = automatic();
let state = State::default(); assert_eq!(
tabs.handle_event(&key(KeyCode::Right), &state, &mut EventCtx::default(),),
EventResult::Emit(Msg::Selected(Screen::C))
);
}
#[test]
fn automatic_keyboard_ignores_a_stale_manual_tab_focus() {
let mut tabs = automatic_with_tab_focus();
let state = State {
focused: Some(Screen::C),
selected: Screen::A,
..State::default()
};
assert_eq!(
tabs.handle_event(&key(KeyCode::Enter), &state, &mut EventCtx::default()),
EventResult::Emit(Msg::Selected(Screen::A))
);
assert_eq!(
tabs.handle_event(&key(KeyCode::Char(' ')), &state, &mut EventCtx::default()),
EventResult::Emit(Msg::Selected(Screen::A))
);
assert_eq!(
tabs.handle_event(&key(KeyCode::Right), &state, &mut EventCtx::default()),
EventResult::Emit(Msg::Selected(Screen::C))
);
}
#[test]
fn manual_without_focus_binding_is_not_focusable_and_ignores_keys() {
let mut tabs = Tabs::new([
Tab::new(Screen::A, "A"),
Tab::new(Screen::B, "B").disabled(true),
Tab::new(Screen::C, "C"),
])
.selection(|s: &State| Some(s.selected), Msg::Selected);
let state = State::default();
assert!(!tabs.is_focusable(&state));
assert_eq!(
tabs.handle_event(&key(KeyCode::Right), &state, &mut EventCtx::default(),),
EventResult::Ignored
);
}
#[test]
fn manual_without_selection_binding_is_not_focusable_and_ignores_keys() {
let mut tabs = Tabs::new([Tab::new(Screen::A, "A"), Tab::new(Screen::B, "B")])
.tab_focus(|_: &State| Some(Screen::A), Msg::Focused);
let state = State::default();
assert!(!tabs.is_focusable(&state));
assert_eq!(
tabs.handle_event(&key(KeyCode::Right), &state, &mut EventCtx::default()),
EventResult::Ignored
);
}
#[test]
fn automatic_without_selection_is_not_focusable_and_ignores_keys() {
let mut tabs: Tabs<Screen, State, Msg> =
Tabs::new([Tab::new(Screen::A, "A"), Tab::new(Screen::B, "B")])
.activation(TabsActivation::Automatic);
let state = State::default();
assert!(!tabs.is_focusable(&state));
assert_eq!(
tabs.handle_event(&key(KeyCode::Right), &state, &mut EventCtx::default()),
EventResult::Ignored
);
}
#[test]
fn manual_right_moves_focus_not_the_selection() {
let mut tabs = manual();
let state = State::default(); assert_eq!(
tabs.handle_event(&key(KeyCode::Right), &state, &mut EventCtx::default(),),
EventResult::Emit(Msg::Focused(Screen::C))
);
}
#[test]
fn stale_manual_focus_recovers_to_first_enabled_tab_in_either_direction() {
let state = State {
focused: Some(Screen::B),
..State::default()
};
for code in [KeyCode::Left, KeyCode::Right] {
let mut tabs = Tabs::new([
Tab::new(Screen::C, "C").disabled(true),
Tab::new(Screen::A, "A"),
Tab::new(Screen::D, "D"),
])
.tab_focus(
|state: &State| Some(state.focused.unwrap_or(state.selected)),
Msg::Focused,
)
.selection(|state: &State| Some(state.selected), Msg::Selected);
assert_eq!(
tabs.handle_event(&key(code), &state, &mut EventCtx::default()),
EventResult::Emit(Msg::Focused(Screen::A))
);
}
}
#[test]
fn stale_automatic_selection_recovers_to_first_enabled_tab_in_either_direction() {
let state = State {
selected: Screen::B,
..State::default()
};
for code in [KeyCode::Left, KeyCode::Right] {
let mut tabs = Tabs::new([
Tab::new(Screen::C, "C").disabled(true),
Tab::new(Screen::A, "A"),
Tab::new(Screen::D, "D"),
])
.selection(|state: &State| Some(state.selected), Msg::Selected)
.activation(TabsActivation::Automatic);
assert_eq!(
tabs.handle_event(&key(code), &state, &mut EventCtx::default()),
EventResult::Emit(Msg::Selected(Screen::A))
);
}
}
#[test]
fn enter_commits_the_focused_tab() {
let mut tabs = manual();
let state = State {
focused: Some(Screen::C),
selected: Screen::A,
..State::default()
};
assert_eq!(
tabs.handle_event(&key(KeyCode::Enter), &state, &mut EventCtx::default(),),
EventResult::Emit(Msg::Selected(Screen::C))
);
}
#[test]
fn enter_on_a_disabled_focused_tab_is_ignored() {
let mut tabs = manual();
let state = State {
focused: Some(Screen::B),
selected: Screen::A,
..State::default()
};
assert_eq!(
tabs.handle_event(&key(KeyCode::Enter), &state, &mut EventCtx::default(),),
EventResult::Ignored
);
}
#[test]
fn hover_is_consumed_without_moving_focus() {
let mut tabs = manual();
tabs.hits = tab_layout(
Rect::new(0, 0, 30, TabsSize::Small.height()),
&["A", "B", "C"],
TabsSize::Small,
0,
);
let state = State::default();
assert_eq!(
tabs.handle_event(
&mouse(MouseKind::Moved, 14, 0),
&state,
&mut EventCtx::default(),
),
EventResult::Consumed
);
}
#[test]
fn automatic_hover_ignores_a_manual_tab_focus_binding() {
let mut tabs = automatic_with_tab_focus();
tabs.hits = tab_layout(
Rect::new(0, 0, 30, TabsSize::Small.height()),
&["A", "B", "C"],
TabsSize::Small,
0,
);
let state = State::default();
assert_eq!(
tabs.handle_event(
&mouse(MouseKind::Moved, 14, 0),
&state,
&mut EventCtx::default(),
),
EventResult::Consumed
);
}
#[test]
fn vim_and_readline_keys_step_along_the_strip() {
let state = State::default(); for forward in [key(KeyCode::Char('l')), ctrl_key('n')] {
assert_eq!(
manual().handle_event(&forward, &state, &mut EventCtx::default()),
EventResult::Emit(Msg::Focused(Screen::C)),
"l and Ctrl+N step like Right, skipping the disabled B"
);
assert_eq!(
automatic().handle_event(&forward, &state, &mut EventCtx::default()),
EventResult::Emit(Msg::Selected(Screen::C)),
"automatic activation commits, as its arrow keys do"
);
}
assert_eq!(
manual().handle_event(&key(KeyCode::Char('h')), &state, &mut EventCtx::default()),
EventResult::Consumed,
"h steps like Left, and the cursor is already at the first tab"
);
assert_eq!(
manual().handle_event(&key(KeyCode::Char('c')), &state, &mut EventCtx::default()),
EventResult::Ignored,
"any other letter bubbles: there is no typeahead"
);
}
#[test]
fn typeahead_skips_disabled_tabs_and_bubbles_when_nothing_matches() {
let state = State::default();
for ch in ['b', 'z'] {
assert_eq!(
manual().handle_event(&key(KeyCode::Char(ch)), &state, &mut EventCtx::default()),
EventResult::Ignored,
"'{ch}' matches no enabled tab, so it bubbles as an app hotkey"
);
}
}
#[test]
fn hover_over_an_unbound_tabs_row_is_ignored() {
let mut tabs: Tabs<Screen, State, Msg> = Tabs::new([
Tab::new(Screen::A, "A"),
Tab::new(Screen::B, "B"),
Tab::new(Screen::C, "C"),
]);
tabs.hits = tab_layout(
Rect::new(0, 0, 30, TabsSize::Small.height()),
&["A", "B", "C"],
TabsSize::Small,
0,
);
let state = State::default();
assert_eq!(
tabs.handle_event(
&mouse(MouseKind::Moved, 14, 0),
&state,
&mut EventCtx::default(),
),
EventResult::Ignored
);
}
#[test]
fn click_commits_the_clicked_tab() {
let mut tabs = automatic();
tabs.hits = tab_layout(
Rect::new(0, 0, 30, TabsSize::Small.height()),
&["A", "B", "C"],
TabsSize::Small,
0,
);
let state = State::default();
assert_eq!(
tabs.handle_event(
&mouse(MouseKind::Click(MouseButton::Left), 14, 0),
&state,
&mut EventCtx::default(),
),
EventResult::Emit(Msg::Selected(Screen::C))
);
}
#[test]
fn right_and_middle_click_do_not_select_a_tab() {
let mut tabs = automatic();
tabs.hits = TabLayout {
tabs: vec![(2, Rect::new(12, 0, 5, 1))],
..TabLayout::default()
};
for button in [MouseButton::Right, MouseButton::Middle] {
assert_eq!(
tabs.handle_event(
&mouse(MouseKind::Click(button), 14, 0),
&State::default(),
&mut EventCtx::default(),
),
EventResult::Ignored
);
}
}
#[test]
fn click_after_window_shift_selects_the_original_tab_index() {
let labels = ["A", "B", "C", "D", "E", "F"];
let state = State {
selected: Screen::E,
..State::default()
};
let theme = Theme::default_dark();
let mut ratcn = Ratcn::new();
let area = Rect::new(0, 0, 18, TabsSize::Small.height());
let mut terminal =
Terminal::new(TestBackend::new(area.width, area.height)).expect("terminal");
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("tabs"),
Tabs::new([
Tab::new(Screen::A, "A"),
Tab::new(Screen::B, "B"),
Tab::new(Screen::C, "C"),
Tab::new(Screen::D, "D"),
Tab::new(Screen::E, "E"),
Tab::new(Screen::F, "F"),
])
.selection(|s: &State| Some(s.selected), Msg::Selected),
area,
);
});
})
.expect("draw");
let hits = tab_layout(area, &labels, TabsSize::Small, 4);
let (_, tab_f) = hits
.tabs
.iter()
.find(|(index, _)| *index == 5)
.expect("tab F should be visible after the window shifts");
assert_eq!(
ratcn.handle_event(
mouse(
MouseKind::Click(MouseButton::Left),
tab_f.x + tab_f.width / 2,
tab_f.y,
),
&state,
),
EventResult::Emit(Msg::Selected(Screen::F))
);
}
#[test]
fn clicking_the_right_marker_steps_toward_the_hidden_tabs() {
let mut tabs = Tabs::new([
Tab::new(Screen::A, "A"),
Tab::new(Screen::B, "B"),
Tab::new(Screen::C, "C"),
Tab::new(Screen::D, "D"),
Tab::new(Screen::E, "E"),
Tab::new(Screen::F, "F"),
])
.tab_focus(
|state: &State| Some(state.focused.unwrap_or(state.selected)),
Msg::Focused,
)
.selection(|state: &State| Some(state.selected), Msg::Selected);
tabs.hits = tab_layout(
Rect::new(0, 0, 18, TabsSize::Small.height()),
&["A", "B", "C", "D", "E", "F"],
TabsSize::Small,
0,
);
let marker = tabs.hits.right.expect("hidden right tabs get a marker");
assert_eq!(
tabs.handle_event(
&mouse(MouseKind::Click(MouseButton::Left), marker.x, marker.y),
&State::default(),
&mut EventCtx::default(),
),
EventResult::Emit(Msg::Focused(Screen::C)),
"the marker steps focus one tab past the last visible one"
);
}
#[test]
fn marker_clicks_route_through_the_retained_runtime_surface() {
let mut ratcn = Ratcn::new();
let mut terminal =
Terminal::new(TestBackend::new(18, TabsSize::Small.height())).expect("terminal");
let theme = Theme::default_dark();
let state = State::default(); terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("tabs"),
Tabs::new([
Tab::new(Screen::A, "A"),
Tab::new(Screen::B, "B"),
Tab::new(Screen::C, "C"),
Tab::new(Screen::D, "D"),
Tab::new(Screen::E, "E"),
Tab::new(Screen::F, "F"),
])
.selection(|state: &State| Some(state.selected), Msg::Selected)
.activation(TabsActivation::Automatic),
area,
);
});
})
.expect("draw");
let marker = tab_layout(
Rect::new(0, 0, 18, TabsSize::Small.height()),
&["A", "B", "C", "D", "E", "F"],
TabsSize::Small,
0,
)
.right
.expect("hidden right tabs get a marker");
assert_eq!(
ratcn.handle_event(
mouse(MouseKind::Click(MouseButton::Left), marker.x, marker.y),
&state,
),
EventResult::Emit(Msg::Selected(Screen::C))
);
}
#[test]
fn click_on_a_disabled_tab_is_ignored() {
let mut tabs = automatic();
tabs.hits = tab_layout(
Rect::new(0, 0, 30, TabsSize::Small.height()),
&["A", "B", "C"],
TabsSize::Small,
0,
);
let state = State::default();
assert_eq!(
tabs.handle_event(
&mouse(MouseKind::Click(MouseButton::Left), 8, 0),
&state,
&mut EventCtx::default(),
),
EventResult::Ignored
);
}
#[test]
fn disabled_tabs_consume_pointer_down_without_focusing_the_row() {
#[derive(Default)]
struct RoutedState {
focus: crate::runtime::FocusState,
selected: Screen,
}
#[derive(Debug, PartialEq)]
enum RoutedMsg {
Focus(crate::runtime::FocusState),
Selected(Screen),
}
let theme = Theme::default_dark();
let mut state = RoutedState {
focus: crate::runtime::FocusState::intent([ChildId::Static("other")]),
..RoutedState::default()
};
let mut ratcn = Ratcn::new().focus(|state: &RoutedState| &state.focus, RoutedMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal");
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("other"),
crate::Button::<RoutedMsg>::new("Other")
.on_press(|| RoutedMsg::Selected(Screen::A)),
Rect::new(0, 0, 20, 1),
);
ctx.render_component(
ChildId::Static("tabs"),
Tabs::new([
Tab::new(Screen::A, "A"),
Tab::new(Screen::B, "B").disabled(true),
])
.selection(
|state: &RoutedState| Some(state.selected),
RoutedMsg::Selected,
)
.activation(TabsActivation::Automatic),
Rect::new(0, 1, 20, 1),
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 8, 1), &state),
EventResult::Consumed
);
assert_eq!(
state.focus,
crate::runtime::FocusState::intent([ChildId::Static("other")])
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 8, 1), &state),
EventResult::Ignored
);
let EventResult::Emit(RoutedMsg::Focus(focus)) =
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 2, 1), &state)
else {
panic!("an enabled tab should receive runtime focus");
};
state.focus = focus;
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 2, 1), &state),
EventResult::Emit(RoutedMsg::Selected(Screen::A))
);
}
#[test]
fn excess_allocated_rows_do_not_focus_or_activate_tabs() {
#[derive(Default)]
struct RoutedState {
focus: crate::runtime::FocusState,
selected: Screen,
}
#[derive(Debug, PartialEq)]
enum RoutedMsg {
Focus(crate::runtime::FocusState),
Selected(Screen),
}
let theme = Theme::default_dark();
let mut state = RoutedState {
focus: crate::runtime::FocusState::intent([ChildId::Static("other")]),
..RoutedState::default()
};
let mut ratcn = Ratcn::new().focus(|state: &RoutedState| &state.focus, RoutedMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(20, 4)).expect("terminal");
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("tabs"),
Tabs::new([Tab::new(Screen::A, "A"), Tab::new(Screen::B, "B")])
.selection(
|state: &RoutedState| Some(state.selected),
RoutedMsg::Selected,
)
.activation(TabsActivation::Automatic),
Rect::new(0, 0, 20, 4),
);
ctx.render_component(
ChildId::Static("other"),
crate::Button::<RoutedMsg>::new("Other")
.on_press(|| RoutedMsg::Selected(Screen::A)),
Rect::new(0, 3, 8, 1),
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 2, 2), &state),
EventResult::Ignored
);
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 2, 2), &state),
EventResult::Ignored
);
assert_eq!(
state.focus,
crate::runtime::FocusState::intent([ChildId::Static("other")])
);
let EventResult::Emit(RoutedMsg::Focus(focus)) =
ratcn.handle_event(mouse(MouseKind::Down(MouseButton::Left), 2, 0), &state)
else {
panic!("the painted tabs row should focus");
};
state.focus = focus;
assert_eq!(
ratcn.handle_event(mouse(MouseKind::Up(MouseButton::Left), 2, 0), &state),
EventResult::Emit(RoutedMsg::Selected(Screen::A))
);
}
#[test]
fn automatic_home_and_end_select_the_first_and_last_enabled_tab() {
let mut tabs = automatic();
let from_c = State {
selected: Screen::C,
..State::default()
};
assert_eq!(
tabs.handle_event(&key(KeyCode::Home), &from_c, &mut EventCtx::default(),),
EventResult::Emit(Msg::Selected(Screen::A))
);
assert_eq!(
tabs.handle_event(
&key(KeyCode::End),
&State::default(),
&mut EventCtx::default(),
),
EventResult::Emit(Msg::Selected(Screen::C))
);
}
#[test]
fn selection_is_stable_when_tabs_are_reordered() {
let mut tabs = Tabs::new([
Tab::new(Screen::C, "C"),
Tab::new(Screen::A, "A"),
Tab::new(Screen::B, "B"),
])
.selection(|s: &State| Some(s.selected), Msg::Selected);
let state = State::default(); assert_eq!(tabs.selected_index(&state), Some(1));
tabs.hits = tab_layout(
Rect::new(0, 0, 30, TabsSize::Small.height()),
&["C", "A", "B"],
TabsSize::Small,
0,
);
assert_eq!(
tabs.handle_event(
&mouse(MouseKind::Click(MouseButton::Left), 1, 0),
&state,
&mut EventCtx::default(),
),
EventResult::Emit(Msg::Selected(Screen::C))
);
}
#[test]
fn duplicate_tab_values_fail_declaration() {
let mut tabs: Tabs<Screen, State, Msg> =
Tabs::new([Tab::new(Screen::A, "First"), Tab::new(Screen::A, "Second")]);
let failed = catch_unwind(AssertUnwindSafe(|| {
Component::prepare(&mut tabs, &State::default());
}));
assert!(failed.is_err());
}
#[test]
fn a_row_of_all_disabled_tabs_is_not_focusable() {
let mut tabs: Tabs<Screen, State, Msg> = Tabs::new([
Tab::new(Screen::A, "A").disabled(true),
Tab::new(Screen::B, "B").disabled(true),
]);
let state = State::default();
assert!(!tabs.is_focusable(&state));
assert_eq!(
tabs.handle_event(&key(KeyCode::Right), &state, &mut EventCtx::default(),),
EventResult::Ignored
);
}
#[test]
fn interaction_area_requires_full_height_and_crops_excess_rows() {
let tabs: Tabs<Screen, State, Msg> =
Tabs::new([Tab::new(Screen::A, "A")]).size(TabsSize::Large);
assert_eq!(
tabs.interaction_area(Rect::new(0, 0, 1, 2)),
Rect::default()
);
assert_eq!(
tabs.interaction_area(Rect::new(2, 3, 1, 5)),
Rect::new(2, 3, 1, 3)
);
assert_eq!(
tabs.interaction_area(Rect::new(0, 0, 0, 3)),
Rect::default()
);
}
#[test]
fn disabledness_controls_navigation_clicks_and_focusability() {
let mut tabs: Tabs<Screen, State, Msg> = Tabs::new([
Tab::new(Screen::A, "A").disabled(true),
Tab::new(Screen::B, "B").disabled(true),
Tab::new(Screen::C, "C"),
])
.tab_focus(
|state: &State| Some(state.focused.unwrap_or(state.selected)),
Msg::Focused,
)
.selection(|state: &State| Some(state.selected), Msg::Selected);
tabs.hits = tab_layout(Rect::new(0, 0, 30, 1), &["A", "B", "C"], TabsSize::Small, 0);
assert!(tabs.is_focusable(&State::default()));
assert_eq!(
tabs.handle_event(
&key(KeyCode::Right),
&State::default(),
&mut EventCtx::default(),
),
EventResult::Emit(Msg::Focused(Screen::C))
);
assert_eq!(
tabs.handle_event(
&mouse(MouseKind::Click(MouseButton::Left), 2, 0),
&State::default(),
&mut EventCtx::default(),
),
EventResult::Ignored
);
assert_eq!(
tabs.handle_event(
&mouse(MouseKind::Click(MouseButton::Left), 8, 0),
&State::default(),
&mut EventCtx::default(),
),
EventResult::Ignored
);
assert_eq!(
tabs.handle_event(
&mouse(MouseKind::Click(MouseButton::Left), 14, 0),
&State::default(),
&mut EventCtx::default(),
),
EventResult::Emit(Msg::Selected(Screen::C))
);
let all_disabled: Tabs<Screen, State, Msg> = Tabs::new([
Tab::new(Screen::A, "A").disabled(true),
Tab::new(Screen::B, "B").disabled(true),
Tab::new(Screen::C, "C").disabled(true),
]);
assert!(!all_disabled.is_focusable(&State::default()));
}
#[test]
fn disabled_bits_remain_from_the_last_successful_runtime_render() {
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(20, 1)).expect("terminal");
let mut state = State::default();
render_runtime(&mut ratcn, &mut terminal, &state, false);
state.disable_b = true;
assert_eq!(
ratcn.handle_event(key(KeyCode::Right), &state),
EventResult::Emit(Msg::Focused(Screen::B))
);
render_runtime(&mut ratcn, &mut terminal, &state, false);
state.disable_b = false;
assert_eq!(
ratcn.handle_event(key(KeyCode::Right), &state),
EventResult::Emit(Msg::Focused(Screen::C))
);
}
#[test]
fn failed_runtime_pass_preserves_previous_disabled_bits() {
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(20, 1)).expect("terminal");
let mut state = State::default();
render_runtime(&mut ratcn, &mut terminal, &state, false);
state.disable_b = true;
let failed = catch_unwind(AssertUnwindSafe(|| {
render_runtime(&mut ratcn, &mut terminal, &state, true);
}));
assert!(failed.is_err());
assert_eq!(
ratcn.handle_event(key(KeyCode::Right), &state),
EventResult::Emit(Msg::Focused(Screen::B))
);
}
#[test]
fn focused_and_selected_bindings_are_current_between_renders() {
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(20, 1)).expect("terminal");
let mut state = State::default();
render_runtime(&mut ratcn, &mut terminal, &state, false);
state.focused = Some(Screen::B);
state.selected = Screen::C;
assert_eq!(
ratcn.handle_event(key(KeyCode::Right), &state),
EventResult::Emit(Msg::Focused(Screen::C))
);
assert_eq!(
ratcn.handle_event(key(KeyCode::Enter), &state),
EventResult::Emit(Msg::Selected(Screen::B))
);
}
#[test]
fn consecutive_automatic_navigation_uses_current_selection_without_redraw() {
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(20, 1)).expect("terminal");
let theme = Theme::default_dark();
let mut state = State::default();
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("tabs"),
Tabs::new([
Tab::new(Screen::A, "A"),
Tab::new(Screen::B, "B"),
Tab::new(Screen::C, "C"),
])
.selection(|state: &State| Some(state.selected), Msg::Selected)
.activation(TabsActivation::Automatic),
area,
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(key(KeyCode::Right), &state),
EventResult::Emit(Msg::Selected(Screen::B))
);
state.selected = Screen::B;
assert_eq!(
ratcn.handle_event(key(KeyCode::Right), &state),
EventResult::Emit(Msg::Selected(Screen::C))
);
}
#[test]
fn hit_window_survives_semantic_changes_and_a_failed_pass() {
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(18, 1)).expect("terminal");
let theme = Theme::default_dark();
let mut state = State {
selected: Screen::E,
..State::default()
};
let render = |ratcn: &mut Ratcn<State, Msg>,
terminal: &mut Terminal<TestBackend>,
state: &State,
fail: bool| {
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("tabs"),
Tabs::new([
Tab::new(Screen::A, "A"),
Tab::new(Screen::B, "B"),
Tab::new(Screen::C, "C"),
Tab::new(Screen::D, "D"),
Tab::new(Screen::E, "E"),
Tab::new(Screen::F, "F"),
])
.selection(|state: &State| Some(state.selected), Msg::Selected),
area,
);
assert!(!fail, "failed pass");
});
})
.expect("draw");
};
render(&mut ratcn, &mut terminal, &state, false);
let (_, tab_f) = tab_layout(
Rect::new(0, 0, 18, 1),
&["A", "B", "C", "D", "E", "F"],
TabsSize::Small,
4,
)
.tabs
.into_iter()
.find(|(index, _)| *index == 5)
.expect("tab F is visible around selected E");
state.selected = Screen::A;
state.focused = Some(Screen::A);
let failed = catch_unwind(AssertUnwindSafe(|| {
render(&mut ratcn, &mut terminal, &state, true);
}));
assert!(failed.is_err());
assert_eq!(
ratcn.handle_event(
mouse(
MouseKind::Click(MouseButton::Left),
tab_f.x + tab_f.width / 2,
tab_f.y,
),
&state,
),
EventResult::Emit(Msg::Selected(Screen::F))
);
}
#[test]
fn same_target_click_enter_and_space_all_select() {
let state = State::default();
let mut tabs = Tabs::new([Tab::new(Screen::A, "A")])
.selection(|state: &State| Some(state.selected), Msg::Selected)
.activation(TabsActivation::Automatic);
tabs.hits = TabLayout {
tabs: vec![(0, Rect::new(0, 0, 5, 1))],
..TabLayout::default()
};
assert_eq!(
tabs.handle_event(
&mouse(MouseKind::Click(MouseButton::Left), 2, 0),
&state,
&mut EventCtx::default(),
),
EventResult::Emit(Msg::Selected(Screen::A))
);
assert_eq!(
tabs.handle_event(&key(KeyCode::Enter), &state, &mut EventCtx::default(),),
EventResult::Emit(Msg::Selected(Screen::A))
);
assert_eq!(
tabs.handle_event(&key(KeyCode::Char(' ')), &state, &mut EventCtx::default(),),
EventResult::Emit(Msg::Selected(Screen::A))
);
}
#[test]
fn automatic_render_uses_selection_instead_of_stale_manual_tab_focus() {
let theme = Theme::default_dark();
let style = TabsStyle::from_theme(&theme);
let state = State {
focused: Some(Screen::C),
selected: Screen::A,
..State::default()
};
let mut ratcn = Ratcn::new();
let mut terminal = Terminal::new(TestBackend::new(18, 1)).expect("terminal");
terminal
.draw(|frame| {
let area = frame.area();
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(ChildId::Static("tabs"), automatic_with_tab_focus(), area);
});
})
.expect("draw");
let buffer = terminal.backend().buffer();
assert_eq!(
buffer.cell((2, 0)).expect("selected A label cell").bg,
style.selected_focused_background
);
assert_eq!(
buffer.cell((14, 0)).expect("unselected C label cell").bg,
style.background
);
}
#[test]
fn selected_tab_fills_like_the_default_button_others_like_secondary() {
let theme = Theme::default_dark();
let style = TabsStyle::from_theme(&theme);
let area = Rect::new(0, 0, 18, TabsSize::Small.height());
let mut buffer = Buffer::empty(area);
Widget::render(
TabsWidget::new(&["A", "B"])
.selected_tab(Some(0))
.style(style),
area,
&mut buffer,
);
let selected = buffer.cell((2, 0)).expect("selected label cell");
assert_eq!(selected.bg, style.selected_background);
assert_eq!(selected.bg, theme.primary);
let unselected = buffer.cell((8, 0)).expect("unselected label cell");
assert_eq!(unselected.bg, style.background);
assert_eq!(unselected.bg, theme.secondary);
}
#[test]
fn disabled_selected_tab_keeps_distinct_active_colors() {
let theme = Theme::default_dark();
let style = TabsStyle::from_theme(&theme);
let area = Rect::new(0, 0, 18, TabsSize::Small.height());
let mut buffer = Buffer::empty(area);
Widget::render(
TabsWidget::new(&["A", "B"])
.selected_tab(Some(0))
.disabled_tabs(&[true, true])
.style(style),
area,
&mut buffer,
);
let selected = buffer.cell((2, 0)).expect("selected disabled tab");
let unselected = buffer.cell((8, 0)).expect("unselected disabled tab");
assert_eq!(selected.bg, style.selected_disabled_background);
assert_eq!(unselected.bg, style.disabled_background);
assert_ne!(selected.bg, unselected.bg);
}
#[test]
fn focused_tab_takes_the_button_focus_shift() {
let theme = Theme::default_dark();
let style = TabsStyle::from_theme(&theme);
let area = Rect::new(0, 0, 18, TabsSize::Small.height());
let mut buffer = Buffer::empty(area);
Widget::render(
TabsWidget::new(&["A", "B"])
.selected_tab(Some(0))
.focused_tab(Some(1))
.focused(true)
.style(style),
area,
&mut buffer,
);
let focused = buffer.cell((8, 0)).expect("focused label cell");
assert_eq!(focused.bg, style.focused_background);
}
#[test]
fn hovered_row_shows_the_hover_shift_not_the_focus_shift() {
use crate::runtime::ScopeOptions;
let theme = Theme::default_dark();
let style = TabsStyle::from_theme(&theme);
let state = State {
focused: Some(Screen::C),
selected: Screen::A,
..State::default()
};
let tabs_area = Rect::new(0, 0, 18, TabsSize::Small.height());
let decoy_area = Rect::new(0, tabs_area.height, 18, 1);
let mut ratcn = Ratcn::new();
let mut terminal =
Terminal::new(TestBackend::new(18, tabs_area.height + 1)).expect("terminal");
let draw = |ratcn: &mut Ratcn<State, Msg>, terminal: &mut Terminal<TestBackend>| {
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.scope(
ChildId::Static("decoy"),
decoy_area,
ScopeOptions::default().focusable(),
|_| {},
);
ctx.render_component(ChildId::Static("tabs"), manual(), tabs_area);
});
})
.expect("draw");
};
draw(&mut ratcn, &mut terminal);
ratcn.handle_event(mouse(MouseKind::Moved, 14, 0), &state);
draw(&mut ratcn, &mut terminal);
let cursor = terminal
.backend()
.buffer()
.cell((14, 0))
.expect("cursor label cell");
assert_eq!(cursor.bg, style.hovered_background);
assert_ne!(
style.hovered_background, style.focused_background,
"hover must be distinguishable from focus"
);
}
#[test]
fn a_row_with_no_fill_still_distinguishes_every_state() {
let surface = Color::Rgb(10, 10, 10);
let style = TabsStyle {
foreground: Color::Rgb(120, 120, 120),
focused_foreground: Color::Rgb(200, 200, 200),
hovered_foreground: Color::Rgb(255, 255, 255),
selected_foreground: Color::Rgb(139, 92, 246),
background: surface,
focused_background: surface,
hovered_background: surface,
selected_background: surface,
selected_focused_background: surface,
selected_hovered_background: surface,
..TabsStyle::fallback()
};
let resting = style.resolve(false, false, false, false);
let focused = style.resolve(false, true, false, false);
let hovered = style.resolve(false, false, true, false);
let selected = style.resolve(true, false, false, false);
for resolved in [resting, focused, hovered, selected] {
assert_eq!(resolved.fill, surface, "no state may paint its own fill");
}
assert_ne!(resting.foreground, focused.foreground);
assert_ne!(focused.foreground, hovered.foreground);
assert_ne!(resting.foreground, selected.foreground);
}
#[test]
fn hover_takes_precedence_over_focus() {
let style = TabsStyle::from_theme(&Theme::default_dark());
let both = style.resolve(false, true, true, false);
assert_eq!(both.fill, style.hovered_background);
}
#[test]
fn one_row_area_renders_tabs() {
let area = Rect::new(0, 0, 20, TabsSize::Small.height());
let mut buffer = Buffer::empty(area);
Widget::render(
TabsWidget::new(&["A", "B"]).selected_tab(Some(0)),
area,
&mut buffer,
);
assert_eq!(
buffer.cell((2, 0)).expect("selected label cell").symbol(),
"A"
);
}
#[test]
fn large_tabs_render_top_and_bottom_caps() {
let area = Rect::new(0, 0, 20, TabsSize::Large.height());
let mut buffer = Buffer::empty(area);
Widget::render(
TabsWidget::new(&["A"])
.selected_tab(Some(0))
.size(TabsSize::Large),
area,
&mut buffer,
);
assert_eq!(TabsWidget::new(&["A"]).size(TabsSize::Large).height(), 3);
assert_eq!(buffer.cell((0, 0)).expect("top cap").symbol(), "â–„");
assert_eq!(buffer.cell((2, 1)).expect("label").symbol(), "A");
assert_eq!(buffer.cell((0, 2)).expect("bottom cap").symbol(), "â–€");
}
#[test]
fn tab_hit_rects_match_the_painted_row() {
let rects = tab_rects(Rect::new(0, 0, 20, 3), &["A", "B"], TabsSize::Small);
assert_eq!(rects[0], Rect::new(0, 0, 5, TabsSize::Small.height()));
assert!(rects[0].contains(Position { x: 2, y: 0 }));
assert!(!rects[0].contains(Position { x: 2, y: 1 }));
}
#[test]
fn tab_rects_omit_tabs_that_do_not_fit_fully() {
let rects = tab_rects(Rect::new(0, 0, 9, 1), &["A", "B"], TabsSize::Small);
assert_eq!(rects, vec![Rect::new(0, 0, 5, 1)]);
}
#[test]
fn hidden_tabs_on_the_right_render_a_right_marker() {
let area = Rect::new(0, 0, 9, TabsSize::Small.height());
let mut buffer = Buffer::empty(area);
Widget::render(
TabsWidget::new(&["A", "B"]).selected_tab(Some(0)),
area,
&mut buffer,
);
assert_eq!(buffer.cell((2, 0)).expect("tab A").symbol(), "A");
assert_eq!(buffer.cell((6, 0)).expect("right marker").symbol(), "›");
}
#[test]
fn window_keeps_the_active_tab_visible_and_marks_the_hidden_side() {
let labels = ["A", "B", "C", "D", "E", "F"];
let area = Rect::new(0, 0, 18, TabsSize::Small.height());
let mut buffer = Buffer::empty(area);
Widget::render(
TabsWidget::new(&labels).selected_tab(Some(4)),
area,
&mut buffer,
);
let row: String = (0..18)
.map(|x| buffer.cell((x, 0)).expect("cell").symbol().to_string())
.collect();
assert!(
row.contains('E'),
"selected tab E must be on screen: {row:?}"
);
assert!(row.contains('‹'), "hidden left tabs get a marker: {row:?}");
assert!(
!row.contains('A'),
"an off-screen tab is not painted: {row:?}"
);
}
#[test]
fn middle_window_marks_hidden_tabs_on_both_sides() {
let labels = ["A", "B", "C", "D", "E", "F"];
let area = Rect::new(0, 0, 18, TabsSize::Small.height());
let mut buffer = Buffer::empty(area);
Widget::render(
TabsWidget::new(&labels).selected_tab(Some(3)),
area,
&mut buffer,
);
let row: String = (0..18)
.map(|x| buffer.cell((x, 0)).expect("cell").symbol().to_string())
.collect();
assert!(row.contains('‹'), "left marker missing: {row:?}");
assert!(row.contains('›'), "right marker missing: {row:?}");
assert!(row.contains('D'), "active tab missing: {row:?}");
}
#[test]
fn oversized_active_tab_clips_but_still_renders() {
let area = Rect::new(0, 0, 4, TabsSize::Small.height());
let mut buffer = Buffer::empty(area);
Widget::render(
TabsWidget::new(&["VeryWide"]).selected_tab(Some(0)),
area,
&mut buffer,
);
let row: String = (0..4)
.map(|x| buffer.cell((x, 0)).expect("cell").symbol().to_string())
.collect();
assert_eq!(row, "Very");
}
#[test]
fn a_row_too_narrow_for_markers_still_shows_the_active_tab() {
let labels = ["AA", "BB", "CC"];
let area = Rect::new(0, 0, 6, TabsSize::Small.height());
let mut buffer = Buffer::empty(area);
Widget::render(
TabsWidget::new(&labels).selected_tab(Some(1)),
area,
&mut buffer,
);
let row: String = (0..6)
.map(|x| buffer.cell((x, 0)).expect("cell").symbol().to_string())
.collect();
assert!(
row.contains("BB"),
"the active tab is shown, not a bare marker: {row:?}"
);
}
#[test]
fn tab_width_matches_a_button_and_counts_cells_not_chars_or_bytes() {
assert_eq!(tab_width("A"), 5);
assert_eq!(tab_width("åβ"), 6);
assert_eq!(tab_width("日本"), 8, "CJK chars are 2 cells");
assert_eq!(tab_width("🚀"), 6, "emoji are 2 cells");
assert_eq!(tab_width("e\u{301}"), 5, "combining mark adds no cell");
assert_eq!(
tab_width("👩\u{200d}👩\u{200d}👦"),
6,
"a ZWJ sequence is one 2-cell glyph, not its char count"
);
}
#[test]
fn filled_middle_truncates_by_cells_and_pads_the_fill() {
assert_eq!(filled_middle("日本", 8), " 日本 ");
assert_eq!(filled_middle("日本語", 5), "日本 ");
assert_eq!(filled_middle("日本語", 4), "日本");
assert_eq!(filled_middle("日本語", 3), "日 ");
assert_eq!(filled_middle("日本語", 0), "");
for width in 0..=8 {
assert_eq!(
display_width(&filled_middle("日本語", width)),
width.min(8),
"middle row must span exactly the tab width"
);
}
}
#[test]
fn wide_label_tab_paints_centered_with_the_fill_spanning_its_cells() {
let theme = Theme::default_dark();
let style = TabsStyle::from_theme(&theme);
let area = Rect::new(0, 0, 12, TabsSize::Small.height());
let mut buffer = Buffer::empty(area);
Widget::render(
TabsWidget::new(&["日本"])
.selected_tab(Some(0))
.style(style),
area,
&mut buffer,
);
assert_eq!(buffer.cell((2, 0)).expect("label start").symbol(), "æ—¥");
assert_eq!(buffer.cell((4, 0)).expect("label middle").symbol(), "本");
for x in [0, 1, 6, 7] {
let pad = buffer.cell((x, 0)).expect("padding cell");
assert_eq!(pad.symbol(), " ", "pad at {x}");
assert_eq!(pad.bg, style.selected_background, "fill spans tab at {x}");
}
assert_eq!(buffer.cell((8, 0)).expect("outside cell").bg, Color::Reset);
}
#[test]
fn undersized_area_never_paints_outside_it() {
let theme = Theme::default_dark();
let style = TabsStyle::from_theme(&theme);
let surround = Rect::new(0, 0, 12, TabsSize::Small.height());
let area = Rect::new(2, 0, 5, TabsSize::Small.height());
let mut buffer = Buffer::empty(surround);
Widget::render(
TabsWidget::new(&["日本語"])
.selected_tab(Some(0))
.style(style),
area,
&mut buffer,
);
for x in [0, 1, 7, 8, 9, 10, 11] {
let outside = buffer.cell((x, 0)).expect("cell outside the area");
assert_eq!(outside.symbol(), " ", "outside cell {x} written");
assert_eq!(outside.bg, Color::Reset, "outside cell {x} styled");
}
let before = buffer.clone();
Widget::render(
TabsWidget::new(&["日本語"]).selected_tab(Some(0)),
Rect::new(2, 0, 0, 1),
&mut buffer,
);
Widget::render(
TabsWidget::new(&["日本語"]).selected_tab(Some(0)),
Rect::new(2, 0, 5, 0),
&mut buffer,
);
assert_eq!(buffer, before);
}
#[test]
fn tabs_widget_width_includes_spacing_between_tabs() {
assert_eq!(TabsWidget::new(&["A", "B"]).width(), 11);
}
#[test]
fn window_width_sums_tab_widths_and_the_gaps_between_them() {
assert_eq!(window_width(&[5, 5, 5], 0, 2), 17);
assert_eq!(window_width(&[5, 9, 5], 1, 1), 9);
assert_eq!(window_width(&[5, 5, 5, 5], 1, 2), 11);
}
#[test]
fn tab_window_shows_everything_when_it_all_fits() {
let window = tab_window(&[5, 5, 5, 5, 5, 5], 0, 100);
assert_eq!((window.lo, window.hi), (0, 5));
assert_eq!(window.width, 35); }
#[test]
fn tab_window_keeps_a_late_active_tab_visible() {
let window = tab_window(&[5, 5, 5, 5, 5, 5], 4, 18);
assert!(
window.lo <= 4 && 4 <= window.hi,
"must include the active tab"
);
assert!(window.lo > 0, "earlier tabs are hidden");
assert!(window.width <= 18);
}
#[test]
fn tab_window_is_just_the_active_tab_when_nothing_else_fits() {
let window = tab_window(&[6, 6, 6], 1, 6);
assert_eq!((window.lo, window.hi), (1, 1));
assert_eq!(window.width, 6);
}
#[test]
fn string_sugar_keys_tabs_by_their_labels() {
let tabs = Tabs::<String, State, Msg>::new(["One", "Two"]);
assert_eq!(tabs.items[0].value(), "One");
assert_eq!(tabs.items[0].label(), "One");
assert!(!tabs.items[0].is_disabled());
}
#[test]
fn duplicate_tab_values_panic_with_the_shared_message() {
let mut tabs: Tabs<Screen, State, Msg> =
Tabs::new([Tab::new(Screen::A, "First"), Tab::new(Screen::A, "Second")]);
let panic = catch_unwind(AssertUnwindSafe(|| {
Component::prepare(&mut tabs, &State::default());
}))
.expect_err("duplicate tab values must panic");
let message = panic
.downcast_ref::<String>()
.expect("panic carries a String");
assert_eq!(
message,
"Tabs item values must be unique within a Tabs declaration"
);
}
#[test]
fn disabled_row_is_not_focusable_and_ignores_events() {
let mut tabs = manual().disabled(true);
tabs.hits = tab_layout(
Rect::new(0, 0, 30, TabsSize::Small.height()),
&["A", "B", "C"],
TabsSize::Small,
0,
);
let state = State::default();
assert!(!tabs.is_focusable(&state));
assert_eq!(
tabs.handle_event(&key(KeyCode::Right), &state, &mut EventCtx::default()),
EventResult::Ignored
);
assert_eq!(
tabs.handle_event(&key(KeyCode::Enter), &state, &mut EventCtx::default()),
EventResult::Ignored
);
assert_eq!(
tabs.handle_event(
&mouse(MouseKind::Click(MouseButton::Left), 2, 0),
&state,
&mut EventCtx::default()
),
EventResult::Ignored
);
}
#[test]
fn disabled_row_paints_every_tab_in_disabled_colors() {
let theme = Theme::default_dark();
let style = TabsStyle::from_theme(&theme);
let area = Rect::new(0, 0, 18, TabsSize::Small.height());
let mut buffer = Buffer::empty(area);
Widget::render(
TabsWidget::new(&["A", "B"])
.selected_tab(Some(0))
.disabled(true)
.style(style),
area,
&mut buffer,
);
let selected = buffer.cell((2, 0)).expect("selected disabled tab");
let unselected = buffer.cell((8, 0)).expect("unselected disabled tab");
assert_eq!(selected.bg, style.selected_disabled_background);
assert_eq!(unselected.bg, style.disabled_background);
}
#[test]
fn a_disabled_row_is_skipped_by_tab_traversal() {
#[derive(Default)]
struct RoutedState {
focus: crate::runtime::FocusState,
selected: Screen,
}
#[derive(Debug, PartialEq)]
enum RoutedMsg {
Focus(crate::runtime::FocusState),
Selected(Screen),
Pressed,
}
let theme = Theme::default_dark();
let state = RoutedState::default();
let mut ratcn = Ratcn::new().focus(|state: &RoutedState| &state.focus, RoutedMsg::Focus);
let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal");
terminal
.draw(|frame| {
ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
ChildId::Static("tabs"),
Tabs::new([Tab::new(Screen::A, "A"), Tab::new(Screen::B, "B")])
.selection(
|state: &RoutedState| Some(state.selected),
RoutedMsg::Selected,
)
.activation(TabsActivation::Automatic)
.disabled(true),
Rect::new(0, 0, 20, 1),
);
ctx.render_component(
ChildId::Static("button"),
crate::Button::new("Next").on_press(|| RoutedMsg::Pressed),
Rect::new(0, 1, 20, 1),
);
});
})
.expect("draw");
assert_eq!(
ratcn.handle_event(key(KeyCode::Enter), &state),
EventResult::Emit(RoutedMsg::Pressed)
);
}
}