Skip to main content

fresh/view/ui/
file_browser.rs

1//! File browser popup renderer for the Open File dialog
2//!
3//! Renders a structured popup above the prompt with:
4//! - Navigation shortcuts (parent, root, home)
5//! - Sortable column headers (name, size, modified)
6//! - File list with metadata
7//! - Scrollbar for long lists
8
9use 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
22/// Renderer for the file browser popup
23pub struct FileBrowserRenderer;
24
25impl FileBrowserRenderer {
26    /// Render the file browser popup
27    ///
28    /// # Arguments
29    /// * `frame` - The ratatui frame to render to
30    /// * `area` - The rectangular area for the popup (above the prompt)
31    /// * `state` - The file open dialog state
32    /// * `theme` - The active theme for colors
33    /// * `hover_target` - Current mouse hover target (for highlighting)
34    /// * `keybindings` - Optional keybinding resolver for displaying shortcuts
35    ///
36    /// # Returns
37    /// Information for mouse hit testing (scrollbar area, thumb positions, etc.)
38    pub fn render(
39        frame: &mut Frame,
40        area: Rect,
41        state: &mut 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        // Clear the area behind the popup
51        frame.render_widget(Clear, area);
52
53        // Truncate path for title if needed (leave space for borders and padding)
54        let max_title_len = (area.width as usize).saturating_sub(4); // 2 for borders, 2 for padding
55        let truncated_path = truncate_path(&state.current_dir, max_title_len);
56        let title = format!(" {} ", truncated_path.to_string_plain());
57
58        // Create styled title with highlighted [...] if truncated
59        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        // Create the popup block with border
78        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        // Layout: Navigation (2-3 rows) | Header (1 row) | File list (remaining) | Scrollbar (1 col)
92        let nav_height = 2u16; // Navigation shortcuts section
93        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        // Navigation area
100        let nav_area = Rect::new(inner_area.x, inner_area.y, content_width, nav_height);
101
102        // Header area
103        let header_area = Rect::new(
104            inner_area.x,
105            inner_area.y + nav_height,
106            content_width,
107            header_height,
108        );
109
110        // File list area
111        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        // Scrollbar area
119        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        // Render each section with hover state
127        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        // Render scrollbar with theme colors (hover-aware)
132        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            popup_area: area,
148            nav_area,
149            header_area,
150            list_area,
151            scrollbar_area,
152            thumb_start,
153            thumb_end,
154            visible_rows,
155            content_width,
156        })
157    }
158
159    /// Render navigation shortcuts section with checkboxes on first row
160    fn render_navigation(
161        frame: &mut Frame,
162        area: Rect,
163        state: &FileOpenState,
164        theme: &crate::view::theme::Theme,
165        hover_target: &Option<crate::app::HoverTarget>,
166        keybindings: Option<&crate::input::keybindings::KeybindingResolver>,
167    ) {
168        use crate::app::HoverTarget;
169
170        // Look up keybindings for toggle actions
171        let hidden_shortcut = keybindings
172            .and_then(|kb| {
173                kb.get_keybinding_for_action(
174                    &crate::input::keybindings::Action::FileBrowserToggleHidden,
175                    crate::input::keybindings::KeyContext::Prompt,
176                )
177            })
178            .unwrap_or_default();
179
180        let encoding_shortcut = keybindings
181            .and_then(|kb| {
182                kb.get_keybinding_for_action(
183                    &crate::input::keybindings::Action::FileBrowserToggleDetectEncoding,
184                    crate::input::keybindings::KeyContext::Prompt,
185                )
186            })
187            .unwrap_or_default();
188
189        // First line: "Show Hidden" and "Detect Encoding" checkboxes
190        let mut checkbox_spans = Vec::new();
191
192        // Show Hidden checkbox
193        let hidden_icon = if state.show_hidden { "☑" } else { "☐" };
194        let hidden_label = format!("{} {}", hidden_icon, t!("file_browser.show_hidden"));
195        let hidden_shortcut_text = if hidden_shortcut.is_empty() {
196            String::new()
197        } else {
198            format!(" ({})", hidden_shortcut)
199        };
200
201        let is_hidden_hovered = matches!(
202            hover_target,
203            Some(HoverTarget::FileBrowserShowHiddenCheckbox)
204        );
205        let hidden_style = if is_hidden_hovered {
206            Style::default()
207                .fg(theme.menu_hover_fg)
208                .bg(theme.menu_hover_bg)
209        } else if state.show_hidden {
210            Style::default()
211                .fg(theme.menu_highlight_fg)
212                .bg(theme.popup_bg)
213        } else {
214            Style::default().fg(theme.help_key_fg).bg(theme.popup_bg)
215        };
216        let hidden_shortcut_style = if is_hidden_hovered {
217            Style::default()
218                .fg(theme.menu_hover_fg)
219                .bg(theme.menu_hover_bg)
220        } else {
221            Style::default()
222                .fg(theme.help_separator_fg)
223                .bg(theme.popup_bg)
224        };
225
226        checkbox_spans.push(Span::styled(format!(" {}", hidden_label), hidden_style));
227        if !hidden_shortcut_text.is_empty() {
228            checkbox_spans.push(Span::styled(hidden_shortcut_text, hidden_shortcut_style));
229        }
230
231        // Separator between checkboxes
232        checkbox_spans.push(Span::styled(
233            " │ ",
234            Style::default()
235                .fg(theme.help_separator_fg)
236                .bg(theme.popup_bg),
237        ));
238
239        // Detect Encoding checkbox with underlined E
240        let encoding_icon = if state.detect_encoding { "☑" } else { "☐" };
241        let is_encoding_hovered = matches!(
242            hover_target,
243            Some(HoverTarget::FileBrowserDetectEncodingCheckbox)
244        );
245        let encoding_style = if is_encoding_hovered {
246            Style::default()
247                .fg(theme.menu_hover_fg)
248                .bg(theme.menu_hover_bg)
249        } else if state.detect_encoding {
250            Style::default()
251                .fg(theme.menu_highlight_fg)
252                .bg(theme.popup_bg)
253        } else {
254            Style::default().fg(theme.help_key_fg).bg(theme.popup_bg)
255        };
256        let encoding_underline_style = if is_encoding_hovered {
257            Style::default()
258                .fg(theme.menu_hover_fg)
259                .bg(theme.menu_hover_bg)
260                .add_modifier(Modifier::UNDERLINED)
261        } else if state.detect_encoding {
262            Style::default()
263                .fg(theme.menu_highlight_fg)
264                .bg(theme.popup_bg)
265                .add_modifier(Modifier::UNDERLINED)
266        } else {
267            Style::default()
268                .fg(theme.help_key_fg)
269                .bg(theme.popup_bg)
270                .add_modifier(Modifier::UNDERLINED)
271        };
272        let encoding_shortcut_style = if is_encoding_hovered {
273            Style::default()
274                .fg(theme.menu_hover_fg)
275                .bg(theme.menu_hover_bg)
276        } else {
277            Style::default()
278                .fg(theme.help_separator_fg)
279                .bg(theme.popup_bg)
280        };
281
282        // "☐ Detect " + "E" (underlined) + "ncoding"
283        checkbox_spans.push(Span::styled(
284            format!("{} Detect ", encoding_icon),
285            encoding_style,
286        ));
287        checkbox_spans.push(Span::styled("E", encoding_underline_style));
288        checkbox_spans.push(Span::styled("ncoding", encoding_style));
289
290        if !encoding_shortcut.is_empty() {
291            checkbox_spans.push(Span::styled(
292                format!(" ({})", encoding_shortcut),
293                encoding_shortcut_style,
294            ));
295        }
296
297        // Fill rest of row with background
298        let checkbox_line_width: usize = checkbox_spans.iter().map(|s| str_width(&s.content)).sum();
299        let remaining = (area.width as usize).saturating_sub(checkbox_line_width);
300        if remaining > 0 {
301            checkbox_spans.push(Span::styled(
302                " ".repeat(remaining),
303                Style::default().bg(theme.popup_bg),
304            ));
305        }
306        let checkbox_line = Line::from(checkbox_spans);
307
308        // Second line: Navigation shortcuts
309        let is_nav_active = state.active_section == FileOpenSection::Navigation;
310
311        let mut nav_spans = Vec::new();
312        nav_spans.push(Span::styled(
313            format!(" {}", t!("file_browser.navigation")),
314            Style::default()
315                .fg(theme.help_separator_fg)
316                .bg(theme.popup_bg),
317        ));
318
319        for (idx, shortcut) in state.shortcuts.iter().enumerate() {
320            let is_selected = is_nav_active && idx == state.selected_shortcut;
321            let is_hovered =
322                matches!(hover_target, Some(HoverTarget::FileBrowserNavShortcut(i)) if *i == idx);
323
324            let style = if is_selected {
325                Style::default()
326                    .fg(theme.popup_text_fg)
327                    .bg(theme.suggestion_selected_bg)
328                    .add_modifier(Modifier::BOLD)
329            } else if is_hovered {
330                Style::default()
331                    .fg(theme.menu_hover_fg)
332                    .bg(theme.menu_hover_bg)
333            } else {
334                Style::default().fg(theme.help_key_fg).bg(theme.popup_bg)
335            };
336
337            nav_spans.push(Span::styled(format!(" {} ", shortcut.label), style));
338
339            if idx < state.shortcuts.len() - 1 {
340                nav_spans.push(Span::styled(
341                    " │ ",
342                    Style::default()
343                        .fg(theme.help_separator_fg)
344                        .bg(theme.popup_bg),
345                ));
346            }
347        }
348
349        // Fill rest of navigation row with background
350        let nav_line_width: usize = nav_spans.iter().map(|s| str_width(&s.content)).sum();
351        let nav_remaining = (area.width as usize).saturating_sub(nav_line_width);
352        if nav_remaining > 0 {
353            nav_spans.push(Span::styled(
354                " ".repeat(nav_remaining),
355                Style::default().bg(theme.popup_bg),
356            ));
357        }
358        let nav_line = Line::from(nav_spans);
359
360        let paragraph = Paragraph::new(vec![checkbox_line, nav_line]);
361        frame.render_widget(paragraph, area);
362    }
363
364    /// Render sortable column headers
365    fn render_header(
366        frame: &mut Frame,
367        area: Rect,
368        state: &FileOpenState,
369        theme: &crate::view::theme::Theme,
370        hover_target: &Option<crate::app::HoverTarget>,
371    ) {
372        use crate::app::HoverTarget;
373
374        let width = area.width as usize;
375
376        // Column widths
377        let size_col_width = 10;
378        let date_col_width = 14;
379        let name_col_width = width.saturating_sub(size_col_width + date_col_width + 4);
380
381        let header_style = Style::default()
382            .fg(theme.help_key_fg)
383            .bg(theme.menu_dropdown_bg)
384            .add_modifier(Modifier::BOLD);
385
386        let active_header_style = Style::default()
387            .fg(theme.menu_highlight_fg)
388            .bg(theme.menu_dropdown_bg)
389            .add_modifier(Modifier::BOLD);
390
391        let hover_header_style = Style::default()
392            .fg(theme.menu_hover_fg)
393            .bg(theme.menu_hover_bg)
394            .add_modifier(Modifier::BOLD);
395
396        // Sort indicator
397        let sort_arrow = if state.sort_ascending { "▲" } else { "▼" };
398
399        let mut spans = Vec::new();
400
401        // Name column
402        let name_header = format!(
403            " {}{}",
404            t!("file_browser.name"),
405            if state.sort_mode == SortMode::Name {
406                sort_arrow
407            } else {
408                " "
409            }
410        );
411        let is_name_hovered = matches!(
412            hover_target,
413            Some(HoverTarget::FileBrowserHeader(SortMode::Name))
414        );
415        let name_style = if state.sort_mode == SortMode::Name {
416            active_header_style
417        } else if is_name_hovered {
418            hover_header_style
419        } else {
420            header_style
421        };
422        let name_display = fit_header_to_col_width(&name_header, name_col_width);
423        spans.push(Span::styled(name_display, name_style));
424
425        // Size column
426        let size_header = format!(
427            "{:>width$}",
428            format!(
429                "{}{}",
430                t!("file_browser.size"),
431                if state.sort_mode == SortMode::Size {
432                    sort_arrow
433                } else {
434                    " "
435                }
436            ),
437            width = size_col_width
438        );
439        let is_size_hovered = matches!(
440            hover_target,
441            Some(HoverTarget::FileBrowserHeader(SortMode::Size))
442        );
443        let size_style = if state.sort_mode == SortMode::Size {
444            active_header_style
445        } else if is_size_hovered {
446            hover_header_style
447        } else {
448            header_style
449        };
450        spans.push(Span::styled(size_header, size_style));
451
452        // Separator
453        spans.push(Span::styled("  ", header_style));
454
455        // Modified column
456        let modified_header = format!(
457            "{:>width$}",
458            format!(
459                "{}{}",
460                t!("file_browser.modified"),
461                if state.sort_mode == SortMode::Modified {
462                    sort_arrow
463                } else {
464                    " "
465                }
466            ),
467            width = date_col_width
468        );
469        let is_modified_hovered = matches!(
470            hover_target,
471            Some(HoverTarget::FileBrowserHeader(SortMode::Modified))
472        );
473        let modified_style = if state.sort_mode == SortMode::Modified {
474            active_header_style
475        } else if is_modified_hovered {
476            hover_header_style
477        } else {
478            header_style
479        };
480        spans.push(Span::styled(modified_header, modified_style));
481
482        let line = Line::from(spans);
483        let paragraph = Paragraph::new(vec![line]);
484        frame.render_widget(paragraph, area);
485    }
486
487    /// Render the file list with metadata columns
488    ///
489    /// Returns the number of visible rows
490    fn render_file_list(
491        frame: &mut Frame,
492        area: Rect,
493        state: &mut FileOpenState,
494        theme: &crate::view::theme::Theme,
495        hover_target: &Option<crate::app::HoverTarget>,
496    ) -> usize {
497        use crate::app::HoverTarget;
498
499        let visible_rows = area.height as usize;
500        // Sync scroll/selection with the actual viewport before drawing —
501        // input handlers had to guess the height; only the renderer knows it.
502        state.update_scroll_for_visible_rows(visible_rows);
503        let width = area.width as usize;
504
505        // Column widths (matching header)
506        let size_col_width = 10;
507        let date_col_width = 14;
508        let name_col_width = width.saturating_sub(size_col_width + date_col_width + 4);
509
510        let is_files_active = state.active_section == FileOpenSection::Files;
511
512        // Loading state
513        if state.loading {
514            let loading_line = Line::from(Span::styled(
515                t!("file_browser.loading").to_string(),
516                Style::default()
517                    .fg(theme.help_separator_fg)
518                    .bg(theme.popup_bg),
519            ));
520            let paragraph = Paragraph::new(vec![loading_line]);
521            frame.render_widget(paragraph, area);
522            return visible_rows;
523        }
524
525        // Error state
526        if let Some(error) = &state.error {
527            let error_line = Line::from(Span::styled(
528                t!("file_browser.error", error = error).to_string(),
529                Style::default()
530                    .fg(theme.diagnostic_error_fg)
531                    .bg(theme.popup_bg),
532            ));
533            let paragraph = Paragraph::new(vec![error_line]);
534            frame.render_widget(paragraph, area);
535            return visible_rows;
536        }
537
538        // Empty state
539        if state.entries.is_empty() {
540            let empty_line = Line::from(Span::styled(
541                format!(" {}", t!("file_browser.empty")),
542                Style::default()
543                    .fg(theme.help_separator_fg)
544                    .bg(theme.popup_bg),
545            ));
546            let paragraph = Paragraph::new(vec![empty_line]);
547            frame.render_widget(paragraph, area);
548            return visible_rows;
549        }
550
551        let mut lines = Vec::new();
552        let visible_entries = state.visible_entries(visible_rows);
553
554        for (view_idx, entry) in visible_entries.iter().enumerate() {
555            let actual_idx = state.scroll_offset + view_idx;
556            let is_selected = is_files_active && state.selected_index == Some(actual_idx);
557            let is_hovered =
558                matches!(hover_target, Some(HoverTarget::FileBrowserEntry(i)) if *i == actual_idx);
559
560            // Base style based on selection, hover, and filter match
561            let base_style = if is_selected {
562                Style::default()
563                    .fg(theme.popup_text_fg)
564                    .bg(theme.suggestion_selected_bg)
565            } else if is_hovered && entry.matches_filter {
566                Style::default()
567                    .fg(theme.menu_hover_fg)
568                    .bg(theme.menu_hover_bg)
569            } else if !entry.matches_filter {
570                // Non-matching items are dimmed using the separator color
571                Style::default()
572                    .fg(theme.help_separator_fg)
573                    .bg(theme.popup_bg)
574                    .add_modifier(Modifier::DIM)
575            } else {
576                Style::default().fg(theme.popup_text_fg).bg(theme.popup_bg)
577            };
578
579            let mut spans = Vec::new();
580
581            // Name column with trailing type indicator (dirs get /, symlinks get @)
582            let name_with_indicator = if entry.fs_entry.is_dir() {
583                format!("{}/", entry.fs_entry.name)
584            } else if entry.fs_entry.is_symlink() {
585                format!("{}@", entry.fs_entry.name)
586            } else {
587                entry.fs_entry.name.clone()
588            };
589            let name_display = if name_with_indicator.len() < name_col_width {
590                format!("{:<width$}", name_with_indicator, width = name_col_width)
591            } else {
592                // Truncate with ellipsis
593                let truncated: String = name_with_indicator
594                    .chars()
595                    .take(name_col_width - 3)
596                    .collect();
597                format!("{}...", truncated)
598            };
599
600            // Color directories differently
601            let name_style = if entry.fs_entry.is_dir() && !is_selected {
602                base_style.fg(theme.help_key_fg)
603            } else {
604                base_style
605            };
606            spans.push(Span::styled(name_display, name_style));
607
608            // Size column
609            let size_display = if entry.fs_entry.is_dir() {
610                format!("{:>width$}", "--", width = size_col_width)
611            } else {
612                let size = entry
613                    .fs_entry
614                    .metadata
615                    .as_ref()
616                    .map(|m| format_size(m.size))
617                    .unwrap_or_else(|| "--".to_string());
618                format!("{:>width$}", size, width = size_col_width)
619            };
620            spans.push(Span::styled(size_display, base_style));
621
622            // Separator
623            spans.push(Span::styled("  ", base_style));
624
625            // Modified column
626            let modified_display = entry
627                .fs_entry
628                .metadata
629                .as_ref()
630                .and_then(|m| m.modified)
631                .map(format_modified)
632                .unwrap_or_else(|| "--".to_string());
633            let modified_formatted =
634                format!("{:>width$}", modified_display, width = date_col_width);
635            spans.push(Span::styled(modified_formatted, base_style));
636
637            lines.push(Line::from(spans));
638        }
639
640        // Fill remaining rows with empty lines
641        while lines.len() < visible_rows {
642            lines.push(Line::from(Span::styled(
643                " ".repeat(width),
644                Style::default().bg(theme.popup_bg),
645            )));
646        }
647
648        let paragraph = Paragraph::new(lines);
649        frame.render_widget(paragraph, area);
650
651        visible_rows
652    }
653}
654
655/// Pad or truncate a header string so it occupies exactly `col_width`
656/// character positions. Counts characters (not bytes) so headers
657/// containing the sort arrow `▲`/`▼` (3 UTF-8 bytes each) or localized
658/// labels from `t!()` don't byte-slice through a multi-byte sequence and
659/// panic — same class as #1718.
660fn fit_header_to_col_width(header: &str, col_width: usize) -> String {
661    let chars = header.chars().count();
662    if chars < col_width {
663        format!("{:<width$}", header, width = col_width)
664    } else {
665        header.chars().take(col_width).collect()
666    }
667}
668
669/// Layout information for mouse hit testing
670#[derive(Debug, Clone)]
671pub struct FileBrowserLayout {
672    /// The overall popup area (including borders)
673    pub popup_area: Rect,
674    /// Navigation shortcuts area
675    pub nav_area: Rect,
676    /// Column headers area
677    pub header_area: Rect,
678    /// File list area
679    pub list_area: Rect,
680    /// Scrollbar area
681    pub scrollbar_area: Rect,
682    /// Scrollbar thumb start position
683    pub thumb_start: usize,
684    /// Scrollbar thumb end position
685    pub thumb_end: usize,
686    /// Number of visible rows in the file list
687    pub visible_rows: usize,
688    /// Width of the content area (for checkbox position calculation)
689    pub content_width: u16,
690}
691
692impl FileBrowserLayout {
693    /// Check if a position is within the overall popup area (including borders)
694    pub fn contains(&self, x: u16, y: u16) -> bool {
695        x >= self.popup_area.x
696            && x < self.popup_area.x + self.popup_area.width
697            && y >= self.popup_area.y
698            && y < self.popup_area.y + self.popup_area.height
699    }
700
701    /// Check if a position is within the file list area
702    pub fn is_in_list(&self, x: u16, y: u16) -> bool {
703        x >= self.list_area.x
704            && x < self.list_area.x + self.list_area.width
705            && y >= self.list_area.y
706            && y < self.list_area.y + self.list_area.height
707    }
708
709    /// Convert a click in the list area to an entry index
710    pub fn click_to_index(&self, y: u16, scroll_offset: usize) -> Option<usize> {
711        if y < self.list_area.y || y >= self.list_area.y + self.list_area.height {
712            return None;
713        }
714        let row = (y - self.list_area.y) as usize;
715        Some(scroll_offset + row)
716    }
717
718    /// Check if a position is in the navigation area
719    pub fn is_in_nav(&self, x: u16, y: u16) -> bool {
720        x >= self.nav_area.x
721            && x < self.nav_area.x + self.nav_area.width
722            && y >= self.nav_area.y
723            && y < self.nav_area.y + self.nav_area.height
724    }
725
726    /// Determine which navigation shortcut was clicked based on x position
727    /// The layout is: " Navigation: " (13 chars) then for each shortcut: " {label} " + " │ " separator
728    /// Navigation shortcuts are on the second row (y == nav_area.y + 1)
729    pub fn nav_shortcut_at(&self, x: u16, y: u16, shortcut_labels: &[&str]) -> Option<usize> {
730        // Navigation shortcuts are on the second row of the nav area
731        if y != self.nav_area.y + 1 {
732            return None;
733        }
734
735        let rel_x = x.saturating_sub(self.nav_area.x) as usize;
736
737        // Skip " Navigation: " prefix
738        let prefix_len = 13;
739        if rel_x < prefix_len {
740            return None;
741        }
742
743        let mut current_x = prefix_len;
744        for (idx, label) in shortcut_labels.iter().enumerate() {
745            // Each shortcut: " {label} " = visual width + 2 spaces
746            let shortcut_width = str_width(label) + 2;
747
748            if rel_x >= current_x && rel_x < current_x + shortcut_width {
749                return Some(idx);
750            }
751            current_x += shortcut_width;
752
753            // Separator: " │ " = 3 chars
754            if idx < shortcut_labels.len() - 1 {
755                current_x += 3;
756            }
757        }
758
759        None
760    }
761
762    /// Check if a position is in the header area (for sorting)
763    pub fn is_in_header(&self, x: u16, y: u16) -> bool {
764        x >= self.header_area.x
765            && x < self.header_area.x + self.header_area.width
766            && y >= self.header_area.y
767            && y < self.header_area.y + self.header_area.height
768    }
769
770    /// Determine which column header was clicked
771    pub fn header_column_at(&self, x: u16) -> Option<SortMode> {
772        let rel_x = x.saturating_sub(self.header_area.x) as usize;
773        let width = self.header_area.width as usize;
774
775        let size_col_width = 10;
776        let date_col_width = 14;
777        let name_col_width = width.saturating_sub(size_col_width + date_col_width + 4);
778
779        if rel_x < name_col_width {
780            Some(SortMode::Name)
781        } else if rel_x < name_col_width + size_col_width {
782            Some(SortMode::Size)
783        } else {
784            Some(SortMode::Modified)
785        }
786    }
787
788    /// Check if a position is in the scrollbar area
789    pub fn is_in_scrollbar(&self, x: u16, y: u16) -> bool {
790        x >= self.scrollbar_area.x
791            && x < self.scrollbar_area.x + self.scrollbar_area.width
792            && y >= self.scrollbar_area.y
793            && y < self.scrollbar_area.y + self.scrollbar_area.height
794    }
795
796    /// Check if a position is in the scrollbar thumb
797    pub fn is_in_thumb(&self, y: u16) -> bool {
798        let rel_y = y.saturating_sub(self.scrollbar_area.y) as usize;
799        rel_y >= self.thumb_start && rel_y < self.thumb_end
800    }
801
802    /// Check if a position is on the "Show Hidden" checkbox
803    /// The checkbox is on the first row of navigation area
804    /// Format: " ☐ Show Hidden (Alt+.) │ ☐ Detect Encoding (Alt+E)"
805    pub fn is_on_show_hidden_checkbox(&self, x: u16, y: u16) -> bool {
806        // Must be on the first row of navigation area (checkbox row)
807        if y != self.nav_area.y {
808            return false;
809        }
810
811        // Must be within the x bounds of the navigation area
812        if x < self.nav_area.x || x >= self.nav_area.x + self.nav_area.width {
813            return false;
814        }
815
816        // Show Hidden checkbox spans the left portion of the row
817        // " ☐ Show Hidden (Alt+.)" is approximately 24 characters
818        let show_hidden_width = 24u16;
819        x < self.nav_area.x + show_hidden_width
820    }
821
822    /// Check if a position is on the "Detect Encoding" checkbox
823    /// The checkbox is on the first row of navigation area, after Show Hidden
824    /// Format: " ☐ Show Hidden (Alt+.) │ ☐ Detect Encoding (Alt+E)"
825    pub fn is_on_detect_encoding_checkbox(&self, x: u16, y: u16) -> bool {
826        // Must be on the first row of navigation area (checkbox row)
827        if y != self.nav_area.y {
828            return false;
829        }
830
831        // Must be within the x bounds of the navigation area
832        if x < self.nav_area.x || x >= self.nav_area.x + self.nav_area.width {
833            return false;
834        }
835
836        // Show Hidden + separator takes about 27 characters
837        // " ☐ Show Hidden (Alt+.)" (24) + " │ " (3) = 27
838        let detect_encoding_start = self.nav_area.x + 27;
839        // Detect Encoding checkbox is about 28 characters
840        // "☐ Detect Encoding (Alt+E)" (25+)
841        let detect_encoding_end = detect_encoding_start + 28;
842
843        x >= detect_encoding_start && x < detect_encoding_end
844    }
845}
846
847#[cfg(test)]
848mod tests {
849    use super::fit_header_to_col_width;
850
851    #[test]
852    fn fit_header_pads_when_short() {
853        assert_eq!(fit_header_to_col_width("Name", 8), "Name    ");
854    }
855
856    #[test]
857    fn fit_header_truncates_ascii() {
858        assert_eq!(fit_header_to_col_width("Filename▲", 4), "File");
859    }
860
861    #[test]
862    fn fit_header_truncates_with_sort_arrow_does_not_panic() {
863        // Regression: header ` Name ▲` is 9 bytes (`▲` = 3 bytes) but
864        // 7 characters. Under the old byte-based code, col_width=7 would
865        // byte-slice at index 7 — inside the 3-byte UTF-8 sequence for
866        // `▲` — and panic the editor (same class as #1718). Now the
867        // header is truncated by character count.
868        let out = fit_header_to_col_width(" Name ▲", 7);
869        assert_eq!(out, " Name ▲");
870        assert_eq!(out.chars().count(), 7);
871    }
872
873    #[test]
874    fn fit_header_truncates_localized_does_not_panic() {
875        // Localized header (e.g. Japanese) where every label char is
876        // 3 UTF-8 bytes. Old byte-based truncation at col_width=4 would
877        // panic mid-character; character-based truncation keeps 4 chars.
878        let out = fit_header_to_col_width(" 名前 ▲", 4);
879        assert!(out.is_char_boundary(out.len()));
880        assert_eq!(out.chars().count(), 4);
881    }
882}