use ratatui::prelude::*;
use ratatui::widgets::Paragraph;
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct BreadcrumbSegment {
label: String,
data: Option<String>,
}
impl BreadcrumbSegment {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
data: None,
}
}
pub fn with_data(mut self, data: impl Into<String>) -> Self {
self.data = Some(data.into());
self
}
pub fn label(&self) -> &str {
&self.label
}
pub fn data(&self) -> Option<&str> {
self.data.as_deref()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BreadcrumbMessage {
Left,
Right,
First,
Last,
Select,
SelectIndex(usize),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BreadcrumbOutput {
Selected(usize),
FocusChanged(usize),
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct BreadcrumbState {
segments: Vec<BreadcrumbSegment>,
focused_index: usize,
separator: String,
max_visible: Option<usize>,
}
impl Default for BreadcrumbState {
fn default() -> Self {
Self {
segments: Vec::new(),
focused_index: 0,
separator: " > ".to_string(),
max_visible: None,
}
}
}
impl BreadcrumbState {
pub fn new(segments: Vec<BreadcrumbSegment>) -> Self {
Self {
segments,
focused_index: 0,
separator: " > ".to_string(),
max_visible: None,
}
}
pub fn from_labels<S: Into<String>>(labels: Vec<S>) -> Self {
let segments = labels
.into_iter()
.map(|label| BreadcrumbSegment::new(label))
.collect();
Self::new(segments)
}
pub fn from_path(path: &str, separator: &str) -> Self {
let segments = path
.split(separator)
.filter(|s| !s.is_empty())
.map(BreadcrumbSegment::new)
.collect();
Self::new(segments)
}
pub fn segments(&self) -> &[BreadcrumbSegment] {
&self.segments
}
pub fn len(&self) -> usize {
self.segments.len()
}
pub fn is_empty(&self) -> bool {
self.segments.is_empty()
}
pub fn focused_index(&self) -> usize {
self.focused_index
}
pub fn focused_segment(&self) -> Option<&BreadcrumbSegment> {
self.segments.get(self.focused_index)
}
pub fn separator(&self) -> &str {
&self.separator
}
pub fn max_visible(&self) -> Option<usize> {
self.max_visible
}
pub fn current(&self) -> Option<&BreadcrumbSegment> {
self.segments.last()
}
pub fn set_segments(&mut self, segments: Vec<BreadcrumbSegment>) {
self.segments = segments;
self.focused_index = 0;
}
pub fn push(&mut self, segment: BreadcrumbSegment) {
self.segments.push(segment);
}
pub fn pop(&mut self) -> Option<BreadcrumbSegment> {
let result = self.segments.pop();
if !self.segments.is_empty() && self.focused_index >= self.segments.len() {
self.focused_index = self.segments.len() - 1;
}
result
}
pub fn set_separator(&mut self, separator: impl Into<String>) {
self.separator = separator.into();
}
pub fn set_max_visible(&mut self, max: Option<usize>) {
self.max_visible = max;
}
pub fn with_separator(mut self, separator: impl Into<String>) -> Self {
self.separator = separator.into();
self
}
pub fn with_max_visible(mut self, max: Option<usize>) -> Self {
self.max_visible = max;
self
}
pub fn is_truncated(&self) -> bool {
match self.max_visible {
Some(max) if max > 0 => self.segments.len() > max,
_ => false,
}
}
fn visible_range(&self) -> (usize, usize) {
match self.max_visible {
Some(max) if max > 0 && self.segments.len() > max => {
let start = self.segments.len() - max;
(start, self.segments.len())
}
_ => (0, self.segments.len()),
}
}
pub fn visible_segments(&self) -> &[BreadcrumbSegment] {
let (start, end) = self.visible_range();
&self.segments[start..end]
}
pub fn update(&mut self, msg: BreadcrumbMessage) -> Option<BreadcrumbOutput> {
Breadcrumb::update(self, msg)
}
}
pub struct Breadcrumb;
impl Component for Breadcrumb {
type State = BreadcrumbState;
type Message = BreadcrumbMessage;
type Output = BreadcrumbOutput;
fn init() -> Self::State {
BreadcrumbState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
if state.segments.is_empty() {
return None;
}
match msg {
BreadcrumbMessage::Left => {
if state.focused_index > 0 {
state.focused_index -= 1;
Some(BreadcrumbOutput::FocusChanged(state.focused_index))
} else {
None
}
}
BreadcrumbMessage::Right => {
if state.focused_index < state.segments.len().saturating_sub(1) {
state.focused_index += 1;
Some(BreadcrumbOutput::FocusChanged(state.focused_index))
} else {
None
}
}
BreadcrumbMessage::First => {
if state.focused_index != 0 {
state.focused_index = 0;
Some(BreadcrumbOutput::FocusChanged(0))
} else {
None
}
}
BreadcrumbMessage::Last => {
let last = state.segments.len().saturating_sub(1);
if state.focused_index != last {
state.focused_index = last;
Some(BreadcrumbOutput::FocusChanged(last))
} else {
None
}
}
BreadcrumbMessage::Select => Some(BreadcrumbOutput::Selected(state.focused_index)),
BreadcrumbMessage::SelectIndex(index) => {
if index < state.segments.len() {
Some(BreadcrumbOutput::Selected(index))
} else {
None
}
}
}
}
fn handle_event(
_state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
if let Some(key) = event.as_key() {
match key.code {
Key::Left | Key::Char('h') => Some(BreadcrumbMessage::Left),
Key::Right | Key::Char('l') => Some(BreadcrumbMessage::Right),
Key::Home => Some(BreadcrumbMessage::First),
Key::End => Some(BreadcrumbMessage::Last),
Key::Enter => Some(BreadcrumbMessage::Select),
_ => None,
}
} else {
None
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
if state.segments.is_empty() {
return;
}
let mut spans: Vec<Span> = Vec::new();
let (start, end) = state.visible_range();
if state.is_truncated() {
spans.push(Span::styled("…", ctx.theme.disabled_style()));
spans.push(Span::raw(&state.separator));
}
for seg_idx in start..end {
let segment = &state.segments[seg_idx];
let is_last = seg_idx == state.segments.len() - 1;
let is_focused_segment = ctx.focused && seg_idx == state.focused_index;
let style = if ctx.disabled {
ctx.theme.disabled_style()
} else if is_focused_segment {
ctx.theme
.focused_style()
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else if is_last {
Style::default().add_modifier(Modifier::BOLD)
} else {
ctx.theme.info_style()
};
spans.push(Span::styled(segment.label(), style));
if !is_last {
spans.push(Span::raw(&state.separator));
}
}
let line = Line::from(spans);
let annotation = crate::annotation::Annotation::breadcrumb("breadcrumb")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled);
let annotated = crate::annotation::Annotate::new(Paragraph::new(line), annotation);
ctx.frame.render_widget(annotated, ctx.area);
}
}
#[cfg(test)]
mod tests;