Skip to main content

retroglyph_core/grid/layers/
tint.rs

1//! Per-cell tint and grapheme-extras storage: [`Grid::tint`] and [`Grid::set_tint`], plus the
2//! shared side-table primitive [`Grid::set_extra`] both write through.
3
4use super::super::{Grid, Pos, TileExtra, to_grixy_pos};
5#[cfg(test)]
6use crate::color::Style;
7use crate::color::Tint;
8#[cfg(test)]
9use crate::tile::Tile;
10use crate::tile::TileFlags;
11use grixy::ops::GridRead;
12
13impl Grid {
14    /// Sets the whole side-table entry for an already-written tile at `(x, y)` on `layer`,
15    /// setting [`TileFlags::HAS_EXTRA`] to match. Does nothing if out of bounds. Crate-private:
16    /// the external ways in are [`write_grapheme`](Self::write_grapheme) and
17    /// [`set_tint`](Self::set_tint).
18    ///
19    /// An empty entry is removed rather than stored, so the flag means exactly "an entry
20    /// exists".
21    pub(crate) fn set_extra(&mut self, layer: u8, x: u16, y: u16, extra: TileExtra) {
22        if x >= self.width || y >= self.height {
23            return;
24        }
25        let pos = to_grixy_pos(Pos::new(x, y));
26        let idx = usize::from(y) * usize::from(self.width) + usize::from(x);
27        let lb = self.layer_or_alloc(layer);
28        if extra.is_empty() {
29            lb.buf[pos].flags.remove(TileFlags::HAS_EXTRA);
30            lb.extras.remove(&idx);
31        } else {
32            lb.buf[pos].flags.insert(TileFlags::HAS_EXTRA);
33            lb.extras.insert(idx, extra);
34        }
35    }
36
37    /// How a pixel backend recolours the sprite drawn for the cell at `(x, y)` on `layer`.
38    ///
39    /// [`Tint::None`] for a cell that has never been tinted, for a cell whose glyph was
40    /// overwritten since (a glyph write drops the tint with the artwork it belonged to), and for
41    /// coordinates outside the grid or on an unallocated layer.
42    ///
43    /// A tint is grid state rather than [`Tile`](crate::tile::Tile) state, for the same reason a multi-codepoint
44    /// grapheme is: it is rare per cell and `Tile` has no room
45    /// left. So it is read here, not through [`Tile::style`](crate::tile::Tile::style).
46    ///
47    /// Cell backends have no sprite to recolour and ignore this entirely.
48    #[must_use]
49    pub fn tint(&self, layer: u8, x: u16, y: u16) -> Tint {
50        let Some(lb) = self.layer(layer) else {
51            return Tint::None;
52        };
53        let Some(tile) = lb.buf.get(to_grixy_pos(Pos::new(x, y))) else {
54            return Tint::None;
55        };
56        let idx = usize::from(y) * usize::from(self.width) + usize::from(x);
57        lb.tint_for(idx, tile)
58    }
59
60    /// Sets how a pixel backend recolours the sprite drawn for the cell at `(x, y)` on `layer`.
61    ///
62    /// Applies to the cell as it stands, so it belongs *after* the write that put the glyph
63    /// there: writing a glyph over a tinted cell drops the tint, on the grounds that a tint
64    /// describes the artwork rather than the position. For a multi-cell span, tint the anchor;
65    /// that is the cell a pixel backend draws the sprite from.
66    ///
67    /// Setting [`Tint::None`] clears the tint, and drops the cell's side-table entry entirely if
68    /// it held nothing else. Does nothing if `(x, y)` is out of bounds.
69    pub fn set_tint(&mut self, layer: u8, x: u16, y: u16, tint: Tint) {
70        if x >= self.width || y >= self.height {
71            return;
72        }
73        let idx = usize::from(y) * usize::from(self.width) + usize::from(x);
74        let pos = to_grixy_pos(Pos::new(x, y));
75        let lb = self.layer_or_alloc(layer);
76        // Preserve any grapheme already stored for this cell: the two members of the entry are
77        // written by separate calls and neither should clobber the other.
78        let grapheme = if lb.buf[pos].flags.contains(TileFlags::HAS_EXTRA) {
79            lb.extras.get(&idx).and_then(|e| e.grapheme.clone())
80        } else {
81            None
82        };
83        let entry = TileExtra { grapheme, tint };
84        if entry.is_empty() {
85            lb.buf[pos].flags.remove(TileFlags::HAS_EXTRA);
86            lb.extras.remove(&idx);
87        } else {
88            lb.buf[pos].flags.insert(TileFlags::HAS_EXTRA);
89            lb.extras.insert(idx, entry);
90        }
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    // ── Tint storage ──────────────────────────────────────────────────────
99    //
100    // A tint lives in the same sparse side table as a grapheme, so it inherits every path that
101    // table already has to get right: rekeying on resize, copying on blit, and being dropped
102    // when the cell it belongs to is overwritten or cleared. These cover each of those, plus the
103    // interaction between the two members now sharing one entry and one flag.
104    #[cfg(feature = "egc")]
105    #[test]
106    fn tint_round_trips_and_defaults_to_none() {
107        let mut g = Grid::new(4, 4);
108        assert_eq!(g.tint(0, 1, 1), Tint::None);
109
110        g.write_grapheme(0, 1, 1, "@", Style::default());
111        g.set_tint(0, 1, 1, Tint::multiply(128, 64, 32));
112        assert_eq!(g.tint(0, 1, 1), Tint::multiply(128, 64, 32));
113
114        // Setting None clears it again.
115        g.set_tint(0, 1, 1, Tint::None);
116        assert_eq!(g.tint(0, 1, 1), Tint::None);
117    }
118
119    #[test]
120    fn tint_is_per_layer_and_per_cell() {
121        let mut g = Grid::new(4, 4);
122        g.set_tint(0, 1, 1, Tint::multiply(10, 20, 30));
123        g.set_tint(3, 1, 1, Tint::mix(1, 2, 3, 4));
124
125        assert_eq!(g.tint(0, 1, 1), Tint::multiply(10, 20, 30));
126        assert_eq!(g.tint(3, 1, 1), Tint::mix(1, 2, 3, 4));
127        assert_eq!(g.tint(0, 1, 2), Tint::None);
128        assert_eq!(g.tint(1, 1, 1), Tint::None);
129    }
130
131    #[test]
132    fn tint_out_of_bounds_reads_none_and_writes_nothing() {
133        let mut g = Grid::new(2, 2);
134        g.set_tint(0, 9, 9, Tint::multiply(1, 2, 3));
135        assert_eq!(g.tint(0, 9, 9), Tint::None);
136        assert_eq!(g.tint(0, 0, 0), Tint::None);
137    }
138
139    #[cfg(feature = "egc")]
140    #[test]
141    fn writing_a_glyph_over_a_tinted_cell_drops_the_tint() {
142        let mut g = Grid::new(4, 4);
143        g.write_grapheme(0, 1, 1, "@", Style::default());
144        g.set_tint(0, 1, 1, Tint::multiply(128, 128, 128));
145
146        // A tint describes the artwork that was drawn, not the position, so replacing the
147        // artwork drops it rather than silently recolouring whatever lands there next.
148        g.write_grapheme(0, 1, 1, "#", Style::default());
149        assert_eq!(g.tint(0, 1, 1), Tint::None);
150    }
151
152    #[test]
153    fn put_tile_drops_the_tint() {
154        let mut g = Grid::new(4, 4);
155        g.set_tint(0, 1, 1, Tint::multiply(128, 128, 128));
156        g.put_tile(0, Pos::new(1, 1), Tile::new('x', Style::default()));
157        assert_eq!(g.tint(0, 1, 1), Tint::None);
158    }
159
160    #[cfg(feature = "egc")]
161    #[test]
162    fn a_tint_and_a_grapheme_share_one_entry_without_clobbering_each_other() {
163        let mut g = Grid::new(4, 4);
164        g.write_grapheme(0, 1, 1, "e\u{0301}", Style::default());
165        g.set_tint(0, 1, 1, Tint::multiply(128, 128, 128));
166
167        // Both members survive: `set_tint` preserves the grapheme already stored.
168        assert_eq!(crate::grid::grapheme_at(&g, 0, 1, 1), Some("e\u{0301}"));
169        assert_eq!(g.tint(0, 1, 1), Tint::multiply(128, 128, 128));
170
171        // Clearing the tint leaves the grapheme, and so leaves the entry in place.
172        g.set_tint(0, 1, 1, Tint::None);
173        assert_eq!(crate::grid::grapheme_at(&g, 0, 1, 1), Some("e\u{0301}"));
174        assert_eq!(g.tint(0, 1, 1), Tint::None);
175    }
176
177    #[cfg(feature = "egc")]
178    #[test]
179    fn a_tint_alone_keeps_grapheme_reads_answering_none() {
180        let mut g = Grid::new(4, 4);
181        g.write_grapheme(0, 1, 1, "@", Style::default());
182        g.set_tint(0, 1, 1, Tint::multiply(128, 128, 128));
183
184        // HAS_EXTRA is now set for a cell with no grapheme text. `grapheme` must still say None
185        // rather than reaching into the entry and finding an empty slot.
186        assert_eq!(crate::grid::grapheme_at(&g, 0, 1, 1), None);
187        assert_eq!(g.tint(0, 1, 1), Tint::multiply(128, 128, 128));
188    }
189}