diff --git a/Cargo.lock b/Cargo.lock index 7e64c1bc..6389ae4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1824,6 +1824,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "shlex", "tempfile", "thiserror", "timeago", diff --git a/radicle-cli/Cargo.toml b/radicle-cli/Cargo.toml index 68445c13..94fbd9a8 100644 --- a/radicle-cli/Cargo.toml +++ b/radicle-cli/Cargo.toml @@ -26,6 +26,7 @@ radicle-surf = { version = "0.17.0" } serde = { version = "1.0" } serde_json = { version = "1" } serde_yaml = { version = "0.8" } +shlex = { version = "1.1.0" } tempfile = { version = "3.3.0" } thiserror = { version = "1" } timeago = { version = "0.3", default-features = false } diff --git a/radicle-cli/src/commands/diff.rs b/radicle-cli/src/commands/diff.rs index 142de8f5..55182958 100644 --- a/radicle-cli/src/commands/diff.rs +++ b/radicle-cli/src/commands/diff.rs @@ -8,10 +8,10 @@ use radicle_surf as surf; use crate::git::pretty_diff::ToPretty as _; use crate::git::Rev; +use crate::pager; use crate::terminal as term; use crate::terminal::args::{Args, Error, Help}; use crate::terminal::highlight::Highlighter; -use crate::terminal::{Constraint, Element as _}; pub const HELP: Help = Help { name: "diff", @@ -145,7 +145,7 @@ pub fn run(options: Options, _ctx: impl term::Context) -> anyhow::Result<()> { let mut hi = Highlighter::default(); let pretty = diff.pretty(&mut hi, &(), &repo); - pretty.write(Constraint::from_env().unwrap_or_default()); + pager::run(pretty)?; Ok(()) } diff --git a/radicle-cli/src/lib.rs b/radicle-cli/src/lib.rs index 2b151c21..3361d6ff 100644 --- a/radicle-cli/src/lib.rs +++ b/radicle-cli/src/lib.rs @@ -3,5 +3,6 @@ #![allow(clippy::too_many_arguments)] pub mod commands; pub mod git; +pub mod pager; pub mod project; pub mod terminal; diff --git a/radicle-cli/src/pager.rs b/radicle-cli/src/pager.rs new file mode 100644 index 00000000..35bf358b --- /dev/null +++ b/radicle-cli/src/pager.rs @@ -0,0 +1,51 @@ +use std::io; +use std::process::{Command, Stdio}; + +use radicle_term::element; +use radicle_term::{Constraint, Element}; + +use crate::terminal; + +/// Output the given element through a pager, if necessary. +/// If it fits within the screen, don't run it through a pager. +pub fn run(elem: impl Element) -> io::Result<()> { + let Some(constraint) = Constraint::from_env() else { + return elem.write(Constraint::UNBOUNDED); + }; + let Some(rows) = terminal::rows() else { + return elem.write(Constraint::UNBOUNDED); + }; + if elem.size(Constraint::UNBOUNDED).rows <= rows { + return elem.write(Constraint::UNBOUNDED); + } + let Some(pager) = radicle::profile::env::pager() else { + return elem.write(Constraint::UNBOUNDED); + }; + let Some(parts) = shlex::split(&pager) else { + return elem.write(Constraint::UNBOUNDED); + }; + let Some((program, args)) = parts.split_first() else { + return elem.write(Constraint::UNBOUNDED); + }; + + let mut child = Command::new(program) + .stdin(Stdio::piped()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .args(args) + .spawn()?; + + let writer = child.stdin.as_mut().unwrap(); + let result = element::write_to(&elem, writer, constraint); + + child.wait()?; + + match result { + // This error is expected when the pager is exited. + Err(e) if e.kind() == io::ErrorKind::BrokenPipe => {} + Err(e) => return Err(e), + Ok(_) => {} + } + + Ok(()) +} diff --git a/radicle-term/src/element.rs b/radicle-term/src/element.rs index 2fb5501b..f0f5679b 100644 --- a/radicle-term/src/element.rs +++ b/radicle-term/src/element.rs @@ -43,19 +43,12 @@ impl Constraint { } } - /// A constraint that can only be satisfied by a single size. - pub fn tight(size: Size) -> Self { + /// A constraint that can only be satisfied by a single column size. + /// The rows are unconstrained. + pub fn tight(cols: usize) -> Self { Self { - min: size, - max: size, - } - } - - /// Return a new constraint that forces objects to the maximum size. - pub fn maximize(self) -> Self { - Self { - min: self.max, - max: self.max, + min: Size::new(cols, 1), + max: Size::new(cols, usize::MAX), } } @@ -96,11 +89,12 @@ pub trait Element: fmt::Debug { } } - /// Write to a writer. - fn write(&self, constraints: Constraint) { - for line in self.render(constraints) { - println!("{}", line.to_string().trim_end()); - } + /// Write using the given constraints to `stdout`. + fn write(&self, constraints: Constraint) -> io::Result<()> + where + Self: Sized, + { + self::write_to(self, &mut io::stdout(), constraints) } #[must_use] @@ -143,6 +137,18 @@ impl Element for &T { } } +/// Write using the given constraints, to a writer. +pub fn write_to( + elem: &impl Element, + writer: &mut impl io::Write, + constraints: Constraint, +) -> io::Result<()> { + for line in elem.render(constraints) { + writeln!(writer, "{}", line.to_string().trim_end())?; + } + Ok(()) +} + /// A line of text that has styling and can be displayed. #[derive(Clone, Default, Debug)] pub struct Line { diff --git a/radicle-term/src/io.rs b/radicle-term/src/io.rs index 3a20dfcb..1f8f4fc4 100644 --- a/radicle-term/src/io.rs +++ b/radicle-term/src/io.rs @@ -92,6 +92,10 @@ pub fn columns() -> Option { termion::terminal_size().map(|(cols, _)| cols as usize).ok() } +pub fn rows() -> Option { + termion::terminal_size().map(|(_, rows)| rows as usize).ok() +} + pub fn viewport() -> Option { termion::terminal_size() .map(|(cols, rows)| Size::new(cols as usize, rows as usize)) diff --git a/radicle-term/src/vstack.rs b/radicle-term/src/vstack.rs index 6fb25b9b..86332dd7 100644 --- a/radicle-term/src/vstack.rs +++ b/radicle-term/src/vstack.rs @@ -32,6 +32,13 @@ impl<'a> Row<'a> { Self::Dividier => c.min.cols, } } + + fn height(&self, c: Constraint) -> usize { + match self { + Self::Element(e) => e.rows(c), + Self::Dividier => 1, + } + } } /// Vertical stack of [`Element`] objects that implements [`Element`]. @@ -109,7 +116,7 @@ impl<'a> VStack<'a> { fn outer(&self, c: Constraint) -> Size { let padding = self.opts.padding * 2; let mut cols = self.rows.iter().map(|r| r.width(c)).max().unwrap_or(0) + padding; - let mut rows = self.rows.len(); + let mut rows = self.rows.iter().map(|r| r.height(c)).sum(); // Account for outer borders. if self.opts.border.is_some() { @@ -129,10 +136,7 @@ impl<'a> Element for VStack<'a> { let mut lines = Vec::new(); let padding = self.opts.padding; let inner = self.inner(parent); - let child = Constraint::tight(Size { - cols: inner.cols - padding * 2, - rows: usize::MAX, - }); + let child = Constraint::tight(inner.cols - padding * 2); if let Some(color) = self.opts.border { lines.push( diff --git a/radicle/src/profile.rs b/radicle/src/profile.rs index a0e33717..441d8d07 100644 --- a/radicle/src/profile.rs +++ b/radicle/src/profile.rs @@ -40,9 +40,22 @@ pub mod env { /// RNG seed. Must be convertible to a `u64`. pub const RAD_RNG_SEED: &str = "RAD_RNG_SEED"; + /// Get the configured pager program from the environment. + pub fn pager() -> Option { + if let Ok(cfg) = git2::Config::open_default() { + if let Ok(pager) = cfg.get_string("core.pager") { + return Some(pager); + } + } + if let Ok(pager) = var("PAGER") { + return Some(pager); + } + None + } + /// Get the radicle passphrase from the environment. pub fn passphrase() -> Option { - let Ok(passphrase) = std::env::var(RAD_PASSPHRASE) else { + let Ok(passphrase) = var(RAD_PASSPHRASE) else { return None; }; Some(super::Passphrase::from(passphrase)) @@ -50,7 +63,7 @@ pub mod env { /// Get a random number generator from the environment. pub fn rng() -> fastrand::Rng { - if let Ok(seed) = std::env::var(RAD_RNG_SEED) { + if let Ok(seed) = var(RAD_RNG_SEED) { return fastrand::Rng::with_seed( seed.parse() .expect("env::rng: invalid seed specified in `RAD_RNG_SEED`"),