feat(chart): Render Braille over Blocks in Charts and Canvas (#2165)

This makes it possible to stack charts, and write text over block symbols in
Charts and Canvas while still showing the block symbols behind the text.

Co-authored-by: Lunderberg <eldritch.cheese@gmail.com>
This commit is contained in:
Jagoda Estera Ślązak
2025-11-25 09:23:54 +01:00
committed by GitHub
parent 3a945dfe06
commit 26b05dee59
3 changed files with 314 additions and 151 deletions

View File

@@ -14,7 +14,6 @@
//! You can also implement your own custom [`Shape`]s.
use alloc::boxed::Box;
use alloc::string::String;
use alloc::vec;
use alloc::vec::Vec;
use core::fmt;
@@ -69,11 +68,20 @@ pub struct Label<'a> {
/// multiple shapes on the canvas in specific order.
#[derive(Debug)]
struct Layer {
// A string of characters representing the grid. This will be wrapped to the width of the grid
// when rendering
string: String,
// Colors for foreground and background of each cell
colors: Vec<(Color, Color)>,
contents: Vec<LayerCell>,
}
/// A cell within a layer.
///
/// If a [`Context`] contains multiple layers, then the symbol, foreground, and background colors
/// for a character will be determined by the top-most layer that provides a value for that
/// character. For example, a chart drawn with [`Marker::Block`] may provide the background color,
/// and a later chart drawn with [`Marker::Braille`] may provide the symbol and foreground color.
#[derive(Debug)]
struct LayerCell {
symbol: Option<char>,
fg: Option<Color>,
bg: Option<Color>,
}
/// A grid of cells that can be painted on.
@@ -121,7 +129,7 @@ struct BrailleGrid {
utf16_code_points: Vec<u16>,
/// The color of each cell only supports foreground colors for now as there's no way to
/// individually set the background color of each dot in the braille pattern.
colors: Vec<Color>,
colors: Vec<Option<Color>>,
}
impl BrailleGrid {
@@ -133,7 +141,7 @@ impl BrailleGrid {
width,
height,
utf16_code_points: vec![symbols::braille::BLANK; length],
colors: vec![Color::Reset; length],
colors: vec![None; length],
}
}
}
@@ -144,15 +152,34 @@ impl Grid for BrailleGrid {
}
fn save(&self) -> Layer {
let string = String::from_utf16(&self.utf16_code_points).unwrap();
// the background color is always reset for braille patterns
let colors = self.colors.iter().map(|c| (*c, Color::Reset)).collect();
Layer { string, colors }
let contents = self
.utf16_code_points
.iter()
.zip(&self.colors)
.map(|(&code_point, &color)| {
let symbol = match code_point {
// Skip rendering blank braille patterns to allow layers underneath
// to show through.
symbols::braille::BLANK => None,
_ => Some(char::from_u32(code_point.into()).unwrap()),
};
LayerCell {
symbol,
fg: color,
// Braille patterns only affect foreground.
// This way we can have braille layered with block.
bg: None,
}
})
.collect();
Layer { contents }
}
fn reset(&mut self) {
self.utf16_code_points.fill(symbols::braille::BLANK);
self.colors.fill(Color::Reset);
self.colors.fill(None);
}
fn paint(&mut self, x: usize, y: usize, color: Color) {
@@ -166,7 +193,7 @@ impl Grid for BrailleGrid {
*c |= symbols::braille::DOTS[y % 4][x % 2];
}
if let Some(c) = self.colors.get_mut(index) {
*c = color;
*c = Some(color);
}
}
}
@@ -181,12 +208,16 @@ struct CharGrid {
width: u16,
/// Height of the grid in number of terminal rows
height: u16,
/// Represents a single character for each cell
cells: Vec<char>,
/// The color of each cell
colors: Vec<Color>,
cells: Vec<Option<Color>>,
/// The character to use for every cell - e.g. a block, dot, etc.
cell_char: char,
/// If true, apply the color to the background as well as the foreground. This is used for
/// [`Marker::Block`], so that it will overwrite any previous foreground character, but also
/// leave a background that can be overlaid with an additional foreground character.
apply_color_to_bg: bool,
}
impl CharGrid {
@@ -197,9 +228,16 @@ impl CharGrid {
Self {
width,
height,
cells: vec![' '; length],
colors: vec![Color::Reset; length],
cells: vec![None; length],
cell_char,
apply_color_to_bg: false,
}
}
fn apply_color_to_bg(self) -> Self {
Self {
apply_color_to_bg: true,
..self
}
}
}
@@ -211,14 +249,20 @@ impl Grid for CharGrid {
fn save(&self) -> Layer {
Layer {
string: self.cells.iter().collect(),
colors: self.colors.iter().map(|c| (*c, Color::Reset)).collect(),
contents: self
.cells
.iter()
.map(|&color| LayerCell {
symbol: color.map(|_| self.cell_char),
fg: color,
bg: color.filter(|_| self.apply_color_to_bg),
})
.collect(),
}
}
fn reset(&mut self) {
self.cells.fill(' ');
self.colors.fill(Color::Reset);
self.cells.fill(None);
}
fn paint(&mut self, x: usize, y: usize, color: Color) {
@@ -226,10 +270,7 @@ impl Grid for CharGrid {
// using get_mut here because we are indexing the vector with usize values
// and we want to make sure we don't panic if the index is out of bounds
if let Some(c) = self.cells.get_mut(index) {
*c = self.cell_char;
}
if let Some(c) = self.colors.get_mut(index) {
*c = color;
*c = Some(color);
}
}
}
@@ -254,7 +295,7 @@ struct HalfBlockGrid {
/// Height of the grid in number of terminal rows
height: u16,
/// Represents a single color for each "pixel" arranged in column, row order
pixels: Vec<Vec<Color>>,
pixels: Vec<Vec<Option<Color>>>,
}
impl HalfBlockGrid {
@@ -264,7 +305,7 @@ impl HalfBlockGrid {
Self {
width,
height,
pixels: vec![vec![Color::Reset; width as usize]; (height as usize) * 2],
pixels: vec![vec![None; width as usize]; (height as usize) * 2],
}
}
}
@@ -302,45 +343,34 @@ impl Grid for HalfBlockGrid {
.tuples()
.flat_map(|(upper_row, lower_row)| zip(upper_row, lower_row));
// then we work out what character to print for each pair of pixels
let string = vertical_color_pairs
.clone()
.map(|(upper, lower)| match (upper, lower) {
(Color::Reset, Color::Reset) => ' ',
(Color::Reset, _) => symbols::half_block::LOWER,
(_, Color::Reset) => symbols::half_block::UPPER,
(&lower, &upper) => {
if lower == upper {
symbols::half_block::FULL
} else {
symbols::half_block::UPPER
}
}
})
.collect();
// then we convert these each vertical pair of pixels into a foreground and background color
let colors = vertical_color_pairs
// Then we determine the character to print for each pair, along with the color of the
// foreground and background.
let contents = vertical_color_pairs
.map(|(upper, lower)| {
let (fg, bg) = match (upper, lower) {
(Color::Reset, Color::Reset) => (Color::Reset, Color::Reset),
(Color::Reset, &lower) => (lower, Color::Reset),
(&upper, Color::Reset) => (upper, Color::Reset),
(&upper, &lower) => (upper, lower),
let (symbol, fg, bg) = match (upper, lower) {
(None, None) => (None, None, None),
(None, Some(lower)) => (Some(symbols::half_block::LOWER), Some(*lower), None),
(Some(upper), None) => (Some(symbols::half_block::UPPER), Some(*upper), None),
(Some(upper), Some(lower)) if lower == upper => {
(Some(symbols::half_block::FULL), Some(*upper), Some(*lower))
}
(Some(upper), Some(lower)) => {
(Some(symbols::half_block::UPPER), Some(*upper), Some(*lower))
}
};
(fg, bg)
LayerCell { symbol, fg, bg }
})
.collect();
Layer { string, colors }
Layer { contents }
}
fn reset(&mut self) {
self.pixels.fill(vec![Color::Reset; self.width as usize]);
self.pixels.fill(vec![None; self.width as usize]);
}
fn paint(&mut self, x: usize, y: usize, color: Color) {
self.pixels[y][x] = color;
self.pixels[y][x] = Some(color);
}
}
@@ -462,7 +492,17 @@ impl<'a, 'b> From<&'a mut Context<'b>> for Painter<'a, 'b> {
/// this as similar to the `Frame` struct that is used to draw widgets on the terminal.
#[derive(Debug)]
pub struct Context<'a> {
// Width of the canvas in cells.
//
// This is NOT the resolution in dots/pixels as this varies by marker type.
width: u16,
// Height of the canvas in cells.
//
// This is NOT the resolution in dots/pixels as this varies by marker type.
height: u16,
// Canvas coordinate system width
x_bounds: [f64; 2],
// Canvas coordinate system height
y_bounds: [f64; 2],
grid: Box<dyn Grid>,
dirty: bool,
@@ -501,17 +541,10 @@ impl<'a> Context<'a> {
y_bounds: [f64; 2],
marker: Marker,
) -> Self {
let dot = symbols::DOT.chars().next().unwrap();
let block = symbols::block::FULL.chars().next().unwrap();
let bar = symbols::bar::HALF.chars().next().unwrap();
let grid: Box<dyn Grid> = match marker {
Marker::Dot => Box::new(CharGrid::new(width, height, dot)),
Marker::Block => Box::new(CharGrid::new(width, height, block)),
Marker::Bar => Box::new(CharGrid::new(width, height, bar)),
Marker::Braille => Box::new(BrailleGrid::new(width, height)),
Marker::HalfBlock => Box::new(HalfBlockGrid::new(width, height)),
};
let grid = Self::marker_to_grid(width, height, marker);
Self {
width,
height,
x_bounds,
y_bounds,
grid,
@@ -521,6 +554,27 @@ impl<'a> Context<'a> {
}
}
fn marker_to_grid(width: u16, height: u16, marker: Marker) -> Box<dyn Grid> {
let dot = symbols::DOT.chars().next().unwrap();
let block = symbols::block::FULL.chars().next().unwrap();
let bar = symbols::bar::HALF.chars().next().unwrap();
match marker {
Marker::Dot => Box::new(CharGrid::new(width, height, dot)),
Marker::Block => Box::new(CharGrid::new(width, height, block).apply_color_to_bg()),
Marker::Bar => Box::new(CharGrid::new(width, height, bar)),
Marker::Braille => Box::new(BrailleGrid::new(width, height)),
Marker::HalfBlock => Box::new(HalfBlockGrid::new(width, height)),
}
}
/// Change the marker being used in this context.
///
/// This will save the last layer if necessary and reset the grid to use the new marker.
pub fn marker(&mut self, marker: Marker) {
self.finish();
self.grid = Self::marker_to_grid(self.width, self.height, marker);
}
/// Draw the given [`Shape`] in this context
pub fn draw<S>(&mut self, shape: &S)
where
@@ -802,19 +856,21 @@ where
// Retrieve painted points for each layer
for layer in ctx.layers {
for (index, (ch, colors)) in layer.string.chars().zip(layer.colors).enumerate() {
if ch != ' ' && ch != '\u{2800}' {
let (x, y) = (
(index % width) as u16 + canvas_area.left(),
(index / width) as u16 + canvas_area.top(),
);
let cell = buf[(x, y)].set_char(ch);
if colors.0 != Color::Reset {
cell.set_fg(colors.0);
}
if colors.1 != Color::Reset {
cell.set_bg(colors.1);
}
for (index, layer_cell) in layer.contents.iter().enumerate() {
let (x, y) = (
(index % width) as u16 + canvas_area.left(),
(index / width) as u16 + canvas_area.top(),
);
let cell = &mut buf[(x, y)];
if let Some(symbol) = layer_cell.symbol {
cell.set_char(symbol);
}
if let Some(fg) = layer_cell.fg {
cell.set_fg(fg);
}
if let Some(bg) = layer_cell.bg {
cell.set_bg(bg);
}
}
}
@@ -847,12 +903,52 @@ where
mod tests {
use indoc::indoc;
use ratatui_core::buffer::Cell;
use rstest::rstest;
use super::*;
// helper to test the canvas checks that drawing a vertical and horizontal line
// results in the expected output
fn test_marker(marker: Marker, expected: &str) {
#[rstest]
#[case::block(Marker::Block, indoc!(
"
█xxxx
█xxxx
█xxxx
█xxxx
█████"
))]
#[case::half_block(Marker::HalfBlock, indoc!(
"
█xxxx
█xxxx
█xxxx
█xxxx
█▄▄▄▄"
))]
#[case::bar(Marker::Bar, indoc!(
"
▄xxxx
▄xxxx
▄xxxx
▄xxxx
▄▄▄▄▄"
))]
#[case::braille(Marker::Braille, indoc!(
"
⡇xxxx
⡇xxxx
⡇xxxx
⡇xxxx
⣇⣀⣀⣀⣀"
))]
#[case::dot(Marker::Dot, indoc!(
"
•xxxx
•xxxx
•xxxx
•xxxx
•••••"
))]
fn test_horizontal_with_vertical(#[case] marker: Marker, #[case] expected: &'static str) {
let area = Rect::new(0, 0, 5, 5);
let mut buf = Buffer::filled(area, Cell::new("x"));
let horizontal_line = Line {
@@ -881,64 +977,73 @@ mod tests {
assert_eq!(buf, Buffer::with_lines(expected.lines()));
}
#[test]
fn test_bar_marker() {
test_marker(
Marker::Bar,
indoc!(
#[rstest]
#[case::block(Marker::Block, indoc!(
"
xxxx
▄xxxx
xxxx
▄xxxx
▄▄▄▄▄"
),
);
}
#[test]
fn test_block_marker() {
test_marker(
Marker::Block,
indoc!(
xxx
x█x█x
xxxx
x█x█x
█xxx█"))]
#[case::half_block(Marker::HalfBlock,
indoc!(
"
█xxxx
█xxxx
xxxx
█xxxx
███"
),
);
}
#[test]
fn test_braille_marker() {
test_marker(
Marker::Braille,
indoc!(
█xxx
x█xx
xxxx
x█xx
xxx")
)]
#[case::bar(Marker::Bar, indoc!(
"
xxxx
⡇xxxx
xxxx
⡇xxxx
⣇⣀⣀⣀⣀"
),
);
}
#[test]
fn test_dot_marker() {
test_marker(
Marker::Dot,
indoc!(
xxx
x▄x▄x
xxxx
x▄x▄x
▄xxx▄"))]
#[case::braille(Marker::Braille, indoc!(
"
xxxx
•xxxx
xxxx
•xxxx
•••••"
),
);
xxx
x⢣x⡜x
xxxx
x⡜x⢣x
⡜xxx⢣"
))]
#[case::dot(Marker::Dot, indoc!(
"
•xxx•
x•x•x
xx•xx
x•x•x
•xxx•"
))]
fn test_diagonal_lines(#[case] marker: Marker, #[case] expected: &'static str) {
let area = Rect::new(0, 0, 5, 5);
let mut buf = Buffer::filled(area, Cell::new("x"));
let diagonal_up = Line {
x1: 0.0,
y1: 0.0,
x2: 10.0,
y2: 10.0,
color: Color::Reset,
};
let diagonal_down = Line {
x1: 0.0,
y1: 10.0,
x2: 10.0,
y2: 0.0,
color: Color::Reset,
};
Canvas::default()
.marker(marker)
.paint(|ctx| {
ctx.draw(&diagonal_down);
ctx.draw(&diagonal_up);
})
.x_bounds([0.0, 10.0])
.y_bounds([0.0, 10.0])
.render(area, &mut buf);
assert_eq!(buf, Buffer::with_lines(expected.lines()));
}
// The canvas methods work a lot with arithmetic so here we enter various width and height

