Skip to main content

frankenterm_core/
cell.rs

1//! Terminal cell: the fundamental unit of the grid.
2//!
3//! Each cell stores a base character plus up to [`MAX_COMBINING`] inline
4//! combining marks, forming a grapheme cluster, along with SGR attributes.
5//! This is intentionally simpler than `ftui-render::Cell` — it models the
6//! terminal's internal state rather than the rendering pipeline.
7
8use bitflags::bitflags;
9use std::collections::HashMap;
10use unicode_width::UnicodeWidthChar;
11
12bitflags! {
13    /// SGR text attribute flags.
14    ///
15    /// Maps directly to the ECMA-48 / VT100 SGR parameter values.
16    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
17    pub struct SgrFlags: u16 {
18        const BOLD          = 1 << 0;
19        const DIM           = 1 << 1;
20        const ITALIC        = 1 << 2;
21        const UNDERLINE     = 1 << 3;
22        const BLINK         = 1 << 4;
23        const INVERSE       = 1 << 5;
24        const HIDDEN        = 1 << 6;
25        const STRIKETHROUGH = 1 << 7;
26        const DOUBLE_UNDERLINE = 1 << 8;
27        const CURLY_UNDERLINE  = 1 << 9;
28        const OVERLINE      = 1 << 10;
29    }
30}
31
32bitflags! {
33    /// Cell-level flags that are orthogonal to SGR attributes.
34    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
35    pub struct CellFlags: u8 {
36        /// This cell is the leading (left) cell of a wide (2-column) character.
37        const WIDE_CHAR = 1 << 0;
38        /// This cell is the trailing (right) continuation of a wide character.
39        /// Its content is meaningless; rendering uses the leading cell.
40        const WIDE_CONTINUATION = 1 << 1;
41        /// This cell has one or more combining marks attached to its base character.
42        const HAS_COMBINING = 1 << 2;
43    }
44}
45
46/// Maximum number of combining marks stored inline per cell.
47///
48/// Two marks cover the vast majority of real-world grapheme clusters
49/// (e.g. base + accent + second diacritic). Excess marks are silently dropped.
50pub const MAX_COMBINING: usize = 2;
51
52/// Color representation for terminal cells.
53///
54/// Supports the standard terminal color model hierarchy:
55/// default → 16 named → 256 indexed → 24-bit RGB.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
57pub enum Color {
58    /// Terminal default (SGR 39 / SGR 49).
59    #[default]
60    Default,
61    /// Named color index (0-15): standard 8 + bright 8.
62    Named(u8),
63    /// 256-color palette index (0-255).
64    Indexed(u8),
65    /// 24-bit true color.
66    Rgb(u8, u8, u8),
67}
68
69/// SGR attributes for a cell: flags + foreground/background colors.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
71pub struct SgrAttrs {
72    pub flags: SgrFlags,
73    pub fg: Color,
74    pub bg: Color,
75    /// Underline color (SGR 58). `None` means use foreground.
76    pub underline_color: Option<Color>,
77}
78
79impl SgrAttrs {
80    /// Reset all attributes to default (SGR 0).
81    pub fn reset(&mut self) {
82        *self = Self::default();
83    }
84
85    /// Apply an SGR parameter list (`CSI ... m`) to this attribute state.
86    ///
87    /// Notes
88    /// - SGR is stateful and delta-based: parameters mutate the current state.
89    /// - An empty parameter list is treated as `[0]` (full reset).
90    pub fn apply_sgr_params(&mut self, params: &[u16]) {
91        let mut i = 0usize;
92        let params = if params.is_empty() {
93            &[0u16][..]
94        } else {
95            params
96        };
97
98        while i < params.len() {
99            let p = params[i];
100            match p {
101                0 => self.reset(),
102
103                1 => self.flags.insert(SgrFlags::BOLD),
104                2 => self.flags.insert(SgrFlags::DIM),
105                3 => self.flags.insert(SgrFlags::ITALIC),
106                4 => self.flags.insert(SgrFlags::UNDERLINE),
107                5 => self.flags.insert(SgrFlags::BLINK),
108                7 => self.flags.insert(SgrFlags::INVERSE),
109                8 => self.flags.insert(SgrFlags::HIDDEN),
110                9 => self.flags.insert(SgrFlags::STRIKETHROUGH),
111                21 => self.flags.insert(SgrFlags::DOUBLE_UNDERLINE),
112                53 => self.flags.insert(SgrFlags::OVERLINE),
113
114                22 => self.flags.remove(SgrFlags::BOLD | SgrFlags::DIM),
115                23 => self.flags.remove(SgrFlags::ITALIC),
116                24 => self.flags.remove(
117                    SgrFlags::UNDERLINE | SgrFlags::DOUBLE_UNDERLINE | SgrFlags::CURLY_UNDERLINE,
118                ),
119                25 => self.flags.remove(SgrFlags::BLINK),
120                27 => self.flags.remove(SgrFlags::INVERSE),
121                28 => self.flags.remove(SgrFlags::HIDDEN),
122                29 => self.flags.remove(SgrFlags::STRIKETHROUGH),
123                55 => self.flags.remove(SgrFlags::OVERLINE),
124
125                30..=37 => self.fg = Color::Named((p - 30) as u8),
126                39 => self.fg = Color::Default,
127                40..=47 => self.bg = Color::Named((p - 40) as u8),
128                49 => self.bg = Color::Default,
129                90..=97 => self.fg = Color::Named(((p - 90) as u8).saturating_add(8)),
130                100..=107 => self.bg = Color::Named(((p - 100) as u8).saturating_add(8)),
131
132                // Extended colors.
133                38 => {
134                    if let Some((c, consumed)) = parse_extended_color(params, i) {
135                        self.fg = c;
136                        i = i.saturating_add(consumed);
137                        continue;
138                    }
139                }
140                48 => {
141                    if let Some((c, consumed)) = parse_extended_color(params, i) {
142                        self.bg = c;
143                        i = i.saturating_add(consumed);
144                        continue;
145                    }
146                }
147                58 => {
148                    if let Some((c, consumed)) = parse_extended_color(params, i) {
149                        self.underline_color = Some(c);
150                        i = i.saturating_add(consumed);
151                        continue;
152                    }
153                }
154                59 => self.underline_color = None,
155
156                _ => {}
157            }
158            i = i.saturating_add(1);
159        }
160
161        fn parse_extended_color(params: &[u16], start: usize) -> Option<(Color, usize)> {
162            // Formats:
163            // - 38;5;n     (indexed fg)
164            // - 38;2;r;g;b (truecolor fg)
165            // ... same for 48 (bg) and 58 (underline color).
166            let mode = *params.get(start + 1)?;
167            match mode {
168                5 => {
169                    let idx = *params.get(start + 2)?;
170                    Some((Color::Indexed(idx.min(255) as u8), 3))
171                }
172                2 => {
173                    let r = *params.get(start + 2)?;
174                    let g = *params.get(start + 3)?;
175                    let b = *params.get(start + 4)?;
176                    Some((
177                        Color::Rgb(r.min(255) as u8, g.min(255) as u8, b.min(255) as u8),
178                        5,
179                    ))
180                }
181                _ => None,
182            }
183        }
184    }
185}
186
187/// Hyperlink identifier for OSC 8 links.
188///
189/// Zero means "no link". Non-zero values index into an external link registry
190/// that maps IDs to URIs.
191pub type HyperlinkId = u16;
192
193/// Registry for OSC 8 hyperlink URIs.
194///
195/// Cells store compact `HyperlinkId`s instead of full URI strings. This
196/// registry provides ID allocation, deduplication, and reference-counted
197/// release so hosts can clear unused hyperlinks when content is dropped
198/// (e.g., scrollback eviction).
199#[derive(Debug, Clone)]
200pub struct HyperlinkRegistry {
201    /// Slots indexed by ID (0 reserved for "no link").
202    slots: Vec<Option<HyperlinkSlot>>,
203    /// URI -> ID lookup for deduplication.
204    lookup: HashMap<String, HyperlinkId>,
205    /// Reusable IDs from released hyperlinks.
206    free_list: Vec<HyperlinkId>,
207}
208
209#[derive(Debug, Clone)]
210struct HyperlinkSlot {
211    uri: String,
212    ref_count: u32,
213}
214
215impl HyperlinkRegistry {
216    /// Create a new empty registry.
217    pub fn new() -> Self {
218        Self {
219            slots: vec![None],
220            lookup: HashMap::new(),
221            free_list: Vec::new(),
222        }
223    }
224
225    /// Intern a URI and return its hyperlink ID without changing refcounts.
226    ///
227    /// Empty URIs return 0 (interpreted as "no link").
228    pub fn intern(&mut self, uri: &str) -> HyperlinkId {
229        if uri.is_empty() {
230            return 0;
231        }
232        if let Some(&id) = self.lookup.get(uri) {
233            return id;
234        }
235
236        let id = if let Some(id) = self.free_list.pop() {
237            id
238        } else {
239            let next = self.slots.len();
240            if next > HyperlinkId::MAX as usize {
241                return 0;
242            }
243            let id = next as HyperlinkId;
244            self.slots.push(None);
245            id
246        };
247
248        if id == 0 {
249            return 0;
250        }
251        let idx = id as usize;
252        if idx >= self.slots.len() {
253            return 0;
254        }
255
256        self.slots[idx] = Some(HyperlinkSlot {
257            uri: uri.to_string(),
258            ref_count: 0,
259        });
260        self.lookup.insert(uri.to_string(), id);
261        id
262    }
263
264    /// Convenience: intern a URI and increment its refcount once.
265    pub fn acquire(&mut self, uri: &str) -> HyperlinkId {
266        let id = self.intern(uri);
267        self.acquire_id(id);
268        id
269    }
270
271    /// Increment the refcount for an existing hyperlink ID.
272    ///
273    /// Invalid IDs and 0 are ignored.
274    pub fn acquire_id(&mut self, id: HyperlinkId) {
275        if id == 0 {
276            return;
277        }
278        let Some(slot) = self.slots.get_mut(id as usize) else {
279            return;
280        };
281        let Some(slot) = slot.as_mut() else {
282            return;
283        };
284        slot.ref_count = slot.ref_count.saturating_add(1);
285    }
286
287    /// Decrement the refcount for an ID and release it when it reaches zero.
288    ///
289    /// Invalid IDs and 0 are ignored. Releasing an ID with refcount 0 is a no-op.
290    pub fn release_id(&mut self, id: HyperlinkId) {
291        if id == 0 {
292            return;
293        }
294        let Some(entry) = self.slots.get_mut(id as usize) else {
295            return;
296        };
297
298        let should_remove = match entry.as_mut() {
299            Some(slot) if slot.ref_count > 0 => {
300                slot.ref_count -= 1;
301                slot.ref_count == 0
302            }
303            _ => false,
304        };
305
306        if should_remove && let Some(removed) = entry.take() {
307            self.lookup.remove(&removed.uri);
308            self.free_list.push(id);
309        }
310    }
311
312    /// Release hyperlink references for all cells in the slice.
313    ///
314    /// Intended for use when dropping content (e.g., evicted scrollback lines).
315    pub fn release_cells(&mut self, cells: &[Cell]) {
316        for cell in cells {
317            self.release_id(cell.hyperlink);
318        }
319    }
320
321    /// Look up the URI for a hyperlink ID.
322    pub fn get(&self, id: HyperlinkId) -> Option<&str> {
323        self.slots
324            .get(id as usize)
325            .and_then(|slot| slot.as_ref())
326            .map(|slot| slot.uri.as_str())
327    }
328
329    /// Clear all hyperlinks, resetting the registry to empty.
330    pub fn clear(&mut self) {
331        self.slots.clear();
332        self.slots.push(None);
333        self.lookup.clear();
334        self.free_list.clear();
335    }
336
337    /// Number of currently registered hyperlinks.
338    pub fn len(&self) -> usize {
339        self.slots.iter().filter(|slot| slot.is_some()).count()
340    }
341
342    /// Whether the registry has no hyperlinks.
343    #[inline]
344    pub fn is_empty(&self) -> bool {
345        self.len() == 0
346    }
347
348    /// Whether the registry contains the given ID.
349    pub fn contains(&self, id: HyperlinkId) -> bool {
350        self.get(id).is_some()
351    }
352}
353
354impl Default for HyperlinkRegistry {
355    fn default() -> Self {
356        Self::new()
357    }
358}
359
360/// A single cell in the terminal grid.
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362pub struct Cell {
363    /// The character content. A space for empty/erased cells.
364    content: char,
365    /// Display width of the content in terminal columns (1 or 2 for wide chars).
366    width: u8,
367    /// Cell-level flags (wide char, continuation, etc.).
368    pub flags: CellFlags,
369    /// SGR text attributes.
370    pub attrs: SgrAttrs,
371    /// Hyperlink ID (0 = no link).
372    pub hyperlink: HyperlinkId,
373    /// Inline storage for combining marks (accents, ZWJ, etc.).
374    combining: [char; MAX_COMBINING],
375    /// Number of combining marks actually stored (0..=MAX_COMBINING).
376    combining_len: u8,
377}
378
379impl Default for Cell {
380    fn default() -> Self {
381        Self {
382            content: ' ',
383            width: 1,
384            flags: CellFlags::empty(),
385            attrs: SgrAttrs::default(),
386            hyperlink: 0,
387            combining: ['\0'; MAX_COMBINING],
388            combining_len: 0,
389        }
390    }
391}
392
393impl Cell {
394    /// Create a new cell with the given character and default attributes.
395    pub fn new(ch: char) -> Self {
396        Self {
397            content: ch,
398            width: 1,
399            flags: CellFlags::empty(),
400            attrs: SgrAttrs::default(),
401            hyperlink: 0,
402            combining: ['\0'; MAX_COMBINING],
403            combining_len: 0,
404        }
405    }
406
407    /// Create a new cell with the given character, width, and attributes.
408    pub fn with_attrs(ch: char, width: u8, attrs: SgrAttrs) -> Self {
409        Self {
410            content: ch,
411            width,
412            flags: CellFlags::empty(),
413            attrs,
414            hyperlink: 0,
415            combining: ['\0'; MAX_COMBINING],
416            combining_len: 0,
417        }
418    }
419
420    /// Create a wide (2-column) character cell.
421    ///
422    /// Returns `(leading, continuation)` pair. The leading cell holds the
423    /// character; the continuation cell is a placeholder.
424    pub fn wide(ch: char, attrs: SgrAttrs) -> (Self, Self) {
425        let leading = Self {
426            content: ch,
427            width: 2,
428            flags: CellFlags::WIDE_CHAR,
429            attrs,
430            hyperlink: 0,
431            combining: ['\0'; MAX_COMBINING],
432            combining_len: 0,
433        };
434        let continuation = Self {
435            content: ' ',
436            width: 0,
437            flags: CellFlags::WIDE_CONTINUATION,
438            attrs,
439            hyperlink: 0,
440            combining: ['\0'; MAX_COMBINING],
441            combining_len: 0,
442        };
443        (leading, continuation)
444    }
445
446    /// The character content of this cell.
447    #[inline]
448    pub fn content(&self) -> char {
449        self.content
450    }
451
452    /// The display width in terminal columns.
453    #[inline]
454    pub fn width(&self) -> u8 {
455        self.width
456    }
457
458    /// Whether this cell is the leading half of a wide character.
459    #[inline]
460    pub fn is_wide(&self) -> bool {
461        self.flags.contains(CellFlags::WIDE_CHAR)
462    }
463
464    /// Whether this cell is a continuation (trailing half) of a wide character.
465    #[inline]
466    pub fn is_wide_continuation(&self) -> bool {
467        self.flags.contains(CellFlags::WIDE_CONTINUATION)
468    }
469
470    /// Set the character content and display width.
471    pub fn set_content(&mut self, ch: char, width: u8) {
472        self.content = ch;
473        self.width = width;
474        // Clear wide and combining flags when replacing content.
475        self.flags
476            .remove(CellFlags::WIDE_CHAR | CellFlags::WIDE_CONTINUATION | CellFlags::HAS_COMBINING);
477        self.combining_len = 0;
478    }
479
480    /// Reset this cell to a blank space with the given background attributes.
481    ///
482    /// Used by erase operations (ED, EL, ECH) which fill with the current
483    /// background color but reset all other attributes.
484    pub fn erase(&mut self, bg: Color) {
485        self.content = ' ';
486        self.width = 1;
487        self.flags = CellFlags::empty();
488        self.attrs = SgrAttrs {
489            bg,
490            ..SgrAttrs::default()
491        };
492        self.hyperlink = 0;
493        self.combining_len = 0;
494    }
495
496    /// Reset this cell to a blank space with default attributes.
497    pub fn clear(&mut self) {
498        *self = Self::default();
499    }
500
501    // ── Combining mark support ────────────────────────────────────────
502
503    /// Append a combining mark to this cell's grapheme cluster.
504    ///
505    /// Returns `true` if the mark was stored, `false` if the inline buffer
506    /// is full (excess marks are silently dropped).
507    pub fn push_combining(&mut self, mark: char) -> bool {
508        let len = self.combining_len as usize;
509        if len >= MAX_COMBINING {
510            return false;
511        }
512        self.combining[len] = mark;
513        self.combining_len += 1;
514        self.flags.insert(CellFlags::HAS_COMBINING);
515        true
516    }
517
518    /// The combining marks attached to this cell's base character.
519    #[inline]
520    pub fn combining_marks(&self) -> &[char] {
521        // Invariant: combining_len is always <= MAX_COMBINING
522        &self.combining[..self.combining_len as usize]
523    }
524
525    /// Whether this cell has any combining marks.
526    #[inline]
527    pub fn has_combining(&self) -> bool {
528        self.combining_len > 0
529    }
530
531    /// Compute terminal display width for a single Unicode scalar.
532    ///
533    /// Returns:
534    /// - `0` for non-spacing marks/format controls (combining marks, ZWJ, VS16, etc.)
535    /// - `1` for narrow characters
536    /// - `2` for wide characters (CJK, emoji presentation)
537    ///
538    /// Widths above 2 are clamped to 2 for terminal cell semantics.
539    pub fn display_width(ch: char) -> u8 {
540        let width = UnicodeWidthChar::width(ch).unwrap_or(0);
541        width.min(2) as u8
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    use crate::grid::Grid;
550    use crate::scrollback::Scrollback;
551
552    #[test]
553    fn default_cell_is_space() {
554        let cell = Cell::default();
555        assert_eq!(cell.content(), ' ');
556        assert_eq!(cell.width(), 1);
557        assert_eq!(cell.attrs, SgrAttrs::default());
558        assert!(!cell.is_wide());
559        assert!(!cell.is_wide_continuation());
560        assert_eq!(cell.hyperlink, 0);
561    }
562
563    #[test]
564    fn cell_new_has_default_attrs() {
565        let cell = Cell::new('A');
566        assert_eq!(cell.content(), 'A');
567        assert_eq!(cell.attrs.flags, SgrFlags::empty());
568        assert_eq!(cell.attrs.fg, Color::Default);
569        assert_eq!(cell.attrs.bg, Color::Default);
570    }
571
572    #[test]
573    fn cell_erase_clears_content_and_attrs() {
574        let mut cell = Cell::with_attrs(
575            'X',
576            1,
577            SgrAttrs {
578                flags: SgrFlags::BOLD | SgrFlags::ITALIC,
579                fg: Color::Named(1),
580                bg: Color::Named(4),
581                underline_color: None,
582            },
583        );
584        cell.hyperlink = 42;
585        cell.erase(Color::Named(2));
586        assert_eq!(cell.content(), ' ');
587        assert_eq!(cell.attrs.flags, SgrFlags::empty());
588        assert_eq!(cell.attrs.fg, Color::Default);
589        assert_eq!(cell.attrs.bg, Color::Named(2));
590        assert_eq!(cell.hyperlink, 0);
591    }
592
593    #[test]
594    fn wide_char_pair() {
595        let attrs = SgrAttrs {
596            flags: SgrFlags::BOLD,
597            ..SgrAttrs::default()
598        };
599        let (lead, cont) = Cell::wide('\u{4E2D}', attrs); // '中'
600        assert!(lead.is_wide());
601        assert!(!lead.is_wide_continuation());
602        assert_eq!(lead.width(), 2);
603        assert_eq!(lead.content(), '中');
604
605        assert!(!cont.is_wide());
606        assert!(cont.is_wide_continuation());
607        assert_eq!(cont.width(), 0);
608    }
609
610    #[test]
611    fn set_content_clears_wide_flags() {
612        let (mut lead, _) = Cell::wide('中', SgrAttrs::default());
613        assert!(lead.is_wide());
614        lead.set_content('A', 1);
615        assert!(!lead.is_wide());
616        assert!(!lead.is_wide_continuation());
617    }
618
619    #[test]
620    fn erase_clears_wide_flags() {
621        let (mut lead, _) = Cell::wide('中', SgrAttrs::default());
622        lead.erase(Color::Default);
623        assert!(!lead.is_wide());
624    }
625
626    #[test]
627    fn display_width_unicode_cases() {
628        assert_eq!(Cell::display_width('a'), 1);
629        assert_eq!(Cell::display_width('中'), 2);
630        assert_eq!(Cell::display_width('\u{1F680}'), 2); // 🚀
631        assert_eq!(Cell::display_width('\u{0301}'), 0); // combining acute accent
632        assert_eq!(Cell::display_width('\u{200D}'), 0); // ZWJ
633        assert_eq!(Cell::display_width('\u{FE0F}'), 0); // VS16
634    }
635
636    #[test]
637    fn sgr_attrs_reset() {
638        let mut attrs = SgrAttrs {
639            flags: SgrFlags::BOLD,
640            fg: Color::Rgb(255, 0, 0),
641            bg: Color::Indexed(42),
642            underline_color: Some(Color::Named(3)),
643        };
644        attrs.reset();
645        assert_eq!(attrs, SgrAttrs::default());
646    }
647
648    #[test]
649    fn sgr_attrs_apply_params_basic_colors_and_reset() {
650        let mut attrs = SgrAttrs::default();
651        attrs.apply_sgr_params(&[31]);
652        assert_eq!(attrs.fg, Color::Named(1));
653        attrs.apply_sgr_params(&[44]);
654        assert_eq!(attrs.bg, Color::Named(4));
655        attrs.apply_sgr_params(&[0]);
656        assert_eq!(attrs, SgrAttrs::default());
657    }
658
659    #[test]
660    fn sgr_attrs_apply_params_extended_colors() {
661        let mut attrs = SgrAttrs::default();
662        attrs.apply_sgr_params(&[38, 5, 200]);
663        assert_eq!(attrs.fg, Color::Indexed(200));
664        attrs.apply_sgr_params(&[48, 2, 1, 2, 3]);
665        assert_eq!(attrs.bg, Color::Rgb(1, 2, 3));
666        attrs.apply_sgr_params(&[39, 49]);
667        assert_eq!(attrs.fg, Color::Default);
668        assert_eq!(attrs.bg, Color::Default);
669    }
670
671    #[test]
672    fn sgr_attrs_apply_params_empty_means_reset() {
673        let mut attrs = SgrAttrs {
674            flags: SgrFlags::BOLD,
675            fg: Color::Named(2),
676            bg: Color::Named(3),
677            underline_color: Some(Color::Indexed(7)),
678        };
679        attrs.apply_sgr_params(&[]);
680        assert_eq!(attrs, SgrAttrs::default());
681    }
682
683    #[test]
684    fn color_default() {
685        assert_eq!(Color::default(), Color::Default);
686    }
687
688    #[test]
689    fn cell_clear_resets_everything() {
690        let mut cell = Cell::with_attrs(
691            'Z',
692            2,
693            SgrAttrs {
694                flags: SgrFlags::BOLD | SgrFlags::UNDERLINE,
695                fg: Color::Rgb(1, 2, 3),
696                bg: Color::Named(5),
697                underline_color: Some(Color::Indexed(100)),
698            },
699        );
700        cell.hyperlink = 99;
701        cell.flags = CellFlags::WIDE_CHAR;
702        cell.clear();
703        assert_eq!(cell, Cell::default());
704    }
705
706    // --- Hyperlink registry fixtures (bd-lff4p.1.7) ---
707
708    #[test]
709    fn hyperlink_registry_intern_and_get() {
710        let mut reg = HyperlinkRegistry::new();
711        let id = reg.intern("https://example.com");
712        assert_ne!(id, 0);
713        assert_eq!(reg.get(id), Some("https://example.com"));
714    }
715
716    #[test]
717    fn hyperlink_registry_dedup_and_id_reuse_on_release() {
718        let mut reg = HyperlinkRegistry::new();
719        let id1 = reg.intern("https://one.test");
720        let id2 = reg.intern("https://one.test");
721        assert_eq!(id1, id2);
722
723        // Acquire twice (two cells) then release twice -> should free the slot.
724        reg.acquire_id(id1);
725        reg.acquire_id(id1);
726        reg.release_id(id1);
727        reg.release_id(id1);
728        assert_eq!(reg.get(id1), None);
729
730        // Next distinct URI should reuse the freed ID.
731        let reused = reg.intern("https://two.test");
732        assert_eq!(reused, id1);
733        assert_eq!(reg.get(reused), Some("https://two.test"));
734    }
735
736    #[test]
737    fn hyperlink_registry_overlap_and_reset() {
738        let mut reg = HyperlinkRegistry::new();
739        let id_a = reg.acquire("https://a.test");
740        let id_b = reg.acquire("https://b.test");
741
742        // Simulate two adjacent cells with different links (overlap boundary).
743        let mut c0 = Cell::new('x');
744        c0.hyperlink = id_a;
745        let mut c1 = Cell::new('y');
746        c1.hyperlink = id_b;
747
748        assert_eq!(reg.get(c0.hyperlink), Some("https://a.test"));
749        assert_eq!(reg.get(c1.hyperlink), Some("https://b.test"));
750
751        // Reset: clear a cell's hyperlink and release the old reference.
752        reg.release_id(c0.hyperlink);
753        c0.hyperlink = 0;
754        assert_eq!(reg.get(c0.hyperlink), None);
755    }
756
757    #[test]
758    fn click_mapping_via_grid_helper() {
759        let mut reg = HyperlinkRegistry::new();
760        let id = reg.acquire("https://click.test");
761        let mut grid = Grid::new(3, 1);
762        let cell = grid.cell_mut(0, 1).unwrap();
763        *cell = Cell::new('C');
764        cell.hyperlink = id;
765
766        assert_eq!(
767            grid.hyperlink_uri_at(0, 1, &reg),
768            Some("https://click.test")
769        );
770        assert_eq!(grid.hyperlink_uri_at(0, 0, &reg), None);
771        assert_eq!(grid.hyperlink_uri_at(9, 9, &reg), None);
772    }
773
774    // ── SGR flag set/remove cycles ────────────────────────────────────
775
776    #[test]
777    fn sgr_dim_flag() {
778        let mut a = SgrAttrs::default();
779        a.apply_sgr_params(&[2]);
780        assert!(a.flags.contains(SgrFlags::DIM));
781        a.apply_sgr_params(&[22]); // removes BOLD | DIM
782        assert!(!a.flags.contains(SgrFlags::DIM));
783    }
784
785    #[test]
786    fn sgr_underline_and_variants() {
787        let mut a = SgrAttrs::default();
788        a.apply_sgr_params(&[4]);
789        assert!(a.flags.contains(SgrFlags::UNDERLINE));
790        a.apply_sgr_params(&[24]); // removes underline + double + curly
791        assert!(!a.flags.contains(SgrFlags::UNDERLINE));
792
793        a.apply_sgr_params(&[21]);
794        assert!(a.flags.contains(SgrFlags::DOUBLE_UNDERLINE));
795        a.apply_sgr_params(&[24]);
796        assert!(!a.flags.contains(SgrFlags::DOUBLE_UNDERLINE));
797    }
798
799    #[test]
800    fn sgr_blink_inverse_hidden_strikethrough() {
801        let mut a = SgrAttrs::default();
802
803        a.apply_sgr_params(&[5]);
804        assert!(a.flags.contains(SgrFlags::BLINK));
805        a.apply_sgr_params(&[25]);
806        assert!(!a.flags.contains(SgrFlags::BLINK));
807
808        a.apply_sgr_params(&[7]);
809        assert!(a.flags.contains(SgrFlags::INVERSE));
810        a.apply_sgr_params(&[27]);
811        assert!(!a.flags.contains(SgrFlags::INVERSE));
812
813        a.apply_sgr_params(&[8]);
814        assert!(a.flags.contains(SgrFlags::HIDDEN));
815        a.apply_sgr_params(&[28]);
816        assert!(!a.flags.contains(SgrFlags::HIDDEN));
817
818        a.apply_sgr_params(&[9]);
819        assert!(a.flags.contains(SgrFlags::STRIKETHROUGH));
820        a.apply_sgr_params(&[29]);
821        assert!(!a.flags.contains(SgrFlags::STRIKETHROUGH));
822    }
823
824    #[test]
825    fn sgr_overline() {
826        let mut a = SgrAttrs::default();
827        a.apply_sgr_params(&[53]);
828        assert!(a.flags.contains(SgrFlags::OVERLINE));
829        a.apply_sgr_params(&[55]);
830        assert!(!a.flags.contains(SgrFlags::OVERLINE));
831    }
832
833    #[test]
834    fn sgr_bold_dim_remove_clears_both() {
835        let mut a = SgrAttrs::default();
836        a.apply_sgr_params(&[1, 2]); // set bold + dim
837        assert!(a.flags.contains(SgrFlags::BOLD));
838        assert!(a.flags.contains(SgrFlags::DIM));
839        a.apply_sgr_params(&[22]); // removes both
840        assert!(!a.flags.contains(SgrFlags::BOLD));
841        assert!(!a.flags.contains(SgrFlags::DIM));
842    }
843
844    #[test]
845    fn sgr_bright_fg_colors() {
846        let mut a = SgrAttrs::default();
847        a.apply_sgr_params(&[90]);
848        assert_eq!(a.fg, Color::Named(8)); // bright black
849        a.apply_sgr_params(&[97]);
850        assert_eq!(a.fg, Color::Named(15)); // bright white
851    }
852
853    #[test]
854    fn sgr_bright_bg_colors() {
855        let mut a = SgrAttrs::default();
856        a.apply_sgr_params(&[100]);
857        assert_eq!(a.bg, Color::Named(8));
858        a.apply_sgr_params(&[107]);
859        assert_eq!(a.bg, Color::Named(15));
860    }
861
862    #[test]
863    fn sgr_underline_color_set_and_reset() {
864        let mut a = SgrAttrs::default();
865        a.apply_sgr_params(&[58, 5, 42]);
866        assert_eq!(a.underline_color, Some(Color::Indexed(42)));
867        a.apply_sgr_params(&[59]);
868        assert_eq!(a.underline_color, None);
869
870        a.apply_sgr_params(&[58, 2, 10, 20, 30]);
871        assert_eq!(a.underline_color, Some(Color::Rgb(10, 20, 30)));
872    }
873
874    #[test]
875    fn sgr_multiple_params_in_one_call() {
876        let mut a = SgrAttrs::default();
877        a.apply_sgr_params(&[1, 3, 31, 42]);
878        assert!(a.flags.contains(SgrFlags::BOLD));
879        assert!(a.flags.contains(SgrFlags::ITALIC));
880        assert_eq!(a.fg, Color::Named(1)); // red
881        assert_eq!(a.bg, Color::Named(2)); // green
882    }
883
884    #[test]
885    fn sgr_unknown_param_ignored() {
886        let mut a = SgrAttrs::default();
887        a.apply_sgr_params(&[999]);
888        assert_eq!(a, SgrAttrs::default());
889    }
890
891    #[test]
892    fn sgr_indexed_fg_clamps_to_255() {
893        let mut a = SgrAttrs::default();
894        a.apply_sgr_params(&[38, 5, 300]);
895        assert_eq!(a.fg, Color::Indexed(255));
896    }
897
898    #[test]
899    fn sgr_rgb_clamps_to_255() {
900        let mut a = SgrAttrs::default();
901        a.apply_sgr_params(&[38, 2, 999, 500, 300]);
902        assert_eq!(a.fg, Color::Rgb(255, 255, 255));
903    }
904
905    #[test]
906    fn sgr_extended_color_with_bad_mode_ignored() {
907        let mut a = SgrAttrs::default();
908        // mode=99 is neither 5 (indexed) nor 2 (rgb) → ignored
909        a.apply_sgr_params(&[38, 99, 100]);
910        assert_eq!(a.fg, Color::Default);
911    }
912
913    #[test]
914    fn sgr_truncated_extended_color_ignored() {
915        let mut a = SgrAttrs::default();
916        // 38 alone, no mode byte
917        a.apply_sgr_params(&[38]);
918        assert_eq!(a.fg, Color::Default);
919        // 38;5 without index
920        a.apply_sgr_params(&[38, 5]);
921        assert_eq!(a.fg, Color::Default);
922        // 38;2 without enough rgb values
923        a.apply_sgr_params(&[38, 2, 10]);
924        assert_eq!(a.fg, Color::Default);
925    }
926
927    // ── HyperlinkRegistry utility methods ───────────────────────────────
928
929    #[test]
930    fn hyperlink_intern_empty_returns_zero() {
931        let mut reg = HyperlinkRegistry::new();
932        assert_eq!(reg.intern(""), 0);
933    }
934
935    #[test]
936    fn hyperlink_get_zero_returns_none() {
937        let reg = HyperlinkRegistry::new();
938        assert_eq!(reg.get(0), None);
939    }
940
941    #[test]
942    fn hyperlink_get_invalid_id_returns_none() {
943        let reg = HyperlinkRegistry::new();
944        assert_eq!(reg.get(999), None);
945    }
946
947    #[test]
948    fn hyperlink_contains() {
949        let mut reg = HyperlinkRegistry::new();
950        let id = reg.intern("https://test.com");
951        assert!(reg.contains(id));
952        assert!(!reg.contains(0));
953        assert!(!reg.contains(999));
954    }
955
956    #[test]
957    fn hyperlink_len_and_is_empty() {
958        let mut reg = HyperlinkRegistry::new();
959        assert!(reg.is_empty());
960        assert_eq!(reg.len(), 0);
961
962        let id = reg.intern("https://a.test");
963        assert!(!reg.is_empty());
964        assert_eq!(reg.len(), 1);
965
966        reg.intern("https://b.test");
967        assert_eq!(reg.len(), 2);
968
969        // Same URI doesn't increase count
970        reg.intern("https://a.test");
971        assert_eq!(reg.len(), 2);
972
973        // Release frees slot
974        reg.acquire_id(id);
975        reg.release_id(id);
976        assert_eq!(reg.len(), 1);
977    }
978
979    #[test]
980    fn hyperlink_clear_resets() {
981        let mut reg = HyperlinkRegistry::new();
982        let id = reg.intern("https://x.test");
983        reg.clear();
984        assert!(reg.is_empty());
985        assert_eq!(reg.get(id), None);
986    }
987
988    #[test]
989    fn hyperlink_release_id_zero_is_noop() {
990        let mut reg = HyperlinkRegistry::new();
991        reg.release_id(0); // should not panic
992        assert!(reg.is_empty());
993    }
994
995    #[test]
996    fn hyperlink_release_invalid_id_is_noop() {
997        let mut reg = HyperlinkRegistry::new();
998        reg.release_id(500); // should not panic
999    }
1000
1001    #[test]
1002    fn hyperlink_acquire_id_zero_is_noop() {
1003        let mut reg = HyperlinkRegistry::new();
1004        reg.acquire_id(0); // should not panic
1005        assert!(reg.is_empty());
1006    }
1007
1008    // ── Cell with_attrs ────────────────────────────────────────────────
1009
1010    #[test]
1011    fn cell_with_attrs_preserves_values() {
1012        let attrs = SgrAttrs {
1013            flags: SgrFlags::ITALIC | SgrFlags::UNDERLINE,
1014            fg: Color::Rgb(10, 20, 30),
1015            bg: Color::Indexed(42),
1016            underline_color: Some(Color::Named(3)),
1017        };
1018        let cell = Cell::with_attrs('Q', 2, attrs);
1019        assert_eq!(cell.content(), 'Q');
1020        assert_eq!(cell.width(), 2);
1021        assert_eq!(cell.attrs, attrs);
1022        assert_eq!(cell.hyperlink, 0);
1023        assert!(!cell.is_wide());
1024    }
1025
1026    #[test]
1027    fn clear_on_scrollback_eviction() {
1028        let mut reg = HyperlinkRegistry::new();
1029        let mut sb = Scrollback::new(1);
1030
1031        // First line uses link A in 3 cells.
1032        let mut row_a = vec![Cell::new('a'), Cell::new('a'), Cell::new('a')];
1033        let id_a = reg.intern("https://a.test");
1034        for cell in &mut row_a {
1035            reg.acquire_id(id_a);
1036            cell.hyperlink = id_a;
1037        }
1038        assert_eq!(reg.get(id_a), Some("https://a.test"));
1039
1040        // Push A then push B, evicting A. Release references from the evicted line.
1041        let _ = sb.push_row(&row_a, false);
1042        let row_b = vec![Cell::new('b')];
1043        let evicted = sb.push_row(&row_b, false).expect("capacity=1 must evict");
1044        reg.release_cells(&evicted.cells);
1045
1046        // A should be gone after all references were released.
1047        assert_eq!(reg.get(id_a), None);
1048    }
1049
1050    // ── Combining mark support ────────────────────────────────────────
1051
1052    #[test]
1053    fn push_combining_stores_mark() {
1054        let mut cell = Cell::new('e');
1055        assert!(!cell.has_combining());
1056        assert!(cell.combining_marks().is_empty());
1057
1058        assert!(cell.push_combining('\u{0301}')); // combining acute accent
1059        assert!(cell.has_combining());
1060        assert_eq!(cell.combining_marks(), &['\u{0301}']);
1061        assert!(cell.flags.contains(CellFlags::HAS_COMBINING));
1062    }
1063
1064    #[test]
1065    fn push_combining_multiple_marks() {
1066        let mut cell = Cell::new('a');
1067        assert!(cell.push_combining('\u{0300}')); // combining grave
1068        assert!(cell.push_combining('\u{0301}')); // combining acute
1069        assert_eq!(cell.combining_marks(), &['\u{0300}', '\u{0301}']);
1070    }
1071
1072    #[test]
1073    fn push_combining_overflow_returns_false() {
1074        let mut cell = Cell::new('a');
1075        let marks = ['\u{0300}', '\u{0301}', '\u{0302}', '\u{0303}'];
1076        for mark in &marks[..MAX_COMBINING] {
1077            assert!(cell.push_combining(*mark));
1078        }
1079        // Buffer full — next push returns false.
1080        assert!(!cell.push_combining(marks[MAX_COMBINING]));
1081        assert_eq!(cell.combining_marks().len(), MAX_COMBINING);
1082    }
1083
1084    #[test]
1085    fn set_content_clears_combining() {
1086        let mut cell = Cell::new('e');
1087        cell.push_combining('\u{0301}');
1088        assert!(cell.has_combining());
1089
1090        cell.set_content('x', 1);
1091        assert!(!cell.has_combining());
1092        assert!(cell.combining_marks().is_empty());
1093        assert!(!cell.flags.contains(CellFlags::HAS_COMBINING));
1094    }
1095
1096    #[test]
1097    fn erase_clears_combining() {
1098        let mut cell = Cell::new('e');
1099        cell.push_combining('\u{0301}');
1100        cell.erase(Color::Default);
1101        assert!(!cell.has_combining());
1102        assert!(cell.combining_marks().is_empty());
1103    }
1104
1105    #[test]
1106    fn clear_clears_combining() {
1107        let mut cell = Cell::new('e');
1108        cell.push_combining('\u{0301}');
1109        cell.clear();
1110        assert!(!cell.has_combining());
1111        assert_eq!(cell, Cell::default());
1112    }
1113
1114    #[test]
1115    fn combining_on_wide_char() {
1116        let (mut lead, _) = Cell::wide('中', SgrAttrs::default());
1117        assert!(lead.push_combining('\u{0300}'));
1118        assert_eq!(lead.combining_marks(), &['\u{0300}']);
1119        assert_eq!(lead.content(), '中');
1120        assert!(lead.is_wide());
1121    }
1122
1123    #[test]
1124    fn default_cell_has_no_combining() {
1125        let cell = Cell::default();
1126        assert!(!cell.has_combining());
1127        assert!(cell.combining_marks().is_empty());
1128    }
1129}