Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
Initialize `radicle-term` crate
Alexis Sellier committed 3 years ago
commit 02304875ce4a03ef1e40e22673b80136804e0619
parent 82d858314fd194a56e38692e9e5540602b732274
40 files changed +2344 -2064
modified Cargo.lock
@@ -1835,6 +1835,7 @@ dependencies = [
 "radicle-cob",
 "radicle-crypto",
 "radicle-node",
+
 "radicle-term",
 "serde",
 "serde_json",
 "serde_yaml",
@@ -2051,6 +2052,22 @@ dependencies = [
]

[[package]]
+
name = "radicle-term"
+
version = "0.1.0"
+
dependencies = [
+
 "anyhow",
+
 "concolor",
+
 "inquire",
+
 "once_cell",
+
 "pretty_assertions",
+
 "tempfile",
+
 "termion 2.0.1",
+
 "unicode-segmentation",
+
 "unicode-width",
+
 "zeroize",
+
]
+

+
[[package]]
name = "radicle-tools"
version = "0.2.0"
dependencies = [
modified Cargo.toml
@@ -22,6 +22,7 @@ default-members = [
  "radicle-node",
  "radicle-ssh",
  "radicle-remote-helper",
+
  "radicle-term",
]

[profile.container]
modified radicle-cli/Cargo.toml
@@ -47,6 +47,10 @@ path = "../radicle-cob"
version = "0"
path = "../radicle-crypto"

+
[dependencies.radicle-term]
+
version = "0"
+
path = "../radicle-term"
+

[dev-dependencies]
pretty_assertions = { version = "1.3.0" }
radicle = { path = "../radicle", features = ["test"] }
modified radicle-cli/src/commands/auth.rs
@@ -5,6 +5,7 @@ use anyhow::anyhow;

use radicle::crypto::ssh;
use radicle::crypto::ssh::Passphrase;
+
use radicle::profile::env::RAD_PASSPHRASE;
use radicle::{profile, Profile};

use crate::terminal as term;
@@ -87,7 +88,7 @@ pub fn init(options: Options) -> anyhow::Result<()> {
    let passphrase = if options.stdin {
        term::passphrase_stdin()
    } else {
-
        term::passphrase_confirm("Enter a passphrase:")
+
        term::passphrase_confirm("Enter a passphrase:", RAD_PASSPHRASE)
    }?;
    let spinner = term::spinner("Creating your Ed25519 keypair...");
    let profile = Profile::init(home, passphrase.clone())?;
@@ -132,7 +133,7 @@ pub fn authenticate(profile: &Profile, options: Options) -> anyhow::Result<()> {
        let passphrase = if options.stdin {
            term::passphrase_stdin()
        } else {
-
            term::passphrase()
+
            term::passphrase(RAD_PASSPHRASE)
        }?;
        let spinner = term::spinner("Unlocking...");
        register(profile, passphrase)?;
modified radicle-cli/src/commands/id.rs
@@ -8,8 +8,9 @@ use radicle::prelude::{Did, Doc};
use radicle::storage::ReadStorage as _;
use radicle_crypto::Verified;

+
use crate::terminal as term;
use crate::terminal::args::{Args, Error, Help};
-
use crate::terminal::{self as term, Interactive};
+
use crate::terminal::Interactive;

pub const HELP: Help = Help {
    name: "id",
modified radicle-cli/src/commands/issue.rs
@@ -218,7 +218,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        }
        Operation::React { id, reaction } => {
            if let Ok(mut issue) = issues.get_mut(&id) {
-
                let (comment_id, _) = term::comment_select(&issue).unwrap();
+
                let (comment_id, _) = term::io::comment_select(&issue).unwrap();
                issue.react(*comment_id, reaction, &signer)?;
            }
        }
modified radicle-cli/src/terminal.rs
@@ -1,29 +1,18 @@
-
pub mod ansi;
pub mod args;
-
pub mod cell;
+
pub use args::{Args, Error, Help};
pub mod cob;
-
pub mod command;
-
pub mod editor;
pub mod format;
pub mod io;
+
pub use io::{proposal, signer};
pub mod patch;
-
pub mod spinner;
-
pub mod table;
-
pub mod textbox;
+
pub use radicle_term::*;

use std::ffi::OsString;
use std::process;

use radicle::profile::Profile;

-
pub use ansi::{paint, Paint};
-
pub use args::{Args, Error, Help};
-
pub use editor::Editor;
-
pub use inquire::ui::Styled;
-
pub use io::*;
-
pub use spinner::{spinner, Spinner};
-
pub use table::Table;
-
pub use textbox::TextBox;
+
use crate::terminal;

/// Context passed to all commands.
pub trait Context {
@@ -117,7 +106,7 @@ where
    match cmd.run(options, self::profile) {
        Ok(()) => process::exit(0),
        Err(err) => {
-
            term::fail(&format!("{action} failed"), &err);
+
            terminal::fail(&format!("{action} failed"), &err);
            process::exit(1);
        }
    }
@@ -136,47 +125,19 @@ pub fn profile() -> Result<Profile, anyhow::Error> {
    }
}

-
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
-
pub enum Interactive {
-
    Yes,
-
    No,
-
}
+
pub fn fail(header: &str, error: &anyhow::Error) {
+
    let err = error.to_string();
+
    let err = err.trim_end();
+
    let separator = if err.contains('\n') { ":\n" } else { ": " };

-
impl Default for Interactive {
-
    fn default() -> Self {
-
        Interactive::No
-
    }
-
}
+
    println!(
+
        "{ERROR_PREFIX} {}{}{error}",
+
        Paint::red(header).bold(),
+
        Paint::red(separator),
+
    );

-
impl Interactive {
-
    pub fn yes(&self) -> bool {
-
        (*self).into()
+
    if let Some(Error::WithHint { hint, .. }) = error.downcast_ref::<Error>() {
+
        println!("{} {}", ERROR_HINT_PREFIX, Paint::yellow(hint));
+
        blank();
    }
-

-
    pub fn no(&self) -> bool {
-
        !self.yes()
-
    }
-
}
-

-
impl From<Interactive> for bool {
-
    fn from(c: Interactive) -> Self {
-
        match c {
-
            Interactive::Yes => true,
-
            Interactive::No => false,
-
        }
-
    }
-
}
-

-
impl From<bool> for Interactive {
-
    fn from(b: bool) -> Self {
-
        if b {
-
            Interactive::Yes
-
        } else {
-
            Interactive::No
-
        }
-
    }
-
}
-

-
pub fn style<T>(item: T) -> Paint<T> {
-
    paint(item)
}
deleted radicle-cli/src/terminal/ansi.rs
@@ -1,16 +0,0 @@
-
//! A dead simple ANSI terminal color painting library.
-
//!
-
//! This library is a port of the `yansi` crate.
-
//! Copyright (c) 2017 Sergio Benitez
-
//!
-
mod color;
-
mod paint;
-
mod style;
-
#[cfg(test)]
-
mod tests;
-
mod windows;
-

-
pub use color::Color;
-
pub use paint::paint;
-
pub use paint::Paint;
-
pub use style::Style;
deleted radicle-cli/src/terminal/ansi/color.rs
@@ -1,63 +0,0 @@
-
use std::fmt;
-

-
use super::{Paint, Style};
-

-
/// An enum representing an ANSI color code.
-
#[derive(Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Copy, Clone)]
-
pub enum Color {
-
    /// No color has been set. Nothing is changed when applied.
-
    #[default]
-
    Unset,
-
    /// Black #0 (foreground code `30`, background code `40`).
-
    Black,
-
    /// Red: #1 (foreground code `31`, background code `41`).
-
    Red,
-
    /// Green: #2 (foreground code `32`, background code `42`).
-
    Green,
-
    /// Yellow: #3 (foreground code `33`, background code `43`).
-
    Yellow,
-
    /// Blue: #4 (foreground code `34`, background code `44`).
-
    Blue,
-
    /// Magenta: #5 (foreground code `35`, background code `45`).
-
    Magenta,
-
    /// Cyan: #6 (foreground code `36`, background code `46`).
-
    Cyan,
-
    /// White: #7 (foreground code `37`, background code `47`).
-
    White,
-
    /// A color number from 0 to 255, for use in 256-color terminals.
-
    Fixed(u8),
-
    /// A 24-bit RGB color, as specified by ISO-8613-3.
-
    RGB(u8, u8, u8),
-
}
-

-
impl Color {
-
    /// Constructs a new `Paint` structure that encapsulates `item` with the
-
    /// foreground color set to the color `self`.
-
    #[inline]
-
    pub fn paint<T>(self, item: T) -> Paint<T> {
-
        Paint::new(item).fg(self)
-
    }
-

-
    /// Constructs a new `Style` structure with the foreground color set to the
-
    /// color `self`.
-
    #[inline]
-
    pub const fn style(self) -> Style {
-
        Style::new(self)
-
    }
-

-
    pub(crate) fn ansi_fmt(&self, f: &mut dyn fmt::Write) -> fmt::Result {
-
        match *self {
-
            Color::Unset => Ok(()),
-
            Color::Black => write!(f, "0"),
-
            Color::Red => write!(f, "1"),
-
            Color::Green => write!(f, "2"),
-
            Color::Yellow => write!(f, "3"),
-
            Color::Blue => write!(f, "4"),
-
            Color::Magenta => write!(f, "5"),
-
            Color::Cyan => write!(f, "6"),
-
            Color::White => write!(f, "7"),
-
            Color::Fixed(num) => write!(f, "8;5;{num}"),
-
            Color::RGB(r, g, b) => write!(f, "8;2;{r};{g};{b}"),
-
        }
-
    }
-
}
deleted radicle-cli/src/terminal/ansi/paint.rs
@@ -1,252 +0,0 @@
-
use std::fmt;
-

-
use unicode_width::UnicodeWidthStr;
-

-
use super::color::Color;
-
use super::style::{Property, Style};
-

-
/// A structure encapsulating an item and styling.
-
#[derive(Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Copy, Clone)]
-
pub struct Paint<T> {
-
    pub item: T,
-
    pub style: Style,
-
}
-

-
impl Paint<&str> {
-
    /// Return plain content.
-
    pub fn content(&self) -> &str {
-
        self.item
-
    }
-
}
-

-
impl Paint<String> {
-
    /// Return plain content.
-
    pub fn content(&self) -> &str {
-
        self.item.as_str()
-
    }
-
}
-

-
impl<T> From<T> for Paint<T> {
-
    fn from(value: T) -> Self {
-
        Self::new(value)
-
    }
-
}
-

-
impl From<&str> for Paint<String> {
-
    fn from(item: &str) -> Self {
-
        Self::new(item.to_string())
-
    }
-
}
-

-
impl<T> Paint<T> {
-
    /// Constructs a new `Paint` structure encapsulating `item` with no set
-
    /// styling.
-
    #[inline]
-
    pub const fn new(item: T) -> Paint<T> {
-
        Paint {
-
            item,
-
            style: Style {
-
                foreground: Color::Unset,
-
                background: Color::Unset,
-
                properties: Property::new(),
-
                wrap: false,
-
            },
-
        }
-
    }
-

-
    /// Constructs a new _wrapping_ `Paint` structure encapsulating `item` with
-
    /// default styling.
-
    ///
-
    /// A wrapping `Paint` converts all color resets written out by the internal
-
    /// value to the styling of itself. This allows for seamless color wrapping
-
    /// of other colored text.
-
    ///
-
    /// # Performance
-
    ///
-
    /// In order to wrap an internal value, the internal value must first be
-
    /// written out to a local buffer and examined. As a result, displaying a
-
    /// wrapped value is likely to result in a heap allocation and copy.
-
    #[inline]
-
    pub const fn wrapping(item: T) -> Paint<T> {
-
        Paint::new(item).wrap()
-
    }
-

-
    /// Constructs a new `Paint` structure encapsulating `item` with the
-
    /// foreground color set to the RGB color `r`, `g`, `b`.
-
    #[inline]
-
    pub const fn rgb(r: u8, g: u8, b: u8, item: T) -> Paint<T> {
-
        Paint::new(item).fg(Color::RGB(r, g, b))
-
    }
-

-
    /// Constructs a new `Paint` structure encapsulating `item` with the
-
    /// foreground color set to the fixed 8-bit color `color`.
-
    #[inline]
-
    pub const fn fixed(color: u8, item: T) -> Paint<T> {
-
        Paint::new(item).fg(Color::Fixed(color))
-
    }
-

-
    pub const fn red(item: T) -> Paint<T> {
-
        Paint::new(item).fg(Color::Red)
-
    }
-

-
    pub const fn black(item: T) -> Paint<T> {
-
        Paint::new(item).fg(Color::Black)
-
    }
-

-
    pub const fn yellow(item: T) -> Paint<T> {
-
        Paint::new(item).fg(Color::Yellow)
-
    }
-

-
    pub const fn green(item: T) -> Paint<T> {
-
        Paint::new(item).fg(Color::Green)
-
    }
-

-
    pub const fn cyan(item: T) -> Paint<T> {
-
        Paint::new(item).fg(Color::Cyan)
-
    }
-

-
    pub const fn blue(item: T) -> Paint<T> {
-
        Paint::new(item).fg(Color::Blue)
-
    }
-

-
    pub const fn magenta(item: T) -> Paint<T> {
-
        Paint::new(item).fg(Color::Magenta)
-
    }
-

-
    pub const fn white(item: T) -> Paint<T> {
-
        Paint::new(item).fg(Color::White)
-
    }
-

-
    /// Retrieves the style currently set on `self`.
-
    #[inline]
-
    pub const fn style(&self) -> Style {
-
        self.style
-
    }
-

-
    /// Retrieves a borrow to the inner item.
-
    #[inline]
-
    pub const fn inner(&self) -> &T {
-
        &self.item
-
    }
-

-
    /// Sets the style of `self` to `style`.
-
    #[inline]
-
    pub fn with_style(mut self, style: Style) -> Paint<T> {
-
        self.style = style;
-
        self
-
    }
-

-
    /// Makes `self` a _wrapping_ `Paint`.
-
    ///
-
    /// A wrapping `Paint` converts all color resets written out by the internal
-
    /// value to the styling of itself. This allows for seamless color wrapping
-
    /// of other colored text.
-
    ///
-
    /// # Performance
-
    ///
-
    /// In order to wrap an internal value, the internal value must first be
-
    /// written out to a local buffer and examined. As a result, displaying a
-
    /// wrapped value is likely to result in a heap allocation and copy.
-
    #[inline]
-
    pub const fn wrap(mut self) -> Paint<T> {
-
        self.style.wrap = true;
-
        self
-
    }
-

-
    /// Sets the foreground to `color`.
-
    #[inline]
-
    pub const fn fg(mut self, color: Color) -> Paint<T> {
-
        self.style.foreground = color;
-
        self
-
    }
-

-
    /// Sets the background to `color`.
-
    #[inline]
-
    pub const fn bg(mut self, color: Color) -> Paint<T> {
-
        self.style.background = color;
-
        self
-
    }
-

-
    pub fn bold(mut self) -> Self {
-
        self.style.properties.set(Property::BOLD);
-
        self
-
    }
-

-
    pub fn dim(mut self) -> Self {
-
        self.style.properties.set(Property::DIM);
-
        self
-
    }
-

-
    pub fn italic(mut self) -> Self {
-
        self.style.properties.set(Property::ITALIC);
-
        self
-
    }
-

-
    pub fn underline(mut self) -> Self {
-
        self.style.properties.set(Property::UNDERLINE);
-
        self
-
    }
-

-
    pub fn invert(mut self) -> Self {
-
        self.style.properties.set(Property::INVERT);
-
        self
-
    }
-

-
    pub fn strikethrough(mut self) -> Self {
-
        self.style.properties.set(Property::STRIKETHROUGH);
-
        self
-
    }
-

-
    pub fn blink(mut self) -> Self {
-
        self.style.properties.set(Property::BLINK);
-
        self
-
    }
-

-
    pub fn hidden(mut self) -> Self {
-
        self.style.properties.set(Property::HIDDEN);
-
        self
-
    }
-
}
-

-
impl<T: UnicodeWidthStr> UnicodeWidthStr for Paint<T> {
-
    fn width(&self) -> usize {
-
        self.item.width()
-
    }
-

-
    fn width_cjk(&self) -> usize {
-
        self.item.width_cjk()
-
    }
-
}
-

-
impl<T: fmt::Display> fmt::Display for Paint<T> {
-
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-
        if Paint::is_enabled() && self.style.wrap {
-
            let mut prefix = String::new();
-
            prefix.push_str("\x1B[0m");
-
            self.style.fmt_prefix(&mut prefix)?;
-
            self.style.fmt_prefix(f)?;
-

-
            let item = format!("{}", self.item).replace("\x1B[0m", &prefix);
-
            fmt::Display::fmt(&item, f)?;
-
            self.style.fmt_suffix(f)
-
        } else if Paint::is_enabled() {
-
            self.style.fmt_prefix(f)?;
-
            fmt::Display::fmt(&self.item, f)?;
-
            self.style.fmt_suffix(f)
-
        } else {
-
            fmt::Display::fmt(&self.item, f)
-
        }
-
    }
-
}
-

-
impl Paint<()> {
-
    /// Returns `true` if coloring is enabled and `false` otherwise.
-
    pub fn is_enabled() -> bool {
-
        concolor::get(concolor::Stream::Stdout).ansi_color()
-
    }
-
}
-

-
/// Shorthand for [`Paint::new`].
-
pub fn paint<T>(item: T) -> Paint<T> {
-
    Paint::new(item)
-
}
deleted radicle-cli/src/terminal/ansi/style.rs
@@ -1,267 +0,0 @@
-
use std::fmt::{self, Display};
-
use std::hash::{Hash, Hasher};
-
use std::ops::BitOr;
-

-
use super::{Color, Paint};
-

-
#[derive(Default, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Copy, Clone)]
-
pub struct Property(u8);
-

-
impl Property {
-
    pub const BOLD: Self = Property(1 << 0);
-
    pub const DIM: Self = Property(1 << 1);
-
    pub const ITALIC: Self = Property(1 << 2);
-
    pub const UNDERLINE: Self = Property(1 << 3);
-
    pub const BLINK: Self = Property(1 << 4);
-
    pub const INVERT: Self = Property(1 << 5);
-
    pub const HIDDEN: Self = Property(1 << 6);
-
    pub const STRIKETHROUGH: Self = Property(1 << 7);
-

-
    pub const fn new() -> Self {
-
        Property(0)
-
    }
-

-
    #[inline(always)]
-
    pub const fn contains(self, other: Property) -> bool {
-
        (other.0 & self.0) == other.0
-
    }
-

-
    #[inline(always)]
-
    pub fn set(&mut self, other: Property) {
-
        self.0 |= other.0;
-
    }
-

-
    #[inline(always)]
-
    pub fn iter(self) -> Iter {
-
        Iter {
-
            index: 0,
-
            properties: self,
-
        }
-
    }
-
}
-

-
impl BitOr for Property {
-
    type Output = Self;
-

-
    #[inline(always)]
-
    fn bitor(self, rhs: Self) -> Self {
-
        Property(self.0 | rhs.0)
-
    }
-
}
-

-
pub struct Iter {
-
    index: u8,
-
    properties: Property,
-
}
-

-
impl Iterator for Iter {
-
    type Item = usize;
-

-
    fn next(&mut self) -> Option<Self::Item> {
-
        while self.index < 8 {
-
            let index = self.index;
-
            self.index += 1;
-

-
            if self.properties.contains(Property(1 << index)) {
-
                return Some(index as usize);
-
            }
-
        }
-

-
        None
-
    }
-
}
-

-
/// Represents a set of styling options.
-
#[repr(packed)]
-
#[derive(Default, Debug, Eq, Ord, PartialOrd, Copy, Clone)]
-
pub struct Style {
-
    pub(crate) foreground: Color,
-
    pub(crate) background: Color,
-
    pub(crate) properties: Property,
-
    pub(crate) wrap: bool,
-
}
-

-
impl PartialEq for Style {
-
    fn eq(&self, other: &Style) -> bool {
-
        self.foreground == other.foreground
-
            && self.background == other.background
-
            && self.properties == other.properties
-
    }
-
}
-

-
impl Hash for Style {
-
    fn hash<H: Hasher>(&self, state: &mut H) {
-
        self.foreground.hash(state);
-
        self.background.hash(state);
-
        self.properties.hash(state);
-
    }
-
}
-

-
#[inline]
-
fn write_spliced<T: Display>(c: &mut bool, f: &mut dyn fmt::Write, t: T) -> fmt::Result {
-
    if *c {
-
        write!(f, ";{t}")
-
    } else {
-
        *c = true;
-
        write!(f, "{t}")
-
    }
-
}
-

-
impl Style {
-
    /// Default style with the foreground set to `color` and no other set
-
    /// properties.
-
    #[inline]
-
    pub const fn new(color: Color) -> Style {
-
        // Avoiding `Default::default` since unavailable as `const`
-
        Self {
-
            foreground: color,
-
            background: Color::Unset,
-
            properties: Property::new(),
-
            wrap: false,
-
        }
-
    }
-

-
    /// Sets the foreground to `color`.
-
    #[inline]
-
    pub const fn fg(mut self, color: Color) -> Style {
-
        self.foreground = color;
-
        self
-
    }
-

-
    /// Sets the background to `color`.
-
    #[inline]
-
    pub const fn bg(mut self, color: Color) -> Style {
-
        self.background = color;
-
        self
-
    }
-

-
    /// Sets `self` to be wrapping.
-
    ///
-
    /// A wrapping `Style` converts all color resets written out by the internal
-
    /// value to the styling of itself. This allows for seamless color wrapping
-
    /// of other colored text.
-
    ///
-
    /// # Performance
-
    ///
-
    /// In order to wrap an internal value, the internal value must first be
-
    /// written out to a local buffer and examined. As a result, displaying a
-
    /// wrapped value is likely to result in a heap allocation and copy.
-
    #[inline]
-
    pub const fn wrap(mut self) -> Style {
-
        self.wrap = true;
-
        self
-
    }
-

-
    pub fn bold(mut self) -> Self {
-
        self.properties.set(Property::BOLD);
-
        self
-
    }
-

-
    pub fn dim(mut self) -> Self {
-
        self.properties.set(Property::DIM);
-
        self
-
    }
-

-
    pub fn italic(mut self) -> Self {
-
        self.properties.set(Property::ITALIC);
-
        self
-
    }
-

-
    pub fn underline(mut self) -> Self {
-
        self.properties.set(Property::UNDERLINE);
-
        self
-
    }
-

-
    pub fn invert(mut self) -> Self {
-
        self.properties.set(Property::INVERT);
-
        self
-
    }
-

-
    pub fn strikethrough(mut self) -> Self {
-
        self.properties.set(Property::STRIKETHROUGH);
-
        self
-
    }
-

-
    /// Constructs a new `Paint` structure that encapsulates `item` with the
-
    /// style set to `self`.
-
    #[inline]
-
    pub fn paint<T>(self, item: T) -> Paint<T> {
-
        Paint::new(item).with_style(self)
-
    }
-

-
    /// Returns the foreground color of `self`.
-
    #[inline]
-
    pub const fn fg_color(&self) -> Color {
-
        self.foreground
-
    }
-

-
    /// Returns the foreground color of `self`.
-
    #[inline]
-
    pub const fn bg_color(&self) -> Color {
-
        self.background
-
    }
-

-
    /// Returns `true` if `self` is wrapping.
-
    #[inline]
-
    pub const fn is_wrapping(&self) -> bool {
-
        self.wrap
-
    }
-

-
    #[inline(always)]
-
    fn is_plain(&self) -> bool {
-
        self == &Style::default()
-
    }
-

-
    /// Writes the ANSI code prefix for the currently set styles.
-
    ///
-
    /// This method is intended to be used inside of [`fmt::Display`] and
-
    /// [`fmt::Debug`] implementations for custom or specialized use-cases. Most
-
    /// users should use [`Paint`] for all painting needs.
-
    ///
-
    /// This method writes the ANSI code prefix irrespective of whether painting
-
    /// is currently enabled or disabled. To write the prefix only if painting
-
    /// is enabled, condition a call to this method on [`Paint::is_enabled()`].
-
    pub fn fmt_prefix(&self, f: &mut dyn fmt::Write) -> fmt::Result {
-
        // A user may just want a code-free string when no styles are applied.
-
        if self.is_plain() {
-
            return Ok(());
-
        }
-

-
        let mut splice = false;
-
        write!(f, "\x1B[")?;
-

-
        for i in self.properties.iter() {
-
            let k = if i >= 5 { i + 2 } else { i + 1 };
-
            write_spliced(&mut splice, f, k)?;
-
        }
-

-
        if self.background != Color::Unset {
-
            write_spliced(&mut splice, f, "4")?;
-
            self.background.ansi_fmt(f)?;
-
        }
-

-
        if self.foreground != Color::Unset {
-
            write_spliced(&mut splice, f, "3")?;
-
            self.foreground.ansi_fmt(f)?;
-
        }
-

-
        // All the codes end with an `m`.
-
        write!(f, "m")
-
    }
-

-
    /// Writes the ANSI code suffix for the currently set styles.
-
    ///
-
    /// This method is intended to be used inside of [`fmt::Display`] and
-
    /// [`fmt::Debug`] implementations for custom or specialized use-cases. Most
-
    /// users should use [`Paint`] for all painting needs.
-
    ///
-
    /// This method writes the ANSI code suffix irrespective of whether painting
-
    /// is currently enabled or disabled. To write the suffix only if painting
-
    /// is enabled, condition a call to this method on [`Paint::is_enabled()`].
-
    pub fn fmt_suffix(&self, f: &mut dyn fmt::Write) -> fmt::Result {
-
        if self.is_plain() {
-
            return Ok(());
-
        }
-
        write!(f, "\x1B[0m")
-
    }
-
}
deleted radicle-cli/src/terminal/ansi/tests.rs
@@ -1,279 +0,0 @@
-
use std::sync::Mutex;
-

-
use super::Color::*;
-
use super::Paint;
-

-
/// Ensures tests are running serially.
-
static SERIAL: Mutex<()> = Mutex::new(());
-

-
#[test]
-
fn colors_enabled() {
-
    let _guard = SERIAL.lock();
-

-
    concolor::set(concolor::ColorChoice::Always);
-

-
    assert_eq!(
-
        Paint::new("text/plain").to_string(),
-
        "text/plain".to_string()
-
    );
-
    assert_eq!(
-
        Paint::red("hi").to_string(),
-
        "\x1B[31mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::black("hi").to_string(),
-
        "\x1B[30mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::yellow("hi").bold().to_string(),
-
        "\x1B[1;33mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::new("hi").fg(Yellow).bold().to_string(),
-
        "\x1B[1;33mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::blue("hi").underline().to_string(),
-
        "\x1B[4;34mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::green("hi").bold().underline().to_string(),
-
        "\x1B[1;4;32mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::green("hi").underline().bold().to_string(),
-
        "\x1B[1;4;32mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::magenta("hi").bg(White).to_string(),
-
        "\x1B[47;35mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::red("hi").bg(Blue).fg(Yellow).to_string(),
-
        "\x1B[44;33mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::cyan("hi").bg(Blue).fg(Yellow).to_string(),
-
        "\x1B[44;33mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::cyan("hi").bold().bg(White).to_string(),
-
        "\x1B[1;47;36mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::cyan("hi").underline().bg(White).to_string(),
-
        "\x1B[4;47;36mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::cyan("hi").bold().underline().bg(White).to_string(),
-
        "\x1B[1;4;47;36mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::cyan("hi").underline().bold().bg(White).to_string(),
-
        "\x1B[1;4;47;36mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::fixed(100, "hi").to_string(),
-
        "\x1B[38;5;100mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::fixed(100, "hi").bg(Magenta).to_string(),
-
        "\x1B[45;38;5;100mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::fixed(100, "hi").bg(Fixed(200)).to_string(),
-
        "\x1B[48;5;200;38;5;100mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::rgb(70, 130, 180, "hi").to_string(),
-
        "\x1B[38;2;70;130;180mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::rgb(70, 130, 180, "hi").bg(Blue).to_string(),
-
        "\x1B[44;38;2;70;130;180mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::blue("hi").bg(RGB(70, 130, 180)).to_string(),
-
        "\x1B[48;2;70;130;180;34mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::rgb(70, 130, 180, "hi")
-
            .bg(RGB(5, 10, 15))
-
            .to_string(),
-
        "\x1B[48;2;5;10;15;38;2;70;130;180mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::new("hi").bold().to_string(),
-
        "\x1B[1mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::new("hi").underline().to_string(),
-
        "\x1B[4mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::new("hi").bold().underline().to_string(),
-
        "\x1B[1;4mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::new("hi").dim().to_string(),
-
        "\x1B[2mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::new("hi").italic().to_string(),
-
        "\x1B[3mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::new("hi").blink().to_string(),
-
        "\x1B[5mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::new("hi").invert().to_string(),
-
        "\x1B[7mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::new("hi").hidden().to_string(),
-
        "\x1B[8mhi\x1B[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::new("hi").strikethrough().to_string(),
-
        "\x1B[9mhi\x1B[0m".to_string()
-
    );
-
}
-

-
#[test]
-
fn colors_disabled() {
-
    let _guard = SERIAL.lock();
-

-
    concolor::set(concolor::ColorChoice::Never);
-

-
    assert_eq!(
-
        Paint::new("text/plain").to_string(),
-
        "text/plain".to_string()
-
    );
-
    assert_eq!(Paint::red("hi").to_string(), "hi".to_string());
-
    assert_eq!(Paint::black("hi").to_string(), "hi".to_string());
-
    assert_eq!(Paint::yellow("hi").bold().to_string(), "hi".to_string());
-
    assert_eq!(
-
        Paint::new("hi").fg(Yellow).bold().to_string(),
-
        "hi".to_string()
-
    );
-
    assert_eq!(Paint::blue("hi").underline().to_string(), "hi".to_string());
-
    assert_eq!(
-
        Paint::green("hi").bold().underline().to_string(),
-
        "hi".to_string()
-
    );
-
    assert_eq!(
-
        Paint::green("hi").underline().bold().to_string(),
-
        "hi".to_string()
-
    );
-
    assert_eq!(Paint::magenta("hi").bg(White).to_string(), "hi".to_string());
-
    assert_eq!(
-
        Paint::red("hi").bg(Blue).fg(Yellow).to_string(),
-
        "hi".to_string()
-
    );
-
    assert_eq!(
-
        Paint::cyan("hi").bg(Blue).fg(Yellow).to_string(),
-
        "hi".to_string()
-
    );
-
    assert_eq!(
-
        Paint::cyan("hi").bold().bg(White).to_string(),
-
        "hi".to_string()
-
    );
-
    assert_eq!(
-
        Paint::cyan("hi").underline().bg(White).to_string(),
-
        "hi".to_string()
-
    );
-
    assert_eq!(
-
        Paint::cyan("hi").bold().underline().bg(White).to_string(),
-
        "hi".to_string()
-
    );
-
    assert_eq!(
-
        Paint::cyan("hi").underline().bold().bg(White).to_string(),
-
        "hi".to_string()
-
    );
-
    assert_eq!(Paint::fixed(100, "hi").to_string(), "hi".to_string());
-
    assert_eq!(
-
        Paint::fixed(100, "hi").bg(Magenta).to_string(),
-
        "hi".to_string()
-
    );
-
    assert_eq!(
-
        Paint::fixed(100, "hi").bg(Fixed(200)).to_string(),
-
        "hi".to_string()
-
    );
-
    assert_eq!(Paint::rgb(70, 130, 180, "hi").to_string(), "hi".to_string());
-
    assert_eq!(
-
        Paint::rgb(70, 130, 180, "hi").bg(Blue).to_string(),
-
        "hi".to_string()
-
    );
-
    assert_eq!(
-
        Paint::blue("hi").bg(RGB(70, 130, 180)).to_string(),
-
        "hi".to_string()
-
    );
-
    assert_eq!(
-
        Paint::blue("hi").bg(RGB(70, 130, 180)).wrap().to_string(),
-
        "hi".to_string()
-
    );
-
    assert_eq!(
-
        Paint::rgb(70, 130, 180, "hi")
-
            .bg(RGB(5, 10, 15))
-
            .to_string(),
-
        "hi".to_string()
-
    );
-
    assert_eq!(Paint::new("hi").bold().to_string(), "hi".to_string());
-
    assert_eq!(Paint::new("hi").underline().to_string(), "hi".to_string());
-
    assert_eq!(
-
        Paint::new("hi").bold().underline().to_string(),
-
        "hi".to_string()
-
    );
-
    assert_eq!(Paint::new("hi").dim().to_string(), "hi".to_string());
-
    assert_eq!(Paint::new("hi").italic().to_string(), "hi".to_string());
-
    assert_eq!(Paint::new("hi").blink().to_string(), "hi".to_string());
-
    assert_eq!(Paint::new("hi").invert().to_string(), "hi".to_string());
-
    assert_eq!(Paint::new("hi").hidden().to_string(), "hi".to_string());
-
    assert_eq!(
-
        Paint::new("hi").strikethrough().to_string(),
-
        "hi".to_string()
-
    );
-
    assert_eq!(
-
        Paint::new("hi").strikethrough().wrap().to_string(),
-
        "hi".to_string()
-
    );
-
}
-

-
#[test]
-
fn wrapping() {
-
    let _guard = SERIAL.lock();
-
    let inner = || format!("{} b {}", Paint::red("a"), Paint::green("c"));
-
    let inner2 = || format!("0 {} 1", Paint::magenta(&inner()).wrap());
-

-
    concolor::set(concolor::ColorChoice::Always);
-

-
    assert_eq!(
-
        Paint::new("text/plain").wrap().to_string(),
-
        "text/plain".to_string()
-
    );
-
    assert_eq!(Paint::new(&inner()).wrap().to_string(), inner());
-
    assert_eq!(
-
        Paint::new(&inner()).wrap().to_string(),
-
        "\u{1b}[31ma\u{1b}[0m b \u{1b}[32mc\u{1b}[0m".to_string()
-
    );
-
    assert_eq!(
-
        Paint::new(&inner()).fg(Blue).wrap().to_string(),
-
        "\u{1b}[34m\u{1b}[31ma\u{1b}[0m\u{1b}[34m b \
-
            \u{1b}[32mc\u{1b}[0m\u{1b}[34m\u{1b}[0m"
-
            .to_string()
-
    );
-
    assert_eq!(Paint::new(&inner2()).wrap().to_string(), inner2());
-
    assert_eq!(
-
        Paint::new(&inner2()).wrap().to_string(),
-
        "0 \u{1b}[35m\u{1b}[31ma\u{1b}[0m\u{1b}[35m b \
-
            \u{1b}[32mc\u{1b}[0m\u{1b}[35m\u{1b}[0m 1"
-
            .to_string()
-
    );
-
    assert_eq!(
-
        Paint::new(&inner2()).fg(Blue).wrap().to_string(),
-
        "\u{1b}[34m0 \u{1b}[35m\u{1b}[31ma\u{1b}[0m\u{1b}[34m\u{1b}[35m b \
-
            \u{1b}[32mc\u{1b}[0m\u{1b}[34m\u{1b}[35m\u{1b}[0m\u{1b}[34m 1\u{1b}[0m"
-
            .to_string()
-
    );
-
}
deleted radicle-cli/src/terminal/ansi/windows.rs
@@ -1,64 +0,0 @@
-
#[cfg(windows)]
-
mod windows_console {
-
    use std::os::raw::c_void;
-

-
    #[allow(non_camel_case_types)]
-
    type c_ulong = u32;
-
    #[allow(non_camel_case_types)]
-
    type c_int = i32;
-
    type DWORD = c_ulong;
-
    type LPDWORD = *mut DWORD;
-
    type HANDLE = *mut c_void;
-
    type BOOL = c_int;
-

-
    const ENABLE_VIRTUAL_TERMINAL_PROCESSING: DWORD = 0x0004;
-
    const STD_OUTPUT_HANDLE: DWORD = 0xFFFFFFF5;
-
    const STD_ERROR_HANDLE: DWORD = 0xFFFFFFF4;
-
    const INVALID_HANDLE_VALUE: HANDLE = -1isize as HANDLE;
-
    const FALSE: BOOL = 0;
-
    const TRUE: BOOL = 1;
-

-
    // This is the win32 console API, taken from the 'winapi' crate.
-
    extern "system" {
-
        fn GetStdHandle(nStdHandle: DWORD) -> HANDLE;
-
        fn GetConsoleMode(hConsoleHandle: HANDLE, lpMode: LPDWORD) -> BOOL;
-
        fn SetConsoleMode(hConsoleHandle: HANDLE, dwMode: DWORD) -> BOOL;
-
    }
-

-
    unsafe fn get_handle(handle_num: DWORD) -> Result<HANDLE, ()> {
-
        match GetStdHandle(handle_num) {
-
            handle if handle == INVALID_HANDLE_VALUE => Err(()),
-
            handle => Ok(handle),
-
        }
-
    }
-

-
    unsafe fn enable_vt(handle: HANDLE) -> Result<(), ()> {
-
        let mut dw_mode: DWORD = 0;
-
        if GetConsoleMode(handle, &mut dw_mode) == FALSE {
-
            return Err(());
-
        }
-

-
        dw_mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING;
-
        match SetConsoleMode(handle, dw_mode) {
-
            result if result == TRUE => Ok(()),
-
            _ => Err(()),
-
        }
-
    }
-

-
    unsafe fn enable_ansi_colors_raw() -> Result<bool, ()> {
-
        let stdout_handle = get_handle(STD_OUTPUT_HANDLE)?;
-
        let stderr_handle = get_handle(STD_ERROR_HANDLE)?;
-

-
        enable_vt(stdout_handle)?;
-
        if stdout_handle != stderr_handle {
-
            enable_vt(stderr_handle)?;
-
        }
-

-
        Ok(true)
-
    }
-

-
    #[inline]
-
    pub fn enable_ansi_colors() -> bool {
-
        unsafe { enable_ansi_colors_raw().unwrap_or(false) }
-
    }
-
}
deleted radicle-cli/src/terminal/cell.rs
@@ -1,146 +0,0 @@
-
use std::fmt::Display;
-

-
use super::Paint;
-

-
use unicode_width::UnicodeWidthStr;
-

-
/// Text that can be displayed on the terminal, measured, truncated and padded.
-
pub trait Cell: Display {
-
    /// Type after truncation.
-
    type Truncated: Cell;
-
    /// Type after padding.
-
    type Padded: Cell;
-

-
    /// Cell display width in number of terminal columns.
-
    fn width(&self) -> usize;
-
    /// Truncate cell if longer than given width. Shows the delimiter if truncated.
-
    fn truncate(&self, width: usize, delim: &str) -> Self::Truncated;
-
    /// Pad the cell so that it is the given width, while keeping the content left-aligned.
-
    fn pad(&self, width: usize) -> Self::Padded;
-
}
-

-
impl Cell for Paint<String> {
-
    type Truncated = Self;
-
    type Padded = Self;
-

-
    fn width(&self) -> usize {
-
        UnicodeWidthStr::width(self.content())
-
    }
-

-
    fn truncate(&self, width: usize, delim: &str) -> Self {
-
        Self {
-
            item: self.item.truncate(width, delim),
-
            style: self.style,
-
        }
-
    }
-

-
    fn pad(&self, width: usize) -> Self {
-
        Self {
-
            item: self.item.pad(width),
-
            style: self.style,
-
        }
-
    }
-
}
-

-
impl Cell for Paint<&str> {
-
    type Truncated = Paint<String>;
-
    type Padded = Paint<String>;
-

-
    fn width(&self) -> usize {
-
        Cell::width(self.item)
-
    }
-

-
    fn truncate(&self, width: usize, delim: &str) -> Paint<String> {
-
        Paint {
-
            item: self.item.truncate(width, delim),
-
            style: self.style,
-
        }
-
    }
-

-
    fn pad(&self, width: usize) -> Paint<String> {
-
        Paint {
-
            item: self.item.pad(width),
-
            style: self.style,
-
        }
-
    }
-
}
-

-
impl Cell for String {
-
    type Truncated = Self;
-
    type Padded = Self;
-

-
    fn width(&self) -> usize {
-
        Cell::width(self.as_str())
-
    }
-

-
    fn truncate(&self, width: usize, delim: &str) -> Self {
-
        self.as_str().truncate(width, delim)
-
    }
-

-
    fn pad(&self, width: usize) -> Self {
-
        self.as_str().pad(width)
-
    }
-
}
-

-
impl Cell for str {
-
    type Truncated = String;
-
    type Padded = String;
-

-
    fn width(&self) -> usize {
-
        UnicodeWidthStr::width(self)
-
    }
-

-
    fn truncate(&self, width: usize, delim: &str) -> String {
-
        use unicode_segmentation::UnicodeSegmentation as _;
-

-
        if width < Cell::width(self) {
-
            let d = Cell::width(delim);
-
            if width < d {
-
                // If we can't even fit the delimiter, just return an empty string.
-
                return String::new();
-
            }
-
            // Find the unicode byte boundary where the display width is the largest,
-
            // while being smaller than the given max width.
-
            let mut cols = 0; // Number of visual columns we need.
-
            let mut boundary = 0; // Boundary in bytes.
-
            for g in self.graphemes(true) {
-
                let c = Cell::width(g);
-
                if cols + c + d > width {
-
                    break;
-
                }
-
                boundary += g.len();
-
                cols += c;
-
            }
-
            format!("{}{delim}", &self[..boundary])
-
        } else {
-
            self.to_owned()
-
        }
-
    }
-

-
    fn pad(&self, max: usize) -> String {
-
        let width = Cell::width(self);
-

-
        if width < max {
-
            format!("{self}{}", " ".repeat(max - width))
-
        } else {
-
            self.to_owned()
-
        }
-
    }
-
}
-

-
impl<T: Cell + ?Sized> Cell for &T {
-
    type Truncated = T::Truncated;
-
    type Padded = T::Padded;
-

-
    fn width(&self) -> usize {
-
        T::width(self)
-
    }
-

-
    fn truncate(&self, width: usize, delim: &str) -> Self::Truncated {
-
        T::truncate(self, width, delim)
-
    }
-

-
    fn pad(&self, width: usize) -> Self::Padded {
-
        T::pad(self, width)
-
    }
-
}
deleted radicle-cli/src/terminal/command.rs
@@ -1,19 +0,0 @@
-
use std::io::Write;
-
use std::process::{Command, Stdio};
-

-
pub fn bat<S: AsRef<std::ffi::OsStr>>(
-
    args: impl IntoIterator<Item = S>,
-
    stdin: &str,
-
) -> anyhow::Result<()> {
-
    let mut child = Command::new("bat")
-
        .stdin(Stdio::piped())
-
        .args(args)
-
        .spawn()?;
-

-
    let writer = child.stdin.as_mut().unwrap();
-
    writer.write_all(stdin.as_bytes())?;
-

-
    child.wait()?;
-

-
    Ok(())
-
}
deleted radicle-cli/src/terminal/editor.rs
@@ -1,94 +0,0 @@
-
use std::ffi::OsString;
-
use std::io::Write;
-
use std::path::PathBuf;
-
use std::process;
-
use std::{env, fs, io};
-

-
pub const COMMENT_FILE: &str = "RAD_COMMENT";
-

-
/// Allows for text input in the configured editor.
-
pub struct Editor {
-
    path: PathBuf,
-
}
-

-
impl Drop for Editor {
-
    fn drop(&mut self) {
-
        fs::remove_file(&self.path).ok();
-
    }
-
}
-

-
impl Default for Editor {
-
    fn default() -> Self {
-
        Self::new()
-
    }
-
}
-

-
impl Editor {
-
    /// Create a new editor.
-
    pub fn new() -> Self {
-
        let path = env::temp_dir().join(COMMENT_FILE);
-

-
        Self { path }
-
    }
-

-
    /// Set the file extension.
-
    pub fn extension(mut self, ext: &str) -> Self {
-
        let ext = ext.trim_start_matches('.');
-

-
        self.path.set_extension(ext);
-
        self
-
    }
-

-
    /// Open the editor and return the edited text.
-
    ///
-
    /// If the text hasn't changed from the initial contents of the editor,
-
    /// return `None`.
-
    pub fn edit(&mut self, initial: impl ToString) -> io::Result<Option<String>> {
-
        let initial = initial.to_string();
-
        let mut file = fs::OpenOptions::new()
-
            .write(true)
-
            .create(true)
-
            .open(&self.path)?;
-

-
        if file.metadata()?.len() == 0 {
-
            file.write_all(initial.as_bytes())?;
-
            if !initial.ends_with('\n') {
-
                file.write_all(b"\n")?;
-
            }
-
            file.flush()?;
-
        }
-

-
        let Some(cmd) = self::default_editor() else {
-
            return Err(
-
                io::Error::new(
-
                    io::ErrorKind::NotFound,
-
                    "editor not configured: the `EDITOR` environment variable is not set"
-
                )
-
            );
-
        };
-
        process::Command::new(cmd).arg(&self.path).spawn()?.wait()?;
-

-
        let text = fs::read_to_string(&self.path)?;
-
        let text = text.strip_prefix(&initial).unwrap_or(&text);
-

-
        if text.trim().is_empty() {
-
            return Ok(None);
-
        }
-
        Ok(Some(text.to_owned()))
-
    }
-
}
-

-
/// Get the default editor command.
-
pub fn default_editor() -> Option<OsString> {
-
    if let Ok(visual) = env::var("VISUAL") {
-
        if !visual.is_empty() {
-
            return Some(visual.into());
-
        }
-
    }
-
    if let Ok(editor) = env::var("EDITOR") {
-
        if !editor.is_empty() {
-
            return Some(editor.into());
-
        }
-
    }
-
    None
-
}
modified radicle-cli/src/terminal/format.rs
@@ -1,6 +1,7 @@
use std::{fmt, time};

-
pub use crate::terminal::{style, Paint};
+
pub use radicle_term::format::*;
+
pub use radicle_term::{style, Paint};

use radicle::cob::{ObjectId, Timestamp};
use radicle::node::NodeId;
@@ -87,79 +88,3 @@ impl<'a> fmt::Display for Identity<'a> {
        }
    }
}
-

-
pub fn wrap<D: std::fmt::Display>(msg: D) -> Paint<D> {
-
    Paint::wrapping(msg)
-
}
-

-
pub fn negative<D: std::fmt::Display>(msg: D) -> Paint<D> {
-
    Paint::red(msg).bold()
-
}
-

-
pub fn positive<D: std::fmt::Display>(msg: D) -> Paint<D> {
-
    Paint::green(msg).bold()
-
}
-

-
pub fn secondary<D: std::fmt::Display>(msg: D) -> Paint<D> {
-
    Paint::blue(msg).bold()
-
}
-

-
pub fn tertiary<D: std::fmt::Display>(msg: D) -> Paint<D> {
-
    Paint::cyan(msg)
-
}
-

-
pub fn tertiary_bold<D: std::fmt::Display>(msg: D) -> Paint<D> {
-
    Paint::cyan(msg).bold()
-
}
-

-
pub fn yellow<D: std::fmt::Display>(msg: D) -> Paint<D> {
-
    Paint::yellow(msg)
-
}
-

-
pub fn highlight<D: std::fmt::Debug + std::fmt::Display>(input: D) -> Paint<D> {
-
    Paint::green(input).bold()
-
}
-

-
pub fn badge_primary<D: std::fmt::Display>(input: D) -> Paint<String> {
-
    if Paint::is_enabled() {
-
        Paint::magenta(format!(" {input} ")).invert()
-
    } else {
-
        Paint::new(format!("❲{input}❳"))
-
    }
-
}
-

-
pub fn badge_positive<D: std::fmt::Display>(input: D) -> Paint<String> {
-
    if Paint::is_enabled() {
-
        Paint::green(format!(" {input} ")).invert()
-
    } else {
-
        Paint::new(format!("❲{input}❳"))
-
    }
-
}
-

-
pub fn badge_negative<D: std::fmt::Display>(input: D) -> Paint<String> {
-
    if Paint::is_enabled() {
-
        Paint::red(format!(" {input} ")).invert()
-
    } else {
-
        Paint::new(format!("❲{input}❳"))
-
    }
-
}
-

-
pub fn badge_secondary<D: std::fmt::Display>(input: D) -> Paint<String> {
-
    if Paint::is_enabled() {
-
        Paint::blue(format!(" {input} ")).invert()
-
    } else {
-
        Paint::new(format!("❲{input}❳"))
-
    }
-
}
-

-
pub fn bold<D: std::fmt::Display>(input: D) -> Paint<D> {
-
    Paint::white(input).bold()
-
}
-

-
pub fn dim<D: std::fmt::Display>(input: D) -> Paint<D> {
-
    Paint::new(input).dim()
-
}
-

-
pub fn italic<D: std::fmt::Display>(input: D) -> Paint<D> {
-
    Paint::new(input).italic().dim()
-
}
modified radicle-cli/src/terminal/io.rs
@@ -1,186 +1,19 @@
-
use std::fmt;
-

-
use inquire::ui::{ErrorMessageRenderConfig, StyleSheet, Styled};
-
use inquire::InquireError;
-
use inquire::{ui::Color, ui::RenderConfig, Confirm, CustomType, Password, Select};
-
use once_cell::sync::Lazy;
-

use radicle::cob::issue::Issue;
use radicle::cob::thread::{Comment, CommentId};
-
use radicle::crypto::ssh::keystore::{MemorySigner, Passphrase};
+
use radicle::crypto::ssh::keystore::MemorySigner;
use radicle::crypto::Signer;
-
use radicle::profile;
+
use radicle::profile::env::RAD_PASSPHRASE;
use radicle::profile::Profile;

-
use super::command;
-
use super::format;
-
use super::spinner::spinner;
-
use super::Error;
-
use super::{style, Paint};
-

-
pub const ERROR_PREFIX: Paint<&str> = Paint::red("✗");
-
pub const ERROR_HINT_PREFIX: Paint<&str> = Paint::yellow("✗");
-
pub const WARNING_PREFIX: Paint<&str> = Paint::yellow("!");
-
pub const TAB: &str = "    ";
-

-
/// Render configuration.
-
pub static CONFIG: Lazy<RenderConfig> = Lazy::new(|| RenderConfig {
-
    prompt: StyleSheet::new().with_fg(Color::LightCyan),
-
    prompt_prefix: Styled::new("?").with_fg(Color::LightBlue),
-
    answered_prompt_prefix: Styled::new("✓").with_fg(Color::LightGreen),
-
    answer: StyleSheet::new(),
-
    highlighted_option_prefix: Styled::new("*").with_fg(Color::LightYellow),
-
    help_message: StyleSheet::new().with_fg(Color::DarkGrey),
-
    error_message: ErrorMessageRenderConfig::default_colored()
-
        .with_prefix(Styled::new("✗").with_fg(Color::LightRed)),
-
    ..RenderConfig::default_colored()
-
});
-

-
#[macro_export]
-
macro_rules! info {
-
    ($($arg:tt)*) => ({
-
        println!("{}", format_args!($($arg)*));
-
    })
-
}
-

-
#[macro_export]
-
macro_rules! success {
-
    ($($arg:tt)*) => ({
-
        $crate::terminal::io::success_args(format_args!($($arg)*));
-
    })
-
}
-

-
#[macro_export]
-
macro_rules! tip {
-
    ($($arg:tt)*) => ({
-
        $crate::terminal::io::tip_args(format_args!($($arg)*));
-
    })
-
}
-

-
pub use info;
-
pub use success;
-
pub use tip;
-

-
pub fn success_args(args: fmt::Arguments) {
-
    println!("{} {args}", Paint::green("✓"));
-
}
-

-
pub fn tip_args(args: fmt::Arguments) {
-
    println!("👉 {}", style(format!("{args}")).italic());
-
}
-

-
pub fn columns() -> Option<usize> {
-
    termion::terminal_size().map(|(cols, _)| cols as usize).ok()
-
}
-

-
pub fn headline(headline: impl fmt::Display) {
-
    println!();
-
    println!("{}", style(headline).bold());
-
    println!();
-
}
-

-
pub fn header(header: &str) {
-
    println!();
-
    println!("{}", style(format::yellow(header)).bold().underline());
-
    println!();
-
}
-

-
pub fn blob(text: impl fmt::Display) {
-
    println!("{}", style(text.to_string().trim()).dim());
-
}
-

-
pub fn blank() {
-
    println!()
-
}
-

-
pub fn print(msg: impl fmt::Display) {
-
    println!("{msg}");
-
}
-

-
pub fn prefixed(prefix: &str, text: &str) -> String {
-
    text.split('\n')
-
        .map(|line| format!("{prefix}{line}\n"))
-
        .collect()
-
}
-

-
pub fn help(name: &str, version: &str, description: &str, usage: &str) {
-
    println!("rad-{name} {version}\n{description}\n{usage}");
-
}
-

-
pub fn usage(name: &str, usage: &str) {
-
    println!(
-
        "{} {}\n{}",
-
        ERROR_PREFIX,
-
        Paint::red(format!("Error: rad-{name}: invalid usage")),
-
        Paint::red(prefixed(TAB, usage)).dim()
-
    );
-
}
-

-
pub fn println(prefix: impl fmt::Display, msg: impl fmt::Display) {
-
    println!("{prefix} {msg}");
-
}
-

-
pub fn indented(msg: impl fmt::Display) {
-
    println!("{TAB}{msg}");
-
}
-

-
pub fn subcommand(msg: impl fmt::Display) {
-
    println!("{} {}", style("$").dim(), style(msg).dim());
-
}
-

-
pub fn warning(warning: &str) {
-
    println!(
-
        "{} {} {warning}",
-
        WARNING_PREFIX,
-
        Paint::yellow("Warning:").bold(),
-
    );
-
}
-

-
pub fn error(error: impl fmt::Display) {
-
    println!("{ERROR_PREFIX} {error}");
-
}
-

-
pub fn fail(header: &str, error: &anyhow::Error) {
-
    let err = error.to_string();
-
    let err = err.trim_end();
-
    let separator = if err.contains('\n') { ":\n" } else { ": " };
-

-
    println!(
-
        "{ERROR_PREFIX} {}{}{error}",
-
        Paint::red(header).bold(),
-
        Paint::red(separator),
-
    );
-

-
    if let Some(Error::WithHint { hint, .. }) = error.downcast_ref::<Error>() {
-
        println!("{} {}", ERROR_HINT_PREFIX, Paint::yellow(hint));
-
        blank();
-
    }
-
}
-

-
pub fn ask<D: fmt::Display>(prompt: D, default: bool) -> bool {
-
    let prompt = prompt.to_string();
-

-
    Confirm::new(&prompt)
-
        .with_default(default)
-
        .with_render_config(*CONFIG)
-
        .prompt()
-
        .unwrap_or_default()
-
}
-

-
pub fn confirm<D: fmt::Display>(prompt: D) -> bool {
-
    ask(prompt, true)
-
}
-

-
pub fn abort<D: fmt::Display>(prompt: D) -> bool {
-
    ask(prompt, false)
-
}
+
pub use radicle_term::io::*;
+
pub use radicle_term::spinner;

/// Get the signer. First we try getting it from ssh-agent, otherwise we prompt the user.
pub fn signer(profile: &Profile) -> anyhow::Result<Box<dyn Signer>> {
    if let Ok(signer) = profile.signer() {
        return Ok(signer);
    }
-
    let passphrase = passphrase()?;
+
    let passphrase = passphrase(RAD_PASSPHRASE)?;
    let spinner = spinner("Unsealing key...");
    let signer = MemorySigner::load(&profile.keystore, passphrase)?;

@@ -189,75 +22,6 @@ pub fn signer(profile: &Profile) -> anyhow::Result<Box<dyn Signer>> {
    Ok(signer.boxed())
}

-
pub fn input<S, E>(message: &str, default: Option<S>) -> anyhow::Result<S>
-
where
-
    S: fmt::Display + std::str::FromStr<Err = E> + Clone,
-
    E: fmt::Debug + fmt::Display,
-
{
-
    let input = CustomType::<S>::new(message).with_render_config(*CONFIG);
-
    let value = match default {
-
        Some(default) => input.with_default(default).prompt()?,
-
        None => input.prompt()?,
-
    };
-
    Ok(value)
-
}
-

-
pub fn passphrase() -> Result<Passphrase, anyhow::Error> {
-
    if let Some(p) = profile::env::passphrase() {
-
        Ok(p)
-
    } else {
-
        Ok(Passphrase::from(
-
            Password::new("Passphrase:")
-
                .with_render_config(*CONFIG)
-
                .with_display_mode(inquire::PasswordDisplayMode::Masked)
-
                .without_confirmation()
-
                .prompt()?,
-
        ))
-
    }
-
}
-

-
pub fn passphrase_confirm(prompt: &str) -> Result<Passphrase, anyhow::Error> {
-
    if let Some(p) = profile::env::passphrase() {
-
        Ok(p)
-
    } else {
-
        Ok(Passphrase::from(
-
            Password::new(prompt)
-
                .with_render_config(*CONFIG)
-
                .with_display_mode(inquire::PasswordDisplayMode::Masked)
-
                .with_custom_confirmation_message("Repeat passphrase:")
-
                .with_custom_confirmation_error_message("The passphrases don't match.")
-
                .with_help_message("This passphrase protects your radicle identity")
-
                .prompt()?,
-
        ))
-
    }
-
}
-

-
pub fn passphrase_stdin() -> Result<Passphrase, anyhow::Error> {
-
    let mut input = String::new();
-
    std::io::stdin().read_line(&mut input)?;
-

-
    Ok(Passphrase::from(input.trim_end().to_owned()))
-
}
-

-
pub fn select<'a, T>(
-
    prompt: &str,
-
    options: &'a [T],
-
    active: &'a T,
-
) -> Result<Option<&'a T>, InquireError>
-
where
-
    T: fmt::Display + Eq + PartialEq,
-
{
-
    let active = options.iter().position(|o| o == active);
-
    let selection =
-
        Select::new(prompt, options.iter().collect::<Vec<_>>()).with_render_config(*CONFIG);
-

-
    if let Some(active) = active {
-
        selection.with_starting_cursor(active).prompt_skippable()
-
    } else {
-
        selection.prompt_skippable()
-
    }
-
}
-

pub fn comment_select(issue: &Issue) -> Option<(&CommentId, &Comment)> {
    let comments = issue.comments().collect::<Vec<_>>();
    let selection = Select::new(
@@ -272,12 +36,6 @@ pub fn comment_select(issue: &Issue) -> Option<(&CommentId, &Comment)> {
    comments.get(selection).copied()
}

-
pub fn markdown(content: &str) {
-
    if !content.is_empty() && command::bat(["-p", "-l", "md"], content).is_err() {
-
        blob(content);
-
    }
-
}
-

pub mod proposal {
    use std::fmt::Write as _;

deleted radicle-cli/src/terminal/spinner.rs
@@ -1,186 +0,0 @@
-
use std::io::Write;
-
use std::mem::ManuallyDrop;
-
use std::sync::{Arc, Mutex};
-
use std::{fmt, io, thread, time};
-

-
use crate::terminal::io::{ERROR_PREFIX, WARNING_PREFIX};
-
use crate::terminal::Paint;
-

-
/// How much time to wait between spinner animation updates.
-
pub const DEFAULT_TICK: time::Duration = time::Duration::from_millis(99);
-
/// The spinner animation strings.
-
pub const DEFAULT_STYLE: [Paint<&'static str>; 4] = [
-
    Paint::magenta("◢"),
-
    Paint::cyan("◣"),
-
    Paint::magenta("◤"),
-
    Paint::blue("◥"),
-
];
-

-
struct Progress {
-
    state: State,
-
    message: Paint<String>,
-
}
-

-
impl Progress {
-
    fn new(message: Paint<String>) -> Self {
-
        Self {
-
            state: State::Running { cursor: 0 },
-
            message,
-
        }
-
    }
-
}
-

-
enum State {
-
    Running { cursor: usize },
-
    Canceled,
-
    Done,
-
    Warn,
-
    Error,
-
}
-

-
/// A progress spinner.
-
pub struct Spinner {
-
    progress: Arc<Mutex<Progress>>,
-
    handle: ManuallyDrop<thread::JoinHandle<()>>,
-
}
-

-
impl Drop for Spinner {
-
    fn drop(&mut self) {
-
        if let Ok(mut progress) = self.progress.lock() {
-
            if let State::Running { .. } = progress.state {
-
                progress.state = State::Canceled;
-
            }
-
        }
-
        unsafe { ManuallyDrop::take(&mut self.handle) }
-
            .join()
-
            .unwrap();
-
    }
-
}
-

-
impl Spinner {
-
    /// Mark the spinner as successfully completed.
-
    pub fn finish(self) {
-
        if let Ok(mut progress) = self.progress.lock() {
-
            progress.state = State::Done;
-
        }
-
    }
-

-
    /// Mark the spinner as failed. This cancels the spinner.
-
    pub fn failed(self) {
-
        if let Ok(mut progress) = self.progress.lock() {
-
            progress.state = State::Error;
-
        }
-
    }
-

-
    /// Cancel the spinner with an error.
-
    pub fn error(self, msg: impl fmt::Display) {
-
        if let Ok(mut progress) = self.progress.lock() {
-
            progress.state = State::Error;
-
            progress.message = Paint::new(format!(
-
                "{} {} {}",
-
                progress.message,
-
                Paint::red("error:"),
-
                msg
-
            ));
-
        }
-
    }
-

-
    /// Cancel the spinner with a warning sign.
-
    pub fn warn(self) {
-
        if let Ok(mut progress) = self.progress.lock() {
-
            progress.state = State::Warn;
-
        }
-
    }
-

-
    /// Set the spinner's message.
-
    pub fn message(&mut self, msg: impl fmt::Display) {
-
        let msg = msg.to_string();
-

-
        if let Ok(mut progress) = self.progress.lock() {
-
            progress.message = Paint::new(msg);
-
        }
-
    }
-
}
-

-
/// Create a new spinner with the given message.
-
pub fn spinner(message: impl ToString) -> Spinner {
-
    let message = message.to_string();
-
    let progress = Arc::new(Mutex::new(Progress::new(Paint::new(message))));
-
    let handle = thread::spawn({
-
        let progress = progress.clone();
-

-
        move || {
-
            let mut stdout = io::stdout();
-
            let mut stderr = termion::cursor::HideCursor::from(io::stderr());
-

-
            loop {
-
                let Ok(mut progress) = progress.lock() else {
-
                    break;
-
                };
-
                match &mut *progress {
-
                    Progress {
-
                        state: State::Running { cursor },
-
                        message,
-
                    } => {
-
                        let spinner = DEFAULT_STYLE[*cursor];
-

-
                        write!(
-
                            stderr,
-
                            "{}{}{spinner} {message}",
-
                            termion::cursor::Save,
-
                            termion::clear::AfterCursor,
-
                        )
-
                        .ok();
-

-
                        write!(stderr, "{}", termion::cursor::Restore).ok();
-

-
                        *cursor += 1;
-
                        *cursor %= DEFAULT_STYLE.len();
-
                    }
-
                    Progress {
-
                        state: State::Done,
-
                        message,
-
                    } => {
-
                        write!(stderr, "{}", termion::clear::AfterCursor).ok();
-
                        writeln!(stdout, "{} {message}", Paint::green("✓")).ok();
-
                        break;
-
                    }
-
                    Progress {
-
                        state: State::Canceled,
-
                        message,
-
                    } => {
-
                        write!(stderr, "{}", termion::clear::AfterCursor).ok();
-
                        writeln!(
-
                            stdout,
-
                            "{ERROR_PREFIX} {message} {}",
-
                            Paint::red("<canceled>")
-
                        )
-
                        .ok();
-
                        break;
-
                    }
-
                    Progress {
-
                        state: State::Warn,
-
                        message,
-
                    } => {
-
                        writeln!(stdout, "{WARNING_PREFIX} {message}").ok();
-
                        break;
-
                    }
-
                    Progress {
-
                        state: State::Error,
-
                        message,
-
                    } => {
-
                        writeln!(stdout, "{ERROR_PREFIX} {message}").ok();
-
                        break;
-
                    }
-
                }
-
                drop(progress);
-
                thread::sleep(DEFAULT_TICK);
-
            }
-
        }
-
    });
-

-
    Spinner {
-
        progress,
-
        handle: ManuallyDrop::new(handle),
-
    }
-
}
deleted radicle-cli/src/terminal/table.rs
@@ -1,227 +0,0 @@
-
//! Print column-aligned text to the console.
-
//!
-
//! Example:
-
//! ```
-
//! use radicle_cli::terminal::table::*;
-
//!
-
//! let mut t = Table::new(TableOptions::default());
-
//! t.push(["pest", "biological control"]);
-
//! t.push(["aphid", "lacewing"]);
-
//! t.push(["spider mite", "ladybug"]);
-
//! t.render();
-
//! ```
-
//! Output:
-
//! ``` plain
-
//! pest        biological control
-
//! aphid       ladybug
-
//! spider mite persimilis
-
//! ```
-
use std::io;
-

-
use crate::terminal as term;
-
use crate::terminal::cell::Cell;
-

-
/// Used to specify maximum width or height.
-
#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)]
-
pub struct Max {
-
    width: Option<usize>,
-
    height: Option<usize>,
-
}
-

-
#[derive(Debug, Default)]
-
pub struct TableOptions {
-
    /// Whether the table should be allowed to overflow.
-
    pub overflow: bool,
-
    /// The maximum width and height.
-
    pub max: Max,
-
}
-

-
#[derive(Debug)]
-
pub struct Table<const W: usize> {
-
    rows: Vec<[String; W]>,
-
    widths: [usize; W],
-
    opts: TableOptions,
-
}
-

-
impl<const W: usize> Default for Table<W> {
-
    fn default() -> Self {
-
        Self {
-
            rows: Vec::new(),
-
            widths: [0; W],
-
            opts: TableOptions::default(),
-
        }
-
    }
-
}
-

-
impl<const W: usize> Table<W> {
-
    pub fn new(opts: TableOptions) -> Self {
-
        Self {
-
            rows: Vec::new(),
-
            widths: [0; W],
-
            opts,
-
        }
-
    }
-

-
    pub fn push(&mut self, row: [impl Cell; W]) {
-
        let row = row.map(|s| s.to_string());
-
        for (i, cell) in row.iter().enumerate() {
-
            self.widths[i] = self.widths[i].max(cell.width());
-
        }
-
        self.rows.push(row);
-
    }
-

-
    pub fn render(self) {
-
        self.write(io::stdout()).ok();
-
    }
-

-
    pub fn write<T: io::Write>(self, mut writer: T) -> io::Result<()> {
-
        let width = self.opts.max.width.or_else(term::columns);
-

-
        for row in &self.rows {
-
            let mut output = String::new();
-
            let cells = row.len();
-

-
            for (i, cell) in row.iter().enumerate() {
-
                if i == cells - 1 || self.opts.overflow {
-
                    output.push_str(cell.to_string().as_str());
-
                } else {
-
                    output.push_str(cell.pad(self.widths[i]).as_str());
-
                    output.push(' ');
-
                }
-
            }
-

-
            let output = output.trim_end();
-
            writeln!(
-
                writer,
-
                "{}",
-
                if let Some(width) = width {
-
                    output.truncate(width, "…")
-
                } else {
-
                    output.into()
-
                }
-
            )?;
-
        }
-
        Ok(())
-
    }
-

-
    pub fn render_tree(self) {
-
        for (r, row) in self.rows.iter().enumerate() {
-
            if r != self.rows.len() - 1 {
-
                print!("├── ");
-
            } else {
-
                print!("└── ");
-
            }
-
            for (i, cell) in row.iter().enumerate() {
-
                print!("{} ", cell.pad(self.widths[i]));
-
            }
-
            println!();
-
        }
-
    }
-
}
-

-
#[cfg(test)]
-
mod test {
-
    use super::*;
-
    use pretty_assertions::assert_eq;
-

-
    #[test]
-
    fn test_truncate() {
-
        assert_eq!("🍍".truncate(1, "…"), String::from("…"));
-
        assert_eq!("🍍".truncate(1, ""), String::from(""));
-
        assert_eq!("🍍🍍".truncate(2, "…"), String::from("…"));
-
        assert_eq!("🍍🍍".truncate(3, "…"), String::from("🍍…"));
-
        assert_eq!("🍍".truncate(1, "🍎"), String::from(""));
-
        assert_eq!("🍍".truncate(2, "🍎"), String::from("🍍"));
-
        assert_eq!("🍍🍍".truncate(3, "🍎"), String::from("🍎"));
-
        assert_eq!("🍍🍍🍍".truncate(4, "🍎"), String::from("🍍🍎"));
-
        assert_eq!("hello".truncate(3, "…"), String::from("he…"));
-
    }
-

-
    #[test]
-
    fn test_table() {
-
        let mut s = Vec::new();
-
        let mut t = Table::new(TableOptions::default());
-

-
        t.push(["pineapple", "rosemary"]);
-
        t.push(["apples", "pears"]);
-
        t.write(&mut s).unwrap();
-

-
        #[rustfmt::skip]
-
        assert_eq!(
-
            String::from_utf8_lossy(&s),
-
            [
-
                "pineapple rosemary\n",
-
                "apples    pears\n"
-
            ].join("")
-
        );
-
    }
-

-
    #[test]
-
    fn test_table_truncate() {
-
        let mut s = Vec::new();
-
        let mut t = Table::new(TableOptions {
-
            max: Max {
-
                width: Some(16),
-
                height: None,
-
            },
-
            ..TableOptions::default()
-
        });
-

-
        t.push(["pineapple", "rosemary"]);
-
        t.push(["apples", "pears"]);
-
        t.write(&mut s).unwrap();
-

-
        #[rustfmt::skip]
-
        assert_eq!(
-
            String::from_utf8_lossy(&s),
-
            [
-
                "pineapple rosem…\n",
-
                "apples    pears\n"
-
            ].join("")
-
        );
-
    }
-

-
    #[test]
-
    fn test_table_unicode() {
-
        let mut s = Vec::new();
-
        let mut t = Table::new(TableOptions::default());
-

-
        t.push(["🍍pineapple", "__rosemary", "__sage"]);
-
        t.push(["__pears", "🍎apples", "🍌bananas"]);
-
        t.write(&mut s).unwrap();
-

-
        #[rustfmt::skip]
-
        assert_eq!(
-
            String::from_utf8_lossy(&s),
-
            [
-
                "🍍pineapple __rosemary __sage\n",
-
                "__pears     🍎apples   🍌bananas\n"
-
            ].join("")
-
        );
-
    }
-

-
    #[test]
-
    fn test_table_unicode_truncate() {
-
        let mut s = Vec::new();
-
        let mut t = Table::new(TableOptions {
-
            max: Max {
-
                width: Some(16),
-
                height: None,
-
            },
-
            ..TableOptions::default()
-
        });
-

-
        t.push(["🍍pineapple", "__rosemary"]);
-
        t.push(["__pears", "🍎apples"]);
-
        t.write(&mut s).unwrap();
-

-
        #[rustfmt::skip]
-
        assert_eq!(
-
            String::from_utf8_lossy(&s),
-
            [
-
                "🍍pineapple __r…\n",
-
                "__pears     🍎a…\n"
-
            ].join("")
-
        );
-
    }
-
}
deleted radicle-cli/src/terminal/textbox.rs
@@ -1,67 +0,0 @@
-
use std::fmt;
-

-
use crate::terminal as term;
-
use crate::terminal::cell::Cell as _;
-

-
pub struct TextBox {
-
    pub body: String,
-
    first: bool,
-
    last: bool,
-
}
-

-
impl TextBox {
-
    pub fn new(body: String) -> Self {
-
        Self {
-
            body,
-
            first: true,
-
            last: true,
-
        }
-
    }
-

-
    /// Is this text box the last one in the list?
-
    pub fn last(mut self, connect: bool) -> Self {
-
        self.last = connect;
-
        self
-
    }
-

-
    /// Is this text box the first one in the list?
-
    pub fn first(mut self, connect: bool) -> Self {
-
        self.first = connect;
-
        self
-
    }
-
}
-

-
impl fmt::Display for TextBox {
-
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-
        let mut width = self.body.lines().map(|l| l.width()).max().unwrap_or(0) + 2;
-
        if let Some(max) = term::columns() {
-
            if width + 2 > max {
-
                width = max - 2
-
            }
-
        }
-

-
        let (connector, header_width) = if !self.first {
-
            ("┴", width - 1)
-
        } else {
-
            ("", width)
-
        };
-
        writeln!(f, "┌{}{}┐", connector, "─".repeat(header_width))?;
-

-
        for l in self.body.lines() {
-
            writeln!(f, "│ {}│", l.pad(width - 1))?;
-
        }
-

-
        let (connector, footer_width) = if !self.last {
-
            ("┬", width - 1)
-
        } else {
-
            ("", width)
-
        };
-

-
        writeln!(f, "└{}{}┘", connector, "─".repeat(footer_width))?;
-

-
        if !self.last {
-
            writeln!(f, " │")?;
-
        }
-
        Ok(())
-
    }
-
}
added radicle-term/Cargo.toml
@@ -0,0 +1,20 @@
+
[package]
+
name = "radicle-term"
+
license = "MIT OR Apache-2.0"
+
version = "0.1.0"
+
authors = ["Alexis Sellier <alexis@radicle.xyz>"]
+
edition = "2021"
+

+
[dependencies]
+
anyhow = { version = "1" }
+
concolor = { version = "0", features = ["api_unstable"] }
+
inquire = { version = "0.5.3", default-features = false, features = ["termion", "editor"] }
+
once_cell = { version = "1.13" }
+
termion = { version = "2" }
+
unicode-width = { version = "0.1.10", default-features = false }
+
unicode-segmentation = { version = "1.7.1" }
+
zeroize = { version = "1.1" }
+

+
[dev-dependencies]
+
pretty_assertions = { version = "1.3.0" }
+
tempfile = { version = "3.3.0" }
added radicle-term/src/ansi.rs
@@ -0,0 +1,16 @@
+
//! A dead simple ANSI terminal color painting library.
+
//!
+
//! This library is a port of the `yansi` crate.
+
//! Copyright (c) 2017 Sergio Benitez
+
//!
+
mod color;
+
mod paint;
+
mod style;
+
#[cfg(test)]
+
mod tests;
+
mod windows;
+

+
pub use color::Color;
+
pub use paint::paint;
+
pub use paint::Paint;
+
pub use style::Style;
added radicle-term/src/ansi/color.rs
@@ -0,0 +1,63 @@
+
use std::fmt;
+

+
use super::{Paint, Style};
+

+
/// An enum representing an ANSI color code.
+
#[derive(Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Copy, Clone)]
+
pub enum Color {
+
    /// No color has been set. Nothing is changed when applied.
+
    #[default]
+
    Unset,
+
    /// Black #0 (foreground code `30`, background code `40`).
+
    Black,
+
    /// Red: #1 (foreground code `31`, background code `41`).
+
    Red,
+
    /// Green: #2 (foreground code `32`, background code `42`).
+
    Green,
+
    /// Yellow: #3 (foreground code `33`, background code `43`).
+
    Yellow,
+
    /// Blue: #4 (foreground code `34`, background code `44`).
+
    Blue,
+
    /// Magenta: #5 (foreground code `35`, background code `45`).
+
    Magenta,
+
    /// Cyan: #6 (foreground code `36`, background code `46`).
+
    Cyan,
+
    /// White: #7 (foreground code `37`, background code `47`).
+
    White,
+
    /// A color number from 0 to 255, for use in 256-color terminals.
+
    Fixed(u8),
+
    /// A 24-bit RGB color, as specified by ISO-8613-3.
+
    RGB(u8, u8, u8),
+
}
+

+
impl Color {
+
    /// Constructs a new `Paint` structure that encapsulates `item` with the
+
    /// foreground color set to the color `self`.
+
    #[inline]
+
    pub fn paint<T>(self, item: T) -> Paint<T> {
+
        Paint::new(item).fg(self)
+
    }
+

+
    /// Constructs a new `Style` structure with the foreground color set to the
+
    /// color `self`.
+
    #[inline]
+
    pub const fn style(self) -> Style {
+
        Style::new(self)
+
    }
+

+
    pub(crate) fn ansi_fmt(&self, f: &mut dyn fmt::Write) -> fmt::Result {
+
        match *self {
+
            Color::Unset => Ok(()),
+
            Color::Black => write!(f, "0"),
+
            Color::Red => write!(f, "1"),
+
            Color::Green => write!(f, "2"),
+
            Color::Yellow => write!(f, "3"),
+
            Color::Blue => write!(f, "4"),
+
            Color::Magenta => write!(f, "5"),
+
            Color::Cyan => write!(f, "6"),
+
            Color::White => write!(f, "7"),
+
            Color::Fixed(num) => write!(f, "8;5;{num}"),
+
            Color::RGB(r, g, b) => write!(f, "8;2;{r};{g};{b}"),
+
        }
+
    }
+
}
added radicle-term/src/ansi/paint.rs
@@ -0,0 +1,252 @@
+
use std::fmt;
+

+
use unicode_width::UnicodeWidthStr;
+

+
use super::color::Color;
+
use super::style::{Property, Style};
+

+
/// A structure encapsulating an item and styling.
+
#[derive(Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Copy, Clone)]
+
pub struct Paint<T> {
+
    pub item: T,
+
    pub style: Style,
+
}
+

+
impl Paint<&str> {
+
    /// Return plain content.
+
    pub fn content(&self) -> &str {
+
        self.item
+
    }
+
}
+

+
impl Paint<String> {
+
    /// Return plain content.
+
    pub fn content(&self) -> &str {
+
        self.item.as_str()
+
    }
+
}
+

+
impl<T> From<T> for Paint<T> {
+
    fn from(value: T) -> Self {
+
        Self::new(value)
+
    }
+
}
+

+
impl From<&str> for Paint<String> {
+
    fn from(item: &str) -> Self {
+
        Self::new(item.to_string())
+
    }
+
}
+

+
impl<T> Paint<T> {
+
    /// Constructs a new `Paint` structure encapsulating `item` with no set
+
    /// styling.
+
    #[inline]
+
    pub const fn new(item: T) -> Paint<T> {
+
        Paint {
+
            item,
+
            style: Style {
+
                foreground: Color::Unset,
+
                background: Color::Unset,
+
                properties: Property::new(),
+
                wrap: false,
+
            },
+
        }
+
    }
+

+
    /// Constructs a new _wrapping_ `Paint` structure encapsulating `item` with
+
    /// default styling.
+
    ///
+
    /// A wrapping `Paint` converts all color resets written out by the internal
+
    /// value to the styling of itself. This allows for seamless color wrapping
+
    /// of other colored text.
+
    ///
+
    /// # Performance
+
    ///
+
    /// In order to wrap an internal value, the internal value must first be
+
    /// written out to a local buffer and examined. As a result, displaying a
+
    /// wrapped value is likely to result in a heap allocation and copy.
+
    #[inline]
+
    pub const fn wrapping(item: T) -> Paint<T> {
+
        Paint::new(item).wrap()
+
    }
+

+
    /// Constructs a new `Paint` structure encapsulating `item` with the
+
    /// foreground color set to the RGB color `r`, `g`, `b`.
+
    #[inline]
+
    pub const fn rgb(r: u8, g: u8, b: u8, item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::RGB(r, g, b))
+
    }
+

+
    /// Constructs a new `Paint` structure encapsulating `item` with the
+
    /// foreground color set to the fixed 8-bit color `color`.
+
    #[inline]
+
    pub const fn fixed(color: u8, item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::Fixed(color))
+
    }
+

+
    pub const fn red(item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::Red)
+
    }
+

+
    pub const fn black(item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::Black)
+
    }
+

+
    pub const fn yellow(item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::Yellow)
+
    }
+

+
    pub const fn green(item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::Green)
+
    }
+

+
    pub const fn cyan(item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::Cyan)
+
    }
+

+
    pub const fn blue(item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::Blue)
+
    }
+

+
    pub const fn magenta(item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::Magenta)
+
    }
+

+
    pub const fn white(item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::White)
+
    }
+

+
    /// Retrieves the style currently set on `self`.
+
    #[inline]
+
    pub const fn style(&self) -> Style {
+
        self.style
+
    }
+

+
    /// Retrieves a borrow to the inner item.
+
    #[inline]
+
    pub const fn inner(&self) -> &T {
+
        &self.item
+
    }
+

+
    /// Sets the style of `self` to `style`.
+
    #[inline]
+
    pub fn with_style(mut self, style: Style) -> Paint<T> {
+
        self.style = style;
+
        self
+
    }
+

+
    /// Makes `self` a _wrapping_ `Paint`.
+
    ///
+
    /// A wrapping `Paint` converts all color resets written out by the internal
+
    /// value to the styling of itself. This allows for seamless color wrapping
+
    /// of other colored text.
+
    ///
+
    /// # Performance
+
    ///
+
    /// In order to wrap an internal value, the internal value must first be
+
    /// written out to a local buffer and examined. As a result, displaying a
+
    /// wrapped value is likely to result in a heap allocation and copy.
+
    #[inline]
+
    pub const fn wrap(mut self) -> Paint<T> {
+
        self.style.wrap = true;
+
        self
+
    }
+

+
    /// Sets the foreground to `color`.
+
    #[inline]
+
    pub const fn fg(mut self, color: Color) -> Paint<T> {
+
        self.style.foreground = color;
+
        self
+
    }
+

+
    /// Sets the background to `color`.
+
    #[inline]
+
    pub const fn bg(mut self, color: Color) -> Paint<T> {
+
        self.style.background = color;
+
        self
+
    }
+

+
    pub fn bold(mut self) -> Self {
+
        self.style.properties.set(Property::BOLD);
+
        self
+
    }
+

+
    pub fn dim(mut self) -> Self {
+
        self.style.properties.set(Property::DIM);
+
        self
+
    }
+

+
    pub fn italic(mut self) -> Self {
+
        self.style.properties.set(Property::ITALIC);
+
        self
+
    }
+

+
    pub fn underline(mut self) -> Self {
+
        self.style.properties.set(Property::UNDERLINE);
+
        self
+
    }
+

+
    pub fn invert(mut self) -> Self {
+
        self.style.properties.set(Property::INVERT);
+
        self
+
    }
+

+
    pub fn strikethrough(mut self) -> Self {
+
        self.style.properties.set(Property::STRIKETHROUGH);
+
        self
+
    }
+

+
    pub fn blink(mut self) -> Self {
+
        self.style.properties.set(Property::BLINK);
+
        self
+
    }
+

+
    pub fn hidden(mut self) -> Self {
+
        self.style.properties.set(Property::HIDDEN);
+
        self
+
    }
+
}
+

+
impl<T: UnicodeWidthStr> UnicodeWidthStr for Paint<T> {
+
    fn width(&self) -> usize {
+
        self.item.width()
+
    }
+

+
    fn width_cjk(&self) -> usize {
+
        self.item.width_cjk()
+
    }
+
}
+

+
impl<T: fmt::Display> fmt::Display for Paint<T> {
+
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+
        if Paint::is_enabled() && self.style.wrap {
+
            let mut prefix = String::new();
+
            prefix.push_str("\x1B[0m");
+
            self.style.fmt_prefix(&mut prefix)?;
+
            self.style.fmt_prefix(f)?;
+

+
            let item = format!("{}", self.item).replace("\x1B[0m", &prefix);
+
            fmt::Display::fmt(&item, f)?;
+
            self.style.fmt_suffix(f)
+
        } else if Paint::is_enabled() {
+
            self.style.fmt_prefix(f)?;
+
            fmt::Display::fmt(&self.item, f)?;
+
            self.style.fmt_suffix(f)
+
        } else {
+
            fmt::Display::fmt(&self.item, f)
+
        }
+
    }
+
}
+

+
impl Paint<()> {
+
    /// Returns `true` if coloring is enabled and `false` otherwise.
+
    pub fn is_enabled() -> bool {
+
        concolor::get(concolor::Stream::Stdout).ansi_color()
+
    }
+
}
+

+
/// Shorthand for [`Paint::new`].
+
pub fn paint<T>(item: T) -> Paint<T> {
+
    Paint::new(item)
+
}
added radicle-term/src/ansi/style.rs
@@ -0,0 +1,267 @@
+
use std::fmt::{self, Display};
+
use std::hash::{Hash, Hasher};
+
use std::ops::BitOr;
+

+
use super::{Color, Paint};
+

+
#[derive(Default, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Copy, Clone)]
+
pub struct Property(u8);
+

+
impl Property {
+
    pub const BOLD: Self = Property(1 << 0);
+
    pub const DIM: Self = Property(1 << 1);
+
    pub const ITALIC: Self = Property(1 << 2);
+
    pub const UNDERLINE: Self = Property(1 << 3);
+
    pub const BLINK: Self = Property(1 << 4);
+
    pub const INVERT: Self = Property(1 << 5);
+
    pub const HIDDEN: Self = Property(1 << 6);
+
    pub const STRIKETHROUGH: Self = Property(1 << 7);
+

+
    pub const fn new() -> Self {
+
        Property(0)
+
    }
+

+
    #[inline(always)]
+
    pub const fn contains(self, other: Property) -> bool {
+
        (other.0 & self.0) == other.0
+
    }
+

+
    #[inline(always)]
+
    pub fn set(&mut self, other: Property) {
+
        self.0 |= other.0;
+
    }
+

+
    #[inline(always)]
+
    pub fn iter(self) -> Iter {
+
        Iter {
+
            index: 0,
+
            properties: self,
+
        }
+
    }
+
}
+

+
impl BitOr for Property {
+
    type Output = Self;
+

+
    #[inline(always)]
+
    fn bitor(self, rhs: Self) -> Self {
+
        Property(self.0 | rhs.0)
+
    }
+
}
+

+
pub struct Iter {
+
    index: u8,
+
    properties: Property,
+
}
+

+
impl Iterator for Iter {
+
    type Item = usize;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        while self.index < 8 {
+
            let index = self.index;
+
            self.index += 1;
+

+
            if self.properties.contains(Property(1 << index)) {
+
                return Some(index as usize);
+
            }
+
        }
+

+
        None
+
    }
+
}
+

+
/// Represents a set of styling options.
+
#[repr(packed)]
+
#[derive(Default, Debug, Eq, Ord, PartialOrd, Copy, Clone)]
+
pub struct Style {
+
    pub(crate) foreground: Color,
+
    pub(crate) background: Color,
+
    pub(crate) properties: Property,
+
    pub(crate) wrap: bool,
+
}
+

+
impl PartialEq for Style {
+
    fn eq(&self, other: &Style) -> bool {
+
        self.foreground == other.foreground
+
            && self.background == other.background
+
            && self.properties == other.properties
+
    }
+
}
+

+
impl Hash for Style {
+
    fn hash<H: Hasher>(&self, state: &mut H) {
+
        self.foreground.hash(state);
+
        self.background.hash(state);
+
        self.properties.hash(state);
+
    }
+
}
+

+
#[inline]
+
fn write_spliced<T: Display>(c: &mut bool, f: &mut dyn fmt::Write, t: T) -> fmt::Result {
+
    if *c {
+
        write!(f, ";{t}")
+
    } else {
+
        *c = true;
+
        write!(f, "{t}")
+
    }
+
}
+

+
impl Style {
+
    /// Default style with the foreground set to `color` and no other set
+
    /// properties.
+
    #[inline]
+
    pub const fn new(color: Color) -> Style {
+
        // Avoiding `Default::default` since unavailable as `const`
+
        Self {
+
            foreground: color,
+
            background: Color::Unset,
+
            properties: Property::new(),
+
            wrap: false,
+
        }
+
    }
+

+
    /// Sets the foreground to `color`.
+
    #[inline]
+
    pub const fn fg(mut self, color: Color) -> Style {
+
        self.foreground = color;
+
        self
+
    }
+

+
    /// Sets the background to `color`.
+
    #[inline]
+
    pub const fn bg(mut self, color: Color) -> Style {
+
        self.background = color;
+
        self
+
    }
+

+
    /// Sets `self` to be wrapping.
+
    ///
+
    /// A wrapping `Style` converts all color resets written out by the internal
+
    /// value to the styling of itself. This allows for seamless color wrapping
+
    /// of other colored text.
+
    ///
+
    /// # Performance
+
    ///
+
    /// In order to wrap an internal value, the internal value must first be
+
    /// written out to a local buffer and examined. As a result, displaying a
+
    /// wrapped value is likely to result in a heap allocation and copy.
+
    #[inline]
+
    pub const fn wrap(mut self) -> Style {
+
        self.wrap = true;
+
        self
+
    }
+

+
    pub fn bold(mut self) -> Self {
+
        self.properties.set(Property::BOLD);
+
        self
+
    }
+

+
    pub fn dim(mut self) -> Self {
+
        self.properties.set(Property::DIM);
+
        self
+
    }
+

+
    pub fn italic(mut self) -> Self {
+
        self.properties.set(Property::ITALIC);
+
        self
+
    }
+

+
    pub fn underline(mut self) -> Self {
+
        self.properties.set(Property::UNDERLINE);
+
        self
+
    }
+

+
    pub fn invert(mut self) -> Self {
+
        self.properties.set(Property::INVERT);
+
        self
+
    }
+

+
    pub fn strikethrough(mut self) -> Self {
+
        self.properties.set(Property::STRIKETHROUGH);
+
        self
+
    }
+

+
    /// Constructs a new `Paint` structure that encapsulates `item` with the
+
    /// style set to `self`.
+
    #[inline]
+
    pub fn paint<T>(self, item: T) -> Paint<T> {
+
        Paint::new(item).with_style(self)
+
    }
+

+
    /// Returns the foreground color of `self`.
+
    #[inline]
+
    pub const fn fg_color(&self) -> Color {
+
        self.foreground
+
    }
+

+
    /// Returns the foreground color of `self`.
+
    #[inline]
+
    pub const fn bg_color(&self) -> Color {
+
        self.background
+
    }
+

+
    /// Returns `true` if `self` is wrapping.
+
    #[inline]
+
    pub const fn is_wrapping(&self) -> bool {
+
        self.wrap
+
    }
+

+
    #[inline(always)]
+
    fn is_plain(&self) -> bool {
+
        self == &Style::default()
+
    }
+

+
    /// Writes the ANSI code prefix for the currently set styles.
+
    ///
+
    /// This method is intended to be used inside of [`fmt::Display`] and
+
    /// [`fmt::Debug`] implementations for custom or specialized use-cases. Most
+
    /// users should use [`Paint`] for all painting needs.
+
    ///
+
    /// This method writes the ANSI code prefix irrespective of whether painting
+
    /// is currently enabled or disabled. To write the prefix only if painting
+
    /// is enabled, condition a call to this method on [`Paint::is_enabled()`].
+
    pub fn fmt_prefix(&self, f: &mut dyn fmt::Write) -> fmt::Result {
+
        // A user may just want a code-free string when no styles are applied.
+
        if self.is_plain() {
+
            return Ok(());
+
        }
+

+
        let mut splice = false;
+
        write!(f, "\x1B[")?;
+

+
        for i in self.properties.iter() {
+
            let k = if i >= 5 { i + 2 } else { i + 1 };
+
            write_spliced(&mut splice, f, k)?;
+
        }
+

+
        if self.background != Color::Unset {
+
            write_spliced(&mut splice, f, "4")?;
+
            self.background.ansi_fmt(f)?;
+
        }
+

+
        if self.foreground != Color::Unset {
+
            write_spliced(&mut splice, f, "3")?;
+
            self.foreground.ansi_fmt(f)?;
+
        }
+

+
        // All the codes end with an `m`.
+
        write!(f, "m")
+
    }
+

+
    /// Writes the ANSI code suffix for the currently set styles.
+
    ///
+
    /// This method is intended to be used inside of [`fmt::Display`] and
+
    /// [`fmt::Debug`] implementations for custom or specialized use-cases. Most
+
    /// users should use [`Paint`] for all painting needs.
+
    ///
+
    /// This method writes the ANSI code suffix irrespective of whether painting
+
    /// is currently enabled or disabled. To write the suffix only if painting
+
    /// is enabled, condition a call to this method on [`Paint::is_enabled()`].
+
    pub fn fmt_suffix(&self, f: &mut dyn fmt::Write) -> fmt::Result {
+
        if self.is_plain() {
+
            return Ok(());
+
        }
+
        write!(f, "\x1B[0m")
+
    }
+
}
added radicle-term/src/ansi/tests.rs
@@ -0,0 +1,279 @@
+
use std::sync::Mutex;
+

+
use super::Color::*;
+
use super::Paint;
+

+
/// Ensures tests are running serially.
+
static SERIAL: Mutex<()> = Mutex::new(());
+

+
#[test]
+
fn colors_enabled() {
+
    let _guard = SERIAL.lock();
+

+
    concolor::set(concolor::ColorChoice::Always);
+

+
    assert_eq!(
+
        Paint::new("text/plain").to_string(),
+
        "text/plain".to_string()
+
    );
+
    assert_eq!(
+
        Paint::red("hi").to_string(),
+
        "\x1B[31mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::black("hi").to_string(),
+
        "\x1B[30mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::yellow("hi").bold().to_string(),
+
        "\x1B[1;33mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").fg(Yellow).bold().to_string(),
+
        "\x1B[1;33mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::blue("hi").underline().to_string(),
+
        "\x1B[4;34mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::green("hi").bold().underline().to_string(),
+
        "\x1B[1;4;32mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::green("hi").underline().bold().to_string(),
+
        "\x1B[1;4;32mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::magenta("hi").bg(White).to_string(),
+
        "\x1B[47;35mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::red("hi").bg(Blue).fg(Yellow).to_string(),
+
        "\x1B[44;33mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").bg(Blue).fg(Yellow).to_string(),
+
        "\x1B[44;33mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").bold().bg(White).to_string(),
+
        "\x1B[1;47;36mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").underline().bg(White).to_string(),
+
        "\x1B[4;47;36mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").bold().underline().bg(White).to_string(),
+
        "\x1B[1;4;47;36mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").underline().bold().bg(White).to_string(),
+
        "\x1B[1;4;47;36mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::fixed(100, "hi").to_string(),
+
        "\x1B[38;5;100mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::fixed(100, "hi").bg(Magenta).to_string(),
+
        "\x1B[45;38;5;100mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::fixed(100, "hi").bg(Fixed(200)).to_string(),
+
        "\x1B[48;5;200;38;5;100mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::rgb(70, 130, 180, "hi").to_string(),
+
        "\x1B[38;2;70;130;180mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::rgb(70, 130, 180, "hi").bg(Blue).to_string(),
+
        "\x1B[44;38;2;70;130;180mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::blue("hi").bg(RGB(70, 130, 180)).to_string(),
+
        "\x1B[48;2;70;130;180;34mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::rgb(70, 130, 180, "hi")
+
            .bg(RGB(5, 10, 15))
+
            .to_string(),
+
        "\x1B[48;2;5;10;15;38;2;70;130;180mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").bold().to_string(),
+
        "\x1B[1mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").underline().to_string(),
+
        "\x1B[4mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").bold().underline().to_string(),
+
        "\x1B[1;4mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").dim().to_string(),
