use super::group::Group;
use super::view::{View, write_line_to_terminal};
use crate::core::draw::DrawBuffer;
use crate::core::event::{Event, EventType, KB_F6, KB_PGDN, KB_PGUP, MB_LEFT_BUTTON};
use crate::core::geometry::{Point, Rect};
use crate::core::palette::{Attr, LABEL_NORMAL, LABEL_SELECTED, LABEL_SHORTCUT};
use crate::core::state::StateFlags;
use crate::terminal::Terminal;
const TAB_PADDING: usize = 1;
const TAB_GAP: usize = 1;
const FRAME_TOP_LEFT: char = '\u{250C}';
const FRAME_TOP_RIGHT: char = '\u{2510}';
const FRAME_BOTTOM_LEFT: char = '\u{2514}';
const FRAME_BOTTOM_RIGHT: char = '\u{2518}';
const FRAME_HORIZONTAL: char = '\u{2500}';
const FRAME_VERTICAL: char = '\u{2502}';
const FRAME_TAB_JOINT: char = '\u{2534}';
const FRAME_ACTIVE_LEFT: char = '\u{2518}';
const FRAME_ACTIVE_RIGHT: char = '\u{2514}';
const HEADER_ROWS: i16 = 3;
const LABEL_DIMMED: u8 = 5;
struct Tab {
title: String,
label: String,
hotkey: Option<char>,
hotkey_pos: Option<usize>,
page: Group,
}
impl Tab {
fn new(title: &str, page: Group) -> Self {
let (label, hotkey, hotkey_pos) = parse_title(title);
Self {
title: title.to_string(),
label,
hotkey,
hotkey_pos,
page,
}
}
fn width(&self) -> usize {
self.label.chars().count() + TAB_PADDING * 2
}
}
fn parse_title(title: &str) -> (String, Option<char>, Option<usize>) {
let mut label = String::new();
let mut hotkey = None;
let mut hotkey_pos = None;
let mut chars = title.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '~' {
label.push(ch);
continue;
}
if let Some(letter) = chars.next() {
if hotkey.is_none() {
hotkey = Some(letter.to_ascii_lowercase());
hotkey_pos = Some(label.chars().count());
}
label.push(letter);
if chars.peek() == Some(&'~') {
chars.next();
}
}
}
(label, hotkey, hotkey_pos)
}
pub struct TabbedPane {
bounds: Rect,
tabs: Vec<Tab>,
active: usize,
view_state: StateFlags,
palette_chain: Option<crate::core::palette_chain::PaletteChainNode>,
}
impl TabbedPane {
pub fn new(bounds: Rect) -> Self {
Self {
bounds,
tabs: Vec::new(),
active: 0,
view_state: 0,
palette_chain: None,
}
}
pub fn page_area(&self) -> Rect {
Rect::new(
self.bounds.a.x + 1,
self.bounds.a.y + HEADER_ROWS,
self.bounds.b.x - 1,
self.bounds.b.y - 1,
)
}
pub fn add_page(&mut self, title: &str, page: Group) -> usize {
self.tabs.push(Tab::new(title, page));
self.tabs.len() - 1
}
pub fn page_count(&self) -> usize {
self.tabs.len()
}
pub fn active(&self) -> usize {
self.active
}
pub fn active_title(&self) -> Option<&str> {
self.tabs.get(self.active).map(|t| &*t.title)
}
pub fn set_active(&mut self, index: usize) -> bool {
if index >= self.tabs.len() || index == self.active {
return false;
}
let focused = self.is_focused();
if let Some(page) = self.active_page_mut() {
page.clear_all_focus();
}
self.active = index;
if focused {
if let Some(page) = self.active_page_mut() {
page.set_initial_focus();
}
}
true
}
pub fn page_mut(&mut self, index: usize) -> Option<&mut Group> {
self.tabs.get_mut(index).map(|t| &mut t.page)
}
pub fn active_page_mut(&mut self) -> Option<&mut Group> {
let active = self.active;
self.page_mut(active)
}
pub fn set_initial_focus(&mut self) {
if let Some(page) = self.active_page_mut() {
page.set_initial_focus();
}
}
fn cycle(&mut self, forward: bool) -> bool {
if self.tabs.len() < 2 {
return false;
}
let next = if forward {
(self.active + 1) % self.tabs.len()
} else {
(self.active + self.tabs.len() - 1) % self.tabs.len()
};
self.set_active(next)
}
fn tab_offsets(&self) -> Vec<usize> {
let mut offsets = Vec::with_capacity(self.tabs.len());
let mut x = 1;
for tab in &self.tabs {
offsets.push(x);
x += tab.width() + TAB_GAP;
}
offsets
}
fn tab_at(&self, pos: Point) -> Option<usize> {
let local_y = pos.y - self.bounds.a.y;
if !(0..HEADER_ROWS).contains(&local_y) || !self.bounds.contains(pos) {
return None;
}
let local_x = (pos.x - self.bounds.a.x) as usize;
let offsets = self.tab_offsets();
for (index, offset) in offsets.iter().enumerate() {
if local_x >= *offset && local_x < offset + self.tabs[index].width() {
return Some(index);
}
}
None
}
fn tab_for_hotkey(&self, letter: char) -> Option<usize> {
let letter = letter.to_ascii_lowercase();
self.tabs.iter().position(|t| t.hotkey == Some(letter))
}
fn draw_frame(&mut self, terminal: &mut Terminal) {
let width = self.bounds.width_clamped().max(0) as usize;
let height = self.bounds.height_clamped().max(0) as usize;
if width < 2 || height <= HEADER_ROWS as usize {
return;
}
let tabs_painter = HeaderPainter {
chain: self.palette_chain.clone(),
palette: crate::core::palette::palettes::CP_LABEL,
};
let rules_painter = HeaderPainter {
chain: self.palette_chain.clone(),
palette: crate::core::palette::palettes::CP_LABEL,
};
let normal = tabs_painter.map_color(LABEL_NORMAL);
let active = tabs_painter.map_color(LABEL_SELECTED);
let shortcut = tabs_painter.map_color(LABEL_SHORTCUT);
let frame = rules_painter.map_color(LABEL_DIMMED);
let x0 = self.bounds.a.x;
let y0 = self.bounds.a.y;
let mut tops = DrawBuffer::new(width);
tops.move_char(0, ' ', frame, width);
let mut titles = DrawBuffer::new(width);
titles.move_char(0, ' ', frame, width);
let mut edge = DrawBuffer::new(width);
edge.move_char(0, FRAME_HORIZONTAL, frame, width);
edge.put_char(0, FRAME_TOP_LEFT, frame);
edge.put_char(width - 1, FRAME_TOP_RIGHT, frame);
for (index, offset) in self.tab_offsets().into_iter().enumerate() {
let tab = &self.tabs[index];
let span = tab.width();
if offset + span > width - 1 {
break;
}
let is_active = index == self.active;
let attr: Attr = if is_active { active } else { normal };
let right = offset + span - 1;
tops.move_char(offset, FRAME_HORIZONTAL, frame, span);
tops.put_char(offset, FRAME_TOP_LEFT, frame);
tops.put_char(right, FRAME_TOP_RIGHT, frame);
titles.move_char(offset, ' ', attr, span);
titles.put_char(offset, FRAME_VERTICAL, frame);
titles.put_char(right, FRAME_VERTICAL, frame);
let text_at = offset + TAB_PADDING;
titles.move_str(text_at, &tab.label, attr);
if let Some(pos) = tab.hotkey_pos {
if let Some(letter) = tab.label.chars().nth(pos) {
titles.put_char(text_at + pos, letter, shortcut);
}
}
if is_active {
edge.put_char(offset, FRAME_ACTIVE_LEFT, frame);
edge.move_char(offset + 1, ' ', frame, span.saturating_sub(2));
edge.put_char(right, FRAME_ACTIVE_RIGHT, frame);
} else {
edge.put_char(offset, FRAME_TAB_JOINT, frame);
edge.put_char(right, FRAME_TAB_JOINT, frame);
}
}
write_line_to_terminal(terminal, x0, y0, &tops);
write_line_to_terminal(terminal, x0, y0 + 1, &titles);
write_line_to_terminal(terminal, x0, y0 + 2, &edge);
let mut wall = DrawBuffer::new(1);
wall.put_char(0, FRAME_VERTICAL, frame);
for row in HEADER_ROWS as usize..height - 1 {
let y = y0 + row as i16;
write_line_to_terminal(terminal, x0, y, &wall);
write_line_to_terminal(terminal, self.bounds.b.x - 1, y, &wall);
}
let mut bottom = DrawBuffer::new(width);
bottom.move_char(0, FRAME_HORIZONTAL, frame, width);
bottom.put_char(0, FRAME_BOTTOM_LEFT, frame);
bottom.put_char(width - 1, FRAME_BOTTOM_RIGHT, frame);
write_line_to_terminal(terminal, x0, self.bounds.b.y - 1, &bottom);
}
}
struct HeaderPainter {
chain: Option<crate::core::palette_chain::PaletteChainNode>,
palette: &'static [u8],
}
impl View for HeaderPainter {
fn bounds(&self) -> Rect {
Rect::new(0, 0, 0, 0)
}
fn set_bounds(&mut self, _bounds: Rect) {}
fn draw(&mut self, _terminal: &mut Terminal) {}
fn handle_event(&mut self, _event: &mut Event) {}
fn get_palette_chain(&self) -> Option<&crate::core::palette_chain::PaletteChainNode> {
self.chain.as_ref()
}
fn get_palette(&self) -> Option<crate::core::palette::Palette> {
Some(crate::core::palette::Palette::from_slice(self.palette))
}
}
impl View for TabbedPane {
fn bounds(&self) -> Rect {
self.bounds
}
fn set_bounds(&mut self, bounds: Rect) {
self.bounds = bounds;
let area = self.page_area();
for tab in &mut self.tabs {
tab.page.set_bounds(area);
}
}
fn can_focus(&self) -> bool {
true
}
fn set_focus(&mut self, focused: bool) {
self.set_state_flag(crate::core::state::SF_FOCUSED, focused);
if let Some(page) = self.active_page_mut() {
if focused {
page.set_initial_focus();
} else {
page.clear_all_focus();
}
}
}
fn state(&self) -> StateFlags {
self.view_state
}
fn set_state(&mut self, state: StateFlags) {
self.view_state = state;
}
fn draw(&mut self, terminal: &mut Terminal) {
self.draw_frame(terminal);
let chain = crate::core::palette_chain::PaletteChainNode::new(
self.get_palette(),
self.palette_chain.clone(),
);
if let Some(tab) = self.tabs.get_mut(self.active) {
tab.page.set_palette_chain(Some(chain));
tab.page.draw(terminal);
}
}
fn handle_event(&mut self, event: &mut Event) {
if event.what == EventType::MouseDown && event.mouse.buttons & MB_LEFT_BUTTON != 0 {
if let Some(index) = self.tab_at(event.mouse.pos) {
self.set_active(index);
event.clear();
return;
}
}
if event.what == EventType::Keyboard {
let modifiers = event.key_modifiers;
let ctrl = modifiers.contains(crossterm::event::KeyModifiers::CONTROL);
let shift = modifiers.contains(crossterm::event::KeyModifiers::SHIFT);
let switched = match event.key_code {
KB_F6 => self.cycle(!shift),
KB_PGDN if ctrl => self.cycle(true),
KB_PGUP if ctrl => self.cycle(false),
_ => false,
};
if switched {
event.clear();
return;
}
if modifiers.contains(crossterm::event::KeyModifiers::ALT) {
let letter = (event.key_code & 0xFF) as u8 as char;
if let Some(index) = self.tab_for_hotkey(letter) {
self.set_active(index);
event.clear();
return;
}
}
}
if let Some(tab) = self.tabs.get_mut(self.active) {
tab.page.handle_event(event);
}
}
fn set_palette_chain(&mut self, node: Option<crate::core::palette_chain::PaletteChainNode>) {
self.palette_chain = node;
}
fn get_palette_chain(&self) -> Option<&crate::core::palette_chain::PaletteChainNode> {
self.palette_chain.as_ref()
}
fn get_palette(&self) -> Option<crate::core::palette::Palette> {
None
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
}
pub struct TabbedPaneBuilder {
bounds: Option<Rect>,
titles: Vec<String>,
active: usize,
}
impl TabbedPaneBuilder {
pub fn new() -> Self {
Self {
bounds: None,
titles: Vec::new(),
active: 0,
}
}
#[must_use]
pub fn bounds(mut self, bounds: Rect) -> Self {
self.bounds = Some(bounds);
self
}
#[must_use]
pub fn page(mut self, title: impl Into<String>) -> Self {
self.titles.push(title.into());
self
}
#[must_use]
pub fn active(mut self, index: usize) -> Self {
self.active = index;
self
}
pub fn build(self) -> TabbedPane {
let bounds = self.bounds.expect("TabbedPane bounds must be set");
let mut pane = TabbedPane::new(bounds);
let area = pane.page_area();
for title in &self.titles {
pane.add_page(title, Group::new(area));
}
pane.set_active(self.active);
pane
}
pub fn build_boxed(self) -> Box<TabbedPane> {
Box::new(self.build())
}
}
impl Default for TabbedPaneBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::event::{KB_DOWN, KB_TAB};
use crossterm::event::KeyModifiers;
fn pane() -> TabbedPane {
TabbedPaneBuilder::new()
.bounds(Rect::new(0, 0, 40, 12))
.page("~G~eneral")
.page("~A~dvanced")
.page("About")
.build()
}
fn ctrl(code: u16) -> Event {
let mut e = Event::keyboard(code);
e.key_modifiers = KeyModifiers::CONTROL;
e
}
fn alt(letter: char) -> Event {
let mut e = Event::keyboard(letter as u16);
e.key_modifiers = KeyModifiers::ALT;
e
}
fn click(x: i16, y: i16) -> Event {
let mut e = Event::nothing();
e.what = EventType::MouseDown;
e.mouse.buttons = MB_LEFT_BUTTON;
e.mouse.pos = Point::new(x, y);
e
}
#[test]
fn titles_lose_their_tilde_markers() {
let (label, hotkey, pos) = parse_title("~G~eneral");
assert_eq!(label, "General");
assert_eq!(hotkey, Some('g'));
assert_eq!(pos, Some(0));
}
#[test]
fn a_hotkey_can_sit_mid_word() {
let (label, hotkey, pos) = parse_title("Ad~v~anced");
assert_eq!(label, "Advanced");
assert_eq!(hotkey, Some('v'));
assert_eq!(pos, Some(2));
}
#[test]
fn a_title_without_tildes_has_no_hotkey() {
let (label, hotkey, _) = parse_title("About");
assert_eq!(label, "About");
assert_eq!(hotkey, None);
}
#[test]
fn an_unterminated_tilde_does_not_panic() {
let (label, hotkey, _) = parse_title("Trailing~");
assert_eq!(label, "Trailing");
assert_eq!(hotkey, None);
}
#[test]
fn the_first_page_starts_active() {
let p = pane();
assert_eq!(p.page_count(), 3);
assert_eq!(p.active(), 0);
assert_eq!(p.active_title(), Some("~G~eneral"));
}
#[test]
fn pages_sit_inside_the_frame_below_the_header() {
let p = pane();
assert_eq!(p.page_area(), Rect::new(1, 3, 39, 11));
}
#[test]
fn out_of_range_activation_is_ignored() {
let mut p = pane();
assert!(!p.set_active(99));
assert_eq!(p.active(), 0);
}
#[test]
fn f6_cycles_forward_and_shift_f6_back() {
let mut p = pane();
let mut e = Event::keyboard(crate::core::event::KB_F6);
p.handle_event(&mut e);
assert_eq!(p.active(), 1);
assert_eq!(e.what, EventType::Nothing);
let mut e = Event::keyboard(crate::core::event::KB_F6);
e.key_modifiers = KeyModifiers::SHIFT;
p.handle_event(&mut e);
assert_eq!(p.active(), 0);
}
#[test]
fn unmodified_page_keys_reach_the_page() {
let mut p = pane();
let mut e = Event::keyboard(KB_PGDN);
p.handle_event(&mut e);
assert_eq!(p.active(), 0);
assert_eq!(e.what, EventType::Keyboard);
}
#[test]
fn ctrl_page_keys_cycle_and_wrap() {
let mut p = pane();
let mut e = ctrl(KB_PGDN);
p.handle_event(&mut e);
assert_eq!(p.active(), 1);
assert_eq!(e.what, EventType::Nothing, "the key was consumed");
p.handle_event(&mut ctrl(KB_PGDN));
p.handle_event(&mut ctrl(KB_PGDN));
assert_eq!(p.active(), 0, "wrapped past the last tab");
p.handle_event(&mut ctrl(KB_PGUP));
assert_eq!(p.active(), 2, "wrapped backwards");
}
#[test]
fn a_lone_tab_does_not_cycle() {
let mut p = TabbedPaneBuilder::new()
.bounds(Rect::new(0, 0, 20, 6))
.page("Only")
.build();
let mut e = ctrl(KB_PGDN);
p.handle_event(&mut e);
assert_eq!(p.active(), 0);
}
#[test]
fn alt_hotkeys_select_their_tab() {
let mut p = pane();
let mut e = alt('a');
p.handle_event(&mut e);
assert_eq!(p.active(), 1);
assert_eq!(e.what, EventType::Nothing);
}
#[test]
fn an_unknown_hotkey_falls_through_to_the_page() {
let mut p = pane();
let mut e = alt('z');
p.handle_event(&mut e);
assert_eq!(p.active(), 0);
assert_eq!(e.what, EventType::Keyboard, "left for the dialog");
}
#[test]
fn plain_keys_reach_the_page_instead_of_switching_tabs() {
let mut p = pane();
let mut e = Event::keyboard(KB_TAB);
p.handle_event(&mut e);
assert_eq!(p.active(), 0);
let mut e = Event::keyboard(KB_DOWN);
p.handle_event(&mut e);
assert_eq!(p.active(), 0);
}
#[test]
fn clicking_a_tab_selects_it() {
let mut p = pane();
let mut e = click(12, 1);
p.handle_event(&mut e);
assert_eq!(p.active(), 1);
assert_eq!(e.what, EventType::Nothing);
}
#[test]
fn clicking_the_gap_between_tabs_changes_nothing() {
let mut p = pane();
let mut e = click(10, 1);
p.handle_event(&mut e);
assert_eq!(p.active(), 0);
}
#[test]
fn clicking_below_the_header_goes_to_the_page() {
let mut p = pane();
let mut e = click(12, 5);
p.handle_event(&mut e);
assert_eq!(p.active(), 0, "not a tab click");
}
#[test]
fn any_of_a_tabs_three_rows_selects_it() {
for row in 0..3 {
let mut p = pane();
let mut e = click(12, row);
p.handle_event(&mut e);
assert_eq!(p.active(), 1, "row {row} of the tab box");
}
}
#[test]
fn tab_offsets_follow_the_title_widths() {
let p = pane();
assert_eq!(p.tab_offsets(), vec![1, 11, 22]);
}
#[test]
fn resizing_the_pane_resizes_every_page() {
let mut p = pane();
p.set_bounds(Rect::new(5, 5, 45, 20));
assert_eq!(p.page_area(), Rect::new(6, 8, 44, 19));
assert_eq!(p.page_mut(2).unwrap().bounds(), Rect::new(6, 8, 44, 19));
}
#[test]
fn switching_away_and_back_keeps_a_page_intact() {
let mut p = pane();
p.page_mut(0)
.unwrap()
.add(Box::new(crate::views::static_text::StaticText::new(
Rect::new(1, 1, 10, 2),
"hello",
)));
p.set_active(2);
p.set_active(0);
assert_eq!(p.page_mut(0).unwrap().len(), 1);
}
}