ommp.git

ommp

~ Oh My Music Player — a standalone terminal music player built with Rust and ratatui
git clone https://github.com/debuman/ommp.git

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 }