Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
term: Implement new output widget system
Alexis Sellier committed 3 years ago
commit ab74e54949cc23485d64354c5da3a825bd8bd6ee
parent 73221e43d781f17aa8ef1830f10b56c14a3d93a5
22 files changed +638 -178
modified radicle-cli/examples/rad-issue.md
@@ -17,7 +17,7 @@ The issue is now listed under our project.

```
$ rad issue list
-
2e8c1bf3fe0532a314778357c886608a966a34bd "flux capacitor underpowered"
+
2e8c1bf3fe0532a314778357c886608a966a34bd "flux capacitor underpowered" ❲unassigned❳
```

Great! Now we've documented the issue for ourselves and others.
modified radicle-cli/examples/rad-patch.md
@@ -58,10 +58,14 @@ Define power requirements 191a14e520f R0 3e674d1 (flux-capacitor-power) ahead 1,
Nothing to show.

$ rad patch show 191a14e520f2eeff7c0e3ee0a5523c5217eecb89
-

-
Define power requirements
-

-
See details.
+
╭────────────────────────────────────────────────────────────────────╮
+
│ Title   Define power requirements                                  │
+
│ Patch   191a14e520f2eeff7c0e3ee0a5523c5217eecb89                   │
+
│ Author  did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi   │
+
│ Status  open                                                       │
+
│                                                                    │
+
│ See details.                                                       │
+
╰────────────────────────────────────────────────────────────────────╯

commit 3e674d1a1df90807e934f9ae5da2591dd6848a33
Author: radicle <radicle@localhost>
modified radicle-cli/examples/workflow/3-issues.md
@@ -17,7 +17,7 @@ The issue is now listed under our project.

```
$ rad issue list
-
b05e945bb63c11bf80320f4e26ad1d1f7c51f755 "flux capacitor underpowered"
+
b05e945bb63c11bf80320f4e26ad1d1f7c51f755 "flux capacitor underpowered" ❲unassigned❳
```

Great! Now we've documented the issue for ourselves and others.
modified radicle-cli/examples/workflow/4-patching-contributor.md
@@ -58,10 +58,14 @@ Define power requirements a07ef7743a3 R0 3e674d1 (flux-capacitor-power) ahead 1,
Nothing to show.

$ rad patch show a07ef7743a32a2e902672ea3526d1db6ee08108a
-

-
Define power requirements
-

-
See details.
+
╭────────────────────────────────────────────────────────────────────╮
+
│ Title   Define power requirements                                  │
+
│ Patch   a07ef7743a32a2e902672ea3526d1db6ee08108a                   │
+
│ Author  did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk   │
+
│ Status  open                                                       │
+
│                                                                    │
+
│ See details.                                                       │
+
╰────────────────────────────────────────────────────────────────────╯

commit 3e674d1a1df90807e934f9ae5da2591dd6848a33
Author: radicle <radicle@localhost>
modified radicle-cli/src/commands/id.rs
@@ -10,6 +10,7 @@ use radicle_crypto::Verified;

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

pub const HELP: Help = Help {
@@ -403,7 +404,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                    state,
                ]);
            }
-
            t.render();
+
            t.print();
        }
        Operation::Commit { id, rev } => {
            let mut proposal = proposals.get_mut(&id)?;
modified radicle-cli/src/commands/issue.rs
@@ -8,6 +8,7 @@ use radicle::prelude::Did;

use crate::terminal as term;
use crate::terminal::args::{Args, Error, Help};
+
use crate::terminal::Element;

use radicle::cob::common::{Reaction, Tag};
use radicle::cob::issue;
@@ -314,10 +315,14 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                t.push([
                    id.to_string(),
                    format!("{:?}", issue.title()),
-
                    assigned.to_string(),
+
                    if assigned.is_empty() {
+
                        String::from("❲unassigned❳")
+
                    } else {
+
                        assigned.to_string()
+
                    },
                ]);
            }
-
            t.render();
