use std::io::{self, Write};
use crossterm::queue;
use crossterm::style::{
Attribute as CtAttr, Print, ResetColor, SetAttribute, SetBackgroundColor,
SetForegroundColor,
};
use crate::core::style::Attribute;
use crate::core::terminal::{ColorSupport, color_support, term_width};
use crate::core::text::{Line, Span, Text};
use crate::error::Result;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Rendered {
pub lines: Vec<Line>,
}
impl Rendered {
pub fn new(lines: Vec<Line>) -> Self {
Self { lines }
}
pub fn empty() -> Self {
Self { lines: Vec::new() }
}
pub fn push(&mut self, line: impl Into<Line>) {
self.lines.push(line.into());
}
pub fn width(&self) -> usize {
self.lines.iter().map(Line::width).max().unwrap_or(0)
}
pub fn height(&self) -> usize {
self.lines.len()
}
pub fn plain(&self) -> String {
self.lines
.iter()
.map(Line::plain)
.collect::<Vec<_>>()
.join("\n")
}
}
pub trait Renderable {
fn render(&self, max_width: u16) -> Rendered;
fn print(&self) -> Result<()> {
let rendered = self.render(term_width());
let mut out = io::stdout().lock();
write_rendered(&mut out, &rendered, color_support())?;
out.flush()?;
Ok(())
}
fn print_to<W: Write>(&self, writer: &mut W) -> Result<()> {
let rendered = self.render(term_width());
write_rendered(writer, &rendered, color_support())?;
writer.flush()?;
Ok(())
}
}
impl Renderable for Rendered {
fn render(&self, _max_width: u16) -> Rendered {
self.clone()
}
}
impl Renderable for Text {
fn render(&self, _max_width: u16) -> Rendered {
Rendered::new(self.lines.clone())
}
}
pub fn write_rendered<W: Write>(
writer: &mut W,
rendered: &Rendered,
support: ColorSupport,
) -> io::Result<()> {
for line in &rendered.lines {
for span in &line.spans {
write_span(writer, span, support)?;
}
queue!(writer, Print("\n"))?;
}
Ok(())
}
pub(crate) fn write_line<W: Write>(
writer: &mut W,
line: &Line,
support: ColorSupport,
) -> io::Result<()> {
for span in &line.spans {
write_span(writer, span, support)?;
}
Ok(())
}
fn write_span<W: Write>(
writer: &mut W,
span: &Span,
support: ColorSupport,
) -> io::Result<()> {
if support == ColorSupport::None {
queue!(writer, Print(sanitize(&span.content)))?;
return Ok(());
}
let colored = apply_colors(writer, span, support)?;
let attributed = apply_attributes(writer, span.style.attrs)?;
write_content(writer, span)?;
if colored || attributed {
queue!(writer, SetAttribute(CtAttr::Reset), ResetColor)?;
}
Ok(())
}
fn apply_colors<W: Write>(
writer: &mut W,
span: &Span,
support: ColorSupport,
) -> io::Result<bool> {
let mut emitted = false;
if let Some(color) = span.style.fg.and_then(|c| c.resolve(support)) {
queue!(writer, SetForegroundColor(color))?;
emitted = true;
}
if let Some(color) = span.style.bg.and_then(|c| c.resolve(support)) {
queue!(writer, SetBackgroundColor(color))?;
emitted = true;
}
Ok(emitted)
}
fn apply_attributes<W: Write>(
writer: &mut W,
attrs: Attribute,
) -> io::Result<bool> {
let mappings = [
(Attribute::BOLD, CtAttr::Bold),
(Attribute::DIM, CtAttr::Dim),
(Attribute::ITALIC, CtAttr::Italic),
(Attribute::UNDERLINED, CtAttr::Underlined),
(Attribute::STRIKETHROUGH, CtAttr::CrossedOut),
];
let mut emitted = false;
for (flag, command) in mappings {
if attrs.contains(flag) {
queue!(writer, SetAttribute(command))?;
emitted = true;
}
}
Ok(emitted)
}
fn write_content<W: Write>(writer: &mut W, span: &Span) -> io::Result<()> {
let content = sanitize(&span.content);
match &span.link {
None => queue!(writer, Print(content))?,
Some(url) => {
let safe = sanitize(url);
queue!(writer, Print(format!("\x1b]8;;{safe}\x1b\\")))?;
queue!(writer, Print(content))?;
queue!(writer, Print("\x1b]8;;\x1b\\"))?;
}
}
Ok(())
}
fn sanitize(text: &str) -> String {
text.chars()
.filter(|&c| c == '\t' || !c.is_control())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::style::{Color, Style};
fn render_to_string(rendered: &Rendered, support: ColorSupport) -> String {
let mut buffer = Vec::new();
write_rendered(&mut buffer, rendered, support).unwrap();
String::from_utf8(buffer).unwrap()
}
#[test]
fn plain_text_has_no_escapes_without_color() {
let rendered = Rendered::new(vec![Line::raw("hello")]);
let output = render_to_string(&rendered, ColorSupport::None);
assert_eq!(output, "hello\n");
}
#[test]
fn colored_span_emits_escape_codes() {
let line = Line::styled("hi", Style::new().fg(Color::Red));
let rendered = Rendered::new(vec![line]);
let output = render_to_string(&rendered, ColorSupport::TrueColor);
assert!(output.contains('\u{1b}'));
assert!(output.contains("hi"));
}
#[test]
fn rendered_reports_width_and_height() {
let rendered = Rendered::new(vec![Line::raw("ab"), Line::raw("abcd")]);
assert_eq!(rendered.width(), 4);
assert_eq!(rendered.height(), 2);
}
#[test]
fn hyperlink_span_emits_osc8_when_colored() {
let line = Line::new(vec![Span::raw("x").link("http://e.com")]);
let rendered = Rendered::new(vec![line]);
let output = render_to_string(&rendered, ColorSupport::TrueColor);
assert!(output.contains("\x1b]8;;http://e.com"));
}
#[test]
fn hyperlink_is_plain_without_color() {
let line = Line::new(vec![Span::raw("x").link("http://e.com")]);
let rendered = Rendered::new(vec![line]);
let output = render_to_string(&rendered, ColorSupport::None);
assert_eq!(output, "x\n");
}
#[test]
fn control_chars_are_stripped_from_content() {
let line = Line::new(vec![Span::raw("a\x1b[31mb\x07\rc")]);
let rendered = Rendered::new(vec![line]);
let output = render_to_string(&rendered, ColorSupport::TrueColor);
assert!(!output.contains('\u{1b}'));
assert!(!output.contains('\u{7}'));
assert!(!output.contains('\r'));
assert_eq!(output, "a[31mbc\n");
}
#[test]
fn tab_survives_sanitization() {
let rendered = Rendered::new(vec![Line::raw("a\tb")]);
let output = render_to_string(&rendered, ColorSupport::None);
assert_eq!(output, "a\tb\n");
}
#[test]
fn link_injection_is_neutralized() {
let line =
Line::new(vec![Span::raw("x").link("http://e\x1b\\\x1b]8;;evil")]);
let rendered = Rendered::new(vec![line]);
let output = render_to_string(&rendered, ColorSupport::Ansi16);
assert!(!output.contains("\x1b\\\x1b]8;;evil"));
}
}