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