+
            t.print();
        }
        Operation::Delete { id } => {
            issues.remove(&id, &signer)?;
modified radicle-cli/src/commands/ls.rs
@@ -4,6 +4,7 @@ use crate::terminal as term;
use crate::terminal::args::{Args, Error, Help};

use radicle::storage::{ReadRepository, ReadStorage};
+
use term::Element;

pub const HELP: Help = Help {
    name: "ls",
@@ -52,13 +53,13 @@ pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        let Ok(proj) = repo.project_of(profile.id()) else { return };
        let head = term::format::oid(head);
        table.push([
-
            term::format::bold(proj.name()),
-
            term::format::tertiary(id.urn().as_str()),
-
            term::format::secondary(head.as_str()),
-
            term::format::italic(proj.description()),
+
            term::format::bold(proj.name().to_owned()),
+
            term::format::tertiary(id.urn()),
+
            term::format::secondary(head),
+
            term::format::italic(proj.description().to_owned()),
        ]);
    });
-
    table.render();
+
    table.print();

    Ok(())
}
modified radicle-cli/src/commands/node/routing.rs
@@ -1,17 +1,25 @@
use radicle::node;

use crate::terminal as term;
+
use crate::terminal::Element;

pub fn run<S: node::routing::Store>(routing: &S) -> anyhow::Result<()> {
    let mut t = term::Table::new(term::table::TableOptions::default());
-
    t.push(["RID", "NID"]);
-
    t.push(["---", "---"]);
+
    t.push([
+
        term::format::default(String::from("RID")),
+
        term::format::default(String::from("NID")),
+
    ]);
+
    t.push([
+
        term::format::default(String::from("---")),
+
        term::format::default(String::from("---")),
+
    ]);
    for (id, node) in routing.entries()? {
        t.push([
-
            term::format::highlight(id).to_string(),
-
            term::format::node(&node),
+
            term::format::highlight(id.to_string()),
+
            term::format::default(term::format::node(&node)),
        ]);
    }
-
    t.render();
+
    t.print();
+

    Ok(())
}
modified radicle-cli/src/commands/node/tracking.rs
@@ -2,6 +2,7 @@ use radicle::node::tracking;
use radicle::prelude::Did;

use crate::terminal as term;
+
use term::Element;

use super::TrackingMode;

@@ -15,33 +16,55 @@ pub fn run(store: &tracking::store::Config, mode: TrackingMode) -> anyhow::Resul

fn print_repos(store: &tracking::store::Config) -> anyhow::Result<()> {
    let mut t = term::Table::new(term::table::TableOptions::default());
-
    t.push(["RID", "Scope", "Policy"]);
-
    t.push(["---", "-----", "------"]);
+
    t.push([
+
        term::format::default(String::from("RID")),
+
        term::format::default(String::from("Scope")),
+
        term::format::default(String::from("Policy")),
+
    ]);
+
    t.push([
+
        term::format::default(String::from("---")),
+
        term::format::default(String::from("-----")),
+
        term::format::default(String::from("------")),
+
    ]);
    for tracking::Repo { id, scope, policy } in store.repo_policies()? {
+
        let id = id.to_string();
+
        let scope = scope.to_string();
+
        let policy = policy.to_string();
+

        t.push([
-
            term::format::highlight(id.to_string()),
-
            term::format::secondary(scope.to_string()),
-
            term::format::secondary(policy.to_string()),
+
            term::format::highlight(id),
+
            term::format::secondary(scope),
+
            term::format::secondary(policy),
        ])
    }
-
    t.render();
+
    t.print();
+

    Ok(())
}

fn print_nodes(store: &tracking::store::Config) -> anyhow::Result<()> {
    let mut t = term::Table::new(term::table::TableOptions::default());
-
    t.push(["DID", "Alias", "Policy"]);
-
    t.push(["---", "-----", "------"]);
+
    t.push([
+
        term::format::default(String::from("DID")),
+
        term::format::default(String::from("Alias")),
+
        term::format::default(String::from("Policy")),
+
    ]);
+
    t.push([
+
        term::format::default(String::from("---")),
+
        term::format::default(String::from("-----")),
+
        term::format::default(String::from("------")),
+
    ]);
    for tracking::Node { id, alias, policy } in store.node_policies()? {
        t.push([
            term::format::highlight(Did::from(id).to_string()),
            match alias {
-
                None => term::format::secondary("n/a".to_string()),
+
                None => term::format::secondary(String::from("n/a")),
                Some(alias) => term::format::secondary(alias),
            },
            term::format::secondary(policy.to_string()),
        ]);
    }
-
    t.render();
+
    t.print();
+

    Ok(())
}
modified radicle-cli/src/commands/patch/show.rs
@@ -6,6 +6,11 @@ use super::*;
use radicle::cob::patch;
use radicle::git;
use radicle::storage::git::Repository;
+
use radicle_term::{
+
    label,
+
    table::{Table, TableOptions},
+
    Element, Paint, VStack,
+
};

