use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph},
Frame,
};
use tui_file_explorer::{render_themed, Theme};
use crate::app::{App, Modal, Pane, Snackbar};
pub fn draw(app: &mut App, frame: &mut Frame) {
let theme = app.theme().clone();
let full = frame.area();
let v_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(6)])
.split(full);
let main_area = v_chunks[0];
let action_area = v_chunks[1];
let action_rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Length(3)])
.split(action_area);
let nav_area = action_rows[0];
let status_area = action_rows[1];
let mut h_constraints = vec![];
if app.single_pane {
h_constraints.push(Constraint::Min(0));
} else {
h_constraints.push(Constraint::Percentage(50));
h_constraints.push(Constraint::Percentage(50));
}
if app.show_theme_panel {
h_constraints.push(Constraint::Length(32));
}
if app.show_options_panel {
h_constraints.push(Constraint::Length(42));
}
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(h_constraints)
.split(main_area);
let active_theme = theme.clone();
let inactive_theme = theme.clone().accent(theme.dim).brand(theme.dim);
let (left_theme, right_theme) = match app.active {
Pane::Left => (&active_theme, &inactive_theme),
Pane::Right => (&inactive_theme, &active_theme),
};
render_themed(&mut app.left, frame, h_chunks[0], left_theme);
if !app.single_pane {
render_themed(&mut app.right, frame, h_chunks[1], right_theme);
}
if app.show_theme_panel {
let panel_area = h_chunks[h_chunks.len() - 1];
render_theme_panel(frame, panel_area, app);
}
if app.show_options_panel {
let panel_area = h_chunks[h_chunks.len() - 1];
render_options_panel(frame, panel_area, app);
}
render_nav_hints(frame, nav_area, &theme);
render_action_bar(frame, status_area, app, &theme);
if let Some(modal) = &app.modal {
render_modal(frame, full, modal, &theme);
}
if app.snackbar.as_ref().is_some_and(|s| s.is_expired()) {
app.snackbar = None;
}
if let Some(snackbar) = &app.snackbar {
render_snackbar(frame, full, snackbar, &theme);
}
}
pub fn render_snackbar(frame: &mut Frame, area: Rect, snackbar: &Snackbar, theme: &Theme) {
let msg = &snackbar.message;
let desired_width = (msg.len() as u16)
.saturating_add(4)
.min(area.width.saturating_sub(4));
let width = desired_width.max(20);
let height = 3u16;
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height + 7);
let snackbar_area = Rect {
x,
y,
width,
height,
};
let border_color = if snackbar.is_error {
theme.brand
} else {
theme.success
};
let text_color = if snackbar.is_error {
theme.brand
} else {
theme.success
};
frame.render_widget(Clear, snackbar_area);
let paragraph = Paragraph::new(Line::from(Span::styled(
format!(" {msg} "),
Style::default().fg(text_color).add_modifier(Modifier::BOLD),
)))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color)),
);
frame.render_widget(paragraph, snackbar_area);
}
pub fn render_theme_panel(frame: &mut Frame, area: Rect, app: &App) {
let theme = app.theme();
let v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(4),
])
.split(area);
let controls = Paragraph::new(Line::from(vec![
Span::styled(" [ ", Style::default().fg(theme.dim)),
Span::styled("prev", Style::default().fg(theme.accent)),
Span::styled(" ", Style::default().fg(theme.dim)),
Span::styled("t ", Style::default().fg(theme.accent)),
Span::styled("next", Style::default().fg(theme.accent)),
]))
.block(
Block::default()
.title(Span::styled(
" \u{1F3A8} Themes ",
Style::default()
.fg(theme.brand)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent)),
);
frame.render_widget(controls, v[0]);
let visible = v[1].height.saturating_sub(2) as usize;
let scroll = if app.theme_idx >= visible {
app.theme_idx - visible + 1
} else {
0
};
let items: Vec<ListItem> = app
.themes
.iter()
.enumerate()
.skip(scroll)
.take(visible)
.map(|(i, (name, _, _))| {
let is_active = i == app.theme_idx;
let marker = if is_active { "\u{25BA} " } else { " " };
let line = Line::from(vec![
Span::styled(
format!("{marker}{:>2}. ", i + 1),
Style::default().fg(if is_active { theme.brand } else { theme.dim }),
),
Span::styled(
name.to_string(),
if is_active {
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
},
),
]);
if is_active {
ListItem::new(line).style(Style::default().bg(theme.sel_bg))
} else {
ListItem::new(line)
}
})
.collect();
let mut list_state = ListState::default();
list_state.select(Some(app.theme_idx.saturating_sub(scroll)));
let list = List::new(items).block(
Block::default()
.borders(Borders::LEFT | Borders::RIGHT)
.border_style(Style::default().fg(theme.accent)),
);
frame.render_stateful_widget(list, v[1], &mut list_state);
let desc_text = format!("{}\n{}", app.theme_name(), app.theme_desc());
let desc = Paragraph::new(desc_text)
.style(Style::default().fg(theme.success))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent)),
);
frame.render_widget(desc, v[2]);
}
pub fn render_options_panel(frame: &mut Frame, area: Rect, app: &App) {
let theme = app.theme();
let on_style = Style::default()
.fg(theme.success)
.add_modifier(Modifier::BOLD);
let off_style = Style::default().fg(theme.dim);
let key_style = Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD);
let label_style = Style::default().fg(theme.fg);
let subtitle_style = Style::default().fg(theme.dim);
let title_style = Style::default()
.fg(theme.brand)
.add_modifier(Modifier::BOLD);
let slots = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Length(1), Constraint::Length(1), Constraint::Length(5), Constraint::Length(1), Constraint::Length(1), Constraint::Length(3), Constraint::Length(1), Constraint::Length(1), Constraint::Length(5), Constraint::Min(0), ])
.split(area);
let section_title = |frame: &mut Frame, slot: Rect, label: &str| {
let dashes = "─".repeat((slot.width as usize).saturating_sub(label.len() + 2));
let para = Paragraph::new(Line::from(vec![
Span::styled(format!(" {label} "), subtitle_style),
Span::styled(dashes, subtitle_style),
]));
frame.render_widget(para, slot);
};
let option_row = |key: &str, label: &str, value: Span<'static>| -> Line {
Line::from(vec![
Span::raw(" "),
Span::styled(format!("{key:<12}"), key_style),
Span::styled(format!("{label:<14}"), label_style),
value,
])
};
let bool_span = |enabled: bool| -> Span {
if enabled {
Span::styled("● on ", on_style)
} else {
Span::styled("○ off", off_style)
}
};
let header = Block::default()
.title(Span::styled(" ⚙ Options ", title_style))
.title_bottom(Line::from(vec![
Span::styled(" Shift + O ", key_style),
Span::styled("close ", subtitle_style),
Span::styled("Shift + C ", key_style),
Span::styled("toggle cd", subtitle_style),
]))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent));
frame.render_widget(header, slots[0]);
section_title(frame, slots[2], "Toggles");
let toggles_rows = vec![
option_row("Shift + C", "cd on exit", bool_span(app.cd_on_exit)),
option_row("w", "single pane", bool_span(app.single_pane)),
option_row("Shift + T", "theme panel", bool_span(app.show_theme_panel)),
];
let toggles_cell = Paragraph::new(toggles_rows).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.dim)),
);
frame.render_widget(toggles_cell, slots[3]);
section_title(frame, slots[5], "Editor");
let editor_label = app.editor.label().to_string();
let editor_val_style = if app.editor == crate::app::Editor::None {
off_style
} else {
Style::default()
.fg(theme.success)
.add_modifier(Modifier::BOLD)
};
let editor_rows = vec![option_row(
"e",
"open with",
Span::styled(editor_label, editor_val_style),
)];
let editor_cell = Paragraph::new(editor_rows).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.dim)),
);
frame.render_widget(editor_cell, slots[6]);
section_title(frame, slots[8], "File Ops");
let fileops_rows = vec![
option_row(
"n",
"new folder",
Span::styled("mkdir", Style::default().fg(theme.accent)),
),
option_row(
"N",
"new file",
Span::styled("touch", Style::default().fg(theme.accent)),
),
option_row(
"r",
"rename",
Span::styled("rename", Style::default().fg(theme.accent)),
),
];
let fileops_cell = Paragraph::new(fileops_rows).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.dim)),
);
frame.render_widget(fileops_cell, slots[9]);
}
pub fn render_nav_hints(frame: &mut Frame, area: Rect, theme: &Theme) {
let hints = Line::from(render_nav_hints_spans(theme));
let nav_bar = Paragraph::new(hints).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.dim)),
);
frame.render_widget(nav_bar, area);
}
pub fn render_nav_hints_spans(theme: &Theme) -> Vec<Span<'_>> {
let k = |s: &'static str| {
Span::styled(
s,
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)
};
let d = |s: &'static str| Span::styled(s, Style::default().fg(theme.dim));
vec![
k("↑"),
d("/"),
k("k"),
d(" up "),
k("↓"),
d("/"),
k("j"),
d(" down "),
k("→"),
d("/"),
k("l"),
d("/"),
k("Enter"),
d(" confirm "),
k("←"),
d("/"),
k("h"),
d("/"),
k("Bksp"),
d(" ascend "),
k("/"),
d(" search "),
k("s"),
d(" sort "),
k("."),
d(" hidden "),
k("n"),
d(" mkdir "),
k("N"),
d(" touch "),
k("r"),
d(" rename "),
k("Esc"),
d(" dismiss"),
]
}
pub fn render_action_bar(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let h = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
if let Some(clip) = &app.clipboard {
let name = clip.path.file_name().unwrap_or_default().to_string_lossy();
let line = Line::from(vec![
Span::styled(
format!(" {} {}: ", clip.icon(), clip.label()),
Style::default()
.fg(theme.brand)
.add_modifier(Modifier::BOLD),
),
Span::styled(
name.to_string(),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
]);
let left_bar = Paragraph::new(line).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.brand)),
);
frame.render_widget(left_bar, h[0]);
} else {
let status = if app.status_msg.is_empty() {
let active = match app.active {
Pane::Left => "left",
Pane::Right => "right",
};
format!(" Active pane: {active}")
} else {
format!(" {}", app.status_msg)
};
let status_color =
if app.status_msg.starts_with("Error") || app.status_msg.starts_with("Delete failed") {
theme.brand
} else {
theme.success
};
let left_bar = Paragraph::new(Span::styled(status, Style::default().fg(status_color)))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.dim)),
);
frame.render_widget(left_bar, h[0]);
}
let hints = Line::from(render_action_bar_spans(theme));
let right_bar = Paragraph::new(hints).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.dim)),
);
frame.render_widget(right_bar, h[1]);
}
pub fn render_action_bar_spans(theme: &Theme) -> Vec<Span<'_>> {
let k = |s: &'static str| {
Span::styled(
s,
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)
};
let d = |s: &'static str| Span::styled(s, Style::default().fg(theme.dim));
vec![
k("Tab"),
d(" pane "),
k("Spc"),
d(" mark "),
k("y"),
d(" copy "),
k("x"),
d(" cut "),
k("p"),
d(" paste "),
k("d"),
d(" del "),
k("e"),
d(" edit "),
k("n"),
d(" mkdir "),
k("N"),
d(" touch "),
k("r"),
d(" rename "),
k("["),
d("/"),
k("t"),
d(" theme "),
k("w"),
d(" split "),
k("Shift + O"),
d(" options"),
]
}
pub fn render_modal(frame: &mut Frame, area: Rect, modal: &Modal, theme: &Theme) {
if let Modal::MultiDelete { paths } = modal {
let count = paths.len();
const MAX_SHOWN: usize = 6;
let shown: Vec<&std::path::PathBuf> = paths.iter().take(MAX_SHOWN).collect();
let remainder = count.saturating_sub(MAX_SHOWN);
let max_name_len = shown
.iter()
.map(|p| p.file_name().unwrap_or_default().to_string_lossy().len())
.max()
.unwrap_or(0);
let w = (max_name_len as u16 + 8)
.max(44)
.min(area.width.saturating_sub(4));
let list_rows = shown.len() + if remainder > 0 { 1 } else { 0 };
let h = (list_rows as u16 + 5).min(area.height.saturating_sub(2));
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let modal_area = Rect::new(x, y, w, h);
frame.render_widget(Clear, modal_area);
let outer = Block::default()
.title(Span::styled(
" Confirm Multi-Delete ",
Style::default()
.fg(theme.brand)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(Style::default().fg(theme.brand));
frame.render_widget(outer, modal_area);
let v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.margin(1)
.split(modal_area);
let summary = Paragraph::new(Span::styled(
format!("Delete {count} item(s)?"),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
))
.alignment(Alignment::Center);
frame.render_widget(summary, v[0]);
let mut name_lines: Vec<Line> = shown
.iter()
.map(|p| {
let name = p.file_name().unwrap_or_default().to_string_lossy();
Line::from(vec![
Span::styled(" ◆ ", Style::default().fg(theme.brand)),
Span::styled(name.to_string(), Style::default().fg(theme.accent)),
])
})
.collect();
if remainder > 0 {
name_lines.push(Line::from(Span::styled(
format!(" … and {remainder} more"),
Style::default().fg(theme.dim),
)));
}
let list_para = Paragraph::new(name_lines);
frame.render_widget(list_para, v[1]);
let hint_para = Paragraph::new(Line::from(vec![
Span::styled(
" y",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(" confirm ", Style::default().fg(theme.dim)),
Span::styled(
"any key",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(" cancel ", Style::default().fg(theme.dim)),
]))
.alignment(Alignment::Center);
frame.render_widget(hint_para, v[2]);
return;
}
let (title, body) = match modal {
Modal::Delete { path } => (
" Confirm Delete ",
format!(
"Delete '{}' ?",
path.file_name().unwrap_or_default().to_string_lossy()
),
),
Modal::Overwrite { dst, .. } => (
" Confirm Overwrite ",
format!(
"'{}' already exists. Overwrite?",
dst.file_name().unwrap_or_default().to_string_lossy()
),
),
Modal::MultiDelete { .. } => unreachable!(),
};
let w = (body.len() as u16 + 6).max(40).min(area.width - 4);
let h = 7u16;
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let modal_area = Rect::new(x, y, w, h);
frame.render_widget(Clear, modal_area);
let v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Min(0),
Constraint::Length(2),
])
.margin(1)
.split(modal_area);
let outer = Block::default()
.title(Span::styled(
title,
Style::default()
.fg(theme.brand)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(Style::default().fg(theme.brand));
frame.render_widget(outer, modal_area);
let body_para = Paragraph::new(Span::styled(
body,
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
))
.alignment(Alignment::Center);
frame.render_widget(body_para, v[0]);
let hint_para = Paragraph::new(Line::from(vec![
Span::styled(
" y",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(" confirm ", Style::default().fg(theme.dim)),
Span::styled(
"any key",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(" cancel ", Style::default().fg(theme.dim)),
]))
.alignment(Alignment::Center);
frame.render_widget(hint_para, v[2]);
}
#[cfg(test)]
mod tests {
use super::*;
use tui_file_explorer::Theme;
#[test]
fn action_bar_spans_contains_expected_key_labels() {
let theme = Theme::default();
let spans = render_action_bar_spans(&theme);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("Tab"), "missing Tab hint");
assert!(text.contains('y'), "missing y hint");
assert!(text.contains('x'), "missing x hint");
assert!(text.contains('p'), "missing p hint");
assert!(text.contains('d'), "missing d hint");
assert!(text.contains('['), "missing [ hint");
assert!(text.contains('t'), "missing t hint");
assert!(text.contains("Spc"), "missing Spc hint");
assert!(text.contains('w'), "missing w hint");
}
#[test]
fn action_bar_spans_count_is_stable() {
let theme = Theme::default();
let spans = render_action_bar_spans(&theme);
assert_eq!(
spans.len(),
28,
"span count changed — update this test if the action bar was intentionally modified"
);
}
#[test]
fn action_bar_spans_key_spans_are_bold() {
let theme = Theme::default();
let spans = render_action_bar_spans(&theme);
let key_labels = [
"Tab",
"Spc",
"y",
"x",
"p",
"d",
"e",
"n",
"N",
"r",
"[",
"t",
"w",
"Shift + O",
];
for label in key_labels {
let span = spans
.iter()
.find(|s| s.content.as_ref() == label)
.unwrap_or_else(|| panic!("span for key '{label}' not found"));
assert!(
span.style.add_modifier.contains(Modifier::BOLD),
"key span '{label}' should be bold"
);
}
}
#[test]
fn action_bar_spans_description_spans_are_not_bold() {
let theme = Theme::default();
let spans = render_action_bar_spans(&theme);
let key_labels = [
"Tab",
"Spc",
"y",
"x",
"p",
"d",
"e",
"n",
"N",
"r",
"[",
"t",
"w",
"Shift + O",
];
for span in &spans {
if !key_labels.contains(&span.content.as_ref()) {
assert!(
!span.style.add_modifier.contains(Modifier::BOLD),
"description span '{}' should not be bold",
span.content
);
}
}
}
#[test]
fn action_bar_spans_key_spans_use_accent_colour() {
let theme = Theme::default();
let spans = render_action_bar_spans(&theme);
let key_labels = [
"Tab",
"Spc",
"y",
"x",
"p",
"d",
"e",
"n",
"N",
"r",
"[",
"t",
"w",
"Shift + O",
];
for label in key_labels {
let span = spans
.iter()
.find(|s| s.content.as_ref() == label)
.unwrap_or_else(|| panic!("span for key '{label}' not found"));
assert_eq!(
span.style.fg,
Some(theme.accent),
"key span '{label}' should use the accent colour"
);
}
}
#[test]
fn action_bar_spans_description_spans_use_dim_colour() {
let theme = Theme::default();
let spans = render_action_bar_spans(&theme);
let key_labels = [
"Tab",
"Spc",
"y",
"x",
"p",
"d",
"e",
"n",
"N",
"r",
"[",
"t",
"w",
"Shift + O",
];
for span in &spans {
if !key_labels.contains(&span.content.as_ref()) {
assert_eq!(
span.style.fg,
Some(theme.dim),
"description span '{}' should use the dim colour",
span.content
);
}
}
}
#[test]
fn nav_hints_spans_contain_arrow_keys() {
let theme = Theme::default();
let spans = render_nav_hints_spans(&theme);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains('k'), "missing k (up)");
assert!(text.contains('j'), "missing j (down)");
assert!(text.contains('h'), "missing h (ascend)");
assert!(text.contains('l'), "missing l (confirm)");
assert!(text.contains("Enter"), "missing Enter");
assert!(text.contains("Bksp"), "missing Bksp");
}
#[test]
fn nav_hints_spans_contain_search_and_sort() {
let theme = Theme::default();
let spans = render_nav_hints_spans(&theme);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains('/'), "missing / (search)");
assert!(text.contains('s'), "missing s (sort)");
assert!(text.contains('.'), "missing . (hidden)");
assert!(text.contains("Esc"), "missing Esc (dismiss)");
}
#[test]
fn nav_hints_key_spans_are_bold() {
let theme = Theme::default();
let spans = render_nav_hints_spans(&theme);
let key_labels = [
"↑", "k", "↓", "j", "→", "l", "Enter", "←", "h", "Bksp", "s", ".", "n", "N", "Esc",
];
for label in key_labels {
let span = spans
.iter()
.find(|s| s.content.as_ref() == label)
.unwrap_or_else(|| panic!("nav hint span for '{label}' not found"));
assert!(
span.style.add_modifier.contains(Modifier::BOLD),
"nav key span '{label}' should be bold"
);
}
let slash_bold = spans
.iter()
.any(|s| s.content.as_ref() == "/" && s.style.add_modifier.contains(Modifier::BOLD));
assert!(slash_bold, "the search '/' key span should be bold");
}
#[test]
fn nav_hints_key_spans_use_accent_colour() {
let theme = Theme::default();
let spans = render_nav_hints_spans(&theme);
let key_labels = [
"↑", "k", "↓", "j", "Enter", "Bksp", "s", ".", "n", "N", "Esc",
];
for label in key_labels {
let span = spans
.iter()
.find(|s| s.content.as_ref() == label)
.unwrap_or_else(|| panic!("nav hint span for '{label}' not found"));
assert_eq!(
span.style.fg,
Some(theme.accent),
"nav key span '{label}' should use the accent colour"
);
}
let slash_accent = spans.iter().any(|s| {
s.content.as_ref() == "/"
&& s.style.add_modifier.contains(Modifier::BOLD)
&& s.style.fg == Some(theme.accent)
});
assert!(
slash_accent,
"the search '/' key span should use the accent colour"
);
}
#[test]
fn nav_hints_description_spans_use_dim_colour() {
let theme = Theme::default();
let spans = render_nav_hints_spans(&theme);
let key_labels = [
"↑", "k", "↓", "j", "→", "l", "Enter", "←", "h", "Bksp", "s", ".", "n", "N", "r", "Esc",
];
for span in &spans {
let content = span.content.as_ref();
if key_labels.contains(&content) || content == "/" {
continue;
}
assert_eq!(
span.style.fg,
Some(theme.dim),
"nav description span '{}' should use the dim colour",
span.content
);
}
}
#[test]
fn nav_hints_span_count_is_stable() {
let theme = Theme::default();
let spans = render_nav_hints_spans(&theme);
assert_eq!(
spans.len(),
34,
"nav hint span count changed — update this test if the nav bar was intentionally modified"
);
}
fn make_snackbar(message: &str, is_error: bool) -> Snackbar {
use std::time::{Duration, Instant};
Snackbar {
message: message.to_string(),
expires_at: Instant::now() + Duration::from_secs(10),
is_error,
}
}
#[test]
fn snackbar_geometry_height_is_three() {
let height: u16 = 3;
assert_eq!(height, 3);
}
#[test]
fn snackbar_info_uses_success_colour() {
let theme = Theme::default();
let sb = make_snackbar("info message", false);
let expected = theme.success;
let actual = if sb.is_error {
theme.brand
} else {
theme.success
};
assert_eq!(actual, expected, "info snackbar should use success colour");
}
#[test]
fn snackbar_error_uses_brand_colour() {
let theme = Theme::default();
let sb = make_snackbar("error message", true);
let expected = theme.brand;
let actual = if sb.is_error {
theme.brand
} else {
theme.success
};
assert_eq!(actual, expected, "error snackbar should use brand colour");
}
#[test]
fn snackbar_info_and_error_colours_are_distinct() {
let theme = Theme::default();
assert_ne!(
theme.success, theme.brand,
"success and brand colours must differ for snackbar colour tests to be meaningful"
);
}
#[test]
fn snackbar_message_is_preserved() {
let msg = "No editor set — open Options (Shift + O) and press e to pick one";
let sb = make_snackbar(msg, true);
assert_eq!(sb.message, msg);
}
#[test]
fn snackbar_width_at_least_minimum() {
let msg = "hi"; let area_width: u16 = 200;
let desired = (msg.len() as u16)
.saturating_add(4)
.min(area_width.saturating_sub(4));
let width = desired.max(20);
assert!(width >= 20, "snackbar width must be at least 20 columns");
}
#[test]
fn snackbar_width_capped_to_area() {
let msg = "a".repeat(300);
let area_width: u16 = 120;
let desired = (msg.len() as u16)
.saturating_add(4)
.min(area_width.saturating_sub(4));
let width = desired.max(20);
assert!(
width <= area_width,
"snackbar must not exceed the terminal width"
);
}
#[test]
fn snackbar_is_not_expired_when_fresh() {
let sb = make_snackbar("fresh", false);
assert!(
!sb.is_expired(),
"a newly created snackbar must not be expired"
);
}
#[test]
fn snackbar_is_expired_after_deadline() {
use std::time::{Duration, Instant};
let sb = Snackbar {
message: "old".into(),
expires_at: Instant::now() - Duration::from_millis(1),
is_error: false,
};
assert!(
sb.is_expired(),
"snackbar past its deadline must be expired"
);
}
}