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