Skip to main content

retroglyph_core/
tile.rs

1//! Fundamental unit of the grid: a single drawable tile.
2
3use crate::color::Style;
4use crate::text::char_width;
5#[cfg(feature = "egc")]
6use alloc::string::String;
7
8/// Computes the display (column) width of a single glyph, capped to what fits in a `u8`.
9///
10/// Delegates to [`char_width`], so a control character occupies the one column
11/// [`Surface`](crate::surface::Surface) actually draws it in, and `Tile::width`'s value can never drift
12/// from what that function documents and tests.
13fn glyph_width(glyph: char) -> u8 {
14    u8::try_from(char_width(glyph)).unwrap_or(1)
15}
16
17bitflags::bitflags! {
18    /// Bit-flags tracking a tile's emptiness and its role in any multi-cell structure it is part
19    /// of: a wide character, or a [span](crate::grid::Grid::write_span).
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`](crate::tile::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; the split is
41        /// what keeps the common single-codepoint tile compact.
42        const HAS_EXTRA         = 0b0000_1000;
43        /// This tile is the top-left anchor of a multi-cell span: it occupies
44        /// [`Tile::span`](crate::tile::Tile::span) cells, not one.
45        ///
46        /// Written only by [`Grid::write_span`](crate::grid::Grid::write_span), which also writes
47        /// the matching [`SPAN_COVERED`](Self::SPAN_COVERED) tiles. An anchor without its covered
48        /// cells is a broken invariant, which is why there is no `Tile` builder for this flag.
49        const SPAN_ANCHOR       = 0b0001_0000;
50        /// This tile is covered by a multi-cell span anchored above and/or to its left; see
51        /// [`Tile::span_offset`](crate::tile::Tile::span_offset).
52        ///
53        /// Unlike [`WIDE_CHAR_SPACER`](Self::WIDE_CHAR_SPACER), a covered tile keeps a real glyph
54        /// and **is** rendered by cell backends: that glyph is the span artwork's text fallback.
55        /// Only a backend that actually draws the span's artwork (a pixel backend blitting one
56        /// sprite across the whole footprint) skips it. See the [`grid`](crate::grid) module
57        /// docs for the full contract.
58        const SPAN_COVERED      = 0b0010_0000;
59    }
60}
61
62/// A single drawable tile in the terminal grid.
63///
64/// Each tile occupies one cell on a single layer; a [`Grid`](crate::grid::Grid)
65/// holds up to 256 independent layers of tiles per cell, composited
66/// bottom-to-top. Sub-cell pixel offsets (`dx`, `dy`) are visual only, they do
67/// not affect grid logic or hit-testing. Backends that cannot represent pixel
68/// offsets (e.g. `CrosstermBackend`) ignore them.
69///
70/// A tile does *not* carry its own multi-codepoint grapheme text (see
71/// [`TileFlags::HAS_EXTRA`]): that lives in a sparse side-table on the owning
72/// [`Grid`](crate::grid::Grid), keeping every `Tile` a small, fully `Copy`
73/// value regardless of whether the `egc` feature is enabled. Read it back via
74/// [`DrawCell::grapheme`](crate::backend::DrawCell::grapheme), streamed off
75/// [`Grid::layers`](crate::grid::Grid::layers).
76///
77/// # Examples
78///
79/// ```
80/// use retroglyph_core::color::{Color, Style};
81/// use retroglyph_core::tile::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)]
88#[doc(alias = "cell")] // ratatui, tcell
89#[doc(alias = "char")]
90pub struct Tile {
91    /// Primary codepoint. For ASCII and most Unicode this is the whole story.
92    pub(crate) glyph: char,
93    /// Style applied to this tile.
94    pub(crate) style: Style,
95    /// Display (column) width of `glyph`, precomputed at write time.
96    ///
97    /// Terminal-family renderers need this on every [`draw`](crate::backend::Output::draw) call
98    /// to know how far the cursor advances after printing a cell; recomputing it with
99    /// `unicode_width` on every cell of every frame is pure waste since a glyph's width never
100    /// changes between frames. It is computed once, here, whenever the glyph is written (see
101    /// [`with_glyph`](Self::with_glyph) and [`Grid::write_grapheme`](crate::grid::Grid::write_grapheme)),
102    /// and just read back afterward. Almost always 0, 1, or 2 (combining marks are 0; control
103    /// characters are 1, matching [`char_width`]; a handful of grapheme
104    /// clusters can report other values via `unicode_width`, but `u8` comfortably covers every
105    /// value that crate returns).
106    pub(crate) width: u8,
107    /// Pixel offset from the cell's left edge. Negative shifts left.
108    ///
109    /// Only meaningful for graphical backends (e.g. `SoftwareBackend`).
110    pub(crate) dx: i16,
111    /// Pixel offset from the cell's top edge. Negative shifts up.
112    ///
113    /// Only meaningful for graphical backends (e.g. `SoftwareBackend`).
114    pub(crate) dy: i16,
115    /// Role and occupancy flags: emptiness, wide-character halves, EGC side-table presence, and
116    /// multi-cell span roles (see [`TileFlags`]).
117    ///
118    /// Always present so `Tile`'s layout is stable whether or not the `egc`
119    /// feature is enabled. `WIDE_CHAR`/`WIDE_CHAR_SPACER` are set on every feature combination
120    /// (both [`Grid::put_tile`](crate::grid::Grid::put_tile) and
121    /// [`Grid::write_grapheme`](crate::grid::Grid::write_grapheme) set them); only the side-table
122    /// presence bit ([`TileFlags::HAS_EXTRA`]) is `egc`-only, since it depends on grapheme
123    /// clustering (`unicode-segmentation`) that this crate only pulls in under `egc`.
124    pub(crate) flags: TileFlags,
125    /// Multi-cell span bookkeeping, **overloaded by role** (see `flags`):
126    ///
127    /// | Flag | `span_w` | `span_h` |
128    /// | --- | --- | --- |
129    /// | [`TileFlags::SPAN_ANCHOR`] | footprint width in cells (>= 1) | footprint height (>= 1) |
130    /// | [`TileFlags::SPAN_COVERED`] | `x - anchor.x` | `y - anchor.y` |
131    /// | neither | 1 | 1 |
132    ///
133    /// The overload is what makes [`Grid::span_owner`](crate::grid::Grid::span_owner) O(1): a
134    /// covered cell names its anchor directly instead of being found by scanning. Both bytes sit
135    /// in `Tile`'s tail padding, so spans cost nothing (see `test_tile_size_is_stable_and_small`).
136    /// Read them through [`span`](Self::span) and [`span_offset`](Self::span_offset), which
137    /// enforce the roles, rather than touching the fields directly.
138    pub(crate) span_w: u8,
139    /// See [`span_w`](Self::span_w): the vertical half of the same overloaded pair.
140    pub(crate) span_h: u8,
141}
142
143impl Default for Tile {
144    fn default() -> Self {
145        Self::EMPTY
146    }
147}
148
149impl Tile {
150    /// The tile every layer cell starts as: a blank, unstyled, unwritten cell.
151    ///
152    /// Equivalent to [`Tile::default`], expressed as an associated `const` so callers that need
153    /// a `'static` reference to a default tile (e.g. [`Grid::diff`](crate::grid::Grid::diff)
154    /// reporting a layer that stopped being written) don't need an owned value to borrow from.
155    pub(crate) const EMPTY: Self = Self {
156        glyph: ' ',
157        style: Style {
158            fg: crate::color::Color::Default,
159            bg: crate::color::Color::Default,
160        },
161        width: 1,
162        dx: 0,
163        dy: 0,
164        flags: TileFlags::EMPTY,
165        span_w: 1,
166        span_h: 1,
167    };
168
169    /// Creates a new tile with the given glyph and style.
170    ///
171    /// `dx` and `dy` default to 0 (no sub-cell offset). `glyph`'s display width is computed
172    /// once here (see [`width`](Self::width)) rather than on every render.
173    #[must_use]
174    pub fn new(glyph: char, style: Style) -> Self {
175        Self {
176            glyph,
177            style,
178            width: glyph_width(glyph),
179            dx: 0,
180            dy: 0,
181            flags: TileFlags::empty(),
182            span_w: 1,
183            span_h: 1,
184        }
185    }
186
187    /// Returns the tile's glyph (primary codepoint).
188    #[must_use]
189    pub const fn glyph(&self) -> char {
190        self.glyph
191    }
192
193    /// Returns the precomputed display (column) width of [`glyph`](Self::glyph).
194    ///
195    /// Computed once when the glyph is written (see [`with_glyph`](Self::with_glyph) and
196    /// [`Grid::write_grapheme`](crate::grid::Grid::write_grapheme)), not recomputed on every
197    /// render. For tiles written via `write_grapheme`, this reflects the full grapheme cluster's
198    /// width, not just the primary codepoint's.
199    #[must_use]
200    pub const fn width(&self) -> u16 {
201        self.width as u16
202    }
203
204    /// Returns the tile's style.
205    #[must_use]
206    pub const fn style(&self) -> Style {
207        self.style
208    }
209
210    /// Returns the sub-cell pixel X offset.
211    #[must_use]
212    pub const fn dx(&self) -> i16 {
213        self.dx
214    }
215
216    /// Returns the sub-cell pixel Y offset.
217    #[must_use]
218    pub const fn dy(&self) -> i16 {
219        self.dy
220    }
221
222    /// Returns the role and occupancy flags for this tile: emptiness, wide-character halves,
223    /// EGC side-table presence, and multi-cell span roles (see [`TileFlags`]).
224    #[must_use]
225    pub const fn flags(&self) -> TileFlags {
226        self.flags
227    }
228
229    /// Returns how many cells this tile occupies, `(width, height)`.
230    ///
231    /// `(1, 1)` for every tile except a [`TileFlags::SPAN_ANCHOR`], which reports the footprint
232    /// declared by [`Grid::write_span`](crate::grid::Grid::write_span). A covered cell reports
233    /// `(1, 1)`: it does not own a footprint, it is inside one (see
234    /// [`span_offset`](Self::span_offset)).
235    #[must_use]
236    pub const fn span(&self) -> (u16, u16) {
237        if self.flags.contains(TileFlags::SPAN_ANCHOR) {
238            (self.span_w as u16, self.span_h as u16)
239        } else {
240            (1, 1)
241        }
242    }
243
244    /// Returns this tile's `(dx, dy)` offset back to its span anchor, or `None` when it is not
245    /// covered by one.
246    ///
247    /// A covered cell at `(x, y)` has its anchor at `(x - dx, y - dy)`, so a backend holding a
248    /// whole layer reaches it with one subtraction. A caller holding a
249    /// [`Grid`](crate::grid::Grid) should use
250    /// [`Grid::span_owner`](crate::grid::Grid::span_owner) instead, which handles the bounds and
251    /// the anchor-cell case too.
252    #[must_use]
253    pub const fn span_offset(&self) -> Option<(u16, u16)> {
254        if self.flags.contains(TileFlags::SPAN_COVERED) {
255            Some((self.span_w as u16, self.span_h as u16))
256        } else {
257            None
258        }
259    }
260
261    /// Returns the flat index of this tile's span anchor in a row-major buffer, given this
262    /// tile's own flat `idx` and the buffer's row stride `cols`.
263    ///
264    /// `None` when this tile is not [`TileFlags::SPAN_COVERED`] (see [`span_offset`]), or when
265    /// the offset would land before the start of the buffer. This does not check `idx` against
266    /// the buffer's length or that the anchor is in the same row-block as `idx`; a caller holding
267    /// a whole layer already knows both hold.
268    ///
269    /// [`span_offset`]: Self::span_offset
270    #[must_use]
271    pub const fn span_anchor_index(&self, idx: usize, cols: usize) -> Option<usize> {
272        let Some((dx, dy)) = self.span_offset() else {
273            return None;
274        };
275        idx.checked_sub(dy as usize * cols + dx as usize)
276    }
277
278    /// Returns `true` if nothing has been written to this tile.
279    ///
280    /// Empty tiles are transparent when compositing layers. An explicit
281    /// space (e.g. `Tile::new(' ', style)`) is **not** empty.
282    #[must_use]
283    pub const fn is_empty(&self) -> bool {
284        self.flags.contains(TileFlags::EMPTY)
285    }
286
287    /// Returns `true` if this tile is the left half of a 2-column wide character.
288    #[must_use]
289    pub const fn is_wide(&self) -> bool {
290        self.flags.contains(TileFlags::WIDE_CHAR)
291    }
292
293    /// Returns `true` if this tile is the invisible right-half spacer of a wide character.
294    #[must_use]
295    pub const fn is_wide_spacer(&self) -> bool {
296        self.flags.contains(TileFlags::WIDE_CHAR_SPACER)
297    }
298
299    /// Returns `true` if this tile is the top-left anchor of a multi-cell span (see
300    /// [`span`](Self::span)).
301    ///
302    /// Unlike `span() != (1, 1)`, this is accurate for a 1x1 span: a span anchor whose declared
303    /// footprint happens to be one cell still reports `true` here, whereas its `span()` is
304    /// indistinguishable from a plain tile's.
305    #[must_use]
306    pub const fn is_span_anchor(&self) -> bool {
307        self.flags.contains(TileFlags::SPAN_ANCHOR)
308    }
309
310    /// Sets the glyph for this tile (builder style).
311    ///
312    /// Writing content marks the tile non-empty (see [`is_empty`](Self::is_empty)). Recomputes
313    /// the cached display width (see [`width`](Self::width)) for the new glyph, and clears
314    /// [`TileFlags::WIDE_CHAR`]/[`TileFlags::WIDE_CHAR_SPACER`], which describe the old glyph's
315    /// role and would otherwise disagree with the recomputed width.
316    #[must_use]
317    pub fn with_glyph(mut self, glyph: char) -> Self {
318        self.glyph = glyph;
319        self.width = glyph_width(glyph);
320        self.flags = self
321            .flags
322            .difference(TileFlags::EMPTY | TileFlags::WIDE_CHAR | TileFlags::WIDE_CHAR_SPACER);
323        self
324    }
325
326    /// Sets the style for this tile (builder style).
327    ///
328    /// Writing content marks the tile non-empty (see [`is_empty`](Self::is_empty)).
329    #[must_use]
330    pub const fn with_style(mut self, style: Style) -> Self {
331        self.style = style;
332        self.flags = self.flags.difference(TileFlags::EMPTY);
333        self
334    }
335
336    /// Sets the sub-cell pixel offset for this tile (builder style).
337    ///
338    /// Writing content marks the tile non-empty (see [`is_empty`](Self::is_empty)).
339    #[must_use]
340    pub const fn with_offset(mut self, dx: i16, dy: i16) -> Self {
341        self.dx = dx;
342        self.dy = dy;
343        self.flags = self.flags.difference(TileFlags::EMPTY);
344        self
345    }
346
347    /// Resets this tile to the default (empty, space, default style, no offset).
348    ///
349    /// Does not touch the owning [`Grid`](crate::grid::Grid)'s EGC side-table; callers that
350    /// reset a tile which may have carried [`TileFlags::HAS_EXTRA`] are
351    /// responsible for also clearing that entry (see `Grid::clear_overlap`).
352    pub(crate) fn reset(&mut self) {
353        self.glyph = ' ';
354        self.style = Style::default();
355        self.width = 1;
356        self.dx = 0;
357        self.dy = 0;
358        self.flags = TileFlags::EMPTY;
359        self.span_w = 1;
360        self.span_h = 1;
361    }
362
363    /// Strips this tile's multi-cell span role, leaving its glyph and style alone.
364    ///
365    /// Used by copy paths that cannot preserve a span's cross-cell invariant
366    /// ([`Grid::blit`](crate::grid::Grid::blit) can clip a footprint in half), so the copy
367    /// degrades to exactly the span's text fallback instead of to a dangling anchor.
368    pub(crate) fn clear_span(&mut self) {
369        self.flags
370            .remove(TileFlags::SPAN_ANCHOR | TileFlags::SPAN_COVERED);
371        self.span_w = 1;
372        self.span_h = 1;
373    }
374
375    /// Strips this tile's wide-character-pair role, leaving its glyph and style alone.
376    ///
377    /// The wide-character counterpart to [`clear_span`](Self::clear_span): used by copy paths
378    /// that cannot preserve a wide pair's cross-cell invariant ([`Grid::blit`](crate::grid::Grid::blit)
379    /// can clip a pair in half via `src_rect`, or land on only one half of a destination pair), so
380    /// the copy degrades to a plain, unpaired cell instead of a dangling lead or spacer.
381    pub(crate) fn clear_wide(&mut self) {
382        self.flags
383            .remove(TileFlags::WIDE_CHAR | TileFlags::WIDE_CHAR_SPACER);
384    }
385}
386
387/// Returns `grapheme` truncated to at most 8 codepoints (combining-mark bomb defence). If the
388/// input is already within the limit it is returned as-is.
389///
390/// The cap bounds how much text one cell can pull into its layer's EGC side-table, so a string
391/// of thousands of combining marks on a single base character can't blow up per-cell storage.
392/// 8 is chosen to clear the longest clusters a caller can reasonably intend (a base plus a couple
393/// of combining marks, or an emoji ZWJ sequence of a few joined code points) while still cutting
394/// off an adversarial run early. A cluster longer than 8 is truncated on a code-point boundary,
395/// so the stored text stays valid UTF-8 but may render differently than the untruncated input.
396///
397/// The exact value was picked by headroom, not measured against a corpus of real clusters; raise
398/// it if a legitimate sequence turns out to exceed it.
399///
400/// Only present when the `egc` feature is enabled.
401#[cfg(feature = "egc")]
402pub(crate) fn cap_grapheme(grapheme: &str) -> String {
403    const MAX_CODEPOINTS: usize = 8;
404    // Most graphemes are already within the cap; avoid allocation when possible.
405    if grapheme.chars().count() <= MAX_CODEPOINTS {
406        return String::from(grapheme);
407    }
408    grapheme.chars().take(MAX_CODEPOINTS).collect()
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use crate::color::Color;
415
416    /// Regression guard for the size win the EGC side-table exists for: a
417    /// `Tile` stays 20 bytes and keeps the same layout with or without
418    /// `egc`, because grapheme text lives in the side table.
419    #[test]
420    fn test_tile_size_is_stable_and_small() {
421        assert_eq!(size_of::<Tile>(), 20);
422    }
423
424    #[test]
425    fn test_tile_defaults() {
426        let tile = Tile::default();
427        assert_eq!(tile.glyph(), ' ');
428        assert_eq!(tile.style(), Style::default());
429        assert_eq!(tile.dx, 0);
430        assert_eq!(tile.dy, 0);
431        // The default tile is empty (transparent when composited).
432        assert!(tile.is_empty());
433        assert_eq!(tile.flags(), TileFlags::EMPTY);
434    }
435
436    #[test]
437    fn test_tile_empty_semantics() {
438        // An explicit space is not empty; a default tile is.
439        assert!(Tile::default().is_empty());
440        assert!(!Tile::new(' ', Style::default()).is_empty());
441        assert!(!Tile::default().with_glyph(' ').is_empty());
442        assert!(!Tile::default().with_style(Style::default()).is_empty());
443        assert!(!Tile::default().with_offset(1, 1).is_empty());
444    }
445
446    #[test]
447    fn test_tile_builder() {
448        let style = Style::new().fg(Color::RED);
449        let tile = Tile::new('A', style);
450        assert_eq!(tile.glyph(), 'A');
451        assert_eq!(tile.style(), style);
452
453        let tile = tile.with_glyph('B');
454        assert_eq!(tile.glyph(), 'B');
455    }
456
457    #[test]
458    fn test_tile_with_offset() {
459        let tile = Tile::new('X', Style::default()).with_offset(-3, 5);
460        assert_eq!(tile.dx, -3);
461        assert_eq!(tile.dy, 5);
462    }
463
464    /// `with_style` only touches `style` and the `EMPTY` flag; the glyph and its precomputed
465    /// width must survive untouched.
466    #[test]
467    fn test_tile_with_style_preserves_glyph_and_width() {
468        let tile = Tile::new('漢', Style::default()).with_style(Style::new().fg(Color::RED));
469        assert_eq!(tile.glyph(), '漢');
470        assert_eq!(tile.width(), 2);
471        assert_eq!(tile.style(), Style::new().fg(Color::RED));
472    }
473
474    /// `with_offset` only touches `dx`/`dy` and the `EMPTY` flag; the glyph and its precomputed
475    /// width must survive untouched.
476    #[test]
477    fn test_tile_with_offset_preserves_glyph_and_width() {
478        let tile = Tile::new('漢', Style::default()).with_offset(1, 1);
479        assert_eq!(tile.glyph(), '漢');
480        assert_eq!(tile.width(), 2);
481    }
482
483    /// Each `with_offset` call sets `dx`/`dy` outright rather than accumulating; a second call
484    /// in a chain must not leave anything from the first behind.
485    #[test]
486    fn test_tile_with_offset_chain_does_not_leak_prior_values() {
487        let tile = Tile::new('X', Style::default())
488            .with_offset(-3, 5)
489            .with_offset(2, -1);
490        assert_eq!(tile.dx, 2);
491        assert_eq!(tile.dy, -1);
492    }
493
494    #[test]
495    fn test_tile_reset() {
496        let style = Style::new().fg(Color::RED);
497        let mut tile = Tile::new('X', style);
498        assert!(!tile.is_empty());
499        tile.reset();
500        assert_eq!(tile.glyph(), ' ');
501        assert_eq!(tile.style(), Style::default());
502        assert_eq!(tile.dx, 0);
503        assert_eq!(tile.dy, 0);
504        assert!(tile.is_empty());
505    }
506
507    #[test]
508    fn test_tile_wide_flag() {
509        let mut tile = Tile::new('漢', Style::default());
510        tile.flags = TileFlags::WIDE_CHAR;
511        assert!(tile.flags().contains(TileFlags::WIDE_CHAR));
512        assert!(!tile.flags().contains(TileFlags::WIDE_CHAR_SPACER));
513    }
514
515    #[test]
516    fn test_tile_width_is_precomputed_from_glyph() {
517        // ASCII is single-column; a CJK ideograph is double-column. Both are computed once at
518        // write time (`new`/`with_glyph`), not left for callers to recompute per render.
519        assert_eq!(Tile::new('A', Style::default()).width(), 1);
520        assert_eq!(Tile::new('漢', Style::default()).width(), 2);
521        assert_eq!(Tile::default().width(), 1);
522    }
523
524    /// Control characters report `None` from `unicode_width`; `glyph_width`'s `unwrap_or(1)`
525    /// fallback treats them as single-column, matching this crate's prior per-cell behavior.
526    #[test]
527    fn test_tile_width_falls_back_to_one_for_control_characters() {
528        assert_eq!(Tile::new('\t', Style::default()).width(), 1);
529        assert_eq!(Tile::new('\u{7}', Style::default()).width(), 1);
530        assert_eq!(Tile::new('\u{1b}', Style::default()).width(), 1);
531    }
532
533    /// Combining marks and zero-width joiners are genuinely zero-column: unlike control
534    /// characters, `unicode_width` reports `Some(0)` for these rather than `None`, so they skip
535    /// the `unwrap_or` fallback entirely.
536    #[test]
537    fn test_tile_width_is_zero_for_zero_width_glyphs() {
538        assert_eq!(Tile::new('\u{0301}', Style::default()).width(), 0);
539        assert_eq!(Tile::new('\u{200d}', Style::default()).width(), 0);
540    }
541
542    #[test]
543    fn test_tile_with_glyph_recomputes_width() {
544        let tile = Tile::new('A', Style::default()).with_glyph('漢');
545        assert_eq!(tile.glyph(), '漢');
546        assert_eq!(tile.width(), 2);
547    }
548
549    /// A tile carrying a stale `WIDE_CHAR_SPACER` (e.g. read back out of a grid via
550    /// `*grid.tile(..)`) must not keep that flag once `with_glyph` gives it a real glyph: the
551    /// flag tells `Grid::put_tile` to treat the tile as an already-resolved replay and store it
552    /// verbatim, which means every backend skips drawing it (see issue #986).
553    #[test]
554    fn test_tile_with_glyph_clears_stale_wide_char_spacer_flag() {
555        let mut spacer = Tile::new(' ', Style::default());
556        spacer.flags = TileFlags::WIDE_CHAR_SPACER;
557
558        let rebuilt = spacer.with_glyph('!');
559
560        assert_eq!(rebuilt.glyph(), '!');
561        assert_eq!(rebuilt.width(), 1);
562        assert!(!rebuilt.flags().contains(TileFlags::WIDE_CHAR_SPACER));
563        assert!(!rebuilt.is_empty());
564    }
565
566    /// A tile carrying a stale `WIDE_CHAR` must not keep that flag once `with_glyph` narrows it:
567    /// the flag tells `Grid::clear_overlap` that the cell to the right is this tile's spacer, so
568    /// a stale flag makes an overlapping write reset an unrelated neighbour (see issue #986).
569    #[test]
570    fn test_tile_with_glyph_clears_stale_wide_char_flag() {
571        let mut wide = Tile::new('漢', Style::default());
572        wide.flags = TileFlags::WIDE_CHAR;
573
574        let rebuilt = wide.with_glyph('A');
575
576        assert_eq!(rebuilt.glyph(), 'A');
577        assert_eq!(rebuilt.width(), 1);
578        assert!(!rebuilt.flags().contains(TileFlags::WIDE_CHAR));
579    }
580
581    #[test]
582    fn test_tile_flag_predicates() {
583        let mut tile = Tile::new('A', Style::default());
584        assert!(!tile.is_wide());
585        assert!(!tile.is_wide_spacer());
586        assert!(!tile.is_span_anchor());
587
588        tile.flags = TileFlags::WIDE_CHAR;
589        assert!(tile.is_wide());
590        assert!(!tile.is_wide_spacer());
591        assert!(!tile.is_span_anchor());
592
593        tile.flags = TileFlags::WIDE_CHAR_SPACER;
594        assert!(!tile.is_wide());
595        assert!(tile.is_wide_spacer());
596        assert!(!tile.is_span_anchor());
597
598        // A 1x1 span anchor is still an anchor even though its `span()` matches a plain tile's.
599        tile.flags = TileFlags::SPAN_ANCHOR;
600        tile.span_w = 1;
601        tile.span_h = 1;
602        assert!(tile.is_span_anchor());
603        assert_eq!(tile.span(), (1, 1));
604    }
605
606    #[test]
607    fn test_tile_span_defaults_to_one_by_one() {
608        assert_eq!(Tile::default().span(), (1, 1));
609        assert_eq!(Tile::new('A', Style::default()).span(), (1, 1));
610        assert_eq!(Tile::default().span_offset(), None);
611        assert_eq!(Tile::new('A', Style::default()).span_offset(), None);
612    }
613
614    /// `span_w`/`span_h` are overloaded by role, so reading them through the wrong accessor must
615    /// report the neutral answer rather than the other role's number.
616    #[test]
617    fn test_tile_span_accessors_are_keyed_by_role() {
618        let mut anchor = Tile::new('C', Style::default());
619        anchor.flags = TileFlags::SPAN_ANCHOR;
620        anchor.span_w = 2;
621        anchor.span_h = 3;
622        assert_eq!(anchor.span(), (2, 3));
623        assert_eq!(anchor.span_offset(), None);
624
625        let mut covered = Tile::new(']', Style::default());
626        covered.flags = TileFlags::SPAN_COVERED;
627        covered.span_w = 1;
628        covered.span_h = 2;
629        assert_eq!(covered.span_offset(), Some((1, 2)));
630        assert_eq!(covered.span(), (1, 1));
631    }
632
633    #[test]
634    fn test_tile_span_anchor_index_resolves_a_covered_cell_to_its_anchor() {
635        let mut covered = Tile::new(']', Style::default());
636        covered.flags = TileFlags::SPAN_COVERED;
637        covered.span_w = 1;
638        covered.span_h = 2;
639        // idx 23 is (3, 2) in a 10-wide buffer; the anchor is (dx, dy) = (1, 2) back, at (2, 0).
640        assert_eq!(covered.span_anchor_index(23, 10), Some(2));
641    }
642
643    #[test]
644    fn test_tile_span_anchor_index_is_none_when_not_covered() {
645        assert_eq!(Tile::default().span_anchor_index(5, 10), None);
646
647        let mut anchor = Tile::new('C', Style::default());
648        anchor.flags = TileFlags::SPAN_ANCHOR;
649        anchor.span_w = 2;
650        anchor.span_h = 3;
651        assert_eq!(anchor.span_anchor_index(5, 10), None);
652    }
653
654    #[test]
655    fn test_tile_span_anchor_index_is_none_past_the_buffer_start() {
656        let mut covered = Tile::new(']', Style::default());
657        covered.flags = TileFlags::SPAN_COVERED;
658        covered.span_w = 1;
659        covered.span_h = 2;
660        assert_eq!(covered.span_anchor_index(1, 10), None);
661    }
662
663    /// `cols == 0` is a caller error (there is no valid row stride), but the method has no way to
664    /// detect it: `checked_sub` only guards against the anchor landing before the buffer start,
665    /// not against a degenerate stride. Documented here rather than in the method's doc, which
666    /// lists exactly the two `None` conditions this is not one of.
667    #[test]
668    fn test_tile_span_anchor_index_does_not_detect_a_zero_stride() {
669        let mut covered = Tile::new(']', Style::default());
670        covered.flags = TileFlags::SPAN_COVERED;
671        covered.span_w = 1;
672        covered.span_h = 0;
673        assert_eq!(covered.span_anchor_index(1, 0), Some(0));
674    }
675
676    /// The method does not check that the resolved anchor is in the same row-block as `idx`; a
677    /// covered cell whose `dx` exceeds its own column lands on the last cell of the *previous*
678    /// row instead of returning `None`. `Grid::write_span` can never produce this (a span's
679    /// footprint always fits, so `x >= dx` holds for every covered cell it writes), so this pins
680    /// the doc's "caller already knows this holds" precondition rather than guarding a real bug.
681    #[test]
682    fn test_tile_span_anchor_index_does_not_detect_crossing_a_row_block() {
683        let mut covered = Tile::new(']', Style::default());
684        covered.flags = TileFlags::SPAN_COVERED;
685        covered.span_w = 1;
686        covered.span_h = 0;
687        // idx 4 is (0, 1) in a 4-wide buffer; dx = 1 walks back past column 0 into row 0's tail.
688        assert_eq!(covered.span_anchor_index(4, 4), Some(3));
689    }
690
691    #[test]
692    fn test_tile_clear_span_keeps_the_glyph() {
693        let mut tile = Tile::new('C', Style::default());
694        tile.flags = TileFlags::SPAN_ANCHOR;
695        tile.span_w = 2;
696        tile.span_h = 2;
697        tile.clear_span();
698        assert_eq!(tile.glyph(), 'C');
699        assert_eq!(tile.span(), (1, 1));
700        assert!(!tile.flags().contains(TileFlags::SPAN_ANCHOR));
701    }
702
703    #[test]
704    fn test_tile_reset_clears_span() {
705        let mut tile = Tile::new('C', Style::default());
706        tile.flags = TileFlags::SPAN_ANCHOR;
707        tile.span_w = 4;
708        tile.span_h = 4;
709        tile.reset();
710        assert_eq!(tile.span(), (1, 1));
711        assert_eq!(tile.span_offset(), None);
712        assert!(tile.is_empty());
713    }
714
715    /// The derived `Default` on `TileFlags` is `empty()`, not `EMPTY`, which disagrees with the
716    /// flags a default `Tile` actually carries. Nothing in the workspace calls
717    /// `TileFlags::default()`; this pins the divergence rather than silently relying on it, given
718    /// how easy it would be to reach for `TileFlags::default()` expecting `EMPTY` back.
719    #[test]
720    fn test_tile_flags_default_is_not_empty_flag() {
721        assert_eq!(TileFlags::default(), TileFlags::empty());
722        assert_ne!(TileFlags::default(), TileFlags::EMPTY);
723        assert_eq!(Tile::default().flags(), TileFlags::EMPTY);
724    }
725
726    #[cfg(feature = "egc")]
727    #[test]
728    fn test_cap_grapheme_leaves_short_input_unchanged() {
729        assert_eq!(cap_grapheme(""), "");
730        assert_eq!(cap_grapheme("a"), "a");
731        assert_eq!(cap_grapheme("e\u{0301}"), "e\u{0301}");
732    }
733
734    #[cfg(feature = "egc")]
735    #[test]
736    fn test_cap_grapheme_leaves_exactly_the_cap_unchanged() {
737        // 8 codepoints: the boundary itself must not be truncated.
738        let input: String = core::iter::repeat_n('\u{0301}', 8).collect();
739        assert_eq!(cap_grapheme(&input), input);
740    }
741
742    #[cfg(feature = "egc")]
743    #[test]
744    fn test_cap_grapheme_truncates_past_the_cap_on_a_codepoint_boundary() {
745        // 9 codepoints, each multi-byte (U+0301 is 2 bytes in UTF-8), so a byte-oriented
746        // truncation would split a codepoint; `cap_grapheme` must not.
747        let input: String = core::iter::repeat_n('\u{0301}', 9).collect();
748        let capped = cap_grapheme(&input);
749        assert_eq!(capped.chars().count(), 8);
750        assert!(capped.is_char_boundary(capped.len()));
751        let expected: String = core::iter::repeat_n('\u{0301}', 8).collect();
752        assert_eq!(capped, expected);
753    }
754}