use std::io::{Result as IoResult, Write};
use crate::color::{CanvasColor, NamedColor, TermColor};
use crate::graphics::{GraphicsArea, RowBuffer};
use crate::plot::Plot;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct StyledCell {
pub glyph: char,
pub color: Option<TermColor>,
pub bold: bool,
}
impl StyledCell {
#[must_use]
pub(crate) const fn plain(glyph: char) -> Self {
Self {
glyph,
color: None,
bold: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct RenderedPlot {
rows: Vec<Vec<StyledCell>>,
}
impl RenderedPlot {
#[must_use]
pub(crate) fn rows(&self) -> &[Vec<StyledCell>] {
&self.rows
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct LayoutMetrics {
pub border_length: usize,
pub max_left_label_width: usize,
pub max_right_label_width: usize,
pub ylabel_row: usize,
pub total_width: usize,
}
impl LayoutMetrics {
#[must_use]
pub(crate) fn for_plot<G: GraphicsArea>(plot: &Plot<G>) -> Self {
let border_length = plot.graphics().ncols() + 2;
let show_labels = plot.show_labels();
let max_left_label_width = if show_labels {
plot.annotations()
.left()
.values()
.map(|label| label.text().chars().count())
.max()
.unwrap_or(0)
} else {
0
};
let max_right_label_width = if show_labels {
plot.annotations()
.right()
.values()
.map(|label| label.text().chars().count())
.max()
.unwrap_or(0)
} else {
0
};
let ylabel_row = plot.graphics().nrows() / 2;
let ylabel_width = plot.ylabel().map_or(0, |text| text.chars().count());
let margin = usize::from(plot.margin());
let padding = usize::from(plot.padding());
let total_width = margin
+ ylabel_width
+ padding
+ max_left_label_width
+ padding
+ border_length
+ padding
+ max_right_label_width;
Self {
border_length,
max_left_label_width,
max_right_label_width,
ylabel_row,
total_width,
}
}
}
#[must_use]
pub(crate) fn build_rendered_plot<G: GraphicsArea>(plot: &Plot<G>) -> RenderedPlot {
let layout = LayoutMetrics::for_plot(plot);
let border_chars = plot.border().chars();
let mut rows = Vec::new();
if let Some(title) = plot.title() {
rows.push(centered_row(title, layout.total_width, None, true));
}
if has_any_decoration(plot) {
rows.push(decoration_row(plot, layout, true));
}
rows.push(border_row(
plot,
layout,
border_chars.tl,
border_chars.t,
border_chars.tr,
));
let mut graphics_row = RowBuffer::new();
for row_index in 0..plot.graphics().nrows() {
plot.graphics().render_row(row_index, &mut graphics_row);
rows.push(body_row(plot, layout, row_index, &graphics_row));
}
rows.push(border_row(
plot,
layout,
border_chars.bl,
border_chars.b,
border_chars.br,
));
if has_any_bottom_decoration(plot) {
rows.push(decoration_row(plot, layout, false));
}
if let Some(xlabel) = plot.xlabel() {
rows.push(centered_row(xlabel, layout.total_width, None, false));
}
RenderedPlot { rows }
}
pub(crate) fn write_plain(rendered: &RenderedPlot, writer: &mut impl Write) -> IoResult<()> {
for row in rendered.rows() {
let mut line = String::with_capacity(row.len());
for cell in &row[..trimmed_render_len(row)] {
line.push(cell.glyph);
}
writer.write_all(line.as_bytes())?;
writer.write_all(b"\n")?;
}
Ok(())
}
pub(crate) fn write_ansi(rendered: &RenderedPlot, writer: &mut impl Write) -> IoResult<()> {
for row in rendered.rows() {
let mut active_style = CellStyle::default();
for cell in &row[..trimmed_render_len(row)] {
let style = CellStyle::from(*cell);
if style != active_style {
emit_style_transition(writer, active_style, style)?;
active_style = style;
}
let mut glyph = [0_u8; 4];
writer.write_all(cell.glyph.encode_utf8(&mut glyph).as_bytes())?;
}
if active_style != CellStyle::default() {
emit_style_transition(writer, active_style, CellStyle::default())?;
}
writer.write_all(b"\n")?;
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct CellStyle {
color: Option<TermColor>,
bold: bool,
}
impl From<StyledCell> for CellStyle {
fn from(value: StyledCell) -> Self {
Self {
color: value.color,
bold: value.bold,
}
}
}
fn trimmed_render_len(row: &[StyledCell]) -> usize {
row.iter()
.rposition(|cell| cell.glyph != ' ')
.map_or(0, |index| index + 1)
}
fn emit_style_transition(writer: &mut impl Write, from: CellStyle, to: CellStyle) -> IoResult<()> {
if from.bold != to.bold {
if to.bold {
writer.write_all(b"\x1b[1m")?;
} else {
writer.write_all(b"\x1b[22m")?;
}
}
if from.color != to.color {
if let Some(color) = to.color {
emit_fg_color(writer, color)?;
} else if from.color.is_some() {
writer.write_all(b"\x1b[39m")?;
}
}
Ok(())
}
fn emit_fg_color(writer: &mut impl Write, color: TermColor) -> IoResult<()> {
match color {
TermColor::Named(named) => write!(writer, "\x1b[{}m", named_color_fg_code(named)),
TermColor::Ansi256(index) => write!(writer, "\x1b[38;5;{index}m"),
TermColor::Rgb(red, green, blue) => write!(writer, "\x1b[38;2;{red};{green};{blue}m"),
}
}
const fn named_color_fg_code(color: NamedColor) -> u8 {
match color {
NamedColor::Black => 30,
NamedColor::Red => 31,
NamedColor::Green => 32,
NamedColor::Yellow => 33,
NamedColor::Blue => 34,
NamedColor::Magenta => 35,
NamedColor::Cyan => 36,
NamedColor::White => 37,
NamedColor::LightBlack | NamedColor::Gray => 90,
NamedColor::LightRed => 91,
NamedColor::LightGreen => 92,
NamedColor::LightYellow => 93,
NamedColor::LightBlue => 94,
NamedColor::LightMagenta => 95,
NamedColor::LightCyan => 96,
}
}
fn centered_row(
text: &str,
total_width: usize,
color: Option<TermColor>,
bold: bool,
) -> Vec<StyledCell> {
let mut row = vec![StyledCell::plain(' '); total_width];
let width = text.chars().count();
let start = total_width.saturating_sub(width) / 2;
for (offset, glyph) in text.chars().enumerate() {
if let Some(cell) = row.get_mut(start + offset) {
*cell = StyledCell { glyph, color, bold };
} else {
break;
}
}
row
}
fn has_any_decoration<G: GraphicsArea>(plot: &Plot<G>) -> bool {
let deco = plot.annotations().decorations();
deco.tl().is_some() || deco.t().is_some() || deco.tr().is_some()
}
fn has_any_bottom_decoration<G: GraphicsArea>(plot: &Plot<G>) -> bool {
let deco = plot.annotations().decorations();
deco.bl().is_some() || deco.b().is_some() || deco.br().is_some()
}
fn border_row<G: GraphicsArea>(
plot: &Plot<G>,
layout: LayoutMetrics,
left_corner: char,
fill: char,
right_corner: char,
) -> Vec<StyledCell> {
let mut row = make_row_prefix(plot, None);
row.extend((0..layout.max_left_label_width).map(|_| StyledCell::plain(' ')));
row.extend((0..usize::from(plot.padding())).map(|_| StyledCell::plain(' ')));
row.push(border_cell(left_corner));
row.extend((0..plot.graphics().ncols()).map(|_| border_cell(fill)));
row.push(border_cell(right_corner));
row.extend((0..usize::from(plot.padding())).map(|_| StyledCell::plain(' ')));
row.extend((0..layout.max_right_label_width).map(|_| StyledCell::plain(' ')));
row
}
fn decoration_row<G: GraphicsArea>(
plot: &Plot<G>,
layout: LayoutMetrics,
top: bool,
) -> Vec<StyledCell> {
let mut row = make_row_prefix(plot, None);
row.extend((0..layout.max_left_label_width).map(|_| StyledCell::plain(' ')));
row.extend((0..usize::from(plot.padding())).map(|_| StyledCell::plain(' ')));
let mut border_area = vec![StyledCell::plain(' '); layout.border_length];
let deco = plot.annotations().decorations();
let (left, center, right) = if top {
(deco.tl(), deco.t(), deco.tr())
} else {
(deco.bl(), deco.b(), deco.br())
};
if let Some(text) = left {
overlay_text(
&mut border_area,
0,
text,
Some(TermColor::Named(NamedColor::White)),
);
}
if let Some(text) = center {
let text_width = text.chars().count();
let start = layout.border_length.saturating_sub(text_width) / 2;
overlay_text(
&mut border_area,
start,
text,
Some(TermColor::Named(NamedColor::White)),
);
}
if let Some(text) = right {
let text_width = text.chars().count();
let start = layout.border_length.saturating_sub(text_width);
overlay_text(
&mut border_area,
start,
text,
Some(TermColor::Named(NamedColor::White)),
);
}
row.extend(border_area);
row.extend((0..usize::from(plot.padding())).map(|_| StyledCell::plain(' ')));
row.extend((0..layout.max_right_label_width).map(|_| StyledCell::plain(' ')));
row
}
fn body_row<G: GraphicsArea>(
plot: &Plot<G>,
layout: LayoutMetrics,
row_index: usize,
graphics_row: &RowBuffer,
) -> Vec<StyledCell> {
let ylabel_text = if row_index == layout.ylabel_row {
plot.ylabel()
} else {
None
};
let mut row = make_row_prefix(plot, ylabel_text);
let border = plot.border().chars();
let left_annotation = if plot.show_labels() {
plot.annotations().left().get(&row_index)
} else {
None
};
if let Some(annotation) = left_annotation {
append_right_aligned(
&mut row,
annotation.text(),
layout.max_left_label_width,
annotation.color(),
);
} else {
row.extend((0..layout.max_left_label_width).map(|_| StyledCell::plain(' ')));
}
row.extend((0..usize::from(plot.padding())).map(|_| StyledCell::plain(' ')));
row.push(border_cell(border.l));
for cell in graphics_row {
row.push(StyledCell {
glyph: cell.glyph,
color: term_color_from_canvas_color(cell.color),
bold: false,
});
}
row.push(border_cell(border.r));
row.extend((0..usize::from(plot.padding())).map(|_| StyledCell::plain(' ')));
let right_annotation = if plot.show_labels() {
plot.annotations().right().get(&row_index)
} else {
None
};
if let Some(annotation) = right_annotation {
append_left_aligned(
&mut row,
annotation.text(),
layout.max_right_label_width,
annotation.color(),
);
} else {
row.extend((0..layout.max_right_label_width).map(|_| StyledCell::plain(' ')));
}
row
}
fn term_color_from_canvas_color(color: CanvasColor) -> Option<TermColor> {
match color {
CanvasColor::BLUE => Some(TermColor::Named(NamedColor::Blue)),
CanvasColor::RED => Some(TermColor::Named(NamedColor::Red)),
CanvasColor::MAGENTA => Some(TermColor::Named(NamedColor::Magenta)),
CanvasColor::GREEN => Some(TermColor::Named(NamedColor::Green)),
CanvasColor::CYAN => Some(TermColor::Named(NamedColor::Cyan)),
CanvasColor::YELLOW => Some(TermColor::Named(NamedColor::Yellow)),
CanvasColor::WHITE => Some(TermColor::Named(NamedColor::White)),
_ => None,
}
}
fn border_cell(glyph: char) -> StyledCell {
StyledCell {
glyph,
color: Some(TermColor::Named(NamedColor::LightBlack)),
bold: false,
}
}
fn make_row_prefix<G: GraphicsArea>(plot: &Plot<G>, ylabel_text: Option<&str>) -> Vec<StyledCell> {
let margin = usize::from(plot.margin());
let padding = usize::from(plot.padding());
let ylabel_width = plot.ylabel().map_or(0, |text| text.chars().count());
let mut row = Vec::new();
row.extend((0..margin).map(|_| StyledCell::plain(' ')));
if let Some(text) = ylabel_text {
append_left_aligned(
&mut row,
text,
ylabel_width,
Some(TermColor::Named(NamedColor::White)),
);
} else {
row.extend((0..ylabel_width).map(|_| StyledCell::plain(' ')));
}
row.extend((0..padding).map(|_| StyledCell::plain(' ')));
row
}
fn append_right_aligned(
out: &mut Vec<StyledCell>,
text: &str,
width: usize,
color: Option<TermColor>,
) {
let text_width = text.chars().count();
let left_pad = width.saturating_sub(text_width);
out.extend((0..left_pad).map(|_| StyledCell::plain(' ')));
out.extend(text.chars().map(|glyph| StyledCell {
glyph,
color,
bold: false,
}));
}
fn append_left_aligned(
out: &mut Vec<StyledCell>,
text: &str,
width: usize,
color: Option<TermColor>,
) {
let text_width = text.chars().count();
out.extend(text.chars().map(|glyph| StyledCell {
glyph,
color,
bold: false,
}));
let right_pad = width.saturating_sub(text_width);
out.extend((0..right_pad).map(|_| StyledCell::plain(' ')));
}
fn overlay_text(out: &mut [StyledCell], start: usize, text: &str, color: Option<TermColor>) {
for (offset, glyph) in text.chars().enumerate() {
if let Some(slot) = out.get_mut(start + offset) {
*slot = StyledCell {
glyph,
color,
bold: false,
};
}
}
}
#[cfg(test)]
mod tests {
use super::{
LayoutMetrics, RenderedPlot, StyledCell, build_rendered_plot, write_ansi, write_plain,
};
use crate::border::BorderType;
use crate::canvas::{CanvasType, canvas_types};
use crate::color::{CanvasColor, NamedColor, TermColor};
use crate::graphics::{GraphicsArea, RowBuffer, RowCell};
use crate::lineplot::{LineplotOptions, lineplot};
use crate::plot::{DecorationPosition, Plot};
use crate::test_util::{assert_fixture_eq, render_plot_text};
#[derive(Debug)]
struct TinyGraphics {
rows: Vec<Vec<(char, CanvasColor)>>,
}
impl TinyGraphics {
fn new() -> Self {
Self {
rows: vec![
vec![
('a', CanvasColor::BLUE),
('b', CanvasColor::RED),
('c', CanvasColor::NORMAL),
],
vec![
('d', CanvasColor::GREEN),
('e', CanvasColor::CYAN),
('f', CanvasColor::YELLOW),
],
],
}
}
}
impl GraphicsArea for TinyGraphics {
fn nrows(&self) -> usize {
self.rows.len()
}
fn ncols(&self) -> usize {
self.rows.first().map_or(0, Vec::len)
}
fn render_row(&self, row: usize, out: &mut RowBuffer) {
out.clear();
out.extend(
self.rows[row]
.iter()
.map(|&(glyph, color)| RowCell { glyph, color }),
);
}
}
fn strip_ansi(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' && chars.peek().copied() == Some('[') {
let _ = chars.next();
for c in chars.by_ref() {
if c == 'm' {
break;
}
}
continue;
}
out.push(ch);
}
out
}
#[test]
fn canvas_types_returns_sorted_public_variants() {
assert_eq!(
canvas_types(),
&[
CanvasType::Ascii,
CanvasType::Block,
CanvasType::Braille,
CanvasType::Density,
CanvasType::Dot,
]
);
}
#[test]
fn layout_metrics_match_expected_simple_plot_dimensions() {
let mut plot = Plot::new(TinyGraphics::new());
plot.ylabel = Some(String::from("Y"));
plot.annotate_left(0, "L0", Some(TermColor::Named(NamedColor::Green)));
plot.annotate_right(0, "R", Some(TermColor::Named(NamedColor::Blue)));
let layout = LayoutMetrics::for_plot(&plot);
assert_eq!(layout.border_length, 5);
assert_eq!(layout.max_left_label_width, 2);
assert_eq!(layout.max_right_label_width, 1);
assert_eq!(layout.ylabel_row, 1);
assert_eq!(layout.total_width, 15);
}
#[test]
fn render_plain_text_has_title_borders_and_aligned_labels() {
let mut plot = Plot::new(TinyGraphics::new());
plot.title = Some(String::from("T"));
plot.xlabel = Some(String::from("X"));
plot.ylabel = Some(String::from("Y"));
plot.border = BorderType::Solid;
plot.margin = 1;
plot.padding = 1;
plot.annotate_left(0, "L0", Some(TermColor::Named(NamedColor::Green)));
plot.annotate_right(1, "R1", Some(TermColor::Named(NamedColor::Magenta)));
plot.set_decoration(DecorationPosition::T, "top");
plot.set_decoration(DecorationPosition::B, "bot");
let rendered = build_rendered_plot(&plot);
let mut output = Vec::new();
write_plain(&rendered, &mut output).unwrap_or_else(|error| {
panic!("failed to write plain rendered output: {error}");
});
let rendered_text = String::from_utf8(output)
.unwrap_or_else(|error| panic!("rendered output must be utf-8: {error}"));
assert_eq!(
rendered_text,
" T\n top\n ┌───┐\n L0 │abc│\n Y │def│ R1\n └───┘\n bot\n X\n"
);
}
#[test]
fn rendered_ir_marks_title_cells_bold_and_preserves_graphics_color() {
let mut plot = Plot::new(TinyGraphics::new());
plot.title = Some(String::from("LONG TITLE"));
let rendered = build_rendered_plot(&plot);
let title_row = &rendered.rows()[0];
assert!(title_row.iter().any(|cell| cell.bold));
let body_row = &rendered.rows()[2];
let graphics_start = body_row
.iter()
.position(|cell| cell.glyph == '│')
.unwrap_or_else(|| panic!("expected left border in body row"));
assert_eq!(
body_row[graphics_start + 1].color,
Some(TermColor::Named(NamedColor::Blue))
);
}
#[test]
fn write_plain_trims_trailing_spaces_only() {
let rendered = RenderedPlot {
rows: vec![
vec![
StyledCell::plain(' '),
StyledCell::plain('x'),
StyledCell::plain(' '),
StyledCell::plain(' '),
],
vec![
StyledCell::plain('a'),
StyledCell::plain(' '),
StyledCell::plain('b'),
StyledCell::plain(' '),
],
vec![StyledCell::plain(' '), StyledCell::plain(' ')],
],
};
let mut output = Vec::new();
write_plain(&rendered, &mut output)
.unwrap_or_else(|error| panic!("failed to write plain rows: {error}"));
let text = String::from_utf8(output)
.unwrap_or_else(|error| panic!("output must be utf-8: {error}"));
assert_eq!(text, " x\na b\n\n");
}
#[test]
fn plot_render_writes_ansi_when_color_enabled() {
let mut plot = Plot::new(TinyGraphics::new());
plot.title = Some(String::from("T"));
let mut plain = Vec::new();
plot.render(&mut plain, false)
.unwrap_or_else(|error| panic!("render with color=false failed: {error}"));
let mut ansi = Vec::new();
plot.render(&mut ansi, true)
.unwrap_or_else(|error| panic!("render with color=true failed: {error}"));
let plain_text = String::from_utf8(plain)
.unwrap_or_else(|error| panic!("plain output must be utf-8: {error}"));
let ansi_text = String::from_utf8(ansi)
.unwrap_or_else(|error| panic!("ansi output must be utf-8: {error}"));
assert!(!plain_text.contains("\u{1b}["));
assert!(ansi_text.contains("\u{1b}["));
}
#[test]
fn write_ansi_batches_contiguous_style_runs() {
let rendered = RenderedPlot {
rows: vec![vec![
StyledCell {
glyph: 'a',
color: Some(TermColor::Named(NamedColor::Red)),
bold: false,
},
StyledCell {
glyph: 'b',
color: Some(TermColor::Named(NamedColor::Red)),
bold: false,
},
StyledCell::plain('c'),
]],
};
let mut output = Vec::new();
write_ansi(&rendered, &mut output)
.unwrap_or_else(|error| panic!("failed to write ansi rows: {error}"));
let text = String::from_utf8(output)
.unwrap_or_else(|error| panic!("output must be utf-8: {error}"));
assert_eq!(text.matches("\u{1b}[").count(), 2);
assert!(text.contains("ab"));
assert!(text.ends_with("c\n"));
}
#[test]
fn write_ansi_emits_selective_sgr_when_switching_off_bold_in_same_color() {
let rendered = RenderedPlot {
rows: vec![vec![
StyledCell {
glyph: 'A',
color: Some(TermColor::Named(NamedColor::Red)),
bold: true,
},
StyledCell {
glyph: 'B',
color: Some(TermColor::Named(NamedColor::Red)),
bold: false,
},
]],
};
let mut output = Vec::new();
write_ansi(&rendered, &mut output)
.unwrap_or_else(|error| panic!("failed to write ansi rows: {error}"));
let text = String::from_utf8(output)
.unwrap_or_else(|error| panic!("output must be utf-8: {error}"));
assert!(text.starts_with("\u{1b}[1m\u{1b}[31mA"));
assert!(text.contains("A\u{1b}[22mB"));
assert!(text.ends_with("\u{1b}[39m\n"));
assert!(!text.contains("\u{1b}[0m"));
}
#[test]
fn write_ansi_switches_between_foreground_colors_without_full_reset() {
let rendered = RenderedPlot {
rows: vec![vec![
StyledCell {
glyph: 'A',
color: Some(TermColor::Named(NamedColor::Red)),
bold: false,
},
StyledCell {
glyph: 'B',
color: Some(TermColor::Named(NamedColor::Green)),
bold: false,
},
StyledCell::plain('C'),
]],
};
let mut output = Vec::new();
write_ansi(&rendered, &mut output)
.unwrap_or_else(|error| panic!("failed to write ansi rows: {error}"));
let text = String::from_utf8(output)
.unwrap_or_else(|error| panic!("output must be utf-8: {error}"));
assert_eq!(text, "\u{1b}[31mA\u{1b}[32mB\u{1b}[39mC\n");
assert!(!text.contains("\u{1b}[0m"));
}
#[test]
fn write_ansi_resets_style_at_row_boundary() {
let rendered = RenderedPlot {
rows: vec![
vec![StyledCell {
glyph: 'A',
color: Some(TermColor::Named(NamedColor::Red)),
bold: false,
}],
vec![StyledCell::plain('B')],
],
};
let mut output = Vec::new();
write_ansi(&rendered, &mut output)
.unwrap_or_else(|error| panic!("failed to write ansi rows: {error}"));
let text = String::from_utf8(output)
.unwrap_or_else(|error| panic!("output must be utf-8: {error}"));
assert_eq!(text, "\u{1b}[31mA\u{1b}[39m\nB\n");
}
#[test]
fn production_lineplot_ansi_matches_existing_fixture() {
let x = [-1, 1, 3, 3, -1];
let y = [2, 0, -5, 2, -5];
let plot = lineplot(&x, &y, LineplotOptions::default())
.unwrap_or_else(|error| panic!("lineplot construction should succeed: {error}"));
let ansi_rendered = render_plot_text(&plot, true);
assert_fixture_eq(&ansi_rendered, "tests/fixtures/lineplot/default.txt");
let plain_rendered = render_plot_text(&plot, false);
assert_eq!(plain_rendered, strip_ansi(&ansi_rendered));
}
}