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