Skip to main content

ftui_render/
cell.rs

1#![forbid(unsafe_code)]
2
3//! Cell types and invariants.
4//!
5//! The `Cell` is the fundamental unit of the terminal grid. Each cell occupies
6//! exactly **16 bytes** to ensure optimal cache utilization (4 cells per 64-byte
7//! cache line) and enable fast SIMD comparisons.
8//!
9//! # Layout (16 bytes, non-negotiable)
10//!
11//! ```text
12//! Cell {
13//!     content: CellContent,  // 4 bytes - char or GraphemeId
14//!     fg: PackedRgba,        // 4 bytes - foreground color
15//!     bg: PackedRgba,        // 4 bytes - background color
16//!     attrs: CellAttrs,      // 4 bytes - style flags + link ID
17//! }
18//! ```
19//!
20//! # Why 16 Bytes?
21//!
22//! - 4 cells per 64-byte cache line (perfect fit)
23//! - Single 128-bit SIMD comparison
24//! - No heap allocation for 99% of cells
25//! - 24 bytes wastes cache, 32 bytes doubles bandwidth
26
27use crate::char_width;
28
29/// Grapheme ID: reference to an interned string in [`GraphemePool`].
30///
31/// # Layout
32///
33/// ```text
34/// [30-24: width (7 bits)][23-0: pool slot (24 bits)]
35/// ```
36///
37/// # Capacity
38///
39/// - Pool slots: 16,777,216 (24 bits = 16M entries)
40/// - Width range: 0-127 (7 bits, plenty for any display width)
41///
42/// # Design Rationale
43///
44/// - 24 bits for slot allows 16M unique graphemes (far exceeding practical usage)
45/// - 7 bits for width allows display widths 0-127 (most graphemes are 1-2)
46/// - Embedded width avoids pool lookup for width queries
47/// - Total 31 bits leaves bit 31 for `CellContent` type discrimination
48#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)]
49#[repr(transparent)]
50pub struct GraphemeId(u32);
51
52impl GraphemeId {
53    /// Maximum slot index (24 bits).
54    pub const MAX_SLOT: u32 = 0x00FF_FFFF;
55
56    /// Maximum width (7 bits).
57    pub const MAX_WIDTH: u8 = 127;
58
59    /// Create a new `GraphemeId` from slot index and display width.
60    ///
61    /// # Panics
62    ///
63    /// Panics in debug mode if `slot > MAX_SLOT` or `width > MAX_WIDTH`.
64    #[inline]
65    pub const fn new(slot: u32, width: u8) -> Self {
66        debug_assert!(slot <= Self::MAX_SLOT, "slot overflow");
67        debug_assert!(width <= Self::MAX_WIDTH, "width overflow");
68        Self((slot & Self::MAX_SLOT) | ((width as u32) << 24))
69    }
70
71    /// Extract the pool slot index (0-16M).
72    #[inline]
73    pub const fn slot(self) -> usize {
74        (self.0 & Self::MAX_SLOT) as usize
75    }
76
77    /// Extract the display width (0-127).
78    #[inline]
79    pub const fn width(self) -> usize {
80        ((self.0 >> 24) & 0x7F) as usize
81    }
82
83    /// Raw u32 value for storage in `CellContent`.
84    #[inline]
85    pub const fn raw(self) -> u32 {
86        self.0
87    }
88
89    /// Reconstruct from a raw u32.
90    #[inline]
91    pub const fn from_raw(raw: u32) -> Self {
92        Self(raw)
93    }
94}
95
96impl core::fmt::Debug for GraphemeId {
97    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
98        f.debug_struct("GraphemeId")
99            .field("slot", &self.slot())
100            .field("width", &self.width())
101            .finish()
102    }
103}
104
105/// Cell content: either a direct Unicode char or a reference to a grapheme cluster.
106///
107/// # Encoding Scheme (4 bytes)
108///
109/// ```text
110/// Bit 31 (type discriminator):
111///   0: Direct char (bits 0-20 contain Unicode scalar value, max U+10FFFF)
112///   1: GraphemeId reference (bits 0-30 contain slot + width)
113/// ```
114///
115/// This allows:
116/// - 99% of cells (ASCII/BMP) to be stored without heap allocation
117/// - Complex graphemes (emoji, ZWJ sequences) stored in pool
118///
119/// # Special Values
120///
121/// - `EMPTY` (0x0): Empty cell, width 0
122/// - `CONTINUATION` (0x1): Placeholder for wide character continuation
123#[derive(Clone, Copy, PartialEq, Eq, Hash)]
124#[repr(transparent)]
125pub struct CellContent(u32);
126
127impl CellContent {
128    /// Empty cell content (no character).
129    pub const EMPTY: Self = Self(0);
130
131    /// Continuation marker for wide characters.
132    ///
133    /// When a character has display width > 1, subsequent cells are filled
134    /// with this marker to indicate they are part of the previous character.
135    ///
136    /// Value is `0x7FFF_FFFF` (max i32), which is outside valid Unicode scalar
137    /// range (0..0x10FFFF) but fits in 31 bits (Direct Char mode).
138    pub const CONTINUATION: Self = Self(0x7FFF_FFFF);
139
140    /// Create content from a single Unicode character.
141    ///
142    /// For characters with display width > 1, subsequent cells should be
143    /// filled with `CONTINUATION`.
144    #[inline]
145    pub const fn from_char(c: char) -> Self {
146        Self(c as u32)
147    }
148
149    /// Create content from a grapheme ID (for multi-codepoint clusters).
150    ///
151    /// The grapheme ID references an entry in the `GraphemePool`.
152    #[inline]
153    pub const fn from_grapheme(id: GraphemeId) -> Self {
154        Self(0x8000_0000 | id.raw())
155    }
156
157    /// Check if this content is a grapheme reference (vs direct char).
158    #[inline]
159    pub const fn is_grapheme(self) -> bool {
160        self.0 & 0x8000_0000 != 0
161    }
162
163    /// Check if this is a continuation cell (part of a wide character).
164    #[inline]
165    pub const fn is_continuation(self) -> bool {
166        self.0 == Self::CONTINUATION.0
167    }
168
169    /// Check if this cell is empty.
170    #[inline]
171    pub const fn is_empty(self) -> bool {
172        self.0 == Self::EMPTY.0
173    }
174
175    /// Check if this content is the default value.
176    ///
177    /// This is equivalent to `is_empty()` and primarily exists for readability in tests.
178    #[inline]
179    pub const fn is_default(self) -> bool {
180        self.0 == Self::EMPTY.0
181    }
182
183    /// Extract the character if this is a direct char (not a grapheme).
184    ///
185    /// Returns `None` if this is empty, continuation, or a grapheme reference.
186    #[inline]
187    pub fn as_char(self) -> Option<char> {
188        if self.is_grapheme() || self.0 == Self::EMPTY.0 || self.0 == Self::CONTINUATION.0 {
189            None
190        } else {
191            char::from_u32(self.0)
192        }
193    }
194
195    /// Extract the grapheme ID if this is a grapheme reference.
196    ///
197    /// Returns `None` if this is a direct char.
198    #[inline]
199    pub const fn grapheme_id(self) -> Option<GraphemeId> {
200        if self.is_grapheme() {
201            Some(GraphemeId::from_raw(self.0 & !0x8000_0000))
202        } else {
203            None
204        }
205    }
206
207    /// Get the display width of this content.
208    ///
209    /// - Empty: 0
210    /// - Continuation: 0
211    /// - Grapheme: width embedded in GraphemeId
212    /// - Char: requires external width lookup (returns 1 as default for ASCII)
213    ///
214    /// Note: For accurate char width, use the unicode-display-width-based
215    /// helpers in this crate. This method provides a fast path for known cases.
216    #[inline]
217    pub const fn width_hint(self) -> usize {
218        if self.is_empty() || self.is_continuation() {
219            0
220        } else if self.is_grapheme() {
221            ((self.0 >> 24) & 0x7F) as usize
222        } else {
223            // For direct chars, assume width 1 (fast path for ASCII)
224            // Callers should use unicode-width for accurate measurement
225            1
226        }
227    }
228
229    /// Get the display width of this content with Unicode width semantics.
230    ///
231    /// This is the accurate (but slower) width computation for direct chars.
232    #[inline]
233    pub fn width(self) -> usize {
234        if self.is_empty() || self.is_continuation() {
235            0
236        } else if self.is_grapheme() {
237            ((self.0 >> 24) & 0x7F) as usize
238        } else {
239            let Some(c) = self.as_char() else {
240                return 1;
241            };
242            char_width(c)
243        }
244    }
245
246    /// Raw u32 value.
247    #[inline]
248    pub const fn raw(self) -> u32 {
249        self.0
250    }
251}
252
253impl Default for CellContent {
254    fn default() -> Self {
255        Self::EMPTY
256    }
257}
258
259impl core::fmt::Debug for CellContent {
260    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
261        if self.is_empty() {
262            write!(f, "CellContent::EMPTY")
263        } else if self.is_continuation() {
264            write!(f, "CellContent::CONTINUATION")
265        } else if let Some(c) = self.as_char() {
266            write!(f, "CellContent::Char({c:?})")
267        } else if let Some(id) = self.grapheme_id() {
268            write!(f, "CellContent::Grapheme({id:?})")
269        } else {
270            write!(f, "CellContent(0x{:08x})", self.0)
271        }
272    }
273}
274
275/// A single terminal cell (16 bytes).
276///
277/// # Layout
278///
279/// ```text
280/// #[repr(C, align(16))]
281/// Cell {
282///     content: CellContent,  // 4 bytes
283///     fg: PackedRgba,        // 4 bytes
284///     bg: PackedRgba,        // 4 bytes
285///     attrs: CellAttrs,      // 4 bytes
286/// }
287/// ```
288///
289/// # Invariants
290///
291/// - Size is exactly 16 bytes (verified by compile-time assert)
292/// - All fields are valid (no uninitialized memory)
293/// - Continuation cells should not have meaningful fg/bg (they inherit from parent)
294///
295/// # Default
296///
297/// The default cell is empty with transparent background, white foreground,
298/// and no style attributes.
299#[derive(Clone, Copy, PartialEq, Eq)]
300#[repr(C, align(16))]
301pub struct Cell {
302    /// Character or grapheme content.
303    pub content: CellContent,
304    /// Foreground color.
305    pub fg: PackedRgba,
306    /// Background color.
307    pub bg: PackedRgba,
308    /// Style flags and hyperlink ID.
309    pub attrs: CellAttrs,
310}
311
312// Compile-time size check
313const _: () = assert!(core::mem::size_of::<Cell>() == 16);
314
315impl Cell {
316    /// A continuation cell (placeholder for wide characters).
317    ///
318    /// When a character has display width > 1, subsequent cells are filled
319    /// with this to indicate they are "owned" by the previous cell.
320    pub const CONTINUATION: Self = Self {
321        content: CellContent::CONTINUATION,
322        fg: PackedRgba::TRANSPARENT,
323        bg: PackedRgba::TRANSPARENT,
324        attrs: CellAttrs::NONE,
325    };
326
327    /// Create a new cell with the given content and default colors.
328    #[inline]
329    pub const fn new(content: CellContent) -> Self {
330        Self {
331            content,
332            fg: PackedRgba::WHITE,
333            bg: PackedRgba::TRANSPARENT,
334            attrs: CellAttrs::NONE,
335        }
336    }
337
338    /// Create a cell from a single character.
339    #[inline]
340    pub const fn from_char(c: char) -> Self {
341        Self::new(CellContent::from_char(c))
342    }
343
344    /// Check if this is a continuation cell.
345    #[inline]
346    pub const fn is_continuation(&self) -> bool {
347        self.content.is_continuation()
348    }
349
350    /// Check if this cell is empty.
351    #[inline]
352    pub const fn is_empty(&self) -> bool {
353        self.content.is_empty()
354    }
355
356    /// Get the display width hint for this cell.
357    ///
358    /// See [`CellContent::width_hint`] for details.
359    #[inline]
360    pub const fn width_hint(&self) -> usize {
361        self.content.width_hint()
362    }
363
364    /// Bitwise equality comparison (fast path for diffing).
365    ///
366    /// Uses bitwise AND (`&`) instead of short-circuit AND (`&&`) so all
367    /// four u32 comparisons are always evaluated. This avoids branch
368    /// mispredictions in tight loops and allows LLVM to lower the check
369    /// to a single 128-bit SIMD compare on supported targets.
370    #[inline(always)]
371    pub fn bits_eq(&self, other: &Self) -> bool {
372        (self.content.raw() == other.content.raw())
373            & (self.fg == other.fg)
374            & (self.bg == other.bg)
375            & (self.attrs == other.attrs)
376    }
377
378    /// Set the cell content to a character, preserving other fields.
379    #[inline]
380    #[must_use]
381    pub const fn with_char(mut self, c: char) -> Self {
382        self.content = CellContent::from_char(c);
383        self
384    }
385
386    /// Set the foreground color.
387    #[inline]
388    #[must_use]
389    pub const fn with_fg(mut self, fg: PackedRgba) -> Self {
390        self.fg = fg;
391        self
392    }
393
394    /// Set the background color.
395    #[inline]
396    #[must_use]
397    pub const fn with_bg(mut self, bg: PackedRgba) -> Self {
398        self.bg = bg;
399        self
400    }
401
402    /// Set the style attributes.
403    #[inline]
404    #[must_use]
405    pub const fn with_attrs(mut self, attrs: CellAttrs) -> Self {
406        self.attrs = attrs;
407        self
408    }
409}
410impl Default for Cell {
411    fn default() -> Self {
412        Self {
413            content: CellContent::EMPTY,
414            fg: PackedRgba::WHITE,
415            bg: PackedRgba::TRANSPARENT,
416            attrs: CellAttrs::NONE,
417        }
418    }
419}
420
421impl core::fmt::Debug for Cell {
422    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
423        f.debug_struct("Cell")
424            .field("content", &self.content)
425            .field("fg", &self.fg)
426            .field("bg", &self.bg)
427            .field("attrs", &self.attrs)
428            .finish()
429    }
430}
431
432/// A compact RGBA color.
433///
434/// - **Size:** 4 bytes (fits within the `Cell` 16-byte budget).
435/// - **Layout:** `0xRRGGBBAA` (R in bits 31..24, A in bits 7..0).
436///
437/// Notes
438/// -----
439/// This is **straight alpha** storage (RGB channels are not pre-multiplied).
440/// Compositing uses Porter-Duff **SourceOver** (`src over dst`).
441#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
442#[repr(transparent)]
443pub struct PackedRgba(pub u32);
444
445impl PackedRgba {
446    /// Fully transparent (alpha = 0).
447    pub const TRANSPARENT: Self = Self(0);
448    /// Opaque black.
449    pub const BLACK: Self = Self::rgb(0, 0, 0);
450    /// Opaque white.
451    pub const WHITE: Self = Self::rgb(255, 255, 255);
452    /// Opaque red.
453    pub const RED: Self = Self::rgb(255, 0, 0);
454    /// Opaque green.
455    pub const GREEN: Self = Self::rgb(0, 255, 0);
456    /// Opaque blue.
457    pub const BLUE: Self = Self::rgb(0, 0, 255);
458
459    /// Create an opaque RGB color (alpha = 255).
460    #[inline]
461    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
462        Self::rgba(r, g, b, 255)
463    }
464
465    /// Create an RGBA color with explicit alpha.
466    #[inline]
467    pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
468        Self(((r as u32) << 24) | ((g as u32) << 16) | ((b as u32) << 8) | (a as u32))
469    }
470
471    /// Red channel.
472    #[inline]
473    pub const fn r(self) -> u8 {
474        (self.0 >> 24) as u8
475    }
476
477    /// Green channel.
478    #[inline]
479    pub const fn g(self) -> u8 {
480        (self.0 >> 16) as u8
481    }
482
483    /// Blue channel.
484    #[inline]
485    pub const fn b(self) -> u8 {
486        (self.0 >> 8) as u8
487    }
488
489    /// Alpha channel.
490    #[inline]
491    pub const fn a(self) -> u8 {
492        self.0 as u8
493    }
494
495    #[inline]
496    const fn div_round_u8(numer: u64, denom: u64) -> u8 {
497        debug_assert!(denom != 0);
498        let v = (numer + (denom / 2)) / denom;
499        if v > 255 { 255 } else { v as u8 }
500    }
501
502    /// Porter-Duff SourceOver: `src over dst`.
503    ///
504    /// Stored as straight alpha, so we compute the exact rational form and round at the end
505    /// (avoids accumulating rounding error across intermediate steps).
506    #[inline]
507    #[must_use]
508    pub fn over(self, dst: Self) -> Self {
509        let s_a = self.a() as u64;
510        if s_a == 255 {
511            return self;
512        }
513        if s_a == 0 {
514            return dst;
515        }
516
517        let d_a = dst.a() as u64;
518        let inv_s_a = 255 - s_a;
519
520        // out_a = s_a + d_a*(1 - s_a)  (all in [0,1], scaled by 255)
521        // We compute numer_a in the "255^2 domain" to keep channels exact:
522        // numer_a = 255*s_a + d_a*(255 - s_a)
523        // out_a_u8 = round(numer_a / 255)
524        let numer_a = 255 * s_a + d_a * inv_s_a;
525        if numer_a == 0 {
526            return Self::TRANSPARENT;
527        }
528
529        let out_a = Self::div_round_u8(numer_a, 255);
530
531        // For straight alpha, the exact rational (scaled to [0,255]) is:
532        // out_c_u8 = round( (src_c*s_a*255 + dst_c*d_a*(255 - s_a)) / numer_a )
533        let r = Self::div_round_u8(
534            (self.r() as u64) * s_a * 255 + (dst.r() as u64) * d_a * inv_s_a,
535            numer_a,
536        );
537        let g = Self::div_round_u8(
538            (self.g() as u64) * s_a * 255 + (dst.g() as u64) * d_a * inv_s_a,
539            numer_a,
540        );
541        let b = Self::div_round_u8(
542            (self.b() as u64) * s_a * 255 + (dst.b() as u64) * d_a * inv_s_a,
543            numer_a,
544        );
545
546        Self::rgba(r, g, b, out_a)
547    }
548
549    /// Apply uniform opacity in `[0.0, 1.0]` by scaling alpha.
550    #[inline]
551    #[must_use]
552    pub fn with_opacity(self, opacity: f32) -> Self {
553        let opacity = opacity.clamp(0.0, 1.0);
554        let a = ((self.a() as f32) * opacity).round().clamp(0.0, 255.0) as u8;
555        Self::rgba(self.r(), self.g(), self.b(), a)
556    }
557}
558
559bitflags::bitflags! {
560    /// 8-bit cell style flags.
561    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
562    pub struct StyleFlags: u8 {
563        /// Bold / increased intensity.
564        const BOLD          = 0b0000_0001;
565        /// Dim / decreased intensity.
566        const DIM           = 0b0000_0010;
567        /// Italic text.
568        const ITALIC        = 0b0000_0100;
569        /// Underlined text.
570        const UNDERLINE     = 0b0000_1000;
571        /// Blinking text.
572        const BLINK         = 0b0001_0000;
573        /// Reverse video (swap fg/bg).
574        const REVERSE       = 0b0010_0000;
575        /// Strikethrough text.
576        const STRIKETHROUGH = 0b0100_0000;
577        /// Hidden / invisible text.
578        const HIDDEN        = 0b1000_0000;
579    }
580}
581
582/// Packed cell attributes:
583/// - bits 31..24: `StyleFlags` (8 bits)
584/// - bits 23..0: `link_id` (24 bits)
585#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
586#[repr(transparent)]
587pub struct CellAttrs(u32);
588
589impl CellAttrs {
590    /// No attributes or link.
591    pub const NONE: Self = Self(0);
592
593    /// Sentinel value for "no hyperlink".
594    pub const LINK_ID_NONE: u32 = 0;
595    /// Maximum link ID (24-bit range).
596    pub const LINK_ID_MAX: u32 = 0x00FF_FFFE;
597
598    /// Create attributes from flags and a hyperlink ID.
599    #[inline]
600    pub fn new(flags: StyleFlags, link_id: u32) -> Self {
601        debug_assert!(
602            link_id <= Self::LINK_ID_MAX,
603            "link_id overflow: {link_id} (max={})",
604            Self::LINK_ID_MAX
605        );
606        Self(((flags.bits() as u32) << 24) | (link_id & 0x00FF_FFFF))
607    }
608
609    /// Extract the style flags.
610    #[inline]
611    pub fn flags(self) -> StyleFlags {
612        StyleFlags::from_bits_truncate((self.0 >> 24) as u8)
613    }
614
615    /// Extract the hyperlink ID.
616    #[inline]
617    pub fn link_id(self) -> u32 {
618        self.0 & 0x00FF_FFFF
619    }
620
621    /// Return a copy with different style flags.
622    #[inline]
623    #[must_use]
624    pub fn with_flags(self, flags: StyleFlags) -> Self {
625        Self((self.0 & 0x00FF_FFFF) | ((flags.bits() as u32) << 24))
626    }
627
628    /// Return a copy with a different hyperlink ID.
629    #[inline]
630    #[must_use]
631    pub fn with_link(self, link_id: u32) -> Self {
632        debug_assert!(
633            link_id <= Self::LINK_ID_MAX,
634            "link_id overflow: {link_id} (max={})",
635            Self::LINK_ID_MAX
636        );
637        Self((self.0 & 0xFF00_0000) | (link_id & 0x00FF_FFFF))
638    }
639
640    /// Check whether a specific flag is set.
641    #[inline]
642    pub fn has_flag(self, flag: StyleFlags) -> bool {
643        self.flags().contains(flag)
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use super::{Cell, CellAttrs, CellContent, GraphemeId, PackedRgba, StyleFlags};
650
651    fn reference_over(src: PackedRgba, dst: PackedRgba) -> PackedRgba {
652        let sr = src.r() as f64 / 255.0;
653        let sg = src.g() as f64 / 255.0;
654        let sb = src.b() as f64 / 255.0;
655        let sa = src.a() as f64 / 255.0;
656
657        let dr = dst.r() as f64 / 255.0;
658        let dg = dst.g() as f64 / 255.0;
659        let db = dst.b() as f64 / 255.0;
660        let da = dst.a() as f64 / 255.0;
661
662        let out_a = sa + da * (1.0 - sa);
663        if out_a <= 0.0 {
664            return PackedRgba::TRANSPARENT;
665        }
666
667        let out_r = (sr * sa + dr * da * (1.0 - sa)) / out_a;
668        let out_g = (sg * sa + dg * da * (1.0 - sa)) / out_a;
669        let out_b = (sb * sa + db * da * (1.0 - sa)) / out_a;
670
671        let to_u8 = |x: f64| -> u8 { (x * 255.0).round().clamp(0.0, 255.0) as u8 };
672        PackedRgba::rgba(to_u8(out_r), to_u8(out_g), to_u8(out_b), to_u8(out_a))
673    }
674
675    #[test]
676    fn packed_rgba_is_4_bytes() {
677        assert_eq!(core::mem::size_of::<PackedRgba>(), 4);
678    }
679
680    #[test]
681    fn rgb_sets_alpha_to_255() {
682        let c = PackedRgba::rgb(1, 2, 3);
683        assert_eq!(c.r(), 1);
684        assert_eq!(c.g(), 2);
685        assert_eq!(c.b(), 3);
686        assert_eq!(c.a(), 255);
687    }
688
689    #[test]
690    fn rgba_round_trips_components() {
691        let c = PackedRgba::rgba(10, 20, 30, 40);
692        assert_eq!(c.r(), 10);
693        assert_eq!(c.g(), 20);
694        assert_eq!(c.b(), 30);
695        assert_eq!(c.a(), 40);
696    }
697
698    #[test]
699    fn over_with_opaque_src_returns_src() {
700        let src = PackedRgba::rgba(1, 2, 3, 255);
701        let dst = PackedRgba::rgba(9, 8, 7, 200);
702        assert_eq!(src.over(dst), src);
703    }
704
705    #[test]
706    fn over_with_transparent_src_returns_dst() {
707        let src = PackedRgba::TRANSPARENT;
708        let dst = PackedRgba::rgba(9, 8, 7, 200);
709        assert_eq!(src.over(dst), dst);
710    }
711
712    #[test]
713    fn over_blends_correctly_for_half_alpha_over_opaque() {
714        // 50% red over opaque blue -> purple-ish, and resulting alpha stays opaque.
715        let src = PackedRgba::rgba(255, 0, 0, 128);
716        let dst = PackedRgba::rgba(0, 0, 255, 255);
717        assert_eq!(src.over(dst), PackedRgba::rgba(128, 0, 127, 255));
718    }
719
720    #[test]
721    fn over_matches_reference_for_partial_alpha_cases() {
722        let cases = [
723            (
724                PackedRgba::rgba(200, 10, 10, 64),
725                PackedRgba::rgba(10, 200, 10, 128),
726            ),
727            (
728                PackedRgba::rgba(1, 2, 3, 1),
729                PackedRgba::rgba(250, 251, 252, 254),
730            ),
731            (
732                PackedRgba::rgba(100, 0, 200, 200),
733                PackedRgba::rgba(0, 120, 30, 50),
734            ),
735        ];
736
737        for (src, dst) in cases {
738            assert_eq!(src.over(dst), reference_over(src, dst));
739        }
740    }
741
742    #[test]
743    fn with_opacity_scales_alpha() {
744        let c = PackedRgba::rgba(10, 20, 30, 255);
745        assert_eq!(c.with_opacity(0.5).a(), 128);
746        assert_eq!(c.with_opacity(-1.0).a(), 0);
747        assert_eq!(c.with_opacity(2.0).a(), 255);
748    }
749
750    #[test]
751    fn cell_attrs_is_4_bytes() {
752        assert_eq!(core::mem::size_of::<CellAttrs>(), 4);
753    }
754
755    #[test]
756    fn cell_attrs_none_has_no_flags_and_no_link() {
757        assert!(CellAttrs::NONE.flags().is_empty());
758        assert_eq!(CellAttrs::NONE.link_id(), 0);
759    }
760
761    #[test]
762    fn cell_attrs_new_stores_flags_and_link() {
763        let flags = StyleFlags::BOLD | StyleFlags::ITALIC;
764        let a = CellAttrs::new(flags, 42);
765        assert_eq!(a.flags(), flags);
766        assert_eq!(a.link_id(), 42);
767    }
768
769    #[test]
770    fn cell_attrs_with_flags_preserves_link_id() {
771        let a = CellAttrs::new(StyleFlags::BOLD, 123);
772        let b = a.with_flags(StyleFlags::UNDERLINE);
773        assert_eq!(b.flags(), StyleFlags::UNDERLINE);
774        assert_eq!(b.link_id(), 123);
775    }
776
777    #[test]
778    fn cell_attrs_with_link_preserves_flags() {
779        let a = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 1);
780        let b = a.with_link(999);
781        assert_eq!(b.flags(), StyleFlags::BOLD | StyleFlags::ITALIC);
782        assert_eq!(b.link_id(), 999);
783    }
784
785    #[test]
786    fn cell_attrs_flag_combinations_work() {
787        let flags = StyleFlags::BOLD | StyleFlags::ITALIC;
788        let a = CellAttrs::new(flags, 0);
789        assert!(a.has_flag(StyleFlags::BOLD));
790        assert!(a.has_flag(StyleFlags::ITALIC));
791        assert!(!a.has_flag(StyleFlags::UNDERLINE));
792    }
793
794    #[test]
795    fn cell_attrs_link_id_max_boundary() {
796        let a = CellAttrs::new(StyleFlags::empty(), CellAttrs::LINK_ID_MAX);
797        assert_eq!(a.link_id(), CellAttrs::LINK_ID_MAX);
798    }
799
800    // ====== GraphemeId tests ======
801
802    #[test]
803    fn grapheme_id_is_4_bytes() {
804        assert_eq!(core::mem::size_of::<GraphemeId>(), 4);
805    }
806
807    #[test]
808    fn grapheme_id_encoding_roundtrip() {
809        let id = GraphemeId::new(12345, 2);
810        assert_eq!(id.slot(), 12345);
811        assert_eq!(id.width(), 2);
812    }
813
814    #[test]
815    fn grapheme_id_max_values() {
816        let id = GraphemeId::new(GraphemeId::MAX_SLOT, GraphemeId::MAX_WIDTH);
817        assert_eq!(id.slot(), 0x00FF_FFFF);
818        assert_eq!(id.width(), 127);
819    }
820
821    #[test]
822    fn grapheme_id_zero_values() {
823        let id = GraphemeId::new(0, 0);
824        assert_eq!(id.slot(), 0);
825        assert_eq!(id.width(), 0);
826    }
827
828    #[test]
829    fn grapheme_id_raw_roundtrip() {
830        let id = GraphemeId::new(999, 5);
831        let raw = id.raw();
832        let restored = GraphemeId::from_raw(raw);
833        assert_eq!(restored.slot(), 999);
834        assert_eq!(restored.width(), 5);
835    }
836
837    // ====== CellContent tests ======
838
839    #[test]
840    fn cell_content_is_4_bytes() {
841        assert_eq!(core::mem::size_of::<CellContent>(), 4);
842    }
843
844    #[test]
845    fn cell_content_empty_properties() {
846        assert!(CellContent::EMPTY.is_empty());
847        assert!(!CellContent::EMPTY.is_continuation());
848        assert!(!CellContent::EMPTY.is_grapheme());
849        assert_eq!(CellContent::EMPTY.width_hint(), 0);
850    }
851
852    #[test]
853    fn cell_content_continuation_properties() {
854        assert!(CellContent::CONTINUATION.is_continuation());
855        assert!(!CellContent::CONTINUATION.is_empty());
856        assert!(!CellContent::CONTINUATION.is_grapheme());
857        assert_eq!(CellContent::CONTINUATION.width_hint(), 0);
858    }
859
860    #[test]
861    fn cell_content_from_char_ascii() {
862        let c = CellContent::from_char('A');
863        assert!(!c.is_grapheme());
864        assert!(!c.is_empty());
865        assert!(!c.is_continuation());
866        assert_eq!(c.as_char(), Some('A'));
867        assert_eq!(c.width_hint(), 1);
868    }
869
870    #[test]
871    fn cell_content_from_char_unicode() {
872        // BMP character
873        let c = CellContent::from_char('日');
874        assert_eq!(c.as_char(), Some('日'));
875        assert!(!c.is_grapheme());
876
877        // Supplementary plane character (emoji)
878        let c2 = CellContent::from_char('🎉');
879        assert_eq!(c2.as_char(), Some('🎉'));
880        assert!(!c2.is_grapheme());
881    }
882
883    #[test]
884    fn cell_content_from_grapheme() {
885        let id = GraphemeId::new(42, 2);
886        let c = CellContent::from_grapheme(id);
887
888        assert!(c.is_grapheme());
889        assert!(!c.is_empty());
890        assert!(!c.is_continuation());
891        assert_eq!(c.grapheme_id(), Some(id));
892        assert_eq!(c.as_char(), None);
893        assert_eq!(c.width_hint(), 2);
894    }
895
896    #[test]
897    fn cell_content_width_for_chars() {
898        let ascii = CellContent::from_char('A');
899        assert_eq!(ascii.width(), 1);
900
901        let wide = CellContent::from_char('日');
902        assert_eq!(wide.width(), 2);
903
904        let emoji = CellContent::from_char('🎉');
905        assert_eq!(emoji.width(), 2);
906
907        // Unicode East Asian Width properties:
908        // - '⚡' (U+26A1) is Wide → always width 2
909        // - '⚙' (U+2699) is Neutral → 1 (non-CJK) or 2 (CJK)
910        // - '❤' (U+2764) is Neutral → 1 (non-CJK) or 2 (CJK)
911        let bolt = CellContent::from_char('⚡');
912        assert_eq!(bolt.width(), 2, "bolt is Wide, always width 2");
913
914        // Neutral-width characters: width depends on CJK mode
915        let gear = CellContent::from_char('⚙');
916        let heart = CellContent::from_char('❤');
917        assert!(
918            [1, 2].contains(&gear.width()),
919            "gear should be 1 (non-CJK) or 2 (CJK), got {}",
920            gear.width()
921        );
922        assert_eq!(
923            gear.width(),
924            heart.width(),
925            "gear and heart should have same width (both Neutral)"
926        );
927    }
928
929    #[test]
930    fn cell_content_width_for_grapheme() {
931        let id = GraphemeId::new(7, 3);
932        let c = CellContent::from_grapheme(id);
933        assert_eq!(c.width(), 3);
934    }
935
936    #[test]
937    fn cell_content_width_empty_is_zero() {
938        assert_eq!(CellContent::EMPTY.width(), 0);
939        assert_eq!(CellContent::CONTINUATION.width(), 0);
940    }
941
942    #[test]
943    fn cell_content_grapheme_discriminator_bit() {
944        // Chars should have bit 31 = 0
945        let char_content = CellContent::from_char('X');
946        assert_eq!(char_content.raw() & 0x8000_0000, 0);
947
948        // Graphemes should have bit 31 = 1
949        let grapheme_content = CellContent::from_grapheme(GraphemeId::new(1, 1));
950        assert_ne!(grapheme_content.raw() & 0x8000_0000, 0);
951    }
952
953    // ====== Cell tests ======
954
955    #[test]
956    fn cell_is_16_bytes() {
957        assert_eq!(core::mem::size_of::<Cell>(), 16);
958    }
959
960    #[test]
961    fn cell_alignment_is_16() {
962        assert_eq!(core::mem::align_of::<Cell>(), 16);
963    }
964
965    #[test]
966    fn cell_default_properties() {
967        let cell = Cell::default();
968        assert!(cell.is_empty());
969        assert!(!cell.is_continuation());
970        assert_eq!(cell.fg, PackedRgba::WHITE);
971        assert_eq!(cell.bg, PackedRgba::TRANSPARENT);
972        assert_eq!(cell.attrs, CellAttrs::NONE);
973    }
974
975    #[test]
976    fn cell_continuation_constant() {
977        assert!(Cell::CONTINUATION.is_continuation());
978        assert!(!Cell::CONTINUATION.is_empty());
979    }
980
981    #[test]
982    fn cell_from_char() {
983        let cell = Cell::from_char('X');
984        assert_eq!(cell.content.as_char(), Some('X'));
985        assert_eq!(cell.fg, PackedRgba::WHITE);
986        assert_eq!(cell.bg, PackedRgba::TRANSPARENT);
987    }
988
989    #[test]
990    fn cell_builder_methods() {
991        let cell = Cell::from_char('A')
992            .with_fg(PackedRgba::rgb(255, 0, 0))
993            .with_bg(PackedRgba::rgb(0, 0, 255))
994            .with_attrs(CellAttrs::new(StyleFlags::BOLD, 0));
995
996        assert_eq!(cell.content.as_char(), Some('A'));
997        assert_eq!(cell.fg, PackedRgba::rgb(255, 0, 0));
998        assert_eq!(cell.bg, PackedRgba::rgb(0, 0, 255));
999        assert!(cell.attrs.has_flag(StyleFlags::BOLD));
1000    }
1001
1002    #[test]
1003    fn cell_bits_eq_same_cells() {
1004        let cell1 = Cell::from_char('X').with_fg(PackedRgba::rgb(1, 2, 3));
1005        let cell2 = Cell::from_char('X').with_fg(PackedRgba::rgb(1, 2, 3));
1006        assert!(cell1.bits_eq(&cell2));
1007    }
1008
1009    #[test]
1010    fn cell_bits_eq_different_cells() {
1011        let cell1 = Cell::from_char('X');
1012        let cell2 = Cell::from_char('Y');
1013        assert!(!cell1.bits_eq(&cell2));
1014
1015        let cell3 = Cell::from_char('X').with_fg(PackedRgba::rgb(1, 2, 3));
1016        assert!(!cell1.bits_eq(&cell3));
1017    }
1018
1019    #[test]
1020    fn cell_width_hint() {
1021        let empty = Cell::default();
1022        assert_eq!(empty.width_hint(), 0);
1023
1024        let cont = Cell::CONTINUATION;
1025        assert_eq!(cont.width_hint(), 0);
1026
1027        let ascii = Cell::from_char('A');
1028        assert_eq!(ascii.width_hint(), 1);
1029    }
1030
1031    // Property tests moved to top-level `cell_proptests` module for edition 2024 compat.
1032
1033    // ====== PackedRgba extended coverage ======
1034
1035    #[test]
1036    fn packed_rgba_named_constants() {
1037        assert_eq!(PackedRgba::TRANSPARENT, PackedRgba(0));
1038        assert_eq!(PackedRgba::TRANSPARENT.a(), 0);
1039
1040        assert_eq!(PackedRgba::BLACK.r(), 0);
1041        assert_eq!(PackedRgba::BLACK.g(), 0);
1042        assert_eq!(PackedRgba::BLACK.b(), 0);
1043        assert_eq!(PackedRgba::BLACK.a(), 255);
1044
1045        assert_eq!(PackedRgba::WHITE.r(), 255);
1046        assert_eq!(PackedRgba::WHITE.g(), 255);
1047        assert_eq!(PackedRgba::WHITE.b(), 255);
1048        assert_eq!(PackedRgba::WHITE.a(), 255);
1049
1050        assert_eq!(PackedRgba::RED, PackedRgba::rgb(255, 0, 0));
1051        assert_eq!(PackedRgba::GREEN, PackedRgba::rgb(0, 255, 0));
1052        assert_eq!(PackedRgba::BLUE, PackedRgba::rgb(0, 0, 255));
1053    }
1054
1055    #[test]
1056    fn packed_rgba_default_is_transparent() {
1057        assert_eq!(PackedRgba::default(), PackedRgba::TRANSPARENT);
1058    }
1059
1060    #[test]
1061    fn over_both_transparent_returns_transparent() {
1062        // Exercises numer_a == 0 branch (line 508)
1063        let result = PackedRgba::TRANSPARENT.over(PackedRgba::TRANSPARENT);
1064        assert_eq!(result, PackedRgba::TRANSPARENT);
1065    }
1066
1067    #[test]
1068    fn over_partial_alpha_over_transparent_dst() {
1069        // d_a == 0 path: src partial alpha over fully transparent
1070        let src = PackedRgba::rgba(200, 100, 50, 128);
1071        let result = src.over(PackedRgba::TRANSPARENT);
1072        // Output alpha = src_alpha (dst contributes nothing)
1073        assert_eq!(result.a(), 128);
1074        // Colors should be src colors since dst has no contribution
1075        assert_eq!(result.r(), 200);
1076        assert_eq!(result.g(), 100);
1077        assert_eq!(result.b(), 50);
1078    }
1079
1080    #[test]
1081    fn over_very_low_alpha() {
1082        // Near-transparent source (alpha=1) over opaque destination
1083        let src = PackedRgba::rgba(255, 0, 0, 1);
1084        let dst = PackedRgba::rgba(0, 0, 255, 255);
1085        let result = src.over(dst);
1086        // Result should be very close to dst
1087        assert_eq!(result.a(), 255);
1088        assert!(result.b() > 250, "b={} should be near 255", result.b());
1089        assert!(result.r() < 5, "r={} should be near 0", result.r());
1090    }
1091
1092    #[test]
1093    fn with_opacity_exact_zero() {
1094        let c = PackedRgba::rgba(10, 20, 30, 200);
1095        let result = c.with_opacity(0.0);
1096        assert_eq!(result.a(), 0);
1097        assert_eq!(result.r(), 10); // RGB preserved
1098        assert_eq!(result.g(), 20);
1099        assert_eq!(result.b(), 30);
1100    }
1101
1102    #[test]
1103    fn with_opacity_exact_one() {
1104        let c = PackedRgba::rgba(10, 20, 30, 200);
1105        let result = c.with_opacity(1.0);
1106        assert_eq!(result.a(), 200); // Alpha unchanged
1107        assert_eq!(result.r(), 10);
1108    }
1109
1110    #[test]
1111    fn with_opacity_preserves_rgb() {
1112        let c = PackedRgba::rgba(42, 84, 168, 255);
1113        let result = c.with_opacity(0.25);
1114        assert_eq!(result.r(), 42);
1115        assert_eq!(result.g(), 84);
1116        assert_eq!(result.b(), 168);
1117        assert_eq!(result.a(), 64); // 255 * 0.25 = 63.75 → 64
1118    }
1119
1120    // ====== CellContent extended coverage ======
1121
1122    #[test]
1123    fn cell_content_as_char_none_for_empty() {
1124        assert_eq!(CellContent::EMPTY.as_char(), None);
1125    }
1126
1127    #[test]
1128    fn cell_content_as_char_none_for_continuation() {
1129        assert_eq!(CellContent::CONTINUATION.as_char(), None);
1130    }
1131
1132    #[test]
1133    fn cell_content_as_char_none_for_grapheme() {
1134        let id = GraphemeId::new(1, 2);
1135        let c = CellContent::from_grapheme(id);
1136        assert_eq!(c.as_char(), None);
1137    }
1138
1139    #[test]
1140    fn cell_content_grapheme_id_none_for_char() {
1141        let c = CellContent::from_char('A');
1142        assert_eq!(c.grapheme_id(), None);
1143    }
1144
1145    #[test]
1146    fn cell_content_grapheme_id_none_for_empty() {
1147        assert_eq!(CellContent::EMPTY.grapheme_id(), None);
1148    }
1149
1150    #[test]
1151    fn cell_content_width_control_chars() {
1152        // Control characters have width 0, except tab/newline/CR which are 1 cell
1153        // Note: NUL (0x00) is CellContent::EMPTY, so test with other controls
1154        let tab = CellContent::from_char('\t');
1155        assert_eq!(tab.width(), 1);
1156
1157        let bel = CellContent::from_char('\x07');
1158        assert_eq!(bel.width(), 0);
1159    }
1160
1161    #[test]
1162    fn cell_content_width_hint_always_1_for_chars() {
1163        // width_hint is the fast path that always returns 1 for non-special chars
1164        let wide = CellContent::from_char('日');
1165        assert_eq!(wide.width_hint(), 1); // fast path says 1
1166        assert_eq!(wide.width(), 2); // accurate path says 2
1167    }
1168
1169    #[test]
1170    fn cell_content_default_is_empty() {
1171        assert_eq!(CellContent::default(), CellContent::EMPTY);
1172    }
1173
1174    #[test]
1175    fn cell_content_debug_empty() {
1176        let s = format!("{:?}", CellContent::EMPTY);
1177        assert_eq!(s, "CellContent::EMPTY");
1178    }
1179
1180    #[test]
1181    fn cell_content_debug_continuation() {
1182        let s = format!("{:?}", CellContent::CONTINUATION);
1183        assert_eq!(s, "CellContent::CONTINUATION");
1184    }
1185
1186    #[test]
1187    fn cell_content_debug_char() {
1188        let s = format!("{:?}", CellContent::from_char('X'));
1189        assert!(s.starts_with("CellContent::Char("), "got: {s}");
1190    }
1191
1192    #[test]
1193    fn cell_content_debug_grapheme() {
1194        let id = GraphemeId::new(1, 2);
1195        let s = format!("{:?}", CellContent::from_grapheme(id));
1196        assert!(s.starts_with("CellContent::Grapheme("), "got: {s}");
1197    }
1198
1199    #[test]
1200    fn cell_content_raw_value() {
1201        let c = CellContent::from_char('A');
1202        assert_eq!(c.raw(), 'A' as u32);
1203
1204        let g = CellContent::from_grapheme(GraphemeId::new(5, 2));
1205        assert_ne!(g.raw() & 0x8000_0000, 0);
1206    }
1207
1208    // ====== CellAttrs extended coverage ======
1209
1210    #[test]
1211    fn cell_attrs_default_is_none() {
1212        assert_eq!(CellAttrs::default(), CellAttrs::NONE);
1213    }
1214
1215    #[test]
1216    fn cell_attrs_each_flag_isolated() {
1217        let all_flags = [
1218            StyleFlags::BOLD,
1219            StyleFlags::DIM,
1220            StyleFlags::ITALIC,
1221            StyleFlags::UNDERLINE,
1222            StyleFlags::BLINK,
1223            StyleFlags::REVERSE,
1224            StyleFlags::STRIKETHROUGH,
1225            StyleFlags::HIDDEN,
1226        ];
1227
1228        for &flag in &all_flags {
1229            let a = CellAttrs::new(flag, 0);
1230            assert!(a.has_flag(flag), "flag {:?} should be set", flag);
1231
1232            // Verify no other flags are set
1233            for &other in &all_flags {
1234                if other != flag {
1235                    assert!(
1236                        !a.has_flag(other),
1237                        "flag {:?} should NOT be set when only {:?} is",
1238                        other,
1239                        flag
1240                    );
1241                }
1242            }
1243        }
1244    }
1245
1246    #[test]
1247    fn cell_attrs_all_flags_combined() {
1248        let all = StyleFlags::BOLD
1249            | StyleFlags::DIM
1250            | StyleFlags::ITALIC
1251            | StyleFlags::UNDERLINE
1252            | StyleFlags::BLINK
1253            | StyleFlags::REVERSE
1254            | StyleFlags::STRIKETHROUGH
1255            | StyleFlags::HIDDEN;
1256        let a = CellAttrs::new(all, 42);
1257        assert_eq!(a.flags(), all);
1258        assert!(a.has_flag(StyleFlags::BOLD));
1259        assert!(a.has_flag(StyleFlags::HIDDEN));
1260        assert_eq!(a.link_id(), 42);
1261    }
1262
1263    #[test]
1264    fn cell_attrs_link_id_zero() {
1265        let a = CellAttrs::new(StyleFlags::BOLD, CellAttrs::LINK_ID_NONE);
1266        assert_eq!(a.link_id(), 0);
1267        assert!(a.has_flag(StyleFlags::BOLD));
1268    }
1269
1270    #[test]
1271    fn cell_attrs_with_link_to_none() {
1272        let a = CellAttrs::new(StyleFlags::ITALIC, 500);
1273        let b = a.with_link(CellAttrs::LINK_ID_NONE);
1274        assert_eq!(b.link_id(), 0);
1275        assert!(b.has_flag(StyleFlags::ITALIC));
1276    }
1277
1278    #[test]
1279    fn cell_attrs_with_flags_to_empty() {
1280        let a = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 123);
1281        let b = a.with_flags(StyleFlags::empty());
1282        assert!(b.flags().is_empty());
1283        assert_eq!(b.link_id(), 123);
1284    }
1285
1286    // ====== Cell extended coverage ======
1287
1288    #[test]
1289    fn cell_bits_eq_detects_bg_difference() {
1290        let cell1 = Cell::from_char('X');
1291        let cell2 = Cell::from_char('X').with_bg(PackedRgba::RED);
1292        assert!(!cell1.bits_eq(&cell2));
1293    }
1294
1295    #[test]
1296    fn cell_bits_eq_detects_attrs_difference() {
1297        let cell1 = Cell::from_char('X');
1298        let cell2 = Cell::from_char('X').with_attrs(CellAttrs::new(StyleFlags::BOLD, 0));
1299        assert!(!cell1.bits_eq(&cell2));
1300    }
1301
1302    #[test]
1303    fn cell_with_char_preserves_colors_and_attrs() {
1304        let cell = Cell::from_char('A')
1305            .with_fg(PackedRgba::RED)
1306            .with_bg(PackedRgba::BLUE)
1307            .with_attrs(CellAttrs::new(StyleFlags::BOLD, 42));
1308
1309        let updated = cell.with_char('Z');
1310        assert_eq!(updated.content.as_char(), Some('Z'));
1311        assert_eq!(updated.fg, PackedRgba::RED);
1312        assert_eq!(updated.bg, PackedRgba::BLUE);
1313        assert!(updated.attrs.has_flag(StyleFlags::BOLD));
1314        assert_eq!(updated.attrs.link_id(), 42);
1315    }
1316
1317    #[test]
1318    fn cell_new_vs_from_char() {
1319        let a = Cell::new(CellContent::from_char('A'));
1320        let b = Cell::from_char('A');
1321        assert!(a.bits_eq(&b));
1322    }
1323
1324    #[test]
1325    fn cell_continuation_has_transparent_colors() {
1326        assert_eq!(Cell::CONTINUATION.fg, PackedRgba::TRANSPARENT);
1327        assert_eq!(Cell::CONTINUATION.bg, PackedRgba::TRANSPARENT);
1328        assert_eq!(Cell::CONTINUATION.attrs, CellAttrs::NONE);
1329    }
1330
1331    #[test]
1332    fn cell_debug_format() {
1333        let cell = Cell::from_char('A');
1334        let s = format!("{:?}", cell);
1335        assert!(s.contains("Cell"), "got: {s}");
1336        assert!(s.contains("content"), "got: {s}");
1337        assert!(s.contains("fg"), "got: {s}");
1338        assert!(s.contains("bg"), "got: {s}");
1339        assert!(s.contains("attrs"), "got: {s}");
1340    }
1341
1342    #[test]
1343    fn cell_is_empty_for_various() {
1344        assert!(Cell::default().is_empty());
1345        assert!(!Cell::from_char('A').is_empty());
1346        assert!(!Cell::CONTINUATION.is_empty());
1347    }
1348
1349    #[test]
1350    fn cell_is_continuation_for_various() {
1351        assert!(!Cell::default().is_continuation());
1352        assert!(!Cell::from_char('A').is_continuation());
1353        assert!(Cell::CONTINUATION.is_continuation());
1354    }
1355
1356    #[test]
1357    fn cell_width_hint_for_grapheme() {
1358        let id = GraphemeId::new(100, 3);
1359        let cell = Cell::new(CellContent::from_grapheme(id));
1360        assert_eq!(cell.width_hint(), 3);
1361    }
1362
1363    // ====== GraphemeId extended coverage ======
1364
1365    #[test]
1366    fn grapheme_id_default() {
1367        let id = GraphemeId::default();
1368        assert_eq!(id.slot(), 0);
1369        assert_eq!(id.width(), 0);
1370    }
1371
1372    #[test]
1373    fn grapheme_id_debug_format() {
1374        let id = GraphemeId::new(42, 2);
1375        let s = format!("{:?}", id);
1376        assert!(s.contains("GraphemeId"), "got: {s}");
1377        assert!(s.contains("42"), "got: {s}");
1378        assert!(s.contains("2"), "got: {s}");
1379    }
1380
1381    #[test]
1382    fn grapheme_id_width_isolated_from_slot() {
1383        // Verify slot bits don't leak into width field
1384        let id = GraphemeId::new(0x00FF_FFFF, 0);
1385        assert_eq!(id.width(), 0);
1386        assert_eq!(id.slot(), 0x00FF_FFFF);
1387
1388        let id2 = GraphemeId::new(0, 127);
1389        assert_eq!(id2.slot(), 0);
1390        assert_eq!(id2.width(), 127);
1391    }
1392
1393    // ====== StyleFlags coverage ======
1394
1395    #[test]
1396    fn style_flags_empty_has_no_bits() {
1397        assert!(StyleFlags::empty().is_empty());
1398        assert_eq!(StyleFlags::empty().bits(), 0);
1399    }
1400
1401    #[test]
1402    fn style_flags_all_has_all_bits() {
1403        let all = StyleFlags::all();
1404        assert!(all.contains(StyleFlags::BOLD));
1405        assert!(all.contains(StyleFlags::DIM));
1406        assert!(all.contains(StyleFlags::ITALIC));
1407        assert!(all.contains(StyleFlags::UNDERLINE));
1408        assert!(all.contains(StyleFlags::BLINK));
1409        assert!(all.contains(StyleFlags::REVERSE));
1410        assert!(all.contains(StyleFlags::STRIKETHROUGH));
1411        assert!(all.contains(StyleFlags::HIDDEN));
1412    }
1413
1414    #[test]
1415    fn style_flags_union_and_intersection() {
1416        let a = StyleFlags::BOLD | StyleFlags::ITALIC;
1417        let b = StyleFlags::ITALIC | StyleFlags::UNDERLINE;
1418        assert_eq!(
1419            a | b,
1420            StyleFlags::BOLD | StyleFlags::ITALIC | StyleFlags::UNDERLINE
1421        );
1422        assert_eq!(a & b, StyleFlags::ITALIC);
1423    }
1424
1425    #[test]
1426    fn style_flags_from_bits_truncate() {
1427        // 0xFF should give all flags
1428        let all = StyleFlags::from_bits_truncate(0xFF);
1429        assert_eq!(all, StyleFlags::all());
1430
1431        // 0x00 should give empty
1432        let none = StyleFlags::from_bits_truncate(0x00);
1433        assert!(none.is_empty());
1434    }
1435
1436    // ====== Edge-case tests (bd-35ddr) ======
1437
1438    // -- PackedRgba edge cases --
1439
1440    #[test]
1441    fn over_not_commutative() {
1442        let red_half = PackedRgba::rgba(255, 0, 0, 128);
1443        let blue_half = PackedRgba::rgba(0, 0, 255, 128);
1444        let a_over_b = red_half.over(blue_half);
1445        let b_over_a = blue_half.over(red_half);
1446        // Porter-Duff SourceOver is NOT commutative
1447        assert_ne!(a_over_b, b_over_a);
1448    }
1449
1450    #[test]
1451    fn over_opaque_self_compositing_is_idempotent() {
1452        let c = PackedRgba::rgba(42, 84, 168, 255);
1453        assert_eq!(c.over(c), c);
1454    }
1455
1456    #[test]
1457    fn over_near_opaque_src_alpha_254() {
1458        // Non-trivial branch: alpha=254 takes the blending path, not the early-out
1459        let src = PackedRgba::rgba(255, 0, 0, 254);
1460        let dst = PackedRgba::rgba(0, 0, 255, 255);
1461        let result = src.over(dst);
1462        assert_eq!(result.a(), 255);
1463        // Red should dominate but not quite 255
1464        assert!(result.r() >= 253, "r={}", result.r());
1465        assert!(result.b() <= 2, "b={}", result.b());
1466    }
1467
1468    #[test]
1469    fn over_both_partial_alpha_symmetric_colors() {
1470        // 128-alpha red over 128-alpha red — output alpha should be ~192
1471        let c = PackedRgba::rgba(200, 100, 50, 128);
1472        let result = c.over(c);
1473        let ref_result = reference_over(c, c);
1474        assert_eq!(result, ref_result);
1475        // Alpha: 128 + 128*(1-128/255) = 128 + 128*0.498 ≈ 192
1476        assert!(result.a() >= 190 && result.a() <= 194, "a={}", result.a());
1477    }
1478
1479    #[test]
1480    fn over_both_alpha_1_minimal() {
1481        let src = PackedRgba::rgba(255, 255, 255, 1);
1482        let dst = PackedRgba::rgba(0, 0, 0, 1);
1483        let result = src.over(dst);
1484        let ref_result = reference_over(src, dst);
1485        assert_eq!(result, ref_result);
1486        // Output alpha should be ~2 (very transparent)
1487        assert!(result.a() <= 3, "a={}", result.a());
1488    }
1489
1490    #[test]
1491    fn over_white_alpha_0_over_opaque_is_dst() {
1492        // White with alpha=0 should be treated as transparent
1493        let src = PackedRgba::rgba(255, 255, 255, 0);
1494        let dst = PackedRgba::rgba(100, 50, 25, 255);
1495        assert_eq!(src.over(dst), dst);
1496    }
1497
1498    #[test]
1499    fn with_opacity_nan_clamps_to_zero() {
1500        let c = PackedRgba::rgba(10, 20, 30, 200);
1501        let result = c.with_opacity(f32::NAN);
1502        // NaN.clamp(0.0, 1.0) returns... let's verify behavior
1503        // In Rust, NaN.clamp returns NaN, and NaN * 200 = NaN, then NaN.round() = NaN
1504        // NaN as u8 = 0 in Rust
1505        assert_eq!(result.r(), 10);
1506        assert_eq!(result.g(), 20);
1507        assert_eq!(result.b(), 30);
1508    }
1509
1510    #[test]
1511    fn with_opacity_negative_infinity_clamps_to_zero() {
1512        let c = PackedRgba::rgba(10, 20, 30, 200);
1513        let result = c.with_opacity(f32::NEG_INFINITY);
1514        assert_eq!(result.a(), 0);
1515    }
1516
1517    #[test]
1518    fn with_opacity_positive_infinity_clamps_to_original() {
1519        let c = PackedRgba::rgba(10, 20, 30, 200);
1520        let result = c.with_opacity(f32::INFINITY);
1521        assert_eq!(result.a(), 200);
1522    }
1523
1524    #[test]
1525    fn with_opacity_on_transparent_stays_transparent() {
1526        let c = PackedRgba::TRANSPARENT;
1527        assert_eq!(c.with_opacity(0.5).a(), 0);
1528        assert_eq!(c.with_opacity(1.0).a(), 0);
1529    }
1530
1531    #[test]
1532    fn packed_rgba_extreme_channel_values() {
1533        let all_max = PackedRgba::rgba(255, 255, 255, 255);
1534        assert_eq!(all_max.r(), 255);
1535        assert_eq!(all_max.g(), 255);
1536        assert_eq!(all_max.b(), 255);
1537        assert_eq!(all_max.a(), 255);
1538
1539        let all_zero = PackedRgba::rgba(0, 0, 0, 0);
1540        assert_eq!(all_zero, PackedRgba::TRANSPARENT);
1541    }
1542
1543    #[test]
1544    fn packed_rgba_hash_differs_for_different_values() {
1545        use std::collections::HashSet;
1546        let mut set = HashSet::new();
1547        set.insert(PackedRgba::RED);
1548        set.insert(PackedRgba::GREEN);
1549        set.insert(PackedRgba::BLUE);
1550        set.insert(PackedRgba::RED); // duplicate
1551        assert_eq!(set.len(), 3);
1552    }
1553
1554    #[test]
1555    fn packed_rgba_channel_isolation() {
1556        // Changing one channel should not affect others
1557        let base = PackedRgba::rgba(10, 20, 30, 40);
1558        let different_r = PackedRgba::rgba(99, 20, 30, 40);
1559        assert_ne!(base, different_r);
1560        assert_eq!(base.g(), different_r.g());
1561        assert_eq!(base.b(), different_r.b());
1562        assert_eq!(base.a(), different_r.a());
1563    }
1564
1565    // -- CellContent edge cases --
1566
1567    #[test]
1568    fn cell_content_nul_char_equals_empty() {
1569        // '\0' as u32 == 0, same as EMPTY.raw()
1570        let nul = CellContent::from_char('\0');
1571        assert_eq!(nul.raw(), CellContent::EMPTY.raw());
1572        assert!(nul.is_empty());
1573        assert_eq!(nul.as_char(), None); // Empty is filtered out by as_char
1574    }
1575
1576    #[test]
1577    fn cell_content_max_unicode_codepoint() {
1578        let max = CellContent::from_char('\u{10FFFF}');
1579        assert_eq!(max.as_char(), Some('\u{10FFFF}'));
1580        assert!(!max.is_grapheme());
1581        // U+10FFFF is a noncharacter, should have width 1 (fast path)
1582        assert_eq!(max.width_hint(), 1);
1583    }
1584
1585    #[test]
1586    fn cell_content_bmp_boundary_chars() {
1587        // Last BMP char before surrogates (U+D7FF)
1588        let last_before_surrogates = CellContent::from_char('\u{D7FF}');
1589        assert_eq!(last_before_surrogates.as_char(), Some('\u{D7FF}'));
1590
1591        // First char after surrogates (U+E000)
1592        let first_after_surrogates = CellContent::from_char('\u{E000}');
1593        assert_eq!(first_after_surrogates.as_char(), Some('\u{E000}'));
1594
1595        // First supplementary char (U+10000)
1596        let supplementary = CellContent::from_char('\u{10000}');
1597        assert_eq!(supplementary.as_char(), Some('\u{10000}'));
1598        assert!(!supplementary.is_grapheme()); // bit 31 is NOT set (U+10000 = 0x10000)
1599    }
1600
1601    #[test]
1602    fn cell_content_grapheme_with_zero_width() {
1603        let id = GraphemeId::new(42, 0);
1604        let c = CellContent::from_grapheme(id);
1605        assert_eq!(c.width_hint(), 0);
1606        assert_eq!(c.width(), 0);
1607        assert!(c.is_grapheme());
1608    }
1609
1610    #[test]
1611    fn cell_content_grapheme_with_max_width() {
1612        let id = GraphemeId::new(1, GraphemeId::MAX_WIDTH);
1613        let c = CellContent::from_grapheme(id);
1614        assert_eq!(c.width_hint(), 127);
1615        assert_eq!(c.width(), 127);
1616    }
1617
1618    #[test]
1619    fn cell_content_continuation_value_is_max_i31() {
1620        // CONTINUATION = 0x7FFF_FFFF — max value with bit 31 clear
1621        assert_eq!(CellContent::CONTINUATION.raw(), 0x7FFF_FFFF);
1622        assert!(!CellContent::CONTINUATION.is_grapheme()); // bit 31 = 0
1623        assert!(CellContent::CONTINUATION.is_continuation());
1624    }
1625
1626    #[test]
1627    fn cell_content_empty_and_continuation_are_distinct() {
1628        assert_ne!(CellContent::EMPTY, CellContent::CONTINUATION);
1629        assert!(CellContent::EMPTY.is_empty());
1630        assert!(!CellContent::EMPTY.is_continuation());
1631        assert!(!CellContent::CONTINUATION.is_empty());
1632        assert!(CellContent::CONTINUATION.is_continuation());
1633    }
1634
1635    #[test]
1636    fn cell_content_grapheme_id_strips_high_bit() {
1637        let id = GraphemeId::new(0x00FF_FFFF, 127);
1638        let c = CellContent::from_grapheme(id);
1639        let extracted = c.grapheme_id().unwrap();
1640        assert_eq!(extracted.slot(), id.slot());
1641        assert_eq!(extracted.width(), id.width());
1642    }
1643
1644    // -- GraphemeId edge cases --
1645
1646    #[test]
1647    fn grapheme_id_slot_one_width_one() {
1648        let id = GraphemeId::new(1, 1);
1649        assert_eq!(id.slot(), 1);
1650        assert_eq!(id.width(), 1);
1651    }
1652
1653    #[test]
1654    fn grapheme_id_hash_eq_consistency() {
1655        use std::collections::HashSet;
1656        let a = GraphemeId::new(42, 2);
1657        let b = GraphemeId::new(42, 2);
1658        let c = GraphemeId::new(42, 3);
1659        assert_eq!(a, b);
1660        assert_ne!(a, c);
1661        let mut set = HashSet::new();
1662        set.insert(a);
1663        assert!(set.contains(&b));
1664        assert!(!set.contains(&c));
1665    }
1666
1667    #[test]
1668    fn grapheme_id_adjacent_slots_differ() {
1669        let a = GraphemeId::new(0, 1);
1670        let b = GraphemeId::new(1, 1);
1671        assert_ne!(a, b);
1672        assert_ne!(a.slot(), b.slot());
1673        assert_eq!(a.width(), b.width());
1674    }
1675
1676    // -- CellAttrs edge cases --
1677
1678    #[test]
1679    fn cell_attrs_link_id_masks_overflow() {
1680        // In release mode (no debug_assert), overflow is masked to 24 bits
1681        let a = CellAttrs::new(StyleFlags::empty(), 0x00FF_FFFE);
1682        assert_eq!(a.link_id(), 0x00FF_FFFE);
1683    }
1684
1685    #[test]
1686    fn cell_attrs_chained_mutations() {
1687        let a = CellAttrs::new(StyleFlags::BOLD, 100)
1688            .with_flags(StyleFlags::ITALIC)
1689            .with_link(200)
1690            .with_flags(StyleFlags::UNDERLINE | StyleFlags::DIM)
1691            .with_link(300);
1692        assert_eq!(a.flags(), StyleFlags::UNDERLINE | StyleFlags::DIM);
1693        assert_eq!(a.link_id(), 300);
1694    }
1695
1696    #[test]
1697    fn cell_attrs_all_flags_max_link() {
1698        let all_flags = StyleFlags::all();
1699        let a = CellAttrs::new(all_flags, CellAttrs::LINK_ID_MAX);
1700        assert_eq!(a.flags(), all_flags);
1701        assert_eq!(a.link_id(), CellAttrs::LINK_ID_MAX);
1702        // Verify no bit overlap
1703        assert_eq!(a.flags().bits(), 0xFF);
1704        assert_eq!(a.link_id(), 0x00FF_FFFE);
1705    }
1706
1707    #[test]
1708    fn cell_attrs_link_id_none_is_zero() {
1709        assert_eq!(CellAttrs::LINK_ID_NONE, 0);
1710    }
1711
1712    // -- Cell edge cases --
1713
1714    #[test]
1715    fn cell_eq_matches_bits_eq() {
1716        let pairs = [
1717            (Cell::default(), Cell::default()),
1718            (Cell::from_char('A'), Cell::from_char('A')),
1719            (Cell::from_char('A'), Cell::from_char('B')),
1720            (Cell::CONTINUATION, Cell::CONTINUATION),
1721            (
1722                Cell::from_char('X').with_fg(PackedRgba::RED),
1723                Cell::from_char('X').with_fg(PackedRgba::BLUE),
1724            ),
1725        ];
1726        for (a, b) in &pairs {
1727            assert_eq!(
1728                a == b,
1729                a.bits_eq(b),
1730                "PartialEq and bits_eq disagree for {:?} vs {:?}",
1731                a,
1732                b
1733            );
1734        }
1735    }
1736
1737    #[test]
1738    fn cell_from_grapheme_content() {
1739        let id = GraphemeId::new(42, 2);
1740        let cell = Cell::new(CellContent::from_grapheme(id));
1741        assert!(cell.content.is_grapheme());
1742        assert_eq!(cell.width_hint(), 2);
1743        assert!(!cell.is_empty());
1744        assert!(!cell.is_continuation());
1745    }
1746
1747    #[test]
1748    fn cell_with_char_on_continuation() {
1749        let cell = Cell::CONTINUATION.with_char('A');
1750        assert_eq!(cell.content.as_char(), Some('A'));
1751        assert!(!cell.is_continuation());
1752        // Colors from CONTINUATION are preserved
1753        assert_eq!(cell.fg, PackedRgba::TRANSPARENT);
1754        assert_eq!(cell.bg, PackedRgba::TRANSPARENT);
1755    }
1756
1757    #[test]
1758    fn cell_default_bits_eq_self() {
1759        let cell = Cell::default();
1760        assert!(cell.bits_eq(&cell));
1761    }
1762
1763    #[test]
1764    fn cell_new_empty_equals_default() {
1765        let a = Cell::new(CellContent::EMPTY);
1766        let b = Cell::default();
1767        assert!(a.bits_eq(&b));
1768    }
1769
1770    #[test]
1771    fn cell_all_builder_methods_chain() {
1772        let cell = Cell::default()
1773            .with_char('Z')
1774            .with_fg(PackedRgba::rgba(1, 2, 3, 4))
1775            .with_bg(PackedRgba::rgba(5, 6, 7, 8))
1776            .with_attrs(CellAttrs::new(
1777                StyleFlags::BOLD | StyleFlags::STRIKETHROUGH,
1778                999,
1779            ));
1780        assert_eq!(cell.content.as_char(), Some('Z'));
1781        assert_eq!(cell.fg.r(), 1);
1782        assert_eq!(cell.bg.a(), 8);
1783        assert!(cell.attrs.has_flag(StyleFlags::BOLD));
1784        assert!(cell.attrs.has_flag(StyleFlags::STRIKETHROUGH));
1785        assert!(!cell.attrs.has_flag(StyleFlags::ITALIC));
1786        assert_eq!(cell.attrs.link_id(), 999);
1787    }
1788
1789    #[test]
1790    fn cell_size_and_alignment_invariants() {
1791        // Non-negotiable: 16 bytes, 16-byte aligned
1792        assert_eq!(core::mem::size_of::<Cell>(), 16);
1793        assert_eq!(core::mem::align_of::<Cell>(), 16);
1794        // 4 cells per 64-byte cache line
1795        assert_eq!(64 / core::mem::size_of::<Cell>(), 4);
1796    }
1797
1798    #[test]
1799    fn cell_content_size_invariant() {
1800        assert_eq!(core::mem::size_of::<CellContent>(), 4);
1801    }
1802
1803    #[test]
1804    fn cell_attrs_size_invariant() {
1805        assert_eq!(core::mem::size_of::<CellAttrs>(), 4);
1806    }
1807
1808    // -- Porter-Duff over() stress tests --
1809
1810    #[test]
1811    fn over_associativity_approximate() {
1812        // Porter-Duff SourceOver is NOT perfectly associative due to rounding,
1813        // but results should be very close (within 1 per channel)
1814        let a = PackedRgba::rgba(200, 50, 100, 128);
1815        let b = PackedRgba::rgba(50, 200, 50, 128);
1816        let c = PackedRgba::rgba(100, 100, 200, 128);
1817
1818        let ab_c = a.over(b).over(c);
1819        let a_bc = a.over(b.over(c));
1820
1821        // Allow ±1 per channel for rounding differences
1822        assert!(
1823            (ab_c.r() as i16 - a_bc.r() as i16).unsigned_abs() <= 1,
1824            "r: {} vs {}",
1825            ab_c.r(),
1826            a_bc.r()
1827        );
1828        assert!(
1829            (ab_c.g() as i16 - a_bc.g() as i16).unsigned_abs() <= 1,
1830            "g: {} vs {}",
1831            ab_c.g(),
1832            a_bc.g()
1833        );
1834        assert!(
1835            (ab_c.b() as i16 - a_bc.b() as i16).unsigned_abs() <= 1,
1836            "b: {} vs {}",
1837            ab_c.b(),
1838            a_bc.b()
1839        );
1840        assert!(
1841            (ab_c.a() as i16 - a_bc.a() as i16).unsigned_abs() <= 1,
1842            "a: {} vs {}",
1843            ab_c.a(),
1844            a_bc.a()
1845        );
1846    }
1847
1848    #[test]
1849    fn over_output_alpha_monotonic_with_src_alpha() {
1850        // Higher src alpha → higher output alpha (or equal)
1851        let dst = PackedRgba::rgba(0, 0, 255, 128);
1852        let mut prev_a = 0u8;
1853        for alpha in (0..=255).step_by(5) {
1854            let src = PackedRgba::rgba(255, 0, 0, alpha);
1855            let result = src.over(dst);
1856            assert!(
1857                result.a() >= prev_a,
1858                "alpha monotonicity violated at src_a={}: result_a={} < prev={}",
1859                alpha,
1860                result.a(),
1861                prev_a
1862            );
1863            prev_a = result.a();
1864        }
1865    }
1866
1867    #[test]
1868    fn over_sweep_matches_reference() {
1869        // Sweep through alpha values and verify each matches the f64 reference
1870        for alpha in (0..=255).step_by(17) {
1871            let src = PackedRgba::rgba(200, 100, 50, alpha);
1872            let dst = PackedRgba::rgba(50, 100, 200, 200);
1873            assert_eq!(
1874                src.over(dst),
1875                reference_over(src, dst),
1876                "mismatch at src_alpha={}",
1877                alpha
1878            );
1879        }
1880    }
1881}
1882
1883/// Property tests for Cell types (bd-10i.13.2).
1884///
1885/// Top-level `#[cfg(test)]` scope: the `proptest!` macro has edition-2024
1886/// compatibility issues when nested inside another test module.
1887#[cfg(test)]
1888mod cell_proptests {
1889    use super::{Cell, CellAttrs, CellContent, GraphemeId, PackedRgba, StyleFlags};
1890    use proptest::prelude::*;
1891
1892    fn arb_packed_rgba() -> impl Strategy<Value = PackedRgba> {
1893        (any::<u8>(), any::<u8>(), any::<u8>(), any::<u8>())
1894            .prop_map(|(r, g, b, a)| PackedRgba::rgba(r, g, b, a))
1895    }
1896
1897    fn arb_grapheme_id() -> impl Strategy<Value = GraphemeId> {
1898        (0u32..=GraphemeId::MAX_SLOT, 0u8..=GraphemeId::MAX_WIDTH)
1899            .prop_map(|(slot, width)| GraphemeId::new(slot, width))
1900    }
1901
1902    fn arb_style_flags() -> impl Strategy<Value = StyleFlags> {
1903        any::<u8>().prop_map(StyleFlags::from_bits_truncate)
1904    }
1905
1906    proptest! {
1907        #[test]
1908        fn packed_rgba_roundtrips_all_components(tuple in (any::<u8>(), any::<u8>(), any::<u8>(), any::<u8>())) {
1909            let (r, g, b, a) = tuple;
1910            let c = PackedRgba::rgba(r, g, b, a);
1911            prop_assert_eq!(c.r(), r);
1912            prop_assert_eq!(c.g(), g);
1913            prop_assert_eq!(c.b(), b);
1914            prop_assert_eq!(c.a(), a);
1915        }
1916
1917        #[test]
1918        fn packed_rgba_rgb_always_opaque(tuple in (any::<u8>(), any::<u8>(), any::<u8>())) {
1919            let (r, g, b) = tuple;
1920            let c = PackedRgba::rgb(r, g, b);
1921            prop_assert_eq!(c.a(), 255);
1922            prop_assert_eq!(c.r(), r);
1923            prop_assert_eq!(c.g(), g);
1924            prop_assert_eq!(c.b(), b);
1925        }
1926
1927        #[test]
1928        fn packed_rgba_over_identity_transparent(dst in arb_packed_rgba()) {
1929            // Transparent source leaves destination unchanged
1930            let result = PackedRgba::TRANSPARENT.over(dst);
1931            prop_assert_eq!(result, dst);
1932        }
1933
1934        #[test]
1935        fn packed_rgba_over_identity_opaque(tuple in (any::<u8>(), any::<u8>(), any::<u8>(), arb_packed_rgba())) {
1936            // Fully opaque source replaces destination
1937            let (r, g, b, dst) = tuple;
1938            let src = PackedRgba::rgba(r, g, b, 255);
1939            let result = src.over(dst);
1940            prop_assert_eq!(result, src);
1941        }
1942
1943        #[test]
1944        fn grapheme_id_slot_width_roundtrip(tuple in (0u32..=GraphemeId::MAX_SLOT, 0u8..=GraphemeId::MAX_WIDTH)) {
1945            let (slot, width) = tuple;
1946            let id = GraphemeId::new(slot, width);
1947            prop_assert_eq!(id.slot(), slot as usize);
1948            prop_assert_eq!(id.width(), width as usize);
1949        }
1950
1951        #[test]
1952        fn grapheme_id_raw_roundtrip(id in arb_grapheme_id()) {
1953            let raw = id.raw();
1954            let restored = GraphemeId::from_raw(raw);
1955            prop_assert_eq!(restored.slot(), id.slot());
1956            prop_assert_eq!(restored.width(), id.width());
1957        }
1958
1959        #[test]
1960        fn cell_content_char_roundtrip(c in (0x20u32..0xD800u32).prop_union(0xE000u32..0x110000u32)) {
1961            if let Some(ch) = char::from_u32(c) {
1962                let content = CellContent::from_char(ch);
1963                prop_assert_eq!(content.as_char(), Some(ch));
1964                prop_assert!(!content.is_grapheme());
1965                prop_assert!(!content.is_empty());
1966                prop_assert!(!content.is_continuation());
1967            }
1968        }
1969
1970        #[test]
1971        fn cell_content_grapheme_roundtrip(id in arb_grapheme_id()) {
1972            let content = CellContent::from_grapheme(id);
1973            prop_assert!(content.is_grapheme());
1974            prop_assert_eq!(content.grapheme_id(), Some(id));
1975            prop_assert_eq!(content.width_hint(), id.width());
1976        }
1977
1978        #[test]
1979        fn cell_bits_eq_is_reflexive(
1980            tuple in (
1981                (0x20u32..0x80u32).prop_map(|c| char::from_u32(c).unwrap()),
1982                any::<u8>(), any::<u8>(), any::<u8>(),
1983                arb_style_flags(),
1984            ),
1985        ) {
1986            let (c, r, g, b, flags) = tuple;
1987            let cell = Cell::from_char(c)
1988                .with_fg(PackedRgba::rgb(r, g, b))
1989                .with_attrs(CellAttrs::new(flags, 0));
1990            prop_assert!(cell.bits_eq(&cell));
1991        }
1992
1993        #[test]
1994        fn cell_bits_eq_detects_fg_difference(
1995            tuple in (
1996                (0x41u32..0x5Bu32).prop_map(|c| char::from_u32(c).unwrap()),
1997                any::<u8>(), any::<u8>(),
1998            ),
1999        ) {
2000            let (c, r1, r2) = tuple;
2001            prop_assume!(r1 != r2);
2002            let cell1 = Cell::from_char(c).with_fg(PackedRgba::rgb(r1, 0, 0));
2003            let cell2 = Cell::from_char(c).with_fg(PackedRgba::rgb(r2, 0, 0));
2004            prop_assert!(!cell1.bits_eq(&cell2));
2005        }
2006
2007        #[test]
2008        fn cell_attrs_flags_roundtrip(tuple in (arb_style_flags(), 0u32..CellAttrs::LINK_ID_MAX)) {
2009            let (flags, link) = tuple;
2010            let attrs = CellAttrs::new(flags, link);
2011            prop_assert_eq!(attrs.flags(), flags);
2012            prop_assert_eq!(attrs.link_id(), link);
2013        }
2014
2015        #[test]
2016        fn cell_attrs_with_flags_preserves_link(tuple in (arb_style_flags(), 0u32..CellAttrs::LINK_ID_MAX, arb_style_flags())) {
2017            let (flags, link, new_flags) = tuple;
2018            let attrs = CellAttrs::new(flags, link);
2019            let updated = attrs.with_flags(new_flags);
2020            prop_assert_eq!(updated.flags(), new_flags);
2021            prop_assert_eq!(updated.link_id(), link);
2022        }
2023
2024        #[test]
2025        fn cell_attrs_with_link_preserves_flags(tuple in (arb_style_flags(), 0u32..CellAttrs::LINK_ID_MAX, 0u32..CellAttrs::LINK_ID_MAX)) {
2026            let (flags, link1, link2) = tuple;
2027            let attrs = CellAttrs::new(flags, link1);
2028            let updated = attrs.with_link(link2);
2029            prop_assert_eq!(updated.flags(), flags);
2030            prop_assert_eq!(updated.link_id(), link2);
2031        }
2032
2033        // --- Executable Invariant Tests (bd-10i.13.2) ---
2034
2035        #[test]
2036        fn cell_bits_eq_is_symmetric(
2037            tuple in (
2038                (0x41u32..0x5Bu32).prop_map(|c| char::from_u32(c).unwrap()),
2039                (0x41u32..0x5Bu32).prop_map(|c| char::from_u32(c).unwrap()),
2040                arb_packed_rgba(),
2041                arb_packed_rgba(),
2042            ),
2043        ) {
2044            let (c1, c2, fg1, fg2) = tuple;
2045            let cell_a = Cell::from_char(c1).with_fg(fg1);
2046            let cell_b = Cell::from_char(c2).with_fg(fg2);
2047            prop_assert_eq!(cell_a.bits_eq(&cell_b), cell_b.bits_eq(&cell_a),
2048                "bits_eq is not symmetric");
2049        }
2050
2051        #[test]
2052        fn cell_content_bit31_discriminates(id in arb_grapheme_id()) {
2053            // Char content: bit 31 is 0
2054            let char_content = CellContent::from_char('A');
2055            prop_assert!(!char_content.is_grapheme());
2056            prop_assert!(char_content.as_char().is_some());
2057            prop_assert!(char_content.grapheme_id().is_none());
2058
2059            // Grapheme content: bit 31 is 1
2060            let grapheme_content = CellContent::from_grapheme(id);
2061            prop_assert!(grapheme_content.is_grapheme());
2062            prop_assert!(grapheme_content.grapheme_id().is_some());
2063            prop_assert!(grapheme_content.as_char().is_none());
2064        }
2065
2066        #[test]
2067        fn cell_from_char_width_matches_unicode(
2068            c in (0x20u32..0x7Fu32).prop_map(|c| char::from_u32(c).unwrap()),
2069        ) {
2070            let cell = Cell::from_char(c);
2071            prop_assert_eq!(cell.width_hint(), 1,
2072                "Cell width hint for '{}' should be 1 for ASCII", c);
2073        }
2074    }
2075
2076    // Zero-parameter invariant tests (cannot be inside proptest! macro)
2077
2078    #[test]
2079    fn cell_content_continuation_has_zero_width() {
2080        let cont = CellContent::CONTINUATION;
2081        assert_eq!(cont.width(), 0, "CONTINUATION cell should have width 0");
2082        assert!(cont.is_continuation());
2083        assert!(!cont.is_grapheme());
2084    }
2085
2086    #[test]
2087    fn cell_content_empty_has_zero_width() {
2088        let empty = CellContent::EMPTY;
2089        assert_eq!(empty.width(), 0, "EMPTY cell should have width 0");
2090        assert!(empty.is_empty());
2091        assert!(!empty.is_grapheme());
2092        assert!(!empty.is_continuation());
2093    }
2094
2095    #[test]
2096    fn cell_default_is_empty() {
2097        let cell = Cell::default();
2098        assert!(cell.is_empty());
2099        assert_eq!(cell.width_hint(), 0);
2100    }
2101}