#![forbid(unsafe_code)]
use smallvec::SmallVec;
use crate::budget::DegradationLevel;
use crate::cell::{Cell, GraphemeId};
use ftui_core::geometry::Rect;
const DIRTY_SPAN_MAX_SPANS_PER_ROW: usize = 64;
const DIRTY_SPAN_MERGE_GAP: u16 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DirtySpanConfig {
pub enabled: bool,
pub max_spans_per_row: usize,
pub merge_gap: u16,
pub guard_band: u16,
}
impl Default for DirtySpanConfig {
fn default() -> Self {
Self {
enabled: true,
max_spans_per_row: DIRTY_SPAN_MAX_SPANS_PER_ROW,
merge_gap: DIRTY_SPAN_MERGE_GAP,
guard_band: 0,
}
}
}
impl DirtySpanConfig {
#[must_use]
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
#[must_use]
pub fn with_max_spans_per_row(mut self, max_spans: usize) -> Self {
self.max_spans_per_row = max_spans;
self
}
#[must_use]
pub fn with_merge_gap(mut self, merge_gap: u16) -> Self {
self.merge_gap = merge_gap;
self
}
#[must_use]
pub fn with_guard_band(mut self, guard_band: u16) -> Self {
self.guard_band = guard_band;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DirtySpan {
pub x0: u16,
pub x1: u16,
}
impl DirtySpan {
#[inline]
pub const fn new(x0: u16, x1: u16) -> Self {
Self { x0, x1 }
}
#[inline]
pub const fn len(self) -> usize {
self.x1.saturating_sub(self.x0) as usize
}
}
#[derive(Debug, Default, Clone)]
pub(crate) struct DirtySpanRow {
overflow: bool,
spans: SmallVec<[DirtySpan; 4]>,
}
impl DirtySpanRow {
#[inline]
fn new_full() -> Self {
Self {
overflow: true,
spans: SmallVec::new(),
}
}
#[inline]
fn clear(&mut self) {
self.overflow = false;
self.spans.clear();
}
#[inline]
fn set_full(&mut self) {
self.overflow = true;
self.spans.clear();
}
#[inline]
pub(crate) fn spans(&self) -> &[DirtySpan] {
&self.spans
}
#[inline]
pub(crate) fn is_full(&self) -> bool {
self.overflow
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DirtySpanStats {
pub rows_full_dirty: usize,
pub rows_with_spans: usize,
pub total_spans: usize,
pub overflows: usize,
pub span_coverage_cells: usize,
pub max_span_len: usize,
pub max_spans_per_row: usize,
}
#[derive(Debug, Clone)]
pub struct Buffer {
width: u16,
height: u16,
cells: Vec<Cell>,
scissor_stack: Vec<Rect>,
opacity_stack: Vec<f32>,
pub degradation: DegradationLevel,
dirty_rows: Vec<bool>,
dirty_spans: Vec<DirtySpanRow>,
dirty_span_config: DirtySpanConfig,
dirty_span_overflows: usize,
dirty_bits: Vec<u8>,
dirty_cells: usize,
dirty_all: bool,
}
impl Buffer {
pub fn new(width: u16, height: u16) -> Self {
let width = width.max(1);
let height = height.max(1);
let size = width as usize * height as usize;
let cells = vec![Cell::default(); size];
let dirty_spans = (0..height)
.map(|_| DirtySpanRow::new_full())
.collect::<Vec<_>>();
let dirty_bits = vec![0u8; size];
let dirty_cells = size;
let dirty_all = true;
Self {
width,
height,
cells,
scissor_stack: vec![Rect::from_size(width, height)],
opacity_stack: vec![1.0],
degradation: DegradationLevel::Full,
dirty_rows: vec![true; height as usize],
dirty_spans,
dirty_span_config: DirtySpanConfig::default(),
dirty_span_overflows: 0,
dirty_bits,
dirty_cells,
dirty_all,
}
}
#[inline]
pub const fn width(&self) -> u16 {
self.width
}
#[inline]
pub const fn height(&self) -> u16 {
self.height
}
#[inline]
pub fn len(&self) -> usize {
self.cells.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.cells.is_empty()
}
#[inline]
pub const fn bounds(&self) -> Rect {
Rect::from_size(self.width, self.height)
}
#[inline]
pub fn content_height(&self) -> u16 {
let default_cell = Cell::default();
let width = self.width as usize;
for y in (0..self.height).rev() {
let row_start = y as usize * width;
let row_end = row_start + width;
if self.cells[row_start..row_end]
.iter()
.any(|cell| *cell != default_cell)
{
return y + 1;
}
}
0
}
#[inline]
fn mark_dirty_row(&mut self, y: u16) {
if let Some(slot) = self.dirty_rows.get_mut(y as usize) {
*slot = true;
}
}
#[inline]
fn mark_dirty_bits_range(&mut self, y: u16, start: u16, end: u16) {
if self.dirty_all {
return;
}
if y >= self.height {
return;
}
let width = self.width;
if start >= width {
return;
}
let end = end.min(width);
if start >= end {
return;
}
let row_start = y as usize * width as usize;
let slice = &mut self.dirty_bits[row_start + start as usize..row_start + end as usize];
let newly_dirty = slice.iter().filter(|&&b| b == 0).count();
slice.fill(1);
self.dirty_cells = self.dirty_cells.saturating_add(newly_dirty);
}
#[inline]
fn mark_dirty_bits_row(&mut self, y: u16) {
self.mark_dirty_bits_range(y, 0, self.width);
}
#[inline]
fn mark_dirty_row_full(&mut self, y: u16) {
self.mark_dirty_row(y);
if self.dirty_span_config.enabled
&& let Some(row) = self.dirty_spans.get_mut(y as usize)
{
row.set_full();
}
self.mark_dirty_bits_row(y);
}
#[inline]
pub(crate) fn mark_dirty_span(&mut self, y: u16, x0: u16, x1: u16) {
self.mark_dirty_row(y);
let width = self.width;
let (start, mut end) = if x0 <= x1 { (x0, x1) } else { (x1, x0) };
if start >= width {
return;
}
if end > width {
end = width;
}
if start >= end {
return;
}
self.mark_dirty_bits_range(y, start, end);
if !self.dirty_span_config.enabled {
return;
}
let guard_band = self.dirty_span_config.guard_band;
let span_start = start.saturating_sub(guard_band);
let mut span_end = end.saturating_add(guard_band);
if span_end > width {
span_end = width;
}
if span_start >= span_end {
return;
}
let Some(row) = self.dirty_spans.get_mut(y as usize) else {
return;
};
if row.is_full() {
return;
}
let new_span = DirtySpan::new(span_start, span_end);
let spans = &mut row.spans;
let insert_at = spans.partition_point(|span| span.x0 <= new_span.x0);
spans.insert(insert_at, new_span);
let merge_gap = self.dirty_span_config.merge_gap;
let mut i = if insert_at > 0 { insert_at - 1 } else { 0 };
while i + 1 < spans.len() {
let current = spans[i];
let next = spans[i + 1];
let merge_limit = current.x1.saturating_add(merge_gap);
if merge_limit >= next.x0 {
spans[i].x1 = current.x1.max(next.x1);
spans.remove(i + 1);
continue;
}
i += 1;
}
if spans.len() > self.dirty_span_config.max_spans_per_row {
row.set_full();
self.dirty_span_overflows = self.dirty_span_overflows.saturating_add(1);
}
}
#[inline]
pub fn mark_all_dirty(&mut self) {
self.dirty_rows.fill(true);
if self.dirty_span_config.enabled {
for row in &mut self.dirty_spans {
row.set_full();
}
} else {
for row in &mut self.dirty_spans {
row.clear();
}
}
self.dirty_all = true;
self.dirty_cells = self.cells.len();
}
#[inline]
pub fn clear_dirty(&mut self) {
self.dirty_rows.fill(false);
for row in &mut self.dirty_spans {
row.clear();
}
self.dirty_span_overflows = 0;
self.dirty_bits.fill(0);
self.dirty_cells = 0;
self.dirty_all = false;
}
#[inline]
pub fn is_row_dirty(&self, y: u16) -> bool {
self.dirty_rows.get(y as usize).copied().unwrap_or(false)
}
#[inline]
pub fn dirty_rows(&self) -> &[bool] {
&self.dirty_rows
}
#[inline]
pub fn dirty_row_count(&self) -> usize {
self.dirty_rows.iter().filter(|&&d| d).count()
}
#[inline]
#[allow(dead_code)]
pub(crate) fn dirty_bits(&self) -> &[u8] {
&self.dirty_bits
}
#[inline]
#[allow(dead_code)]
pub(crate) fn dirty_cell_count(&self) -> usize {
self.dirty_cells
}
#[inline]
#[allow(dead_code)]
pub(crate) fn dirty_all(&self) -> bool {
self.dirty_all
}
#[inline]
#[allow(dead_code)]
pub(crate) fn dirty_span_row(&self, y: u16) -> Option<&DirtySpanRow> {
if !self.dirty_span_config.enabled {
return None;
}
self.dirty_spans.get(y as usize)
}
pub fn dirty_span_stats(&self) -> DirtySpanStats {
if !self.dirty_span_config.enabled {
return DirtySpanStats {
rows_full_dirty: 0,
rows_with_spans: 0,
total_spans: 0,
overflows: 0,
span_coverage_cells: 0,
max_span_len: 0,
max_spans_per_row: self.dirty_span_config.max_spans_per_row,
};
}
let mut rows_full_dirty = 0usize;
let mut rows_with_spans = 0usize;
let mut total_spans = 0usize;
let mut span_coverage_cells = 0usize;
let mut max_span_len = 0usize;
for row in &self.dirty_spans {
if row.is_full() {
rows_full_dirty += 1;
span_coverage_cells += self.width as usize;
max_span_len = max_span_len.max(self.width as usize);
continue;
}
if !row.spans().is_empty() {
rows_with_spans += 1;
}
total_spans += row.spans().len();
for span in row.spans() {
span_coverage_cells += span.len();
max_span_len = max_span_len.max(span.len());
}
}
DirtySpanStats {
rows_full_dirty,
rows_with_spans,
total_spans,
overflows: self.dirty_span_overflows,
span_coverage_cells,
max_span_len,
max_spans_per_row: self.dirty_span_config.max_spans_per_row,
}
}
#[inline]
pub fn dirty_span_config(&self) -> DirtySpanConfig {
self.dirty_span_config
}
pub fn set_dirty_span_config(&mut self, config: DirtySpanConfig) {
if self.dirty_span_config == config {
return;
}
self.dirty_span_config = config;
for row in &mut self.dirty_spans {
row.clear();
}
self.dirty_span_overflows = 0;
}
#[inline]
fn index(&self, x: u16, y: u16) -> Option<usize> {
if x < self.width && y < self.height {
Some(y as usize * self.width as usize + x as usize)
} else {
None
}
}
#[inline]
pub(crate) fn index_unchecked(&self, x: u16, y: u16) -> usize {
debug_assert!(x < self.width && y < self.height);
y as usize * self.width as usize + x as usize
}
#[inline]
pub(crate) fn cell_mut_unchecked(&mut self, idx: usize) -> &mut Cell {
&mut self.cells[idx]
}
#[inline]
#[must_use]
pub fn get(&self, x: u16, y: u16) -> Option<&Cell> {
self.index(x, y).map(|i| &self.cells[i])
}
#[inline]
#[must_use]
pub fn get_mut(&mut self, x: u16, y: u16) -> Option<&mut Cell> {
let idx = self.index(x, y)?;
self.mark_dirty_span(y, x, x.saturating_add(1));
Some(&mut self.cells[idx])
}
#[inline]
pub fn get_unchecked(&self, x: u16, y: u16) -> &Cell {
let i = self.index_unchecked(x, y);
&self.cells[i]
}
#[inline]
fn cleanup_overlap(&mut self, x: u16, y: u16, new_cell: &Cell) -> Option<DirtySpan> {
let idx = self.index(x, y)?;
let current = self.cells[idx];
let mut touched = false;
let mut min_x = x;
let mut max_x = x;
if current.content.width() > 1 {
let width = current.content.width();
for i in 1..width {
let Some(cx) = x.checked_add(i as u16) else {
break;
};
if let Some(tail_idx) = self.index(cx, y)
&& self.cells[tail_idx].is_continuation()
{
self.cells[tail_idx] = Cell::default();
touched = true;
min_x = min_x.min(cx);
max_x = max_x.max(cx);
}
}
}
else if current.is_continuation() && !new_cell.is_continuation() {
let mut back_x = x;
let limit = x.saturating_sub(GraphemeId::MAX_WIDTH as u16);
while back_x > limit {
back_x -= 1;
if let Some(h_idx) = self.index(back_x, y) {
let h_cell = self.cells[h_idx];
if !h_cell.is_continuation() {
let width = h_cell.content.width();
if (back_x as usize + width) > x as usize {
self.cells[h_idx] = Cell::default();
touched = true;
min_x = min_x.min(back_x);
max_x = max_x.max(back_x);
for i in 1..width {
let Some(cx) = back_x.checked_add(i as u16) else {
break;
};
if let Some(tail_idx) = self.index(cx, y) {
if self.cells[tail_idx].is_continuation() {
self.cells[tail_idx] = Cell::default();
touched = true;
min_x = min_x.min(cx);
max_x = max_x.max(cx);
}
}
}
}
break;
}
}
}
}
if touched {
Some(DirtySpan::new(min_x, max_x.saturating_add(1)))
} else {
None
}
}
#[inline]
fn cleanup_orphaned_tails(&mut self, start_x: u16, y: u16) {
if start_x >= self.width {
return;
}
let Some(idx) = self.index(start_x, y) else {
return;
};
if !self.cells[idx].is_continuation() {
return;
}
let mut x = start_x;
let mut max_x = x;
let row_end_idx = (y as usize * self.width as usize) + self.width as usize;
let mut curr_idx = idx;
while curr_idx < row_end_idx && self.cells[curr_idx].is_continuation() {
self.cells[curr_idx] = Cell::default();
max_x = x;
x = x.saturating_add(1);
curr_idx += 1;
}
self.mark_dirty_span(y, start_x, max_x.saturating_add(1));
}
#[inline]
pub fn set_fast(&mut self, x: u16, y: u16, cell: Cell) {
let bg_a = cell.bg.a();
if cell.content.width() > 1 || cell.is_continuation() || (bg_a != 255 && bg_a != 0) {
return self.set(x, y, cell);
}
if self.scissor_stack.len() != 1 || self.opacity_stack.len() != 1 {
return self.set(x, y, cell);
}
let Some(idx) = self.index(x, y) else {
return;
};
let existing = self.cells[idx];
if existing.content.width() > 1 || existing.is_continuation() {
return self.set(x, y, cell);
}
let mut final_cell = cell;
if bg_a == 0 {
final_cell.bg = existing.bg;
}
self.cells[idx] = final_cell;
self.mark_dirty_span(y, x, x.saturating_add(1));
self.cleanup_orphaned_tails(x.saturating_add(1), y);
}
#[inline]
pub fn set(&mut self, x: u16, y: u16, cell: Cell) {
let width = cell.content.width();
if width <= 1 {
let Some(idx) = self.index(x, y) else {
return;
};
if !self.current_scissor().contains(x, y) {
return;
}
let mut span_start = x;
let mut span_end = x.saturating_add(1);
if let Some(span) = self.cleanup_overlap(x, y, &cell) {
span_start = span_start.min(span.x0);
span_end = span_end.max(span.x1);
}
let existing_bg = self.cells[idx].bg;
let mut final_cell = if self.current_opacity() < 1.0 {
let opacity = self.current_opacity();
Cell {
fg: cell.fg.with_opacity(opacity),
bg: cell.bg.with_opacity(opacity),
..cell
}
} else {
cell
};
final_cell.bg = final_cell.bg.over(existing_bg);
self.cells[idx] = final_cell;
self.mark_dirty_span(y, span_start, span_end);
self.cleanup_orphaned_tails(x.saturating_add(1), y);
return;
}
let scissor = self.current_scissor();
for i in 0..width {
let Some(cx) = x.checked_add(i as u16) else {
return;
};
if cx >= self.width || y >= self.height {
return;
}
if !scissor.contains(cx, y) {
return;
}
}
let mut span_start = x;
let mut span_end = x.saturating_add(width as u16);
if let Some(span) = self.cleanup_overlap(x, y, &cell) {
span_start = span_start.min(span.x0);
span_end = span_end.max(span.x1);
}
for i in 1..width {
if let Some(span) = self.cleanup_overlap(x + i as u16, y, &Cell::CONTINUATION) {
span_start = span_start.min(span.x0);
span_end = span_end.max(span.x1);
}
}
let idx = self.index_unchecked(x, y);
let old_cell = self.cells[idx];
let mut final_cell = if self.current_opacity() < 1.0 {
let opacity = self.current_opacity();
Cell {
fg: cell.fg.with_opacity(opacity),
bg: cell.bg.with_opacity(opacity),
..cell
}
} else {
cell
};
final_cell.bg = final_cell.bg.over(old_cell.bg);
self.cells[idx] = final_cell;
for i in 1..width {
let idx = self.index_unchecked(x + i as u16, y);
self.cells[idx] = Cell::CONTINUATION;
}
self.mark_dirty_span(y, span_start, span_end);
self.cleanup_orphaned_tails(x.saturating_add(width as u16), y);
}
#[inline]
pub fn set_raw(&mut self, x: u16, y: u16, cell: Cell) {
if let Some(idx) = self.index(x, y) {
let mut span = DirtySpan::new(x, x.saturating_add(1));
let raw_wide_head = cell.content.width() > 1 && !cell.is_continuation();
if !raw_wide_head && let Some(cleanup_span) = self.cleanup_overlap(x, y, &cell) {
span = DirtySpan::new(span.x0.min(cleanup_span.x0), span.x1.max(cleanup_span.x1));
}
self.cells[idx] = cell;
self.mark_dirty_span(y, span.x0, span.x1);
if !raw_wide_head {
self.cleanup_orphaned_tails(x.saturating_add(1), y);
}
}
}
#[inline]
pub fn fill(&mut self, rect: Rect, cell: Cell) {
let clipped = self.current_scissor().intersection(&rect);
if clipped.is_empty() {
return;
}
let cell_width = cell.content.width();
if cell_width <= 1
&& !cell.is_continuation()
&& self.current_opacity() >= 1.0
&& cell.bg.a() == 255
&& clipped.x == 0
&& clipped.width == self.width
{
let row_width = self.width as usize;
for y in clipped.y..clipped.bottom() {
let row_start = y as usize * row_width;
let row_end = row_start + row_width;
self.cells[row_start..row_end].fill(cell);
self.mark_dirty_row_full(y);
}
return;
}
if cell_width <= 1
&& !cell.is_continuation()
&& self.current_opacity() >= 1.0
&& cell.bg.a() == 255
&& self.scissor_stack.len() == 1
{
let row_width = self.width as usize;
let x_start = clipped.x as usize;
let x_end = clipped.right() as usize;
for y in clipped.y..clipped.bottom() {
let row_start = y as usize * row_width;
let mut dirty_left = clipped.x;
let mut dirty_right = clipped.right();
if x_start > 0 && self.cells[row_start + x_start].is_continuation() {
let mut head_found = None;
for hx in (0..x_start).rev() {
if !self.cells[row_start + hx].is_continuation() {
head_found = Some(hx);
break;
}
}
if let Some(hx) = head_found {
let c = self.cells[row_start + hx];
let width = c.content.width();
if width > 1 && hx + width > x_start {
for cx in hx..x_start {
self.cells[row_start + cx] = Cell::default();
}
dirty_left = hx as u16;
}
}
}
{
let mut cx = x_end;
while cx < row_width && self.cells[row_start + cx].is_continuation() {
self.cells[row_start + cx] = Cell::default();
dirty_right = (cx as u16).saturating_add(1);
cx += 1;
}
}
self.cells[row_start + x_start..row_start + x_end].fill(cell);
self.mark_dirty_span(y, dirty_left, dirty_right);
}
return;
}
self.push_scissor(clipped);
let step = cell.content.width().max(1) as u16;
for y in clipped.y..clipped.bottom() {
let mut x = clipped.x;
while x < clipped.right() {
self.set(x, y, cell);
x = x.saturating_add(step);
}
}
self.pop_scissor();
}
#[inline]
pub fn clear(&mut self) {
self.cells.fill(Cell::default());
self.mark_all_dirty();
}
pub fn reset_for_frame(&mut self) {
self.scissor_stack.truncate(1);
if let Some(base) = self.scissor_stack.first_mut() {
*base = Rect::from_size(self.width, self.height);
} else {
self.scissor_stack
.push(Rect::from_size(self.width, self.height));
}
self.opacity_stack.truncate(1);
if let Some(base) = self.opacity_stack.first_mut() {
*base = 1.0;
} else {
self.opacity_stack.push(1.0);
}
self.clear();
}
#[inline]
pub fn clear_with(&mut self, cell: Cell) {
if cell.is_continuation() {
self.clear();
return;
}
let width = cell.content.width();
if width <= 1 {
self.cells.fill(cell);
self.mark_all_dirty();
return;
}
self.cells.fill(Cell::default());
let step = width as u16;
for y in 0..self.height {
let row_start = y as usize * self.width as usize;
let mut x = 0u16;
while x.saturating_add(step) <= self.width {
let head_idx = row_start + x as usize;
self.cells[head_idx] = cell;
for off in 1..step {
self.cells[head_idx + off as usize] = Cell::CONTINUATION;
}
x = x.saturating_add(step);
}
}
self.mark_all_dirty();
}
#[inline]
pub fn cells(&self) -> &[Cell] {
&self.cells
}
#[inline]
pub fn cells_mut(&mut self) -> &mut [Cell] {
self.mark_all_dirty();
&mut self.cells
}
#[inline]
pub fn row_cells(&self, y: u16) -> &[Cell] {
let start = y as usize * self.width as usize;
&self.cells[start..start + self.width as usize]
}
#[inline]
pub fn row_cells_mut_span(&mut self, y: u16, x0: u16, x1: u16) -> Option<&mut [Cell]> {
if y >= self.height {
return None;
}
if x0 >= x1 {
return None;
}
let start = x0.min(self.width);
let end = x1.min(self.width);
if start >= end {
return None;
}
self.mark_dirty_span(y, start, end);
let row_start = y as usize * self.width as usize;
let slice_start = row_start + start as usize;
let slice_end = row_start + end as usize;
Some(&mut self.cells[slice_start..slice_end])
}
#[inline]
pub fn push_scissor(&mut self, rect: Rect) {
let current = self.current_scissor();
let intersected = current.intersection(&rect);
self.scissor_stack.push(intersected);
}
#[inline]
pub fn pop_scissor(&mut self) {
if self.scissor_stack.len() > 1 {
self.scissor_stack.pop();
}
}
#[inline]
pub fn current_scissor(&self) -> Rect {
*self
.scissor_stack
.last()
.expect("scissor stack always has at least one element")
}
#[inline]
pub fn scissor_depth(&self) -> usize {
self.scissor_stack.len()
}
#[inline]
pub fn push_opacity(&mut self, opacity: f32) {
let clamped = opacity.clamp(0.0, 1.0);
let current = self.current_opacity();
self.opacity_stack.push(current * clamped);
}
#[inline]
pub fn pop_opacity(&mut self) {
if self.opacity_stack.len() > 1 {
self.opacity_stack.pop();
}
}
#[inline]
pub fn current_opacity(&self) -> f32 {
*self
.opacity_stack
.last()
.expect("opacity stack always has at least one element")
}
#[inline]
pub fn opacity_depth(&self) -> usize {
self.opacity_stack.len()
}
pub fn copy_from(&mut self, src: &Buffer, src_rect: Rect, dst_x: u16, dst_y: u16) {
let copy_bounds = Rect::new(dst_x, dst_y, src_rect.width, src_rect.height);
self.push_scissor(copy_bounds);
let clip = self.current_scissor();
for dy in 0..src_rect.height {
let Some(target_y) = dst_y.checked_add(dy) else {
continue;
};
let Some(sy) = src_rect.y.checked_add(dy) else {
continue;
};
let mut dx = 0u16;
while dx < src_rect.width {
let Some(target_x) = dst_x.checked_add(dx) else {
dx = dx.saturating_add(1);
continue;
};
let Some(sx) = src_rect.x.checked_add(dx) else {
dx = dx.saturating_add(1);
continue;
};
if let Some(cell) = src.get(sx, sy) {
if cell.is_continuation() {
self.set(target_x, target_y, Cell::default());
dx = dx.saturating_add(1);
continue;
}
let width = cell.content.width();
let target_right = target_x.saturating_add(width as u16);
let src_clipped = width > 1 && dx.saturating_add(width as u16) > src_rect.width;
let dst_clipped = target_right > clip.right();
if src_clipped || dst_clipped {
let valid_width = (clip.right().saturating_sub(target_x)).min(width as u16);
for i in 0..valid_width {
self.set(target_x + i, target_y, Cell::default());
}
} else {
self.set(target_x, target_y, *cell);
}
if width > 1 {
dx = dx.saturating_add(width as u16);
} else {
dx = dx.saturating_add(1);
}
} else {
dx = dx.saturating_add(1);
}
}
}
self.pop_scissor();
}
pub fn content_eq(&self, other: &Buffer) -> bool {
self.width == other.width && self.height == other.height && self.cells == other.cells
}
}
impl Default for Buffer {
fn default() -> Self {
Self::new(1, 1)
}
}
impl PartialEq for Buffer {
fn eq(&self, other: &Self) -> bool {
self.content_eq(other)
}
}
impl Eq for Buffer {}
#[derive(Debug)]
pub struct DoubleBuffer {
buffers: [Buffer; 2],
current_idx: u8,
}
const ADAPTIVE_GROWTH_FACTOR: f32 = 1.25;
const ADAPTIVE_SHRINK_THRESHOLD: f32 = 0.50;
const ADAPTIVE_MAX_OVERAGE: u16 = 200;
#[derive(Debug)]
pub struct AdaptiveDoubleBuffer {
inner: DoubleBuffer,
logical_width: u16,
logical_height: u16,
capacity_width: u16,
capacity_height: u16,
stats: AdaptiveStats,
}
#[derive(Debug, Clone, Default)]
pub struct AdaptiveStats {
pub resize_avoided: u64,
pub resize_reallocated: u64,
pub resize_growth: u64,
pub resize_shrink: u64,
}
impl AdaptiveStats {
pub fn reset(&mut self) {
*self = Self::default();
}
pub fn avoidance_ratio(&self) -> f64 {
let total = self.resize_avoided + self.resize_reallocated;
if total == 0 {
1.0
} else {
self.resize_avoided as f64 / total as f64
}
}
}
impl DoubleBuffer {
pub fn new(width: u16, height: u16) -> Self {
Self {
buffers: [Buffer::new(width, height), Buffer::new(width, height)],
current_idx: 0,
}
}
#[inline]
pub fn swap(&mut self) {
self.current_idx = 1 - self.current_idx;
}
#[inline]
pub fn current(&self) -> &Buffer {
&self.buffers[self.current_idx as usize]
}
#[inline]
pub fn current_mut(&mut self) -> &mut Buffer {
&mut self.buffers[self.current_idx as usize]
}
#[inline]
pub fn previous(&self) -> &Buffer {
&self.buffers[(1 - self.current_idx) as usize]
}
#[inline]
pub fn previous_mut(&mut self) -> &mut Buffer {
&mut self.buffers[(1 - self.current_idx) as usize]
}
#[inline]
pub fn width(&self) -> u16 {
self.buffers[0].width()
}
#[inline]
pub fn height(&self) -> u16 {
self.buffers[0].height()
}
pub fn resize(&mut self, width: u16, height: u16) -> bool {
if self.buffers[0].width() == width && self.buffers[0].height() == height {
return false;
}
self.buffers = [Buffer::new(width, height), Buffer::new(width, height)];
self.current_idx = 0;
true
}
#[inline]
pub fn dimensions_match(&self, width: u16, height: u16) -> bool {
self.buffers[0].width() == width && self.buffers[0].height() == height
}
}
impl AdaptiveDoubleBuffer {
pub fn new(width: u16, height: u16) -> Self {
let (cap_w, cap_h) = Self::compute_capacity(width, height);
Self {
inner: DoubleBuffer::new(cap_w, cap_h),
logical_width: width,
logical_height: height,
capacity_width: cap_w,
capacity_height: cap_h,
stats: AdaptiveStats::default(),
}
}
fn compute_capacity(width: u16, height: u16) -> (u16, u16) {
let extra_w =
((width as f32 * (ADAPTIVE_GROWTH_FACTOR - 1.0)) as u16).min(ADAPTIVE_MAX_OVERAGE);
let extra_h =
((height as f32 * (ADAPTIVE_GROWTH_FACTOR - 1.0)) as u16).min(ADAPTIVE_MAX_OVERAGE);
let cap_w = width.saturating_add(extra_w);
let cap_h = height.saturating_add(extra_h);
(cap_w, cap_h)
}
fn needs_reallocation(&self, width: u16, height: u16) -> bool {
if width > self.capacity_width || height > self.capacity_height {
return true;
}
let shrink_threshold_w = (self.capacity_width as f32 * ADAPTIVE_SHRINK_THRESHOLD) as u16;
let shrink_threshold_h = (self.capacity_height as f32 * ADAPTIVE_SHRINK_THRESHOLD) as u16;
width < shrink_threshold_w || height < shrink_threshold_h
}
#[inline]
pub fn swap(&mut self) {
self.inner.swap();
}
#[inline]
pub fn current(&self) -> &Buffer {
self.inner.current()
}
#[inline]
pub fn current_mut(&mut self) -> &mut Buffer {
self.inner.current_mut()
}
#[inline]
pub fn previous(&self) -> &Buffer {
self.inner.previous()
}
#[inline]
pub fn width(&self) -> u16 {
self.logical_width
}
#[inline]
pub fn height(&self) -> u16 {
self.logical_height
}
#[inline]
pub fn capacity_width(&self) -> u16 {
self.capacity_width
}
#[inline]
pub fn capacity_height(&self) -> u16 {
self.capacity_height
}
#[inline]
pub fn stats(&self) -> &AdaptiveStats {
&self.stats
}
pub fn reset_stats(&mut self) {
self.stats.reset();
}
pub fn resize(&mut self, width: u16, height: u16) -> bool {
if width == self.logical_width && height == self.logical_height {
return false;
}
let is_growth = width > self.logical_width || height > self.logical_height;
if is_growth {
self.stats.resize_growth += 1;
} else {
self.stats.resize_shrink += 1;
}
if self.needs_reallocation(width, height) {
let (cap_w, cap_h) = Self::compute_capacity(width, height);
self.inner = DoubleBuffer::new(cap_w, cap_h);
self.capacity_width = cap_w;
self.capacity_height = cap_h;
self.stats.resize_reallocated += 1;
} else {
self.inner.current_mut().clear();
self.inner.previous_mut().clear();
self.stats.resize_avoided += 1;
}
self.logical_width = width;
self.logical_height = height;
true
}
#[inline]
pub fn dimensions_match(&self, width: u16, height: u16) -> bool {
self.logical_width == width && self.logical_height == height
}
#[inline]
pub fn logical_bounds(&self) -> Rect {
Rect::from_size(self.logical_width, self.logical_height)
}
pub fn memory_efficiency(&self) -> f64 {
let logical = self.logical_width as u64 * self.logical_height as u64;
let capacity = self.capacity_width as u64 * self.capacity_height as u64;
if capacity == 0 {
1.0
} else {
logical as f64 / capacity as f64
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cell::PackedRgba;
#[test]
fn set_composites_background() {
let mut buf = Buffer::new(1, 1);
let red = PackedRgba::rgb(255, 0, 0);
buf.set(0, 0, Cell::default().with_bg(red));
let cell = Cell::from_char('X'); buf.set(0, 0, cell);
let result = buf.get(0, 0).unwrap();
assert_eq!(result.content.as_char(), Some('X'));
assert_eq!(
result.bg, red,
"Background should be preserved (composited)"
);
}
#[test]
fn set_fast_matches_set_for_transparent_bg() {
let red = PackedRgba::rgb(255, 0, 0);
let cell = Cell::from_char('X').with_fg(PackedRgba::rgb(0, 255, 0));
let mut a = Buffer::new(1, 1);
a.set(0, 0, Cell::default().with_bg(red));
a.set(0, 0, cell);
let mut b = Buffer::new(1, 1);
b.set(0, 0, Cell::default().with_bg(red));
b.set_fast(0, 0, cell);
assert_eq!(a.get(0, 0), b.get(0, 0));
}
#[test]
fn set_fast_matches_set_for_opaque_bg() {
let cell = Cell::from_char('X')
.with_fg(PackedRgba::rgb(0, 255, 0))
.with_bg(PackedRgba::rgb(255, 0, 0));
let mut a = Buffer::new(1, 1);
a.set(0, 0, cell);
let mut b = Buffer::new(1, 1);
b.set_fast(0, 0, cell);
assert_eq!(a.get(0, 0), b.get(0, 0));
}
#[test]
fn set_fast_clears_orphaned_tail_like_set() {
let mut slow = Buffer::new(3, 1);
slow.set_raw(0, 0, Cell::from_char('A'));
slow.set_raw(1, 0, Cell::CONTINUATION);
slow.clear_dirty();
let mut fast = slow.clone();
slow.set(0, 0, Cell::from_char('X'));
fast.set_fast(0, 0, Cell::from_char('X'));
assert_eq!(slow.cells(), fast.cells());
assert_eq!(fast.get(1, 0), Some(&Cell::default()));
let spans = fast.dirty_span_row(0).expect("dirty span row").spans();
assert_eq!(spans, &[DirtySpan::new(0, 2)]);
}
#[test]
fn rect_contains() {
let r = Rect::new(5, 5, 10, 10);
assert!(r.contains(5, 5)); assert!(r.contains(14, 14)); assert!(!r.contains(4, 5)); assert!(!r.contains(15, 5)); assert!(!r.contains(5, 15)); }
#[test]
fn rect_intersection() {
let a = Rect::new(0, 0, 10, 10);
let b = Rect::new(5, 5, 10, 10);
let i = a.intersection(&b);
assert_eq!(i, Rect::new(5, 5, 5, 5));
let c = Rect::new(20, 20, 5, 5);
assert_eq!(a.intersection(&c), Rect::default());
}
#[test]
fn buffer_creation() {
let buf = Buffer::new(80, 24);
assert_eq!(buf.width(), 80);
assert_eq!(buf.height(), 24);
assert_eq!(buf.len(), 80 * 24);
}
#[test]
fn content_height_empty_is_zero() {
let buf = Buffer::new(8, 4);
assert_eq!(buf.content_height(), 0);
}
#[test]
fn content_height_tracks_last_non_empty_row() {
let mut buf = Buffer::new(5, 4);
buf.set(0, 0, Cell::from_char('A'));
assert_eq!(buf.content_height(), 1);
buf.set(2, 3, Cell::from_char('Z'));
assert_eq!(buf.content_height(), 4);
}
#[test]
fn buffer_zero_width_clamped_to_one() {
let buf = Buffer::new(0, 24);
assert_eq!(buf.width(), 1);
assert_eq!(buf.height(), 24);
}
#[test]
fn buffer_zero_height_clamped_to_one() {
let buf = Buffer::new(80, 0);
assert_eq!(buf.width(), 80);
assert_eq!(buf.height(), 1);
}
#[test]
fn buffer_get_and_set() {
let mut buf = Buffer::new(10, 10);
let cell = Cell::from_char('X');
buf.set(5, 5, cell);
assert_eq!(buf.get(5, 5).unwrap().content.as_char(), Some('X'));
}
#[test]
fn buffer_out_of_bounds_get() {
let buf = Buffer::new(10, 10);
assert!(buf.get(10, 0).is_none());
assert!(buf.get(0, 10).is_none());
assert!(buf.get(100, 100).is_none());
}
#[test]
fn buffer_out_of_bounds_set_ignored() {
let mut buf = Buffer::new(10, 10);
buf.set(100, 100, Cell::from_char('X')); assert_eq!(buf.cells().iter().filter(|c| !c.is_empty()).count(), 0);
}
#[test]
fn buffer_clear() {
let mut buf = Buffer::new(10, 10);
buf.set(5, 5, Cell::from_char('X'));
buf.clear();
assert!(buf.get(5, 5).unwrap().is_empty());
}
#[test]
fn scissor_stack_basic() {
let mut buf = Buffer::new(20, 20);
assert_eq!(buf.current_scissor(), Rect::from_size(20, 20));
assert_eq!(buf.scissor_depth(), 1);
buf.push_scissor(Rect::new(5, 5, 10, 10));
assert_eq!(buf.current_scissor(), Rect::new(5, 5, 10, 10));
assert_eq!(buf.scissor_depth(), 2);
buf.set(7, 7, Cell::from_char('I'));
assert_eq!(buf.get(7, 7).unwrap().content.as_char(), Some('I'));
buf.set(0, 0, Cell::from_char('O'));
assert!(buf.get(0, 0).unwrap().is_empty());
buf.pop_scissor();
assert_eq!(buf.current_scissor(), Rect::from_size(20, 20));
assert_eq!(buf.scissor_depth(), 1);
buf.set(0, 0, Cell::from_char('N'));
assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('N'));
}
#[test]
fn scissor_intersection() {
let mut buf = Buffer::new(20, 20);
buf.push_scissor(Rect::new(5, 5, 10, 10));
buf.push_scissor(Rect::new(8, 8, 10, 10));
assert_eq!(buf.current_scissor(), Rect::new(8, 8, 7, 7));
}
#[test]
fn scissor_base_cannot_be_popped() {
let mut buf = Buffer::new(10, 10);
buf.pop_scissor(); assert_eq!(buf.scissor_depth(), 1);
buf.pop_scissor(); assert_eq!(buf.scissor_depth(), 1);
}
#[test]
fn opacity_stack_basic() {
let mut buf = Buffer::new(10, 10);
assert!((buf.current_opacity() - 1.0).abs() < f32::EPSILON);
assert_eq!(buf.opacity_depth(), 1);
buf.push_opacity(0.5);
assert!((buf.current_opacity() - 0.5).abs() < f32::EPSILON);
assert_eq!(buf.opacity_depth(), 2);
buf.push_opacity(0.5);
assert!((buf.current_opacity() - 0.25).abs() < f32::EPSILON);
assert_eq!(buf.opacity_depth(), 3);
buf.pop_opacity();
assert!((buf.current_opacity() - 0.5).abs() < f32::EPSILON);
}
#[test]
fn opacity_applied_to_cells() {
let mut buf = Buffer::new(10, 10);
buf.push_opacity(0.5);
let cell = Cell::from_char('X').with_fg(PackedRgba::rgba(100, 100, 100, 255));
buf.set(5, 5, cell);
let stored = buf.get(5, 5).unwrap();
assert_eq!(stored.fg.a(), 128);
}
#[test]
fn opacity_composites_background_before_storage() {
let mut buf = Buffer::new(1, 1);
let red = PackedRgba::rgb(255, 0, 0);
let blue = PackedRgba::rgb(0, 0, 255);
buf.set(0, 0, Cell::default().with_bg(red));
buf.push_opacity(0.5);
buf.set(0, 0, Cell::default().with_bg(blue));
let stored = buf.get(0, 0).unwrap();
let expected = blue.with_opacity(0.5).over(red);
assert_eq!(stored.bg, expected);
}
#[test]
fn opacity_clamped() {
let mut buf = Buffer::new(10, 10);
buf.push_opacity(2.0); assert!((buf.current_opacity() - 1.0).abs() < f32::EPSILON);
buf.push_opacity(-1.0); assert!((buf.current_opacity() - 0.0).abs() < f32::EPSILON);
}
#[test]
fn opacity_base_cannot_be_popped() {
let mut buf = Buffer::new(10, 10);
buf.pop_opacity(); assert_eq!(buf.opacity_depth(), 1);
}
#[test]
fn buffer_fill() {
let mut buf = Buffer::new(10, 10);
let cell = Cell::from_char('#');
buf.fill(Rect::new(2, 2, 5, 5), cell);
assert_eq!(buf.get(3, 3).unwrap().content.as_char(), Some('#'));
assert!(buf.get(0, 0).unwrap().is_empty());
}
#[test]
fn buffer_fill_respects_scissor() {
let mut buf = Buffer::new(10, 10);
buf.push_scissor(Rect::new(3, 3, 4, 4));
let cell = Cell::from_char('#');
buf.fill(Rect::new(0, 0, 10, 10), cell);
assert_eq!(buf.get(3, 3).unwrap().content.as_char(), Some('#'));
assert!(buf.get(0, 0).unwrap().is_empty());
assert!(buf.get(7, 7).unwrap().is_empty());
}
#[test]
fn buffer_copy_from() {
let mut src = Buffer::new(10, 10);
src.set(2, 2, Cell::from_char('S'));
let mut dst = Buffer::new(10, 10);
dst.copy_from(&src, Rect::new(0, 0, 5, 5), 3, 3);
assert_eq!(dst.get(5, 5).unwrap().content.as_char(), Some('S'));
}
#[test]
fn copy_from_clips_wide_char_at_boundary() {
let mut src = Buffer::new(10, 1);
src.set(0, 0, Cell::from_char('ä¸'));
let mut dst = Buffer::new(10, 1);
dst.copy_from(&src, Rect::new(0, 0, 1, 1), 0, 0);
assert!(
dst.get(0, 0).unwrap().is_empty(),
"Wide char head should not be written if tail is clipped"
);
assert!(
dst.get(1, 0).unwrap().is_empty(),
"Wide char tail should not be leaked outside copy region"
);
}
#[test]
fn buffer_content_eq() {
let mut buf1 = Buffer::new(10, 10);
let mut buf2 = Buffer::new(10, 10);
assert!(buf1.content_eq(&buf2));
buf1.set(0, 0, Cell::from_char('X'));
assert!(!buf1.content_eq(&buf2));
buf2.set(0, 0, Cell::from_char('X'));
assert!(buf1.content_eq(&buf2));
}
#[test]
fn buffer_bounds() {
let buf = Buffer::new(80, 24);
let bounds = buf.bounds();
assert_eq!(bounds.x, 0);
assert_eq!(bounds.y, 0);
assert_eq!(bounds.width, 80);
assert_eq!(bounds.height, 24);
}
#[test]
fn buffer_set_raw_bypasses_scissor() {
let mut buf = Buffer::new(10, 10);
buf.push_scissor(Rect::new(5, 5, 5, 5));
buf.set(0, 0, Cell::from_char('S'));
assert!(buf.get(0, 0).unwrap().is_empty());
buf.set_raw(0, 0, Cell::from_char('R'));
assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('R'));
}
#[test]
fn set_handles_wide_chars() {
let mut buf = Buffer::new(10, 10);
buf.set(0, 0, Cell::from_char('ä¸'));
let head = buf.get(0, 0).unwrap();
assert_eq!(head.content.as_char(), Some('ä¸'));
let cont = buf.get(1, 0).unwrap();
assert!(cont.is_continuation());
assert!(!cont.is_empty());
}
#[test]
fn set_handles_wide_chars_clipped() {
let mut buf = Buffer::new(10, 10);
buf.push_scissor(Rect::new(0, 0, 1, 10));
buf.set(0, 0, Cell::from_char('ä¸'));
assert!(buf.get(0, 0).unwrap().is_empty());
assert!(buf.get(1, 0).unwrap().is_empty());
}
#[test]
fn overwrite_wide_head_with_single_clears_tails() {
let mut buf = Buffer::new(10, 1);
buf.set(0, 0, Cell::from_char('ä¸'));
assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('ä¸'));
assert!(buf.get(1, 0).unwrap().is_continuation());
buf.set(0, 0, Cell::from_char('A'));
assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('A'));
assert!(
buf.get(1, 0).unwrap().is_empty(),
"Continuation at x=1 should be cleared when head is overwritten"
);
}
#[test]
fn set_raw_overwrite_wide_head_with_single_clears_tails() {
let mut buf = Buffer::new(10, 1);
buf.set(0, 0, Cell::from_char('ä¸'));
assert!(buf.get(1, 0).unwrap().is_continuation());
buf.clear_dirty();
buf.set_raw(0, 0, Cell::from_char('A'));
assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('A'));
assert!(
buf.get(1, 0).unwrap().is_empty(),
"set_raw should clear stale continuation tails when overwriting a wide head"
);
let spans = buf.dirty_span_row(0).expect("dirty span row").spans();
assert_eq!(spans, &[DirtySpan::new(0, 2)]);
}
#[test]
fn set_raw_wide_head_preserves_manual_tail_cells() {
let mut buf = Buffer::new(10, 1);
buf.set_raw(0, 0, Cell::from_char('ä¸'));
buf.set_raw(1, 0, Cell::CONTINUATION);
assert!(buf.get(1, 0).unwrap().is_continuation());
buf.clear_dirty();
buf.set_raw(0, 0, Cell::from_char('æ—¥'));
assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('æ—¥'));
assert!(
buf.get(1, 0).unwrap().is_continuation(),
"set_raw wide-head replacement should not clear caller-managed tails"
);
let spans = buf.dirty_span_row(0).expect("dirty span row").spans();
assert_eq!(spans, &[DirtySpan::new(0, 1)]);
}
#[test]
fn overwrite_continuation_with_single_clears_head_and_tails() {
let mut buf = Buffer::new(10, 1);
buf.set(0, 0, Cell::from_char('ä¸'));
assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('ä¸'));
assert!(buf.get(1, 0).unwrap().is_continuation());
buf.set(1, 0, Cell::from_char('B'));
assert!(
buf.get(0, 0).unwrap().is_empty(),
"Head at x=0 should be cleared when its continuation is overwritten"
);
assert_eq!(buf.get(1, 0).unwrap().content.as_char(), Some('B'));
}
#[test]
fn overwrite_wide_with_another_wide() {
let mut buf = Buffer::new(10, 1);
buf.set(0, 0, Cell::from_char('ä¸'));
assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('ä¸'));
assert!(buf.get(1, 0).unwrap().is_continuation());
buf.set(0, 0, Cell::from_char('æ—¥'));
assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('æ—¥'));
assert!(
buf.get(1, 0).unwrap().is_continuation(),
"Continuation should still exist for new wide char"
);
}
#[test]
fn overwrite_continuation_middle_of_wide_sequence() {
let mut buf = Buffer::new(10, 1);
buf.set(0, 0, Cell::from_char('ä¸'));
buf.set(2, 0, Cell::from_char('æ—¥'));
assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('ä¸'));
assert!(buf.get(1, 0).unwrap().is_continuation());
assert_eq!(buf.get(2, 0).unwrap().content.as_char(), Some('æ—¥'));
assert!(buf.get(3, 0).unwrap().is_continuation());
buf.set(1, 0, Cell::from_char('X'));
assert!(
buf.get(0, 0).unwrap().is_empty(),
"Head of first wide char should be cleared"
);
assert_eq!(buf.get(1, 0).unwrap().content.as_char(), Some('X'));
assert_eq!(buf.get(2, 0).unwrap().content.as_char(), Some('æ—¥'));
assert!(buf.get(3, 0).unwrap().is_continuation());
}
#[test]
fn wide_char_overlapping_previous_wide_char() {
let mut buf = Buffer::new(10, 1);
buf.set(0, 0, Cell::from_char('ä¸'));
assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('ä¸'));
assert!(buf.get(1, 0).unwrap().is_continuation());
buf.set(1, 0, Cell::from_char('æ—¥'));
assert!(
buf.get(0, 0).unwrap().is_empty(),
"First wide char head should be cleared when continuation is overwritten by new wide"
);
assert_eq!(buf.get(1, 0).unwrap().content.as_char(), Some('æ—¥'));
assert!(buf.get(2, 0).unwrap().is_continuation());
}
#[test]
fn wide_char_at_end_of_buffer_atomic_reject() {
let mut buf = Buffer::new(5, 1);
buf.set(4, 0, Cell::from_char('ä¸'));
assert!(
buf.get(4, 0).unwrap().is_empty(),
"Wide char should be rejected when tail would be out of bounds"
);
}
#[test]
fn three_wide_chars_sequential_cleanup() {
let mut buf = Buffer::new(10, 1);
buf.set(0, 0, Cell::from_char('一'));
buf.set(2, 0, Cell::from_char('二'));
buf.set(4, 0, Cell::from_char('三'));
assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('一'));
assert!(buf.get(1, 0).unwrap().is_continuation());
assert_eq!(buf.get(2, 0).unwrap().content.as_char(), Some('二'));
assert!(buf.get(3, 0).unwrap().is_continuation());
assert_eq!(buf.get(4, 0).unwrap().content.as_char(), Some('三'));
assert!(buf.get(5, 0).unwrap().is_continuation());
buf.set(3, 0, Cell::from_char('M'));
assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('一'));
assert!(buf.get(1, 0).unwrap().is_continuation());
assert!(buf.get(2, 0).unwrap().is_empty());
assert_eq!(buf.get(3, 0).unwrap().content.as_char(), Some('M'));
assert_eq!(buf.get(4, 0).unwrap().content.as_char(), Some('三'));
assert!(buf.get(5, 0).unwrap().is_continuation());
}
#[test]
fn overwrite_empty_cell_no_cleanup_needed() {
let mut buf = Buffer::new(10, 1);
buf.set(5, 0, Cell::from_char('X'));
assert_eq!(buf.get(5, 0).unwrap().content.as_char(), Some('X'));
assert!(buf.get(4, 0).unwrap().is_empty());
assert!(buf.get(6, 0).unwrap().is_empty());
}
#[test]
fn wide_char_cleanup_with_opacity() {
let mut buf = Buffer::new(10, 1);
buf.set(0, 0, Cell::default().with_bg(PackedRgba::rgb(255, 0, 0)));
buf.set(1, 0, Cell::default().with_bg(PackedRgba::rgb(0, 255, 0)));
buf.set(0, 0, Cell::from_char('ä¸'));
buf.push_opacity(0.5);
buf.set(0, 0, Cell::from_char('A'));
buf.pop_opacity();
assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('A'));
assert!(buf.get(1, 0).unwrap().is_empty());
}
#[test]
fn wide_char_continuation_not_treated_as_head() {
let mut buf = Buffer::new(10, 1);
buf.set(0, 0, Cell::from_char('ä¸'));
let cont = buf.get(1, 0).unwrap();
assert!(cont.is_continuation());
assert_eq!(cont.content.width(), 0);
buf.set(1, 0, Cell::from_char('æ—¥'));
assert!(buf.get(0, 0).unwrap().is_empty());
assert_eq!(buf.get(1, 0).unwrap().content.as_char(), Some('æ—¥'));
assert!(buf.get(2, 0).unwrap().is_continuation());
}
#[test]
fn wide_char_fill_region() {
let mut buf = Buffer::new(10, 3);
let wide_cell = Cell::from_char('ä¸');
buf.fill(Rect::new(0, 0, 4, 2), wide_cell);
assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('ä¸'));
assert!(buf.get(1, 0).unwrap().is_continuation());
assert_eq!(buf.get(2, 0).unwrap().content.as_char(), Some('ä¸'));
assert!(buf.get(3, 0).unwrap().is_continuation());
}
#[test]
fn default_buffer_dimensions() {
let buf = Buffer::default();
assert_eq!(buf.width(), 1);
assert_eq!(buf.height(), 1);
assert_eq!(buf.len(), 1);
}
#[test]
fn buffer_partial_eq_impl() {
let buf1 = Buffer::new(5, 5);
let buf2 = Buffer::new(5, 5);
let mut buf3 = Buffer::new(5, 5);
buf3.set(0, 0, Cell::from_char('X'));
assert_eq!(buf1, buf2);
assert_ne!(buf1, buf3);
}
#[test]
fn degradation_level_accessible() {
let mut buf = Buffer::new(10, 10);
assert_eq!(buf.degradation, DegradationLevel::Full);
buf.degradation = DegradationLevel::SimpleBorders;
assert_eq!(buf.degradation, DegradationLevel::SimpleBorders);
}
#[test]
fn get_mut_modifies_cell() {
let mut buf = Buffer::new(10, 10);
buf.set(3, 3, Cell::from_char('A'));
if let Some(cell) = buf.get_mut(3, 3) {
*cell = Cell::from_char('B');
}
assert_eq!(buf.get(3, 3).unwrap().content.as_char(), Some('B'));
}
#[test]
fn get_mut_out_of_bounds() {
let mut buf = Buffer::new(5, 5);
assert!(buf.get_mut(10, 10).is_none());
}
#[test]
fn clear_with_fills_all_cells() {
let mut buf = Buffer::new(5, 3);
let fill_cell = Cell::from_char('*');
buf.clear_with(fill_cell);
for y in 0..3 {
for x in 0..5 {
assert_eq!(buf.get(x, y).unwrap().content.as_char(), Some('*'));
}
}
}
#[test]
fn clear_with_wide_cell_preserves_head_tail_invariant() {
let mut buf = Buffer::new(5, 2);
buf.clear_with(Cell::from_char('ä¸'));
for y in 0..2 {
assert_eq!(buf.get(0, y).unwrap().content.as_char(), Some('ä¸'));
assert!(buf.get(1, y).unwrap().is_continuation());
assert_eq!(buf.get(2, y).unwrap().content.as_char(), Some('ä¸'));
assert!(buf.get(3, y).unwrap().is_continuation());
assert!(buf.get(4, y).unwrap().is_empty());
}
}
#[test]
fn cells_slice_has_correct_length() {
let buf = Buffer::new(10, 5);
assert_eq!(buf.cells().len(), 50);
}
#[test]
fn cells_mut_allows_direct_modification() {
let mut buf = Buffer::new(3, 2);
let cells = buf.cells_mut();
cells[0] = Cell::from_char('Z');
assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('Z'));
}
#[test]
fn row_cells_returns_correct_row() {
let mut buf = Buffer::new(5, 3);
buf.set(2, 1, Cell::from_char('R'));
let row = buf.row_cells(1);
assert_eq!(row.len(), 5);
assert_eq!(row[2].content.as_char(), Some('R'));
}
#[test]
fn row_cells_mut_span_marks_once_and_returns_slice() {
let mut buf = Buffer::new(5, 3);
buf.clear_dirty();
let row = buf
.row_cells_mut_span(1, 1, 4)
.expect("row span should be in bounds");
assert_eq!(row.len(), 3);
row[0] = Cell::from_char('A');
row[1] = Cell::from_char('B');
row[2] = Cell::from_char('C');
assert!(buf.is_row_dirty(1));
let spans = buf.dirty_span_row(1).expect("dirty span row").spans();
assert_eq!(spans, &[DirtySpan::new(1, 4)]);
assert_eq!(buf.get(1, 1).unwrap().content.as_char(), Some('A'));
assert_eq!(buf.get(2, 1).unwrap().content.as_char(), Some('B'));
assert_eq!(buf.get(3, 1).unwrap().content.as_char(), Some('C'));
}
#[test]
fn row_cells_mut_span_clamps_to_buffer_width() {
let mut buf = Buffer::new(5, 1);
buf.clear_dirty();
let row = buf
.row_cells_mut_span(0, 3, 99)
.expect("row span should clamp");
assert_eq!(row.len(), 2);
row[0] = Cell::from_char('X');
row[1] = Cell::from_char('Y');
let spans = buf.dirty_span_row(0).expect("dirty span row").spans();
assert_eq!(spans, &[DirtySpan::new(3, 5)]);
assert_eq!(buf.get(3, 0).unwrap().content.as_char(), Some('X'));
assert_eq!(buf.get(4, 0).unwrap().content.as_char(), Some('Y'));
}
#[test]
fn row_cells_mut_span_rejects_reversed_ranges() {
let mut buf = Buffer::new(5, 1);
buf.clear_dirty();
assert!(buf.row_cells_mut_span(0, 4, 2).is_none());
assert!(
!buf.is_row_dirty(0),
"reversed ranges should not mark rows dirty"
);
assert!(
buf.dirty_span_row(0)
.expect("dirty span row")
.spans()
.is_empty(),
"reversed ranges should not add dirty spans"
);
}
#[test]
#[should_panic]
fn row_cells_out_of_bounds_panics() {
let buf = Buffer::new(5, 3);
let _ = buf.row_cells(5);
}
#[test]
fn buffer_is_not_empty() {
let buf = Buffer::new(1, 1);
assert!(!buf.is_empty());
}
#[test]
fn set_raw_out_of_bounds_is_safe() {
let mut buf = Buffer::new(5, 5);
buf.set_raw(100, 100, Cell::from_char('X'));
}
#[test]
fn copy_from_out_of_bounds_partial() {
let mut src = Buffer::new(5, 5);
src.set(0, 0, Cell::from_char('A'));
src.set(4, 4, Cell::from_char('B'));
let mut dst = Buffer::new(5, 5);
dst.copy_from(&src, Rect::new(0, 0, 5, 5), 3, 3);
assert_eq!(dst.get(3, 3).unwrap().content.as_char(), Some('A'));
assert!(dst.get(4, 4).unwrap().is_empty());
}
#[test]
fn content_eq_different_dimensions() {
let buf1 = Buffer::new(5, 5);
let buf2 = Buffer::new(10, 10);
assert!(!buf1.content_eq(&buf2));
}
mod property {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn buffer_dimensions_are_preserved(width in 1u16..200, height in 1u16..200) {
let buf = Buffer::new(width, height);
prop_assert_eq!(buf.width(), width);
prop_assert_eq!(buf.height(), height);
prop_assert_eq!(buf.len(), width as usize * height as usize);
}
#[test]
fn buffer_get_in_bounds_always_succeeds(width in 1u16..100, height in 1u16..100) {
let buf = Buffer::new(width, height);
for x in 0..width {
for y in 0..height {
prop_assert!(buf.get(x, y).is_some(), "get({x},{y}) failed for {width}x{height} buffer");
}
}
}
#[test]
fn buffer_get_out_of_bounds_returns_none(width in 1u16..50, height in 1u16..50) {
let buf = Buffer::new(width, height);
prop_assert!(buf.get(width, 0).is_none());
prop_assert!(buf.get(0, height).is_none());
prop_assert!(buf.get(width, height).is_none());
}
#[test]
fn buffer_set_get_roundtrip(
width in 5u16..50,
height in 5u16..50,
x in 0u16..5,
y in 0u16..5,
ch_idx in 0u32..26,
) {
let x = x % width;
let y = y % height;
let ch = char::from_u32('A' as u32 + ch_idx).unwrap();
let mut buf = Buffer::new(width, height);
buf.set(x, y, Cell::from_char(ch));
let got = buf.get(x, y).unwrap();
prop_assert_eq!(got.content.as_char(), Some(ch));
}
#[test]
fn scissor_push_pop_stack_depth(
width in 10u16..50,
height in 10u16..50,
push_count in 1usize..10,
) {
let mut buf = Buffer::new(width, height);
prop_assert_eq!(buf.scissor_depth(), 1);
for i in 0..push_count {
buf.push_scissor(Rect::new(0, 0, width, height));
prop_assert_eq!(buf.scissor_depth(), i + 2);
}
for i in (0..push_count).rev() {
buf.pop_scissor();
prop_assert_eq!(buf.scissor_depth(), i + 1);
}
buf.pop_scissor();
prop_assert_eq!(buf.scissor_depth(), 1);
}
#[test]
fn scissor_monotonic_intersection(
width in 20u16..60,
height in 20u16..60,
) {
let mut buf = Buffer::new(width, height);
let outer = Rect::new(2, 2, width - 4, height - 4);
buf.push_scissor(outer);
let s1 = buf.current_scissor();
let inner = Rect::new(5, 5, 10, 10);
buf.push_scissor(inner);
let s2 = buf.current_scissor();
prop_assert!(s2.width <= s1.width, "inner width {} > outer width {}", s2.width, s1.width);
prop_assert!(s2.height <= s1.height, "inner height {} > outer height {}", s2.height, s1.height);
}
#[test]
fn opacity_push_pop_stack_depth(
width in 5u16..20,
height in 5u16..20,
push_count in 1usize..10,
) {
let mut buf = Buffer::new(width, height);
prop_assert_eq!(buf.opacity_depth(), 1);
for i in 0..push_count {
buf.push_opacity(0.9);
prop_assert_eq!(buf.opacity_depth(), i + 2);
}
for i in (0..push_count).rev() {
buf.pop_opacity();
prop_assert_eq!(buf.opacity_depth(), i + 1);
}
buf.pop_opacity();
prop_assert_eq!(buf.opacity_depth(), 1);
}
#[test]
fn opacity_multiplication_is_monotonic(
opacity1 in 0.0f32..=1.0,
opacity2 in 0.0f32..=1.0,
) {
let mut buf = Buffer::new(5, 5);
buf.push_opacity(opacity1);
let after_first = buf.current_opacity();
buf.push_opacity(opacity2);
let after_second = buf.current_opacity();
prop_assert!(after_second <= after_first + f32::EPSILON,
"opacity increased: {} -> {}", after_first, after_second);
}
#[test]
fn clear_resets_all_cells(width in 1u16..30, height in 1u16..30) {
let mut buf = Buffer::new(width, height);
for x in 0..width {
buf.set_raw(x, 0, Cell::from_char('X'));
}
buf.clear();
for y in 0..height {
for x in 0..width {
prop_assert!(buf.get(x, y).unwrap().is_empty(),
"cell ({x},{y}) not empty after clear");
}
}
}
#[test]
fn content_eq_is_reflexive(width in 1u16..30, height in 1u16..30) {
let buf = Buffer::new(width, height);
prop_assert!(buf.content_eq(&buf));
}
#[test]
fn content_eq_detects_single_change(
width in 5u16..30,
height in 5u16..30,
x in 0u16..5,
y in 0u16..5,
) {
let x = x % width;
let y = y % height;
let buf1 = Buffer::new(width, height);
let mut buf2 = Buffer::new(width, height);
buf2.set_raw(x, y, Cell::from_char('Z'));
prop_assert!(!buf1.content_eq(&buf2));
}
#[test]
fn dimensions_immutable_through_operations(
width in 5u16..30,
height in 5u16..30,
) {
let mut buf = Buffer::new(width, height);
buf.set(0, 0, Cell::from_char('A'));
prop_assert_eq!(buf.width(), width);
prop_assert_eq!(buf.height(), height);
prop_assert_eq!(buf.len(), width as usize * height as usize);
buf.push_scissor(Rect::new(1, 1, 3, 3));
prop_assert_eq!(buf.width(), width);
prop_assert_eq!(buf.height(), height);
buf.push_opacity(0.5);
prop_assert_eq!(buf.width(), width);
prop_assert_eq!(buf.height(), height);
buf.pop_scissor();
buf.pop_opacity();
prop_assert_eq!(buf.width(), width);
prop_assert_eq!(buf.height(), height);
buf.clear();
prop_assert_eq!(buf.width(), width);
prop_assert_eq!(buf.height(), height);
prop_assert_eq!(buf.len(), width as usize * height as usize);
}
#[test]
fn scissor_area_never_increases_random_rects(
width in 20u16..60,
height in 20u16..60,
rects in proptest::collection::vec(
(0u16..20, 0u16..20, 1u16..15, 1u16..15),
1..8
),
) {
let mut buf = Buffer::new(width, height);
let mut prev_area = (width as u32) * (height as u32);
for (x, y, w, h) in rects {
buf.push_scissor(Rect::new(x, y, w, h));
let s = buf.current_scissor();
let area = (s.width as u32) * (s.height as u32);
prop_assert!(area <= prev_area,
"scissor area increased: {} -> {} after push({},{},{},{})",
prev_area, area, x, y, w, h);
prev_area = area;
}
}
#[test]
fn opacity_range_invariant_random_sequence(
opacities in proptest::collection::vec(0.0f32..=1.0, 1..15),
) {
let mut buf = Buffer::new(5, 5);
for &op in &opacities {
buf.push_opacity(op);
let current = buf.current_opacity();
prop_assert!(current >= 0.0, "opacity below 0: {}", current);
prop_assert!(current <= 1.0 + f32::EPSILON,
"opacity above 1: {}", current);
}
for _ in &opacities {
buf.pop_opacity();
}
prop_assert!((buf.current_opacity() - 1.0).abs() < f32::EPSILON);
}
#[test]
fn opacity_clamp_out_of_range(
neg in -100.0f32..0.0,
over in 1.01f32..100.0,
) {
let mut buf = Buffer::new(5, 5);
buf.push_opacity(neg);
prop_assert!(buf.current_opacity() >= 0.0,
"negative opacity not clamped: {}", buf.current_opacity());
buf.pop_opacity();
buf.push_opacity(over);
prop_assert!(buf.current_opacity() <= 1.0 + f32::EPSILON,
"over-1 opacity not clamped: {}", buf.current_opacity());
}
#[test]
fn scissor_stack_always_has_base(
pushes in 0usize..10,
pops in 0usize..15,
) {
let mut buf = Buffer::new(10, 10);
for _ in 0..pushes {
buf.push_scissor(Rect::new(0, 0, 5, 5));
}
for _ in 0..pops {
buf.pop_scissor();
}
prop_assert!(buf.scissor_depth() >= 1,
"scissor depth dropped below 1 after {} pushes, {} pops",
pushes, pops);
}
#[test]
fn opacity_stack_always_has_base(
pushes in 0usize..10,
pops in 0usize..15,
) {
let mut buf = Buffer::new(10, 10);
for _ in 0..pushes {
buf.push_opacity(0.5);
}
for _ in 0..pops {
buf.pop_opacity();
}
prop_assert!(buf.opacity_depth() >= 1,
"opacity depth dropped below 1 after {} pushes, {} pops",
pushes, pops);
}
#[test]
fn cells_len_invariant_always_holds(
width in 1u16..50,
height in 1u16..50,
) {
let mut buf = Buffer::new(width, height);
let expected = width as usize * height as usize;
prop_assert_eq!(buf.cells().len(), expected);
buf.set(0, 0, Cell::from_char('X'));
prop_assert_eq!(buf.cells().len(), expected);
buf.clear();
prop_assert_eq!(buf.cells().len(), expected);
}
#[test]
fn set_outside_scissor_is_noop(
width in 10u16..30,
height in 10u16..30,
) {
let mut buf = Buffer::new(width, height);
buf.push_scissor(Rect::new(2, 2, 3, 3));
buf.set(0, 0, Cell::from_char('X'));
let cell = buf.get(0, 0).unwrap();
prop_assert!(cell.is_empty(),
"cell (0,0) modified outside scissor region");
buf.set(3, 3, Cell::from_char('Y'));
let cell = buf.get(3, 3).unwrap();
prop_assert_eq!(cell.content.as_char(), Some('Y'));
}
#[test]
fn wide_char_overwrites_cleanup_tails(
width in 10u16..30,
x in 0u16..8,
) {
let x = x % (width.saturating_sub(2).max(1));
let mut buf = Buffer::new(width, 1);
buf.set(x, 0, Cell::from_char('ä¸'));
if x + 1 < width {
let head = buf.get(x, 0).unwrap();
let tail = buf.get(x + 1, 0).unwrap();
if head.content.as_char() == Some('ä¸') {
prop_assert!(tail.is_continuation(),
"tail at x+1={} should be continuation", x + 1);
buf.set(x, 0, Cell::from_char('A'));
let new_head = buf.get(x, 0).unwrap();
let cleared_tail = buf.get(x + 1, 0).unwrap();
prop_assert_eq!(new_head.content.as_char(), Some('A'));
prop_assert!(cleared_tail.is_empty(),
"tail should be cleared after head overwrite");
}
}
}
#[test]
fn wide_char_atomic_rejection_at_boundary(
width in 3u16..20,
) {
let mut buf = Buffer::new(width, 1);
let last_pos = width - 1;
buf.set(last_pos, 0, Cell::from_char('ä¸'));
let cell = buf.get(last_pos, 0).unwrap();
prop_assert!(cell.is_empty(),
"wide char at boundary position {} (width {}) should be rejected",
last_pos, width);
}
#[test]
fn double_buffer_swap_is_involution(ops in proptest::collection::vec(proptest::bool::ANY, 0..100)) {
let mut db = DoubleBuffer::new(10, 10);
let initial_idx = db.current_idx;
for do_swap in &ops {
if *do_swap {
db.swap();
}
}
let swap_count = ops.iter().filter(|&&x| x).count();
let expected_idx = if swap_count % 2 == 0 { initial_idx } else { 1 - initial_idx };
prop_assert_eq!(db.current_idx, expected_idx,
"After {} swaps, index should be {} but was {}",
swap_count, expected_idx, db.current_idx);
}
#[test]
fn double_buffer_resize_preserves_invariant(
init_w in 1u16..200,
init_h in 1u16..100,
new_w in 1u16..200,
new_h in 1u16..100,
) {
let mut db = DoubleBuffer::new(init_w, init_h);
db.resize(new_w, new_h);
prop_assert_eq!(db.width(), new_w);
prop_assert_eq!(db.height(), new_h);
prop_assert!(db.dimensions_match(new_w, new_h));
}
#[test]
fn double_buffer_current_previous_disjoint(
width in 1u16..50,
height in 1u16..50,
) {
let mut db = DoubleBuffer::new(width, height);
db.current_mut().set(0, 0, Cell::from_char('C'));
prop_assert!(db.previous().get(0, 0).unwrap().is_empty(),
"Previous buffer should not reflect changes to current");
db.swap();
prop_assert_eq!(db.previous().get(0, 0).unwrap().content.as_char(), Some('C'),
"After swap, previous should have the 'C' we wrote");
}
#[test]
fn double_buffer_swap_content_semantics(
width in 5u16..30,
height in 5u16..30,
) {
let mut db = DoubleBuffer::new(width, height);
db.current_mut().set(0, 0, Cell::from_char('X'));
db.swap();
db.current_mut().set(0, 0, Cell::from_char('Y'));
db.swap();
prop_assert_eq!(db.current().get(0, 0).unwrap().content.as_char(), Some('X'));
prop_assert_eq!(db.previous().get(0, 0).unwrap().content.as_char(), Some('Y'));
}
#[test]
fn double_buffer_resize_clears_both(
w1 in 5u16..30,
h1 in 5u16..30,
w2 in 5u16..30,
h2 in 5u16..30,
) {
prop_assume!(w1 != w2 || h1 != h2);
let mut db = DoubleBuffer::new(w1, h1);
db.current_mut().set(0, 0, Cell::from_char('A'));
db.swap();
db.current_mut().set(0, 0, Cell::from_char('B'));
db.resize(w2, h2);
prop_assert!(db.current().get(0, 0).unwrap().is_empty(),
"Current buffer should be empty after resize");
prop_assert!(db.previous().get(0, 0).unwrap().is_empty(),
"Previous buffer should be empty after resize");
}
}
}
#[test]
fn dirty_rows_start_dirty() {
let buf = Buffer::new(10, 5);
assert_eq!(buf.dirty_row_count(), 5);
for y in 0..5 {
assert!(buf.is_row_dirty(y));
}
}
#[test]
fn dirty_bitmap_starts_full() {
let buf = Buffer::new(4, 3);
assert!(buf.dirty_all());
assert_eq!(buf.dirty_cell_count(), 12);
}
#[test]
fn dirty_bitmap_tracks_single_cell() {
let mut buf = Buffer::new(4, 3);
buf.clear_dirty();
assert!(!buf.dirty_all());
buf.set_raw(1, 1, Cell::from_char('X'));
let idx = 1 + 4;
assert_eq!(buf.dirty_cell_count(), 1);
assert_eq!(buf.dirty_bits()[idx], 1);
}
#[test]
fn dirty_bitmap_dedupes_cells() {
let mut buf = Buffer::new(4, 3);
buf.clear_dirty();
buf.set_raw(2, 2, Cell::from_char('A'));
buf.set_raw(2, 2, Cell::from_char('B'));
assert_eq!(buf.dirty_cell_count(), 1);
}
#[test]
fn set_marks_row_dirty() {
let mut buf = Buffer::new(10, 5);
buf.clear_dirty(); buf.set(3, 2, Cell::from_char('X'));
assert!(buf.is_row_dirty(2));
assert!(!buf.is_row_dirty(0));
assert!(!buf.is_row_dirty(1));
assert!(!buf.is_row_dirty(3));
assert!(!buf.is_row_dirty(4));
}
#[test]
fn set_raw_marks_row_dirty() {
let mut buf = Buffer::new(10, 5);
buf.clear_dirty(); buf.set_raw(0, 4, Cell::from_char('Z'));
assert!(buf.is_row_dirty(4));
assert_eq!(buf.dirty_row_count(), 1);
}
#[test]
fn clear_marks_all_dirty() {
let mut buf = Buffer::new(10, 5);
buf.clear();
assert_eq!(buf.dirty_row_count(), 5);
}
#[test]
fn clear_dirty_resets_flags() {
let mut buf = Buffer::new(10, 5);
assert_eq!(buf.dirty_row_count(), 5);
buf.clear_dirty();
assert_eq!(buf.dirty_row_count(), 0);
buf.set(0, 0, Cell::from_char('A'));
buf.set(0, 3, Cell::from_char('B'));
assert_eq!(buf.dirty_row_count(), 2);
buf.clear_dirty();
assert_eq!(buf.dirty_row_count(), 0);
}
#[test]
fn clear_dirty_resets_bitmap() {
let mut buf = Buffer::new(4, 3);
buf.clear();
assert!(buf.dirty_all());
buf.clear_dirty();
assert!(!buf.dirty_all());
assert_eq!(buf.dirty_cell_count(), 0);
assert!(buf.dirty_bits().iter().all(|&b| b == 0));
}
#[test]
fn fill_marks_affected_rows_dirty() {
let mut buf = Buffer::new(10, 10);
buf.clear_dirty(); buf.fill(Rect::new(0, 2, 5, 3), Cell::from_char('.'));
assert!(!buf.is_row_dirty(0));
assert!(!buf.is_row_dirty(1));
assert!(buf.is_row_dirty(2));
assert!(buf.is_row_dirty(3));
assert!(buf.is_row_dirty(4));
assert!(!buf.is_row_dirty(5));
}
#[test]
fn get_mut_marks_row_dirty() {
let mut buf = Buffer::new(10, 5);
buf.clear_dirty(); if let Some(cell) = buf.get_mut(5, 3) {
cell.fg = PackedRgba::rgb(255, 0, 0);
}
assert!(buf.is_row_dirty(3));
assert_eq!(buf.dirty_row_count(), 1);
}
#[test]
fn cells_mut_marks_all_dirty() {
let mut buf = Buffer::new(10, 5);
let _ = buf.cells_mut();
assert_eq!(buf.dirty_row_count(), 5);
}
#[test]
fn dirty_rows_slice_length_matches_height() {
let buf = Buffer::new(10, 7);
assert_eq!(buf.dirty_rows().len(), 7);
}
#[test]
fn out_of_bounds_set_does_not_dirty() {
let mut buf = Buffer::new(10, 5);
buf.clear_dirty(); buf.set(100, 100, Cell::from_char('X'));
assert_eq!(buf.dirty_row_count(), 0);
}
#[test]
fn property_dirty_soundness() {
let mut buf = Buffer::new(20, 10);
let positions = [(3, 0), (5, 2), (0, 9), (19, 5), (10, 7)];
for &(x, y) in &positions {
buf.set(x, y, Cell::from_char('*'));
}
for &(_, y) in &positions {
assert!(
buf.is_row_dirty(y),
"Row {} should be dirty after set({}, {})",
y,
positions.iter().find(|(_, ry)| *ry == y).unwrap().0,
y
);
}
}
#[test]
fn dirty_clear_between_frames() {
let mut buf = Buffer::new(10, 5);
assert_eq!(buf.dirty_row_count(), 5);
buf.clear_dirty();
assert_eq!(buf.dirty_row_count(), 0);
buf.set(0, 0, Cell::from_char('A'));
buf.set(0, 2, Cell::from_char('B'));
assert_eq!(buf.dirty_row_count(), 2);
buf.clear_dirty();
assert_eq!(buf.dirty_row_count(), 0);
buf.set(0, 4, Cell::from_char('C'));
assert_eq!(buf.dirty_row_count(), 1);
assert!(buf.is_row_dirty(4));
assert!(!buf.is_row_dirty(0));
}
#[test]
fn dirty_spans_start_full_dirty() {
let buf = Buffer::new(10, 5);
for y in 0..5 {
let row = buf.dirty_span_row(y).unwrap();
assert!(row.is_full(), "row {y} should start full-dirty");
assert!(row.spans().is_empty(), "row {y} spans should start empty");
}
}
#[test]
fn clear_dirty_resets_spans() {
let mut buf = Buffer::new(10, 5);
buf.clear_dirty();
for y in 0..5 {
let row = buf.dirty_span_row(y).unwrap();
assert!(!row.is_full(), "row {y} should clear full-dirty");
assert!(row.spans().is_empty(), "row {y} spans should be cleared");
}
assert_eq!(buf.dirty_span_overflows, 0);
}
#[test]
fn set_records_dirty_span() {
let mut buf = Buffer::new(20, 2);
buf.clear_dirty();
buf.set(2, 0, Cell::from_char('A'));
let row = buf.dirty_span_row(0).unwrap();
assert_eq!(row.spans(), &[DirtySpan::new(2, 3)]);
assert!(!row.is_full());
}
#[test]
fn set_merges_adjacent_spans() {
let mut buf = Buffer::new(20, 2);
buf.clear_dirty();
buf.set(2, 0, Cell::from_char('A'));
buf.set(3, 0, Cell::from_char('B')); let row = buf.dirty_span_row(0).unwrap();
assert_eq!(row.spans(), &[DirtySpan::new(2, 4)]);
}
#[test]
fn set_merges_close_spans() {
let mut buf = Buffer::new(20, 2);
buf.clear_dirty();
buf.set(2, 0, Cell::from_char('A'));
buf.set(4, 0, Cell::from_char('B')); let row = buf.dirty_span_row(0).unwrap();
assert_eq!(row.spans(), &[DirtySpan::new(2, 5)]);
}
#[test]
fn span_overflow_sets_full_row() {
let width = (DIRTY_SPAN_MAX_SPANS_PER_ROW as u16 + 2) * 3;
let mut buf = Buffer::new(width, 1);
buf.clear_dirty();
for i in 0..(DIRTY_SPAN_MAX_SPANS_PER_ROW + 1) {
let x = (i as u16) * 3;
buf.set(x, 0, Cell::from_char('x'));
}
let row = buf.dirty_span_row(0).unwrap();
assert!(row.is_full());
assert!(row.spans().is_empty());
assert_eq!(buf.dirty_span_overflows, 1);
}
#[test]
fn fill_full_row_marks_full_span() {
let mut buf = Buffer::new(10, 3);
buf.clear_dirty();
let cell = Cell::from_char('x').with_bg(PackedRgba::rgb(0, 0, 0));
buf.fill(Rect::new(0, 1, 10, 1), cell);
let row = buf.dirty_span_row(1).unwrap();
assert!(row.is_full());
assert!(row.spans().is_empty());
}
#[test]
fn get_mut_records_dirty_span() {
let mut buf = Buffer::new(10, 5);
buf.clear_dirty();
let _ = buf.get_mut(5, 3);
let row = buf.dirty_span_row(3).unwrap();
assert_eq!(row.spans(), &[DirtySpan::new(5, 6)]);
}
#[test]
fn cells_mut_marks_all_full_spans() {
let mut buf = Buffer::new(10, 5);
buf.clear_dirty();
let _ = buf.cells_mut();
for y in 0..5 {
let row = buf.dirty_span_row(y).unwrap();
assert!(row.is_full(), "row {y} should be full after cells_mut");
}
}
#[test]
fn dirty_span_config_disabled_skips_rows() {
let mut buf = Buffer::new(10, 1);
buf.clear_dirty();
buf.set_dirty_span_config(DirtySpanConfig::default().with_enabled(false));
buf.set(5, 0, Cell::from_char('x'));
assert!(buf.dirty_span_row(0).is_none());
let stats = buf.dirty_span_stats();
assert_eq!(stats.total_spans, 0);
assert_eq!(stats.span_coverage_cells, 0);
}
#[test]
fn dirty_span_guard_band_expands_span_bounds() {
let mut buf = Buffer::new(10, 1);
buf.clear_dirty();
buf.set_dirty_span_config(DirtySpanConfig::default().with_guard_band(2));
buf.set(5, 0, Cell::from_char('x'));
let row = buf.dirty_span_row(0).unwrap();
assert_eq!(row.spans(), &[DirtySpan::new(3, 8)]);
}
#[test]
fn dirty_span_max_spans_overflow_triggers_full_row() {
let mut buf = Buffer::new(10, 1);
buf.clear_dirty();
buf.set_dirty_span_config(
DirtySpanConfig::default()
.with_max_spans_per_row(1)
.with_merge_gap(0),
);
buf.set(0, 0, Cell::from_char('a'));
buf.set(4, 0, Cell::from_char('b'));
let row = buf.dirty_span_row(0).unwrap();
assert!(row.is_full());
assert!(row.spans().is_empty());
assert_eq!(buf.dirty_span_overflows, 1);
}
#[test]
fn dirty_span_stats_counts_full_rows_and_spans() {
let mut buf = Buffer::new(6, 2);
buf.clear_dirty();
buf.set_dirty_span_config(DirtySpanConfig::default().with_merge_gap(0));
buf.set(1, 0, Cell::from_char('a'));
buf.set(4, 0, Cell::from_char('b'));
buf.mark_dirty_row_full(1);
let stats = buf.dirty_span_stats();
assert_eq!(stats.rows_full_dirty, 1);
assert_eq!(stats.rows_with_spans, 1);
assert_eq!(stats.total_spans, 2);
assert_eq!(stats.max_span_len, 6);
assert_eq!(stats.span_coverage_cells, 8);
}
#[test]
fn dirty_span_stats_reports_overflow_and_full_row() {
let mut buf = Buffer::new(8, 1);
buf.clear_dirty();
buf.set_dirty_span_config(
DirtySpanConfig::default()
.with_max_spans_per_row(1)
.with_merge_gap(0),
);
buf.set(0, 0, Cell::from_char('x'));
buf.set(3, 0, Cell::from_char('y'));
let stats = buf.dirty_span_stats();
assert_eq!(stats.overflows, 1);
assert_eq!(stats.rows_full_dirty, 1);
assert_eq!(stats.total_spans, 0);
assert_eq!(stats.span_coverage_cells, 8);
}
#[test]
fn double_buffer_new_has_matching_dimensions() {
let db = DoubleBuffer::new(80, 24);
assert_eq!(db.width(), 80);
assert_eq!(db.height(), 24);
assert!(db.dimensions_match(80, 24));
assert!(!db.dimensions_match(120, 40));
}
#[test]
fn double_buffer_swap_is_o1() {
let mut db = DoubleBuffer::new(80, 24);
db.current_mut().set(0, 0, Cell::from_char('A'));
assert_eq!(db.current().get(0, 0).unwrap().content.as_char(), Some('A'));
db.swap();
assert_eq!(
db.previous().get(0, 0).unwrap().content.as_char(),
Some('A')
);
assert!(db.current().get(0, 0).unwrap().is_empty());
}
#[test]
fn double_buffer_swap_round_trip() {
let mut db = DoubleBuffer::new(10, 5);
db.current_mut().set(0, 0, Cell::from_char('X'));
db.swap();
db.current_mut().set(0, 0, Cell::from_char('Y'));
db.swap();
assert_eq!(db.current().get(0, 0).unwrap().content.as_char(), Some('X'));
assert_eq!(
db.previous().get(0, 0).unwrap().content.as_char(),
Some('Y')
);
}
#[test]
fn double_buffer_resize_changes_dimensions() {
let mut db = DoubleBuffer::new(80, 24);
assert!(!db.resize(80, 24)); assert!(db.resize(120, 40)); assert_eq!(db.width(), 120);
assert_eq!(db.height(), 40);
assert!(db.dimensions_match(120, 40));
}
#[test]
fn double_buffer_resize_clears_content() {
let mut db = DoubleBuffer::new(10, 5);
db.current_mut().set(0, 0, Cell::from_char('Z'));
db.swap();
db.current_mut().set(0, 0, Cell::from_char('W'));
db.resize(20, 10);
assert!(db.current().get(0, 0).unwrap().is_empty());
assert!(db.previous().get(0, 0).unwrap().is_empty());
}
#[test]
fn double_buffer_current_and_previous_are_distinct() {
let mut db = DoubleBuffer::new(10, 5);
db.current_mut().set(0, 0, Cell::from_char('C'));
assert!(db.previous().get(0, 0).unwrap().is_empty());
assert_eq!(db.current().get(0, 0).unwrap().content.as_char(), Some('C'));
}
#[test]
fn adaptive_buffer_new_has_over_allocation() {
let adb = AdaptiveDoubleBuffer::new(80, 24);
assert_eq!(adb.width(), 80);
assert_eq!(adb.height(), 24);
assert!(adb.dimensions_match(80, 24));
assert!(adb.capacity_width() > 80);
assert!(adb.capacity_height() > 24);
assert_eq!(adb.capacity_width(), 100); assert_eq!(adb.capacity_height(), 30); }
#[test]
fn adaptive_buffer_resize_avoids_reallocation_when_within_capacity() {
let mut adb = AdaptiveDoubleBuffer::new(80, 24);
assert!(adb.resize(90, 28)); assert_eq!(adb.width(), 90);
assert_eq!(adb.height(), 28);
assert_eq!(adb.stats().resize_avoided, 1);
assert_eq!(adb.stats().resize_reallocated, 0);
assert_eq!(adb.stats().resize_growth, 1);
}
#[test]
fn adaptive_buffer_resize_reallocates_on_growth_beyond_capacity() {
let mut adb = AdaptiveDoubleBuffer::new(80, 24);
assert!(adb.resize(120, 40)); assert_eq!(adb.width(), 120);
assert_eq!(adb.height(), 40);
assert_eq!(adb.stats().resize_reallocated, 1);
assert_eq!(adb.stats().resize_avoided, 0);
assert!(adb.capacity_width() > 120);
assert!(adb.capacity_height() > 40);
}
#[test]
fn adaptive_buffer_resize_reallocates_on_significant_shrink() {
let mut adb = AdaptiveDoubleBuffer::new(100, 50);
assert!(adb.resize(40, 20)); assert_eq!(adb.width(), 40);
assert_eq!(adb.height(), 20);
assert_eq!(adb.stats().resize_reallocated, 1);
assert_eq!(adb.stats().resize_shrink, 1);
}
#[test]
fn adaptive_buffer_resize_avoids_reallocation_on_minor_shrink() {
let mut adb = AdaptiveDoubleBuffer::new(100, 50);
assert!(adb.resize(80, 40));
assert_eq!(adb.width(), 80);
assert_eq!(adb.height(), 40);
assert_eq!(adb.stats().resize_avoided, 1);
assert_eq!(adb.stats().resize_reallocated, 0);
assert_eq!(adb.stats().resize_shrink, 1);
}
#[test]
fn adaptive_buffer_no_change_returns_false() {
let mut adb = AdaptiveDoubleBuffer::new(80, 24);
assert!(!adb.resize(80, 24)); assert_eq!(adb.stats().resize_avoided, 0);
assert_eq!(adb.stats().resize_reallocated, 0);
assert_eq!(adb.stats().resize_growth, 0);
assert_eq!(adb.stats().resize_shrink, 0);
}
#[test]
fn adaptive_buffer_swap_works() {
let mut adb = AdaptiveDoubleBuffer::new(10, 5);
adb.current_mut().set(0, 0, Cell::from_char('A'));
assert_eq!(
adb.current().get(0, 0).unwrap().content.as_char(),
Some('A')
);
adb.swap();
assert_eq!(
adb.previous().get(0, 0).unwrap().content.as_char(),
Some('A')
);
assert!(adb.current().get(0, 0).unwrap().is_empty());
}
#[test]
fn adaptive_buffer_stats_reset() {
let mut adb = AdaptiveDoubleBuffer::new(80, 24);
adb.resize(90, 28);
adb.resize(120, 40);
assert!(adb.stats().resize_avoided > 0 || adb.stats().resize_reallocated > 0);
adb.reset_stats();
assert_eq!(adb.stats().resize_avoided, 0);
assert_eq!(adb.stats().resize_reallocated, 0);
assert_eq!(adb.stats().resize_growth, 0);
assert_eq!(adb.stats().resize_shrink, 0);
}
#[test]
fn adaptive_buffer_memory_efficiency() {
let adb = AdaptiveDoubleBuffer::new(80, 24);
let efficiency = adb.memory_efficiency();
assert!(efficiency > 0.5);
assert!(efficiency < 1.0);
}
#[test]
fn adaptive_buffer_logical_bounds() {
let adb = AdaptiveDoubleBuffer::new(80, 24);
let bounds = adb.logical_bounds();
assert_eq!(bounds.x, 0);
assert_eq!(bounds.y, 0);
assert_eq!(bounds.width, 80);
assert_eq!(bounds.height, 24);
}
#[test]
fn adaptive_buffer_capacity_clamped_for_large_sizes() {
let adb = AdaptiveDoubleBuffer::new(1000, 500);
assert_eq!(adb.capacity_width(), 1000 + 200); assert_eq!(adb.capacity_height(), 500 + 125); }
#[test]
fn adaptive_stats_avoidance_ratio() {
let mut stats = AdaptiveStats::default();
assert!((stats.avoidance_ratio() - 1.0).abs() < f64::EPSILON);
stats.resize_avoided = 3;
stats.resize_reallocated = 1;
assert!((stats.avoidance_ratio() - 0.75).abs() < f64::EPSILON);
stats.resize_avoided = 0;
stats.resize_reallocated = 5;
assert!((stats.avoidance_ratio() - 0.0).abs() < f64::EPSILON);
}
#[test]
fn adaptive_buffer_resize_storm_simulation() {
let mut adb = AdaptiveDoubleBuffer::new(80, 24);
for i in 1..=10 {
adb.resize(80 + i, 24 + (i / 2));
}
let ratio = adb.stats().avoidance_ratio();
assert!(
ratio > 0.5,
"Expected >50% avoidance ratio, got {:.2}",
ratio
);
}
#[test]
fn adaptive_buffer_width_only_growth() {
let mut adb = AdaptiveDoubleBuffer::new(80, 24);
assert!(adb.resize(95, 24)); assert_eq!(adb.stats().resize_avoided, 1);
assert_eq!(adb.stats().resize_growth, 1);
}
#[test]
fn adaptive_buffer_height_only_growth() {
let mut adb = AdaptiveDoubleBuffer::new(80, 24);
assert!(adb.resize(80, 28)); assert_eq!(adb.stats().resize_avoided, 1);
assert_eq!(adb.stats().resize_growth, 1);
}
#[test]
fn adaptive_buffer_one_dimension_exceeds_capacity() {
let mut adb = AdaptiveDoubleBuffer::new(80, 24);
assert!(adb.resize(105, 24)); assert_eq!(adb.stats().resize_reallocated, 1);
}
#[test]
fn adaptive_buffer_current_and_previous_distinct() {
let mut adb = AdaptiveDoubleBuffer::new(10, 5);
adb.current_mut().set(0, 0, Cell::from_char('X'));
assert!(adb.previous().get(0, 0).unwrap().is_empty());
assert_eq!(
adb.current().get(0, 0).unwrap().content.as_char(),
Some('X')
);
}
#[test]
fn adaptive_buffer_resize_within_capacity_clears_previous() {
let mut adb = AdaptiveDoubleBuffer::new(10, 5);
adb.current_mut().set(9, 4, Cell::from_char('X'));
adb.swap();
assert!(adb.resize(8, 4));
assert!(adb.previous().get(9, 4).unwrap().is_empty());
}
#[test]
fn adaptive_buffer_invariant_capacity_geq_logical() {
for width in [1u16, 10, 80, 200, 1000, 5000] {
for height in [1u16, 10, 24, 100, 500, 2000] {
let adb = AdaptiveDoubleBuffer::new(width, height);
assert!(
adb.capacity_width() >= adb.width(),
"capacity_width {} < logical_width {} for ({}, {})",
adb.capacity_width(),
adb.width(),
width,
height
);
assert!(
adb.capacity_height() >= adb.height(),
"capacity_height {} < logical_height {} for ({}, {})",
adb.capacity_height(),
adb.height(),
width,
height
);
}
}
}
#[test]
fn adaptive_buffer_invariant_resize_dimensions_correct() {
let mut adb = AdaptiveDoubleBuffer::new(80, 24);
let test_sizes = [
(100, 50),
(40, 20),
(80, 24),
(200, 100),
(10, 5),
(1000, 500),
];
for (w, h) in test_sizes {
adb.resize(w, h);
assert_eq!(adb.width(), w, "width mismatch for ({}, {})", w, h);
assert_eq!(adb.height(), h, "height mismatch for ({}, {})", w, h);
assert!(
adb.capacity_width() >= w,
"capacity_width < width for ({}, {})",
w,
h
);
assert!(
adb.capacity_height() >= h,
"capacity_height < height for ({}, {})",
w,
h
);
}
}
#[test]
fn adaptive_buffer_no_ghosting_on_shrink() {
let mut adb = AdaptiveDoubleBuffer::new(80, 24);
for y in 0..adb.height() {
for x in 0..adb.width() {
adb.current_mut().set(x, y, Cell::from_char('X'));
}
}
adb.resize(60, 20);
for y in 0..adb.height() {
for x in 0..adb.width() {
let cell = adb.current().get(x, y).unwrap();
assert!(
cell.is_empty(),
"Ghost content at ({}, {}): expected empty, got {:?}",
x,
y,
cell.content
);
}
}
}
#[test]
fn adaptive_buffer_no_ghosting_on_reallocation_shrink() {
let mut adb = AdaptiveDoubleBuffer::new(100, 50);
for y in 0..adb.height() {
for x in 0..adb.width() {
adb.current_mut().set(x, y, Cell::from_char('A'));
}
}
adb.swap();
for y in 0..adb.height() {
for x in 0..adb.width() {
adb.current_mut().set(x, y, Cell::from_char('B'));
}
}
adb.resize(30, 15);
assert_eq!(adb.stats().resize_reallocated, 1);
for y in 0..adb.height() {
for x in 0..adb.width() {
assert!(
adb.current().get(x, y).unwrap().is_empty(),
"Ghost in current at ({}, {})",
x,
y
);
assert!(
adb.previous().get(x, y).unwrap().is_empty(),
"Ghost in previous at ({}, {})",
x,
y
);
}
}
}
#[test]
fn adaptive_buffer_no_ghosting_on_growth_reallocation() {
let mut adb = AdaptiveDoubleBuffer::new(80, 24);
for y in 0..adb.height() {
for x in 0..adb.width() {
adb.current_mut().set(x, y, Cell::from_char('Z'));
}
}
adb.resize(150, 60);
assert_eq!(adb.stats().resize_reallocated, 1);
for y in 0..adb.height() {
for x in 0..adb.width() {
assert!(
adb.current().get(x, y).unwrap().is_empty(),
"Ghost at ({}, {}) after growth reallocation",
x,
y
);
}
}
}
#[test]
fn adaptive_buffer_resize_idempotent() {
let mut adb = AdaptiveDoubleBuffer::new(80, 24);
adb.current_mut().set(5, 5, Cell::from_char('K'));
let changed = adb.resize(80, 24);
assert!(!changed);
assert_eq!(
adb.current().get(5, 5).unwrap().content.as_char(),
Some('K')
);
}
#[test]
fn dirty_span_merge_adjacent() {
let mut buf = Buffer::new(100, 1);
buf.clear_dirty();
buf.mark_dirty_span(0, 10, 20);
let spans = buf.dirty_span_row(0).unwrap().spans();
assert_eq!(spans.len(), 1);
assert_eq!(spans[0], DirtySpan::new(10, 20));
buf.mark_dirty_span(0, 20, 30);
let spans = buf.dirty_span_row(0).unwrap().spans();
assert_eq!(spans.len(), 1);
assert_eq!(spans[0], DirtySpan::new(10, 30));
}
#[test]
fn dirty_span_merge_overlapping() {
let mut buf = Buffer::new(100, 1);
buf.clear_dirty();
buf.mark_dirty_span(0, 10, 20);
buf.mark_dirty_span(0, 15, 25);
let spans = buf.dirty_span_row(0).unwrap().spans();
assert_eq!(spans.len(), 1);
assert_eq!(spans[0], DirtySpan::new(10, 25));
}
#[test]
fn dirty_span_merge_with_gap() {
let mut buf = Buffer::new(100, 1);
buf.clear_dirty();
buf.mark_dirty_span(0, 10, 20);
buf.mark_dirty_span(0, 21, 30);
let spans = buf.dirty_span_row(0).unwrap().spans();
assert_eq!(spans.len(), 1);
assert_eq!(spans[0], DirtySpan::new(10, 30));
}
#[test]
fn dirty_span_no_merge_large_gap() {
let mut buf = Buffer::new(100, 1);
buf.clear_dirty();
buf.mark_dirty_span(0, 10, 20);
buf.mark_dirty_span(0, 22, 30);
let spans = buf.dirty_span_row(0).unwrap().spans();
assert_eq!(spans.len(), 2);
assert_eq!(spans[0], DirtySpan::new(10, 20));
assert_eq!(spans[1], DirtySpan::new(22, 30));
}
#[test]
fn dirty_span_overflow_to_full() {
let mut buf = Buffer::new(1000, 1);
buf.clear_dirty();
for i in 0..DIRTY_SPAN_MAX_SPANS_PER_ROW + 10 {
let start = (i * 4) as u16;
buf.mark_dirty_span(0, start, start + 1);
}
let row = buf.dirty_span_row(0).unwrap();
assert!(row.is_full(), "Row should overflow to full scan");
assert!(
row.spans().is_empty(),
"Spans should be cleared on overflow"
);
}
#[test]
fn dirty_span_bounds_clamping() {
let mut buf = Buffer::new(10, 1);
buf.clear_dirty();
buf.mark_dirty_span(0, 15, 20);
let spans = buf.dirty_span_row(0).unwrap().spans();
assert!(spans.is_empty());
buf.mark_dirty_span(0, 8, 15);
let spans = buf.dirty_span_row(0).unwrap().spans();
assert_eq!(spans.len(), 1);
assert_eq!(spans[0], DirtySpan::new(8, 10)); }
#[test]
fn dirty_span_guard_band_clamps_bounds() {
let mut buf = Buffer::new(10, 1);
buf.clear_dirty();
buf.set_dirty_span_config(DirtySpanConfig::default().with_guard_band(5));
buf.mark_dirty_span(0, 2, 3);
let spans = buf.dirty_span_row(0).unwrap().spans();
assert_eq!(spans.len(), 1);
assert_eq!(spans[0], DirtySpan::new(0, 8));
buf.clear_dirty();
buf.mark_dirty_span(0, 8, 10);
let spans = buf.dirty_span_row(0).unwrap().spans();
assert_eq!(spans.len(), 1);
assert_eq!(spans[0], DirtySpan::new(3, 10));
}
#[test]
fn dirty_span_empty_span_is_ignored() {
let mut buf = Buffer::new(10, 1);
buf.clear_dirty();
buf.mark_dirty_span(0, 5, 5);
let spans = buf.dirty_span_row(0).unwrap().spans();
assert!(spans.is_empty());
}
#[test]
fn buffer_fill_wide_char_clipping() {
let mut buf = Buffer::new(10, 5);
let wide_cell = Cell::from_char('🦀');
buf.fill(Rect::new(0, 0, 10, 5), wide_cell);
let head = buf.get(0, 0).unwrap();
assert_eq!(head.content.as_char(), Some('🦀'));
assert_eq!(head.content.width(), 2);
let tail = buf.get(1, 0).unwrap();
assert!(tail.is_continuation());
buf.push_scissor(Rect::new(0, 0, 1, 5));
let x_cell = Cell::from_char('X');
buf.fill(Rect::new(0, 0, 10, 5), x_cell);
assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('X'));
assert!(buf.get(1, 0).unwrap().is_empty());
buf.pop_scissor();
}
}