View File

@@ -115,7 +115,7 @@ mod tests {
"█ █",
"██████████",
]);
expected.set_style(buffer.area, Style::new().red());
expected.set_style(buffer.area, Style::new().red().on_red());
expected.set_style(buffer.area.inner(Margin::new(1, 1)), Style::reset());
assert_eq!(buffer, expected);
}

View File

@@ -1017,16 +1017,18 @@ impl Widget for &Chart<'_> {
}
}
for dataset in &self.datasets {
Canvas::default()
.background_color(self.style.bg.unwrap_or(Color::Reset))
.x_bounds(self.x_axis.bounds)
.y_bounds(self.y_axis.bounds)
.marker(dataset.marker)
.paint(|ctx| {
Canvas::default()
.background_color(self.style.bg.unwrap_or(Color::Reset))
.x_bounds(self.x_axis.bounds)
.y_bounds(self.y_axis.bounds)
.paint(|ctx| {
for dataset in &self.datasets {
ctx.marker(dataset.marker);
let color = dataset.style.fg.unwrap_or(Color::Reset);
ctx.draw(&Points {
coords: dataset.data,
color: dataset.style.fg.unwrap_or(Color::Reset),
color,
});
match dataset.graph_type {
GraphType::Line => {
@@ -1036,7 +1038,7 @@ impl Widget for &Chart<'_> {
y1: data[0].1,
x2: data[1].0,
y2: data[1].1,
color: dataset.style.fg.unwrap_or(Color::Reset),
color,
});
}
}
@@ -1047,15 +1049,15 @@ impl Widget for &Chart<'_> {
y1: 0.0,
x2: *x,
y2: *y,
color: dataset.style.fg.unwrap_or(Color::Reset),
color,
});
}
}
GraphType::Scatter => {}
}
})
.render(graph_area, buf);
}
}
})
.render(graph_area, buf);
if let Some(Position { x, y }) = layout.title_x {
let title = self.x_axis.title.as_ref().unwrap();
@@ -1548,6 +1550,62 @@ mod tests {
assert_eq!(buffer, expected);
}
#[rstest]
#[case::dot(symbols::Marker::Dot, '•')]
#[case::dot(symbols::Marker::Braille, '⢣')]
fn overlapping_lines(#[case] marker: symbols::Marker, #[case] symbol: char) {
let data_diagonal_up = [(0.0, 0.0), (5.0, 5.0)];
let data_diagonal_down = [(0.0, 5.0), (5.0, 0.0)];
let lines = vec![
Dataset::default()
.data(&data_diagonal_up)
.marker(symbols::Marker::Block)
.graph_type(GraphType::Line)
.blue(),
Dataset::default()
.data(&data_diagonal_down)
.marker(marker)
.graph_type(GraphType::Line)
.red(),
];
let chart = Chart::new(lines)
.x_axis(Axis::default().bounds([0.0, 5.0]))
.y_axis(Axis::default().bounds([0.0, 5.0]));
let area = Rect::new(0, 0, 5, 5);
let mut buffer = Buffer::empty(area);
chart.render(buffer.area, &mut buffer);
#[rustfmt::skip]
let mut expected = Buffer::with_lines([
format!("{symbol}"),
format!(" {symbol}"),
format!(" {symbol} "),
format!("{symbol} "),
format!("{symbol}"),
]);
for i in 0..5 {
// The Marker::Dot and Marker::Braille tiles have the
// foreground set to Red.
expected.set_style(Rect::new(i, i, 1, 1), Style::new().fg(Color::Red));
// The Marker::Block tiles have both the foreground and
// background set to Blue.
expected.set_style(
Rect::new(i, 4 - i, 1, 1),
Style::new().fg(Color::Blue).bg(Color::Blue),
);
}
// Where the Marker::Dot/Braille overlaps with Marker::Block,
// the background is set to blue from the Block, but the
// foreground is set to red from the Dot/Braille. This allows
// two line plots to overlap, so long as one of them is a
// Block.
expected.set_style(
Rect::new(2, 2, 1, 1),
Style::new().fg(Color::Red).bg(Color::Blue),
);
assert_eq!(buffer, expected);
}
#[test]
fn render_in_minimal_buffer() {
let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 1));