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            let mut x = clipped.x;
1103            while x < clipped.right() {
1104                self.set(x, y, cell);
1105                x = x.saturating_add(step);
1106            }
1107        }
1108
1109        self.pop_scissor();
1110    }
1111
1112    /// Clear all cells to the default.
1113    #[inline]
1114    pub fn clear(&mut self) {
1115        self.cells.fill(Cell::default());
1116        self.mark_all_dirty();
1117    }
1118
1119    /// Reset per-frame state and clear all cells.
1120    ///
1121    /// This restores scissor/opacity stacks to their base values to ensure
1122    /// each frame starts from a clean rendering state.
1123    pub fn reset_for_frame(&mut self) {
1124        self.scissor_stack.truncate(1);
1125        if let Some(base) = self.scissor_stack.first_mut() {
1126            *base = Rect::from_size(self.width, self.height);
1127        } else {
1128            self.scissor_stack
1129                .push(Rect::from_size(self.width, self.height));
1130        }
1131
1132        self.opacity_stack.truncate(1);
1133        if let Some(base) = self.opacity_stack.first_mut() {
1134            *base = 1.0;
1135        } else {
1136            self.opacity_stack.push(1.0);
1137        }
1138
1139        self.clear();
1140    }
1141
1142    /// Clear all cells to the given cell.
1143    #[inline]
1144    pub fn clear_with(&mut self, cell: Cell) {
1145        if cell.is_continuation() {
1146            self.clear();
1147            return;
1148        }
1149
1150        let width = cell.content.width();
1151        if width <= 1 {
1152            self.cells.fill(cell);
1153            self.mark_all_dirty();
1154            return;
1155        }
1156
1157        self.cells.fill(Cell::default());
1158        let step = width as u16;
1159        for y in 0..self.height {
1160            let row_start = y as usize * self.width as usize;
1161            let mut x = 0u16;
1162            while x.saturating_add(step) <= self.width {
1163                let head_idx = row_start + x as usize;
1164                self.cells[head_idx] = cell;
1165                for off in 1..step {
1166                    self.cells[head_idx + off as usize] = Cell::CONTINUATION;
1167                }
1168                x = x.saturating_add(step);
1169            }
1170        }
1171        self.mark_all_dirty();
1172    }
1173
1174    /// Get raw access to the cell slice.
1175    ///
1176    /// This is useful for diffing against another buffer.
1177    #[inline]
1178    pub fn cells(&self) -> &[Cell] {
1179        &self.cells
1180    }
1181
1182    /// Get mutable raw access to the cell slice.
1183    ///
1184    /// Marks all rows dirty since caller may modify arbitrary cells.
1185    #[inline]
1186    pub fn cells_mut(&mut self) -> &mut [Cell] {
1187        self.mark_all_dirty();
1188        &mut self.cells
1189    }
1190
1191    /// Get the cells for a single row as a slice.
1192    ///
1193    /// # Panics
1194    ///
1195    /// Panics if `y >= height`.
1196    #[inline]
1197    pub fn row_cells(&self, y: u16) -> &[Cell] {
1198        let start = y as usize * self.width as usize;
1199        &self.cells[start..start + self.width as usize]
1200    }
1201
1202    /// Get mutable cells for a contiguous span on a row.
1203    ///
1204    /// The requested range is treated as half-open `[x0, x1)` and clamped to
1205    /// the buffer width. The span is marked dirty once before returning the
1206    /// mutable slice.
1207    ///
1208    /// This is a raw bulk-mutation helper: callers must already have applied
1209    /// any required scissor/opacity clipping and must not use it for writes
1210    /// that can change cell-content width invariants.
1211    #[inline]
1212    pub fn row_cells_mut_span(&mut self, y: u16, x0: u16, x1: u16) -> Option<&mut [Cell]> {
1213        if y >= self.height {
1214            return None;
1215        }
1216        if x0 >= x1 {
1217            return None;
1218        }
1219
1220        let start = x0.min(self.width);
1221        let end = x1.min(self.width);
1222        if start >= end {
1223            return None;
1224        }
1225
1226        self.mark_dirty_span(y, start, end);
1227
1228        let row_start = y as usize * self.width as usize;
1229        let slice_start = row_start + start as usize;
1230        let slice_end = row_start + end as usize;
1231        Some(&mut self.cells[slice_start..slice_end])
1232    }
1233
1234    // ========== Scissor Stack ==========
1235
1236    /// Push a scissor (clipping) region onto the stack.
1237    ///
1238    /// The effective scissor is the intersection of all pushed rects.
1239    /// If the intersection is empty, no cells will be drawn.
1240    #[inline]
1241    pub fn push_scissor(&mut self, rect: Rect) {
1242        let current = self.current_scissor();
1243        let intersected = current.intersection(&rect);
1244        self.scissor_stack.push(intersected);
1245    }
1246
1247    /// Pop a scissor region from the stack.
1248    ///
1249    /// Does nothing if only the base scissor remains.
1250    #[inline]
1251    pub fn pop_scissor(&mut self) {
1252        if self.scissor_stack.len() > 1 {
1253            self.scissor_stack.pop();
1254        }
1255    }
1256
1257    /// Get the current effective scissor region.
1258    #[inline]
1259    pub fn current_scissor(&self) -> Rect {
1260        *self
1261            .scissor_stack
1262            .last()
1263            .expect("scissor stack always has at least one element")
1264    }
1265
1266    /// Get the scissor stack depth.
1267    #[inline]
1268    pub fn scissor_depth(&self) -> usize {
1269        self.scissor_stack.len()
1270    }
1271
1272    // ========== Opacity Stack ==========
1273
1274    /// Push an opacity multiplier onto the stack.
1275    ///
1276    /// The effective opacity is the product of all pushed values.
1277    /// Values are clamped to `[0.0, 1.0]`.
1278    #[inline]
1279    pub fn push_opacity(&mut self, opacity: f32) {
1280        let clamped = opacity.clamp(0.0, 1.0);
1281        let current = self.current_opacity();
1282        self.opacity_stack.push(current * clamped);
1283    }
1284
1285    /// Pop an opacity value from the stack.
1286    ///
1287    /// Does nothing if only the base opacity remains.
1288    #[inline]
1289    pub fn pop_opacity(&mut self) {
1290        if self.opacity_stack.len() > 1 {
1291            self.opacity_stack.pop();
1292        }
1293    }
1294
1295    /// Get the current effective opacity.
1296    #[inline]
1297    pub fn current_opacity(&self) -> f32 {
1298        *self
1299            .opacity_stack
1300            .last()
1301            .expect("opacity stack always has at least one element")
1302    }
1303
1304    /// Get the opacity stack depth.
1305    #[inline]
1306    pub fn opacity_depth(&self) -> usize {
1307        self.opacity_stack.len()
1308    }
1309
1310    // ========== Copying and Diffing ==========
1311
1312    /// Copy a rectangular region from another buffer.
1313    ///
1314    /// Copies cells from `src` at `src_rect` to this buffer at `dst_pos`.
1315    /// Respects scissor region.
1316    pub fn copy_from(&mut self, src: &Buffer, src_rect: Rect, dst_x: u16, dst_y: u16) {
1317        // Enforce strict bounds on the destination area to prevent wide characters
1318        // from leaking outside the requested copy region.
1319        let copy_bounds = Rect::new(dst_x, dst_y, src_rect.width, src_rect.height);
1320        self.push_scissor(copy_bounds);
1321        let clip = self.current_scissor();
1322
1323        for dy in 0..src_rect.height {
1324            // Compute destination y with overflow check
1325            let Some(target_y) = dst_y.checked_add(dy) else {
1326                continue;
1327            };
1328            let Some(sy) = src_rect.y.checked_add(dy) else {
1329                continue;
1330            };
1331
1332            let mut dx = 0u16;
1333            while dx < src_rect.width {
1334                // Compute coordinates with overflow checks
1335                let Some(target_x) = dst_x.checked_add(dx) else {
1336                    dx = dx.saturating_add(1);
1337                    continue;
1338                };
1339                let Some(sx) = src_rect.x.checked_add(dx) else {
1340                    dx = dx.saturating_add(1);
1341                    continue;
1342                };
1343
1344                if let Some(cell) = src.get(sx, sy) {
1345                    // Continuation cells without their head should not be copied.
1346                    // Heads are handled separately and skip over tails, so any
1347                    // continuation we see here is orphaned by the copy region.
1348                    if cell.is_continuation() {
1349                        self.set(target_x, target_y, Cell::default());
1350                        dx = dx.saturating_add(1);
1351                        continue;
1352                    }
1353
1354                    let width = cell.content.width();
1355                    let target_right = target_x.saturating_add(width as u16);
1356
1357                    // Check for clipping.
1358                    // 1. Source clipping: tail extends beyond the source copy region.
1359                    // 2. Destination clipping: tail extends beyond the effective scissor.
1360                    let src_clipped = width > 1 && dx.saturating_add(width as u16) > src_rect.width;
1361                    let dst_clipped = target_right > clip.right();
1362
1363                    if src_clipped || dst_clipped {
1364                        // Write default cells to all valid positions in the span to ensure
1365                        // previous content is cleared. `set` is atomic for wide chars,
1366                        // so we must write single-width default cells individually.
1367                        let valid_width = (clip.right().saturating_sub(target_x)).min(width as u16);
1368                        for i in 0..valid_width {
1369                            self.set(target_x + i, target_y, Cell::default());
1370                        }
1371                    } else {
1372                        self.set(target_x, target_y, *cell);
1373                    }
1374
1375                    // Skip tails in source iteration.
1376                    if width > 1 {
1377                        dx = dx.saturating_add(width as u16);
1378                    } else {
1379                        dx = dx.saturating_add(1);
1380                    }
1381                } else {
1382                    dx = dx.saturating_add(1);
1383                }
1384            }
1385        }
1386
1387        self.pop_scissor();
1388    }
1389
1390    /// Check if two buffers have identical content.
1391    pub fn content_eq(&self, other: &Buffer) -> bool {
1392        self.width == other.width && self.height == other.height && self.cells == other.cells
1393    }
1394}
1395
1396impl Default for Buffer {
1397    /// Create a 1x1 buffer (minimum size).
1398    fn default() -> Self {
1399        Self::new(1, 1)
1400    }
1401}
1402
1403impl PartialEq for Buffer {
1404    fn eq(&self, other: &Self) -> bool {
1405        self.content_eq(other)
1406    }
1407}
1408
1409impl Eq for Buffer {}
1410
1411// ---------------------------------------------------------------------------
1412// DoubleBuffer: O(1) frame swap (bd-1rz0.4.4)
1413// ---------------------------------------------------------------------------
1414
1415/// Double-buffered render target with O(1) swap.
1416///
1417/// Maintains two pre-allocated buffers and swaps between them by flipping an
1418/// index, avoiding the O(width × height) clone that a naive prev/current
1419/// pattern requires.
1420///
1421/// # Invariants
1422///
1423/// 1. Both buffers always have the same dimensions.
1424/// 2. `swap()` is O(1) — it only flips the index, never copies cells.
1425/// 3. After `swap()`, `current_mut().clear()` should be called to prepare
1426///    the new frame buffer.
1427/// 4. `resize()` discards both buffers and returns `true` so callers know
1428///    a full redraw is needed.
1429#[derive(Debug)]
1430pub struct DoubleBuffer {
1431    buffers: [Buffer; 2],
1432    /// Index of the *current* buffer (0 or 1).
1433    current_idx: u8,
1434}
1435
1436// ---------------------------------------------------------------------------
1437// AdaptiveDoubleBuffer: Allocation-efficient resize (bd-1rz0.4.2)
1438// ---------------------------------------------------------------------------
1439
1440/// Over-allocation factor for growth headroom (1.25x = 25% extra capacity).
1441const ADAPTIVE_GROWTH_FACTOR: f32 = 1.25;
1442
1443/// Shrink threshold: only reallocate if new size < this fraction of capacity.
1444/// This prevents thrashing at size boundaries.
1445const ADAPTIVE_SHRINK_THRESHOLD: f32 = 0.50;
1446
1447/// Maximum over-allocation per dimension (prevent excessive memory usage).
1448const ADAPTIVE_MAX_OVERAGE: u16 = 200;
1449
1450/// Adaptive double-buffered render target with allocation efficiency.
1451///
1452/// Wraps `DoubleBuffer` with capacity tracking to minimize allocations during
1453/// resize storms. Key strategies:
1454///
1455/// 1. **Over-allocation headroom**: Allocate slightly more than needed to handle
1456///    minor size increases without reallocation.
1457/// 2. **Shrink threshold**: Only shrink if new size is significantly smaller
1458///    than allocated capacity (prevents thrashing at size boundaries).
1459/// 3. **Logical vs physical dimensions**: Track both the current view size
1460///    and the allocated capacity separately.
1461///
1462/// # Invariants
1463///
1464/// 1. `capacity_width >= logical_width` and `capacity_height >= logical_height`
1465/// 2. Logical dimensions represent the actual usable area for rendering.
1466/// 3. Physical capacity may exceed logical dimensions by up to `ADAPTIVE_GROWTH_FACTOR`.
1467/// 4. Shrink only occurs when logical size drops below `ADAPTIVE_SHRINK_THRESHOLD * capacity`.
1468///
1469/// # Failure Modes
1470///
1471/// | Condition | Behavior | Rationale |
1472/// |-----------|----------|-----------|
1473/// | Capacity overflow | Clamp to u16::MAX | Prevents panic on extreme sizes |
1474/// | Zero dimensions | Delegate to DoubleBuffer (panic) | Invalid state |
1475///
1476/// # Performance
1477///
1478/// - `resize()` is O(1) when the new size fits within capacity.
1479/// - `resize()` is O(width × height) when reallocation is required.
1480/// - Target: < 5% allocation overhead during resize storms.
1481#[derive(Debug)]
1482pub struct AdaptiveDoubleBuffer {
1483    /// The underlying double buffer (may have larger capacity than logical size).
1484    inner: DoubleBuffer,
1485    /// Logical width (the usable rendering area).
1486    logical_width: u16,
1487    /// Logical height (the usable rendering area).
1488    logical_height: u16,
1489    /// Allocated capacity width (>= logical_width).
1490    capacity_width: u16,
1491    /// Allocated capacity height (>= logical_height).
1492    capacity_height: u16,
1493    /// Statistics for observability.
1494    stats: AdaptiveStats,
1495}
1496
1497/// Statistics for adaptive buffer allocation.
1498#[derive(Debug, Clone, Default)]
1499pub struct AdaptiveStats {
1500    /// Number of resize calls that avoided reallocation.
1501    pub resize_avoided: u64,
1502    /// Number of resize calls that required reallocation.
1503    pub resize_reallocated: u64,
1504    /// Number of resize calls for growth.
1505    pub resize_growth: u64,
1506    /// Number of resize calls for shrink.
1507    pub resize_shrink: u64,
1508}
1509
1510impl AdaptiveStats {
1511    /// Reset statistics to zero.
1512    pub fn reset(&mut self) {
1513        *self = Self::default();
1514    }
1515
1516    /// Calculate the reallocation avoidance ratio (higher is better).
1517    pub fn avoidance_ratio(&self) -> f64 {
1518        let total = self.resize_avoided + self.resize_reallocated;
1519        if total == 0 {
1520            1.0
1521        } else {
1522            self.resize_avoided as f64 / total as f64
1523        }
1524    }
1525}
1526
1527impl DoubleBuffer {
1528    /// Create a double buffer with the given dimensions.
1529    ///
1530    /// Both buffers are initialized to default (empty) cells.
1531    /// Dimensions are clamped to a minimum of 1x1.
1532    pub fn new(width: u16, height: u16) -> Self {
1533        Self {
1534            buffers: [Buffer::new(width, height), Buffer::new(width, height)],
1535            current_idx: 0,
1536        }
1537    }
1538
1539    /// O(1) swap: the current buffer becomes previous, and vice versa.
1540    ///
1541    /// After swapping, call `current_mut().clear()` to prepare for the
1542    /// next frame.
1543    #[inline]
1544    pub fn swap(&mut self) {
1545        self.current_idx = 1 - self.current_idx;
1546    }
1547
1548    /// Reference to the current (in-progress) frame buffer.
1549    #[inline]
1550    pub fn current(&self) -> &Buffer {
1551        &self.buffers[self.current_idx as usize]
1552    }
1553
1554    /// Mutable reference to the current (in-progress) frame buffer.
1555    #[inline]
1556    pub fn current_mut(&mut self) -> &mut Buffer {
1557        &mut self.buffers[self.current_idx as usize]
1558    }
1559
1560    /// Reference to the previous (last-presented) frame buffer.
1561    #[inline]
1562    pub fn previous(&self) -> &Buffer {
1563        &self.buffers[(1 - self.current_idx) as usize]
1564    }
1565
1566    /// Mutable reference to the previous (last-presented) frame buffer.
1567    #[inline]
1568    pub fn previous_mut(&mut self) -> &mut Buffer {
1569        &mut self.buffers[(1 - self.current_idx) as usize]
1570    }
1571
1572    /// Width of both buffers.
1573    #[inline]
1574    pub fn width(&self) -> u16 {
1575        self.buffers[0].width()
1576    }
1577
1578    /// Height of both buffers.
1579    #[inline]
1580    pub fn height(&self) -> u16 {
1581        self.buffers[0].height()
1582    }
1583
1584    /// Resize both buffers. Returns `true` if dimensions actually changed.
1585    ///
1586    /// Both buffers are replaced with fresh allocations and the index is
1587    /// reset. Callers should force a full redraw when this returns `true`.
1588    pub fn resize(&mut self, width: u16, height: u16) -> bool {
1589        if self.buffers[0].width() == width && self.buffers[0].height() == height {
1590            return false;
1591        }
1592        self.buffers = [Buffer::new(width, height), Buffer::new(width, height)];
1593        self.current_idx = 0;
1594        true
1595    }
1596
1597    /// Check whether both buffers have the given dimensions.
1598    #[inline]
1599    pub fn dimensions_match(&self, width: u16, height: u16) -> bool {
1600        self.buffers[0].width() == width && self.buffers[0].height() == height
1601    }
1602}
1603
1604// ---------------------------------------------------------------------------
1605// AdaptiveDoubleBuffer implementation (bd-1rz0.4.2)
1606// ---------------------------------------------------------------------------
1607
1608impl AdaptiveDoubleBuffer {
1609    /// Create a new adaptive buffer with the given logical dimensions.
1610    ///
1611    /// Initial capacity is set with growth headroom applied.
1612    /// Dimensions are clamped to a minimum of 1x1.
1613    pub fn new(width: u16, height: u16) -> Self {
1614        let (cap_w, cap_h) = Self::compute_capacity(width, height);
1615        Self {
1616            inner: DoubleBuffer::new(cap_w, cap_h),
1617            logical_width: width,
1618            logical_height: height,
1619            capacity_width: cap_w,
1620            capacity_height: cap_h,
1621            stats: AdaptiveStats::default(),
1622        }
1623    }
1624
1625    /// Compute the capacity for a given logical size.
1626    ///
1627    /// Applies growth factor with clamping to prevent overflow.
1628    fn compute_capacity(width: u16, height: u16) -> (u16, u16) {
1629        let extra_w =
1630            ((width as f32 * (ADAPTIVE_GROWTH_FACTOR - 1.0)) as u16).min(ADAPTIVE_MAX_OVERAGE);
1631        let extra_h =
1632            ((height as f32 * (ADAPTIVE_GROWTH_FACTOR - 1.0)) as u16).min(ADAPTIVE_MAX_OVERAGE);
1633
1634        let cap_w = width.saturating_add(extra_w);
1635        let cap_h = height.saturating_add(extra_h);
1636
1637        (cap_w, cap_h)
1638    }
1639
1640    /// Check if the new dimensions require reallocation.
1641    ///
1642    /// Returns `true` if reallocation is needed, `false` if current capacity suffices.
1643    fn needs_reallocation(&self, width: u16, height: u16) -> bool {
1644        // Growth beyond capacity always requires reallocation
1645        if width > self.capacity_width || height > self.capacity_height {
1646            return true;
1647        }
1648
1649        // Shrink threshold: reallocate if new size is significantly smaller
1650        let shrink_threshold_w = (self.capacity_width as f32 * ADAPTIVE_SHRINK_THRESHOLD) as u16;
1651        let shrink_threshold_h = (self.capacity_height as f32 * ADAPTIVE_SHRINK_THRESHOLD) as u16;
1652
1653        width < shrink_threshold_w || height < shrink_threshold_h
1654    }
1655
1656    /// O(1) swap: the current buffer becomes previous, and vice versa.
1657    ///
1658    /// After swapping, call `current_mut().clear()` to prepare for the
1659    /// next frame.
1660    #[inline]
1661    pub fn swap(&mut self) {
1662        self.inner.swap();
1663    }
1664
1665    /// Reference to the current (in-progress) frame buffer.
1666    ///
1667    /// Note: The buffer may have larger dimensions than the logical size.
1668    /// Use `logical_width()` and `logical_height()` for rendering bounds.
1669    #[inline]
1670    pub fn current(&self) -> &Buffer {
1671        self.inner.current()
1672    }
1673
1674    /// Mutable reference to the current (in-progress) frame buffer.
1675    #[inline]
1676    pub fn current_mut(&mut self) -> &mut Buffer {
1677        self.inner.current_mut()
1678    }
1679
1680    /// Reference to the previous (last-presented) frame buffer.
1681    #[inline]
1682    pub fn previous(&self) -> &Buffer {
1683        self.inner.previous()
1684    }
1685
1686    /// Logical width (the usable rendering area).
1687    #[inline]
1688    pub fn width(&self) -> u16 {
1689        self.logical_width
1690    }
1691
1692    /// Logical height (the usable rendering area).
1693    #[inline]
1694    pub fn height(&self) -> u16 {
1695        self.logical_height
1696    }
1697
1698    /// Allocated capacity width (may be larger than logical width).
1699    #[inline]
1700    pub fn capacity_width(&self) -> u16 {
1701        self.capacity_width
1702    }
1703
1704    /// Allocated capacity height (may be larger than logical height).
1705    #[inline]
1706    pub fn capacity_height(&self) -> u16 {
1707        self.capacity_height
1708    }
1709
1710    /// Get allocation statistics.
1711    #[inline]
1712    pub fn stats(&self) -> &AdaptiveStats {
1713        &self.stats
1714    }
1715
1716    /// Reset allocation statistics.
1717    pub fn reset_stats(&mut self) {
1718        self.stats.reset();
1719    }
1720
1721    /// Resize the logical dimensions. Returns `true` if dimensions changed.
1722    ///
1723    /// This method minimizes allocations by:
1724    /// 1. Reusing existing capacity when the new size fits.
1725    /// 2. Only reallocating on significant shrink (below threshold).
1726    /// 3. Applying growth headroom to avoid immediate reallocation on growth.
1727    ///
1728    /// # Performance
1729    ///
1730    /// - O(1) when new size fits within existing capacity.
1731    /// - O(width × height) when reallocation is required.
1732    pub fn resize(&mut self, width: u16, height: u16) -> bool {
1733        // No change in logical dimensions
1734        if width == self.logical_width && height == self.logical_height {
1735            return false;
1736        }
1737
1738        let is_growth = width > self.logical_width || height > self.logical_height;
1739        if is_growth {
1740            self.stats.resize_growth += 1;
1741        } else {
1742            self.stats.resize_shrink += 1;
1743        }
1744
1745        if self.needs_reallocation(width, height) {
1746            // Reallocate with new capacity
1747            let (cap_w, cap_h) = Self::compute_capacity(width, height);
1748            self.inner = DoubleBuffer::new(cap_w, cap_h);
1749            self.capacity_width = cap_w;
1750            self.capacity_height = cap_h;
1751            self.stats.resize_reallocated += 1;
1752        } else {
1753            // Reuse existing capacity - just update logical dimensions
1754            // Clear both buffers to avoid stale content outside new bounds
1755            self.inner.current_mut().clear();
1756            self.inner.previous_mut().clear();
1757            self.stats.resize_avoided += 1;
1758        }
1759
1760        self.logical_width = width;
1761        self.logical_height = height;
1762        true
1763    }
1764
1765    /// Check whether logical dimensions match the given values.
1766    #[inline]
1767    pub fn dimensions_match(&self, width: u16, height: u16) -> bool {
1768        self.logical_width == width && self.logical_height == height
1769    }
1770
1771    /// Get the logical bounding rect (for scissoring/rendering).
1772    #[inline]
1773    pub fn logical_bounds(&self) -> Rect {
1774        Rect::from_size(self.logical_width, self.logical_height)
1775    }
1776
1777    /// Calculate memory efficiency (logical cells / capacity cells).
1778    pub fn memory_efficiency(&self) -> f64 {
1779        let logical = self.logical_width as u64 * self.logical_height as u64;
1780        let capacity = self.capacity_width as u64 * self.capacity_height as u64;
1781        if capacity == 0 {
1782            1.0
1783        } else {
1784            logical as f64 / capacity as f64
1785        }
1786    }
1787}
1788
1789#[cfg(test)]
1790mod tests {
1791    use super::*;
1792    use crate::cell::PackedRgba;
1793
1794    #[test]
1795    fn set_composites_background() {
1796        let mut buf = Buffer::new(1, 1);
1797
1798        // Set background to RED
1799        let red = PackedRgba::rgb(255, 0, 0);
1800        buf.set(0, 0, Cell::default().with_bg(red));
1801
1802        // Write 'X' with transparent background
1803        let cell = Cell::from_char('X'); // Default bg is TRANSPARENT
1804        buf.set(0, 0, cell);
1805
1806        let result = buf.get(0, 0).unwrap();
1807        assert_eq!(result.content.as_char(), Some('X'));
1808        assert_eq!(
1809            result.bg, red,
1810            "Background should be preserved (composited)"
1811        );
1812    }
1813
1814    #[test]
1815    fn set_fast_matches_set_for_transparent_bg() {
1816        let red = PackedRgba::rgb(255, 0, 0);
1817        let cell = Cell::from_char('X').with_fg(PackedRgba::rgb(0, 255, 0));
1818
1819        let mut a = Buffer::new(1, 1);
1820        a.set(0, 0, Cell::default().with_bg(red));
1821        a.set(0, 0, cell);
1822
1823        let mut b = Buffer::new(1, 1);
1824        b.set(0, 0, Cell::default().with_bg(red));
1825        b.set_fast(0, 0, cell);
1826
1827        assert_eq!(a.get(0, 0), b.get(0, 0));
1828    }
1829
1830    #[test]
1831    fn set_fast_matches_set_for_opaque_bg() {
1832        let cell = Cell::from_char('X')
1833            .with_fg(PackedRgba::rgb(0, 255, 0))
1834            .with_bg(PackedRgba::rgb(255, 0, 0));
1835
1836        let mut a = Buffer::new(1, 1);
1837        a.set(0, 0, cell);
1838
1839        let mut b = Buffer::new(1, 1);
1840        b.set_fast(0, 0, cell);
1841
1842        assert_eq!(a.get(0, 0), b.get(0, 0));
1843    }
1844
1845    #[test]
1846    fn set_fast_clears_orphaned_tail_like_set() {
1847        let mut slow = Buffer::new(3, 1);
1848        slow.set_raw(0, 0, Cell::from_char('A'));
1849        slow.set_raw(1, 0, Cell::CONTINUATION);
1850        slow.clear_dirty();
1851
1852        let mut fast = slow.clone();
1853
1854        slow.set(0, 0, Cell::from_char('X'));
1855        fast.set_fast(0, 0, Cell::from_char('X'));
1856
1857        assert_eq!(slow.cells(), fast.cells());
1858        assert_eq!(fast.get(1, 0), Some(&Cell::default()));
1859
1860        let spans = fast.dirty_span_row(0).expect("dirty span row").spans();
1861        assert_eq!(spans, &[DirtySpan::new(0, 2)]);
1862    }
1863
1864    #[test]
1865    fn rect_contains() {
1866        let r = Rect::new(5, 5, 10, 10);
1867        assert!(r.contains(5, 5)); // Top-left corner
1868        assert!(r.contains(14, 14)); // Bottom-right inside
1869        assert!(!r.contains(4, 5)); // Left of rect
1870        assert!(!r.contains(15, 5)); // Right of rect (exclusive)
1871        assert!(!r.contains(5, 15)); // Below rect (exclusive)
1872    }
1873
1874    #[test]
1875    fn rect_intersection() {
1876        let a = Rect::new(0, 0, 10, 10);
1877        let b = Rect::new(5, 5, 10, 10);
1878        let i = a.intersection(&b);
1879        assert_eq!(i, Rect::new(5, 5, 5, 5));
1880
1881        // Non-overlapping
1882        let c = Rect::new(20, 20, 5, 5);
1883        assert_eq!(a.intersection(&c), Rect::default());
1884    }
1885
1886    #[test]
1887    fn buffer_creation() {
1888        let buf = Buffer::new(80, 24);
1889        assert_eq!(buf.width(), 80);
1890        assert_eq!(buf.height(), 24);
1891        assert_eq!(buf.len(), 80 * 24);
1892    }
1893
1894    #[test]
1895    fn content_height_empty_is_zero() {
1896        let buf = Buffer::new(8, 4);
1897        assert_eq!(buf.content_height(), 0);
1898    }
1899
1900    #[test]
1901    fn content_height_tracks_last_non_empty_row() {
1902        let mut buf = Buffer::new(5, 4);
1903        buf.set(0, 0, Cell::from_char('A'));
1904        assert_eq!(buf.content_height(), 1);
1905
1906        buf.set(2, 3, Cell::from_char('Z'));
1907        assert_eq!(buf.content_height(), 4);
1908    }
1909
1910    #[test]
1911    fn buffer_zero_width_clamped_to_one() {
1912        let buf = Buffer::new(0, 24);
1913        assert_eq!(buf.width(), 1);
1914        assert_eq!(buf.height(), 24);
1915    }
1916
1917    #[test]
1918    fn buffer_zero_height_clamped_to_one() {
1919        let buf = Buffer::new(80, 0);
1920        assert_eq!(buf.width(), 80);
1921        assert_eq!(buf.height(), 1);
1922    }
1923
1924    #[test]
1925    fn buffer_get_and_set() {
1926        let mut buf = Buffer::new(10, 10);
1927        let cell = Cell::from_char('X');
1928        buf.set(5, 5, cell);
1929        assert_eq!(buf.get(5, 5).unwrap().content.as_char(), Some('X'));
1930    }
1931
1932    #[test]
1933    fn buffer_out_of_bounds_get() {
1934        let buf = Buffer::new(10, 10);
1935        assert!(buf.get(10, 0).is_none());
1936        assert!(buf.get(0, 10).is_none());
1937        assert!(buf.get(100, 100).is_none());
1938    }
1939
1940    #[test]
1941    fn buffer_out_of_bounds_set_ignored() {
1942        let mut buf = Buffer::new(10, 10);
1943        buf.set(100, 100, Cell::from_char('X')); // Should not panic
1944        assert_eq!(buf.cells().iter().filter(|c| !c.is_empty()).count(), 0);
1945    }
1946
1947    #[test]
1948    fn buffer_clear() {
1949        let mut buf = Buffer::new(10, 10);
1950        buf.set(5, 5, Cell::from_char('X'));
1951        buf.clear();
1952        assert!(buf.get(5, 5).unwrap().is_empty());
1953    }
1954
1955    #[test]
1956    fn scissor_stack_basic() {
1957        let mut buf = Buffer::new(20, 20);
1958
1959        // Default scissor covers entire buffer
1960        assert_eq!(buf.current_scissor(), Rect::from_size(20, 20));
1961        assert_eq!(buf.scissor_depth(), 1);
1962
1963        // Push smaller scissor
1964        buf.push_scissor(Rect::new(5, 5, 10, 10));
1965        assert_eq!(buf.current_scissor(), Rect::new(5, 5, 10, 10));
1966        assert_eq!(buf.scissor_depth(), 2);
1967
1968        // Set inside scissor works
1969        buf.set(7, 7, Cell::from_char('I'));
1970        assert_eq!(buf.get(7, 7).unwrap().content.as_char(), Some('I'));
1971
1972        // Set outside scissor is ignored
1973        buf.set(0, 0, Cell::from_char('O'));
1974        assert!(buf.get(0, 0).unwrap().is_empty());
1975
1976        // Pop scissor
1977        buf.pop_scissor();
1978        assert_eq!(buf.current_scissor(), Rect::from_size(20, 20));
1979        assert_eq!(buf.scissor_depth(), 1);
1980
1981        // Now can set at (0, 0)
1982        buf.set(0, 0, Cell::from_char('N'));
1983        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('N'));
1984    }
1985
1986    #[test]
1987    fn scissor_intersection() {
1988        let mut buf = Buffer::new(20, 20);
1989        buf.push_scissor(Rect::new(5, 5, 10, 10));
1990        buf.push_scissor(Rect::new(8, 8, 10, 10));
1991
1992        // Intersection: (8,8) to (15,15) intersected with (5,5) to (15,15)
1993        // Result: (8,8) to (15,15) -> width=7, height=7
1994        assert_eq!(buf.current_scissor(), Rect::new(8, 8, 7, 7));
1995    }
1996
1997    #[test]
1998    fn scissor_base_cannot_be_popped() {
1999        let mut buf = Buffer::new(10, 10);
2000        buf.pop_scissor(); // Should be a no-op
2001        assert_eq!(buf.scissor_depth(), 1);
2002        buf.pop_scissor(); // Still no-op
2003        assert_eq!(buf.scissor_depth(), 1);
2004    }
2005
2006    #[test]
2007    fn opacity_stack_basic() {
2008        let mut buf = Buffer::new(10, 10);
2009
2010        // Default opacity is 1.0
2011        assert!((buf.current_opacity() - 1.0).abs() < f32::EPSILON);
2012        assert_eq!(buf.opacity_depth(), 1);
2013
2014        // Push 0.5 opacity
2015        buf.push_opacity(0.5);
2016        assert!((buf.current_opacity() - 0.5).abs() < f32::EPSILON);
2017        assert_eq!(buf.opacity_depth(), 2);
2018
2019        // Push another 0.5 -> effective 0.25
2020        buf.push_opacity(0.5);
2021        assert!((buf.current_opacity() - 0.25).abs() < f32::EPSILON);
2022        assert_eq!(buf.opacity_depth(), 3);
2023
2024        // Pop back to 0.5
2025        buf.pop_opacity();
2026        assert!((buf.current_opacity() - 0.5).abs() < f32::EPSILON);
2027    }
2028
2029    #[test]
2030    fn opacity_applied_to_cells() {
2031        let mut buf = Buffer::new(10, 10);
2032        buf.push_opacity(0.5);
2033
2034        let cell = Cell::from_char('X').with_fg(PackedRgba::rgba(100, 100, 100, 255));
2035        buf.set(5, 5, cell);
2036
2037        let stored = buf.get(5, 5).unwrap();
2038        // Alpha should be reduced by 0.5
2039        assert_eq!(stored.fg.a(), 128);
2040    }
2041
2042    #[test]
2043    fn opacity_composites_background_before_storage() {
2044        let mut buf = Buffer::new(1, 1);
2045
2046        let red = PackedRgba::rgb(255, 0, 0);
2047        let blue = PackedRgba::rgb(0, 0, 255);
2048
2049        buf.set(0, 0, Cell::default().with_bg(red));
2050        buf.push_opacity(0.5);
2051        buf.set(0, 0, Cell::default().with_bg(blue));
2052
2053        let stored = buf.get(0, 0).unwrap();
2054        let expected = blue.with_opacity(0.5).over(red);
2055        assert_eq!(stored.bg, expected);
2056    }
2057
2058    #[test]
2059    fn opacity_clamped() {
2060        let mut buf = Buffer::new(10, 10);
2061        buf.push_opacity(2.0); // Should clamp to 1.0
2062        assert!((buf.current_opacity() - 1.0).abs() < f32::EPSILON);
2063
2064        buf.push_opacity(-1.0); // Should clamp to 0.0
2065        assert!((buf.current_opacity() - 0.0).abs() < f32::EPSILON);
2066    }
2067
2068    #[test]
2069    fn opacity_base_cannot_be_popped() {
2070        let mut buf = Buffer::new(10, 10);
2071        buf.pop_opacity(); // No-op
2072        assert_eq!(buf.opacity_depth(), 1);
2073    }
2074
2075    #[test]
2076    fn buffer_fill() {
2077        let mut buf = Buffer::new(10, 10);
2078        let cell = Cell::from_char('#');
2079        buf.fill(Rect::new(2, 2, 5, 5), cell);
2080
2081        // Inside fill region
2082        assert_eq!(buf.get(3, 3).unwrap().content.as_char(), Some('#'));
2083
2084        // Outside fill region
2085        assert!(buf.get(0, 0).unwrap().is_empty());
2086    }
2087
2088    #[test]
2089    fn buffer_fill_respects_scissor() {
2090        let mut buf = Buffer::new(10, 10);
2091        buf.push_scissor(Rect::new(3, 3, 4, 4));
2092
2093        let cell = Cell::from_char('#');
2094        buf.fill(Rect::new(0, 0, 10, 10), cell);
2095
2096        // Only scissor region should be filled
2097        assert_eq!(buf.get(3, 3).unwrap().content.as_char(), Some('#'));
2098        assert!(buf.get(0, 0).unwrap().is_empty());
2099        assert!(buf.get(7, 7).unwrap().is_empty());
2100    }
2101
2102    #[test]
2103    fn buffer_copy_from() {
2104        let mut src = Buffer::new(10, 10);
2105        src.set(2, 2, Cell::from_char('S'));
2106
2107        let mut dst = Buffer::new(10, 10);
2108        dst.copy_from(&src, Rect::new(0, 0, 5, 5), 3, 3);
2109
2110        // Cell at (2,2) in src should be at (5,5) in dst (offset by 3,3)
2111        assert_eq!(dst.get(5, 5).unwrap().content.as_char(), Some('S'));
2112    }
2113
2114    #[test]
2115    fn copy_from_clips_wide_char_at_boundary() {
2116        let mut src = Buffer::new(10, 1);
2117        // Wide char at x=0 (width 2)
2118        src.set(0, 0, Cell::from_char('中'));
2119
2120        let mut dst = Buffer::new(10, 1);
2121        // Copy only the first column (x=0, width=1) from src to dst at (0,0)
2122        // This includes the head of '中' but EXCLUDES the tail.
2123        dst.copy_from(&src, Rect::new(0, 0, 1, 1), 0, 0);
2124
2125        // The copy should be atomic: since the tail doesn't fit in the copy region,
2126        // the head should NOT be written (or at least the tail should not be written outside the region).
2127
2128        // Check x=0: Should be empty (atomic rejection) or clipped?
2129        // With implicit scissor fix: atomic rejection means x=0 is empty.
2130        // Without fix: x=0 is '中', x=1 is CONTINUATION (leak).
2131
2132        // Asserting the fix behavior (atomic rejection):
2133        assert!(
2134            dst.get(0, 0).unwrap().is_empty(),
2135            "Wide char head should not be written if tail is clipped"
2136        );
2137        assert!(
2138            dst.get(1, 0).unwrap().is_empty(),
2139            "Wide char tail should not be leaked outside copy region"
2140        );
2141    }
2142
2143    #[test]
2144    fn buffer_content_eq() {
2145        let mut buf1 = Buffer::new(10, 10);
2146        let mut buf2 = Buffer::new(10, 10);
2147
2148        assert!(buf1.content_eq(&buf2));
2149
2150        buf1.set(0, 0, Cell::from_char('X'));
2151        assert!(!buf1.content_eq(&buf2));
2152
2153        buf2.set(0, 0, Cell::from_char('X'));
2154        assert!(buf1.content_eq(&buf2));
2155    }
2156
2157    #[test]
2158    fn buffer_bounds() {
2159        let buf = Buffer::new(80, 24);
2160        let bounds = buf.bounds();
2161        assert_eq!(bounds.x, 0);
2162        assert_eq!(bounds.y, 0);
2163        assert_eq!(bounds.width, 80);
2164        assert_eq!(bounds.height, 24);
2165    }
2166
2167    #[test]
2168    fn buffer_set_raw_bypasses_scissor() {
2169        let mut buf = Buffer::new(10, 10);
2170        buf.push_scissor(Rect::new(5, 5, 5, 5));
2171
2172        // set() respects scissor - this should be ignored
2173        buf.set(0, 0, Cell::from_char('S'));
2174        assert!(buf.get(0, 0).unwrap().is_empty());
2175
2176        // set_raw() bypasses scissor - this should work
2177        buf.set_raw(0, 0, Cell::from_char('R'));
2178        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('R'));
2179    }
2180
2181    #[test]
2182    fn set_handles_wide_chars() {
2183        let mut buf = Buffer::new(10, 10);
2184
2185        // Set a wide character (width 2)
2186        buf.set(0, 0, Cell::from_char('中'));
2187
2188        // Check head
2189        let head = buf.get(0, 0).unwrap();
2190        assert_eq!(head.content.as_char(), Some('中'));
2191
2192        // Check continuation
2193        let cont = buf.get(1, 0).unwrap();
2194        assert!(cont.is_continuation());
2195        assert!(!cont.is_empty());
2196    }
2197
2198    #[test]
2199    fn set_handles_wide_chars_clipped() {
2200        let mut buf = Buffer::new(10, 10);
2201        buf.push_scissor(Rect::new(0, 0, 1, 10)); // Only column 0 is visible
2202
2203        // Set wide char at 0,0. Tail at x=1 is outside scissor.
2204        // Atomic rejection: entire write is rejected because tail doesn't fit.
2205        buf.set(0, 0, Cell::from_char('中'));
2206
2207        // Head should NOT be written (atomic rejection)
2208        assert!(buf.get(0, 0).unwrap().is_empty());
2209        // Tail position should also be unmodified
2210        assert!(buf.get(1, 0).unwrap().is_empty());
2211    }
2212
2213    // ========== Wide Glyph Continuation Cleanup Tests ==========
2214
2215    #[test]
2216    fn overwrite_wide_head_with_single_clears_tails() {
2217        let mut buf = Buffer::new(10, 1);
2218
2219        // Write a wide character (width 2) at position 0
2220        buf.set(0, 0, Cell::from_char('中'));
2221        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('中'));
2222        assert!(buf.get(1, 0).unwrap().is_continuation());
2223
2224        // Overwrite the head with a single-width character
2225        buf.set(0, 0, Cell::from_char('A'));
2226
2227        // Head should be replaced
2228        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('A'));
2229        // Tail (continuation) should be cleared to default
2230        assert!(
2231            buf.get(1, 0).unwrap().is_empty(),
2232            "Continuation at x=1 should be cleared when head is overwritten"
2233        );
2234    }
2235
2236    #[test]
2237    fn set_raw_overwrite_wide_head_with_single_clears_tails() {
2238        let mut buf = Buffer::new(10, 1);
2239
2240        buf.set(0, 0, Cell::from_char('中'));
2241        assert!(buf.get(1, 0).unwrap().is_continuation());
2242        buf.clear_dirty();
2243
2244        buf.set_raw(0, 0, Cell::from_char('A'));
2245
2246        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('A'));
2247        assert!(
2248            buf.get(1, 0).unwrap().is_empty(),
2249            "set_raw should clear stale continuation tails when overwriting a wide head"
2250        );
2251        let spans = buf.dirty_span_row(0).expect("dirty span row").spans();
2252        assert_eq!(spans, &[DirtySpan::new(0, 2)]);
2253    }
2254
2255    #[test]
2256    fn set_raw_wide_head_preserves_manual_tail_cells() {
2257        let mut buf = Buffer::new(10, 1);
2258
2259        buf.set_raw(0, 0, Cell::from_char('中'));
2260        buf.set_raw(1, 0, Cell::CONTINUATION);
2261        assert!(buf.get(1, 0).unwrap().is_continuation());
2262        buf.clear_dirty();
2263
2264        buf.set_raw(0, 0, Cell::from_char('日'));
2265
2266        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('日'));
2267        assert!(
2268            buf.get(1, 0).unwrap().is_continuation(),
2269            "set_raw wide-head replacement should not clear caller-managed tails"
2270        );
2271        let spans = buf.dirty_span_row(0).expect("dirty span row").spans();
2272        assert_eq!(spans, &[DirtySpan::new(0, 1)]);
2273    }
2274
2275    #[test]
2276    fn overwrite_continuation_with_single_clears_head_and_tails() {
2277        let mut buf = Buffer::new(10, 1);
2278
2279        // Write a wide character at position 0
2280        buf.set(0, 0, Cell::from_char('中'));
2281        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('中'));
2282        assert!(buf.get(1, 0).unwrap().is_continuation());
2283
2284        // Overwrite the continuation (position 1) with a single-width char
2285        buf.set(1, 0, Cell::from_char('B'));
2286
2287        // The head at position 0 should be cleared
2288        assert!(
2289            buf.get(0, 0).unwrap().is_empty(),
2290            "Head at x=0 should be cleared when its continuation is overwritten"
2291        );
2292        // Position 1 should have the new character
2293        assert_eq!(buf.get(1, 0).unwrap().content.as_char(), Some('B'));
2294    }
2295
2296    #[test]
2297    fn overwrite_wide_with_another_wide() {
2298        let mut buf = Buffer::new(10, 1);
2299
2300        // Write first wide character
2301        buf.set(0, 0, Cell::from_char('中'));
2302        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('中'));
2303        assert!(buf.get(1, 0).unwrap().is_continuation());
2304
2305        // Overwrite with another wide character
2306        buf.set(0, 0, Cell::from_char('日'));
2307
2308        // Should have new wide character
2309        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('日'));
2310        assert!(
2311            buf.get(1, 0).unwrap().is_continuation(),
2312            "Continuation should still exist for new wide char"
2313        );
2314    }
2315
2316    #[test]
2317    fn overwrite_continuation_middle_of_wide_sequence() {
2318        let mut buf = Buffer::new(10, 1);
2319
2320        // Write two adjacent wide characters: 中 at 0-1, 日 at 2-3
2321        buf.set(0, 0, Cell::from_char('中'));
2322        buf.set(2, 0, Cell::from_char('日'));
2323
2324        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('中'));
2325        assert!(buf.get(1, 0).unwrap().is_continuation());
2326        assert_eq!(buf.get(2, 0).unwrap().content.as_char(), Some('日'));
2327        assert!(buf.get(3, 0).unwrap().is_continuation());
2328
2329        // Overwrite position 1 (continuation of first wide char)
2330        buf.set(1, 0, Cell::from_char('X'));
2331
2332        // First wide char's head should be cleared
2333        assert!(
2334            buf.get(0, 0).unwrap().is_empty(),
2335            "Head of first wide char should be cleared"
2336        );
2337        // Position 1 has new char
2338        assert_eq!(buf.get(1, 0).unwrap().content.as_char(), Some('X'));
2339        // Second wide char should be unaffected
2340        assert_eq!(buf.get(2, 0).unwrap().content.as_char(), Some('日'));
2341        assert!(buf.get(3, 0).unwrap().is_continuation());
2342    }
2343
2344    #[test]
2345    fn wide_char_overlapping_previous_wide_char() {
2346        let mut buf = Buffer::new(10, 1);
2347
2348        // Write wide char at position 0
2349        buf.set(0, 0, Cell::from_char('中'));
2350        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('中'));
2351        assert!(buf.get(1, 0).unwrap().is_continuation());
2352
2353        // Write another wide char at position 1 (overlaps with continuation)
2354        buf.set(1, 0, Cell::from_char('日'));
2355
2356        // First wide char's head should be cleared (its continuation was overwritten)
2357        assert!(
2358            buf.get(0, 0).unwrap().is_empty(),
2359            "First wide char head should be cleared when continuation is overwritten by new wide"
2360        );
2361        // New wide char at positions 1-2
2362        assert_eq!(buf.get(1, 0).unwrap().content.as_char(), Some('日'));
2363        assert!(buf.get(2, 0).unwrap().is_continuation());
2364    }
2365
2366    #[test]
2367    fn wide_char_at_end_of_buffer_atomic_reject() {
2368        let mut buf = Buffer::new(5, 1);
2369
2370        // Try to write wide char at position 4 (would need position 5 for tail, out of bounds)
2371        buf.set(4, 0, Cell::from_char('中'));
2372
2373        // Should be rejected atomically - nothing written
2374        assert!(
2375            buf.get(4, 0).unwrap().is_empty(),
2376            "Wide char should be rejected when tail would be out of bounds"
2377        );
2378    }
2379
2380    #[test]
2381    fn three_wide_chars_sequential_cleanup() {
2382        let mut buf = Buffer::new(10, 1);
2383
2384        // Write three wide chars: positions 0-1, 2-3, 4-5
2385        buf.set(0, 0, Cell::from_char('一'));
2386        buf.set(2, 0, Cell::from_char('二'));
2387        buf.set(4, 0, Cell::from_char('三'));
2388
2389        // Verify initial state
2390        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('一'));
2391        assert!(buf.get(1, 0).unwrap().is_continuation());
2392        assert_eq!(buf.get(2, 0).unwrap().content.as_char(), Some('二'));
2393        assert!(buf.get(3, 0).unwrap().is_continuation());
2394        assert_eq!(buf.get(4, 0).unwrap().content.as_char(), Some('三'));
2395        assert!(buf.get(5, 0).unwrap().is_continuation());
2396
2397        // Overwrite middle wide char's continuation with single char
2398        buf.set(3, 0, Cell::from_char('M'));
2399
2400        // First wide char should be unaffected
2401        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('一'));
2402        assert!(buf.get(1, 0).unwrap().is_continuation());
2403        // Middle wide char's head should be cleared
2404        assert!(buf.get(2, 0).unwrap().is_empty());
2405        // Position 3 has new char
2406        assert_eq!(buf.get(3, 0).unwrap().content.as_char(), Some('M'));
2407        // Third wide char should be unaffected
2408        assert_eq!(buf.get(4, 0).unwrap().content.as_char(), Some('三'));
2409        assert!(buf.get(5, 0).unwrap().is_continuation());
2410    }
2411
2412    #[test]
2413    fn overwrite_empty_cell_no_cleanup_needed() {
2414        let mut buf = Buffer::new(10, 1);
2415
2416        // Write to an empty cell - no cleanup should be needed
2417        buf.set(5, 0, Cell::from_char('X'));
2418
2419        assert_eq!(buf.get(5, 0).unwrap().content.as_char(), Some('X'));
2420        // Adjacent cells should still be empty
2421        assert!(buf.get(4, 0).unwrap().is_empty());
2422        assert!(buf.get(6, 0).unwrap().is_empty());
2423    }
2424
2425    #[test]
2426    fn wide_char_cleanup_with_opacity() {
2427        let mut buf = Buffer::new(10, 1);
2428
2429        // Set background
2430        buf.set(0, 0, Cell::default().with_bg(PackedRgba::rgb(255, 0, 0)));
2431        buf.set(1, 0, Cell::default().with_bg(PackedRgba::rgb(0, 255, 0)));
2432
2433        // Write wide char
2434        buf.set(0, 0, Cell::from_char('中'));
2435
2436        // Overwrite with opacity
2437        buf.push_opacity(0.5);
2438        buf.set(0, 0, Cell::from_char('A'));
2439        buf.pop_opacity();
2440
2441        // Check head is replaced
2442        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('A'));
2443        // Continuation should be cleared
2444        assert!(buf.get(1, 0).unwrap().is_empty());
2445    }
2446
2447    #[test]
2448    fn wide_char_continuation_not_treated_as_head() {
2449        let mut buf = Buffer::new(10, 1);
2450
2451        // Write a wide character
2452        buf.set(0, 0, Cell::from_char('中'));
2453
2454        // Verify the continuation cell has zero width (not treated as a head)
2455        let cont = buf.get(1, 0).unwrap();
2456        assert!(cont.is_continuation());
2457        assert_eq!(cont.content.width(), 0);
2458
2459        // Writing another wide char starting at position 1 should work correctly
2460        buf.set(1, 0, Cell::from_char('日'));
2461
2462        // Original head should be cleared
2463        assert!(buf.get(0, 0).unwrap().is_empty());
2464        // New wide char at 1-2
2465        assert_eq!(buf.get(1, 0).unwrap().content.as_char(), Some('日'));
2466        assert!(buf.get(2, 0).unwrap().is_continuation());
2467    }
2468
2469    #[test]
2470    fn wide_char_fill_region() {
2471        let mut buf = Buffer::new(10, 3);
2472
2473        // Fill a 4x2 region with a wide character.
2474        // Wide fills advance by width to prevent overlap churn.
2475        let wide_cell = Cell::from_char('中');
2476        buf.fill(Rect::new(0, 0, 4, 2), wide_cell);
2477
2478        // Row 0 should contain two wide graphemes at x={0,2}.
2479        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('中'));
2480        assert!(buf.get(1, 0).unwrap().is_continuation());
2481        assert_eq!(buf.get(2, 0).unwrap().content.as_char(), Some('中'));
2482        assert!(buf.get(3, 0).unwrap().is_continuation());
2483    }
2484
2485    #[test]
2486    fn default_buffer_dimensions() {
2487        let buf = Buffer::default();
2488        assert_eq!(buf.width(), 1);
2489        assert_eq!(buf.height(), 1);
2490        assert_eq!(buf.len(), 1);
2491    }
2492
2493    #[test]
2494    fn buffer_partial_eq_impl() {
2495        let buf1 = Buffer::new(5, 5);
2496        let buf2 = Buffer::new(5, 5);
2497        let mut buf3 = Buffer::new(5, 5);
2498        buf3.set(0, 0, Cell::from_char('X'));
2499
2500        assert_eq!(buf1, buf2);
2501        assert_ne!(buf1, buf3);
2502    }
2503
2504    #[test]
2505    fn degradation_level_accessible() {
2506        let mut buf = Buffer::new(10, 10);
2507        assert_eq!(buf.degradation, DegradationLevel::Full);
2508
2509        buf.degradation = DegradationLevel::SimpleBorders;
2510        assert_eq!(buf.degradation, DegradationLevel::SimpleBorders);
2511    }
2512
2513    // --- get_mut ---
2514
2515    #[test]
2516    fn get_mut_modifies_cell() {
2517        let mut buf = Buffer::new(10, 10);
2518        buf.set(3, 3, Cell::from_char('A'));
2519
2520        if let Some(cell) = buf.get_mut(3, 3) {
2521            *cell = Cell::from_char('B');
2522        }
2523
2524        assert_eq!(buf.get(3, 3).unwrap().content.as_char(), Some('B'));
2525    }
2526
2527    #[test]
2528    fn get_mut_out_of_bounds() {
2529        let mut buf = Buffer::new(5, 5);
2530        assert!(buf.get_mut(10, 10).is_none());
2531    }
2532
2533    // --- clear_with ---
2534
2535    #[test]
2536    fn clear_with_fills_all_cells() {
2537        let mut buf = Buffer::new(5, 3);
2538        let fill_cell = Cell::from_char('*');
2539        buf.clear_with(fill_cell);
2540
2541        for y in 0..3 {
2542            for x in 0..5 {
2543                assert_eq!(buf.get(x, y).unwrap().content.as_char(), Some('*'));
2544            }
2545        }
2546    }
2547
2548    #[test]
2549    fn clear_with_wide_cell_preserves_head_tail_invariant() {
2550        let mut buf = Buffer::new(5, 2);
2551        buf.clear_with(Cell::from_char('中'));
2552
2553        for y in 0..2 {
2554            assert_eq!(buf.get(0, y).unwrap().content.as_char(), Some('中'));
2555            assert!(buf.get(1, y).unwrap().is_continuation());
2556            assert_eq!(buf.get(2, y).unwrap().content.as_char(), Some('中'));
2557            assert!(buf.get(3, y).unwrap().is_continuation());
2558            assert!(buf.get(4, y).unwrap().is_empty());
2559        }
2560    }
2561
2562    // --- cells / cells_mut ---
2563
2564    #[test]
2565    fn cells_slice_has_correct_length() {
2566        let buf = Buffer::new(10, 5);
2567        assert_eq!(buf.cells().len(), 50);
2568    }
2569
2570    #[test]
2571    fn cells_mut_allows_direct_modification() {
2572        let mut buf = Buffer::new(3, 2);
2573        let cells = buf.cells_mut();
2574        cells[0] = Cell::from_char('Z');
2575
2576        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('Z'));
2577    }
2578
2579    // --- row_cells ---
2580
2581    #[test]
2582    fn row_cells_returns_correct_row() {
2583        let mut buf = Buffer::new(5, 3);
2584        buf.set(2, 1, Cell::from_char('R'));
2585
2586        let row = buf.row_cells(1);
2587        assert_eq!(row.len(), 5);
2588        assert_eq!(row[2].content.as_char(), Some('R'));
2589    }
2590
2591    #[test]
2592    fn row_cells_mut_span_marks_once_and_returns_slice() {
2593        let mut buf = Buffer::new(5, 3);
2594        buf.clear_dirty();
2595
2596        let row = buf
2597            .row_cells_mut_span(1, 1, 4)
2598            .expect("row span should be in bounds");
2599        assert_eq!(row.len(), 3);
2600        row[0] = Cell::from_char('A');
2601        row[1] = Cell::from_char('B');
2602        row[2] = Cell::from_char('C');
2603
2604        assert!(buf.is_row_dirty(1));
2605        let spans = buf.dirty_span_row(1).expect("dirty span row").spans();
2606        assert_eq!(spans, &[DirtySpan::new(1, 4)]);
2607        assert_eq!(buf.get(1, 1).unwrap().content.as_char(), Some('A'));
2608        assert_eq!(buf.get(2, 1).unwrap().content.as_char(), Some('B'));
2609        assert_eq!(buf.get(3, 1).unwrap().content.as_char(), Some('C'));
2610    }
2611
2612    #[test]
2613    fn row_cells_mut_span_clamps_to_buffer_width() {
2614        let mut buf = Buffer::new(5, 1);
2615        buf.clear_dirty();
2616
2617        let row = buf
2618            .row_cells_mut_span(0, 3, 99)
2619            .expect("row span should clamp");
2620        assert_eq!(row.len(), 2);
2621        row[0] = Cell::from_char('X');
2622        row[1] = Cell::from_char('Y');
2623
2624        let spans = buf.dirty_span_row(0).expect("dirty span row").spans();
2625        assert_eq!(spans, &[DirtySpan::new(3, 5)]);
2626        assert_eq!(buf.get(3, 0).unwrap().content.as_char(), Some('X'));
2627        assert_eq!(buf.get(4, 0).unwrap().content.as_char(), Some('Y'));
2628    }
2629
2630    #[test]
2631    fn row_cells_mut_span_rejects_reversed_ranges() {
2632        let mut buf = Buffer::new(5, 1);
2633        buf.clear_dirty();
2634
2635        assert!(buf.row_cells_mut_span(0, 4, 2).is_none());
2636        assert!(
2637            !buf.is_row_dirty(0),
2638            "reversed ranges should not mark rows dirty"
2639        );
2640        assert!(
2641            buf.dirty_span_row(0)
2642                .expect("dirty span row")
2643                .spans()
2644                .is_empty(),
2645            "reversed ranges should not add dirty spans"
2646        );
2647    }
2648
2649    #[test]
2650    #[should_panic]
2651    fn row_cells_out_of_bounds_panics() {
2652        let buf = Buffer::new(5, 3);
2653        let _ = buf.row_cells(5);
2654    }
2655
2656    // --- is_empty ---
2657
2658    #[test]
2659    fn buffer_is_not_empty() {
2660        let buf = Buffer::new(1, 1);
2661        assert!(!buf.is_empty());
2662    }
2663
2664    // --- set_raw out of bounds ---
2665
2666    #[test]
2667    fn set_raw_out_of_bounds_is_safe() {
2668        let mut buf = Buffer::new(5, 5);
2669        buf.set_raw(100, 100, Cell::from_char('X'));
2670        // Should not panic, just be ignored
2671    }
2672
2673    // --- copy_from with offset ---
2674
2675    #[test]
2676    fn copy_from_out_of_bounds_partial() {
2677        let mut src = Buffer::new(5, 5);
2678        src.set(0, 0, Cell::from_char('A'));
2679        src.set(4, 4, Cell::from_char('B'));
2680
2681        let mut dst = Buffer::new(5, 5);
2682        // Copy entire src with offset that puts part out of bounds
2683        dst.copy_from(&src, Rect::new(0, 0, 5, 5), 3, 3);
2684
2685        // (0,0) in src → (3,3) in dst = inside
2686        assert_eq!(dst.get(3, 3).unwrap().content.as_char(), Some('A'));
2687        // (4,4) in src → (7,7) in dst = outside, should be ignored
2688        assert!(dst.get(4, 4).unwrap().is_empty());
2689    }
2690
2691    // --- content_eq with different dimensions ---
2692
2693    #[test]
2694    fn content_eq_different_dimensions() {
2695        let buf1 = Buffer::new(5, 5);
2696        let buf2 = Buffer::new(10, 10);
2697        // Different dimensions should not be equal (different cell counts)
2698        assert!(!buf1.content_eq(&buf2));
2699    }
2700
2701    // ====== Property tests (proptest) ======
2702
2703    mod property {
2704        use super::*;
2705        use proptest::prelude::*;
2706
2707        proptest! {
2708            #[test]
2709            fn buffer_dimensions_are_preserved(width in 1u16..200, height in 1u16..200) {
2710                let buf = Buffer::new(width, height);
2711                prop_assert_eq!(buf.width(), width);
2712                prop_assert_eq!(buf.height(), height);
2713                prop_assert_eq!(buf.len(), width as usize * height as usize);
2714            }
2715
2716            #[test]
2717            fn buffer_get_in_bounds_always_succeeds(width in 1u16..100, height in 1u16..100) {
2718                let buf = Buffer::new(width, height);
2719                for x in 0..width {
2720                    for y in 0..height {
2721                        prop_assert!(buf.get(x, y).is_some(), "get({x},{y}) failed for {width}x{height} buffer");
2722                    }
2723                }
2724            }
2725
2726            #[test]
2727            fn buffer_get_out_of_bounds_returns_none(width in 1u16..50, height in 1u16..50) {
2728                let buf = Buffer::new(width, height);
2729                prop_assert!(buf.get(width, 0).is_none());
2730                prop_assert!(buf.get(0, height).is_none());
2731                prop_assert!(buf.get(width, height).is_none());
2732            }
2733
2734            #[test]
2735            fn buffer_set_get_roundtrip(
2736                width in 5u16..50,
2737                height in 5u16..50,
2738                x in 0u16..5,
2739                y in 0u16..5,
2740                ch_idx in 0u32..26,
2741            ) {
2742                let x = x % width;
2743                let y = y % height;
2744                let ch = char::from_u32('A' as u32 + ch_idx).unwrap();
2745                let mut buf = Buffer::new(width, height);
2746                buf.set(x, y, Cell::from_char(ch));
2747                let got = buf.get(x, y).unwrap();
2748                prop_assert_eq!(got.content.as_char(), Some(ch));
2749            }
2750
2751            #[test]
2752            fn scissor_push_pop_stack_depth(
2753                width in 10u16..50,
2754                height in 10u16..50,
2755                push_count in 1usize..10,
2756            ) {
2757                let mut buf = Buffer::new(width, height);
2758                prop_assert_eq!(buf.scissor_depth(), 1); // base
2759
2760                for i in 0..push_count {
2761                    buf.push_scissor(Rect::new(0, 0, width, height));
2762                    prop_assert_eq!(buf.scissor_depth(), i + 2);
2763                }
2764
2765                for i in (0..push_count).rev() {
2766                    buf.pop_scissor();
2767                    prop_assert_eq!(buf.scissor_depth(), i + 1);
2768                }
2769
2770                // Base cannot be popped
2771                buf.pop_scissor();
2772                prop_assert_eq!(buf.scissor_depth(), 1);
2773            }
2774
2775            #[test]
2776            fn scissor_monotonic_intersection(
2777                width in 20u16..60,
2778                height in 20u16..60,
2779            ) {
2780                // Scissor stack always shrinks or stays the same
2781                let mut buf = Buffer::new(width, height);
2782                let outer = Rect::new(2, 2, width - 4, height - 4);
2783                buf.push_scissor(outer);
2784                let s1 = buf.current_scissor();
2785
2786                let inner = Rect::new(5, 5, 10, 10);
2787                buf.push_scissor(inner);
2788                let s2 = buf.current_scissor();
2789
2790                // Inner scissor must be contained within or equal to outer
2791                prop_assert!(s2.width <= s1.width, "inner width {} > outer width {}", s2.width, s1.width);
2792                prop_assert!(s2.height <= s1.height, "inner height {} > outer height {}", s2.height, s1.height);
2793            }
2794
2795            #[test]
2796            fn opacity_push_pop_stack_depth(
2797                width in 5u16..20,
2798                height in 5u16..20,
2799                push_count in 1usize..10,
2800            ) {
2801                let mut buf = Buffer::new(width, height);
2802                prop_assert_eq!(buf.opacity_depth(), 1);
2803
2804                for i in 0..push_count {
2805                    buf.push_opacity(0.9);
2806                    prop_assert_eq!(buf.opacity_depth(), i + 2);
2807                }
2808
2809                for i in (0..push_count).rev() {
2810                    buf.pop_opacity();
2811                    prop_assert_eq!(buf.opacity_depth(), i + 1);
2812                }
2813
2814                buf.pop_opacity();
2815                prop_assert_eq!(buf.opacity_depth(), 1);
2816            }
2817
2818            #[test]
2819            fn opacity_multiplication_is_monotonic(
2820                opacity1 in 0.0f32..=1.0,
2821                opacity2 in 0.0f32..=1.0,
2822            ) {
2823                let mut buf = Buffer::new(5, 5);
2824                buf.push_opacity(opacity1);
2825                let after_first = buf.current_opacity();
2826                buf.push_opacity(opacity2);
2827                let after_second = buf.current_opacity();
2828
2829                // Effective opacity can only decrease (or stay same at 0 or 1)
2830                prop_assert!(after_second <= after_first + f32::EPSILON,
2831                    "opacity increased: {} -> {}", after_first, after_second);
2832            }
2833
2834            #[test]
2835            fn clear_resets_all_cells(width in 1u16..30, height in 1u16..30) {
2836                let mut buf = Buffer::new(width, height);
2837                // Write some data
2838                for x in 0..width {
2839                    buf.set_raw(x, 0, Cell::from_char('X'));
2840                }
2841                buf.clear();
2842                // All cells should be default (empty)
2843                for y in 0..height {
2844                    for x in 0..width {
2845                        prop_assert!(buf.get(x, y).unwrap().is_empty(),
2846                            "cell ({x},{y}) not empty after clear");
2847                    }
2848                }
2849            }
2850
2851            #[test]
2852            fn content_eq_is_reflexive(width in 1u16..30, height in 1u16..30) {
2853                let buf = Buffer::new(width, height);
2854                prop_assert!(buf.content_eq(&buf));
2855            }
2856
2857            #[test]
2858            fn content_eq_detects_single_change(
2859                width in 5u16..30,
2860                height in 5u16..30,
2861                x in 0u16..5,
2862                y in 0u16..5,
2863            ) {
2864                let x = x % width;
2865                let y = y % height;
2866                let buf1 = Buffer::new(width, height);
2867                let mut buf2 = Buffer::new(width, height);
2868                buf2.set_raw(x, y, Cell::from_char('Z'));
2869                prop_assert!(!buf1.content_eq(&buf2));
2870            }
2871
2872            // --- Executable Invariant Tests (bd-10i.13.2) ---
2873
2874            #[test]
2875            fn dimensions_immutable_through_operations(
2876                width in 5u16..30,
2877                height in 5u16..30,
2878            ) {
2879                let mut buf = Buffer::new(width, height);
2880
2881                // Operations that must not change dimensions
2882                buf.set(0, 0, Cell::from_char('A'));
2883                prop_assert_eq!(buf.width(), width);
2884                prop_assert_eq!(buf.height(), height);
2885                prop_assert_eq!(buf.len(), width as usize * height as usize);
2886
2887                buf.push_scissor(Rect::new(1, 1, 3, 3));
2888                prop_assert_eq!(buf.width(), width);
2889                prop_assert_eq!(buf.height(), height);
2890
2891                buf.push_opacity(0.5);
2892                prop_assert_eq!(buf.width(), width);
2893                prop_assert_eq!(buf.height(), height);
2894
2895                buf.pop_scissor();
2896                buf.pop_opacity();
2897                prop_assert_eq!(buf.width(), width);
2898                prop_assert_eq!(buf.height(), height);
2899
2900                buf.clear();
2901                prop_assert_eq!(buf.width(), width);
2902                prop_assert_eq!(buf.height(), height);
2903                prop_assert_eq!(buf.len(), width as usize * height as usize);
2904            }
2905
2906            #[test]
2907            fn scissor_area_never_increases_random_rects(
2908                width in 20u16..60,
2909                height in 20u16..60,
2910                rects in proptest::collection::vec(
2911                    (0u16..20, 0u16..20, 1u16..15, 1u16..15),
2912                    1..8
2913                ),
2914            ) {
2915                let mut buf = Buffer::new(width, height);
2916                let mut prev_area = (width as u32) * (height as u32);
2917
2918                for (x, y, w, h) in rects {
2919                    buf.push_scissor(Rect::new(x, y, w, h));
2920                    let s = buf.current_scissor();
2921                    let area = (s.width as u32) * (s.height as u32);
2922                    prop_assert!(area <= prev_area,
2923                        "scissor area increased: {} -> {} after push({},{},{},{})",
2924                        prev_area, area, x, y, w, h);
2925                    prev_area = area;
2926                }
2927            }
2928
2929            #[test]
2930            fn opacity_range_invariant_random_sequence(
2931                opacities in proptest::collection::vec(0.0f32..=1.0, 1..15),
2932            ) {
2933                let mut buf = Buffer::new(5, 5);
2934
2935                for &op in &opacities {
2936                    buf.push_opacity(op);
2937                    let current = buf.current_opacity();
2938                    prop_assert!(current >= 0.0, "opacity below 0: {}", current);
2939                    prop_assert!(current <= 1.0 + f32::EPSILON,
2940                        "opacity above 1: {}", current);
2941                }
2942
2943                // Pop everything and verify we get back to 1.0
2944                for _ in &opacities {
2945                    buf.pop_opacity();
2946                }
2947                // After popping all pushed, should be back to base (1.0)
2948                prop_assert!((buf.current_opacity() - 1.0).abs() < f32::EPSILON);
2949            }
2950
2951            #[test]
2952            fn opacity_clamp_out_of_range(
2953                neg in -100.0f32..0.0,
2954                over in 1.01f32..100.0,
2955            ) {
2956                let mut buf = Buffer::new(5, 5);
2957
2958                buf.push_opacity(neg);
2959                prop_assert!(buf.current_opacity() >= 0.0,
2960                    "negative opacity not clamped: {}", buf.current_opacity());
2961                buf.pop_opacity();
2962
2963                buf.push_opacity(over);
2964                prop_assert!(buf.current_opacity() <= 1.0 + f32::EPSILON,
2965                    "over-1 opacity not clamped: {}", buf.current_opacity());
2966            }
2967
2968            #[test]
2969            fn scissor_stack_always_has_base(
2970                pushes in 0usize..10,
2971                pops in 0usize..15,
2972            ) {
2973                let mut buf = Buffer::new(10, 10);
2974
2975                for _ in 0..pushes {
2976                    buf.push_scissor(Rect::new(0, 0, 5, 5));
2977                }
2978                for _ in 0..pops {
2979                    buf.pop_scissor();
2980                }
2981
2982                // Invariant: depth is always >= 1
2983                prop_assert!(buf.scissor_depth() >= 1,
2984                    "scissor depth dropped below 1 after {} pushes, {} pops",
2985                    pushes, pops);
2986            }
2987
2988            #[test]
2989            fn opacity_stack_always_has_base(
2990                pushes in 0usize..10,
2991                pops in 0usize..15,
2992            ) {
2993                let mut buf = Buffer::new(10, 10);
2994
2995                for _ in 0..pushes {
2996                    buf.push_opacity(0.5);
2997                }
2998                for _ in 0..pops {
2999                    buf.pop_opacity();
3000                }
3001
3002                // Invariant: depth is always >= 1
3003                prop_assert!(buf.opacity_depth() >= 1,
3004                    "opacity depth dropped below 1 after {} pushes, {} pops",
3005                    pushes, pops);
3006            }
3007
3008            #[test]
3009            fn cells_len_invariant_always_holds(
3010                width in 1u16..50,
3011                height in 1u16..50,
3012            ) {
3013                let mut buf = Buffer::new(width, height);
3014                let expected = width as usize * height as usize;
3015
3016                prop_assert_eq!(buf.cells().len(), expected);
3017
3018                // After mutations
3019                buf.set(0, 0, Cell::from_char('X'));
3020                prop_assert_eq!(buf.cells().len(), expected);
3021
3022                buf.clear();
3023                prop_assert_eq!(buf.cells().len(), expected);
3024            }
3025
3026            #[test]
3027            fn set_outside_scissor_is_noop(
3028                width in 10u16..30,
3029                height in 10u16..30,
3030            ) {
3031                let mut buf = Buffer::new(width, height);
3032                buf.push_scissor(Rect::new(2, 2, 3, 3));
3033
3034                // Write outside scissor region
3035                buf.set(0, 0, Cell::from_char('X'));
3036                // Should be unmodified (still empty)
3037                let cell = buf.get(0, 0).unwrap();
3038                prop_assert!(cell.is_empty(),
3039                    "cell (0,0) modified outside scissor region");
3040
3041                // Write inside scissor region should work
3042                buf.set(3, 3, Cell::from_char('Y'));
3043                let cell = buf.get(3, 3).unwrap();
3044                prop_assert_eq!(cell.content.as_char(), Some('Y'));
3045            }
3046
3047            // --- Wide Glyph Cleanup Property Tests ---
3048
3049            #[test]
3050            fn wide_char_overwrites_cleanup_tails(
3051                width in 10u16..30,
3052                x in 0u16..8,
3053            ) {
3054                let x = x % (width.saturating_sub(2).max(1));
3055                let mut buf = Buffer::new(width, 1);
3056
3057                // Write wide char
3058                buf.set(x, 0, Cell::from_char('中'));
3059
3060                // If it fit, check structure
3061                if x + 1 < width {
3062                    let head = buf.get(x, 0).unwrap();
3063                    let tail = buf.get(x + 1, 0).unwrap();
3064
3065                    if head.content.as_char() == Some('中') {
3066                        prop_assert!(tail.is_continuation(),
3067                            "tail at x+1={} should be continuation", x + 1);
3068
3069                        // Overwrite head with single char
3070                        buf.set(x, 0, Cell::from_char('A'));
3071                        let new_head = buf.get(x, 0).unwrap();
3072                        let cleared_tail = buf.get(x + 1, 0).unwrap();
3073
3074                        prop_assert_eq!(new_head.content.as_char(), Some('A'));
3075                        prop_assert!(cleared_tail.is_empty(),
3076                            "tail should be cleared after head overwrite");
3077                    }
3078                }
3079            }
3080
3081            #[test]
3082            fn wide_char_atomic_rejection_at_boundary(
3083                width in 3u16..20,
3084            ) {
3085                let mut buf = Buffer::new(width, 1);
3086
3087                // Try to write wide char at last position (needs x and x+1)
3088                let last_pos = width - 1;
3089                buf.set(last_pos, 0, Cell::from_char('中'));
3090
3091                // Should be rejected - cell should remain empty
3092                let cell = buf.get(last_pos, 0).unwrap();
3093                prop_assert!(cell.is_empty(),
3094                    "wide char at boundary position {} (width {}) should be rejected",
3095                    last_pos, width);
3096            }
3097
3098            // =====================================================================
3099            // DoubleBuffer property tests (bd-1rz0.4.4)
3100            // =====================================================================
3101
3102            #[test]
3103            fn double_buffer_swap_is_involution(ops in proptest::collection::vec(proptest::bool::ANY, 0..100)) {
3104                let mut db = DoubleBuffer::new(10, 10);
3105                let initial_idx = db.current_idx;
3106
3107                for do_swap in &ops {
3108                    if *do_swap {
3109                        db.swap();
3110                    }
3111                }
3112
3113                let swap_count = ops.iter().filter(|&&x| x).count();
3114                let expected_idx = if swap_count % 2 == 0 { initial_idx } else { 1 - initial_idx };
3115
3116                prop_assert_eq!(db.current_idx, expected_idx,
3117                    "After {} swaps, index should be {} but was {}",
3118                    swap_count, expected_idx, db.current_idx);
3119            }
3120
3121            #[test]
3122            fn double_buffer_resize_preserves_invariant(
3123                init_w in 1u16..200,
3124                init_h in 1u16..100,
3125                new_w in 1u16..200,
3126                new_h in 1u16..100,
3127            ) {
3128                let mut db = DoubleBuffer::new(init_w, init_h);
3129                db.resize(new_w, new_h);
3130
3131                prop_assert_eq!(db.width(), new_w);
3132                prop_assert_eq!(db.height(), new_h);
3133                prop_assert!(db.dimensions_match(new_w, new_h));
3134            }
3135
3136            #[test]
3137            fn double_buffer_current_previous_disjoint(
3138                width in 1u16..50,
3139                height in 1u16..50,
3140            ) {
3141                let mut db = DoubleBuffer::new(width, height);
3142
3143                // Write to current
3144                db.current_mut().set(0, 0, Cell::from_char('C'));
3145
3146                // Previous should be unaffected
3147                prop_assert!(db.previous().get(0, 0).unwrap().is_empty(),
3148                    "Previous buffer should not reflect changes to current");
3149
3150                // After swap, roles reverse
3151                db.swap();
3152                prop_assert_eq!(db.previous().get(0, 0).unwrap().content.as_char(), Some('C'),
3153                    "After swap, previous should have the 'C' we wrote");
3154            }
3155
3156            #[test]
3157            fn double_buffer_swap_content_semantics(
3158                width in 5u16..30,
3159                height in 5u16..30,
3160            ) {
3161                let mut db = DoubleBuffer::new(width, height);
3162
3163                // Write 'X' to current
3164                db.current_mut().set(0, 0, Cell::from_char('X'));
3165                db.swap();
3166
3167                // Write 'Y' to current (now the other buffer)
3168                db.current_mut().set(0, 0, Cell::from_char('Y'));
3169                db.swap();
3170
3171                // After two swaps, we're back to the buffer with 'X'
3172                prop_assert_eq!(db.current().get(0, 0).unwrap().content.as_char(), Some('X'));
3173                prop_assert_eq!(db.previous().get(0, 0).unwrap().content.as_char(), Some('Y'));
3174            }
3175
3176            #[test]
3177            fn double_buffer_resize_clears_both(
3178                w1 in 5u16..30,
3179                h1 in 5u16..30,
3180                w2 in 5u16..30,
3181                h2 in 5u16..30,
3182            ) {
3183                // Skip if dimensions are the same (resize returns early)
3184                prop_assume!(w1 != w2 || h1 != h2);
3185
3186                let mut db = DoubleBuffer::new(w1, h1);
3187
3188                // Populate both buffers
3189                db.current_mut().set(0, 0, Cell::from_char('A'));
3190                db.swap();
3191                db.current_mut().set(0, 0, Cell::from_char('B'));
3192
3193                // Resize
3194                db.resize(w2, h2);
3195
3196                // Both should be empty
3197                prop_assert!(db.current().get(0, 0).unwrap().is_empty(),
3198                    "Current buffer should be empty after resize");
3199                prop_assert!(db.previous().get(0, 0).unwrap().is_empty(),
3200                    "Previous buffer should be empty after resize");
3201            }
3202        }
3203    }
3204
3205    // ========== Dirty Row Tracking Tests (bd-4kq0.1.1) ==========
3206
3207    #[test]
3208    fn dirty_rows_start_dirty() {
3209        // All rows start dirty to ensure initial diffs see all content.
3210        let buf = Buffer::new(10, 5);
3211        assert_eq!(buf.dirty_row_count(), 5);
3212        for y in 0..5 {
3213            assert!(buf.is_row_dirty(y));
3214        }
3215    }
3216
3217    #[test]
3218    fn dirty_bitmap_starts_full() {
3219        let buf = Buffer::new(4, 3);
3220        assert!(buf.dirty_all());
3221        assert_eq!(buf.dirty_cell_count(), 12);
3222    }
3223
3224    #[test]
3225    fn dirty_bitmap_tracks_single_cell() {
3226        let mut buf = Buffer::new(4, 3);
3227        buf.clear_dirty();
3228        assert!(!buf.dirty_all());
3229        buf.set_raw(1, 1, Cell::from_char('X'));
3230        let idx = 1 + 4;
3231        assert_eq!(buf.dirty_cell_count(), 1);
3232        assert_eq!(buf.dirty_bits()[idx], 1);
3233    }
3234
3235    #[test]
3236    fn dirty_bitmap_dedupes_cells() {
3237        let mut buf = Buffer::new(4, 3);
3238        buf.clear_dirty();
3239        buf.set_raw(2, 2, Cell::from_char('A'));
3240        buf.set_raw(2, 2, Cell::from_char('B'));
3241        assert_eq!(buf.dirty_cell_count(), 1);
3242    }
3243
3244    #[test]
3245    fn set_marks_row_dirty() {
3246        let mut buf = Buffer::new(10, 5);
3247        buf.clear_dirty(); // Reset initial dirty state
3248        buf.set(3, 2, Cell::from_char('X'));
3249        assert!(buf.is_row_dirty(2));
3250        assert!(!buf.is_row_dirty(0));
3251        assert!(!buf.is_row_dirty(1));
3252        assert!(!buf.is_row_dirty(3));
3253        assert!(!buf.is_row_dirty(4));
3254    }
3255
3256    #[test]
3257    fn set_raw_marks_row_dirty() {
3258        let mut buf = Buffer::new(10, 5);
3259        buf.clear_dirty(); // Reset initial dirty state
3260        buf.set_raw(0, 4, Cell::from_char('Z'));
3261        assert!(buf.is_row_dirty(4));
3262        assert_eq!(buf.dirty_row_count(), 1);
3263    }
3264
3265    #[test]
3266    fn clear_marks_all_dirty() {
3267        let mut buf = Buffer::new(10, 5);
3268        buf.clear();
3269        assert_eq!(buf.dirty_row_count(), 5);
3270    }
3271
3272    #[test]
3273    fn clear_dirty_resets_flags() {
3274        let mut buf = Buffer::new(10, 5);
3275        // All rows start dirty; clear_dirty should reset all of them.
3276        assert_eq!(buf.dirty_row_count(), 5);
3277        buf.clear_dirty();
3278        assert_eq!(buf.dirty_row_count(), 0);
3279
3280        // Now mark specific rows dirty and verify clear_dirty resets again.
3281        buf.set(0, 0, Cell::from_char('A'));
3282        buf.set(0, 3, Cell::from_char('B'));
3283        assert_eq!(buf.dirty_row_count(), 2);
3284
3285        buf.clear_dirty();
3286        assert_eq!(buf.dirty_row_count(), 0);
3287    }
3288
3289    #[test]
3290    fn clear_dirty_resets_bitmap() {
3291        let mut buf = Buffer::new(4, 3);
3292        buf.clear();
3293        assert!(buf.dirty_all());
3294        buf.clear_dirty();
3295        assert!(!buf.dirty_all());
3296        assert_eq!(buf.dirty_cell_count(), 0);
3297        assert!(buf.dirty_bits().iter().all(|&b| b == 0));
3298    }
3299
3300    #[test]
3301    fn fill_marks_affected_rows_dirty() {
3302        let mut buf = Buffer::new(10, 10);
3303        buf.clear_dirty(); // Reset initial dirty state
3304        buf.fill(Rect::new(0, 2, 5, 3), Cell::from_char('.'));
3305        // Rows 2, 3, 4 should be dirty
3306        assert!(!buf.is_row_dirty(0));
3307        assert!(!buf.is_row_dirty(1));
3308        assert!(buf.is_row_dirty(2));
3309        assert!(buf.is_row_dirty(3));
3310        assert!(buf.is_row_dirty(4));
3311        assert!(!buf.is_row_dirty(5));
3312    }
3313
3314    #[test]
3315    fn get_mut_marks_row_dirty() {
3316        let mut buf = Buffer::new(10, 5);
3317        buf.clear_dirty(); // Reset initial dirty state
3318        if let Some(cell) = buf.get_mut(5, 3) {
3319            cell.fg = PackedRgba::rgb(255, 0, 0);
3320        }
3321        assert!(buf.is_row_dirty(3));
3322        assert_eq!(buf.dirty_row_count(), 1);
3323    }
3324
3325    #[test]
3326    fn cells_mut_marks_all_dirty() {
3327        let mut buf = Buffer::new(10, 5);
3328        let _ = buf.cells_mut();
3329        assert_eq!(buf.dirty_row_count(), 5);
3330    }
3331
3332    #[test]
3333    fn dirty_rows_slice_length_matches_height() {
3334        let buf = Buffer::new(10, 7);
3335        assert_eq!(buf.dirty_rows().len(), 7);
3336    }
3337
3338    #[test]
3339    fn out_of_bounds_set_does_not_dirty() {
3340        let mut buf = Buffer::new(10, 5);
3341        buf.clear_dirty(); // Reset initial dirty state
3342        buf.set(100, 100, Cell::from_char('X'));
3343        assert_eq!(buf.dirty_row_count(), 0);
3344    }
3345
3346    #[test]
3347    fn property_dirty_soundness() {
3348        // Randomized test: any mutation must mark its row.
3349        let mut buf = Buffer::new(20, 10);
3350        let positions = [(3, 0), (5, 2), (0, 9), (19, 5), (10, 7)];
3351        for &(x, y) in &positions {
3352            buf.set(x, y, Cell::from_char('*'));
3353        }
3354        for &(_, y) in &positions {
3355            assert!(
3356                buf.is_row_dirty(y),
3357                "Row {} should be dirty after set({}, {})",
3358                y,
3359                positions.iter().find(|(_, ry)| *ry == y).unwrap().0,
3360                y
3361            );
3362        }
3363    }
3364
3365    #[test]
3366    fn dirty_clear_between_frames() {
3367        // Simulates frame transition: render, diff, clear, render again.
3368        let mut buf = Buffer::new(10, 5);
3369
3370        // All rows start dirty (initial frame needs full diff).
3371        assert_eq!(buf.dirty_row_count(), 5);
3372
3373        // Diff consumes dirty state after initial frame.
3374        buf.clear_dirty();
3375        assert_eq!(buf.dirty_row_count(), 0);
3376
3377        // Frame 1: write to rows 0, 2
3378        buf.set(0, 0, Cell::from_char('A'));
3379        buf.set(0, 2, Cell::from_char('B'));
3380        assert_eq!(buf.dirty_row_count(), 2);
3381
3382        // Diff consumes dirty state
3383        buf.clear_dirty();
3384        assert_eq!(buf.dirty_row_count(), 0);
3385
3386        // Frame 2: write to row 4 only
3387        buf.set(0, 4, Cell::from_char('C'));
3388        assert_eq!(buf.dirty_row_count(), 1);
3389        assert!(buf.is_row_dirty(4));
3390        assert!(!buf.is_row_dirty(0));
3391    }
3392
3393    // ========== Dirty Span Tracking Tests (bd-3e1t.6.2) ==========
3394
3395    #[test]
3396    fn dirty_spans_start_full_dirty() {
3397        let buf = Buffer::new(10, 5);
3398        for y in 0..5 {
3399            let row = buf.dirty_span_row(y).unwrap();
3400            assert!(row.is_full(), "row {y} should start full-dirty");
3401            assert!(row.spans().is_empty(), "row {y} spans should start empty");
3402        }
3403    }
3404
3405    #[test]
3406    fn clear_dirty_resets_spans() {
3407        let mut buf = Buffer::new(10, 5);
3408        buf.clear_dirty();
3409        for y in 0..5 {
3410            let row = buf.dirty_span_row(y).unwrap();
3411            assert!(!row.is_full(), "row {y} should clear full-dirty");
3412            assert!(row.spans().is_empty(), "row {y} spans should be cleared");
3413        }
3414        assert_eq!(buf.dirty_span_overflows, 0);
3415    }
3416
3417    #[test]
3418    fn set_records_dirty_span() {
3419        let mut buf = Buffer::new(20, 2);
3420        buf.clear_dirty();
3421        buf.set(2, 0, Cell::from_char('A'));
3422        let row = buf.dirty_span_row(0).unwrap();
3423        assert_eq!(row.spans(), &[DirtySpan::new(2, 3)]);
3424        assert!(!row.is_full());
3425    }
3426
3427    #[test]
3428    fn set_merges_adjacent_spans() {
3429        let mut buf = Buffer::new(20, 2);
3430        buf.clear_dirty();
3431        buf.set(2, 0, Cell::from_char('A'));
3432        buf.set(3, 0, Cell::from_char('B')); // adjacent, should merge
3433        let row = buf.dirty_span_row(0).unwrap();
3434        assert_eq!(row.spans(), &[DirtySpan::new(2, 4)]);
3435    }
3436
3437    #[test]
3438    fn set_merges_close_spans() {
3439        let mut buf = Buffer::new(20, 2);
3440        buf.clear_dirty();
3441        buf.set(2, 0, Cell::from_char('A'));
3442        buf.set(4, 0, Cell::from_char('B')); // gap of 1, should merge
3443        let row = buf.dirty_span_row(0).unwrap();
3444        assert_eq!(row.spans(), &[DirtySpan::new(2, 5)]);
3445    }
3446
3447    #[test]
3448    fn span_overflow_sets_full_row() {
3449        let width = (DIRTY_SPAN_MAX_SPANS_PER_ROW as u16 + 2) * 3;
3450        let mut buf = Buffer::new(width, 1);
3451        buf.clear_dirty();
3452        for i in 0..(DIRTY_SPAN_MAX_SPANS_PER_ROW + 1) {
3453            let x = (i as u16) * 3;
3454            buf.set(x, 0, Cell::from_char('x'));
3455        }
3456        let row = buf.dirty_span_row(0).unwrap();
3457        assert!(row.is_full());
3458        assert!(row.spans().is_empty());
3459        assert_eq!(buf.dirty_span_overflows, 1);
3460    }
3461
3462    #[test]
3463    fn fill_full_row_marks_full_span() {
3464        let mut buf = Buffer::new(10, 3);
3465        buf.clear_dirty();
3466        let cell = Cell::from_char('x').with_bg(PackedRgba::rgb(0, 0, 0));
3467        buf.fill(Rect::new(0, 1, 10, 1), cell);
3468        let row = buf.dirty_span_row(1).unwrap();
3469        assert!(row.is_full());
3470        assert!(row.spans().is_empty());
3471    }
3472
3473    #[test]
3474    fn get_mut_records_dirty_span() {
3475        let mut buf = Buffer::new(10, 5);
3476        buf.clear_dirty();
3477        let _ = buf.get_mut(5, 3);
3478        let row = buf.dirty_span_row(3).unwrap();
3479        assert_eq!(row.spans(), &[DirtySpan::new(5, 6)]);
3480    }
3481
3482    #[test]
3483    fn cells_mut_marks_all_full_spans() {
3484        let mut buf = Buffer::new(10, 5);
3485        buf.clear_dirty();
3486        let _ = buf.cells_mut();
3487        for y in 0..5 {
3488            let row = buf.dirty_span_row(y).unwrap();
3489            assert!(row.is_full(), "row {y} should be full after cells_mut");
3490        }
3491    }
3492
3493    #[test]
3494    fn dirty_span_config_disabled_skips_rows() {
3495        let mut buf = Buffer::new(10, 1);
3496        buf.clear_dirty();
3497        buf.set_dirty_span_config(DirtySpanConfig::default().with_enabled(false));
3498        buf.set(5, 0, Cell::from_char('x'));
3499        assert!(buf.dirty_span_row(0).is_none());
3500        let stats = buf.dirty_span_stats();
3501        assert_eq!(stats.total_spans, 0);
3502        assert_eq!(stats.span_coverage_cells, 0);
3503    }
3504
3505    #[test]
3506    fn dirty_span_guard_band_expands_span_bounds() {
3507        let mut buf = Buffer::new(10, 1);
3508        buf.clear_dirty();
3509        buf.set_dirty_span_config(DirtySpanConfig::default().with_guard_band(2));
3510        buf.set(5, 0, Cell::from_char('x'));
3511        let row = buf.dirty_span_row(0).unwrap();
3512        assert_eq!(row.spans(), &[DirtySpan::new(3, 8)]);
3513    }
3514
3515    #[test]
3516    fn dirty_span_max_spans_overflow_triggers_full_row() {
3517        let mut buf = Buffer::new(10, 1);
3518        buf.clear_dirty();
3519        buf.set_dirty_span_config(
3520            DirtySpanConfig::default()
3521                .with_max_spans_per_row(1)
3522                .with_merge_gap(0),
3523        );
3524        buf.set(0, 0, Cell::from_char('a'));
3525        buf.set(4, 0, Cell::from_char('b'));
3526        let row = buf.dirty_span_row(0).unwrap();
3527        assert!(row.is_full());
3528        assert!(row.spans().is_empty());
3529        assert_eq!(buf.dirty_span_overflows, 1);
3530    }
3531
3532    #[test]
3533    fn dirty_span_stats_counts_full_rows_and_spans() {
3534        let mut buf = Buffer::new(6, 2);
3535        buf.clear_dirty();
3536        buf.set_dirty_span_config(DirtySpanConfig::default().with_merge_gap(0));
3537        buf.set(1, 0, Cell::from_char('a'));
3538        buf.set(4, 0, Cell::from_char('b'));
3539        buf.mark_dirty_row_full(1);
3540
3541        let stats = buf.dirty_span_stats();
3542        assert_eq!(stats.rows_full_dirty, 1);
3543        assert_eq!(stats.rows_with_spans, 1);
3544        assert_eq!(stats.total_spans, 2);
3545        assert_eq!(stats.max_span_len, 6);
3546        assert_eq!(stats.span_coverage_cells, 8);
3547    }
3548
3549    #[test]
3550    fn dirty_span_stats_reports_overflow_and_full_row() {
3551        let mut buf = Buffer::new(8, 1);
3552        buf.clear_dirty();
3553        buf.set_dirty_span_config(
3554            DirtySpanConfig::default()
3555                .with_max_spans_per_row(1)
3556                .with_merge_gap(0),
3557        );
3558        buf.set(0, 0, Cell::from_char('x'));
3559        buf.set(3, 0, Cell::from_char('y'));
3560
3561        let stats = buf.dirty_span_stats();
3562        assert_eq!(stats.overflows, 1);
3563        assert_eq!(stats.rows_full_dirty, 1);
3564        assert_eq!(stats.total_spans, 0);
3565        assert_eq!(stats.span_coverage_cells, 8);
3566    }
3567
3568    // =====================================================================
3569    // DoubleBuffer tests (bd-1rz0.4.4)
3570    // =====================================================================
3571
3572    #[test]
3573    fn double_buffer_new_has_matching_dimensions() {
3574        let db = DoubleBuffer::new(80, 24);
3575        assert_eq!(db.width(), 80);
3576        assert_eq!(db.height(), 24);
3577        assert!(db.dimensions_match(80, 24));
3578        assert!(!db.dimensions_match(120, 40));
3579    }
3580
3581    #[test]
3582    fn double_buffer_swap_is_o1() {
3583        let mut db = DoubleBuffer::new(80, 24);
3584
3585        // Write to current buffer
3586        db.current_mut().set(0, 0, Cell::from_char('A'));
3587        assert_eq!(db.current().get(0, 0).unwrap().content.as_char(), Some('A'));
3588
3589        // Swap — previous should now have 'A', current should be clean
3590        db.swap();
3591        assert_eq!(
3592            db.previous().get(0, 0).unwrap().content.as_char(),
3593            Some('A')
3594        );
3595        // Current was the old "previous" (empty by default)
3596        assert!(db.current().get(0, 0).unwrap().is_empty());
3597    }
3598
3599    #[test]
3600    fn double_buffer_swap_round_trip() {
3601        let mut db = DoubleBuffer::new(10, 5);
3602
3603        db.current_mut().set(0, 0, Cell::from_char('X'));
3604        db.swap();
3605        db.current_mut().set(0, 0, Cell::from_char('Y'));
3606        db.swap();
3607
3608        // After two swaps, we're back to the buffer that had 'X'
3609        assert_eq!(db.current().get(0, 0).unwrap().content.as_char(), Some('X'));
3610        assert_eq!(
3611            db.previous().get(0, 0).unwrap().content.as_char(),
3612            Some('Y')
3613        );
3614    }
3615
3616    #[test]
3617    fn double_buffer_resize_changes_dimensions() {
3618        let mut db = DoubleBuffer::new(80, 24);
3619        assert!(!db.resize(80, 24)); // No change
3620        assert!(db.resize(120, 40)); // Changed
3621        assert_eq!(db.width(), 120);
3622        assert_eq!(db.height(), 40);
3623        assert!(db.dimensions_match(120, 40));
3624    }
3625
3626    #[test]
3627    fn double_buffer_resize_clears_content() {
3628        let mut db = DoubleBuffer::new(10, 5);
3629        db.current_mut().set(0, 0, Cell::from_char('Z'));
3630        db.swap();
3631        db.current_mut().set(0, 0, Cell::from_char('W'));
3632
3633        db.resize(20, 10);
3634
3635        // Both buffers should be fresh/empty
3636        assert!(db.current().get(0, 0).unwrap().is_empty());
3637        assert!(db.previous().get(0, 0).unwrap().is_empty());
3638    }
3639
3640    #[test]
3641    fn double_buffer_current_and_previous_are_distinct() {
3642        let mut db = DoubleBuffer::new(10, 5);
3643        db.current_mut().set(0, 0, Cell::from_char('C'));
3644
3645        // Previous should not reflect changes to current
3646        assert!(db.previous().get(0, 0).unwrap().is_empty());
3647        assert_eq!(db.current().get(0, 0).unwrap().content.as_char(), Some('C'));
3648    }
3649
3650    // =====================================================================
3651    // AdaptiveDoubleBuffer tests (bd-1rz0.4.2)
3652    // =====================================================================
3653
3654    #[test]
3655    fn adaptive_buffer_new_has_over_allocation() {
3656        let adb = AdaptiveDoubleBuffer::new(80, 24);
3657
3658        // Logical dimensions match requested size
3659        assert_eq!(adb.width(), 80);
3660        assert_eq!(adb.height(), 24);
3661        assert!(adb.dimensions_match(80, 24));
3662
3663        // Capacity should be larger (1.25x growth factor, capped at 200)
3664        // 80 * 0.25 = 20, so capacity_width = 100
3665        // 24 * 0.25 = 6, so capacity_height = 30
3666        assert!(adb.capacity_width() > 80);
3667        assert!(adb.capacity_height() > 24);
3668        assert_eq!(adb.capacity_width(), 100); // 80 + 20
3669        assert_eq!(adb.capacity_height(), 30); // 24 + 6
3670    }
3671
3672    #[test]
3673    fn adaptive_buffer_resize_avoids_reallocation_when_within_capacity() {
3674        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
3675
3676        // Small growth should be absorbed by over-allocation
3677        assert!(adb.resize(90, 28)); // Still within (100, 30) capacity
3678        assert_eq!(adb.width(), 90);
3679        assert_eq!(adb.height(), 28);
3680        assert_eq!(adb.stats().resize_avoided, 1);
3681        assert_eq!(adb.stats().resize_reallocated, 0);
3682        assert_eq!(adb.stats().resize_growth, 1);
3683    }
3684
3685    #[test]
3686    fn adaptive_buffer_resize_reallocates_on_growth_beyond_capacity() {
3687        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
3688
3689        // Growth beyond capacity requires reallocation
3690        assert!(adb.resize(120, 40)); // Exceeds (100, 30) capacity
3691        assert_eq!(adb.width(), 120);
3692        assert_eq!(adb.height(), 40);
3693        assert_eq!(adb.stats().resize_reallocated, 1);
3694        assert_eq!(adb.stats().resize_avoided, 0);
3695
3696        // New capacity should have headroom
3697        assert!(adb.capacity_width() > 120);
3698        assert!(adb.capacity_height() > 40);
3699    }
3700
3701    #[test]
3702    fn adaptive_buffer_resize_reallocates_on_significant_shrink() {
3703        let mut adb = AdaptiveDoubleBuffer::new(100, 50);
3704
3705        // Shrink below 50% threshold should reallocate
3706        // Threshold: 100 * 0.5 = 50, 50 * 0.5 = 25
3707        assert!(adb.resize(40, 20)); // Below 50% of capacity
3708        assert_eq!(adb.width(), 40);
3709        assert_eq!(adb.height(), 20);
3710        assert_eq!(adb.stats().resize_reallocated, 1);
3711        assert_eq!(adb.stats().resize_shrink, 1);
3712    }
3713
3714    #[test]
3715    fn adaptive_buffer_resize_avoids_reallocation_on_minor_shrink() {
3716        let mut adb = AdaptiveDoubleBuffer::new(100, 50);
3717
3718        // Shrink above 50% threshold should reuse capacity
3719        // Threshold: capacity ~125 * 0.5 = 62.5 for width
3720        // 100 > 62.5, so no reallocation
3721        assert!(adb.resize(80, 40));
3722        assert_eq!(adb.width(), 80);
3723        assert_eq!(adb.height(), 40);
3724        assert_eq!(adb.stats().resize_avoided, 1);
3725        assert_eq!(adb.stats().resize_reallocated, 0);
3726        assert_eq!(adb.stats().resize_shrink, 1);
3727    }
3728
3729    #[test]
3730    fn adaptive_buffer_no_change_returns_false() {
3731        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
3732
3733        assert!(!adb.resize(80, 24)); // No change
3734        assert_eq!(adb.stats().resize_avoided, 0);
3735        assert_eq!(adb.stats().resize_reallocated, 0);
3736        assert_eq!(adb.stats().resize_growth, 0);
3737        assert_eq!(adb.stats().resize_shrink, 0);
3738    }
3739
3740    #[test]
3741    fn adaptive_buffer_swap_works() {
3742        let mut adb = AdaptiveDoubleBuffer::new(10, 5);
3743
3744        adb.current_mut().set(0, 0, Cell::from_char('A'));
3745        assert_eq!(
3746            adb.current().get(0, 0).unwrap().content.as_char(),
3747            Some('A')
3748        );
3749
3750        adb.swap();
3751        assert_eq!(
3752            adb.previous().get(0, 0).unwrap().content.as_char(),
3753            Some('A')
3754        );
3755        assert!(adb.current().get(0, 0).unwrap().is_empty());
3756    }
3757
3758    #[test]
3759    fn adaptive_buffer_stats_reset() {
3760        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
3761
3762        adb.resize(90, 28);
3763        adb.resize(120, 40);
3764        assert!(adb.stats().resize_avoided > 0 || adb.stats().resize_reallocated > 0);
3765
3766        adb.reset_stats();
3767        assert_eq!(adb.stats().resize_avoided, 0);
3768        assert_eq!(adb.stats().resize_reallocated, 0);
3769        assert_eq!(adb.stats().resize_growth, 0);
3770        assert_eq!(adb.stats().resize_shrink, 0);
3771    }
3772
3773    #[test]
3774    fn adaptive_buffer_memory_efficiency() {
3775        let adb = AdaptiveDoubleBuffer::new(80, 24);
3776
3777        let efficiency = adb.memory_efficiency();
3778        // 80*24 = 1920 logical cells
3779        // 100*30 = 3000 capacity cells
3780        // efficiency = 1920/3000 = 0.64
3781        assert!(efficiency > 0.5);
3782        assert!(efficiency < 1.0);
3783    }
3784
3785    #[test]
3786    fn adaptive_buffer_logical_bounds() {
3787        let adb = AdaptiveDoubleBuffer::new(80, 24);
3788
3789        let bounds = adb.logical_bounds();
3790        assert_eq!(bounds.x, 0);
3791        assert_eq!(bounds.y, 0);
3792        assert_eq!(bounds.width, 80);
3793        assert_eq!(bounds.height, 24);
3794    }
3795
3796    #[test]
3797    fn adaptive_buffer_capacity_clamped_for_large_sizes() {
3798        // Test that over-allocation is capped at ADAPTIVE_MAX_OVERAGE (200)
3799        let adb = AdaptiveDoubleBuffer::new(1000, 500);
3800
3801        // 1000 * 0.25 = 250, capped to 200
3802        // 500 * 0.25 = 125, not capped
3803        assert_eq!(adb.capacity_width(), 1000 + 200); // capped
3804        assert_eq!(adb.capacity_height(), 500 + 125); // not capped
3805    }
3806
3807    #[test]
3808    fn adaptive_stats_avoidance_ratio() {
3809        let mut stats = AdaptiveStats::default();
3810
3811        // Empty stats should return 1.0 (perfect avoidance)
3812        assert!((stats.avoidance_ratio() - 1.0).abs() < f64::EPSILON);
3813
3814        // 3 avoided, 1 reallocated = 75% avoidance
3815        stats.resize_avoided = 3;
3816        stats.resize_reallocated = 1;
3817        assert!((stats.avoidance_ratio() - 0.75).abs() < f64::EPSILON);
3818
3819        // All reallocations = 0% avoidance
3820        stats.resize_avoided = 0;
3821        stats.resize_reallocated = 5;
3822        assert!((stats.avoidance_ratio() - 0.0).abs() < f64::EPSILON);
3823    }
3824
3825    #[test]
3826    fn adaptive_buffer_resize_storm_simulation() {
3827        // Simulate a resize storm (rapid size changes)
3828        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
3829
3830        // Simulate user resizing terminal in small increments
3831        for i in 1..=10 {
3832            adb.resize(80 + i, 24 + (i / 2));
3833        }
3834
3835        // Most resizes should have avoided reallocation due to over-allocation
3836        let ratio = adb.stats().avoidance_ratio();
3837        assert!(
3838            ratio > 0.5,
3839            "Expected >50% avoidance ratio, got {:.2}",
3840            ratio
3841        );
3842    }
3843
3844    #[test]
3845    fn adaptive_buffer_width_only_growth() {
3846        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
3847
3848        // Grow only width, within capacity
3849        assert!(adb.resize(95, 24)); // 95 < 100 capacity
3850        assert_eq!(adb.stats().resize_avoided, 1);
3851        assert_eq!(adb.stats().resize_growth, 1);
3852    }
3853
3854    #[test]
3855    fn adaptive_buffer_height_only_growth() {
3856        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
3857
3858        // Grow only height, within capacity
3859        assert!(adb.resize(80, 28)); // 28 < 30 capacity
3860        assert_eq!(adb.stats().resize_avoided, 1);
3861        assert_eq!(adb.stats().resize_growth, 1);
3862    }
3863
3864    #[test]
3865    fn adaptive_buffer_one_dimension_exceeds_capacity() {
3866        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
3867
3868        // One dimension exceeds capacity, should reallocate
3869        assert!(adb.resize(105, 24)); // 105 > 100 capacity, 24 < 30
3870        assert_eq!(adb.stats().resize_reallocated, 1);
3871    }
3872
3873    #[test]
3874    fn adaptive_buffer_current_and_previous_distinct() {
3875        let mut adb = AdaptiveDoubleBuffer::new(10, 5);
3876        adb.current_mut().set(0, 0, Cell::from_char('X'));
3877
3878        // Previous should not reflect changes to current
3879        assert!(adb.previous().get(0, 0).unwrap().is_empty());
3880        assert_eq!(
3881            adb.current().get(0, 0).unwrap().content.as_char(),
3882            Some('X')
3883        );
3884    }
3885
3886    #[test]
3887    fn adaptive_buffer_resize_within_capacity_clears_previous() {
3888        let mut adb = AdaptiveDoubleBuffer::new(10, 5);
3889        adb.current_mut().set(9, 4, Cell::from_char('X'));
3890        adb.swap();
3891
3892        // Shrink within capacity (no reallocation expected)
3893        assert!(adb.resize(8, 4));
3894
3895        // Previous buffer should be cleared to avoid stale content outside bounds.
3896        assert!(adb.previous().get(9, 4).unwrap().is_empty());
3897    }
3898
3899    // Property tests for AdaptiveDoubleBuffer invariants
3900    #[test]
3901    fn adaptive_buffer_invariant_capacity_geq_logical() {
3902        // Test across various sizes that capacity always >= logical
3903        for width in [1u16, 10, 80, 200, 1000, 5000] {
3904            for height in [1u16, 10, 24, 100, 500, 2000] {
3905                let adb = AdaptiveDoubleBuffer::new(width, height);
3906                assert!(
3907                    adb.capacity_width() >= adb.width(),
3908                    "capacity_width {} < logical_width {} for ({}, {})",
3909                    adb.capacity_width(),
3910                    adb.width(),
3911                    width,
3912                    height
3913                );
3914                assert!(
3915                    adb.capacity_height() >= adb.height(),
3916                    "capacity_height {} < logical_height {} for ({}, {})",
3917                    adb.capacity_height(),
3918                    adb.height(),
3919                    width,
3920                    height
3921                );
3922            }
3923        }
3924    }
3925
3926    #[test]
3927    fn adaptive_buffer_invariant_resize_dimensions_correct() {
3928        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
3929
3930        // After any resize, logical dimensions should match requested
3931        let test_sizes = [
3932            (100, 50),
3933            (40, 20),
3934            (80, 24),
3935            (200, 100),
3936            (10, 5),
3937            (1000, 500),
3938        ];
3939        for (w, h) in test_sizes {
3940            adb.resize(w, h);
3941            assert_eq!(adb.width(), w, "width mismatch for ({}, {})", w, h);
3942            assert_eq!(adb.height(), h, "height mismatch for ({}, {})", w, h);
3943            assert!(
3944                adb.capacity_width() >= w,
3945                "capacity_width < width for ({}, {})",
3946                w,
3947                h
3948            );
3949            assert!(
3950                adb.capacity_height() >= h,
3951                "capacity_height < height for ({}, {})",
3952                w,
3953                h
3954            );
3955        }
3956    }
3957
3958    // Property test: no-ghosting on shrink
3959    // When buffer shrinks without reallocation, the current buffer is cleared
3960    // to prevent stale content from appearing in the visible area.
3961    #[test]
3962    fn adaptive_buffer_no_ghosting_on_shrink() {
3963        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
3964
3965        // Fill the entire logical area with content
3966        for y in 0..adb.height() {
3967            for x in 0..adb.width() {
3968                adb.current_mut().set(x, y, Cell::from_char('X'));
3969            }
3970        }
3971
3972        // Shrink to a smaller size (still above 50% threshold, so no reallocation)
3973        // 80 * 0.5 = 40, so 60 > 40 means no reallocation
3974        adb.resize(60, 20);
3975
3976        // Verify current buffer is cleared after shrink (no stale 'X' visible)
3977        // The current buffer should be empty because resize() calls clear()
3978        for y in 0..adb.height() {
3979            for x in 0..adb.width() {
3980                let cell = adb.current().get(x, y).unwrap();
3981                assert!(
3982                    cell.is_empty(),
3983                    "Ghost content at ({}, {}): expected empty, got {:?}",
3984                    x,
3985                    y,
3986                    cell.content
3987                );
3988            }
3989        }
3990    }
3991
3992    // Property test: shrink-reallocation clears all content
3993    // When buffer shrinks below threshold (requiring reallocation), both buffers
3994    // should be fresh/empty.
3995    #[test]
3996    fn adaptive_buffer_no_ghosting_on_reallocation_shrink() {
3997        let mut adb = AdaptiveDoubleBuffer::new(100, 50);
3998
3999        // Fill both buffers with content
4000        for y in 0..adb.height() {
4001            for x in 0..adb.width() {
4002                adb.current_mut().set(x, y, Cell::from_char('A'));
4003            }
4004        }
4005        adb.swap();
4006        for y in 0..adb.height() {
4007            for x in 0..adb.width() {
4008                adb.current_mut().set(x, y, Cell::from_char('B'));
4009            }
4010        }
4011
4012        // Shrink below 50% threshold, forcing reallocation
4013        adb.resize(30, 15);
4014        assert_eq!(adb.stats().resize_reallocated, 1);
4015
4016        // Both buffers should be fresh/empty
4017        for y in 0..adb.height() {
4018            for x in 0..adb.width() {
4019                assert!(
4020                    adb.current().get(x, y).unwrap().is_empty(),
4021                    "Ghost in current at ({}, {})",
4022                    x,
4023                    y
4024                );
4025                assert!(
4026                    adb.previous().get(x, y).unwrap().is_empty(),
4027                    "Ghost in previous at ({}, {})",
4028                    x,
4029                    y
4030                );
4031            }
4032        }
4033    }
4034
4035    // Property test: growth preserves no-ghosting guarantee
4036    // When buffer grows beyond capacity (requiring reallocation), the new
4037    // capacity area should be empty.
4038    #[test]
4039    fn adaptive_buffer_no_ghosting_on_growth_reallocation() {
4040        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
4041
4042        // Fill current buffer
4043        for y in 0..adb.height() {
4044            for x in 0..adb.width() {
4045                adb.current_mut().set(x, y, Cell::from_char('Z'));
4046            }
4047        }
4048
4049        // Grow beyond capacity (100, 30) to force reallocation
4050        adb.resize(150, 60);
4051        assert_eq!(adb.stats().resize_reallocated, 1);
4052
4053        // Entire new buffer should be empty
4054        for y in 0..adb.height() {
4055            for x in 0..adb.width() {
4056                assert!(
4057                    adb.current().get(x, y).unwrap().is_empty(),
4058                    "Ghost at ({}, {}) after growth reallocation",
4059                    x,
4060                    y
4061                );
4062            }
4063        }
4064    }
4065
4066    // Property test: idempotence - same resize is no-op
4067    #[test]
4068    fn adaptive_buffer_resize_idempotent() {
4069        let mut adb = AdaptiveDoubleBuffer::new(80, 24);
4070        adb.current_mut().set(5, 5, Cell::from_char('K'));
4071
4072        // Resize to same dimensions should be no-op
4073        let changed = adb.resize(80, 24);
4074        assert!(!changed);
4075
4076        // Content should be preserved
4077        assert_eq!(
4078            adb.current().get(5, 5).unwrap().content.as_char(),
4079            Some('K')
4080        );
4081    }
4082
4083    // =========================================================================
4084    // Dirty Span Tests (bd-3e1t.6.4)
4085    // =========================================================================
4086
4087    #[test]
4088    fn dirty_span_merge_adjacent() {
4089        let mut buf = Buffer::new(100, 1);
4090        buf.clear_dirty(); // Start clean
4091
4092        // Mark [10, 20) dirty
4093        buf.mark_dirty_span(0, 10, 20);
4094        let spans = buf.dirty_span_row(0).unwrap().spans();
4095        assert_eq!(spans.len(), 1);
4096        assert_eq!(spans[0], DirtySpan::new(10, 20));
4097
4098        // Mark [20, 30) dirty (adjacent) -> merge
4099        buf.mark_dirty_span(0, 20, 30);
4100        let spans = buf.dirty_span_row(0).unwrap().spans();
4101        assert_eq!(spans.len(), 1);
4102        assert_eq!(spans[0], DirtySpan::new(10, 30));
4103    }
4104
4105    #[test]
4106    fn dirty_span_merge_overlapping() {
4107        let mut buf = Buffer::new(100, 1);
4108        buf.clear_dirty();
4109
4110        // Mark [10, 20)
4111        buf.mark_dirty_span(0, 10, 20);
4112        // Mark [15, 25) -> merge to [10, 25)
4113        buf.mark_dirty_span(0, 15, 25);
4114
4115        let spans = buf.dirty_span_row(0).unwrap().spans();
4116        assert_eq!(spans.len(), 1);
4117        assert_eq!(spans[0], DirtySpan::new(10, 25));
4118    }
4119
4120    #[test]
4121    fn dirty_span_merge_with_gap() {
4122        let mut buf = Buffer::new(100, 1);
4123        buf.clear_dirty();
4124
4125        // DIRTY_SPAN_MERGE_GAP is 1
4126        // Mark [10, 20)
4127        buf.mark_dirty_span(0, 10, 20);
4128        // Mark [21, 30) -> gap is 1 (index 20) -> merge to [10, 30)
4129        buf.mark_dirty_span(0, 21, 30);
4130
4131        let spans = buf.dirty_span_row(0).unwrap().spans();
4132        assert_eq!(spans.len(), 1);
4133        assert_eq!(spans[0], DirtySpan::new(10, 30));
4134    }
4135
4136    #[test]
4137    fn dirty_span_no_merge_large_gap() {
4138        let mut buf = Buffer::new(100, 1);
4139        buf.clear_dirty();
4140
4141        // Mark [10, 20)
4142        buf.mark_dirty_span(0, 10, 20);
4143        // Mark [22, 30) -> gap is 2 (indices 20, 21) -> no merge
4144        buf.mark_dirty_span(0, 22, 30);
4145
4146        let spans = buf.dirty_span_row(0).unwrap().spans();
4147        assert_eq!(spans.len(), 2);
4148        assert_eq!(spans[0], DirtySpan::new(10, 20));
4149        assert_eq!(spans[1], DirtySpan::new(22, 30));
4150    }
4151
4152    #[test]
4153    fn dirty_span_overflow_to_full() {
4154        let mut buf = Buffer::new(1000, 1);
4155        buf.clear_dirty();
4156
4157        // Create > 64 small spans separated by gaps
4158        for i in 0..DIRTY_SPAN_MAX_SPANS_PER_ROW + 10 {
4159            let start = (i * 4) as u16;
4160            buf.mark_dirty_span(0, start, start + 1);
4161        }
4162
4163        let row = buf.dirty_span_row(0).unwrap();
4164        assert!(row.is_full(), "Row should overflow to full scan");
4165        assert!(
4166            row.spans().is_empty(),
4167            "Spans should be cleared on overflow"
4168        );
4169    }
4170
4171    #[test]
4172    fn dirty_span_bounds_clamping() {
4173        let mut buf = Buffer::new(10, 1);
4174        buf.clear_dirty();
4175
4176        // Mark out of bounds
4177        buf.mark_dirty_span(0, 15, 20);
4178        let spans = buf.dirty_span_row(0).unwrap().spans();
4179        assert!(spans.is_empty());
4180
4181        // Mark crossing bounds
4182        buf.mark_dirty_span(0, 8, 15);
4183        let spans = buf.dirty_span_row(0).unwrap().spans();
4184        assert_eq!(spans.len(), 1);
4185        assert_eq!(spans[0], DirtySpan::new(8, 10)); // Clamped to width
4186    }
4187
4188    #[test]
4189    fn dirty_span_guard_band_clamps_bounds() {
4190        let mut buf = Buffer::new(10, 1);
4191        buf.clear_dirty();
4192        buf.set_dirty_span_config(DirtySpanConfig::default().with_guard_band(5));
4193
4194        buf.mark_dirty_span(0, 2, 3);
4195        let spans = buf.dirty_span_row(0).unwrap().spans();
4196        assert_eq!(spans.len(), 1);
4197        assert_eq!(spans[0], DirtySpan::new(0, 8));
4198
4199        buf.clear_dirty();
4200        buf.mark_dirty_span(0, 8, 10);
4201        let spans = buf.dirty_span_row(0).unwrap().spans();
4202        assert_eq!(spans.len(), 1);
4203        assert_eq!(spans[0], DirtySpan::new(3, 10));
4204    }
4205
4206    #[test]
4207    fn dirty_span_empty_span_is_ignored() {
4208        let mut buf = Buffer::new(10, 1);
4209        buf.clear_dirty();
4210        buf.mark_dirty_span(0, 5, 5);
4211        let spans = buf.dirty_span_row(0).unwrap().spans();
4212        assert!(spans.is_empty());
4213    }
4214
4215    #[test]
4216    fn buffer_fill_wide_char_clipping() {
4217        // Regression test for wide character clipping during fill.
4218        // Verifies that wide characters are not written if they would be clipped,
4219        // and that previous wide characters are cleared correctly.
4220        let mut buf = Buffer::new(10, 5);
4221        let wide_cell = Cell::from_char('🦀'); // Width 2
4222
4223        // 1. Fill with wide char
4224        buf.fill(Rect::new(0, 0, 10, 5), wide_cell);
4225
4226        // Verify head and tail
4227        let head = buf.get(0, 0).unwrap();
4228        assert_eq!(head.content.as_char(), Some('🦀'));
4229        assert_eq!(head.content.width(), 2);
4230
4231        let tail = buf.get(1, 0).unwrap();
4232        assert!(tail.is_continuation());
4233
4234        // 2. Overwrite with clipping
4235        // Push a scissor that splits the wide char at (0,0)
4236        buf.push_scissor(Rect::new(0, 0, 1, 5));
4237        // Fill with 'X'
4238        let x_cell = Cell::from_char('X');
4239        buf.fill(Rect::new(0, 0, 10, 5), x_cell);
4240
4241        // (0,0) should be 'X'. (1,0) should be cleared (orphaned tail).
4242        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('X'));
4243        assert!(buf.get(1, 0).unwrap().is_empty()); // Should be default/empty, not continuation
4244
4245        buf.pop_scissor();
4246    }
4247}