use crate::app::{AppState, AttributeKind, AttributesForm, Dialog, FindFiles, PromptKind, Screen, TextInput, TransferKind};
use crate::constants::*;
use crate::display::{palette, tab_width};
use crate::options::{Clock, DateFormat, IconStyle, OPTION_ROWS, Options, SortKey};
use crate::strip::{ARROW, Strip, StripHit};
use crate::utils::*;
use crate::viewer::ViewMode;
use chrono::Local;
use ratatui::{
Terminal,
backend::Backend,
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::{Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, TableState},
};
use std::path::{Path, PathBuf};
use unicode_width::UnicodeWidthChar;
fn style_border() -> Style {
Style::new().fg(palette().border)
}
fn style_title() -> Style {
Style::new().fg(palette().title)
}
fn style_columns() -> Style {
Style::new().fg(palette().columns)
}
fn style_file() -> Style {
Style::new().fg(palette().file)
}
fn style_dir() -> Style {
Style::new().fg(palette().directory)
}
fn style_dir_dark() -> Style {
Style::new().fg(palette().directory_dark)
}
fn style_input() -> Style {
Style::new().fg(palette().selected_foreground).bg(palette().rename_background).add_modifier(Modifier::BOLD)
}
fn style_input_cursor() -> Style {
style_input().add_modifier(FIELD_CURSOR)
}
const FIELD_CURSOR: Modifier = Modifier::SLOW_BLINK;
const EDITOR_CURSOR: Modifier = Modifier::RAPID_BLINK;
fn style_selection() -> Style {
Style::new().bg(palette().selected_background_inactive)
}
fn style_match() -> Style {
Style::new().add_modifier(Modifier::UNDERLINED | Modifier::BOLD)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Column {
Ext,
Size,
Modified,
Attributes,
}
const OPTIONAL_COLUMNS: [Column; 4] = [Column::Ext, Column::Size, Column::Modified, Column::Attributes];
const MIN_NAME_WIDTH: u16 = 12;
impl Column {
fn title(self) -> &'static str {
match self {
Column::Ext => "Ext",
Column::Size => "Size",
Column::Modified => "Modified",
Column::Attributes => "Attributes",
}
}
fn sort_key(self) -> Option<SortKey> {
match self {
Column::Ext => Some(SortKey::Extension),
Column::Size => Some(SortKey::Size),
Column::Modified => Some(SortKey::Modified),
Column::Attributes => None,
}
}
fn width(self, options: &Options) -> u16 {
match self {
Column::Ext => 5,
Column::Size => 8,
Column::Modified => match options.date_format {
DateFormat::Short => 14,
DateFormat::Iso => 16,
DateFormat::Relative => 11,
},
Column::Attributes => 10,
}
}
fn enabled(self, options: &Options) -> bool {
match self {
Column::Ext => options.column_ext,
Column::Size => options.column_size,
Column::Modified => options.column_modified,
Column::Attributes => options.column_attributes,
}
}
}
fn visible_columns(options: &Options, panel_width: u16) -> Vec<Column> {
let mut used = 3; let mut columns = Vec::with_capacity(OPTIONAL_COLUMNS.len());
for column in OPTIONAL_COLUMNS.into_iter().filter(|column| column.enabled(options)) {
let next = used + column.width(options) + 3;
if panel_width.saturating_sub(next) < MIN_NAME_WIDTH {
break;
}
used = next;
columns.push(column);
}
columns
}
fn name_width(options: &Options, panel_width: u16) -> u16 {
let mut used = 3; for column in visible_columns(options, panel_width) {
used += column.width(options) + 3;
}
panel_width.saturating_sub(used + 1) }
pub fn render_ui<B: Backend>(terminal: &mut Terminal<B>, app_state: &mut AppState) {
let current_time = match app_state.options.clock {
Clock::Hours24 => Local::now().format(" %H:%M:%S ").to_string(),
Clock::Hours12 => Local::now().format(" %I:%M:%S %p ").to_string(),
Clock::Off => String::new(),
};
if app_state.cached_clock != current_time {
app_state.cached_clock = current_time;
}
crate::display::set_classic_cursor(app_state.options.cursor == crate::options::CursorShape::Classic);
let _ = terminal.draw(|f| {
draw_frame(f, app_state);
show_cursor(f, app_state);
});
}
fn show_cursor(f: &mut ratatui::Frame<'_>, app_state: &AppState) {
let classic = app_state.options.cursor == crate::options::CursorShape::Classic;
let buffer = f.buffer_mut();
let (mut field, mut editor) = (None, None);
for y in buffer.area.top()..buffer.area.bottom() {
for x in buffer.area.left()..buffer.area.right() {
let cell = &mut buffer[(x, y)];
if cell.modifier.contains(crate::app::PAST_END) {
cell.modifier.remove(crate::app::PAST_END);
if !classic && x + 1 < buffer.area.right() {
let after = buffer[(x + 1, y)].bg;
buffer[(x, y)].set_bg(after);
}
}
let cell = &mut buffer[(x, y)];
if cell.modifier.contains(crate::app::CURSOR_AFTER) {
cell.modifier.remove(crate::app::CURSOR_AFTER);
let width = display_width(cell.symbol()).max(1) as u16;
field.get_or_insert(ratatui::layout::Position::new((x + width).min(buffer.area.right() - 1), y));
}
let cell = &mut buffer[(x, y)];
if cell.modifier.contains(FIELD_CURSOR) {
cell.modifier.remove(FIELD_CURSOR);
field.get_or_insert(ratatui::layout::Position::new(x, y));
if classic {
cell.modifier.insert(Modifier::REVERSED);
}
}
if cell.modifier.contains(EDITOR_CURSOR) {
cell.modifier.remove(EDITOR_CURSOR);
editor.get_or_insert(ratatui::layout::Position::new(x, y));
if classic {
cell.set_fg(palette().selected_foreground).set_bg(palette().selected_background);
}
}
}
}
if !classic
&& app_state.job.is_none()
&& app_state.error.is_none()
&& let Some(position) = field.or(editor)
{
f.set_cursor_position(position);
}
}
fn draw_frame(f: &mut ratatui::Frame<'_>, app_state: &mut AppState) {
{
let area = f.area();
paint_background(f, area);
if area.height < 10 || area.width < 30 {
let msg = Paragraph::new("Terminal too small").alignment(Alignment::Center).style(style_title());
let y = area.height / 2;
if y < area.height {
f.render_widget(msg, Rect::new(area.x, area.y + y, area.width, 1));
}
return;
}
let chunks_main = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Length(1), Constraint::Percentage(100), Constraint::Length(1), Constraint::Length(3)])
.split(area);
render_top_panel(f, chunks_main[0], app_state);
let going_to = match &app_state.dialog {
Some(Dialog::Prompt(PromptKind::GoTo, input)) => Some(input),
_ => None,
};
render_path_bar(f, chunks_main[1], app_state, going_to);
app_state.path_bar_area = chunks_main[1];
match app_state.screen {
Screen::Viewer(_) => {
let (height, width, content_area) = render_viewer(f, chunks_main[2], app_state);
app_state.viewer_viewport_height = height;
app_state.viewer_viewport_width = width;
app_state.viewer_content_area = content_area;
}
Screen::Editor(_) => app_state.editor_viewport_height = render_editor(f, chunks_main[2], app_state),
Screen::Diff(_) => {
let (height, width) = render_diff(f, chunks_main[2], app_state);
app_state.diff_viewport_height = height;
app_state.diff_viewport_width = width;
}
Screen::Panels => app_state.page_size = render_file_tables(f, chunks_main[2], app_state),
}
render_bottom_panel(f, chunks_main[3], app_state);
render_fkey_bar(f, chunks_main[4], app_state);
render_detail(f, chunks_main[4].inner(Margin { vertical: 0, horizontal: 2 }), app_state);
let mut find_files_page = None;
let mut attribute_rows = Vec::new();
match &app_state.dialog {
Some(Dialog::Help) => render_help_popup(f, area),
Some(Dialog::Options { editing }) => render_options_popup(f, area, app_state, editing.as_ref()),
Some(Dialog::Create { is_dir, input }) => render_create_popup(f, area, *is_dir, input),
Some(Dialog::Delete { items, to_trash }) => render_delete_popup(f, area, items, *to_trash),
Some(Dialog::Transfer { sources, is_copy, input, .. }) => render_destination_popup(f, area, sources, if *is_copy { "Copy" } else { "Move" }, "to", input),
Some(Dialog::Symlink { sources, input, .. }) => render_destination_popup(f, area, sources, "Symlink", "at", input),
Some(Dialog::LargeFile(large)) => render_large_file_popup(f, area, large),
Some(Dialog::SaveChanges { .. }) => render_editor_save_popup(f, area),
Some(Dialog::DiskChanged { opened, now, .. }) => render_disk_changed_popup(f, area, app_state, *opened, *now),
Some(Dialog::FindFiles(find)) => match find.as_ref() {
FindFiles::Asking { name, containing, on_containing } => {
let start = if app_state.is_left_active { &app_state.dir_left } else { &app_state.dir_right };
render_find_files_form(f, area, start, name, containing, *on_containing);
}
FindFiles::Found(search) => find_files_page = Some(render_find_files_results(f, area, search)),
},
Some(Dialog::Attributes(form)) => attribute_rows = render_attributes_popup(f, area, form),
Some(Dialog::Bookmarks { list, index }) => render_bookmarks_popup(f, area, list, *index, &app_state.panel_dir(app_state.is_left_active)),
Some(Dialog::Rename(_) | Dialog::Prompt(..) | Dialog::DrivePicker { .. } | Dialog::Replacing { .. }) | None => {}
}
if let Some(page) = find_files_page {
app_state.find_files_page = page;
}
app_state.attribute_rows = attribute_rows;
if app_state.job.is_some() {
render_transfer_popup(f, area, app_state);
} else if let Some(message) = &app_state.error {
render_error_popup(f, area, message);
}
}
}
fn shown_name(path: &std::path::Path) -> String {
path.file_name().map(|name| printable_name(&name.to_string_lossy())).unwrap_or_default()
}
fn paint_background(f: &mut ratatui::Frame<'_>, area: Rect) {
if let Some(background) = crate::display::background() {
f.render_widget(Block::new().style(Style::new().bg(background)), area);
}
}
fn clear(f: &mut ratatui::Frame<'_>, area: Rect) {
f.render_widget(Clear, area);
paint_background(f, area);
}
fn panel_split(area: Rect) -> std::rc::Rc<[Rect]> {
Layout::default().direction(Direction::Horizontal).constraints([Constraint::Percentage(50), Constraint::Length(1), Constraint::Percentage(50)]).split(area)
}
fn mount_icon(kind: crate::fs_ops::MountKind, style: IconStyle) -> &'static str {
use crate::fs_ops::MountKind;
if style == IconStyle::Plain {
return match kind {
MountKind::Home => "~",
MountKind::Disk => "D",
MountKind::Removable => "U",
MountKind::Network => "N",
MountKind::Optical => "O",
};
}
match kind {
MountKind::Home => ICON_HOME,
MountKind::Disk => ICON_DRIVE,
MountKind::Removable => ICON_REMOVABLE,
MountKind::Network => ICON_NETWORK,
MountKind::Optical => ICON_OPTICAL,
}
}
fn row_icon(is_dir: bool, style: IconStyle) -> &'static str {
match (style, is_dir) {
(IconStyle::Plain, true) => "/",
(IconStyle::Plain, false) => " ",
(_, true) => ICON_FOLDER,
(_, false) => ICON_FILE,
}
}
fn logo_icon(style: IconStyle) -> &'static str {
if style == IconStyle::Plain { "84" } else { ICON_LOGO }
}
fn lay_strip(strip: &mut Strip, area: Rect, items: Vec<Span<'static>>, focus: Option<usize>) -> Vec<Span<'static>> {
let widths: Vec<usize> = items.iter().map(|item| display_width(&item.content)).collect();
let room = area.width as usize;
let fits = Strip::fits(&widths, room);
let count = items.len();
strip.area = area;
strip.place(widths, room, focus);
let (first, visible) = (strip.first, strip.visible);
let mut spans = Vec::with_capacity(visible + 2);
let mut slots = Vec::with_capacity(visible + 2);
let mut hits = Vec::with_capacity(visible + 2);
let mut column;
if fits {
spans.push(Span::raw(" "));
column = 1; } else {
let back = first > 0;
spans.push(Span::styled(if back { " <" } else { " " }, style_dir_dark()));
if back {
slots.push((0, ARROW as u16));
hits.push(StripHit::Back);
}
column = ARROW as u16;
}
for (index, item) in items.into_iter().enumerate().skip(first).take(visible) {
let width = display_width(&item.content) as u16;
slots.push((column, width));
hits.push(StripHit::Item(index));
column += width;
spans.push(item);
}
if !fits {
let forward = first + visible < count;
spans.push(Span::styled(if forward { "> " } else { " " }, style_dir_dark()));
if forward {
slots.push((column, ARROW as u16));
hits.push(StripHit::Forward);
}
}
strip.slots = slots;
strip.hits = hits;
spans
}
fn drive_strip(app_state: &mut AppState, is_left: bool, area: Rect) -> Line<'static> {
let current = app_state.current_mount(is_left);
let picking = match app_state.dialog {
Some(Dialog::DrivePicker { is_left: side, index }) if side == is_left => Some(index),
_ => None,
};
let icon_style = app_state.options.icon_style;
let spill = if icon_style.is_wide() { " " } else { "" };
let items = app_state
.mounts
.iter()
.enumerate()
.map(|(index, mount)| {
let style = if Some(index) == picking {
Style::new().fg(palette().selected_foreground).bg(palette().selected_background)
} else if Some(index) == current {
Style::new().fg(palette().selected_foreground).bg(palette().selected_background_inactive)
} else {
style_dir_dark()
};
Span::styled(format!(" {} {}", mount_icon(mount.kind, icon_style), spill), style)
})
.collect();
let strip = if is_left { &mut app_state.drive_strip_left } else { &mut app_state.drive_strip_right };
let mut spans = lay_strip(strip, area, items, picking.or(current));
if let Some(mount) = picking.or(current).and_then(|index| app_state.mounts.get(index)) {
spans.push(Span::styled(format!(" {}", printable_name(&mount.label)), style_columns()));
}
Line::from(spans)
}
fn tab_label(dir: &Path) -> String {
const LONGEST: usize = 16;
let name = dir.file_name().map_or_else(|| dir.display().to_string(), |name| name.to_string_lossy().into_owned());
let name = printable_name(&name);
if display_width(&name) <= LONGEST {
return name;
}
let mut width = 0;
let kept: String = name
.chars()
.take_while(|&character| {
width += UnicodeWidthChar::width(character).unwrap_or(0);
width < LONGEST
})
.collect();
format!("{kept}…")
}
fn tab_strip(app_state: &mut AppState, is_left: bool, area: Rect) -> Line<'static> {
let (dirs, active) = app_state.tab_dirs(is_left);
let panel_active = app_state.is_left_active == is_left;
let items = dirs
.iter()
.enumerate()
.map(|(index, dir)| {
let style = if index != active {
style_dir_dark()
} else if panel_active {
Style::new().fg(palette().selected_foreground).bg(palette().selected_background)
} else {
Style::new().fg(palette().selected_foreground).bg(palette().selected_background_inactive)
};
Span::styled(format!(" {} ", tab_label(dir)), style)
})
.collect();
let tabs = if is_left { &mut app_state.tabs_left } else { &mut app_state.tabs_right };
Line::from(lay_strip(&mut tabs.strip, area, items, Some(active)))
}
fn render_top_panel(f: &mut ratatui::Frame<'_>, area: Rect, app_state: &mut AppState) {
let cached_clock = app_state.cached_clock.as_str();
let logo = Span::styled(format!(" {} ", logo_icon(app_state.options.icon_style)), style_title());
let title = Span::styled(format!(" {} v{} ", TITLE, VERSION), style_title());
let clock = Span::styled(cached_clock, style_title());
let (user_at_host, root) = &app_state.user_at_host;
let user_style = if *root { style_title().add_modifier(Modifier::REVERSED) } else { style_title() };
let user = Span::styled(format!(" {} ", printable_name(user_at_host)), user_style);
let left_width = display_width(&logo.content) + 1 + display_width(&user.content);
let fits = left_width.max(display_width(&clock.content)) * 2 + display_width(&title.content) + 4 <= area.width as usize;
let mut block_top = Block::default().title_top(Line::from(logo).left_aligned());
if fits {
block_top = block_top.title_top(Line::from(user).left_aligned());
}
let block_top = block_top
.title_top(Line::from(title).centered())
.title_top(Line::from(clock).right_aligned())
.borders(Borders::LEFT | Borders::TOP | Borders::RIGHT)
.border_style(style_border());
let inner = block_top.inner(area);
f.render_widget(block_top, area);
app_state.drive_strip_left.area = Rect::default();
app_state.drive_strip_right.area = Rect::default();
app_state.tabs_left.strip.area = Rect::default();
app_state.tabs_right.strip.area = Rect::default();
if inner.height > 0 && !app_state.mounts.is_empty() {
let halves = panel_split(Rect { height: 1, ..inner });
let left = drive_strip(app_state, true, halves[0]);
let right = drive_strip(app_state, false, halves[2]);
f.render_widget(Paragraph::new(left), halves[0]);
f.render_widget(Paragraph::new(right), halves[2]);
}
if inner.height > 1 {
let halves = panel_split(Rect { y: inner.y + 1, height: 1, ..inner });
let left = tab_strip(app_state, true, halves[0]);
let right = tab_strip(app_state, false, halves[2]);
f.render_widget(Paragraph::new(left), halves[0]);
f.render_widget(Paragraph::new(right), halves[2]);
}
}
fn render_path_bar(f: &mut ratatui::Frame<'_>, area: Rect, app_state: &AppState, going_to: Option<&TextInput>) {
let total_width = area.width;
let is_left_active = app_state.is_left_active;
let length_left = ((total_width as usize).saturating_sub(3)) / 2;
let length_right = ((total_width as usize).saturating_sub(2)) / 2;
const PATH_ROOM: usize = 12;
let (color_left, color_right) = if is_left_active { (style_dir(), style_dir_dark()) } else { (style_dir_dark(), style_dir()) };
let branch_label = |branch: &Option<String>| {
branch.as_ref().map(|branch| match app_state.options.icon_style {
IconStyle::Plain => format!(" git:{} ", printable_name(branch)),
_ => format!(" {ICON_BRANCH} {} ", printable_name(branch)),
})
};
let side = |dir: &std::path::Path, branch: Option<String>, length: usize, style: Style, editing: Option<&TextInput>, end: &'static str| -> Vec<Span<'static>> {
let mut spans = vec![Span::styled(" ", style)];
let branch = branch.filter(|label| editing.is_none() && length.saturating_sub(8 + display_width(label) + 1) >= PATH_ROOM);
let branch_width = branch.as_ref().map_or(0, |label| display_width(label) + 1);
match editing {
Some(input) => spans.extend(input.cursor_spans_within(length.saturating_sub(7), style_input(), style_input_cursor())),
None => spans.push(Span::styled(limit_path_string(dir, length.saturating_sub(8 + branch_width)), style)),
}
spans.push(Span::styled(" ", style));
if let Some(label) = branch {
spans.push(Span::styled("─", style_border()));
spans.push(Span::styled(label, style_columns()));
}
let used: usize = spans.iter().map(|span| display_width(&span.content)).sum();
spans.push(Span::styled(format!("{}{end}", "─".repeat(length.saturating_sub(used + 3))), style_border()));
spans
};
let mut border_line = vec![Span::styled("├──", style_border())];
border_line.extend(side(&app_state.dir_left, branch_label(&app_state.branch_left), length_left, color_left, going_to.filter(|_| is_left_active), "─┬──"));
border_line.extend(side(&app_state.dir_right, branch_label(&app_state.branch_right), length_right, color_right, going_to.filter(|_| !is_left_active), "─┤"));
f.render_widget(Paragraph::new(Line::from(border_line)), area);
}
fn render_file_tables(f: &mut ratatui::Frame<'_>, chunk: Rect, app_state: &mut AppState) -> u16 {
let chunks = panel_split(chunk);
let columns = visible_columns(&app_state.options, chunks[0].width);
let mut widths = vec![Constraint::Length(2), Constraint::Fill(1)];
for column in &columns {
widths.push(Constraint::Length(1));
widths.push(Constraint::Length(column.width(&app_state.options)));
}
let renaming = matches!(app_state.dialog, Some(Dialog::Rename(_)));
let table_style = |active: bool| {
Style::default()
.bg(if active {
if renaming { palette().rename_background } else { palette().selected_background }
} else {
palette().selected_background_inactive
})
.fg(palette().selected_foreground)
.add_modifier(Modifier::BOLD)
};
let viewport_height = chunks[0].height.saturating_sub(1) as usize;
let header = make_header_row(&columns, &app_state.options);
let field = name_width(&app_state.options, chunks[0].width);
let (rows_left, offset_left) = build_viewport_rows(app_state, true, viewport_height, &columns, field);
let mut state_left_view = TableState::default();
state_left_view.select(app_state.state_left.selected().map(|s| s.saturating_sub(offset_left)));
let block_left = Block::default().borders(Borders::LEFT).border_style(style_border());
let block_right = Block::default().borders(Borders::RIGHT).border_style(style_border());
app_state.sort_headers.clear();
for (area, block, is_left) in [(chunks[0], &block_left, true), (chunks[2], &block_right, false)] {
if app_state.preview.is_some() && is_left != app_state.is_left_active {
continue;
}
let inner = block.inner(area);
let cells = Layout::horizontal(widths.clone()).spacing(1).split(Rect { height: 1, ..inner });
let name = cells[0].union(cells[1]);
app_state.sort_headers.push((name, SortKey::Name));
for (index, column) in columns.iter().enumerate() {
if let Some(key) = column.sort_key() {
app_state.sort_headers.push((cells[3 + 2 * index], key));
}
}
}
let table_left = Table::new(rows_left, widths.clone()).block(block_left).header(header.clone()).row_highlight_style(table_style(app_state.is_left_active)).column_spacing(1);
f.render_stateful_widget(table_left, chunks[0], &mut state_left_view);
let separator_height = chunks[0].height;
if app_state.cached_separator_height != separator_height {
app_state.cached_separator_height = separator_height;
app_state.cached_separator = "│\n".repeat(separator_height.saturating_sub(1) as usize) + "│";
}
let separator_vertical = Paragraph::new(Text::raw(&app_state.cached_separator)).style(style_border());
f.render_widget(separator_vertical, chunks[1]);
let (rows_right, offset_right) = build_viewport_rows(app_state, false, viewport_height, &columns, field);
let mut state_right_view = TableState::default();
state_right_view.select(app_state.state_right.selected().map(|s| s.saturating_sub(offset_right)));
let table_right = Table::new(rows_right, widths).block(block_right).header(header).row_highlight_style(table_style(!app_state.is_left_active)).column_spacing(1);
f.render_stateful_widget(table_right, chunks[2], &mut state_right_view);
if let Some(preview) = &app_state.preview {
let (area, is_left) = if app_state.is_left_active { (chunks[2], false) } else { (chunks[0], true) };
render_preview(f, area, preview, is_left);
}
app_state.table_area_left = chunks[0];
app_state.table_area_right = chunks[2];
app_state.viewport_start_left = offset_left;
app_state.viewport_start_right = offset_right;
chunks[0].height
}
fn build_viewport_rows(app_state: &AppState, is_left: bool, viewport_height: usize, columns: &[Column], name_width: u16) -> (Vec<Row<'static>>, usize) {
let children = if is_left { &app_state.children_left } else { &app_state.children_right };
let state = if is_left { &app_state.state_left } else { &app_state.state_right };
let selected_set = if is_left { &app_state.selected_left } else { &app_state.selected_right };
let current_dir = if is_left { &app_state.dir_left } else { &app_state.dir_right };
let selected = state.selected().unwrap_or(0);
let total = children.len();
if total == 0 {
return (Vec::new(), 0);
}
let half_view = viewport_height / 2;
let start = if selected <= half_view {
0
} else if selected + half_view >= total {
total.saturating_sub(viewport_height)
} else {
selected.saturating_sub(half_view)
};
let end = (start + viewport_height).min(total);
let rename_input = match &app_state.dialog {
Some(Dialog::Rename(input)) if app_state.is_left_active == is_left => Some(input),
_ => None,
};
let is_renaming_current_side = rename_input.is_some();
let border_cell = Cell::from(Span::styled("│", style_border()));
let mut rows = Vec::with_capacity(end - start);
let now = std::time::SystemTime::now();
let date_format = app_state.options.date_format;
let has_ext_column = columns.contains(&Column::Ext);
for (index, child) in children.iter().enumerate().take(end).skip(start) {
let is_renaming_current_item = is_renaming_current_side && (index == selected);
let is_selected = selected_set.contains(&child.name_os);
let icon = row_icon(child.is_dir, app_state.options.icon_style);
let file_color = color_for_extension(&child.extension);
let text_color = if is_selected {
palette().selected_marker
} else if child.is_dir {
palette().directory
} else {
file_color
};
let text_style = Style::default().fg(text_color);
let (dir_prefix, dir_suffix) = if child.is_dir { ("[", "]") } else { ("", "") };
let bracket_style = if is_selected { Style::default().fg(palette().selected_marker) } else { Style::default().fg(palette().directory_bracket) };
let (name_cell, extension) = if is_renaming_current_item {
let cursor_style = text_style.add_modifier(FIELD_CURSOR);
let field = name_width as usize - (dir_prefix.len() + dir_suffix.len()).min(name_width as usize);
let mut spans = vec![Span::styled(dir_prefix, bracket_style)];
if let Some(input) = rename_input {
spans.extend(input.cursor_spans_within(field, text_style, cursor_style));
}
spans.push(Span::styled(dir_suffix, bracket_style));
(Cell::from(Line::from(spans)), String::new())
} else {
(
Cell::from(Line::from(vec![
Span::styled(dir_prefix, bracket_style),
Span::styled(printable_name(if has_ext_column { &child.name } else { &child.name_full }), text_style),
Span::styled(dir_suffix, bracket_style),
])),
printable_name(&child.extension),
)
};
let size = if child.is_dir && child.name != ".." {
let path = child.path_in(current_dir);
if let Some(&calculated_size) = app_state.dir_sizes.get(&path) {
format_size(calculated_size)
} else if let Some(so_far) = app_state.dir_size_so_far(&path) {
if so_far == 0 { "…".to_string() } else { format!("{}…", format_size(so_far)) }
} else {
child.size.clone()
}
} else {
child.size.clone()
};
let mut cells = vec![Cell::from(Span::styled(icon, Style::default().fg(text_color))), name_cell];
for column in columns {
let value = match column {
Column::Ext => extension.clone(),
Column::Size => size.clone(),
Column::Modified => child.modified_at.map(|time| format_modified(time, date_format, now)).unwrap_or_default(),
Column::Attributes => child.attributes.clone(),
};
cells.push(border_cell.clone());
cells.push(Cell::from(Span::styled(value, text_style)));
}
rows.push(Row::new(cells));
}
(rows, start)
}
fn render_preview(f: &mut ratatui::Frame<'_>, area: Rect, preview: &crate::app::PreviewState, is_left: bool) {
let block = Block::default().borders(if is_left { Borders::LEFT } else { Borders::RIGHT }).border_style(style_border());
let inner = block.inner(area);
clear(f, area);
f.render_widget(block, area);
if inner.height == 0 {
return;
}
let room = inner.height as usize - 1;
let body = if preview.bytes.is_empty() {
preview.lines.iter().take(room).map(|line| line.replace('\t', &" ".repeat(tab_width()))).collect()
} else {
hex_preview(&preview.bytes, inner.width, room)
};
let mut lines = vec![Line::from(Span::styled(format!(" {}", printable_name(&preview.label)), style_columns()))];
lines.extend(body.into_iter().map(|line| Line::from(Span::styled(format!(" {line}"), style_file()))));
f.render_widget(Paragraph::new(lines), inner);
}
const PREVIEW_HEX_LAYOUTS: [(usize, usize); 4] = [(16, 4), (8, 4), (8, 0), (4, 0)];
const _: () = assert!(crate::constants::PREVIEW_HEX_BYTES <= 0xffff);
fn hex_preview(bytes: &[u8], width: u16, rows: usize) -> Vec<String> {
let room = width.saturating_sub(1) as usize;
let (per_line, digits) = PREVIEW_HEX_LAYOUTS
.into_iter()
.find(|&(per_line, digits)| crate::viewer::hex_row_width(per_line, digits) <= room)
.unwrap_or(PREVIEW_HEX_LAYOUTS[PREVIEW_HEX_LAYOUTS.len() - 1]);
bytes.chunks(per_line).take(rows).enumerate().map(|(index, chunk)| crate::viewer::hex_row(index * per_line, chunk, per_line, digits)).collect()
}
fn make_header_row(columns: &[Column], options: &Options) -> Row<'static> {
let arrow = if options.sort_descending { " ↓" } else { " ↑" };
let heading = |title: &str, key: Option<SortKey>| {
let title = if key == Some(options.sort_key) { format!("{title}{arrow}") } else { title.to_string() };
Cell::from(Span::styled(title, style_columns()))
};
let mut cells = vec![Cell::from(Span::styled("", style_columns())), heading("Name", Some(SortKey::Name))];
for column in columns {
cells.push(Cell::from(Span::styled("", style_columns())));
cells.push(heading(column.title(), column.sort_key()));
}
Row::new(cells)
}
type LaidOut = (usize, Vec<char>, Vec<(usize, usize)>);
fn render_viewer(f: &mut ratatui::Frame<'_>, area: Rect, app_state: &AppState) -> (usize, usize, Rect) {
if let Some(viewer_state) = app_state.viewer() {
let filename = shown_name(&viewer_state.file_path);
let prefix = if viewer_state.from_edit { "Edit" } else { "View" };
let title = format!(" {}: {} ", prefix, filename);
let border_block = Block::default().title(Line::from(Span::styled(title, style_title())).centered()).borders(Borders::ALL).border_style(style_border());
let inner_area = border_block.inner(area);
f.render_widget(border_block, area);
let no_gutter = viewer_state.mode != ViewMode::Text || !app_state.options.line_numbers;
let line_num_width = if no_gutter { 0 } else { (viewer_state.line_count().to_string().len() as u16).max(3) + 2 };
let chunks = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Length(line_num_width), Constraint::Min(0)]).split(inner_area);
let viewport_height = inner_area.height as usize;
let start = viewer_state.scroll_offset;
let end = (start + viewport_height).min(viewer_state.total_lines);
if !no_gutter {
let num_width = (line_num_width as usize).saturating_sub(1);
let line_numbers: Vec<Line> = (start..end)
.map(|index| {
let (line, from, _) = viewer_state.row(index);
let number = if from == 0 { (line + 1).to_string() } else { String::new() };
Line::from(Span::styled(format!("{number:>num_width$} "), style_columns()))
})
.collect();
let line_number_para = Paragraph::new(line_numbers).style(Style::default().bg(crate::display::gutter()));
f.render_widget(line_number_para, chunks[0]);
}
let content_area = if viewer_state.mode == ViewMode::Image {
centered(chunks[1], viewer_state.image_columns, viewer_state.total_lines)
} else {
chunks[1]
};
if viewer_state.from_edit {
let binary_msg = Paragraph::new("Binary file detected. Press Esc to return.").alignment(Alignment::Center).style(style_title());
f.render_widget(binary_msg, chunks[1]);
} else {
let selection = viewer_state.selected_range();
let marking = app_state.find_shown && viewer_state.mode != ViewMode::Image;
let needle = marking.then(|| crate::find::Needle::new(&app_state.find_term)).flatten();
let mut held: Option<LaidOut> = None;
let content_lines: Vec<Line> = (start..end)
.map(|index| {
let (line, row_from, row_to) = viewer_state.row(index);
if held.as_ref().is_none_or(|(held_line, _, _)| *held_line != line) {
let whole = viewer_state.line_text(line);
let matches = needle.as_ref().map(|needle| needle.find_all(&whole)).unwrap_or_default();
held = Some((line, whole.chars().collect(), matches));
}
let (_, characters, matches) = held.as_ref().expect("held just above");
let width = characters.len();
let (row_from, row_to) = (row_from.min(width), row_to.min(width));
let on_row = |from: usize, to: usize| {
let (from, to) = (from.clamp(row_from, row_to), to.clamp(row_from, row_to));
(from < to).then(|| (from - row_from, to - row_from))
};
let text: String = characters[row_from..row_to].iter().collect();
let mut spans = match viewer_state.image_colors.get(line) {
Some(colors) if viewer_state.mode == ViewMode::Image => colored_spans(&text, colors, viewer_state.image_backgrounds.get(line).map(Vec::as_slice)),
_ => vec![Span::styled(text, style_file())],
};
let first = matches.partition_point(|&(_, to)| to <= row_from);
for (from, to) in matches[first..].iter().take_while(|&&(from, _)| from < row_to).filter_map(|&(from, to)| on_row(from, to)) {
spans = overlay_range(spans, from, to, style_match());
}
if let Some(((first_line, first_col), (last_line, last_col))) = selection
&& (first_line..=last_line).contains(&line)
{
let to = if line == last_line { last_col.min(width) } else { width };
let from = if line == first_line { first_col.min(to) } else { 0 };
if let Some((from, to)) = on_row(from, to) {
spans = overlay_range(spans, from, to, style_selection());
}
}
Line::from(skip_columns(spans, viewer_state.horizontal_offset))
})
.collect();
let content_para = Paragraph::new(content_lines).style(style_file());
f.render_widget(content_para, content_area);
}
(viewport_height, chunks[1].width as usize, content_area)
} else {
(0, 0, Rect::default())
}
}
fn render_diff(f: &mut ratatui::Frame<'_>, area: Rect, app_state: &AppState) -> (usize, usize) {
let Screen::Diff(state) = &app_state.screen else {
return (0, 0);
};
let block = Block::default().title(Line::from(Span::styled(" Compare ", style_title())).centered()).borders(Borders::ALL).border_style(style_border());
let inner = block.inner(area);
f.render_widget(block, area);
let [left_area, marker_area, right_area] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(3), Constraint::Fill(1)]).areas(inner);
let [_, marker_area] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(marker_area);
let height = inner.height.saturating_sub(1) as usize;
let shown = &state.rows[state.scroll_offset.min(state.rows.len())..(state.scroll_offset + height).min(state.rows.len())];
let markers: Vec<Line> = shown.iter().map(|row| Line::from(Span::styled(format!(" {} ", row.marker()), style_columns().add_modifier(Modifier::BOLD)))).collect();
f.render_widget(Paragraph::new(markers), marker_area);
let gutter_width = if app_state.options.line_numbers { (state.left.len().max(state.right.len()).to_string().len() as u16).max(3) + 1 } else { 0 };
let mut text_width = 0;
for (area, path, lines, is_left) in [(left_area, &state.left_path, &state.left, true), (right_area, &state.right_path, &state.right, false)] {
let [header_area, body_area] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
let header = crate::utils::limit_path_string(path, header_area.width as usize);
f.render_widget(Paragraph::new(Line::from(Span::styled(header, style_title()))), header_area);
let [gutter_area, text_area] = Layout::horizontal([Constraint::Length(gutter_width), Constraint::Min(0)]).areas(body_area);
text_width = text_area.width as usize;
let mut numbers = Vec::with_capacity(shown.len());
let mut texts = Vec::with_capacity(shown.len());
for row in shown {
let line = if is_left { row.left } else { row.right };
let style = if row.same || line.is_none() { style_file() } else { style_file().patch(style_selection()) };
let number = line.map(|index| (index + 1).to_string()).unwrap_or_default();
numbers.push(Line::from(Span::styled(format!("{number:>width$} ", width = (gutter_width as usize).saturating_sub(1)), style_columns())));
let text = line.and_then(|index| lines.get(index)).map(|line| crate::utils::printable_line(line)).unwrap_or_default();
texts.push(Line::from(skip_columns(vec![Span::raw(text)], state.horizontal_offset)).style(style));
}
if gutter_width > 0 {
f.render_widget(Paragraph::new(numbers).style(Style::default().bg(crate::display::gutter())), gutter_area);
}
f.render_widget(Paragraph::new(texts), text_area);
}
(height, text_width)
}
fn centered(area: Rect, width: usize, height: usize) -> Rect {
let width = width.min(area.width as usize) as u16;
let height = height.min(area.height as usize) as u16;
Rect::new(area.x + (area.width - width) / 2, area.y + (area.height - height) / 2, width, height)
}
fn colored_spans(text: &str, colors: &[ratatui::style::Color], backgrounds: Option<&[ratatui::style::Color]>) -> Vec<Span<'static>> {
let paint = |(fg, bg): (ratatui::style::Color, Option<ratatui::style::Color>)| match bg {
Some(bg) => Style::new().fg(fg).bg(bg),
None => Style::new().fg(fg),
};
let mut spans = Vec::new();
let mut run = String::new();
let mut run_color: Option<(ratatui::style::Color, Option<ratatui::style::Color>)> = None;
for (index, character) in text.chars().enumerate() {
let Some(&color) = colors.get(index) else {
break;
};
let pair = (color, backgrounds.and_then(|row| row.get(index)).copied());
if let Some(previous) = run_color
&& previous != pair
{
spans.push(Span::styled(std::mem::take(&mut run), paint(previous)));
}
run_color = Some(pair);
run.push(character);
}
if let Some(pair) = run_color {
spans.push(Span::styled(run, paint(pair)));
}
spans
}
fn visual_column(line: &str, char_col: usize) -> usize {
line.chars().take(char_col).map(|c| if c == '\t' { tab_width() } else { 1 }).sum()
}
fn overlay_range(spans: Vec<Span<'static>>, from: usize, to: usize, extra: Style) -> Vec<Span<'static>> {
if from >= to {
return spans;
}
let mut result = Vec::with_capacity(spans.len() + 2);
let mut column = 0;
for span in spans {
let text = span.content.into_owned();
let length = text.chars().count();
let span_end = column + length;
if span_end <= from || column >= to {
result.push(Span::styled(text, span.style));
} else {
let characters: Vec<char> = text.chars().collect();
let head = from.saturating_sub(column).min(length);
let tail = to.saturating_sub(column).min(length);
if head > 0 {
result.push(Span::styled(characters[..head].iter().collect::<String>(), span.style));
}
result.push(Span::styled(characters[head..tail].iter().collect::<String>(), span.style.patch(extra)));
if tail < length {
result.push(Span::styled(characters[tail..].iter().collect::<String>(), span.style));
}
}
column = span_end;
}
result
}
fn skip_columns(spans: Vec<Span<'static>>, columns: usize) -> Vec<Span<'static>> {
if columns == 0 {
return spans;
}
let mut left = columns;
let mut result = Vec::with_capacity(spans.len());
for span in spans {
if left == 0 {
result.push(span);
continue;
}
let mut kept = String::new();
for character in span.content.chars() {
if left == 0 {
kept.push(character);
continue;
}
let width = UnicodeWidthChar::width(character).unwrap_or(0);
if width > left {
kept.extend(std::iter::repeat_n(' ', width - left));
left = 0;
} else {
left -= width;
}
}
if !kept.is_empty() {
result.push(Span::styled(kept, span.style));
}
}
result
}
fn place_cursor(mut spans: Vec<Span<'static>>, column: usize, style: Style) -> Vec<Span<'static>> {
let total: usize = spans.iter().map(|span| span.content.chars().count()).sum();
if column < total {
return overlay_range(spans, column, column + 1, style);
}
if column > total {
spans.push(Span::raw(" ".repeat(column - total)));
}
spans.push(Span::styled(" ", style));
spans
}
fn render_editor(f: &mut ratatui::Frame<'_>, area: Rect, app_state: &mut AppState) -> usize {
let line_numbers = app_state.options.line_numbers;
let (viewport_height, content_area) = if let Screen::Editor(editor_state) = &mut app_state.screen {
let filename = shown_name(&editor_state.file_path);
let modified = if editor_state.modified { " [Modified]" } else { "" };
let title = format!(" Edit: {}{} ", filename, modified);
let border_block = Block::default().title(Line::from(Span::styled(title, style_title())).centered()).borders(Borders::ALL).border_style(style_border());
let inner_area = border_block.inner(area);
f.render_widget(border_block, area);
let total_lines = editor_state.lines.len();
let line_num_width = if line_numbers { (total_lines.to_string().len() as u16).max(3) + 2 } else { 0 };
let chunks = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Length(line_num_width), Constraint::Min(0)]).split(inner_area);
let viewport_height = inner_area.height as usize;
editor_state.keep_in_view(viewport_height);
let start = editor_state.scroll_offset;
let end = (start + viewport_height).min(total_lines);
let num_width = (line_num_width as usize).saturating_sub(1);
let style_current_line = style_title().add_modifier(Modifier::BOLD);
let numbers: Vec<Line> = (start..end)
.map(|line_num| {
let style = if line_num == editor_state.cursor_line { style_current_line } else { style_columns() };
Line::from(Span::styled(format!("{:>width$} ", line_num + 1, width = num_width), style))
})
.collect();
if line_numbers {
let line_number_para = Paragraph::new(numbers).style(Style::default().bg(crate::display::gutter()));
f.render_widget(line_number_para, chunks[0]);
}
if editor_state.auto_scroll {
let visual_cursor_col: usize = editor_state.lines[editor_state.cursor_line].chars().take(editor_state.cursor_col).map(|c| if c == '\t' { tab_width() } else { 1 }).sum();
let viewport_width = chunks[1].width as usize;
if visual_cursor_col < editor_state.horizontal_offset {
editor_state.horizontal_offset = visual_cursor_col;
}
if visual_cursor_col >= editor_state.horizontal_offset + viewport_width {
editor_state.horizontal_offset = visual_cursor_col - viewport_width + 1;
}
}
let h_offset = editor_state.horizontal_offset;
let has_highlighting = !editor_state.highlighted_lines.is_empty();
let cursor_style = Style::new().add_modifier(EDITOR_CURSOR);
let selection = editor_state.selection();
let needle = app_state.find_shown.then(|| crate::find::Needle::new(&app_state.find_term)).flatten();
let mut content_lines: Vec<Line> = Vec::with_capacity(end - start);
for (idx, line) in editor_state.lines[start..end].iter().enumerate() {
let actual_line_idx = start + idx;
let mut spans: Vec<Span<'static>> = if has_highlighting && actual_line_idx < editor_state.highlighted_lines.len() {
editor_state.highlighted_lines[actual_line_idx].iter().map(|span| Span::styled(printable_line(&span.content), span.style)).collect()
} else {
vec![Span::styled(printable_line(line), style_file())]
};
for (from, to) in needle.as_ref().map(|needle| needle.find_all(line)).unwrap_or_default() {
spans = overlay_range(spans, visual_column(line, from), visual_column(line, to), style_match());
}
if let Some(((first_line, first_col), (last_line, last_col))) = selection
&& (first_line..=last_line).contains(&actual_line_idx)
{
let from = if actual_line_idx == first_line { visual_column(line, first_col) } else { 0 };
let to = if actual_line_idx == last_line { visual_column(line, last_col) } else { visual_column(line, line.chars().count()) };
spans = overlay_range(spans, from, to, style_selection());
}
if actual_line_idx == editor_state.cursor_line {
spans = place_cursor(spans, visual_column(line, editor_state.cursor_col), cursor_style);
}
content_lines.push(Line::from(skip_columns(spans, h_offset)));
}
let content_para = Paragraph::new(content_lines);
f.render_widget(content_para, chunks[1]);
(viewport_height, chunks[1])
} else {
(0, Rect::default())
};
app_state.editor_content_area = content_area;
viewport_height
}
fn render_segmented_status_bar(f: &mut ratatui::Frame<'_>, area: Rect, segments: &[&str]) {
let mut spans = Vec::new();
spans.push(Span::styled("├─", style_border()));
let mut used = 3usize;
for (i, &seg) in segments.iter().enumerate() {
let padded = format!(" {} ", seg);
let needed = display_width(&padded) + usize::from(i > 0);
if i > 0 && used + needed > area.width as usize {
break;
}
if i > 0 {
spans.push(Span::styled("─", style_border()));
}
used += needed;
spans.push(Span::styled(padded, style_title()));
}
let fill = (area.width as usize).saturating_sub(used);
spans.push(Span::styled("─".repeat(fill), style_border()));
spans.push(Span::styled("┤", style_border()));
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn render_prompt_bar(f: &mut ratatui::Frame<'_>, area: Rect, label: &str, input: &crate::app::TextInput, style: Style) {
let typed = input.cursor_spans(style_input(), style_input_cursor());
let typed_width: usize = typed.iter().map(|span| display_width(&span.content)).sum();
let used = 2 + display_width(label) + typed_width + 1 + 1;
let mut line = vec![Span::styled("├─", style_border()), Span::styled(label.to_string(), style)];
line.extend(typed);
line.extend([Span::styled(" ", style), Span::styled("─".repeat((area.width as usize).saturating_sub(used)), style_border()), Span::styled("┤", style_border())]);
f.render_widget(Paragraph::new(Line::from(line)), area);
}
fn render_status_bar(f: &mut ratatui::Frame<'_>, area: Rect, text: String, style: Style) {
let text_len = display_width(&text);
let status_line = vec![
Span::styled("├─", style_border()),
Span::styled(text, style),
Span::styled("─".repeat((area.width as usize).saturating_sub(text_len).saturating_sub(3)), style_border()),
Span::styled("┤", style_border()),
];
f.render_widget(Paragraph::new(Line::from(status_line)), area);
}
fn usage_meter(used: u64, total: u64) -> String {
const CELLS: u64 = 5;
let filled = used.saturating_mul(CELLS).checked_div(total).unwrap_or(0).min(CELLS);
"▪".repeat(filled as usize) + &"▫".repeat((CELLS - filled) as usize)
}
fn disk_readout(usage: Option<(u64, u64)>, available: usize) -> Option<String> {
let (used, total) = usage?;
let figures = format!("{}/{}", format_size(used), format_size(total));
let options = [format!(" {} {} ", usage_meter(used, total), figures), format!(" {} ", figures)];
options.into_iter().find(|text| display_width(text) + 2 <= available)
}
fn render_bottom_panel(f: &mut ratatui::Frame<'_>, area: Rect, app_state: &AppState) {
let status_style = style_title().bg(palette().selected_background);
if let Some(Dialog::Replacing { .. }) = &app_state.dialog {
let question = format!(" Replace \"{}\" with \"{}\"? Y - Yes N - No A - All Esc - Stop ", printable_name(&app_state.find_term), printable_name(&app_state.replace_with));
render_status_bar(f, area, question, status_style);
return;
}
if let Some(Dialog::Prompt(kind, input)) = &app_state.dialog
&& *kind != PromptKind::GoTo
{
let label = match kind {
PromptKind::Find => " Find: ",
PromptKind::GoToLine => " Go to line: ",
PromptKind::Select => " Select: ",
PromptKind::Deselect => " Deselect: ",
PromptKind::Replace => " Replace: ",
PromptKind::ReplaceWith => " With: ",
PromptKind::GoTo => unreachable!("typed in the path bar"),
};
render_prompt_bar(f, area, label, input, status_style);
return;
}
if let Some(note) = &app_state.find_note {
render_status_bar(f, area, format!(" {} ", printable_name(note)), status_style);
return;
}
if let Some(editor_state) = app_state.editor() {
{
let filename = shown_name(&editor_state.file_path);
let modified = if editor_state.modified { " [Modified]" } else { "" };
let name_seg = format!("{}{}", filename, modified);
let pos_seg = format!("Ln {}, Col {}", editor_state.cursor_line + 1, editor_state.cursor_col + 1);
let lines_seg = format!("{} lines", editor_state.lines.len());
render_segmented_status_bar(f, area, &[&name_seg, &pos_seg, &lines_seg, "F2/Ctrl+S Save", "Ctrl+F Find", "Ctrl+Z/Y Undo", "Esc Exit"]);
}
} else if let Screen::Diff(state) = &app_state.screen {
let at = match (state.current, state.hunks.len()) {
(Some(current), count) => format!("Difference {}/{}", current + 1, count),
(None, count) => format!("{count} differences"),
};
let rows = format!("Row {}/{}", state.scroll_offset + 1, state.rows.len());
render_segmented_status_bar(f, area, &[&at, &rows, "F3/N Next", "Shift+F3/P Previous", "Esc Exit"]);
} else if let Some(viewer_state) = app_state.viewer() {
{
let filename = shown_name(&viewer_state.file_path);
let line_seg = format!("Line {}/{}", viewer_state.row(viewer_state.scroll_offset).0 + 1, viewer_state.line_count());
let size_seg = format_size(viewer_state.file_size);
let zoom_seg = format!("+/- Zoom {}%", viewer_state.image_zoom);
let mut segments = vec![filename.as_str(), line_seg.as_str(), size_seg.as_str(), viewer_state.syntax_name.as_str()];
if !viewer_state.from_edit {
segments.push(match (viewer_state.image.is_some(), viewer_state.mode) {
(true, ViewMode::Image) => "X [Image] Text Hex",
(true, ViewMode::Text) => "X Image [Text] Hex",
(true, ViewMode::Hex) => "X Image Text [Hex]",
(false, ViewMode::Hex) => "X Text [Hex]",
(false, _) => "X [Text] Hex",
});
if viewer_state.mode == ViewMode::Image {
segments.push(if viewer_state.image_fill { "F Fit [Fill]" } else { "F [Fit] Fill" });
segments.push(zoom_seg.as_str());
} else {
if viewer_state.mode == ViewMode::Text {
segments.push(if viewer_state.wrap { "W [Wrap]" } else { "W Wrap" });
}
segments.push("Ctrl+F Find");
}
}
render_segmented_status_bar(f, area, &segments);
}
} else if let Some(listing) = if app_state.is_left_active { &app_state.listing_left } else { &app_state.listing_right } {
let text = format!(" Reading {}… Esc to stop ", printable_name(&listing.dir.to_string_lossy()));
render_status_bar(f, area, text, status_style);
} else if !app_state.search_input.is_empty() {
let text = format!(" Search: {} ", app_state.search_input);
render_status_bar(f, area, text, status_style);
} else {
let panel_stat = |children: &[crate::app::Item], selected_set: &std::collections::HashSet<std::ffi::OsString>, current_dir: &PathBuf, dir_sizes: &std::collections::HashMap<PathBuf, u64>| -> (String, String) {
let item_size = |c: &crate::app::Item| -> u64 { if c.is_dir { dir_sizes.get(&c.path_in(current_dir)).copied().unwrap_or(0) } else { c.size_bytes } };
let total_count = children.iter().filter(|c| c.name != "..").count();
let total_size: u64 = children.iter().filter(|c| c.name != "..").map(&item_size).sum();
if selected_set.is_empty() {
(format!("0/{}", total_count), format_size(total_size))
} else {
let is_selected = |c: &crate::app::Item| c.name != ".." && selected_set.contains(&c.name_os);
let sel_count = children.iter().filter(|c| is_selected(c)).count();
let sel_size: u64 = children.iter().filter(|c| is_selected(c)).map(item_size).sum();
(format!("{}/{}", sel_count, total_count), format!("{}/{}", format_size(sel_size), format_size(total_size)))
}
};
let (left_count, left_size) = panel_stat(&app_state.children_left, &app_state.selected_left, &app_state.dir_left, &app_state.dir_sizes);
let (right_count, right_size) = panel_stat(&app_state.children_right, &app_state.selected_right, &app_state.dir_right, &app_state.dir_sizes);
let left_stat_len = 1 + left_count.len() + 3 + left_size.len() + 1;
let right_stat_len = 1 + right_count.len() + 3 + right_size.len() + 1;
let total_width = area.width as usize;
let left_pad = (total_width.saturating_sub(3) / 2).saturating_sub(left_stat_len + 1);
let right_pad = (total_width.saturating_sub(2) / 2).saturating_sub(right_stat_len + 1);
let (left_style, right_style) = if app_state.is_left_active { (style_title(), style_dir_dark()) } else { (style_dir_dark(), style_title()) };
let left_disk = disk_readout(app_state.disk_left, left_pad);
let right_disk = disk_readout(app_state.disk_right, right_pad);
let dashes = |pad: usize, disk: &Option<String>| pad - disk.as_ref().map_or(0, |text| display_width(text) + 1);
let mut status_line = vec![
Span::styled("├─", style_border()),
Span::styled(format!(" {}", left_count), left_style),
Span::styled(" - ", style_border()),
Span::styled(format!("{} ", left_size), left_style),
Span::styled("─".repeat(dashes(left_pad, &left_disk)), style_border()),
];
if let Some(text) = left_disk {
status_line.push(Span::styled(text, left_style));
status_line.push(Span::styled("─", style_border()));
}
status_line.extend([
Span::styled("┴─", style_border()),
Span::styled(format!(" {}", right_count), right_style),
Span::styled(" - ", style_border()),
Span::styled(format!("{} ", right_size), right_style),
Span::styled("─".repeat(dashes(right_pad, &right_disk)), style_border()),
]);
if let Some(text) = right_disk {
status_line.push(Span::styled(text, right_style));
status_line.push(Span::styled("─", style_border()));
}
status_line.push(Span::styled("┤", style_border()));
f.render_widget(Paragraph::new(Line::from(status_line)), area);
}
}
fn fkey_labels(app_state: &AppState) -> Vec<(u8, &'static str)> {
match app_state.screen {
Screen::Viewer(_) if app_state.can_find() => vec![(3, "Next"), (10, "Quit")],
Screen::Viewer(_) => vec![(10, "Quit")],
Screen::Editor(_) => vec![(2, "Save"), (3, "Next"), (4, "Replace"), (10, "Quit")],
Screen::Diff(_) => vec![(3, "Next"), (10, "Quit")],
Screen::Panels => vec![
(1, "Help"),
(2, "Rename"),
(3, "View"),
(4, "Edit"),
(5, "Copy"),
(6, "Move"),
(7, "Mkdir"),
(8, if app_state.options.delete_to_trash { "Trash" } else { "Delete" }),
(9, "Terminal"),
(10, "Quit"),
(11, "Options"),
(12, "Preview"),
],
}
}
fn render_detail(f: &mut ratatui::Frame<'_>, area: Rect, app_state: &AppState) {
let Some(detail) = &app_state.cursor_detail else {
return;
};
if area.height < 2 || detail.name.is_empty() {
return;
}
const NAME_MIN: usize = 16;
let room = area.width as usize;
let gap = " ";
let cost = |parts: &[(&str, Style)]| -> usize { parts.iter().filter(|(text, _)| !text.is_empty()).map(|(text, _)| display_width(text) + gap.len()).sum() };
let spans = |parts: &[(&str, Style)], lead: bool| -> Vec<Span<'static>> {
let mut out: Vec<Span<'static>> = Vec::new();
for (text, style) in parts.iter().filter(|(text, _)| !text.is_empty()) {
let before = if out.is_empty() && !lead { "" } else { gap };
out.push(Span::styled(format!("{before}{text}"), *style));
}
out
};
let mut figures: Vec<(&str, Style)> = vec![(&detail.size, style_file()), (&detail.modified, style_columns())];
while !figures.is_empty() && NAME_MIN + cost(&figures) > room {
figures.pop();
}
let mut first = vec![Span::styled(tail_of(&detail.name, room.saturating_sub(cost(&figures))), style_title())];
first.extend(spans(&figures, true));
let owner: Vec<(&str, Style)> = vec![(&detail.owner, style_columns()), (&detail.attributes, style_file())];
let mut second = spans(&owner, false);
if let Some(link) = &detail.link {
let arrow = format!("{gap}\u{2192} ");
let left = room.saturating_sub(cost(&owner) + display_width(&arrow));
if left >= 4 {
second.push(Span::styled(format!("{arrow}{}", tail_of(link, left)), style_dir()));
}
}
for (offset, row_spans) in [(0, first), (1, second)] {
let row = Rect::new(area.x, area.y + offset, area.width, 1);
f.render_widget(Paragraph::new(Line::from(row_spans)), row);
}
}
fn tail_of(text: &str, room: usize) -> String {
let text = &printable_name(text);
if display_width(text) <= room {
return text.to_string();
}
if room <= 3 {
return ".".repeat(room);
}
let mut kept: Vec<char> = Vec::new();
let mut used = 3; for character in text.chars().rev() {
let width = display_width(&character.to_string()).max(1);
if used + width > room {
break;
}
used += width;
kept.push(character);
}
kept.reverse();
format!("...{}", kept.into_iter().collect::<String>())
}
fn render_fkey_bar(f: &mut ratatui::Frame<'_>, area: Rect, app_state: &mut AppState) {
f.render_widget(Block::default().borders(Borders::LEFT | Borders::BOTTOM | Borders::RIGHT).border_style(style_border()), area);
let mut labels = Vec::new();
let mut used = 2;
for (key, name) in fkey_labels(app_state) {
let label = format!(" F{key} {name} ");
let needed = used + display_width(&label) + usize::from(!labels.is_empty());
if needed > area.width as usize + 1 {
break;
}
used = needed;
labels.push((key, label));
}
app_state.fkey_hits.clear();
let room = area.width.saturating_sub(2);
let total = (used - 2) as u16;
let mut x = area.x + 1 + room.saturating_sub(total) / 2;
let y = area.bottom().saturating_sub(1);
for (key, label) in labels {
let width = (display_width(&label) as u16).min(area.right().saturating_sub(x));
let cell = Rect::new(x, y, width, 1);
f.render_widget(Paragraph::new(Span::styled(label, style_title())), cell);
app_state.fkey_hits.push((cell, key));
x += width + 1;
}
}
fn popup_inner(area: Rect) -> Rect {
area.inner(Margin { vertical: 1, horizontal: 2 })
}
fn popup_body(f: &mut ratatui::Frame<'_>, area: Rect, lines: Vec<Line<'static>>) -> Vec<Option<u16>> {
let inner = popup_inner(area);
let room = inner.height as usize;
let blocks = fit_rows(lines.into_iter().map(|line| wrap_line(line, inner.width as usize)).collect(), room);
let rows: usize = blocks.iter().map(Vec::len).sum();
let airy = (rows + blocks.len()).saturating_sub(1);
let mut body: Vec<Line> = Vec::with_capacity(airy.max(rows));
let mut starts = Vec::with_capacity(blocks.len());
for (index, block) in blocks.into_iter().enumerate() {
if index > 0 && airy <= room {
body.push(Line::from(""));
}
starts.push(body.len());
body.extend(block);
}
let padding = room.saturating_sub(body.len()) / 2;
let mut out = vec![Line::from(""); padding];
out.extend(body);
f.render_widget(Paragraph::new(out).alignment(Alignment::Center), inner);
starts.into_iter().map(|start| (padding + start < room).then(|| inner.y + (padding + start) as u16)).collect()
}
fn wrap_line(line: Line<'static>, width: usize) -> Vec<Line<'static>> {
if width == 0 || line.width() <= width || line.spans.len() != 1 {
return vec![line];
}
let style = line.spans[0].style;
wrap_text(&line.spans[0].content, width)
.into_iter()
.map(|row| Line {
spans: vec![Span::styled(row, style)],
style: line.style,
alignment: line.alignment,
})
.collect()
}
fn wrap_text(text: &str, width: usize) -> Vec<String> {
let mut rows = Vec::new();
let mut row = String::new();
let mut used = 0;
for word in text.split(' ') {
if used > 0 && used + 1 + display_width(word) > width {
rows.push(std::mem::take(&mut row));
used = 0;
}
if used > 0 {
row.push(' ');
used += 1;
}
for character in word.chars() {
let cell = UnicodeWidthChar::width(character).unwrap_or(0);
if used > 0 && used + cell > width {
rows.push(std::mem::take(&mut row));
used = 0;
}
row.push(character);
used += cell;
}
}
if !row.is_empty() || rows.is_empty() {
rows.push(row);
}
rows
}
fn fit_rows(mut blocks: Vec<Vec<Line<'static>>>, room: usize) -> Vec<Vec<Line<'static>>> {
let rows: usize = blocks.iter().map(Vec::len).sum();
if rows <= room {
return blocks;
}
let Some(longest) = (0..blocks.len()).max_by_key(|&index| blocks[index].len()) else {
return blocks;
};
let others = rows - blocks[longest].len();
let keep = room.saturating_sub(others).max(1);
let block = &mut blocks[longest];
if keep < block.len() {
block.truncate(keep);
if let Some(span) = block.last_mut().and_then(|line| line.spans.last_mut()) {
let mut text = span.content.to_string();
text.pop();
text.push('…');
span.content = text.into();
}
}
blocks
}
fn render_error_popup(f: &mut ratatui::Frame<'_>, area: Rect, message: &str) {
let popup_area = centered_rect(60, 20, area);
let popup_block = Block::default().title(Line::from(Span::styled(" Error ", style_title())).centered()).borders(Borders::ALL).style(style_border());
clear(f, popup_area);
f.render_widget(popup_block, popup_area);
popup_body(f, popup_area, vec![Line::from(Span::styled(printable_name(message), style_title()))]);
}
fn render_help_popup(f: &mut ratatui::Frame<'_>, area: Rect) {
let help_lines = vec![
"F1 - This help",
"F2 - Rename folder/file",
" Shift+F2 compare directories",
"F3 - View file (X text/hex/image)",
" F fit/fill, +/- zoom, W wrap",
" Shift+F3 (or =) compare two files",
"F4 - Edit file (Ctrl+S/F2 save)",
" Ctrl+F find, F3/Shift+F3 next/prev",
" Ctrl+G go to line, Ctrl+R/F4 replace",
" Ctrl+Z undo, Ctrl+Y redo, Ctrl+X/C/V",
" Shift+arrows select, Ctrl+A all",
"F5 - Copy (to the other panel, or where typed)",
" Alt+F5 (or Ctrl+Shift+F5) symlink",
"F6 - Move (to the other panel, or where typed)",
"F7 - Create directory",
" Alt+F7 (or Ctrl+F7) find files",
"Shift+F4 - Create file",
"F8 - Move to trash",
" Shift+F8 delete permanently",
"Alt+A - Permissions, owner, time",
"F9 - Open terminal",
"F10 - Quit",
"F11 - Options (Alt+F1/F2 drives)",
"F12 - Preview in other panel",
"Ctrl+Left/Right - Dir to that panel",
" Ctrl+U swap panels, Alt+I same dir there",
"Ctrl+L - Go to path (or click it)",
" Tab completes a name",
" Alt+Left/Right back/forward",
"Ctrl+D - Bookmarks",
"Ctrl+T/Ctrl+W - New/close tab",
" Ctrl+PgUp/PgDn, Alt+1..9 switch",
"Space - Select/deselect file",
"+/- select/deselect by pattern, * invert",
" Alt+* invert with directories",
"Ctrl+C/Alt+C - Copy paths/names",
"Ctrl+F3..F6 - Sort name/ext/date/size",
"Ctrl+R - Reload both panels",
"Type to search, Esc to clear",
];
let content_height = (help_lines.len() as u16) + 4;
let popup_height = content_height.min(area.height);
let popup_width = (area.width * 60 / 100).max(1);
let y = area.y + (area.height.saturating_sub(popup_height)) / 2;
let x = area.x + (area.width.saturating_sub(popup_width)) / 2;
let popup_area = Rect::new(x, y, popup_width, popup_height);
let popup_block = Block::default().title(Line::from(Span::styled(" Help/About ", style_title())).centered()).borders(Borders::ALL).style(style_border());
clear(f, popup_area);
f.render_widget(popup_block, popup_area);
let inner = popup_area.inner(Margin { vertical: 2, horizontal: 2 });
let max_len = help_lines.iter().map(|l| l.len()).max().unwrap_or(0);
let lines: Vec<Line> = help_lines.iter().map(|&text| Line::from(Span::styled(format!("{:<width$}", text, width = max_len), style_title()))).collect();
let help_para = Paragraph::new(lines).alignment(Alignment::Center);
f.render_widget(help_para, inner);
}
fn render_options_popup(f: &mut ratatui::Frame<'_>, area: Rect, app_state: &AppState, editing: Option<&TextInput>) {
let inner_width = 60.min(area.width as usize).saturating_sub(4);
let cursor_style = Style::new().fg(palette().selected_foreground).bg(palette().selected_background).add_modifier(Modifier::BOLD);
let mut list: Vec<Line> = Vec::with_capacity(OPTION_ROWS.len() * 2);
let mut cursor_line = 0;
for (index, &row) in OPTION_ROWS.iter().enumerate() {
if let Some(section) = row.section() {
if !list.is_empty() {
list.push(Line::from(""));
}
list.push(Line::from(Span::styled(format!(" {}", section), style_title().add_modifier(Modifier::BOLD))));
}
if index == app_state.options_cursor {
cursor_line = list.len();
}
list.push(option_line(app_state, row, index == app_state.options_cursor, inner_width, cursor_style, editing));
}
let popup_area = centered(area, inner_width + 4, list.len() + 6);
let mut popup_block = Block::default().title(Line::from(Span::styled(" Options ", style_title())).centered()).borders(Borders::ALL).style(style_border());
let inner = popup_inner(popup_area);
let room = (inner.height as usize).saturating_sub(3).max(1);
let first = (cursor_line + 1).saturating_sub(room);
if room < list.len() {
let position = format!(" {}/{} ", app_state.options_cursor + 1, OPTION_ROWS.len());
popup_block = popup_block.title_bottom(Line::from(Span::styled(position, style_columns())).right_aligned());
}
clear(f, popup_area);
f.render_widget(popup_block, popup_area);
let mut lines = Vec::with_capacity(room + 3);
lines.push(Line::from(""));
lines.extend(list.into_iter().skip(first).take(room));
let hint = if editing.is_none() {
"↑↓ - Move Enter/←→ - Change Esc - Close"
} else if OPTION_ROWS[app_state.options_cursor] == crate::options::OptionRow::Editor {
"Enter - Save Esc - Cancel {} - file"
} else {
"Enter - Save Esc - Cancel {} - directory"
};
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(hint, style_columns())).centered());
f.render_widget(Paragraph::new(lines), inner);
}
fn option_line(app_state: &AppState, row: crate::options::OptionRow, is_cursor: bool, row_width: usize, cursor_style: Style, editing: Option<&TextInput>) -> Line<'static> {
let label = format!(" {}", row.label());
if is_cursor && let Some(input) = editing {
let typed = input.cursor_spans(style_input(), style_input_cursor());
let typed_width: usize = typed.iter().map(|span| display_width(&span.content)).sum();
let gap = row_width.saturating_sub(display_width(&label) + typed_width + 1);
let mut spans = vec![Span::styled(label, cursor_style), Span::styled(" ".repeat(gap), cursor_style)];
spans.extend(typed);
spans.push(Span::styled(" ", cursor_style));
return Line::from(spans);
}
let value = app_state.options.value(row);
let room = row_width.saturating_sub(display_width(&label) + 3);
let value = if display_width(&value) > room {
let tail: String = value.chars().rev().take(room.saturating_sub(1)).collect::<Vec<_>>().into_iter().rev().collect();
format!("…{}", tail)
} else {
value
};
let gap = row_width.saturating_sub(display_width(&label) + display_width(&value) + 1);
if is_cursor {
Line::from(Span::styled(format!("{}{}{} ", label, " ".repeat(gap), value), cursor_style))
} else {
Line::from(vec![Span::styled(format!("{}{}", label, " ".repeat(gap)), style_dir()), Span::styled(format!("{} ", value), style_columns())])
}
}
fn render_create_popup(f: &mut ratatui::Frame<'_>, area: Rect, is_dir: bool, input: &TextInput) {
let popup_area = centered_rect(60, 20, area);
let popup_block = Block::default()
.title(Line::from(Span::styled(if is_dir { " Create Directory " } else { " Create File " }, style_title())).centered())
.borders(Borders::ALL)
.style(style_border());
clear(f, popup_area);
f.render_widget(popup_block, popup_area);
let typed = input.cursor_spans(style_input(), style_input_cursor());
let width = popup_inner(popup_area).width as usize;
let typed_width: usize = typed.iter().map(|span| display_width(&span.content)).sum();
let left = width.saturating_sub(typed_width) / 2;
let mut spans = vec![Span::raw(" ".repeat(left))];
spans.extend(typed);
spans.push(Span::raw(" ".repeat(width.saturating_sub(left + typed_width))));
let input_line = Line::from(spans).style(style_input());
popup_body(f, popup_area, vec![input_line, Line::from(Span::styled("Enter - Create Esc - Cancel", style_columns()))]);
}
fn render_delete_popup(f: &mut ratatui::Frame<'_>, area: Rect, items: &[(PathBuf, bool)], to_trash: bool) {
let count = items.len();
let popup_area = centered_rect(60, 30, area);
let verb = if to_trash { "Move to trash" } else { "Delete" };
let title = if count == 1 {
let item_type = if items[0].1 { "directory" } else { "file" };
format!(" {verb}: {item_type} ")
} else {
format!(" {verb}: {count} items ")
};
let popup_block = Block::default().title(Line::from(Span::styled(title, style_title())).centered()).borders(Borders::ALL).style(style_border());
clear(f, popup_area);
f.render_widget(popup_block, popup_area);
let mut lines = Vec::new();
let question = if to_trash { "Move {} to the trash?" } else { "Delete {} permanently?" };
if count == 1 {
let name = format!("\"{}\"", shown_name(&items[0].0));
lines.push(Line::from(Span::styled(question.replace("{}", &name), style_title())));
} else {
let names: Vec<String> = items.iter().map(|(path, _)| shown_name(path)).collect();
lines.push(Line::from(Span::styled(question.replace("{}", &format!("{count} items")), style_title())));
lines.push(Line::from(Span::styled(names.join(", "), style_file())));
}
if !to_trash {
lines.push(Line::from(Span::styled("This cannot be undone", style_file())));
}
lines.push(Line::from(Span::styled("Y / Enter - Yes N / Esc - No", style_columns())));
popup_body(f, popup_area, lines);
}
fn render_job_clash(f: &mut ratatui::Frame<'_>, area: Rect, app_state: &AppState, clash: &crate::app::JobClash) {
let popup_area = centered_rect(64, 40, area);
let popup_block = Block::default().title(Line::from(Span::styled(" Already there ", style_title())).centered()).borders(Borders::ALL).style(style_border());
clear(f, popup_area);
f.render_widget(popup_block, popup_area);
let folder = clash.dest.parent().map(|parent| printable_name(&parent.to_string_lossy())).unwrap_or_default();
let exact = clash.source_size != clash.dest_size && clash.source_size.map(format_size) == clash.dest_size.map(format_size);
let width = if exact { 15 } else { 9 };
let describe = |label: &str, size: Option<u64>, modified: Option<std::time::SystemTime>, other: Option<std::time::SystemTime>| {
let size = size.map(|bytes| if exact { format!("{} B", grouped(bytes)) } else { format_size(bytes) }).unwrap_or_else(|| "?".to_string());
let date = modified.map(|at| format_exact(at, app_state.options.date_format)).unwrap_or_else(|| "?".to_string());
let newer = matches!((modified, other), (Some(this), Some(that)) if this > that);
format!("{label} {size:>width$} {date}{}", if newer { " newer" } else { " " })
};
let mut lines = vec![Line::from(Span::styled(format!("\"{}\" is already in {folder}", shown_name(&clash.dest)), style_title()))];
if clash.source.file_name() != clash.dest.file_name() {
lines.push(Line::from(Span::styled(format!("to be replaced by \"{}\"", shown_name(&clash.source)), style_file())));
}
lines.extend([
Line::from(Span::styled(describe("New ", clash.source_size, clash.source_modified, clash.dest_modified), style_file())),
Line::from(Span::styled(describe("Existing", clash.dest_size, clash.dest_modified, clash.source_modified), style_file())),
Line::from(Span::styled("Y - Overwrite N - Skip O - Overwrite all older", style_columns())),
Line::from(Span::styled("A - Overwrite all S - Skip all Esc - Abort", style_columns())),
]);
if app_state.quit_armed {
lines.push(Line::from(Span::styled("F10 again - Quit", style_file())));
}
popup_body(f, popup_area, lines);
}
fn progress_bar(fraction: f64, width: usize) -> String {
let filled = ((fraction.clamp(0.0, 1.0) * width as f64).round() as usize).min(width);
"\u{25aa}".repeat(filled) + &"\u{25ab}".repeat(width - filled)
}
fn render_transfer_popup(f: &mut ratatui::Frame<'_>, area: Rect, app_state: &AppState) {
let Some(job) = &app_state.job else {
return;
};
if let Some(clash) = &job.clash {
render_job_clash(f, area, app_state, clash);
return;
}
if let Some(problem) = &job.problem {
render_job_problem(f, area, job.kind, problem, app_state.quit_armed);
return;
}
let elapsed = job.started.elapsed();
if elapsed < TRANSFER_POPUP_DELAY {
return;
}
let title = if job.is_cancelling() {
" Cancelling ".to_string()
} else {
match job.kind {
TransferKind::Copy => " Copying ".to_string(),
TransferKind::Move => " Moving ".to_string(),
TransferKind::Delete => " Deleting ".to_string(),
TransferKind::Trash => " Moving to trash ".to_string(),
}
};
let popup_area = centered_rect(60, 30, area);
let popup_block = Block::default().title(Line::from(Span::styled(title, style_title())).centered()).borders(Borders::ALL).style(style_border());
clear(f, popup_area);
f.render_widget(popup_block, popup_area);
let name = shown_name(&job.current);
let bar_width = popup_area.width.saturating_sub(14) as usize;
let bar = match job.fraction() {
Some(fraction) => format!("{} {:>3.0}%", progress_bar(fraction, bar_width), fraction * 100.0),
None => "Counting...".to_string(),
};
let hint = if app_state.quit_armed { "Esc - Cancel F10 again - Quit" } else { "Esc - Cancel" };
let detail = match (job.kind, job.total) {
(TransferKind::Delete, Some(total)) if total > 0 => {
format!("{} of {} entries {hint}", job.done, total)
}
(TransferKind::Trash, Some(total)) if total > 0 => {
format!("{} of {} items {hint}", job.done, total)
}
(_, Some(total)) if total > 0 => {
let rate = job.done as f64 / elapsed.as_secs_f64().max(0.001);
format!("{} of {} at {}/s {hint}", format_size(job.done), format_size(total), format_size(rate as u64))
}
_ => hint.to_string(),
};
let lines = vec![
Line::from(""),
Line::from(Span::styled(name.to_string(), style_title())),
Line::from(""),
Line::from(Span::styled(bar, style_columns())),
Line::from(""),
Line::from(Span::styled(detail, style_file())),
];
f.render_widget(Paragraph::new(lines).alignment(Alignment::Center), popup_area.inner(Margin { vertical: 1, horizontal: 2 }));
}
fn render_job_problem(f: &mut ratatui::Frame<'_>, area: Rect, kind: TransferKind, problem: &crate::app::JobProblem, quit_armed: bool) {
let title = match kind {
TransferKind::Copy => " Cannot copy ",
TransferKind::Move => " Cannot move ",
TransferKind::Delete => " Cannot delete ",
TransferKind::Trash => " Cannot move to trash ",
};
let popup_area = centered_rect(60, 30, area);
let popup_block = Block::default().title(Line::from(Span::styled(title, style_title())).centered()).borders(Borders::ALL).style(style_border());
clear(f, popup_area);
f.render_widget(popup_block, popup_area);
let hint = "R - Retry S - Skip A - Skip all Esc - Abort";
let mut lines = vec![
Line::from(""),
Line::from(Span::styled(shown_name(&problem.path), style_title())),
Line::from(""),
Line::from(Span::styled(printable_name(&problem.message), style_columns())),
Line::from(""),
Line::from(Span::styled(hint, style_file())),
];
if kind == TransferKind::Trash {
lines.push(Line::from(Span::styled("D - Delete permanently", style_file())));
}
if quit_armed {
lines.push(Line::from(Span::styled("F10 again - Quit", style_file())));
}
f.render_widget(Paragraph::new(lines).alignment(Alignment::Center).wrap(ratatui::widgets::Wrap { trim: true }), popup_area.inner(Margin { vertical: 1, horizontal: 2 }));
}
fn render_disk_changed_popup(f: &mut ratatui::Frame<'_>, area: Rect, app_state: &AppState, opened: Option<crate::app::DiskStamp>, now: Option<crate::app::DiskStamp>) {
let popup_area = centered_rect(64, 40, area);
let popup_block = Block::default().title(Line::from(Span::styled(" Changed on disk ", style_title())).centered()).borders(Borders::ALL).style(style_border());
clear(f, popup_area);
f.render_widget(popup_block, popup_area);
let name = app_state.editor().map(|state| shown_name(&state.file_path)).unwrap_or_default();
let stamp = |label: &str, stamp: Option<crate::app::DiskStamp>| {
let shown = match stamp {
Some(stamp) => {
let date = stamp.modified.map(|at| format_exact(at, app_state.options.date_format)).unwrap_or_else(|| "?".to_string());
format!("{:>9} {date}", format_size(stamp.size))
}
None => "not there".to_string(),
};
Line::from(Span::styled(format!("{label:<12}{shown}"), style_file()))
};
let mut lines = match now {
Some(_) => vec![Line::from(Span::styled(format!("\"{name}\" has changed since it was opened"), style_title()))],
None => vec![Line::from(Span::styled(format!("\"{name}\" is no longer there"), style_title()))],
};
lines.push(stamp("When opened", opened));
lines.push(stamp("Now", now));
let keys = match now {
Some(_) => "O - Overwrite it R - Reload, losing your edits",
None => "O - Save it again",
};
lines.push(Line::from(Span::styled(keys, style_columns())));
lines.push(Line::from(Span::styled("Esc - Back to editing", style_columns())));
popup_body(f, popup_area, lines);
}
fn render_find_files_form(f: &mut ratatui::Frame<'_>, area: Rect, start: &Path, name: &TextInput, containing: &TextInput, on_containing: bool) {
let popup_area = centered_rect(70, 45, area);
let popup_block = Block::default().title(Line::from(Span::styled(" Find files ", style_title())).centered()).borders(Borders::ALL).style(style_border());
clear(f, popup_area);
f.render_widget(popup_block, popup_area);
let width = popup_inner(popup_area).width as usize;
const LABEL: usize = 12;
let field = |label: &str, input: &TextInput, focused: bool| {
let room = width.saturating_sub(LABEL);
let mut spans = vec![Span::styled(format!("{label:<LABEL$}"), style_columns())];
let typed = if focused {
input.cursor_spans_within(room, style_input(), style_input_cursor())
} else {
vec![Span::styled(tail_of(&input.text, room), style_file())]
};
let typed_width: usize = typed.iter().map(|span| display_width(&span.content)).sum();
spans.extend(typed);
let padding = " ".repeat(room.saturating_sub(typed_width));
spans.push(if focused { Span::styled(padding, style_input()) } else { Span::raw(padding) });
Line::from(spans)
};
popup_body(
f,
popup_area,
vec![
Line::from(Span::styled(
format!("{:<LABEL$}{:<room$}", "In", tail_of(&start.to_string_lossy(), width.saturating_sub(LABEL)), room = width.saturating_sub(LABEL)),
style_file(),
)),
field("Name", name, !on_containing),
field("Containing", containing, on_containing),
Line::from(Span::styled("Name as + takes it: *.jpg;*.png, or */ for directories. Lower case matches either case", style_columns())),
Line::from(Span::styled("Tab - Next field Enter - Find Esc - Cancel", style_columns())),
],
);
}
fn render_find_files_results(f: &mut ratatui::Frame<'_>, area: Rect, search: &crate::app::Search) -> usize {
let popup_area = centered_rect(80, 80, area);
let title = format!(" Find: {} ", printable_name(&search.label));
let popup_block = Block::default().title(Line::from(Span::styled(title, style_title())).centered()).borders(Borders::ALL).style(style_border());
clear(f, popup_area);
f.render_widget(popup_block, popup_area);
let inner = popup_area.inner(Margin { vertical: 1, horizontal: 2 });
let width = inner.width as usize;
let rows = (inner.height as usize).saturating_sub(3);
let found = search.results.len();
let status = match &search.looking_in {
Some(dir) => {
let said = format!("{found} found, searching ");
format!("{said}{}", tail_of(&dir.to_string_lossy(), width.saturating_sub(display_width(&said) + 1)))
}
None if found == 0 => "Nothing found".to_string(),
None => format!("{found} found"),
};
let status = match search.unreadable {
0 => status,
1 => format!("{status} - 1 directory could not be read"),
many => format!("{status} - {many} directories could not be read"),
};
let mut lines = vec![Line::from(Span::styled(status, style_title())), Line::from("")];
let mut first = search.scroll.min(found.saturating_sub(rows)).min(search.cursor);
if rows > 0 && search.cursor >= first + rows {
first = search.cursor + 1 - rows;
}
for (index, (path, is_dir)) in search.results.iter().enumerate().skip(first).take(rows) {
let shown = path.strip_prefix(&search.start).unwrap_or(path).to_string_lossy().into_owned();
let shown = if *is_dir { format!("{shown}{}", std::path::MAIN_SEPARATOR) } else { shown };
let text = tail_of(&shown, width);
let pad = " ".repeat(width.saturating_sub(display_width(&text)));
let style = match (index == search.cursor, *is_dir) {
(true, _) => style_title().bg(palette().selected_background),
(false, true) => style_dir(),
(false, false) => style_file(),
};
lines.push(Line::from(Span::styled(format!("{text}{pad}"), style)));
}
while lines.len() < rows + 2 {
lines.push(Line::from(""));
}
let hint = if search.done { "Enter - Go to Esc - Close" } else { "Enter - Go to Esc - Stop and close" };
lines.push(Line::from(Span::styled(hint, style_columns())).centered());
f.render_widget(Paragraph::new(lines), inner);
rows
}
fn render_attributes_popup(f: &mut ratatui::Frame<'_>, area: Rect, form: &AttributesForm) -> Vec<(Rect, u16)> {
let popup_area = centered_rect(70, 50, area);
let popup_block = Block::default().title(Line::from(Span::styled(" Attributes ", style_title())).centered()).borders(Borders::ALL).style(style_border());
clear(f, popup_area);
f.render_widget(popup_block, popup_area);
let width = popup_inner(popup_area).width as usize;
const LABEL: usize = 10;
const BECOMES: usize = 12;
let what = match form.items.as_slice() {
[(path, is_dir)] => format!("{} \"{}\"", if *is_dir { "Directory" } else { "File" }, shown_name(path)),
items => format!("{} items - a field left as it is, or empty, changes nothing", items.len()),
};
let mut lines = vec![Line::from(Span::styled(tail_of(&what, width), style_file()))];
for (index, field) in form.fields.iter().enumerate() {
let focused = index == form.focus;
let becomes = match (field.kind, form.items.as_slice(), form.modes.first()) {
(AttributeKind::Mode, [(_, is_dir)], Some(&mode)) => crate::attributes::ModeChange::parse(&field.input.text).ok().map(|change| mode_preview(change.apply(mode, *is_dir))),
_ => None,
};
let room = width.saturating_sub(LABEL + if field.kind == AttributeKind::Mode { BECOMES } else { 0 });
let mut spans = vec![Span::styled(format!("{:<LABEL$}", field.kind.label()), style_columns())];
let typed = if focused {
field.input.cursor_spans_within(room, style_input(), style_input_cursor())
} else {
vec![Span::styled(tail_of(&field.input.text, room), style_file())]
};
let typed_width: usize = typed.iter().map(|span| display_width(&span.content)).sum();
spans.extend(typed);
let padding = " ".repeat(room.saturating_sub(typed_width));
spans.push(if focused { Span::styled(padding, style_input()) } else { Span::raw(padding) });
if field.kind == AttributeKind::Mode {
spans.push(Span::styled(format!("{:>BECOMES$}", becomes.unwrap_or_default()), style_file()));
}
lines.push(Line::from(spans));
}
if form.fields.len() > 1 {
lines.push(Line::from(Span::styled("Mode as 755 or u+x,go-w; owner and group by name or number", style_columns())));
}
lines.push(Line::from(Span::styled("Tab - Next field Enter - Apply Esc - Cancel", style_columns())));
let inner = popup_inner(popup_area);
let starts = popup_body(f, popup_area, lines);
starts[1..=form.fields.len()].iter().flatten().map(|&y| (Rect::new(inner.x, y, inner.width, 1), inner.x + LABEL as u16)).collect()
}
#[cfg(unix)]
fn mode_preview(mode: u32) -> String {
crate::fs_ops::mode_string(mode)
}
#[cfg(not(unix))]
fn mode_preview(_mode: u32) -> String {
String::new()
}
fn render_bookmarks_popup(f: &mut ratatui::Frame<'_>, area: Rect, list: &[PathBuf], index: usize, here: &Path) {
let popup_area = centered_rect(70, 60, area);
let popup_block = Block::default().title(Line::from(Span::styled(" Bookmarks ", style_title())).centered()).borders(Borders::ALL).style(style_border());
clear(f, popup_area);
f.render_widget(popup_block, popup_area);
let inner = popup_area.inner(Margin { vertical: 1, horizontal: 2 });
let width = inner.width as usize;
let rows = (inner.height as usize).saturating_sub(3);
let home = std::env::var_os("HOME").map(PathBuf::from).filter(|home| !home.as_os_str().is_empty());
let mut lines = Vec::new();
if list.is_empty() {
lines.push(Line::from(Span::styled("No bookmarks yet - A keeps the panel's directory", style_file())));
}
let first = if rows > 0 && index >= rows { index + 1 - rows } else { 0 };
for (number, path) in list.iter().enumerate().skip(first).take(rows) {
let shown = match home.as_ref().and_then(|home| path.strip_prefix(home).ok()) {
Some(rest) if rest.as_os_str().is_empty() => "~".to_string(),
Some(rest) => format!("~{}{}", std::path::MAIN_SEPARATOR, rest.to_string_lossy()),
None => path.to_string_lossy().into_owned(),
};
let label = if number < 9 { format!("{} ", number + 1) } else { " ".to_string() };
let mark = if path == here { "• " } else { " " };
let room = width.saturating_sub(label.len() + mark.len());
let text = format!("{label}{mark}{}", tail_of(&printable_name(&shown), room));
let pad = " ".repeat(width.saturating_sub(display_width(&text)));
let style = if number == index { style_title().bg(palette().selected_background) } else { style_dir() };
lines.push(Line::from(Span::styled(format!("{text}{pad}"), style)));
}
while lines.len() < rows + 1 {
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled("Enter/1-9 - Go A/Ins - Add here D/Del - Remove", style_columns())).centered());
lines.push(Line::from(Span::styled("Shift+Up/Down - Move Esc - Close", style_columns())).centered());
f.render_widget(Paragraph::new(lines), inner);
}
fn render_destination_popup(f: &mut ratatui::Frame<'_>, area: Rect, sources: &[(PathBuf, bool)], verb: &str, preposition: &str, input: &TextInput) {
let popup_area = centered_rect(70, 35, area);
let count = sources.len();
let title = if count == 1 {
let item_type = if sources[0].1 { "directory" } else { "file" };
format!(" {} {} ", verb, item_type)
} else {
format!(" {} {} items ", verb, count)
};
let popup_block = Block::default().title(Line::from(Span::styled(title, style_title())).centered()).borders(Borders::ALL).style(style_border());
clear(f, popup_area);
f.render_widget(popup_block, popup_area);
let source_msg = if count == 1 {
format!("{} \"{}\" {}:", verb, shown_name(&sources[0].0), preposition)
} else {
let names: Vec<String> = sources.iter().map(|(source, _)| shown_name(source)).collect();
format!("{} {} items {}: {}", verb, count, preposition, names.join(", "))
};
let width = popup_inner(popup_area).width as usize;
let typed = input.cursor_spans_within(width, style_input(), style_input_cursor());
let typed_width: usize = typed.iter().map(|span| display_width(&span.content)).sum();
let mut spans = typed;
spans.push(Span::raw(" ".repeat(width.saturating_sub(typed_width))));
let input_line = Line::from(spans).style(style_input());
popup_body(
f,
popup_area,
vec![Line::from(Span::styled(source_msg, style_title())), input_line, Line::from(Span::styled(format!("Enter - {verb} Esc - Cancel"), style_columns()))],
);
}
fn render_large_file_popup(f: &mut ratatui::Frame<'_>, area: Rect, large: &crate::app::LargeFile) {
let name = shown_name(&large.path);
let verb = if large.is_edit { "Edit" } else { "View" };
let popup_area = centered_rect(60, 30, area);
let title = if large.dimensions.is_some() { " Large Picture " } else { " Large File " };
let popup_block = Block::default().title(Line::from(Span::styled(title, style_title())).centered()).borders(Borders::ALL).style(style_border());
clear(f, popup_area);
f.render_widget(popup_block, popup_area);
let message = match large.dimensions {
Some((width, height)) => format!("{} \"{}\" ({}x{}, {})?", verb, name, width, height, format_size(large.size)),
None => format!("{} \"{}\" ({})?", verb, name, format_size(large.size)),
};
let note = if large.dimensions.is_some() {
"Decoding a picture this size needs that much memory."
} else {
"Reading a file this large may take a while."
};
popup_body(
f,
popup_area,
vec![
Line::from(Span::styled(message, style_title())),
Line::from(Span::styled(note, style_file())),
Line::from(Span::styled("Y / Enter - Yes N / Esc - No", style_columns())),
],
);
}
fn render_editor_save_popup(f: &mut ratatui::Frame<'_>, area: Rect) {
let popup_area = centered_rect(60, 25, area);
let popup_block = Block::default().title(Line::from(Span::styled(" Unsaved Changes ", style_title())).centered()).borders(Borders::ALL).style(style_border());
clear(f, popup_area);
f.render_widget(popup_block, popup_area);
popup_body(
f,
popup_area,
vec![
Line::from(Span::styled("Save changes before closing?", style_title())),
Line::from(Span::styled("Y - Save N - Discard Esc - Cancel", style_columns())),
],
);
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage((100 - percent_y) / 2), Constraint::Percentage(percent_y), Constraint::Percentage((100 - percent_y) / 2)])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage((100 - percent_x) / 2), Constraint::Percentage(percent_x), Constraint::Percentage((100 - percent_x) / 2)])
.split(popup_layout[1])[1]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_branch_after_the_path_and_who_by_the_logo() {
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.dir_left = PathBuf::from("/home/colt/fm84");
app_state.branch_left = Some("main".to_string());
app_state.user_at_host = ("colt@laserbeak".to_string(), false);
let row = |width: u16, y: u16, app_state: &mut AppState| {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 30)).unwrap();
render_ui(&mut terminal, app_state);
(0..width).map(|x| terminal.backend().buffer()[(x, y)].symbol().to_string()).collect::<String>()
};
let path_bar = row(100, 3, &mut app_state);
assert!(path_bar.contains(&format!("/home/colt/fm84 ─ {ICON_BRANCH} main ─")), "{path_bar}");
let top = row(100, 0, &mut app_state);
assert!(top.contains(&format!("{} ─ colt@laserbeak ─", logo_icon(IconStyle::NerdFont))), "{top}");
app_state.options.icon_style = IconStyle::Plain;
assert!(row(100, 3, &mut app_state).contains("/home/colt/fm84 ─ git:main ─"));
let narrow = row(44, 3, &mut app_state);
assert!(!narrow.contains("git:main"), "{narrow}");
assert!(!row(44, 0, &mut app_state).contains("colt@"));
}
#[test]
fn tabs_are_drawn_and_clicked_where_they_are() {
assert_eq!(tab_label(Path::new("/")), "/");
assert_eq!(tab_label(Path::new("/home/colt/src")), "src");
assert_eq!(tab_label(Path::new("/tmp/a-rather-long-directory-name")), "a-rather-long-d…");
let mut app_state = AppState::new();
app_state.is_left_active = true;
app_state.dir_left = "/tmp/b".into();
app_state.tabs_left = crate::app::Tabs::new(vec!["/a".into(), "/tmp/b".into()], 1);
tab_strip(&mut app_state, true, Rect::new(0, 1, 40, 1));
assert_eq!(app_state.tab_at(0, 1), None);
assert_eq!(app_state.tab_at(1, 1), Some((true, StripHit::Item(0))));
assert_eq!(app_state.tab_at(3, 1), Some((true, StripHit::Item(0))));
assert_eq!(app_state.tab_at(4, 1), Some((true, StripHit::Item(1))));
assert_eq!(app_state.tab_at(7, 1), None);
assert_eq!(app_state.tab_at(4, 0), None);
}
#[test]
fn a_crowded_drive_strip_scrolls() {
use crate::fs_ops::{Mount, MountKind};
let mut app_state = AppState::new();
app_state.options.icon_style = IconStyle::Plain;
app_state.dir_left = "/".into();
app_state.mounts = (0..12)
.map(|index| Mount {
path: format!("/mnt/{index}").into(),
label: format!("d{index}"),
kind: MountKind::Disk,
})
.collect();
let area = Rect::new(0, 0, 30, 1);
drive_strip(&mut app_state, true, area);
let strip = &app_state.drive_strip_left;
assert_eq!((strip.first, strip.visible), (0, 5));
assert_eq!(strip.hits.first(), Some(&StripHit::Item(0)));
assert_eq!(strip.hits.last(), Some(&StripHit::Forward));
assert_eq!(app_state.drive_at(1, 0), None); assert_eq!(app_state.drive_at(2, 0), Some((true, StripHit::Item(0))));
assert_eq!(app_state.drive_at(27, 0), Some((true, StripHit::Forward)));
app_state.scroll_drive_strip(true, true);
drive_strip(&mut app_state, true, area);
assert_eq!(app_state.drive_at(0, 0), Some((true, StripHit::Back)));
assert_eq!(app_state.drive_at(2, 0), Some((true, StripHit::Item(5))));
app_state.scroll_drive_strip(true, true);
drive_strip(&mut app_state, true, area);
assert_eq!(app_state.drive_strip_left.first, 7);
assert!(!app_state.drive_strip_left.hits.contains(&StripHit::Forward));
drive_strip(&mut app_state, true, Rect::new(0, 0, 80, 1));
assert_eq!(app_state.drive_strip_left.first, 0);
assert_eq!(app_state.drive_at(1, 0), Some((true, StripHit::Item(0))));
}
#[test]
fn a_hostile_name_never_reaches_the_terminal() {
let dir = std::env::temp_dir().join(format!("fm84-hostile-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let nasty = "a\u{1b}[41;97mHACKED\u{1b}[0m\tbell\u{7}.txt";
std::fs::write(dir.join(nasty), "contents").unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.show_preview = true;
app_state.open_dir(true, dir.clone(), Some(nasty.as_ref()));
app_state.refresh_cursor_detail();
app_state.refresh_preview();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
let mut clean = |app_state: &mut AppState, what: &str| {
render_ui(&mut terminal, app_state);
let dirty: Vec<String> = terminal.backend().buffer().content().iter().filter(|cell| cell.symbol().chars().any(char::is_control)).map(|cell| format!("{:?}", cell.symbol())).collect();
assert!(dirty.is_empty(), "{what} drew {dirty:?}");
};
clean(&mut app_state, "the panel");
app_state.dialog = Some(Dialog::Delete {
items: vec![(dir.join(nasty), false)],
to_trash: true,
});
clean(&mut app_state, "the delete popup");
let mut input = TextInput::new();
input.set(nasty.to_string());
app_state.dialog = Some(Dialog::Rename(input));
clean(&mut app_state, "the rename field");
let mut input = TextInput::new();
input.set(nasty.to_string());
app_state.dialog = Some(Dialog::Prompt(crate::app::PromptKind::GoTo, input));
clean(&mut app_state, "the go to field");
app_state.dialog = None;
app_state.error = Some(format!("No such file or directory: {nasty}"));
clean(&mut app_state, "the error popup");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn every_input_field_wears_the_rename_colours() {
use crate::app::PromptKind;
use crate::options::CursorShape;
use ratatui::backend::Backend;
let dir = std::env::temp_dir().join(format!("fm84-input-fields-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("file.txt"), "").unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.is_left_active = true;
app_state.open_dir(true, dir.clone(), Some("file.txt".as_ref()));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
let typed = || {
let mut input = TextInput::new();
input.set("typed".to_string());
input
};
let dialogs: Vec<(&str, Dialog)> = vec![
("F2", Dialog::Rename(typed())),
("F7", Dialog::Create { is_dir: true, input: typed() }),
(
"F5",
Dialog::Transfer {
sources: vec![(std::path::PathBuf::from("/a"), false)],
is_copy: true,
input: typed(),
opened_with: String::new(),
default: std::path::PathBuf::from("/b"),
},
),
(
"Ctrl+Shift+F5",
Dialog::Symlink {
sources: vec![(std::path::PathBuf::from("/a"), false)],
input: typed(),
opened_with: String::new(),
default: std::path::PathBuf::from("/b"),
},
),
("+", Dialog::Prompt(PromptKind::Select, typed())),
("Ctrl+L", Dialog::Prompt(PromptKind::GoTo, typed())),
("Options", Dialog::Options { editing: Some(typed()) }),
];
let reversed = |terminal: &Terminal<ratatui::backend::TestBackend>| terminal.backend().buffer().content().iter().filter(|cell| cell.modifier.contains(Modifier::REVERSED) && cell.bg == palette().rename_background).count();
let marked = |terminal: &Terminal<ratatui::backend::TestBackend>| {
terminal
.backend()
.buffer()
.content()
.iter()
.any(|cell| cell.modifier.intersects(FIELD_CURSOR | EDITOR_CURSOR | crate::app::CURSOR_AFTER | crate::app::PAST_END))
};
for (what, dialog) in dialogs {
app_state.dialog = Some(dialog);
for shape in [CursorShape::Bar, CursorShape::Classic] {
app_state.options.cursor = shape;
render_ui(&mut terminal, &mut app_state);
assert!(!marked(&terminal), "{what}, {shape:?}: a mark reached the terminal");
if shape == CursorShape::Classic {
assert_eq!(reversed(&terminal), 1, "{what}: no cursor in the rename colours");
} else {
let at = terminal.backend_mut().get_cursor_position().unwrap();
let last = &terminal.backend().buffer()[(at.x - 1, at.y)];
assert_eq!((last.symbol(), last.bg), ("d", palette().rename_background), "{what}: the cursor is not after the text");
assert_eq!(reversed(&terminal), 0, "{what}: a cell reversed as well");
}
let text = terminal
.backend()
.buffer()
.content()
.iter()
.filter(|cell| cell.bg == palette().rename_background && cell.fg == palette().selected_foreground && !cell.modifier.contains(Modifier::REVERSED))
.count();
assert!(text >= "typed".len() - 1, "{what}: typed text not in the rename colours");
}
}
app_state.open_find_files();
render_ui(&mut terminal, &mut app_state);
assert_eq!(reversed(&terminal), 1, "Alt+F7");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn nothing_is_added_past_the_end_for_the_cursor() {
use crate::options::CursorShape;
let dir = std::env::temp_dir().join(format!("fm84-past-end-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("doc")).unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.is_left_active = true;
app_state.open_dir(true, dir.clone(), Some("doc".as_ref()));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
let screen = |terminal: &Terminal<ratatui::backend::TestBackend>| terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect::<String>();
let typed = |text: &str| {
let mut input = TextInput::new();
input.set(text.to_string());
input
};
app_state.dialog = Some(Dialog::Rename(typed("doc")));
render_ui(&mut terminal, &mut app_state);
assert!(screen(&terminal).contains("[doc]"), "F2 on a directory");
app_state.options.cursor = CursorShape::Classic;
render_ui(&mut terminal, &mut app_state);
assert!(screen(&terminal).contains("[doc ]"), "Classic's cursor has a cell");
app_state.options.cursor = CursorShape::Bar;
app_state.dialog = Some(Dialog::Prompt(PromptKind::GoTo, typed("/somewhere")));
render_ui(&mut terminal, &mut app_state);
assert!(screen(&terminal).contains("/somewhere ─"), "Ctrl+L");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_editor_cursor_gives_way_to_a_prompt() {
use crate::options::CursorShape;
use ratatui::backend::Backend;
let file = std::env::temp_dir().join(format!("fm84-editor-cursor-{}.txt", std::process::id()));
std::fs::write(&file, "hello\n").unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.open_editor(file.clone()).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
render_ui(&mut terminal, &mut app_state);
let in_editor = terminal.backend_mut().get_cursor_position().unwrap();
assert_eq!(terminal.backend().buffer()[in_editor].symbol(), "h");
app_state.open_prompt(crate::app::PromptKind::Find);
app_state.dialog_input().unwrap().set("he".to_string());
render_ui(&mut terminal, &mut app_state);
let at = terminal.backend_mut().get_cursor_position().unwrap();
let last = &terminal.backend().buffer()[(at.x - 1, at.y)];
assert_eq!((last.symbol(), last.bg), ("e", palette().rename_background), "the prompt has the cursor");
app_state.dialog = None;
app_state.options.cursor = CursorShape::Classic;
render_ui(&mut terminal, &mut app_state);
let cell = &terminal.backend().buffer()[in_editor];
assert_eq!((cell.symbol(), cell.bg), ("h", palette().selected_background));
std::fs::remove_file(&file).unwrap();
}
#[test]
fn go_to_is_typed_over_the_panels_own_path() {
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.dir_left = std::path::PathBuf::from("/left/dir");
app_state.dir_right = std::path::PathBuf::from("/right/dir");
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 20)).unwrap();
let row = |terminal: &Terminal<ratatui::backend::TestBackend>, y: u16| -> String {
let buffer = terminal.backend().buffer();
(0..buffer.area.width).map(|x| buffer[(x, y)].symbol().to_string()).collect()
};
for (is_left, typed) in [(true, "/typed/here"), (false, "/somewhere/else"), (true, &format!("/{}/end", "long".repeat(30))[..])] {
app_state.is_left_active = is_left;
let mut input = TextInput::new();
input.set(typed.to_string());
app_state.dialog = Some(Dialog::Prompt(PromptKind::GoTo, input));
render_ui(&mut terminal, &mut app_state);
let bar = row(&terminal, 3);
let (left, right) = bar.split_at(bar.find('┬').unwrap());
let (typed_in, other) = if is_left { (left, right) } else { (right, left) };
assert!(typed_in.contains(if typed.len() > 40 { "long/end" } else { typed }), "{bar}");
assert!(other.contains(if is_left { "/right/dir" } else { "/left/dir" }), "{bar}");
assert!(bar.starts_with("├──") && bar.ends_with("─┤") && bar.chars().count() == 80, "{bar}");
assert!(!row(&terminal, 19).contains("Go to"));
}
}
#[test]
fn hex_preview_fits_the_pane_it_is_given() {
let bytes: Vec<u8> = (0..=255u8).collect();
let mut seen = Vec::new();
for width in 1..100u16 {
let rows = hex_preview(&bytes, width, 4);
let widest = rows.iter().map(|row| row.chars().count()).max().unwrap();
if width > 1 + crate::viewer::hex_row_width(4, 0) as u16 {
assert!(widest < width as usize, "{width} columns held a {widest}-column row");
}
let bytes_shown: usize = rows.iter().map(|row| row.chars().filter(|c| *c == '|').count()).sum();
assert_eq!(bytes_shown, 8, "{width}: every row keeps its gutter");
seen.push((width, widest));
}
assert!(seen.windows(2).all(|pair| pair[0].1 <= pair[1].1), "{seen:?}");
assert_eq!(hex_preview(&bytes, 100, 4)[0].chars().count(), crate::viewer::hex_row_width(16, 4));
}
#[test]
fn scrolling_sideways_goes_past_what_a_u16_holds() {
let text = |spans: &[Span<'static>]| spans.iter().map(|span| span.content.as_ref()).collect::<String>();
let spans = vec![Span::raw("ab"), Span::styled("cdef", style_title())];
assert_eq!(text(&skip_columns(spans.clone(), 0)), "abcdef");
assert_eq!(text(&skip_columns(spans.clone(), 3)), "def");
assert_eq!(skip_columns(spans.clone(), 3)[0].style, style_title());
assert!(skip_columns(spans, 10).is_empty());
assert_eq!(text(&skip_columns(vec![Span::raw("界x")], 1)), " x");
let long = format!("{}END", "x".repeat(70_000));
assert_eq!(text(&skip_columns(vec![Span::raw(long)], 70_000)), "END");
}
#[test]
fn popup_text_wraps_at_spaces_and_keeps_the_last_line_in_view() {
assert_eq!(wrap_text("one two three", 7), ["one two", "three"]);
assert_eq!(wrap_text("short", 20), ["short"]);
assert_eq!(wrap_text("abcdefghij", 4), ["abcd", "efgh", "ij"]);
assert_eq!(wrap_text("", 4), [""]);
assert_eq!(wrap_text("界界界", 4), ["界界", "界"]);
let names = (0..60).map(|n| format!("file{n:02}")).collect::<Vec<_>>().join(", ");
let lines = vec![Line::from(names), Line::from("to: /tmp"), Line::from("Y / Enter - Yes")];
let blocks = fit_rows(lines.into_iter().map(|line| wrap_line(line, 30)).collect(), 7);
let rows: Vec<String> = blocks.iter().flatten().map(|line| line.to_string()).collect();
assert_eq!(rows.len(), 7);
assert!(rows[4].ends_with('…'), "{rows:?}");
assert_eq!(rows[5..], ["to: /tmp", "Y / Enter - Yes"]);
}
#[test]
fn matches_on_wrapped_rows_are_marked_where_they_fall() {
let path = std::env::temp_dir().join(format!("fm84-wrap-marks-{}.txt", std::process::id()));
let words: Vec<String> = (0..40).map(|n| if n % 7 == 3 { "needle".to_string() } else { format!("w{n:02}") }).collect();
std::fs::write(&path, words.join(" ")).unwrap();
let mut app_state = AppState::new();
app_state.open_viewer(path.clone()).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 20)).unwrap();
render_ui(&mut terminal, &mut app_state);
app_state.viewer_toggle_wrap();
app_state.find_term = "needle".to_string();
app_state.find_shown = true;
render_ui(&mut terminal, &mut app_state);
let buffer = terminal.backend().buffer().clone();
let mut marked = Vec::new();
for y in 0..20 {
let mut run = String::new();
for x in 0..40 {
let cell = &buffer[(x, y)];
if cell.modifier.contains(Modifier::UNDERLINED) {
run.push_str(cell.symbol());
} else if !run.is_empty() {
marked.push(std::mem::take(&mut run));
}
}
if !run.is_empty() {
marked.push(run);
}
}
assert_eq!(marked, vec!["needle"; 6]);
std::fs::remove_file(path).unwrap();
}
#[test]
fn centered_sits_in_the_middle_and_never_overflows() {
let area = Rect::new(10, 5, 80, 20);
assert_eq!(centered(area, 40, 20), Rect::new(30, 5, 40, 20));
assert_eq!(centered(area, 80, 10), Rect::new(10, 10, 80, 10));
assert_eq!(centered(area, 200, 90), area);
assert_eq!(centered(area, 79, 20).x, 10);
}
fn editor_on(name: &str, lines: usize) -> (AppState, Terminal<ratatui::backend::TestBackend>, std::path::PathBuf) {
let path = std::env::temp_dir().join(format!("fm84-{name}-{}.txt", std::process::id()));
let text: String = (1..=lines).map(|n| format!("{n}\n")).collect();
std::fs::write(&path, text).unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.open_editor(path.clone()).unwrap();
let terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
(app_state, terminal, path)
}
#[test]
fn deleting_everything_from_the_bottom_of_a_long_file_draws() {
let (mut app_state, mut terminal, path) = editor_on("select-all", 100);
render_ui(&mut terminal, &mut app_state);
app_state.editor_select_all();
app_state.editor_backspace();
render_ui(&mut terminal, &mut app_state);
let state = app_state.editor().unwrap();
assert_eq!(state.lines, [""]);
assert_eq!(state.scroll_offset, 0);
std::fs::remove_file(path).unwrap();
}
#[test]
fn a_long_paste_leaves_the_cursor_on_screen() {
let (mut app_state, mut terminal, path) = editor_on("paste", 3);
render_ui(&mut terminal, &mut app_state);
let pasted: String = (0..200).map(|n| format!("pasted {n}\n")).collect();
app_state.editor_insert_text(&pasted);
render_ui(&mut terminal, &mut app_state);
let state = app_state.editor().unwrap();
let height = app_state.editor_viewport_height;
assert!(state.cursor_line >= state.scroll_offset && state.cursor_line < state.scroll_offset + height);
std::fs::remove_file(path).unwrap();
}
}