+
        "\x1B[2mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").italic().to_string(),
+
        "\x1B[3mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").blink().to_string(),
+
        "\x1B[5mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").invert().to_string(),
+
        "\x1B[7mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").hidden().to_string(),
+
        "\x1B[8mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").strikethrough().to_string(),
+
        "\x1B[9mhi\x1B[0m".to_string()
+
    );
+
}
+

+
#[test]
+
fn colors_disabled() {
+
    let _guard = SERIAL.lock();
+

+
    concolor::set(concolor::ColorChoice::Never);
+

+
    assert_eq!(
+
        Paint::new("text/plain").to_string(),
+
        "text/plain".to_string()
+
    );
+
    assert_eq!(Paint::red("hi").to_string(), "hi".to_string());
+
    assert_eq!(Paint::black("hi").to_string(), "hi".to_string());
+
    assert_eq!(Paint::yellow("hi").bold().to_string(), "hi".to_string());
+
    assert_eq!(
+
        Paint::new("hi").fg(Yellow).bold().to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(Paint::blue("hi").underline().to_string(), "hi".to_string());
+
    assert_eq!(
+
        Paint::green("hi").bold().underline().to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::green("hi").underline().bold().to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(Paint::magenta("hi").bg(White).to_string(), "hi".to_string());
+
    assert_eq!(
+
        Paint::red("hi").bg(Blue).fg(Yellow).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").bg(Blue).fg(Yellow).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").bold().bg(White).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").underline().bg(White).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").bold().underline().bg(White).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").underline().bold().bg(White).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(Paint::fixed(100, "hi").to_string(), "hi".to_string());
+
    assert_eq!(
+
        Paint::fixed(100, "hi").bg(Magenta).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::fixed(100, "hi").bg(Fixed(200)).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(Paint::rgb(70, 130, 180, "hi").to_string(), "hi".to_string());
+
    assert_eq!(
+
        Paint::rgb(70, 130, 180, "hi").bg(Blue).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::blue("hi").bg(RGB(70, 130, 180)).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::blue("hi").bg(RGB(70, 130, 180)).wrap().to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::rgb(70, 130, 180, "hi")
+
            .bg(RGB(5, 10, 15))
+
            .to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(Paint::new("hi").bold().to_string(), "hi".to_string());
+
    assert_eq!(Paint::new("hi").underline().to_string(), "hi".to_string());
+
    assert_eq!(
+
        Paint::new("hi").bold().underline().to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(Paint::new("hi").dim().to_string(), "hi".to_string());
+
    assert_eq!(Paint::new("hi").italic().to_string(), "hi".to_string());
+
    assert_eq!(Paint::new("hi").blink().to_string(), "hi".to_string());
+
    assert_eq!(Paint::new("hi").invert().to_string(), "hi".to_string());
+
    assert_eq!(Paint::new("hi").hidden().to_string(), "hi".to_string());
+
    assert_eq!(
+
        Paint::new("hi").strikethrough().to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").strikethrough().wrap().to_string(),
+
        "hi".to_string()
+
    );
+
}
+

+
#[test]
+
fn wrapping() {
+
    let _guard = SERIAL.lock();
+
    let inner = || format!("{} b {}", Paint::red("a"), Paint::green("c"));
+
    let inner2 = || format!("0 {} 1", Paint::magenta(&inner()).wrap());
+

+
    concolor::set(concolor::ColorChoice::Always);
+

+
    assert_eq!(
+
        Paint::new("text/plain").wrap().to_string(),
+
        "text/plain".to_string()
+
    );
+
    assert_eq!(Paint::new(&inner()).wrap().to_string(), inner());
+
    assert_eq!(
+
        Paint::new(&inner()).wrap().to_string(),
+
        "\u{1b}[31ma\u{1b}[0m b \u{1b}[32mc\u{1b}[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new(&inner()).fg(Blue).wrap().to_string(),
+
        "\u{1b}[34m\u{1b}[31ma\u{1b}[0m\u{1b}[34m b \
+
            \u{1b}[32mc\u{1b}[0m\u{1b}[34m\u{1b}[0m"
+
            .to_string()
+
    );
+
    assert_eq!(Paint::new(&inner2()).wrap().to_string(), inner2());
+
    assert_eq!(
+
        Paint::new(&inner2()).wrap().to_string(),
+
        "0 \u{1b}[35m\u{1b}[31ma\u{1b}[0m\u{1b}[35m b \
+
            \u{1b}[32mc\u{1b}[0m\u{1b}[35m\u{1b}[0m 1"
+
            .to_string()
+
    );
+
    assert_eq!(
+
        Paint::new(&inner2()).fg(Blue).wrap().to_string(),
+
        "\u{1b}[34m0 \u{1b}[35m\u{1b}[31ma\u{1b}[0m\u{1b}[34m\u{1b}[35m b \
+
            \u{1b}[32mc\u{1b}[0m\u{1b}[34m\u{1b}[35m\u{1b}[0m\u{1b}[34m 1\u{1b}[0m"
+
            .to_string()
+
    );
+
}
added radicle-term/src/ansi/windows.rs
@@ -0,0 +1,64 @@
+
#[cfg(windows)]
+
mod windows_console {
+
    use std::os::raw::c_void;
+

+
    #[allow(non_camel_case_types)]
+
    type c_ulong = u32;
+
    #[allow(non_camel_case_types)]
+
    type c_int = i32;
+
    type DWORD = c_ulong;
+
    type LPDWORD = *mut DWORD;
+
    type HANDLE = *mut c_void;
+
    type BOOL = c_int;
+

+
    const ENABLE_VIRTUAL_TERMINAL_PROCESSING: DWORD = 0x0004;
+
    const STD_OUTPUT_HANDLE: DWORD = 0xFFFFFFF5;
+
    const STD_ERROR_HANDLE: DWORD = 0xFFFFFFF4;
+
    const INVALID_HANDLE_VALUE: HANDLE = -1isize as HANDLE;
+
    const FALSE: BOOL = 0;
+
    const TRUE: BOOL = 1;
+

+
    // This is the win32 console API, taken from the 'winapi' crate.
+
    extern "system" {
+
        fn GetStdHandle(nStdHandle: DWORD) -> HANDLE;
+
        fn GetConsoleMode(hConsoleHandle: HANDLE, lpMode: LPDWORD) -> BOOL;
+
        fn SetConsoleMode(hConsoleHandle: HANDLE, dwMode: DWORD) -> BOOL;
+
    }
+

+
    unsafe fn get_handle(handle_num: DWORD) -> Result<HANDLE, ()> {
+
        match GetStdHandle(handle_num) {
+
            handle if handle == INVALID_HANDLE_VALUE => Err(()),
+
            handle => Ok(handle),
+
        }
+
    }
+

+
    unsafe fn enable_vt(handle: HANDLE) -> Result<(), ()> {
+
        let mut dw_mode: DWORD = 0;
+
        if GetConsoleMode(handle, &mut dw_mode) == FALSE {
+
            return Err(());
+
        }
+

+
        dw_mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING;
+
        match SetConsoleMode(handle, dw_mode) {
+
            result if result == TRUE => Ok(()),
+
            _ => Err(()),
+
        }
+
    }
+

+
    unsafe fn enable_ansi_colors_raw() -> Result<bool, ()> {
+
        let stdout_handle = get_handle(STD_OUTPUT_HANDLE)?;
+
        let stderr_handle = get_handle(STD_ERROR_HANDLE)?;
+

+
        enable_vt(stdout_handle)?;
+
        if stdout_handle != stderr_handle {
+
            enable_vt(stderr_handle)?;
+
        }
+

+
        Ok(true)
+
    }
+

+
    #[inline]
+
    pub fn enable_ansi_colors() -> bool {
+
        unsafe { enable_ansi_colors_raw().unwrap_or(false) }
+
    }
+
}
added radicle-term/src/args.rs
@@ -0,0 +1,97 @@
+
use std::ffi::OsString;
+
use std::str::FromStr;
+

+
use anyhow::anyhow;
+
use radicle::crypto;
+
use radicle::prelude::{Did, Id, NodeId};
+

+
#[derive(thiserror::Error, Debug)]
+
pub enum Error {
+
    /// If this error is returned from argument parsing, help is displayed.
+
    #[error("help invoked")]
+
    Help,
+
    /// If this error is returned from argument parsing, usage is displayed.
+
    #[error("usage invoked")]
+
    Usage,
+
    /// An error with a hint.
+
    #[error("{err}")]
+
    WithHint {
+
        err: anyhow::Error,
+
        hint: &'static str,
+
    },
+
}
+

+
pub struct Help {
+
    pub name: &'static str,
+
    pub description: &'static str,
+
    pub version: &'static str,
+
    pub usage: &'static str,
+
}
+

+
pub trait Args: Sized {
+
    fn from_env() -> anyhow::Result<Self> {
+
        let args: Vec<_> = std::env::args_os().into_iter().skip(1).collect();
+

+
        match Self::from_args(args) {
+
            Ok((opts, unparsed)) => {
+
                self::finish(unparsed)?;
+

+
                Ok(opts)
+
            }
+
            Err(err) => Err(err),
+
        }
+
    }
+

+
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)>;
+
}
+

+
pub fn parse_value<T: FromStr>(flag: &str, value: OsString) -> anyhow::Result<T>
+
where
+
    <T as FromStr>::Err: std::error::Error,
+
{
+
    value
+
        .into_string()
+
        .map_err(|_| anyhow!("the value specified for '--{}' is not valid unicode", flag))?
+
        .parse()
+
        .map_err(|e| anyhow!("invalid value specified for '--{}' ({})", flag, e))
+
}
+

+
pub fn format(arg: lexopt::Arg) -> OsString {
+
    match arg {
+
        lexopt::Arg::Long(flag) => format!("--{flag}").into(),
+
        lexopt::Arg::Short(flag) => format!("-{flag}").into(),
+
        lexopt::Arg::Value(val) => val,
+
    }
+
}
+

+
pub fn finish(unparsed: Vec<OsString>) -> anyhow::Result<()> {
+
    if let Some(arg) = unparsed.first() {
+
        return Err(anyhow::anyhow!(
+
            "unexpected argument `{}`",
+
            arg.to_string_lossy()
+
        ));
+
    }
+
    Ok(())
+
}
+

+
pub fn did(val: &OsString) -> anyhow::Result<Did> {
+
    let val = val.to_string_lossy();
+
    let Ok(peer) = Did::from_str(&val) else {
+
        if crypto::PublicKey::from_str(&val).is_ok() {
+
            return Err(anyhow!("expected DID, did you mean 'did:key:{val}'?"));
+
        } else {
+
            return Err(anyhow!("invalid DID '{}', expected 'did:key'", val));
+
        }
+
    };
+
    Ok(peer)
+
}
+

+
pub fn nid(val: &OsString) -> anyhow::Result<NodeId> {
+
    let val = val.to_string_lossy();
+
    NodeId::from_str(&val).map_err(|_| anyhow!("invalid Node ID '{}'", val))
+
}
+

+
pub fn rid(val: &OsString) -> anyhow::Result<Id> {
+
    let val = val.to_string_lossy();
+
    Id::from_str(&val).map_err(|_| anyhow!("invalid Repository ID '{}'", val))
+
}
added radicle-term/src/cell.rs
@@ -0,0 +1,146 @@
+
use std::fmt::Display;
+

+
use super::Paint;
+

+
use unicode_width::UnicodeWidthStr;
+

+
/// Text that can be displayed on the terminal, measured, truncated and padded.
+
pub trait Cell: Display {
+
    /// Type after truncation.
+
    type Truncated: Cell;
+
    /// Type after padding.
+
    type Padded: Cell;
+

+
    /// Cell display width in number of terminal columns.
+
    fn width(&self) -> usize;
+
    /// Truncate cell if longer than given width. Shows the delimiter if truncated.
+
    fn truncate(&self, width: usize, delim: &str) -> Self::Truncated;
+
    /// Pad the cell so that it is the given width, while keeping the content left-aligned.
+
    fn pad(&self, width: usize) -> Self::Padded;
+
}
+

+
impl Cell for Paint<String> {
+
    type Truncated = Self;
+
    type Padded = Self;
+

+
    fn width(&self) -> usize {
+
        UnicodeWidthStr::width(self.content())
+
    }
+

+
    fn truncate(&self, width: usize, delim: &str) -> Self {
+
        Self {
+
            item: self.item.truncate(width, delim),
+
            style: self.style,
+
        }
+
    }
+

+
    fn pad(&self, width: usize) -> Self {
+
        Self {
+
            item: self.item.pad(width),
+
            style: self.style,
+
        }
+
    }
+
}
+

+
impl Cell for Paint<&str> {
+
    type Truncated = Paint<String>;
+
    type Padded = Paint<String>;
+

+
    fn width(&self) -> usize {
+
        Cell::width(self.item)
+
    }
+

+
    fn truncate(&self, width: usize, delim: &str) -> Paint<String> {
+
        Paint {
+
            item: self.item.truncate(width, delim),
+
            style: self.style,
+
        }
+
    }
+

+
    fn pad(&self, width: usize) -> Paint<String> {
+
        Paint {
+
            item: self.item.pad(width),
+
            style: self.style,
+
        }
+
    }
+
}
+

+
impl Cell for String {
+
    type Truncated = Self;
+
    type Padded = Self;
+

+
    fn width(&self) -> usize {
+
        Cell::width(self.as_str())
+
    }
+

+
    fn truncate(&self, width: usize, delim: &str) -> Self {
+
        self.as_str().truncate(width, delim)
+
    }
+

+
    fn pad(&self, width: usize) -> Self {
+
        self.as_str().pad(width)
+
    }
+
}
+

+
impl Cell for str {
+
    type Truncated = String;
+
    type Padded = String;
+

+
    fn width(&self) -> usize {
+
        UnicodeWidthStr::width(self)
+
    }
+

+
    fn truncate(&self, width: usize, delim: &str) -> String {
+
        use unicode_segmentation::UnicodeSegmentation as _;
+

+
        if width < Cell::width(self) {
+
            let d = Cell::width(delim);
+
            if width < d {
+
                // If we can't even fit the delimiter, just return an empty string.
+
                return String::new();
+
            }
+
            // Find the unicode byte boundary where the display width is the largest,
+
            // while being smaller than the given max width.
+
            let mut cols = 0; // Number of visual columns we need.
+
            let mut boundary = 0; // Boundary in bytes.
+
            for g in self.graphemes(true) {
+
                let c = Cell::width(g);
+
                if cols + c + d > width {
+
                    break;
+
                }
+
                boundary += g.len();
+
                cols += c;
+
            }
+
            format!("{}{delim}", &self[..boundary])
+
        } else {
+
            self.to_owned()
+
        }
+
    }
+

+
    fn pad(&self, max: usize) -> String {
+
        let width = Cell::width(self);
+

+
        if width < max {
+
            format!("{self}{}", " ".repeat(max - width))
+
        } else {
+
            self.to_owned()
+
        }
+
    }
+
}
+

+
impl<T: Cell + ?Sized> Cell for &T {
+
    type Truncated = T::Truncated;
+
    type Padded = T::Padded;
+

+
    fn width(&self) -> usize {
+
        T::width(self)
+
    }
+

+
    fn truncate(&self, width: usize, delim: &str) -> Self::Truncated {
+
        T::truncate(self, width, delim)
+
    }
+

+
    fn pad(&self, width: usize) -> Self::Padded {
+
        T::pad(self, width)
+
    }
+
}
added radicle-term/src/cob.rs
@@ -0,0 +1,15 @@
+
use std::str::FromStr;
+

+
use super::*;
+
use radicle::cob::patch;
+

+
use anyhow::anyhow;
+

+
pub fn parse_patch_id(val: OsString) -> Result<patch::PatchId, anyhow::Error> {
+
    let val = val
+
        .to_str()
+
        .ok_or_else(|| anyhow!("patch id specified is not UTF-8"))?;
+
    let patch_id =
+
        patch::PatchId::from_str(val).map_err(|_| anyhow!("invalid patch id '{}'", val))?;
+
    Ok(patch_id)
+
}
added radicle-term/src/command.rs
@@ -0,0 +1,19 @@
+
use std::io::Write;
+
use std::process::{Command, Stdio};
+

+
pub fn bat<S: AsRef<std::ffi::OsStr>>(
+
    args: impl IntoIterator<Item = S>,
+
    stdin: &str,
+
) -> anyhow::Result<()> {
+
    let mut child = Command::new("bat")
+
        .stdin(Stdio::piped())
+
        .args(args)
+
        .spawn()?;
+

+
    let writer = child.stdin.as_mut().unwrap();
+
    writer.write_all(stdin.as_bytes())?;
+

+
    child.wait()?;
+

+
    Ok(())
+
}
added radicle-term/src/editor.rs
@@ -0,0 +1,94 @@
+
use std::ffi::OsString;
+
use std::io::Write;
+
use std::path::PathBuf;
+
use std::process;
+
use std::{env, fs, io};
+

+
pub const COMMENT_FILE: &str = "RAD_COMMENT";
+

+
/// Allows for text input in the configured editor.
+
pub struct Editor {
+
    path: PathBuf,
+
}
+

+
impl Drop for Editor {
+
    fn drop(&mut self) {
+
        fs::remove_file(&self.path).ok();
+
    }
+
}
+

+
impl Default for Editor {
+
    fn default() -> Self {
+
        Self::new()
+
    }
+
}
+

+
impl Editor {
+
    /// Create a new editor.
+
    pub fn new() -> Self {
+
        let path = env::temp_dir().join(COMMENT_FILE);
+

+
        Self { path }
+
    }
+

+
    /// Set the file extension.
+
    pub fn extension(mut self, ext: &str) -> Self {
+
        let ext = ext.trim_start_matches('.');
+

+
        self.path.set_extension(ext);
+
        self
+
    }
+

+
    /// Open the editor and return the edited text.
+
    ///
+
    /// If the text hasn't changed from the initial contents of the editor,
+
    /// return `None`.
+
    pub fn edit(&mut self, initial: impl ToString) -> io::Result<Option<String>> {
+
        let initial = initial.to_string();
+
        let mut file = fs::OpenOptions::new()
+
            .write(true)
+
            .create(true)
+
            .open(&self.path)?;
+

+
        if file.metadata()?.len() == 0 {
+
            file.write_all(initial.as_bytes())?;
+
            if !initial.ends_with('\n') {
+
                file.write_all(b"\n")?;
+
            }
+
            file.flush()?;
+
        }
+

+
        let Some(cmd) = self::default_editor() else {
+
            return Err(
+
                io::Error::new(
+
                    io::ErrorKind::NotFound,
+
                    "editor not configured: the `EDITOR` environment variable is not set"
+
                )
+
            );
+
        };
+
        process::Command::new(cmd).arg(&self.path).spawn()?.wait()?;
+

+
        let text = fs::read_to_string(&self.path)?;
+
        let text = text.strip_prefix(&initial).unwrap_or(&text);
+

+
        if text.trim().is_empty() {
+
            return Ok(None);
+
        }
+
        Ok(Some(text.to_owned()))
+
    }
+
}
+

+
/// Get the default editor command.
+
pub fn default_editor() -> Option<OsString> {
+
    if let Ok(visual) = env::var("VISUAL") {
+
        if !visual.is_empty() {
+
            return Some(visual.into());
+
        }
+
    }
+
    if let Ok(editor) = env::var("EDITOR") {
+
        if !editor.is_empty() {
+
            return Some(editor.into());
+
        }
+
    }
+
    None
+
}
added radicle-term/src/format.rs
@@ -0,0 +1,77 @@
+
use crate::Paint;
+

+
pub fn wrap<D: std::fmt::Display>(msg: D) -> Paint<D> {
+
    Paint::wrapping(msg)
+
}
+

+
pub fn negative<D: std::fmt::Display>(msg: D) -> Paint<D> {
+
    Paint::red(msg).bold()
+
}
+

+
pub fn positive<D: std::fmt::Display>(msg: D) -> Paint<D> {
+
    Paint::green(msg).bold()
+
}
+

+
pub fn secondary<D: std::fmt::Display>(msg: D) -> Paint<D> {
+
    Paint::blue(msg).bold()
+
}
+

+
pub fn tertiary<D: std::fmt::Display>(msg: D) -> Paint<D> {
+
    Paint::cyan(msg)
+
}
+

+
pub fn tertiary_bold<D: std::fmt::Display>(msg: D) -> Paint<D> {
+
    Paint::cyan(msg).bold()
+
}
+

+
pub fn yellow<D: std::fmt::Display>(msg: D) -> Paint<D> {
+
    Paint::yellow(msg)
+
}
+

+
pub fn highlight<D: std::fmt::Debug + std::fmt::Display>(input: D) -> Paint<D> {
+
    Paint::green(input).bold()
+
}
+

+
pub fn badge_primary<D: std::fmt::Display>(input: D) -> Paint<String> {
+
    if Paint::is_enabled() {
+
        Paint::magenta(format!(" {input} ")).invert()
+
    } else {
+
        Paint::new(format!("❲{input}❳"))
+
    }
+
}
+

+
pub fn badge_positive<D: std::fmt::Display>(input: D) -> Paint<String> {
+
    if Paint::is_enabled() {
+
        Paint::green(format!(" {input} ")).invert()
+
    } else {
+
        Paint::new(format!("❲{input}❳"))
+
    }
+
}
+

+
pub fn badge_negative<D: std::fmt::Display>(input: D) -> Paint<String> {
+
    if Paint::is_enabled() {
+
        Paint::red(format!(" {input} ")).invert()
+
    } else {
+
        Paint::new(format!("❲{input}❳"))
+
    }
+
}
+

+
pub fn badge_secondary<D: std::fmt::Display>(input: D) -> Paint<String> {
+
    if Paint::is_enabled() {
+
        Paint::blue(format!(" {input} ")).invert()
+
    } else {
+
        Paint::new(format!("❲{input}❳"))
+
    }
+
}
+

+
pub fn bold<D: std::fmt::Display>(input: D) -> Paint<D> {
+
    Paint::white(input).bold()
+
}
+

+
pub fn dim<D: std::fmt::Display>(input: D) -> Paint<D> {
+
    Paint::new(input).dim()
+
}
+

+
pub fn italic<D: std::fmt::Display>(input: D) -> Paint<D> {
+
    Paint::new(input).italic().dim()
+
}
added radicle-term/src/io.rs
@@ -0,0 +1,236 @@
+
use std::ffi::OsStr;
+
use std::{env, fmt};
+

+
use inquire::ui::{ErrorMessageRenderConfig, StyleSheet, Styled};
+
use inquire::InquireError;
+
use inquire::{ui::Color, ui::RenderConfig, Confirm, CustomType, Password};
+
use once_cell::sync::Lazy;
+
use zeroize::Zeroizing;
+

+
use crate::command;
+
use crate::format;
+
use crate::{style, Paint};
+

+
// TODO: Try not to export this.
+
pub use inquire::Select;
+

+
pub const ERROR_PREFIX: Paint<&str> = Paint::red("✗");
+
pub const ERROR_HINT_PREFIX: Paint<&str> = Paint::yellow("✗");
+
pub const WARNING_PREFIX: Paint<&str> = Paint::yellow("!");
+
pub const TAB: &str = "    ";
+

+
/// Passphrase input.
+
pub type Passphrase = Zeroizing<String>;
+

+
/// Render configuration.
+
pub static CONFIG: Lazy<RenderConfig> = Lazy::new(|| RenderConfig {
+
    prompt: StyleSheet::new().with_fg(Color::LightCyan),
+
    prompt_prefix: Styled::new("?").with_fg(Color::LightBlue),
+
    answered_prompt_prefix: Styled::new("✓").with_fg(Color::LightGreen),
+
    answer: StyleSheet::new(),
+
    highlighted_option_prefix: Styled::new("*").with_fg(Color::LightYellow),
+
    help_message: StyleSheet::new().with_fg(Color::DarkGrey),
+
    error_message: ErrorMessageRenderConfig::default_colored()
+
        .with_prefix(Styled::new("✗").with_fg(Color::LightRed)),
+
    ..RenderConfig::default_colored()
+
});
+

+
#[macro_export]
+
macro_rules! info {
+
    ($($arg:tt)*) => ({
+
        println!("{}", format_args!($($arg)*));
+
    })
+
}
+

+
#[macro_export]
+
macro_rules! success {
+
    ($($arg:tt)*) => ({
+
        $crate::io::success_args(format_args!($($arg)*));
+
    })
+
}
+

+
#[macro_export]
+
macro_rules! tip {
+
    ($($arg:tt)*) => ({
+
        $crate::io::tip_args(format_args!($($arg)*));
+
    })
+
}
+

+
pub use info;
+
pub use success;
+
pub use tip;
+

+
pub fn success_args(args: fmt::Arguments) {
+
    println!("{} {args}", Paint::green("✓"));
+
}
+

+
pub fn tip_args(args: fmt::Arguments) {
+
    println!("👉 {}", style(format!("{args}")).italic());
+
}
+

+
pub fn columns() -> Option<usize> {
+
    termion::terminal_size().map(|(cols, _)| cols as usize).ok()
+
}
+

+
pub fn headline(headline: impl fmt::Display) {
+
    println!();
+
    println!("{}", style(headline).bold());
+
    println!();
+
}
+

+
pub fn header(header: &str) {
+
    println!();
+
    println!("{}", style(format::yellow(header)).bold().underline());
+
    println!();
+
}
+

+
pub fn blob(text: impl fmt::Display) {
+
    println!("{}", style(text.to_string().trim()).dim());
+
}
+

+
pub fn blank() {
+
    println!()
+
}
+

+
pub fn print(msg: impl fmt::Display) {
+
    println!("{msg}");
+
}
+

+
pub fn prefixed(prefix: &str, text: &str) -> String {
+
    text.split('\n')
+
        .map(|line| format!("{prefix}{line}\n"))
+
        .collect()
+
}
+

+
pub fn help(name: &str, version: &str, description: &str, usage: &str) {
+
    println!("rad-{name} {version}\n{description}\n{usage}");
+
}
+

+
pub fn usage(name: &str, usage: &str) {
+
    println!(
+
        "{} {}\n{}",
+
        ERROR_PREFIX,
+
        Paint::red(format!("Error: rad-{name}: invalid usage")),
+
        Paint::red(prefixed(TAB, usage)).dim()
+
    );
+
}
+

+
pub fn println(prefix: impl fmt::Display, msg: impl fmt::Display) {
+
    println!("{prefix} {msg}");
+
}
+

+
pub fn indented(msg: impl fmt::Display) {
+
    println!("{TAB}{msg}");
+
}
+

+
pub fn subcommand(msg: impl fmt::Display) {
+
    println!("{} {}", style("$").dim(), style(msg).dim());
+
}
+

+
pub fn warning(warning: &str) {
+
    println!(
+
        "{} {} {warning}",
+
        WARNING_PREFIX,
+
        Paint::yellow("Warning:").bold(),
+
    );
+
}
+

+
pub fn error(error: impl fmt::Display) {
+
    println!("{ERROR_PREFIX} {error}");
+
}
+

+
pub fn ask<D: fmt::Display>(prompt: D, default: bool) -> bool {
+
    let prompt = prompt.to_string();
+

+
    Confirm::new(&prompt)
+
        .with_default(default)
+
        .with_render_config(*CONFIG)
+
        .prompt()
+
        .unwrap_or_default()
+
}
+

+
pub fn confirm<D: fmt::Display>(prompt: D) -> bool {
+
    ask(prompt, true)
+
}
+

+
pub fn abort<D: fmt::Display>(prompt: D) -> bool {
+
    ask(prompt, false)
+
}
+

+
pub fn input<S, E>(message: &str, default: Option<S>) -> anyhow::Result<S>
+
where
+
    S: fmt::Display + std::str::FromStr<Err = E> + Clone,
+
    E: fmt::Debug + fmt::Display,
+
{
+
    let input = CustomType::<S>::new(message).with_render_config(*CONFIG);
+
    let value = match default {
+
        Some(default) => input.with_default(default).prompt()?,
+
        None => input.prompt()?,
+
    };
+
    Ok(value)
+
}
+

+
pub fn passphrase<K: AsRef<OsStr>>(var: K) -> Result<Passphrase, anyhow::Error> {
+
    if let Ok(p) = env::var(var) {
+
        Ok(Passphrase::from(p))
+
    } else {
+
        Ok(Passphrase::from(
+
            Password::new("Passphrase:")
+
                .with_render_config(*CONFIG)
+
                .with_display_mode(inquire::PasswordDisplayMode::Masked)
+
                .without_confirmation()
+
                .prompt()?,
+
        ))
+
    }
+
}
+

+
pub fn passphrase_confirm<K: AsRef<OsStr>>(
+
    prompt: &str,
+
    var: K,
+
) -> Result<Passphrase, anyhow::Error> {
+
    if let Ok(p) = env::var(var) {
+
        Ok(Passphrase::from(p))
+
    } else {
+
        Ok(Passphrase::from(
+
            Password::new(prompt)
+
                .with_render_config(*CONFIG)
+
                .with_display_mode(inquire::PasswordDisplayMode::Masked)
+
                .with_custom_confirmation_message("Repeat passphrase:")
+
                .with_custom_confirmation_error_message("The passphrases don't match.")
+
                .with_help_message("This passphrase protects your radicle identity")
+
                .prompt()?,
+
        ))
+
    }
+
}
+

+
pub fn passphrase_stdin() -> Result<Passphrase, anyhow::Error> {
+
    let mut input = String::new();
+
    std::io::stdin().read_line(&mut input)?;
+

+
    Ok(Passphrase::from(input.trim_end().to_owned()))
+
}
+

+
pub fn select<'a, T>(
+
    prompt: &str,
+
    options: &'a [T],
+
    active: &'a T,
+
) -> Result<Option<&'a T>, InquireError>
+
where
+
    T: fmt::Display + Eq + PartialEq,
+
{
+
    let active = options.iter().position(|o| o == active);
+
    let selection =
+
        Select::new(prompt, options.iter().collect::<Vec<_>>()).with_render_config(*CONFIG);
+

+
    if let Some(active) = active {
+
        selection.with_starting_cursor(active).prompt_skippable()
+
    } else {
+
        selection.prompt_skippable()
+
    }
+
}
+

+
pub fn markdown(content: &str) {
+
    if !content.is_empty() && command::bat(["-p", "-l", "md"], content).is_err() {
+
        blob(content);
+
    }
+
}
added radicle-term/src/lib.rs
@@ -0,0 +1,62 @@
+
pub mod ansi;
+
pub mod cell;
+
pub mod command;
+
pub mod editor;
+
pub mod format;
+
pub mod io;
+
pub mod spinner;
+
pub mod table;
+
pub mod textbox;
+

+
pub use ansi::{paint, Paint};
+
pub use editor::Editor;
+
pub use inquire::ui::Styled;
+
pub use io::*;
+
pub use spinner::{spinner, Spinner};
+
pub use table::Table;
+
pub use textbox::TextBox;
+

+
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
+
pub enum Interactive {
+
    Yes,
+
    No,
+
}
+

+
impl Default for Interactive {
+
    fn default() -> Self {
+
        Interactive::No
+
    }
+
}
+

+
impl Interactive {
+
    pub fn yes(&self) -> bool {
+
        (*self).into()
+
    }
+

+
    pub fn no(&self) -> bool {
+
        !self.yes()
+
    }
+
}
+

+
impl From<Interactive> for bool {
+
    fn from(c: Interactive) -> Self {
+
        match c {
+
            Interactive::Yes => true,
+
            Interactive::No => false,
+
        }
+
    }
+
}
+

+
impl From<bool> for Interactive {
+
    fn from(b: bool) -> Self {
+
        if b {
+
            Interactive::Yes
+
        } else {
+
            Interactive::No
+
        }
+
    }
+
}
+

+
pub fn style<T>(item: T) -> Paint<T> {
+
    paint(item)
+
}
added radicle-term/src/patch.rs
@@ -0,0 +1,105 @@
+
use radicle::git;
+

+
use crate::terminal as term;
+
use crate::terminal::cell::Cell as _;
+

+
/// The user supplied `Patch` description.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub enum Message {
+
    /// Prompt user to write comment in editor.
+
    Edit,
+
    /// Don't leave a comment.
+
    Blank,
+
    /// Use the following string as comment.
+
    Text(String),
+
}
+

+
impl Message {
+
    /// Get the `Message` as a string according to the method.
+
    pub fn get(self, help: &str) -> String {
+
        let comment = match self {
+
            Message::Edit => term::Editor::new()
+
                .extension("markdown")
+
                .edit(help)
+
                .ok()
+
                .flatten(),
+
            Message::Blank => None,
+
            Message::Text(c) => Some(c),
+
        };
+
        let comment = comment.unwrap_or_default();
+
        let comment = comment.trim();
+

+
        comment.to_owned()
+
    }
+

+
    pub fn append(&mut self, arg: &str) {
+
        if let Message::Text(v) = self {
+
            v.extend(["\n\n", arg]);
+
        } else {
+
            *self = Message::Text(arg.into());
+
        };
+
    }
+
}
+

+
impl Default for Message {
+
    fn default() -> Self {
+
        Self::Edit
+
    }
+
}
+

+
/// List the given commits in a table.
+
pub fn list_commits(commits: &[git::raw::Commit]) -> anyhow::Result<()> {
+
    let mut table = term::Table::default();
+

+
    for commit in commits {
+
        let message = commit
+
            .summary_bytes()
+
            .unwrap_or_else(|| commit.message_bytes());
+
        table.push([
+
            term::format::secondary(term::format::oid(commit.id())),
+
            term::format::italic(String::from_utf8_lossy(message).to_string()),
+
        ]);
+
    }
+
    table.render();
+

+
    Ok(())
+
}
+

+
/// Print commits ahead and behind.
+
pub fn print_commits_ahead_behind(
+
    repo: &git::raw::Repository,
+
    left: git::raw::Oid,
+
    right: git::raw::Oid,
+
) -> anyhow::Result<()> {
+
    let (ahead, behind) = repo.graph_ahead_behind(left, right)?;
+

+
    term::info!(
+
        "{} commit(s) ahead, {} commit(s) behind",
+
        term::format::positive(ahead),
+
        if behind > 0 {
+
            term::format::negative(behind)
+
        } else {
+
            term::format::dim(behind)
+
        }
+
    );
+
    Ok(())
+
}
+

+
/// Print title and description in a text box.
+
pub fn print_title_desc(title: &str, description: &str) {
+
    let title_pretty = &term::format::dim(format!("╭─ {title} ───────"));
+
    term::print(title_pretty);
+
    term::blank();
+

+
    if description.is_empty() {
+
        term::print(term::format::italic("No description provided."));
+
    } else {
+
        term::markdown(description);
+
    }
+

+
    term::blank();
+
    term::print(term::format::dim(format!(
+
        "╰{}",
+
        "─".repeat(title_pretty.to_string().width() - 1)
+
    )));
+
}
added radicle-term/src/spinner.rs
@@ -0,0 +1,186 @@
+
use std::io::Write;
+
use std::mem::ManuallyDrop;
+
use std::sync::{Arc, Mutex};
+
use std::{fmt, io, thread, time};
+

+
use crate::io::{ERROR_PREFIX, WARNING_PREFIX};
+
use crate::Paint;
+

+
/// How much time to wait between spinner animation updates.
+
pub const DEFAULT_TICK: time::Duration = time::Duration::from_millis(99);
+
/// The spinner animation strings.
+
pub const DEFAULT_STYLE: [Paint<&'static str>; 4] = [
+
    Paint::magenta("◢"),
+
    Paint::cyan("◣"),
+
    Paint::magenta("◤"),
+
    Paint::blue("◥"),
+
];
+

+
struct Progress {
+
    state: State,
+
    message: Paint<String>,
+
}
+

+
impl Progress {
+
    fn new(message: Paint<String>) -> Self {
+
        Self {
+
            state: State::Running { cursor: 0 },
+
            message,
+
        }
+
    }
+
}
+

+
enum State {
+
    Running { cursor: usize },
+
    Canceled,
+
    Done,
+
    Warn,
+
    Error,
+
}
+

+
/// A progress spinner.
+
pub struct Spinner {
+
    progress: Arc<Mutex<Progress>>,
+
    handle: ManuallyDrop<thread::JoinHandle<()>>,
+
}
+

+
impl Drop for Spinner {
+
    fn drop(&mut self) {
+
        if let Ok(mut progress) = self.progress.lock() {
+
            if let State::Running { .. } = progress.state {
+
                progress.state = State::Canceled;
+
            }
+
        }
+
        unsafe { ManuallyDrop::take(&mut self.handle) }
+
            .join()
+
            .unwrap();
+
    }
+
}
+

+
impl Spinner {
+
    /// Mark the spinner as successfully completed.
+
    pub fn finish(self) {
+
        if let Ok(mut progress) = self.progress.lock() {
+
            progress.state = State::Done;
+
        }
+
    }
+

+
    /// Mark the spinner as failed. This cancels the spinner.
+
    pub fn failed(self) {
+
        if let Ok(mut progress) = self.progress.lock() {
+
            progress.state = State::Error;
+
        }
+
    }
+

+
    /// Cancel the spinner with an error.
+
    pub fn error(self, msg: impl fmt::Display) {
+
        if let Ok(mut progress) = self.progress.lock() {
+
            progress.state = State::Error;
+
            progress.message = Paint::new(format!(
+
                "{} {} {}",
+
                progress.message,
+
                Paint::red("error:"),
+
                msg
+
            ));
+
        }
+
    }
+

+
    /// Cancel the spinner with a warning sign.
+
    pub fn warn(self) {
+
        if let Ok(mut progress) = self.progress.lock() {
+
            progress.state = State::Warn;
+
        }
+
    }
+

+
    /// Set the spinner's message.
+
    pub fn message(&mut self, msg: impl fmt::Display) {
+
        let msg = msg.to_string();
+

+
        if let Ok(mut progress) = self.progress.lock() {
+
            progress.message = Paint::new(msg);
+
        }
+
    }
+
}
+

+
/// Create a new spinner with the given message.
+
pub fn spinner(message: impl ToString) -> Spinner {
+
    let message = message.to_string();
+
    let progress = Arc::new(Mutex::new(Progress::new(Paint::new(message))));
+
    let handle = thread::spawn({
+
        let progress = progress.clone();
+

+
        move || {
+
            let mut stdout = io::stdout();
+
            let mut stderr = termion::cursor::HideCursor::from(io::stderr());
+

+
            loop {
+
                let Ok(mut progress) = progress.lock() else {
+
                    break;
+
                };
+
                match &mut *progress {
+
                    Progress {
+
                        state: State::Running { cursor },
+
                        message,
+
                    } => {
+
                        let spinner = DEFAULT_STYLE[*cursor];
+

+
                        write!(
+
                            stderr,
+
                            "{}{}{spinner} {message}",
+
                            termion::cursor::Save,
+
                            termion::clear::AfterCursor,
+
                        )
+
                        .ok();
+

+
                        write!(stderr, "{}", termion::cursor::Restore).ok();
+

+
                        *cursor += 1;
+
                        *cursor %= DEFAULT_STYLE.len();
+
                    }
+
                    Progress {
+
                        state: State::Done,
+
                        message,
+
                    } => {
+
                        write!(stderr, "{}", termion::clear::AfterCursor).ok();
+
                        writeln!(stdout, "{} {message}", Paint::green("✓")).ok();
+
                        break;
+
                    }
+
                    Progress {
+
                        state: State::Canceled,
+
                        message,
+
                    } => {
+
                        write!(stderr, "{}", termion::clear::AfterCursor).ok();
+
                        writeln!(
+
                            stdout,
+
                            "{ERROR_PREFIX} {message} {}",
+
                            Paint::red("<canceled>")
+
                        )
+
                        .ok();
+
                        break;
+
                    }
+
                    Progress {
+
                        state: State::Warn,
+
                        message,
+
                    } => {
+
                        writeln!(stdout, "{WARNING_PREFIX} {message}").ok();
+
                        break;
+
                    }
+
                    Progress {
+
                        state: State::Error,
+
                        message,
+
                    } => {
+
                        writeln!(stdout, "{ERROR_PREFIX} {message}").ok();
+
                        break;
+
                    }
+
                }
+
                drop(progress);
+
                thread::sleep(DEFAULT_TICK);
+
            }
+
        }
+
    });
+

+
    Spinner {
+
        progress,
+
        handle: ManuallyDrop::new(handle),
+
    }
+
}
added radicle-term/src/table.rs
@@ -0,0 +1,227 @@
+
//! Print column-aligned text to the console.
+
//!
+
//! Example:
+
//! ```
+
//! use radicle_term::table::*;
+
//!
+
//! let mut t = Table::new(TableOptions::default());
+
//! t.push(["pest", "biological control"]);
+
//! t.push(["aphid", "lacewing"]);
+
//! t.push(["spider mite", "ladybug"]);
+
//! t.render();
+
//! ```
+
//! Output:
+
//! ``` plain
+
//! pest        biological control
+
//! aphid       ladybug
+
//! spider mite persimilis
+
//! ```
+
use std::io;
+

+
use crate as term;
+
use crate::cell::Cell;
+

+
/// Used to specify maximum width or height.
+
#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)]
+
pub struct Max {
+
    width: Option<usize>,
+
    height: Option<usize>,
+
}
+

+
#[derive(Debug, Default)]
+
pub struct TableOptions {
+
    /// Whether the table should be allowed to overflow.
+
    pub overflow: bool,
+
    /// The maximum width and height.
+
    pub max: Max,
+
}
+

+
#[derive(Debug)]
+
pub struct Table<const W: usize> {
+
    rows: Vec<[String; W]>,
+
    widths: [usize; W],
+
    opts: TableOptions,
+
}
+

+
impl<const W: usize> Default for Table<W> {
+
    fn default() -> Self {
+
        Self {
+
            rows: Vec::new(),
+
            widths: [0; W],
+
            opts: TableOptions::default(),
+
        }
+
    }
+
}
+

+
impl<const W: usize> Table<W> {
+
    pub fn new(opts: TableOptions) -> Self {
+
        Self {
+
            rows: Vec::new(),
+
            widths: [0; W],
+
            opts,
+
        }
+
    }
+

+
    pub fn push(&mut self, row: [impl Cell; W]) {
+
        let row = row.map(|s| s.to_string());
+
        for (i, cell) in row.iter().enumerate() {
+
            self.widths[i] = self.widths[i].max(cell.width());
+
        }
+
        self.rows.push(row);
+
    }
+

+
    pub fn render(self) {
+
        self.write(io::stdout()).ok();
+
    }
+

+
    pub fn write<T: io::Write>(self, mut writer: T) -> io::Result<()> {
+
        let width = self.opts.max.width.or_else(term::columns);
+

+
        for row in &self.rows {
+
            let mut output = String::new();
+
            let cells = row.len();
+

+
            for (i, cell) in row.iter().enumerate() {
+
                if i == cells - 1 || self.opts.overflow {
+
                    output.push_str(cell.to_string().as_str());
+
                } else {
+
                    output.push_str(cell.pad(self.widths[i]).as_str());
+
                    output.push(' ');
+
                }
+
            }
+

+
            let output = output.trim_end();
+
            writeln!(
+
                writer,
+
                "{}",
+
                if let Some(width) = width {
+
                    output.truncate(width, "…")
+
                } else {
+
                    output.into()
+
                }
+
            )?;
+
        }
+
        Ok(())
+
    }
+

+
    pub fn render_tree(self) {
+
        for (r, row) in self.rows.iter().enumerate() {
+
            if r != self.rows.len() - 1 {
+
                print!("├── ");
+
            } else {
+
                print!("└── ");
+
            }
+
            for (i, cell) in row.iter().enumerate() {
+
                print!("{} ", cell.pad(self.widths[i]));
+
            }
+
            println!();
+
        }
+
    }
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use super::*;
+
    use pretty_assertions::assert_eq;
+

+
    #[test]
+
    fn test_truncate() {
+
        assert_eq!("🍍".truncate(1, "…"), String::from("…"));
+
        assert_eq!("🍍".truncate(1, ""), String::from(""));
+
        assert_eq!("🍍🍍".truncate(2, "…"), String::from("…"));
+
        assert_eq!("🍍🍍".truncate(3, "…"), String::from("🍍…"));
+
        assert_eq!("🍍".truncate(1, "🍎"), String::from(""));
+
        assert_eq!("🍍".truncate(2, "🍎"), String::from("🍍"));
+
        assert_eq!("🍍🍍".truncate(3, "🍎"), String::from("🍎"));
+
        assert_eq!("🍍🍍🍍".truncate(4, "🍎"), String::from("🍍🍎"));
+
        assert_eq!("hello".truncate(3, "…"), String::from("he…"));
+
    }
+

+
    #[test]
+
    fn test_table() {
+
        let mut s = Vec::new();
+
        let mut t = Table::new(TableOptions::default());
+

+
        t.push(["pineapple", "rosemary"]);
+
        t.push(["apples", "pears"]);
+
        t.write(&mut s).unwrap();
+

+
        #[rustfmt::skip]
+
        assert_eq!(
+
            String::from_utf8_lossy(&s),
+
            [
+
                "pineapple rosemary\n",
+
                "apples    pears\n"
+
            ].join("")
+
        );
+
    }
+

+
    #[test]
+
    fn test_table_truncate() {
+
        let mut s = Vec::new();
+
        let mut t = Table::new(TableOptions {
+
            max: Max {
+
                width: Some(16),
+
                height: None,
+
            },
+
            ..TableOptions::default()
+
        });
+

+
        t.push(["pineapple", "rosemary"]);
+
        t.push(["apples", "pears"]);
+
        t.write(&mut s).unwrap();
+

+
        #[rustfmt::skip]
+
        assert_eq!(
+
            String::from_utf8_lossy(&s),
+
            [
+
                "pineapple rosem…\n",
+
                "apples    pears\n"
+
            ].join("")
+
        );
+
    }
+

+
    #[test]
+
    fn test_table_unicode() {
+
        let mut s = Vec::new();
+
        let mut t = Table::new(TableOptions::default());
+

+
        t.push(["🍍pineapple", "__rosemary", "__sage"]);
+
        t.push(["__pears", "🍎apples", "🍌bananas"]);
+
        t.write(&mut s).unwrap();
+

+
        #[rustfmt::skip]
+
        assert_eq!(
+
            String::from_utf8_lossy(&s),
+
            [
+
                "🍍pineapple __rosemary __sage\n",
+
                "__pears     🍎apples   🍌bananas\n"
+
            ].join("")
+
        );
+
    }
+

+
    #[test]
+
    fn test_table_unicode_truncate() {
+
        let mut s = Vec::new();
+
        let mut t = Table::new(TableOptions {
+
            max: Max {
+
                width: Some(16),
+
                height: None,
+
            },
+
            ..TableOptions::default()
+
        });
+

+
        t.push(["🍍pineapple", "__rosemary"]);
+
        t.push(["__pears", "🍎apples"]);
+
        t.write(&mut s).unwrap();
+

+
        #[rustfmt::skip]
+
        assert_eq!(
+
            String::from_utf8_lossy(&s),
+
            [
+
                "🍍pineapple __r…\n",
+
                "__pears     🍎a…\n"
+
            ].join("")
+
        );
+
    }
+
}
added radicle-term/src/textbox.rs
@@ -0,0 +1,67 @@
+
use std::fmt;
+

+
use crate as term;
+
use crate::cell::Cell as _;
+

+
pub struct TextBox {
+
    pub body: String,
+
    first: bool,
+
    last: bool,
+
}
+

+
impl TextBox {
+
    pub fn new(body: String) -> Self {
+
        Self {
+
            body,
+
            first: true,
+
            last: true,
+
        }
+
    }
+

+
    /// Is this text box the last one in the list?
+
    pub fn last(mut self, connect: bool) -> Self {
+
        self.last = connect;
+
        self
+
    }
+

+
    /// Is this text box the first one in the list?
+
    pub fn first(mut self, connect: bool) -> Self {
+
        self.first = connect;
+
        self
+
    }
+
}
+

+
impl fmt::Display for TextBox {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        let mut width = self.body.lines().map(|l| l.width()).max().unwrap_or(0) + 2;
+
        if let Some(max) = term::columns() {
+
            if width + 2 > max {
+
                width = max - 2
+
            }
+
        }
+

+
        let (connector, header_width) = if !self.first {
+
            ("┴", width - 1)
+
        } else {
+
            ("", width)
+
        };
+
        writeln!(f, "┌{}{}┐", connector, "─".repeat(header_width))?;
+

+
        for l in self.body.lines() {
+
            writeln!(f, "│ {}│", l.pad(width - 1))?;
+
        }
+

+
        let (connector, footer_width) = if !self.last {
+
            ("┬", width - 1)
+
        } else {
+
            ("", width)
+
        };
+

+
        writeln!(f, "└{}{}┘", connector, "─".repeat(footer_width))?;
+

+
        if !self.last {
+
            writeln!(f, " │")?;
+
        }
+
        Ok(())
+
    }
+
}