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));
}
if app.show_editor_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),
};
let theme_name = app.theme_name().to_string();
app.left.theme_name = theme_name.clone();
app.right.theme_name = theme_name;
let editor_name = if app.editor == crate::app::Editor::None {
String::new()
} else {
app.editor.label().to_string()
};
app.left.editor_name = editor_name.clone();
app.right.editor_name = editor_name;
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);
}
if app.show_editor_panel {
let panel_area = h_chunks[h_chunks.len() - 1];
render_editor_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.dim)),
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_editor_panel(frame: &mut Frame, area: Rect, app: &App) {
use crate::app::{App as TfeApp, Editor};
let theme = app.theme();
let on_style = Style::default()
.fg(theme.success)
.add_modifier(Modifier::BOLD);
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 editors = TfeApp::all_editors();
let first_ide = TfeApp::first_ide_idx();
let terminal_editors = &editors[..first_ide]; let ide_editors = &editors[first_ide..];
let terminal_cell_h = terminal_editors.len() as u16 + 2;
let ide_cell_h = ide_editors.len() as u16 + 2;
let slots = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Length(1), Constraint::Length(1), Constraint::Length(terminal_cell_h), Constraint::Length(1), Constraint::Length(1), Constraint::Length(ide_cell_h), Constraint::Length(1), Constraint::Length(3), 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 editor_row = |editor: &Editor, idx: usize| -> Line {
let is_highlighted = idx == app.editor_panel_idx;
let is_selected = editor == &app.editor;
let marker = if is_highlighted { "\u{25BA} " } else { " " };
let check = if is_selected { "\u{2713} " } else { " " };
Line::from(vec![
Span::styled(
marker,
Style::default().fg(if is_highlighted {
theme.brand
} else {
theme.dim
}),
),
Span::styled(
check,
if is_selected {
on_style
} else {
subtitle_style
},
),
Span::styled(
format!("{:<width$}", editor.label(), width = 16),
if is_highlighted {
key_style
} else {
label_style
},
),
])
};
let header = Block::default()
.title(Span::styled(" \u{1F4DD} Editor ", title_style))
.title_bottom(Line::from(vec![
Span::styled(" Shift + E ", key_style),
Span::styled("close", 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], "Terminal Editors");
let terminal_rows: Vec<Line> = terminal_editors
.iter()
.enumerate()
.map(|(i, ed)| editor_row(ed, i))
.collect();
let terminal_cell = Paragraph::new(terminal_rows).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.dim)),
);
frame.render_widget(terminal_cell, slots[3]);
section_title(frame, slots[5], "IDEs & GUI Editors");
let ide_rows: Vec<Line> = ide_editors
.iter()
.enumerate()
.map(|(i, ed)| editor_row(ed, first_ide + i))
.collect();
let ide_cell = Paragraph::new(ide_rows).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.dim)),
);
frame.render_widget(ide_cell, slots[6]);
let highlighted_editor = &editors[app.editor_panel_idx];
let footer_text = if *highlighted_editor == Editor::None {
"none — no editor".to_string()
} else {
format!(
"{} → {}",
highlighted_editor.label(),
highlighted_editor.binary().unwrap_or_default()
)
};
let footer = Paragraph::new(footer_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(footer, slots[8]);
}
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(9), 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),
]))
.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(
"Shift + E",
"editor",
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(
"Spc",
"mark",
Span::styled("multi-select", Style::default().fg(theme.accent)),
),
option_row(
"y",
"copy",
Span::styled("yank", Style::default().fg(theme.accent)),
),
option_row(
"x",
"cut",
Span::styled("cut", Style::default().fg(theme.accent)),
),
option_row(
"p",
"paste",
Span::styled("paste", Style::default().fg(theme.accent)),
),
option_row(
"d",
"delete",
Span::styled("delete", Style::default().fg(theme.accent)),
),
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 cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(40),
Constraint::Percentage(35),
Constraint::Percentage(25),
])
.split(area);
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));
let nav_spans = vec![
k("↑"),
d("/"),
k("k"),
d(" up │ "),
k("↓"),
d("/"),
k("j"),
d(" down │ "),
k("→"),
d("/"),
k("l"),
d("/"),
k("Enter"),
d(" open │ "),
k("←"),
d("/"),
k("h"),
d("/"),
k("Bksp"),
d(" back │ "),
k("/"),
d(" search │ "),
k("s"),
d(" sort │ "),
k("."),
d(" hidden │ "),
k("Esc"),
d(" dismiss"),
];
let nav_col = Paragraph::new(Line::from(nav_spans)).block(
Block::default()
.title(Span::styled(" Navigate ", Style::default().fg(theme.dim)))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.dim)),
);
frame.render_widget(nav_col, cols[0]);
let fileops_spans = vec![
k("y"),
d(" copy │ "),
k("x"),
d(" cut │ "),
k("p"),
d(" paste │ "),
k("d"),
d(" del │ "),
k("n"),
d(" mkdir │ "),
k("N"),
d(" touch │ "),
k("r"),
d(" rename │ "),
k("Spc"),
d(" mark"),
];
let fileops_col = Paragraph::new(Line::from(fileops_spans)).block(
Block::default()
.title(Span::styled(" File Ops ", Style::default().fg(theme.dim)))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.dim)),
);
frame.render_widget(fileops_col, cols[1]);
let global_spans = vec![
k("Tab"),
d(" pane │ "),
k("w"),
d(" split │ "),
k("["),
d("/"),
k("t"),
d(" theme │ "),
k("Shift+E"),
d(" editor │ "),
k("Shift+O"),
d(" options"),
];
let global_col = Paragraph::new(Line::from(global_spans)).block(
Block::default()
.title(Span::styled(" Global ", Style::default().fg(theme.dim)))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.dim)),
);
frame.render_widget(global_col, cols[2]);
}
#[cfg(test)]
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(" open │ "),
k("←"),
d("/"),
k("h"),
d("/"),
k("Bksp"),
d(" back │ "),
k("/"),
d(" search │ "),
k("s"),
d(" sort │ "),
k("."),
d(" hidden │ "),
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_color =
if app.status_msg.starts_with("Error") || app.status_msg.starts_with("Delete failed") {
theme.brand
} else {
theme.success
};
let status = if app.status_msg.is_empty() {
" No pending operations".to_string()
} else {
format!(" {}", app.status_msg)
};
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 active_label = match app.active {
Pane::Left => "left",
Pane::Right => "right",
};
let mut right_spans = vec![
Span::styled(" pane: ", Style::default().fg(theme.dim)),
Span::styled(
active_label,
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(" editor: ", Style::default().fg(theme.dim)),
];
if app.editor == crate::app::Editor::None {
right_spans.push(Span::styled("none", Style::default().fg(theme.dim)));
right_spans.push(Span::styled(
" (Shift+E to pick)",
Style::default().fg(theme.dim),
));
} else {
right_spans.push(Span::styled(
format!("\u{270F} {}", app.editor.label()),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
));
}
let right_bar = Paragraph::new(Line::from(right_spans)).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.dim)),
);
frame.render_widget(right_bar, h[1]);
}
#[cfg(test)]
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("w"),
d(" split │ "),
k("["),
d("/"),
k("t"),
d(" theme │ "),
k("Shift+E"),
d(" editor │ "),
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('['), "missing [ hint");
assert!(text.contains('t'), "missing t hint");
assert!(text.contains('w'), "missing w hint");
assert!(text.contains("Shift+E"), "missing Shift+E (editor) hint");
assert!(text.contains("Shift+O"), "missing Shift+O (options) hint");
}
#[test]
fn action_bar_spans_count_is_stable() {
let theme = Theme::default();
let spans = render_action_bar_spans(&theme);
assert_eq!(
spans.len(),
12,
"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", "w", "[", "t", "Shift+E", "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", "w", "[", "t", "Shift+E", "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", "w", "[", "t", "Shift+E", "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", "w", "[", "t", "Shift+E", "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", ".", "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", ".", "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", ".", "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(),
28,
"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"
);
}
}