use crate::terminal as term;

@@ -40,15 +45,40 @@ pub fn run(
        anyhow::bail!("Patch `{patch_id}` not found");
    };

-
    term::blank();
-
    term::info!("{}", term::format::bold(patch.title()));
-
    term::blank();
+
    let mut attrs = Table::<2, Paint<String>>::new(TableOptions {
+
        spacing: 2,
+
        ..TableOptions::default()
+
    });
+
    attrs.push([
+
        term::format::tertiary("Title".to_owned()),
+
        term::format::bold(patch.title().to_owned()),
+
    ]);
+
    attrs.push([
+
        term::format::tertiary("Patch".to_owned()),
+
        term::format::default(patch_id.to_string()),
+
    ]);
+
    attrs.push([
+
        term::format::tertiary("Author".to_owned()),
+
        term::format::default(patch.author().id().to_string()),
+
    ]);
+
    attrs.push([
+
        term::format::tertiary("Status".to_owned()),
+
        term::format::default(patch.state().to_string()),
+
    ]);

    let description = patch.description().trim();
-
    if !description.is_empty() {
-
        term::blob(description);
-
        term::blank();
-
    }
+
    let meta = VStack::default()
+
        .border(Some(term::ansi::Color::Blue))
+
        .child(attrs)
+
        .blank()
+
        .children(if !description.is_empty() {
+
            Some(label(term::format::dim(description)))
+
        } else {
+
            None
+
        });
+

+
    meta.print();
+
    term::blank();

    show_patch_diff(&patch, storage, workdir)?;
    term::blank();
modified radicle-cli/src/commands/self.rs
@@ -5,6 +5,7 @@ use radicle::Profile;

use crate::terminal as term;
use crate::terminal::args::{Args, Error, Help};
+
use crate::terminal::Element as _;

pub const HELP: Help = Help {
    name: "self",
@@ -143,7 +144,7 @@ fn all(profile: &Profile) -> anyhow::Result<()> {
        term::format::tertiary(node_path.join("radicle.sock").display()).to_string(),
    ]);

-
    table.render();
+
    table.print();

    Ok(())
}
modified radicle-cli/src/terminal/patch.rs
@@ -1,6 +1,7 @@
use radicle::git;

use crate::terminal as term;
+
use crate::terminal::Element;

/// The user supplied `Patch` description.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -59,7 +60,7 @@ pub fn list_commits(commits: &[git::raw::Commit]) -> anyhow::Result<()> {
            term::format::italic(String::from_utf8_lossy(message).to_string()),
        ]);
    }
-
    table.render();
+
    table.print();

    Ok(())
}
modified radicle-term/src/ansi/paint.rs
@@ -38,6 +38,15 @@ impl From<&str> for Paint<String> {
    }
}

+
impl From<Paint<&str>> for Paint<String> {
+
    fn from(paint: Paint<&str>) -> Self {
+
        Self {
+
            item: paint.item.to_owned(),
+
            style: paint.style,
+
        }
+
    }
+
}
+

