| 1 | //! List: a scrolling list of rows the caller paints, with a selection that |
| 2 | //! always stays on screen. |
| 3 | //! |
| 4 | //! ```text |
| 5 | //! ▸ Theme Shoreline · follows terminal |
| 6 | //! Motion Full |
| 7 | //! Density Comfortable █ |
| 8 | //! ``` |
| 9 | //! |
| 10 | //! The list owns what every selectable list repeats: the `▸` marker, the |
| 11 | //! selection ground, the scrollbar, wrapping navigation that skips disabled |
| 12 | //! rows, paging, and the viewport arithmetic that keeps the selected row |
| 13 | //! fully visible. The caller owns what a row says: a row is anything that |
| 14 | //! implements [`ListRow`], one to several lines tall. |
| 15 | //! |
| 16 | //! A selected row carries three cues, because a fill alone measures only |
| 17 | //! about 1.3:1 against its neighbours: the `▸` marker (`>` in ASCII), the |
| 18 | //! label in bold (a row asks [`ListRowState::ink`]), and the `Selected` |
| 19 | //! ground where grounds paint. The marker and the bold survive 16 colors and |
| 20 | //! `NO_COLOR`; the ground is the extra. |
| 21 | //! |
| 22 | //! An empty list paints its [`EmptyState`] if it has one, so the "nothing |
| 23 | //! here" message lives where the rows would have been. |
| 24 | //! |
| 25 | //! Replaces the scattered selection markers (`▸`, `❯`, a literal `"▸ "`), |
| 26 | //! `menu_style::selected_row_style` and `list_nav::apply` in the engine's |
| 27 | //! `crates/tui/src/tui/` (`Hmbown/CodeWhale` `58b1dd3dd`), and |
| 28 | //! `render_panel_scroll_rail`. [`crate::Picker`] is built on the same |
| 29 | //! navigation and scrollbar. |
| 30 | |
| 31 | use std::borrow::Cow; |
| 32 | |
| 33 | use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; |
| 34 | use ratatui::{ |
| 35 | buffer::Buffer, |
| 36 | layout::Rect, |
| 37 | style::{Modifier, Style}, |
| 38 | symbols::scrollbar, |
| 39 | text::{Line, Span}, |
| 40 | widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget}, |
| 41 | }; |
| 42 | |
| 43 | use crate::{EmptyState, Paint, Role, Theme, glyphs, text}; |
| 44 | |
| 45 | /// Cells the marker takes before a row's content: `▸ `. |
| 46 | const GUTTER: u16 = 2; |
| 47 | /// Cells the scrollbar takes: the bar and one of air. |
| 48 | const RAIL: u16 = 2; |
| 49 | |
| 50 | /// What a row is asked to know when it paints. |
| 51 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 52 | pub struct ListRowState { |
| 53 | pub index: usize, |
| 54 | /// This is the selected row. The list has already drawn the marker and |
| 55 | /// the ground; the row makes its label bold ([`ListRowState::ink`]). |
| 56 | pub selected: bool, |
| 57 | /// The row cannot be chosen; it should recede and say why. |
| 58 | pub disabled: bool, |
| 59 | } |
| 60 | |
| 61 | impl ListRowState { |
| 62 | /// The style for a row's main text: bold `Foreground` when selected, |
| 63 | /// receded when disabled, `Foreground` otherwise. |
| 64 | #[must_use] |
| 65 | pub fn ink(&self, theme: &Theme) -> Style { |
| 66 | if self.disabled { |
| 67 | theme.fg(Role::Muted).add_modifier(Modifier::DIM) |
| 68 | } else if self.selected { |
| 69 | theme.fg(Role::Foreground).add_modifier(Modifier::BOLD) |
| 70 | } else { |
| 71 | theme.fg(Role::Foreground) |
| 72 | } |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | /// One row of a [`List`]: its height and how it paints. |
| 77 | pub trait ListRow { |
| 78 | /// Lines this row takes at `width` cells of content. At least one; |
| 79 | /// zero is read as one. |
| 80 | fn height(&self, _width: u16) -> u16 { |
| 81 | 1 |
| 82 | } |
| 83 | |
| 84 | /// Whether navigation skips this row and Enter cannot choose it. |
| 85 | fn is_disabled(&self) -> bool { |
| 86 | false |
| 87 | } |
| 88 | |
| 89 | /// Paint the row's content into `area`: the list has already drawn the |
| 90 | /// marker, the selection ground and the gutter, and `area` is as tall as |
| 91 | /// [`ListRow::height`] (less if the viewport clips it). Style every cell |
| 92 | /// through `theme`; use [`ListRowState::ink`] for the main text. |
| 93 | fn paint_row(&self, area: Rect, buf: &mut Buffer, theme: &Theme, state: ListRowState); |
| 94 | } |
| 95 | |
| 96 | fn paint_label(label: &str, area: Rect, buf: &mut Buffer, theme: &Theme, state: ListRowState) { |
| 97 | let label = text::display_safe(label); |
| 98 | let shown = text::truncate(&label, usize::from(area.width), theme.ascii()); |
| 99 | Line::from(Span::styled(shown.into_owned(), state.ink(theme))).render(area, buf); |
| 100 | } |
| 101 | |
| 102 | impl ListRow for &str { |
| 103 | fn paint_row(&self, area: Rect, buf: &mut Buffer, theme: &Theme, state: ListRowState) { |
| 104 | paint_label(self, area, buf, theme, state); |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | impl ListRow for String { |
| 109 | fn paint_row(&self, area: Rect, buf: &mut Buffer, theme: &Theme, state: ListRowState) { |
| 110 | paint_label(self, area, buf, theme, state); |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | impl ListRow for Cow<'_, str> { |
| 115 | fn paint_row(&self, area: Rect, buf: &mut Buffer, theme: &Theme, state: ListRowState) { |
| 116 | paint_label(self, area, buf, theme, state); |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | /// The next enabled row after `selected`, wrapping at the ends. `selected` |
| 121 | /// stays put when no other row is enabled. |
| 122 | pub(crate) fn step_to( |
| 123 | selected: usize, |
| 124 | len: usize, |
| 125 | forward: bool, |
| 126 | enabled: impl Fn(usize) -> bool, |
| 127 | ) -> usize { |
| 128 | if len == 0 { |
| 129 | return 0; |
| 130 | } |
| 131 | let selected = selected.min(len - 1); |
| 132 | for d in 1..=len { |
| 133 | let at = if forward { |
| 134 | (selected + d) % len |
| 135 | } else { |
| 136 | (selected + len - d % len) % len |
| 137 | }; |
| 138 | if enabled(at) { |
| 139 | return at; |
| 140 | } |
| 141 | } |
| 142 | selected |
| 143 | } |
| 144 | |
| 145 | /// The enabled row nearest `target` in the direction of travel, else the |
| 146 | /// nearest the other way, else `target`. No wrapping: for Home, End and |
| 147 | /// paging, which clamp. |
| 148 | pub(crate) fn settle_at( |
| 149 | target: usize, |
| 150 | len: usize, |
| 151 | forward: bool, |
| 152 | enabled: impl Fn(usize) -> bool, |
| 153 | ) -> usize { |
| 154 | if len == 0 { |
| 155 | return 0; |
| 156 | } |
| 157 | let target = target.min(len - 1); |
| 158 | let after = (target..len).find(|&i| enabled(i)); |
| 159 | let before = (0..=target).rev().find(|&i| enabled(i)); |
| 160 | let (first, second) = if forward { |
| 161 | (after, before) |
| 162 | } else { |
| 163 | (before, after) |
| 164 | }; |
| 165 | first.or(second).unwrap_or(target) |
| 166 | } |
| 167 | |
| 168 | /// The offset that keeps `selected` fully visible in `height` lines, keeps |
| 169 | /// `offset` where it can, and leaves no blank lines under the last row while |
| 170 | /// rows are hidden above. `row_h` gives each row's height. |
| 171 | pub(crate) fn offset_for( |
| 172 | selected: usize, |
| 173 | offset: usize, |
| 174 | len: usize, |
| 175 | height: u16, |
| 176 | row_h: impl Fn(usize) -> u16, |
| 177 | ) -> usize { |
| 178 | if len == 0 { |
| 179 | return 0; |
| 180 | } |
| 181 | let height = usize::from(height); |
| 182 | let h = |i: usize| usize::from(row_h(i).max(1)); |
| 183 | let selected = selected.min(len - 1); |
| 184 | |
| 185 | // The lowest offset that still puts the last row on the bottom line. |
| 186 | let mut tail = 0; |
| 187 | let mut bottom = len; |
| 188 | while bottom > 0 && tail + h(bottom - 1) <= height { |
| 189 | tail += h(bottom - 1); |
| 190 | bottom -= 1; |
| 191 | } |
| 192 | let mut offset = offset.min(bottom.min(len - 1)); |
| 193 | |
| 194 | if selected < offset { |
| 195 | offset = selected; |
| 196 | } else { |
| 197 | // The first row from which the selected row still ends in view. |
| 198 | let mut used = 0; |
| 199 | let mut first = selected + 1; |
| 200 | while first > 0 && used + h(first - 1) <= height { |
| 201 | used += h(first - 1); |
| 202 | first -= 1; |
| 203 | } |
| 204 | offset = offset.max(first.min(selected)); |
| 205 | } |
| 206 | offset |
| 207 | } |
| 208 | |
| 209 | /// Which row is selected and how far the list has scrolled. |
| 210 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
| 211 | pub struct ListState { |
| 212 | pub selected: usize, |
| 213 | /// The first row painted. [`List`] recomputes it from the selection when |
| 214 | /// it paints; keep it here so scrolling is stable between frames. |
| 215 | pub offset: usize, |
| 216 | } |
| 217 | |
| 218 | impl ListState { |
| 219 | #[must_use] |
| 220 | pub const fn new(selected: usize) -> Self { |
| 221 | Self { |
| 222 | selected, |
| 223 | offset: 0, |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | /// Put the selection on an enabled row inside `0..len`, after the rows |
| 228 | /// changed (a refresh, a filter). Keeps the row if it still can be |
| 229 | /// selected; otherwise takes the next enabled row, else the previous one. |
| 230 | pub fn settle(&mut self, len: usize, enabled: impl Fn(usize) -> bool) { |
| 231 | if len == 0 { |
| 232 | *self = Self::default(); |
| 233 | return; |
| 234 | } |
| 235 | let at = self.selected.min(len - 1); |
| 236 | self.selected = settle_at(at, len, true, enabled); |
| 237 | } |
| 238 | |
| 239 | /// The next enabled row, wrapping from the last to the first. |
| 240 | pub fn select_next(&mut self, len: usize, enabled: impl Fn(usize) -> bool) { |
| 241 | self.selected = step_to(self.selected, len, true, enabled); |
| 242 | } |
| 243 | |
| 244 | /// The previous enabled row, wrapping from the first to the last. |
| 245 | pub fn select_prev(&mut self, len: usize, enabled: impl Fn(usize) -> bool) { |
| 246 | self.selected = step_to(self.selected, len, false, enabled); |
| 247 | } |
| 248 | |
| 249 | /// The first enabled row. |
| 250 | pub fn home(&mut self, len: usize, enabled: impl Fn(usize) -> bool) { |
| 251 | self.selected = settle_at(0, len, true, enabled); |
| 252 | } |
| 253 | |
| 254 | /// The last enabled row. |
| 255 | pub fn end(&mut self, len: usize, enabled: impl Fn(usize) -> bool) { |
| 256 | self.selected = settle_at(len.saturating_sub(1), len, false, enabled); |
| 257 | } |
| 258 | |
| 259 | /// Move `step` rows, clamped at the ends (a page is a request to travel, |
| 260 | /// not to wrap), landing on an enabled row. |
| 261 | pub fn page(&mut self, len: usize, step: usize, down: bool, enabled: impl Fn(usize) -> bool) { |
| 262 | let step = step.max(1); |
| 263 | let target = if down { |
| 264 | (self.selected + step).min(len.saturating_sub(1)) |
| 265 | } else { |
| 266 | self.selected.saturating_sub(step) |
| 267 | }; |
| 268 | self.selected = settle_at(target, len, down, enabled); |
| 269 | } |
| 270 | |
| 271 | /// The offset a list of `len` rows paints with in `height` lines, where |
| 272 | /// `row_h` gives each row's height: the selected row is fully visible. |
| 273 | #[must_use] |
| 274 | pub fn visible_offset(&self, len: usize, height: u16, row_h: impl Fn(usize) -> u16) -> usize { |
| 275 | offset_for(self.selected, self.offset, len, height, row_h) |
| 276 | } |
| 277 | |
| 278 | /// Store the offset [`List`] will paint with. |
| 279 | pub fn scroll_into_view(&mut self, len: usize, height: u16, row_h: impl Fn(usize) -> u16) { |
| 280 | self.offset = self.visible_offset(len, height, row_h); |
| 281 | } |
| 282 | |
| 283 | /// Apply a key press to the state of `rows` painted in `viewport`, and |
| 284 | /// say what happened. Keys: `↑`/`↓` (wrapping, skipping disabled rows), |
| 285 | /// `Home`, `End`, `PgUp`, `PgDn`, `Enter`, `Space`, `Esc`. Releases and |
| 286 | /// other keys are [`ListOutcome::Ignored`]; so are Enter and Space on a |
| 287 | /// disabled row. Esc cancels even an empty list. Modified keys belong |
| 288 | /// to the host; held keys repeat navigation but never choose or toggle. |
| 289 | pub fn handle_key<R: ListRow>( |
| 290 | &mut self, |
| 291 | key: KeyEvent, |
| 292 | rows: &[R], |
| 293 | viewport: Rect, |
| 294 | ) -> ListOutcome { |
| 295 | if key.kind == KeyEventKind::Release |
| 296 | || !key.modifiers.is_empty() |
| 297 | || (key.kind == KeyEventKind::Repeat |
| 298 | && matches!(key.code, KeyCode::Enter | KeyCode::Esc | KeyCode::Char(' '))) |
| 299 | { |
| 300 | return ListOutcome::Ignored; |
| 301 | } |
| 302 | let len = rows.len(); |
| 303 | if key.code == KeyCode::Esc { |
| 304 | return ListOutcome::Cancelled; |
| 305 | } |
| 306 | if len == 0 { |
| 307 | return ListOutcome::Ignored; |
| 308 | } |
| 309 | let enabled = |i: usize| !rows[i].is_disabled(); |
| 310 | let width = viewport.width.saturating_sub(GUTTER); |
| 311 | let row_h = |i: usize| rows[i].height(width); |
| 312 | match key.code { |
| 313 | KeyCode::Up => self.select_prev(len, enabled), |
| 314 | KeyCode::Down => self.select_next(len, enabled), |
| 315 | KeyCode::Home => self.home(len, enabled), |
| 316 | KeyCode::End => self.end(len, enabled), |
| 317 | KeyCode::PageUp | KeyCode::PageDown => { |
| 318 | let page = self.rows_per_page(len, viewport.height, row_h); |
| 319 | self.page(len, page, key.code == KeyCode::PageDown, enabled); |
| 320 | } |
| 321 | KeyCode::Enter | KeyCode::Char(' ') => { |
| 322 | if self.selected >= len || rows[self.selected].is_disabled() { |
| 323 | return ListOutcome::Ignored; |
| 324 | } |
| 325 | return if key.code == KeyCode::Enter { |
| 326 | ListOutcome::Chose(self.selected) |
| 327 | } else { |
| 328 | ListOutcome::Toggled(self.selected) |
| 329 | }; |
| 330 | } |
| 331 | _ => return ListOutcome::Ignored, |
| 332 | } |
| 333 | self.scroll_into_view(len, viewport.height, row_h); |
| 334 | ListOutcome::Moved |
| 335 | } |
| 336 | |
| 337 | /// Rows that fit in one screenful from the current offset. |
| 338 | fn rows_per_page(&self, len: usize, height: u16, row_h: impl Fn(usize) -> u16) -> usize { |
| 339 | let mut used = 0usize; |
| 340 | let mut count = 0; |
| 341 | for i in self.offset.min(len)..len { |
| 342 | used += usize::from(row_h(i).max(1)); |
| 343 | if used > usize::from(height) { |
| 344 | break; |
| 345 | } |
| 346 | count += 1; |
| 347 | } |
| 348 | count.max(1) |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | /// What a key did to a [`ListState`]: the message a host reacts to. |
| 353 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 354 | pub enum ListOutcome { |
| 355 | /// The key means nothing to a list; the host may use it. |
| 356 | Ignored, |
| 357 | /// The selection moved (or tried to); repaint. |
| 358 | Moved, |
| 359 | /// Enter: row `n` was chosen. |
| 360 | Chose(usize), |
| 361 | /// Space: row `n` was toggled (a checklist row). |
| 362 | Toggled(usize), |
| 363 | /// Esc: the list was dismissed. |
| 364 | Cancelled, |
| 365 | } |
| 366 | |
| 367 | /// Draw ratatui's scrollbar down the right edge of `area`: `█` on `│`, or |
| 368 | /// `#` on `|` in ASCII-safe terminals, in `Foreground` over `Border` so it |
| 369 | /// reads without any ground. `visible` rows of `len` are showing, from |
| 370 | /// `offset`. |
| 371 | pub(crate) fn paint_scrollbar( |
| 372 | area: Rect, |
| 373 | buf: &mut Buffer, |
| 374 | theme: &Theme, |
| 375 | len: usize, |
| 376 | offset: usize, |
| 377 | visible: usize, |
| 378 | ) { |
| 379 | let (thumb, track) = if theme.ascii() { |
| 380 | ("#", "|") |
| 381 | } else { |
| 382 | ("█", "│") |
| 383 | }; |
| 384 | let mut state = ScrollbarState::new(len.saturating_sub(visible)) |
| 385 | .position(offset) |
| 386 | .viewport_content_length(visible); |
| 387 | Scrollbar::new(ScrollbarOrientation::VerticalRight) |
| 388 | .symbols(scrollbar::Set { |
| 389 | track, |
| 390 | thumb, |
| 391 | begin: track, |
| 392 | end: track, |
| 393 | }) |
| 394 | .begin_symbol(None) |
| 395 | .end_symbol(None) |
| 396 | .thumb_style(theme.fg(Role::Foreground)) |
| 397 | .track_style(theme.fg(Role::Border)) |
| 398 | .render(area, buf, &mut state); |
| 399 | } |
| 400 | |
| 401 | /// A row placed in the viewport: its index, its line from the top, and its |
| 402 | /// height (clipped at the bottom edge). |
| 403 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 404 | struct Slot { |
| 405 | index: usize, |
| 406 | y: u16, |
| 407 | height: u16, |
| 408 | } |
| 409 | |
| 410 | /// Where a painted [`List`] put its rows. |
| 411 | struct Placed { |
| 412 | offset: usize, |
| 413 | slots: Vec<Slot>, |
| 414 | /// Whether the scrollbar is drawn (and rows are narrower for it). |
| 415 | rail: bool, |
| 416 | } |
| 417 | |
| 418 | /// A scrolling list of caller-painted rows. |
| 419 | /// |
| 420 | /// Use [`Paint::paint`] with a state snapshot, or render the themed widget |
| 421 | /// with application-owned [`ListState`]. Stateful rendering stores the |
| 422 | /// actual viewport offset, including variable-height rows and clipping: |
| 423 | /// |
| 424 | /// ```no_run |
| 425 | /// use codewhale_ratatui::{List, ListState, Paint, Theme}; |
| 426 | /// # fn draw(frame: &mut ratatui::Frame<'_>, state: &mut ListState) { |
| 427 | /// let theme = Theme::detect().tui(); |
| 428 | /// let rows = ["First session", "Second session"]; |
| 429 | /// let list = List::new(&rows, ListState::default()); |
| 430 | /// frame.render_stateful_widget(list.themed(&theme), frame.area(), state); |
| 431 | /// # } |
| 432 | /// ``` |
| 433 | pub struct List<'a, R: ListRow> { |
| 434 | rows: &'a [R], |
| 435 | state: ListState, |
| 436 | empty: Option<EmptyState<'a>>, |
| 437 | } |
| 438 | |
| 439 | impl<R: ListRow> Clone for List<'_, R> { |
| 440 | fn clone(&self) -> Self { |
| 441 | Self { |
| 442 | rows: self.rows, |
| 443 | state: self.state, |
| 444 | empty: self.empty.clone(), |
| 445 | } |
| 446 | } |
| 447 | } |
| 448 | |
| 449 | impl<'a, R: ListRow> List<'a, R> { |
| 450 | #[must_use] |
| 451 | pub const fn new(rows: &'a [R], state: ListState) -> Self { |
| 452 | Self { |
| 453 | rows, |
| 454 | state, |
| 455 | empty: None, |
| 456 | } |
| 457 | } |
| 458 | |
| 459 | /// What to show when there are no rows. |
| 460 | #[must_use] |
| 461 | pub fn empty(mut self, empty: EmptyState<'a>) -> Self { |
| 462 | self.empty = Some(empty); |
| 463 | self |
| 464 | } |
| 465 | |
| 466 | fn place(&self, area: Rect) -> Placed { |
| 467 | let len = self.rows.len(); |
| 468 | let place_at = |width: u16| { |
| 469 | let h = |i: usize| self.rows[i].height(width).max(1); |
| 470 | let offset = self.state.visible_offset(len, area.height, h); |
| 471 | let mut slots = Vec::new(); |
| 472 | let mut y = 0u16; |
| 473 | for index in offset..len { |
| 474 | if y >= area.height { |
| 475 | break; |
| 476 | } |
| 477 | let height = h(index).min(area.height - y); |
| 478 | slots.push(Slot { index, y, height }); |
| 479 | // By the clipped height: a row may report up to `u16::MAX` |
| 480 | // lines, and `y` plus that would overflow. |
| 481 | y += height; |
| 482 | } |
| 483 | let overflow = offset > 0 |
| 484 | || slots |
| 485 | .last() |
| 486 | .is_some_and(|s| s.index + 1 < len || s.height < h(s.index)); |
| 487 | (offset, slots, overflow) |
| 488 | }; |
| 489 | let content = area.width.saturating_sub(GUTTER); |
| 490 | let (offset, slots, overflow) = place_at(content); |
| 491 | if overflow && area.width > GUTTER + RAIL { |
| 492 | let (offset, slots, _) = place_at(content - RAIL); |
| 493 | return Placed { |
| 494 | offset, |
| 495 | slots, |
| 496 | rail: true, |
| 497 | }; |
| 498 | } |
| 499 | Placed { |
| 500 | offset, |
| 501 | slots, |
| 502 | rail: false, |
| 503 | } |
| 504 | } |
| 505 | |
| 506 | /// The row under a click at `(column, row)` when the list was painted in |
| 507 | /// `area`, for hosts that take mouse input. |
| 508 | #[must_use] |
| 509 | pub fn row_at(&self, area: Rect, column: u16, row: u16) -> Option<usize> { |
| 510 | let inside = |
| 511 | column >= area.x && column < area.right() && row >= area.y && row < area.bottom(); |
| 512 | if !inside || self.rows.is_empty() { |
| 513 | return None; |
| 514 | } |
| 515 | let y = row - area.y; |
| 516 | self.place(area) |
| 517 | .slots |
| 518 | .iter() |
| 519 | .find(|s| y >= s.y && y < s.y + s.height) |
| 520 | .map(|s| s.index) |
| 521 | } |
| 522 | } |
| 523 | |
| 524 | impl<R: ListRow> Paint for List<'_, R> { |
| 525 | fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 526 | let area = area.intersection(buf.area); |
| 527 | if area.is_empty() { |
| 528 | return; |
| 529 | } |
| 530 | if self.rows.is_empty() { |
| 531 | if let Some(empty) = &self.empty { |
| 532 | empty.paint(area, buf, theme); |
| 533 | } |
| 534 | return; |
| 535 | } |
| 536 | let placed = self.place(area); |
| 537 | let list_w = area.width - if placed.rail { RAIL } else { 0 }; |
| 538 | let gutter = GUTTER.min(list_w); |
| 539 | let marker = glyphs::pick(glyphs::SELECTION, theme.ascii()); |
| 540 | for slot in &placed.slots { |
| 541 | let row = &self.rows[slot.index]; |
| 542 | let state = ListRowState { |
| 543 | index: slot.index, |
| 544 | selected: slot.index == self.state.selected, |
| 545 | disabled: row.is_disabled(), |
| 546 | }; |
| 547 | let rect = Rect { |
| 548 | y: area.y + slot.y, |
| 549 | height: slot.height, |
| 550 | width: list_w, |
| 551 | ..area |
| 552 | }; |
| 553 | if state.selected { |
| 554 | buf.set_style(rect, theme.bg(Role::Selected)); |
| 555 | if gutter > 0 { |
| 556 | buf.set_string(rect.x, rect.y, marker, theme.fg(Role::Primary)); |
| 557 | } |
| 558 | } |
| 559 | row.paint_row( |
| 560 | Rect { |
| 561 | x: rect.x + gutter, |
| 562 | width: list_w - gutter, |
| 563 | ..rect |
| 564 | }, |
| 565 | buf, |
| 566 | theme, |
| 567 | state, |
| 568 | ); |
| 569 | } |
| 570 | if placed.rail { |
| 571 | paint_scrollbar( |
| 572 | area, |
| 573 | buf, |
| 574 | theme, |
| 575 | self.rows.len(), |
| 576 | placed.offset, |
| 577 | placed.slots.len(), |
| 578 | ); |
| 579 | } |
| 580 | } |
| 581 | |
| 582 | fn height(&self, width: u16, _theme: &Theme) -> u16 { |
| 583 | let content = width.saturating_sub(GUTTER); |
| 584 | let total: usize = self |
| 585 | .rows |
| 586 | .iter() |
| 587 | .map(|r| usize::from(r.height(content).max(1))) |
| 588 | .sum(); |
| 589 | u16::try_from(total).unwrap_or(u16::MAX) |
| 590 | } |
| 591 | } |
| 592 | |
| 593 | /// Render with application-owned selection and scroll state. This explicit |
| 594 | /// state overrides the constructor's snapshot; the resolved viewport offset |
| 595 | /// is stored after clipping and scrollbar layout. |
| 596 | impl<R: ListRow> StatefulWidget for crate::Themed<'_, List<'_, R>> { |
| 597 | type State = ListState; |
| 598 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { |
| 599 | StatefulWidget::render(&self, area, buf, state); |
| 600 | } |
| 601 | } |
| 602 | |
| 603 | impl<R: ListRow> StatefulWidget for &crate::Themed<'_, List<'_, R>> { |
| 604 | type State = ListState; |
| 605 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { |
| 606 | let area = area.intersection(buf.area); |
| 607 | if area.is_empty() { |
| 608 | return; |
| 609 | } |
| 610 | if self.component.rows.is_empty() { |
| 611 | *state = ListState::default(); |
| 612 | self.component.paint(area, buf, self.theme); |
| 613 | return; |
| 614 | } |
| 615 | state.selected = state.selected.min(self.component.rows.len() - 1); |
| 616 | let view = List { |
| 617 | rows: self.component.rows, |
| 618 | state: *state, |
| 619 | empty: None, |
| 620 | }; |
| 621 | state.offset = view.place(area).offset; |
| 622 | view.paint(area, buf, self.theme); |
| 623 | } |
| 624 | } |
| 625 |