use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Paragraph, Widget},
};
use crate::traits::ClickRegion;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BreadcrumbAction {
Navigate(String),
ExpandEllipsis,
}
#[derive(Debug, Clone)]
pub struct BreadcrumbItem {
pub id: String,
pub label: String,
pub icon: Option<String>,
pub enabled: bool,
}
impl BreadcrumbItem {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self {
id: id.into(),
label: label.into(),
icon: None,
enabled: true,
}
}
pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
}
#[derive(Debug, Clone)]
pub struct BreadcrumbState {
pub items: Vec<BreadcrumbItem>,
pub selected_index: Option<usize>,
pub focused: bool,
pub enabled: bool,
pub expanded: bool,
}
impl Default for BreadcrumbState {
fn default() -> Self {
Self {
items: Vec::new(),
selected_index: None,
focused: false,
enabled: true,
expanded: false,
}
}
}
impl BreadcrumbState {
pub fn new(items: Vec<BreadcrumbItem>) -> Self {
Self {
items,
..Default::default()
}
}
pub fn empty() -> Self {
Self::default()
}
pub fn select_next(&mut self) {
if self.items.is_empty() {
return;
}
self.selected_index = Some(match self.selected_index {
Some(idx) if idx + 1 < self.items.len() => idx + 1,
Some(idx) => idx, None => 0,
});
}
pub fn select_prev(&mut self) {
if self.items.is_empty() {
return;
}
self.selected_index = Some(match self.selected_index {
Some(idx) if idx > 0 => idx - 1,
Some(idx) => idx, None => self.items.len().saturating_sub(1),
});
}
pub fn select(&mut self, index: usize) {
if index < self.items.len() {
self.selected_index = Some(index);
}
}
pub fn select_by_id(&mut self, id: &str) {
if let Some(idx) = self.items.iter().position(|item| item.id == id) {
self.selected_index = Some(idx);
}
}
pub fn select_first(&mut self) {
if !self.items.is_empty() {
self.selected_index = Some(0);
}
}
pub fn select_last(&mut self) {
if !self.items.is_empty() {
self.selected_index = Some(self.items.len() - 1);
}
}
pub fn clear_selection(&mut self) {
self.selected_index = None;
}
pub fn push(&mut self, item: BreadcrumbItem) {
self.items.push(item);
}
pub fn pop(&mut self) -> Option<BreadcrumbItem> {
let item = self.items.pop();
if let Some(idx) = self.selected_index {
if idx >= self.items.len() && !self.items.is_empty() {
self.selected_index = Some(self.items.len() - 1);
} else if self.items.is_empty() {
self.selected_index = None;
}
}
item
}
pub fn clear(&mut self) {
self.items.clear();
self.selected_index = None;
self.expanded = false;
}
pub fn set_items(&mut self, items: Vec<BreadcrumbItem>) {
self.items = items;
if let Some(idx) = self.selected_index {
if idx >= self.items.len() {
self.selected_index = if self.items.is_empty() {
None
} else {
Some(self.items.len() - 1)
};
}
}
self.expanded = false;
}
pub fn toggle_expanded(&mut self) {
self.expanded = !self.expanded;
}
pub fn selected_item(&self) -> Option<&BreadcrumbItem> {
self.selected_index.and_then(|idx| self.items.get(idx))
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
}
#[derive(Debug, Clone)]
pub struct BreadcrumbStyle {
pub separator: &'static str,
pub separator_style: Style,
pub ellipsis: &'static str,
pub ellipsis_style: Style,
pub collapse_threshold: usize,
pub visible_start: usize,
pub visible_end: usize,
pub item_style: Style,
pub focused_item_style: Style,
pub selected_item_style: Style,
pub hovered_item_style: Style,
pub disabled_item_style: Style,
pub last_item_style: Style,
pub icon_style: Style,
pub icon_separator: &'static str,
pub padding: (u16, u16),
}
impl Default for BreadcrumbStyle {
fn default() -> Self {
Self {
separator: " > ",
separator_style: Style::default().fg(Color::DarkGray),
ellipsis: "...",
ellipsis_style: Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
collapse_threshold: 4,
visible_start: 1,
visible_end: 2,
item_style: Style::default().fg(Color::Blue),
focused_item_style: Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
selected_item_style: Style::default().fg(Color::Black).bg(Color::Yellow),
hovered_item_style: Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::UNDERLINED),
disabled_item_style: Style::default().fg(Color::DarkGray),
last_item_style: Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
icon_style: Style::default(),
icon_separator: " ",
padding: (1, 1),
}
}
}
impl From<&crate::theme::Theme> for BreadcrumbStyle {
fn from(theme: &crate::theme::Theme) -> Self {
let p = &theme.palette;
Self {
separator: " > ",
separator_style: Style::default().fg(p.text_disabled),
ellipsis: "...",
ellipsis_style: Style::default()
.fg(p.secondary)
.add_modifier(Modifier::BOLD),
collapse_threshold: 4,
visible_start: 1,
visible_end: 2,
item_style: Style::default().fg(Color::Blue),
focused_item_style: Style::default().fg(p.primary).add_modifier(Modifier::BOLD),
selected_item_style: Style::default().fg(p.highlight_fg).bg(p.highlight_bg),
hovered_item_style: Style::default()
.fg(p.secondary)
.add_modifier(Modifier::UNDERLINED),
disabled_item_style: Style::default().fg(p.text_disabled),
last_item_style: Style::default().fg(p.text).add_modifier(Modifier::BOLD),
icon_style: Style::default(),
icon_separator: " ",
padding: (1, 1),
}
}
}
impl BreadcrumbStyle {
pub fn slash() -> Self {
Self {
separator: " / ",
..Default::default()
}
}
pub fn chevron() -> Self {
Self {
separator: " › ",
separator_style: Style::default().fg(Color::Gray),
..Default::default()
}
}
pub fn arrow() -> Self {
Self {
separator: " → ",
separator_style: Style::default().fg(Color::Gray),
..Default::default()
}
}
pub fn minimal() -> Self {
Self {
separator: " / ",
separator_style: Style::default().fg(Color::DarkGray),
item_style: Style::default().fg(Color::Gray),
focused_item_style: Style::default().fg(Color::White),
selected_item_style: Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
last_item_style: Style::default().fg(Color::White),
ellipsis_style: Style::default().fg(Color::Gray),
..Default::default()
}
}
pub fn separator(mut self, sep: &'static str) -> Self {
self.separator = sep;
self
}
pub fn separator_style(mut self, style: Style) -> Self {
self.separator_style = style;
self
}
pub fn collapse_threshold(mut self, threshold: usize) -> Self {
self.collapse_threshold = threshold;
self
}
pub fn visible_ends(mut self, start: usize, end: usize) -> Self {
self.visible_start = start;
self.visible_end = end;
self
}
pub fn item_style(mut self, style: Style) -> Self {
self.item_style = style;
self
}
pub fn focused_item_style(mut self, style: Style) -> Self {
self.focused_item_style = style;
self
}
pub fn last_item_style(mut self, style: Style) -> Self {
self.last_item_style = style;
self
}
pub fn padding(mut self, left: u16, right: u16) -> Self {
self.padding = (left, right);
self
}
}
#[derive(Debug, Clone)]
enum VisibleElement {
Item(usize),
Ellipsis,
}
pub struct Breadcrumb<'a> {
state: &'a BreadcrumbState,
style: BreadcrumbStyle,
hovered_index: Option<usize>,
}
impl<'a> Breadcrumb<'a> {
pub fn new(state: &'a BreadcrumbState) -> Self {
Self {
state,
style: BreadcrumbStyle::default(),
hovered_index: None,
}
}
pub fn style(mut self, style: BreadcrumbStyle) -> Self {
self.style = style;
self
}
pub fn theme(self, theme: &crate::theme::Theme) -> Self {
self.style(BreadcrumbStyle::from(theme))
}
pub fn hovered(mut self, index: Option<usize>) -> Self {
self.hovered_index = index;
self
}
fn visible_elements(&self) -> Vec<VisibleElement> {
let len = self.state.items.len();
if len <= self.style.collapse_threshold || self.state.expanded {
return (0..len).map(VisibleElement::Item).collect();
}
let mut elements = Vec::new();
for i in 0..self.style.visible_start.min(len) {
elements.push(VisibleElement::Item(i));
}
elements.push(VisibleElement::Ellipsis);
let start = len.saturating_sub(self.style.visible_end);
for i in start..len {
elements.push(VisibleElement::Item(i));
}
elements
}
fn item_style(&self, idx: usize) -> Style {
let item = &self.state.items[idx];
let is_last = idx == self.state.items.len() - 1;
let is_selected = self.state.selected_index == Some(idx);
let is_hovered = self.hovered_index == Some(idx);
let is_focused = self.state.focused && is_selected;
if !item.enabled {
self.style.disabled_item_style
} else if is_focused {
self.style.selected_item_style
} else if is_hovered {
self.style.hovered_item_style
} else if is_selected {
self.style.focused_item_style
} else if is_last {
self.style.last_item_style
} else {
self.style.item_style
}
}
pub fn render_stateful(
self,
area: Rect,
buf: &mut Buffer,
) -> Vec<ClickRegion<BreadcrumbAction>> {
let mut regions = Vec::new();
if self.state.items.is_empty() {
return regions;
}
let visible = self.visible_elements();
let mut spans = Vec::new();
let mut x_offset = area.x + self.style.padding.0;
let mut element_positions: Vec<(VisibleElement, u16, u16)> = Vec::new();
for (i, element) in visible.iter().enumerate() {
if i > 0 {
let sep_span = Span::styled(self.style.separator, self.style.separator_style);
let sep_width = self.style.separator.chars().count() as u16;
spans.push(sep_span);
x_offset += sep_width;
}
match element {
VisibleElement::Item(idx) => {
let item = &self.state.items[*idx];
let style = self.item_style(*idx);
let mut item_text = String::new();
if let Some(ref icon) = item.icon {
item_text.push_str(icon);
item_text.push_str(self.style.icon_separator);
}
item_text.push_str(&item.label);
let item_width = item_text.chars().count() as u16;
element_positions.push((element.clone(), x_offset, item_width));
spans.push(Span::styled(item_text, style));
x_offset += item_width;
}
VisibleElement::Ellipsis => {
let ellipsis_width = self.style.ellipsis.chars().count() as u16;
element_positions.push((element.clone(), x_offset, ellipsis_width));
spans.push(Span::styled(self.style.ellipsis, self.style.ellipsis_style));
x_offset += ellipsis_width;
}
}
}
let line = Line::from(spans);
let paragraph = Paragraph::new(line);
paragraph.render(area, buf);
for (element, start_x, width) in element_positions {
if width == 0 {
continue;
}
let click_area = Rect::new(start_x, area.y, width, 1);
match element {
VisibleElement::Item(idx) => {
let item = &self.state.items[idx];
if item.enabled {
regions.push(ClickRegion::new(
click_area,
BreadcrumbAction::Navigate(item.id.clone()),
));
}
}
VisibleElement::Ellipsis => {
regions.push(ClickRegion::new(
click_area,
BreadcrumbAction::ExpandEllipsis,
));
}
}
}
regions
}
pub fn calculate_width(&self) -> u16 {
if self.state.items.is_empty() {
return 0;
}
let visible = self.visible_elements();
let mut width = self.style.padding.0 + self.style.padding.1;
for (i, element) in visible.iter().enumerate() {
if i > 0 {
width += self.style.separator.chars().count() as u16;
}
match element {
VisibleElement::Item(idx) => {
let item = &self.state.items[*idx];
if let Some(ref icon) = item.icon {
width += icon.chars().count() as u16;
width += self.style.icon_separator.chars().count() as u16;
}
width += item.label.chars().count() as u16;
}
VisibleElement::Ellipsis => {
width += self.style.ellipsis.chars().count() as u16;
}
}
}
width
}
}
pub fn handle_breadcrumb_key(
key: &KeyEvent,
state: &mut BreadcrumbState,
) -> Option<BreadcrumbAction> {
if !state.enabled || state.items.is_empty() {
return None;
}
match key.code {
KeyCode::Left | KeyCode::Char('h') => {
state.select_prev();
None
}
KeyCode::Right | KeyCode::Char('l') => {
state.select_next();
None
}
KeyCode::Home => {
state.select_first();
None
}
KeyCode::End => {
state.select_last();
None
}
KeyCode::Enter | KeyCode::Char(' ') => {
if let Some(item) = state.selected_item() {
if item.enabled {
Some(BreadcrumbAction::Navigate(item.id.clone()))
} else {
None
}
} else {
None
}
}
KeyCode::Char('e') => {
state.toggle_expanded();
Some(BreadcrumbAction::ExpandEllipsis)
}
_ => None,
}
}
pub fn handle_breadcrumb_mouse(
mouse: &MouseEvent,
state: &mut BreadcrumbState,
regions: &[ClickRegion<BreadcrumbAction>],
) -> Option<BreadcrumbAction> {
if !state.enabled {
return None;
}
if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
let col = mouse.column;
let row = mouse.row;
for region in regions {
if region.contains(col, row) {
match ®ion.data {
BreadcrumbAction::Navigate(id) => {
state.select_by_id(id);
return Some(region.data.clone());
}
BreadcrumbAction::ExpandEllipsis => {
state.toggle_expanded();
return Some(BreadcrumbAction::ExpandEllipsis);
}
}
}
}
}
None
}
pub fn get_hovered_index(
col: u16,
row: u16,
regions: &[ClickRegion<BreadcrumbAction>],
state: &BreadcrumbState,
) -> Option<usize> {
for region in regions {
if region.contains(col, row) {
if let BreadcrumbAction::Navigate(ref id) = region.data {
return state.items.iter().position(|item| &item.id == id);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_breadcrumb_item_creation() {
let item = BreadcrumbItem::new("home", "Home").icon("🏠").enabled(true);
assert_eq!(item.id, "home");
assert_eq!(item.label, "Home");
assert_eq!(item.icon, Some("🏠".to_string()));
assert!(item.enabled);
}
#[test]
fn test_breadcrumb_state_navigation() {
let items = vec![
BreadcrumbItem::new("a", "A"),
BreadcrumbItem::new("b", "B"),
BreadcrumbItem::new("c", "C"),
];
let mut state = BreadcrumbState::new(items);
assert!(state.selected_index.is_none());
state.select_next();
assert_eq!(state.selected_index, Some(0));
state.select_next();
assert_eq!(state.selected_index, Some(1));
state.select_prev();
assert_eq!(state.selected_index, Some(0));
state.select_prev();
assert_eq!(state.selected_index, Some(0));
state.select_last();
assert_eq!(state.selected_index, Some(2));
state.select_first();
assert_eq!(state.selected_index, Some(0));
}
#[test]
fn test_breadcrumb_state_select_by_id() {
let items = vec![
BreadcrumbItem::new("home", "Home"),
BreadcrumbItem::new("settings", "Settings"),
BreadcrumbItem::new("profile", "Profile"),
];
let mut state = BreadcrumbState::new(items);
state.select_by_id("settings");
assert_eq!(state.selected_index, Some(1));
state.select_by_id("nonexistent");
assert_eq!(state.selected_index, Some(1)); }
#[test]
fn test_breadcrumb_state_push_pop() {
let mut state = BreadcrumbState::empty();
assert!(state.is_empty());
state.push(BreadcrumbItem::new("a", "A"));
state.push(BreadcrumbItem::new("b", "B"));
assert_eq!(state.len(), 2);
state.select_last();
assert_eq!(state.selected_index, Some(1));
let popped = state.pop();
assert!(popped.is_some());
assert_eq!(popped.unwrap().id, "b");
assert_eq!(state.selected_index, Some(0)); }
#[test]
fn test_breadcrumb_state_clear() {
let items = vec![BreadcrumbItem::new("a", "A"), BreadcrumbItem::new("b", "B")];
let mut state = BreadcrumbState::new(items);
state.select(1);
state.clear();
assert!(state.is_empty());
assert!(state.selected_index.is_none());
}
#[test]
fn test_breadcrumb_style_presets() {
let default = BreadcrumbStyle::default();
assert_eq!(default.separator, " > ");
let slash = BreadcrumbStyle::slash();
assert_eq!(slash.separator, " / ");
let chevron = BreadcrumbStyle::chevron();
assert_eq!(chevron.separator, " › ");
let arrow = BreadcrumbStyle::arrow();
assert_eq!(arrow.separator, " → ");
}
#[test]
fn test_breadcrumb_style_builder() {
let style = BreadcrumbStyle::default()
.separator(" | ")
.collapse_threshold(5)
.visible_ends(2, 3)
.padding(2, 2);
assert_eq!(style.separator, " | ");
assert_eq!(style.collapse_threshold, 5);
assert_eq!(style.visible_start, 2);
assert_eq!(style.visible_end, 3);
assert_eq!(style.padding, (2, 2));
}
#[test]
fn test_breadcrumb_collapse_logic() {
let items: Vec<BreadcrumbItem> = (0..6)
.map(|i| BreadcrumbItem::new(format!("item{}", i), format!("Item {}", i)))
.collect();
let state = BreadcrumbState::new(items);
let breadcrumb = Breadcrumb::new(&state);
let visible = breadcrumb.visible_elements();
assert_eq!(visible.len(), 4);
let mut expanded_state = state.clone();
expanded_state.expanded = true;
let expanded_breadcrumb = Breadcrumb::new(&expanded_state);
let visible = expanded_breadcrumb.visible_elements();
assert_eq!(visible.len(), 6);
}
#[test]
fn test_breadcrumb_no_collapse() {
let items: Vec<BreadcrumbItem> = (0..3)
.map(|i| BreadcrumbItem::new(format!("item{}", i), format!("Item {}", i)))
.collect();
let state = BreadcrumbState::new(items);
let breadcrumb = Breadcrumb::new(&state);
let visible = breadcrumb.visible_elements();
assert_eq!(visible.len(), 3); }
#[test]
fn test_handle_breadcrumb_key() {
let items = vec![
BreadcrumbItem::new("a", "A"),
BreadcrumbItem::new("b", "B"),
BreadcrumbItem::new("c", "C"),
];
let mut state = BreadcrumbState::new(items);
state.focused = true;
let key = KeyEvent::from(KeyCode::Right);
handle_breadcrumb_key(&key, &mut state);
assert_eq!(state.selected_index, Some(0));
handle_breadcrumb_key(&key, &mut state);
assert_eq!(state.selected_index, Some(1));
state.select(1);
let key = KeyEvent::from(KeyCode::Enter);
let action = handle_breadcrumb_key(&key, &mut state);
assert_eq!(action, Some(BreadcrumbAction::Navigate("b".to_string())));
}
#[test]
fn test_handle_breadcrumb_key_disabled() {
let items = vec![BreadcrumbItem::new("a", "A")];
let mut state = BreadcrumbState::new(items);
state.enabled = false;
let key = KeyEvent::from(KeyCode::Right);
let action = handle_breadcrumb_key(&key, &mut state);
assert!(action.is_none());
}
#[test]
fn test_calculate_width() {
let items = vec![
BreadcrumbItem::new("home", "Home"),
BreadcrumbItem::new("settings", "Settings"),
];
let state = BreadcrumbState::new(items);
let breadcrumb = Breadcrumb::new(&state);
let width = breadcrumb.calculate_width();
assert_eq!(width, 17);
}
#[test]
fn test_click_region_contains() {
let region = ClickRegion::new(
Rect::new(10, 5, 20, 1),
BreadcrumbAction::Navigate("test".to_string()),
);
assert!(region.contains(10, 5));
assert!(region.contains(29, 5));
assert!(!region.contains(9, 5));
assert!(!region.contains(30, 5));
}
}