返回 CodeWhale
tabs.rs
1 //! Tabs: a strip of labels with one selected.
2 //!
3 //! ```text
4 //! General Appearance Agents 2 Privacy 2 more ›
5 //! ──────────
6 //! ```
7 //!
8 //! The selected tab is bold `Foreground` with an underline as wide as its
9 //! label, never the full row; the others are `Muted`. Given one row only,
10 //! the selected tab is bracketed instead (`[Appearance]`), so it still shows
11 //! at 16 colors, under `NO_COLOR` and in ASCII. A tab may carry a count
12 //! badge (`Agents 2`). When the tabs do not fit, the strip scrolls to keep
13 //! the selection visible and marks what is hidden on each side:
14 //! `‹ 2 more` and `3 more ›`.
15 //!
16 //! Replaces the engine's `render_settings_category_strip`
17 //! (`crates/tui/src/tui/views/mod.rs`, `Hmbown/CodeWhale` `58b1dd3dd`),
18 //! whose windowing this follows.
19
20 use std::borrow::Cow;
21 use std::ops::Range;
22
23 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
24 use ratatui::{buffer::Buffer, layout::Rect, style::Modifier};
25
26 use crate::{Paint, Role, Theme, glyphs, text};
27
28 /// One tab: its label and an optional count.
29 #[derive(Clone, Debug, Default, PartialEq, Eq)]
30 pub struct Tab {
31 pub label: Cow<'static, str>,
32 /// A number shown after the label (`Agents 2`); above 99 it reads `99+`.
33 pub badge: Option<u32>,
34 }
35
36 impl Tab {
37 #[must_use]
38 pub fn new(label: impl Into<Cow<'static, str>>) -> Self {
39 Self {
40 label: label.into(),
41 badge: None,
42 }
43 }
44
45 #[must_use]
46 pub fn badge(mut self, count: u32) -> Self {
47 self.badge = Some(count);
48 self
49 }
50
51 fn badge_text(&self) -> Option<String> {
52 self.badge
53 .map(|n| if n > 99 { "99+".into() } else { n.to_string() })
54 }
55 }
56
57 /// The word in the overflow marks, `2 more ›`. The kit owns no copy beyond
58 /// this English default; an empty word leaves just the count: `2 ›`.
59 #[derive(Clone, Debug, PartialEq, Eq)]
60 pub struct TabsWords {
61 pub more: Cow<'static, str>,
62 }
63
64 impl Default for TabsWords {
65 fn default() -> Self {
66 Self {
67 more: Cow::Borrowed("more"),
68 }
69 }
70 }
71
72 /// Which tab is selected and how far the strip has scrolled.
73 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
74 pub struct TabsState {
75 pub selected: usize,
76 /// The first tab shown, remembered between frames so the strip scrolls
77 /// by as little as it must. [`Tabs::handle_key`] and
78 /// [`Tabs::scroll_into_view`] keep it current.
79 pub offset: usize,
80 }
81
82 /// What a key did to a [`TabsState`].
83 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
84 pub enum TabsOutcome {
85 /// The key means nothing to tabs, or changed nothing (an arrow at the
86 /// end); the host may use it.
87 Ignored,
88 /// Tab `n` is now selected.
89 Selected(usize),
90 }
91
92 impl TabsState {
93 #[must_use]
94 pub const fn new(selected: usize) -> Self {
95 Self {
96 selected,
97 offset: 0,
98 }
99 }
100
101 /// Apply a key press to a strip of `len` tabs. `←`/`→` stop at the ends;
102 /// `Tab` and `Shift+Tab` wrap around; `Home` and `End` jump. Releases,
103 /// chords with Ctrl, Alt or Super, other keys and an empty strip are
104 /// [`TabsOutcome::Ignored`]. [`Tabs::handle_key`] also scrolls the new
105 /// selection into view.
106 pub fn handle_key(&mut self, key: KeyEvent, len: usize) -> TabsOutcome {
107 let held = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER;
108 if len == 0 || key.kind == KeyEventKind::Release || key.modifiers.intersects(held) {
109 return TabsOutcome::Ignored;
110 }
111 let current = self.selected.min(len - 1);
112 let backwards = key.modifiers.contains(KeyModifiers::SHIFT);
113 let next = match key.code {
114 KeyCode::Left => current.saturating_sub(1),
115 KeyCode::Right => (current + 1).min(len - 1),
116 KeyCode::Home => 0,
117 KeyCode::End => len - 1,
118 KeyCode::BackTab => (current + len - 1) % len,
119 KeyCode::Tab if backwards => (current + len - 1) % len,
120 KeyCode::Tab => (current + 1) % len,
121 _ => return TabsOutcome::Ignored,
122 };
123 if next == current && self.selected < len {
124 // One tab wrapping onto itself, or an arrow at an end.
125 return TabsOutcome::Ignored;
126 }
127 self.selected = next;
128 TabsOutcome::Selected(next)
129 }
130 }
131
132 /// A strip of tabs.
133 #[derive(Clone, Debug)]
134 pub struct Tabs<'a> {
135 pub items: &'a [Tab],
136 pub state: TabsState,
137 pub words: TabsWords,
138 /// This strip has the keyboard: the selection is lit in `Primary`.
139 pub focused: bool,
140 }
141
142 impl<'a> Tabs<'a> {
143 #[must_use]
144 pub fn new(items: &'a [Tab], state: TabsState) -> Self {
145 Self {
146 items,
147 state,
148 words: TabsWords::default(),
149 focused: false,
150 }
151 }
152
153 #[must_use]
154 pub fn with_words(mut self, words: &TabsWords) -> Self {
155 self.words = words.clone();
156 self
157 }
158
159 #[must_use]
160 pub fn focused(mut self, focused: bool) -> Self {
161 self.focused = focused;
162 self
163 }
164
165 fn selected(&self) -> usize {
166 self.state.selected.min(self.items.len().saturating_sub(1))
167 }
168
169 /// The label and badge of tab `index`, as painted.
170 fn content(&self, index: usize) -> String {
171 let tab = &self.items[index];
172 let label = text::display_safe(&tab.label);
173 match tab.badge_text() {
174 Some(badge) => format!("{label} {badge}"),
175 None => label.into_owned(),
176 }
177 }
178
179 /// A tab's cells: its content and a cell of padding either side.
180 fn slot_width(&self, index: usize) -> usize {
181 text::width(&self.content(index)) + 2
182 }
183
184 /// `‹ 2 more `: what is hidden to the left, with a cell of air before the
185 /// first tab. The compact form, `‹2`, is for a strip too narrow to spare
186 /// room for the word.
187 fn left_mark(&self, hidden: usize, ascii: bool, compact: bool) -> String {
188 let arrow = glyphs::pick("‹", ascii);
189 if compact {
190 format!("{arrow}{hidden}")
191 } else if self.words.more.is_empty() {
192 format!("{arrow} {hidden} ")
193 } else {
194 format!("{arrow} {hidden} {} ", text::display_safe(&self.words.more))
195 }
196 }
197
198 /// ` 3 more ›`: what is hidden to the right, with a cell of air after the
199 /// last tab (`3›` when compact).
200 fn right_mark(&self, hidden: usize, ascii: bool, compact: bool) -> String {
201 let arrow = glyphs::pick("›", ascii);
202 if compact {
203 format!("{hidden}{arrow}")
204 } else if self.words.more.is_empty() {
205 format!(" {hidden} {arrow}")
206 } else {
207 format!(" {hidden} {} {arrow}", text::display_safe(&self.words.more))
208 }
209 }
210
211 /// The window of tabs for a strip `width` cells wide, scrolled as little
212 /// as it must from [`TabsState::offset`], with room kept for the marks.
213 fn window(&self, width: u16, compact: bool) -> Range<usize> {
214 let (n, width) = (self.items.len(), usize::from(width));
215 if n == 0 {
216 return 0..0;
217 }
218 let selected = self.selected();
219 let slots: Vec<usize> = (0..n).map(|i| self.slot_width(i)).collect();
220 let left = |hidden: usize| text::width(&self.left_mark(hidden, false, compact));
221 let right = |hidden: usize| text::width(&self.right_mark(hidden, false, compact));
222 let mut start = self.state.offset.min(selected);
223 loop {
224 let mut used = if start > 0 { left(start) } else { 0 };
225 let mut end = start;
226 while end < n && used + slots[end] <= width {
227 used += slots[end];
228 end += 1;
229 }
230 while end < n && end > start + 1 && used + right(n - end) > width {
231 end -= 1;
232 used -= slots[end];
233 }
234 end = end.max(start + 1);
235 if selected < end {
236 return start..end;
237 }
238 start += 1;
239 }
240 }
241
242 /// The tabs shown in a strip `width` cells wide, and whether the marks
243 /// had to shrink to `‹2` and `3›` to leave the selected tab its room.
244 fn layout(&self, width: u16) -> (Range<usize>, bool) {
245 let n = self.items.len();
246 let total = |range: &Range<usize>, compact: bool| {
247 let slots: usize = range.clone().map(|i| self.slot_width(i)).sum();
248 let left = if range.start > 0 {
249 text::width(&self.left_mark(range.start, false, compact))
250 } else {
251 0
252 };
253 let right = if range.end < n {
254 text::width(&self.right_mark(n - range.end, false, compact))
255 } else {
256 0
257 };
258 left + slots + right
259 };
260 let full = self.window(width, false);
261 if total(&full, false) <= usize::from(width) {
262 return (full, false);
263 }
264 (self.window(width, true), true)
265 }
266
267 /// The tabs shown in a strip `width` cells wide: scrolled as little as
268 /// it must from [`TabsState::offset`], with room kept for the overflow
269 /// marks, and always including the selected tab (cut to fit if it alone
270 /// is wider than the strip).
271 #[must_use]
272 pub fn visible(&self, width: u16) -> Range<usize> {
273 self.layout(width).0
274 }
275
276 /// Store the offset this strip paints with at `width` into `state`, so
277 /// scrolling is stable from one frame to the next.
278 pub fn scroll_into_view(&self, state: &mut TabsState, width: u16) {
279 let strip = Tabs {
280 state: *state,
281 ..self.clone()
282 };
283 state.offset = strip.visible(width).start;
284 }
285
286 /// Apply a key to this strip's state and keep the selection on screen at
287 /// `width`.
288 pub fn handle_key(&mut self, key: KeyEvent, width: u16) -> TabsOutcome {
289 let outcome = self.state.handle_key(key, self.items.len());
290 self.state.offset = self.visible(width).start;
291 outcome
292 }
293
294 /// The tab under a click at `(column, row)` when the strip was painted
295 /// in `area`, for hosts that take mouse input. The overflow marks are
296 /// not tabs.
297 #[must_use]
298 pub fn tab_at(&self, area: Rect, column: u16, row: u16) -> Option<usize> {
299 if row < area.y || row >= area.bottom() || column < area.x || column >= area.right() {
300 return None;
301 }
302 let (range, compact) = self.layout(area.width);
303 let mut x = area.x
304 + if range.start > 0 {
305 u16::try_from(text::width(&self.left_mark(range.start, false, compact)))
306 .unwrap_or(0)
307 } else {
308 0
309 };
310 for index in range {
311 let w = u16::try_from(self.slot_width(index)).unwrap_or(u16::MAX);
312 if column >= x && column < x.saturating_add(w) {
313 return Some(index);
314 }
315 x = x.saturating_add(w);
316 }
317 None
318 }
319 }
320
321 impl Paint for Tabs<'_> {
322 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
323 let area = area.intersection(buf.area);
324 if area.is_empty() || self.items.is_empty() {
325 return;
326 }
327 let ascii = theme.ascii();
328 let underline_row = area.height >= 2;
329 let (range, compact) = self.layout(area.width);
330 let (selected, y) = (self.selected(), area.y);
331 let room = |x: u16| usize::from(area.right().saturating_sub(x));
332 let cells = |s: &str| u16::try_from(text::width(s)).unwrap_or(u16::MAX);
333 let mut x = area.x;
334
335 let quiet = theme.fg(Role::Muted);
336 if range.start > 0 {
337 let mark = self.left_mark(range.start, ascii, compact);
338 buf.set_stringn(x, y, &mark, room(x), quiet);
339 x = x.saturating_add(cells(&mark));
340 }
341 let lit = if self.focused {
342 theme.fg(Role::Primary)
343 } else {
344 theme.fg(Role::Foreground)
345 };
346 for index in range.clone() {
347 if x >= area.right() {
348 break;
349 }
350 let is_selected = index == selected;
351 let tab = &self.items[index];
352 let label = text::display_safe(&tab.label);
353 let badge = tab.badge_text();
354 // The last visible tab leaves room for the mark that follows it,
355 // unless it is the one tab wider than the whole strip.
356 let tail = if index + 1 == range.end && range.end < self.items.len() {
357 usize::from(cells(&self.right_mark(
358 self.items.len() - range.end,
359 false,
360 compact,
361 )))
362 } else {
363 0
364 };
365 let avail = room(x).saturating_sub(tail);
366 let badge_w = badge.as_deref().map_or(0, |b| text::width(b) + 1);
367 let label_room = avail.saturating_sub(2 + badge_w).max(1);
368 let label = text::truncate(&label, label_room, ascii);
369 let label_w = cells(&label);
370
371 let (left_pad, right_pad) = if is_selected && !underline_row {
372 ("[", "]")
373 } else {
374 (" ", " ")
375 };
376 let label_style = if is_selected {
377 theme.fg(Role::Foreground).add_modifier(Modifier::BOLD)
378 } else {
379 quiet
380 };
381 let pad_style = if is_selected { lit } else { quiet };
382 buf.set_stringn(x, y, left_pad, room(x), pad_style);
383 buf.set_stringn(x + 1, y, &label, room(x + 1), label_style);
384 let mut end = x + 1 + label_w;
385 if let Some(badge) = &badge
386 && usize::from(label_w) + 2 + badge_w <= avail
387 {
388 buf.set_stringn(end + 1, y, badge, room(end + 1), quiet);
389 end += 1 + cells(badge);
390 }
391 buf.set_stringn(end, y, right_pad, room(end), pad_style);
392 if is_selected && underline_row {
393 let rule = glyphs::pick("─", ascii);
394 for dx in x + 1..end {
395 if dx < area.right() {
396 buf[(dx, y + 1)].set_symbol(rule).set_style(lit);
397 }
398 }
399 }
400 x = end.saturating_add(1);
401 }
402 if range.end < self.items.len() && x < area.right() {
403 let mark = self.right_mark(self.items.len() - range.end, ascii, compact);
404 buf.set_stringn(x, y, &mark, room(x), quiet);
405 }
406 }
407
408 fn height(&self, _width: u16, _theme: &Theme) -> u16 {
409 2
410 }
411 }
412
413 #[cfg(test)]
414 mod tests {
415 use super::*;
416 use crate::testing::{Profile, render, text as rendered};
417
418 const LABELS: [&str; 6] = [
419 "General",
420 "Appearance",
421 "Agents",
422 "Privacy",
423 "Shortcuts",
424 "Advanced",
425 ];
426
427 fn key(code: KeyCode) -> KeyEvent {
428 KeyEvent::new(code, KeyModifiers::NONE)
429 }
430
431 fn items() -> Vec<Tab> {
432 LABELS.into_iter().map(Tab::new).collect()
433 }
434
435 #[test]
436 fn arrows_stop_and_tab_wraps() {
437 let mut state = TabsState::new(0);
438 assert_eq!(
439 state.handle_key(key(KeyCode::Left), 3),
440 TabsOutcome::Ignored
441 );
442 assert_eq!(
443 state.handle_key(key(KeyCode::Right), 3),
444 TabsOutcome::Selected(1)
445 );
446 assert_eq!(
447 state.handle_key(key(KeyCode::Tab), 3),
448 TabsOutcome::Selected(2)
449 );
450 assert_eq!(
451 state.handle_key(key(KeyCode::Right), 3),
452 TabsOutcome::Ignored
453 );
454 assert_eq!(
455 state.handle_key(key(KeyCode::Tab), 3),
456 TabsOutcome::Selected(0)
457 );
458 assert_eq!(
459 state.handle_key(key(KeyCode::BackTab), 3),
460 TabsOutcome::Selected(2)
461 );
462 let shift_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT);
463 assert_eq!(state.handle_key(shift_tab, 3), TabsOutcome::Selected(1));
464 assert_eq!(
465 state.handle_key(key(KeyCode::End), 3),
466 TabsOutcome::Selected(2)
467 );
468 assert_eq!(
469 state.handle_key(key(KeyCode::Home), 3),
470 TabsOutcome::Selected(0)
471 );
472 }
473
474 #[test]
475 fn releases_chords_empty_strips_and_lone_tabs_do_nothing() {
476 let mut state = TabsState::new(0);
477 let mut release = key(KeyCode::Right);
478 release.kind = KeyEventKind::Release;
479 assert_eq!(state.handle_key(release, 3), TabsOutcome::Ignored);
480 let chord = KeyEvent::new(KeyCode::Right, KeyModifiers::CONTROL);
481 assert_eq!(state.handle_key(chord, 3), TabsOutcome::Ignored);
482 assert_eq!(
483 state.handle_key(key(KeyCode::Right), 0),
484 TabsOutcome::Ignored
485 );
486 assert_eq!(state.handle_key(key(KeyCode::Tab), 1), TabsOutcome::Ignored);
487 assert_eq!(
488 state.handle_key(key(KeyCode::Char('x')), 3),
489 TabsOutcome::Ignored
490 );
491 assert_eq!(state.selected, 0);
492 }
493
494 #[test]
495 fn a_selection_past_the_end_is_pulled_back_in() {
496 let mut state = TabsState::new(99);
497 assert_eq!(
498 state.handle_key(key(KeyCode::Tab), 3),
499 TabsOutcome::Selected(0)
500 );
501 let items = items();
502 assert!(
503 Tabs::new(&items, TabsState::new(99))
504 .visible(30)
505 .contains(&5)
506 );
507 }
508
509 #[test]
510 fn everything_shows_when_it_fits() {
511 let items = items();
512 assert_eq!(Tabs::new(&items, TabsState::new(0)).visible(120), 0..6);
513 }
514
515 #[test]
516 fn the_selection_stays_visible_at_every_width_and_position() {
517 let items = items();
518 for width in 1..=120 {
519 for selected in 0..items.len() {
520 let range = Tabs::new(&items, TabsState::new(selected)).visible(width);
521 assert!(
522 range.contains(&selected),
523 "width {width} selected {selected}: {range:?}"
524 );
525 }
526 }
527 }
528
529 #[test]
530 fn a_scrolled_strip_marks_what_it_hides_on_both_sides() {
531 let theme = Profile::DarkTrue.theme();
532 let items = items();
533 let tabs = Tabs::new(&items, TabsState::new(3));
534 let buf = render(36, 2, |area, buf| tabs.paint(area, buf, &theme));
535 let text = rendered(&buf);
536 let first = text.lines().next().unwrap();
537 let range = tabs.visible(36);
538 assert!(range.start > 0 && range.end < LABELS.len(), "{range:?}");
539 assert!(first.starts_with("‹ "), "{first:?}");
540 assert!(first.contains("Privacy"), "{first:?}");
541 assert!(first.contains("more"), "{first:?}");
542 assert!(first.trim_end().ends_with('›'), "{first:?}");
543 }
544
545 #[test]
546 fn the_strip_scrolls_by_as_little_as_it_must() {
547 // Walk right across a strip that cannot show everything, as a host
548 // does: the first visible tab never moves back, and by at most two.
549 let items = items();
550 let mut state = TabsState::new(0);
551 let mut last = 0;
552 for _ in 0..items.len() - 1 {
553 let mut strip = Tabs::new(&items, state);
554 strip.handle_key(key(KeyCode::Right), 30);
555 state = strip.state;
556 assert!(
557 state.offset >= last && state.offset <= last + 2,
558 "{state:?}"
559 );
560 last = state.offset;
561 }
562 // Walking back does not snap the strip to the left.
563 let before = state.offset;
564 let mut strip = Tabs::new(&items, state);
565 strip.handle_key(key(KeyCode::Left), 30);
566 assert_eq!(strip.state.offset, before.min(strip.state.selected));
567 assert!(strip.visible(30).contains(&strip.state.selected));
568 }
569
570 #[test]
571 fn scroll_into_view_stores_the_painted_offset() {
572 let items = items();
573 let mut state = TabsState::new(5);
574 Tabs::new(&items, state).scroll_into_view(&mut state, 30);
575 assert!(state.offset > 0);
576 assert!(Tabs::new(&items, state).visible(30).contains(&5));
577 }
578
579 #[test]
580 fn the_selected_tab_is_underlined_or_bracketed_in_every_profile() {
581 let items = [Tab::new("General"), Tab::new("Agents").badge(2)];
582 for profile in Profile::ALL {
583 let theme = profile.theme();
584 let tabs = Tabs::new(&items, TabsState::new(1));
585 let two = rendered(&render(40, 2, |area, buf| tabs.paint(area, buf, &theme)));
586 let rows: Vec<_> = two.lines().collect();
587 assert_eq!(rows[0].trim_end(), " General Agents 2", "{profile:?}");
588 let rule = if profile == Profile::Ascii {
589 "-"
590 } else {
591 "─"
592 };
593 assert_eq!(
594 rows[1].trim_end(),
595 format!(" {}", rule.repeat(8)),
596 "{profile:?}"
597 );
598 let one = rendered(&render(40, 1, |area, buf| tabs.paint(area, buf, &theme)));
599 assert_eq!(one, " General [Agents 2]", "{profile:?}");
600 }
601 }
602
603 #[test]
604 fn badges_cap_at_ninety_nine() {
605 let items = [Tab::new("Inbox").badge(250)];
606 let tabs = Tabs::new(&items, TabsState::new(0));
607 let theme = Profile::NoColor.theme();
608 let line = rendered(&render(20, 1, |area, buf| tabs.paint(area, buf, &theme)));
609 assert!(line.contains("Inbox 99+"), "{line:?}");
610 }
611
612 #[test]
613 fn a_click_finds_the_tab_under_it() {
614 let items = items();
615 let tabs = Tabs::new(&items, TabsState::new(0));
616 let area = Rect::new(0, 0, 120, 2);
617 assert_eq!(tabs.tab_at(area, 3, 0), Some(0));
618 assert_eq!(tabs.tab_at(area, 12, 0), Some(1));
619 assert_eq!(tabs.tab_at(area, 3, 5), None);
620 }
621
622 #[test]
623 fn tiny_and_empty_strips_do_not_panic() {
624 let theme = Profile::DarkTrue.theme();
625 let mut items = items();
626 items[2] = Tab::new("Agents").badge(4);
627 for selected in [0, 3, 9] {
628 for (w, h) in [(0, 0), (1, 1), (2, 2), (5, 1), (7, 2), (12, 1)] {
629 let tabs = Tabs::new(&items, TabsState::new(selected)).focused(true);
630 let _ = render(w, h, |area, buf| tabs.paint(area, buf, &theme));
631 }
632 }
633 let none = Tabs::new(&[], TabsState::new(0));
634 let _ = render(10, 2, |area, buf| none.paint(area, buf, &theme));
635 assert_eq!(none.visible(10), 0..0);
636 }
637 }
638
638 lines RUST