bea-rs 0.9.0

A file-based task tracker CLI and MCP server for AI agent workflows
Documentation
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};

use super::super::app::{FocusPane, Mode};
use super::super::style::Theme;

pub(in crate::tui) struct BottomBarWidget<'a> {
    mode: &'a Mode,
    focus: &'a FocusPane,
    error_message: Option<&'a str>,
    theme: &'a Theme,
}

impl<'a> BottomBarWidget<'a> {
    pub fn new(
        mode: &'a Mode,
        focus: &'a FocusPane,
        error_message: Option<&'a str>,
        theme: &'a Theme,
    ) -> Self {
        Self {
            mode,
            focus,
            error_message,
            theme,
        }
    }
}

impl Widget for BottomBarWidget<'_> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        // Show error message if present
        if let Some(msg) = self.error_message {
            let bar = Paragraph::new(Line::from(vec![
                Span::styled(
                    " ERROR ",
                    Style::default()
                        .fg(self.theme.bar_error_fg)
                        .bg(self.theme.bar_error_bg),
                ),
                Span::styled(
                    format!(" {msg} "),
                    Style::default().fg(self.theme.bar_error_msg),
                ),
            ]))
            .style(Style::default().bg(self.theme.bar_bg));
            bar.render(area, buf);
            return;
        }

        let hints: Vec<(&str, &str)> = match self.mode {
            Mode::Normal => {
                let nav_hint = match self.focus {
                    FocusPane::List => "navigate",
                    FocusPane::Detail => "scroll",
                };
                vec![
                    ("q", "quit"),
                    ("Tab", "switch pane"),
                    ("j/k", nav_hint),
                    ("e", "edit"),
                    ("c", "create"),
                    ("s", "status"),
                    ("d", "delete"),
                    ("/", "filter"),
                    ("m", "mode"),
                ]
            }
            Mode::StatusSelect { .. } => {
                vec![("Esc", "cancel"), ("j/k", "navigate"), ("Enter", "confirm")]
            }
            Mode::CreateInput { .. } => vec![("Esc", "cancel"), ("Enter", "create")],
            Mode::FilterInput { .. } => vec![("Esc", "cancel"), ("Enter", "apply")],
            Mode::ConfirmDelete { .. } => vec![("y", "confirm delete"), ("any", "cancel")],
        };

        // Build hint spans, truncating when terminal width is exceeded
        let max_width = area.width as usize;
        let mut spans: Vec<Span> = Vec::new();
        let mut used_width: usize = 0;

        for (i, (key, desc)) in hints.iter().enumerate() {
            let key_text = format!(" {key} ");
            let desc_text = format!(" {desc} ");
            let sep_width = if i < hints.len() - 1 { 1 } else { 0 };
            let entry_width = key_text.len() + desc_text.len() + sep_width;

            if used_width + entry_width > max_width {
                break;
            }

            spans.push(Span::styled(
                key_text,
                Style::default()
                    .fg(self.theme.bar_key_fg)
                    .bg(self.theme.bar_key_bg),
            ));
            spans.push(Span::styled(
                desc_text,
                Style::default().fg(self.theme.bar_desc_fg),
            ));
            if i < hints.len() - 1 {
                spans.push(Span::raw(" "));
            }
            used_width += entry_width;
        }

        let bar = Paragraph::new(Line::from(spans)).style(Style::default().bg(self.theme.bar_bg));
        bar.render(area, buf);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normal_mode_shows_quit_hint() {
        let mode = Mode::Normal;
        let focus = FocusPane::List;
        let theme = Theme::default();
        let widget = BottomBarWidget::new(&mode, &focus, None, &theme);

        let area = Rect::new(0, 0, 80, 1);
        let mut buf = Buffer::empty(area);
        widget.render(area, &mut buf);

        let text: String = buf.content().iter().map(|c| c.symbol()).collect::<String>();
        assert!(text.contains("quit"));
        assert!(text.contains("navigate"));
    }

    #[test]
    fn detail_focus_shows_scroll() {
        let mode = Mode::Normal;
        let focus = FocusPane::Detail;
        let theme = Theme::default();
        let widget = BottomBarWidget::new(&mode, &focus, None, &theme);

        let area = Rect::new(0, 0, 80, 1);
        let mut buf = Buffer::empty(area);
        widget.render(area, &mut buf);

        let text: String = buf.content().iter().map(|c| c.symbol()).collect::<String>();
        assert!(text.contains("scroll"));
    }

    #[test]
    fn error_message_displayed() {
        let mode = Mode::Normal;
        let focus = FocusPane::List;
        let theme = Theme::default();
        let widget = BottomBarWidget::new(&mode, &focus, Some("something broke"), &theme);

        let area = Rect::new(0, 0, 80, 1);
        let mut buf = Buffer::empty(area);
        widget.render(area, &mut buf);

        let text: String = buf.content().iter().map(|c| c.symbol()).collect::<String>();
        assert!(text.contains("ERROR"));
        assert!(text.contains("something broke"));
    }
}