| 1 | //! A virtualized, searchable list used by every screen. |
| 2 | |
| 3 | use std::time::{Duration, Instant}; |
| 4 | |
| 5 | use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; |
| 6 | use nucleo_matcher::{ |
| 7 | Config as MatcherConfig, Matcher, Utf32Str, |
| 8 | pattern::{CaseMatching, Normalization, Pattern}, |
| 9 | }; |
| 10 | use ratatui::{ |
| 11 | Frame, |
| 12 | layout::{Constraint, Position, Rect}, |
| 13 | style::{Modifier, Style}, |
| 14 | widgets::{Block, Row, Table, TableState}, |
| 15 | }; |
| 16 | |
| 17 | use crate::{action::Action, config::Theme}; |
| 18 | |
| 19 | /// Cursor, scroll offset and search state of a list. |
| 20 | /// |
| 21 | /// Only the visible rows are built when drawing, so lists with tens of |
| 22 | /// thousands of items stay cheap. |
| 23 | #[derive(Debug, Default)] |
| 24 | pub struct ListView { |
| 25 | selected: usize, |
| 26 | offset: usize, |
| 27 | /// Rows visible in the last draw. |
| 28 | height: usize, |
| 29 | /// Where the rows were drawn last, for mouse hit-testing. |
| 30 | rows_area: Rect, |
| 31 | last_click: Option<(usize, Instant)>, |
| 32 | pub search: Search, |
| 33 | } |
| 34 | |
| 35 | /// What a mouse event on a list did. |
| 36 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 37 | pub enum Click { |
| 38 | /// The cursor moved (click or wheel). |
| 39 | Moved, |
| 40 | /// A row was double-clicked. |
| 41 | Activate(usize), |
| 42 | } |
| 43 | |
| 44 | impl ListView { |
| 45 | pub fn selected(&self, len: usize) -> Option<usize> { |
| 46 | (len > 0).then(|| self.selected.min(len - 1)) |
| 47 | } |
| 48 | |
| 49 | pub fn select(&mut self, index: usize, len: usize) { |
| 50 | self.selected = index.min(len.saturating_sub(1)); |
| 51 | } |
| 52 | |
| 53 | /// Resets cursor, scroll and search, e.g. when the content is replaced. |
| 54 | pub fn reset(&mut self) { |
| 55 | self.selected = 0; |
| 56 | self.offset = 0; |
| 57 | self.search.clear(); |
| 58 | } |
| 59 | |
| 60 | /// Handles movement actions. Returns whether the action was one. |
| 61 | pub fn navigate(&mut self, action: Action, len: usize) -> bool { |
| 62 | let page = self.height.max(1); |
| 63 | let current = self.selected.min(len.saturating_sub(1)); |
| 64 | let target = match action { |
| 65 | Action::Up => current.saturating_sub(1), |
| 66 | Action::Down => current + 1, |
| 67 | Action::Top => 0, |
| 68 | Action::Bottom => len.saturating_sub(1), |
| 69 | Action::PageUp => current.saturating_sub(page), |
| 70 | Action::PageDown => current + page, |
| 71 | Action::HalfPageUp => current.saturating_sub(page / 2), |
| 72 | Action::HalfPageDown => current + page / 2, |
| 73 | Action::SearchNext => match self.search.next_after(current) { |
| 74 | Some(index) => index, |
| 75 | None => return true, |
| 76 | }, |
| 77 | Action::SearchPrevious => match self.search.previous_before(current) { |
| 78 | Some(index) => index, |
| 79 | None => return true, |
| 80 | }, |
| 81 | _ => return false, |
| 82 | }; |
| 83 | self.select(target, len); |
| 84 | true |
| 85 | } |
| 86 | |
| 87 | /// Scrolls by `delta` rows (mouse wheel), moving the cursor along. |
| 88 | pub fn scroll(&mut self, delta: isize, len: usize) { |
| 89 | let target = self.selected.saturating_add_signed(delta); |
| 90 | self.select(target, len); |
| 91 | } |
| 92 | |
| 93 | /// Whether a position lies within the rows drawn last. |
| 94 | pub fn contains(&self, position: Position) -> bool { |
| 95 | self.rows_area.contains(position) |
| 96 | } |
| 97 | |
| 98 | /// Handles wheel scrolling and (double) clicks inside the list. |
| 99 | pub fn mouse(&mut self, event: MouseEvent, len: usize) -> Option<Click> { |
| 100 | let position = Position::new(event.column, event.row); |
| 101 | match event.kind { |
| 102 | MouseEventKind::ScrollDown => self.scroll(3, len), |
| 103 | MouseEventKind::ScrollUp => self.scroll(-3, len), |
| 104 | MouseEventKind::Down(MouseButton::Left) => { |
| 105 | let index = self.index_at(position, len)?; |
| 106 | self.select(index, len); |
| 107 | let double = self.last_click.is_some_and(|(last, at)| { |
| 108 | last == index && at.elapsed() < Duration::from_millis(400) |
| 109 | }); |
| 110 | if double { |
| 111 | self.last_click = None; |
| 112 | return Some(Click::Activate(index)); |
| 113 | } |
| 114 | self.last_click = Some((index, Instant::now())); |
| 115 | } |
| 116 | _ => return None, |
| 117 | } |
| 118 | Some(Click::Moved) |
| 119 | } |
| 120 | |
| 121 | /// The item under a terminal position from the last draw. |
| 122 | pub fn index_at(&self, position: Position, len: usize) -> Option<usize> { |
| 123 | if !self.rows_area.contains(position) { |
| 124 | return None; |
| 125 | } |
| 126 | let index = self.offset + usize::from(position.y - self.rows_area.y); |
| 127 | (index < len).then_some(index) |
| 128 | } |
| 129 | |
| 130 | /// Recomputes search matches and jumps to the first one at or after the |
| 131 | /// cursor. |
| 132 | pub fn search<'a>(&mut self, query: &str, items: impl Iterator<Item = &'a str>, len: usize) { |
| 133 | self.search.update(query, items); |
| 134 | let current = self.selected.min(len.saturating_sub(1)); |
| 135 | if let Some(index) = self.search.first_from(current) { |
| 136 | self.select(index, len); |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | /// Draws rows `offset..offset + height` inside `block`. |
| 141 | #[allow(clippy::too_many_arguments)] |
| 142 | pub fn render<'a>( |
| 143 | &mut self, |
| 144 | frame: &mut Frame, |
| 145 | area: Rect, |
| 146 | block: Block<'a>, |
| 147 | header: Option<Row<'a>>, |
| 148 | widths: &[Constraint], |
| 149 | len: usize, |
| 150 | focused: bool, |
| 151 | theme: &Theme, |
| 152 | mut row: impl FnMut(usize) -> Row<'a>, |
| 153 | ) { |
| 154 | let inner = block.inner(area); |
| 155 | let header_height = u16::from(header.is_some()); |
| 156 | self.rows_area = Rect { |
| 157 | y: inner.y + header_height, |
| 158 | height: inner.height.saturating_sub(header_height), |
| 159 | ..inner |
| 160 | }; |
| 161 | self.height = usize::from(self.rows_area.height); |
| 162 | self.selected = self.selected.min(len.saturating_sub(1)); |
| 163 | if self.selected < self.offset { |
| 164 | self.offset = self.selected; |
| 165 | } else if self.height > 0 && self.selected >= self.offset + self.height { |
| 166 | self.offset = self.selected + 1 - self.height; |
| 167 | } |
| 168 | self.offset = self.offset.min(len.saturating_sub(self.height)); |
| 169 | |
| 170 | let end = (self.offset + self.height).min(len); |
| 171 | let rows: Vec<Row> = (self.offset..end) |
| 172 | .map(|index| { |
| 173 | let r = row(index); |
| 174 | if self.search.is_match(index) { |
| 175 | r.style(Style::new().add_modifier(Modifier::UNDERLINED)) |
| 176 | } else { |
| 177 | r |
| 178 | } |
| 179 | }) |
| 180 | .collect(); |
| 181 | let highlight = if focused { |
| 182 | theme.selection() |
| 183 | } else { |
| 184 | theme.selection_inactive() |
| 185 | }; |
| 186 | let mut table = Table::new(rows, widths.to_vec()) |
| 187 | .block(block) |
| 188 | .column_spacing(1) |
| 189 | .row_highlight_style(highlight); |
| 190 | if let Some(header) = header { |
| 191 | table = table.header(header); |
| 192 | } |
| 193 | let mut state = TableState::default(); |
| 194 | if len > 0 { |
| 195 | state.select(Some(self.selected - self.offset)); |
| 196 | } |
| 197 | frame.render_stateful_widget(table, area, &mut state); |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | /// Fuzzy search results over a list, kept in list order so that `n`/`N` |
| 202 | /// walk through them like in a pager. |
| 203 | #[derive(Debug, Default)] |
| 204 | pub struct Search { |
| 205 | query: String, |
| 206 | matches: Vec<usize>, |
| 207 | } |
| 208 | |
| 209 | impl Search { |
| 210 | pub fn update<'a>(&mut self, query: &str, items: impl Iterator<Item = &'a str>) { |
| 211 | self.query = query.to_owned(); |
| 212 | self.matches.clear(); |
| 213 | if query.trim().is_empty() { |
| 214 | return; |
| 215 | } |
| 216 | let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart); |
| 217 | let mut matcher = Matcher::new(MatcherConfig::DEFAULT); |
| 218 | let mut buf = Vec::new(); |
| 219 | self.matches |
| 220 | .extend(items.enumerate().filter_map(|(index, item)| { |
| 221 | pattern |
| 222 | .score(Utf32Str::new(item, &mut buf), &mut matcher) |
| 223 | .map(|_| index) |
| 224 | })); |
| 225 | } |
| 226 | |
| 227 | pub fn clear(&mut self) { |
| 228 | self.query.clear(); |
| 229 | self.matches.clear(); |
| 230 | } |
| 231 | |
| 232 | pub fn query(&self) -> &str { |
| 233 | &self.query |
| 234 | } |
| 235 | |
| 236 | pub fn match_count(&self) -> usize { |
| 237 | self.matches.len() |
| 238 | } |
| 239 | |
| 240 | pub fn is_match(&self, index: usize) -> bool { |
| 241 | self.matches.binary_search(&index).is_ok() |
| 242 | } |
| 243 | |
| 244 | /// The first match at or after `index`, wrapping around. |
| 245 | pub fn first_from(&self, index: usize) -> Option<usize> { |
| 246 | let start = self.matches.partition_point(|&m| m < index); |
| 247 | self.matches |
| 248 | .get(start) |
| 249 | .or_else(|| self.matches.first()) |
| 250 | .copied() |
| 251 | } |
| 252 | |
| 253 | /// The first match after `index`, wrapping around. |
| 254 | pub fn next_after(&self, index: usize) -> Option<usize> { |
| 255 | self.first_from(index.saturating_add(1)) |
| 256 | } |
| 257 | |
| 258 | /// The last match before `index`, wrapping around. |
| 259 | pub fn previous_before(&self, index: usize) -> Option<usize> { |
| 260 | let end = self.matches.partition_point(|&m| m < index); |
| 261 | end.checked_sub(1) |
| 262 | .and_then(|i| self.matches.get(i)) |
| 263 | .or_else(|| self.matches.last()) |
| 264 | .copied() |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | #[cfg(test)] |
| 269 | mod tests { |
| 270 | use super::*; |
| 271 | |
| 272 | const ITEMS: [&str; 5] = ["Alpha", "beta", "Gamma", "alphabet", "delta"]; |
| 273 | |
| 274 | fn search(query: &str) -> Search { |
| 275 | let mut search = Search::default(); |
| 276 | search.update(query, ITEMS.into_iter()); |
| 277 | search |
| 278 | } |
| 279 | |
| 280 | #[test] |
| 281 | fn finds_fuzzy_matches_in_list_order() { |
| 282 | let s = search("alp"); |
| 283 | assert_eq!(s.matches, [0, 3]); |
| 284 | assert!(s.is_match(3) && !s.is_match(1)); |
| 285 | assert_eq!(search("").match_count(), 0); |
| 286 | } |
| 287 | |
| 288 | #[test] |
| 289 | fn cycles_matches_with_wraparound() { |
| 290 | let s = search("alp"); |
| 291 | assert_eq!(s.next_after(0), Some(3)); |
| 292 | assert_eq!(s.next_after(3), Some(0)); |
| 293 | assert_eq!(s.first_from(3), Some(3)); |
| 294 | assert_eq!(s.previous_before(3), Some(0)); |
| 295 | assert_eq!(s.previous_before(0), Some(3)); |
| 296 | } |
| 297 | |
| 298 | #[test] |
| 299 | fn navigates_and_clamps() { |
| 300 | let mut view = ListView { |
| 301 | height: 10, |
| 302 | ..ListView::default() |
| 303 | }; |
| 304 | assert!(view.navigate(Action::Bottom, 25)); |
| 305 | assert_eq!(view.selected(25), Some(24)); |
| 306 | view.navigate(Action::PageUp, 25); |
| 307 | assert_eq!(view.selected(25), Some(14)); |
| 308 | view.navigate(Action::HalfPageDown, 25); |
| 309 | assert_eq!(view.selected(25), Some(19)); |
| 310 | view.navigate(Action::Down, 25); |
| 311 | view.navigate(Action::PageDown, 25); |
| 312 | assert_eq!(view.selected(25), Some(24)); |
| 313 | assert_eq!(view.selected(3), Some(2)); |
| 314 | assert_eq!(view.selected(0), None); |
| 315 | assert!(!view.navigate(Action::Quit, 25)); |
| 316 | } |
| 317 | |
| 318 | #[test] |
| 319 | fn search_jumps_to_first_match_from_cursor() { |
| 320 | let mut view = ListView::default(); |
| 321 | view.select(1, ITEMS.len()); |
| 322 | view.search("alp", ITEMS.into_iter(), ITEMS.len()); |
| 323 | assert_eq!(view.selected(ITEMS.len()), Some(3)); |
| 324 | view.navigate(Action::SearchNext, ITEMS.len()); |
| 325 | assert_eq!(view.selected(ITEMS.len()), Some(0)); |
| 326 | } |
| 327 | } |