1use super::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
10use super::status_bar::truncate_path;
11use crate::app::file_open::{
12 format_modified, format_size, FileOpenSection, FileOpenState, SortMode,
13};
14use crate::primitives::display_width::str_width;
15use ratatui::layout::Rect;
16use ratatui::style::{Modifier, Style};
17use ratatui::text::{Line, Span};
18use ratatui::widgets::{Block, Borders, Clear, Paragraph};
19use ratatui::Frame;
20use rust_i18n::t;
21
22pub struct FileBrowserRenderer;
24
25impl FileBrowserRenderer {
26 pub fn render(
39 frame: &mut Frame,
40 area: Rect,
41 state: &FileOpenState,
42 theme: &crate::view::theme::Theme,
43 hover_target: &Option<crate::app::HoverTarget>,
44 keybindings: Option<&crate::input::keybindings::KeybindingResolver>,
45 ) -> Option<FileBrowserLayout> {
46 if area.height < 5 || area.width < 20 {
47 return None;
48 }
49
50 frame.render_widget(Clear, area);
52
53 let max_title_len = (area.width as usize).saturating_sub(4); let truncated_path = truncate_path(&state.current_dir, max_title_len);
56 let title = format!(" {} ", truncated_path.to_string_plain());
57
58 let title_line = if truncated_path.truncated {
60 Line::from(vec![
61 Span::raw(" "),
62 Span::styled(
63 truncated_path.prefix.clone(),
64 Style::default().fg(theme.popup_border_fg),
65 ),
66 Span::styled("/[...]", Style::default().fg(theme.menu_highlight_fg)),
67 Span::styled(
68 truncated_path.suffix.clone(),
69 Style::default().fg(theme.popup_border_fg),
70 ),
71 Span::raw(" "),
72 ])
73 } else {
74 Line::from(title)
75 };
76
77 let block = Block::default()
79 .borders(Borders::ALL)
80 .border_style(Style::default().fg(theme.popup_border_fg))
81 .style(Style::default().bg(theme.popup_bg))
82 .title(title_line);
83
84 let inner_area = block.inner(area);
85 frame.render_widget(block, area);
86
87 if inner_area.height < 3 || inner_area.width < 10 {
88 return None;
89 }
90
91 let nav_height = 2u16; let header_height = 1u16;
94 let scrollbar_width = 1u16;
95
96 let content_width = inner_area.width.saturating_sub(scrollbar_width);
97 let list_height = inner_area.height.saturating_sub(nav_height + header_height);
98
99 let nav_area = Rect::new(inner_area.x, inner_area.y, content_width, nav_height);
101
102 let header_area = Rect::new(
104 inner_area.x,
105 inner_area.y + nav_height,
106 content_width,
107 header_height,
108 );
109
110 let list_area = Rect::new(
112 inner_area.x,
113 inner_area.y + nav_height + header_height,
114 content_width,
115 list_height,
116 );
117
118 let scrollbar_area = Rect::new(
120 inner_area.x + content_width,
121 inner_area.y + nav_height + header_height,
122 scrollbar_width,
123 list_height,
124 );
125
126 Self::render_navigation(frame, nav_area, state, theme, hover_target, keybindings);
128 Self::render_header(frame, header_area, state, theme, hover_target);
129 let visible_rows = Self::render_file_list(frame, list_area, state, theme, hover_target);
130
131 let scrollbar_state =
133 ScrollbarState::new(state.entries.len(), visible_rows, state.scroll_offset);
134 let is_scrollbar_hovered = matches!(
135 hover_target,
136 Some(crate::app::HoverTarget::FileBrowserScrollbar)
137 );
138 let colors = if is_scrollbar_hovered {
139 ScrollbarColors::from_theme_hover(theme)
140 } else {
141 ScrollbarColors::from_theme(theme)
142 };
143 let (thumb_start, thumb_end) =
144 render_scrollbar(frame, scrollbar_area, &scrollbar_state, &colors);
145
146 Some(FileBrowserLayout {
147 nav_area,
148 header_area,
149 list_area,
150 scrollbar_area,
151 thumb_start,
152 thumb_end,
153 visible_rows,
154 content_width,
155 })
156 }
157
158 fn render_navigation(
160 frame: &mut Frame,
161 area: Rect,
162 state: &FileOpenState,
163 theme: &crate::view::theme::Theme,
164 hover_target: &Option<crate::app::HoverTarget>,
165 keybindings: Option<&crate::input::keybindings::KeybindingResolver>,
166 ) {
167 use crate::app::HoverTarget;
168
169 let shortcut_hint = keybindings
171 .and_then(|kb| {
172 kb.get_keybinding_for_action(
173 &crate::input::keybindings::Action::FileBrowserToggleHidden,
174 crate::input::keybindings::KeyContext::Prompt,
175 )
176 })
177 .unwrap_or_default();
178
179 let checkbox_icon = if state.show_hidden { "☑" } else { "☐" };
181 let checkbox_label = format!("{} {}", checkbox_icon, t!("file_browser.show_hidden"));
182 let shortcut_text = if shortcut_hint.is_empty() {
183 String::new()
184 } else {
185 format!(" ({})", shortcut_hint)
186 };
187
188 let is_checkbox_hovered = matches!(
189 hover_target,
190 Some(HoverTarget::FileBrowserShowHiddenCheckbox)
191 );
192 let checkbox_style = if is_checkbox_hovered {
193 Style::default()
194 .fg(theme.menu_hover_fg)
195 .bg(theme.menu_hover_bg)
196 } else if state.show_hidden {
197 Style::default()
198 .fg(theme.menu_highlight_fg)
199 .bg(theme.popup_bg)
200 } else {
201 Style::default().fg(theme.help_key_fg).bg(theme.popup_bg)
202 };
203 let shortcut_style = if is_checkbox_hovered {
204 Style::default()
205 .fg(theme.menu_hover_fg)
206 .bg(theme.menu_hover_bg)
207 } else {
208 Style::default()
209 .fg(theme.help_separator_fg)
210 .bg(theme.popup_bg)
211 };
212
213 let mut checkbox_spans = Vec::new();
214 checkbox_spans.push(Span::styled(format!(" {}", checkbox_label), checkbox_style));
215 if !shortcut_text.is_empty() {
216 checkbox_spans.push(Span::styled(shortcut_text, shortcut_style));
217 }
218 let checkbox_line_width: usize = checkbox_spans.iter().map(|s| str_width(&s.content)).sum();
220 let remaining = (area.width as usize).saturating_sub(checkbox_line_width);
221 if remaining > 0 {
222 checkbox_spans.push(Span::styled(
223 " ".repeat(remaining),
224 Style::default().bg(theme.popup_bg),
225 ));
226 }
227 let checkbox_line = Line::from(checkbox_spans);
228
229 let is_nav_active = state.active_section == FileOpenSection::Navigation;
231
232 let mut nav_spans = Vec::new();
233 nav_spans.push(Span::styled(
234 format!(" {}", t!("file_browser.navigation")),
235 Style::default()
236 .fg(theme.help_separator_fg)
237 .bg(theme.popup_bg),
238 ));
239
240 for (idx, shortcut) in state.shortcuts.iter().enumerate() {
241 let is_selected = is_nav_active && idx == state.selected_shortcut;
242 let is_hovered =
243 matches!(hover_target, Some(HoverTarget::FileBrowserNavShortcut(i)) if *i == idx);
244
245 let style = if is_selected {
246 Style::default()
247 .fg(theme.popup_text_fg)
248 .bg(theme.suggestion_selected_bg)
249 .add_modifier(Modifier::BOLD)
250 } else if is_hovered {
251 Style::default()
252 .fg(theme.menu_hover_fg)
253 .bg(theme.menu_hover_bg)
254 } else {
255 Style::default().fg(theme.help_key_fg).bg(theme.popup_bg)
256 };
257
258 nav_spans.push(Span::styled(format!(" {} ", shortcut.label), style));
259
260 if idx < state.shortcuts.len() - 1 {
261 nav_spans.push(Span::styled(
262 " │ ",
263 Style::default()
264 .fg(theme.help_separator_fg)
265 .bg(theme.popup_bg),
266 ));
267 }
268 }
269
270 let nav_line_width: usize = nav_spans.iter().map(|s| str_width(&s.content)).sum();
272 let nav_remaining = (area.width as usize).saturating_sub(nav_line_width);
273 if nav_remaining > 0 {
274 nav_spans.push(Span::styled(
275 " ".repeat(nav_remaining),
276 Style::default().bg(theme.popup_bg),
277 ));
278 }
279 let nav_line = Line::from(nav_spans);
280
281 let paragraph = Paragraph::new(vec![checkbox_line, nav_line]);
282 frame.render_widget(paragraph, area);
283 }
284
285 fn render_header(
287 frame: &mut Frame,
288 area: Rect,
289 state: &FileOpenState,
290 theme: &crate::view::theme::Theme,
291 hover_target: &Option<crate::app::HoverTarget>,
292 ) {
293 use crate::app::HoverTarget;
294
295 let width = area.width as usize;
296
297 let size_col_width = 10;
299 let date_col_width = 14;
300 let name_col_width = width.saturating_sub(size_col_width + date_col_width + 4);
301
302 let header_style = Style::default()
303 .fg(theme.help_key_fg)
304 .bg(theme.menu_dropdown_bg)
305 .add_modifier(Modifier::BOLD);
306
307 let active_header_style = Style::default()
308 .fg(theme.menu_highlight_fg)
309 .bg(theme.menu_dropdown_bg)
310 .add_modifier(Modifier::BOLD);
311
312 let hover_header_style = Style::default()
313 .fg(theme.menu_hover_fg)
314 .bg(theme.menu_hover_bg)
315 .add_modifier(Modifier::BOLD);
316
317 let sort_arrow = if state.sort_ascending { "▲" } else { "▼" };
319
320 let mut spans = Vec::new();
321
322 let name_header = format!(
324 " {}{}",
325 t!("file_browser.name"),
326 if state.sort_mode == SortMode::Name {
327 sort_arrow
328 } else {
329 " "
330 }
331 );
332 let is_name_hovered = matches!(
333 hover_target,
334 Some(HoverTarget::FileBrowserHeader(SortMode::Name))
335 );
336 let name_style = if state.sort_mode == SortMode::Name {
337 active_header_style
338 } else if is_name_hovered {
339 hover_header_style
340 } else {
341 header_style
342 };
343 let name_display = if name_header.len() < name_col_width {
344 format!("{:<width$}", name_header, width = name_col_width)
345 } else {
346 name_header[..name_col_width].to_string()
347 };
348 spans.push(Span::styled(name_display, name_style));
349
350 let size_header = format!(
352 "{:>width$}",
353 format!(
354 "{}{}",
355 t!("file_browser.size"),
356 if state.sort_mode == SortMode::Size {
357 sort_arrow
358 } else {
359 " "
360 }
361 ),
362 width = size_col_width
363 );
364 let is_size_hovered = matches!(
365 hover_target,
366 Some(HoverTarget::FileBrowserHeader(SortMode::Size))
367 );
368 let size_style = if state.sort_mode == SortMode::Size {
369 active_header_style
370 } else if is_size_hovered {
371 hover_header_style
372 } else {
373 header_style
374 };
375 spans.push(Span::styled(size_header, size_style));
376
377 spans.push(Span::styled(" ", header_style));
379
380 let modified_header = format!(
382 "{:>width$}",
383 format!(
384 "{}{}",
385 t!("file_browser.modified"),
386 if state.sort_mode == SortMode::Modified {
387 sort_arrow
388 } else {
389 " "
390 }
391 ),
392 width = date_col_width
393 );
394 let is_modified_hovered = matches!(
395 hover_target,
396 Some(HoverTarget::FileBrowserHeader(SortMode::Modified))
397 );
398 let modified_style = if state.sort_mode == SortMode::Modified {
399 active_header_style
400 } else if is_modified_hovered {
401 hover_header_style
402 } else {
403 header_style
404 };
405 spans.push(Span::styled(modified_header, modified_style));
406
407 let line = Line::from(spans);
408 let paragraph = Paragraph::new(vec![line]);
409 frame.render_widget(paragraph, area);
410 }
411
412 fn render_file_list(
416 frame: &mut Frame,
417 area: Rect,
418 state: &FileOpenState,
419 theme: &crate::view::theme::Theme,
420 hover_target: &Option<crate::app::HoverTarget>,
421 ) -> usize {
422 use crate::app::HoverTarget;
423
424 let visible_rows = area.height as usize;
425 let width = area.width as usize;
426
427 let size_col_width = 10;
429 let date_col_width = 14;
430 let name_col_width = width.saturating_sub(size_col_width + date_col_width + 4);
431
432 let is_files_active = state.active_section == FileOpenSection::Files;
433
434 if state.loading {
436 let loading_line = Line::from(Span::styled(
437 t!("file_browser.loading").to_string(),
438 Style::default()
439 .fg(theme.help_separator_fg)
440 .bg(theme.popup_bg),
441 ));
442 let paragraph = Paragraph::new(vec![loading_line]);
443 frame.render_widget(paragraph, area);
444 return visible_rows;
445 }
446
447 if let Some(error) = &state.error {
449 let error_line = Line::from(Span::styled(
450 t!("file_browser.error", error = error).to_string(),
451 Style::default()
452 .fg(theme.diagnostic_error_fg)
453 .bg(theme.popup_bg),
454 ));
455 let paragraph = Paragraph::new(vec![error_line]);
456 frame.render_widget(paragraph, area);
457 return visible_rows;
458 }
459
460 if state.entries.is_empty() {
462 let empty_line = Line::from(Span::styled(
463 format!(" {}", t!("file_browser.empty")),
464 Style::default()
465 .fg(theme.help_separator_fg)
466 .bg(theme.popup_bg),
467 ));
468 let paragraph = Paragraph::new(vec![empty_line]);
469 frame.render_widget(paragraph, area);
470 return visible_rows;
471 }
472
473 let mut lines = Vec::new();
474 let visible_entries = state.visible_entries(visible_rows);
475
476 for (view_idx, entry) in visible_entries.iter().enumerate() {
477 let actual_idx = state.scroll_offset + view_idx;
478 let is_selected = is_files_active && state.selected_index == Some(actual_idx);
479 let is_hovered =
480 matches!(hover_target, Some(HoverTarget::FileBrowserEntry(i)) if *i == actual_idx);
481
482 let base_style = if is_selected {
484 Style::default()
485 .fg(theme.popup_text_fg)
486 .bg(theme.suggestion_selected_bg)
487 } else if is_hovered && entry.matches_filter {
488 Style::default()
489 .fg(theme.menu_hover_fg)
490 .bg(theme.menu_hover_bg)
491 } else if !entry.matches_filter {
492 Style::default()
494 .fg(theme.help_separator_fg)
495 .bg(theme.popup_bg)
496 .add_modifier(Modifier::DIM)
497 } else {
498 Style::default().fg(theme.popup_text_fg).bg(theme.popup_bg)
499 };
500
501 let mut spans = Vec::new();
502
503 let name_with_indicator = if entry.fs_entry.is_dir() {
505 format!("{}/", entry.fs_entry.name)
506 } else if entry.fs_entry.is_symlink() {
507 format!("{}@", entry.fs_entry.name)
508 } else {
509 entry.fs_entry.name.clone()
510 };
511 let name_display = if name_with_indicator.len() < name_col_width {
512 format!("{:<width$}", name_with_indicator, width = name_col_width)
513 } else {
514 let truncated: String = name_with_indicator
516 .chars()
517 .take(name_col_width - 3)
518 .collect();
519 format!("{}...", truncated)
520 };
521
522 let name_style = if entry.fs_entry.is_dir() && !is_selected {
524 base_style.fg(theme.help_key_fg)
525 } else {
526 base_style
527 };
528 spans.push(Span::styled(name_display, name_style));
529
530 let size_display = if entry.fs_entry.is_dir() {
532 format!("{:>width$}", "--", width = size_col_width)
533 } else {
534 let size = entry
535 .fs_entry
536 .metadata
537 .as_ref()
538 .and_then(|m| m.size)
539 .map(format_size)
540 .unwrap_or_else(|| "--".to_string());
541 format!("{:>width$}", size, width = size_col_width)
542 };
543 spans.push(Span::styled(size_display, base_style));
544
545 spans.push(Span::styled(" ", base_style));
547
548 let modified_display = entry
550 .fs_entry
551 .metadata
552 .as_ref()
553 .and_then(|m| m.modified)
554 .map(format_modified)
555 .unwrap_or_else(|| "--".to_string());
556 let modified_formatted =
557 format!("{:>width$}", modified_display, width = date_col_width);
558 spans.push(Span::styled(modified_formatted, base_style));
559
560 lines.push(Line::from(spans));
561 }
562
563 while lines.len() < visible_rows {
565 lines.push(Line::from(Span::styled(
566 " ".repeat(width),
567 Style::default().bg(theme.popup_bg),
568 )));
569 }
570
571 let paragraph = Paragraph::new(lines);
572 frame.render_widget(paragraph, area);
573
574 visible_rows
575 }
576}
577
578#[derive(Debug, Clone)]
580pub struct FileBrowserLayout {
581 pub nav_area: Rect,
583 pub header_area: Rect,
585 pub list_area: Rect,
587 pub scrollbar_area: Rect,
589 pub thumb_start: usize,
591 pub thumb_end: usize,
593 pub visible_rows: usize,
595 pub content_width: u16,
597}
598
599impl FileBrowserLayout {
600 pub fn is_in_list(&self, x: u16, y: u16) -> bool {
602 x >= self.list_area.x
603 && x < self.list_area.x + self.list_area.width
604 && y >= self.list_area.y
605 && y < self.list_area.y + self.list_area.height
606 }
607
608 pub fn click_to_index(&self, y: u16, scroll_offset: usize) -> Option<usize> {
610 if y < self.list_area.y || y >= self.list_area.y + self.list_area.height {
611 return None;
612 }
613 let row = (y - self.list_area.y) as usize;
614 Some(scroll_offset + row)
615 }
616
617 pub fn is_in_nav(&self, x: u16, y: u16) -> bool {
619 x >= self.nav_area.x
620 && x < self.nav_area.x + self.nav_area.width
621 && y >= self.nav_area.y
622 && y < self.nav_area.y + self.nav_area.height
623 }
624
625 pub fn nav_shortcut_at(&self, x: u16, y: u16, shortcut_labels: &[&str]) -> Option<usize> {
629 if y != self.nav_area.y + 1 {
631 return None;
632 }
633
634 let rel_x = x.saturating_sub(self.nav_area.x) as usize;
635
636 let prefix_len = 13;
638 if rel_x < prefix_len {
639 return None;
640 }
641
642 let mut current_x = prefix_len;
643 for (idx, label) in shortcut_labels.iter().enumerate() {
644 let shortcut_width = str_width(label) + 2;
646
647 if rel_x >= current_x && rel_x < current_x + shortcut_width {
648 return Some(idx);
649 }
650 current_x += shortcut_width;
651
652 if idx < shortcut_labels.len() - 1 {
654 current_x += 3;
655 }
656 }
657
658 None
659 }
660
661 pub fn is_in_header(&self, x: u16, y: u16) -> bool {
663 x >= self.header_area.x
664 && x < self.header_area.x + self.header_area.width
665 && y >= self.header_area.y
666 && y < self.header_area.y + self.header_area.height
667 }
668
669 pub fn header_column_at(&self, x: u16) -> Option<SortMode> {
671 let rel_x = x.saturating_sub(self.header_area.x) as usize;
672 let width = self.header_area.width as usize;
673
674 let size_col_width = 10;
675 let date_col_width = 14;
676 let name_col_width = width.saturating_sub(size_col_width + date_col_width + 4);
677
678 if rel_x < name_col_width {
679 Some(SortMode::Name)
680 } else if rel_x < name_col_width + size_col_width {
681 Some(SortMode::Size)
682 } else {
683 Some(SortMode::Modified)
684 }
685 }
686
687 pub fn is_in_scrollbar(&self, x: u16, y: u16) -> bool {
689 x >= self.scrollbar_area.x
690 && x < self.scrollbar_area.x + self.scrollbar_area.width
691 && y >= self.scrollbar_area.y
692 && y < self.scrollbar_area.y + self.scrollbar_area.height
693 }
694
695 pub fn is_in_thumb(&self, y: u16) -> bool {
697 let rel_y = y.saturating_sub(self.scrollbar_area.y) as usize;
698 rel_y >= self.thumb_start && rel_y < self.thumb_end
699 }
700
701 pub fn is_on_show_hidden_checkbox(&self, x: u16, y: u16) -> bool {
705 if y != self.nav_area.y {
707 return false;
708 }
709
710 if x < self.nav_area.x || x >= self.nav_area.x + self.nav_area.width {
712 return false;
713 }
714
715 let checkbox_width = 24u16;
718 x < self.nav_area.x + checkbox_width
719 }
720}