use ratatui::{
buffer::Buffer,
layout::Rect,
style::Style,
text::{Line, Span},
widgets::{Paragraph, Widget},
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::config::Theme;
use crate::editor::Mode;
pub struct StatusBarState<'a> {
pub mode: Mode,
pub filename: &'a str,
pub line_count: usize,
pub scroll_total: usize,
pub viewport_rows: usize,
pub modified: bool,
pub scroll: usize,
pub cursor_line: Option<usize>,
pub cursor_col: Option<usize>,
pub section_path: Vec<String>,
pub diff_progress: Option<(usize, usize)>,
pub vim_mode_label: Option<&'a str>,
}
pub struct StatusBar<'a> {
pub state: StatusBarState<'a>,
pub theme: &'a Theme,
}
const SEP_COST: usize = 3;
const MIN_TRUNC_VISIBLE_CELLS: usize = 3;
impl<'a> Widget for StatusBar<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let s = &self.state;
let theme = self.theme;
let bar_style = if matches!(s.mode, Mode::Diff) {
theme.status_bar_diff
} else {
theme.status_bar
};
let bar_bg = if matches!(s.mode, Mode::Diff) {
bar_style.bg
} else {
None
};
let with_bar_bg = |st: Style| match bar_bg {
Some(bg) => st.bg(bg),
None => st,
};
let vim_label = s.vim_mode_label.filter(|_| !matches!(s.mode, Mode::Diff));
let (mode_text, mode_style) = match vim_label {
Some(label) => (format!(" {} ", label), vim_badge_style(theme, label)),
None => (format!(" {} ", s.mode), theme.status_mode_style(s.mode)),
};
let mode_width = UnicodeWidthStr::width(mode_text.as_str());
let mode_span = Span::styled(mode_text, mode_style);
let diff_text = match s.diff_progress {
Some((resolved, total)) => format!(" {}/{} ", resolved, total),
None => String::new(),
};
let diff_width = UnicodeWidthStr::width(diff_text.as_str());
let diff_span = Span::styled(diff_text, theme.status_mode_diff);
let filename_lead = format!(" {}", s.filename);
let filename_width = UnicodeWidthStr::width(filename_lead.as_str());
let filename_span = Span::styled(filename_lead, with_bar_bg(theme.status_filename));
let modified_span = s
.modified
.then(|| Span::styled("*".to_string(), theme.status_modified));
let left_committed_width =
mode_width + diff_width + filename_width + if s.modified { 1 } else { 0 };
let cursor_text = match (s.cursor_line, s.cursor_col) {
(Some(l), Some(c)) => format!(" {}:{} ", l, c),
_ => String::new(),
};
let cursor_width = UnicodeWidthStr::width(cursor_text.as_str());
let cursor_span = Span::styled(cursor_text, with_bar_bg(theme.status_info));
let pct = match s.scroll_total {
0 => 100,
total => {
let visible_end = s.scroll.saturating_add(s.viewport_rows.max(1));
(visible_end.min(total) * 100) / total
}
};
let info_text = format!(" {} lines {}% ", s.line_count, pct);
let info_width = UnicodeWidthStr::width(info_text.as_str());
let info_span = Span::styled(info_text, with_bar_bg(theme.status_info));
let right_width = cursor_width + info_width;
let breadcrumb_budget = (area.width as usize)
.saturating_sub(left_committed_width)
.saturating_sub(right_width)
.saturating_sub(1);
let breadcrumb_segments = fit_breadcrumb(&s.section_path, breadcrumb_budget);
let mut breadcrumb_spans: Vec<Span<'_>> = Vec::with_capacity(breadcrumb_segments.len() * 2);
let mut breadcrumb_width = 0usize;
let last_idx = breadcrumb_segments.len().saturating_sub(1);
for (i, seg) in breadcrumb_segments.iter().enumerate() {
breadcrumb_spans.push(Span::styled(" › ", theme.status_breadcrumb_sep));
breadcrumb_width += SEP_COST + UnicodeWidthStr::width(seg.as_str());
let seg_style = if i == last_idx {
theme.status_breadcrumb_current
} else {
theme.status_breadcrumb_ancestor
};
breadcrumb_spans.push(Span::styled(seg.clone(), seg_style));
}
let gap = (area.width as usize)
.saturating_sub(left_committed_width)
.saturating_sub(breadcrumb_width)
.saturating_sub(right_width);
let gap_span = Span::styled(" ".repeat(gap), bar_style);
let mut spans: Vec<Span<'_>> = Vec::with_capacity(9 + breadcrumb_spans.len());
spans.push(mode_span);
spans.push(diff_span);
spans.push(filename_span);
if let Some(m) = modified_span {
spans.push(m);
}
spans.extend(breadcrumb_spans);
spans.push(gap_span);
spans.push(cursor_span);
spans.push(info_span);
Paragraph::new(Line::from(spans))
.style(bar_style)
.render(area, buf);
}
}
fn vim_badge_style(theme: &Theme, label: &str) -> Style {
match label {
"INSERT" => theme.status_mode_vim_insert,
"VISUAL" | "V-LINE" => theme.status_mode_vim_visual,
_ => theme.status_mode_vim_normal,
}
}
fn fit_breadcrumb(chain: &[String], budget: usize) -> Vec<String> {
let mut included: Vec<String> = Vec::new();
let mut used = 0usize;
for text in chain.iter().rev() {
let text_width = UnicodeWidthStr::width(text.as_str());
let full_cost = SEP_COST + text_width;
if used + full_cost <= budget {
included.push(text.clone());
used += full_cost;
continue;
}
let leftover = budget
.saturating_sub(used)
.saturating_sub(SEP_COST)
.saturating_sub(1);
if leftover >= MIN_TRUNC_VISIBLE_CELLS {
let suffix = last_cells(text, leftover);
if !suffix.is_empty() {
included.push(format!("…{}", suffix));
}
}
break;
}
included.reverse();
included
}
fn last_cells(text: &str, cells: usize) -> String {
let chars: Vec<(char, usize)> = text
.chars()
.map(|c| (c, UnicodeWidthChar::width(c).unwrap_or(0)))
.collect();
let mut taken_width = 0usize;
let mut start_idx = chars.len();
for i in (0..chars.len()).rev() {
let w = chars[i].1;
if taken_width + w > cells {
break;
}
taken_width += w;
start_idx = i;
}
chars[start_idx..].iter().map(|(c, _)| *c).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{backend::TestBackend, Terminal};
fn base_state(mode: Mode, filename: &str) -> StatusBarState<'_> {
StatusBarState {
mode,
filename,
line_count: 10,
scroll_total: 10,
viewport_rows: 1,
modified: false,
scroll: 0,
cursor_line: None,
cursor_col: None,
section_path: Vec::new(),
diff_progress: None,
vim_mode_label: None,
}
}
fn render_bar(state: StatusBarState<'_>, width: u16) -> String {
let theme = Box::leak(Box::new(Theme::default()));
let backend = TestBackend::new(width, 1);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
frame.render_widget(StatusBar { state, theme }, frame.area());
})
.unwrap();
let buf = terminal.backend().buffer().clone();
(0..width)
.map(|x| {
buf.cell((x, 0))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
})
.collect()
}
fn make_bar(mode: Mode, filename: &str, line_count: usize, modified: bool) -> String {
make_bar_with_path(mode, filename, line_count, modified, Vec::new(), 60)
}
fn make_bar_with_path(
mode: Mode,
filename: &str,
line_count: usize,
modified: bool,
section_path: Vec<String>,
width: u16,
) -> String {
render_bar(
StatusBarState {
line_count,
modified,
section_path,
..base_state(mode, filename)
},
width,
)
}
#[test]
fn shows_mode() {
let output = make_bar(Mode::Preview, "test.md", 42, false);
assert!(output.contains("PREVIEW"), "output was: {:?}", output);
}
#[test]
fn diff_badge_outranks_the_vim_sub_mode_badge() {
let output = render_bar(
StatusBarState {
diff_progress: Some((3, 7)),
vim_mode_label: Some("NORMAL"),
..base_state(Mode::Diff, "f.md")
},
60,
);
assert!(
output.contains("DIFF"),
"DIFF badge must win over the vim label, output was: {output:?}"
);
assert!(
!output.contains("NORMAL"),
"vim sub-mode badge leaked into diff mode: {output:?}"
);
assert!(
output.contains("3/7"),
"diff progress must ride beside the badge: {output:?}"
);
}
#[test]
fn vim_badge_still_wins_outside_diff_mode() {
let output = render_bar(
StatusBarState {
vim_mode_label: Some("NORMAL"),
..base_state(Mode::Rendered, "f.md")
},
60,
);
assert!(
output.contains("NORMAL"),
"vim badge must still supersede the mode badge: {output:?}"
);
assert!(
!output.contains("EDIT"),
"rendering-mode badge leaked alongside the vim badge: {output:?}"
);
}
#[test]
fn vim_badge_uses_per_sub_mode_colors() {
let t = Theme::default();
assert_eq!(
super::vim_badge_style(&t, "NORMAL"),
t.status_mode_vim_normal
);
assert_eq!(
super::vim_badge_style(&t, "INSERT"),
t.status_mode_vim_insert
);
assert_eq!(
super::vim_badge_style(&t, "VISUAL"),
t.status_mode_vim_visual
);
assert_eq!(
super::vim_badge_style(&t, "V-LINE"),
t.status_mode_vim_visual
);
}
#[test]
fn shows_filename() {
let output = make_bar(Mode::Preview, "readme.md", 10, false);
assert!(output.contains("readme.md"), "output was: {:?}", output);
}
#[test]
fn shows_line_count() {
let output = make_bar(Mode::Preview, "f.md", 99, false);
assert!(output.contains("99"), "output was: {:?}", output);
}
#[test]
fn line_count_and_percentage_use_separate_counts() {
let output = render_bar(
StatusBarState {
line_count: 6,
scroll_total: 10,
scroll: 9,
..base_state(Mode::Rendered, "f.md")
},
60,
);
assert!(output.contains("6 lines"), "output was: {output:?}");
assert!(output.contains("100%"), "output was: {output:?}");
}
#[test]
fn percentage_reaches_100_when_the_document_end_is_on_screen() {
let output = render_bar(
StatusBarState {
scroll_total: 200,
viewport_rows: 40,
scroll: 160, ..base_state(Mode::Rendered, "f.md")
},
60,
);
assert!(output.contains("100%"), "output was: {output:?}");
}
#[test]
fn percentage_reports_the_viewport_fraction_at_the_top() {
let output = render_bar(
StatusBarState {
scroll_total: 200,
viewport_rows: 40,
scroll: 0,
..base_state(Mode::Rendered, "f.md")
},
60,
);
assert!(output.contains("20%"), "output was: {output:?}");
}
#[test]
fn zero_height_viewport_still_counts_the_top_row() {
let output = render_bar(
StatusBarState {
scroll_total: 10,
viewport_rows: 0,
scroll: 0,
..base_state(Mode::Rendered, "f.md")
},
60,
);
assert!(output.contains("10%"), "output was: {output:?}");
}
#[test]
fn dirty_marker_is_asterisk_glued_to_filename() {
let output = make_bar(Mode::Preview, "f.md", 5, true);
assert!(
output.contains("f.md*"),
"expected `f.md*`, output was: {:?}",
output
);
assert!(
!output.contains("[modified]"),
"stale `[modified]` text leaked: {:?}",
output
);
}
#[test]
fn no_asterisk_when_clean() {
let output = make_bar(Mode::Preview, "f.md", 5, false);
assert!(!output.contains("f.md*"), "output was: {:?}", output);
}
#[test]
fn shows_cursor_position() {
let output = render_bar(
StatusBarState {
cursor_line: Some(3),
cursor_col: Some(7),
..base_state(Mode::Rendered, "f.md")
},
60,
);
assert!(output.contains("3:7"), "output was: {:?}", output);
}
#[test]
fn breadcrumb_renders_full_chain_when_space_allows() {
let output = make_bar_with_path(
Mode::Rendered,
"notes.md",
42,
false,
vec!["Checkpoint 1".to_string(), "Item 1".to_string()],
80,
);
assert!(
output.contains("notes.md › Checkpoint 1 › Item 1"),
"expected full breadcrumb, output was: {:?}",
output
);
}
#[test]
fn breadcrumb_drops_shallowest_when_overlong() {
let output = make_bar_with_path(
Mode::Rendered,
"notes.md",
5,
false,
vec!["Top".to_string(), "Mid".to_string(), "Deep".to_string()],
48,
);
assert!(
output.contains("notes.md › Mid › Deep"),
"expected ancestor drop, output was: {:?}",
output
);
assert!(
!output.contains("Top"),
"shallow segment leaked: {:?}",
output
);
}
#[test]
fn breadcrumb_prefix_truncates_leftmost_when_partial_fit() {
let output = make_bar_with_path(
Mode::Rendered,
"notes.md",
5,
false,
vec!["Checkpoint 1".to_string(), "Item 1".to_string()],
50,
);
assert!(
output.contains('…'),
"expected ellipsis from prefix-truncation, output was: {:?}",
output
);
assert!(
output.contains("Item 1"),
"deepest segment must survive truncation, output was: {:?}",
output
);
}
#[test]
fn fit_breadcrumb_returns_empty_for_no_chain() {
assert!(fit_breadcrumb(&[], 80).is_empty());
}
#[test]
fn fit_breadcrumb_fits_full_chain_when_budget_is_ample() {
let chain = vec!["A".to_string(), "B".to_string(), "C".to_string()];
assert_eq!(fit_breadcrumb(&chain, 80), chain);
}
#[test]
fn fit_breadcrumb_drops_shallowest_first() {
let chain = vec!["Top".to_string(), "Middle".to_string(), "Deep".to_string()];
let fit = fit_breadcrumb(&chain, 16);
assert_eq!(fit, vec!["Middle".to_string(), "Deep".to_string()]);
}
#[test]
fn fit_breadcrumb_prefix_truncates_leftmost_when_partial() {
let chain = vec!["Checkpoint 1".to_string(), "Item 1".to_string()];
let fit = fit_breadcrumb(&chain, 16);
assert_eq!(fit, vec!["…t 1".to_string(), "Item 1".to_string()]);
}
#[test]
fn fit_breadcrumb_drops_when_too_few_visible_chars_remain() {
let chain = vec!["Checkpoint 1".to_string(), "Item 1".to_string()];
let fit = fit_breadcrumb(&chain, 11);
assert_eq!(fit, vec!["Item 1".to_string()]);
}
#[test]
fn fit_breadcrumb_returns_empty_when_deepest_alone_overflows() {
let chain = vec!["A really long heading title".to_string()];
assert!(fit_breadcrumb(&chain, 6).is_empty());
}
#[test]
fn last_cells_returns_full_text_when_budget_ample() {
assert_eq!(last_cells("hello", 10), "hello");
}
#[test]
fn last_cells_returns_suffix_within_budget() {
assert_eq!(last_cells("Checkpoint 1", 3), "t 1");
assert_eq!(last_cells("Checkpoint 1", 5), "int 1");
}
#[test]
fn last_cells_zero_budget_is_empty() {
assert_eq!(last_cells("hi", 0), "");
}
#[test]
fn last_cells_respects_wide_characters() {
assert_eq!(last_cells("漢字 ", 3), "字 ");
}
}