返回 CodeWhale
picker.rs
1 //! Picker: the list behind the mode picker, the status-line picker, and
2 //! every other "choose one" or "choose some" list.
3 //!
4 //! A selected row carries three cues, because a fill alone measures only
5 //! about 1.3:1 against its neighbours: the `▸` marker lit in `Primary`, the
6 //! label in bold, and the `Selected` ground where grounds paint. Checked rows
7 //! show `●` and unchecked `○` (`[x]` and `[ ]` in ASCII), so on/off is a
8 //! shape, not a color. Disabled rows say why.
9 //!
10 //! With a query line the picker is a fuzzy finder. The caller owns the text
11 //! and its editing; the picker paints the query it is given
12 //! ([`Picker::query`]), ranks with [`PickerMatches::rank`] (the matcher in
13 //! [`crate::fuzzy_score`]), underlines the matched characters as well as
14 //! coloring them, and says "No matches" with an [`crate::EmptyState`] rather
15 //! than a count of zero. Tabs ([`PickerTabs`]) filter by category, and a
16 //! preview pane ([`PickerPreview`]) sits beside the list at
17 //! [`PICKER_PREVIEW_MIN_WIDTH`] columns and up and is left out below it.
18 //! [`PickerState::handle_picker_key`] separates the keys that move from the
19 //! keys that belong to the query. Without any of these, a picker is the plain
20 //! list it always was.
21 //!
22 //! Replaces the row rendering in the engine's `views/mode_picker.rs` and
23 //! `views/status_picker.rs`, and `menu_style::selected_row_style`
24 //! (`Hmbown/CodeWhale` `58b1dd3dd`).
25
26 use std::borrow::Cow;
27
28 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
29 use ratatui::{
30 buffer::Buffer,
31 layout::Rect,
32 style::{Modifier, Style},
33 text::{Line, Span},
34 widgets::Widget,
35 };
36 use unicode_segmentation::UnicodeSegmentation;
37
38 use super::list::{paint_scrollbar, settle_at, step_to};
39 use crate::{EmptyState, Paint, Role, Theme, fuzzy_score, glyphs, text};
40
41 /// One row.
42 #[derive(Clone, Debug, Default, PartialEq, Eq)]
43 pub struct PickerItem {
44 pub label: Cow<'static, str>,
45 /// Muted words after the label: what choosing this does.
46 pub detail: Option<Cow<'static, str>>,
47 /// A shortcut shown before the label (`1`, `2`, `3`).
48 pub key: Option<char>,
49 /// `Some` makes this a checklist row.
50 pub checked: Option<bool>,
51 /// `Some(reason)` greys the row and shows the reason as its detail.
52 pub disabled: Option<Cow<'static, str>>,
53 }
54
55 impl PickerItem {
56 #[must_use]
57 pub fn new(label: impl Into<Cow<'static, str>>) -> Self {
58 Self {
59 label: label.into(),
60 ..Self::default()
61 }
62 }
63
64 #[must_use]
65 pub fn detail(mut self, detail: impl Into<Cow<'static, str>>) -> Self {
66 self.detail = Some(detail.into());
67 self
68 }
69
70 #[must_use]
71 pub fn key(mut self, key: char) -> Self {
72 self.key = Some(key);
73 self
74 }
75
76 #[must_use]
77 pub fn checked(mut self, checked: bool) -> Self {
78 self.checked = Some(checked);
79 self
80 }
81
82 #[must_use]
83 pub fn disabled(mut self, reason: impl Into<Cow<'static, str>>) -> Self {
84 self.disabled = Some(reason.into());
85 self
86 }
87 }
88
89 /// Which row is selected and how far the list has scrolled.
90 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
91 pub struct PickerState {
92 pub selected: usize,
93 pub offset: usize,
94 }
95
96 impl PickerState {
97 #[must_use]
98 pub const fn new(selected: usize) -> Self {
99 Self {
100 selected,
101 offset: 0,
102 }
103 }
104
105 pub fn next(&mut self, len: usize) {
106 if len > 0 {
107 self.selected = self.selected.min(len - 1).saturating_add(1) % len;
108 }
109 }
110
111 pub fn prev(&mut self, len: usize) {
112 if len > 0 {
113 self.selected = if self.selected.min(len - 1) == 0 {
114 len - 1
115 } else {
116 self.selected.min(len - 1) - 1
117 };
118 }
119 }
120
121 pub fn home(&mut self) {
122 self.selected = 0;
123 }
124
125 pub fn end(&mut self, len: usize) {
126 self.selected = len.saturating_sub(1);
127 }
128
129 /// Move a page, clamped at the ends.
130 pub fn page(&mut self, len: usize, rows: u16, down: bool) {
131 let step = usize::from(rows.max(1));
132 self.selected = if down {
133 self.selected
134 .saturating_add(step)
135 .min(len.saturating_sub(1))
136 } else {
137 self.selected
138 .min(len.saturating_sub(1))
139 .saturating_sub(step)
140 };
141 }
142
143 /// The offset that keeps the selection on screen in `rows` rows.
144 #[must_use]
145 pub fn visible_offset(&self, len: usize, rows: usize) -> usize {
146 let rows = rows.max(1);
147 let selected = self.selected.min(len.saturating_sub(1));
148 let mut offset = self.offset.min(len.saturating_sub(rows));
149 if selected < offset {
150 offset = selected;
151 } else if selected >= offset.saturating_add(rows) {
152 offset = selected + 1 - rows;
153 }
154 offset
155 }
156
157 /// Store the offset [`Picker`] painted with, so scrolling is stable.
158 pub fn scroll_into_view(&mut self, len: usize, rows: u16) {
159 self.selected = self.selected.min(len.saturating_sub(1));
160 self.offset = self.visible_offset(len, usize::from(rows));
161 }
162 }
163
164 /// What a key did to a [`PickerState`]: the message a host reacts to.
165 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
166 pub enum PickerOutcome {
167 /// The key means nothing to a picker; the host may use it.
168 Ignored,
169 /// The selection moved (or tried to); repaint.
170 Moved,
171 /// Enter: row `n` was chosen.
172 Chose(usize),
173 /// Space: row `n` was toggled (a checklist row).
174 Toggled(usize),
175 /// Esc: the picker was dismissed.
176 Cancelled,
177 /// A picker with a query line: the key belongs to the query. Feed it to
178 /// the text editor, then rebuild the [`PickerMatches`] and
179 /// [`PickerState::reset`]. Only [`PickerState::handle_picker_key`]
180 /// returns it.
181 Query,
182 /// Tabs: the active tab is now `n`. Store it, rebuild the matches and
183 /// repaint; the state has already been reset.
184 Tab(usize),
185 }
186
187 impl PickerState {
188 /// Apply a key press to the state for a list of `len` rows shown in
189 /// `rows` rows, and say what happened. Keys: `↑`/`↓` (wrapping), `Home`,
190 /// `End`, `PgUp`, `PgDn`, `Enter`, `Space`, `Esc`. Releases and other
191 /// keys are [`PickerOutcome::Ignored`]; so is every key on an empty list.
192 /// Modified keys belong to the host. Held keys repeat navigation only.
193 pub fn handle_key(&mut self, key: KeyEvent, len: usize, rows: u16) -> PickerOutcome {
194 if key.kind == KeyEventKind::Release
195 || len == 0
196 || !key.modifiers.is_empty()
197 || (key.kind == KeyEventKind::Repeat
198 && matches!(key.code, KeyCode::Enter | KeyCode::Esc | KeyCode::Char(' ')))
199 {
200 return PickerOutcome::Ignored;
201 }
202 self.scroll_into_view(len, rows);
203 match key.code {
204 KeyCode::Up => self.prev(len),
205 KeyCode::Down => self.next(len),
206 KeyCode::Home => self.home(),
207 KeyCode::End => self.end(len),
208 KeyCode::PageUp => self.page(len, rows, false),
209 KeyCode::PageDown => self.page(len, rows, true),
210 KeyCode::Enter => return PickerOutcome::Chose(self.selected),
211 KeyCode::Char(' ') => return PickerOutcome::Toggled(self.selected),
212 KeyCode::Esc => return PickerOutcome::Cancelled,
213 _ => return PickerOutcome::Ignored,
214 }
215 self.scroll_into_view(len, rows);
216 PickerOutcome::Moved
217 }
218 }
219
220 impl PickerState {
221 /// Back to the first row, as after the query or the tab changed.
222 pub fn reset(&mut self) {
223 *self = Self::default();
224 }
225
226 /// [`PickerState::handle_key`] for a picker with a query line, tabs,
227 /// filtered rows or disabled rows: it asks `picker` how many rows are
228 /// shown, which are disabled, and whether a query is being typed.
229 ///
230 /// Rows are the *shown* rows: with a filter, `Chose(n)` is the `n`th
231 /// match, and [`Picker::item_index`] gives the item it stands for.
232 ///
233 /// Keys, with a query line (typing): `↑`/`↓`, `PgUp`/`PgDn`, `Enter`,
234 /// `Esc`, `Tab`/`Shift+Tab` (tabs) and `Ctrl+Space` (toggle) are the
235 /// picker's; every other key press is [`PickerOutcome::Query`], for the
236 /// host's text editor, so letters, `Space`, `Backspace`, `←`/`→`,
237 /// `Home` and `End` are never eaten. Without a query line, `Home`/`End`
238 /// and `←`/`→` (tabs) also move, and `Space` toggles. Navigation skips
239 /// disabled rows, and `Enter`/`Space` on one are ignored. `Esc` cancels
240 /// even with nothing shown. Held keys repeat editing/navigation only;
241 /// modified activation/navigation and releases are ignored.
242 ///
243 /// When the query or the tab changes, rebuild the [`PickerMatches`] and
244 /// [`PickerState::reset`] (a tab change resets for you).
245 pub fn handle_picker_key(
246 &mut self,
247 key: KeyEvent,
248 picker: &Picker<'_>,
249 rows: u16,
250 ) -> PickerOutcome {
251 if key.kind == KeyEventKind::Release {
252 return PickerOutcome::Ignored;
253 }
254 let allowed_modifiers = match key.code {
255 KeyCode::Tab | KeyCode::BackTab => {
256 key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT
257 }
258 KeyCode::Char(' ') => {
259 key.modifiers.is_empty() || key.modifiers == KeyModifiers::CONTROL
260 }
261 KeyCode::Enter
262 | KeyCode::Esc
263 | KeyCode::Up
264 | KeyCode::Down
265 | KeyCode::PageUp
266 | KeyCode::PageDown => key.modifiers.is_empty(),
267 _ => true,
268 };
269 if !allowed_modifiers {
270 return PickerOutcome::Ignored;
271 }
272 let len = picker.shown_len();
273 let typing = picker.query.is_some();
274 if key.kind == KeyEventKind::Repeat
275 && (matches!(key.code, KeyCode::Enter | KeyCode::Esc)
276 || (key.code == KeyCode::Char(' ')
277 && (!typing || key.modifiers == KeyModifiers::CONTROL)))
278 {
279 return PickerOutcome::Ignored;
280 }
281 let enabled = |i: usize| {
282 picker
283 .shown_item(i)
284 .is_none_or(|(item, _)| item.disabled.is_none())
285 };
286 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
287 let tabs = picker.tabs.map_or(0, |t| t.labels.len());
288 let tab = |forward: bool, state: &mut Self| {
289 let active = picker
290 .tabs
291 .map_or(0, |t| t.active.min(tabs.saturating_sub(1)));
292 let next = if forward {
293 (active + 1) % tabs
294 } else {
295 (active + tabs - 1) % tabs
296 };
297 state.reset();
298 PickerOutcome::Tab(next)
299 };
300 if len > 0 {
301 self.selected = self.selected.min(len - 1);
302 }
303 let page = usize::from(rows.max(1));
304 match key.code {
305 KeyCode::Esc => return PickerOutcome::Cancelled,
306 KeyCode::Tab | KeyCode::BackTab if tabs > 1 => {
307 let back =
308 key.code == KeyCode::BackTab || key.modifiers.contains(KeyModifiers::SHIFT);
309 return tab(!back, self);
310 }
311 // No tabs to change: Tab is the host's (focus), never the query's.
312 KeyCode::Tab | KeyCode::BackTab => return PickerOutcome::Ignored,
313 KeyCode::Left | KeyCode::Right if !typing && tabs > 1 => {
314 if !key.modifiers.is_empty() {
315 return PickerOutcome::Ignored;
316 }
317 return tab(key.code == KeyCode::Right, self);
318 }
319 KeyCode::Enter => {
320 return if len > 0 && enabled(self.selected) {
321 PickerOutcome::Chose(self.selected)
322 } else {
323 PickerOutcome::Ignored
324 };
325 }
326 KeyCode::Char(' ') if ctrl || !typing => {
327 return if len > 0 && enabled(self.selected) {
328 PickerOutcome::Toggled(self.selected)
329 } else {
330 PickerOutcome::Ignored
331 };
332 }
333 KeyCode::Up | KeyCode::Down | KeyCode::PageUp | KeyCode::PageDown if len > 0 => {
334 self.selected = match key.code {
335 KeyCode::Up => step_to(self.selected, len, false, enabled),
336 KeyCode::Down => step_to(self.selected, len, true, enabled),
337 KeyCode::PageUp => {
338 settle_at(self.selected.saturating_sub(page), len, false, enabled)
339 }
340 _ => settle_at((self.selected + page).min(len - 1), len, true, enabled),
341 };
342 }
343 KeyCode::Home if !typing && len > 0 && key.modifiers.is_empty() => {
344 self.selected = settle_at(0, len, true, enabled)
345 }
346 KeyCode::End if !typing && len > 0 && key.modifiers.is_empty() => {
347 self.selected = settle_at(len - 1, len, false, enabled);
348 }
349 _ => {
350 return if typing && !matches!(key.code, KeyCode::Up | KeyCode::Down) {
351 PickerOutcome::Query
352 } else {
353 PickerOutcome::Ignored
354 };
355 }
356 }
357 self.scroll_into_view(len, rows);
358 PickerOutcome::Moved
359 }
360 }
361
362 /// The words a [`Picker`] shows itself. The kit owns no copy beyond this
363 /// English default: a host fills one per locale and passes it with
364 /// [`Picker::words`].
365 #[derive(Clone, Debug, PartialEq, Eq)]
366 pub struct PickerWords {
367 /// The query line while it is empty.
368 pub placeholder: Cow<'static, str>,
369 /// Before the quoted query when nothing matches: `No matches for "x"`.
370 pub no_matches: Cow<'static, str>,
371 /// The one next step when nothing matches.
372 pub no_matches_action: Cow<'static, str>,
373 /// When there is nothing to choose from at all.
374 pub empty: Cow<'static, str>,
375 }
376
377 static ENGLISH: PickerWords = PickerWords {
378 placeholder: Cow::Borrowed("Type to filter"),
379 no_matches: Cow::Borrowed("No matches for"),
380 no_matches_action: Cow::Borrowed("Edit the search to see more"),
381 empty: Cow::Borrowed("Nothing to choose from"),
382 };
383
384 impl Default for PickerWords {
385 fn default() -> Self {
386 ENGLISH.clone()
387 }
388 }
389
390 /// The query line's text and where the cursor is. The caller owns editing:
391 /// it passes what the line holds, the picker paints it and filters nothing
392 /// by itself (see [`PickerMatches::rank`]).
393 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
394 pub struct PickerQuery<'a> {
395 pub text: &'a str,
396 /// The cursor's column in display cells from the start of `text`.
397 pub cursor: usize,
398 }
399
400 impl<'a> PickerQuery<'a> {
401 #[must_use]
402 pub const fn new(text: &'a str, cursor: usize) -> Self {
403 Self { text, cursor }
404 }
405 }
406
407 /// Categories across the top. Each item belongs to one tab (or to every
408 /// tab); `Tab` and `Shift+Tab` (`←`/`→` without a query line) move between
409 /// them through [`PickerOutcome::Tab`].
410 #[derive(Clone, Copy, Debug)]
411 pub struct PickerTabs<'a> {
412 pub labels: &'a [Cow<'static, str>],
413 pub active: usize,
414 /// The tab each item belongs to, by item index; `None`, or an index past
415 /// the end, puts the item in every tab.
416 pub of: &'a [Option<usize>],
417 /// A tab that shows every item (an "All" tab), if there is one.
418 pub all: Option<usize>,
419 }
420
421 impl<'a> PickerTabs<'a> {
422 #[must_use]
423 pub const fn new(labels: &'a [Cow<'static, str>], active: usize) -> Self {
424 Self {
425 labels,
426 active,
427 of: &[],
428 all: None,
429 }
430 }
431
432 /// Which tab each item belongs to.
433 #[must_use]
434 pub const fn assign(mut self, of: &'a [Option<usize>]) -> Self {
435 self.of = of;
436 self
437 }
438
439 /// The tab that shows every item.
440 #[must_use]
441 pub const fn all(mut self, index: usize) -> Self {
442 self.all = Some(index);
443 self
444 }
445
446 fn shows(&self, item: usize) -> bool {
447 self.all == Some(self.active)
448 || self
449 .of
450 .get(item)
451 .copied()
452 .flatten()
453 .is_none_or(|tab| tab == self.active)
454 }
455 }
456
457 /// A caller-painted pane beside the list: what the highlighted item is.
458 /// Paint into `area` through `theme`; `item` is the highlighted item and
459 /// `index` its place in the picker's `items`. Any
460 /// `Fn(Rect, &mut Buffer, &Theme, &PickerItem, usize)` is one.
461 pub trait PickerPreview {
462 fn paint_preview(
463 &self,
464 area: Rect,
465 buf: &mut Buffer,
466 theme: &Theme,
467 item: &PickerItem,
468 index: usize,
469 );
470 }
471
472 impl<F> PickerPreview for F
473 where
474 F: Fn(Rect, &mut Buffer, &Theme, &PickerItem, usize),
475 {
476 fn paint_preview(
477 &self,
478 area: Rect,
479 buf: &mut Buffer,
480 theme: &Theme,
481 item: &PickerItem,
482 index: usize,
483 ) {
484 self(area, buf, theme, item, index);
485 }
486 }
487
488 /// One item that passed the filter.
489 #[derive(Clone, Debug, PartialEq, Eq)]
490 pub struct PickerMatch {
491 /// The item's index in the picker's `items`.
492 pub index: usize,
493 pub score: i32,
494 /// `char` indices of the query in the item's display-safe label, to
495 /// highlight; empty when the query matched the detail instead, or there
496 /// is no query.
497 pub positions: Vec<usize>,
498 }
499
500 /// A detail match ranks below every label match.
501 const DETAIL_PENALTY: i32 = 10_000;
502
503 /// The items a picker shows: the ones in the active tab that match the
504 /// query, best first. Build it when the query or the tab changes, keep it
505 /// across frames, and hand it to [`Picker::matches`] and to
506 /// [`PickerState::handle_picker_key`] (through the picker).
507 #[derive(Clone, Debug, Default, PartialEq, Eq)]
508 pub struct PickerMatches {
509 rows: Vec<PickerMatch>,
510 }
511
512 impl PickerMatches {
513 /// Filter and rank `items` by `query` (a fuzzy subsequence match on the
514 /// label, then on the detail), keeping the active tab's items. An empty
515 /// query keeps every item of the tab in its original order.
516 #[must_use]
517 pub fn rank(items: &[PickerItem], query: &str, tabs: Option<&PickerTabs<'_>>) -> Self {
518 let mut rows: Vec<PickerMatch> = items
519 .iter()
520 .enumerate()
521 .filter(|(index, _)| tabs.is_none_or(|t| t.shows(*index)))
522 .filter_map(|(index, item)| {
523 let label = text::display_safe(&item.label);
524 if let Some(m) = fuzzy_score(query, &label) {
525 return Some(PickerMatch {
526 index,
527 score: m.score,
528 positions: m.positions,
529 });
530 }
531 let detail = text::display_safe(item.detail.as_deref()?);
532 fuzzy_score(query, &detail).map(|m| PickerMatch {
533 index,
534 score: m.score - DETAIL_PENALTY,
535 positions: Vec::new(),
536 })
537 })
538 .collect();
539 rows.sort_by_key(|m| (std::cmp::Reverse(m.score), m.index));
540 Self { rows }
541 }
542
543 #[must_use]
544 pub fn len(&self) -> usize {
545 self.rows.len()
546 }
547
548 #[must_use]
549 pub fn is_empty(&self) -> bool {
550 self.rows.is_empty()
551 }
552
553 /// The `shown`th match.
554 #[must_use]
555 pub fn get(&self, shown: usize) -> Option<&PickerMatch> {
556 self.rows.get(shown)
557 }
558
559 pub fn iter(&self) -> impl Iterator<Item = &PickerMatch> {
560 self.rows.iter()
561 }
562 }
563
564 /// The narrowest picker that shows its preview pane; below this the pane is
565 /// left out and the list takes the width.
566 pub const PICKER_PREVIEW_MIN_WIDTH: u16 = 56;
567 /// The narrowest the preview pane is drawn, and the narrowest list beside it.
568 const PREVIEW_MIN: u16 = 20;
569
570 /// Where a [`Picker`] puts its parts in an area.
571 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
572 pub struct PickerLayout {
573 pub tabs: Option<Rect>,
574 pub query: Option<Rect>,
575 pub list: Rect,
576 /// The rule between the list and the preview, one column wide.
577 pub separator: Option<Rect>,
578 pub preview: Option<Rect>,
579 }
580
581 /// Spans plus the cells they cover.
582 #[derive(Default)]
583 struct Row {
584 spans: Vec<Span<'static>>,
585 used: usize,
586 }
587
588 impl Row {
589 fn push(&mut self, s: String, style: Style) {
590 self.used += text::width(&s);
591 self.spans.push(Span::styled(s, style));
592 }
593 }
594
595 /// `label` cut to `width` cells and padded, as runs that are or are not on a
596 /// matched position. Cuts and pads exactly as [`text::pad`] does.
597 fn label_runs(
598 label: &str,
599 positions: &[usize],
600 width: usize,
601 ascii: bool,
602 base: Style,
603 hit: Style,
604 ) -> Vec<(String, Style)> {
605 let fitted = text::truncate(label, width, ascii);
606 // The ellipsis a cut adds is not part of the label.
607 let common = fitted
608 .chars()
609 .zip(label.chars())
610 .take_while(|(a, b)| a == b)
611 .count();
612 let mut runs: Vec<(String, Style)> = Vec::new();
613 let mut at = 0;
614 for g in fitted.graphemes(true) {
615 let n = g.chars().count();
616 let hot = at < common && (at..at + n).any(|c| positions.binary_search(&c).is_ok());
617 at += n;
618 let style = if hot { hit } else { base };
619 match runs.last_mut() {
620 Some((s, st)) if *st == style => s.push_str(g),
621 _ => runs.push((g.to_owned(), style)),
622 }
623 }
624 let gap = width.saturating_sub(text::width(&fitted));
625 if gap > 0 {
626 match runs.last_mut() {
627 Some((s, st)) if *st == base => s.push_str(&" ".repeat(gap)),
628 _ => runs.push((" ".repeat(gap), base)),
629 }
630 }
631 runs
632 }
633
634 /// A list of [`PickerItem`]s, optionally with a query line, tabs, a filter
635 /// and a preview pane.
636 ///
637 /// ```text
638 /// ● All Models Themes
639 /// › shore▏ 2/17
640 /// ▸ Shoreline Graphite ground │ Shoreline
641 /// Shoreline light Paper ground │ ● Working
642 /// ```
643 ///
644 /// Alone it is the plain list it always was. Add pieces as the picker needs
645 /// them: [`Picker::query`] (the caller edits the text, the picker paints it
646 /// and highlights the match), [`Picker::matches`] (what [`PickerMatches`]
647 /// kept), [`Picker::tabs`], [`Picker::preview`] (beside the list at
648 /// [`PICKER_PREVIEW_MIN_WIDTH`] columns and up, dropped below) and
649 /// [`Picker::words`].
650 ///
651 /// Ratatui's stateful adapter persists selection and the viewport offset in
652 /// your own [`PickerState`], overriding the constructor's state snapshot:
653 ///
654 /// ```no_run
655 /// use codewhale_ratatui::{Paint, Picker, PickerItem, PickerState, Theme};
656 /// # fn draw(frame: &mut ratatui::Frame<'_>, state: &mut PickerState) {
657 /// let theme = Theme::detect().tui();
658 /// let items = [PickerItem::new("Underwater"), PickerItem::new("Dracula")];
659 /// let picker = Picker::new(&items, PickerState::default()).query("", 0);
660 /// frame.render_stateful_widget(picker.themed(&theme), frame.area(), state);
661 /// # }
662 /// ```
663 #[derive(Clone, Copy)]
664 pub struct Picker<'a> {
665 pub items: &'a [PickerItem],
666 pub state: PickerState,
667 query: Option<PickerQuery<'a>>,
668 tabs: Option<PickerTabs<'a>>,
669 matches: Option<&'a PickerMatches>,
670 preview: Option<&'a dyn PickerPreview>,
671 preview_min: u16,
672 words: Option<&'a PickerWords>,
673 }
674
675 impl std::fmt::Debug for Picker<'_> {
676 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
677 f.debug_struct("Picker")
678 .field("items", &self.items)
679 .field("state", &self.state)
680 .field("query", &self.query)
681 .field("tabs", &self.tabs)
682 .field("matches", &self.matches)
683 .field("preview", &self.preview.is_some())
684 .finish_non_exhaustive()
685 }
686 }
687
688 impl<'a> Picker<'a> {
689 #[must_use]
690 pub const fn new(items: &'a [PickerItem], state: PickerState) -> Self {
691 Self {
692 items,
693 state,
694 query: None,
695 tabs: None,
696 matches: None,
697 preview: None,
698 preview_min: PICKER_PREVIEW_MIN_WIDTH,
699 words: None,
700 }
701 }
702
703 /// Show a query line holding `text` with the cursor at `cursor` (display
704 /// cells from the start). The caller edits; filtering is
705 /// [`PickerMatches::rank`].
706 #[must_use]
707 pub const fn query(mut self, text: &'a str, cursor: usize) -> Self {
708 self.query = Some(PickerQuery::new(text, cursor));
709 self
710 }
711
712 /// Show tabs across the top.
713 #[must_use]
714 pub const fn tabs(mut self, tabs: PickerTabs<'a>) -> Self {
715 self.tabs = Some(tabs);
716 self
717 }
718
719 /// Show only these items, in this order, with their matches highlighted.
720 /// Selection and scrolling then index the matches.
721 #[must_use]
722 pub const fn matches(mut self, matches: &'a PickerMatches) -> Self {
723 self.matches = Some(matches);
724 self
725 }
726
727 /// Show a pane beside the list for the highlighted item.
728 #[must_use]
729 pub const fn preview(mut self, preview: &'a dyn PickerPreview) -> Self {
730 self.preview = Some(preview);
731 self
732 }
733
734 /// The width from which the preview pane shows (default
735 /// [`PICKER_PREVIEW_MIN_WIDTH`]).
736 #[must_use]
737 pub const fn preview_min_width(mut self, width: u16) -> Self {
738 self.preview_min = width;
739 self
740 }
741
742 /// Words for the placeholder and the empty states (default English).
743 #[must_use]
744 pub const fn words(mut self, words: &'a PickerWords) -> Self {
745 self.words = Some(words);
746 self
747 }
748
749 fn words_or_default(&self) -> &PickerWords {
750 self.words.unwrap_or(&ENGLISH)
751 }
752
753 /// How many rows are shown: the matches, or every item.
754 #[must_use]
755 pub fn shown_len(&self) -> usize {
756 self.matches.map_or(self.items.len(), PickerMatches::len)
757 }
758
759 /// The index in `items` of the `shown`th row.
760 #[must_use]
761 pub fn item_index(&self, shown: usize) -> Option<usize> {
762 match self.matches {
763 Some(m) => m.get(shown).map(|m| m.index),
764 None => (shown < self.items.len()).then_some(shown),
765 }
766 }
767
768 /// The `shown`th row's item and the label positions to highlight.
769 fn shown_item(&self, shown: usize) -> Option<(&'a PickerItem, &'a [usize])> {
770 match self.matches {
771 Some(m) => {
772 let m = m.get(shown)?;
773 Some((self.items.get(m.index)?, m.positions.as_slice()))
774 }
775 None => Some((self.items.get(shown)?, &[])),
776 }
777 }
778
779 /// Where the parts go in `area`.
780 #[must_use]
781 pub fn layout(&self, area: Rect) -> PickerLayout {
782 let tabs = self.tabs.is_some_and(|t| !t.labels.is_empty());
783 let query = self.query.is_some();
784 // A row for the list outranks the tabs, and the tabs the query.
785 let (tabs, query) = if area.height > u16::from(tabs) + u16::from(query) {
786 (tabs, query)
787 } else if area.height > u16::from(query) {
788 (false, query)
789 } else {
790 (false, false)
791 };
792 let mut y = area.y;
793 let mut row = |wanted: bool| {
794 wanted.then(|| {
795 let r = Rect {
796 y,
797 height: 1,
798 ..area
799 };
800 y += 1;
801 r
802 })
803 };
804 let tabs = row(tabs);
805 let query = row(query);
806 let body = Rect {
807 y,
808 height: area.bottom().saturating_sub(y),
809 ..area
810 };
811 let pane =
812 (self.preview.is_some() && self.shown_len() > 0 && body.width >= self.preview_min)
813 .then(|| {
814 let pane = (body.width * 2 / 5).max(PREVIEW_MIN);
815 (body.width.saturating_sub(pane + 3), pane)
816 })
817 .filter(|&(list, _)| list >= PREVIEW_MIN);
818 match pane {
819 Some((list_w, pane_w)) => PickerLayout {
820 tabs,
821 query,
822 list: Rect {
823 width: list_w,
824 ..body
825 },
826 separator: Some(Rect {
827 x: body.x + list_w + 1,
828 width: 1,
829 ..body
830 }),
831 preview: Some(Rect {
832 x: body.x + list_w + 3,
833 width: pane_w,
834 ..body
835 }),
836 },
837 None => PickerLayout {
838 tabs,
839 query,
840 list: body,
841 separator: None,
842 preview: None,
843 },
844 }
845 }
846
847 /// How many rows the list shows in `area`: what to pass
848 /// [`PickerState::handle_picker_key`].
849 #[must_use]
850 pub fn list_rows(&self, area: Rect) -> u16 {
851 self.layout(area).list.height
852 }
853
854 /// Where the terminal cursor belongs when the query line has focus, for
855 /// `Frame::set_cursor_position`. It follows the text when a long query
856 /// is cut to its end.
857 #[must_use]
858 pub fn cursor(&self, area: Rect) -> Option<ratatui::layout::Position> {
859 let rect = self.layout(area).query?;
860 let view = self.query_view(self.query?, rect.width, false)?;
861 let col = u16::try_from(view.cursor).unwrap_or(u16::MAX);
862 Some(ratatui::layout::Position::new(
863 (rect.x + QUERY_INDENT)
864 .saturating_add(col)
865 .min(rect.right().saturating_sub(1)),
866 rect.y,
867 ))
868 }
869
870 /// The shown row under a click at `(column, row)` when the picker was
871 /// painted in `area`, for hosts that take mouse input. Without a filter
872 /// that is the item's index; otherwise pass it to
873 /// [`Picker::item_index`].
874 #[must_use]
875 pub fn row_at(&self, area: Rect, column: u16, row: u16) -> Option<usize> {
876 let list = self.layout(area).list;
877 let shown = self.shown_len();
878 let inside =
879 column >= list.x && column < list.right() && row >= list.y && row < list.bottom();
880 let reserved = if shown > usize::from(list.height) && list.width > 2 {
881 2
882 } else {
883 0
884 };
885 if !inside || column >= list.right().saturating_sub(reserved) {
886 return None;
887 }
888 let offset = self.state.visible_offset(shown, usize::from(list.height));
889 let index = offset + usize::from(row - list.y);
890 (index < shown).then_some(index)
891 }
892
893 fn label_column(&self) -> usize {
894 (0..self.shown_len())
895 .filter_map(|at| self.shown_item(at))
896 .map(|(item, _)| text::width(&text::display_safe(&item.label)))
897 .max()
898 .unwrap_or(0)
899 }
900
901 fn row(
902 &self,
903 selected: bool,
904 item: &PickerItem,
905 positions: &[usize],
906 width: usize,
907 label_col: usize,
908 theme: &Theme,
909 ) -> Line<'static> {
910 let ascii = theme.ascii();
911 let disabled = item.disabled.is_some();
912
913 let mut row = Row::default();
914
915 let marker = glyphs::pick(glyphs::selection_marker(selected), ascii);
916 row.push(format!("{marker} "), theme.fg(Role::Primary));
917 if let Some(key) = item.key {
918 row.push(format!("{key} "), theme.fg(Role::Muted));
919 }
920 if let Some(checked) = item.checked {
921 let (mark, role) = match (checked, ascii) {
922 (true, false) => (glyphs::CURRENT, Role::Foreground),
923 (false, false) => (glyphs::AVAILABLE, Role::Muted),
924 (true, true) => ("[x]", Role::Foreground),
925 (false, true) => ("[ ]", Role::Muted),
926 };
927 row.push(format!("{mark} "), theme.fg(role));
928 }
929
930 let label_style = if disabled {
931 theme.fg(Role::Muted).add_modifier(Modifier::DIM)
932 } else if selected {
933 theme.fg(Role::Foreground).add_modifier(Modifier::BOLD)
934 } else {
935 theme.fg(Role::Foreground)
936 };
937 let label = text::display_safe(&item.label);
938 let label_w = label_col.min(width.saturating_sub(row.used));
939 if positions.is_empty() {
940 row.push(text::pad(&label, label_w, ascii), label_style);
941 } else {
942 // Matched characters are underlined as well as colored, so the
943 // match shows without color.
944 let hit = if disabled {
945 label_style.add_modifier(Modifier::UNDERLINED)
946 } else {
947 label_style
948 .patch(theme.fg(Role::Primary))
949 .add_modifier(Modifier::UNDERLINED)
950 };
951 for (run, style) in label_runs(&label, positions, label_w, ascii, label_style, hit) {
952 row.push(run, style);
953 }
954 }
955
956 let detail = item.disabled.as_ref().or(item.detail.as_ref());
957 if let Some(detail) = detail {
958 let room = width.saturating_sub(row.used + 2);
959 if room >= 4 {
960 let detail = text::display_safe(detail);
961 row.push(" ".into(), theme.fg(Role::Muted));
962 row.push(
963 text::truncate_words(&detail, room, ascii).into_owned(),
964 theme.fg(Role::Muted),
965 );
966 }
967 }
968
969 let spans = row.spans;
970 let mut line = Line::from(spans);
971 if selected {
972 line = line.style(theme.bg(Role::Selected));
973 }
974 line
975 }
976
977 fn paint_rows(&self, list: Rect, buf: &mut Buffer, theme: &Theme) {
978 let shown = self.shown_len();
979 let rows = usize::from(list.height);
980 let overflow = shown > rows;
981 // The scrollbar takes the last column and keeps one cell of air.
982 let body = Rect {
983 width: list
984 .width
985 .saturating_sub(2 * u16::from(overflow && list.width > 2)),
986 ..list
987 };
988 let offset = self.state.visible_offset(shown, rows);
989 let selected = self.state.selected.min(shown.saturating_sub(1));
990 let label_col = self.label_column();
991 for (row, at) in (offset..shown).take(rows).enumerate() {
992 let Some((item, positions)) = self.shown_item(at) else {
993 continue;
994 };
995 let rect = Rect {
996 y: body.y + row as u16,
997 height: 1,
998 ..body
999 };
1000 if at == selected {
1001 buf.set_style(rect, theme.bg(Role::Selected));
1002 }
1003 self.row(
1004 at == selected,
1005 item,
1006 positions,
1007 usize::from(body.width),
1008 label_col,
1009 theme,
1010 )
1011 .render(rect, buf);
1012 }
1013 if overflow && list.width > 2 {
1014 paint_scrollbar(list, buf, theme, shown, offset, rows);
1015 }
1016 }
1017
1018 fn paint_empty(&self, list: Rect, buf: &mut Buffer, theme: &Theme) {
1019 let words = self.words_or_default();
1020 let query = self
1021 .query
1022 .map(|q| text::display_safe(q.text).trim().to_owned())
1023 .unwrap_or_default();
1024 let empty = if query.is_empty() {
1025 EmptyState::new(words.empty.as_ref())
1026 } else {
1027 let (open, close) = if theme.ascii() {
1028 ("\"", "\"")
1029 } else {
1030 ("\u{201c}", "\u{201d}")
1031 };
1032 let query = text::truncate(&query, 24, theme.ascii());
1033 EmptyState::new(format!("{} {open}{query}{close}", words.no_matches))
1034 .action(words.no_matches_action.as_ref())
1035 };
1036 empty.paint(list, buf, theme);
1037 }
1038
1039 fn paint_tabs(&self, tabs: PickerTabs<'_>, rect: Rect, buf: &mut Buffer, theme: &Theme) {
1040 const GAP: usize = 2;
1041 let ascii = theme.ascii();
1042 let n = tabs.labels.len();
1043 if n == 0 {
1044 return;
1045 }
1046 let active = tabs.active.min(n - 1);
1047 let mark = glyphs::pick(glyphs::CURRENT, ascii);
1048 let labels: Vec<String> = tabs
1049 .labels
1050 .iter()
1051 .map(|l| text::display_safe(l).into_owned())
1052 .collect();
1053 let cost = |i: usize| {
1054 text::width(&labels[i])
1055 + if i == active {
1056 text::width(mark) + 1
1057 } else {
1058 0
1059 }
1060 };
1061 let room = usize::from(rect.width);
1062 // Cells the `…` on a hidden side takes, with its space.
1063 let more = |lo: usize, hi: usize| 2 * usize::from(lo > 0) + 2 * usize::from(hi + 1 < n);
1064 let (mut lo, mut hi) = (active, active);
1065 let mut used = cost(active);
1066 loop {
1067 if hi + 1 < n && used + GAP + cost(hi + 1) + more(lo, hi + 1) <= room {
1068 used += GAP + cost(hi + 1);
1069 hi += 1;
1070 } else if lo > 0 && used + GAP + cost(lo - 1) + more(lo - 1, hi) <= room {
1071 used += GAP + cost(lo - 1);
1072 lo -= 1;
1073 } else {
1074 break;
1075 }
1076 }
1077 let mut spans: Vec<Span<'static>> = Vec::new();
1078 let ellipsis = glyphs::pick(glyphs::ELLIPSIS, ascii);
1079 if lo > 0 {
1080 spans.push(Span::styled(format!("{ellipsis} "), theme.fg(Role::Hint)));
1081 }
1082 for (i, label) in labels.iter().enumerate().take(hi + 1).skip(lo) {
1083 if i > lo {
1084 spans.push(Span::raw(" ".repeat(GAP)));
1085 }
1086 if i == active {
1087 spans.push(Span::styled(format!("{mark} "), theme.fg(Role::Primary)));
1088 let room = room.saturating_sub(text::width(mark) + 1);
1089 spans.push(Span::styled(
1090 text::truncate(label, room, ascii).into_owned(),
1091 theme.fg(Role::Foreground).add_modifier(Modifier::BOLD),
1092 ));
1093 } else {
1094 spans.push(Span::styled(label.clone(), theme.fg(Role::Muted)));
1095 }
1096 }
1097 if hi + 1 < n {
1098 spans.push(Span::styled(format!(" {ellipsis}"), theme.fg(Role::Hint)));
1099 }
1100 Line::from(spans).render(rect, buf);
1101 }
1102
1103 /// What the query line shows in `width` cells: the text (or the
1104 /// placeholder), the cursor's column in it, and the count at the right.
1105 fn query_view(&self, query: PickerQuery<'_>, width: u16, ascii: bool) -> Option<QueryView> {
1106 let room = usize::from(width.checked_sub(QUERY_INDENT)?);
1107 let typed = text::display_safe(query.text);
1108
1109 // `3/17`, right-aligned, while there is something typed.
1110 let count = (!typed.is_empty() && self.shown_len() > 0)
1111 .then(|| format!("{}/{}", self.shown_len(), self.items.len()))
1112 .filter(|c| text::width(c) + 2 < room);
1113 let room = room.saturating_sub(count.as_ref().map_or(0, |c| text::width(c) + 2));
1114
1115 let mut cursor = query.cursor.min(text::width(&typed));
1116 let (shown, hint) = if typed.is_empty() {
1117 let words = self.words_or_default();
1118 (
1119 text::truncate(&words.placeholder, room, ascii).into_owned(),
1120 true,
1121 )
1122 } else if text::width(&typed) <= room {
1123 (typed.into_owned(), false)
1124 } else {
1125 // Keep the end, where the cursor usually is.
1126 let ellipsis = glyphs::pick(glyphs::ELLIPSIS, ascii);
1127 let budget = room.saturating_sub(text::width(ellipsis));
1128 let mut tail: Vec<&str> = Vec::new();
1129 let mut used = 0;
1130 for g in typed.graphemes(true).rev() {
1131 let w = text::width(g);
1132 if used + w > budget {
1133 break;
1134 }
1135 used += w;
1136 tail.push(g);
1137 }
1138 tail.reverse();
1139 let skipped = text::width(&typed) - used;
1140 cursor = cursor.saturating_sub(skipped) + text::width(ellipsis);
1141 (format!("{ellipsis}{}", tail.concat()), false)
1142 };
1143 Some(QueryView {
1144 shown,
1145 hint,
1146 cursor,
1147 room,
1148 count,
1149 })
1150 }
1151
1152 fn paint_query(&self, query: PickerQuery<'_>, rect: Rect, buf: &mut Buffer, theme: &Theme) {
1153 let ascii = theme.ascii();
1154 // `/` where ASCII-safe: `>` would read as the selection marker below.
1155 let prompt = if ascii { "/" } else { "\u{203a}" };
1156 buf.set_string(rect.x, rect.y, prompt, theme.fg(Role::Primary));
1157 let Some(view) = self.query_view(query, rect.width, ascii) else {
1158 return;
1159 };
1160 let x0 = rect.x + QUERY_INDENT;
1161 let style = if view.hint {
1162 theme.fg(Role::Hint)
1163 } else {
1164 theme.fg(Role::Foreground)
1165 };
1166 buf.set_stringn(x0, rect.y, &view.shown, view.room, style);
1167 if let Some(count) = &view.count {
1168 let x = rect
1169 .right()
1170 .saturating_sub(u16::try_from(text::width(count)).unwrap_or(0));
1171 buf.set_string(x, rect.y, count, theme.fg(Role::Hint));
1172 }
1173 // A reversed cell at the cursor shows it in every profile.
1174 if let Ok(col) = u16::try_from(view.cursor)
1175 && view.cursor <= view.room
1176 {
1177 let at = x0 + col;
1178 if at < rect.right() {
1179 buf[(at, rect.y)].modifier.insert(Modifier::REVERSED);
1180 }
1181 }
1182 }
1183 }
1184
1185 /// The query line as it is painted.
1186 struct QueryView {
1187 shown: String,
1188 /// `shown` is the placeholder.
1189 hint: bool,
1190 /// The cursor's column in `shown`.
1191 cursor: usize,
1192 /// Cells left for `shown`, before the count.
1193 room: usize,
1194 count: Option<String>,
1195 }
1196
1197 /// The query line's text starts after `› `.
1198 const QUERY_INDENT: u16 = 2;
1199
1200 impl Paint for Picker<'_> {
1201 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
1202 let area = area.intersection(buf.area);
1203 if area.is_empty() {
1204 return;
1205 }
1206 let layout = self.layout(area);
1207 if let (Some(rect), Some(tabs)) = (layout.tabs, self.tabs) {
1208 self.paint_tabs(tabs, rect, buf, theme);
1209 }
1210 if let (Some(rect), Some(query)) = (layout.query, self.query) {
1211 self.paint_query(query, rect, buf, theme);
1212 }
1213 if layout.list.is_empty() {
1214 return;
1215 }
1216 let shown = self.shown_len();
1217 if shown == 0 {
1218 self.paint_empty(layout.list, buf, theme);
1219 return;
1220 }
1221 self.paint_rows(layout.list, buf, theme);
1222 if let (Some(rule), Some(pane), Some(preview)) =
1223 (layout.separator, layout.preview, self.preview)
1224 {
1225 let bar = glyphs::pick("\u{2502}", theme.ascii());
1226 for y in rule.y..rule.bottom() {
1227 buf.set_string(rule.x, y, bar, theme.fg(Role::Border));
1228 }
1229 let at = self.state.selected.min(shown - 1);
1230 if let (Some((item, _)), Some(index)) = (self.shown_item(at), self.item_index(at)) {
1231 preview.paint_preview(pane, buf, theme, item, index);
1232 }
1233 }
1234 }
1235
1236 fn height(&self, _width: u16, _theme: &Theme) -> u16 {
1237 let head = u16::from(self.tabs.is_some_and(|t| !t.labels.is_empty()))
1238 + u16::from(self.query.is_some());
1239 let body = match self.shown_len() {
1240 0 => 3,
1241 n => u16::try_from(n).unwrap_or(u16::MAX),
1242 };
1243 head.saturating_add(body)
1244 }
1245 }
1246
1247 /// Render with persistent host state instead of the constructor's snapshot.
1248 /// Query rows, tabs and clipping are included in the stored scroll offset.
1249 impl ratatui::widgets::StatefulWidget for crate::Themed<'_, Picker<'_>> {
1250 type State = PickerState;
1251 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
1252 ratatui::widgets::StatefulWidget::render(&self, area, buf, state);
1253 }
1254 }
1255
1256 impl ratatui::widgets::StatefulWidget for &crate::Themed<'_, Picker<'_>> {
1257 type State = PickerState;
1258 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
1259 let area = area.intersection(buf.area);
1260 if area.is_empty() {
1261 return;
1262 }
1263 let mut view = Picker {
1264 items: self.component.items,
1265 state: *state,
1266 query: self.component.query,
1267 tabs: self.component.tabs,
1268 matches: self.component.matches,
1269 preview: self.component.preview,
1270 preview_min: self.component.preview_min,
1271 words: self.component.words,
1272 };
1273 let viewport = view.layout(area).list;
1274 if !viewport.is_empty() {
1275 state.scroll_into_view(view.shown_len(), viewport.height);
1276 view.state = *state;
1277 }
1278 view.paint(area, buf, self.theme);
1279 }
1280 }
1281
1282 #[cfg(test)]
1283 mod tests {
1284 use super::*;
1285
1286 #[test]
1287 fn clicks_map_to_the_scrolled_row() {
1288 let items: Vec<PickerItem> = (0..10)
1289 .map(|i| PickerItem::new(format!("Item {i}")))
1290 .collect();
1291 let picker = Picker::new(&items, PickerState::new(9));
1292 let area = Rect::new(2, 3, 20, 4);
1293 assert_eq!(picker.row_at(area, 5, 3), Some(6));
1294 assert_eq!(picker.row_at(area, 5, 6), Some(9));
1295 assert_eq!(picker.row_at(area, 1, 3), None);
1296 assert_eq!(picker.row_at(area, 5, 7), None);
1297 }
1298
1299 /// A disabled row says why, in place of its detail, and recedes.
1300 #[test]
1301 fn disabled_rows_say_why() {
1302 use crate::testing::{Profile, render, text};
1303 let items = [
1304 PickerItem::new("Workspace").detail("This folder"),
1305 PickerItem::new("Git branch")
1306 .detail("Current branch")
1307 .disabled("Not a git repository"),
1308 ];
1309 let picker = Picker::new(&items, PickerState::new(0));
1310 for profile in Profile::ALL {
1311 let theme = profile.theme();
1312 let buf = render(48, 2, |area, buf| picker.paint(area, buf, &theme));
1313 let shown = text(&buf);
1314 let row = shown.lines().nth(1).unwrap_or_default();
1315 assert!(
1316 row.contains("Not a git repository"),
1317 "{}: {row}",
1318 profile.name()
1319 );
1320 assert!(!row.contains("Current branch"), "{}: {row}", profile.name());
1321 let label = row.find("Git").expect("label drawn");
1322 let cell = &buf[(u16::try_from(label).unwrap_or(0), 1)];
1323 assert!(
1324 cell.modifier.contains(Modifier::DIM),
1325 "{}: disabled label recedes",
1326 profile.name()
1327 );
1328 }
1329 }
1330
1331 #[test]
1332 fn keys_move_choose_toggle_and_cancel() {
1333 let key = |code| KeyEvent::new(code, crossterm::event::KeyModifiers::NONE);
1334 let mut s = PickerState::new(0);
1335 assert_eq!(s.handle_key(key(KeyCode::Up), 3, 2), PickerOutcome::Moved);
1336 assert_eq!(s.selected, 2, "wraps");
1337 assert_eq!(s.offset, 1, "keeps the selection in view");
1338 assert_eq!(s.handle_key(key(KeyCode::Home), 3, 2), PickerOutcome::Moved);
1339 assert_eq!(s.handle_key(key(KeyCode::Down), 3, 2), PickerOutcome::Moved);
1340 assert_eq!(
1341 s.handle_key(key(KeyCode::Enter), 3, 2),
1342 PickerOutcome::Chose(1)
1343 );
1344 assert_eq!(
1345 s.handle_key(key(KeyCode::Char(' ')), 3, 2),
1346 PickerOutcome::Toggled(1)
1347 );
1348 assert_eq!(
1349 s.handle_key(key(KeyCode::Esc), 3, 2),
1350 PickerOutcome::Cancelled
1351 );
1352 assert_eq!(
1353 s.handle_key(key(KeyCode::Char('x')), 3, 2),
1354 PickerOutcome::Ignored
1355 );
1356 let mut release = key(KeyCode::Down);
1357 release.kind = KeyEventKind::Release;
1358 assert_eq!(s.handle_key(release, 3, 2), PickerOutcome::Ignored);
1359 assert_eq!(s.selected, 1, "a release moves nothing");
1360 assert_eq!(
1361 PickerState::default().handle_key(key(KeyCode::Enter), 0, 2),
1362 PickerOutcome::Ignored,
1363 "an empty list chooses nothing"
1364 );
1365 }
1366
1367 #[test]
1368 fn state_wraps_pages_and_keeps_selection_visible() {
1369 let mut s = PickerState::new(0);
1370 s.prev(3);
1371 assert_eq!(s.selected, 2);
1372 s.next(3);
1373 assert_eq!(s.selected, 0);
1374 s.page(20, 5, true);
1375 assert_eq!(s.selected, 5);
1376 assert_eq!(s.visible_offset(20, 4), 2);
1377 s.end(20);
1378 s.scroll_into_view(20, 4);
1379 assert_eq!(s.offset, 16);
1380 s.home();
1381 assert_eq!(s.visible_offset(20, 4), 0);
1382 }
1383 }
1384
1384 lines RUST