impl<T> Paint<T> {
    /// Constructs a new `Paint` structure encapsulating `item` with no set
    /// styling.
added radicle-term/src/element.rs
@@ -0,0 +1,187 @@
+
use std::fmt;
+
use std::ops::Deref;
+
use std::vec;
+

+
use crate::cell::Cell;
+
use crate::Label;
+

+
/// A text element that has a size and can be rendered to the terminal.
+
pub trait Element: fmt::Debug {
+
    /// Get the size of the element, in rows and columns.
+
    fn size(&self) -> Size;
+

+
    #[must_use]
+
    /// Render the element as lines of text that can be printed.
+
    fn render(&self) -> Vec<Line>;
+

+
    /// Get the number of columns occupied by this element.
+
    fn columns(&self) -> usize {
+
        self.size().cols
+
    }
+

+
    /// Get the number of rows occupied by this element.
+
    fn rows(&self) -> usize {
+
        self.size().rows
+
    }
+

+
    /// Print this element to stdout.
+
    fn print(&self) {
+
        for line in self.render() {
+
            println!("{line}");
+
        }
+
    }
+

+
    #[must_use]
+
    /// Return a string representation of this element.
+
    fn display(&self) -> String {
+
        let mut out = String::new();
+
        for line in self.render() {
+
            out.extend(line.into_iter().map(|l| l.to_string()));
+
            out.push('\n');
+
        }
+
        out
+
    }
+
}
+

+
impl<T: Element> Element for &T {
+
    fn size(&self) -> Size {
+
        self.deref().size()
+
    }
+

+
    fn render(&self) -> Vec<Line> {
+
        self.deref().render()
+
    }
+

+
    fn print(&self) {
+
        self.deref().print()
+
    }
+
}
+

+
/// A line of text that has styling and can be displayed.
+
#[derive(Clone, Default, Debug)]
+
pub struct Line {
+
    items: Vec<Label>,
+
}
+

+
impl Line {
+
    /// Create a new line.
+
    pub fn new(item: impl Into<Label>) -> Self {
+
        Self {
+
            items: vec![item.into()],
+
        }
+
    }
+

+
    /// Add an item to this line.
+
    pub fn item(mut self, item: impl Into<Label>) -> Self {
+
        self.push(item);
+
        self
+
    }
+

+
    /// Add multiple items to this line.
+
    pub fn extend(mut self, items: impl IntoIterator<Item = Label>) -> Self {
+
        self.items.extend(items.into_iter());
+
        self
+
    }
+

+
    /// Add an item to this line.
+
    pub fn push(&mut self, item: impl Into<Label>) {
+
        self.items.push(item.into());
+
    }
+

+
    /// Pad this line to occupy the given width.
+
    pub fn pad(&mut self, width: usize) {
+
        let w = Element::columns(self);
+
        let pad = width - w;
+

+
        self.items.push(Label::new(" ".repeat(pad).as_str()));
+
    }
+

+
    /// Truncate this line to the given width.
+
    pub fn truncate(&mut self, width: usize, delim: &str) {
+
        while self.columns() > width {
+
            let total = self.columns();
+

+
            if total - self.items.last().map_or(0, Cell::width) > width {
+
                self.items.pop();
+
            } else if let Some(item) = self.items.last_mut() {
+
                *item = item.truncate(width - (total - Cell::width(item)), delim);
+
            }
+
        }
+
    }
+
}
+

+
impl IntoIterator for Line {
+
    type Item = Label;
+
    type IntoIter = vec::IntoIter<Label>;
+

+
    fn into_iter(self) -> Self::IntoIter {
+
        self.items.into_iter()
+
    }
+
}
+

+
impl From<Label> for Line {
+
    fn from(label: Label) -> Self {
+
        Self { items: vec![label] }
+
    }
+
}
+

+
impl Element for Line {
+
    fn size(&self) -> Size {
+
        Size::new(self.items.iter().map(Cell::width).sum(), 1)
+
    }
+

+
    fn render(&self) -> Vec<Line> {
+
        vec![self.clone()]
+
    }
+
}
+

+
impl fmt::Display for Line {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        for item in &self.items {
+
            write!(f, "{item}")?;
+
        }
+
        Ok(())
+
    }
+
}
+

+
/// Size of a text element, in columns and rows.
+
#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)]
+
pub struct Size {
+
    /// Columns occupied.
+
    pub cols: usize,
+
    /// Rows occupied.
+
    pub rows: usize,
+
}
+

+
impl Size {
+
    /// Create a new [`Size`].
+
    pub fn new(cols: usize, rows: usize) -> Self {
+
        Self { cols, rows }
+
    }
+
}
+

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

+
    #[test]
+
    fn test_truncate() {
+
        let line = Line::default().item("banana").item("peach").item("apple");
+

+
        let mut actual = line.clone();
+
        actual.truncate(9, "…");
+
        assert_eq!(actual.to_string(), "bananape…");
+

+
        let mut actual = line.clone();
+
        actual.truncate(7, "…");
+
        assert_eq!(actual.to_string(), "banana…");
+

+
        let mut actual = line.clone();
+
        actual.truncate(1, "…");
+
        assert_eq!(actual.to_string(), "…");
+

+
        let mut actual = line;
+
        actual.truncate(0, "…");
+
        assert_eq!(actual.to_string(), "");
+
    }
