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