use std::fmt;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use crate::cell::{Cell, normalize_cell_symbol};
use crate::rect::Rect;
use crate::style::Style;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
pub const MAX_BUFFER_CELLS: usize = 1_048_576;
pub const MAX_BUFFER_ROWS: usize = MAX_BUFFER_CELLS;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BufferError {
InvalidEdges,
CellBudgetExceeded {
requested: u64,
maximum: usize,
},
RowBudgetExceeded {
requested: u32,
maximum: usize,
},
AllocationFailed,
}
impl fmt::Display for BufferError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidEdges => write!(f, "buffer rectangle edges overflow u32 coordinates"),
Self::CellBudgetExceeded { requested, maximum } => write!(
f,
"buffer requires {requested} cells, exceeding the {maximum}-cell budget"
),
Self::RowBudgetExceeded { requested, maximum } => write!(
f,
"buffer requires {requested} rows, exceeding the {maximum}-row budget"
),
Self::AllocationFailed => write!(f, "buffer allocation failed within the cell budget"),
}
}
}
impl std::error::Error for BufferError {}
pub(crate) const MAX_IMAGE_PIXELS: u64 = 16_777_216;
#[cfg(feature = "bidi")]
#[inline]
pub(crate) fn needs_bidi_reorder(s: &str) -> bool {
use unicode_bidi::BidiClass::{AL, FSI, LRE, LRI, LRO, PDF, PDI, R, RLE, RLI, RLO};
s.chars().any(|ch| {
matches!(
unicode_bidi::bidi_class(ch),
R | AL | RLE | RLO | RLI | LRE | LRO | LRI | FSI | PDI | PDF
)
})
}
#[cfg(feature = "bidi")]
fn reorder_line_visual(s: &str) -> String {
use unicode_bidi::BidiInfo;
let info = BidiInfo::new(s, None);
let Some(para) = info.paragraphs.first() else {
return s.to_string();
};
let resolved = info.reordered_levels(para, para.range.clone());
let graphemes: Vec<(usize, &str)> = s.grapheme_indices(true).collect();
let levels: Vec<_> = graphemes.iter().map(|(byte, _)| resolved[*byte]).collect();
let visual_to_logical = BidiInfo::reorder_visual(&levels);
let mut reordered = String::with_capacity(s.len());
for logical in visual_to_logical {
reordered.push_str(graphemes[logical].1);
}
reordered
}
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub(crate) struct KittyPlacement {
pub content_hash: u64,
pub rgba: Arc<Vec<u8>>,
pub src_width: u32,
pub src_height: u32,
pub x: u32,
pub y: u32,
pub cols: u32,
pub rows: u32,
pub crop_y: u32,
pub crop_h: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[allow(dead_code)]
pub(crate) enum SprixelCell {
Opaque,
Mixed,
Transparent,
Annihilated,
}
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub(crate) struct SprixelPlacement {
pub content_hash: u64,
pub seq: String,
pub x: u32,
pub y: u32,
pub cols: u32,
pub rows: u32,
pub cells: Vec<SprixelCell>,
}
impl PartialEq for SprixelPlacement {
fn eq(&self, other: &Self) -> bool {
self.content_hash == other.content_hash
&& self.x == other.x
&& self.y == other.y
&& self.cols == other.cols
&& self.rows == other.rows
}
}
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
pub(crate) struct Fnv1a(u64);
impl Default for Fnv1a {
#[inline]
fn default() -> Self {
Self(FNV_OFFSET_BASIS)
}
}
impl Hasher for Fnv1a {
#[inline]
fn finish(&self) -> u64 {
self.0
}
#[inline]
fn write(&mut self, bytes: &[u8]) {
let mut hash = self.0;
for &byte in bytes {
hash ^= byte as u64;
hash = hash.wrapping_mul(FNV_PRIME);
}
self.0 = hash;
}
}
pub(crate) fn hash_rgba(data: &[u8]) -> u64 {
let mut hasher = Fnv1a::default();
data.hash(&mut hasher);
hasher.finish()
}
fn crop_kitty_horizontal(placement: &mut KittyPlacement, info: KittyHorizontalClipInfo) -> bool {
if info.original_width == 0 || placement.src_width == 0 || placement.src_height == 0 {
return false;
}
let visible_start = info.left_clip_cols.min(info.original_width);
let visible_end = visible_start
.saturating_add(placement.cols)
.min(info.original_width);
if visible_start >= visible_end {
return false;
}
let source_width = u64::from(placement.src_width);
let original_width = u64::from(info.original_width);
let start_pixel = source_width.saturating_mul(u64::from(visible_start)) / original_width;
let scaled_end = source_width.saturating_mul(u64::from(visible_end));
let end_pixel = scaled_end
.saturating_add(original_width.saturating_sub(1))
.checked_div(original_width)
.unwrap_or(0)
.min(source_width);
let crop_width = end_pixel.saturating_sub(start_pixel);
if crop_width == 0 {
return false;
}
if start_pixel == 0 && crop_width == source_width {
return true;
}
let Some(source_stride) = usize::try_from(source_width)
.ok()
.and_then(|width| width.checked_mul(4))
else {
return false;
};
let Some(crop_stride) = usize::try_from(crop_width)
.ok()
.and_then(|width| width.checked_mul(4))
else {
return false;
};
let Some(expected_source) = source_stride.checked_mul(placement.src_height as usize) else {
return false;
};
if placement.rgba.len() < expected_source {
return false;
}
let Some(cropped_len) = crop_stride.checked_mul(placement.src_height as usize) else {
return false;
};
let mut cropped = Vec::new();
if cropped.try_reserve_exact(cropped_len).is_err() {
return false;
}
let start_byte = start_pixel as usize * 4;
for row in 0..placement.src_height as usize {
let row_start = row * source_stride + start_byte;
cropped.extend_from_slice(&placement.rgba[row_start..row_start + crop_stride]);
}
placement.src_width = crop_width as u32;
placement.content_hash = hash_rgba(&cropped);
placement.rgba = Arc::new(cropped);
true
}
impl PartialEq for KittyPlacement {
fn eq(&self, other: &Self) -> bool {
self.content_hash == other.content_hash
&& self.x == other.x
&& self.y == other.y
&& self.cols == other.cols
&& self.rows == other.rows
&& self.crop_y == other.crop_y
&& self.crop_h == other.crop_h
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct KittyClipInfo {
pub top_clip_rows: u32,
pub original_height: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct KittyHorizontalClipInfo {
pub left_clip_cols: u32,
pub original_width: u32,
}
pub struct Buffer {
pub area: Rect,
pub content: Vec<Cell>,
pub(crate) clip_stack: Vec<Rect>,
pub(crate) raw_sequences: Vec<(u32, u32, String)>,
pub(crate) sprixels: Vec<SprixelPlacement>,
pub(crate) kitty_placements: Vec<KittyPlacement>,
pub(crate) cursor_pos: Option<(u32, u32)>,
pub(crate) kitty_clip_info_stack: Vec<KittyClipInfo>,
pub(crate) kitty_horizontal_clip_stack: Vec<KittyHorizontalClipInfo>,
pub(crate) line_hashes: Vec<u64>,
pub(crate) line_dirty: Vec<bool>,
}
fn checked_buffer_dimensions(area: Rect) -> Result<(usize, usize), BufferError> {
if !area.has_valid_edges() {
return Err(BufferError::InvalidEdges);
}
let requested = area.area_u64();
if requested > MAX_BUFFER_CELLS as u64 {
return Err(BufferError::CellBudgetExceeded {
requested,
maximum: MAX_BUFFER_CELLS,
});
}
if u64::from(area.height) > MAX_BUFFER_ROWS as u64 {
return Err(BufferError::RowBudgetExceeded {
requested: area.height,
maximum: MAX_BUFFER_ROWS,
});
}
let cells = usize::try_from(requested).map_err(|_| BufferError::CellBudgetExceeded {
requested,
maximum: MAX_BUFFER_CELLS,
})?;
let rows = usize::try_from(area.height).map_err(|_| BufferError::CellBudgetExceeded {
requested,
maximum: MAX_BUFFER_CELLS,
})?;
Ok((cells, rows))
}
fn try_repeated<T: Clone>(value: T, len: usize) -> Result<Vec<T>, BufferError> {
let mut values = Vec::new();
values
.try_reserve_exact(len)
.map_err(|_| BufferError::AllocationFailed)?;
values.resize(len, value);
Ok(values)
}
fn trim_excess_capacity<T>(values: &mut Vec<T>) {
const RETAIN_FACTOR: usize = 4;
const HEADROOM_FACTOR: usize = 2;
if values.capacity() > values.len().saturating_mul(RETAIN_FACTOR) {
values.shrink_to(values.len().saturating_mul(HEADROOM_FACTOR));
}
}
impl Buffer {
pub fn validate_area(area: Rect) -> Result<(), BufferError> {
checked_buffer_dimensions(area).map(|_| ())
}
pub fn empty(area: Rect) -> Self {
Self::try_empty(area)
.unwrap_or_else(|error| panic!("Buffer::empty({area:?}) failed: {error}"))
}
pub fn try_empty(area: Rect) -> Result<Self, BufferError> {
let (size, height) = checked_buffer_dimensions(area)?;
Ok(Self {
area,
content: try_repeated(Cell::default(), size)?,
clip_stack: Vec::new(),
raw_sequences: Vec::new(),
sprixels: Vec::new(),
kitty_placements: Vec::new(),
cursor_pos: None,
kitty_clip_info_stack: Vec::new(),
kitty_horizontal_clip_stack: Vec::new(),
line_hashes: try_repeated(0, height)?,
line_dirty: try_repeated(true, height)?,
})
}
pub(crate) fn push_kitty_clip(&mut self, info: KittyClipInfo) {
self.kitty_clip_info_stack.push(info);
}
#[cfg(test)]
pub(crate) fn pop_kitty_clip(&mut self) -> Option<KittyClipInfo> {
self.kitty_clip_info_stack.pop()
}
pub(crate) fn current_kitty_clip(&self) -> Option<&KittyClipInfo> {
self.kitty_clip_info_stack.last()
}
#[allow(dead_code)] pub(crate) fn push_kitty_horizontal_clip(&mut self, info: KittyHorizontalClipInfo) {
self.kitty_horizontal_clip_stack.push(info);
}
#[allow(dead_code)] pub(crate) fn pop_kitty_horizontal_clip(&mut self) -> Option<KittyHorizontalClipInfo> {
self.kitty_horizontal_clip_stack.pop()
}
fn current_kitty_horizontal_clip(&self) -> Option<&KittyHorizontalClipInfo> {
self.kitty_horizontal_clip_stack.last()
}
pub(crate) fn set_cursor_pos(&mut self, x: u32, y: u32) {
self.cursor_pos = Some((x, y));
}
#[cfg(feature = "crossterm")]
pub(crate) fn cursor_pos(&self) -> Option<(u32, u32)> {
self.cursor_pos
}
pub fn raw_sequence(&mut self, x: u32, y: u32, seq: String) {
if let Some(clip) = self.effective_clip()
&& (x >= clip.right() || y >= clip.bottom())
{
return;
}
self.raw_sequences.push((x, y, seq));
}
pub(crate) fn kitty_place(&mut self, mut p: KittyPlacement) {
if let Some(clip) = self.effective_clip()
&& (p.x >= clip.right()
|| p.y >= clip.bottom()
|| p.x.saturating_add(p.cols) <= clip.x
|| p.y.saturating_add(p.rows) <= clip.y)
{
return;
}
if let Some(info) = self.current_kitty_horizontal_clip().copied()
&& !crop_kitty_horizontal(&mut p, info)
{
return;
}
if let Some(info) = self.current_kitty_clip() {
let top_clip_rows = info.top_clip_rows;
let original_height = info.original_height;
if original_height > 0 && (top_clip_rows > 0 || p.rows < original_height) {
let ratio = p.src_height as f64 / original_height as f64;
p.crop_y = (top_clip_rows as f64 * ratio) as u32;
let bottom_clip =
original_height.saturating_sub(top_clip_rows.saturating_add(p.rows));
let bottom_pixels = (bottom_clip as f64 * ratio) as u32;
p.crop_h = p
.src_height
.saturating_sub(p.crop_y.saturating_add(bottom_pixels));
}
}
self.kitty_placements.push(p);
}
#[cfg_attr(not(feature = "crossterm"), allow(dead_code))]
pub(crate) fn sprixel_place(&mut self, p: SprixelPlacement) {
if let Some(clip) = self.effective_clip()
&& (p.x >= clip.right()
|| p.y >= clip.bottom()
|| p.x.saturating_add(p.cols) <= clip.x
|| p.y.saturating_add(p.rows) <= clip.y)
{
return;
}
self.sprixels.push(p);
}
pub fn push_clip(&mut self, rect: Rect) {
let effective = if let Some(current) = self.clip_stack.last() {
intersect_rects(*current, rect)
} else {
rect
};
self.clip_stack.push(effective);
}
pub fn pop_clip(&mut self) {
self.clip_stack.pop();
}
fn effective_clip(&self) -> Option<&Rect> {
self.clip_stack.last()
}
#[inline]
fn index_of(&self, x: u32, y: u32) -> usize {
((y - self.area.y) * self.area.width + (x - self.area.x)) as usize
}
#[inline]
pub fn in_bounds(&self, x: u32, y: u32) -> bool {
x >= self.area.x && x < self.area.right() && y >= self.area.y && y < self.area.bottom()
}
#[inline]
pub fn get(&self, x: u32, y: u32) -> &Cell {
assert!(
self.in_bounds(x, y),
"Buffer::get({x}, {y}) out of bounds for area {:?}",
self.area
);
&self.content[self.index_of(x, y)]
}
#[inline]
pub fn get_mut(&mut self, x: u32, y: u32) -> &mut Cell {
assert!(
self.in_bounds(x, y),
"Buffer::get_mut({x}, {y}) out of bounds for area {:?}",
self.area
);
let idx = self.index_of(x, y);
self.mark_row_dirty(y);
&mut self.content[idx]
}
#[inline]
pub fn try_get(&self, x: u32, y: u32) -> Option<&Cell> {
if self.in_bounds(x, y) {
Some(&self.content[self.index_of(x, y)])
} else {
None
}
}
#[inline]
pub fn try_get_mut(&mut self, x: u32, y: u32) -> Option<&mut Cell> {
if self.in_bounds(x, y) {
let idx = self.index_of(x, y);
self.mark_row_dirty(y);
Some(&mut self.content[idx])
} else {
None
}
}
pub fn set_string(&mut self, x: u32, y: u32, s: &str, style: Style) {
self.set_string_inner(x, y, s, style, None);
}
pub fn set_string_linked(&mut self, x: u32, y: u32, s: &str, style: Style, url: &str) {
let link = sanitize_osc8_url(url).map(compact_str::CompactString::new);
self.set_string_inner(x, y, s, style, link.as_ref());
}
fn set_string_inner(
&mut self,
mut x: u32,
y: u32,
s: &str,
style: Style,
link: Option<&compact_str::CompactString>,
) {
if y < self.area.y || y >= self.area.bottom() {
return;
}
#[cfg(feature = "bidi")]
let reordered;
#[cfg(feature = "bidi")]
let s: &str = if needs_bidi_reorder(s) {
reordered = reorder_line_visual(s);
&reordered
} else {
s
};
let clip = self.effective_clip().copied();
for grapheme in s.graphemes(true) {
if x >= self.area.right() {
break;
}
let width = self.set_grapheme_visual_inner(x, y, grapheme, style, link, clip);
x = x.saturating_add(width);
}
}
pub(crate) fn set_grapheme_visual(
&mut self,
x: u32,
y: u32,
grapheme: &str,
style: Style,
link: Option<&compact_str::CompactString>,
) -> u32 {
let clip = self.effective_clip().copied();
self.set_grapheme_visual_inner(x, y, grapheme, style, link, clip)
}
fn set_grapheme_visual_inner(
&mut self,
x: u32,
y: u32,
grapheme: &str,
style: Style,
link: Option<&compact_str::CompactString>,
clip: Option<Rect>,
) -> u32 {
let symbol = normalize_cell_symbol(grapheme);
let width = UnicodeWidthStr::width(symbol.as_str()) as u32;
if width == 0 {
self.append_zero_width(x, y, &symbol, clip);
return 0;
}
let Some(target_right) = x.checked_add(width) else {
return width;
};
if y < self.area.y
|| y >= self.area.bottom()
|| x < self.area.x
|| target_right > self.area.right()
{
return width;
}
let (mut affected_left, mut affected_right) = (x, target_right);
for col in x..target_right {
let (old_left, old_right) = self.existing_grapheme_range(col, y);
affected_left = affected_left.min(old_left);
affected_right = affected_right.max(old_right);
}
if affected_left < self.area.x || affected_right > self.area.right() {
return width;
}
let fully_in_clip = clip.is_none_or(|clip| {
y >= clip.y
&& y < clip.bottom()
&& affected_left >= clip.x
&& affected_right <= clip.right()
});
if !fully_in_clip {
return width;
}
self.mark_row_dirty(y);
for col in affected_left..affected_right {
let idx = self.index_of(col, y);
self.content[idx].reset();
}
let leading_idx = self.index_of(x, y);
let leading = &mut self.content[leading_idx];
leading.set_symbol(&symbol);
leading.set_style(style);
leading.hyperlink = link.cloned();
for col in x.saturating_add(1)..target_right {
let idx = self.index_of(col, y);
self.content[idx].set_continuation(style);
self.content[idx].hyperlink = link.cloned();
}
width
}
fn existing_grapheme_range(&self, x: u32, y: u32) -> (u32, u32) {
let mut left = x;
if self.content[self.index_of(x, y)].is_continuation() && x > self.area.x {
left = x - 1;
}
let symbol = self.content[self.index_of(left, y)].normalized_symbol();
let width = (UnicodeWidthStr::width(symbol.as_str()) as u32).max(1);
(left, left.saturating_add(width).min(self.area.right()))
}
fn append_zero_width(&mut self, x: u32, y: u32, suffix: &str, clip: Option<Rect>) {
if suffix.is_empty() || y < self.area.y || y >= self.area.bottom() || x <= self.area.x {
return;
}
let mut leading_x = x.saturating_sub(1).min(self.area.right().saturating_sub(1));
if self.content[self.index_of(leading_x, y)].is_continuation() && leading_x > self.area.x {
leading_x -= 1;
}
if clip.is_some_and(|clip| !clip.contains(leading_x, y)) {
return;
}
let idx = self.index_of(leading_x, y);
let mut combined = self.content[idx].normalized_symbol();
combined.push_str(suffix);
let normalized = normalize_cell_symbol(&combined);
if normalized != self.content[idx].symbol {
self.mark_row_dirty(y);
self.content[idx].symbol = normalized;
}
}
pub fn set_char(&mut self, x: u32, y: u32, ch: char, style: Style) {
let mut encoded = [0; 4];
self.set_grapheme_visual(x, y, ch.encode_utf8(&mut encoded), style, None);
}
#[inline]
pub(crate) fn mark_row_dirty(&mut self, y: u32) {
if y < self.area.y {
return;
}
let idx = (y - self.area.y) as usize;
if let Some(slot) = self.line_dirty.get_mut(idx) {
*slot = true;
}
}
#[cfg(any(feature = "crossterm", test))]
pub(crate) fn recompute_line_hashes(&mut self) {
let height = self.area.height;
if height == 0 {
return;
}
let expected_len = height as usize;
if self.line_hashes.len() != expected_len {
self.line_hashes.resize(expected_len, 0);
}
if self.line_dirty.len() != expected_len {
self.line_dirty.resize(expected_len, true);
}
let width = self.area.width as usize;
for (idx, dirty) in self.line_dirty.iter_mut().enumerate() {
if !*dirty {
continue;
}
let row_start = idx * width;
let row_end = row_start + width;
let mut hasher = Fnv1a::default();
for cell in &self.content[row_start..row_end] {
cell.symbol.as_str().hash(&mut hasher);
cell.style.hash(&mut hasher);
cell.hyperlink.as_deref().hash(&mut hasher);
}
self.line_hashes[idx] = hasher.finish();
*dirty = false;
}
}
#[inline]
#[cfg(any(feature = "crossterm", test))]
pub(crate) fn row_clean(&self, y: u32) -> bool {
if y < self.area.y {
return false;
}
let idx = (y - self.area.y) as usize;
self.line_dirty
.get(idx)
.copied()
.map(|d| !d)
.unwrap_or(false)
}
#[inline]
#[cfg(any(feature = "crossterm", test))]
pub(crate) fn row_hash(&self, y: u32) -> Option<u64> {
if y < self.area.y {
return None;
}
let idx = (y - self.area.y) as usize;
self.line_hashes.get(idx).copied()
}
pub fn diff<'a>(&'a self, other: &'a Buffer) -> Vec<(u32, u32, &'a Cell)> {
let Some(expected) = usize::try_from(self.area.area_u64()).ok() else {
return Vec::new();
};
let len = self.content.len().min(expected);
if self.area.width == 0 || len == 0 {
return Vec::new();
}
let same_geometry = self.area == other.area
&& self.content.len() == expected
&& other.content.len() == expected;
let mut updates = Vec::new();
for (index, cell) in self.content[..len].iter().enumerate() {
let changed = !same_geometry || other.content.get(index) != Some(cell);
if !changed {
continue;
}
let row = index / self.area.width as usize;
let col = index % self.area.width as usize;
let x = self.area.x.saturating_add(col as u32);
let y = self.area.y.saturating_add(row as u32);
updates.push((x, y, cell));
}
updates
}
pub fn reset(&mut self) {
for cell in &mut self.content {
cell.reset();
}
self.clip_stack.clear();
self.raw_sequences.clear();
self.sprixels.clear();
self.kitty_placements.clear();
self.cursor_pos = None;
self.kitty_clip_info_stack.clear();
self.kitty_horizontal_clip_stack.clear();
self.line_dirty.fill(true);
}
pub fn reset_with_bg(&mut self, bg: crate::style::Color) {
for cell in &mut self.content {
cell.reset();
cell.style.bg = Some(bg);
}
self.clip_stack.clear();
self.raw_sequences.clear();
self.sprixels.clear();
self.kitty_placements.clear();
self.cursor_pos = None;
self.kitty_clip_info_stack.clear();
self.kitty_horizontal_clip_stack.clear();
self.line_dirty.fill(true);
}
pub fn resize(&mut self, area: Rect) {
self.try_resize(area)
.unwrap_or_else(|error| panic!("Buffer::resize({area:?}) failed: {error}"));
}
pub fn try_resize(&mut self, area: Rect) -> Result<(), BufferError> {
let (size, height) = checked_buffer_dimensions(area)?;
self.content
.try_reserve_exact(size.saturating_sub(self.content.len()))
.map_err(|_| BufferError::AllocationFailed)?;
self.line_hashes
.try_reserve_exact(height.saturating_sub(self.line_hashes.len()))
.map_err(|_| BufferError::AllocationFailed)?;
self.line_dirty
.try_reserve_exact(height.saturating_sub(self.line_dirty.len()))
.map_err(|_| BufferError::AllocationFailed)?;
self.area = area;
self.content.resize(size, Cell::default());
self.line_hashes.resize(height, 0);
self.line_dirty.resize(height, true);
self.reset();
trim_excess_capacity(&mut self.content);
trim_excess_capacity(&mut self.line_hashes);
trim_excess_capacity(&mut self.line_dirty);
Ok(())
}
pub fn snapshot_format(&self) -> String {
let mut out = String::new();
let width = self.area.width;
let height = self.area.height;
if width == 0 || height == 0 {
return out;
}
for y in self.area.y..self.area.bottom() {
if y > self.area.y {
out.push('\n');
}
let mut current_style: Option<Style> = None;
let mut run_text = String::new();
for x in self.area.x..self.area.right() {
let cell = self.get(x, y);
let style = cell.style;
let sym: &str = if cell.symbol.is_empty() {
" "
} else {
cell.symbol.as_str()
};
match current_style {
Some(s) if s == style => {
run_text.push_str(sym);
}
_ => {
if let Some(s) = current_style.take() {
flush_run(&mut out, s, &run_text);
run_text.clear();
}
current_style = Some(style);
run_text.push_str(sym);
}
}
}
if let Some(s) = current_style {
flush_run(&mut out, s, &run_text);
}
}
out
}
}
fn flush_run(out: &mut String, style: Style, text: &str) {
if style == Style::default() {
out.push_str(text);
return;
}
out.push('[');
let mut first = true;
if let Some(fg) = style.fg {
out.push_str("fg=");
write_color(out, fg);
first = false;
}
if let Some(bg) = style.bg {
if !first {
out.push(',');
}
out.push_str("bg=");
write_color(out, bg);
first = false;
}
let mods = style.modifiers;
let pairs: [(crate::style::Modifiers, &str); 6] = [
(crate::style::Modifiers::BOLD, "bold"),
(crate::style::Modifiers::DIM, "dim"),
(crate::style::Modifiers::ITALIC, "italic"),
(crate::style::Modifiers::UNDERLINE, "underline"),
(crate::style::Modifiers::REVERSED, "reversed"),
(crate::style::Modifiers::STRIKETHROUGH, "strikethrough"),
];
for (bit, name) in pairs {
if mods.contains(bit) {
if !first {
out.push(',');
}
out.push_str(name);
first = false;
}
}
out.push(']');
out.push('"');
for ch in text.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
other => out.push(other),
}
}
out.push('"');
out.push_str("[/]");
}
fn write_color(out: &mut String, color: crate::style::Color) {
use crate::style::Color;
match color {
Color::Reset => out.push_str("reset"),
Color::Black => out.push_str("black"),
Color::Red => out.push_str("red"),
Color::Green => out.push_str("green"),
Color::Yellow => out.push_str("yellow"),
Color::Blue => out.push_str("blue"),
Color::Magenta => out.push_str("magenta"),
Color::Cyan => out.push_str("cyan"),
Color::White => out.push_str("white"),
Color::DarkGray => out.push_str("dark_gray"),
Color::LightRed => out.push_str("light_red"),
Color::LightGreen => out.push_str("light_green"),
Color::LightYellow => out.push_str("light_yellow"),
Color::LightBlue => out.push_str("light_blue"),
Color::LightMagenta => out.push_str("light_magenta"),
Color::LightCyan => out.push_str("light_cyan"),
Color::LightWhite => out.push_str("light_white"),
Color::Rgb(r, g, b) => {
use std::fmt::Write;
let _ = write!(out, "#{r:02x}{g:02x}{b:02x}");
}
Color::Indexed(idx) => {
use std::fmt::Write;
let _ = write!(out, "idx{idx}");
}
}
}
const MAX_OSC8_URL_BYTES: usize = 2048;
#[inline]
pub(crate) fn is_valid_osc8_url(url: &str) -> bool {
if url.is_empty() || url.len() > MAX_OSC8_URL_BYTES {
return false;
}
url.bytes().all(|b| b >= 0x20 && b != 0x7f)
}
pub(crate) fn sanitize_osc8_url(url: &str) -> Option<String> {
if is_valid_osc8_url(url) {
Some(url.to_string())
} else {
None
}
}
fn intersect_rects(a: Rect, b: Rect) -> Rect {
let x = a.x.max(b.x);
let y = a.y.max(b.y);
let right = a.right().min(b.right());
let bottom = a.bottom().min(b.bottom());
let width = right.saturating_sub(x);
let height = bottom.saturating_sub(y);
Rect::new(x, y, width, height)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cell::MAX_CELL_SYMBOL_BYTES;
#[test]
fn clip_stack_intersects_nested_regions() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
buf.push_clip(Rect::new(1, 1, 6, 3));
buf.push_clip(Rect::new(4, 0, 6, 4));
buf.set_char(3, 2, 'x', Style::new());
buf.set_char(4, 2, 'y', Style::new());
assert_eq!(buf.get(3, 2).symbol, " ");
assert_eq!(buf.get(4, 2).symbol, "y");
}
#[test]
fn set_string_advances_even_when_clipped() {
let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1));
buf.push_clip(Rect::new(2, 0, 6, 1));
buf.set_string(0, 0, "abcd", Style::new());
assert_eq!(buf.get(2, 0).symbol, "c");
assert_eq!(buf.get(3, 0).symbol, "d");
}
#[test]
fn pop_clip_restores_previous_clip() {
let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
buf.push_clip(Rect::new(0, 0, 2, 1));
buf.push_clip(Rect::new(4, 0, 2, 1));
buf.set_char(1, 0, 'a', Style::new());
buf.pop_clip();
buf.set_char(1, 0, 'b', Style::new());
assert_eq!(buf.get(1, 0).symbol, "b");
}
#[test]
fn reset_clears_clip_stack() {
let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
buf.push_clip(Rect::new(0, 0, 0, 0));
buf.reset();
buf.set_char(0, 0, 'z', Style::new());
assert_eq!(buf.get(0, 0).symbol, "z");
}
#[test]
fn set_string_replaces_control_chars_with_replacement() {
let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
buf.set_string(0, 0, "a\x1bbc", Style::new());
assert_eq!(buf.get(0, 0).symbol, "a");
assert_eq!(buf.get(1, 0).symbol, "\u{FFFD}");
assert_eq!(buf.get(2, 0).symbol, "b");
assert_eq!(buf.get(3, 0).symbol, "c");
}
#[test]
fn zero_width_combining_does_not_append_control_bytes() {
let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
buf.set_char(0, 0, 'a', Style::new());
buf.set_string(1, 0, "\x07", Style::new());
let symbol = buf.get(1, 0).symbol.as_str();
assert!(!symbol.contains('\x07'), "BEL leaked into cell symbol");
}
#[test]
fn set_string_caps_combining_overflow() {
let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
buf.set_char(0, 0, 'a', Style::new());
let combining: String = "\u{0301}".repeat(200);
buf.set_string(1, 0, &combining, Style::new());
assert!(
buf.get(0, 0).symbol.len() <= MAX_CELL_SYMBOL_BYTES,
"cell symbol exceeded MAX_CELL_SYMBOL_BYTES cap"
);
}
#[test]
fn sanitize_osc8_url_rejects_control_chars_and_esc() {
assert!(sanitize_osc8_url("https://example.com").is_some());
assert!(sanitize_osc8_url("https://example.com?q=1&r=2").is_some());
assert!(sanitize_osc8_url("https://example.com\x07attack").is_none());
assert!(sanitize_osc8_url("https://example.com\x1b]52;c;hi\x1b\\").is_none());
assert!(sanitize_osc8_url("").is_none());
assert!(sanitize_osc8_url(&"a".repeat(2049)).is_none());
}
#[test]
fn is_valid_osc8_url_matches_sanitize() {
let oversize = "x".repeat(2049);
let cases: &[&str] = &[
"https://example.com",
"http://localhost:8080/path?q=1#frag",
"ftp://[::1]/file",
"",
&oversize,
"https://evil.com\x1b]52;c;inject\x1b\\",
"https://evil.com\x07bel",
"https://example.com\x7f",
"https://example.com\x00",
];
for url in cases {
assert_eq!(
is_valid_osc8_url(url),
sanitize_osc8_url(url).is_some(),
"is_valid_osc8_url and sanitize_osc8_url disagree on {url:?}"
);
}
}
#[test]
fn set_string_inner_parity_no_link() {
let area = Rect::new(0, 0, 20, 1);
let mut buf_a = Buffer::empty(area);
let mut buf_b = Buffer::empty(area);
let style = Style::new();
buf_a.set_string(0, 0, "Hello wide世界", style);
buf_b.set_string_linked(0, 0, "Hello wide世界", style, "");
for x in 0..20 {
let ca = buf_a.get(x, 0);
let cb = buf_b.get(x, 0);
assert_eq!(ca.symbol, cb.symbol, "symbol mismatch at x={x}");
assert_eq!(ca.style, cb.style, "style mismatch at x={x}");
assert_eq!(
cb.hyperlink, None,
"invalid URL must produce None hyperlink at x={x}"
);
}
}
#[test]
fn set_string_linked_attaches_hyperlink_to_wide_char_pair() {
let area = Rect::new(0, 0, 4, 1);
let mut buf = Buffer::empty(area);
buf.set_string_linked(0, 0, "世", Style::new(), "https://example.com");
let leading = buf.get(0, 0);
let trailing = buf.get(1, 0);
assert_eq!(leading.symbol, "世");
assert!(trailing.symbol.is_empty(), "wide-char trailing must blank");
assert!(leading.hyperlink.is_some());
assert_eq!(leading.hyperlink, trailing.hyperlink);
}
#[test]
fn try_get_out_of_bounds_returns_none() {
let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
assert!(buf.try_get(0, 0).is_some());
assert!(buf.try_get(2, 0).is_none());
assert!(buf.try_get(0, 2).is_none());
assert!(buf.try_get_mut(5, 5).is_none());
}
#[test]
fn kitty_clip_stack_restores_outer_on_pop() {
let mut buf = Buffer::empty(Rect::new(0, 0, 4, 4));
assert!(buf.current_kitty_clip().is_none());
let outer = KittyClipInfo {
top_clip_rows: 2,
original_height: 10,
};
let inner = KittyClipInfo {
top_clip_rows: 5,
original_height: 20,
};
buf.push_kitty_clip(outer);
assert_eq!(buf.current_kitty_clip(), Some(&outer));
buf.push_kitty_clip(inner);
assert_eq!(buf.current_kitty_clip(), Some(&inner));
let popped_inner = buf.pop_kitty_clip();
assert_eq!(popped_inner, Some(inner));
assert_eq!(buf.current_kitty_clip(), Some(&outer));
let popped_outer = buf.pop_kitty_clip();
assert_eq!(popped_outer, Some(outer));
assert!(buf.current_kitty_clip().is_none());
}
#[test]
fn kitty_clip_stack_cleared_on_reset() {
let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
buf.push_kitty_clip(KittyClipInfo {
top_clip_rows: 1,
original_height: 2,
});
buf.push_kitty_clip(KittyClipInfo {
top_clip_rows: 3,
original_height: 4,
});
buf.reset();
assert!(buf.kitty_clip_info_stack.is_empty());
assert!(buf.current_kitty_clip().is_none());
}
#[test]
fn kitty_clip_pop_on_empty_stack_is_none() {
let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
assert!(buf.pop_kitty_clip().is_none());
}
#[test]
fn kitty_horizontal_clip_crops_source_pixels_to_visible_columns() {
let rgba = Arc::new(vec![
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255, ]);
let placement = KittyPlacement {
content_hash: hash_rgba(&rgba),
rgba,
src_width: 4,
src_height: 1,
x: 0,
y: 0,
cols: 2,
rows: 1,
crop_y: 0,
crop_h: 0,
};
let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
buf.push_kitty_horizontal_clip(KittyHorizontalClipInfo {
left_clip_cols: 1,
original_width: 4,
});
buf.kitty_place(placement);
let cropped = &buf.kitty_placements[0];
assert_eq!(cropped.src_width, 2);
assert_eq!(cropped.rgba.as_slice(), &[0, 255, 0, 255, 0, 0, 255, 255]);
assert!(buf.pop_kitty_horizontal_clip().is_some());
}
#[test]
fn snapshot_format_default_style_unannotated() {
let mut buf = Buffer::empty(Rect::new(0, 0, 5, 1));
buf.set_string(0, 0, "abc", Style::new());
assert_eq!(buf.snapshot_format(), "abc ");
}
#[test]
fn snapshot_format_color_runs_grouped() {
use crate::style::Color;
let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
buf.set_string(0, 0, "abc", Style::new().fg(Color::Red));
buf.set_string(3, 0, "def", Style::new().fg(Color::Blue));
let snap = buf.snapshot_format();
assert_eq!(snap, "[fg=red]\"abc\"[/][fg=blue]\"def\"[/]");
}
#[test]
fn snapshot_format_modifier_transitions() {
let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
buf.set_string(0, 0, "ab", Style::new().bold());
buf.set_string(2, 0, "cd", Style::new());
buf.set_string(4, 0, "ef", Style::new().bold());
let snap = buf.snapshot_format();
assert_eq!(snap, "[bold]\"ab\"[/]cd[bold]\"ef\"[/]");
}
#[test]
fn snapshot_format_deterministic() {
use crate::style::Color;
let mut buf = Buffer::empty(Rect::new(0, 0, 8, 2));
buf.set_string(0, 0, "hello", Style::new().fg(Color::Cyan).bold());
buf.set_string(0, 1, "world", Style::new().bg(Color::Rgb(10, 20, 30)));
let a = buf.snapshot_format();
let b = buf.snapshot_format();
assert_eq!(a, b, "snapshot_format must be deterministic");
assert_eq!(a.len(), b.len());
}
#[test]
fn snapshot_format_empty_buffer_is_spaces() {
let buf = Buffer::empty(Rect::new(0, 0, 4, 2));
assert_eq!(buf.snapshot_format(), " \n ");
}
#[test]
fn snapshot_format_zero_dim_returns_empty() {
let buf_a = Buffer::empty(Rect::new(0, 0, 0, 4));
let buf_b = Buffer::empty(Rect::new(0, 0, 4, 0));
assert_eq!(buf_a.snapshot_format(), "");
assert_eq!(buf_b.snapshot_format(), "");
}
#[test]
fn snapshot_format_rgb_uses_hex_codes() {
use crate::style::Color;
let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
buf.set_string(0, 0, "x", Style::new().fg(Color::Rgb(0xff, 0x00, 0xab)));
let snap = buf.snapshot_format();
assert!(
snap.contains("fg=#ff00ab"),
"expected hex RGB code, got {snap:?}"
);
}
#[test]
fn snapshot_format_indexed_color() {
use crate::style::Color;
let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
buf.set_string(0, 0, "x", Style::new().fg(Color::Indexed(42)));
assert!(buf.snapshot_format().contains("fg=idx42"));
}
#[test]
fn snapshot_format_modifiers_canonical_order() {
let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
let style = Style::new().strikethrough().italic().bold();
buf.set_string(0, 0, "x", style);
let snap = buf.snapshot_format();
let bold_idx = snap.find("bold").expect("bold present");
let italic_idx = snap.find("italic").expect("italic present");
let strike_idx = snap.find("strikethrough").expect("strikethrough present");
assert!(bold_idx < italic_idx);
assert!(italic_idx < strike_idx);
}
#[test]
fn snapshot_format_escapes_quote_and_backslash() {
let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
buf.set_string(0, 0, "a\"b\\", Style::new().bold());
let snap = buf.snapshot_format();
assert!(
snap.contains("\"a\\\"b\\\\\""),
"expected escapes, got {snap:?}"
);
}
#[test]
fn snapshot_format_multi_row_uses_newlines() {
let mut buf = Buffer::empty(Rect::new(0, 0, 3, 3));
buf.set_string(0, 0, "aaa", Style::new());
buf.set_string(0, 1, "bbb", Style::new());
buf.set_string(0, 2, "ccc", Style::new());
assert_eq!(buf.snapshot_format(), "aaa\nbbb\nccc");
}
#[test]
fn line_dirty_initial_state_is_all_dirty() {
let buf = Buffer::empty(Rect::new(0, 0, 4, 3));
assert_eq!(buf.line_dirty.len(), 3);
assert!(buf.line_dirty.iter().all(|d| *d));
}
#[test]
fn set_string_marks_row_dirty() {
let mut buf = Buffer::empty(Rect::new(0, 0, 8, 4));
buf.recompute_line_hashes();
assert!(buf.line_dirty.iter().all(|d| !*d));
buf.set_string(0, 1, "hello", Style::new());
assert!(!buf.line_dirty[0]);
assert!(buf.line_dirty[1]);
assert!(!buf.line_dirty[2]);
assert!(!buf.line_dirty[3]);
}
#[test]
fn set_char_marks_row_dirty() {
let mut buf = Buffer::empty(Rect::new(0, 0, 4, 3));
buf.recompute_line_hashes();
buf.set_char(2, 2, 'X', Style::new());
assert!(!buf.line_dirty[0]);
assert!(!buf.line_dirty[1]);
assert!(buf.line_dirty[2]);
}
#[test]
fn recompute_line_hashes_clears_dirty_and_caches_hashes() {
let mut buf = Buffer::empty(Rect::new(0, 0, 4, 2));
buf.set_string(0, 0, "abcd", Style::new());
buf.set_string(0, 1, "wxyz", Style::new());
buf.recompute_line_hashes();
assert!(buf.line_dirty.iter().all(|d| !*d));
assert_ne!(buf.line_hashes[0], buf.line_hashes[1]);
assert!(buf.row_clean(0));
assert!(buf.row_clean(1));
}
#[test]
fn row_clean_returns_false_for_unrecomputed_or_dirty_row() {
let mut buf = Buffer::empty(Rect::new(0, 0, 4, 2));
assert!(!buf.row_clean(0));
buf.recompute_line_hashes();
assert!(buf.row_clean(0));
buf.set_string(0, 0, "z", Style::new());
assert!(!buf.row_clean(0));
}
#[test]
fn identical_buffers_share_line_hashes_after_recompute() {
let area = Rect::new(0, 0, 5, 3);
let mut a = Buffer::empty(area);
let mut b = Buffer::empty(area);
a.set_string(0, 0, "hello", Style::new());
b.set_string(0, 0, "hello", Style::new());
a.set_string(0, 1, "world", Style::new());
b.set_string(0, 1, "world", Style::new());
a.recompute_line_hashes();
b.recompute_line_hashes();
assert_eq!(a.row_hash(0), b.row_hash(0));
assert_eq!(a.row_hash(1), b.row_hash(1));
assert_eq!(a.row_hash(2), b.row_hash(2));
}
#[test]
fn different_styles_yield_different_line_hashes() {
use crate::style::Color;
let area = Rect::new(0, 0, 3, 1);
let mut a = Buffer::empty(area);
let mut b = Buffer::empty(area);
a.set_string(0, 0, "abc", Style::new().fg(Color::Red));
b.set_string(0, 0, "abc", Style::new().fg(Color::Blue));
a.recompute_line_hashes();
b.recompute_line_hashes();
assert_ne!(a.row_hash(0), b.row_hash(0));
}
#[test]
fn resize_keeps_line_arrays_in_sync() {
let mut buf = Buffer::empty(Rect::new(0, 0, 4, 3));
buf.recompute_line_hashes();
buf.resize(Rect::new(0, 0, 4, 5));
assert_eq!(buf.line_dirty.len(), 5);
assert_eq!(buf.line_hashes.len(), 5);
assert!(buf.line_dirty.iter().all(|d| *d));
buf.resize(Rect::new(0, 0, 4, 2));
assert_eq!(buf.line_dirty.len(), 2);
assert_eq!(buf.line_hashes.len(), 2);
assert!(buf.line_dirty.iter().all(|d| *d));
}
#[test]
fn checked_construction_rejects_budget_and_edge_overflow() {
let oversized = Rect::new(0, 0, MAX_BUFFER_CELLS as u32 + 1, 1);
assert!(matches!(
Buffer::try_empty(oversized),
Err(BufferError::CellBudgetExceeded {
requested,
maximum,
}) if requested == MAX_BUFFER_CELLS as u64 + 1 && maximum == MAX_BUFFER_CELLS
));
assert!(matches!(
Buffer::try_empty(Rect::new(u32::MAX, 0, 1, 1)),
Err(BufferError::InvalidEdges)
));
assert!(matches!(
Buffer::try_empty(Rect::new(0, 0, 0, u32::MAX)),
Err(BufferError::RowBudgetExceeded { .. })
));
assert!(Buffer::validate_area(Rect::new(12, 34, 80, 24)).is_ok());
}
#[test]
fn failed_checked_resize_preserves_existing_geometry_and_content() {
let mut buf = Buffer::empty(Rect::new(7, 9, 4, 2));
buf.set_string(7, 9, "safe", Style::new());
let result = buf.try_resize(Rect::new(0, 0, MAX_BUFFER_CELLS as u32 + 1, 1));
assert!(matches!(
result,
Err(BufferError::CellBudgetExceeded { .. })
));
assert_eq!(buf.area, Rect::new(7, 9, 4, 2));
assert_eq!(buf.get(7, 9).symbol, "s");
}
#[test]
fn nonzero_origin_string_writes_clip_on_all_four_edges() {
let mut buf = Buffer::empty(Rect::new(10, 20, 4, 2));
buf.set_string(8, 20, "abcd", Style::new());
buf.set_string(10, 19, "top", Style::new());
buf.set_string(10, 22, "bottom", Style::new());
assert_eq!(buf.get(10, 20).symbol, "c");
assert_eq!(buf.get(11, 20).symbol, "d");
assert_eq!(buf.get(10, 21).symbol, " ");
}
#[test]
fn diff_with_different_origins_and_sizes_is_a_bounded_full_redraw() {
let mut current = Buffer::empty(Rect::new(10, 20, 3, 2));
current.set_string(10, 20, "abc", Style::new());
let previous = Buffer::empty(Rect::new(0, 0, 1, 1));
let updates = current.diff(&previous);
assert_eq!(updates.len(), current.content.len());
assert_eq!((updates[0].0, updates[0].1), (10, 20));
assert_eq!((updates[5].0, updates[5].1), (12, 21));
}
#[test]
fn zwj_grapheme_is_atomic_and_marks_continuation_cells() {
let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
buf.set_string(0, 0, "👩💻x", Style::new());
assert_eq!(buf.get(0, 0).symbol, "👩💻");
assert!(buf.get(1, 0).is_continuation());
assert_eq!(buf.get(2, 0).symbol, "x");
}
#[test]
fn wide_replacement_clears_continuation_and_stale_hyperlink() {
let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
buf.set_string_linked(1, 0, "世", Style::new(), "https://example.com");
buf.set_char(1, 0, 'a', Style::new());
assert_eq!(buf.get(1, 0).symbol, "a");
assert_eq!(buf.get(2, 0).symbol, " ");
assert!(buf.get(1, 0).hyperlink.is_none());
assert!(buf.get(2, 0).hyperlink.is_none());
}
#[test]
fn wide_write_never_splits_at_area_or_clip_boundary() {
let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
buf.set_char(3, 0, 'x', Style::new());
buf.set_string(3, 0, "世", Style::new());
assert_eq!(buf.get(3, 0).symbol, "x");
buf.set_string(1, 0, "世", Style::new());
buf.push_clip(Rect::new(1, 0, 1, 1));
buf.set_char(1, 0, 'a', Style::new());
assert_eq!(buf.get(1, 0).symbol, "世");
assert!(buf.get(2, 0).is_continuation());
}
#[test]
fn fnv1a_distinct_rows_distinct_identical_rows_collide() {
let area = Rect::new(0, 0, 5, 3);
let mut buf = Buffer::empty(area);
buf.set_string(0, 0, "alpha", Style::new());
buf.set_string(0, 1, "alpha", Style::new()); buf.set_string(0, 2, "omega", Style::new()); buf.recompute_line_hashes();
assert_eq!(
buf.row_hash(0),
buf.row_hash(1),
"identical rows must collide"
);
assert_ne!(
buf.row_hash(0),
buf.row_hash(2),
"distinct rows must not collide"
);
}
#[test]
fn fnv1a_hash_rgba_is_deterministic_and_content_sensitive() {
let a = [1u8, 2, 3, 4];
let b = [1u8, 2, 3, 4];
let c = [1u8, 2, 3, 5];
assert_eq!(hash_rgba(&a), hash_rgba(&b));
assert_ne!(hash_rgba(&a), hash_rgba(&c));
assert_eq!(hash_rgba(&a), hash_rgba(&a));
}
#[cfg(feature = "bidi")]
fn line_visual(buf: &Buffer, y: u32) -> String {
let mut s = String::new();
for x in buf.area.x..buf.area.right() {
let sym = buf.get(x, y).symbol.as_str();
if sym.is_empty() {
continue; }
s.push_str(sym);
}
s.trim_end().to_string()
}
#[cfg(feature = "bidi")]
#[test]
fn needs_bidi_reorder_false_for_pure_ltr() {
assert!(!needs_bidi_reorder("Hello, world 123"));
assert!(!needs_bidi_reorder(""));
assert!(!needs_bidi_reorder("café résumé"));
assert!(!needs_bidi_reorder("世界 CJK wide"));
}
#[cfg(feature = "bidi")]
#[test]
fn needs_bidi_reorder_true_for_rtl_and_controls() {
assert!(needs_bidi_reorder("שלום")); assert!(needs_bidi_reorder("شكرا")); assert!(needs_bidi_reorder("abc אבג def")); assert!(needs_bidi_reorder("a\u{202E}bc")); assert!(needs_bidi_reorder("\u{200F}")); }
#[cfg(feature = "bidi")]
#[test]
fn set_string_ltr_unchanged_by_reorder_path() {
let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
buf.set_string(0, 0, "abcde", Style::new());
assert_eq!(buf.get(0, 0).symbol, "a");
assert_eq!(buf.get(1, 0).symbol, "b");
assert_eq!(buf.get(2, 0).symbol, "c");
assert_eq!(buf.get(3, 0).symbol, "d");
assert_eq!(buf.get(4, 0).symbol, "e");
}
#[cfg(feature = "bidi")]
#[test]
fn set_string_pure_rtl_reverses_to_visual_order() {
let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
buf.set_string(0, 0, "\u{05E9}\u{05DC}\u{05D5}\u{05DD}", Style::new());
assert_eq!(buf.get(0, 0).symbol, "\u{05DD}"); assert_eq!(buf.get(3, 0).symbol, "\u{05E9}"); assert_eq!(line_visual(&buf, 0), "\u{05DD}\u{05D5}\u{05DC}\u{05E9}");
}
#[cfg(feature = "bidi")]
#[test]
fn set_string_mixed_ltr_rtl_run() {
let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1));
buf.set_string(0, 0, "abc \u{05D0}\u{05D1}\u{05D2}", Style::new());
assert_eq!(line_visual(&buf, 0), "abc \u{05D2}\u{05D1}\u{05D0}");
}
#[cfg(feature = "bidi")]
#[test]
fn set_string_numbers_inside_rtl_stay_ltr() {
let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1));
buf.set_string(0, 0, "123 \u{05D0}\u{05D1}\u{05D2}", Style::new());
assert_eq!(line_visual(&buf, 0), "\u{05D2}\u{05D1}\u{05D0} 123");
}
#[cfg(feature = "bidi")]
#[test]
fn set_string_wide_char_with_rtl_blanks_trailing_cell() {
let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
buf.set_string(0, 0, "\u{4E16} \u{05D0}\u{05D1}", Style::new());
assert_eq!(buf.get(0, 0).symbol, "\u{4E16}"); assert!(buf.get(1, 0).symbol.is_empty(), "wide trailing must blank");
assert_eq!(buf.get(3, 0).symbol, "\u{05D1}"); assert_eq!(buf.get(4, 0).symbol, "\u{05D0}"); }
#[cfg(feature = "bidi")]
#[test]
fn set_string_linked_hyperlink_survives_reorder() {
let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
buf.set_string_linked(
0,
0,
"\u{05E9}\u{05DC}\u{05D5}\u{05DD}",
Style::new(),
"https://example.com",
);
for x in 0..4 {
let cell = buf.get(x, 0);
assert!(
cell.hyperlink.is_some(),
"hyperlink missing at visual column {x}"
);
}
}
#[cfg(feature = "bidi")]
#[test]
fn set_string_control_chars_filtered_in_rtl() {
let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
buf.set_string(0, 0, "\u{05D0}\x1b\u{05D1}", Style::new());
let mut found_replacement = false;
for x in 0..6 {
let sym = buf.get(x, 0).symbol.as_str();
assert!(!sym.contains('\x1b'), "ESC leaked into a cell");
if sym.contains('\u{FFFD}') {
found_replacement = true;
}
}
assert!(found_replacement, "ESC was not replaced with U+FFFD");
}
#[cfg(feature = "bidi")]
#[test]
fn reorder_line_visual_empty_is_noop() {
assert_eq!(reorder_line_visual(""), "");
}
mod geometry_proptest {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn origin_writes_and_mismatched_diffs_never_panic(
x in 0u32..200,
y in 0u32..200,
width in 0u32..32,
height in 0u32..16,
other_x in 0u32..200,
other_y in 0u32..200,
other_width in 0u32..32,
other_height in 0u32..16,
text in ".{0,48}",
) {
let area = Rect::new(x, y, width, height);
let other_area = Rect::new(other_x, other_y, other_width, other_height);
let mut current = Buffer::try_empty(area).expect("small geometry is valid");
let previous = Buffer::try_empty(other_area).expect("small geometry is valid");
current.set_string(x.saturating_sub(3), y.saturating_sub(3), &text, Style::new());
let updates = current.diff(&previous);
prop_assert!(updates.len() <= current.content.len());
prop_assert!(updates.iter().all(|(cx, cy, _)| current.in_bounds(*cx, *cy)));
}
}
}
#[cfg(feature = "bidi")]
mod bidi_proptest {
use super::{needs_bidi_reorder, reorder_line_visual};
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn ascii_takes_fast_path_and_reorder_is_identity(s in "[ -~]{0,64}") {
prop_assert!(!needs_bidi_reorder(&s));
prop_assert_eq!(reorder_line_visual(&s), s);
}
#[test]
fn reorder_is_codepoint_permutation(
s in "[a-z\\x{05D0}-\\x{05EA}\\x{0627}-\\x{064A}0-9 ]{0,48}"
) {
let mut before: Vec<char> = s.chars().collect();
let mut after: Vec<char> = reorder_line_visual(&s).chars().collect();
before.sort_unstable();
after.sort_unstable();
prop_assert_eq!(before, after);
}
}
}
}