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