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 a tile's emptiness and its role in any multi-cell structure it is part
20    /// of: a wide character, or a [span](crate::grid::Grid::write_span).
21    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
22    pub struct TileFlags: u8 {
23        /// This tile is the left half of a 2-column wide character.
24        const WIDE_CHAR        = 0b0000_0001;
25        /// This tile is the invisible right-half spacer of a wide character.
26        const WIDE_CHAR_SPACER = 0b0000_0010;
27        /// No content has been written to this tile: it is fully transparent.
28        ///
29        /// Set on [`Tile::default`] and cleared by every write. Compositing
30        /// ([`Grid::blit`](crate::grid::Grid::blit), layer flattening) skips
31        /// empty tiles, so an *explicit* space (which is not empty) is opaque
32        /// and overwrites lower layers, while an untouched cell is not.
33        const EMPTY            = 0b0000_0100;
34        /// This tile has an entry in its layer's sparse EGC side-table
35        /// (see `Grid`'s internal `LayerBuf::extras`), because it holds a
36        /// multi-codepoint grapheme cluster (combining marks, ZWJ sequences).
37        ///
38        /// This flag is authoritative for whether extra text exists: code
39        /// that reads a tile's grapheme must check this bit first and treat
40        /// the side-table as backing storage only, never the other way
41        /// around. `Tile` cannot carry the string itself and stay small (see
42        /// [`Grid::grapheme`](crate::grid::Grid::grapheme)); the split is
43        /// what keeps the common single-codepoint tile compact.
44        const HAS_EXTRA         = 0b0000_1000;
45        /// This tile is the top-left anchor of a multi-cell span: it occupies
46        /// [`Tile::span`] cells, not one.
47        ///
48        /// Written only by [`Grid::write_span`](crate::grid::Grid::write_span), which also writes
49        /// the matching [`SPAN_COVERED`](Self::SPAN_COVERED) tiles. An anchor without its covered
50        /// cells is a broken invariant, which is why there is no `Tile` builder for this flag.
51        const SPAN_ANCHOR       = 0b0001_0000;
52        /// This tile is covered by a multi-cell span anchored above and/or to its left; see
53        /// [`Tile::span_offset`].
54        ///
55        /// Unlike [`WIDE_CHAR_SPACER`](Self::WIDE_CHAR_SPACER), a covered tile keeps a real glyph
56        /// and **is** rendered by cell backends: that glyph is the span artwork's text fallback.
57        /// Only a backend that actually draws the span's artwork (a pixel backend blitting one
58        /// sprite across the whole footprint) skips it. See the [`grid`](crate::grid) module
59        /// docs for the full contract.
60        const SPAN_COVERED      = 0b0010_0000;
61    }
62}
63
64/// A single drawable tile in the terminal grid.
65///
66/// Each tile occupies one cell on a single layer; a [`Grid`](crate::grid::Grid)
67/// holds up to 256 independent layers of tiles per cell, composited
68/// bottom-to-top. Sub-cell pixel offsets (`dx`, `dy`) are visual only, they do
69/// not affect grid logic or hit-testing. Backends that cannot represent pixel
70/// offsets (e.g. `CrosstermBackend`) ignore them.
71///
72/// A tile does *not* carry its own multi-codepoint grapheme text (see
73/// [`TileFlags::HAS_EXTRA`]): that lives in a sparse side-table on the owning
74/// [`Grid`](crate::grid::Grid), keeping every `Tile` a small, fully `Copy`
75/// value regardless of whether the `egc` feature is enabled. Read it back via
76/// [`Grid::grapheme`](crate::grid::Grid::grapheme).
77///
78/// # Examples
79///
80/// ```
81/// use retroglyph_core::{Color, Style, Tile};
82///
83/// let tile = Tile::new('@', Style::new().fg(Color::GREEN));
84/// assert_eq!(tile.glyph(), '@');
85/// assert_eq!(tile.style().foreground(), Color::GREEN);
86/// ```
87#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
88pub struct Tile {
89    /// Primary codepoint. For ASCII and most Unicode this is the whole story.
90    pub(crate) glyph: char,
91    /// Style applied to this tile.
92    pub(crate) style: Style,
93    /// Display (column) width of `glyph`, precomputed at write time.
94    ///
95    /// Terminal-family renderers need this on every [`draw`](crate::backend::Output::draw) call
96    /// to know how far the cursor advances after printing a cell; recomputing it with
97    /// `unicode_width` on every cell of every frame is pure waste since a glyph's width never
98    /// changes between frames. It is computed once, here, whenever the glyph is written (see
99    /// [`with_glyph`](Self::with_glyph) and [`Grid::write_grapheme`](crate::grid::Grid::write_grapheme)),
100    /// and just read back afterward. Almost always 0, 1, or 2 (control characters/combining
101    /// marks are 0; a handful of grapheme clusters can report other values via
102    /// `unicode_width`, but `u8` comfortably covers every value that crate returns).
103    pub(crate) width: u8,
104    /// Pixel offset from the cell's left edge. Negative shifts left.
105    ///
106    /// Only meaningful for graphical backends (e.g. `SoftwareBackend`).
107    pub(crate) dx: i16,
108    /// Pixel offset from the cell's top edge. Negative shifts up.
109    ///
110    /// Only meaningful for graphical backends (e.g. `SoftwareBackend`).
111    pub(crate) dy: i16,
112    /// Role and occupancy flags: emptiness, wide-character halves, EGC side-table presence, and
113    /// multi-cell span roles (see [`TileFlags`]).
114    ///
115    /// Always present so `Tile`'s layout is stable whether or not the `egc`
116    /// feature is enabled. Without `egc`, the wide-character and side-table bits are never set.
117    pub(crate) flags: TileFlags,
118    /// Multi-cell span bookkeeping, **overloaded by role** (see `flags`):
119    ///
120    /// | Flag | `span_w` | `span_h` |
121    /// | --- | --- | --- |
122    /// | [`TileFlags::SPAN_ANCHOR`] | footprint width in cells (>= 1) | footprint height (>= 1) |
123    /// | [`TileFlags::SPAN_COVERED`] | `x - anchor.x` | `y - anchor.y` |
124    /// | neither | 1 | 1 |
125    ///
126    /// The overload is what makes [`Grid::span_owner`](crate::grid::Grid::span_owner) O(1): a
127    /// covered cell names its anchor directly instead of being found by scanning. Both bytes sit
128    /// in `Tile`'s tail padding, so spans cost nothing (see `test_tile_size_is_stable_and_small`).
129    /// Read them through [`span`](Self::span) and [`span_offset`](Self::span_offset), which
130    /// enforce the roles, rather than touching the fields directly.
131    pub(crate) span_w: u8,
132    /// See [`span_w`](Self::span_w): the vertical half of the same overloaded pair.
133    pub(crate) span_h: u8,
134}
135
136impl Default for Tile {
137    fn default() -> Self {
138        Self {
139            glyph: ' ',
140            style: Style::default(),
141            width: 1,
142            dx: 0,
143            dy: 0,
144            flags: TileFlags::EMPTY,
145            span_w: 1,
146            span_h: 1,
147        }
148    }
149}
150
151impl Tile {
152    /// Creates a new tile with the given glyph and style.
153    ///
154    /// `dx` and `dy` default to 0 (no sub-cell offset). `glyph`'s display width is computed
155    /// once here (see [`width`](Self::width)) rather than on every render.
156    #[must_use]
157    pub fn new(glyph: char, style: Style) -> Self {
158        Self {
159            glyph,
160            style,
161            width: glyph_width(glyph),
162            dx: 0,
163            dy: 0,
164            flags: TileFlags::empty(),
165            span_w: 1,
166            span_h: 1,
167        }
168    }
169
170    /// Returns the tile's glyph (primary codepoint).
171    #[must_use]
172    pub const fn glyph(&self) -> char {
173        self.glyph
174    }
175
176    /// Returns the precomputed display (column) width of [`glyph`](Self::glyph).
177    ///
178    /// Computed once when the glyph is written (see [`with_glyph`](Self::with_glyph) and
179    /// [`Grid::write_grapheme`](crate::grid::Grid::write_grapheme)), not recomputed on every
180    /// render. For tiles written via `write_grapheme`, this reflects the full grapheme cluster's
181    /// width, not just the primary codepoint's.
182    #[must_use]
183    pub const fn width(&self) -> u16 {
184        self.width as u16
185    }
186
187    /// Returns the tile's style.
188    #[must_use]
189    pub const fn style(&self) -> Style {
190        self.style
191    }
192
193    /// Returns the sub-cell pixel X offset.
194    #[must_use]
195    pub const fn dx(&self) -> i16 {
196        self.dx
197    }
198
199    /// Returns the sub-cell pixel Y offset.
200    #[must_use]
201    pub const fn dy(&self) -> i16 {
202        self.dy
203    }
204
205    /// Returns the wide-character flags for this tile.
206    #[must_use]
207    pub const fn flags(&self) -> TileFlags {
208        self.flags
209    }
210
211    /// Returns how many cells this tile occupies, `(width, height)`.
212    ///
213    /// `(1, 1)` for every tile except a [`TileFlags::SPAN_ANCHOR`], which reports the footprint
214    /// declared by [`Grid::write_span`](crate::grid::Grid::write_span). A covered cell reports
215    /// `(1, 1)`: it does not own a footprint, it is inside one (see
216    /// [`span_offset`](Self::span_offset)).
217    #[must_use]
218    pub const fn span(&self) -> (u16, u16) {
219        if self.flags.contains(TileFlags::SPAN_ANCHOR) {
220            (self.span_w as u16, self.span_h as u16)
221        } else {
222            (1, 1)
223        }
224    }
225
226    /// Returns this tile's `(dx, dy)` offset back to its span anchor, or `None` when it is not
227    /// covered by one.
228    ///
229    /// A covered cell at `(x, y)` has its anchor at `(x - dx, y - dy)`, so a backend holding a
230    /// whole layer reaches it with one subtraction. A caller holding a
231    /// [`Grid`](crate::grid::Grid) should use
232    /// [`Grid::span_owner`](crate::grid::Grid::span_owner) instead, which handles the bounds and
233    /// the anchor-cell case too.
234    #[must_use]
235    pub const fn span_offset(&self) -> Option<(u16, u16)> {
236        if self.flags.contains(TileFlags::SPAN_COVERED) {
237            Some((self.span_w as u16, self.span_h as u16))
238        } else {
239            None
240        }
241    }
242
243    /// Returns `true` if nothing has been written to this tile.
244    ///
245    /// Empty tiles are transparent when compositing layers. An explicit
246    /// space (e.g. `Tile::new(' ', style)`) is **not** empty.
247    #[must_use]
248    pub const fn is_empty(&self) -> bool {
249        self.flags.contains(TileFlags::EMPTY)
250    }
251
252    /// Sets the glyph for this tile (builder style).
253    ///
254    /// Writing content marks the tile non-empty (see [`is_empty`](Self::is_empty)). Recomputes
255    /// the cached display width (see [`width`](Self::width)) for the new glyph.
256    #[must_use]
257    pub fn with_glyph(mut self, glyph: char) -> Self {
258        self.glyph = glyph;
259        self.width = glyph_width(glyph);
260        self.flags = self.flags.difference(TileFlags::EMPTY);
261        self
262    }
263
264    /// Sets the style for this tile (builder style).
265    ///
266    /// Writing content marks the tile non-empty (see [`is_empty`](Self::is_empty)).
267    #[must_use]
268    pub const fn with_style(mut self, style: Style) -> Self {
269        self.style = style;
270        self.flags = self.flags.difference(TileFlags::EMPTY);
271        self
272    }
273
274    /// Sets the sub-cell pixel offset for this tile (builder style).
275    ///
276    /// Writing content marks the tile non-empty (see [`is_empty`](Self::is_empty)).
277    #[must_use]
278    pub const fn with_offset(mut self, dx: i16, dy: i16) -> Self {
279        self.dx = dx;
280        self.dy = dy;
281        self.flags = self.flags.difference(TileFlags::EMPTY);
282        self
283    }
284
285    /// Resets this tile to the default (empty, space, default style, no offset).
286    ///
287    /// Does not touch the owning [`Grid`]'s EGC side-table; callers that
288    /// reset a tile which may have carried [`TileFlags::HAS_EXTRA`] are
289    /// responsible for also clearing that entry (see `Grid::clear_overlap`).
290    pub(crate) fn reset(&mut self) {
291        self.glyph = ' ';
292        self.style = Style::default();
293        self.width = 1;
294        self.dx = 0;
295        self.dy = 0;
296        self.flags = TileFlags::EMPTY;
297        self.span_w = 1;
298        self.span_h = 1;
299    }
300
301    /// Strips this tile's multi-cell span role, leaving its glyph and style alone.
302    ///
303    /// Used by copy paths that cannot preserve a span's cross-cell invariant
304    /// ([`Grid::blit`](crate::grid::Grid::blit) can clip a footprint in half), so the copy
305    /// degrades to exactly the span's text fallback instead of to a dangling anchor.
306    pub(crate) fn clear_span(&mut self) {
307        self.flags
308            .remove(TileFlags::SPAN_ANCHOR | TileFlags::SPAN_COVERED);
309        self.span_w = 1;
310        self.span_h = 1;
311    }
312}
313
314/// Returns `grapheme` truncated to at most 8 codepoints (combining-mark bomb
315/// defence). If the input is already within the limit it is returned as-is.
316///
317/// Only present when the `egc` feature is enabled.
318#[cfg(feature = "egc")]
319pub(crate) fn cap_grapheme(grapheme: &str) -> String {
320    const MAX_CODEPOINTS: usize = 8;
321    // Most graphemes are already within the cap; avoid allocation when possible.
322    if grapheme.chars().count() <= MAX_CODEPOINTS {
323        return String::from(grapheme);
324    }
325    grapheme.chars().take(MAX_CODEPOINTS).collect()
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::color::Color;
332
333    /// Regression guard for the size win the EGC side-table exists for: a
334    /// `Tile` must stay small and feature-stable (same layout with or
335    /// without `egc`) now that it no longer inlines grapheme text.
336    #[test]
337    fn test_tile_size_is_stable_and_small() {
338        assert_eq!(size_of::<Tile>(), 20);
339    }
340
341    #[test]
342    fn test_tile_defaults() {
343        let tile = Tile::default();
344        assert_eq!(tile.glyph(), ' ');
345        assert_eq!(tile.style(), Style::default());
346        assert_eq!(tile.dx, 0);
347        assert_eq!(tile.dy, 0);
348        // The default tile is empty (transparent when composited).
349        assert!(tile.is_empty());
350        assert_eq!(tile.flags(), TileFlags::EMPTY);
351    }
352
353    #[test]
354    fn test_tile_empty_semantics() {
355        // An explicit space is not empty; a default tile is.
356        assert!(Tile::default().is_empty());
357        assert!(!Tile::new(' ', Style::default()).is_empty());
358        assert!(!Tile::default().with_glyph(' ').is_empty());
359        assert!(!Tile::default().with_style(Style::default()).is_empty());
360        assert!(!Tile::default().with_offset(1, 1).is_empty());
361    }
362
363    #[test]
364    fn test_tile_builder() {
365        let style = Style::new().fg(Color::RED);
366        let tile = Tile::new('A', style);
367        assert_eq!(tile.glyph(), 'A');
368        assert_eq!(tile.style(), style);
369
370        let tile = tile.with_glyph('B');
371        assert_eq!(tile.glyph(), 'B');
372    }
373
374    #[test]
375    fn test_tile_with_offset() {
376        let tile = Tile::new('X', Style::default()).with_offset(-3, 5);
377        assert_eq!(tile.dx, -3);
378        assert_eq!(tile.dy, 5);
379    }
380
381    #[test]
382    fn test_tile_reset() {
383        let style = Style::new().fg(Color::RED);
384        let mut tile = Tile::new('X', style);
385        assert!(!tile.is_empty());
386        tile.reset();
387        assert_eq!(tile.glyph(), ' ');
388        assert_eq!(tile.style(), Style::default());
389        assert_eq!(tile.dx, 0);
390        assert_eq!(tile.dy, 0);
391        assert!(tile.is_empty());
392    }
393
394    #[cfg(feature = "egc")]
395    #[test]
396    fn test_tile_wide_flag() {
397        let mut tile = Tile::new('漢', Style::default());
398        tile.flags = TileFlags::WIDE_CHAR;
399        assert!(tile.flags().contains(TileFlags::WIDE_CHAR));
400        assert!(!tile.flags().contains(TileFlags::WIDE_CHAR_SPACER));
401    }
402
403    #[test]
404    fn test_tile_width_is_precomputed_from_glyph() {
405        // ASCII is single-column; a CJK ideograph is double-column. Both are computed once at
406        // write time (`new`/`with_glyph`), not left for callers to recompute per render.
407        assert_eq!(Tile::new('A', Style::default()).width(), 1);
408        assert_eq!(Tile::new('漢', Style::default()).width(), 2);
409        assert_eq!(Tile::default().width(), 1);
410    }
411
412    #[test]
413    fn test_tile_with_glyph_recomputes_width() {
414        let tile = Tile::new('A', Style::default()).with_glyph('漢');
415        assert_eq!(tile.glyph(), '漢');
416        assert_eq!(tile.width(), 2);
417    }
418
419    #[test]
420    fn test_tile_span_defaults_to_one_by_one() {
421        assert_eq!(Tile::default().span(), (1, 1));
422        assert_eq!(Tile::new('A', Style::default()).span(), (1, 1));
423        assert_eq!(Tile::default().span_offset(), None);
424        assert_eq!(Tile::new('A', Style::default()).span_offset(), None);
425    }
426
427    /// `span_w`/`span_h` are overloaded by role, so reading them through the wrong accessor must
428    /// report the neutral answer rather than the other role's number.
429    #[test]
430    fn test_tile_span_accessors_are_keyed_by_role() {
431        let mut anchor = Tile::new('C', Style::default());
432        anchor.flags = TileFlags::SPAN_ANCHOR;
433        anchor.span_w = 2;
434        anchor.span_h = 3;
435        assert_eq!(anchor.span(), (2, 3));
436        assert_eq!(anchor.span_offset(), None);
437
438        let mut covered = Tile::new(']', Style::default());
439        covered.flags = TileFlags::SPAN_COVERED;
440        covered.span_w = 1;
441        covered.span_h = 2;
442        assert_eq!(covered.span_offset(), Some((1, 2)));
443        assert_eq!(covered.span(), (1, 1));
444    }
445
446    #[test]
447    fn test_tile_clear_span_keeps_the_glyph() {
448        let mut tile = Tile::new('C', Style::default());
449        tile.flags = TileFlags::SPAN_ANCHOR;
450        tile.span_w = 2;
451        tile.span_h = 2;
452        tile.clear_span();
453        assert_eq!(tile.glyph(), 'C');
454        assert_eq!(tile.span(), (1, 1));
455        assert!(!tile.flags().contains(TileFlags::SPAN_ANCHOR));
456    }
457
458    #[test]
459    fn test_tile_reset_clears_span() {
460        let mut tile = Tile::new('C', Style::default());
461        tile.flags = TileFlags::SPAN_ANCHOR;
462        tile.span_w = 4;
463        tile.span_h = 4;
464        tile.reset();
465        assert_eq!(tile.span(), (1, 1));
466        assert_eq!(tile.span_offset(), None);
467        assert!(tile.is_empty());
468    }
469}