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;
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}