use super::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
use super::status_bar::truncate_path;
use crate::app::file_open::{
format_modified, format_size, FileOpenSection, FileOpenState, SortMode,
};
use crate::primitives::display_width::str_width;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;
use rust_i18n::t;
pub struct FileBrowserRenderer;
impl FileBrowserRenderer {
pub fn render(
frame: &mut Frame,
area: Rect,
state: &FileOpenState,
theme: &crate::view::theme::Theme,
hover_target: &Option<crate::app::HoverTarget>,
keybindings: Option<&crate::input::keybindings::KeybindingResolver>,
) -> Option<FileBrowserLayout> {
if area.height < 5 || area.width < 20 {
return None;
}
frame.render_widget(Clear, area);
let max_title_len = (area.width as usize).saturating_sub(4); let truncated_path = truncate_path(&state.current_dir, max_title_len);
let title = format!(" {} ", truncated_path.to_string_plain());
let title_line = if truncated_path.truncated {
Line::from(vec![
Span::raw(" "),
Span::styled(
truncated_path.prefix.clone(),
Style::default().fg(theme.popup_border_fg),
),
Span::styled("/[...]", Style::default().fg(theme.menu_highlight_fg)),
Span::styled(
truncated_path.suffix.clone(),
Style::default().fg(theme.popup_border_fg),
),
Span::raw(" "),
])
} else {
Line::from(title)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.popup_border_fg))
.style(Style::default().bg(theme.popup_bg))
.title(title_line);
let inner_area = block.inner(area);
frame.render_widget(block, area);
if inner_area.height < 3 || inner_area.width < 10 {
return None;
}
let nav_height = 2u16; let header_height = 1u16;
let scrollbar_width = 1u16;
let content_width = inner_area.width.saturating_sub(scrollbar_width);
let list_height = inner_area.height.saturating_sub(nav_height + header_height);
let nav_area = Rect::new(inner_area.x, inner_area.y, content_width, nav_height);
let header_area = Rect::new(
inner_area.x,
inner_area.y + nav_height,
content_width,
header_height,
);
let list_area = Rect::new(
inner_area.x,
inner_area.y + nav_height + header_height,
content_width,
list_height,
);
let scrollbar_area = Rect::new(
inner_area.x + content_width,
inner_area.y + nav_height + header_height,
scrollbar_width,
list_height,
);
Self::render_navigation(frame, nav_area, state, theme, hover_target, keybindings);
Self::render_header(frame, header_area, state, theme, hover_target);
let visible_rows = Self::render_file_list(frame, list_area, state, theme, hover_target);
let scrollbar_state =
ScrollbarState::new(state.entries.len(), visible_rows, state.scroll_offset);
let is_scrollbar_hovered = matches!(
hover_target,
Some(crate::app::HoverTarget::FileBrowserScrollbar)
);
let colors = if is_scrollbar_hovered {
ScrollbarColors::from_theme_hover(theme)
} else {
ScrollbarColors::from_theme(theme)
};
let (thumb_start, thumb_end) =
render_scrollbar(frame, scrollbar_area, &scrollbar_state, &colors);
Some(FileBrowserLayout {
nav_area,
header_area,
list_area,
scrollbar_area,
thumb_start,
thumb_end,
visible_rows,
content_width,
})
}
fn render_navigation(
frame: &mut Frame,
area: Rect,
state: &FileOpenState,
theme: &crate::view::theme::Theme,
hover_target: &Option<crate::app::HoverTarget>,
keybindings: Option<&crate::input::keybindings::KeybindingResolver>,
) {
use crate::app::HoverTarget;
let shortcut_hint = keybindings
.and_then(|kb| {
kb.get_keybinding_for_action(
&crate::input::keybindings::Action::FileBrowserToggleHidden,
crate::input::keybindings::KeyContext::Prompt,
)
})
.unwrap_or_default();
let checkbox_icon = if state.show_hidden { "☑" } else { "☐" };
let checkbox_label = format!("{} {}", checkbox_icon, t!("file_browser.show_hidden"));
let shortcut_text = if shortcut_hint.is_empty() {
String::new()
} else {
format!(" ({})", shortcut_hint)
};
let is_checkbox_hovered = matches!(
hover_target,
Some(HoverTarget::FileBrowserShowHiddenCheckbox)
);
let checkbox_style = if is_checkbox_hovered {
Style::default()
.fg(theme.menu_hover_fg)
.bg(theme.menu_hover_bg)
} else if state.show_hidden {
Style::default()
.fg(theme.menu_highlight_fg)
.bg(theme.popup_bg)
} else {
Style::default().fg(theme.help_key_fg).bg(theme.popup_bg)
};
let shortcut_style = if is_checkbox_hovered {
Style::default()
.fg(theme.menu_hover_fg)
.bg(theme.menu_hover_bg)
} else {
Style::default()
.fg(theme.help_separator_fg)
.bg(theme.popup_bg)
};
let mut checkbox_spans = Vec::new();
checkbox_spans.push(Span::styled(format!(" {}", checkbox_label), checkbox_style));
if !shortcut_text.is_empty() {
checkbox_spans.push(Span::styled(shortcut_text, shortcut_style));
}
let checkbox_line_width: usize = checkbox_spans.iter().map(|s| str_width(&s.content)).sum();
let remaining = (area.width as usize).saturating_sub(checkbox_line_width);
if remaining > 0 {
checkbox_spans.push(Span::styled(
" ".repeat(remaining),
Style::default().bg(theme.popup_bg),
));
}
let checkbox_line = Line::from(checkbox_spans);
let is_nav_active = state.active_section == FileOpenSection::Navigation;
let mut nav_spans = Vec::new();
nav_spans.push(Span::styled(
format!(" {}", t!("file_browser.navigation")),
Style::default()
.fg(theme.help_separator_fg)
.bg(theme.popup_bg),
));
for (idx, shortcut) in state.shortcuts.iter().enumerate() {
let is_selected = is_nav_active && idx == state.selected_shortcut;
let is_hovered =
matches!(hover_target, Some(HoverTarget::FileBrowserNavShortcut(i)) if *i == idx);
let style = if is_selected {
Style::default()
.fg(theme.popup_text_fg)
.bg(theme.suggestion_selected_bg)
.add_modifier(Modifier::BOLD)
} else if is_hovered {
Style::default()
.fg(theme.menu_hover_fg)
.bg(theme.menu_hover_bg)
} else {
Style::default().fg(theme.help_key_fg).bg(theme.popup_bg)
};
nav_spans.push(Span::styled(format!(" {} ", shortcut.label), style));
if idx < state.shortcuts.len() - 1 {
nav_spans.push(Span::styled(
" │ ",
Style::default()
.fg(theme.help_separator_fg)
.bg(theme.popup_bg),
));
}
}
let nav_line_width: usize = nav_spans.iter().map(|s| str_width(&s.content)).sum();
let nav_remaining = (area.width as usize).saturating_sub(nav_line_width);
if nav_remaining > 0 {
nav_spans.push(Span::styled(
" ".repeat(nav_remaining),
Style::default().bg(theme.popup_bg),
));
}
let nav_line = Line::from(nav_spans);
let paragraph = Paragraph::new(vec![checkbox_line, nav_line]);
frame.render_widget(paragraph, area);
}
fn render_header(
frame: &mut Frame,
area: Rect,
state: &FileOpenState,
theme: &crate::view::theme::Theme,
hover_target: &Option<crate::app::HoverTarget>,
) {
use crate::app::HoverTarget;
let width = area.width as usize;
let size_col_width = 10;
let date_col_width = 14;
let name_col_width = width.saturating_sub(size_col_width + date_col_width + 4);
let header_style = Style::default()
.fg(theme.help_key_fg)
.bg(theme.menu_dropdown_bg)
.add_modifier(Modifier::BOLD);
let active_header_style = Style::default()
.fg(theme.menu_highlight_fg)
.bg(theme.menu_dropdown_bg)
.add_modifier(Modifier::BOLD);
let hover_header_style = Style::default()
.fg(theme.menu_hover_fg)
.bg(theme.menu_hover_bg)
.add_modifier(Modifier::BOLD);
let sort_arrow = if state.sort_ascending { "▲" } else { "▼" };
let mut spans = Vec::new();
let name_header = format!(
" {}{}",
t!("file_browser.name"),
if state.sort_mode == SortMode::Name {
sort_arrow
} else {
" "
}
);
let is_name_hovered = matches!(
hover_target,
Some(HoverTarget::FileBrowserHeader(SortMode::Name))
);
let name_style = if state.sort_mode == SortMode::Name {
active_header_style
} else if is_name_hovered {
hover_header_style
} else {
header_style
};
let name_display = if name_header.len() < name_col_width {
format!("{:<width$}", name_header, width = name_col_width)
} else {
name_header[..name_col_width].to_string()
};
spans.push(Span::styled(name_display, name_style));
let size_header = format!(
"{:>width$}",
format!(
"{}{}",
t!("file_browser.size"),
if state.sort_mode == SortMode::Size {
sort_arrow
} else {
" "
}
),
width = size_col_width
);
let is_size_hovered = matches!(
hover_target,
Some(HoverTarget::FileBrowserHeader(SortMode::Size))
);
let size_style = if state.sort_mode == SortMode::Size {
active_header_style
} else if is_size_hovered {
hover_header_style
} else {
header_style
};
spans.push(Span::styled(size_header, size_style));
spans.push(Span::styled(" ", header_style));
let modified_header = format!(
"{:>width$}",
format!(
"{}{}",
t!("file_browser.modified"),
if state.sort_mode == SortMode::Modified {
sort_arrow
} else {
" "
}
),
width = date_col_width
);
let is_modified_hovered = matches!(
hover_target,
Some(HoverTarget::FileBrowserHeader(SortMode::Modified))
);
let modified_style = if state.sort_mode == SortMode::Modified {
active_header_style
} else if is_modified_hovered {
hover_header_style
} else {
header_style
};
spans.push(Span::styled(modified_header, modified_style));
let line = Line::from(spans);
let paragraph = Paragraph::new(vec![line]);
frame.render_widget(paragraph, area);
}
fn render_file_list(
frame: &mut Frame,
area: Rect,
state: &FileOpenState,
theme: &crate::view::theme::Theme,
hover_target: &Option<crate::app::HoverTarget>,
) -> usize {
use crate::app::HoverTarget;
let visible_rows = area.height as usize;
let width = area.width as usize;
let size_col_width = 10;
let date_col_width = 14;
let name_col_width = width.saturating_sub(size_col_width + date_col_width + 4);
let is_files_active = state.active_section == FileOpenSection::Files;
if state.loading {
let loading_line = Line::from(Span::styled(
t!("file_browser.loading").to_string(),
Style::default()
.fg(theme.help_separator_fg)
.bg(theme.popup_bg),
));
let paragraph = Paragraph::new(vec![loading_line]);
frame.render_widget(paragraph, area);
return visible_rows;
}
if let Some(error) = &state.error {
let error_line = Line::from(Span::styled(
t!("file_browser.error", error = error).to_string(),
Style::default()
.fg(theme.diagnostic_error_fg)
.bg(theme.popup_bg),
));
let paragraph = Paragraph::new(vec![error_line]);
frame.render_widget(paragraph, area);
return visible_rows;
}
if state.entries.is_empty() {
let empty_line = Line::from(Span::styled(
format!(" {}", t!("file_browser.empty")),
Style::default()
.fg(theme.help_separator_fg)
.bg(theme.popup_bg),
));
let paragraph = Paragraph::new(vec![empty_line]);
frame.render_widget(paragraph, area);
return visible_rows;
}
let mut lines = Vec::new();
let visible_entries = state.visible_entries(visible_rows);
for (view_idx, entry) in visible_entries.iter().enumerate() {
let actual_idx = state.scroll_offset + view_idx;
let is_selected = is_files_active && state.selected_index == Some(actual_idx);
let is_hovered =
matches!(hover_target, Some(HoverTarget::FileBrowserEntry(i)) if *i == actual_idx);
let base_style = if is_selected {
Style::default()
.fg(theme.popup_text_fg)
.bg(theme.suggestion_selected_bg)
} else if is_hovered && entry.matches_filter {
Style::default()
.fg(theme.menu_hover_fg)
.bg(theme.menu_hover_bg)
} else if !entry.matches_filter {
Style::default()
.fg(theme.help_separator_fg)
.bg(theme.popup_bg)
.add_modifier(Modifier::DIM)
} else {
Style::default().fg(theme.popup_text_fg).bg(theme.popup_bg)
};
let mut spans = Vec::new();
let name_with_indicator = if entry.fs_entry.is_dir() {
format!("{}/", entry.fs_entry.name)
} else if entry.fs_entry.is_symlink() {
format!("{}@", entry.fs_entry.name)
} else {
entry.fs_entry.name.clone()
};
let name_display = if name_with_indicator.len() < name_col_width {
format!("{:<width$}", name_with_indicator, width = name_col_width)
} else {
let truncated: String = name_with_indicator
.chars()
.take(name_col_width - 3)
.collect();
format!("{}...", truncated)
};
let name_style = if entry.fs_entry.is_dir() && !is_selected {
base_style.fg(theme.help_key_fg)
} else {
base_style
};
spans.push(Span::styled(name_display, name_style));
let size_display = if entry.fs_entry.is_dir() {
format!("{:>width$}", "--", width = size_col_width)
} else {
let size = entry
.fs_entry
.metadata
.as_ref()
.map(|m| format_size(m.size))
.unwrap_or_else(|| "--".to_string());
format!("{:>width$}", size, width = size_col_width)
};
spans.push(Span::styled(size_display, base_style));
spans.push(Span::styled(" ", base_style));
let modified_display = entry
.fs_entry
.metadata
.as_ref()
.and_then(|m| m.modified)
.map(format_modified)
.unwrap_or_else(|| "--".to_string());
let modified_formatted =
format!("{:>width$}", modified_display, width = date_col_width);
spans.push(Span::styled(modified_formatted, base_style));
lines.push(Line::from(spans));
}
while lines.len() < visible_rows {
lines.push(Line::from(Span::styled(
" ".repeat(width),
Style::default().bg(theme.popup_bg),
)));
}
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, area);
visible_rows
}
}
#[derive(Debug, Clone)]
pub struct FileBrowserLayout {
pub nav_area: Rect,
pub header_area: Rect,
pub list_area: Rect,
pub scrollbar_area: Rect,
pub thumb_start: usize,
pub thumb_end: usize,
pub visible_rows: usize,
pub content_width: u16,
}
impl FileBrowserLayout {
pub fn is_in_list(&self, x: u16, y: u16) -> bool {
x >= self.list_area.x
&& x < self.list_area.x + self.list_area.width
&& y >= self.list_area.y
&& y < self.list_area.y + self.list_area.height
}
pub fn click_to_index(&self, y: u16, scroll_offset: usize) -> Option<usize> {
if y < self.list_area.y || y >= self.list_area.y + self.list_area.height {
return None;
}
let row = (y - self.list_area.y) as usize;
Some(scroll_offset + row)
}
pub fn is_in_nav(&self, x: u16, y: u16) -> bool {
x >= self.nav_area.x
&& x < self.nav_area.x + self.nav_area.width
&& y >= self.nav_area.y
&& y < self.nav_area.y + self.nav_area.height
}
pub fn nav_shortcut_at(&self, x: u16, y: u16, shortcut_labels: &[&str]) -> Option<usize> {
if y != self.nav_area.y + 1 {
return None;
}
let rel_x = x.saturating_sub(self.nav_area.x) as usize;
let prefix_len = 13;
if rel_x < prefix_len {
return None;
}
let mut current_x = prefix_len;
for (idx, label) in shortcut_labels.iter().enumerate() {
let shortcut_width = str_width(label) + 2;
if rel_x >= current_x && rel_x < current_x + shortcut_width {
return Some(idx);
}
current_x += shortcut_width;
if idx < shortcut_labels.len() - 1 {
current_x += 3;
}
}
None
}
pub fn is_in_header(&self, x: u16, y: u16) -> bool {
x >= self.header_area.x
&& x < self.header_area.x + self.header_area.width
&& y >= self.header_area.y
&& y < self.header_area.y + self.header_area.height
}
pub fn header_column_at(&self, x: u16) -> Option<SortMode> {
let rel_x = x.saturating_sub(self.header_area.x) as usize;
let width = self.header_area.width as usize;
let size_col_width = 10;
let date_col_width = 14;
let name_col_width = width.saturating_sub(size_col_width + date_col_width + 4);
if rel_x < name_col_width {
Some(SortMode::Name)
} else if rel_x < name_col_width + size_col_width {
Some(SortMode::Size)
} else {
Some(SortMode::Modified)
}
}
pub fn is_in_scrollbar(&self, x: u16, y: u16) -> bool {
x >= self.scrollbar_area.x
&& x < self.scrollbar_area.x + self.scrollbar_area.width
&& y >= self.scrollbar_area.y
&& y < self.scrollbar_area.y + self.scrollbar_area.height
}
pub fn is_in_thumb(&self, y: u16) -> bool {
let rel_y = y.saturating_sub(self.scrollbar_area.y) as usize;
rel_y >= self.thumb_start && rel_y < self.thumb_end
}
pub fn is_on_show_hidden_checkbox(&self, x: u16, y: u16) -> bool {
if y != self.nav_area.y {
return false;
}
if x < self.nav_area.x || x >= self.nav_area.x + self.nav_area.width {
return false;
}
let checkbox_width = 24u16;
x < self.nav_area.x + checkbox_width
}
}