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 }