/home/runner/work/retroglyph/retroglyph/crates/ui/src/widget/tabs.rs
Line | Count | Source |
1 | | //! [`Tabs`]: a horizontal strip of tab labels with a highlighted selected index. |
2 | | use alloc::vec::Vec; |
3 | | |
4 | | use retroglyph_core::color::{Color, Style}; |
5 | | use retroglyph_core::grid::Rect; |
6 | | use retroglyph_core::text::truncate_measured; |
7 | | |
8 | | use super::{InteractiveWidget, Widget}; |
9 | | use crate::Align; |
10 | | use crate::Response; |
11 | | use crate::Sense; |
12 | | use crate::Surface; |
13 | | use crate::Theme; |
14 | | use crate::draw::fill_rect; |
15 | | use crate::text::draw_clipped; |
16 | | |
17 | | /// A horizontal strip of `titles` with the tab at `selected` highlighted. |
18 | | /// |
19 | | /// Unlike [`Table`](super::Table)/[`List`](super::List), `Tabs` is a plain [`Widget`], not a |
20 | | /// [`StatefulWidget`](super::StatefulWidget): there is no scroll offset for a tab strip, only a |
21 | | /// selected index, so it takes `selected: Option<usize>` directly (set via [`Tabs::select`]) |
22 | | /// rather than a [`ListState`](crate::ListState): the app is free to drive that index however |
23 | | /// it likes (a plain `usize` it owns, a [`FocusRing`](crate::FocusRing), whatever fits), the same |
24 | | /// "app- or interaction-machinery-driven, widget just reads it" division of labor as every other |
25 | | /// widget here. |
26 | | /// |
27 | | /// Titles render left to right, `column_spacing` blank columns apart (default `1`, matching |
28 | | /// [`Table::column_spacing`](super::Table::column_spacing)), with an optional single-character |
29 | | /// `divider` (default `None`, i.e. no divider) centered in that spacing: set with |
30 | | /// [`Tabs::divider`]. Drawing stops once a title would start past the area's right edge; there is |
31 | | /// no horizontal scrolling. |
32 | | /// |
33 | | /// `style` and `selected_style` default to [`Theme::DARK`] (as if [`Tabs::theme`] had been |
34 | | /// called); set them with [`Tabs::style`]/[`Tabs::selected_style`]. |
35 | | /// |
36 | | /// As an [`InteractiveWidget`], `type State = usize` (the same index a caller already threads |
37 | | /// into [`Tabs::select`] for the plain [`Widget`] path): a single id covers the whole strip, and |
38 | | /// a click selects the tab whose column range contains [`Response::pointer_pos`], resolved |
39 | | /// against the very same per-tab column layout the drawing routine uses, so the two can't |
40 | | /// diverge. |
41 | | /// |
42 | | /// # Examples |
43 | | /// |
44 | | /// ``` |
45 | | /// use retroglyph_core::grid::{Grid, Rect}; |
46 | | /// use retroglyph_ui::{Surface, Tabs, Widget}; |
47 | | /// |
48 | | /// let titles = ["Overview", "Details", "Settings"]; |
49 | | /// let area = Rect::new(0, 0, 30, 1); |
50 | | /// let mut grid = Grid::new(30, 1); |
51 | | /// Tabs::new(&titles) |
52 | | /// .select(Some(0)) |
53 | | /// .render(&mut Surface::new(&mut grid, area, 0)); |
54 | | /// ``` |
55 | | #[derive(Clone, Copy, Debug)] |
56 | | pub struct Tabs<'a> { |
57 | | titles: &'a [&'a str], |
58 | | selected: Option<usize>, |
59 | | style: Style, |
60 | | selected_style: Style, |
61 | | column_spacing: u16, |
62 | | divider: Option<char>, |
63 | | } |
64 | | |
65 | | impl<'a> Tabs<'a> { |
66 | | /// A tab strip over `titles`, with nothing selected, styled from [`Theme::DARK`] (as if |
67 | | /// [`Tabs::theme`] had been called). |
68 | | #[must_use] |
69 | 16 | pub fn new(titles: &'a [&'a str]) -> Self { |
70 | 16 | Self { |
71 | 16 | titles, |
72 | 16 | selected: None, |
73 | 16 | style: Style::new(), |
74 | 16 | selected_style: Style::new(), |
75 | 16 | column_spacing: 1, |
76 | 16 | divider: None, |
77 | 16 | } |
78 | 16 | .theme(Theme::DARK) |
79 | 16 | } |
80 | | |
81 | | /// Select tab `index` (or clear the selection with `None`). |
82 | | #[must_use] |
83 | 4 | pub const fn select(mut self, index: Option<usize>) -> Self { |
84 | 4 | self.selected = index; |
85 | 4 | self |
86 | 4 | } |
87 | | |
88 | | /// Set the style of unselected tabs. |
89 | | #[must_use] |
90 | 1 | pub const fn style(mut self, style: Style) -> Self { |
91 | 1 | self.style = style; |
92 | 1 | self |
93 | 1 | } |
94 | | |
95 | | /// Set the style of the selected tab, including its background fill. |
96 | | #[must_use] |
97 | 1 | pub const fn selected_style(mut self, style: Style) -> Self { |
98 | 1 | self.selected_style = style; |
99 | 1 | self |
100 | 1 | } |
101 | | |
102 | | /// Set the number of blank columns between tabs. |
103 | | #[must_use] |
104 | 2 | pub const fn column_spacing(mut self, spacing: u16) -> Self { |
105 | 2 | self.column_spacing = spacing; |
106 | 2 | self |
107 | 2 | } |
108 | | |
109 | | /// Set a divider character drawn within the spacing between tabs. `None` (the default) draws |
110 | | /// no divider: just `column_spacing` blank columns. |
111 | | #[must_use] |
112 | 1 | pub const fn divider(mut self, divider: Option<char>) -> Self { |
113 | 1 | self.divider = divider; |
114 | 1 | self |
115 | 1 | } |
116 | | |
117 | | /// Applies `theme`'s named roles to this tab strip: `style` becomes `theme.dim` (unselected |
118 | | /// tabs read as de-emphasized) on `theme.panel_bg`, and `selected_style` becomes `theme.bg` |
119 | | /// on `theme.accent`: the same bright-on-accent highlight [`super::List::theme`] and |
120 | | /// [`super::Table::theme`] give their own selected state. |
121 | | /// |
122 | | /// `style` sets an explicit background rather than leaving it at [`Style::new()`]'s default: |
123 | | /// an unset background isn't "transparent" once a real backend draws it (a bare |
124 | | /// `Color::Default` cell paints as solid black behind the glyph; see |
125 | | /// `retroglyph-software`'s `DEFAULT_BG`), so this widget assumes it's drawn on |
126 | | /// `theme.panel_bg`, true when composed with a themed [`super::Panel`]/[`super::Modal`]. |
127 | | /// Drawing this tab strip directly on the raw screen background instead needs a manual |
128 | | /// `.style(...)` override afterwards. |
129 | | /// |
130 | | /// Call before any manual [`Tabs::style`]/[`Tabs::selected_style`] override you want to keep. |
131 | | #[must_use] |
132 | 17 | pub fn theme(self, theme: Theme) -> Self { |
133 | 17 | self.theme_on(theme, theme.panel_bg) |
134 | 17 | } |
135 | | |
136 | | /// Same as [`Tabs::theme`], but `style` is drawn on `bg` instead of `theme.panel_bg`: for a |
137 | | /// tab strip drawn directly on a backdrop other than a themed [`super::Panel`]/ |
138 | | /// [`super::Modal`]'s fill. `selected_style` still uses `theme.accent` as its background, |
139 | | /// unaffected by `bg`. [`Tabs::theme`] is exactly `theme_on(theme, theme.panel_bg)`. |
140 | | #[must_use] |
141 | 18 | pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self { |
142 | 18 | self.style = Style::new().fg(theme.dim).bg(bg); |
143 | 18 | self.selected_style = Style::new().fg(theme.bg).bg(theme.accent); |
144 | 18 | self |
145 | 18 | } |
146 | | } |
147 | | |
148 | | impl Tabs<'_> { |
149 | | /// Each drawn tab's `(index, start_x, text_width)`, left to right, stopping once a title |
150 | | /// would start past `area`'s right edge: the same layout [`Tabs::draw`] paints and |
151 | | /// [`Tabs::tab_at`] hit-tests against, computed once here so the two can't diverge. `x` |
152 | | /// values are absolute grid coordinates (matching `area`'s own space), the same space |
153 | | /// [`Response::pointer_pos`] reports in, since [`Tabs::tab_at`] compares them directly. |
154 | 17 | fn tab_columns(&self, area: Rect) -> Vec<(usize, u16, u16)> { |
155 | 17 | let mut columns = Vec::with_capacity(self.titles.len()); |
156 | 17 | let mut x = area.left(); |
157 | 31 | for (index, &title) in self.titles17 .iter17 ().enumerate17 () { |
158 | 31 | if x >= area.right() { |
159 | 1 | break; |
160 | 30 | } |
161 | | // x < area.right() per the break check above, so this subtraction fits a u16. |
162 | 30 | let avail = area.right() - x; |
163 | 30 | let (_text, text_width) = truncate_measured(title, avail); |
164 | 30 | columns.push((index, x, text_width)); |
165 | 30 | x = x.saturating_add(text_width); |
166 | | |
167 | 30 | if index + 1 < self.titles.len() { |
168 | 14 | x = x.saturating_add(self.column_spacing); |
169 | 16 | } |
170 | | } |
171 | 17 | columns |
172 | 17 | } |
173 | | |
174 | | /// The shared drawing routine both [`Widget::render`] and [`InteractiveWidget::render`] use, |
175 | | /// parameterized on which tab (if any) is highlighted as selected. |
176 | 15 | fn draw(&self, surface: &mut Surface<'_>, selected: Option<usize>) { |
177 | 15 | let area = surface.area(); |
178 | 15 | if area.width() == 0 || area.height() == 014 { |
179 | 1 | return; |
180 | 14 | } |
181 | | |
182 | | // `tab_columns` reports absolute `x`, matching `area`'s own space (needed so |
183 | | // `Tabs::tab_at` can compare it directly against an absolute pointer position); `put`/ |
184 | | // `print`/`fill_rect` below address this surface's own local coordinates instead, so |
185 | | // `area.left()` is subtracted back out at the point of drawing. |
186 | 14 | let columns = self.tab_columns(area); |
187 | 24 | for &(index, abs_x, text_width) in &columns14 { |
188 | 24 | let x = abs_x - area.left(); |
189 | 24 | let title = self.titles[index]; |
190 | 24 | let style = if Some(index) == selected { |
191 | 6 | self.selected_style |
192 | | } else { |
193 | 18 | self.style |
194 | | }; |
195 | 24 | if Some(index) == selected && text_width > 06 { |
196 | 6 | fill_rect( |
197 | 6 | surface, |
198 | 6 | Rect::new(x, 0, text_width, 1), |
199 | 6 | ' ', |
200 | 6 | Style::new().bg(style.background()), |
201 | 6 | ); |
202 | 18 | } |
203 | 24 | let _ = draw_clipped(surface, (x, 0), text_width, title, Align::Left, style); |
204 | | |
205 | 24 | if let Some(divider2 ) = self.divider |
206 | 2 | && index + 1 < self.titles.len() |
207 | | { |
208 | | // Divider column: floor of half the inter-tab gap, so it sits just left of center |
209 | | // for an even `column_spacing`. |
210 | 1 | let mid = x + text_width + self.column_spacing / 2; |
211 | 1 | if mid < area.width() { |
212 | 1 | surface.put((mid, 0), divider, Style::new()); |
213 | 1 | }0 |
214 | 23 | } |
215 | | } |
216 | 15 | } |
217 | | |
218 | | /// The index of the tab whose column range contains `pos`, or `None` if `pos` falls in the |
219 | | /// spacing between tabs, past the last drawn tab, or outside `area` entirely: a click there |
220 | | /// selects nothing rather than clamping to the nearest tab. |
221 | 2 | fn tab_at(&self, area: Rect, pos: retroglyph_core::grid::Pos) -> Option<usize> { |
222 | 2 | if !area.contains_pos(pos) { |
223 | 0 | return None; |
224 | 2 | } |
225 | 2 | self.tab_columns(area) |
226 | 2 | .into_iter() |
227 | 4 | .find2 (|&(_, x, width)| pos.x >= x && pos.x < x + width) |
228 | 2 | .map(|(index, _, _)| index) |
229 | 2 | } |
230 | | } |
231 | | |
232 | | impl Widget for Tabs<'_> { |
233 | 13 | fn render(&self, surface: &mut Surface<'_>) { |
234 | 13 | self.draw(surface, self.selected); |
235 | 13 | } |
236 | | } |
237 | | |
238 | | impl<Id> InteractiveWidget<Id> for Tabs<'_> { |
239 | | type State = usize; |
240 | | |
241 | | /// A single id covers the whole strip: clicking resolves which tab via |
242 | | /// [`Response::pointer_pos`] and this strip's own column layout, rather than each tab |
243 | | /// registering its own id. |
244 | 0 | fn sense(&self) -> Sense { |
245 | 0 | Sense::click() | Sense::HOVER |
246 | 0 | } |
247 | | |
248 | 2 | fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State, response: Response<Id>) { |
249 | 2 | let area = surface.area(); |
250 | | |
251 | 2 | if response.clicked() |
252 | 2 | && let Some(pos) = response.pointer_pos() |
253 | 2 | && let Some(index1 ) = self.tab_at(area, pos) |
254 | 1 | { |
255 | 1 | *state = index; |
256 | 1 | } |
257 | | |
258 | 2 | self.draw(surface, Some(*state)); |
259 | 2 | } |
260 | | } |
261 | | |
262 | | #[cfg(test)] |
263 | | mod tests { |
264 | | use retroglyph_core::grid::{Grid, Pos}; |
265 | | |
266 | | use super::*; |
267 | | |
268 | | #[test] |
269 | 1 | fn draws_every_title_left_to_right() { |
270 | 1 | let area = Rect::new(0, 0, 20, 1); |
271 | 1 | let titles = ["One", "Two"]; |
272 | 1 | let mut grid = Grid::new(20, 1); |
273 | 1 | let tabs = Tabs::new(&titles); |
274 | 1 | Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0)); |
275 | | |
276 | 1 | assert_eq!(grid[Pos::new(0, 0)].glyph(), 'O'); |
277 | | // "One" (3) + column_spacing (1) = tab 2 starts at column 4. |
278 | 1 | assert_eq!(grid[Pos::new(4, 0)].glyph(), 'T'); |
279 | 1 | } |
280 | | |
281 | | #[test] |
282 | 1 | fn highlights_the_selected_tab() { |
283 | 1 | let area = Rect::new(0, 0, 20, 1); |
284 | 1 | let titles = ["One", "Two"]; |
285 | 1 | let mut grid = Grid::new(20, 1); |
286 | 1 | let tabs = Tabs::new(&titles).select(Some(1)); |
287 | 1 | Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0)); |
288 | | |
289 | | // Themed tabs (the `new()` default) distinguish the selected tab by both foreground and |
290 | | // background color: `theme.bg` on `theme.accent` vs `theme.dim` on `theme.panel_bg`. |
291 | 1 | let selected_style = grid[Pos::new(4, 0)].style(); |
292 | 1 | let unselected_style = grid[Pos::new(0, 0)].style(); |
293 | 1 | assert_ne!(selected_style.foreground(), unselected_style.foreground()); |
294 | 1 | assert_ne!(selected_style.background(), unselected_style.background()); |
295 | 1 | } |
296 | | |
297 | | #[test] |
298 | 1 | fn nothing_highlighted_when_unselected() { |
299 | 1 | let area = Rect::new(0, 0, 20, 1); |
300 | 1 | let titles = ["One", "Two"]; |
301 | 1 | let mut grid = Grid::new(20, 1); |
302 | 1 | let tabs = Tabs::new(&titles); |
303 | 1 | Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0)); |
304 | | |
305 | | // With nothing selected, both tabs use the same unselected `style`, so their backgrounds |
306 | | // match (unlike a selected tab, which gets `theme.accent` instead). |
307 | 1 | let bg0 = grid[Pos::new(0, 0)].style().background(); |
308 | 1 | let bg1 = grid[Pos::new(4, 0)].style().background(); |
309 | 1 | assert_eq!(bg0, bg1); |
310 | 1 | } |
311 | | |
312 | | #[test] |
313 | 1 | fn column_spacing_can_be_overridden() { |
314 | 1 | let area = Rect::new(0, 0, 20, 1); |
315 | 1 | let titles = ["A", "B"]; |
316 | 1 | let mut grid = Grid::new(20, 1); |
317 | 1 | let tabs = Tabs::new(&titles).column_spacing(3); |
318 | 1 | Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0)); |
319 | | |
320 | | // Default spacing (1) would put "B" at column 2; spacing 3 pushes it to column 4. |
321 | 1 | assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A'); |
322 | 1 | assert_eq!(grid[Pos::new(4, 0)].glyph(), 'B'); |
323 | 1 | } |
324 | | |
325 | | #[test] |
326 | 1 | fn divider_renders_between_tabs_when_set() { |
327 | 1 | let area = Rect::new(0, 0, 20, 1); |
328 | 1 | let titles = ["A", "B"]; |
329 | 1 | let mut grid = Grid::new(20, 1); |
330 | 1 | let tabs = Tabs::new(&titles).column_spacing(3).divider(Some('|')); |
331 | 1 | Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0)); |
332 | | |
333 | | // "A" at 0, spacing [1,3), midpoint at 1 + 3/2 = 2. |
334 | 1 | assert_eq!(grid[Pos::new(2, 0)].glyph(), '|'); |
335 | 1 | } |
336 | | |
337 | | #[test] |
338 | 1 | fn no_divider_by_default() { |
339 | 1 | let area = Rect::new(0, 0, 20, 1); |
340 | 1 | let titles = ["A", "B"]; |
341 | 1 | let mut grid = Grid::new(20, 1); |
342 | 1 | let tabs = Tabs::new(&titles); |
343 | 1 | Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0)); |
344 | | |
345 | 1 | assert_eq!(grid[Pos::new(1, 0)].glyph(), ' '); |
346 | 1 | } |
347 | | |
348 | | #[test] |
349 | 1 | fn stops_drawing_past_the_area_width_without_panicking() { |
350 | 1 | let area = Rect::new(0, 0, 4, 1); |
351 | 1 | let titles = ["Alpha", "Bravo", "Charlie"]; |
352 | 1 | let mut grid = Grid::new(4, 1); |
353 | 1 | let tabs = Tabs::new(&titles); |
354 | 1 | Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0)); // must not panic |
355 | | |
356 | 1 | assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A'); |
357 | 1 | } |
358 | | |
359 | | #[test] |
360 | 1 | fn style_can_be_overridden() { |
361 | | use retroglyph_core::color::Color; |
362 | | |
363 | 1 | let area = Rect::new(0, 0, 20, 1); |
364 | 1 | let titles = ["One"]; |
365 | 1 | let custom = Style::new().fg(Color::RED); |
366 | 1 | let mut grid = Grid::new(20, 1); |
367 | 1 | let tabs = Tabs::new(&titles).style(custom); |
368 | 1 | Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0)); |
369 | | |
370 | 1 | assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Color::RED); |
371 | 1 | } |
372 | | |
373 | | #[test] |
374 | 1 | fn selected_style_can_be_overridden() { |
375 | | use retroglyph_core::color::Color; |
376 | | |
377 | 1 | let area = Rect::new(0, 0, 20, 1); |
378 | 1 | let titles = ["One"]; |
379 | 1 | let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE); |
380 | 1 | let mut grid = Grid::new(20, 1); |
381 | 1 | let tabs = Tabs::new(&titles).selected_style(custom).select(Some(0)); |
382 | 1 | Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0)); |
383 | | |
384 | 1 | assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Color::GREEN); |
385 | 1 | assert_eq!(grid[Pos::new(0, 0)].style().background(), Color::BLUE); |
386 | 1 | } |
387 | | |
388 | | #[test] |
389 | 1 | fn zero_width_is_a_no_op() { |
390 | 1 | let area = Rect::new(0, 0, 0, 1); |
391 | 1 | let titles = ["One"]; |
392 | 1 | let mut grid = Grid::new(1, 1); |
393 | 1 | let tabs = Tabs::new(&titles); |
394 | 1 | Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0)); |
395 | 1 | assert_eq!(grid[Pos::new(0, 0)].glyph(), ' '); |
396 | 1 | } |
397 | | |
398 | | #[test] |
399 | 1 | fn theme_maps_named_roles_onto_style_and_selected_style() { |
400 | 1 | let area = Rect::new(0, 0, 20, 1); |
401 | 1 | let titles = ["One", "Two"]; |
402 | 1 | let mut grid = Grid::new(20, 1); |
403 | 1 | let tabs = Tabs::new(&titles).theme(Theme::DARK).select(Some(1)); |
404 | 1 | Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0)); |
405 | | |
406 | 1 | assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.dim); |
407 | 1 | assert_eq!( |
408 | 1 | grid[Pos::new(0, 0)].style().background(), |
409 | | Theme::DARK.panel_bg |
410 | | ); |
411 | 1 | assert_eq!(grid[Pos::new(4, 0)].style().foreground(), Theme::DARK.bg); |
412 | 1 | assert_eq!( |
413 | 1 | grid[Pos::new(4, 0)].style().background(), |
414 | | Theme::DARK.accent |
415 | | ); |
416 | 1 | } |
417 | | |
418 | | #[test] |
419 | 1 | fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() { |
420 | | use retroglyph_core::color::Color; |
421 | | |
422 | 1 | let area = Rect::new(0, 0, 20, 1); |
423 | 1 | let titles = ["One"]; |
424 | 1 | let mut grid = Grid::new(20, 1); |
425 | 1 | let tabs = Tabs::new(&titles) |
426 | 1 | .theme_on(Theme::DARK, Color::Default) |
427 | 1 | .select(Some(0)); |
428 | 1 | Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0)); |
429 | | |
430 | | // `selected_style` uses `theme.accent` as its background regardless of `bg`: only the |
431 | | // unselected `style` picks up the custom backdrop. |
432 | 1 | assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.bg); |
433 | 1 | assert_eq!( |
434 | 1 | grid[Pos::new(0, 0)].style().background(), |
435 | | Theme::DARK.accent |
436 | | ); |
437 | 1 | } |
438 | | |
439 | | #[test] |
440 | 1 | fn click_selects_the_tab_under_the_pointer() { |
441 | 1 | let area = Rect::new(0, 0, 20, 1); |
442 | 1 | let titles = ["One", "Two"]; |
443 | 1 | let tabs = Tabs::new(&titles); |
444 | 1 | let mut state = 0usize; |
445 | | |
446 | 1 | let response: Response<()> = Response { |
447 | 1 | hovered: true, |
448 | 1 | clicked: true, |
449 | 1 | pointer_pos: Some(Pos::new(4, 0)), // over "Two" |
450 | 1 | ..Response::default() |
451 | 1 | }; |
452 | 1 | let mut grid = Grid::new(20, 1); |
453 | 1 | InteractiveWidget::render( |
454 | 1 | &tabs, |
455 | 1 | &mut Surface::new(&mut grid, area, 0), |
456 | 1 | &mut state, |
457 | 1 | response, |
458 | | ); |
459 | 1 | assert_eq!(state, 1); |
460 | 1 | } |
461 | | |
462 | | #[test] |
463 | 1 | fn click_past_the_last_tab_selects_nothing() { |
464 | 1 | let area = Rect::new(0, 0, 20, 1); |
465 | 1 | let titles = ["One", "Two"]; |
466 | 1 | let tabs = Tabs::new(&titles); |
467 | 1 | let mut state = 0usize; |
468 | | |
469 | 1 | let response: Response<()> = Response { |
470 | 1 | hovered: true, |
471 | 1 | clicked: true, |
472 | 1 | pointer_pos: Some(Pos::new(10, 0)), // past "Two", still inside the wide area |
473 | 1 | ..Response::default() |
474 | 1 | }; |
475 | 1 | let mut grid = Grid::new(20, 1); |
476 | 1 | InteractiveWidget::render( |
477 | 1 | &tabs, |
478 | 1 | &mut Surface::new(&mut grid, area, 0), |
479 | 1 | &mut state, |
480 | 1 | response, |
481 | | ); |
482 | 1 | assert_eq!(state, 0); // unchanged: not clamped to the last tab |
483 | 1 | } |
484 | | |
485 | | #[test] |
486 | 1 | fn tabs_wide_title_loses_half_its_characters() { |
487 | 1 | let area = Rect::new(0, 0, 20, 1); |
488 | 1 | let titles = ["設定", "次"]; |
489 | 1 | let mut grid = Grid::new(20, 1); |
490 | 1 | let tabs = Tabs::new(&titles); |
491 | 1 | Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0)); |
492 | | |
493 | | // "設定" is 4 columns wide; both characters must draw in full. |
494 | 1 | assert_eq!(grid[Pos::new(0, 0)].glyph(), '設'); |
495 | 1 | assert_eq!(grid[Pos::new(2, 0)].glyph(), '定'); |
496 | | // "次" (2 cols) starts after "設定" (4) + column_spacing (1) = column 5. |
497 | 1 | assert_eq!(grid[Pos::new(5, 0)].glyph(), '次'); |
498 | 1 | } |
499 | | |
500 | | #[test] |
501 | 1 | fn tabs_wide_title_does_not_overlap_the_next_tab() { |
502 | 1 | let area = Rect::new(0, 0, 20, 1); |
503 | 1 | let titles = ["設定", "次"]; |
504 | 1 | let tabs = Tabs::new(&titles); |
505 | 1 | let columns = tabs.tab_columns(area); |
506 | | |
507 | | // "設定" occupies 4 columns starting at 0; "次" must start no earlier than column 5 |
508 | | // (4 + column_spacing 1), never inside "設定"'s own span. |
509 | 1 | assert_eq!(columns[0], (0, 0, 4)); |
510 | 1 | assert_eq!(columns[1].1, 5); |
511 | 1 | } |
512 | | } |