+
}
modified radicle-term/src/format.rs
@@ -1,5 +1,9 @@
use crate::Paint;

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

pub fn wrap<D: std::fmt::Display>(msg: D) -> Paint<D> {
    Paint::wrapping(msg)
}
@@ -28,6 +32,10 @@ pub fn yellow<D: std::fmt::Display>(msg: D) -> Paint<D> {
    Paint::yellow(msg)
}

+
pub fn faint<D: std::fmt::Display>(msg: D) -> Paint<D> {
+
    Paint::fixed(236, msg)
+
}
+

pub fn highlight<D: std::fmt::Debug + std::fmt::Display>(input: D) -> Paint<D> {
    Paint::green(input).bold()
}
added radicle-term/src/hstack.rs
@@ -0,0 +1,43 @@
+
use crate::{Element, Line, Size};
+

+
/// Horizontal stack of [`Element`] objects that implements [`Element`].
+
#[derive(Default, Debug)]
+
pub struct HStack<'a> {
+
    elems: Vec<Box<dyn Element + 'a>>,
+
    width: usize,
+
    height: usize,
+
}
+

+
impl<'a> HStack<'a> {
+
    /// Add an element to the stack.
+
    pub fn child(mut self, child: impl Element + 'a) -> Self {
+
        self.width += child.columns();
+
        self.height = self.height.max(child.rows());
+
        self.elems.push(Box::new(child));
+
        self
+
    }
+
}
+

+
impl<'a> Element for HStack<'a> {
+
    fn size(&self) -> Size {
+
        Size::new(self.width, self.height)
+
    }
+

+
    fn render(&self) -> Vec<Line> {
+
        fn rearrange(input: Vec<Vec<Line>>) -> Vec<Line> {
+
            let max_len = input.iter().map(|v| v.len()).max().unwrap_or(0);
+

+
            (0..max_len)
+
                .map(|i| {
+
                    Line::default().extend(
+
                        input
+
                            .iter()
+
                            .filter_map(move |v| v.get(i))
+
                            .flat_map(|l| l.clone().into_iter()),
+
                    )
+
                })
+
                .collect()
+
        }
+
        rearrange(self.elems.iter().map(|e| e.render()).collect())
+
    }
+
}
added radicle-term/src/label.rs
@@ -0,0 +1,87 @@
+
use std::fmt;
+

+
use crate::{cell::Cell, Element, Line, Paint, Size};
+

+
/// A styled string that does not contain any `'\n'` and implements [`Element`] and [`Cell`].
+
#[derive(Clone, Default, Debug)]
+
pub struct Label(Paint<String>);
+

+
impl Label {
+
    /// Create a new label.
+
    pub fn new(s: &str) -> Self {
+
        Self(Paint::new(s.replace('\n', " ")))
+
    }
+

+
    /// Create a blank label.
+
    pub fn blank() -> Self {
+
        Self(Paint::default())
+
    }
+
}
+

+
impl Element for Label {
+
    fn size(&self) -> Size {
+
        Size::new(self.0.width(), 1)
+
    }
+

+
    fn render(&self) -> Vec<Line> {
+
        vec![Line::new(self.clone())]
+
    }
+
}
+

+
impl fmt::Display for Label {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        write!(f, "{}", self.0)
+
    }
+
}
+

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

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

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

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

+
impl From<Paint<String>> for Label {
+
    fn from(paint: Paint<String>) -> Self {
+
        Self(Paint {
+
            item: paint.item.replace('\n', " "),
+
            style: paint.style,
+
        })
+
    }
+
}
+

+
impl From<Paint<&str>> for Label {
+
    fn from(paint: Paint<&str>) -> Self {
+
        Self(Paint {
+
            item: paint.item.replace('\n', " "),
+
            style: paint.style,
+
        })
+
    }
+
}
+

+
impl From<String> for Label {
+
    fn from(value: String) -> Self {
+
        Label::from(value.as_str())
+
    }
+
}
+

