mod interaction;
mod layout;
mod render;
use std::cell::Cell;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use super::chrome;
#[derive(Debug, Clone)]
pub struct SidebarItem {
pub label: String,
pub id: usize,
}
impl SidebarItem {
pub fn new(label: impl Into<String>, id: usize) -> Self {
Self {
label: label.into(),
id,
}
}
}
#[derive(Debug, Clone)]
pub struct SidebarSection {
pub title: String,
pub items: Vec<SidebarItem>,
}
impl SidebarSection {
pub fn new(title: impl Into<String>, items: Vec<SidebarItem>) -> Self {
Self {
title: title.into(),
items,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Overflow {
#[default]
Truncate,
Scroll,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SidebarOutcome {
Activated,
Consumed,
Ignored,
}
enum Row<'a> {
Header(&'a str),
Item(&'a SidebarItem),
}
pub struct Sidebar {
sections: Vec<SidebarSection>,
selected: usize,
offset: Cell<usize>,
viewport: Cell<usize>,
h_offset: Cell<usize>,
overflow: Overflow,
filter_enabled: bool,
filtering: bool,
filter: String,
decor: Option<chrome::BoxDecor>,
}
impl Sidebar {
pub fn new(sections: Vec<SidebarSection>) -> Self {
Self {
sections,
selected: 0,
offset: Cell::new(0),
viewport: Cell::new(1),
h_offset: Cell::new(0),
overflow: Overflow::Truncate,
filter_enabled: false,
filtering: false,
filter: String::new(),
decor: None,
}
}
#[must_use]
pub fn overflow(mut self, overflow: Overflow) -> Self {
self.overflow = overflow;
self
}
#[must_use]
pub fn filterable(mut self) -> Self {
self.filter_enabled = true;
self
}
#[must_use]
pub fn boxed(mut self, decor: chrome::BoxDecor) -> Self {
self.decor = Some(decor);
self
}
pub fn is_filtering(&self) -> bool {
self.filtering
}
pub fn selected_id(&self) -> Option<usize> {
self.filtered_items().nth(self.selected).map(|item| item.id)
}
pub fn selected_label(&self) -> Option<&str> {
self.filtered_items()
.nth(self.selected)
.map(|item| item.label.as_str())
}
}
const H_STEP: usize = 4;
fn section_header_row(rows: &[Row], selected_row: usize) -> usize {
let last = selected_row.min(rows.len().saturating_sub(1));
(0..=last)
.rev()
.find(|&index| matches!(rows[index], Row::Header(_)))
.unwrap_or(selected_row)
}
fn split_for_hbar(area: Rect, overflowing: bool) -> (Rect, Option<Rect>) {
if !overflowing {
return (area, None);
}
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(area);
(split[0], Some(split[1]))
}
#[cfg(test)]
mod tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::CONTROL)
}
fn sample() -> Sidebar {
Sidebar::new(vec![
SidebarSection::new(
"FRUIT",
vec![
SidebarItem::new("Apple", 10),
SidebarItem::new("Banana", 11),
],
),
SidebarSection::new("VEG", vec![SidebarItem::new("Carrot", 20)]),
])
}
#[test]
fn starts_on_the_first_item() {
let sidebar = sample();
assert_eq!(sidebar.selected_id(), Some(10));
assert_eq!(sidebar.selected_label(), Some("Apple"));
}
#[test]
fn selecting_the_first_item_scrolls_to_the_top() {
let sidebar = sample();
let rows = sidebar.rows();
sidebar.offset.set(1);
let selected_row = sidebar.selected_row(&rows); let offset = sidebar.scroll_offset(&rows, selected_row, 4);
assert_eq!(offset, 0);
}
#[test]
fn section_header_row_finds_the_header_above() {
let sidebar = sample();
let rows = sidebar.rows();
assert_eq!(section_header_row(&rows, 1), 0); assert_eq!(section_header_row(&rows, 4), 3); }
#[test]
fn down_advances_across_sections_skipping_headers() {
let mut sidebar = sample();
sidebar.handle_key(key(KeyCode::Down));
sidebar.handle_key(key(KeyCode::Down));
assert_eq!(sidebar.selected_id(), Some(20));
}
#[test]
fn up_from_the_top_wraps_to_the_last_item() {
let mut sidebar = sample();
sidebar.handle_key(key(KeyCode::Up));
assert_eq!(sidebar.selected_id(), Some(20));
}
#[test]
fn enter_activates_the_selected_item() {
let mut sidebar = sample();
assert_eq!(
sidebar.handle_key(key(KeyCode::Enter)),
SidebarOutcome::Activated,
);
}
#[test]
fn unrelated_keys_are_ignored() {
let mut sidebar = sample();
assert_eq!(
sidebar.handle_key(key(KeyCode::Char('x'))),
SidebarOutcome::Ignored,
);
}
#[test]
fn filter_narrows_matches_and_resets_the_cursor() {
let mut sidebar = sample().filterable();
sidebar.handle_key(key(KeyCode::Down)); sidebar.handle_key(key(KeyCode::Char('/')));
assert!(sidebar.is_filtering());
for ch in "carr".chars() {
sidebar.handle_key(key(KeyCode::Char(ch)));
}
assert_eq!(sidebar.selected_id(), Some(20));
}
#[test]
fn filter_excluding_everything_yields_no_selection() {
let mut sidebar = sample().filterable();
sidebar.handle_key(key(KeyCode::Char('/')));
for ch in "zzzz".chars() {
sidebar.handle_key(key(KeyCode::Char(ch)));
}
assert_eq!(sidebar.selected_id(), None);
}
#[test]
fn escape_clears_the_filter() {
let mut sidebar = sample().filterable();
sidebar.handle_key(key(KeyCode::Char('/')));
sidebar.handle_key(key(KeyCode::Char('z')));
sidebar.handle_key(key(KeyCode::Esc));
assert!(!sidebar.is_filtering());
assert_eq!(sidebar.selected_id(), Some(10));
}
#[test]
fn slash_does_nothing_without_a_filter() {
let mut sidebar = sample();
assert_eq!(
sidebar.handle_key(key(KeyCode::Char('/'))),
SidebarOutcome::Ignored,
);
assert!(!sidebar.is_filtering());
}
#[test]
fn ctrl_chords_do_not_navigate() {
let mut sidebar = sample().overflow(Overflow::Scroll);
assert_eq!(
sidebar.handle_key(ctrl(KeyCode::Char('j'))),
SidebarOutcome::Ignored,
);
assert_eq!(sidebar.selected_id(), Some(10));
sidebar.handle_key(ctrl(KeyCode::Char('l')));
assert_eq!(sidebar.h_offset.get(), 0);
}
#[test]
fn ctrl_chords_are_not_typed_into_the_filter() {
let mut sidebar = sample().filterable();
sidebar.handle_key(key(KeyCode::Char('/')));
sidebar.handle_key(ctrl(KeyCode::Char('u')));
assert!(sidebar.filter.is_empty());
sidebar.handle_key(key(KeyCode::Char('u')));
assert_eq!(sidebar.filter, "u");
}
#[test]
fn altgr_characters_still_reach_the_filter() {
let mut sidebar = sample().filterable();
sidebar.handle_key(key(KeyCode::Char('/')));
for ch in ['@', '\\', '['] {
sidebar.handle_key(KeyEvent::new(
KeyCode::Char(ch),
KeyModifiers::CONTROL | KeyModifiers::ALT,
));
}
assert_eq!(sidebar.filter, "@\\[");
}
#[test]
fn panning_only_applies_in_scroll_mode() {
let mut truncate = sample();
assert_eq!(
truncate.handle_key(key(KeyCode::Right)),
SidebarOutcome::Ignored,
);
let mut scroll = sample().overflow(Overflow::Scroll);
assert_eq!(
scroll.handle_key(key(KeyCode::Right)),
SidebarOutcome::Consumed,
);
assert_eq!(scroll.h_offset.get(), H_STEP);
scroll.handle_key(key(KeyCode::Left));
scroll.handle_key(key(KeyCode::Left));
assert_eq!(scroll.h_offset.get(), 0);
}
}