radicle_term/
pager.rs

1use std::io::{IsTerminal, Write};
2use std::{io, thread};
3
4use crate::element::Size;
5use crate::{Constraint, Element, Line, Paint};
6
7use crossbeam_channel as chan;
8use radicle_signals as signals;
9use termion::event::{Event, Key, MouseButton, MouseEvent};
10use termion::{input::TermRead, raw::IntoRawMode, screen::IntoAlternateScreen};
11
12/// How many lines to scroll when the mouse wheel is used.
13const MOUSE_SCROLL_LINES: usize = 3;
14
15/// Pager error.
16#[derive(Debug, thiserror::Error)]
17pub enum Error {
18    #[error(transparent)]
19    Io(#[from] io::Error),
20    #[error(transparent)]
21    Channel(#[from] chan::RecvError),
22}
23
24/// A pager for the given element. Re-renders the element when the terminal is resized so that
25/// it doesn't wrap. If the output device is not a TTY, just prints the element via
26/// [`Element::print`].
27///
28/// # Signal Handling
29///
30/// This will install handlers for the pager until finished by the user, with there
31/// being only one element handling signals at a time. If the pager cannot install
32/// handlers, then it will return with an error.
33pub fn page<E: Element + Send + 'static>(element: E) -> Result<(), Error> {
34    let (events_tx, events_rx) = chan::unbounded();
35    let (signals_tx, signals_rx) = chan::unbounded();
36
37    signals::install(signals_tx)?;
38
39    thread::spawn(move || {
40        for e in io::stdin().events() {
41            events_tx.send(e).ok();
42        }
43    });
44    let result = thread::spawn(move || main(element, signals_rx, events_rx))
45        .join()
46        .unwrap();
47
48    signals::uninstall()?;
49
50    result
51}
52
53fn main<E: Element>(
54    element: E,
55    signals_rx: chan::Receiver<signals::Signal>,
56    events_rx: chan::Receiver<Result<Event, io::Error>>,
57) -> Result<(), Error> {
58    let stdout = io::stdout();
59    if !stdout.is_terminal() {
60        element.print();
61        return Ok(());
62    }
63    let raw = stdout.into_raw_mode()?;
64    let mut stdout = termion::input::MouseTerminal::from(raw).into_alternate_screen()?;
65    let (mut width, mut height) = termion::terminal_size()?;
66    let mut lines = element.render(Constraint::max(Size::new(width as usize, height as usize)));
67    let mut line = 0;
68
69    render(&mut stdout, lines.as_slice(), line, (width, height))?;
70
71    loop {
72        chan::select! {
73            recv(signals_rx) -> signal => {
74                match signal? {
75                    signals::Signal::WindowChanged => {
76                        let (w, h) = termion::terminal_size()?;
77
78                        lines = element.render(Constraint::max(Size::new(w as usize, h as usize)));
79                        width = w;
80                        height = h;
81                    }
82                    signals::Signal::Interrupt | signals::Signal::Terminate => {
83                        break;
84                    }
85                    _ => continue,
86                }
87            }
88            recv(events_rx) -> event => {
89                let event = event??;
90                let page = height as usize - 1; // Don't count the status bar.
91                let end = if page > lines.len() { 0 } else { lines.len() - page };
92                let prev = line;
93
94                match event {
95                    Event::Key(key) => match key {
96                        Key::Up | Key::Char('k') => {
97                            line = line.saturating_sub(1);
98                        }
99                        Key::Home => {
100                            line = 0;
101                        }
102                        Key::End | Key::Char('G') => {
103                            line = end;
104                        }
105                        Key::PageUp | Key::Char('b') => {
106                            line = line.saturating_sub(page);
107                        }
108                        Key::PageDown | Key::Char(' ') => {
109                            line = (line + page).min(end);
110                        }
111                        Key::Down | Key::Char('j') => {
112                            if line < end {
113                                line += 1;
114                            }
115                        }
116                        Key::Char('q') => break,
117
118                        _ => continue,
119                    }
120                    Event::Mouse(MouseEvent::Press(MouseButton::WheelDown, _, _)) => {
121                        if line < end {
122                            line += MOUSE_SCROLL_LINES;
123                        }
124                    }
125                    Event::Mouse(MouseEvent::Press(MouseButton::WheelUp, _, _)) => {
126                        line = line.saturating_sub(MOUSE_SCROLL_LINES);
127                    }
128                    _ => continue,
129                }
130                // Don't re-render if there's no change in line.
131                if line == prev {
132                    continue;
133                }
134            }
135        }
136        render(&mut stdout, &lines, line, (width, height))?;
137    }
138    Ok(())
139}
140
141fn render<W: Write>(
142    out: &mut W,
143    lines: &[Line],
144    start_line: usize,
145    (width, height): (u16, u16),
146) -> io::Result<()> {
147    write!(
148        out,
149        "{}{}",
150        termion::clear::All,
151        termion::cursor::Goto(1, 1)
152    )?;
153
154    let content_length = lines.len();
155    let window_size = height as usize - 1;
156    let end_line = if start_line + window_size > content_length {
157        content_length
158    } else {
159        start_line + window_size
160    };
161    // Render content.
162    for (ix, line) in lines[start_line..end_line].iter().enumerate() {
163        write!(out, "{}{}", termion::cursor::Goto(1, ix as u16 + 1), line)?;
164    }
165    // Render progress meter.
166    write!(
167        out,
168        "{}{}",
169        termion::cursor::Goto(width - 3, height),
170        Paint::new(format!(
171            "{:.0}%",
172            end_line as f64 / lines.len() as f64 * 100.
173        ))
174        .dim()
175    )?;
176    // Render cursor input area.
177    write!(
178        out,
179        "{}{}",
180        termion::cursor::Goto(1, height),
181        Paint::new(":").dim()
182    )?;
183    out.flush()?;
184
185    Ok(())
186}