Skip to main content

ftui_render/
buffer.rs

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