Krolyxon
Commit 96dd350

rmptuisrcuilist.rs

327 lines · 10.7 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 nucleo_matcher::{
7 Config as MatcherConfig, Matcher, Utf32Str,
8 pattern::{CaseMatching, Normalization, Pattern},
9};
10use ratatui::{
11 Frame,
12 layout::{Constraint, Position, Rect},
13 style::{Modifier, Style},
14 widgets::{Block, Row, Table, TableState},
15};
16
17use 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)]
24pub 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)]
37pub enum Click {
38 /// The cursor moved (click or wheel).
39 Moved,
40 /// A row was double-clicked.
41 Activate(usize),
42}
43
44impl 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)]
204pub struct Search {
205 query: String,
206 matches: Vec<usize>,
207}
208
209impl 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)]
269mod 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}