Skip to main content

retroglyph/
tile.rs

1//! Fundamental unit of the grid: a single drawable tile.
2
3use crate::style::Style;
4#[cfg(feature = "egc")]
5use alloc::sync::Arc;
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    }
16}
17
18/// A single drawable tile in the terminal grid.
19///
20/// Each tile occupies one cell on a single layer (see ADR 008 for the layer
21/// model). Sub-cell pixel offsets (`dx`, `dy`) are visual only — they do not
22/// affect grid logic or hit-testing. Backends that cannot represent pixel
23/// offsets (e.g. `CrosstermBackend`) ignore them.
24#[derive(Clone, Debug, PartialEq, Eq, Hash)]
25pub struct Tile {
26    /// Primary codepoint. For ASCII and most Unicode this is the whole story.
27    pub(crate) glyph: char,
28    /// Style applied to this tile.
29    pub(crate) style: Style,
30    /// Pixel offset from the cell's left edge. Negative shifts left.
31    ///
32    /// Only meaningful for graphical backends (e.g. `SoftwareBackend`).
33    pub(crate) dx: i16,
34    /// Pixel offset from the cell's top edge. Negative shifts up.
35    ///
36    /// Only meaningful for graphical backends (e.g. `SoftwareBackend`).
37    pub(crate) dy: i16,
38    /// Wide-character role flags (e.g. [`TileFlags::WIDE_CHAR`]).
39    ///
40    /// Only present when the `egc` feature is enabled.
41    #[cfg(feature = "egc")]
42    pub(crate) flags: TileFlags,
43    /// Allocated only when the grapheme cluster has more than one codepoint
44    /// (combining marks, ZWJ emoji sequences, etc.).
45    ///
46    /// When `Some`, the full EGC string is stored here. The `glyph` field
47    /// still holds the first codepoint for fast single-char paths.
48    ///
49    /// Only present when the `egc` feature is enabled.
50    #[cfg(feature = "egc")]
51    pub(crate) extra: Option<Arc<String>>,
52}
53
54impl Default for Tile {
55    fn default() -> Self {
56        Self {
57            glyph: ' ',
58            style: Style::default(),
59            dx: 0,
60            dy: 0,
61            #[cfg(feature = "egc")]
62            flags: TileFlags::empty(),
63            #[cfg(feature = "egc")]
64            extra: None,
65        }
66    }
67}
68
69impl Tile {
70    /// Creates a new tile with the given glyph and style.
71    ///
72    /// `dx` and `dy` default to 0 (no sub-cell offset).
73    #[must_use]
74    pub const fn new(glyph: char, style: Style) -> Self {
75        Self {
76            glyph,
77            style,
78            dx: 0,
79            dy: 0,
80            #[cfg(feature = "egc")]
81            flags: TileFlags::empty(),
82            #[cfg(feature = "egc")]
83            extra: None,
84        }
85    }
86
87    /// Returns the tile's glyph (primary codepoint).
88    #[must_use]
89    pub const fn glyph(&self) -> char {
90        self.glyph
91    }
92
93    /// Returns the tile's style.
94    #[must_use]
95    pub const fn style(&self) -> Style {
96        self.style
97    }
98
99    /// Returns the wide-character flags for this tile.
100    ///
101    /// Only present when the `egc` feature is enabled.
102    #[cfg(feature = "egc")]
103    #[must_use]
104    pub const fn flags(&self) -> TileFlags {
105        self.flags
106    }
107
108    /// Returns the extra EGC data for this tile, if any.
109    ///
110    /// `Some` only for multi-codepoint grapheme clusters (combining marks,
111    /// ZWJ sequences, etc.). `None` for the common single-codepoint case.
112    ///
113    /// Only present when the `egc` feature is enabled.
114    #[cfg(feature = "egc")]
115    #[must_use]
116    pub fn extra(&self) -> Option<&str> {
117        self.extra.as_deref().map(String::as_str)
118    }
119
120    /// Returns the full grapheme cluster for this tile.
121    ///
122    /// When the tile contains a multi-codepoint EGC (combining marks, ZWJ
123    /// sequences, etc.) this returns the stored string. For the common
124    /// single-codepoint case it returns `None`; use [`glyph`](Self::glyph)
125    /// and [`encode_utf8`](char::encode_utf8) to reconstruct the string.
126    ///
127    /// Only present when the `egc` feature is enabled.
128    #[cfg(feature = "egc")]
129    #[must_use]
130    pub fn grapheme(&self) -> Option<&str> {
131        self.extra.as_deref().map(String::as_str)
132    }
133
134    /// Sets the glyph for this tile (builder style).
135    #[must_use]
136    pub const fn with_glyph(mut self, glyph: char) -> Self {
137        self.glyph = glyph;
138        self
139    }
140
141    /// Sets the style for this tile (builder style).
142    #[must_use]
143    pub const fn with_style(mut self, style: Style) -> Self {
144        self.style = style;
145        self
146    }
147
148    /// Sets the sub-cell pixel offset for this tile (builder style).
149    #[must_use]
150    pub const fn with_offset(mut self, dx: i16, dy: i16) -> Self {
151        self.dx = dx;
152        self.dy = dy;
153        self
154    }
155
156    /// Resets this tile to the default (space, default style, no offset).
157    #[cfg(feature = "egc")]
158    pub(crate) fn reset(&mut self) {
159        self.glyph = ' ';
160        self.style = Style::default();
161        self.dx = 0;
162        self.dy = 0;
163        self.flags = TileFlags::empty();
164        self.extra = None;
165    }
166}
167
168/// Returns `grapheme` truncated to at most 8 codepoints (combining-mark bomb
169/// defence). If the input is already within the limit it is returned as-is.
170///
171/// Only present when the `egc` feature is enabled.
172#[cfg(feature = "egc")]
173pub(crate) fn cap_grapheme(grapheme: &str) -> String {
174    const MAX_CODEPOINTS: usize = 8;
175    // Most graphemes are already within the cap; avoid allocation when possible.
176    if grapheme.chars().count() <= MAX_CODEPOINTS {
177        return String::from(grapheme);
178    }
179    grapheme.chars().take(MAX_CODEPOINTS).collect()
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::color::Color;
186
187    #[test]
188    fn test_tile_defaults() {
189        let tile = Tile::default();
190        assert_eq!(tile.glyph(), ' ');
191        assert_eq!(tile.style(), Style::default());
192        assert_eq!(tile.dx, 0);
193        assert_eq!(tile.dy, 0);
194        #[cfg(feature = "egc")]
195        {
196            assert_eq!(tile.flags(), TileFlags::empty());
197            assert!(tile.extra().is_none());
198        }
199    }
200
201    #[test]
202    fn test_tile_builder() {
203        let style = Style::new().fg(Color::RED);
204        let tile = Tile::new('A', style);
205        assert_eq!(tile.glyph(), 'A');
206        assert_eq!(tile.style(), style);
207
208        let tile = tile.with_glyph('B');
209        assert_eq!(tile.glyph(), 'B');
210    }
211
212    #[test]
213    fn test_tile_with_offset() {
214        let tile = Tile::new('X', Style::default()).with_offset(-3, 5);
215        assert_eq!(tile.dx, -3);
216        assert_eq!(tile.dy, 5);
217    }
218
219    #[test]
220    fn test_tile_reset() {
221        let style = Style::new().fg(Color::RED);
222        let mut tile = Tile::new('X', style);
223        tile.reset();
224        assert_eq!(tile.glyph(), ' ');
225        assert_eq!(tile.style(), Style::default());
226        assert_eq!(tile.dx, 0);
227        assert_eq!(tile.dy, 0);
228    }
229
230    #[cfg(feature = "egc")]
231    #[test]
232    fn test_tile_grapheme_single() {
233        let tile = Tile::new('A', Style::default());
234        assert_eq!(tile.grapheme(), None); // single-char, no extra
235    }
236
237    #[cfg(feature = "egc")]
238    #[test]
239    fn test_tile_grapheme_multi() {
240        // Multi-codepoint EGC: e + combining acute
241        let extra_str = Arc::new(String::from("e\u{0301}"));
242        let tile = Tile {
243            glyph: 'e',
244            style: Style::default(),
245            dx: 0,
246            dy: 0,
247            flags: TileFlags::empty(),
248            extra: Some(extra_str),
249        };
250        assert_eq!(tile.grapheme(), Some("e\u{0301}"));
251    }
252
253    #[cfg(feature = "egc")]
254    #[test]
255    fn test_tile_wide_flag() {
256        let mut tile = Tile::new('漢', Style::default());
257        tile.flags = TileFlags::WIDE_CHAR;
258        assert!(tile.flags().contains(TileFlags::WIDE_CHAR));
259        assert!(!tile.flags().contains(TileFlags::WIDE_CHAR_SPACER));
260    }
261}