From 7abfd4870cf22731fc3999b3c4d8091cb9ba1c53 Mon Sep 17 00:00:00 2001 From: cloudhead Date: Mon, 25 Sep 2023 17:43:23 +0200 Subject: [PATCH] term: Overhaul rendering The `Element::render` function now takes a constraint object. This allows the parent element to influence how the child is rendered. This is useful eg. to have elements take up more space than they need to fill some space. We also make various changes needed for pretty diffs, such as merging styles, a new `Filled` type, and some color theming. --- radicle-cli/examples/rad-patch.md | 8 +- radicle-term/src/ansi.rs | 1 + radicle-term/src/ansi/paint.rs | 20 +++ radicle-term/src/ansi/style.rs | 13 ++ radicle-term/src/cell.rs | 41 ++++++- radicle-term/src/colors.rs | 80 +++++++++++- radicle-term/src/element.rs | 198 +++++++++++++++++++++++------- radicle-term/src/hstack.rs | 19 ++- radicle-term/src/io.rs | 8 +- radicle-term/src/label.rs | 42 ++++++- radicle-term/src/lib.rs | 4 +- radicle-term/src/table.rs | 180 +++++++++++++++------------ radicle-term/src/textarea.rs | 6 +- radicle-term/src/vstack.rs | 169 +++++++++++++++++++++---- 14 files changed, 615 insertions(+), 174 deletions(-) diff --git a/radicle-cli/examples/rad-patch.md b/radicle-cli/examples/rad-patch.md index 406a8d8d..9fad65b6 100644 --- a/radicle-cli/examples/rad-patch.md +++ b/radicle-cli/examples/rad-patch.md @@ -99,10 +99,10 @@ And let's leave a quick comment for our team: ``` $ rad patch comment 73b73f376e93e09e0419664766ac9e433bf7d389 --message 'I cannot wait to get back to the 90s!' -╭───────────────────────────────────────────────╮ -│ z6MknSL…StBU8Vi (you) [ ... ] 5c418a5 │ -│ I cannot wait to get back to the 90s! │ -╰───────────────────────────────────────────────╯ +╭────────────────────────────────────────────╮ +│ z6MknSL…StBU8Vi (you) [ ... ] 5c418a5 │ +│ I cannot wait to get back to the 90s! │ +╰────────────────────────────────────────────╯ $ rad patch comment 73b73f376e93e09e0419664766ac9e433bf7d389 --message 'My favorite decade!' --reply-to 5c418a5 -q 729cdf63ce4793ab3cabffbe0dce24db16e45549 ``` diff --git a/radicle-term/src/ansi.rs b/radicle-term/src/ansi.rs index 1fb22463..1b6f594e 100644 --- a/radicle-term/src/ansi.rs +++ b/radicle-term/src/ansi.rs @@ -12,5 +12,6 @@ mod windows; pub use color::Color; pub use paint::paint; +pub use paint::Filled; pub use paint::Paint; pub use style::Style; diff --git a/radicle-term/src/ansi/paint.rs b/radicle-term/src/ansi/paint.rs index bbe14a54..e69d17a0 100644 --- a/radicle-term/src/ansi/paint.rs +++ b/radicle-term/src/ansi/paint.rs @@ -3,6 +3,7 @@ use std::sync; use std::sync::atomic::AtomicBool; use std::{fmt, io}; +use once_cell::sync::Lazy; use unicode_width::UnicodeWidthStr; use super::color::Color; @@ -282,6 +283,12 @@ impl Paint<()> { || anstyle_query::clicolor_force() } + /// Check 24-bit RGB color support. + pub fn truecolor() -> bool { + static TRUECOLOR: Lazy = Lazy::new(anstyle_query::term_supports_color); + *TRUECOLOR + } + /// Enable paint styling. pub fn enable() { ENABLED.store(true, sync::atomic::Ordering::SeqCst); @@ -299,6 +306,19 @@ impl Paint<()> { } } +/// An object filled with a background color. +#[derive(Debug, Clone)] +pub struct Filled { + pub item: T, + pub color: Color, +} + +impl fmt::Display for Filled { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", Paint::wrapping(&self.item).bg(self.color)) + } +} + /// Shorthand for [`Paint::new`]. pub fn paint(item: T) -> Paint { Paint::new(item) diff --git a/radicle-term/src/ansi/style.rs b/radicle-term/src/ansi/style.rs index e17bb14c..5d4d1ee5 100644 --- a/radicle-term/src/ansi/style.rs +++ b/radicle-term/src/ansi/style.rs @@ -135,6 +135,19 @@ impl Style { self } + /// Merge styles with other. This is an additive process, so colors will only + /// be changed if they aren't set on the receiver object. + pub fn merge(mut self, other: Style) -> Style { + if self.foreground == Color::Unset { + self.foreground = other.foreground; + } + if self.background == Color::Unset { + self.background = other.background; + } + self.properties.set(other.properties); + self + } + /// Sets `self` to be wrapping. /// /// A wrapping `Style` converts all color resets written out by the internal diff --git a/radicle-term/src/cell.rs b/radicle-term/src/cell.rs index fb8e8c18..a7d82ff4 100644 --- a/radicle-term/src/cell.rs +++ b/radicle-term/src/cell.rs @@ -1,12 +1,12 @@ -use std::fmt::Display; +use std::fmt; -use super::{Element, Line, Paint}; +use super::{Color, Filled, Line, Paint}; use unicode_segmentation::UnicodeSegmentation as _; use unicode_width::UnicodeWidthStr; /// Text that can be displayed on the terminal, measured, truncated and padded. -pub trait Cell: Display { +pub trait Cell: fmt::Display { /// Type after truncation. type Truncated: Cell; /// Type after padding. @@ -14,6 +14,10 @@ pub trait Cell: Display { /// Cell display width in number of terminal columns. fn width(&self) -> usize; + /// Background color of cell. + fn background(&self) -> Color { + Color::Unset + } /// Truncate cell if longer than given width. Shows the delimiter if truncated. #[must_use] fn truncate(&self, width: usize, delim: &str) -> Self::Truncated; @@ -30,6 +34,10 @@ impl Cell for Paint { Cell::width(self.content()) } + fn background(&self) -> Color { + self.style.background + } + fn truncate(&self, width: usize, delim: &str) -> Self { Self { item: self.item.truncate(width, delim), @@ -50,7 +58,7 @@ impl Cell for Line { type Padded = Line; fn width(&self) -> usize { - ::size(self).cols + Line::width(self) } fn pad(&self, width: usize) -> Self::Padded { @@ -74,6 +82,10 @@ impl Cell for Paint<&str> { Cell::width(self.item) } + fn background(&self) -> Color { + self.style.background + } + fn truncate(&self, width: usize, delim: &str) -> Paint { Paint { item: self.item.truncate(width, delim), @@ -171,3 +183,24 @@ impl Cell for &T { T::pad(self, width) } } + +impl Cell for Filled { + type Truncated = T::Truncated; + type Padded = T::Padded; + + fn width(&self) -> usize { + T::width(&self.item) + } + + fn background(&self) -> Color { + self.color + } + + fn truncate(&self, width: usize, delim: &str) -> Self::Truncated { + T::truncate(&self.item, width, delim) + } + + fn pad(&self, width: usize) -> Self::Padded { + T::pad(&self.item, width) + } +} diff --git a/radicle-term/src/colors.rs b/radicle-term/src/colors.rs index 71f6f102..ffdd7853 100644 --- a/radicle-term/src/colors.rs +++ b/radicle-term/src/colors.rs @@ -1,7 +1,81 @@ use crate::ansi::Color; /// The faintest color; useful for borders and such. -pub const FAINT: Color = Color::Fixed(236); +pub const FAINT: Color = fixed::FAINT; -/// Negative color, useful for errors. -pub const NEGATIVE: Color = Color::Red; +// RGB (24-bit) colors supported by modern terminals. +pub mod rgb { + use super::*; + + pub const NEGATIVE: Color = Color::RGB(60, 10, 20); + pub const POSITIVE: Color = Color::RGB(10, 60, 20); + pub const NEGATIVE_DARK: Color = Color::RGB(30, 10, 20); + pub const POSITIVE_DARK: Color = Color::RGB(10, 30, 20); + pub const NEGATIVE_LIGHT: Color = Color::RGB(170, 80, 120); + pub const POSITIVE_LIGHT: Color = Color::RGB(80, 170, 120); + pub const DIM: Color = Color::RGB(100, 100, 100); + pub const FAINT: Color = Color::RGB(20, 20, 20); + + // Default syntax theme. + pub const PURPLE: Color = Color::RGB(0xd2, 0xa8, 0xff); + pub const RED: Color = Color::RGB(0xff, 0x7b, 0x72); + pub const GREEN: Color = Color::RGB(0x7e, 0xd7, 0x87); + pub const TEAL: Color = Color::RGB(0xa5, 0xd6, 0xff); + pub const ORANGE: Color = Color::RGB(0xff, 0xa6, 0x57); + pub const BLUE: Color = Color::RGB(0x79, 0xc0, 0xff); + pub const GREY: Color = Color::RGB(0x8b, 0x94, 0x9e); + pub const GREY_LIGHT: Color = Color::RGB(0xc9, 0xd1, 0xd9); + + /// Get a color using the color name. + pub fn theme(name: &'static str) -> Option { + match name { + "negative" => Some(NEGATIVE), + "negative.dark" => Some(NEGATIVE_DARK), + "negative.light" => Some(NEGATIVE_LIGHT), + "positive" => Some(POSITIVE), + "positive.dark" => Some(POSITIVE_DARK), + "positive.light" => Some(POSITIVE_LIGHT), + "dim" => Some(DIM), + "faint" => Some(FAINT), + "purple" => Some(PURPLE), + "red" => Some(RED), + "green" => Some(GREEN), + "teal" => Some(TEAL), + "orange" => Some(ORANGE), + "blue" => Some(BLUE), + "grey" => Some(GREY), + "grey.light" => Some(GREY_LIGHT), + + _ => None, + } + } +} + +/// "Fixed" ANSI colors, supported by most terminals. +pub mod fixed { + use super::*; + + /// The faintest color; useful for borders and such. + pub const FAINT: Color = Color::Fixed(236); + /// Slightly brighter than faint. + pub const DIM: Color = Color::Fixed(239); + + /// Get a color using the color name. + pub fn theme(name: &'static str) -> Option { + match name { + "negative" => Some(Color::Red), + "negative.dark" => None, + "positive" => Some(Color::Green), + "positive.dark" => None, + "dim" => None, + "faint" => None, + "blue" => Some(Color::Blue), + "green" => Some(Color::Green), + "red" => Some(Color::Red), + "teal" => Some(Color::Cyan), + "purple" => Some(Color::Magenta), + + _ => None, + } + } +} diff --git a/radicle-term/src/element.rs b/radicle-term/src/element.rs index 75f27aa8..38a0beac 100644 --- a/radicle-term/src/element.rs +++ b/radicle-term/src/element.rs @@ -1,41 +1,114 @@ use std::fmt; +use std::io::IsTerminal; use std::ops::Deref; -use std::vec; +use std::{io, vec}; use crate::cell::Cell; -use crate::Label; +use crate::{viewport, Color, Filled, Label, Style}; + +/// Rendering constraint. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct Constraint { + /// Minimum space the element can take. + pub min: Size, + /// Maximum space the element can take. + pub max: Size, +} + +impl Default for Constraint { + fn default() -> Self { + Self::UNBOUNDED + } +} + +impl Constraint { + /// Can satisfy any size of object. + pub const UNBOUNDED: Self = Self { + min: Size::MIN, + max: Size::MAX, + }; + + /// Create a new constraint. + pub fn new(min: Size, max: Size) -> Self { + assert!(min.cols <= max.cols && min.rows <= max.rows); + + Self { min, max } + } + + /// A constraint with only a maximum size. + pub fn max(max: Size) -> Self { + Self { + min: Size::MIN, + max, + } + } + + /// A constraint that can only be satisfied by a single size. + pub fn tight(size: Size) -> Self { + Self { + min: size, + max: size, + } + } + + /// Return a new constraint that forces objects to the maximum size. + pub fn maximize(self) -> Self { + Self { + min: self.max, + max: self.max, + } + } + + /// Create a constraint from the terminal environment. + /// + /// If standard out isn't a terminal, returns an unbounded constraint. + pub fn from_env() -> Self { + if io::stdout().is_terminal() { + Self::max(viewport().unwrap_or(Size::MAX)) + } else { + Self::UNBOUNDED + } + } +} /// 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; + fn size(&self, parent: Constraint) -> Size; #[must_use] /// Render the element as lines of text that can be printed. - fn render(&self) -> Vec; + fn render(&self, parent: Constraint) -> Vec; /// Get the number of columns occupied by this element. - fn columns(&self) -> usize { - self.size().cols + fn columns(&self, parent: Constraint) -> usize { + self.size(parent).cols } /// Get the number of rows occupied by this element. - fn rows(&self) -> usize { - self.size().rows + fn rows(&self, parent: Constraint) -> usize { + self.size(parent).rows } /// Print this element to stdout. fn print(&self) { - for line in self.render() { - println!("{line}"); + for line in self.render(Constraint::from_env()) { + println!("{}", line.to_string().trim_end()); + } + } + + /// Write to a writer. + fn write(&self, constraints: Constraint) { + for line in self.render(constraints) { + println!("{}", line.to_string().trim_end()); } } #[must_use] /// Return a string representation of this element. - fn display(&self) -> String { + fn display(&self, constraints: Constraint) -> String { let mut out = String::new(); - for line in self.render() { + for line in self.render(constraints) { out.extend(line.into_iter().map(|l| l.to_string())); out.push('\n'); } @@ -44,12 +117,12 @@ pub trait Element: fmt::Debug { } impl<'a> Element for Box { - fn size(&self) -> Size { - self.deref().size() + fn size(&self, parent: Constraint) -> Size { + self.deref().size(parent) } - fn render(&self) -> Vec { - self.deref().render() + fn render(&self, parent: Constraint) -> Vec { + self.deref().render(parent) } fn print(&self) { @@ -58,12 +131,12 @@ impl<'a> Element for Box { } impl Element for &T { - fn size(&self) -> Size { - (*self).size() + fn size(&self, parent: Constraint) -> Size { + (*self).size(parent) } - fn render(&self) -> Vec { - (*self).render() + fn render(&self, parent: Constraint) -> Vec { + (*self).render(parent) } fn print(&self) { @@ -71,13 +144,6 @@ impl Element for &T { } } -/// Used to specify maximum width or height. -#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] -pub struct Max { - pub width: Option, - pub height: Option, -} - /// A line of text that has styling and can be displayed. #[derive(Clone, Default, Debug)] pub struct Line { @@ -97,6 +163,21 @@ impl Line { Self { items: vec![] } } + /// Return a styled line by styling all its labels. + pub fn style(self, style: Style) -> Line { + Self { + items: self + .items + .into_iter() + .map(|l| { + let style = l.paint().style().merge(style); + l.style(style) + }) + .collect(), + } + } + + /// Return a line with a single space between the given labels. pub fn spaced(items: impl IntoIterator) -> Self { let mut line = Self::default(); for item in items.into_iter() { @@ -126,18 +207,23 @@ impl Line { /// Pad this line to occupy the given width. pub fn pad(&mut self, width: usize) { - let w = self.columns(); + let w = self.width(); if width > w { let pad = width - w; - self.items.push(Label::new(" ".repeat(pad).as_str())); + let bg = if let Some(last) = self.items.last() { + last.background() + } else { + Color::Unset + }; + self.items.push(Label::new(" ".repeat(pad).as_str()).bg(bg)); } } /// Truncate this line to the given width. pub fn truncate(&mut self, width: usize, delim: &str) { - while self.columns() > width { - let total = self.columns(); + while self.width() > width { + let total = self.width(); if total - self.items.last().map_or(0, Cell::width) > width { self.items.pop(); @@ -147,22 +233,34 @@ impl Line { } } + /// Get the actual column width of this line. + pub fn width(&self) -> usize { + self.items.iter().map(Cell::width).sum() + } + + /// Create a line that contains a single space. pub fn space(mut self) -> Self { self.items.push(Label::space()); self } + /// Box this line as an [`Element`]. pub fn boxed(self) -> Box { Box::new(self) } + + /// Return a filled line. + pub fn filled(self, color: Color) -> Filled { + Filled { item: self, color } + } } impl IntoIterator for Line { type Item = Label; - type IntoIter = vec::IntoIter