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)]
40pub struct Tabs<'a> {
41 titles: &'a [&'a str],
42 selected: Option<usize>,
43 style: Style,
44 selected_style: Style,
45 column_spacing: u16,
46 divider: Option<char>,
47}
48
49impl<'a> Tabs<'a> {
50 #[must_use]
52 pub fn new(titles: &'a [&'a str]) -> Self {
53 Self {
54 titles,
55 selected: None,
56 style: Style::new().fg(Color::Rgb {
57 r: 170,
58 g: 175,
59 b: 190,
60 }),
61 selected_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
62 r: 40,
63 g: 60,
64 b: 90,
65 }),
66 column_spacing: 1,
67 divider: None,
68 }
69 }
70
71 #[must_use]
73 pub const fn select(mut self, index: Option<usize>) -> Self {
74 self.selected = index;
75 self
76 }
77
78 #[must_use]
80 pub const fn style(mut self, style: Style) -> Self {
81 self.style = style;
82 self
83 }
84
85 #[must_use]
87 pub const fn selected_style(mut self, style: Style) -> Self {
88 self.selected_style = style;
89 self
90 }
91
92 #[must_use]
94 pub const fn column_spacing(mut self, spacing: u16) -> Self {
95 self.column_spacing = spacing;
96 self
97 }
98
99 #[must_use]
102 pub const fn divider(mut self, divider: Option<char>) -> Self {
103 self.divider = divider;
104 self
105 }
106
107 #[must_use]
121 pub fn theme(self, theme: Theme) -> Self {
122 self.theme_on(theme, theme.panel_bg)
123 }
124
125 #[must_use]
130 pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
131 self.style = Style::new().fg(theme.dim).bg(bg);
132 self.selected_style = Style::new().fg(theme.accent).bg(bg);
133 self
134 }
135}
136
137impl<B: Backend> Widget<B> for Tabs<'_> {
138 fn render(self, area: Rect, term: &mut Terminal<B>) {
139 if area.width() == 0 || area.height() == 0 {
140 return;
141 }
142
143 let y = area.top();
144 let mut x = area.left();
145 for (index, &title) in self.titles.iter().enumerate() {
146 if x >= area.right() {
147 break;
148 }
149 let avail = (area.right() - x) as usize;
150 let text = truncate_to_cols(title, avail);
151 let style = if Some(index) == self.selected {
152 self.selected_style
153 } else {
154 self.style
155 };
156 let text_width = text.chars().count() as u16;
157 if Some(index) == self.selected && text_width > 0 {
158 fill_rect(
159 term,
160 Rect::new(x, y, text_width, 1),
161 ' ',
162 Style::new().bg(style.background()),
163 );
164 }
165 term.reset_style()
166 .fg(style.foreground())
167 .bg(style.background());
168 term.print(x, y, text);
169 x = x.saturating_add(text_width);
170
171 if index + 1 < self.titles.len() {
172 if let Some(divider) = self.divider {
173 let mid = x + self.column_spacing / 2;
174 if mid < area.right() {
175 term.reset_style();
176 term.put(mid, y, divider);
177 }
178 }
179 x = x.saturating_add(self.column_spacing);
180 }
181 }
182 term.reset_style();
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use retroglyph_core::Headless;
189
190 use super::*;
191
192 #[test]
193 fn draws_every_title_left_to_right() {
194 let area = Rect::new(0, 0, 20, 1);
195 let titles = ["One", "Two"];
196 let mut term = Terminal::new(Headless::new(20, 1));
197 Tabs::new(&titles).render(area, &mut term);
198
199 assert_eq!(term.grid().get(0, 0).glyph(), 'O');
200 assert_eq!(term.grid().get(4, 0).glyph(), 'T');
202 }
203
204 #[test]
205 fn highlights_the_selected_tab() {
206 let area = Rect::new(0, 0, 20, 1);
207 let titles = ["One", "Two"];
208 let mut term = Terminal::new(Headless::new(20, 1));
209 Tabs::new(&titles).select(Some(1)).render(area, &mut term);
210
211 let selected_bg = term.grid().get(4, 0).style().background();
212 let plain_bg = term.grid().get(0, 0).style().background();
213 assert_ne!(selected_bg, plain_bg);
214 }
215
216 #[test]
217 fn nothing_highlighted_when_unselected() {
218 let area = Rect::new(0, 0, 20, 1);
219 let titles = ["One", "Two"];
220 let mut term = Terminal::new(Headless::new(20, 1));
221 Tabs::new(&titles).render(area, &mut term);
222
223 let bg0 = term.grid().get(0, 0).style().background();
224 let bg1 = term.grid().get(4, 0).style().background();
225 assert_eq!(bg0, bg1);
226 }
227
228 #[test]
229 fn column_spacing_can_be_overridden() {
230 let area = Rect::new(0, 0, 20, 1);
231 let titles = ["A", "B"];
232 let mut term = Terminal::new(Headless::new(20, 1));
233 Tabs::new(&titles).column_spacing(3).render(area, &mut term);
234
235 assert_eq!(term.grid().get(0, 0).glyph(), 'A');
237 assert_eq!(term.grid().get(4, 0).glyph(), 'B');
238 }
239
240 #[test]
241 fn divider_renders_between_tabs_when_set() {
242 let area = Rect::new(0, 0, 20, 1);
243 let titles = ["A", "B"];
244 let mut term = Terminal::new(Headless::new(20, 1));
245 Tabs::new(&titles)
246 .column_spacing(3)
247 .divider(Some('|'))
248 .render(area, &mut term);
249
250 assert_eq!(term.grid().get(2, 0).glyph(), '|');
252 }
253
254 #[test]
255 fn no_divider_by_default() {
256 let area = Rect::new(0, 0, 20, 1);
257 let titles = ["A", "B"];
258 let mut term = Terminal::new(Headless::new(20, 1));
259 Tabs::new(&titles).render(area, &mut term);
260
261 assert_eq!(term.grid().get(1, 0).glyph(), ' ');
262 }
263
264 #[test]
265 fn stops_drawing_past_the_area_width_without_panicking() {
266 let area = Rect::new(0, 0, 4, 1);
267 let titles = ["Alpha", "Bravo", "Charlie"];
268 let mut term = Terminal::new(Headless::new(4, 1));
269 Tabs::new(&titles).render(area, &mut term); assert_eq!(term.grid().get(0, 0).glyph(), 'A');
272 }
273
274 #[test]
275 fn style_can_be_overridden() {
276 let area = Rect::new(0, 0, 20, 1);
277 let titles = ["One"];
278 let custom = Style::new().fg(Color::RED);
279 let mut term = Terminal::new(Headless::new(20, 1));
280 Tabs::new(&titles).style(custom).render(area, &mut term);
281
282 assert_eq!(term.grid().get(0, 0).style().foreground(), Color::RED);
283 }
284
285 #[test]
286 fn selected_style_can_be_overridden() {
287 let area = Rect::new(0, 0, 20, 1);
288 let titles = ["One"];
289 let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
290 let mut term = Terminal::new(Headless::new(20, 1));
291 Tabs::new(&titles)
292 .selected_style(custom)
293 .select(Some(0))
294 .render(area, &mut term);
295
296 assert_eq!(term.grid().get(0, 0).style().foreground(), Color::GREEN);
297 assert_eq!(term.grid().get(0, 0).style().background(), Color::BLUE);
298 }
299
300 #[test]
301 fn zero_width_is_a_no_op() {
302 let area = Rect::new(0, 0, 0, 1);
303 let titles = ["One"];
304 let mut term = Terminal::new(Headless::new(1, 1));
305 Tabs::new(&titles).render(area, &mut term);
306 assert_eq!(term.grid().get(0, 0).glyph(), ' ');
307 }
308
309 #[test]
310 fn theme_maps_named_roles_onto_style_and_selected_style() {
311 let area = Rect::new(0, 0, 20, 1);
312 let titles = ["One", "Two"];
313 let mut term = Terminal::new(Headless::new(20, 1));
314 Tabs::new(&titles)
315 .theme(Theme::DARK)
316 .select(Some(1))
317 .render(area, &mut term);
318
319 assert_eq!(term.grid().get(0, 0).style().foreground(), Theme::DARK.dim);
320 assert_eq!(
321 term.grid().get(0, 0).style().background(),
322 Theme::DARK.panel_bg
323 );
324 assert_eq!(
325 term.grid().get(4, 0).style().foreground(),
326 Theme::DARK.accent
327 );
328 assert_eq!(
329 term.grid().get(4, 0).style().background(),
330 Theme::DARK.panel_bg
331 );
332 }
333
334 #[test]
335 fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
336 let area = Rect::new(0, 0, 20, 1);
337 let titles = ["One"];
338 let mut term = Terminal::new(Headless::new(20, 1));
339 Tabs::new(&titles)
340 .theme_on(Theme::DARK, Color::Default)
341 .select(Some(0))
342 .render(area, &mut term);
343
344 assert_eq!(
345 term.grid().get(0, 0).style().foreground(),
346 Theme::DARK.accent
347 );
348 assert_eq!(term.grid().get(0, 0).style().background(), Color::Default);
349 }
350}