use std::borrow::Cow;
use std::fmt;
use std::fmt::Display;
use std::fmt::Formatter;
use crossterm::style::Attribute;
use crossterm::style::Color;
use crossterm::style::ContentStyle;
use crossterm::style::ResetColor;
use crossterm::style::SetAttributes;
use crossterm::style::SetBackgroundColor;
use crossterm::style::SetForegroundColor;
use crossterm::style::StyledContent;
use crossterm::Command;
use termwiz::cell;
use unicode_segmentation::Graphemes;
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug, thiserror::Error)]
enum SpanError {
#[error("Word {0} contains non-space whitespace")]
InvalidWhitespace(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct Span {
pub(crate) content: Cow<'static, str>,
pub style: ContentStyle,
}
pub(crate) fn char_valid(c: char) -> bool {
c == ' ' || !c.is_whitespace()
}
pub(crate) fn sanitize<S: std::fmt::Display>(stringlike: S) -> String {
let mut content = stringlike.to_string();
content.retain(char_valid);
content
}
impl Span {
#[inline]
pub fn dash() -> Span {
Span {
content: Cow::Borrowed("-"),
style: ContentStyle::default(),
}
}
pub fn valid(stringlike: &str) -> bool {
!stringlike.contains(|c: char| !char_valid(c))
}
pub fn sanitized<S: std::fmt::Display>(string: S) -> Self {
let content = sanitize(string);
Span {
content: Cow::Owned(content),
style: ContentStyle::default(),
}
}
pub fn content(&self) -> &str {
&self.content
}
pub fn padding(amount: usize) -> Self {
Self {
content: Cow::Owned(format!("{:<width$}", "", width = amount)),
style: ContentStyle::default(),
}
}
pub fn new_unstyled<S: std::fmt::Display>(stringlike: S) -> anyhow::Result<Self> {
let owned = stringlike.to_string();
if Self::valid(&owned) {
Ok(Self {
content: Cow::Owned(owned),
style: ContentStyle::default(),
})
} else {
Err(SpanError::InvalidWhitespace(owned).into())
}
}
pub fn new_unstyled_lossy<S: std::fmt::Display>(stringlike: S) -> Self {
let content = sanitize(stringlike);
Self {
content: Cow::Owned(content),
style: ContentStyle::default(),
}
}
pub fn new_styled(content: StyledContent<String>) -> anyhow::Result<Self> {
if Self::valid(content.content()) {
Ok(Self {
content: Cow::Owned(content.content().clone()),
style: *content.style(),
})
} else {
Err(SpanError::InvalidWhitespace(content.content().to_owned()).into())
}
}
pub fn new_styled_lossy(span: StyledContent<String>) -> Self {
let content = sanitize(span.content());
Self {
content: Cow::Owned(content),
style: *span.style(),
}
}
pub fn new_colored(text: &str, color: Color) -> anyhow::Result<Self> {
Self::new_styled(StyledContent::new(
ContentStyle {
foreground_color: Some(color),
..ContentStyle::default()
},
text.to_owned(),
))
}
pub fn new_colored_lossy(text: &str, color: Color) -> Self {
Self::new_styled_lossy(StyledContent::new(
ContentStyle {
foreground_color: Some(color),
..ContentStyle::default()
},
text.to_owned(),
))
}
pub fn len(&self) -> usize {
cell::unicode_column_width(&self.content, None)
}
pub fn is_empty(&self) -> bool {
self.content.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = Span> + '_ {
SpanIterator(&self.style, self.content.graphemes(true))
}
pub(crate) fn render(&self, f: &mut impl fmt::Write) -> fmt::Result {
if self.is_empty() {
return Ok(());
}
let mut reset_background = false;
let mut reset_foreground = false;
let mut reset = false;
if let Some(bg) = self.style.background_color {
SetBackgroundColor(bg).write_ansi(f)?;
reset_background = true;
}
if let Some(fg) = self.style.foreground_color {
SetForegroundColor(fg).write_ansi(f)?;
reset_foreground = true;
}
if !self.style.attributes.is_empty() {
SetAttributes(self.style.attributes).write_ansi(f)?;
reset = true;
}
write!(f, "{}", self.content)?;
if reset {
ResetColor.write_ansi(f)?;
} else {
if reset_background {
SetBackgroundColor(Color::Reset).write_ansi(f)?;
}
if reset_foreground {
SetForegroundColor(Color::Reset).write_ansi(f)?;
}
}
Ok(())
}
pub fn fmt_for_test(&self) -> impl Display + '_ {
fn to_snake_case(s: &str) -> String {
let mut result = String::new();
for c in s.chars() {
if c.is_uppercase() {
if !result.is_empty() {
result.push('_');
}
result.push(c.to_ascii_lowercase());
} else {
result.push(c);
}
}
result
}
fn fmt_color(color: Color) -> impl Display {
struct Impl(Color);
impl Display for Impl {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self.0 {
Color::Reset
| Color::Black
| Color::Red
| Color::Green
| Color::Yellow
| Color::Blue
| Color::Magenta
| Color::Cyan
| Color::White
| Color::Grey
| Color::DarkGrey
| Color::DarkRed
| Color::DarkGreen
| Color::DarkYellow
| Color::DarkBlue
| Color::DarkMagenta
| Color::DarkCyan => {
write!(f, "{}", to_snake_case(&format!("{:?}", self.0)))
}
Color::Rgb { r, g, b } => write!(f, "rgb({}, {}, {})", r, g, b),
Color::AnsiValue(v) => write!(f, "ansi({})", v),
}
}
}
Impl(color)
}
struct Impl<'a>(&'a Span);
impl<'a> Display for Impl<'a> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let style_is_default = self.0.style.foreground_color.is_none()
&& self.0.style.background_color.is_none()
&& self.0.style.attributes.is_empty();
if style_is_default {
write!(f, "{}", self.0.content)
} else {
write!(f, "<span")?;
if let Some(fg) = self.0.style.foreground_color {
write!(f, " fg={}", fmt_color(fg))?;
}
if let Some(bg) = self.0.style.background_color {
write!(f, " bg={}", fmt_color(bg))?;
}
if !self.0.style.attributes.is_empty() {
let mut a = self.0.style.attributes;
for known in Attribute::iterator() {
if a.has(known) {
write!(f, " {}", to_snake_case(&format!("{:?}", known)))?;
a.unset(known);
}
}
if !a.is_empty() {
write!(f, " unknown_attributes={:?}", a)?;
}
}
write!(f, ">")?;
write!(f, "{}", self.0.content)?;
write!(f, "</span>")?;
Ok(())
}
}
}
Impl(self)
}
}
impl TryFrom<String> for Span {
type Error = anyhow::Error;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new_unstyled(value)
}
}
impl TryFrom<&str> for Span {
type Error = anyhow::Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new_unstyled(value)
}
}
impl TryFrom<StyledContent<String>> for Span {
type Error = anyhow::Error;
fn try_from(value: StyledContent<String>) -> Result<Self, Self::Error> {
Self::new_styled(value)
}
}
pub(crate) struct SpanIterator<'a>(&'a ContentStyle, Graphemes<'a>);
impl<'a> Iterator for SpanIterator<'a> {
type Item = Span;
fn next(&mut self) -> Option<Self::Item> {
let content = self.1.next();
content.map(|content| Span {
style: *self.0,
content: Cow::Owned(content.to_owned()),
})
}
}
#[cfg(test)]
mod tests {
use crossterm::style::Attributes;
use crossterm::style::Stylize;
use super::*;
const BAD_WORD: &str = "i'm really gonna do it\n汉字";
#[test]
fn invalid_span() {
assert!(Span::new_unstyled(BAD_WORD).is_err());
}
#[test]
fn invalid_sanitized() {
let sanitized = Span::sanitized(BAD_WORD);
assert_eq!(sanitized.content, "i'm really gonna do it汉字");
assert!(Span::new_unstyled(sanitized.content).is_ok());
}
#[test]
fn multi_column_character() {
let foot = "\u{1f9b6}";
let span = Span::new_unstyled(foot).unwrap();
assert_eq!(span.len(), 2);
}
#[test]
fn test_padding_equality() {
let lhs = Span::new_styled_lossy(" ".to_owned().red());
let rhs = Span::new_styled_lossy(" ".to_owned().yellow());
assert_ne!(lhs, rhs);
let lhs = Span::new_styled_lossy(" ".to_owned().red().on_yellow());
let rhs = Span::new_styled_lossy(" ".to_owned().yellow().on_green());
assert_ne!(lhs, rhs);
}
#[test]
fn test_inequality() {
let lhs = Span::new_styled_lossy("hello".to_owned().red());
let rhs = Span::new_styled_lossy("world".to_owned().yellow());
assert_ne!(lhs, rhs);
}
#[test]
fn test_equality() {
let lhs = Span::new_styled_lossy("hello".to_owned().red().on_yellow());
let rhs = Span::new_styled_lossy("hello".to_owned().red().on_yellow());
assert_eq!(lhs, rhs);
}
#[test]
fn test_fmt_for_test() {
let span = Span::new_styled(StyledContent::new(
ContentStyle {
foreground_color: Some(Color::Cyan),
background_color: None,
attributes: Attributes::from(Attribute::Bold) | Attributes::from(Attribute::Italic),
},
"fish".to_owned(),
))
.unwrap();
assert_eq!(
"<span fg=cyan bold italic>fish</span>",
span.fmt_for_test().to_string()
);
}
}