oma_console/
pager.rs

1use std::{
2    io::{self, BufRead, IsTerminal, Write, stderr, stdin, stdout},
3    time::{Duration, Instant},
4};
5
6use aho_corasick::{AhoCorasick, BuildError};
7use ansi_to_tui::IntoText;
8use ratatui::{
9    Frame, Terminal,
10    backend::{Backend, CrosstermBackend},
11    layout::{Alignment, Constraint, Layout},
12    restore,
13    style::{Color, Stylize},
14    widgets::{Block, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
15};
16use ratatui::{
17    crossterm::{
18        self,
19        event::{self, Event, KeyCode, KeyModifiers},
20        execute,
21        terminal::{EnterAlternateScreen, enable_raw_mode},
22    },
23    widgets::Borders,
24};
25use termbg::Theme;
26use tracing::debug;
27
28use crate::{print::OmaColorFormat, writer::Writer};
29
30const HIGHLIGHT_START: &str = "\x1b[7m";
31const HIGHLIGHT_END: &str = "\x1b[0m";
32
33pub enum Pager<'a> {
34    Plain,
35    External(Box<OmaPager<'a>>),
36}
37
38impl<'a> Pager<'a> {
39    pub fn plain() -> Self {
40        Self::Plain
41    }
42
43    pub fn external(
44        ui_text: Box<dyn PagerUIText>,
45        title: Option<String>,
46        color_format: &'a OmaColorFormat,
47    ) -> io::Result<Self> {
48        if !stdout().is_terminal() || !stderr().is_terminal() || !stdin().is_terminal() {
49            return Ok(Pager::Plain);
50        }
51
52        let app = OmaPager::new(title, color_format, ui_text);
53        let res = Pager::External(Box::new(app));
54
55        Ok(res)
56    }
57
58    /// Get writer to writer something to pager
59    pub fn get_writer(&mut self) -> io::Result<Box<dyn Write + '_>> {
60        let res = match self {
61            Pager::Plain => Writer::new_stdout().get_writer(),
62            Pager::External(app) => {
63                let res: Box<dyn Write> = Box::new(app);
64                res
65            }
66        };
67
68        Ok(res)
69    }
70
71    /// Wait for the pager to exit
72    /// Use this function to start the pager
73    pub fn wait_for_exit(self) -> io::Result<PagerExit> {
74        let success = if let Pager::External(app) = self {
75            let mut terminal = prepare_create_tui()?;
76            let res = app.run(&mut terminal, Duration::from_millis(250))?;
77            exit_tui(&mut terminal)?;
78
79            res
80        } else {
81            PagerExit::NormalExit
82        };
83
84        Ok(success)
85    }
86}
87
88pub fn exit_tui(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
89    restore();
90    terminal.show_cursor()?;
91
92    Ok(())
93}
94
95pub fn prepare_create_tui() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
96    let hook = std::panic::take_hook();
97    std::panic::set_hook(Box::new(move |info| {
98        restore();
99        hook(info);
100    }));
101
102    execute!(stdout(), EnterAlternateScreen)?;
103    enable_raw_mode()?;
104
105    let backend = CrosstermBackend::new(stdout());
106    let mut terminal = Terminal::new(backend)?;
107
108    terminal.clear()?;
109
110    Ok(terminal)
111}
112
113enum PagerInner {
114    Working(Vec<u8>),
115    Finished(Vec<String>),
116}
117
118/// `OmaPager` is a structure that implements a pager displaying text-based content in a terminal UI.
119pub struct OmaPager<'a> {
120    /// The internal state of the pager, which can be either `Working` or `Finished`.
121    inner: PagerInner,
122    /// The state of the vertical scrollbar.
123    vertical_scroll_state: ScrollbarState,
124    /// The state of the horizontal scrollbar.
125    horizontal_scroll_state: ScrollbarState,
126    /// The current vertical scroll position.
127    vertical_scroll: usize,
128    /// The current horizontal scroll position.
129    horizontal_scroll: usize,
130    /// The height of the display area.
131    area_height: u16,
132    /// The maximum width of the display area.
133    max_width: u16,
134    /// A string containing tips to be displayed in the pager at the bottom.
135    tips: String,
136    /// An optional title for the pager.
137    title: Option<String>,
138    /// The length of the inner content.
139    inner_len: usize,
140    /// A reference to the color format used for the pager's theme.
141    theme: &'a OmaColorFormat,
142    /// A vector containing the indices of search results.
143    search_results: Vec<usize>,
144    /// The index of the current search result being displayed.
145    current_result_index: usize,
146    /// The current mode of the pager, which can be either `Normal`, `Search` and `SearchInputText`.
147    mode: TuiMode,
148    /// A reference to a trait object that provides UI text for the pager.
149    ui_text: Box<dyn PagerUIText>,
150    /// A terminal writer to print oma-style message
151    writer: Writer,
152}
153
154impl Write for OmaPager<'_> {
155    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
156        match self.inner {
157            PagerInner::Working(ref mut v) => v.extend_from_slice(buf),
158            PagerInner::Finished(_) => {
159                return Err(io::Error::other("write is finished"));
160            }
161        }
162
163        Ok(buf.len())
164    }
165
166    fn flush(&mut self) -> io::Result<()> {
167        Ok(())
168    }
169}
170
171pub trait PagerUIText {
172    fn normal_tips(&self) -> String;
173    fn search_tips_with_result(&self) -> String;
174    fn searct_tips_with_query(&self, query: &str) -> String;
175    fn search_tips_with_empty(&self) -> String;
176    fn search_tips_not_found(&self) -> String;
177}
178
179#[derive(PartialEq, Eq)]
180enum TuiMode {
181    Search,
182    SearchInputText,
183    Normal,
184}
185
186pub enum PagerExit {
187    NormalExit,
188    Sigint,
189    DryRun,
190}
191
192impl From<PagerExit> for i32 {
193    fn from(value: PagerExit) -> Self {
194        match value {
195            PagerExit::NormalExit => 0,
196            PagerExit::Sigint => 130,
197            PagerExit::DryRun => 0,
198        }
199    }
200}
201
202impl<'a> OmaPager<'a> {
203    pub fn new(
204        title: Option<String>,
205        theme: &'a OmaColorFormat,
206        ui_text: Box<dyn PagerUIText>,
207    ) -> Self {
208        Self {
209            inner: PagerInner::Working(vec![]),
210            vertical_scroll_state: ScrollbarState::new(0),
211            horizontal_scroll_state: ScrollbarState::new(0),
212            vertical_scroll: 0,
213            horizontal_scroll: 0,
214            area_height: 0,
215            max_width: 0,
216            tips: ui_text.normal_tips(),
217            title,
218            inner_len: 0,
219            theme,
220            search_results: Vec::new(),
221            current_result_index: 0,
222            mode: TuiMode::Normal,
223            ui_text,
224            writer: Writer::default(),
225        }
226    }
227    /// Run the pager
228    ///
229    /// This function runs the pager, processes user/program input, and renders the output in a terminal UI.
230    /// Note: Please use `wait_for_exit` to run a pager instead of calling this function directly.
231    ///
232    /// # Arguments
233    /// * `terminal` - A mutable reference to a `Terminal` object that handles the terminal UI rendering.
234    /// * `tick_rate` - A `Duration` object that specifies the tick rate for the terminal updates.
235    ///
236    /// # Returns
237    ///
238    /// Returns an `io::Result` containing a `PagerExit` value that indicates the exit status of the pager.
239    pub fn run<B: Backend>(
240        mut self,
241        terminal: &mut Terminal<B>,
242        tick_rate: Duration,
243    ) -> io::Result<PagerExit> {
244        self.inner = if let PagerInner::Working(v) = self.inner {
245            PagerInner::Finished(v.lines().map_while(Result::ok).collect::<Vec<_>>())
246        } else {
247            return Err(io::Error::other("write is finished"));
248        };
249
250        let PagerInner::Finished(ref text) = self.inner else {
251            unreachable!()
252        };
253
254        let width = text
255            .iter()
256            .map(|x| console::measure_text_width(x))
257            .max()
258            .unwrap_or(1);
259
260        self.max_width = width as u16;
261        self.inner_len = text.len();
262
263        let mut query = String::new();
264
265        let mut last_tick = Instant::now();
266        // Start the loop, waiting for the keyboard interrupts.
267        loop {
268            terminal.draw(|f| self.ui(f))?;
269            let timeout = tick_rate.saturating_sub(last_tick.elapsed());
270            if crossterm::event::poll(timeout)? {
271                match event::read()? {
272                    Event::Key(key) => {
273                        if key.modifiers == KeyModifiers::CONTROL {
274                            match key.code {
275                                KeyCode::Char('c') => return Ok(PagerExit::Sigint),
276                                KeyCode::Char('p') => self.up(),
277                                KeyCode::Char('n') => self.down(),
278                                _ => {}
279                            }
280                        };
281
282                        match key.code {
283                            KeyCode::Char(c) if c == 'q' || c == 'Q' => {
284                                if self.mode == TuiMode::SearchInputText {
285                                    query.push(c);
286                                    self.tips = self.ui_text.searct_tips_with_query(&query);
287                                    continue;
288                                }
289                                return Ok(PagerExit::NormalExit);
290                            }
291                            KeyCode::Down => {
292                                self.down();
293                            }
294                            KeyCode::Up => {
295                                self.up();
296                            }
297                            KeyCode::Left => {
298                                self.left();
299                            }
300                            KeyCode::Right => {
301                                self.right();
302                            }
303                            KeyCode::Char('y') => {
304                                if self.mode == TuiMode::SearchInputText {
305                                    query.push('y');
306                                    self.tips = self.ui_text.searct_tips_with_query(&query);
307                                    continue;
308                                }
309                                self.up();
310                            }
311                            KeyCode::Char('j') => {
312                                if self.mode == TuiMode::SearchInputText {
313                                    query.push('j');
314                                    self.tips = self.ui_text.searct_tips_with_query(&query);
315                                    continue;
316                                }
317                                self.down();
318                            }
319                            KeyCode::Char('k') => {
320                                if self.mode == TuiMode::SearchInputText {
321                                    query.push('k');
322                                    self.tips = self.ui_text.searct_tips_with_query(&query);
323                                    continue;
324                                }
325                                self.up();
326                            }
327                            KeyCode::Char('h') => {
328                                if self.mode == TuiMode::SearchInputText {
329                                    query.push('h');
330                                    self.tips = self.ui_text.searct_tips_with_query(&query);
331                                    continue;
332                                }
333                                self.left();
334                            }
335                            KeyCode::Char('l') => {
336                                if self.mode == TuiMode::SearchInputText {
337                                    query.push('l');
338                                    self.tips = self.ui_text.searct_tips_with_query(&query);
339                                    continue;
340                                }
341                                self.right();
342                            }
343                            KeyCode::Char('g') => {
344                                if self.mode == TuiMode::SearchInputText {
345                                    query.push('g');
346                                    self.tips = self.ui_text.searct_tips_with_query(&query);
347                                    continue;
348                                }
349                                self.goto_begin();
350                            }
351                            KeyCode::Char('G') => {
352                                if self.mode == TuiMode::SearchInputText {
353                                    query.push('G');
354                                    self.tips = self.ui_text.searct_tips_with_query(&query);
355                                    continue;
356                                }
357                                self.goto_end();
358                            }
359                            KeyCode::Enter => {
360                                if self.mode != TuiMode::SearchInputText {
361                                    self.down();
362                                    continue;
363                                }
364                                if query.trim().is_empty() {
365                                    self.tips = self.ui_text.search_tips_with_empty();
366                                } else {
367                                    self.search_results = self.search(&query);
368                                    if self.search_results.is_empty() {
369                                        self.tips = self.ui_text.search_tips_not_found();
370                                    } else {
371                                        self.current_result_index = 0;
372                                        self.jump_to(
373                                            self.search_results[self.current_result_index],
374                                        );
375                                        self.tips = self.ui_text.search_tips_with_result();
376                                    }
377                                }
378                                self.mode = TuiMode::Search;
379                            }
380                            KeyCode::Esc => {
381                                if self.mode != TuiMode::Normal {
382                                    self.mode = TuiMode::Normal;
383                                }
384                                // clear highlight
385                                self.clear_highlight();
386                                // clear search tips
387                                self.tips = self.ui_text.normal_tips();
388                            }
389                            KeyCode::Backspace => {
390                                if self.mode == TuiMode::SearchInputText {
391                                    query.pop();
392                                    // update tips with search patterns
393                                    self.tips = self.ui_text.searct_tips_with_query(&query);
394                                }
395                            }
396                            KeyCode::Char('/') => {
397                                if self.mode != TuiMode::SearchInputText {
398                                    self.clear_highlight();
399                                    self.mode = TuiMode::SearchInputText;
400                                    // update tips with search patterns
401                                    self.tips = self.ui_text.searct_tips_with_query(&query);
402                                } else {
403                                    query.push('/');
404                                    self.tips = self.ui_text.searct_tips_with_query(&query);
405                                    continue;
406                                }
407                            }
408                            KeyCode::Char('n') => match self.mode {
409                                TuiMode::Search => {
410                                    if !self.search_results.is_empty() {
411                                        self.current_result_index = (self.current_result_index + 1)
412                                            % self.search_results.len();
413                                        self.jump_to(
414                                            self.search_results[self.current_result_index],
415                                        );
416                                    }
417                                }
418                                TuiMode::SearchInputText => {
419                                    query.push('n');
420                                    self.tips = self.ui_text.searct_tips_with_query(&query);
421                                    continue;
422                                }
423                                TuiMode::Normal => continue,
424                            },
425                            KeyCode::Char('N') => match self.mode {
426                                TuiMode::Search => {
427                                    if !self.search_results.is_empty() {
428                                        if self.current_result_index == 0 {
429                                            self.current_result_index =
430                                                self.search_results.len() - 1;
431                                        } else {
432                                            self.current_result_index -= 1;
433                                        }
434                                        self.jump_to(
435                                            self.search_results[self.current_result_index],
436                                        );
437                                    }
438                                }
439                                TuiMode::SearchInputText => {
440                                    query.push('N');
441                                    self.tips = self.ui_text.searct_tips_with_query(&query);
442                                    continue;
443                                }
444                                TuiMode::Normal => continue,
445                            },
446                            KeyCode::Char(c) if c == 'u' || c == 'U' || c == 'b' || c == 'B' => {
447                                if self.mode == TuiMode::SearchInputText {
448                                    query.push(c);
449                                    self.tips = self.ui_text.searct_tips_with_query(&query);
450                                    continue;
451                                }
452                                self.page_up();
453                            }
454                            KeyCode::Char(c)
455                                if c == 'd' || c == 'D' || c == ' ' || c == 'F' || c == 'f' =>
456                            {
457                                if self.mode == TuiMode::SearchInputText {
458                                    query.push(c);
459                                    self.tips = self.ui_text.searct_tips_with_query(&query);
460                                    continue;
461                                }
462                                self.page_down();
463                            }
464                            KeyCode::Char(input_char) => {
465                                if self.mode == TuiMode::SearchInputText {
466                                    query.push(input_char);
467                                    // update tips with search patterns
468                                    self.tips = self.ui_text.searct_tips_with_query(&query);
469                                }
470                            }
471                            KeyCode::PageUp => {
472                                self.page_up();
473                            }
474                            KeyCode::PageDown => {
475                                self.page_down();
476                            }
477                            KeyCode::End => {
478                                self.goto_end();
479                            }
480                            KeyCode::Home => {
481                                self.goto_begin();
482                            }
483                            _ => {}
484                        }
485                    }
486                    _ => continue,
487                }
488            }
489            if last_tick.elapsed() >= tick_rate {
490                last_tick = Instant::now();
491            }
492        }
493    }
494
495    fn page_down(&mut self) {
496        let pos = self
497            .vertical_scroll
498            .saturating_add(self.area_height as usize);
499        if pos < self.inner_len {
500            self.vertical_scroll = pos;
501        } else {
502            return;
503        }
504        self.vertical_scroll_state = self.vertical_scroll_state.position(self.vertical_scroll);
505    }
506
507    fn page_up(&mut self) {
508        self.vertical_scroll = self
509            .vertical_scroll
510            .saturating_sub(self.area_height as usize);
511        self.vertical_scroll_state = self.vertical_scroll_state.position(self.vertical_scroll);
512    }
513
514    fn goto_end(&mut self) {
515        self.vertical_scroll = self.inner_len.saturating_sub(self.area_height.into());
516        self.vertical_scroll_state = self.vertical_scroll_state.position(self.vertical_scroll);
517    }
518
519    fn goto_begin(&mut self) {
520        self.vertical_scroll = 0;
521        self.vertical_scroll_state = self.vertical_scroll_state.position(0);
522    }
523
524    fn right(&mut self) {
525        let width = self.writer.get_length();
526
527        if self.max_width <= self.horizontal_scroll as u16 + width {
528            return;
529        }
530
531        self.horizontal_scroll = self.horizontal_scroll.saturating_add((width / 4).into());
532        self.horizontal_scroll_state = self
533            .horizontal_scroll_state
534            .position(self.horizontal_scroll);
535    }
536
537    fn left(&mut self) {
538        let width = self.writer.get_length();
539        self.horizontal_scroll = self.horizontal_scroll.saturating_sub((width / 4).into());
540        self.horizontal_scroll_state = self
541            .horizontal_scroll_state
542            .position(self.horizontal_scroll);
543    }
544
545    fn up(&mut self) {
546        self.vertical_scroll = self.vertical_scroll.saturating_sub(1);
547        self.vertical_scroll_state = self.vertical_scroll_state.position(self.vertical_scroll);
548    }
549
550    fn down(&mut self) {
551        if self
552            .vertical_scroll
553            .saturating_add(self.area_height as usize)
554            >= self.inner_len
555        {
556            return;
557        }
558        self.vertical_scroll = self.vertical_scroll.saturating_add(1);
559        self.vertical_scroll_state = self.vertical_scroll_state.position(self.vertical_scroll);
560    }
561    /// Search for a pattern in the pager content
562    /// # Returns:
563    /// The lines contain this pattern (In vec<usize>)
564    fn search(&mut self, pattern: &str) -> Vec<usize> {
565        let mut result: Vec<usize> = Vec::new();
566
567        if let PagerInner::Finished(ref mut text) = self.inner {
568            match Highlight::new(pattern) {
569                Ok(highlight) => {
570                    for (i, line) in text.iter_mut().enumerate() {
571                        if line.contains(pattern) {
572                            result.push(i);
573                            // highlight the pattern
574                            *line = highlight.replace(line);
575                        }
576                    }
577                }
578                Err(e) => {
579                    debug!("{e}");
580                }
581            }
582        }
583
584        result
585    }
586
587    /// Jump to line
588    fn jump_to(&mut self, line: usize) {
589        self.vertical_scroll = line;
590        self.vertical_scroll_state = self.vertical_scroll_state.position(self.vertical_scroll);
591    }
592
593    fn clear_highlight(&mut self) {
594        if let PagerInner::Finished(ref mut text) = self.inner {
595            let clear_highlighter = ClearHighlight::new();
596            for line_index in &self.search_results {
597                if let Some(line) = text.get_mut(*line_index) {
598                    *line = clear_highlighter.replace(line);
599                }
600            }
601        }
602    }
603
604    /// Render and fresh the UI
605    fn ui(&mut self, f: &mut Frame) {
606        let area = f.area();
607        let mut layout = vec![
608            Constraint::Min(0),
609            // 2 是 block 的两条线
610            Constraint::Length(self.tips.lines().count() as u16 + 2),
611        ];
612
613        let mut has_title = false;
614        if self.title.is_some() {
615            layout.insert(0, Constraint::Length(1));
616            has_title = true;
617        }
618
619        let chunks = Layout::vertical(layout).split(area);
620
621        let color = self.theme.theme;
622
623        let title_bg_color = match color {
624            Some(Theme::Dark) => Color::Indexed(25),
625            Some(Theme::Light) => Color::Indexed(189),
626            None => Color::Indexed(25),
627        };
628
629        let title_fg_color = match color {
630            Some(Theme::Dark) => Color::White,
631            Some(Theme::Light) => Color::Black,
632            None => Color::White,
633        };
634
635        if let Some(title) = &self.title {
636            let title = Block::new()
637                .title_alignment(Alignment::Left)
638                .title(title.to_string())
639                .fg(title_fg_color)
640                .bg(title_bg_color);
641
642            f.render_widget(title, chunks[0]);
643        }
644
645        self.area_height = if has_title {
646            chunks[1].height
647        } else {
648            chunks[0].height
649        };
650
651        let width = if self.max_width <= self.writer.get_length() {
652            0
653        } else {
654            self.max_width
655        };
656
657        self.horizontal_scroll_state = self.horizontal_scroll_state.content_length(width as usize);
658
659        self.vertical_scroll_state = self
660            .vertical_scroll_state
661            .content_length(self.inner_len.saturating_sub(self.area_height as usize));
662
663        let PagerInner::Finished(ref text) = self.inner else {
664            unreachable!()
665        };
666
667        let text = if let Some(text) =
668            text.get(self.vertical_scroll..self.vertical_scroll + self.area_height as usize)
669        {
670            // 根据屏幕高度来决定显示多少行
671            text
672        } else {
673            // 达到末尾,即剩余行数小于屏幕高度
674            &text[self.vertical_scroll..]
675        };
676
677        let text = text.join("\n");
678        let text = match text.to_text() {
679            Ok(text) => text,
680            Err(e) => {
681                debug!("{e}");
682                return;
683            }
684        };
685
686        // 不使用 .scroll 控制上下滚动是因为它需要一整个 self.text 来计算滚动
687        // 因为 Paragraph 只接受 owner, self.text 每一次都需要 clone 获取主动权
688        // 当 self.text 行数一多,性能就会非常的“好”
689        f.render_widget(
690            Paragraph::new(text).scroll((0, self.horizontal_scroll as u16)),
691            if has_title { chunks[1] } else { chunks[0] },
692        );
693
694        f.render_stateful_widget(
695            Scrollbar::new(ScrollbarOrientation::VerticalRight)
696                .begin_symbol(Some("↑"))
697                .end_symbol(Some("↓")),
698            if has_title { chunks[1] } else { chunks[0] },
699            &mut self.vertical_scroll_state,
700        );
701
702        let text = match self.tips.into_text() {
703            Ok(t) => t,
704            Err(e) => {
705                debug!("{e}");
706                return;
707            }
708        };
709
710        f.render_widget(
711            Paragraph::new(text).block(Block::default().borders(Borders::ALL)),
712            if has_title { chunks[2] } else { chunks[1] },
713        );
714    }
715}
716
717struct Highlight<'a> {
718    pattern: &'a str,
719    ac: AhoCorasick,
720}
721
722impl<'a> Highlight<'a> {
723    fn new(pattern: &'a str) -> Result<Self, BuildError> {
724        Ok(Self {
725            ac: AhoCorasick::new([pattern])?,
726            pattern,
727        })
728    }
729
730    fn replace(&self, input: &str) -> String {
731        self.ac.replace_all(
732            input,
733            &[format!(
734                "{}{}{}",
735                HIGHLIGHT_START, self.pattern, HIGHLIGHT_END
736            )],
737        )
738    }
739}
740
741struct ClearHighlight(AhoCorasick);
742
743impl ClearHighlight {
744    fn new() -> Self {
745        Self(AhoCorasick::new([HIGHLIGHT_START, HIGHLIGHT_END]).unwrap())
746    }
747
748    fn replace(&self, input: &str) -> String {
749        self.0.replace_all(input, &["", ""])
750    }
751}