Skip to content

Commit 83f61d0

Browse files
committed
renderers: add background-aware cell styling
1 parent b938eab commit 83f61d0

9 files changed

Lines changed: 173 additions & 21 deletions

File tree

ARCHITECTURE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ The public API is intentionally split into two layers:
4141

4242
- Stores one byte mask per terminal cell for Braille dots
4343
- Stores optional foreground colors per cell
44+
- Stores optional background colors per cell
4445
- Stores an optional text overlay layer per cell
4546
- Tracks plot insets in pixel coordinates
4647
- Owns composition helpers such as overlay and merge behavior
@@ -84,11 +85,12 @@ The important boundary is between data-space and pixel-space:
8485

8586
## Composition Model
8687

87-
The current composition model uses three parallel concerns at the cell level:
88+
The current composition model uses four parallel concerns at the cell level:
8889

8990
1. Braille mask bits for dot occupancy
9091
2. Optional foreground color
91-
3. Optional text overlay
92+
3. Optional background color
93+
4. Optional text overlay
9294

9395
This enables:
9496

GUIDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ Typical flow:
130130
1. Create a canvas once
131131
2. Clear and redraw each frame
132132
3. Use screen coordinates for sprites, UI chrome, and projected geometry
133-
4. Overlay text with `set_char()`
133+
4. Overlay text with `set_char()` and use `set_cell_background()` for panel-style cells
134134
5. Render into a reusable `String` with `render_to()`
135135

136136
Good fits for direct-canvas work:

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ If you use Nix, `nix develop` provides the Rust toolchain plus `cargo-nextest`,
3131
- Advanced pixel and color control:
3232
- `unset_pixel` and `toggle_pixel`
3333
- color blending modes with `Overwrite` and `KeepFirst`
34+
- cell background colors for terminal panels and HUD-style layouts
3435
- Drawing primitives:
3536
- lines, circles, polygons
3637
- filled shapes via `rect_filled` and `circle_filled`

src/canvas/composition.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ impl<R: CellRenderer> CellCanvas<R> {
1212
plot_bottom_inset_px: 0,
1313
buffer: vec![R::Cell::default(); size],
1414
colors: vec![None; size],
15+
background_colors: vec![None; size],
1516
text_layer: vec![None; size],
1617
_renderer: PhantomData,
1718
}
@@ -30,6 +31,7 @@ impl<R: CellRenderer> CellCanvas<R> {
3031
pub fn clear(&mut self) {
3132
self.buffer.fill(R::Cell::default());
3233
self.colors.fill(None);
34+
self.background_colors.fill(None);
3335
self.text_layer.fill(None);
3436
self.plot_left_inset_px = 0;
3537
self.plot_bottom_inset_px = 0;
@@ -58,6 +60,7 @@ impl<R: CellRenderer> CellCanvas<R> {
5860
if !R::is_empty(top.buffer[idx]) || top.text_layer[idx].is_some() {
5961
self.buffer[idx] = top.buffer[idx];
6062
self.colors[idx] = top.colors[idx];
63+
self.background_colors[idx] = top.background_colors[idx];
6164
self.text_layer[idx] = top.text_layer[idx];
6265
}
6366
}
@@ -73,13 +76,19 @@ impl<R: CellRenderer> CellCanvas<R> {
7376
if top.colors[idx].is_some() {
7477
self.colors[idx] = top.colors[idx];
7578
}
79+
if top.background_colors[idx].is_some() {
80+
self.background_colors[idx] = top.background_colors[idx];
81+
}
7682
}
7783

7884
if let Some(ch) = top.text_layer[idx] {
7985
self.text_layer[idx] = Some(ch);
8086
if top.colors[idx].is_some() {
8187
self.colors[idx] = top.colors[idx];
8288
}
89+
if top.background_colors[idx].is_some() {
90+
self.background_colors[idx] = top.background_colors[idx];
91+
}
8392
}
8493
}
8594
}
@@ -123,6 +132,10 @@ impl<R: CellRenderer> CellCanvas<R> {
123132
} else if top.colors[idx].is_some() || R::is_empty(self.buffer[idx]) {
124133
self.colors[idx] = top.colors[idx];
125134
}
135+
136+
if top.background_colors[idx].is_some() || R::is_empty(self.buffer[idx]) {
137+
self.background_colors[idx] = top.background_colors[idx];
138+
}
126139
}
127140
}
128141
}

src/canvas/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ mod tests;
1111
use colored::Color;
1212
use std::marker::PhantomData;
1313

