retroglyph_core/tile.rs
1//! Fundamental unit of the grid: a single drawable tile.
2
3use crate::style::Style;
4#[cfg(feature = "egc")]
5use alloc::string::String;
6
7bitflags::bitflags! {
8 /// Bit-flags tracking wide-character tile roles.
9 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
10 pub struct TileFlags: u8 {
11 /// This tile is the left half of a 2-column wide character.
12 const WIDE_CHAR = 0b0000_0001;
13 /// This tile is the invisible right-half spacer of a wide character.
14 const WIDE_CHAR_SPACER = 0b0000_0010;
15 /// No content has been written to this tile: it is fully transparent.
16 ///
17 /// Set on [`Tile::default`] and cleared by every write. Compositing
18 /// ([`Grid::blit`](crate::grid::Grid::blit), layer flattening) skips
19 /// empty tiles, so an *explicit* space (which is not empty) is opaque
20 /// and overwrites lower layers, while an untouched cell is not.
21 const EMPTY = 0b0000_0100;
22 /// This tile has an entry in its layer's sparse EGC side-table
23 /// (see `Grid`'s internal `LayerBuf::extras`), because it holds a
24 /// multi-codepoint grapheme cluster (combining marks, ZWJ sequences).
25 ///
26 /// This flag is authoritative for whether extra text exists: code
27 /// that reads a tile's grapheme must check this bit first and treat
28 /// the side-table as backing storage only, never the other way
29 /// around. `Tile` cannot carry the string itself and stay small (see
30 /// [`Grid::grapheme`](crate::grid::Grid::grapheme)); the split is
31 /// what keeps the common single-codepoint tile compact.
32 const HAS_EXTRA = 0b0000_1000;
33 }
34}
35
36/// A single drawable tile in the terminal grid.
37///
38/// Each tile occupies one cell on a single layer; a [`Grid`](crate::grid::Grid)
39/// holds up to 256 independent layers of tiles per cell, composited
40/// bottom-to-top. Sub-cell pixel offsets (`dx`, `dy`) are visual only, they do
41/// not affect grid logic or hit-testing. Backends that cannot represent pixel
42/// offsets (e.g. `CrosstermBackend`) ignore them.
43///
44/// A tile does *not* carry its own multi-codepoint grapheme text (see
45/// [`TileFlags::HAS_EXTRA`]): that lives in a sparse side-table on the owning
46/// [`Grid`](crate::grid::Grid), keeping every `Tile` a small, fully `Copy`
47/// value regardless of whether the `egc` feature is enabled. Read it back via
48/// [`Grid::grapheme`](crate::grid::Grid::grapheme).
49#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
50pub struct Tile {
51 /// Primary codepoint. For ASCII and most Unicode this is the whole story.
52 pub(crate) glyph: char,
53 /// Style applied to this tile.
54 pub(crate) style: Style,
55 /// Pixel offset from the cell's left edge. Negative shifts left.
56 ///
57 /// Only meaningful for graphical backends (e.g. `SoftwareBackend`).
58 pub(crate) dx: i16,
59 /// Pixel offset from the cell's top edge. Negative shifts up.
60 ///
61 /// Only meaningful for graphical backends (e.g. `SoftwareBackend`).
62 pub(crate) dy: i16,
63 /// Wide-character role flags (e.g. [`TileFlags::WIDE_CHAR`]).
64 ///
65 /// Always present so `Tile`'s layout is stable whether or not the `egc`
66 /// feature is enabled. Without `egc` it is never set to anything but empty.
67 pub(crate) flags: TileFlags,
68}
69
70impl Default for Tile {
71 fn default() -> Self {
72 Self {
73 glyph: ' ',
74 style: Style::default(),
75 dx: 0,
76 dy: 0,
77 flags: TileFlags::EMPTY,
78 }
79 }
80}
81
82impl Tile {
83 /// Creates a new tile with the given glyph and style.
84 ///
85 /// `dx` and `dy` default to 0 (no sub-cell offset).
86 #[must_use]
87 pub const fn new(glyph: char, style: Style) -> Self {
88 Self {
89 glyph,
90 style,
91 dx: 0,
92 dy: 0,
93 flags: TileFlags::empty(),
94 }
95 }
96
97 /// Returns the tile's glyph (primary codepoint).
98 #[must_use]
99 pub const fn glyph(&self) -> char {
100 self.glyph
101 }
102
103 /// Returns the tile's style.
104 #[must_use]
105 pub const fn style(&self) -> Style {
106 self.style
107 }
108
109 /// Returns the sub-cell pixel X offset.
110 #[must_use]
111 pub const fn dx(&self) -> i16 {
112 self.dx
113 }
114
115 /// Returns the sub-cell pixel Y offset.
116 #[must_use]
117 pub const fn dy(&self) -> i16 {
118 self.dy
119 }
120
121 /// Returns the wide-character flags for this tile.
122 #[must_use]
123 pub const fn flags(&self) -> TileFlags {
124 self.flags
125 }
126
127 /// Returns `true` if nothing has been written to this tile.
128 ///
129 /// Empty tiles are transparent when compositing layers. An explicit
130 /// space (e.g. `Tile::new(' ', style)`) is **not** empty.
131 #[must_use]
132 pub const fn is_empty(&self) -> bool {
133 self.flags.contains(TileFlags::EMPTY)
134 }
135
136 /// Sets the glyph for this tile (builder style).
137 ///
138 /// Writing content marks the tile non-empty (see [`is_empty`](Self::is_empty)).
139 #[must_use]
140 pub const fn with_glyph(mut self, glyph: char) -> Self {
141 self.glyph = glyph;
142 self.flags = self.flags.difference(TileFlags::EMPTY);
143 self
144 }
145
146 /// Sets the style for this tile (builder style).
147 ///
148 /// Writing content marks the tile non-empty (see [`is_empty`](Self::is_empty)).
149 #[must_use]
150 pub const fn with_style(mut self, style: Style) -> Self {
151 self.style = style;
152 self.flags = self.flags.difference(TileFlags::EMPTY);
153 self
154 }
155
156 /// Sets the sub-cell pixel offset for this tile (builder style).
157 ///
158 /// Writing content marks the tile non-empty (see [`is_empty`](Self::is_empty)).
159 #[must_use]
160 pub const fn with_offset(mut self, dx: i16, dy: i16) -> Self {
161 self.dx = dx;
162 self.dy = dy;
163 self.flags = self.flags.difference(TileFlags::EMPTY);
164 self
165 }
166
167 /// Resets this tile to the default (empty, space, default style, no offset).
168 ///
169 /// Does not touch the owning [`Grid`]'s EGC side-table; callers that
170 /// reset a tile which may have carried [`TileFlags::HAS_EXTRA`] are
171 /// responsible for also clearing that entry (see `Grid::clear_overlap`).
172 #[cfg(feature = "egc")]
173 pub(crate) fn reset(&mut self) {
174 self.glyph = ' ';
175 self.style = Style::default();
176 self.dx = 0;
177 self.dy = 0;
178 self.flags = TileFlags::EMPTY;
179 }
180}
181
182/// Returns `grapheme` truncated to at most 8 codepoints (combining-mark bomb
183/// defence). If the input is already within the limit it is returned as-is.
184///
185/// Only present when the `egc` feature is enabled.
186#[cfg(feature = "egc")]
187pub(crate) fn cap_grapheme(grapheme: &str) -> String {
188 const MAX_CODEPOINTS: usize = 8;
189 // Most graphemes are already within the cap; avoid allocation when possible.
190 if grapheme.chars().count() <= MAX_CODEPOINTS {
191 return String::from(grapheme);
192 }
193 grapheme.chars().take(MAX_CODEPOINTS).collect()
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use crate::color::Color;
200
201 /// Regression guard for the size win the EGC side-table exists for: a
202 /// `Tile` must stay small and feature-stable (same layout with or
203 /// without `egc`) now that it no longer inlines grapheme text.
204 #[test]
205 fn test_tile_size_is_stable_and_small() {
206 assert_eq!(size_of::<Tile>(), 20);
207 }
208
209 #[test]
210 fn test_tile_defaults() {
211 let tile = Tile::default();
212 assert_eq!(tile.glyph(), ' ');
213 assert_eq!(tile.style(), Style::default());
214 assert_eq!(tile.dx, 0);
215 assert_eq!(tile.dy, 0);
216 // The default tile is empty (transparent when composited).
217 assert!(tile.is_empty());
218 assert_eq!(tile.flags(), TileFlags::EMPTY);
219 }
220
221 #[test]
222 fn test_tile_empty_semantics() {
223 // An explicit space is not empty; a default tile is.
224 assert!(Tile::default().is_empty());
225 assert!(!Tile::new(' ', Style::default()).is_empty());
226 assert!(!Tile::default().with_glyph(' ').is_empty());
227 assert!(!Tile::default().with_style(Style::default()).is_empty());
228 assert!(!Tile::default().with_offset(1, 1).is_empty());
229 }
230
231 #[test]
232 fn test_tile_builder() {
233 let style = Style::new().fg(Color::RED);
234 let tile = Tile::new('A', style);
235 assert_eq!(tile.glyph(), 'A');
236 assert_eq!(tile.style(), style);
237
238 let tile = tile.with_glyph('B');
239 assert_eq!(tile.glyph(), 'B');
240 }
241
242 #[test]
243 fn test_tile_with_offset() {
244 let tile = Tile::new('X', Style::default()).with_offset(-3, 5);
245 assert_eq!(tile.dx, -3);
246 assert_eq!(tile.dy, 5);
247 }
248
249 #[test]
250 fn test_tile_reset() {
251 let style = Style::new().fg(Color::RED);
252 let mut tile = Tile::new('X', style);
253 assert!(!tile.is_empty());
254 tile.reset();
255 assert_eq!(tile.glyph(), ' ');
256 assert_eq!(tile.style(), Style::default());
257 assert_eq!(tile.dx, 0);
258 assert_eq!(tile.dy, 0);
259 assert!(tile.is_empty());
260 }
261
262 #[cfg(feature = "egc")]
263 #[test]
264 fn test_tile_wide_flag() {
265 let mut tile = Tile::new('漢', Style::default());
266 tile.flags = TileFlags::WIDE_CHAR;
267 assert!(tile.flags().contains(TileFlags::WIDE_CHAR));
268 assert!(!tile.flags().contains(TileFlags::WIDE_CHAR_SPACER));
269 }
270}