use std::cell::Cell;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
use unicode_width::UnicodeWidthStr;
use super::{chrome, fuzzy, input, nav, scroll, style, text};
use crate::theme::Skin;
const H_STEP: usize = 4;
#[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())
}
pub fn handle_key(&mut self, key: KeyEvent) -> SidebarOutcome {
if self.filtering {
self.handle_filter_key(key);
return SidebarOutcome::Consumed;
}
let len = self.filtered_items().count();
let page = self.viewport.get().max(1) as isize;
let scroll = self.overflow == Overflow::Scroll;
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.selected = nav::cycle(self.selected, len, -1);
}
KeyCode::Down | KeyCode::Char('j') => {
self.selected = nav::cycle(self.selected, len, 1);
}
KeyCode::PageUp => {
self.selected = nav::step_clamped(self.selected, len, -page);
}
KeyCode::PageDown => {
self.selected = nav::step_clamped(self.selected, len, page);
}
KeyCode::Home | KeyCode::Char('g') => self.selected = 0,
KeyCode::End | KeyCode::Char('G') => {
self.selected = len.saturating_sub(1);
}
KeyCode::Left | KeyCode::Char('h') if scroll => {
self.h_offset
.set(self.h_offset.get().saturating_sub(H_STEP));
}
KeyCode::Right | KeyCode::Char('l') if scroll => {
self.h_offset.set(self.h_offset.get() + H_STEP);
}
KeyCode::Char('/') if self.filter_enabled => self.filtering = true,
KeyCode::Enter => return SidebarOutcome::Activated,
_ => return SidebarOutcome::Ignored,
}
SidebarOutcome::Consumed
}
fn handle_filter_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
self.filtering = false;
self.filter.clear();
self.selected = 0;
}
KeyCode::Enter => self.filtering = false,
KeyCode::Backspace => {
self.filter.pop();
self.selected = 0;
}
KeyCode::Char(ch) => {
self.filter.push(ch);
self.selected = 0;
}
_ => {}
}
}
fn matches(&self, item: &SidebarItem) -> bool {
!self.filter_enabled
|| self.filter.is_empty()
|| fuzzy::score(&item.label, &self.filter).is_some()
}
fn filtered_items(&self) -> impl Iterator<Item = &SidebarItem> {
self.sections
.iter()
.flat_map(|section| section.items.iter())
.filter(|item| self.matches(item))
}
fn rows(&self) -> Vec<Row<'_>> {
let mut rows = Vec::new();
for section in &self.sections {
let mut header_shown = false;
for item in section.items.iter().filter(|item| self.matches(item)) {
if !header_shown {
if !section.title.is_empty() {
rows.push(Row::Header(§ion.title));
}
header_shown = true;
}
rows.push(Row::Item(item));
}
}
rows
}
pub fn render(&self, frame: &mut Frame, area: Rect, skin: &Skin) {
let inner = self.render_frame(frame, area, skin);
let list_area = self.render_filter_line(frame, inner, skin);
self.render_list(frame, list_area, skin);
}
fn render_frame(&self, frame: &mut Frame, area: Rect, skin: &Skin) -> Rect {
if let Some(decor) = &self.decor {
let badge = self.filtered_items().count().to_string();
return chrome::framed_decor(frame, area, skin, decor, &badge);
}
let block = chrome::menu_panel(skin);
let inner = block.inner(area);
frame.render_widget(block, area);
inner
}
fn render_filter_line(
&self,
frame: &mut Frame,
inner: Rect,
skin: &Skin,
) -> Rect {
if !self.filtering {
return inner;
}
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(inner);
let palette = &skin.palette;
let mut query = vec![Span::styled("/", style::fg(palette.accent))];
query.extend(input::query_spans(
&self.filter,
palette,
(split[0].width as usize).saturating_sub(1),
));
frame.render_widget(Paragraph::new(Line::from(query)), split[0]);
split[1]
}
fn render_list(&self, frame: &mut Frame, area: Rect, skin: &Skin) {
let rows = self.rows();
let max_width = rows.iter().map(Self::row_width).max().unwrap_or(0);
let has_scrollbar = rows.len() > area.height as usize;
let content_width =
(area.width as usize).saturating_sub(usize::from(has_scrollbar));
let overflowing =
self.overflow == Overflow::Scroll && max_width > content_width;
let (body, hbar) = split_for_hbar(area, overflowing);
let height = body.height as usize;
self.viewport.set(height.max(1));
let selected_row = self.selected_row(&rows);
let offset = self.scroll_offset(&rows, selected_row, height);
self.offset.set(offset);
let h_offset = self.clamp_h_offset(max_width, content_width);
let lines: Vec<Line> = rows
.iter()
.enumerate()
.skip(offset)
.take(height)
.map(|(index, row)| {
self.render_row(
row,
index == selected_row,
content_width,
h_offset,
skin,
)
})
.collect();
frame.render_widget(Paragraph::new(lines), body);
scroll::render_scrollbar(
frame,
body,
skin,
nav::ScrollView {
total: rows.len(),
offset,
viewport: height,
},
);
if let Some(hbar) = hbar {
scroll::render_hscrollbar(
frame,
hbar,
skin,
nav::ScrollView {
total: max_width,
offset: h_offset,
viewport: content_width,
},
);
}
}
fn render_row(
&self,
row: &Row,
selected: bool,
width: usize,
h_offset: usize,
skin: &Skin,
) -> Line<'static> {
let palette = &skin.palette;
match row {
Row::Header(title) => Line::from(Span::styled(
self.clip(title, h_offset, width),
style::dim().add_modifier(Modifier::BOLD),
)),
Row::Item(item) => {
let pointer = if selected { skin.glyphs.pointer } else { " " };
let full = format!("{pointer} {}", item.label);
let clipped = self.clip(&full, h_offset, width);
if selected {
let bar = text::pad_end(&clipped, width);
let style = style::fg(palette.accent)
.add_modifier(Modifier::BOLD)
.bg(style::to_ratatui(palette.selection));
Line::from(Span::styled(bar, style))
} else {
Line::from(Span::styled(clipped, Style::default()))
}
}
}
}
fn clip(&self, text: &str, h_offset: usize, width: usize) -> String {
match self.overflow {
Overflow::Truncate => text::truncate(text, width),
Overflow::Scroll => text::window(text, h_offset, width),
}
}
fn selected_row(&self, rows: &[Row]) -> usize {
let mut item_index = 0;
for (row_index, row) in rows.iter().enumerate() {
if let Row::Item(_) = row {
if item_index == self.selected {
return row_index;
}
item_index += 1;
}
}
0
}
fn scroll_offset(
&self,
rows: &[Row],
selected_row: usize,
height: usize,
) -> usize {
if height == 0 || rows.is_empty() {
return 0;
}
let max_offset = rows.len().saturating_sub(height);
let anchor = section_header_row(rows, selected_row);
let mut offset = self.offset.get().min(max_offset);
if anchor < offset {
offset = anchor;
}
if selected_row >= offset + height {
offset = selected_row + 1 - height;
}
offset.min(max_offset)
}
fn clamp_h_offset(&self, max_width: usize, inner_width: usize) -> usize {
if self.overflow != Overflow::Scroll {
return 0;
}
let max_offset = max_width.saturating_sub(inner_width);
let clamped = self.h_offset.get().min(max_offset);
self.h_offset.set(clamped);
clamped
}
fn row_width(row: &Row) -> usize {
match row {
Row::Header(title) => title.width(),
Row::Item(item) => item.label.width() + 2,
}
}
}
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::KeyModifiers;
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
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 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);
}
}