14+
pub use renderer::CellAppearance;
1415
pub use renderer::{BrailleRenderer, CellRenderer, QuadrantRenderer};
1516

1617
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -29,6 +30,7 @@ pub struct CellCanvas<R: CellRenderer> {
2930
plot_bottom_inset_px: usize,
3031
buffer: Vec<R::Cell>,
3132
colors: Vec<Option<Color>>,
33+
background_colors: Vec<Option<Color>>,
3234
text_layer: Vec<Option<char>>,
3335
_renderer: PhantomData<R>,
3436
}
@@ -81,4 +83,13 @@ impl<R: CellRenderer> CellCanvas<R> {
8183
self.colors[index] = None;
8284
}
8385
}
86+
87+
fn set_cell_background_impl(&mut self, col: usize, row: usize, color: Option<Color>) {
88+
if col >= self.width || row >= self.height {
89+
return;
90+
}
91+
92+
let index = self.idx(col, row);
93+
self.background_colors[index] = color;
94+
}
8495
}

src/canvas/pixels.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ impl<R: CellRenderer> CellCanvas<R> {
2020
self.unset_pixel_impl(x, y);
2121
}
2222

23+
pub fn set_cell_background(&mut self, col: usize, row: usize, color: Option<Color>) {
24+
let inverted_row = self.height.saturating_sub(1).saturating_sub(row);
25+
self.set_cell_background_impl(col, inverted_row, color);
26+
}
27+
28+
pub fn set_cell_background_screen(&mut self, col: usize, row: usize, color: Option<Color>) {
29+
self.set_cell_background_impl(col, row, color);
30+
}
31+
2332
pub fn toggle_pixel_screen(&mut self, x: usize, y: usize, color: Option<Color>) {
2433
if x >= self.pixel_width() || y >= self.pixel_height() {
2534
return;

src/canvas/render.rs

Lines changed: 82 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,68 @@ impl<R: CellRenderer> CellCanvas<R> {
2525
}
2626
}
2727

28+
fn write_ansi_background_color<W: Write>(w: &mut W, color: Color) -> fmt::Result {
29+
match color {
30+
Color::Black => w.write_str("\x1b[40m"),
31+
Color::Red => w.write_str("\x1b[41m"),
32+
Color::Green => w.write_str("\x1b[42m"),
33+
Color::Yellow => w.write_str("\x1b[43m"),
34+
Color::Blue => w.write_str("\x1b[44m"),
35+
Color::Magenta => w.write_str("\x1b[45m"),
36+
Color::Cyan => w.write_str("\x1b[46m"),
37+
Color::White => w.write_str("\x1b[47m"),
38+
Color::BrightBlack => w.write_str("\x1b[100m"),
39+
Color::BrightRed => w.write_str("\x1b[101m"),
40+
Color::BrightGreen => w.write_str("\x1b[102m"),
41+
Color::BrightYellow => w.write_str("\x1b[103m"),
42+
Color::BrightBlue => w.write_str("\x1b[104m"),
43+
Color::BrightMagenta => w.write_str("\x1b[105m"),
44+
Color::BrightCyan => w.write_str("\x1b[106m"),
45+
Color::BrightWhite => w.write_str("\x1b[107m"),
46+
Color::TrueColor { r, g, b } => write!(w, "\x1b[48;2;{};{};{}m", r, g, b),
47+
}
48+
}
49+
50+
fn write_style<W: Write>(
51+
w: &mut W,
52+
foreground: Option<Color>,
53+
background: Option<Color>,
54+
last_foreground: &mut Option<Color>,
55+
last_background: &mut Option<Color>,
56+
) -> fmt::Result {
57+
if foreground == *last_foreground && background == *last_background {
58+
return Ok(());
59+
}
60+
61+
let needs_reset = (foreground.is_none() && last_foreground.is_some())
62+
|| (background.is_none() && last_background.is_some());
63+
64+
if needs_reset {
65+
w.write_str("\x1b[0m")?;
66+
if let Some(bg) = background {
67+
Self::write_ansi_background_color(w, bg)?;
68+
}
69+
if let Some(fg) = foreground {
70+
Self::write_ansi_color(w, fg)?;
71+
}
72+
} else {
73+
if background != *last_background {
74+
if let Some(bg) = background {
75+
Self::write_ansi_background_color(w, bg)?;
76+
}
77+
}
78+
if foreground != *last_foreground {
79+
if let Some(fg) = foreground {
80+
Self::write_ansi_color(w, fg)?;
81+
}
82+
}
83+
}
84+
85+
*last_foreground = foreground;
86+
*last_background = background;
87+
Ok(())
88+
}
89+
2890
pub fn render_to<W: Write>(
2991
&self,
3092
w: &mut W,
@@ -44,7 +106,8 @@ impl<R: CellRenderer> CellCanvas<R> {
44106
w.write_char('\n')?;
45107
}
46108

47-
let mut last_color: Option<Color> = None;
109+
let mut last_foreground: Option<Color> = None;
110+
let mut last_background: Option<Color> = None;
48111

49112
for row in 0..self.height {
50113
if show_border {
@@ -53,27 +116,28 @@ impl<R: CellRenderer> CellCanvas<R> {
53116

54117
for col in 0..self.width {
55118
let idx = self.idx(col, row);
56-
let char_to_print = if let Some(c) = self.text_layer[idx] {
57-
c
58-
} else {
59-
R::glyph(self.buffer[idx])
60-
};
61-
62-
let current_color = self.colors[idx];
63-
if current_color != last_color {
64-
match current_color {
65-
Some(c) => Self::write_ansi_color(w, c)?,
66-
None => w.write_str("\x1b[0m")?,
67-
}
68-
last_color = current_color;
69-
}
119+
let appearance = R::appearance(
120+
self.buffer[idx],
121+
self.colors[idx],
122+
self.background_colors[idx],
123+
self.text_layer[idx],
124+
);
125+
126+
Self::write_style(
127+
w,
128+
appearance.foreground,
129+
appearance.background,
130+
&mut last_foreground,
131+
&mut last_background,
132+
)?;
70133

71-
w.write_char(char_to_print)?;
134+
w.write_char(appearance.glyph)?;
72135
}
73136

74-
if last_color.is_some() {
137+
if last_foreground.is_some() || last_background.is_some() {
75138
w.write_str("\x1b[0m")?;
76-
last_color = None;
139+
last_foreground = None;
140+
last_background = None;
77141
}
78142

79143
if show_border {

src/canvas/renderer.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
use colored::Color;
2+
3+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4+
pub struct CellAppearance {
5+
pub glyph: char,
6+
pub foreground: Option<Color>,
7+
pub background: Option<Color>,
8+
}
9+
110
pub trait CellRenderer {
211
type Cell: Copy + Default + PartialEq + Eq;
312

@@ -14,6 +23,27 @@ pub trait CellRenderer {
1423
fn subpixel_count(cell: Self::Cell) -> u32;
1524
fn is_empty(cell: Self::Cell) -> bool;
1625
fn glyph(cell: Self::Cell) -> char;
26+
27+
fn appearance(
28+
cell: Self::Cell,
29+
foreground: Option<Color>,
30+
background: Option<Color>,
31+
text: Option<char>,
32+
) -> CellAppearance {
33+
let glyph = text.unwrap_or_else(|| {
34+
if background.is_some() && Self::is_empty(cell) {
35+
' '
36+
} else {
37+
Self::glyph(cell)
38+
}
39+
});
40+
41+
CellAppearance {
42+
glyph,
43+
foreground,
44+
background,
45+
}
46+
}
1747
}
1848

1949
#[derive(Clone, Copy, Default)]

src/canvas/tests.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,25 @@ fn quadrant_canvas_renders_quadrant_blocks() {
6767
canvas.set_pixel_screen(0, 1, None);
6868
assert_eq!(canvas.render_no_color(), "█\n");
6969
}
70+
71+
#[test]
72+
fn render_with_background_color_emits_ansi_background() {
73+
let mut canvas = BrailleCanvas::new(1, 1);
74+
canvas.set_char(0, 0, 'A', None);
75+
canvas.set_cell_background(0, 0, Some(Color::Blue));
76+
77+
let rendered = canvas.render_with_options(false, None);
78+
79+
assert!(rendered.contains("\x1b[44m"));
80+
assert!(rendered.contains("A"));
81+
}
82+
83+
#[test]
84+
fn background_only_cells_render_as_spaces_with_background() {
85+
let mut canvas = BrailleCanvas::new(1, 1);
86+
canvas.set_cell_background(0, 0, Some(Color::BrightBlack));
87+
88+
let rendered = canvas.render_with_options(false, None);
89+
90+
assert!(rendered.contains("\x1b[100m "));
91+
}

0 commit comments

Comments
 (0)