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