Krolyxon
Commit 815a27a

rmptuisrcuilist.rs

371 lines · 12.3 KB · Rust

1//! A virtualized, searchable list used by every screen.
2
3use std::time::{Duration, Instant};
4
5use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
6use ratatui::{
7 Frame,
8 layout::{Constraint, Layout, Position, Rect},
9 style::{Modifier, Style},
10 text::Line,
11 widgets::{Row, Table, TableState},
12};
13
14use regex::{Regex, RegexBuilder};
15
16use 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)]
23pub 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)]
36pub enum Click {
37 /// The cursor moved (click or wheel).
38 Moved,
39 /// A row was double-clicked.
40 Activate(usize),
41}
42
43impl 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.
211pub 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)]
224pub struct Search {
225 query: String,
226 matches: Vec<usize>,
227}
228
229impl 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.
288fn compile(query: &str) -> Regex {
289 let build = |pattern: &str| RegexBuilder::new(pattern).case_insensitive(true).build();
290 build(query)
291 .or_else(|_| build(&regex::escape(query)))
292 .expect("escaped pattern is valid")
293}
294
295#[cfg(test)]
296mod 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}