返回 CodeWhale
list.rs
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
625 lines RUST