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 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 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
118pub struct OmaPager<'a> {
120 inner: PagerInner,
122 vertical_scroll_state: ScrollbarState,
124 horizontal_scroll_state: ScrollbarState,
126 vertical_scroll: usize,
128 horizontal_scroll: usize,
130 area_height: u16,
132 max_width: u16,
134 tips: String,
136 title: Option<String>,
138 inner_len: usize,
140 theme: &'a OmaColorFormat,
142 search_results: Vec<usize>,
144 current_result_index: usize,
146 mode: TuiMode,
148 ui_text: Box<dyn PagerUIText>,
150 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 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 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 self.clear_highlight();
386 self.tips = self.ui_text.normal_tips();
388 }
389 KeyCode::Backspace => {
390 if self.mode == TuiMode::SearchInputText {
391 query.pop();
392 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 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 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 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 *line = highlight.replace(line);
575 }
576 }
577 }
578 Err(e) => {
579 debug!("{e}");
580 }
581 }
582 }
583
584 result
585 }
586
587 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 fn ui(&mut self, f: &mut Frame) {
606 let area = f.area();
607 let mut layout = vec![
608 Constraint::Min(0),
609 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 text
672 } else {
673 &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 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}