+
impl From<&str> for Label {
+
    fn from(value: &str) -> Self {
+
        Self(Paint::new(value.replace('\n', " ")))
+
    }
+
}
+

+
/// Create a new label from a [`Paint`] object.
+
pub fn label(s: impl Into<Paint<String>>) -> Label {
+
    Label(s.into())
+
}
modified radicle-term/src/lib.rs
@@ -2,19 +2,26 @@ pub mod ansi;
pub mod cell;
pub mod command;
pub mod editor;
+
pub mod element;
pub mod format;
+
pub mod hstack;
pub mod io;
+
pub mod label;
pub mod spinner;
pub mod table;
-
pub mod textbox;
+
pub mod vstack;

+
pub use ansi::Color;
pub use ansi::{paint, Paint};
pub use editor::Editor;
+
pub use element::{Element, Line, Size};
+
pub use hstack::HStack;
pub use inquire::ui::Styled;
pub use io::*;
+
pub use label::{label, Label};
pub use spinner::{spinner, Spinner};
pub use table::Table;
-
pub use textbox::TextBox;
+
pub use vstack::{VStack, VStackOptions};

#[derive(Debug, PartialEq, Eq, Copy, Clone)]
pub enum Interactive {
modified radicle-term/src/table.rs
@@ -8,7 +8,7 @@
//! t.push(["pest", "biological control"]);
//! t.push(["aphid", "lacewing"]);
//! t.push(["spider mite", "ladybug"]);
-
//! t.render();
+
//! t.print();
//! ```
//! Output:
//! ``` plain
@@ -16,10 +16,13 @@
//! aphid       ladybug
//! spider mite persimilis
//! ```
-
use std::io;
+
use std::fmt;

use crate as term;
use crate::cell::Cell;
+
use crate::{Label, Line, Size};
+

+
pub use crate::Element;

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

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

+
impl Default for TableOptions {
+
    fn default() -> Self {
+
        Self {
+
            overflow: false,
+
            max: Max::default(),
+
            spacing: 1,
+
        }
+
    }
}

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

-
impl<const W: usize> Default for Table<W> {
+
impl<const W: usize, T> Default for Table<W, T> {
    fn default() -> Self {
        Self {
            rows: Vec::new(),
            widths: [0; W],
+
            width: 0,
            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);
+
impl<const W: usize, T: Cell + fmt::Debug> Element for Table<W, T>
+
where
+
    T::Padded: Into<Label>,
+
{
+
    fn size(&self) -> Size {
+
        Table::size(self)
    }

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

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

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

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

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

-
    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!();
+
impl<const W: usize, T: Cell> Table<W, T> {
+
    pub fn new(opts: TableOptions) -> Self {
+
        Self {
+
            rows: Vec::new(),
+
            widths: [0; W],
+
            width: 0,
+
            opts,
+
        }
+
    }
+

+
    pub fn size(&self) -> Size {
+
        Size::new(self.width, self.rows.len())
+
    }
+

+
    pub fn push(&mut self, row: [T; W]) {
+
        for (i, cell) in row.iter().enumerate() {
+
            self.widths[i] = self.widths[i].max(cell.width());
        }
+
        self.width =
+
            self.width.max(row.iter().map(Cell::width).sum()) + (W - 1) * self.opts.spacing;
+
        self.rows.push(row);
    }
}

#[cfg(test)]
mod test {
+
    use crate::Element;
+

    use super::*;
    use pretty_assertions::assert_eq;

@@ -139,16 +149,14 @@ mod test {

    #[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),
+
            t.display(),
            [
                "pineapple rosemary\n",
                "apples    pears\n"
@@ -158,7 +166,6 @@ mod test {

    #[test]
    fn test_table_truncate() {
-
        let mut s = Vec::new();
        let mut t = Table::new(TableOptions {
            max: Max {
                width: Some(16),
@@ -169,11 +176,10 @@ mod test {

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

        #[rustfmt::skip]
        assert_eq!(
-
            String::from_utf8_lossy(&s),
+
            t.display(),
            [
                "pineapple rosem…\n",
                "apples    pears\n"
@@ -183,16 +189,14 @@ mod test {

    #[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),
+
            t.display(),
            [
                "🍍pineapple __rosemary __sage\n",
                "__pears     🍎apples   🍌bananas\n"
@@ -202,7 +206,6 @@ mod test {

    #[test]
    fn test_table_unicode_truncate() {
-
        let mut s = Vec::new();
        let mut t = Table::new(TableOptions {
            max: Max {
                width: Some(16),
@@ -213,11 +216,10 @@ mod test {

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

        #[rustfmt::skip]
        assert_eq!(
-
            String::from_utf8_lossy(&s),
+
            t.display(),
            [
                "🍍pineapple __r…\n",
                "__pears     🍎a…\n"
deleted radicle-term/src/textbox.rs
@@ -1,67 +0,0 @@
-
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(())
-
    }
-
}
added radicle-term/src/vstack.rs
@@ -0,0 +1,96 @@
+
use crate::{Color, Element, Label, Line, Paint, Size};
+

+
/// Options for [`VStack`].
+
#[derive(Default, Debug)]
+
pub struct VStackOptions {
+
    border: Option<Color>,
+
}
+

+
/// Vertical stack of [`Element`] objects that implements [`Element`].
+
#[derive(Default, Debug)]
+
pub struct VStack<'a> {
+
    elems: Vec<Box<dyn Element + 'a>>,
+
    opts: VStackOptions,
+
    width: usize,
+
    height: usize,
+
}
+

+
impl<'a> VStack<'a> {
+
    /// Add an element to the stack.
+
    pub fn child(mut self, child: impl Element + 'a) -> Self {
+
        self.width = self.width.max(child.columns());
+
        self.height += child.rows();
+
        self.elems.push(Box::new(child));
+
        self
+
    }
+

+
    /// Add a blank line to the stack.
+
    pub fn blank(self) -> Self {
+
        self.child(Label::blank())
+
    }
+

+
    /// Add multiple elements to the stack.
+
    pub fn children<E: Element + 'a>(self, children: impl IntoIterator<Item = E>) -> Self {
+
        let mut vstack = self;
+

+
        for child in children.into_iter() {
+
            vstack = vstack.child(child);
+
        }
+
        vstack
+
    }
+

+
    /// Set or unset the outer border.
+
    pub fn border(mut self, color: Option<Color>) -> Self {
+
        self.opts.border = color;
+
        self
+
    }
+
}
+

+
impl<'a> Element for VStack<'a> {
+
    fn size(&self) -> Size {
+
        if self.opts.border.is_some() {
+
            Size::new(self.width + 4, self.height + 2)
+
        } else {
+
            Size::new(self.width, self.height)
+
        }
+
    }
+

+
    fn render(&self) -> Vec<Line> {
+
        let mut lines = Vec::new();
+

+
        if let Some(color) = self.opts.border {
+
            lines.push(
+
                Line::default()
+
                    .item(Paint::new("╭").fg(color))
+
                    .item(Paint::new("─".repeat(self.width + 2)).fg(color))
+
                    .item(Paint::new("╮").fg(color)),
+
            );
+
        }
+

+
        for elem in &self.elems {
+
            for mut line in elem.render() {
+
                if let Some(color) = self.opts.border {
+
                    line.pad(self.width);
+
                    lines.push(
+
                        Line::default()
+
                            .item(Paint::new("│ ").fg(color))
+
                            .extend(line)
+
                            .item(Paint::new(" │").fg(color)),
+
                    );
+
                } else {
+
                    lines.push(line);
+
                }
+
            }
+
        }
+

+
        if let Some(color) = self.opts.border {
+
            lines.push(
+
                Line::default()
+
                    .item(Paint::new("╰").fg(color))
+
                    .item(Paint::new("─".repeat(self.width + 2)).fg(color))
+
                    .item(Paint::new("╯").fg(color)),
+
            );
+
        }
+
        lines.into_iter().flat_map(|h| h.render()).collect()
+
    }
+
}
modified radicle/src/cob/patch.rs
@@ -411,6 +411,16 @@ pub enum State {
    Archived,
}

+
impl fmt::Display for State {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        match self {
+
            Self::Proposed => write!(f, "open"),
+
            Self::Draft => write!(f, "draft"),
+
            Self::Archived => write!(f, "archived"),
+
        }
+
    }
+
}
+

/// A merged patch revision.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "camelCase")]