#![allow(clippy::too_many_arguments)]
use crate::bookmarks::Bookmarks;
use crate::config::Config;
use crate::disks::Disks;
use crate::navigation::{CopyFeedback, Navigation};
use crate::quick_jump::QuickJump;
use crate::search::Search;
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{List, ListItem, ListState, Paragraph},
Frame,
};
fn truncate_name_middle(name: &str, max_len: usize) -> String {
if max_len == 0 {
return name.to_string();
}
let char_count = name.chars().count();
if char_count <= max_len || max_len < 4 {
return name.to_string();
}
let available = max_len - 3;
let first_len = available.div_ceil(2);
let last_len = available - first_len;
let first: String = name.chars().take(first_len).collect();
let last: String = name
.chars()
.rev()
.take(last_len)
.collect::<String>()
.chars()
.rev()
.collect();
format!("{}...{}", first, last)
}
fn truncate_start(s: &str, max_len: usize) -> String {
let char_count = s.chars().count();
if char_count <= max_len {
return s.to_string();
}
let tail: String = s
.chars()
.rev()
.take(max_len)
.collect::<String>()
.chars()
.rev()
.collect();
format!("...{}", tail)
}
const TREE_MODE_HINTS: &str = " hjkl u:up m:bmk ':jump d:disk /:srch Tab:qj c:copy q:exit";
pub struct UI {
pub tree_area_start: u16,
pub tree_area_end: u16,
pub tree_area_top: u16,
pub tree_area_height: u16,
pub tree_item_top: u16,
pub terminal_width: u16,
pub terminal_height: u16,
pub tree_scroll_offset: usize,
pub disk_scroll_offset: usize,
pub bookmark_panel_height: usize,
pub bookmark_scroll_offset: usize,
pub search_scroll_offset: usize,
}
impl Default for UI {
fn default() -> Self {
Self::new()
}
}
impl UI {
pub fn new() -> Self {
Self {
tree_area_start: 0,
tree_area_end: 0,
tree_area_top: 0,
tree_area_height: 0,
tree_item_top: 0,
terminal_width: 0,
terminal_height: 0,
tree_scroll_offset: 0,
disk_scroll_offset: 0,
bookmark_panel_height: 0,
bookmark_scroll_offset: 0,
search_scroll_offset: 0,
}
}
pub fn render_compact(
&mut self,
frame: &mut Frame,
nav: &Navigation,
search: &Search,
quick_jump: &QuickJump,
bookmarks: &Bookmarks,
disks: &Disks,
config: &Config,
) {
let area = frame.area();
self.terminal_width = area.width;
self.terminal_height = area.height;
let selected_color =
Config::parse_color(Config::get_color(&config.appearance.colors.selected_color));
let (content_area, search_bar_area, bookmark_input_area) =
if search.mode || quick_jump.active {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(2), Constraint::Length(1)])
.split(area);
(chunks[0], Some(chunks[1]), None)
} else if bookmarks.is_creating {
let header_rows: u16 = 1;
let input_rows: u16 = 1;
let available = area.height.saturating_sub(header_rows + input_rows);
let body_rows: u16 = if bookmarks.list().is_empty() {
0
} else {
(bookmarks.list().len() as u16).min(available).max(1)
};
let content_height = header_rows + body_rows;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(content_height),
Constraint::Length(input_rows),
Constraint::Min(0),
])
.split(area);
(chunks[0], None, Some(chunks[1]))
} else {
(area, None, None)
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(content_area);
let header_area = chunks[0];
let body_area = chunks[1];
self.tree_area_top = body_area.y;
self.tree_area_height = body_area.height;
self.tree_area_start = body_area.x;
self.tree_area_end = body_area.x + body_area.width;
self.tree_item_top = body_area.y;
let root_path = nav.root.borrow().path.display().to_string();
let header_path_color = Config::parse_color(Config::get_color(
&config.appearance.colors.header_path_color,
));
let header_hints_color = Config::parse_color(Config::get_color(
&config.appearance.colors.header_hints_color,
));
if disks.is_selecting {
let hints = " jk:select Enter:go Esc:cancel";
let label = if disks.is_loading {
"Disks (loading...)".to_string()
} else if disks.disks.is_empty() {
"Disks (none found)".to_string()
} else {
format!("Disks ({}/{})", disks.selected_index + 1, disks.disks.len())
};
let max_label_len = (area.width as usize).saturating_sub(hints.len() + 4).max(8);
let label_display = truncate_start(&label, max_label_len);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" > ", Style::default().fg(Color::Yellow)),
Span::styled(
label_display,
Style::default()
.fg(header_path_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(hints, Style::default().fg(header_hints_color)),
])),
header_area,
);
} else if bookmarks.is_selecting {
let hints = if bookmarks.filter_mode {
" jk:select Enter:jump Tab:nav Esc:cancel"
} else {
" jk:select Enter:jump q:exit Tab:filter dd:del Esc:cancel"
};
let error_color =
Config::parse_color(Config::get_color(&config.appearance.colors.error_color));
if let Some(ref err) = bookmarks.bookmark_error {
let max_msg_len = (area.width as usize).saturating_sub(hints.len() + 4).max(8);
let msg_display = truncate_start(err, max_msg_len);
let error_icon = if config.appearance.icons != "ascii" {
" ✗ "
} else {
" ! "
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(error_icon, Style::default().fg(error_color)),
Span::styled(
msg_display,
Style::default()
.fg(error_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(hints, Style::default().fg(header_hints_color)),
])),
header_area,
);
} else {
let filtered = bookmarks.get_filtered_bookmarks();
let label = if bookmarks.filter_mode {
format!("Filter: {}", bookmarks.get_input())
} else if filtered.is_empty() {
"Bookmarks (empty)".to_string()
} else {
format!(
"Bookmarks ({}/{})",
bookmarks.selected_index + 1,
filtered.len()
)
};
let max_label_len = (area.width as usize).saturating_sub(hints.len() + 4).max(8);
let label_display = truncate_start(&label, max_label_len);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" > ", Style::default().fg(Color::Yellow)),
Span::styled(
label_display,
Style::default()
.fg(header_path_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(hints, Style::default().fg(header_hints_color)),
])),
header_area,
);
}
} else if search.show_results && search.focus_on_results {
let (label, hints): (String, String) = if search.filtering_results {
(
format!("Filter: {}_", search.result_filter),
" Enter:apply Esc:clear".to_string(),
)
} else if search.is_searching {
(
format!(
"Search: {} found | Scanning {} dirs...",
search.results.len(),
search.scanned_count
),
" jk:select Enter:jump /:filter q:exit Esc:cancel".to_string(),
)
} else {
let hints = " jk:select Enter:jump /:filter q:exit Esc:close".to_string();
let label = if search.result_filter.is_empty() {
if search.results.is_empty() {
"Search: no results".to_string()
} else {
format!("Search: {}/{}", search.selected + 1, search.results.len())
}
} else if search.results.is_empty() {
format!("Filter \"{}\": no match", search.result_filter)
} else {
format!(
"Filter \"{}\": {}/{}",
search.result_filter,
search.selected + 1,
search.results.len()
)
};
(label, hints)
};
let max_label_len = (area.width as usize).saturating_sub(hints.len() + 4).max(8);
let label_display = truncate_start(&label, max_label_len);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" * ", Style::default().fg(Color::Yellow)),
Span::styled(
label_display,
Style::default()
.fg(header_path_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(hints, Style::default().fg(header_hints_color)),
])),
header_area,
);
} else if let Some(ref error_msg) = nav.nav_error {
let hints = TREE_MODE_HINTS;
let max_msg_len = (area.width as usize).saturating_sub(hints.len() + 4).max(8);
let msg_display = truncate_start(error_msg, max_msg_len);
let error_icon = if config.appearance.icons != "ascii" {
" ✗ "
} else {
" ! "
};
let error_color =
Config::parse_color(Config::get_color(&config.appearance.colors.error_color));
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(error_icon, Style::default().fg(error_color)),
Span::styled(
msg_display,
Style::default()
.fg(error_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(hints, Style::default().fg(header_hints_color)),
])),
header_area,
);
} else if let Some(ref feedback) = nav.copy_feedback {
let hints = TREE_MODE_HINTS;
let error_color =
Config::parse_color(Config::get_color(&config.appearance.colors.error_color));
let (icon, msg_color, label) = match feedback {
CopyFeedback::Success => {
let icon = if config.appearance.icons != "ascii" {
" ✓ "
} else {
" + "
};
let path = nav
.get_selected_node()
.map(|n| n.borrow().path.display().to_string())
.unwrap_or_default();
(icon, header_path_color, format!("Copied: {}", path))
}
CopyFeedback::Error(err) => {
let icon = if config.appearance.icons != "ascii" {
" ✗ "
} else {
" ! "
};
(icon, error_color, format!("Copy failed: {}", err))
}
};
let max_msg_len = (area.width as usize).saturating_sub(hints.len() + 4).max(8);
let msg_display = truncate_start(&label, max_msg_len);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(icon, Style::default().fg(msg_color)),
Span::styled(
msg_display,
Style::default().fg(msg_color).add_modifier(Modifier::BOLD),
),
Span::styled(hints, Style::default().fg(header_hints_color)),
])),
header_area,
);
} else if bookmarks.is_creating && bookmarks.bookmark_error.is_some() {
let err = bookmarks.bookmark_error.as_deref().unwrap_or_default();
let hints = " Enter:save Ctrl+j/k:scroll Esc:cancel";
let max_msg_len = (area.width as usize).saturating_sub(hints.len() + 4).max(8);
let msg_display = truncate_start(err, max_msg_len);
let error_icon = if config.appearance.icons != "ascii" {
" ✗ "
} else {
" ! "
};
let error_color =
Config::parse_color(Config::get_color(&config.appearance.colors.error_color));
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(error_icon, Style::default().fg(error_color)),
Span::styled(
msg_display,
Style::default()
.fg(error_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(hints, Style::default().fg(header_hints_color)),
])),
header_area,
);
} else {
let is_duplicate = bookmarks.is_creating
&& !bookmarks.get_input().is_empty()
&& bookmarks.get(bookmarks.get_input()).is_some();
let hints = if is_duplicate {
" Enter:overwrite Ctrl+j/k:scroll Esc:cancel"
} else if bookmarks.is_creating {
" Enter:save Ctrl+j/k:scroll Esc:cancel"
} else {
TREE_MODE_HINTS
};
let total = nav.flat_list.len();
let count_str = if total > 0 {
format!(" ({}/{})", nav.selected + 1, total)
} else {
String::new()
};
let active_path = if bookmarks.is_creating || config.appearance.show_cursor_path {
nav.get_selected_node()
.map(|n| n.borrow().path.display().to_string())
.unwrap_or(root_path)
} else {
root_path
};
let max_path_len = (area.width as usize)
.saturating_sub(hints.len() + count_str.len() + 6)
.max(8);
let path_display = truncate_start(&active_path, max_path_len);
let highlight_color =
Config::parse_color(Config::get_color(&config.appearance.colors.highlight_color));
let (header_icon, icon_color, path_color) = if is_duplicate {
let icon = if config.appearance.icons != "ascii" {
" ⚠ "
} else {
" ! "
};
(icon, highlight_color, highlight_color)
} else {
let icon = if config.appearance.icons != "ascii" {
" ▼ "
} else {
" v "
};
let dir_color = Config::parse_color(Config::get_color(
&config.appearance.colors.directory_color,
));
(icon, dir_color, header_path_color)
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(header_icon, Style::default().fg(icon_color)),
Span::styled(
path_display,
Style::default().fg(path_color).add_modifier(Modifier::BOLD),
),
Span::styled(
count_str,
Style::default()
.fg(header_path_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(hints, Style::default().fg(header_hints_color)),
])),
header_area,
);
}
if disks.is_selecting {
let file_color =
Config::parse_color(Config::get_color(&config.appearance.colors.file_color));
let cursor_color_str = Config::get_color(&config.appearance.colors.cursor_color);
let highlight_style = if cursor_color_str.to_lowercase() == "dim" {
Style::default().add_modifier(Modifier::DIM)
} else {
Style::default()
.fg(Config::parse_color(cursor_color_str))
.add_modifier(Modifier::BOLD)
};
if disks.disks.is_empty() {
let msg = if disks.is_loading {
"Loading disks..."
} else {
"No disks found."
};
frame.render_widget(
Paragraph::new(msg).style(Style::default().fg(Color::DarkGray)),
body_area,
);
} else {
let items: Vec<ListItem> = disks
.disks
.iter()
.map(|d| ListItem::new(d.display_line()).style(Style::default().fg(file_color)))
.collect();
let mut state = ListState::default();
state.select(Some(disks.selected_index));
let visible = body_area.height as usize;
let total = disks.disks.len();
let offset = if disks.center_selection {
if disks.selected_index < visible / 2 {
0
} else if disks.selected_index >= total.saturating_sub(visible / 2) {
total.saturating_sub(visible)
} else {
disks.selected_index.saturating_sub(visible / 2)
}
} else {
let current = self.disk_scroll_offset;
if disks.selected_index < current {
disks.selected_index
} else if visible > 0 && disks.selected_index >= current + visible {
disks.selected_index.saturating_sub(visible - 1)
} else {
current
}
};
*state.offset_mut() = offset;
self.disk_scroll_offset = offset;
frame.render_stateful_widget(
List::new(items)
.highlight_style(highlight_style)
.highlight_symbol(">> "),
body_area,
&mut state,
);
}
} else if bookmarks.is_selecting {
let filtered = bookmarks.get_filtered_bookmarks();
let file_color =
Config::parse_color(Config::get_color(&config.appearance.colors.file_color));
let error_color =
Config::parse_color(Config::get_color(&config.appearance.colors.error_color));
if filtered.is_empty() {
let msg = if bookmarks.filter_mode {
format!("No bookmarks match '{}'", bookmarks.get_input())
} else {
"No bookmarks. Press 'm' to create one.".to_string()
};
frame.render_widget(
Paragraph::new(msg).style(Style::default().fg(Color::DarkGray)),
body_area,
);
} else {
let items: Vec<ListItem> = filtered
.iter()
.enumerate()
.map(|(idx, bookmark)| {
let name = bookmark.name.as_deref().unwrap_or("(unnamed)");
let path_str = bookmark.path.display().to_string();
let is_marked = bookmarks.pending_deletion_index == Some(idx);
let text = if is_marked {
format!("[DEL] {:<10} {} ({})", bookmark.key, name, path_str)
} else {
format!("{:<10} {} ({})", bookmark.key, name, path_str)
};
let style = if is_marked {
Style::default().fg(error_color)
} else {
Style::default().fg(file_color)
};
ListItem::new(text).style(style)
})
.collect();
let mut state = ListState::default();
state.select(Some(bookmarks.selected_index));
let visible = body_area.height as usize;
let total = filtered.len();
let offset = if bookmarks.center_selection {
if bookmarks.selected_index < visible / 2 {
0
} else if bookmarks.selected_index >= total.saturating_sub(visible / 2) {
total.saturating_sub(visible)
} else {
bookmarks.selected_index.saturating_sub(visible / 2)
}
} else {
let current = self.bookmark_scroll_offset;
if bookmarks.selected_index < current {
bookmarks.selected_index
} else if visible > 0 && bookmarks.selected_index >= current + visible {
bookmarks.selected_index.saturating_sub(visible - 1)
} else {
current
}
};
*state.offset_mut() = offset;
self.bookmark_scroll_offset = offset;
let cursor_color_str = Config::get_color(&config.appearance.colors.cursor_color);
let highlight_style = if cursor_color_str.to_lowercase() == "dim" {
Style::default().add_modifier(Modifier::DIM)
} else {
Style::default()
.fg(Config::parse_color(cursor_color_str))
.add_modifier(Modifier::BOLD)
};
frame.render_stateful_widget(
List::new(items)
.highlight_style(highlight_style)
.highlight_symbol(">> "),
body_area,
&mut state,
);
}
} else if bookmarks.is_creating {
self.bookmark_panel_height = body_area.height as usize;
let all_bookmarks = bookmarks.list();
let file_color =
Config::parse_color(Config::get_color(&config.appearance.colors.file_color));
let highlight_color =
Config::parse_color(Config::get_color(&config.appearance.colors.highlight_color));
if !all_bookmarks.is_empty() {
let input = bookmarks.get_input();
let duplicate_key = if !input.is_empty() && bookmarks.get(input).is_some() {
Some(input)
} else {
None
};
let items: Vec<ListItem> = all_bookmarks
.iter()
.map(|b| {
let text = format!("{:<10} {}", b.key, b.path.display());
let style = if duplicate_key == Some(b.key.as_str()) {
Style::default()
.fg(highlight_color)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(file_color)
};
ListItem::new(text).style(style)
})
.collect();
let visible = body_area.height as usize;
let max_offset = all_bookmarks.len().saturating_sub(visible);
let dup_idx = duplicate_key
.and_then(|k| all_bookmarks.iter().position(|b| b.key.as_str() == k));
let offset = if let Some(idx) = dup_idx {
if idx < bookmarks.scroll_offset {
idx
} else if visible > 0 && idx >= bookmarks.scroll_offset + visible {
idx.saturating_sub(visible - 1)
} else {
bookmarks.scroll_offset
}
} else {
bookmarks.scroll_offset
}
.min(max_offset);
let mut state = ListState::default();
*state.offset_mut() = offset;
frame.render_stateful_widget(List::new(items), body_area, &mut state);
}
} else if search.show_results && search.focus_on_results {
let dir_color =
Config::parse_color(Config::get_color(&config.appearance.colors.directory_color));
let file_color =
Config::parse_color(Config::get_color(&config.appearance.colors.file_color));
let highlight_color =
Config::parse_color(Config::get_color(&config.appearance.colors.highlight_color));
if search.results.is_empty() {
let msg = if !search.result_filter.is_empty() {
"No result matches the filter"
} else if search.is_searching {
"Searching..."
} else {
"No results found"
};
frame.render_widget(
Paragraph::new(msg).style(Style::default().fg(Color::DarkGray)),
body_area,
);
} else {
let root_path_buf = nav.root.borrow().path.clone();
let root_parent = root_path_buf
.parent()
.unwrap_or(&root_path_buf)
.to_path_buf();
let items: Vec<ListItem> = search
.results
.iter()
.map(|result| {
let display_path = result
.path
.strip_prefix(&root_parent)
.unwrap_or(&result.path)
.display()
.to_string();
let base_color = if result.is_dir { dir_color } else { file_color };
if let (true, Some(indices)) =
(search.fuzzy_mode, result.match_indices.as_ref())
{
let chars: Vec<char> = display_path.chars().collect();
let name_offset = display_path
.rfind(std::path::MAIN_SEPARATOR)
.map(|byte_idx| display_path[..=byte_idx].chars().count())
.unwrap_or(0);
let mut spans = Vec::new();
let mut last_idx = 0;
for &raw_idx in indices {
let match_idx = (raw_idx + name_offset).min(chars.len());
if match_idx > last_idx {
let text: String = chars[last_idx..match_idx].iter().collect();
spans.push(Span::styled(text, Style::default().fg(base_color)));
}
if match_idx < chars.len() {
let text: String =
chars[match_idx..match_idx + 1].iter().collect();
spans.push(Span::styled(
text,
Style::default()
.fg(highlight_color)
.add_modifier(Modifier::BOLD),
));
}
last_idx = match_idx + 1;
}
if last_idx < chars.len() {
let text: String = chars[last_idx..].iter().collect();
spans.push(Span::styled(text, Style::default().fg(base_color)));
}
ListItem::new(Line::from(spans))
} else {
ListItem::new(display_path).style(Style::default().fg(base_color))
}
})
.collect();
let mut state = ListState::default();
state.select(Some(search.selected));
let visible = body_area.height as usize;
let total = search.results.len();
let offset = if search.center_selection {
if search.selected < visible / 2 {
0
} else if search.selected >= total.saturating_sub(visible / 2) {
total.saturating_sub(visible)
} else {
search.selected.saturating_sub(visible / 2)
}
} else {
let current = self.search_scroll_offset;
if search.selected < current {
search.selected
} else if visible > 0 && search.selected >= current + visible {
search.selected.saturating_sub(visible - 1)
} else {
current
}
};
*state.offset_mut() = offset;
self.search_scroll_offset = offset;
let cursor_color_str = Config::get_color(&config.appearance.colors.cursor_color);
let highlight_style = if cursor_color_str.to_lowercase() == "dim" {
Style::default().add_modifier(Modifier::DIM)
} else {
Style::default()
.fg(Config::parse_color(cursor_color_str))
.add_modifier(Modifier::BOLD)
};
frame.render_stateful_widget(
List::new(items)
.highlight_style(highlight_style)
.highlight_symbol(">> "),
body_area,
&mut state,
);
}
} else {
let dir_color =
Config::parse_color(Config::get_color(&config.appearance.colors.directory_color));
let error_color =
Config::parse_color(Config::get_color(&config.appearance.colors.error_color));
let visible = body_area.height as usize;
let total = nav.flat_list.len();
let offset = if nav.center_selection {
if nav.selected < visible / 2 {
0
} else if nav.selected >= total.saturating_sub(visible / 2) {
total.saturating_sub(visible)
} else {
nav.selected.saturating_sub(visible / 2)
}
} else {
let current = self.tree_scroll_offset;
if nav.selected < current {
nav.selected
} else if visible > 0 && nav.selected >= current + visible {
nav.selected.saturating_sub(visible - 1)
} else {
current
}
};
self.tree_scroll_offset = offset;
let items: Vec<ListItem> = nav
.flat_list
.iter()
.enumerate()
.map(|(i, node)| {
let in_viewport = i >= offset && i < offset + visible;
let needs_probe = in_viewport && {
let n = node.borrow();
n.is_dir && n.has_children.is_none()
};
if needs_probe {
node.borrow_mut().probe_has_children(
false,
nav.show_hidden,
nav.follow_symlinks,
);
}
let n = node.borrow();
let indent = " ".repeat(n.depth);
let unicode_icons = config.appearance.icons != "ascii";
let icon = if n.has_error {
if unicode_icons {
"⊘ "
} else {
"! "
}
} else if n.is_dir {
if n.is_expanded {
if unicode_icons {
"▼ "
} else {
"v "
}
} else if n.has_children == Some(false) {
" "
} else if unicode_icons {
"▶ "
} else {
"> "
}
} else {
" "
};
let display_name =
truncate_name_middle(&n.name, config.appearance.max_name_length);
let text = format!("{}{}{}", indent, icon, display_name);
let style = if n.has_error {
Style::default().fg(error_color)
} else if n.is_dir {
Style::default().fg(dir_color)
} else {
Style::default()
};
ListItem::new(text).style(style)
})
.collect();
let mut state = ListState::default();
state.select(Some(nav.selected));
*state.offset_mut() = offset;
let cursor_color_str = Config::get_color(&config.appearance.colors.tree_cursor_color);
let cursor_bg_str = Config::get_color(&config.appearance.colors.tree_cursor_bg_color);
let mut highlight_style = if cursor_color_str.to_lowercase() == "dim" {
Style::default().add_modifier(Modifier::DIM)
} else {
Style::default().fg(Config::parse_color(cursor_color_str))
};
if cursor_bg_str.to_lowercase() != "dim" {
highlight_style = highlight_style.bg(Config::parse_color(cursor_bg_str));
}
frame.render_stateful_widget(
List::new(items)
.highlight_style(highlight_style)
.highlight_symbol(">> "),
body_area,
&mut state,
);
}
if let Some(bar_area) = search_bar_area {
if quick_jump.active {
let marker = if config.appearance.icons != "ascii" {
"⇥ "
} else {
"> "
};
let status = if quick_jump.is_scanning {
" (scanning...)"
} else {
""
};
let extra_hint = if quick_jump.has_match && quick_jump.matches.len() > 1 {
format!(
" ({}/{}, Shift+Tab)",
quick_jump.current_index + 1,
quick_jump.matches.len()
)
} else {
String::new()
};
let no_match_color =
Config::parse_color(Config::get_color(&config.appearance.colors.error_color));
let bar_color = if quick_jump.has_match {
selected_color
} else {
no_match_color
};
let bar_text = format!("{}{}{}{}", marker, quick_jump.buffer, extra_hint, status);
frame.render_widget(
Paragraph::new(bar_text).style(Style::default().fg(bar_color)),
bar_area,
);
} else {
let mode_tag = if search.fuzzy_mode { "(fuzzy) " } else { "" };
let bar_text = format!("/{}{}", mode_tag, search.query);
frame.render_widget(
Paragraph::new(bar_text).style(Style::default().fg(selected_color)),
bar_area,
);
}
}
if let Some(input_area) = bookmark_input_area {
let input_text = format!(" Add bookmark: {}|", bookmarks.get_input());
frame.render_widget(
Paragraph::new(input_text).style(
Style::default()
.fg(selected_color)
.add_modifier(Modifier::BOLD),
),
input_area,
);
}
}
}
pub fn get_help_content() -> Vec<String> {
const HELP_TEXT: &str = include_str!("../HELP.txt");
HELP_TEXT.lines().map(|line| line.to_string()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bookmarks::Bookmarks;
use crate::disks::Disks;
use crate::navigation::Navigation;
use ratatui::{backend::TestBackend, Terminal};
fn render_header(nav: &Navigation, config: &Config) -> String {
let search = Search::new();
let quick_jump = QuickJump::new();
let bookmarks = Bookmarks::new().unwrap();
let disks = Disks::new();
let mut ui = UI::new();
let backend = TestBackend::new(240, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| ui.render_compact(f, nav, &search, &quick_jump, &bookmarks, &disks, config))
.unwrap();
terminal.backend().to_string()
}
fn render_search_header(
nav: &Navigation,
search: &Search,
config: &Config,
width: u16,
) -> String {
let quick_jump = QuickJump::new();
let bookmarks = Bookmarks::new().unwrap();
let disks = Disks::new();
let mut ui = UI::new();
let backend = TestBackend::new(width, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| ui.render_compact(f, nav, search, &quick_jump, &bookmarks, &disks, config))
.unwrap();
terminal.backend().to_string()
}
#[test]
fn search_results_header_stays_readable_with_active_filter_at_80_cols() {
let temp_dir = std::env::temp_dir().join("bmrk_test_ui_results_filter_header");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let nav = Navigation::new(temp_dir.clone(), false, true, true).unwrap();
let config = Config::load().unwrap();
let mut search = Search::new();
search.show_results = true;
search.focus_on_results = true;
for i in 0..12 {
search.push_result_for_test(temp_dir.join(format!("report-{i}")), true);
}
search.enter_result_filter();
for c in "report".chars() {
search.result_filter_push(c);
}
let screen = render_search_header(&nav, &search, &config, 80);
assert!(
screen.contains("Filter: report_"),
"filter input line must be visible verbatim:\n{screen}"
);
assert!(
screen.contains("Enter:apply") && screen.contains("Esc:clear"),
"filter-input hints must survive an 80-column header:\n{screen}"
);
search.finish_result_filter();
let screen = render_search_header(&nav, &search, &config, 80);
assert!(
screen.contains("1/12"),
"applied-filter header must still show the selected/total count:\n{screen}"
);
assert!(
screen.contains("Enter:jump") && screen.contains("Esc:close"),
"applied-filter header hints must survive an 80-column header:\n{screen}"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
fn render_with_bookmarks(
nav: &Navigation,
bookmarks: &Bookmarks,
config: &Config,
) -> Terminal<TestBackend> {
let search = Search::new();
let quick_jump = QuickJump::new();
let disks = Disks::new();
let mut ui = UI::new();
let backend = TestBackend::new(120, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| ui.render_compact(f, nav, &search, &quick_jump, bookmarks, &disks, config))
.unwrap();
terminal
}
#[test]
fn header_shows_selected_node_path_when_show_cursor_path_enabled() {
let temp_dir = std::env::temp_dir().join("bmrk_test_ui_header_cursor_path");
let _ = std::fs::remove_dir_all(&temp_dir);
let deep = temp_dir.join("sub").join("target_deep");
std::fs::create_dir_all(&deep).unwrap();
let mut nav = Navigation::new(temp_dir.clone(), false, true, true).unwrap();
nav.expand_path_to_node(&deep, false).unwrap();
let mut config = Config::load().unwrap();
config.appearance.show_cursor_path = true;
let screen = render_header(&nav, &config);
assert!(
screen.contains("sub/target_deep") || screen.contains("sub\\target_deep"),
"header must show the selected node's full path when show_cursor_path is true:\n{screen}"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn header_shows_only_root_path_when_show_cursor_path_disabled() {
let temp_dir = std::env::temp_dir().join("bmrk_test_ui_header_root_path");
let _ = std::fs::remove_dir_all(&temp_dir);
let deep = temp_dir.join("sub").join("target_deep");
std::fs::create_dir_all(&deep).unwrap();
let mut nav = Navigation::new(temp_dir.clone(), false, true, true).unwrap();
nav.expand_path_to_node(&deep, false).unwrap();
let mut config = Config::load().unwrap();
config.appearance.show_cursor_path = false;
let screen = render_header(&nav, &config);
assert!(
!screen.contains("sub/target_deep") && !screen.contains("sub\\target_deep"),
"header must not show the deep selected path when show_cursor_path is false:\n{screen}"
);
let root_name = temp_dir.file_name().unwrap().to_str().unwrap();
assert!(
screen.contains(root_name),
"header must fall back to the root path when show_cursor_path is false:\n{screen}"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn header_shows_success_feedback_after_copy() {
let temp_dir = std::env::temp_dir().join("bmrk_test_ui_header_copy_success");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let mut nav = Navigation::new(temp_dir.clone(), false, true, true).unwrap();
nav.copy_feedback = Some(CopyFeedback::Success);
let config = Config::load().unwrap();
let screen = render_header(&nav, &config);
assert!(
screen.contains("Copied:"),
"header must show success feedback after a copy:\n{screen}"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn header_shows_error_feedback_when_copy_fails() {
let temp_dir = std::env::temp_dir().join("bmrk_test_ui_header_copy_error");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let mut nav = Navigation::new(temp_dir.clone(), false, true, true).unwrap();
nav.copy_feedback = Some(CopyFeedback::Error("no clipboard".to_string()));
let config = Config::load().unwrap();
let screen = render_header(&nav, &config);
assert!(
screen.contains("Copy failed:") && screen.contains("no clipboard"),
"header must show the failure reason:\n{screen}"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn header_shows_duplicate_warning_when_typed_name_matches_existing_bookmark() {
let temp_dir = std::env::temp_dir().join("bmrk_test_ui_header_dup_warning");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let nav = Navigation::new(temp_dir.clone(), false, true, true).unwrap();
let bm_temp_dir = tempfile::TempDir::new().unwrap();
let mut bookmarks = Bookmarks::new_in_memory(bm_temp_dir.path().join("bookmarks.json"));
bookmarks
.add("work".to_string(), temp_dir.clone(), None)
.unwrap();
bookmarks.enter_creation_mode();
for c in "work".chars() {
bookmarks.add_char(c);
}
let config = Config::load().unwrap();
let highlight_color =
Config::parse_color(Config::get_color(&config.appearance.colors.highlight_color));
let terminal = render_with_bookmarks(&nav, &bookmarks, &config);
let screen = terminal.backend().to_string();
assert!(
screen.contains("Enter:overwrite"),
"header hints must offer overwrite when the typed name duplicates an existing bookmark:\n{screen}"
);
let root_name = temp_dir.file_name().unwrap().to_str().unwrap();
assert!(
screen.contains(root_name),
"header must still show the target path while warning about the duplicate:\n{screen}"
);
let buffer = terminal.backend().buffer();
assert_eq!(
buffer[(0, 0)].style().fg,
Some(highlight_color),
"the leading header icon must switch to the highlight color on a duplicate"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn header_shows_no_duplicate_warning_for_new_name() {
let temp_dir = std::env::temp_dir().join("bmrk_test_ui_header_no_dup_warning");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let nav = Navigation::new(temp_dir.clone(), false, true, true).unwrap();
let bm_temp_dir = tempfile::TempDir::new().unwrap();
let mut bookmarks = Bookmarks::new_in_memory(bm_temp_dir.path().join("bookmarks.json"));
bookmarks
.add("work".to_string(), temp_dir.clone(), None)
.unwrap();
bookmarks.enter_creation_mode();
for c in "brand-new".chars() {
bookmarks.add_char(c);
}
let config = Config::load().unwrap();
let directory_color =
Config::parse_color(Config::get_color(&config.appearance.colors.directory_color));
let terminal = render_with_bookmarks(&nav, &bookmarks, &config);
let screen = terminal.backend().to_string();
assert!(
screen.contains("Enter:save"),
"header must offer the normal save hint when the typed name is new:\n{screen}"
);
let buffer = terminal.backend().buffer();
assert_eq!(
buffer[(0, 0)].style().fg,
Some(directory_color),
"the leading header icon must keep its normal directory color for a non-duplicate name"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn body_highlights_matching_bookmark_key_when_typed_name_duplicates() {
let temp_dir = std::env::temp_dir().join("bmrk_test_ui_body_dup_highlight");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let nav = Navigation::new(temp_dir.clone(), false, true, true).unwrap();
let bm_temp_dir = tempfile::TempDir::new().unwrap();
let mut bookmarks = Bookmarks::new_in_memory(bm_temp_dir.path().join("bookmarks.json"));
bookmarks
.add("work".to_string(), temp_dir.clone(), None)
.unwrap();
bookmarks
.add("zzz".to_string(), temp_dir.clone(), None)
.unwrap();
bookmarks.enter_creation_mode();
for c in "work".chars() {
bookmarks.add_char(c);
}
let config = Config::load().unwrap();
let highlight_color =
Config::parse_color(Config::get_color(&config.appearance.colors.highlight_color));
let file_color =
Config::parse_color(Config::get_color(&config.appearance.colors.file_color));
let terminal = render_with_bookmarks(&nav, &bookmarks, &config);
let screen = terminal.backend().to_string();
let root_name = temp_dir.file_name().unwrap().to_str().unwrap();
assert!(
screen.matches(root_name).count() >= 2,
"the bookmark list must show each bookmark's path, one per row:\n{screen}"
);
let buffer = terminal.backend().buffer();
let work_row = 1;
let zzz_row = 2;
let mut found_highlighted_work = false;
let mut found_plain_zzz = false;
for x in 0..buffer.area().width {
if buffer[(x, work_row)].symbol() == "w"
&& buffer[(x, work_row)].style().fg == Some(highlight_color)
{
found_highlighted_work = true;
}
if buffer[(x, zzz_row)].symbol() == "z"
&& buffer[(x, zzz_row)].style().fg == Some(file_color)
{
found_plain_zzz = true;
}
}
assert!(
found_highlighted_work,
"the row for the duplicated key 'work' must be rendered in the highlight color"
);
assert!(
found_plain_zzz,
"the row for the non-duplicated key 'zzz' must stay in the normal file color"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn body_scroll_offset_is_applied_when_bookmark_list_overflows_view() {
let temp_dir = std::env::temp_dir().join("bmrk_test_ui_body_scroll");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let nav = Navigation::new(temp_dir.clone(), false, true, true).unwrap();
let bm_temp_dir = tempfile::TempDir::new().unwrap();
let mut bookmarks = Bookmarks::new_in_memory(bm_temp_dir.path().join("bookmarks.json"));
for i in 0..12 {
bookmarks
.add(format!("b{:02}", i), temp_dir.clone(), None)
.unwrap();
}
bookmarks.enter_creation_mode();
bookmarks.scroll_offset = 3;
let config = Config::load().unwrap();
let terminal = render_with_bookmarks(&nav, &bookmarks, &config);
let screen = terminal.backend().to_string();
assert!(
!screen.contains("b00"),
"scrolling past the first bookmarks must hide them from view:\n{screen}"
);
assert!(
screen.contains("b03"),
"scrolling must bring the offset bookmark into view:\n{screen}"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn body_scrolls_a_duplicate_match_into_view_even_when_off_screen() {
let temp_dir = std::env::temp_dir().join("bmrk_test_ui_body_scroll_to_duplicate");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let nav = Navigation::new(temp_dir.clone(), false, true, true).unwrap();
let bm_temp_dir = tempfile::TempDir::new().unwrap();
let mut bookmarks = Bookmarks::new_in_memory(bm_temp_dir.path().join("bookmarks.json"));
for i in 0..12 {
bookmarks
.add(format!("b{:02}", i), temp_dir.clone(), None)
.unwrap();
}
bookmarks.enter_creation_mode();
for c in "b11".chars() {
bookmarks.add_char(c);
}
let config = Config::load().unwrap();
let terminal = render_with_bookmarks(&nav, &bookmarks, &config);
let screen = terminal.backend().to_string();
assert!(
screen.contains("b11"),
"typing a duplicate name must scroll its row into view even if it's off-screen:\n{screen}"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_tree_mode_hints_fit_80_columns() {
let temp_dir = std::env::temp_dir().join("bmrk_test_ui_hints_width_80col");
let _ = std::fs::remove_dir_all(&temp_dir);
let long_name = "a".repeat(60);
let root = temp_dir.join(long_name);
std::fs::create_dir_all(&root).unwrap();
for i in 0..15 {
std::fs::create_dir_all(root.join(format!("sub{i}"))).unwrap();
}
let nav = Navigation::new(root, false, true, true).unwrap();
let config = Config::load().unwrap();
let search = Search::new();
let quick_jump = QuickJump::new();
let bookmarks = Bookmarks::new().unwrap();
let disks = Disks::new();
let mut ui = UI::new();
let backend = TestBackend::new(80, 8);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| ui.render_compact(f, &nav, &search, &quick_jump, &bookmarks, &disks, &config))
.unwrap();
let screen = terminal.backend().to_string();
assert!(
screen.contains("c:copy"),
"the copy-path hint must not be clipped on an 80-column terminal:\n{screen}"
);
assert!(
screen.contains("q:exit"),
"the exit hint must not be clipped on an 80-column terminal:\n{screen}"
);
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_render_probes_only_visible_rows() {
let temp_dir = std::env::temp_dir().join("bmrk_test_ui_lazy_probe_visible_rows");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
for i in 0..15 {
std::fs::create_dir_all(temp_dir.join(format!("sub{i:02}"))).unwrap();
}
let nav = Navigation::new(temp_dir.clone(), false, true, true).unwrap();
assert_eq!(nav.flat_list.len(), 16);
assert_eq!(nav.flat_list[0].borrow().has_children, Some(true));
for node in &nav.flat_list[1..] {
assert_eq!(
node.borrow().has_children,
None,
"children must start unprobed before any render"
);
}
let config = Config::load().unwrap();
let search = Search::new();
let quick_jump = QuickJump::new();
let bookmarks = Bookmarks::new().unwrap();
let disks = Disks::new();
let mut ui = UI::new();
let backend = TestBackend::new(80, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| ui.render_compact(f, &nav, &search, &quick_jump, &bookmarks, &disks, &config))
.unwrap();
let visible = 9;
for (i, node) in nav.flat_list.iter().enumerate().skip(1) {
let has_children = node.borrow().has_children;
if i < visible {
assert!(
has_children.is_some(),
"row {i} is inside the viewport and must be probed after render"
);
} else {
assert_eq!(
has_children, None,
"row {i} is outside the viewport and must stay unprobed"
);
}
}
std::fs::remove_dir_all(&temp_dir).ok();
}
}