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/widgets/tab_bar.rs (4793B)

      1 use ratatui::layout::{Alignment, Rect};
      2 use ratatui::style::{Color, Modifier, Style};
      3 use ratatui::text::{Line, Span};
      4 use ratatui::widgets::{Block, Borders, Paragraph};
      5 use ratatui::Frame;
      6 
      7 use crate::app::state::Tab;
      8 use crate::ui::theme::Theme;
      9 
     10 pub fn render_tab_bar(
     11     frame: &mut Frame,
     12     area: Rect,
     13     current: Tab,
     14     hovered: Option<usize>,
     15     theme: &Theme,
     16 ) {
     17     let block = Block::default()
     18         .borders(Borders::ALL)
     19         .border_style(Style::default().fg(theme.border_unfocused));
     20 
     21     let inner = block.inner(area);
     22     frame.render_widget(block, area);
     23 
     24     // One accent colour per tab. This was `[Color; 6]` against seven tabs, so
     25     // the modulo wrapped and Playlists rendered in Queue's sky blue — and every
     26     // comment from index 5 named the wrong tab.
     27     let tab_colors: [Color; Tab::ALL.len()] = [
     28         Color::Rgb(100, 220, 255), // Queue - sky blue
     29         Color::Rgb(120, 255, 180), // Directories - mint green
     30         Color::Rgb(255, 180, 100), // Artists - orange
     31         Color::Rgb(200, 130, 255), // Albums - purple
     32         Color::Rgb(255, 120, 150), // Genre - pink
     33         Color::Rgb(120, 200, 255), // Format - steel blue
     34         Color::Rgb(255, 220, 100), // Playlists - gold
     35     ];
     36 
     37     let mut spans = Vec::new();
     38     for (i, tab) in Tab::ALL.iter().enumerate() {
     39         if i > 0 {
     40             spans.push(Span::styled(" \u{2502} ", theme.dim_style));
     41         }
     42         let color = tab_colors[i];
     43         let style = if i == current.index() {
     44             Style::default().fg(color).add_modifier(Modifier::BOLD)
     45         } else if hovered == Some(i) {
     46             Style::default().fg(color)
     47         } else {
     48             theme.tab_inactive
     49         };
     50         spans.push(Span::styled(tab.title(), style));
     51     }
     52 
     53     let line = Line::from(spans);
     54     let paragraph = Paragraph::new(line).alignment(Alignment::Center);
     55     frame.render_widget(paragraph, inner);
     56 }
     57 
     58 /// Which tab, if any, sits under the pointer.
     59 ///
     60 /// `mouse_y` matters: only the x range was checked, so clicking the tab bar's
     61 /// top or bottom border switched tabs.
     62 pub fn tab_hit_test(area: Rect, mouse_x: u16, mouse_y: u16) -> Option<usize> {
     63     let block = Block::default().borders(Borders::ALL);
     64     let inner = block.inner(area);
     65 
     66     if mouse_x < inner.x
     67         || mouse_x >= inner.x + inner.width
     68         || mouse_y < inner.y
     69         || mouse_y >= inner.y + inner.height
     70     {
     71         return None;
     72     }
     73 
     74     // Calculate total content width to find center offset
     75     let divider_len = 3; // " │ "
     76     let mut total_width: usize = 0;
     77     for (i, tab) in Tab::ALL.iter().enumerate() {
     78         if i > 0 {
     79             total_width += divider_len;
     80         }
     81         total_width += tab.title().len();
     82     }
     83 
     84     let inner_w = inner.width as usize;
     85     let pad_left = if inner_w > total_width {
     86         (inner_w - total_width) / 2
     87     } else {
     88         0
     89     };
     90 
     91     let rel_x = (mouse_x - inner.x) as usize;
     92     if rel_x < pad_left {
     93         return None;
     94     }
     95 
     96     let content_x = rel_x - pad_left;
     97     let mut pos = 0;
     98     for (i, tab) in Tab::ALL.iter().enumerate() {
     99         if i > 0 {
    100             // Skip the divider rather than folding it into the next tab: the
    101             // three columns of " │ " used to select whichever tab followed.
    102             if content_x < pos + divider_len {
    103                 return None;
    104             }
    105             pos += divider_len;
    106         }
    107         let title_len = tab.title().len();
    108         if content_x < pos + title_len {
    109             return Some(i);
    110         }
    111         pos += title_len;
    112     }
    113     None
    114 }
    115 
    116 #[cfg(test)]
    117 mod tests {
    118     use super::*;
    119 
    120     /// Wide enough that the tab strip is not padded off-centre.
    121     fn bar() -> Rect {
    122         Rect::new(0, 0, 200, 3)
    123     }
    124 
    125     #[test]
    126     fn borders_are_not_part_of_any_tab() {
    127         // Row 0 and row 2 are the block's borders; row 1 is the content.
    128         assert!(tab_hit_test(bar(), 100, 0).is_none());
    129         assert!(tab_hit_test(bar(), 100, 2).is_none());
    130         assert!(tab_hit_test(bar(), 100, 1).is_some());
    131     }
    132 
    133     #[test]
    134     fn dividers_belong_to_neither_neighbour() {
    135         let inner_x = 1;
    136         let pad = (198 - total_content_width()) / 2;
    137         let queue_len = Tab::ALL[0].title().len();
    138         // The three divider columns sit right after "Queue".
    139         for offset in 0..3 {
    140             let x = inner_x + pad + queue_len + offset;
    141             assert_eq!(tab_hit_test(bar(), x as u16, 1), None, "offset {offset}");
    142         }
    143         // The tab after the divider is still reachable.
    144         let x = inner_x + pad + queue_len + 3;
    145         assert_eq!(tab_hit_test(bar(), x as u16, 1), Some(1));
    146     }
    147 
    148     fn total_content_width() -> usize {
    149         Tab::ALL.iter().map(|t| t.title().len()).sum::<usize>() + 3 * (Tab::ALL.len() - 1)
    150     }
    151 }