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(
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 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 let nav_height = 2u16; 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 let nav_area = Rect::new(inner_area.x, inner_area.y, content_width, nav_height);
104
105 let header_area = Rect::new(
107 inner_area.x,
108 inner_area.y + nav_height,
109 content_width,
110 header_height,
111 );
112
113 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 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 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 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 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 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 let mut checkbox_spans = Vec::new();
194
195 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 checkbox_spans.push(Span::styled(
236 " │ ",
237 Style::default()
238 .fg(theme.help_separator_fg)
239 .bg(theme.popup_bg),
240 ));
241
242 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 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 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 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 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 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 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 let sort_arrow = if state.sort_ascending { "▲" } else { "▼" };
401
402 let mut spans = Vec::new();
403
404 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 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 spans.push(Span::styled(" ", header_style));
457
458 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 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 state.update_scroll_for_visible_rows(visible_rows);
506 let width = area.width as usize;
507
508 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 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 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 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 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 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 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 let truncated: String = name_with_indicator
597 .chars()
598 .take(name_col_width - 3)
599 .collect();
600 format!("{}...", truncated)
601 };
602
603 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 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 spans.push(Span::styled(" ", base_style));
627
628 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 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
658fn 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#[derive(Debug, Clone)]
674pub struct FileBrowserLayout {
675 pub popup_area: Rect,
677 pub nav_area: Rect,
679 pub header_area: Rect,
681 pub list_area: Rect,
683 pub scrollbar_area: Rect,
685 pub thumb_start: usize,
687 pub thumb_end: usize,
689 pub visible_rows: usize,
691 pub content_width: u16,
693}
694
695impl FileBrowserLayout {
696 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 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 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 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 pub fn nav_shortcut_at(&self, x: u16, y: u16, shortcut_labels: &[&str]) -> Option<usize> {
733 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 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 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 if idx < shortcut_labels.len() - 1 {
758 current_x += 3;
759 }
760 }
761
762 None
763 }
764
765 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 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 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 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 pub fn is_on_show_hidden_checkbox(&self, x: u16, y: u16) -> bool {
809 if y != self.nav_area.y {
811 return false;
812 }
813
814 if x < self.nav_area.x || x >= self.nav_area.x + self.nav_area.width {
816 return false;
817 }
818
819 let show_hidden_width = 24u16;
822 x < self.nav_area.x + show_hidden_width
823 }
824
825 pub fn is_on_detect_encoding_checkbox(&self, x: u16, y: u16) -> bool {
829 if y != self.nav_area.y {
831 return false;
832 }
833
834 if x < self.nav_area.x || x >= self.nav_area.x + self.nav_area.width {
836 return false;
837 }
838
839 let detect_encoding_start = self.nav_area.x + 27;
842 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 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 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}