Skip to main content

ftui_render/
buffer.rs

1#![forbid(unsafe_code)]
2
3//! Buffer grid storage.
4//!
5//! The `Buffer` is a 2D grid of [`Cell`]s representing the terminal display.
6//! It provides efficient cell access, scissor (clipping) regions, and opacity
7//! stacks for compositing.
8//!
9//! # Layout
10//!
11//! Cells are stored in row-major order: `index = y * width + x`.
12//!
13//! # Invariants
14//!
15//! 1. `cells.len() == width * height`
16//! 2. Width and height never change after creation
17//! 3. Scissor stack intersection monotonically decreases on push
18//! 4. Opacity stack product stays in `[0.0, 1.0]`
19//! 5. Scissor/opacity stacks always have at least one element
20//!
21//! # Dirty Row Tracking (bd-4kq0.1.1)
22//!
23//! ## Mathematical Invariant
24//!
25//! Let D be the set of dirty rows. The fundamental soundness property:
26//!
27//! ```text
28//! ∀ y ∈ [0, height): if ∃ x such that old(x, y) ≠ new(x, y), then y ∈ D
29//! ```
30//!
31//! This ensures the diff algorithm can safely skip non-dirty rows without
32//! missing any changes. The invariant is maintained by marking rows dirty
33//! on every cell mutation.
34//!
35//! ## Bookkeeping Cost
36//!
37//! - O(1) per mutation (single array write)
38//! - O(height) space for dirty bitmap
39//! - Target: < 2% overhead vs baseline rendering
40//!
41//! # Dirty Span Tracking (bd-3e1t.6.2)
42//!
43//! Dirty spans refine dirty rows by recording per-row x-ranges of mutations.
44//!
45//! ## Invariant
46//!
47//! ```text
48//! ∀ (x, y) mutated since last clear, ∃ span in row y with x ∈ [x0, x1)
49//! ```
50//!
51//! Spans are sorted, non-overlapping, and merged when overlapping, adjacent, or separated
52//! by at most `DIRTY_SPAN_MERGE_GAP` cells (gap becomes dirty). If a row exceeds
53//! `DIRTY_SPAN_MAX_SPANS_PER_ROW`, it falls back to full-row scan.
54
55use smallvec::SmallVec;
56
57use crate::budget::DegradationLevel;
58use crate::cell::{Cell, GraphemeId};
59use ftui_core::geometry::Rect;
60
61/// Maximum number of dirty spans per row before falling back to full-row scan.
62const DIRTY_SPAN_MAX_SPANS_PER_ROW: usize = 64;
63/// Merge spans when the gap between them is at most this many cells.
64const DIRTY_SPAN_MERGE_GAP: u16 = 1;
65
66/// Configuration for dirty-span tracking.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct DirtySpanConfig {
69    /// Enable dirty-span tracking (used by diff).
70    pub enabled: bool,
71    /// Maximum spans per row before falling back to full-row scan.
72    pub max_spans_per_row: usize,
73    /// Merge spans when the gap between them is at most this many cells.
74    pub merge_gap: u16,
75    /// Expand spans by this many cells on each side.
76    pub guard_band: u16,
77}
78
79impl Default for DirtySpanConfig {
80    fn default() -> Self {
81        Self {
82            enabled: true,
83            max_spans_per_row: DIRTY_SPAN_MAX_SPANS_PER_ROW,
84            merge_gap: DIRTY_SPAN_MERGE_GAP,
85            guard_band: 0,
86        }
87    }
88}
89
90impl DirtySpanConfig {
91    /// Toggle dirty-span tracking.
92    #[must_use]
93    pub fn with_enabled(mut self, enabled: bool) -> Self {
94        self.enabled = enabled;
95        self
96    }
97
98    /// Set max spans per row before fallback.
99    #[must_use]
100    pub fn with_max_spans_per_row(mut self, max_spans: usize) -> Self {
101        self.max_spans_per_row = max_spans;
102        self
103    }
104
105    /// Set merge gap threshold.
106    #[must_use]
107    pub fn with_merge_gap(mut self, merge_gap: u16) -> Self {
108        self.merge_gap = merge_gap;
109        self
110    }
111
112    /// Set guard band expansion (cells).
113    #[must_use]
114    pub fn with_guard_band(mut self, guard_band: u16) -> Self {
115        self.guard_band = guard_band;
116        self
117    }
118}
119
120/// Half-open dirty span [x0, x1) for a single row.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub(crate) struct DirtySpan {
123    pub x0: u16,
124    pub x1: u16,
125}
126
127impl DirtySpan {
128    #[inline]
129    pub const fn new(x0: u16, x1: u16) -> Self {
130        Self { x0, x1 }
131    }
132
133    #[inline]
134    pub const fn len(self) -> usize {
135        self.x1.saturating_sub(self.x0) as usize
136    }
137}
138
139#[derive(Debug, Default, Clone)]
140pub(crate) struct DirtySpanRow {
141    overflow: bool,
142    /// Inline storage for up to 4 spans (16 bytes) avoids heap allocation for ~90% of rows.
143    spans: SmallVec<[DirtySpan; 4]>,
144}
145
146impl DirtySpanRow {
147    #[inline]
148    fn new_full() -> Self {
149        Self {
150            overflow: true,
151            spans: SmallVec::new(),
152        }
153    }
154
155    #[inline]
156    fn clear(&mut self) {
157        self.overflow = false;
158        self.spans.clear();
159    }
160
161    #[inline]
162    fn set_full(&mut self) {
163        self.overflow = true;
164        self.spans.clear();
165    }
166
167    #[inline]
168    pub(crate) fn spans(&self) -> &[DirtySpan] {
169        &self.spans
170    }
171
172    #[inline]
173    pub(crate) fn is_full(&self) -> bool {
174        self.overflow
175    }
176}
177
178/// Dirty-span statistics for logging/telemetry.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub struct DirtySpanStats {
181    /// Rows marked as full-row dirty.
182    pub rows_full_dirty: usize,
183    /// Rows with at least one span.
184    pub rows_with_spans: usize,
185    /// Total number of spans across all rows.
186    pub total_spans: usize,
187    /// Total number of span overflow events since last clear.
188    pub overflows: usize,
189    /// Total coverage in cells (span lengths + full rows).
190    pub span_coverage_cells: usize,
191    /// Maximum span length observed (including full-row spans).
192    pub max_span_len: usize,
193    /// Configured max spans per row.
194    pub max_spans_per_row: usize,
195}
196
197/// A 2D grid of terminal cells.
198///
199/// # Example
200///
201/// ```
202/// use ftui_render::buffer::Buffer;
203/// use ftui_render::cell::Cell;
204///
205/// let mut buffer = Buffer::new(80, 24);
206/// buffer.set(0, 0, Cell::from_char('H'));
207/// buffer.set(1, 0, Cell::from_char('i'));
208/// ```
209#[derive(Debug, Clone)]
210pub struct Buffer {
211    width: u16,
212    height: u16,
213    cells: Vec<Cell>,
214    scissor_stack: Vec<Rect>,
215    opacity_stack: Vec<f32>,
216    /// Current degradation level for this frame.
217    ///
218    /// Widgets read this during rendering to decide how much visual fidelity
219    /// to provide. Set by the runtime before calling `Model::view()`.
220    pub degradation: DegradationLevel,
221    /// Per-row dirty flags for diff optimization.
222    ///
223    /// When a row is marked dirty, the diff algorithm must compare it cell-by-cell.
224    /// Clean rows can be skipped entirely.
225    ///
226    /// Invariant: `dirty_rows.len() == height`
227    dirty_rows: Vec<bool>,
228    /// Per-row dirty span tracking for sparse diff scans.
229    dirty_spans: Vec<DirtySpanRow>,
230    /// Dirty-span tracking configuration.
231    dirty_span_config: DirtySpanConfig,
232    /// Number of span overflow events since the last `clear_dirty()`.
233    dirty_span_overflows: usize,
234    /// Per-cell dirty bitmap for tile-based diff skipping.
235    dirty_bits: Vec<u8>,
236    /// Count of dirty cells tracked in the bitmap.
237    dirty_cells: usize,
238    /// Whether the whole buffer is marked dirty (bitmap may be stale).
239    dirty_all: bool,
240}
241
242impl Buffer {
243    /// Create a new buffer with the given dimensions.
244    ///
245    /// All cells are initialized to the default (empty cell with white
246    /// foreground and transparent background).
247    ///
248    /// Dimensions are clamped to a minimum of 1x1 to prevent panics during
249    /// extreme window resizes.
250    pub fn new(width: u16, height: u16) -> Self {
251        let width = width.max(1);
252        let height = height.max(1);
253
254        let size = width as usize * height as usize;
255        let cells = vec![Cell::default(); size];
256
257        let dirty_spans = (0..height)
258            .map(|_| DirtySpanRow::new_full())
259            .collect::<Vec<_>>();
260        let dirty_bits = vec![0u8; size];
261        let dirty_cells = size;
262        let dirty_all = true;
263
264        Self {
265            width,
266            height,
267            cells,
268            scissor_stack: vec![Rect::from_size(width, height)],
269            opacity_stack: vec![1.0],
270            degradation: DegradationLevel::Full,
271            // All rows start dirty to ensure initial diffs against this buffer
272            // (e.g. from DoubleBuffer resize) correctly identify it as changed/empty.
273            dirty_rows: vec![true; height as usize],
274            // Start with full-row dirty spans to force initial full scan.
275            dirty_spans,
276            dirty_span_config: DirtySpanConfig::default(),
277            dirty_span_overflows: 0,
278            dirty_bits,
279            dirty_cells,
280            dirty_all,
281        }
282    }
283
284    /// Buffer width in cells.
285    #[inline]
286    pub const fn width(&self) -> u16 {
287        self.width
288    }
289
290    /// Buffer height in cells.
291    #[inline]
292    pub const fn height(&self) -> u16 {
293        self.height
294    }
295
296    /// Total number of cells.
297    #[inline]
298    pub fn len(&self) -> usize {
299        self.cells.len()
300    }
301
302    /// Check if the buffer is empty (should never be true for valid buffers).
303    #[inline]
304    pub fn is_empty(&self) -> bool {
305        self.cells.is_empty()
306    }
307
308    /// Bounding rect of the entire buffer.
309    #[inline]
310    pub const fn bounds(&self) -> Rect {
311        Rect::from_size(self.width, self.height)
312    }
313
314    /// Return the height of content (last non-empty row + 1).
315    ///
316    /// Rows are considered empty only if all cells are the default cell.
317    /// Returns 0 if the buffer contains no content.
318    #[inline]
319    pub fn content_height(&self) -> u16 {
320        let default_cell = Cell::default();
321        let width = self.width as usize;
322        for y in (0..self.height).rev() {
323            let row_start = y as usize * width;
324            let row_end = row_start + width;
325            if self.cells[row_start..row_end]
326                .iter()
327                .any(|cell| *cell != default_cell)
328            {
329                return y + 1;
330            }
331        }
332        0
333    }
334
335    // ----- Dirty Tracking API -----
336
337    /// Mark a row as dirty (modified since last clear).
338    ///
339    /// This is O(1) and must be called on every cell mutation to maintain
340    /// the dirty-soundness invariant.
341    #[inline]
342    fn mark_dirty_row(&mut self, y: u16) {
343        if let Some(slot) = self.dirty_rows.get_mut(y as usize) {
344            *slot = true;
345        }
346    }
347
348    /// Mark a range of cells in a row as dirty in the bitmap (end exclusive).
349    #[inline]
350    fn mark_dirty_bits_range(&mut self, y: u16, start: u16, end: u16) {
351        if self.dirty_all {
352            return;
353        }
354        if y >= self.height {
355            return;
356        }
357
358        let width = self.width;
359        if start >= width {
360            return;
361        }
362        let end = end.min(width);
363        if start >= end {
364            return;
365        }
366
367        let row_start = y as usize * width as usize;
368        let slice = &mut self.dirty_bits[row_start + start as usize..row_start + end as usize];
369        let newly_dirty = slice.iter().filter(|&&b| b == 0).count();
370        slice.fill(1);
371        self.dirty_cells = self.dirty_cells.saturating_add(newly_dirty);
372    }
373
374    /// Mark an entire row as dirty in the bitmap.
375    #[inline]
376    fn mark_dirty_bits_row(&mut self, y: u16) {
377        self.mark_dirty_bits_range(y, 0, self.width);
378    }
379
380    /// Mark a row as fully dirty (full scan).
381    #[inline]
382    fn mark_dirty_row_full(&mut self, y: u16) {
383        self.mark_dirty_row(y);
384        if self.dirty_span_config.enabled
385            && let Some(row) = self.dirty_spans.get_mut(y as usize)
386        {
387            row.set_full();
388        }
389        self.mark_dirty_bits_row(y);
390    }
391
392    /// Mark a span within a row as dirty (half-open).
393    #[inline]
394    pub(crate) fn mark_dirty_span(&mut self, y: u16, x0: u16, x1: u16) {
395        self.mark_dirty_row(y);
396        let width = self.width;
397        let (start, mut end) = if x0 <= x1 { (x0, x1) } else { (x1, x0) };
398        if start >= width {
399            return;
400        }
401        if end > width {
402            end = width;
403        }
404        if start >= end {
405            return;
406        }
407
408        self.mark_dirty_bits_range(y, start, end);
409
410        if !self.dirty_span_config.enabled {
411            return;
412        }
413
414        let guard_band = self.dirty_span_config.guard_band;
415        let span_start = start.saturating_sub(guard_band);
416        let mut span_end = end.saturating_add(guard_band);
417        if span_end > width {
418            span_end = width;
419        }
420        if span_start >= span_end {
421            return;
422        }
423
424        let Some(row) = self.dirty_spans.get_mut(y as usize) else {
425            return;
426        };
427
428        if row.is_full() {
429            return;
430        }
431
432        let new_span = DirtySpan::new(span_start, span_end);
433        let spans = &mut row.spans;
434        let insert_at = spans.partition_point(|span| span.x0 <= new_span.x0);
435        spans.insert(insert_at, new_span);
436
437        // Merge overlapping or near-adjacent spans (gap <= merge_gap).
438        let merge_gap = self.dirty_span_config.merge_gap;
439        let mut i = if insert_at > 0 { insert_at - 1 } else { 0 };
440        while i + 1 < spans.len() {
441            let current = spans[i];
442            let next = spans[i + 1];
443            let merge_limit = current.x1.saturating_add(merge_gap);
444            if merge_limit >= next.x0 {
445                spans[i].x1 = current.x1.max(next.x1);
446                spans.remove(i + 1);
447                continue;
448            }
449            i += 1;
450        }
451
452        if spans.len() > self.dirty_span_config.max_spans_per_row {
453            row.set_full();
454            self.dirty_span_overflows = self.dirty_span_overflows.saturating_add(1);
455        }
456    }
457
458    /// Mark all rows as dirty (e.g., after a full clear or bulk write).
459    #[inline]
460    pub fn mark_all_dirty(&mut self) {
461        self.dirty_rows.fill(true);
462        if self.dirty_span_config.enabled {
463            for row in &mut self.dirty_spans {
464                row.set_full();
465            }
466        } else {
467            for row in &mut self.dirty_spans {
468                row.clear();
469            }
470        }
471        self.dirty_all = true;
472        self.dirty_cells = self.cells.len();
473    }
474
475    /// Reset all dirty flags and spans to clean.
476    ///
477    /// Call this after the diff has consumed the dirty state (between frames).
478    #[inline]
479    pub fn clear_dirty(&mut self) {
480        self.dirty_rows.fill(false);
481        for row in &mut self.dirty_spans {
482            row.clear();
483        }
484        self.dirty_span_overflows = 0;
485        self.dirty_bits.fill(0);
486        self.dirty_cells = 0;
487        self.dirty_all = false;
488    }
489
490    /// Check if a specific row is dirty.
491    #[inline]
492    pub fn is_row_dirty(&self, y: u16) -> bool {
493        self.dirty_rows.get(y as usize).copied().unwrap_or(false)
494    }
495
496    /// Get the dirty row flags as a slice.
497    ///
498    /// Each element corresponds to a row: `true` means the row was modified
499    /// since the last `clear_dirty()` call.
500    #[inline]
501    pub fn dirty_rows(&self) -> &[bool] {
502        &self.dirty_rows
503    }
504
505    /// Count the number of dirty rows.
506    #[inline]
507    pub fn dirty_row_count(&self) -> usize {
508        self.dirty_rows.iter().filter(|&&d| d).count()
509    }
510
511    /// Indices of dirty rows in ascending order (the witness consumed by the
512    /// render-certificate narrow path).
513    #[must_use]
514    pub fn dirty_row_indices(&self) -> Vec<u16> {
515        self.dirty_rows
516            .iter()
517            .enumerate()
518            .filter_map(|(y, &dirty)| dirty.then_some(y as u16))
519            .collect()
520    }
521
522    /// Access the per-cell dirty bitmap (0 = clean, 1 = dirty).
523    #[inline]
524    #[allow(dead_code)]
525    pub(crate) fn dirty_bits(&self) -> &[u8] {
526        &self.dirty_bits
527    }
528
529    /// Count of dirty cells tracked in the bitmap.
530    #[inline]
531    #[allow(dead_code)]
532    pub(crate) fn dirty_cell_count(&self) -> usize {
533        self.dirty_cells
534    }
535
536    /// Whether the whole buffer is marked dirty (bitmap may be stale).
537    #[inline]
538    #[allow(dead_code)]
539    pub(crate) fn dirty_all(&self) -> bool {
540        self.dirty_all
541    }
542
543    /// Access a row's dirty span state.
544    #[inline]
545    #[allow(dead_code)]
546    pub(crate) fn dirty_span_row(&self, y: u16) -> Option<&DirtySpanRow> {
547        if !self.dirty_span_config.enabled {
548            return None;
549        }
550        self.dirty_spans.get(y as usize)
551    }
552
553    /// Summarize dirty-span stats for logging/telemetry.
554    pub fn dirty_span_stats(&self) -> DirtySpanStats {
555        if !self.dirty_span_config.enabled {
556            return DirtySpanStats {
557                rows_full_dirty: 0,
558                rows_with_spans: 0,
559                total_spans: 0,
560                overflows: 0,
561                span_coverage_cells: 0,
562                max_span_len: 0,
563                max_spans_per_row: self.dirty_span_config.max_spans_per_row,
564            };
565        }
566
567        let mut rows_full_dirty = 0usize;
568        let mut rows_with_spans = 0usize;
569        let mut total_spans = 0usize;
570        let mut span_coverage_cells = 0usize;
571        let mut max_span_len = 0usize;
572
573        for row in &self.dirty_spans {
574            if row.is_full() {
575                rows_full_dirty += 1;
576                span_coverage_cells += self.width as usize;
577                max_span_len = max_span_len.max(self.width as usize);
578                continue;
579            }
580            if !row.spans().is_empty() {
581                rows_with_spans += 1;
582            }
583            total_spans += row.spans().len();
584            for span in row.spans() {
585                span_coverage_cells += span.len();
586                max_span_len = max_span_len.max(span.len());
587            }
588        }
589
590        DirtySpanStats {
591            rows_full_dirty,
592            rows_with_spans,
593            total_spans,
594            overflows: self.dirty_span_overflows,
595            span_coverage_cells,
596            max_span_len,
597            max_spans_per_row: self.dirty_span_config.max_spans_per_row,
598        }
599    }
600
601    /// Access the dirty-span configuration.
602    #[inline]
603    pub fn dirty_span_config(&self) -> DirtySpanConfig {
604        self.dirty_span_config
605    }
606
607    /// Update dirty-span configuration.
608    ///
609    /// Existing span records were built under the old config, so they are
610    /// replaced conservatively: every currently dirty row goes to full-row
611    /// dirty. Merely clearing the spans (and the full-row overflow flag)
612    /// would break the span soundness invariant — the diff scans only the
613    /// recorded spans of a dirty row, so pre-existing mutations (including a
614    /// fresh buffer's implicit all-dirty state) would silently stop being
615    /// diffed once new mutations record narrower spans.
616    pub fn set_dirty_span_config(&mut self, config: DirtySpanConfig) {
617        if self.dirty_span_config == config {
618            return;
619        }
620        self.dirty_span_config = config;
621        for (y, row) in self.dirty_spans.iter_mut().enumerate() {
622            if self.dirty_rows.get(y).copied().unwrap_or(false) {
623                row.set_full();
624            } else {
625                row.clear();
626            }
627        }
628        self.dirty_span_overflows = 0;
629    }
630
631    // ----- Coordinate Helpers -----
632
633    /// Convert (x, y) coordinates to a linear index.
634    ///
635    /// Returns `None` if coordinates are out of bounds.
636    #[inline]
637    fn index(&self, x: u16, y: u16) -> Option<usize> {
638        if x < self.width && y < self.height {
639            Some(y as usize * self.width as usize + x as usize)
640        } else {
641            None
642        }
643    }
644
645    /// Convert (x, y) coordinates to a linear index without bounds checking.
646    ///
647    /// # Safety
648    ///
649    /// Caller must ensure x < width and y < height.
650    #[inline]
651    pub(crate) fn index_unchecked(&self, x: u16, y: u16) -> usize {
652        debug_assert!(x < self.width && y < self.height);
653        y as usize * self.width as usize + x as usize
654    }
655
656    /// Get mutable reference to a cell at a linear index without bounds checking.
657    ///
658    /// # Safety
659    ///
660    /// Caller must ensure idx < width * height and handle dirty tracking manually.
661    #[inline]
662    pub(crate) fn cell_mut_unchecked(&mut self, idx: usize) -> &mut Cell {
663        &mut self.cells[idx]
664    }
665
666    /// Get a reference to the cell at (x, y).
667    ///
668    /// Returns `None` if coordinates are out of bounds.
669    #[inline]
670    #[must_use]
671    pub fn get(&self, x: u16, y: u16) -> Option<&Cell> {
672        self.index(x, y).map(|i| &self.cells[i])
673    }
674
675    /// Get a mutable reference to the cell at (x, y).
676    ///
677    /// Returns `None` if coordinates are out of bounds.
678    /// Proactively marks the row dirty since the caller may mutate the cell.
679    #[inline]
680    #[must_use]
681    pub fn get_mut(&mut self, x: u16, y: u16) -> Option<&mut Cell> {
682        let idx = self.index(x, y)?;
683        self.mark_dirty_span(y, x, x.saturating_add(1));
684        Some(&mut self.cells[idx])
685    }
686
687    /// Get a reference to the cell at (x, y) without bounds checking.
688    ///
689    /// # Panics
690    ///
691    /// Panics in debug mode if coordinates are out of bounds.
692    /// May cause undefined behavior in release mode if out of bounds.
693    #[inline]
694    pub fn get_unchecked(&self, x: u16, y: u16) -> &Cell {
695        let i = self.index_unchecked(x, y);
696        &self.cells[i]
697    }
698
699    /// Helper to clean up overlapping multi-width cells before writing.
700    ///
701    /// Returns the half-open span of any cells cleared by this cleanup.
702    #[inline]
703    fn cleanup_overlap(&mut self, x: u16, y: u16, new_cell: &Cell) -> Option<DirtySpan> {
704        let idx = self.index(x, y)?;
705        let current = self.cells[idx];
706        let mut touched = false;
707        let mut min_x = x;
708        let mut max_x = x;
709
710        // Case 1: Overwriting a Wide Head
711        if current.content.width() > 1 {
712            let width = current.content.width();
713            // Clear the head
714            // self.cells[idx] = Cell::default(); // Caller (set) will overwrite this, but for correctness/safety we could.
715            // Actually, `set` overwrites `cells[idx]` immediately after.
716            // But we must clear the tails.
717            for i in 1..width {
718                let Some(cx) = x.checked_add(i as u16) else {
719                    break;
720                };
721                if let Some(tail_idx) = self.index(cx, y)
722                    && self.cells[tail_idx].is_continuation()
723                {
724                    self.cells[tail_idx] = Cell::default();
725                    touched = true;
726                    min_x = min_x.min(cx);
727                    max_x = max_x.max(cx);
728                }
729            }
730        }
731        // Case 2: Overwriting a Continuation
732        else if current.is_continuation() && !new_cell.is_continuation() {
733            let mut back_x = x;
734            // Limit scan to max possible grapheme width to avoid O(N) scan on rows
735            // filled with orphaned continuations.
736            let limit = x.saturating_sub(GraphemeId::MAX_WIDTH as u16);
737
738            while back_x > limit {
739                back_x -= 1;
740                if let Some(h_idx) = self.index(back_x, y) {
741                    let h_cell = self.cells[h_idx];
742                    if !h_cell.is_continuation() {
743                        // Found the potential head
744                        let width = h_cell.content.width();
745                        if (back_x as usize + width) > x as usize {
746                            // This head owns the cell we are overwriting.
747                            // Clear the head.
748                            self.cells[h_idx] = Cell::default();
749                            touched = true;
750                            min_x = min_x.min(back_x);
751                            max_x = max_x.max(back_x);
752
753                            // Clear all its tails (except the one we're about to write, effectively)
754                            // We just iterate 1..width and clear CONTs.
755                            for i in 1..width {
756                                let Some(cx) = back_x.checked_add(i as u16) else {
757                                    break;
758                                };
759                                if let Some(tail_idx) = self.index(cx, y) {
760                                    // Note: tail_idx might be our current `idx`.
761                                    // We can clear it; `set` will overwrite it in a moment.
762                                    if self.cells[tail_idx].is_continuation() {
763                                        self.cells[tail_idx] = Cell::default();
764                                        touched = true;
765                                        min_x = min_x.min(cx);
766                                        max_x = max_x.max(cx);
767                                    }
768                                }
769                            }
770                        }
771                        break;
772                    }
773                }
774            }
775        }
776
777        if touched {
778            Some(DirtySpan::new(min_x, max_x.saturating_add(1)))
779        } else {
780            None
781        }
782    }
783
784    /// Helper to clean up orphaned continuation cells to the right of a write.
785    ///
786    /// If we write a cell at `x`, and `x+1` contains a continuation cell that
787    /// is NOT owned by `x` (which is guaranteed since we just wrote `x`),
788    /// then `x+1` (and subsequent continuations) are orphans. This method
789    /// scans forward and clears them to prevent visual artifacts.
790    #[inline]
791    fn cleanup_orphaned_tails(&mut self, start_x: u16, y: u16) {
792        if start_x >= self.width {
793            return;
794        }
795
796        // Optimization: check first cell without loop overhead
797        let Some(idx) = self.index(start_x, y) else {
798            return;
799        };
800        if !self.cells[idx].is_continuation() {
801            return;
802        }
803
804        // Found an orphan, start scanning
805        let mut x = start_x;
806        let mut max_x = x;
807        let row_end_idx = (y as usize * self.width as usize) + self.width as usize;
808        let mut curr_idx = idx;
809
810        while curr_idx < row_end_idx && self.cells[curr_idx].is_continuation() {
811            self.cells[curr_idx] = Cell::default();
812            max_x = x;
813            x = x.saturating_add(1);
814            curr_idx += 1;
815        }
816
817        // Mark the cleared range as dirty
818        self.mark_dirty_span(y, start_x, max_x.saturating_add(1));
819    }
820
821    /// Fast-path cell write for the common case.
822    ///
823    /// Bypasses scissor intersection, opacity blending, and overlap cleanup
824    /// when all of the following hold:
825    ///
826    /// - The cell is single-width (`width() <= 1`) and not a continuation
827    /// - The cell background is either fully opaque or fully transparent
828    ///   (`bg.a() == 255 || bg.a() == 0`)
829    /// - Only the base scissor is active (no nested push)
830    /// - Only the base opacity is active (no nested push)
831    /// - The existing cell at the target is also single-width and not a continuation
832    ///
833    /// Falls through to `set()` for any non-trivial case, so behavior is
834    /// always identical to calling `set()` directly.
835    #[inline]
836    pub fn set_fast(&mut self, x: u16, y: u16, cell: Cell) {
837        // Bail to full path for wide, continuation, or non-trivial bg alpha cells.
838        // Must use width() not width_hint(): width_hint() returns 1 for all
839        // direct chars including CJK, but width() does a proper unicode lookup.
840        // set() always composites bg over the existing cell (src-over). We can
841        // skip compositing only when bg alpha is 255 (result is bg) or 0 (result
842        // is existing bg).
843        let bg_a = cell.bg.a();
844        if cell.content.width() > 1 || cell.is_continuation() || (bg_a != 255 && bg_a != 0) {
845            return self.set(x, y, cell);
846        }
847
848        // Bail if scissor or opacity stacks are non-trivial
849        if self.scissor_stack.len() != 1 || self.opacity_stack.len() != 1 {
850            return self.set(x, y, cell);
851        }
852
853        // Bounds check
854        let Some(idx) = self.index(x, y) else {
855            return;
856        };
857
858        // Check that existing cell doesn't need overlap cleanup.
859        // Must use width() for the same reason: a CJK direct char at this
860        // position would have width() == 2 with a continuation at x+1.
861        let existing = self.cells[idx];
862        if existing.content.width() > 1 || existing.is_continuation() {
863            return self.set(x, y, cell);
864        }
865
866        // All fast-path conditions met: direct write.
867        //
868        // bg compositing is safe to skip:
869        // - alpha 255: bg.over(existing_bg) == bg
870        // - alpha 0: bg.over(existing_bg) == existing_bg
871        let mut final_cell = cell;
872        if bg_a == 0 {
873            final_cell.bg = existing.bg;
874        }
875
876        self.cells[idx] = final_cell;
877        self.mark_dirty_span(y, x, x.saturating_add(1));
878        self.cleanup_orphaned_tails(x.saturating_add(1), y);
879    }
880
881    /// Set the cell at (x, y).
882    ///
883    /// This method:
884    /// - Respects the current scissor region (skips if outside)
885    /// - Applies the current opacity stack to cell colors
886    /// - Does nothing if coordinates are out of bounds
887    /// - **Automatically sets CONTINUATION cells** for multi-width content
888    /// - **Atomic wide writes**: If a wide character doesn't fully fit in the
889    ///   scissor region/bounds, NOTHING is written.
890    ///
891    /// For bulk operations without scissor/opacity/safety, use `set_raw`.
892    #[inline]
893    pub fn set(&mut self, x: u16, y: u16, cell: Cell) {
894        let width = cell.content.width();
895
896        // Single cell fast path (width 0 or 1)
897        if width <= 1 {
898            // Check bounds
899            let Some(idx) = self.index(x, y) else {
900                return;
901            };
902
903            // Check scissor region
904            if !self.current_scissor().contains(x, y) {
905                return;
906            }
907
908            // Cleanup overlaps and track any cleared span.
909            let mut span_start = x;
910            let mut span_end = x.saturating_add(1);
911            if let Some(span) = self.cleanup_overlap(x, y, &cell) {
912                span_start = span_start.min(span.x0);
913                span_end = span_end.max(span.x1);
914            }
915
916            let existing_bg = self.cells[idx].bg;
917
918            // Apply opacity to the incoming cell, then composite over existing background.
919            let mut final_cell = if self.current_opacity() < 1.0 {
920                let opacity = self.current_opacity();
921                Cell {
922                    fg: cell.fg.with_opacity(opacity),
923                    bg: cell.bg.with_opacity(opacity),
924                    ..cell
925                }
926            } else {
927                cell
928            };
929
930            final_cell.bg = final_cell.bg.over(existing_bg);
931
932            self.cells[idx] = final_cell;
933            self.mark_dirty_span(y, span_start, span_end);
934            self.cleanup_orphaned_tails(x.saturating_add(1), y);
935            return;
936        }
937
938        // Multi-width character atomicity check
939        // Ensure ALL cells (head + tail) are within bounds and scissor
940        let scissor = self.current_scissor();
941        for i in 0..width {
942            let Some(cx) = x.checked_add(i as u16) else {
943                return;
944            };
945            // Check bounds
946            if cx >= self.width || y >= self.height {
947                return;
948            }
949            // Check scissor
950            if !scissor.contains(cx, y) {
951                return;
952            }
953        }
954
955        // If we get here, it's safe to write everything.
956
957        // Cleanup overlaps for all cells and track any cleared span.
958        let mut span_start = x;
959        let mut span_end = x.saturating_add(width as u16);
960        if let Some(span) = self.cleanup_overlap(x, y, &cell) {
961            span_start = span_start.min(span.x0);
962            span_end = span_end.max(span.x1);
963        }
964        for i in 1..width {
965            // Safe: atomicity check above verified x + i fits in u16
966            if let Some(span) = self.cleanup_overlap(x + i as u16, y, &Cell::CONTINUATION) {
967                span_start = span_start.min(span.x0);
968                span_end = span_end.max(span.x1);
969            }
970        }
971
972        // 1. Write Head
973        let idx = self.index_unchecked(x, y);
974        let old_cell = self.cells[idx];
975        let mut final_cell = if self.current_opacity() < 1.0 {
976            let opacity = self.current_opacity();
977            Cell {
978                fg: cell.fg.with_opacity(opacity),
979                bg: cell.bg.with_opacity(opacity),
980                ..cell
981            }
982        } else {
983            cell
984        };
985
986        // Composite background (src over dst)
987        final_cell.bg = final_cell.bg.over(old_cell.bg);
988
989        self.cells[idx] = final_cell;
990
991        // 2. Write Tail (Continuation cells)
992        // We can use set_raw-like access because we already verified bounds
993        for i in 1..width {
994            let idx = self.index_unchecked(x + i as u16, y);
995            self.cells[idx] = Cell::CONTINUATION;
996        }
997        self.mark_dirty_span(y, span_start, span_end);
998        self.cleanup_orphaned_tails(x.saturating_add(width as u16), y);
999    }
1000
1001    /// Set the cell at (x, y) without scissor or opacity processing.
1002    ///
1003    /// This is faster but bypasses clipping and transparency.
1004    ///
1005    /// Unlike [`set`](Self::set), this does not automatically write
1006    /// continuation cells for multi-width content; callers that build wide
1007    /// glyphs manually must still populate the tail cells themselves. For
1008    /// single-width and continuation writes, it still preserves
1009    /// overlap/orphan-tail cleanup so stale continuation cells are not left
1010    /// behind. Raw wide-head writes remain strictly local so callers can
1011    /// manage continuation ownership explicitly.
1012    /// Does nothing if coordinates are out of bounds.
1013    #[inline]
1014    pub fn set_raw(&mut self, x: u16, y: u16, cell: Cell) {
1015        if let Some(idx) = self.index(x, y) {
1016            let mut span = DirtySpan::new(x, x.saturating_add(1));
1017            let raw_wide_head = cell.content.width() > 1 && !cell.is_continuation();
1018
1019            if !raw_wide_head && let Some(cleanup_span) = self.cleanup_overlap(x, y, &cell) {
1020                span = DirtySpan::new(span.x0.min(cleanup_span.x0), span.x1.max(cleanup_span.x1));
1021            }
1022            self.cells[idx] = cell;
1023            self.mark_dirty_span(y, span.x0, span.x1);
1024            if !raw_wide_head {
1025                // The orphan sweep's premise ("a continuation at x+1 cannot
1026                // be owned by x") only holds for non-continuation writes.
1027                // When the written cell is itself a continuation, x belongs
1028                // to a head on its left whose further tails (x+1, ...) are
1029                // legitimate — sweeping from x+1 would corrupt a width>2
1030                // glyph (e.g. rewriting the first tail of a width-3 glyph
1031                // used to clear its second tail). Sweep only beyond the
1032                // owning head's extent.
1033                let sweep_from = if cell.is_continuation() {
1034                    self.continuation_owner_extent(x, y)
1035                        .unwrap_or_else(|| x.saturating_add(1))
1036                } else {
1037                    x.saturating_add(1)
1038                };
1039                self.cleanup_orphaned_tails(sweep_from, y);
1040            }
1041        }
1042    }
1043
1044    /// Find the exclusive end column of the glyph owning the continuation at
1045    /// `x`, scanning left at most `GraphemeId::MAX_WIDTH` cells. Returns
1046    /// `None` when the continuation is orphaned (no owning head).
1047    fn continuation_owner_extent(&self, x: u16, y: u16) -> Option<u16> {
1048        let limit = x.saturating_sub(GraphemeId::MAX_WIDTH as u16);
1049        let mut back_x = x;
1050        while back_x > limit {
1051            back_x -= 1;
1052            let idx = self.index(back_x, y)?;
1053            let cell = self.cells[idx];
1054            if !cell.is_continuation() {
1055                let end = back_x.saturating_add(cell.content.width() as u16);
1056                return (end > x).then_some(end);
1057            }
1058        }
1059        None
1060    }
1061
1062    /// Fill a rectangular region with the given cell.
1063    ///
1064    /// Respects scissor region and applies opacity.
1065    #[inline]
1066    pub fn fill(&mut self, rect: Rect, cell: Cell) {
1067        let clipped = self.current_scissor().intersection(&rect);
1068        if clipped.is_empty() {
1069            return;
1070        }
1071
1072        // Fast path: full-row fill with an opaque, single-width cell and no opacity.
1073        // Safe because every cell in the row is overwritten, and no blending is required.
1074        let cell_width = cell.content.width();
1075        if cell_width <= 1
1076            && !cell.is_continuation()
1077            && self.current_opacity() >= 1.0
1078            && cell.bg.a() == 255
1079            && clipped.x == 0
1080            && clipped.width == self.width
1081        {
1082            let row_width = self.width as usize;
1083            for y in clipped.y..clipped.bottom() {
1084                let row_start = y as usize * row_width;
1085                let row_end = row_start + row_width;
1086                self.cells[row_start..row_end].fill(cell);
1087                self.mark_dirty_row_full(y);
1088            }
1089            return;
1090        }
1091
1092        // Medium path: partial-width fill with opaque, single-width cell, base scissor/opacity.
1093        // Direct slice::fill per row instead of per-cell set(). We only need to handle
1094        // wide-char fragments at the fill boundaries (interior cells are fully overwritten).
1095        if cell_width <= 1
1096            && !cell.is_continuation()
1097            && self.current_opacity() >= 1.0
1098            && cell.bg.a() == 255
1099            && self.scissor_stack.len() == 1
1100        {
1101            let row_width = self.width as usize;
1102            let x_start = clipped.x as usize;
1103            let x_end = clipped.right() as usize;
1104            for y in clipped.y..clipped.bottom() {
1105                let row_start = y as usize * row_width;
1106                let mut dirty_left = clipped.x;
1107                let mut dirty_right = clipped.right();
1108
1109                // Left boundary: if first fill cell is a continuation, its wide-char
1110                // head is outside the fill region and would be orphaned. Clear it.
1111                if x_start > 0 && self.cells[row_start + x_start].is_continuation() {
1112                    let mut head_found = None;
1113                    for hx in (0..x_start).rev() {
1114                        if !self.cells[row_start + hx].is_continuation() {
1115                            head_found = Some(hx);
1116                            break;
1117                        }
1118                    }
1119
1120                    if let Some(hx) = head_found {
1121                        let c = self.cells[row_start + hx];
1122                        let width = c.content.width();
1123                        // Only clear if the head actually overlaps the fill region.
1124                        if width > 1 && hx + width > x_start {
1125                            // Clear the head and any tails before x_start.
1126                            // Tails from x_start onwards will be overwritten by the fill.
1127                            for cx in hx..x_start {
1128                                self.cells[row_start + cx] = Cell::default();
1129                            }
1130                            dirty_left = hx as u16;
1131                        }
1132                    }
1133                }
1134
1135                // Right boundary: clear orphaned continuations past the fill whose
1136                // head is being overwritten.
1137                {
1138                    let mut cx = x_end;
1139                    while cx < row_width && self.cells[row_start + cx].is_continuation() {
1140                        self.cells[row_start + cx] = Cell::default();
1141                        dirty_right = (cx as u16).saturating_add(1);
1142                        cx += 1;
1143                    }
1144                }
1145
1146                self.cells[row_start + x_start..row_start + x_end].fill(cell);
1147                self.mark_dirty_span(y, dirty_left, dirty_right);
1148            }
1149            return;
1150        }
1151
1152        // Enforce strict bounds for wide characters to prevent spilling.
1153        self.push_scissor(clipped);
1154
1155        let step = cell.content.width().max(1) as u16;
1156        for y in clipped.y..clipped.bottom() {
1157            // Pre-clear the row span first: `set` drops a wide glyph whose
1158            // tail would cross the clip edge, so trailing columns that cannot
1159            // hold a whole glyph would otherwise keep their previous content
1160            // inside a region the caller asked to be filled (mirrors
1161            // `clear_with`).
1162            for x in clipped.x..clipped.right() {
1163                self.set(x, y, Cell::default());
1164            }
1165            let mut x = clipped.x;
1166            while x < clipped.right() {
1167                self.set(x, y, cell);
1168                x = x.saturating_add(step);
1169            }
1170        }
1171
1172        self.pop_scissor();
1173    }
1174
1175    /// Clear all cells to the default.
1176    #[inline]
1177    pub fn clear(&mut self) {
1178        self.cells.fill(Cell::default());
1179        self.mark_all_dirty();
1180    }
1181
1182    /// Reset per-frame state and clear all cells.
1183    ///
1184    /// This restores scissor/opacity stacks to their base values to ensure
1185    /// each frame starts from a clean rendering state.
1186    pub fn reset_for_frame(&mut self) {
1187        self.scissor_stack.truncate(1);
1188        if let Some(base) = self.scissor_stack.first_mut() {
1189            *base = Rect::from_size(self.width, self.height);
1190        } else {
1191            self.scissor_stack
1192                .push(Rect::from_size(self.width, self.height));
1193        }
1194
1195        self.opacity_stack.truncate(1);
1196        if let Some(base) = self.opacity_stack.first_mut() {
1197            *base = 1.0;
1198        } else {
1199            self.opacity_stack.push(1.0);
1200        }
1201
1202        self.clear();
1203    }
1204
1205    /// Clear all cells to the given cell.
1206    #[inline]
1207    pub fn clear_with(&mut self, cell: Cell) {
1208        if cell.is_continuation() {
1209            self.clear();
1210            return;
1211        }
1212
1213        let width = cell.content.width();
1214        if width <= 1 {
1215            self.cells.fill(cell);
1216            self.mark_all_dirty();
1217            return;
1218        }
1219
1220        self.cells.fill(Cell::default());
1221        let step = width as u16;
1222        for y in 0..self.height {
1223            let row_start = y as usize * self.width as usize;
1224            let mut x = 0u16;
1225            while x.saturating_add(step) <= self.width {
1226                let head_idx = row_start + x as usize;
1227                self.cells[head_idx] = cell;
1228                for off in 1..step {
1229                    self.cells[head_idx + off as usize] = Cell::CONTINUATION;
1230                }
1231                x = x.saturating_add(step);
1232            }
1233        }
1234        self.mark_all_dirty();
1235    }
1236
1237    /// Get raw access to the cell slice.
1238    ///
1239    /// This is useful for diffing against another buffer.
1240    #[inline]
1241    pub fn cells(&self) -> &[Cell] {
1242        &self.cells
1243    }
1244
1245    /// Get mutable raw access to the cell slice.
1246    ///
1247    /// Marks all rows dirty since caller may modify arbitrary cells.
1248    #[inline]
1249    pub fn cells_mut(&mut self) -> &mut [Cell] {
1250        self.mark_all_dirty();
1251        &mut self.cells
1252    }
1253
1254    /// Get the cells for a single row as a slice.
1255    ///
1256    /// # Panics
1257    ///
1258    /// Panics if `y >= height`.
1259    #[inline]
1260    pub fn row_cells(&self, y: u16) -> &[Cell] {
1261        let start = y as usize * self.width as usize;
1262        &self.cells[start..start + self.width as usize]
1263    }
1264
1265    /// Get mutable cells for a contiguous span on a row.
1266    ///
1267    /// The requested range is treated as half-open `[x0, x1)` and clamped to
1268    /// the buffer width. The span is marked dirty once before returning the
1269    /// mutable slice.
1270    ///
1271    /// This is a raw bulk-mutation helper: callers must already have applied
1272    /// any required scissor/opacity clipping and must not use it for writes
1273    /// that can change cell-content width invariants.
1274    #[inline]
1275    pub fn row_cells_mut_span(&mut self, y: u16, x0: u16, x1: u16) -> Option<&mut [Cell]> {
1276        if y >= self.height {
1277            return None;
1278        }
1279        if x0 >= x1 {
1280            return None;
1281        }
1282
1283        let start = x0.min(self.width);
1284        let end = x1.min(self.width);
1285        if start >= end {
1286            return None;
1287        }
1288
1289        self.mark_dirty_span(y, start, end);
1290
1291        let row_start = y as usize * self.width as usize;
1292        let slice_start = row_start + start as usize;
1293        let slice_end = row_start + end as usize;
1294        Some(&mut self.cells[slice_start..slice_end])
1295    }
1296
1297    // ========== Scissor Stack ==========
1298
1299    /// Push a scissor (clipping) region onto the stack.
1300    ///
1301    /// The effective scissor is the intersection of all pushed rects.
1302    /// If the intersection is empty, no cells will be drawn.
1303    #[inline]
1304    pub fn push_scissor(&mut self, rect: Rect) {
1305        let current = self.current_scissor();
1306        let intersected = current.intersection(&rect);
1307        self.scissor_stack.push(intersected);
1308    }
1309
1310    /// Pop a scissor region from the stack.
1311    ///
1312    /// Does nothing if only the base scissor remains.
1313    #[inline]
1314    pub fn pop_scissor(&mut self) {
1315        if self.scissor_stack.len() > 1 {
1316            self.scissor_stack.pop();
1317        }
1318    }
1319
1320    /// Get the current effective scissor region.
1321    #[inline]
1322    pub fn current_scissor(&self) -> Rect {
1323        *self
1324            .scissor_stack
1325            .last()
1326            .expect("scissor stack always has at least one element")
1327    }
1328
1329    /// Get the scissor stack depth.
1330    #[inline]
1331    pub fn scissor_depth(&self) -> usize {
1332        self.scissor_stack.len()
1333    }
1334
1335    // ========== Opacity Stack ==========
1336
1337    /// Push an opacity multiplier onto the stack.
1338    ///
1339    /// The effective opacity is the product of all pushed values.
1340    /// Values are clamped to `[0.0, 1.0]`.
1341    #[inline]
1342    pub fn push_opacity(&mut self, opacity: f32) {
1343        let clamped = opacity.clamp(0.0, 1.0);
1344        let current = self.current_opacity();
1345        self.opacity_stack.push(current * clamped);
1346    }
1347
1348    /// Pop an opacity value from the stack.
1349    ///
1350    /// Does nothing if only the base opacity remains.
1351    #[inline]
1352    pub fn pop_opacity(&mut self) {
1353        if self.opacity_stack.len() > 1 {
1354            self.opacity_stack.pop();
1355        }
1356    }
1357
1358    /// Get the current effective opacity.
1359    #[inline]
1360    pub fn current_opacity(&self) -> f32 {
1361        *self
1362            .opacity_stack
1363            .last()
1364            .expect("opacity stack always has at least one element")
1365    }
1366
1367    /// Get the opacity stack depth.
1368    #[inline]
1369    pub fn opacity_depth(&self) -> usize {
1370        self.opacity_stack.len()
1371    }
1372
1373    // ========== Copying and Diffing ==========
1374
1375    /// Copy a rectangular region from another buffer.
1376    ///
1377    /// Copies cells from `src` at `src_rect` to this buffer at `dst_pos`.
1378    /// Respects scissor region.
1379    pub fn copy_from(&mut self, src: &Buffer, src_rect: Rect, dst_x: u16, dst_y: u16) {
1380        // Enforce strict bounds on the destination area to prevent wide characters
1381        // from leaking outside the requested copy region.
1382        let copy_bounds = Rect::new(dst_x, dst_y, src_rect.width, src_rect.height);
1383        self.push_scissor(copy_bounds);
1384        let clip = self.current_scissor();
1385
1386        for dy in 0..src_rect.height {
1387            // Compute destination y with overflow check
1388            let Some(target_y) = dst_y.checked_add(dy) else {
1389                continue;
1390            };
1391            let Some(sy) = src_rect.y.checked_add(dy) else {
1392                continue;
1393            };
1394
1395            let mut dx = 0u16;
1396            while dx < src_rect.width {
1397                // Compute coordinates with overflow checks
1398                let Some(target_x) = dst_x.checked_add(dx) else {
1399                    dx = dx.saturating_add(1);
1400                    continue;
1401                };
1402                let Some(sx) = src_rect.x.checked_add(dx) else {
1403                    dx = dx.saturating_add(1);
1404                    continue;
1405                };
1406
1407                if let Some(cell) = src.get(sx, sy) {
1408                    // Continuation cells without their head should not be copied.
1409                    // Heads are handled separately and skip over tails, so any
1410                    // continuation we see here is orphaned by the copy region.
1411                    if cell.is_continuation() {
1412                        self.set(target_x, target_y, Cell::default());
1413                        dx = dx.saturating_add(1);
1414                        continue;
1415                    }
1416
1417                    let width = cell.content.width();
1418                    let target_right = target_x.saturating_add(width as u16);
1419
1420                    // Check for clipping.
1421                    // 1. Source clipping: tail extends beyond the source copy region.
1422                    // 2. Destination clipping: tail extends beyond the effective
1423                    //    scissor on the right, OR the head lands left of it while
1424                    //    a tail would land inside (an outer scissor narrower on
1425                    //    the left). Both halves matter: `set` rejects wide writes
1426                    //    atomically, so without the left check the in-clip tail
1427                    //    cell would silently keep its stale content.
1428                    let src_clipped = width > 1 && dx.saturating_add(width as u16) > src_rect.width;
1429                    let dst_clipped = target_right > clip.right()
1430                        || (width > 1 && target_x < clip.left() && target_right > clip.left());
1431
1432                    if src_clipped || dst_clipped {
1433                        // Write default cells to all valid positions in the span to ensure
1434                        // previous content is cleared. `set` is atomic for wide chars,
1435                        // so we must write single-width default cells individually.
1436                        // (`set` clips each single-width write to the scissor, so
1437                        // starting at the head position is safe on the left edge.)
1438                        let valid_width = (clip.right().saturating_sub(target_x)).min(width as u16);
1439                        for i in 0..valid_width {
1440                            self.set(target_x + i, target_y, Cell::default());
1441                        }
1442                    } else {
1443                        self.set(target_x, target_y, *cell);
1444                    }
1445
1446                    // Skip tails in source iteration.
1447                    if width > 1 {
1448                        dx = dx.saturating_add(width as u16);
1449                    } else {
1450                        dx = dx.saturating_add(1);
1451                    }
1452                } else {
1453                    dx = dx.saturating_add(1);
1454                }
1455            }
1456        }
1457
1458        self.pop_scissor();
1459    }
1460
1461    /// Check if two buffers have identical content.
1462    pub fn content_eq(&self, other: &Buffer) -> bool {
1463        self.width == other.width && self.height == other.height && self.cells == other.cells
1464    }
1465}
1466
1467impl Default for Buffer {
1468    /// Create a 1x1 buffer (minimum size).
1469    fn default() -> Self {
1470        Self::new(1, 1)
1471    }
1472}
1473
1474impl PartialEq for Buffer {
1475    fn eq(&self, other: &Self) -> bool {
1476        self.content_eq(other)
1477    }
1478}
1479
1480impl Eq for Buffer {}
1481
1482// ---------------------------------------------------------------------------
1483// DoubleBuffer: O(1) frame swap (bd-1rz0.4.4)
1484// ---------------------------------------------------------------------------
1485
1486/// Double-buffered render target with O(1) swap.
1487///
1488/// Maintains two pre-allocated buffers and swaps between them by flipping an
1489/// index, avoiding the O(width × height) clone that a naive prev/current
1490/// pattern requires.
1491///
1492/// # Invariants
1493///
1494/// 1. Both buffers always have the same dimensions.
1495/// 2. `swap()` is O(1) — it only flips the index, never copies cells.
1496/// 3. After `swap()`, `current_mut().clear()` should be called to prepare
1497///    the new frame buffer.
1498/// 4. `resize()` discards both buffers and returns `true` so callers know
1499///    a full redraw is needed.
1500#[derive(Debug)]
1501pub struct DoubleBuffer {
1502    buffers: [Buffer; 2],
1503    /// Index of the *current* buffer (0 or 1).
1504    current_idx: u8,
1505}
1506
1507// ---------------------------------------------------------------------------
1508// AdaptiveDoubleBuffer: Allocation-efficient resize (bd-1rz0.4.2)
1509// ---------------------------------------------------------------------------
1510
1511/// Over-allocation factor for growth headroom (1.25x = 25% extra capacity).
1512const ADAPTIVE_GROWTH_FACTOR: f32 = 1.25;
1513
1514/// Shrink threshold: only reallocate if new size < this fraction of capacity.
1515/// This prevents thrashing at size boundaries.
1516const ADAPTIVE_SHRINK_THRESHOLD: f32 = 0.50;
1517
1518/// Maximum over-allocation per dimension (prevent excessive memory usage).
1519const ADAPTIVE_MAX_OVERAGE: u16 = 200;
1520
1521/// Adaptive double-buffered render target with allocation efficiency.
1522///
1523/// Wraps `DoubleBuffer` with capacity tracking to minimize allocations during
1524/// resize storms. Key strategies:
1525///
1526/// 1. **Over-allocation headroom**: Allocate slightly more than needed to handle
1527///    minor size increases without reallocation.
1528/// 2. **Shrink threshold**: Only shrink if new size is significantly smaller
1529///    than allocated capacity (prevents thrashing at size boundaries).
1530/// 3. **Logical vs physical dimensions**: Track both the current view size
1531///    and the allocated capacity separately.
1532///
1533/// # Invariants
1534///
1535/// 1. `capacity_width >= logical_width` and `capacity_height >= logical_height`
1536/// 2. Logical dimensions represent the actual usable area for rendering.
1537/// 3. Physical capacity may exceed logical dimensions by up to `ADAPTIVE_GROWTH_FACTOR`.
1538/// 4. Shrink only occurs when logical size drops below `ADAPTIVE_SHRINK_THRESHOLD * capacity`.
1539///
1540/// # Failure Modes
1541///
1542/// | Condition | Behavior | Rationale |
1543/// |-----------|----------|-----------|
1544/// | Capacity overflow | Clamp to u16::MAX | Prevents panic on extreme sizes |
1545/// | Zero dimensions | Delegate to DoubleBuffer (panic) | Invalid state |
1546///
1547/// # Performance
1548///
1549/// - `resize()` is O(1) when the new size fits within capacity.
1550/// - `resize()` is O(width × height) when reallocation is required.
1551/// - Target: < 5% allocation overhead during resize storms.
1552#[derive(Debug)]
1553pub struct AdaptiveDoubleBuffer {
1554    /// The underlying double buffer (may have larger capacity than logical size).
1555    inner: DoubleBuffer,
1556    /// Logical width (the usable rendering area).
1557    logical_width: u16,
1558    /// Logical height (the usable rendering area).
1559    logical_height: u16,
1560    /// Allocated capacity width (>= logical_width).
1561    capacity_width: u16,
1562    /// Allocated capacity height (>= logical_height).
1563    capacity_height: u16,
1564    /// Statistics for observability.
1565    stats: AdaptiveStats,
1566}
1567
1568/// Statistics for adaptive buffer allocation.
1569#[derive(Debug, Clone, Default)]
1570pub struct AdaptiveStats {
1571    /// Number of resize calls that avoided reallocation.
1572    pub resize_avoided: u64,
1573    /// Number of resize calls that required reallocation.
1574    pub resize_reallocated: u64,
1575    /// Number of resize calls for growth.
1576    pub resize_growth: u64,
1577    /// Number of resize calls for shrink.
1578    pub resize_shrink: u64,
1579}
1580
1581impl AdaptiveStats {
1582    /// Reset statistics to zero.
1583    pub fn reset(&mut self) {
1584        *self = Self::default();
1585    }
1586
1587    /// Calculate the reallocation avoidance ratio (higher is better).
1588    pub fn avoidance_ratio(&self) -> f64 {
1589        let total = self.resize_avoided + self.resize_reallocated;
1590        if total == 0 {
1591            1.0
1592        } else {
1593            self.resize_avoided as f64 / total as f64
1594        }
1595    }
1596}
1597
1598impl DoubleBuffer {
1599    /// Create a double buffer with the given dimensions.
1600    ///
1601    /// Both buffers are initialized to default (empty) cells.
1602    /// Dimensions are clamped to a minimum of 1x1.
1603    pub fn new(width: u16, height: u16) -> Self {
1604        Self {
1605            buffers: [Buffer::new(width, height), Buffer::new(width, height)],
1606            current_idx: 0,
1607        }
1608    }
1609
1610    /// O(1) swap: the current buffer becomes previous, and vice versa.
1611    ///
1612    /// After swapping, call `current_mut().clear()` to prepare for the
1613    /// next frame.
1614    #[inline]
1615    pub fn swap(&mut self) {
1616        self.current_idx = 1 - self.current_idx;
1617    }
1618
1619    /// Reference to the current (in-progress) frame buffer.
1620    #[inline]
1621    pub fn current(&self) -> &Buffer {
1622        &self.buffers[self.current_idx as usize]
1623    }
1624
1625    /// Mutable reference to the current (in-progress) frame buffer.
1626    #[inline]
1627    pub fn current_mut(&mut self) -> &mut Buffer {
1628        &mut self.buffers[self.current_idx as usize]
1629    }
1630
1631    /// Reference to the previous (last-presented) frame buffer.
1632    #[inline]
1633    pub fn previous(&self) -> &Buffer {
1634        &self.buffers[(1 - self.current_idx) as usize]
1635    }
1636
1637    /// Mutable reference to the previous (last-presented) frame buffer.
1638    #[inline]
1639    pub fn previous_mut(&mut self) -> &mut Buffer {
1640        &mut self.buffers[(1 - self.current_idx) as usize]
1641    }
1642
1643    /// Width of both buffers.
1644    #[inline]
1645    pub fn width(&self) -> u16 {
1646        self.buffers[0].width()
1647    }
1648
1649    /// Height of both buffers.
1650    #[inline]
1651    pub fn height(&self) -> u16 {
1652        self.buffers[0].height()
1653    }
1654
1655    /// Resize both buffers. Returns `true` if dimensions actually changed.
1656    ///
1657    /// Both buffers are replaced with fresh allocations and the index is
1658    /// reset. Callers should force a full redraw when this returns `true`.
1659    pub fn resize(&mut self, width: u16, height: u16) -> bool {
1660        // Compare against the same ≥1 clamp Buffer::new applies, so a caller
1661        // repeatedly passing a degenerate 0 dimension doesn't reallocate both
1662        // buffers (and force a full redraw) every frame.
1663        let width = width.max(1);
1664        let height = height.max(1);
1665        if self.buffers[0].width() == width && self.buffers[0].height() == height {
1666            return false;
1667        }
1668        self.buffers = [Buffer::new(width, height), Buffer::new(width, height)];
1669        self.current_idx = 0;
1670        true
1671    }
1672
1673    /// Check whether both buffers have the given dimensions (after the same
1674    /// ≥1 clamp `Buffer::new` applies).
1675    #[inline]
1676    pub fn dimensions_match(&self, width: u16, height: u16) -> bool {
1677        self.buffers[0].width() == width.max(1) && self.buffers[0].height() == height.max(1)
1678    }
1679}
1680
1681// ---------------------------------------------------------------------------
1682// AdaptiveDoubleBuffer implementation (bd-1rz0.4.2)
1683// ---------------------------------------------------------------------------
1684
1685impl AdaptiveDoubleBuffer {
1686    /// Create a new adaptive buffer with the given logical dimensions.
1687    ///
1688    /// Initial capacity is set with growth headroom applied.
1689    /// Dimensions are clamped to a minimum of 1x1.
1690    pub fn new(width: u16, height: u16) -> Self {
1691        let (cap_w, cap_h) = Self::compute_capacity(width, height);
1692        Self {
1693            inner: DoubleBuffer::new(cap_w, cap_h),
1694            logical_width: width,
1695            logical_height: height,
1696            capacity_width: cap_w,
1697            capacity_height: cap_h,
1698            stats: AdaptiveStats::default(),
1699        }
1700    }
1701
1702    /// Compute the capacity for a given logical size.
1703    ///
1704    /// Applies growth factor with clamping to prevent overflow.
1705    fn compute_capacity(width: u16, height: u16) -> (u16, u16) {
1706        let extra_w =
1707            ((width as f32 * (ADAPTIVE_GROWTH_FACTOR - 1.0)) as u16).min(ADAPTIVE_MAX_OVERAGE);
1708        let extra_h =
1709            ((height as f32 * (ADAPTIVE_GROWTH_FACTOR - 1.0)) as u16).min(ADAPTIVE_MAX_OVERAGE);
1710
1711        let cap_w = width.saturating_add(extra_w);
1712        let cap_h = height.saturating_add(extra_h);
1713
1714        (cap_w, cap_h)
1715    }
1716
1717    /// Check if the new dimensions require reallocation.
1718    ///
1719    /// Returns `true` if reallocation is needed, `false` if current capacity suffices.
1720    fn needs_reallocation(&self, width: u16, height: u16) -> bool {
1721        // Growth beyond capacity always requires reallocation
1722        if width > self.capacity_width || height > self.capacity_height {
1723            return true;
1724        }
1725
1726        // Shrink threshold: reallocate if new size is significantly smaller
1727        let shrink_threshold_w = (self.capacity_width as f32 * ADAPTIVE_SHRINK_THRESHOLD) as u16;
1728        let shrink_threshold_h = (self.capacity_height as f32 * ADAPTIVE_SHRINK_THRESHOLD) as u16;
1729
1730        width < shrink_threshold_w || height < shrink_threshold_h
1731    }
1732
1733    /// O(1) swap: the current buffer becomes previous, and vice versa.
1734    ///
1735    /// After swapping, call `current_mut().clear()` to prepare for the
1736    /// next frame.
1737    #[inline]
1738    pub fn swap(&mut self) {
1739        self.inner.swap();
1740    }
1741
1742    /// Reference to the current (in-progress) frame buffer.
1743    ///
1744    /// Note: The buffer may have larger dimensions than the logical size.
1745    /// Use `logical_width()` and `logical_height()` for rendering bounds.
1746    #[inline]
1747    pub fn current(&self) -> &Buffer {
1748        self.inner.current()
1749    }
1750
1751    /// Mutable reference to the current (in-progress) frame buffer.
1752    #[inline]
1753    pub fn current_mut(&mut self) -> &mut Buffer {
1754        self.inner.current_mut()
1755    }
1756
1757    /// Reference to the previous (last-presented) frame buffer.
1758    #[inline]
1759    pub fn previous(&self) -> &Buffer {
1760        self.inner.previous()
1761    }
1762
1763    /// Logical width (the usable rendering area).
1764    #[inline]
1765    pub fn width(&self) -> u16 {
1766        self.logical_width
1767    }
1768
1769    /// Logical height (the usable rendering area).
1770    #[inline]
1771    pub fn height(&self) -> u16 {
1772        self.logical_height
1773    }
1774
1775    /// Allocated capacity width (may be larger than logical width).
1776    #[inline]
1777    pub fn capacity_width(&self) -> u16 {
1778        self.capacity_width
1779    }
1780
1781    /// Allocated capacity height (may be larger than logical height).
1782    #[inline]
1783    pub fn capacity_height(&self) -> u16 {
1784        self.capacity_height
1785    }
1786
1787    /// Get allocation statistics.
1788    #[inline]
1789    pub fn stats(&self) -> &AdaptiveStats {
1790        &self.stats
1791    }
1792
1793    /// Reset allocation statistics.
1794    pub fn reset_stats(&mut self) {
1795        self.stats.reset();
1796    }
1797
1798    /// Resize the logical dimensions. Returns `true` if dimensions changed.
1799    ///
1800    /// This method minimizes allocations by:
1801    /// 1. Reusing existing capacity when the new size fits.
1802    /// 2. Only reallocating on significant shrink (below threshold).
1803    /// 3. Applying growth headroom to avoid immediate reallocation on growth.
1804    ///
1805    /// # Performance
1806    ///
1807    /// - O(1) when new size fits within existing capacity.
1808    /// - O(width × height) when reallocation is required.
1809    pub fn resize(&mut self, width: u16, height: u16) -> bool {
1810        // No change in logical dimensions
1811        if width == self.logical_width && height == self.logical_height {
1812            return false;
1813        }
1814
1815        let is_growth = width > self.logical_width || height > self.logical_height;
1816        if is_growth {
1817            self.stats.resize_growth += 1;
1818        } else {
1819            self.stats.resize_shrink += 1;
1820        }
1821
1822        if self.needs_reallocation(width, height) {
1823            // Reallocate with new capacity
1824            let (cap_w, cap_h) = Self::compute_capacity(width, height);
1825            self.inner = DoubleBuffer::new(cap_w, cap_h);
1826            self.capacity_width = cap_w;
1827            self.capacity_height = cap_h;
1828            self.stats.resize_reallocated += 1;
1829        } else {
1830            // Reuse existing capacity - just update logical dimensions
1831            // Clear both buffers to avoid stale content outside new bounds
1832            self.inner.current_mut().clear();
1833            self.inner.previous_mut().clear();
1834            self.stats.resize_avoided += 1;
1835        }
1836
1837        self.logical_width = width;
1838        self.logical_height = height;
1839        true
1840    }
1841
1842    /// Check whether logical dimensions match the given values.
1843    #[inline]
1844    pub fn dimensions_match(&self, width: u16, height: u16) -> bool {
1845        self.logical_width == width && self.logical_height == height
1846    }
1847
1848    /// Get the logical bounding rect (for scissoring/rendering).
1849    #[inline]
1850    pub fn logical_bounds(&self) -> Rect {
1851        Rect::from_size(self.logical_width, self.logical_height)
1852    }
1853
1854    /// Calculate memory efficiency (logical cells / capacity cells).
1855    pub fn memory_efficiency(&self) -> f64 {
1856        let logical = self.logical_width as u64 * self.logical_height as u64;
1857        let capacity = self.capacity_width as u64 * self.capacity_height as u64;
1858        if capacity == 0 {
1859            1.0
1860        } else {
1861            logical as f64 / capacity as f64
1862        }
1863    }
1864}
1865
1866#[cfg(test)]
1867mod tests {
1868    use super::*;
1869    use crate::cell::{CellContent, PackedRgba};
1870
1871    #[test]
1872    fn set_composites_background() {
1873        let mut buf = Buffer::new(1, 1);
1874
1875        // Set background to RED
1876        let red = PackedRgba::rgb(255, 0, 0);
1877        buf.set(0, 0, Cell::default().with_bg(red));
1878
1879        // Write 'X' with transparent background
1880        let cell = Cell::from_char('X'); // Default bg is TRANSPARENT
1881        buf.set(0, 0, cell);
1882
1883        let result = buf.get(0, 0).unwrap();
1884        assert_eq!(result.content.as_char(), Some('X'));
1885        assert_eq!(
1886            result.bg, red,
1887            "Background should be preserved (composited)"
1888        );
1889    }
1890
1891    #[test]
1892    fn set_fast_matches_set_for_transparent_bg() {
1893        let red = PackedRgba::rgb(255, 0, 0);
1894        let cell = Cell::from_char('X').with_fg(PackedRgba::rgb(0, 255, 0));
1895
1896        let mut a = Buffer::new(1, 1);
1897        a.set(0, 0, Cell::default().with_bg(red));
1898        a.set(0, 0, cell);
1899
1900        let mut b = Buffer::new(1, 1);
1901        b.set(0, 0, Cell::default().with_bg(red));
1902        b.set_fast(0, 0, cell);
1903
1904        assert_eq!(a.get(0, 0), b.get(0, 0));
1905    }
1906
1907    #[test]
1908    fn set_fast_matches_set_for_opaque_bg() {
1909        let cell = Cell::from_char('X')
1910            .with_fg(PackedRgba::rgb(0, 255, 0))
1911            .with_bg(PackedRgba::rgb(255, 0, 0));
1912
1913        let mut a = Buffer::new(1, 1);
1914        a.set(0, 0, cell);
1915
1916        let mut b = Buffer::new(1, 1);
1917        b.set_fast(0, 0, cell);
1918
1919        assert_eq!(a.get(0, 0), b.get(0, 0));
1920    }
1921
1922    #[test]
1923    fn set_fast_clears_orphaned_tail_like_set() {
1924        let mut slow = Buffer::new(3, 1);
1925        slow.set_raw(0, 0, Cell::from_char('A'));
1926        slow.set_raw(1, 0, Cell::CONTINUATION);
1927        slow.clear_dirty();
1928
1929        let mut fast = slow.clone();
1930
1931        slow.set(0, 0, Cell::from_char('X'));
1932        fast.set_fast(0, 0, Cell::from_char('X'));
1933
1934        assert_eq!(slow.cells(), fast.cells());
1935        assert_eq!(fast.get(1, 0), Some(&Cell::default()));
1936
1937        let spans = fast.dirty_span_row(0).expect("dirty span row").spans();
1938        assert_eq!(spans, &[DirtySpan::new(0, 2)]);
1939    }
1940
1941    #[test]
1942    fn rect_contains() {
1943        let r = Rect::new(5, 5, 10, 10);
1944        assert!(r.contains(5, 5)); // Top-left corner
1945        assert!(r.contains(14, 14)); // Bottom-right inside
1946        assert!(!r.contains(4, 5)); // Left of rect
1947        assert!(!r.contains(15, 5)); // Right of rect (exclusive)
1948        assert!(!r.contains(5, 15)); // Below rect (exclusive)
1949    }
1950
1951    #[test]
1952    fn rect_intersection() {
1953        let a = Rect::new(0, 0, 10, 10);
1954        let b = Rect::new(5, 5, 10, 10);
1955        let i = a.intersection(&b);
1956        assert_eq!(i, Rect::new(5, 5, 5, 5));
1957
1958        // Non-overlapping
1959        let c = Rect::new(20, 20, 5, 5);
1960        assert_eq!(a.intersection(&c), Rect::default());
1961    }
1962
1963    #[test]
1964    fn buffer_creation() {
1965        let buf = Buffer::new(80, 24);
1966        assert_eq!(buf.width(), 80);
1967        assert_eq!(buf.height(), 24);
1968        assert_eq!(buf.len(), 80 * 24);
1969    }
1970
1971    #[test]
1972    fn content_height_empty_is_zero() {
1973        let buf = Buffer::new(8, 4);
1974        assert_eq!(buf.content_height(), 0);
1975    }
1976
1977    #[test]
1978    fn content_height_tracks_last_non_empty_row() {
1979        let mut buf = Buffer::new(5, 4);
1980        buf.set(0, 0, Cell::from_char('A'));
1981        assert_eq!(buf.content_height(), 1);
1982
1983        buf.set(2, 3, Cell::from_char('Z'));
1984        assert_eq!(buf.content_height(), 4);
1985    }
1986
1987    #[test]
1988    fn buffer_zero_width_clamped_to_one() {
1989        let buf = Buffer::new(0, 24);
1990        assert_eq!(buf.width(), 1);
1991        assert_eq!(buf.height(), 24);
1992    }
1993
1994    #[test]
1995    fn buffer_zero_height_clamped_to_one() {
1996        let buf = Buffer::new(80, 0);
1997        assert_eq!(buf.width(), 80);
1998        assert_eq!(buf.height(), 1);
1999    }
2000
2001    #[test]
2002    fn buffer_get_and_set() {
2003        let mut buf = Buffer::new(10, 10);
2004        let cell = Cell::from_char('X');
2005        buf.set(5, 5, cell);
2006        assert_eq!(buf.get(5, 5).unwrap().content.as_char(), Some('X'));
2007    }
2008
2009    #[test]
2010    fn buffer_out_of_bounds_get() {
2011        let buf = Buffer::new(10, 10);
2012        assert!(buf.get(10, 0).is_none());
2013        assert!(buf.get(0, 10).is_none());
2014        assert!(buf.get(100, 100).is_none());
2015    }
2016
2017    #[test]
2018    fn buffer_out_of_bounds_set_ignored() {
2019        let mut buf = Buffer::new(10, 10);
2020        buf.set(100, 100, Cell::from_char('X')); // Should not panic
2021        assert_eq!(buf.cells().iter().filter(|c| !c.is_empty()).count(), 0);
2022    }
2023
2024    #[test]
2025    fn buffer_clear() {
2026        let mut buf = Buffer::new(10, 10);
2027        buf.set(5, 5, Cell::from_char('X'));
2028        buf.clear();
2029        assert!(buf.get(5, 5).unwrap().is_empty());
2030    }
2031
2032    #[test]
2033    fn scissor_stack_basic() {
2034        let mut buf = Buffer::new(20, 20);
2035
2036        // Default scissor covers entire buffer
2037        assert_eq!(buf.current_scissor(), Rect::from_size(20, 20));
2038        assert_eq!(buf.scissor_depth(), 1);
2039
2040        // Push smaller scissor
2041        buf.push_scissor(Rect::new(5, 5, 10, 10));
2042        assert_eq!(buf.current_scissor(), Rect::new(5, 5, 10, 10));
2043        assert_eq!(buf.scissor_depth(), 2);
2044
2045        // Set inside scissor works
2046        buf.set(7, 7, Cell::from_char('I'));
2047        assert_eq!(buf.get(7, 7).unwrap().content.as_char(), Some('I'));
2048
2049        // Set outside scissor is ignored
2050        buf.set(0, 0, Cell::from_char('O'));
2051        assert!(buf.get(0, 0).unwrap().is_empty());
2052
2053        // Pop scissor
2054        buf.pop_scissor();
2055        assert_eq!(buf.current_scissor(), Rect::from_size(20, 20));
2056        assert_eq!(buf.scissor_depth(), 1);
2057
2058        // Now can set at (0, 0)
2059        buf.set(0, 0, Cell::from_char('N'));
2060        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('N'));
2061    }
2062
2063    #[test]
2064    fn scissor_intersection() {
2065        let mut buf = Buffer::new(20, 20);
2066        buf.push_scissor(Rect::new(5, 5, 10, 10));
2067        buf.push_scissor(Rect::new(8, 8, 10, 10));
2068
2069        // Intersection: (8,8) to (15,15) intersected with (5,5) to (15,15)
2070        // Result: (8,8) to (15,15) -> width=7, height=7
2071        assert_eq!(buf.current_scissor(), Rect::new(8, 8, 7, 7));
2072    }
2073
2074    #[test]
2075    fn scissor_base_cannot_be_popped() {
2076        let mut buf = Buffer::new(10, 10);
2077        buf.pop_scissor(); // Should be a no-op
2078        assert_eq!(buf.scissor_depth(), 1);
2079        buf.pop_scissor(); // Still no-op
2080        assert_eq!(buf.scissor_depth(), 1);
2081    }
2082
2083    #[test]
2084    fn opacity_stack_basic() {
2085        let mut buf = Buffer::new(10, 10);
2086
2087        // Default opacity is 1.0
2088        assert!((buf.current_opacity() - 1.0).abs() < f32::EPSILON);
2089        assert_eq!(buf.opacity_depth(), 1);
2090
2091        // Push 0.5 opacity
2092        buf.push_opacity(0.5);
2093        assert!((buf.current_opacity() - 0.5).abs() < f32::EPSILON);
2094        assert_eq!(buf.opacity_depth(), 2);
2095
2096        // Push another 0.5 -> effective 0.25
2097        buf.push_opacity(0.5);
2098        assert!((buf.current_opacity() - 0.25).abs() < f32::EPSILON);
2099        assert_eq!(buf.opacity_depth(), 3);
2100
2101        // Pop back to 0.5
2102        buf.pop_opacity();
2103        assert!((buf.current_opacity() - 0.5).abs() < f32::EPSILON);
2104    }
2105
2106    #[test]
2107    fn opacity_applied_to_cells() {
2108        let mut buf = Buffer::new(10, 10);
2109        buf.push_opacity(0.5);
2110
2111        let cell = Cell::from_char('X').with_fg(PackedRgba::rgba(100, 100, 100, 255));
2112        buf.set(5, 5, cell);
2113
2114        let stored = buf.get(5, 5).unwrap();
2115        // Alpha should be reduced by 0.5
2116        assert_eq!(stored.fg.a(), 128);
2117    }
2118
2119    #[test]
2120    fn opacity_composites_background_before_storage() {
2121        let mut buf = Buffer::new(1, 1);
2122
2123        let red = PackedRgba::rgb(255, 0, 0);
2124        let blue = PackedRgba::rgb(0, 0, 255);
2125
2126        buf.set(0, 0, Cell::default().with_bg(red));
2127        buf.push_opacity(0.5);
2128        buf.set(0, 0, Cell::default().with_bg(blue));
2129
2130        let stored = buf.get(0, 0).unwrap();
2131        let expected = blue.with_opacity(0.5).over(red);
2132        assert_eq!(stored.bg, expected);
2133    }
2134
2135    #[test]
2136    fn opacity_clamped() {
2137        let mut buf = Buffer::new(10, 10);
2138        buf.push_opacity(2.0); // Should clamp to 1.0
2139        assert!((buf.current_opacity() - 1.0).abs() < f32::EPSILON);
2140
2141        buf.push_opacity(-1.0); // Should clamp to 0.0
2142        assert!((buf.current_opacity() - 0.0).abs() < f32::EPSILON);
2143    }
2144
2145    #[test]
2146    fn opacity_base_cannot_be_popped() {
2147        let mut buf = Buffer::new(10, 10);
2148        buf.pop_opacity(); // No-op
2149        assert_eq!(buf.opacity_depth(), 1);
2150    }
2151
2152    #[test]
2153    fn buffer_fill() {
2154        let mut buf = Buffer::new(10, 10);
2155        let cell = Cell::from_char('#');
2156        buf.fill(Rect::new(2, 2, 5, 5), cell);
2157
2158        // Inside fill region
2159        assert_eq!(buf.get(3, 3).unwrap().content.as_char(), Some('#'));
2160
2161        // Outside fill region
2162        assert!(buf.get(0, 0).unwrap().is_empty());
2163    }
2164
2165    #[test]
2166    fn buffer_fill_respects_scissor() {
2167        let mut buf = Buffer::new(10, 10);
2168        buf.push_scissor(Rect::new(3, 3, 4, 4));
2169
2170        let cell = Cell::from_char('#');
2171        buf.fill(Rect::new(0, 0, 10, 10), cell);
2172
2173        // Only scissor region should be filled
2174        assert_eq!(buf.get(3, 3).unwrap().content.as_char(), Some('#'));
2175        assert!(buf.get(0, 0).unwrap().is_empty());
2176        assert!(buf.get(7, 7).unwrap().is_empty());
2177    }
2178
2179    #[test]
2180    fn buffer_copy_from() {
2181        let mut src = Buffer::new(10, 10);
2182        src.set(2, 2, Cell::from_char('S'));
2183
2184        let mut dst = Buffer::new(10, 10);
2185        dst.copy_from(&src, Rect::new(0, 0, 5, 5), 3, 3);
2186
2187        // Cell at (2,2) in src should be at (5,5) in dst (offset by 3,3)
2188        assert_eq!(dst.get(5, 5).unwrap().content.as_char(), Some('S'));
2189    }
2190
2191    #[test]
2192    fn copy_from_clips_wide_char_at_boundary() {
2193        let mut src = Buffer::new(10, 1);
2194        // Wide char at x=0 (width 2)
2195        src.set(0, 0, Cell::from_char('中'));
2196
2197        let mut dst = Buffer::new(10, 1);
2198        // Copy only the first column (x=0, width=1) from src to dst at (0,0)
2199        // This includes the head of '中' but EXCLUDES the tail.
2200        dst.copy_from(&src, Rect::new(0, 0, 1, 1), 0, 0);
2201
2202        // The copy should be atomic: since the tail doesn't fit in the copy region,
2203        // the head should NOT be written (or at least the tail should not be written outside the region).
2204
2205        // Check x=0: Should be empty (atomic rejection) or clipped?
2206        // With implicit scissor fix: atomic rejection means x=0 is empty.
2207        // Without fix: x=0 is '中', x=1 is CONTINUATION (leak).
2208
2209        // Asserting the fix behavior (atomic rejection):
2210        assert!(
2211            dst.get(0, 0).unwrap().is_empty(),
2212            "Wide char head should not be written if tail is clipped"
2213        );
2214        assert!(
2215            dst.get(1, 0).unwrap().is_empty(),
2216            "Wide char tail should not be leaked outside copy region"
2217        );
2218    }
2219
2220    #[test]
2221    fn buffer_content_eq() {
2222        let mut buf1 = Buffer::new(10, 10);
2223        let mut buf2 = Buffer::new(10, 10);
2224
2225        assert!(buf1.content_eq(&buf2));
2226
2227        buf1.set(0, 0, Cell::from_char('X'));
2228        assert!(!buf1.content_eq(&buf2));
2229
2230        buf2.set(0, 0, Cell::from_char('X'));
2231        assert!(buf1.content_eq(&buf2));
2232    }
2233
2234    #[test]
2235    fn buffer_bounds() {
2236        let buf = Buffer::new(80, 24);
2237        let bounds = buf.bounds();
2238        assert_eq!(bounds.x, 0);
2239        assert_eq!(bounds.y, 0);
2240        assert_eq!(bounds.width, 80);
2241        assert_eq!(bounds.height, 24);
2242    }
2243
2244    #[test]
2245    fn buffer_set_raw_bypasses_scissor() {
2246        let mut buf = Buffer::new(10, 10);
2247        buf.push_scissor(Rect::new(5, 5, 5, 5));
2248
2249        // set() respects scissor - this should be ignored
2250        buf.set(0, 0, Cell::from_char('S'));
2251        assert!(buf.get(0, 0).unwrap().is_empty());
2252
2253        // set_raw() bypasses scissor - this should work
2254        buf.set_raw(0, 0, Cell::from_char('R'));
2255        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('R'));
2256    }
2257
2258    #[test]
2259    fn set_handles_wide_chars() {
2260        let mut buf = Buffer::new(10, 10);
2261
2262        // Set a wide character (width 2)
2263        buf.set(0, 0, Cell::from_char('中'));
2264
2265        // Check head
2266        let head = buf.get(0, 0).unwrap();
2267        assert_eq!(head.content.as_char(), Some('中'));
2268
2269        // Check continuation
2270        let cont = buf.get(1, 0).unwrap();
2271        assert!(cont.is_continuation());
2272        assert!(!cont.is_empty());
2273    }
2274
2275    #[test]
2276    fn set_handles_wide_chars_clipped() {
2277        let mut buf = Buffer::new(10, 10);
2278        buf.push_scissor(Rect::new(0, 0, 1, 10)); // Only column 0 is visible
2279
2280        // Set wide char at 0,0. Tail at x=1 is outside scissor.
2281        // Atomic rejection: entire write is rejected because tail doesn't fit.
2282        buf.set(0, 0, Cell::from_char('中'));
2283
2284        // Head should NOT be written (atomic rejection)
2285        assert!(buf.get(0, 0).unwrap().is_empty());
2286        // Tail position should also be unmodified
2287        assert!(buf.get(1, 0).unwrap().is_empty());
2288    }
2289
2290    // ========== Wide Glyph Continuation Cleanup Tests ==========
2291
2292    #[test]
2293    fn overwrite_wide_head_with_single_clears_tails() {
2294        let mut buf = Buffer::new(10, 1);
2295
2296        // Write a wide character (width 2) at position 0
2297        buf.set(0, 0, Cell::from_char('中'));
2298        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('中'));
2299        assert!(buf.get(1, 0).unwrap().is_continuation());
2300
2301        // Overwrite the head with a single-width character
2302        buf.set(0, 0, Cell::from_char('A'));
2303
2304        // Head should be replaced
2305        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('A'));
2306        // Tail (continuation) should be cleared to default
2307        assert!(
2308            buf.get(1, 0).unwrap().is_empty(),
2309            "Continuation at x=1 should be cleared when head is overwritten"
2310        );
2311    }
2312
2313    #[test]
2314    fn set_raw_overwrite_wide_head_with_single_clears_tails() {
2315        let mut buf = Buffer::new(10, 1);
2316
2317        buf.set(0, 0, Cell::from_char('中'));
2318        assert!(buf.get(1, 0).unwrap().is_continuation());
2319        buf.clear_dirty();
2320
2321        buf.set_raw(0, 0, Cell::from_char('A'));
2322
2323        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('A'));
2324        assert!(
2325            buf.get(1, 0).unwrap().is_empty(),
2326            "set_raw should clear stale continuation tails when overwriting a wide head"
2327        );
2328        let spans = buf.dirty_span_row(0).expect("dirty span row").spans();
2329        assert_eq!(spans, &[DirtySpan::new(0, 2)]);
2330    }
2331
2332    #[test]
2333    fn set_raw_wide_head_preserves_manual_tail_cells() {
2334        let mut buf = Buffer::new(10, 1);
2335
2336        buf.set_raw(0, 0, Cell::from_char('中'));
2337        buf.set_raw(1, 0, Cell::CONTINUATION);
2338        assert!(buf.get(1, 0).unwrap().is_continuation());
2339        buf.clear_dirty();
2340
2341        buf.set_raw(0, 0, Cell::from_char('日'));
2342
2343        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('日'));
2344        assert!(
2345            buf.get(1, 0).unwrap().is_continuation(),
2346            "set_raw wide-head replacement should not clear caller-managed tails"
2347        );
2348        let spans = buf.dirty_span_row(0).expect("dirty span row").spans();
2349        assert_eq!(spans, &[DirtySpan::new(0, 1)]);
2350    }
2351
2352    #[test]
2353    fn overwrite_continuation_with_single_clears_head_and_tails() {
2354        let mut buf = Buffer::new(10, 1);
2355
2356        // Write a wide character at position 0
2357        buf.set(0, 0, Cell::from_char('中'));
2358        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('中'));
2359        assert!(buf.get(1, 0).unwrap().is_continuation());
2360
2361        // Overwrite the continuation (position 1) with a single-width char
2362        buf.set(1, 0, Cell::from_char('B'));
2363
2364        // The head at position 0 should be cleared
2365        assert!(
2366            buf.get(0, 0).unwrap().is_empty(),
2367            "Head at x=0 should be cleared when its continuation is overwritten"
2368        );
2369        // Position 1 should have the new character
2370        assert_eq!(buf.get(1, 0).unwrap().content.as_char(), Some('B'));
2371    }
2372
2373    #[test]
2374    fn overwrite_wide_with_another_wide() {
2375        let mut buf = Buffer::new(10, 1);
2376
2377        // Write first wide character
2378        buf.set(0, 0, Cell::from_char('中'));
2379        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('中'));
2380        assert!(buf.get(1, 0).unwrap().is_continuation());
2381
2382        // Overwrite with another wide character
2383        buf.set(0, 0, Cell::from_char('日'));
2384
2385        // Should have new wide character
2386        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('日'));
2387        assert!(
2388            buf.get(1, 0).unwrap().is_continuation(),
2389            "Continuation should still exist for new wide char"
2390        );
2391    }
2392
2393    #[test]
2394    fn overwrite_continuation_middle_of_wide_sequence() {
2395        let mut buf = Buffer::new(10, 1);
2396
2397        // Write two adjacent wide characters: 中 at 0-1, 日 at 2-3
2398        buf.set(0, 0, Cell::from_char('中'));
2399        buf.set(2, 0, Cell::from_char('日'));
2400
2401        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('中'));
2402        assert!(buf.get(1, 0).unwrap().is_continuation());
2403        assert_eq!(buf.get(2, 0).unwrap().content.as_char(), Some('日'));
2404        assert!(buf.get(3, 0).unwrap().is_continuation());
2405
2406        // Overwrite position 1 (continuation of first wide char)
2407        buf.set(1, 0, Cell::from_char('X'));
2408
2409        // First wide char's head should be cleared
2410        assert!(
2411            buf.get(0, 0).unwrap().is_empty(),
2412            "Head of first wide char should be cleared"
2413        );
2414        // Position 1 has new char
2415        assert_eq!(buf.get(1, 0).unwrap().content.as_char(), Some('X'));
2416        // Second wide char should be unaffected
2417        assert_eq!(buf.get(2, 0).unwrap().content.as_char(), Some('日'));
2418        assert!(buf.get(3, 0).unwrap().is_continuation());
2419    }
2420
2421    #[test]
2422    fn wide_char_overlapping_previous_wide_char() {
2423        let mut buf = Buffer::new(10, 1);
2424
2425        // Write wide char at position 0
2426        buf.set(0, 0, Cell::from_char('中'));
2427        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('中'));
2428        assert!(buf.get(1, 0).unwrap().is_continuation());
2429
2430        // Write another wide char at position 1 (overlaps with continuation)
2431        buf.set(1, 0, Cell::from_char('日'));
2432
2433        // First wide char's head should be cleared (its continuation was overwritten)
2434        assert!(
2435            buf.get(0, 0).unwrap().is_empty(),
2436            "First wide char head should be cleared when continuation is overwritten by new wide"
2437        );
2438        // New wide char at positions 1-2
2439        assert_eq!(buf.get(1, 0).unwrap().content.as_char(), Some('日'));
2440        assert!(buf.get(2, 0).unwrap().is_continuation());
2441    }
2442
2443    #[test]
2444    fn wide_char_at_end_of_buffer_atomic_reject() {
2445        let mut buf = Buffer::new(5, 1);
2446
2447        // Try to write wide char at position 4 (would need position 5 for tail, out of bounds)
2448        buf.set(4, 0, Cell::from_char('中'));
2449
2450        // Should be rejected atomically - nothing written
2451        assert!(
2452            buf.get(4, 0).unwrap().is_empty(),
2453            "Wide char should be rejected when tail would be out of bounds"
2454        );
2455    }
2456
2457    #[test]
2458    fn three_wide_chars_sequential_cleanup() {
2459        let mut buf = Buffer::new(10, 1);
2460
2461        // Write three wide chars: positions 0-1, 2-3, 4-5
2462        buf.set(0, 0, Cell::from_char('一'));
2463        buf.set(2, 0, Cell::from_char('二'));
2464        buf.set(4, 0, Cell::from_char('三'));
2465
2466        // Verify initial state
2467        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('一'));
2468        assert!(buf.get(1, 0).unwrap().is_continuation());
2469        assert_eq!(buf.get(2, 0).unwrap().content.as_char(), Some('二'));
2470        assert!(buf.get(3, 0).unwrap().is_continuation());
2471        assert_eq!(buf.get(4, 0).unwrap().content.as_char(), Some('三'));
2472        assert!(buf.get(5, 0).unwrap().is_continuation());
2473
2474        // Overwrite middle wide char's continuation with single char
2475        buf.set(3, 0, Cell::from_char('M'));
2476
2477        // First wide char should be unaffected
2478        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('一'));
2479        assert!(buf.get(1, 0).unwrap().is_continuation());
2480        // Middle wide char's head should be cleared
2481        assert!(buf.get(2, 0).unwrap().is_empty());
2482        // Position 3 has new char
2483        assert_eq!(buf.get(3, 0).unwrap().content.as_char(), Some('M'));
2484        // Third wide char should be unaffected
2485        assert_eq!(buf.get(4, 0).unwrap().content.as_char(), Some('三'));
2486        assert!(buf.get(5, 0).unwrap().is_continuation());
2487    }
2488
2489    #[test]
2490    fn overwrite_empty_cell_no_cleanup_needed() {
2491        let mut buf = Buffer::new(10, 1);
2492
2493        // Write to an empty cell - no cleanup should be needed
2494        buf.set(5, 0, Cell::from_char('X'));
2495
2496        assert_eq!(buf.get(5, 0).unwrap().content.as_char(), Some('X'));
2497        // Adjacent cells should still be empty
2498        assert!(buf.get(4, 0).unwrap().is_empty());
2499        assert!(buf.get(6, 0).unwrap().is_empty());
2500    }
2501
2502    #[test]
2503    fn wide_char_cleanup_with_opacity() {
2504        let mut buf = Buffer::new(10, 1);
2505
2506        // Set background
2507        buf.set(0, 0, Cell::default().with_bg(PackedRgba::rgb(255, 0, 0)));
2508        buf.set(1, 0, Cell::default().with_bg(PackedRgba::rgb(0, 255, 0)));
2509
2510        // Write wide char
2511        buf.set(0, 0, Cell::from_char('中'));
2512
2513        // Overwrite with opacity
2514        buf.push_opacity(0.5);
2515        buf.set(0, 0, Cell::from_char('A'));
2516        buf.pop_opacity();
2517
2518        // Check head is replaced
2519        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('A'));
2520        // Continuation should be cleared
2521        assert!(buf.get(1, 0).unwrap().is_empty());
2522    }
2523
2524    #[test]
2525    fn wide_char_continuation_not_treated_as_head() {
2526        let mut buf = Buffer::new(10, 1);
2527
2528        // Write a wide character
2529        buf.set(0, 0, Cell::from_char('中'));
2530
2531        // Verify the continuation cell has zero width (not treated as a head)
2532        let cont = buf.get(1, 0).unwrap();
2533        assert!(cont.is_continuation());
2534        assert_eq!(cont.content.width(), 0);
2535
2536        // Writing another wide char starting at position 1 should work correctly
2537        buf.set(1, 0, Cell::from_char('日'));
2538
2539        // Original head should be cleared
2540        assert!(buf.get(0, 0).unwrap().is_empty());
2541        // New wide char at 1-2
2542        assert_eq!(buf.get(1, 0).unwrap().content.as_char(), Some('日'));
2543        assert!(buf.get(2, 0).unwrap().is_continuation());
2544    }
2545
2546    #[test]
2547    fn wide_char_fill_region() {
2548        let mut buf = Buffer::new(10, 3);
2549
2550        // Fill a 4x2 region with a wide character.
2551        // Wide fills advance by width to prevent overlap churn.
2552        let wide_cell = Cell::from_char('中');
2553        buf.fill(Rect::new(0, 0, 4, 2), wide_cell);
2554
2555        // Row 0 should contain two wide graphemes at x={0,2}.
2556        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('中'));
2557        assert!(buf.get(1, 0).unwrap().is_continuation());
2558        assert_eq!(buf.get(2, 0).unwrap().content.as_char(), Some('中'));
2559        assert!(buf.get(3, 0).unwrap().is_continuation());
2560    }
2561
2562    #[test]
2563    fn default_buffer_dimensions() {
2564        let buf = Buffer::default();
2565        assert_eq!(buf.width(), 1);
2566        assert_eq!(buf.height(), 1);
2567        assert_eq!(buf.len(), 1);
2568    }
2569
2570    #[test]
2571    fn buffer_partial_eq_impl() {
2572        let buf1 = Buffer::new(5, 5);
2573        let buf2 = Buffer::new(5, 5);
2574        let mut buf3 = Buffer::new(5, 5);
2575        buf3.set(0, 0, Cell::from_char('X'));
2576
2577        assert_eq!(buf1, buf2);
2578        assert_ne!(buf1, buf3);
2579    }
2580
2581    #[test]
2582    fn degradation_level_accessible() {
2583        let mut buf = Buffer::new(10, 10);
2584        assert_eq!(buf.degradation, DegradationLevel::Full);
2585
2586        buf.degradation = DegradationLevel::SimpleBorders;
2587        assert_eq!(buf.degradation, DegradationLevel::SimpleBorders);
2588    }
2589
2590    // --- get_mut ---
2591
2592    #[test]
2593    fn get_mut_modifies_cell() {
2594        let mut buf = Buffer::new(10, 10);
2595        buf.set(3, 3, Cell::from_char('A'));
2596
2597        if let Some(cell) = buf.get_mut(3, 3) {
2598            *cell = Cell::from_char('B');
2599        }
2600
2601        assert_eq!(buf.get(3, 3).unwrap().content.as_char(), Some('B'));
2602    }
2603
2604    #[test]
2605    fn get_mut_out_of_bounds() {
2606        let mut buf = Buffer::new(5, 5);
2607        assert!(buf.get_mut(10, 10).is_none());
2608    }
2609
2610    // --- clear_with ---
2611
2612    #[test]
2613    fn clear_with_fills_all_cells() {
2614        let mut buf = Buffer::new(5, 3);
2615        let fill_cell = Cell::from_char('*');
2616        buf.clear_with(fill_cell);
2617
2618        for y in 0..3 {
2619            for x in 0..5 {
2620                assert_eq!(buf.get(x, y).unwrap().content.as_char(), Some('*'));
2621            }
2622        }
2623    }
2624
2625    #[test]
2626    fn clear_with_wide_cell_preserves_head_tail_invariant() {
2627        let mut buf = Buffer::new(5, 2);
2628        buf.clear_with(Cell::from_char('中'));
2629
2630        for y in 0..2 {
2631            assert_eq!(buf.get(0, y).unwrap().content.as_char(), Some('中'));
2632            assert!(buf.get(1, y).unwrap().is_continuation());
2633            assert_eq!(buf.get(2, y).unwrap().content.as_char(), Some('中'));
2634            assert!(buf.get(3, y).unwrap().is_continuation());
2635            assert!(buf.get(4, y).unwrap().is_empty());
2636        }
2637    }
2638
2639    // --- cells / cells_mut ---
2640
2641    #[test]
2642    fn cells_slice_has_correct_length() {
2643        let buf = Buffer::new(10, 5);
2644        assert_eq!(buf.cells().len(), 50);
2645    }
2646
2647    #[test]
2648    fn cells_mut_allows_direct_modification() {
2649        let mut buf = Buffer::new(3, 2);
2650        let cells = buf.cells_mut();
2651        cells[0] = Cell::from_char('Z');
2652
2653        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('Z'));
2654    }
2655
2656    // --- row_cells ---
2657
2658    #[test]
2659    fn row_cells_returns_correct_row() {
2660        let mut buf = Buffer::new(5, 3);
2661        buf.set(2, 1, Cell::from_char('R'));
2662
2663        let row = buf.row_cells(1);
2664        assert_eq!(row.len(), 5);
2665        assert_eq!(row[2].content.as_char(), Some('R'));
2666    }
2667
2668    #[test]
2669    fn row_cells_mut_span_marks_once_and_returns_slice() {
2670        let mut buf = Buffer::new(5, 3);
2671        buf.clear_dirty();
2672
2673        let row = buf
2674            .row_cells_mut_span(1, 1, 4)
2675            .expect("row span should be in bounds");
2676        assert_eq!(row.len(), 3);
2677        row[0] = Cell::from_char('A');
2678        row[1] = Cell::from_char('B');
2679        row[2] = Cell::from_char('C');
2680
2681        assert!(buf.is_row_dirty(1));
2682        let spans = buf.dirty_span_row(1).expect("dirty span row").spans();
2683        assert_eq!(spans, &[DirtySpan::new(1, 4)]);
2684        assert_eq!(buf.get(1, 1).unwrap().content.as_char(), Some('A'));
2685        assert_eq!(buf.get(2, 1).unwrap().content.as_char(), Some('B'));
2686        assert_eq!(buf.get(3, 1).unwrap().content.as_char(), Some('C'));
2687    }
2688
2689    #[test]
2690    fn row_cells_mut_span_clamps_to_buffer_width() {
2691        let mut buf = Buffer::new(5, 1);
2692        buf.clear_dirty();
2693
2694        let row = buf
2695            .row_cells_mut_span(0, 3, 99)
2696            .expect("row span should clamp");
2697        assert_eq!(row.len(), 2);
2698        row[0] = Cell::from_char('X');
2699        row[1] = Cell::from_char('Y');
2700
2701        let spans = buf.dirty_span_row(0).expect("dirty span row").spans();
2702        assert_eq!(spans, &[DirtySpan::new(3, 5)]);
2703        assert_eq!(buf.get(3, 0).unwrap().content.as_char(), Some('X'));
2704        assert_eq!(buf.get(4, 0).unwrap().content.as_char(), Some('Y'));
2705    }
2706
2707    #[test]
2708    fn row_cells_mut_span_rejects_reversed_ranges() {
2709        let mut buf = Buffer::new(5, 1);
2710        buf.clear_dirty();
2711
2712        assert!(buf.row_cells_mut_span(0, 4, 2).is_none());
2713        assert!(
2714            !buf.is_row_dirty(0),
2715            "reversed ranges should not mark rows dirty"
2716        );
2717        assert!(
2718            buf.dirty_span_row(0)
2719                .expect("dirty span row")
2720                .spans()
2721                .is_empty(),
2722            "reversed ranges should not add dirty spans"
2723        );
2724    }
2725
2726    #[test]
2727    #[should_panic]
2728    fn row_cells_out_of_bounds_panics() {
2729        let buf = Buffer::new(5, 3);
2730        let _ = buf.row_cells(5);
2731    }
2732
2733    // --- is_empty ---
2734
2735    #[test]
2736    fn buffer_is_not_empty() {
2737        let buf = Buffer::new(1, 1);
2738        assert!(!buf.is_empty());
2739    }
2740
2741    // --- set_raw out of bounds ---
2742
2743    #[test]
2744    fn set_raw_out_of_bounds_is_safe() {
2745        let mut buf = Buffer::new(5, 5);
2746        buf.set_raw(100, 100, Cell::from_char('X'));
2747        // Should not panic, just be ignored
2748    }
2749
2750    // --- copy_from with offset ---
2751
2752    #[test]
2753    fn copy_from_out_of_bounds_partial() {
2754        let mut src = Buffer::new(5, 5);
2755        src.set(0, 0, Cell::from_char('A'));
2756        src.set(4, 4, Cell::from_char('B'));
2757
2758        let mut dst = Buffer::new(5, 5);
2759        // Copy entire src with offset that puts part out of bounds
2760        dst.copy_from(&src, Rect::new(0, 0, 5, 5), 3, 3);
2761
2762        // (0,0) in src → (3,3) in dst = inside
2763        assert_eq!(dst.get(3, 3).unwrap().content.as_char(), Some('A'));
2764        // (4,4) in src → (7,7) in dst = outside, should be ignored
2765        assert!(dst.get(4, 4).unwrap().is_empty());
2766    }
2767
2768    // --- content_eq with different dimensions ---
2769
2770    #[test]
2771    fn content_eq_different_dimensions() {
2772        let buf1 = Buffer::new(5, 5);
2773        let buf2 = Buffer::new(10, 10);
2774        // Different dimensions should not be equal (different cell counts)
2775        assert!(!buf1.content_eq(&buf2));
2776    }
2777
2778    // ====== Property tests (proptest) ======
2779
2780    mod property {
2781        use super::*;
2782        use proptest::prelude::*;
2783
2784        proptest! {
2785            #[test]
2786            fn buffer_dimensions_are_preserved(width in 1u16..200, height in 1u16..200) {
2787                let buf = Buffer::new(width, height);
2788                prop_assert_eq!(buf.width(), width);
2789                prop_assert_eq!(buf.height(), height);
2790                prop_assert_eq!(buf.len(), width as usize * height as usize);
2791            }
2792
2793            #[test]
2794            fn buffer_get_in_bounds_always_succeeds(width in 1u16..100, height in 1u16..100) {
2795                let buf = Buffer::new(width, height);
2796                for x in 0..width {
2797                    for y in 0..height {
2798                        prop_assert!(buf.get(x, y).is_some(), "get({x},{y}) failed for {width}x{height} buffer");
2799                    }
2800                }
2801            }
2802
2803            #[test]
2804            fn buffer_get_out_of_bounds_returns_none(width in 1u16..50, height in 1u16..50) {
2805                let buf = Buffer::new(width, height);
2806                prop_assert!(buf.get(width, 0).is_none());
2807                prop_assert!(buf.get(0, height).is_none());
2808                prop_assert!(buf.get(width, height).is_none());
2809            }
2810
2811            #[test]
2812            fn buffer_set_get_roundtrip(
2813                width in 5u16..50,
2814                height in 5u16..50,
2815                x in 0u16..5,
2816                y in 0u16..5,
2817                ch_idx in 0u32..26,
2818            ) {
2819                let x = x % width;
2820                let y = y % height;
2821                let ch = char::from_u32('A' as u32 + ch_idx).unwrap();
2822                let mut buf = Buffer::new(width, height);
2823                buf.set(x, y, Cell::from_char(ch));
2824                let got = buf.get(x, y).unwrap();
2825                prop_assert_eq!(got.content.as_char(), Some(ch));
2826            }
2827
2828            #[test]
2829            fn scissor_push_pop_stack_depth(
2830                width in 10u16..50,
2831                height in 10u16..50,
2832                push_count in 1usize..10,
2833            ) {
2834                let mut buf = Buffer::new(width, height);
2835                prop_assert_eq!(buf.scissor_depth(), 1); // base
2836
2837                for i in 0..push_count {
2838                    buf.push_scissor(Rect::new(0, 0, width, height));
2839                    prop_assert_eq!(buf.scissor_depth(), i + 2);
2840                }
2841
2842                for i in (0..push_count).rev() {
2843                    buf.pop_scissor();
2844                    prop_assert_eq!(buf.scissor_depth(), i + 1);
2845                }
2846
2847                // Base cannot be popped
2848                buf.pop_scissor();
2849                prop_assert_eq!(buf.scissor_depth(), 1);
2850            }
2851
2852            #[test]
2853            fn scissor_monotonic_intersection(
2854                width in 20u16..60,
2855                height in 20u16..60,
2856            ) {
2857                // Scissor stack always shrinks or stays the same
2858                let mut buf = Buffer::new(width, height);
2859                let outer = Rect::new(2, 2, width - 4, height - 4);
2860                buf.push_scissor(outer);
2861                let s1 = buf.current_scissor();
2862
2863                let inner = Rect::new(5, 5, 10, 10);
2864                buf.push_scissor(inner);
2865                let s2 = buf.current_scissor();
2866
2867                // Inner scissor must be contained within or equal to outer
2868                prop_assert!(s2.width <= s1.width, "inner width {} > outer width {}", s2.width, s1.width);
2869                prop_assert!(s2.height <= s1.height, "inner height {} > outer height {}", s2.height, s1.height);
2870            }
2871
2872            #[test]
2873            fn opacity_push_pop_stack_depth(
2874                width in 5u16..20,
2875                height in 5u16..20,
2876                push_count in 1usize..10,
2877            ) {
2878                let mut buf = Buffer::new(width, height);
2879                prop_assert_eq!(buf.opacity_depth(), 1);
2880
2881                for i in 0..push_count {
2882                    buf.push_opacity(0.9);
2883                    prop_assert_eq!(buf.opacity_depth(), i + 2);
2884                }
2885
2886                for i in (0..push_count).rev() {
2887                    buf.pop_opacity();
2888                    prop_assert_eq!(buf.opacity_depth(), i + 1);
2889                }
2890
2891                buf.pop_opacity();
2892                prop_assert_eq!(buf.opacity_depth(), 1);
2893            }
2894
2895            #[test]
2896            fn opacity_multiplication_is_monotonic(
2897                opacity1 in 0.0f32..=1.0,
2898                opacity2 in 0.0f32..=1.0,
2899            ) {
2900                let mut buf = Buffer::new(5, 5);
2901                buf.push_opacity(opacity1);
2902                let after_first = buf.current_opacity();
2903                buf.push_opacity(opacity2);
2904                let after_second = buf.current_opacity();
2905
2906                // Effective opacity can only decrease (or stay same at 0 or 1)
2907                prop_assert!(after_second <= after_first + f32::EPSILON,
2908                    "opacity increased: {} -> {}", after_first, after_second);
2909            }
2910
2911            #[test]
2912            fn clear_resets_all_cells(width in 1u16..30, height in 1u16..30) {
2913                let mut buf = Buffer::new(width, height);
2914                // Write some data
2915                for x in 0..width {
2916                    buf.set_raw(x, 0, Cell::from_char('X'));
2917                }
2918                buf.clear();
2919                // All cells should be default (empty)
2920                for y in 0..height {
2921                    for x in 0..width {
2922                        prop_assert!(buf.get(x, y).unwrap().is_empty(),
2923                            "cell ({x},{y}) not empty after clear");
2924                    }
2925                }
2926            }
2927
2928            #[test]
2929            fn content_eq_is_reflexive(width in 1u16..30, height in 1u16..30) {
2930                let buf = Buffer::new(width, height);
2931                prop_assert!(buf.content_eq(&buf));
2932            }
2933
2934            #[test]
2935            fn content_eq_detects_single_change(
2936                width in 5u16..30,
2937                height in 5u16..30,
2938                x in 0u16..5,
2939                y in 0u16..5,
2940            ) {
2941                let x = x % width;
2942                let y = y % height;
2943                let buf1 = Buffer::new(width, height);
2944                let mut buf2 = Buffer::new(width, height);
2945                buf2.set_raw(x, y, Cell::from_char('Z'));
2946                prop_assert!(!buf1.content_eq(&buf2));
2947            }
2948
2949            // --- Executable Invariant Tests (bd-10i.13.2) ---
2950
2951            #[test]
2952            fn dimensions_immutable_through_operations(
2953                width in 5u16..30,
2954                height in 5u16..30,
2955            ) {
2956                let mut buf = Buffer::new(width, height);
2957
2958                // Operations that must not change dimensions
2959                buf.set(0, 0, Cell::from_char('A'));
2960                prop_assert_eq!(buf.width(), width);
2961                prop_assert_eq!(buf.height(), height);
2962                prop_assert_eq!(buf.len(), width as usize * height as usize);
2963
2964                buf.push_scissor(Rect::new(1, 1, 3, 3));
2965                prop_assert_eq!(buf.width(), width);
2966                prop_assert_eq!(buf.height(), height);
2967
2968                buf.push_opacity(0.5);
2969                prop_assert_eq!(buf.width(), width);
2970                prop_assert_eq!(buf.height(), height);
2971
2972                buf.pop_scissor();
2973                buf.pop_opacity();
2974                prop_assert_eq!(buf.width(), width);
2975                prop_assert_eq!(buf.height(), height);
2976
2977                buf.clear();
2978                prop_assert_eq!(buf.width(), width);
2979                prop_assert_eq!(buf.height(), height);
2980                prop_assert_eq!(buf.len(), width as usize * height as usize);
2981            }
2982
2983            #[test]
2984            fn scissor_area_never_increases_random_rects(
2985                width in 20u16..60,
2986                height in 20u16..60,
2987                rects in proptest::collection::vec(
2988                    (0u16..20, 0u16..20, 1u16..15, 1u16..15),
2989                    1..8
2990                ),
2991            ) {
2992                let mut buf = Buffer::new(width, height);
2993                let mut prev_area = (width as u32) * (height as u32);
2994
2995                for (x, y, w, h) in rects {
2996                    buf.push_scissor(Rect::new(x, y, w, h));
2997                    let s = buf.current_scissor();
2998                    let area = (s.width as u32) * (s.height as u32);
2999                    prop_assert!(area <= prev_area,
3000                        "scissor area increased: {} -> {} after push({},{},{},{})",
3001                        prev_area, area, x, y, w, h);
3002                    prev_area = area;
3003                }
3004            }
3005
3006            #[test]
3007            fn opacity_range_invariant_random_sequence(
3008                opacities in proptest::collection::vec(0.0f32..=1.0, 1..15),
3009            ) {
3010                let mut buf = Buffer::new(5, 5);
3011
3012                for &op in &opacities {
3013                    buf.push_opacity(op);
3014                    let current = buf.current_opacity();
3015                    prop_assert!(current >= 0.0, "opacity below 0: {}", current);
3016                    prop_assert!(current <= 1.0 + f32::EPSILON,
3017                        "opacity above 1: {}", current);
3018                }
3019
3020                // Pop everything and verify we get back to 1.0
3021                for _ in &opacities {
3022                    buf.pop_opacity();
3023                }
3024                // After popping all pushed, should be back to base (1.0)
3025                prop_assert!((buf.current_opacity() - 1.0).abs() < f32::EPSILON);
3026            }
3027
3028            #[test]
3029            fn opacity_clamp_out_of_range(
3030                neg in -100.0f32..0.0,
3031                over in 1.01f32..100.0,
3032            ) {
3033                let mut buf = Buffer::new(5, 5);
3034
3035                buf.push_opacity(neg);
3036                prop_assert!(buf.current_opacity() >= 0.0,
3037                    "negative opacity not clamped: {}", buf.current_opacity());
3038                buf.pop_opacity();
3039
3040                buf.push_opacity(over);
3041                prop_assert!(buf.current_opacity() <= 1.0 + f32::EPSILON,
3042                    "over-1 opacity not clamped: {}", buf.current_opacity());
3043            }
3044
3045            #[test]
3046            fn scissor_stack_always_has_base(
3047                pushes in 0usize..10,
3048                pops in 0usize..15,
3049            ) {
3050                let mut buf = Buffer::new(10, 10);
3051
3052                for _ in 0..pushes {
3053                    buf.push_scissor(Rect::new(0, 0, 5, 5));
3054                }
3055                for _ in 0..pops {
3056                    buf.pop_scissor();
3057                }
3058
3059                // Invariant: depth is always >= 1
3060                prop_assert!(buf.scissor_depth() >= 1,
3061                    "scissor depth dropped below 1 after {} pushes, {} pops",
3062                    pushes, pops);
3063            }
3064
3065            #[test]
3066            fn opacity_stack_always_has_base(
3067                pushes in 0usize..10,
3068                pops in 0usize..15,
3069            ) {
3070                let mut buf = Buffer::new(10, 10);
3071
3072                for _ in 0..pushes {
3073                    buf.push_opacity(0.5);
3074                }
3075                for _ in 0..pops {
3076                    buf.pop_opacity();
3077                }
3078
3079                // Invariant: depth is always >= 1
3080                prop_assert!(buf.opacity_depth() >= 1,
3081                    "opacity depth dropped below 1 after {} pushes, {} pops",
3082                    pushes, pops);
3083            }
3084
3085            #[test]
3086            fn cells_len_invariant_always_holds(
3087                width in 1u16..50,
3088                height in 1u16..50,
3089            ) {
3090                let mut buf = Buffer::new(width, height);
3091                let expected = width as usize * height as usize;
3092
3093                prop_assert_eq!(buf.cells().len(), expected);
3094
3095                // After mutations
3096                buf.set(0, 0, Cell::from_char('X'));
3097                prop_assert_eq!(buf.cells().len(), expected);
3098
3099                buf.clear();
3100                prop_assert_eq!(buf.cells().len(), expected);
3101            }
3102
3103            #[test]
3104            fn set_outside_scissor_is_noop(
3105                width in 10u16..30,
3106                height in 10u16..30,
3107            ) {
3108                let mut buf = Buffer::new(width, height);
3109                buf.push_scissor(Rect::new(2, 2, 3, 3));
3110
3111                // Write outside scissor region
3112                buf.set(0, 0, Cell::from_char('X'));
3113                // Should be unmodified (still empty)
3114                let cell = buf.get(0, 0).unwrap();
3115                prop_assert!(cell.is_empty(),
3116                    "cell (0,0) modified outside scissor region");
3117
3118                // Write inside scissor region should work
3119                buf.set(3, 3, Cell::from_char('Y'));
3120                let cell = buf.get(3, 3).unwrap();
3121                prop_assert_eq!(cell.content.as_char(), Some('Y'));
3122            }
3123
3124            // --- Wide Glyph Cleanup Property Tests ---
3125
3126            #[test]
3127            fn wide_char_overwrites_cleanup_tails(
3128                width in 10u16..30,
3129                x in 0u16..8,
3130            ) {
3131                let x = x % (width.saturating_sub(2).max(1));
3132                let mut buf = Buffer::new(width, 1);
3133
3134                // Write wide char
3135                buf.set(x, 0, Cell::from_char('中'));
3136
3137                // If it fit, check structure
3138                if x + 1 < width {
3139                    let head = buf.get(x, 0).unwrap();
3140                    let tail = buf.get(x + 1, 0).unwrap();
3141
3142                    if head.content.as_char() == Some('中') {
3143                        prop_assert!(tail.is_continuation(),
3144                            "tail at x+1={} should be continuation", x + 1);
3145
3146                        // Overwrite head with single char
3147                        buf.set(x, 0, Cell::from_char('A'));
3148                        let new_head = buf.get(x, 0).unwrap();
3149                        let cleared_tail = buf.get(x + 1, 0).unwrap();
3150
3151                        prop_assert_eq!(new_head.content.as_char(), Some('A'));
3152                        prop_assert!(cleared_tail.is_empty(),
3153                            "tail should be cleared after head overwrite");
3154                    }
3155                }
3156            }
3157
3158            #[test]
3159            fn wide_char_atomic_rejection_at_boundary(
3160                width in 3u16..20,
3161            ) {
3162                let mut buf = Buffer::new(width, 1);
3163
3164                // Try to write wide char at last position (needs x and x+1)
3165                let last_pos = width - 1;
3166                buf.set(last_pos, 0, Cell::from_char('中'));
3167
3168                // Should be rejected - cell should remain empty
3169                let cell = buf.get(last_pos, 0).unwrap();
3170                prop_assert!(cell.is_empty(),
3171                    "wide char at boundary position {} (width {}) should be rejected",
3172                    last_pos, width);
3173            }
3174
3175            // =====================================================================
3176            // DoubleBuffer property tests (bd-1rz0.4.4)
3177            // =====================================================================
3178
3179            #[test]
3180            fn double_buffer_swap_is_involution(ops in proptest::collection::vec(proptest::bool::ANY, 0..100)) {
3181                let mut db = DoubleBuffer::new(10, 10);
3182                let initial_idx = db.current_idx;
3183
3184                for do_swap in &ops {
3185                    if *do_swap {
3186                        db.swap();
3187                    }
3188                }
3189
3190                let swap_count = ops.iter().filter(|&&x| x).count();
3191                let expected_idx = if swap_count % 2 == 0 { initial_idx } else { 1 - initial_idx };
3192
3193                prop_assert_eq!(db.current_idx, expected_idx,
3194                    "After {} swaps, index should be {} but was {}",
3195                    swap_count, expected_idx, db.current_idx);
3196            }
3197
3198            #[test]
3199            fn double_buffer_resize_preserves_invariant(
3200                init_w in 1u16..200,
3201                init_h in 1u16..100,
3202                new_w in 1u16..200,
3203                new_h in 1u16..100,
3204            ) {
3205                let mut db = DoubleBuffer::new(init_w, init_h);
3206                db.resize(new_w, new_h);
3207
3208                prop_assert_eq!(db.width(), new_w);
3209                prop_assert_eq!(db.height(), new_h);
3210                prop_assert!(db.dimensions_match(new_w, new_h));
3211            }
3212
3213            #[test]
3214            fn double_buffer_current_previous_disjoint(
3215                width in 1u16..50,
3216                height in 1u16..50,
3217            ) {
3218                let mut db = DoubleBuffer::new(width, height);
3219
3220                // Write to current
3221                db.current_mut().set(0, 0, Cell::from_char('C'));
3222
3223                // Previous should be unaffected
3224                prop_assert!(db.previous().get(0, 0).unwrap().is_empty(),
3225                    "Previous buffer should not reflect changes to current");
3226
3227                // After swap, roles reverse
3228                db.swap();
3229                prop_assert_eq!(db.previous().get(0, 0).unwrap().content.as_char(), Some('C'),
3230                    "After swap, previous should have the 'C' we wrote");
3231            }
3232
3233            #[test]
3234            fn double_buffer_swap_content_semantics(
3235                width in 5u16..30,
3236                height in 5u16..30,
3237            ) {
3238                let mut db = DoubleBuffer::new(width, height);
3239
3240                // Write 'X' to current
3241                db.current_mut().set(0, 0, Cell::from_char('X'));
3242                db.swap();
3243
3244                // Write 'Y' to current (now the other buffer)
3245                db.current_mut().set(0, 0, Cell::from_char('Y'));
3246                db.swap();
3247
3248                // After two swaps, we're back to the buffer with 'X'
3249                prop_assert_eq!(db.current().get(0, 0).unwrap().content.as_char(), Some('X'));
3250                prop_assert_eq!(db.previous().get(0, 0).unwrap().content.as_char(), Some('Y'));
3251            }
3252
3253            #[test]
3254            fn double_buffer_resize_clears_both(
3255                w1 in 5u16..30,
3256                h1 in 5u16..30,
3257                w2 in 5u16..30,
3258                h2 in 5u16..30,
3259            ) {
3260                // Skip if dimensions are the same (resize returns early)
3261                prop_assume!(w1 != w2 || h1 != h2);
3262
3263                let mut db = DoubleBuffer::new(w1, h1);
3264
3265                // Populate both buffers
3266                db.current_mut().set(0, 0, Cell::from_char('A'));
3267                db.swap();
3268                db.current_mut().set(0, 0, Cell::from_char('B'));
3269
3270                // Resize
3271                db.resize(w2, h2);
3272
3273                // Both should be empty
3274                prop_assert!(db.current().get(0, 0).unwrap().is_empty(),
3275                    "Current buffer should be empty after resize");
3276                prop_assert!(db.previous().get(0, 0).unwrap().is_empty(),
3277                    "Previous buffer should be empty after resize");
3278            }
3279        }
3280    }
3281
3282    // ========== Dirty Row Tracking Tests (bd-4kq0.1.1) ==========
3283
3284    #[test]
3285    fn dirty_rows_start_dirty() {
3286        // All rows start dirty to ensure initial diffs see all content.
3287        let buf = Buffer::new(10, 5);
3288        assert_eq!(buf.dirty_row_count(), 5);
3289        for y in 0..5 {
3290            assert!(buf.is_row_dirty(y));
3291        }
3292    }
3293
3294    #[test]
3295    fn dirty_bitmap_starts_full() {
3296        let buf = Buffer::new(4, 3);
3297        assert!(buf.dirty_all());
3298        assert_eq!(buf.dirty_cell_count(), 12);
3299    }
3300
3301    #[test]
3302    fn dirty_bitmap_tracks_single_cell() {
3303        let mut buf = Buffer::new(4, 3);
3304        buf.clear_dirty();
3305        assert!(!buf.dirty_all());
3306        buf.set_raw(1, 1, Cell::from_char('X'));
3307        let idx = 1 + 4;
3308        assert_eq!(buf.dirty_cell_count(), 1);
3309        assert_eq!(buf.dirty_bits()[idx], 1);
3310    }
3311
3312    #[test]
3313    fn dirty_bitmap_dedupes_cells() {
3314        let mut buf = Buffer::new(4, 3);
3315        buf.clear_dirty();
3316        buf.set_raw(2, 2, Cell::from_char('A'));
3317        buf.set_raw(2, 2, Cell::from_char('B'));
3318        assert_eq!(buf.dirty_cell_count(), 1);
3319    }
3320
3321    #[test]
3322    fn set_marks_row_dirty() {
3323        let mut buf = Buffer::new(10, 5);
3324        buf.clear_dirty(); // Reset initial dirty state
3325        buf.set(3, 2, Cell::from_char('X'));
3326        assert!(buf.is_row_dirty(2));
3327        assert!(!buf.is_row_dirty(0));
3328        assert!(!buf.is_row_dirty(1));
3329        assert!(!buf.is_row_dirty(3));
3330        assert!(!buf.is_row_dirty(4));
3331    }
3332
3333    #[test]
3334    fn set_raw_marks_row_dirty() {
3335        let mut buf = Buffer::new(10, 5);
3336        buf.clear_dirty(); // Reset initial dirty state
3337        buf.set_raw(0, 4, Cell::from_char('Z'));
3338        assert!(buf.is_row_dirty(4));
3339        assert_eq!(buf.dirty_row_count(), 1);
3340    }
3341
3342    #[test]
3343    fn clear_marks_all_dirty() {
3344        let mut buf = Buffer::new(10, 5);
3345        buf.clear();
3346        assert_eq!(buf.dirty_row_count(), 5);
3347    }
3348
3349    #[test]
3350    fn clear_dirty_resets_flags() {
3351        let mut buf = Buffer::new(10, 5);
3352        // All rows start dirty; clear_dirty should reset all of them.
3353        assert_eq!(buf.dirty_row_count(), 5);
3354        buf.clear_dirty();
3355        assert_eq!(buf.dirty_row_count(), 0);
3356
3357        // Now mark specific rows dirty and verify clear_dirty resets again.
3358        buf.set(0, 0, Cell::from_char('A'));
3359        buf.set(0, 3, Cell::from_char('B'));
3360        assert_eq!(buf.dirty_row_count(), 2);
3361
3362        buf.clear_dirty();
3363        assert_eq!(buf.dirty_row_count(), 0);
3364    }
3365
3366    #[test]
3367    fn clear_dirty_resets_bitmap() {
3368        let mut buf = Buffer::new(4, 3);
3369        buf.clear();
3370        assert!(buf.dirty_all());
3371        buf.clear_dirty();
3372        assert!(!buf.dirty_all());
3373        assert_eq!(buf.dirty_cell_count(), 0);
3374        assert!(buf.dirty_bits().iter().all(|&b| b == 0));
3375    }
3376
3377    #[test]
3378    fn fill_with_wide_cells_leaves_no_stale_content() {
3379        let mut buf = Buffer::new(5, 1);
3380        for x in 0..5 {
3381            buf.set(x, 0, Cell::from_char('X'));
3382        }
3383        // A width-2 cell filled into a 5-wide rect: heads land at 0 and 2;
3384        // column 4 cannot hold a whole glyph, so `set` drops it there — the
3385        // pre-clear must still purge the old 'X'.
3386        let wide = Cell::from_char('世');
3387        assert_eq!(wide.content.width(), 2);
3388        buf.fill(Rect::new(0, 0, 5, 1), wide);
3389        let trailing = *buf.get(4, 0).expect("in bounds");
3390        assert_ne!(
3391            trailing,
3392            Cell::from_char('X'),
3393            "stale content survived fill"
3394        );
3395        assert_eq!(trailing, Cell::default());
3396        // The two whole glyphs are present.
3397        assert_eq!(*buf.get(0, 0).unwrap(), wide);
3398        assert_eq!(*buf.get(2, 0).unwrap(), wide);
3399    }
3400
3401    #[test]
3402    fn fill_marks_affected_rows_dirty() {
3403        let mut buf = Buffer::new(10, 10);
3404        buf.clear_dirty(); // Reset initial dirty state
3405        buf.fill(Rect::new(0, 2, 5, 3), Cell::from_char('.'));
3406        // Rows 2, 3, 4 should be dirty
3407        assert!(!buf.is_row_dirty(0));
3408        assert!(!buf.is_row_dirty(1));
3409        assert!(buf.is_row_dirty(2));
3410        assert!(buf.is_row_dirty(3));
3411        assert!(buf.is_row_dirty(4));
3412        assert!(!buf.is_row_dirty(5));
3413    }
3414
3415    #[test]
3416    fn get_mut_marks_row_dirty() {
3417        let mut buf = Buffer::new(10, 5);
3418        buf.clear_dirty(); // Reset initial dirty state
3419        if let Some(cell) = buf.get_mut(5, 3) {
3420            cell.fg = PackedRgba::rgb(255, 0, 0);
3421        }
3422        assert!(buf.is_row_dirty(3));
3423        assert_eq!(buf.dirty_row_count(), 1);
3424    }
3425
3426    #[test]
3427    fn cells_mut_marks_all_dirty() {
3428        let mut buf = Buffer::new(10, 5);
3429        let _ = buf.cells_mut();
3430        assert_eq!(buf.dirty_row_count(), 5);
3431    }
3432
3433    #[test]
3434    fn dirty_rows_slice_length_matches_height() {
3435        let buf = Buffer::new(10, 7);
3436        assert_eq!(buf.dirty_rows().len(), 7);
3437    }
3438
3439    #[test]
3440    fn out_of_bounds_set_does_not_dirty() {
3441        let mut buf = Buffer::new(10, 5);
3442        buf.clear_dirty(); // Reset initial dirty state
3443        buf.set(100, 100, Cell::from_char('X'));
3444        assert_eq!(buf.dirty_row_count(), 0);
3445    }
3446
3447    #[test]
3448    fn property_dirty_soundness() {
3449        // Randomized test: any mutation must mark its row.
3450        let mut buf = Buffer::new(20, 10);
3451        let positions = [(3, 0), (5, 2), (0, 9), (19, 5), (10, 7)];
3452        for &(x, y) in &positions {
3453            buf.set(x, y, Cell::from_char('*'));
3454        }
3455        for &(_, y) in &positions {
3456            assert!(
3457                buf.is_row_dirty(y),
3458                "Row {} should be dirty after set({}, {})",
3459                y,
3460                positions.iter().find(|(_, ry)| *ry == y).unwrap().0,
3461                y
3462            );
3463        }
3464    }
3465
3466    #[test]
3467    fn dirty_clear_between_frames() {
3468        // Simulates frame transition: render, diff, clear, render again.
3469        let mut buf = Buffer::new(10, 5);
3470
3471        // All rows start dirty (initial frame needs full diff).
3472        assert_eq!(buf.dirty_row_count(), 5);
3473
3474        // Diff consumes dirty state after initial frame.
3475        buf.clear_dirty();
3476        assert_eq!(buf.dirty_row_count(), 0);
3477
3478        // Frame 1: write to rows 0, 2
3479        buf.set(0, 0, Cell::from_char('A'));
3480        buf.set(0, 2, Cell::from_char('B'));
3481        assert_eq!(buf.dirty_row_count(), 2);
3482
3483        // Diff consumes dirty state
3484        buf.clear_dirty();
3485        assert_eq!(buf.dirty_row_count(), 0);
3486
3487        // Frame 2: write to row 4 only
3488        buf.set(0, 4, Cell::from_char('C'));
3489        assert_eq!(buf.dirty_row_count(), 1);
3490        assert!(buf.is_row_dirty(4));
3491        assert!(!buf.is_row_dirty(0));
3492    }
3493
3494    // ========== Dirty Span Tracking Tests (bd-3e1t.6.2) ==========
3495
3496    #[test]
3497    fn dirty_spans_start_full_dirty() {
3498        let buf = Buffer::new(10, 5);
3499        for y in 0..5 {
3500            let row = buf.dirty_span_row(y).unwrap();
3501            assert!(row.is_full(), "row {y} should start full-dirty");
3502            assert!(row.spans().is_empty(), "row {y} spans should start empty");
3503        }
3504    }
3505
3506    #[test]
3507    fn clear_dirty_resets_spans() {
3508        let mut buf = Buffer::new(10, 5);
3509        buf.clear_dirty();
3510        for y in 0..5 {
3511            let row = buf.dirty_span_row(y).unwrap();
3512            assert!(!row.is_full(), "row {y} should clear full-dirty");
3513            assert!(row.spans().is_empty(), "row {y} spans should be cleared");
3514        }
3515        assert_eq!(buf.dirty_span_overflows, 0);
3516    }
3517
3518    #[test]
3519    fn set_records_dirty_span() {
3520        let mut buf = Buffer::new(20, 2);
3521        buf.clear_dirty();
3522        buf.set(2, 0, Cell::from_char('A'));
3523        let row = buf.dirty_span_row(0).unwrap();
3524        assert_eq!(row.spans(), &[DirtySpan::new(2, 3)]);
3525        assert!(!row.is_full());
3526    }
3527
3528    #[test]
3529    fn set_merges_adjacent_spans() {
3530        let mut buf = Buffer::new(20, 2);
3531        buf.clear_dirty();
3532        buf.set(2, 0, Cell::from_char('A'));
3533        buf.set(3, 0, Cell::from_char('B')); // adjacent, should merge
3534        let row = buf.dirty_span_row(0).unwrap();
3535        assert_eq!(row.spans(), &[DirtySpan::new(2, 4)]);
3536    }
3537
3538    #[test]
3539    fn set_merges_close_spans() {
3540        let mut buf = Buffer::new(20, 2);
3541        buf.clear_dirty();
3542        buf.set(2, 0, Cell::from_char('A'));
3543        buf.set(4, 0, Cell::from_char('B')); // gap of 1, should merge
3544        let row = buf.dirty_span_row(0).unwrap();
3545        assert_eq!(row.spans(), &[DirtySpan::new(2, 5)]);
3546    }
3547
3548    #[test]
3549    fn span_overflow_sets_full_row() {
3550        let width = (DIRTY_SPAN_MAX_SPANS_PER_ROW as u16 + 2) * 3;
3551        let mut buf = Buffer::new(width, 1);
3552        buf.clear_dirty();
3553        for i in 0..(DIRTY_SPAN_MAX_SPANS_PER_ROW + 1) {
3554            let x = (i as u16) * 3;
3555            buf.set(x, 0, Cell::from_char('x'));
3556        }
3557        let row = buf.dirty_span_row(0).unwrap();
3558        assert!(row.is_full());
3559        assert!(row.spans().is_empty());
3560        assert_eq!(buf.dirty_span_overflows, 1);
3561    }
3562
3563    #[test]
3564    fn fill_full_row_marks_full_span() {
3565        let mut buf = Buffer::new(10, 3);
3566        buf.clear_dirty();
3567        let cell = Cell::from_char('x').with_bg(PackedRgba::rgb(0, 0, 0));
3568        buf.fill(Rect::new(0, 1, 10, 1), cell);
3569        let row = buf.dirty_span_row(1).unwrap();
3570        assert!(row.is_full());
3571        assert!(row.spans().is_empty());
3572    }
3573
3574    #[test]
3575    fn get_mut_records_dirty_span() {
3576        let mut buf = Buffer::new(10, 5);
3577        buf.clear_dirty();
3578        let _ = buf.get_mut(5, 3);
3579        let row = buf.dirty_span_row(3).unwrap();
3580        assert_eq!(row.spans(), &[DirtySpan::new(5, 6)]);
3581    }
3582
3583    #[test]
3584    fn cells_mut_marks_all_full_spans() {
3585        let mut buf = Buffer::new(10, 5);
3586        buf.clear_dirty();
3587        let _ = buf.cells_mut();
3588        for y in 0..5 {
3589            let row = buf.dirty_span_row(y).unwrap();
3590            assert!(row.is_full(), "row {y} should be full after cells_mut");
3591        }
3592    }
3593
3594    #[test]
3595    fn dirty_span_config_disabled_skips_rows() {
3596        let mut buf = Buffer::new(10, 1);
3597        buf.clear_dirty();
3598        buf.set_dirty_span_config(DirtySpanConfig::default().with_enabled(false));
3599        buf.set(5, 0, Cell::from_char('x'));
3600        assert!(buf.dirty_span_row(0).is_none());
3601        let stats = buf.dirty_span_stats();
3602        assert_eq!(stats.total_spans, 0);
3603        assert_eq!(stats.span_coverage_cells, 0);
3604    }
3605
3606    #[test]
3607    fn set_dirty_span_config_keeps_dirty_rows_full() {
3608        // Regression: changing the span config used to clear() every span
3609        // row — including the full-row overflow flag — without re-marking
3610        // dirty rows as full. A pre-existing mutation (or a fresh buffer's
3611        // implicit all-dirty state) then stopped being covered by any span,
3612        // and the diff (which scans only the recorded spans of a dirty row
3613        // once new spans exist) silently missed it: a permanent ghost cell.
3614        let mut buf = Buffer::new(10, 2);
3615        buf.clear_dirty();
3616        buf.set(7, 0, Cell::from_char('B')); // pre-config mutation, row 0 dirty
3617
3618        buf.set_dirty_span_config(DirtySpanConfig::default().with_merge_gap(3));
3619
3620        // Row 0 was dirty: it must be conservatively full, not span-less.
3621        let row = buf.dirty_span_row(0).expect("row 0 must have span state");
3622        assert!(
3623            row.is_full(),
3624            "dirty row must go full-row on config change, got spans {:?}",
3625            row.spans()
3626        );
3627        // Row 1 was clean: no span state required.
3628        assert!(!buf.is_row_dirty(1));
3629
3630        // New mutations after the change still record spans without
3631        // narrowing away the pre-config mutation (full-row wins).
3632        buf.set(2, 0, Cell::from_char('A'));
3633        let row = buf.dirty_span_row(0).unwrap();
3634        assert!(row.is_full(), "full-row flag must survive later marks");
3635    }
3636
3637    #[test]
3638    fn set_raw_rewriting_tail_preserves_other_tails_of_wide_glyph() {
3639        // Regression: set_raw of a continuation cell ran the orphan sweep
3640        // from x+1, whose "cannot be owned by x" premise is false for
3641        // continuation writes — rewriting the first tail of a width-3 glyph
3642        // cleared its second (legitimate) tail.
3643        let mut buf = Buffer::new(10, 1);
3644        // Manually assemble a width-3 glyph at x=2 (doc-endorsed pattern);
3645        // width is carried by the GraphemeId's embedded width bits.
3646        let head = Cell::new(CellContent::from_grapheme(GraphemeId::new(0, 0, 3)));
3647        buf.set_raw(2, 0, head);
3648        buf.set_raw(3, 0, Cell::CONTINUATION);
3649        buf.set_raw(4, 0, Cell::CONTINUATION);
3650        assert!(buf.get(4, 0).unwrap().is_continuation());
3651
3652        // Content no-op: rewrite the first tail in place.
3653        buf.set_raw(3, 0, Cell::CONTINUATION);
3654        assert!(
3655            buf.get(4, 0).unwrap().is_continuation(),
3656            "second tail of the width-3 glyph must survive a tail rewrite"
3657        );
3658
3659        // The sweep still clears tails beyond the owning head's extent:
3660        // rebuild as width-2 over the width-3 remains.
3661        let head2 = Cell::new(CellContent::from_grapheme(GraphemeId::new(1, 0, 2)));
3662        buf.set_raw(2, 0, head2);
3663        buf.set_raw(3, 0, Cell::CONTINUATION);
3664        assert!(
3665            !buf.get(4, 0).unwrap().is_continuation(),
3666            "stale tail beyond a narrower rebuilt glyph must be swept"
3667        );
3668    }
3669
3670    #[test]
3671    fn dirty_span_guard_band_expands_span_bounds() {
3672        let mut buf = Buffer::new(10, 1);
3673        buf.clear_dirty();
3674        buf.set_dirty_span_config(DirtySpanConfig::default().with_guard_band(2));
3675        buf.set(5, 0, Cell::from_char('x'));
3676        let row = buf.dirty_span_row(0).unwrap();
3677        assert_eq!(row.spans(), &[DirtySpan::new(3, 8)]);
3678    }
3679
3680    #[test]
3681    fn dirty_span_max_spans_overflow_triggers_full_row() {
3682        let mut buf = Buffer::new(10, 1);
3683        buf.clear_dirty();
3684        buf.set_dirty_span_config(
3685            DirtySpanConfig::default()
3686                .with_max_spans_per_row(1)
3687                .with_merge_gap(0),
3688        );
3689        buf.set(0, 0, Cell::from_char('a'));
3690        buf.set(4, 0, Cell::from_char('b'));
3691        let row = buf.dirty_span_row(0).unwrap();
3692        assert!(row.is_full());
3693        assert!(row.spans().is_empty());
3694        assert_eq!(buf.dirty_span_overflows, 1);
3695    }
3696
3697    #[test]
3698    fn dirty_span_stats_counts_full_rows_and_spans() {
3699        let mut buf = Buffer::new(6, 2);
3700        buf.clear_dirty();
3701        buf.set_dirty_span_config(DirtySpanConfig::default().with_merge_gap(0));
3702        buf.set(1, 0, Cell::from_char('a'));
3703        buf.set(4, 0, Cell::from_char('b'));
3704        buf.mark_dirty_row_full(1);
3705
3706        let stats = buf.dirty_span_stats();
3707        assert_eq!(stats.rows_full_dirty, 1);
3708        assert_eq!(stats.rows_with_spans, 1);
3709        assert_eq!(stats.total_spans, 2);
3710        assert_eq!(stats.max_span_len, 6);
3711        assert_eq!(stats.span_coverage_cells, 8);
3712    }
3713
3714    #[test]
3715    fn dirty_span_stats_reports_overflow_and_full_row() {
3716        let mut buf = Buffer::new(8, 1);
3717        buf.clear_dirty();
3718        buf.set_dirty_span_config(
3719            DirtySpanConfig::default()
3720                .with_max_spans_per_row(1)
3721                .with_merge_gap(0),
3722        );
3723        buf.set(0, 0, Cell::from_char('x'));
3724        buf.set(3, 0, Cell::from_char('y'));
3725
3726        let stats = buf.dirty_span_stats();
3727        assert_eq!(stats.overflows, 1);
3728        assert_eq!(stats.rows_full_dirty, 1);
3729        assert_eq!(stats.total_spans, 0);
3730        assert_eq!(stats.span_coverage_cells, 8);
3731    }
3732
3733    // =====================================================================
3734    // DoubleBuffer tests (bd-1rz0.4.4)
3735    // =====================================================================
3736
3737    #[test]
3738    fn double_buffer_new_has_matching_dimensions() {
3739        let db = DoubleBuffer::new(80, 24);
3740        assert_eq!(db.width(), 80);
3741        assert_eq!(db.height(), 24);
3742        assert!(db.dimensions_match(80, 24));
3743        assert!(!db.dimensions_match(120, 40));
3744    }
3745
3746    #[test]
3747    fn double_buffer_swap_is_o1() {
3748        let mut db = DoubleBuffer::new(80, 24);
3749
3750        // Write to current buffer
3751        db.current_mut().set(0, 0, Cell::from_char('A'));
3752        assert_eq!(db.current().get(0, 0).unwrap().content.as_char(), Some('A'));
3753
3754        // Swap — previous should now have 'A', current should be clean
3755        db.swap();
3756        assert_eq!(
3757            db.previous().get(0, 0).unwrap().content.as_char(),
3758            Some('A')
3759        );
3760        // Current was the old "previous" (empty by default)
3761        assert!(db.current().get(0, 0).unwrap().is_empty());
3762    }
3763
3764    #[test]
3765    fn double_buffer_swap_round_trip() {
3766        let mut db = DoubleBuffer::new(10, 5);
3767
3768        db.current_mut().set(0, 0, Cell::from_char('X'));
3769        db.swap();
3770        db.current_mut().set(0, 0, Cell::from_char('Y'));
3771        db.swap();
3772
3773        // After two swaps, we're back to the buffer that had 'X'
3774        assert_eq!(db.current().get(0, 0).unwrap().content.as_char(), Some('X'));
3775        assert_eq!(
3776            db.previous().get(0, 0).unwrap().content.as_char(),
3777            Some('Y')
3778        );
3779    }
3780
3781    #[test]
3782    fn double_buffer_resize_changes_dimensions() {
3783        let mut db = DoubleBuffer::new(80, 24);
3784        assert!(!db.resize(80, 24)); // No change
3785        assert!(db.resize(120, 40)); // Changed
3786        assert_eq!(db.width(), 120);
3787        assert_eq!(db.height(), 40);
3788        assert!(db.dimensions_match(120, 40));
3789    }
3790
3791    #[test]
3792    fn double_buffer_resize_clears_content() {
3793        let mut db = DoubleBuffer::new(10, 5);
3794        db.current_mut().set(0, 0, Cell::from_char('Z'));
3795        db.swap();
3796        db.current_mut().set(0, 0, Cell::from_char('W'));
3797
3798        db.resize(20, 10);
3799
3800        // Both buffers should be fresh/empty
3801        assert!(db.current().get(0, 0).unwrap().is_empty());
3802        assert!(db.previous().get(0, 0).unwrap().is_empty());
3803    }
3804
3805    #[test]
3806    fn double_buffer_current_and_previous_are_distinct() {
3807        let mut db = DoubleBuffer::new(10, 5);
3808        db.current_mut().set(0, 0, Cell::from_char('C'));
3809
3810        // Previous should not reflect changes to current
3811        assert!(db.previous().get(0, 0).unwrap().is_empty());
3812        assert_eq!(db.current().get(0, 0).unwrap().content.as_char(), Some('C'));
3813    }
3814
3815    // =====================================================================
3816    // AdaptiveDoubleBuffer tests (bd-1rz0.4.2)
3817    // =====================================================================
3818
3819    #[test]
3820    fn adaptive_buffer_new_has_over_allocation() {
3821        let adb = AdaptiveDoubleBuffer::new(80, 24);
3822
3823        // Logical dimensions match requested size
3824        assert_eq!(adb.width(), 80);
3825        assert_eq!(adb.height(), 24);
3826        assert!(adb.dimensions_match(80, 24));
3827
3828        // Capacity should be larger (1.25x growth factor, capped at 200)
3829        // 80 * 0.25 = 20, so capacity_width = 100
3830        // 24 * 0.25 = 6, so capacity_height = 30
3831        assert!(adb.capacity_width() > 80);
3832        assert!(adb.capacity_height() > 24);
3833        assert_eq!(adb.capacity_width(), 100); // 80 + 20
3834        assert_eq!(adb.capacity_height(), 30); // 24 + 6
3835    }
3836
3837    #[test]
3838    fn adaptive_buffer_resize_avoids_reallocation_when_within_capacity() {
3839        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
3840
3841        // Small growth should be absorbed by over-allocation
3842        assert!(adb.resize(90, 28)); // Still within (100, 30) capacity
3843        assert_eq!(adb.width(), 90);
3844        assert_eq!(adb.height(), 28);
3845        assert_eq!(adb.stats().resize_avoided, 1);
3846        assert_eq!(adb.stats().resize_reallocated, 0);
3847        assert_eq!(adb.stats().resize_growth, 1);
3848    }
3849
3850    #[test]
3851    fn adaptive_buffer_resize_reallocates_on_growth_beyond_capacity() {
3852        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
3853
3854        // Growth beyond capacity requires reallocation
3855        assert!(adb.resize(120, 40)); // Exceeds (100, 30) capacity
3856        assert_eq!(adb.width(), 120);
3857        assert_eq!(adb.height(), 40);
3858        assert_eq!(adb.stats().resize_reallocated, 1);
3859        assert_eq!(adb.stats().resize_avoided, 0);
3860
3861        // New capacity should have headroom
3862        assert!(adb.capacity_width() > 120);
3863        assert!(adb.capacity_height() > 40);
3864    }
3865
3866    #[test]
3867    fn adaptive_buffer_resize_reallocates_on_significant_shrink() {
3868        let mut adb = AdaptiveDoubleBuffer::new(100, 50);
3869
3870        // Shrink below 50% threshold should reallocate
3871        // Threshold: 100 * 0.5 = 50, 50 * 0.5 = 25
3872        assert!(adb.resize(40, 20)); // Below 50% of capacity
3873        assert_eq!(adb.width(), 40);
3874        assert_eq!(adb.height(), 20);
3875        assert_eq!(adb.stats().resize_reallocated, 1);
3876        assert_eq!(adb.stats().resize_shrink, 1);
3877    }
3878
3879    #[test]
3880    fn adaptive_buffer_resize_avoids_reallocation_on_minor_shrink() {
3881        let mut adb = AdaptiveDoubleBuffer::new(100, 50);
3882
3883        // Shrink above 50% threshold should reuse capacity
3884        // Threshold: capacity ~125 * 0.5 = 62.5 for width
3885        // 100 > 62.5, so no reallocation
3886        assert!(adb.resize(80, 40));
3887        assert_eq!(adb.width(), 80);
3888        assert_eq!(adb.height(), 40);
3889        assert_eq!(adb.stats().resize_avoided, 1);
3890        assert_eq!(adb.stats().resize_reallocated, 0);
3891        assert_eq!(adb.stats().resize_shrink, 1);
3892    }
3893
3894    #[test]
3895    fn adaptive_buffer_no_change_returns_false() {
3896        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
3897
3898        assert!(!adb.resize(80, 24)); // No change
3899        assert_eq!(adb.stats().resize_avoided, 0);
3900        assert_eq!(adb.stats().resize_reallocated, 0);
3901        assert_eq!(adb.stats().resize_growth, 0);
3902        assert_eq!(adb.stats().resize_shrink, 0);
3903    }
3904
3905    #[test]
3906    fn adaptive_buffer_swap_works() {
3907        let mut adb = AdaptiveDoubleBuffer::new(10, 5);
3908
3909        adb.current_mut().set(0, 0, Cell::from_char('A'));
3910        assert_eq!(
3911            adb.current().get(0, 0).unwrap().content.as_char(),
3912            Some('A')
3913        );
3914
3915        adb.swap();
3916        assert_eq!(
3917            adb.previous().get(0, 0).unwrap().content.as_char(),
3918            Some('A')
3919        );
3920        assert!(adb.current().get(0, 0).unwrap().is_empty());
3921    }
3922
3923    #[test]
3924    fn adaptive_buffer_stats_reset() {
3925        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
3926
3927        adb.resize(90, 28);
3928        adb.resize(120, 40);
3929        assert!(adb.stats().resize_avoided > 0 || adb.stats().resize_reallocated > 0);
3930
3931        adb.reset_stats();
3932        assert_eq!(adb.stats().resize_avoided, 0);
3933        assert_eq!(adb.stats().resize_reallocated, 0);
3934        assert_eq!(adb.stats().resize_growth, 0);
3935        assert_eq!(adb.stats().resize_shrink, 0);
3936    }
3937
3938    #[test]
3939    fn adaptive_buffer_memory_efficiency() {
3940        let adb = AdaptiveDoubleBuffer::new(80, 24);
3941
3942        let efficiency = adb.memory_efficiency();
3943        // 80*24 = 1920 logical cells
3944        // 100*30 = 3000 capacity cells
3945        // efficiency = 1920/3000 = 0.64
3946        assert!(efficiency > 0.5);
3947        assert!(efficiency < 1.0);
3948    }
3949
3950    #[test]
3951    fn adaptive_buffer_logical_bounds() {
3952        let adb = AdaptiveDoubleBuffer::new(80, 24);
3953
3954        let bounds = adb.logical_bounds();
3955        assert_eq!(bounds.x, 0);
3956        assert_eq!(bounds.y, 0);
3957        assert_eq!(bounds.width, 80);
3958        assert_eq!(bounds.height, 24);
3959    }
3960
3961    #[test]
3962    fn adaptive_buffer_capacity_clamped_for_large_sizes() {
3963        // Test that over-allocation is capped at ADAPTIVE_MAX_OVERAGE (200)
3964        let adb = AdaptiveDoubleBuffer::new(1000, 500);
3965
3966        // 1000 * 0.25 = 250, capped to 200
3967        // 500 * 0.25 = 125, not capped
3968        assert_eq!(adb.capacity_width(), 1000 + 200); // capped
3969        assert_eq!(adb.capacity_height(), 500 + 125); // not capped
3970    }
3971
3972    #[test]
3973    fn adaptive_stats_avoidance_ratio() {
3974        let mut stats = AdaptiveStats::default();
3975
3976        // Empty stats should return 1.0 (perfect avoidance)
3977        assert!((stats.avoidance_ratio() - 1.0).abs() < f64::EPSILON);
3978
3979        // 3 avoided, 1 reallocated = 75% avoidance
3980        stats.resize_avoided = 3;
3981        stats.resize_reallocated = 1;
3982        assert!((stats.avoidance_ratio() - 0.75).abs() < f64::EPSILON);
3983
3984        // All reallocations = 0% avoidance
3985        stats.resize_avoided = 0;
3986        stats.resize_reallocated = 5;
3987        assert!((stats.avoidance_ratio() - 0.0).abs() < f64::EPSILON);
3988    }
3989
3990    #[test]
3991    fn adaptive_buffer_resize_storm_simulation() {
3992        // Simulate a resize storm (rapid size changes)
3993        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
3994
3995        // Simulate user resizing terminal in small increments
3996        for i in 1..=10 {
3997            adb.resize(80 + i, 24 + (i / 2));
3998        }
3999
4000        // Most resizes should have avoided reallocation due to over-allocation
4001        let ratio = adb.stats().avoidance_ratio();
4002        assert!(
4003            ratio > 0.5,
4004            "Expected >50% avoidance ratio, got {:.2}",
4005            ratio
4006        );
4007    }
4008
4009    #[test]
4010    fn adaptive_buffer_width_only_growth() {
4011        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
4012
4013        // Grow only width, within capacity
4014        assert!(adb.resize(95, 24)); // 95 < 100 capacity
4015        assert_eq!(adb.stats().resize_avoided, 1);
4016        assert_eq!(adb.stats().resize_growth, 1);
4017    }
4018
4019    #[test]
4020    fn adaptive_buffer_height_only_growth() {
4021        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
4022
4023        // Grow only height, within capacity
4024        assert!(adb.resize(80, 28)); // 28 < 30 capacity
4025        assert_eq!(adb.stats().resize_avoided, 1);
4026        assert_eq!(adb.stats().resize_growth, 1);
4027    }
4028
4029    #[test]
4030    fn adaptive_buffer_one_dimension_exceeds_capacity() {
4031        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
4032
4033        // One dimension exceeds capacity, should reallocate
4034        assert!(adb.resize(105, 24)); // 105 > 100 capacity, 24 < 30
4035        assert_eq!(adb.stats().resize_reallocated, 1);
4036    }
4037
4038    #[test]
4039    fn adaptive_buffer_current_and_previous_distinct() {
4040        let mut adb = AdaptiveDoubleBuffer::new(10, 5);
4041        adb.current_mut().set(0, 0, Cell::from_char('X'));
4042
4043        // Previous should not reflect changes to current
4044        assert!(adb.previous().get(0, 0).unwrap().is_empty());
4045        assert_eq!(
4046            adb.current().get(0, 0).unwrap().content.as_char(),
4047            Some('X')
4048        );
4049    }
4050
4051    #[test]
4052    fn adaptive_buffer_resize_within_capacity_clears_previous() {
4053        let mut adb = AdaptiveDoubleBuffer::new(10, 5);
4054        adb.current_mut().set(9, 4, Cell::from_char('X'));
4055        adb.swap();
4056
4057        // Shrink within capacity (no reallocation expected)
4058        assert!(adb.resize(8, 4));
4059
4060        // Previous buffer should be cleared to avoid stale content outside bounds.
4061        assert!(adb.previous().get(9, 4).unwrap().is_empty());
4062    }
4063
4064    // Property tests for AdaptiveDoubleBuffer invariants
4065    #[test]
4066    fn adaptive_buffer_invariant_capacity_geq_logical() {
4067        // Test across various sizes that capacity always >= logical
4068        for width in [1u16, 10, 80, 200, 1000, 5000] {
4069            for height in [1u16, 10, 24, 100, 500, 2000] {
4070                let adb = AdaptiveDoubleBuffer::new(width, height);
4071                assert!(
4072                    adb.capacity_width() >= adb.width(),
4073                    "capacity_width {} < logical_width {} for ({}, {})",
4074                    adb.capacity_width(),
4075                    adb.width(),
4076                    width,
4077                    height
4078                );
4079                assert!(
4080                    adb.capacity_height() >= adb.height(),
4081                    "capacity_height {} < logical_height {} for ({}, {})",
4082                    adb.capacity_height(),
4083                    adb.height(),
4084                    width,
4085                    height
4086                );
4087            }
4088        }
4089    }
4090
4091    #[test]
4092    fn adaptive_buffer_invariant_resize_dimensions_correct() {
4093        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
4094
4095        // After any resize, logical dimensions should match requested
4096        let test_sizes = [
4097            (100, 50),
4098            (40, 20),
4099            (80, 24),
4100            (200, 100),
4101            (10, 5),
4102            (1000, 500),
4103        ];
4104        for (w, h) in test_sizes {
4105            adb.resize(w, h);
4106            assert_eq!(adb.width(), w, "width mismatch for ({}, {})", w, h);
4107            assert_eq!(adb.height(), h, "height mismatch for ({}, {})", w, h);
4108            assert!(
4109                adb.capacity_width() >= w,
4110                "capacity_width < width for ({}, {})",
4111                w,
4112                h
4113            );
4114            assert!(
4115                adb.capacity_height() >= h,
4116                "capacity_height < height for ({}, {})",
4117                w,
4118                h
4119            );
4120        }
4121    }
4122
4123    // Property test: no-ghosting on shrink
4124    // When buffer shrinks without reallocation, the current buffer is cleared
4125    // to prevent stale content from appearing in the visible area.
4126    #[test]
4127    fn adaptive_buffer_no_ghosting_on_shrink() {
4128        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
4129
4130        // Fill the entire logical area with content
4131        for y in 0..adb.height() {
4132            for x in 0..adb.width() {
4133                adb.current_mut().set(x, y, Cell::from_char('X'));
4134            }
4135        }
4136
4137        // Shrink to a smaller size (still above 50% threshold, so no reallocation)
4138        // 80 * 0.5 = 40, so 60 > 40 means no reallocation
4139        adb.resize(60, 20);
4140
4141        // Verify current buffer is cleared after shrink (no stale 'X' visible)
4142        // The current buffer should be empty because resize() calls clear()
4143        for y in 0..adb.height() {
4144            for x in 0..adb.width() {
4145                let cell = adb.current().get(x, y).unwrap();
4146                assert!(
4147                    cell.is_empty(),
4148                    "Ghost content at ({}, {}): expected empty, got {:?}",
4149                    x,
4150                    y,
4151                    cell.content
4152                );
4153            }
4154        }
4155    }
4156
4157    // Property test: shrink-reallocation clears all content
4158    // When buffer shrinks below threshold (requiring reallocation), both buffers
4159    // should be fresh/empty.
4160    #[test]
4161    fn adaptive_buffer_no_ghosting_on_reallocation_shrink() {
4162        let mut adb = AdaptiveDoubleBuffer::new(100, 50);
4163
4164        // Fill both buffers with content
4165        for y in 0..adb.height() {
4166            for x in 0..adb.width() {
4167                adb.current_mut().set(x, y, Cell::from_char('A'));
4168            }
4169        }
4170        adb.swap();
4171        for y in 0..adb.height() {
4172            for x in 0..adb.width() {
4173                adb.current_mut().set(x, y, Cell::from_char('B'));
4174            }
4175        }
4176
4177        // Shrink below 50% threshold, forcing reallocation
4178        adb.resize(30, 15);
4179        assert_eq!(adb.stats().resize_reallocated, 1);
4180
4181        // Both buffers should be fresh/empty
4182        for y in 0..adb.height() {
4183            for x in 0..adb.width() {
4184                assert!(
4185                    adb.current().get(x, y).unwrap().is_empty(),
4186                    "Ghost in current at ({}, {})",
4187                    x,
4188                    y
4189                );
4190                assert!(
4191                    adb.previous().get(x, y).unwrap().is_empty(),
4192                    "Ghost in previous at ({}, {})",
4193                    x,
4194                    y
4195                );
4196            }
4197        }
4198    }
4199
4200    // Property test: growth preserves no-ghosting guarantee
4201    // When buffer grows beyond capacity (requiring reallocation), the new
4202    // capacity area should be empty.
4203    #[test]
4204    fn adaptive_buffer_no_ghosting_on_growth_reallocation() {
4205        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
4206
4207        // Fill current buffer
4208        for y in 0..adb.height() {
4209            for x in 0..adb.width() {
4210                adb.current_mut().set(x, y, Cell::from_char('Z'));
4211            }
4212        }
4213
4214        // Grow beyond capacity (100, 30) to force reallocation
4215        adb.resize(150, 60);
4216        assert_eq!(adb.stats().resize_reallocated, 1);
4217
4218        // Entire new buffer should be empty
4219        for y in 0..adb.height() {
4220            for x in 0..adb.width() {
4221                assert!(
4222                    adb.current().get(x, y).unwrap().is_empty(),
4223                    "Ghost at ({}, {}) after growth reallocation",
4224                    x,
4225                    y
4226                );
4227            }
4228        }
4229    }
4230
4231    // Property test: idempotence - same resize is no-op
4232    #[test]
4233    fn adaptive_buffer_resize_idempotent() {
4234        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
4235        adb.current_mut().set(5, 5, Cell::from_char('K'));
4236
4237        // Resize to same dimensions should be no-op
4238        let changed = adb.resize(80, 24);
4239        assert!(!changed);
4240
4241        // Content should be preserved
4242        assert_eq!(
4243            adb.current().get(5, 5).unwrap().content.as_char(),
4244            Some('K')
4245        );
4246    }
4247
4248    // =========================================================================
4249    // Dirty Span Tests (bd-3e1t.6.4)
4250    // =========================================================================
4251
4252    #[test]
4253    fn dirty_span_merge_adjacent() {
4254        let mut buf = Buffer::new(100, 1);
4255        buf.clear_dirty(); // Start clean
4256
4257        // Mark [10, 20) dirty
4258        buf.mark_dirty_span(0, 10, 20);
4259        let spans = buf.dirty_span_row(0).unwrap().spans();
4260        assert_eq!(spans.len(), 1);
4261        assert_eq!(spans[0], DirtySpan::new(10, 20));
4262
4263        // Mark [20, 30) dirty (adjacent) -> merge
4264        buf.mark_dirty_span(0, 20, 30);
4265        let spans = buf.dirty_span_row(0).unwrap().spans();
4266        assert_eq!(spans.len(), 1);
4267        assert_eq!(spans[0], DirtySpan::new(10, 30));
4268    }
4269
4270    #[test]
4271    fn dirty_span_merge_overlapping() {
4272        let mut buf = Buffer::new(100, 1);
4273        buf.clear_dirty();
4274
4275        // Mark [10, 20)
4276        buf.mark_dirty_span(0, 10, 20);
4277        // Mark [15, 25) -> merge to [10, 25)
4278        buf.mark_dirty_span(0, 15, 25);
4279
4280        let spans = buf.dirty_span_row(0).unwrap().spans();
4281        assert_eq!(spans.len(), 1);
4282        assert_eq!(spans[0], DirtySpan::new(10, 25));
4283    }
4284
4285    #[test]
4286    fn dirty_span_merge_with_gap() {
4287        let mut buf = Buffer::new(100, 1);
4288        buf.clear_dirty();
4289
4290        // DIRTY_SPAN_MERGE_GAP is 1
4291        // Mark [10, 20)
4292        buf.mark_dirty_span(0, 10, 20);
4293        // Mark [21, 30) -> gap is 1 (index 20) -> merge to [10, 30)
4294        buf.mark_dirty_span(0, 21, 30);
4295
4296        let spans = buf.dirty_span_row(0).unwrap().spans();
4297        assert_eq!(spans.len(), 1);
4298        assert_eq!(spans[0], DirtySpan::new(10, 30));
4299    }
4300
4301    #[test]
4302    fn dirty_span_no_merge_large_gap() {
4303        let mut buf = Buffer::new(100, 1);
4304        buf.clear_dirty();
4305
4306        // Mark [10, 20)
4307        buf.mark_dirty_span(0, 10, 20);
4308        // Mark [22, 30) -> gap is 2 (indices 20, 21) -> no merge
4309        buf.mark_dirty_span(0, 22, 30);
4310
4311        let spans = buf.dirty_span_row(0).unwrap().spans();
4312        assert_eq!(spans.len(), 2);
4313        assert_eq!(spans[0], DirtySpan::new(10, 20));
4314        assert_eq!(spans[1], DirtySpan::new(22, 30));
4315    }
4316
4317    #[test]
4318    fn dirty_span_overflow_to_full() {
4319        let mut buf = Buffer::new(1000, 1);
4320        buf.clear_dirty();
4321
4322        // Create > 64 small spans separated by gaps
4323        for i in 0..DIRTY_SPAN_MAX_SPANS_PER_ROW + 10 {
4324            let start = (i * 4) as u16;
4325            buf.mark_dirty_span(0, start, start + 1);
4326        }
4327
4328        let row = buf.dirty_span_row(0).unwrap();
4329        assert!(row.is_full(), "Row should overflow to full scan");
4330        assert!(
4331            row.spans().is_empty(),
4332            "Spans should be cleared on overflow"
4333        );
4334    }
4335
4336    #[test]
4337    fn dirty_span_bounds_clamping() {
4338        let mut buf = Buffer::new(10, 1);
4339        buf.clear_dirty();
4340
4341        // Mark out of bounds
4342        buf.mark_dirty_span(0, 15, 20);
4343        let spans = buf.dirty_span_row(0).unwrap().spans();
4344        assert!(spans.is_empty());
4345
4346        // Mark crossing bounds
4347        buf.mark_dirty_span(0, 8, 15);
4348        let spans = buf.dirty_span_row(0).unwrap().spans();
4349        assert_eq!(spans.len(), 1);
4350        assert_eq!(spans[0], DirtySpan::new(8, 10)); // Clamped to width
4351    }
4352
4353    #[test]
4354    fn dirty_span_guard_band_clamps_bounds() {
4355        let mut buf = Buffer::new(10, 1);
4356        buf.clear_dirty();
4357        buf.set_dirty_span_config(DirtySpanConfig::default().with_guard_band(5));
4358
4359        buf.mark_dirty_span(0, 2, 3);
4360        let spans = buf.dirty_span_row(0).unwrap().spans();
4361        assert_eq!(spans.len(), 1);
4362        assert_eq!(spans[0], DirtySpan::new(0, 8));
4363
4364        buf.clear_dirty();
4365        buf.mark_dirty_span(0, 8, 10);
4366        let spans = buf.dirty_span_row(0).unwrap().spans();
4367        assert_eq!(spans.len(), 1);
4368        assert_eq!(spans[0], DirtySpan::new(3, 10));
4369    }
4370
4371    #[test]
4372    fn dirty_span_empty_span_is_ignored() {
4373        let mut buf = Buffer::new(10, 1);
4374        buf.clear_dirty();
4375        buf.mark_dirty_span(0, 5, 5);
4376        let spans = buf.dirty_span_row(0).unwrap().spans();
4377        assert!(spans.is_empty());
4378    }
4379
4380    #[test]
4381    fn buffer_fill_wide_char_clipping() {
4382        // Regression test for wide character clipping during fill.
4383        // Verifies that wide characters are not written if they would be clipped,
4384        // and that previous wide characters are cleared correctly.
4385        let mut buf = Buffer::new(10, 5);
4386        let wide_cell = Cell::from_char('🦀'); // Width 2
4387
4388        // 1. Fill with wide char
4389        buf.fill(Rect::new(0, 0, 10, 5), wide_cell);
4390
4391        // Verify head and tail
4392        let head = buf.get(0, 0).unwrap();
4393        assert_eq!(head.content.as_char(), Some('🦀'));
4394        assert_eq!(head.content.width(), 2);
4395
4396        let tail = buf.get(1, 0).unwrap();
4397        assert!(tail.is_continuation());
4398
4399        // 2. Overwrite with clipping
4400        // Push a scissor that splits the wide char at (0,0)
4401        buf.push_scissor(Rect::new(0, 0, 1, 5));
4402        // Fill with 'X'
4403        let x_cell = Cell::from_char('X');
4404        buf.fill(Rect::new(0, 0, 10, 5), x_cell);
4405
4406        // (0,0) should be 'X'. (1,0) should be cleared (orphaned tail).
4407        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('X'));
4408        assert!(buf.get(1, 0).unwrap().is_empty()); // Should be default/empty, not continuation
4409
4410        buf.pop_scissor();
4411    }
4412}