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