Skip to main content

ftui_render/
buffer.rs

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