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