1use std::sync::Arc;
15
16use rich::cells::cell_len;
17use rich::{Renderable, Segment, Style};
18
19use crate::component::{Component, Context, Flow, View};
20use crate::components::{Theme, fit, plain, text};
21use crate::event::{Event, Key, KeyCode};
22use crate::keymap::{Keymap, keys};
23use crate::policy::{LineIo, NotInteractive};
24use crate::viewport::Viewport;
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28struct Hit {
29 line: usize,
30 start: usize,
31 end: usize,
32}
33
34pub fn pager_keymap() -> Keymap {
39 let mut keymap = Keymap::new("pager")
40 .bind("quit", keys("q escape"), "quit")
41 .bind("search", keys("/"), "search")
42 .bind("next-match", keys("n"), "next match")
43 .bind("previous-match", keys("N"), "previous match");
44 keymap.extend(crate::kit::ScrollState::keymap("pager"));
45 keymap
46}
47
48pub fn pager_search_keymap() -> Keymap {
51 Keymap::new("pager-search")
52 .bind("find", keys("enter"), "find")
53 .bind("cancel", keys("escape"), "stop searching")
54 .bind("delete", keys("backspace"), "delete a character")
55}
56
57#[must_use = "a component does nothing until it is run or mounted"]
59pub struct Pager {
60 keymap: Keymap,
61 search_keymap: Keymap,
62 source: Option<Arc<dyn Renderable + Send + Sync>>,
63 rendered_at: Option<usize>,
64 viewport: Viewport,
65 plain: Vec<String>,
67 typing: Option<String>,
69 query: Option<String>,
70 hits: Vec<Hit>,
71 current: usize,
72 theme: Theme,
73 mouse: bool,
74 clicked: Vec<String>,
76 sticky: Sticky,
78}
79
80#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82enum Sticky {
83 Lines(usize),
84 Table,
86}
87
88impl Pager {
89 pub fn new(renderable: impl Renderable + Send + Sync + 'static) -> Pager {
91 let mut pager = Pager::lines(Vec::new());
92 pager.source = Some(Arc::new(renderable));
93 pager
94 }
95
96 pub fn lines(lines: Vec<Vec<Segment>>) -> Pager {
98 let mut pager = Pager {
99 keymap: pager_keymap(),
100 search_keymap: pager_search_keymap(),
101 source: None,
102 rendered_at: None,
103 viewport: Viewport::default(),
104 plain: Vec::new(),
105 typing: None,
106 query: None,
107 hits: Vec::new(),
108 current: 0,
109 theme: Theme::default(),
110 mouse: false,
111 clicked: Vec::new(),
112 sticky: Sticky::Lines(0),
113 };
114 pager.set(lines);
115 pager
116 }
117
118 pub fn rebind(mut self, action: &str, keys: impl IntoIterator<Item = Key>) -> Self {
121 let keys: Vec<Key> = keys.into_iter().collect();
122 self.keymap.rebind(action, keys.clone());
123 self.search_keymap.rebind(action, keys);
124 self
125 }
126
127 pub fn theme(mut self, theme: Theme) -> Self {
128 self.theme = theme;
129 self
130 }
131
132 pub fn with_mouse(mut self, on: bool) -> Self {
136 self.mouse = on;
137 self
138 }
139
140 pub fn links(&self) -> &[String] {
143 &self.clicked
144 }
145
146 pub fn sticky_header(mut self, lines: usize) -> Self {
149 self.sticky = Sticky::Lines(lines);
150 self.stick();
151 self
152 }
153
154 pub fn sticky_table_header(mut self, on: bool) -> Self {
160 self.sticky = if on { Sticky::Table } else { Sticky::Lines(0) };
161 self.stick();
162 self
163 }
164
165 pub fn sticky_rows(&self) -> usize {
167 self.viewport.sticky_rows()
168 }
169
170 fn stick(&mut self) {
172 let rows = match self.sticky {
173 Sticky::Lines(lines) => lines,
174 Sticky::Table => table_header_rows(self.viewport.lines()).unwrap_or(0),
175 };
176 self.viewport.set_sticky(rows);
177 }
178
179 pub fn search(mut self, query: impl Into<String>) -> Self {
181 self.query = Some(query.into());
182 self.find();
183 self
184 }
185
186 fn set(&mut self, mut lines: Vec<Vec<Segment>>) {
187 for line in &mut lines {
189 crate::paint::sanitize_line(line);
190 }
191 self.plain = lines
192 .iter()
193 .map(|line| line.iter().map(|s| s.text.as_str()).collect())
194 .collect();
195 self.viewport.set_lines(lines);
196 self.stick();
197 self.find();
198 }
199
200 fn render_at(&mut self, context: &Context<'_>) {
202 let width = context.width;
203 if let Some(source) = &self.source
204 && self.rendered_at != Some(width)
205 {
206 let lines = context.lines_at(&**source, width);
207 self.rendered_at = Some(width);
208 self.set(lines);
209 }
210 }
211
212 fn find(&mut self) {
214 self.hits.clear();
215 self.current = 0;
216 let Some(query) = self.query.as_ref().filter(|q| !q.is_empty()) else {
217 return;
218 };
219 let needle: Vec<char> = query.chars().flat_map(char::to_lowercase).collect();
220 for (line, text) in self.plain.iter().enumerate() {
221 for (start, end) in find_folded(text, &needle) {
222 self.hits.push(Hit {
223 line,
224 start: cell_len(&text[..start]),
225 end: cell_len(&text[..end]),
226 });
227 }
228 }
229 }
230
231 fn page(context: &Context<'_>) -> usize {
232 context.height.saturating_sub(1).max(1)
233 }
234
235 fn jump(&mut self, delta: isize, page: usize) {
236 if self.hits.is_empty() {
237 return;
238 }
239 let count = self.hits.len() as isize;
240 self.current = (self.current as isize + delta).rem_euclid(count) as usize;
241 self.viewport.show(self.hits[self.current].line, page);
242 }
243
244 fn mark(&self, line: &[Segment], index: usize) -> Vec<Segment> {
246 let hits: Vec<(usize, &Hit)> = self
247 .hits
248 .iter()
249 .enumerate()
250 .filter(|(_, hit)| hit.line == index)
251 .collect();
252 if hits.is_empty() {
253 return line.to_vec();
254 }
255 let other = Style::parse("reverse").expect("style");
256 let current = Style::parse("black on yellow").expect("style");
257 let mut out = Vec::new();
258 let mut column = 0;
259 for segment in line {
260 for c in segment.text.chars() {
261 let width = cell_len(c.encode_utf8(&mut [0; 4]));
262 let marked = hits
263 .iter()
264 .find(|(_, hit)| column >= hit.start && column < hit.end)
265 .map(|(n, _)| if *n == self.current { ¤t } else { &other });
266 let style = match (marked, &segment.style) {
267 (Some(mark), Some(style)) => Some(style.combine(mark)),
268 (Some(mark), None) => Some(mark.clone()),
269 (None, style) => style.clone(),
270 };
271 match out.last_mut() {
272 Some(Segment {
273 text, style: last, ..
274 }) if *last == style => text.push(c),
275 _ => out.push(Segment::new(c.to_string(), style)),
276 }
277 column += width;
278 }
279 }
280 out
281 }
282}
283
284pub fn table_header_rows(lines: &[Vec<Segment>]) -> Option<usize> {
295 const LIMIT: usize = 16;
297 let plain: Vec<String> = lines
298 .iter()
299 .take(LIMIT)
300 .map(|line| {
301 let text: String = line
302 .iter()
303 .filter(|s| !s.control)
304 .map(|s| s.text.as_str())
305 .collect();
306 text.trim_matches(' ').to_string()
307 })
308 .collect();
309 for under in 1..plain.len() {
310 for set in BOXES.iter() {
311 if let Some(rows) = header_above(&plain, under, set) {
312 return (rows < lines.len()).then_some(rows);
313 }
314 }
315 }
316 None
317}
318
319const BOXES: [rich::r#box::Box; 15] = {
323 use rich::r#box::*;
324 [
325 HEAVY_HEAD,
326 SQUARE,
327 ROUNDED,
328 HEAVY,
329 DOUBLE,
330 MINIMAL,
331 ASCII,
332 ASCII2,
333 ASCII_DOUBLE_HEAD,
334 SQUARE_DOUBLE_HEAD,
335 MINIMAL_HEAVY_HEAD,
336 MINIMAL_DOUBLE_HEAD,
337 HEAVY_EDGE,
338 DOUBLE_EDGE,
339 MARKDOWN,
340 ]
341};
342
343fn header_above(plain: &[String], under: usize, set: &rich::r#box::Box) -> Option<usize> {
347 let line = &plain[under];
348 let (left, across, cross, right) = (
349 set.head_row_left,
350 set.head_row_horizontal,
351 set.head_row_cross,
352 set.head_row_right,
353 );
354 let ends = |text: &str, first: char, last: char| {
355 text.starts_with(first) && text.ends_with(last) && text.chars().count() > 1
356 };
357 let drawn = !line.is_empty()
358 && line.contains(across)
359 && line
360 .chars()
361 .all(|c| [left, across, cross, right].contains(&c) && c != ' ');
362 let edged = left != ' ' && right != ' ' && ends(line, left, right);
364 if !drawn || !(edged || (cross != across && line.contains(cross))) {
365 return None;
366 }
367 let between = (
373 set.row_left,
374 set.row_horizontal,
375 set.row_cross,
376 set.row_right,
377 );
378 if between == (left, across, cross, right)
379 && plain
380 .iter()
381 .enumerate()
382 .any(|(at, text)| at != under && text == line)
383 {
384 return None;
385 }
386 let header = |text: &str| {
388 (set.head_left != ' ' && set.head_right != ' ' && ends(text, set.head_left, set.head_right))
389 || (set.head_vertical != ' ' && text.contains(set.head_vertical))
390 };
391 let mut top = under;
392 while top > 0 && header(&plain[top - 1]) {
393 top -= 1;
394 }
395 if top == under {
396 return None;
397 }
398 let edge = |text: &str| {
399 !text.is_empty()
400 && text
401 .chars()
402 .all(|c| [set.top_left, set.top, set.top_divider, set.top_right, ' '].contains(&c))
403 };
404 if top > 0 && edge(&plain[top - 1]) {
405 top -= 1;
406 }
407 (top <= 3).then_some(under + 1)
408}
409
410fn find_folded(text: &str, needle: &[char]) -> Vec<(usize, usize)> {
416 let mut hits = Vec::new();
417 if needle.is_empty() {
418 return hits;
419 }
420 let mut folded: Vec<(char, usize, usize)> = Vec::with_capacity(text.len());
423 for (at, c) in text.char_indices() {
424 let end = at + c.len_utf8();
425 folded.extend(c.to_lowercase().map(|lower| (lower, at, end)));
426 }
427 let mut from = 0;
428 while from + needle.len() <= folded.len() {
429 let window = &folded[from..from + needle.len()];
430 if window.iter().map(|(c, _, _)| c).eq(needle.iter()) {
431 let (start, end) = (window[0].1, window[needle.len() - 1].2);
432 hits.push((start, end));
433 from += needle.len();
435 while from < folded.len() && folded[from].1 < end {
436 from += 1;
437 }
438 } else {
439 from += 1;
440 }
441 }
442 hits
443}
444
445impl Component for Pager {
446 type Output = ();
447
448 fn start(&mut self, context: &Context<'_>) -> Flow<()> {
451 self.render_at(context);
452 self.jump(0, Self::page(context));
453 Flow::Continue
454 }
455
456 fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<()> {
457 self.render_at(context);
458 let page = Self::page(context);
459 if let Event::Link(url) = event {
460 self.clicked.push(url.clone());
461 return Flow::Continue;
462 }
463 if self.typing.is_some() {
464 let Some(key) = event.key() else {
465 return Flow::Continue;
466 };
467 match self
468 .search_keymap
469 .action(key)
470 .map(str::to_string)
471 .as_deref()
472 {
473 Some("find") => {
474 self.query = self.typing.take();
475 self.find();
476 let top = match self.viewport.offset() {
478 0 => 0,
479 _ => self.viewport.top(page),
480 };
481 let first = self
482 .hits
483 .iter()
484 .position(|hit| hit.line >= top)
485 .unwrap_or(0);
486 self.current = first;
487 self.jump(0, page);
488 }
489 Some("cancel") => self.typing = None,
490 Some("delete") => {
491 if self.typing.as_mut().and_then(String::pop).is_none() {
492 self.typing = None;
493 }
494 }
495 _ => {
496 if let (KeyCode::Char(c), Some(typing)) = (key.code, &mut self.typing) {
497 typing.push(c);
498 }
499 }
500 }
501 return Flow::Continue;
502 }
503 let Some(key) = event.key() else {
504 self.viewport.handle_scroll(event, page);
506 return Flow::Continue;
507 };
508 match self.keymap.action(key).map(str::to_string).as_deref() {
509 Some("quit") => Flow::Done(()),
510 Some("search") => {
511 self.typing = Some(String::new());
512 Flow::Continue
513 }
514 Some("next-match") => {
515 self.jump(1, page);
516 Flow::Continue
517 }
518 Some("previous-match") => {
519 self.jump(-1, page);
520 Flow::Continue
521 }
522 Some(action) if self.viewport.act(action, page) => Flow::Continue,
523 _ => Flow::Ignored,
524 }
525 }
526
527 fn keymap(&self) -> Keymap {
528 if self.typing.is_some() {
529 return self.search_keymap.clone();
530 }
531 self.keymap.clone()
532 }
533
534 fn render(&self, context: &Context<'_>) -> View {
535 let width = context.width;
536 let page = Self::page(context);
537 let theme = &self.theme;
538 let fresh;
540 let (rows, plain_lines): (Vec<(usize, &[Segment])>, usize) =
541 match (&self.source, self.rendered_at) {
542 (Some(source), None) => {
543 fresh = context.lines_at(&**source, width);
544 let rows = fresh.iter().map(Vec::as_slice).enumerate();
545 (rows.take(page).collect(), fresh.len())
546 }
547 _ => (self.viewport.rows(page), self.viewport.len()),
549 };
550 let mut out: Vec<Vec<Segment>> = rows
551 .into_iter()
552 .map(|(index, line)| fit(self.mark(line, index), width))
553 .collect();
554 out.resize_with(page.min(plain_lines.max(1)), Vec::new);
555 let status = if let Some(typing) = &self.typing {
556 let line = vec![text("/", &theme.prompt), plain(typing.clone())];
557 let column = 1 + cell_len(typing);
558 out.push(fit(line, width));
559 return View::new(out).with_cursor(page.min(plain_lines.max(1)), column);
560 } else {
561 let viewport_status = if self.viewport.is_empty() && self.source.is_some() {
562 format!("all {plain_lines} lines")
563 } else {
564 self.viewport.status(page)
565 };
566 let search = match (&self.query, self.hits.len()) {
567 (Some(query), 0) => format!(" · no match for {query:?}"),
568 (Some(_), count) => format!(" · match {}/{count} · n/N", self.current + 1),
569 (None, _) => String::new(),
570 };
571 let link = match self.clicked.last() {
572 Some(url) => format!(" · link {url}"),
573 None => String::new(),
574 };
575 format!("{viewport_status}{search}{link} · / search · q quit")
576 };
577 out.push(fit(vec![text(status, &theme.hint)], width));
578 View::new(out)
579 }
580
581 fn mouse(&self) -> bool {
582 self.mouse
583 }
584
585 fn default_value(&self) -> Option<()> {
586 Some(())
587 }
588
589 fn prompt(&mut self, io: &mut dyn LineIo) -> Result<Option<()>, NotInteractive> {
591 if let (Some(source), None) = (&self.source, self.rendered_at) {
592 let console = rich::Console::builder().force_terminal(false).build();
593 let options = console.options();
594 let lines = console.render_lines(&**source, &options, false);
595 self.set(lines);
596 }
597 for line in &self.plain {
598 io.write(line);
599 io.write("\n");
600 }
601 Ok(Some(()))
602 }
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 #[test]
610 fn search_folds_case_one_character_at_a_time() {
611 assert_eq!(find_folded("Ⱥẞ", &['ß']), [(2, 5)]);
614 assert_eq!(find_folded("ȺẞxSS", &['ß']), [(2, 5)]);
615 let needle: Vec<char> = "x".chars().collect();
617 assert_eq!(find_folded("İx", &needle), [(2, 3)]);
618 let needle: Vec<char> = "i̇".chars().collect();
619 assert_eq!(find_folded("aİb", &needle), [(1, 3)]);
620 assert_eq!(find_folded("AaAa", &['a', 'a']), [(0, 2), (2, 4)]);
622 assert!(find_folded("abc", &[]).is_empty());
623 }
624
625 #[test]
626 fn a_search_that_changes_byte_lengths_marks_the_right_cells() {
627 let pager = Pager::lines(vec![vec![Segment::new("Ⱥẞ end", None)]]).search("ß");
628 assert_eq!(
629 pager.hits,
630 [Hit {
631 line: 0,
632 start: 1,
633 end: 2
634 }]
635 );
636 }
637}