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: &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 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 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 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 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 let mut checkbox_spans = Vec::new();
191
192 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 checkbox_spans.push(Span::styled(
233 " │ ",
234 Style::default()
235 .fg(theme.help_separator_fg)
236 .bg(theme.popup_bg),
237 ));
238
239 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 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 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 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 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 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 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 let sort_arrow = if state.sort_ascending { "▲" } else { "▼" };
398
399 let mut spans = Vec::new();
400
401 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 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 spans.push(Span::styled(" ", header_style));
454
455 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 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 state.update_scroll_for_visible_rows(visible_rows);
503 let width = area.width as usize;
504
505 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 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 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 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 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 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 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 let truncated: String = name_with_indicator
594 .chars()
595 .take(name_col_width - 3)
596 .collect();
597 format!("{}...", truncated)
598 };
599
600 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 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 spans.push(Span::styled(" ", base_style));
624
625 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 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
655fn 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#[derive(Debug, Clone)]
671pub struct FileBrowserLayout {
672 pub popup_area: Rect,
674 pub nav_area: Rect,
676 pub header_area: Rect,
678 pub list_area: Rect,
680 pub scrollbar_area: Rect,
682 pub thumb_start: usize,
684 pub thumb_end: usize,
686 pub visible_rows: usize,
688 pub content_width: u16,
690}
691
692impl FileBrowserLayout {
693 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 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 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 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 pub fn nav_shortcut_at(&self, x: u16, y: u16, shortcut_labels: &[&str]) -> Option<usize> {
730 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 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 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 if idx < shortcut_labels.len() - 1 {
755 current_x += 3;
756 }
757 }
758
759 None
760 }
761
762 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 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 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 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 pub fn is_on_show_hidden_checkbox(&self, x: u16, y: u16) -> bool {
806 if y != self.nav_area.y {
808 return false;
809 }
810
811 if x < self.nav_area.x || x >= self.nav_area.x + self.nav_area.width {
813 return false;
814 }
815
816 let show_hidden_width = 24u16;
819 x < self.nav_area.x + show_hidden_width
820 }
821
822 pub fn is_on_detect_encoding_checkbox(&self, x: u16, y: u16) -> bool {
826 if y != self.nav_area.y {
828 return false;
829 }
830
831 if x < self.nav_area.x || x >= self.nav_area.x + self.nav_area.width {
833 return false;
834 }
835
836 let detect_encoding_start = self.nav_area.x + 27;
839 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 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 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}