| 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 |