src/ui/text.rs (3600B)
1 //! Small formatting and layout helpers shared across the UI.
2 //!
3 //! Each of these existed as two to four byte-identical copies scattered through
4 //! the panes and modals.
5
6 use ratatui::layout::{Constraint, Direction, Layout, Rect};
7 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
8
9 /// Truncate `s` to `max_width` terminal columns, appending "…" when it does not
10 /// fit, and pad with spaces so the result occupies exactly `max_width`.
11 ///
12 /// Column-aware rather than byte- or char-aware: a CJK title is twice as wide as
13 /// its character count, so padding by length misaligns every following column.
14 pub fn fit_to_width(s: &str, max_width: usize) -> String {
15 let str_width = UnicodeWidthStr::width(s);
16 if str_width <= max_width {
17 return format!("{}{}", s, " ".repeat(max_width - str_width));
18 }
19
20 let mut w = 0;
21 let mut result = String::new();
22 for ch in s.chars() {
23 let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
24 // Reserve one column for the ellipsis.
25 if w + ch_w + 1 > max_width {
26 result.push('\u{2026}');
27 w += 1;
28 break;
29 }
30 w += ch_w;
31 result.push(ch);
32 }
33 result.push_str(&" ".repeat(max_width.saturating_sub(w)));
34 result
35 }
36
37 /// Seconds as `m:ss`.
38 pub fn format_time(secs: f64) -> String {
39 let total = secs.max(0.0) as u64;
40 format!("{}:{:02}", total / 60, total % 60)
41 }
42
43 /// A rect of the given percentage size, centred in `area`.
44 pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
45 let vertical = Layout::default()
46 .direction(Direction::Vertical)
47 .constraints([
48 Constraint::Percentage((100 - percent_y) / 2),
49 Constraint::Percentage(percent_y),
50 Constraint::Percentage((100 - percent_y) / 2),
51 ])
52 .split(area);
53
54 Layout::default()
55 .direction(Direction::Horizontal)
56 .constraints([
57 Constraint::Percentage((100 - percent_x) / 2),
58 Constraint::Percentage(percent_x),
59 Constraint::Percentage((100 - percent_x) / 2),
60 ])
61 .split(vertical[1])[1]
62 }
63
64 /// Largest scroll offset that still fills the viewport.
65 ///
66 /// The panes each clamped to `count - 1`, which let the list scroll until a
67 /// single row sat above a screen of blank space.
68 pub fn max_scroll(count: usize, viewport: usize) -> usize {
69 count.saturating_sub(viewport)
70 }
71
72 #[cfg(test)]
73 mod tests {
74 use super::*;
75
76 #[test]
77 fn fit_pads_and_truncates_by_column_not_by_character() {
78 assert_eq!(fit_to_width("abc", 5), "abc ");
79 assert_eq!(UnicodeWidthStr::width(fit_to_width("abcdefgh", 5).as_str()), 5);
80 // Four wide characters are eight columns; two fit alongside the ellipsis.
81 assert_eq!(fit_to_width("日本語テスト", 5), "日本\u{2026}");
82 assert_eq!(UnicodeWidthStr::width(fit_to_width("日本語テスト", 5).as_str()), 5);
83 // A wide string that exactly fits is left alone.
84 assert_eq!(fit_to_width("肺", 2), "肺");
85 }
86
87 #[test]
88 fn max_scroll_stops_before_the_list_runs_out() {
89 // 100 items in a 30-row viewport: the last full screen starts at 70.
90 assert_eq!(max_scroll(100, 30), 70);
91 // Everything already visible means no scrolling.
92 assert_eq!(max_scroll(10, 30), 0);
93 assert_eq!(max_scroll(0, 30), 0);
94 }
95
96 #[test]
97 fn format_time_pads_seconds() {
98 assert_eq!(format_time(0.0), "0:00");
99 assert_eq!(format_time(65.9), "1:05");
100 assert_eq!(format_time(-1.0), "0:00");
101 }
102 }