Krolyxon
Commit a3a311f

rmptuisrcuilist.rs

346 lines · 11.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 nucleo_matcher::{
7 Config as MatcherConfig, Matcher, Utf32Str,
8 pattern::{CaseMatching, Normalization, Pattern},
9};
10use ratatui::{
11 Frame,
12 layout::{Constraint, Layout, Position, Rect},
13 style::{Modifier, Style},
14 text::Line,
15 widgets::{Row, Table, TableState},
16};
17
18use 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)]
25pub 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)]
38pub enum Click {
39 /// The cursor moved (click or wheel).
40 Moved,
41 /// A row was double-clicked.
42 Activate(usize),
43}
44
45impl 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.
213pub 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)]
223pub struct Search {
224 query: String,
225 matches: Vec<usize>,
226}
227
228impl 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)]
288mod 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}