Skip to main content

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