retroglyph_widgets/widget/
tabs.rs1use retroglyph_core::{Backend, Color, Rect, Style, Terminal};
3
4use super::Widget;
5use crate::Theme;
6use crate::draw::fill_rect;
7use crate::text::truncate as truncate_to_cols;
8
9#[derive(Clone, Copy, Debug)]
29pub struct Tabs<'a> {
30 titles: &'a [&'a str],
31 selected: Option<usize>,
32 style: Style,
33 selected_style: Style,
34 column_spacing: u16,
35 divider: Option<char>,
36}
37
38impl<'a> Tabs<'a> {
39 #[must_use]
41 pub fn new(titles: &'a [&'a str]) -> Self {
42 Self {
43 titles,
44 selected: None,
45 style: Style::new().fg(Color::Rgb {
46 r: 170,
47 g: 175,
48 b: 190,
49 }),
50 selected_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
51 r: 40,
52 g: 60,
53 b: 90,
54 }),
55 column_spacing: 1,
56 divider: None,
57 }
58 }
59
60 #[must_use]
62 pub const fn select(mut self, index: Option<usize>) -> Self {
63 self.selected = index;
64 self
65 }
66
67 #[must_use]
69 pub const fn style(mut self, style: Style) -> Self {
70 self.style = style;
71 self
72 }
73
74 #[must_use]
76 pub const fn selected_style(mut self, style: Style) -> Self {
77 self.selected_style = style;
78 self
79 }
80
81 #[must_use]
83 pub const fn column_spacing(mut self, spacing: u16) -> Self {
84 self.column_spacing = spacing;
85 self
86 }
87
88 #[must_use]
91 pub const fn divider(mut self, divider: Option<char>) -> Self {
92 self.divider = divider;
93 self
94 }
95
96 #[must_use]
110 pub fn theme(mut self, theme: Theme) -> Self {
111 self.style = Style::new().fg(theme.dim).bg(theme.panel_bg);
112 self.selected_style = Style::new().fg(theme.accent).bg(theme.panel_bg);
113 self
114 }
115}
116
117impl<B: Backend> Widget<B> for Tabs<'_> {
118 fn render(self, area: Rect, term: &mut Terminal<B>) {
119 if area.width() == 0 || area.height() == 0 {
120 return;
121 }
122
123 let y = area.top();
124 let mut x = area.left();
125 for (index, &title) in self.titles.iter().enumerate() {
126 if x >= area.right() {
127 break;
128 }
129 let avail = (area.right() - x) as usize;
130 let text = truncate_to_cols(title, avail);
131 let style = if Some(index) == self.selected {
132 self.selected_style
133 } else {
134 self.style
135 };
136 let text_width = text.chars().count() as u16;
137 if Some(index) == self.selected && text_width > 0 {
138 fill_rect(
139 term,
140 Rect::new(x, y, text_width, 1),
141 ' ',
142 Style::new().bg(style.background()),
143 );
144 }
145 term.reset_style()
146 .fg(style.foreground())
147 .bg(style.background());
148 term.print(x, y, &text);
149 x = x.saturating_add(text_width);
150
151 if index + 1 < self.titles.len() {
152 if let Some(divider) = self.divider {
153 let mid = x + self.column_spacing / 2;
154 if mid < area.right() {
155 term.reset_style();
156 term.put(mid, y, divider);
157 }
158 }
159 x = x.saturating_add(self.column_spacing);
160 }
161 }
162 term.reset_style();
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use retroglyph_core::Headless;
169
170 use super::*;
171
172 #[test]
173 fn draws_every_title_left_to_right() {
174 let area = Rect::new(0, 0, 20, 1);
175 let titles = ["One", "Two"];
176 let mut term = Terminal::new(Headless::new(20, 1));
177 Tabs::new(&titles).render(area, &mut term);
178
179 assert_eq!(term.grid().get(0, 0).glyph(), 'O');
180 assert_eq!(term.grid().get(4, 0).glyph(), 'T');
182 }
183
184 #[test]
185 fn highlights_the_selected_tab() {
186 let area = Rect::new(0, 0, 20, 1);
187 let titles = ["One", "Two"];
188 let mut term = Terminal::new(Headless::new(20, 1));
189 Tabs::new(&titles).select(Some(1)).render(area, &mut term);
190
191 let selected_bg = term.grid().get(4, 0).style().background();
192 let plain_bg = term.grid().get(0, 0).style().background();
193 assert_ne!(selected_bg, plain_bg);
194 }
195
196 #[test]
197 fn nothing_highlighted_when_unselected() {
198 let area = Rect::new(0, 0, 20, 1);
199 let titles = ["One", "Two"];
200 let mut term = Terminal::new(Headless::new(20, 1));
201 Tabs::new(&titles).render(area, &mut term);
202
203 let bg0 = term.grid().get(0, 0).style().background();
204 let bg1 = term.grid().get(4, 0).style().background();
205 assert_eq!(bg0, bg1);
206 }
207
208 #[test]
209 fn column_spacing_can_be_overridden() {
210 let area = Rect::new(0, 0, 20, 1);
211 let titles = ["A", "B"];
212 let mut term = Terminal::new(Headless::new(20, 1));
213 Tabs::new(&titles).column_spacing(3).render(area, &mut term);
214
215 assert_eq!(term.grid().get(0, 0).glyph(), 'A');
217 assert_eq!(term.grid().get(4, 0).glyph(), 'B');
218 }
219
220 #[test]
221 fn divider_renders_between_tabs_when_set() {
222 let area = Rect::new(0, 0, 20, 1);
223 let titles = ["A", "B"];
224 let mut term = Terminal::new(Headless::new(20, 1));
225 Tabs::new(&titles)
226 .column_spacing(3)
227 .divider(Some('|'))
228 .render(area, &mut term);
229
230 assert_eq!(term.grid().get(2, 0).glyph(), '|');
232 }
233
234 #[test]
235 fn no_divider_by_default() {
236 let area = Rect::new(0, 0, 20, 1);
237 let titles = ["A", "B"];
238 let mut term = Terminal::new(Headless::new(20, 1));
239 Tabs::new(&titles).render(area, &mut term);
240
241 assert_eq!(term.grid().get(1, 0).glyph(), ' ');
242 }
243
244 #[test]
245 fn stops_drawing_past_the_area_width_without_panicking() {
246 let area = Rect::new(0, 0, 4, 1);
247 let titles = ["Alpha", "Bravo", "Charlie"];
248 let mut term = Terminal::new(Headless::new(4, 1));
249 Tabs::new(&titles).render(area, &mut term); assert_eq!(term.grid().get(0, 0).glyph(), 'A');
252 }
253
254 #[test]
255 fn style_can_be_overridden() {
256 let area = Rect::new(0, 0, 20, 1);
257 let titles = ["One"];
258 let custom = Style::new().fg(Color::RED);
259 let mut term = Terminal::new(Headless::new(20, 1));
260 Tabs::new(&titles).style(custom).render(area, &mut term);
261
262 assert_eq!(term.grid().get(0, 0).style().foreground(), Color::RED);
263 }
264
265 #[test]
266 fn selected_style_can_be_overridden() {
267 let area = Rect::new(0, 0, 20, 1);
268 let titles = ["One"];
269 let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
270 let mut term = Terminal::new(Headless::new(20, 1));
271 Tabs::new(&titles)
272 .selected_style(custom)
273 .select(Some(0))
274 .render(area, &mut term);
275
276 assert_eq!(term.grid().get(0, 0).style().foreground(), Color::GREEN);
277 assert_eq!(term.grid().get(0, 0).style().background(), Color::BLUE);
278 }
279
280 #[test]
281 fn zero_width_is_a_no_op() {
282 let area = Rect::new(0, 0, 0, 1);
283 let titles = ["One"];
284 let mut term = Terminal::new(Headless::new(1, 1));
285 Tabs::new(&titles).render(area, &mut term);
286 assert_eq!(term.grid().get(0, 0).glyph(), ' ');
287 }
288
289 #[test]
290 fn theme_maps_named_roles_onto_style_and_selected_style() {
291 let area = Rect::new(0, 0, 20, 1);
292 let titles = ["One", "Two"];
293 let mut term = Terminal::new(Headless::new(20, 1));
294 Tabs::new(&titles)
295 .theme(Theme::DARK)
296 .select(Some(1))
297 .render(area, &mut term);
298
299 assert_eq!(term.grid().get(0, 0).style().foreground(), Theme::DARK.dim);
300 assert_eq!(
301 term.grid().get(0, 0).style().background(),
302 Theme::DARK.panel_bg
303 );
304 assert_eq!(
305 term.grid().get(4, 0).style().foreground(),
306 Theme::DARK.accent
307 );
308 assert_eq!(
309 term.grid().get(4, 0).style().background(),
310 Theme::DARK.panel_bg
311 );
312 }
313}