diff --git a/Cargo.lock b/Cargo.lock index 5f7ca9f3..5c838cd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2623,14 +2623,17 @@ version = "0.9.0" dependencies = [ "anstyle-query", "anyhow", + "crossbeam-channel", "git2", "inquire", "libc", "once_cell", "pretty_assertions", + "radicle-signals", "shlex", "tempfile", "termion 3.0.0", + "thiserror", "unicode-display-width", "unicode-segmentation", "zeroize", @@ -2644,6 +2647,7 @@ dependencies = [ "radicle", "radicle-cli", "radicle-git-ext", + "radicle-term", ] [[package]] diff --git a/radicle-term/Cargo.toml b/radicle-term/Cargo.toml index abf9dfc5..f22c0bec 100644 --- a/radicle-term/Cargo.toml +++ b/radicle-term/Cargo.toml @@ -14,11 +14,13 @@ default = ["git2"] [dependencies] anyhow = { version = "1" } anstyle-query = { version = "1.0.0" } +crossbeam-channel = { version = "0.5.6" } inquire = { version = "0.7.4", default-features = false, features = ["termion", "editor"] } libc = { version = "0.2" } once_cell = { version = "1.13" } shlex = { version = "1.1" } termion = { version = "3" } +thiserror = { version = "1" } unicode-display-width = { version = "0.3.0" } unicode-segmentation = { version = "1.7.1" } zeroize = { version = "1.1" } @@ -29,6 +31,10 @@ default-features = false features = ["vendored-libgit2"] optional = true +[dependencies.radicle-signals] +path = "../radicle-signals" +version = "0" + [dev-dependencies] pretty_assertions = { version = "1.3.0" } tempfile = { version = "3.3.0" } diff --git a/radicle-term/src/lib.rs b/radicle-term/src/lib.rs index 7102da48..b7fd4f05 100644 --- a/radicle-term/src/lib.rs +++ b/radicle-term/src/lib.rs @@ -8,6 +8,7 @@ pub mod format; pub mod hstack; pub mod io; pub mod label; +pub mod pager; pub mod spinner; pub mod table; pub mod textarea; diff --git a/radicle-term/src/pager.rs b/radicle-term/src/pager.rs new file mode 100644 index 00000000..a25a6e50 --- /dev/null +++ b/radicle-term/src/pager.rs @@ -0,0 +1,176 @@ +use std::io::{IsTerminal, Write}; +use std::{io, thread}; + +use crate::element::Size; +use crate::{Constraint, Element, Line, Paint}; + +use crossbeam_channel as chan; +use radicle_signals as signals; +use termion::event::{Event, Key, MouseButton, MouseEvent}; +use termion::{input::TermRead, raw::IntoRawMode, screen::IntoAlternateScreen}; + +/// How many lines to scroll when the mouse wheel is used. +const MOUSE_SCROLL_LINES: usize = 3; + +/// Pager error. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Channel(#[from] chan::RecvError), +} + +/// A pager for the given element. Re-renders the element when the terminal is resized so that +/// it doesn't wrap. If the output device is not a TTY, just prints the element via +/// [`Element::print`]. +pub fn page(element: E) -> Result<(), Error> { + let (events_tx, events_rx) = chan::unbounded(); + let (signals_tx, signals_rx) = chan::unbounded(); + + signals::install(signals_tx)?; + + thread::spawn(move || { + for e in io::stdin().events() { + events_tx.send(e).ok(); + } + }); + thread::spawn(move || main(element, signals_rx, events_rx)) + .join() + .unwrap() +} + +fn main( + element: E, + signals_rx: chan::Receiver, + events_rx: chan::Receiver>, +) -> Result<(), Error> { + let stdout = io::stdout(); + if !stdout.is_terminal() { + element.print(); + return Ok(()); + } + let raw = stdout.into_raw_mode()?; + let mut stdout = termion::input::MouseTerminal::from(raw).into_alternate_screen()?; + let (mut width, mut height) = termion::terminal_size()?; + let mut lines = element.render(Constraint::max(Size::new(width as usize, height as usize))); + let mut line = 0; + + render(&mut stdout, lines.as_slice(), line, (width, height))?; + + loop { + chan::select! { + recv(signals_rx) -> signal => { + match signal? { + signals::Signal::WindowChanged => { + let (w, h) = termion::terminal_size()?; + + lines = element.render(Constraint::max(Size::new(w as usize, h as usize))); + width = w; + height = h; + } + signals::Signal::Interrupt | signals::Signal::Terminate => { + break; + } + _ => continue, + } + } + recv(events_rx) -> event => { + let event = event??; + let page = height as usize - 1; // Don't count the status bar. + let end = lines.len() - page; + let prev = line; + + match event { + Event::Key(key) => match key { + Key::Up | Key::Char('k') => { + line = line.saturating_sub(1); + } + Key::Home => { + line = 0; + } + Key::End | Key::Char('G') => { + line = end; + } + Key::PageUp | Key::Char('b') => { + line = line.saturating_sub(page); + } + Key::PageDown | Key::Char(' ') => { + line = (line + page).min(end); + } + Key::Down | Key::Char('j') => { + if line < end { + line += 1; + } + } + Key::Char('q') => break, + + _ => continue, + } + Event::Mouse(MouseEvent::Press(MouseButton::WheelDown, _, _)) => { + if line < end { + line += MOUSE_SCROLL_LINES; + } + } + Event::Mouse(MouseEvent::Press(MouseButton::WheelUp, _, _)) => { + line = line.saturating_sub(MOUSE_SCROLL_LINES); + } + _ => continue, + } + // Don't re-render if there's no change in line. + if line == prev { + continue; + } + } + } + render(&mut stdout, &lines, line, (width, height))?; + } + Ok(()) +} + +fn render( + out: &mut W, + lines: &[Line], + start_line: usize, + (width, height): (u16, u16), +) -> io::Result<()> { + write!( + out, + "{}{}", + termion::clear::All, + termion::cursor::Goto(1, 1) + )?; + + let content_length = lines.len(); + let window_size = height as usize - 1; + let end_line = if start_line + window_size > content_length { + content_length + } else { + start_line + window_size + }; + // Render content. + for (ix, line) in lines[start_line..end_line].iter().enumerate() { + write!(out, "{}{}", termion::cursor::Goto(1, ix as u16 + 1), line)?; + } + // Render progress meter. + write!( + out, + "{}{}", + termion::cursor::Goto(width - 3, height), + Paint::new(format!( + "{:.0}%", + end_line as f64 / lines.len() as f64 * 100. + )) + .dim() + )?; + // Render cursor input area. + write!( + out, + "{}{}", + termion::cursor::Goto(1, height), + Paint::new(":").dim() + )?; + out.flush()?; + + Ok(()) +} diff --git a/radicle-tools/Cargo.toml b/radicle-tools/Cargo.toml index b813da84..a940f965 100644 --- a/radicle-tools/Cargo.toml +++ b/radicle-tools/Cargo.toml @@ -19,6 +19,10 @@ path = "../radicle" version = "0" path = "../radicle-cli" +[dependencies.radicle-term] +version = "0" +path = "../radicle-term" + [[bin]] name = "rad-init" path = "src/rad-init.rs" diff --git a/radicle-tools/src/rad-cli-demo.rs b/radicle-tools/src/rad-cli-demo.rs index 618662b7..36caf318 100644 --- a/radicle-tools/src/rad-cli-demo.rs +++ b/radicle-tools/src/rad-cli-demo.rs @@ -7,6 +7,7 @@ fn main() -> anyhow::Result<()> { "Choose something to try out:", &[ "confirm", + "pager", "spinner", "spinner-drop", "spinner-error", @@ -22,6 +23,17 @@ fn main() -> anyhow::Result<()> { terminal::success!("You said 'yes'"); } } + "pager" => { + let mut table = radicle_term::Table::<1, radicle_term::Label>::new( + radicle_term::TableOptions::bordered(), + ); + let rows = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/rad-cli-demo.rs")); + + for row in rows.lines() { + table.push([row.into()]); + } + radicle_term::pager::page(table)?; + } "editor" => { let output = terminal::editor::Editor::new() .extension("rs")