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