1use std::fmt;
8use std::hash::{Hash, Hasher};
9use std::sync::Arc;
10
11use crate::cell::{Cell, normalize_cell_symbol};
12use crate::rect::Rect;
13use crate::style::Style;
14use unicode_segmentation::UnicodeSegmentation;
15use unicode_width::UnicodeWidthStr;
16
17pub const MAX_BUFFER_CELLS: usize = 1_048_576;
22pub const MAX_BUFFER_ROWS: usize = MAX_BUFFER_CELLS;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum BufferError {
28 InvalidEdges,
30 CellBudgetExceeded {
32 requested: u64,
34 maximum: usize,
36 },
37 RowBudgetExceeded {
39 requested: u32,
41 maximum: usize,
43 },
44 AllocationFailed,
46}
47
48impl fmt::Display for BufferError {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 match self {
51 Self::InvalidEdges => write!(f, "buffer rectangle edges overflow u32 coordinates"),
52 Self::CellBudgetExceeded { requested, maximum } => write!(
53 f,
54 "buffer requires {requested} cells, exceeding the {maximum}-cell budget"
55 ),
56 Self::RowBudgetExceeded { requested, maximum } => write!(
57 f,
58 "buffer requires {requested} rows, exceeding the {maximum}-row budget"
59 ),
60 Self::AllocationFailed => write!(f, "buffer allocation failed within the cell budget"),
61 }
62 }
63}
64
65impl std::error::Error for BufferError {}
66
67pub(crate) const MAX_IMAGE_PIXELS: u64 = 16_777_216;
73
74#[cfg(feature = "bidi")]
91#[inline]
92pub(crate) fn needs_bidi_reorder(s: &str) -> bool {
93 use unicode_bidi::BidiClass::{AL, FSI, LRE, LRI, LRO, PDF, PDI, R, RLE, RLI, RLO};
94
95 s.chars().any(|ch| {
96 matches!(
97 unicode_bidi::bidi_class(ch),
98 R | AL | RLE | RLO | RLI | LRE | LRO | LRI | FSI | PDI | PDF
99 )
100 })
101}
102
103#[cfg(feature = "bidi")]
114fn reorder_line_visual(s: &str) -> String {
115 use unicode_bidi::BidiInfo;
116 let info = BidiInfo::new(s, None);
118 let Some(para) = info.paragraphs.first() else {
119 return s.to_string();
120 };
121
122 let resolved = info.reordered_levels(para, para.range.clone());
126 let graphemes: Vec<(usize, &str)> = s.grapheme_indices(true).collect();
127 let levels: Vec<_> = graphemes.iter().map(|(byte, _)| resolved[*byte]).collect();
128 let visual_to_logical = BidiInfo::reorder_visual(&levels);
129 let mut reordered = String::with_capacity(s.len());
130 for logical in visual_to_logical {
131 reordered.push_str(graphemes[logical].1);
132 }
133 reordered
134}
135
136#[derive(Clone, Debug)]
142#[allow(dead_code)]
143pub(crate) struct KittyPlacement {
144 pub content_hash: u64,
146 pub rgba: Arc<Vec<u8>>,
148 pub src_width: u32,
150 pub src_height: u32,
152 pub x: u32,
154 pub y: u32,
155 pub cols: u32,
157 pub rows: u32,
158 pub crop_y: u32,
160 pub crop_h: u32,
162}
163
164#[derive(Clone, Copy, Debug, PartialEq, Eq)]
179#[allow(dead_code)]
180pub(crate) enum SprixelCell {
181 Opaque,
183 Mixed,
185 Transparent,
187 Annihilated,
190}
191
192#[derive(Clone, Debug)]
205#[allow(dead_code)]
206pub(crate) struct SprixelPlacement {
207 pub content_hash: u64,
209 pub seq: String,
211 pub x: u32,
213 pub y: u32,
214 pub cols: u32,
216 pub rows: u32,
217 pub cells: Vec<SprixelCell>,
219}
220
221impl PartialEq for SprixelPlacement {
222 fn eq(&self, other: &Self) -> bool {
223 self.content_hash == other.content_hash
229 && self.x == other.x
230 && self.y == other.y
231 && self.cols == other.cols
232 && self.rows == other.rows
233 }
234}
235
236const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
238const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
240
241pub(crate) struct Fnv1a(u64);
253
254impl Default for Fnv1a {
255 #[inline]
256 fn default() -> Self {
257 Self(FNV_OFFSET_BASIS)
258 }
259}
260
261impl Hasher for Fnv1a {
262 #[inline]
263 fn finish(&self) -> u64 {
264 self.0
265 }
266
267 #[inline]
268 fn write(&mut self, bytes: &[u8]) {
269 let mut hash = self.0;
270 for &byte in bytes {
271 hash ^= byte as u64;
272 hash = hash.wrapping_mul(FNV_PRIME);
273 }
274 self.0 = hash;
275 }
276}
277
278pub(crate) fn hash_rgba(data: &[u8]) -> u64 {
283 let mut hasher = Fnv1a::default();
284 data.hash(&mut hasher);
285 hasher.finish()
286}
287
288fn crop_kitty_horizontal(placement: &mut KittyPlacement, info: KittyHorizontalClipInfo) -> bool {
289 if info.original_width == 0 || placement.src_width == 0 || placement.src_height == 0 {
290 return false;
291 }
292 let visible_start = info.left_clip_cols.min(info.original_width);
293 let visible_end = visible_start
294 .saturating_add(placement.cols)
295 .min(info.original_width);
296 if visible_start >= visible_end {
297 return false;
298 }
299
300 let source_width = u64::from(placement.src_width);
301 let original_width = u64::from(info.original_width);
302 let start_pixel = source_width.saturating_mul(u64::from(visible_start)) / original_width;
303 let scaled_end = source_width.saturating_mul(u64::from(visible_end));
304 let end_pixel = scaled_end
305 .saturating_add(original_width.saturating_sub(1))
306 .checked_div(original_width)
307 .unwrap_or(0)
308 .min(source_width);
309 let crop_width = end_pixel.saturating_sub(start_pixel);
310 if crop_width == 0 {
311 return false;
312 }
313 if start_pixel == 0 && crop_width == source_width {
314 return true;
315 }
316
317 let Some(source_stride) = usize::try_from(source_width)
318 .ok()
319 .and_then(|width| width.checked_mul(4))
320 else {
321 return false;
322 };
323 let Some(crop_stride) = usize::try_from(crop_width)
324 .ok()
325 .and_then(|width| width.checked_mul(4))
326 else {
327 return false;
328 };
329 let Some(expected_source) = source_stride.checked_mul(placement.src_height as usize) else {
330 return false;
331 };
332 if placement.rgba.len() < expected_source {
333 return false;
334 }
335 let Some(cropped_len) = crop_stride.checked_mul(placement.src_height as usize) else {
336 return false;
337 };
338 let mut cropped = Vec::new();
339 if cropped.try_reserve_exact(cropped_len).is_err() {
340 return false;
341 }
342 let start_byte = start_pixel as usize * 4;
343 for row in 0..placement.src_height as usize {
344 let row_start = row * source_stride + start_byte;
345 cropped.extend_from_slice(&placement.rgba[row_start..row_start + crop_stride]);
346 }
347
348 placement.src_width = crop_width as u32;
349 placement.content_hash = hash_rgba(&cropped);
350 placement.rgba = Arc::new(cropped);
351 true
352}
353
354impl PartialEq for KittyPlacement {
355 fn eq(&self, other: &Self) -> bool {
356 self.content_hash == other.content_hash
357 && self.x == other.x
358 && self.y == other.y
359 && self.cols == other.cols
360 && self.rows == other.rows
361 && self.crop_y == other.crop_y
362 && self.crop_h == other.crop_h
363 }
364}
365
366#[derive(Clone, Copy, Debug, PartialEq, Eq)]
372pub(crate) struct KittyClipInfo {
373 pub top_clip_rows: u32,
375 pub original_height: u32,
377}
378
379#[derive(Clone, Copy, Debug, PartialEq, Eq)]
381pub(crate) struct KittyHorizontalClipInfo {
382 pub left_clip_cols: u32,
384 pub original_width: u32,
386}
387
388pub struct Buffer {
397 pub area: Rect,
399 pub content: Vec<Cell>,
401 pub(crate) clip_stack: Vec<Rect>,
402 pub(crate) raw_sequences: Vec<(u32, u32, String)>,
403 pub(crate) sprixels: Vec<SprixelPlacement>,
407 pub(crate) kitty_placements: Vec<KittyPlacement>,
408 pub(crate) cursor_pos: Option<(u32, u32)>,
409 pub(crate) kitty_clip_info_stack: Vec<KittyClipInfo>,
413 pub(crate) kitty_horizontal_clip_stack: Vec<KittyHorizontalClipInfo>,
415 pub(crate) line_hashes: Vec<u64>,
423 pub(crate) line_dirty: Vec<bool>,
433}
434
435fn checked_buffer_dimensions(area: Rect) -> Result<(usize, usize), BufferError> {
436 if !area.has_valid_edges() {
437 return Err(BufferError::InvalidEdges);
438 }
439 let requested = area.area_u64();
440 if requested > MAX_BUFFER_CELLS as u64 {
441 return Err(BufferError::CellBudgetExceeded {
442 requested,
443 maximum: MAX_BUFFER_CELLS,
444 });
445 }
446 if u64::from(area.height) > MAX_BUFFER_ROWS as u64 {
447 return Err(BufferError::RowBudgetExceeded {
448 requested: area.height,
449 maximum: MAX_BUFFER_ROWS,
450 });
451 }
452 let cells = usize::try_from(requested).map_err(|_| BufferError::CellBudgetExceeded {
453 requested,
454 maximum: MAX_BUFFER_CELLS,
455 })?;
456 let rows = usize::try_from(area.height).map_err(|_| BufferError::CellBudgetExceeded {
457 requested,
458 maximum: MAX_BUFFER_CELLS,
459 })?;
460 Ok((cells, rows))
461}
462
463fn try_repeated<T: Clone>(value: T, len: usize) -> Result<Vec<T>, BufferError> {
464 let mut values = Vec::new();
465 values
466 .try_reserve_exact(len)
467 .map_err(|_| BufferError::AllocationFailed)?;
468 values.resize(len, value);
469 Ok(values)
470}
471
472fn trim_excess_capacity<T>(values: &mut Vec<T>) {
473 const RETAIN_FACTOR: usize = 4;
474 const HEADROOM_FACTOR: usize = 2;
475
476 if values.capacity() > values.len().saturating_mul(RETAIN_FACTOR) {
477 values.shrink_to(values.len().saturating_mul(HEADROOM_FACTOR));
478 }
479}
480
481impl Buffer {
482 pub fn validate_area(area: Rect) -> Result<(), BufferError> {
487 checked_buffer_dimensions(area).map(|_| ())
488 }
489
490 pub fn empty(area: Rect) -> Self {
498 Self::try_empty(area)
499 .unwrap_or_else(|error| panic!("Buffer::empty({area:?}) failed: {error}"))
500 }
501
502 pub fn try_empty(area: Rect) -> Result<Self, BufferError> {
504 let (size, height) = checked_buffer_dimensions(area)?;
505 Ok(Self {
506 area,
507 content: try_repeated(Cell::default(), size)?,
508 clip_stack: Vec::new(),
509 raw_sequences: Vec::new(),
510 sprixels: Vec::new(),
511 kitty_placements: Vec::new(),
512 cursor_pos: None,
513 kitty_clip_info_stack: Vec::new(),
514 kitty_horizontal_clip_stack: Vec::new(),
515 line_hashes: try_repeated(0, height)?,
519 line_dirty: try_repeated(true, height)?,
520 })
521 }
522
523 pub(crate) fn push_kitty_clip(&mut self, info: KittyClipInfo) {
525 self.kitty_clip_info_stack.push(info);
526 }
527
528 #[cfg(test)]
529 pub(crate) fn pop_kitty_clip(&mut self) -> Option<KittyClipInfo> {
530 self.kitty_clip_info_stack.pop()
531 }
532
533 pub(crate) fn current_kitty_clip(&self) -> Option<&KittyClipInfo> {
535 self.kitty_clip_info_stack.last()
536 }
537
538 #[allow(dead_code)] pub(crate) fn push_kitty_horizontal_clip(&mut self, info: KittyHorizontalClipInfo) {
541 self.kitty_horizontal_clip_stack.push(info);
542 }
543
544 #[allow(dead_code)] pub(crate) fn pop_kitty_horizontal_clip(&mut self) -> Option<KittyHorizontalClipInfo> {
547 self.kitty_horizontal_clip_stack.pop()
548 }
549
550 fn current_kitty_horizontal_clip(&self) -> Option<&KittyHorizontalClipInfo> {
551 self.kitty_horizontal_clip_stack.last()
552 }
553
554 pub(crate) fn set_cursor_pos(&mut self, x: u32, y: u32) {
555 self.cursor_pos = Some((x, y));
556 }
557
558 #[cfg(feature = "crossterm")]
559 pub(crate) fn cursor_pos(&self) -> Option<(u32, u32)> {
560 self.cursor_pos
561 }
562
563 pub fn raw_sequence(&mut self, x: u32, y: u32, seq: String) {
568 if let Some(clip) = self.effective_clip()
569 && (x >= clip.right() || y >= clip.bottom())
570 {
571 return;
572 }
573 self.raw_sequences.push((x, y, seq));
574 }
575
576 pub(crate) fn kitty_place(&mut self, mut p: KittyPlacement) {
583 if let Some(clip) = self.effective_clip()
585 && (p.x >= clip.right()
586 || p.y >= clip.bottom()
587 || p.x.saturating_add(p.cols) <= clip.x
588 || p.y.saturating_add(p.rows) <= clip.y)
589 {
590 return;
591 }
592
593 if let Some(info) = self.current_kitty_horizontal_clip().copied()
594 && !crop_kitty_horizontal(&mut p, info)
595 {
596 return;
597 }
598
599 if let Some(info) = self.current_kitty_clip() {
601 let top_clip_rows = info.top_clip_rows;
602 let original_height = info.original_height;
603 if original_height > 0 && (top_clip_rows > 0 || p.rows < original_height) {
604 let ratio = p.src_height as f64 / original_height as f64;
605 p.crop_y = (top_clip_rows as f64 * ratio) as u32;
606 let bottom_clip =
607 original_height.saturating_sub(top_clip_rows.saturating_add(p.rows));
608 let bottom_pixels = (bottom_clip as f64 * ratio) as u32;
609 p.crop_h = p
610 .src_height
611 .saturating_sub(p.crop_y.saturating_add(bottom_pixels));
612 }
613 }
614
615 self.kitty_placements.push(p);
616 }
617
618 #[cfg_attr(not(feature = "crossterm"), allow(dead_code))]
631 pub(crate) fn sprixel_place(&mut self, p: SprixelPlacement) {
632 if let Some(clip) = self.effective_clip()
633 && (p.x >= clip.right()
634 || p.y >= clip.bottom()
635 || p.x.saturating_add(p.cols) <= clip.x
636 || p.y.saturating_add(p.rows) <= clip.y)
637 {
638 return;
639 }
640 self.sprixels.push(p);
641 }
642
643 pub fn push_clip(&mut self, rect: Rect) {
649 let effective = if let Some(current) = self.clip_stack.last() {
650 intersect_rects(*current, rect)
651 } else {
652 rect
653 };
654 self.clip_stack.push(effective);
655 }
656
657 pub fn pop_clip(&mut self) {
662 self.clip_stack.pop();
663 }
664
665 fn effective_clip(&self) -> Option<&Rect> {
666 self.clip_stack.last()
667 }
668
669 #[inline]
670 fn index_of(&self, x: u32, y: u32) -> usize {
671 ((y - self.area.y) * self.area.width + (x - self.area.x)) as usize
672 }
673
674 #[inline]
676 pub fn in_bounds(&self, x: u32, y: u32) -> bool {
677 x >= self.area.x && x < self.area.right() && y >= self.area.y && y < self.area.bottom()
678 }
679
680 #[inline]
685 pub fn get(&self, x: u32, y: u32) -> &Cell {
686 assert!(
687 self.in_bounds(x, y),
688 "Buffer::get({x}, {y}) out of bounds for area {:?}",
689 self.area
690 );
691 &self.content[self.index_of(x, y)]
692 }
693
694 #[inline]
699 pub fn get_mut(&mut self, x: u32, y: u32) -> &mut Cell {
700 assert!(
701 self.in_bounds(x, y),
702 "Buffer::get_mut({x}, {y}) out of bounds for area {:?}",
703 self.area
704 );
705 let idx = self.index_of(x, y);
706 self.mark_row_dirty(y);
707 &mut self.content[idx]
708 }
709
710 #[inline]
716 pub fn try_get(&self, x: u32, y: u32) -> Option<&Cell> {
717 if self.in_bounds(x, y) {
718 Some(&self.content[self.index_of(x, y)])
719 } else {
720 None
721 }
722 }
723
724 #[inline]
729 pub fn try_get_mut(&mut self, x: u32, y: u32) -> Option<&mut Cell> {
730 if self.in_bounds(x, y) {
731 let idx = self.index_of(x, y);
732 self.mark_row_dirty(y);
733 Some(&mut self.content[idx])
734 } else {
735 None
736 }
737 }
738
739 pub fn set_string(&mut self, x: u32, y: u32, s: &str, style: Style) {
746 self.set_string_inner(x, y, s, style, None);
747 }
748
749 pub fn set_string_linked(&mut self, x: u32, y: u32, s: &str, style: Style, url: &str) {
754 let link = sanitize_osc8_url(url).map(compact_str::CompactString::new);
755 self.set_string_inner(x, y, s, style, link.as_ref());
756 }
757
758 fn set_string_inner(
766 &mut self,
767 mut x: u32,
768 y: u32,
769 s: &str,
770 style: Style,
771 link: Option<&compact_str::CompactString>,
772 ) {
773 if y < self.area.y || y >= self.area.bottom() {
774 return;
775 }
776 #[cfg(feature = "bidi")]
785 let reordered;
786 #[cfg(feature = "bidi")]
787 let s: &str = if needs_bidi_reorder(s) {
788 reordered = reorder_line_visual(s);
789 &reordered
790 } else {
791 s
792 };
793 let clip = self.effective_clip().copied();
794 for grapheme in s.graphemes(true) {
795 if x >= self.area.right() {
796 break;
797 }
798 let width = self.set_grapheme_visual_inner(x, y, grapheme, style, link, clip);
799 x = x.saturating_add(width);
800 }
801 }
802
803 pub(crate) fn set_grapheme_visual(
807 &mut self,
808 x: u32,
809 y: u32,
810 grapheme: &str,
811 style: Style,
812 link: Option<&compact_str::CompactString>,
813 ) -> u32 {
814 let clip = self.effective_clip().copied();
815 self.set_grapheme_visual_inner(x, y, grapheme, style, link, clip)
816 }
817
818 fn set_grapheme_visual_inner(
819 &mut self,
820 x: u32,
821 y: u32,
822 grapheme: &str,
823 style: Style,
824 link: Option<&compact_str::CompactString>,
825 clip: Option<Rect>,
826 ) -> u32 {
827 let symbol = normalize_cell_symbol(grapheme);
828 let width = UnicodeWidthStr::width(symbol.as_str()) as u32;
829 if width == 0 {
830 self.append_zero_width(x, y, &symbol, clip);
831 return 0;
832 }
833
834 let Some(target_right) = x.checked_add(width) else {
835 return width;
836 };
837 if y < self.area.y
838 || y >= self.area.bottom()
839 || x < self.area.x
840 || target_right > self.area.right()
841 {
842 return width;
843 }
844
845 let (mut affected_left, mut affected_right) = (x, target_right);
846 for col in x..target_right {
847 let (old_left, old_right) = self.existing_grapheme_range(col, y);
848 affected_left = affected_left.min(old_left);
849 affected_right = affected_right.max(old_right);
850 }
851 if affected_left < self.area.x || affected_right > self.area.right() {
852 return width;
853 }
854 let fully_in_clip = clip.is_none_or(|clip| {
855 y >= clip.y
856 && y < clip.bottom()
857 && affected_left >= clip.x
858 && affected_right <= clip.right()
859 });
860 if !fully_in_clip {
861 return width;
862 }
863
864 self.mark_row_dirty(y);
865 for col in affected_left..affected_right {
866 let idx = self.index_of(col, y);
867 self.content[idx].reset();
868 }
869
870 let leading_idx = self.index_of(x, y);
871 let leading = &mut self.content[leading_idx];
872 leading.set_symbol(&symbol);
873 leading.set_style(style);
874 leading.hyperlink = link.cloned();
875 for col in x.saturating_add(1)..target_right {
876 let idx = self.index_of(col, y);
877 self.content[idx].set_continuation(style);
878 self.content[idx].hyperlink = link.cloned();
879 }
880 width
881 }
882
883 fn existing_grapheme_range(&self, x: u32, y: u32) -> (u32, u32) {
884 let mut left = x;
885 if self.content[self.index_of(x, y)].is_continuation() && x > self.area.x {
886 left = x - 1;
887 }
888 let symbol = self.content[self.index_of(left, y)].normalized_symbol();
889 let width = (UnicodeWidthStr::width(symbol.as_str()) as u32).max(1);
890 (left, left.saturating_add(width).min(self.area.right()))
891 }
892
893 fn append_zero_width(&mut self, x: u32, y: u32, suffix: &str, clip: Option<Rect>) {
894 if suffix.is_empty() || y < self.area.y || y >= self.area.bottom() || x <= self.area.x {
895 return;
896 }
897 let mut leading_x = x.saturating_sub(1).min(self.area.right().saturating_sub(1));
898 if self.content[self.index_of(leading_x, y)].is_continuation() && leading_x > self.area.x {
899 leading_x -= 1;
900 }
901 if clip.is_some_and(|clip| !clip.contains(leading_x, y)) {
902 return;
903 }
904
905 let idx = self.index_of(leading_x, y);
906 let mut combined = self.content[idx].normalized_symbol();
907 combined.push_str(suffix);
908 let normalized = normalize_cell_symbol(&combined);
909 if normalized != self.content[idx].symbol {
910 self.mark_row_dirty(y);
911 self.content[idx].symbol = normalized;
912 }
913 }
914
915 pub fn set_char(&mut self, x: u32, y: u32, ch: char, style: Style) {
919 let mut encoded = [0; 4];
920 self.set_grapheme_visual(x, y, ch.encode_utf8(&mut encoded), style, None);
921 }
922
923 #[inline]
929 pub(crate) fn mark_row_dirty(&mut self, y: u32) {
930 if y < self.area.y {
931 return;
932 }
933 let idx = (y - self.area.y) as usize;
934 if let Some(slot) = self.line_dirty.get_mut(idx) {
935 *slot = true;
936 }
937 }
938
939 #[cfg(any(feature = "crossterm", test))]
953 pub(crate) fn recompute_line_hashes(&mut self) {
954 let height = self.area.height;
955 if height == 0 {
956 return;
957 }
958 let expected_len = height as usize;
963 if self.line_hashes.len() != expected_len {
964 self.line_hashes.resize(expected_len, 0);
965 }
966 if self.line_dirty.len() != expected_len {
967 self.line_dirty.resize(expected_len, true);
968 }
969
970 let width = self.area.width as usize;
971 for (idx, dirty) in self.line_dirty.iter_mut().enumerate() {
972 if !*dirty {
973 continue;
974 }
975 let row_start = idx * width;
976 let row_end = row_start + width;
977 let mut hasher = Fnv1a::default();
978 for cell in &self.content[row_start..row_end] {
979 cell.symbol.as_str().hash(&mut hasher);
980 cell.style.hash(&mut hasher);
981 cell.hyperlink.as_deref().hash(&mut hasher);
982 }
983 self.line_hashes[idx] = hasher.finish();
984 *dirty = false;
985 }
986 }
987
988 #[inline]
998 #[cfg(any(feature = "crossterm", test))]
999 pub(crate) fn row_clean(&self, y: u32) -> bool {
1000 if y < self.area.y {
1001 return false;
1002 }
1003 let idx = (y - self.area.y) as usize;
1004 self.line_dirty
1005 .get(idx)
1006 .copied()
1007 .map(|d| !d)
1008 .unwrap_or(false)
1009 }
1010
1011 #[inline]
1017 #[cfg(any(feature = "crossterm", test))]
1018 pub(crate) fn row_hash(&self, y: u32) -> Option<u64> {
1019 if y < self.area.y {
1020 return None;
1021 }
1022 let idx = (y - self.area.y) as usize;
1023 self.line_hashes.get(idx).copied()
1024 }
1025
1026 pub fn diff<'a>(&'a self, other: &'a Buffer) -> Vec<(u32, u32, &'a Cell)> {
1047 let Some(expected) = usize::try_from(self.area.area_u64()).ok() else {
1048 return Vec::new();
1049 };
1050 let len = self.content.len().min(expected);
1051 if self.area.width == 0 || len == 0 {
1052 return Vec::new();
1053 }
1054
1055 let same_geometry = self.area == other.area
1056 && self.content.len() == expected
1057 && other.content.len() == expected;
1058 let mut updates = Vec::new();
1059 for (index, cell) in self.content[..len].iter().enumerate() {
1060 let changed = !same_geometry || other.content.get(index) != Some(cell);
1061 if !changed {
1062 continue;
1063 }
1064 let row = index / self.area.width as usize;
1065 let col = index % self.area.width as usize;
1066 let x = self.area.x.saturating_add(col as u32);
1067 let y = self.area.y.saturating_add(row as u32);
1068 updates.push((x, y, cell));
1069 }
1070 updates
1071 }
1072
1073 pub fn reset(&mut self) {
1075 for cell in &mut self.content {
1076 cell.reset();
1077 }
1078 self.clip_stack.clear();
1079 self.raw_sequences.clear();
1080 self.sprixels.clear();
1081 self.kitty_placements.clear();
1082 self.cursor_pos = None;
1083 self.kitty_clip_info_stack.clear();
1084 self.kitty_horizontal_clip_stack.clear();
1085 self.line_dirty.fill(true);
1088 }
1089
1090 pub fn reset_with_bg(&mut self, bg: crate::style::Color) {
1092 for cell in &mut self.content {
1093 cell.reset();
1094 cell.style.bg = Some(bg);
1095 }
1096 self.clip_stack.clear();
1097 self.raw_sequences.clear();
1098 self.sprixels.clear();
1099 self.kitty_placements.clear();
1100 self.cursor_pos = None;
1101 self.kitty_clip_info_stack.clear();
1102 self.kitty_horizontal_clip_stack.clear();
1103 self.line_dirty.fill(true);
1105 }
1106
1107 pub fn resize(&mut self, area: Rect) {
1112 self.try_resize(area)
1113 .unwrap_or_else(|error| panic!("Buffer::resize({area:?}) failed: {error}"));
1114 }
1115
1116 pub fn try_resize(&mut self, area: Rect) -> Result<(), BufferError> {
1120 let (size, height) = checked_buffer_dimensions(area)?;
1121 self.content
1122 .try_reserve_exact(size.saturating_sub(self.content.len()))
1123 .map_err(|_| BufferError::AllocationFailed)?;
1124 self.line_hashes
1125 .try_reserve_exact(height.saturating_sub(self.line_hashes.len()))
1126 .map_err(|_| BufferError::AllocationFailed)?;
1127 self.line_dirty
1128 .try_reserve_exact(height.saturating_sub(self.line_dirty.len()))
1129 .map_err(|_| BufferError::AllocationFailed)?;
1130
1131 self.area = area;
1132 self.content.resize(size, Cell::default());
1133 self.line_hashes.resize(height, 0);
1137 self.line_dirty.resize(height, true);
1138 self.reset();
1139 trim_excess_capacity(&mut self.content);
1143 trim_excess_capacity(&mut self.line_hashes);
1144 trim_excess_capacity(&mut self.line_dirty);
1145 Ok(())
1146 }
1147
1148 pub fn snapshot_format(&self) -> String {
1207 let mut out = String::new();
1208 let width = self.area.width;
1209 let height = self.area.height;
1210 if width == 0 || height == 0 {
1211 return out;
1212 }
1213
1214 for y in self.area.y..self.area.bottom() {
1215 if y > self.area.y {
1216 out.push('\n');
1217 }
1218
1219 let mut current_style: Option<Style> = None;
1221 let mut run_text = String::new();
1222
1223 for x in self.area.x..self.area.right() {
1224 let cell = self.get(x, y);
1225 let style = cell.style;
1226 let sym: &str = if cell.symbol.is_empty() {
1228 " "
1229 } else {
1230 cell.symbol.as_str()
1231 };
1232
1233 match current_style {
1234 Some(s) if s == style => {
1235 run_text.push_str(sym);
1236 }
1237 _ => {
1238 if let Some(s) = current_style.take() {
1239 flush_run(&mut out, s, &run_text);
1240 run_text.clear();
1241 }
1242 current_style = Some(style);
1243 run_text.push_str(sym);
1244 }
1245 }
1246 }
1247
1248 if let Some(s) = current_style {
1249 flush_run(&mut out, s, &run_text);
1250 }
1251 }
1252
1253 out
1254 }
1255}
1256
1257fn flush_run(out: &mut String, style: Style, text: &str) {
1264 if style == Style::default() {
1265 out.push_str(text);
1266 return;
1267 }
1268 out.push('[');
1269 let mut first = true;
1270 if let Some(fg) = style.fg {
1271 out.push_str("fg=");
1272 write_color(out, fg);
1273 first = false;
1274 }
1275 if let Some(bg) = style.bg {
1276 if !first {
1277 out.push(',');
1278 }
1279 out.push_str("bg=");
1280 write_color(out, bg);
1281 first = false;
1282 }
1283 let mods = style.modifiers;
1284 let pairs: [(crate::style::Modifiers, &str); 6] = [
1286 (crate::style::Modifiers::BOLD, "bold"),
1287 (crate::style::Modifiers::DIM, "dim"),
1288 (crate::style::Modifiers::ITALIC, "italic"),
1289 (crate::style::Modifiers::UNDERLINE, "underline"),
1290 (crate::style::Modifiers::REVERSED, "reversed"),
1291 (crate::style::Modifiers::STRIKETHROUGH, "strikethrough"),
1292 ];
1293 for (bit, name) in pairs {
1294 if mods.contains(bit) {
1295 if !first {
1296 out.push(',');
1297 }
1298 out.push_str(name);
1299 first = false;
1300 }
1301 }
1302 out.push(']');
1303 out.push('"');
1304 for ch in text.chars() {
1305 match ch {
1306 '"' => out.push_str("\\\""),
1307 '\\' => out.push_str("\\\\"),
1308 other => out.push(other),
1309 }
1310 }
1311 out.push('"');
1312 out.push_str("[/]");
1313}
1314
1315fn write_color(out: &mut String, color: crate::style::Color) {
1320 use crate::style::Color;
1321 match color {
1322 Color::Reset => out.push_str("reset"),
1323 Color::Black => out.push_str("black"),
1324 Color::Red => out.push_str("red"),
1325 Color::Green => out.push_str("green"),
1326 Color::Yellow => out.push_str("yellow"),
1327 Color::Blue => out.push_str("blue"),
1328 Color::Magenta => out.push_str("magenta"),
1329 Color::Cyan => out.push_str("cyan"),
1330 Color::White => out.push_str("white"),
1331 Color::DarkGray => out.push_str("dark_gray"),
1332 Color::LightRed => out.push_str("light_red"),
1333 Color::LightGreen => out.push_str("light_green"),
1334 Color::LightYellow => out.push_str("light_yellow"),
1335 Color::LightBlue => out.push_str("light_blue"),
1336 Color::LightMagenta => out.push_str("light_magenta"),
1337 Color::LightCyan => out.push_str("light_cyan"),
1338 Color::LightWhite => out.push_str("light_white"),
1339 Color::Rgb(r, g, b) => {
1340 use std::fmt::Write;
1341 let _ = write!(out, "#{r:02x}{g:02x}{b:02x}");
1342 }
1343 Color::Indexed(idx) => {
1344 use std::fmt::Write;
1345 let _ = write!(out, "idx{idx}");
1346 }
1347 }
1348}
1349
1350const MAX_OSC8_URL_BYTES: usize = 2048;
1356
1357#[inline]
1364pub(crate) fn is_valid_osc8_url(url: &str) -> bool {
1365 if url.is_empty() || url.len() > MAX_OSC8_URL_BYTES {
1366 return false;
1367 }
1368 url.bytes().all(|b| b >= 0x20 && b != 0x7f)
1374}
1375
1376pub(crate) fn sanitize_osc8_url(url: &str) -> Option<String> {
1386 if is_valid_osc8_url(url) {
1387 Some(url.to_string())
1388 } else {
1389 None
1390 }
1391}
1392
1393fn intersect_rects(a: Rect, b: Rect) -> Rect {
1394 let x = a.x.max(b.x);
1395 let y = a.y.max(b.y);
1396 let right = a.right().min(b.right());
1397 let bottom = a.bottom().min(b.bottom());
1398 let width = right.saturating_sub(x);
1399 let height = bottom.saturating_sub(y);
1400 Rect::new(x, y, width, height)
1401}
1402
1403#[cfg(test)]
1404mod tests {
1405 use super::*;
1406 use crate::cell::MAX_CELL_SYMBOL_BYTES;
1407
1408 #[test]
1409 fn clip_stack_intersects_nested_regions() {
1410 let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
1411 buf.push_clip(Rect::new(1, 1, 6, 3));
1412 buf.push_clip(Rect::new(4, 0, 6, 4));
1413
1414 buf.set_char(3, 2, 'x', Style::new());
1415 buf.set_char(4, 2, 'y', Style::new());
1416
1417 assert_eq!(buf.get(3, 2).symbol, " ");
1418 assert_eq!(buf.get(4, 2).symbol, "y");
1419 }
1420
1421 #[test]
1422 fn set_string_advances_even_when_clipped() {
1423 let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1));
1424 buf.push_clip(Rect::new(2, 0, 6, 1));
1425
1426 buf.set_string(0, 0, "abcd", Style::new());
1427
1428 assert_eq!(buf.get(2, 0).symbol, "c");
1429 assert_eq!(buf.get(3, 0).symbol, "d");
1430 }
1431
1432 #[test]
1433 fn pop_clip_restores_previous_clip() {
1434 let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
1435 buf.push_clip(Rect::new(0, 0, 2, 1));
1436 buf.push_clip(Rect::new(4, 0, 2, 1));
1437
1438 buf.set_char(1, 0, 'a', Style::new());
1439 buf.pop_clip();
1440 buf.set_char(1, 0, 'b', Style::new());
1441
1442 assert_eq!(buf.get(1, 0).symbol, "b");
1443 }
1444
1445 #[test]
1446 fn reset_clears_clip_stack() {
1447 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1448 buf.push_clip(Rect::new(0, 0, 0, 0));
1449 buf.reset();
1450 buf.set_char(0, 0, 'z', Style::new());
1451
1452 assert_eq!(buf.get(0, 0).symbol, "z");
1453 }
1454
1455 #[test]
1456 fn set_string_replaces_control_chars_with_replacement() {
1457 let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
1458 buf.set_string(0, 0, "a\x1bbc", Style::new());
1461 assert_eq!(buf.get(0, 0).symbol, "a");
1462 assert_eq!(buf.get(1, 0).symbol, "\u{FFFD}");
1463 assert_eq!(buf.get(2, 0).symbol, "b");
1464 assert_eq!(buf.get(3, 0).symbol, "c");
1465 }
1466
1467 #[test]
1468 fn zero_width_combining_does_not_append_control_bytes() {
1469 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1470 buf.set_char(0, 0, 'a', Style::new());
1471 buf.set_string(1, 0, "\x07", Style::new());
1475 let symbol = buf.get(1, 0).symbol.as_str();
1476 assert!(!symbol.contains('\x07'), "BEL leaked into cell symbol");
1477 }
1478
1479 #[test]
1480 fn set_string_caps_combining_overflow() {
1481 let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1482 buf.set_char(0, 0, 'a', Style::new());
1483 let combining: String = "\u{0301}".repeat(200);
1487 buf.set_string(1, 0, &combining, Style::new());
1488 assert!(
1489 buf.get(0, 0).symbol.len() <= MAX_CELL_SYMBOL_BYTES,
1490 "cell symbol exceeded MAX_CELL_SYMBOL_BYTES cap"
1491 );
1492 }
1493
1494 #[test]
1495 fn sanitize_osc8_url_rejects_control_chars_and_esc() {
1496 assert!(sanitize_osc8_url("https://example.com").is_some());
1497 assert!(sanitize_osc8_url("https://example.com?q=1&r=2").is_some());
1498 assert!(sanitize_osc8_url("https://example.com\x07attack").is_none());
1500 assert!(sanitize_osc8_url("https://example.com\x1b]52;c;hi\x1b\\").is_none());
1502 assert!(sanitize_osc8_url("").is_none());
1504 assert!(sanitize_osc8_url(&"a".repeat(2049)).is_none());
1505 }
1506
1507 #[test]
1508 fn is_valid_osc8_url_matches_sanitize() {
1509 let oversize = "x".repeat(2049);
1513 let cases: &[&str] = &[
1514 "https://example.com",
1515 "http://localhost:8080/path?q=1#frag",
1516 "ftp://[::1]/file",
1517 "",
1518 &oversize,
1519 "https://evil.com\x1b]52;c;inject\x1b\\",
1520 "https://evil.com\x07bel",
1521 "https://example.com\x7f",
1522 "https://example.com\x00",
1523 ];
1524 for url in cases {
1525 assert_eq!(
1526 is_valid_osc8_url(url),
1527 sanitize_osc8_url(url).is_some(),
1528 "is_valid_osc8_url and sanitize_osc8_url disagree on {url:?}"
1529 );
1530 }
1531 }
1532
1533 #[test]
1534 fn set_string_inner_parity_no_link() {
1535 let area = Rect::new(0, 0, 20, 1);
1538 let mut buf_a = Buffer::empty(area);
1539 let mut buf_b = Buffer::empty(area);
1540 let style = Style::new();
1541
1542 buf_a.set_string(0, 0, "Hello wide世界", style);
1543 buf_b.set_string_linked(0, 0, "Hello wide世界", style, "");
1544
1545 for x in 0..20 {
1546 let ca = buf_a.get(x, 0);
1547 let cb = buf_b.get(x, 0);
1548 assert_eq!(ca.symbol, cb.symbol, "symbol mismatch at x={x}");
1549 assert_eq!(ca.style, cb.style, "style mismatch at x={x}");
1550 assert_eq!(
1551 cb.hyperlink, None,
1552 "invalid URL must produce None hyperlink at x={x}"
1553 );
1554 }
1555 }
1556
1557 #[test]
1558 fn set_string_linked_attaches_hyperlink_to_wide_char_pair() {
1559 let area = Rect::new(0, 0, 4, 1);
1561 let mut buf = Buffer::empty(area);
1562 buf.set_string_linked(0, 0, "世", Style::new(), "https://example.com");
1563 let leading = buf.get(0, 0);
1564 let trailing = buf.get(1, 0);
1565 assert_eq!(leading.symbol, "世");
1566 assert!(trailing.symbol.is_empty(), "wide-char trailing must blank");
1567 assert!(leading.hyperlink.is_some());
1568 assert_eq!(leading.hyperlink, trailing.hyperlink);
1569 }
1570
1571 #[test]
1572 fn try_get_out_of_bounds_returns_none() {
1573 let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
1574 assert!(buf.try_get(0, 0).is_some());
1575 assert!(buf.try_get(2, 0).is_none());
1576 assert!(buf.try_get(0, 2).is_none());
1577 assert!(buf.try_get_mut(5, 5).is_none());
1578 }
1579
1580 #[test]
1581 fn kitty_clip_stack_restores_outer_on_pop() {
1582 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 4));
1583 assert!(buf.current_kitty_clip().is_none());
1584
1585 let outer = KittyClipInfo {
1586 top_clip_rows: 2,
1587 original_height: 10,
1588 };
1589 let inner = KittyClipInfo {
1590 top_clip_rows: 5,
1591 original_height: 20,
1592 };
1593
1594 buf.push_kitty_clip(outer);
1595 assert_eq!(buf.current_kitty_clip(), Some(&outer));
1596
1597 buf.push_kitty_clip(inner);
1599 assert_eq!(buf.current_kitty_clip(), Some(&inner));
1600
1601 let popped_inner = buf.pop_kitty_clip();
1604 assert_eq!(popped_inner, Some(inner));
1605 assert_eq!(buf.current_kitty_clip(), Some(&outer));
1606
1607 let popped_outer = buf.pop_kitty_clip();
1608 assert_eq!(popped_outer, Some(outer));
1609 assert!(buf.current_kitty_clip().is_none());
1610 }
1611
1612 #[test]
1613 fn kitty_clip_stack_cleared_on_reset() {
1614 let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
1615 buf.push_kitty_clip(KittyClipInfo {
1616 top_clip_rows: 1,
1617 original_height: 2,
1618 });
1619 buf.push_kitty_clip(KittyClipInfo {
1620 top_clip_rows: 3,
1621 original_height: 4,
1622 });
1623 buf.reset();
1624 assert!(buf.kitty_clip_info_stack.is_empty());
1625 assert!(buf.current_kitty_clip().is_none());
1626 }
1627
1628 #[test]
1629 fn kitty_clip_pop_on_empty_stack_is_none() {
1630 let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
1631 assert!(buf.pop_kitty_clip().is_none());
1632 }
1633
1634 #[test]
1635 fn kitty_horizontal_clip_crops_source_pixels_to_visible_columns() {
1636 let rgba = Arc::new(vec![
1637 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255, ]);
1642 let placement = KittyPlacement {
1643 content_hash: hash_rgba(&rgba),
1644 rgba,
1645 src_width: 4,
1646 src_height: 1,
1647 x: 0,
1648 y: 0,
1649 cols: 2,
1650 rows: 1,
1651 crop_y: 0,
1652 crop_h: 0,
1653 };
1654 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1655 buf.push_kitty_horizontal_clip(KittyHorizontalClipInfo {
1656 left_clip_cols: 1,
1657 original_width: 4,
1658 });
1659 buf.kitty_place(placement);
1660
1661 let cropped = &buf.kitty_placements[0];
1662 assert_eq!(cropped.src_width, 2);
1663 assert_eq!(cropped.rgba.as_slice(), &[0, 255, 0, 255, 0, 0, 255, 255]);
1664 assert!(buf.pop_kitty_horizontal_clip().is_some());
1665 }
1666
1667 #[test]
1670 fn snapshot_format_default_style_unannotated() {
1671 let mut buf = Buffer::empty(Rect::new(0, 0, 5, 1));
1672 buf.set_string(0, 0, "abc", Style::new());
1673 assert_eq!(buf.snapshot_format(), "abc ");
1675 }
1676
1677 #[test]
1678 fn snapshot_format_color_runs_grouped() {
1679 use crate::style::Color;
1680 let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
1681 buf.set_string(0, 0, "abc", Style::new().fg(Color::Red));
1682 buf.set_string(3, 0, "def", Style::new().fg(Color::Blue));
1683 let snap = buf.snapshot_format();
1684 assert_eq!(snap, "[fg=red]\"abc\"[/][fg=blue]\"def\"[/]");
1685 }
1686
1687 #[test]
1688 fn snapshot_format_modifier_transitions() {
1689 let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
1690 buf.set_string(0, 0, "ab", Style::new().bold());
1691 buf.set_string(2, 0, "cd", Style::new());
1693 buf.set_string(4, 0, "ef", Style::new().bold());
1694 let snap = buf.snapshot_format();
1695 assert_eq!(snap, "[bold]\"ab\"[/]cd[bold]\"ef\"[/]");
1696 }
1697
1698 #[test]
1699 fn snapshot_format_deterministic() {
1700 use crate::style::Color;
1701 let mut buf = Buffer::empty(Rect::new(0, 0, 8, 2));
1702 buf.set_string(0, 0, "hello", Style::new().fg(Color::Cyan).bold());
1703 buf.set_string(0, 1, "world", Style::new().bg(Color::Rgb(10, 20, 30)));
1704 let a = buf.snapshot_format();
1705 let b = buf.snapshot_format();
1706 assert_eq!(a, b, "snapshot_format must be deterministic");
1707 assert_eq!(a.len(), b.len());
1709 }
1710
1711 #[test]
1712 fn snapshot_format_empty_buffer_is_spaces() {
1713 let buf = Buffer::empty(Rect::new(0, 0, 4, 2));
1714 assert_eq!(buf.snapshot_format(), " \n ");
1716 }
1717
1718 #[test]
1719 fn snapshot_format_zero_dim_returns_empty() {
1720 let buf_a = Buffer::empty(Rect::new(0, 0, 0, 4));
1721 let buf_b = Buffer::empty(Rect::new(0, 0, 4, 0));
1722 assert_eq!(buf_a.snapshot_format(), "");
1723 assert_eq!(buf_b.snapshot_format(), "");
1724 }
1725
1726 #[test]
1727 fn snapshot_format_rgb_uses_hex_codes() {
1728 use crate::style::Color;
1729 let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1730 buf.set_string(0, 0, "x", Style::new().fg(Color::Rgb(0xff, 0x00, 0xab)));
1731 let snap = buf.snapshot_format();
1732 assert!(
1733 snap.contains("fg=#ff00ab"),
1734 "expected hex RGB code, got {snap:?}"
1735 );
1736 }
1737
1738 #[test]
1739 fn snapshot_format_indexed_color() {
1740 use crate::style::Color;
1741 let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1742 buf.set_string(0, 0, "x", Style::new().fg(Color::Indexed(42)));
1743 assert!(buf.snapshot_format().contains("fg=idx42"));
1744 }
1745
1746 #[test]
1747 fn snapshot_format_modifiers_canonical_order() {
1748 let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
1750 let style = Style::new().strikethrough().italic().bold();
1751 buf.set_string(0, 0, "x", style);
1752 let snap = buf.snapshot_format();
1753 let bold_idx = snap.find("bold").expect("bold present");
1755 let italic_idx = snap.find("italic").expect("italic present");
1756 let strike_idx = snap.find("strikethrough").expect("strikethrough present");
1757 assert!(bold_idx < italic_idx);
1758 assert!(italic_idx < strike_idx);
1759 }
1760
1761 #[test]
1762 fn snapshot_format_escapes_quote_and_backslash() {
1763 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1764 buf.set_string(0, 0, "a\"b\\", Style::new().bold());
1765 let snap = buf.snapshot_format();
1766 assert!(
1768 snap.contains("\"a\\\"b\\\\\""),
1769 "expected escapes, got {snap:?}"
1770 );
1771 }
1772
1773 #[test]
1774 fn snapshot_format_multi_row_uses_newlines() {
1775 let mut buf = Buffer::empty(Rect::new(0, 0, 3, 3));
1776 buf.set_string(0, 0, "aaa", Style::new());
1777 buf.set_string(0, 1, "bbb", Style::new());
1778 buf.set_string(0, 2, "ccc", Style::new());
1779 assert_eq!(buf.snapshot_format(), "aaa\nbbb\nccc");
1780 }
1781
1782 #[test]
1785 fn line_dirty_initial_state_is_all_dirty() {
1786 let buf = Buffer::empty(Rect::new(0, 0, 4, 3));
1789 assert_eq!(buf.line_dirty.len(), 3);
1790 assert!(buf.line_dirty.iter().all(|d| *d));
1791 }
1792
1793 #[test]
1794 fn set_string_marks_row_dirty() {
1795 let mut buf = Buffer::empty(Rect::new(0, 0, 8, 4));
1798 buf.recompute_line_hashes();
1799 assert!(buf.line_dirty.iter().all(|d| !*d));
1800
1801 buf.set_string(0, 1, "hello", Style::new());
1802 assert!(!buf.line_dirty[0]);
1803 assert!(buf.line_dirty[1]);
1804 assert!(!buf.line_dirty[2]);
1805 assert!(!buf.line_dirty[3]);
1806 }
1807
1808 #[test]
1809 fn set_char_marks_row_dirty() {
1810 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 3));
1811 buf.recompute_line_hashes();
1812 buf.set_char(2, 2, 'X', Style::new());
1813 assert!(!buf.line_dirty[0]);
1814 assert!(!buf.line_dirty[1]);
1815 assert!(buf.line_dirty[2]);
1816 }
1817
1818 #[test]
1819 fn recompute_line_hashes_clears_dirty_and_caches_hashes() {
1820 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 2));
1821 buf.set_string(0, 0, "abcd", Style::new());
1822 buf.set_string(0, 1, "wxyz", Style::new());
1823 buf.recompute_line_hashes();
1824
1825 assert!(buf.line_dirty.iter().all(|d| !*d));
1826 assert_ne!(buf.line_hashes[0], buf.line_hashes[1]);
1828 assert!(buf.row_clean(0));
1829 assert!(buf.row_clean(1));
1830 }
1831
1832 #[test]
1833 fn row_clean_returns_false_for_unrecomputed_or_dirty_row() {
1834 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 2));
1835 assert!(!buf.row_clean(0));
1837 buf.recompute_line_hashes();
1838 assert!(buf.row_clean(0));
1839 buf.set_string(0, 0, "z", Style::new());
1841 assert!(!buf.row_clean(0));
1842 }
1843
1844 #[test]
1845 fn identical_buffers_share_line_hashes_after_recompute() {
1846 let area = Rect::new(0, 0, 5, 3);
1849 let mut a = Buffer::empty(area);
1850 let mut b = Buffer::empty(area);
1851 a.set_string(0, 0, "hello", Style::new());
1852 b.set_string(0, 0, "hello", Style::new());
1853 a.set_string(0, 1, "world", Style::new());
1854 b.set_string(0, 1, "world", Style::new());
1855 a.recompute_line_hashes();
1856 b.recompute_line_hashes();
1857
1858 assert_eq!(a.row_hash(0), b.row_hash(0));
1859 assert_eq!(a.row_hash(1), b.row_hash(1));
1860 assert_eq!(a.row_hash(2), b.row_hash(2));
1862 }
1863
1864 #[test]
1865 fn different_styles_yield_different_line_hashes() {
1866 use crate::style::Color;
1870 let area = Rect::new(0, 0, 3, 1);
1871 let mut a = Buffer::empty(area);
1872 let mut b = Buffer::empty(area);
1873 a.set_string(0, 0, "abc", Style::new().fg(Color::Red));
1874 b.set_string(0, 0, "abc", Style::new().fg(Color::Blue));
1875 a.recompute_line_hashes();
1876 b.recompute_line_hashes();
1877
1878 assert_ne!(a.row_hash(0), b.row_hash(0));
1879 }
1880
1881 #[test]
1882 fn resize_keeps_line_arrays_in_sync() {
1883 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 3));
1884 buf.recompute_line_hashes();
1885 buf.resize(Rect::new(0, 0, 4, 5));
1887 assert_eq!(buf.line_dirty.len(), 5);
1888 assert_eq!(buf.line_hashes.len(), 5);
1889 assert!(buf.line_dirty.iter().all(|d| *d));
1890 buf.resize(Rect::new(0, 0, 4, 2));
1892 assert_eq!(buf.line_dirty.len(), 2);
1893 assert_eq!(buf.line_hashes.len(), 2);
1894 assert!(buf.line_dirty.iter().all(|d| *d));
1895 }
1896
1897 #[test]
1898 fn checked_construction_rejects_budget_and_edge_overflow() {
1899 let oversized = Rect::new(0, 0, MAX_BUFFER_CELLS as u32 + 1, 1);
1900 assert!(matches!(
1901 Buffer::try_empty(oversized),
1902 Err(BufferError::CellBudgetExceeded {
1903 requested,
1904 maximum,
1905 }) if requested == MAX_BUFFER_CELLS as u64 + 1 && maximum == MAX_BUFFER_CELLS
1906 ));
1907 assert!(matches!(
1908 Buffer::try_empty(Rect::new(u32::MAX, 0, 1, 1)),
1909 Err(BufferError::InvalidEdges)
1910 ));
1911 assert!(matches!(
1912 Buffer::try_empty(Rect::new(0, 0, 0, u32::MAX)),
1913 Err(BufferError::RowBudgetExceeded { .. })
1914 ));
1915 assert!(Buffer::validate_area(Rect::new(12, 34, 80, 24)).is_ok());
1916 }
1917
1918 #[test]
1919 fn failed_checked_resize_preserves_existing_geometry_and_content() {
1920 let mut buf = Buffer::empty(Rect::new(7, 9, 4, 2));
1921 buf.set_string(7, 9, "safe", Style::new());
1922
1923 let result = buf.try_resize(Rect::new(0, 0, MAX_BUFFER_CELLS as u32 + 1, 1));
1924 assert!(matches!(
1925 result,
1926 Err(BufferError::CellBudgetExceeded { .. })
1927 ));
1928 assert_eq!(buf.area, Rect::new(7, 9, 4, 2));
1929 assert_eq!(buf.get(7, 9).symbol, "s");
1930 }
1931
1932 #[test]
1933 fn nonzero_origin_string_writes_clip_on_all_four_edges() {
1934 let mut buf = Buffer::empty(Rect::new(10, 20, 4, 2));
1935 buf.set_string(8, 20, "abcd", Style::new());
1936 buf.set_string(10, 19, "top", Style::new());
1937 buf.set_string(10, 22, "bottom", Style::new());
1938
1939 assert_eq!(buf.get(10, 20).symbol, "c");
1940 assert_eq!(buf.get(11, 20).symbol, "d");
1941 assert_eq!(buf.get(10, 21).symbol, " ");
1942 }
1943
1944 #[test]
1945 fn diff_with_different_origins_and_sizes_is_a_bounded_full_redraw() {
1946 let mut current = Buffer::empty(Rect::new(10, 20, 3, 2));
1947 current.set_string(10, 20, "abc", Style::new());
1948 let previous = Buffer::empty(Rect::new(0, 0, 1, 1));
1949
1950 let updates = current.diff(&previous);
1951 assert_eq!(updates.len(), current.content.len());
1952 assert_eq!((updates[0].0, updates[0].1), (10, 20));
1953 assert_eq!((updates[5].0, updates[5].1), (12, 21));
1954 }
1955
1956 #[test]
1957 fn zwj_grapheme_is_atomic_and_marks_continuation_cells() {
1958 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1959 buf.set_string(0, 0, "👩💻x", Style::new());
1960
1961 assert_eq!(buf.get(0, 0).symbol, "👩💻");
1962 assert!(buf.get(1, 0).is_continuation());
1963 assert_eq!(buf.get(2, 0).symbol, "x");
1964 }
1965
1966 #[test]
1967 fn wide_replacement_clears_continuation_and_stale_hyperlink() {
1968 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1969 buf.set_string_linked(1, 0, "世", Style::new(), "https://example.com");
1970 buf.set_char(1, 0, 'a', Style::new());
1971
1972 assert_eq!(buf.get(1, 0).symbol, "a");
1973 assert_eq!(buf.get(2, 0).symbol, " ");
1974 assert!(buf.get(1, 0).hyperlink.is_none());
1975 assert!(buf.get(2, 0).hyperlink.is_none());
1976 }
1977
1978 #[test]
1979 fn wide_write_never_splits_at_area_or_clip_boundary() {
1980 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1981 buf.set_char(3, 0, 'x', Style::new());
1982 buf.set_string(3, 0, "世", Style::new());
1983 assert_eq!(buf.get(3, 0).symbol, "x");
1984
1985 buf.set_string(1, 0, "世", Style::new());
1986 buf.push_clip(Rect::new(1, 0, 1, 1));
1987 buf.set_char(1, 0, 'a', Style::new());
1988 assert_eq!(buf.get(1, 0).symbol, "世");
1989 assert!(buf.get(2, 0).is_continuation());
1990 }
1991
1992 #[test]
1993 fn fnv1a_distinct_rows_distinct_identical_rows_collide() {
1994 let area = Rect::new(0, 0, 5, 3);
1998 let mut buf = Buffer::empty(area);
1999 buf.set_string(0, 0, "alpha", Style::new());
2000 buf.set_string(0, 1, "alpha", Style::new()); buf.set_string(0, 2, "omega", Style::new()); buf.recompute_line_hashes();
2003
2004 assert_eq!(
2005 buf.row_hash(0),
2006 buf.row_hash(1),
2007 "identical rows must collide"
2008 );
2009 assert_ne!(
2010 buf.row_hash(0),
2011 buf.row_hash(2),
2012 "distinct rows must not collide"
2013 );
2014 }
2015
2016 #[test]
2017 fn fnv1a_hash_rgba_is_deterministic_and_content_sensitive() {
2018 let a = [1u8, 2, 3, 4];
2021 let b = [1u8, 2, 3, 4];
2022 let c = [1u8, 2, 3, 5];
2023 assert_eq!(hash_rgba(&a), hash_rgba(&b));
2024 assert_ne!(hash_rgba(&a), hash_rgba(&c));
2025 assert_eq!(hash_rgba(&a), hash_rgba(&a));
2027 }
2028
2029 #[cfg(feature = "bidi")]
2035 fn line_visual(buf: &Buffer, y: u32) -> String {
2036 let mut s = String::new();
2037 for x in buf.area.x..buf.area.right() {
2038 let sym = buf.get(x, y).symbol.as_str();
2039 if sym.is_empty() {
2040 continue; }
2042 s.push_str(sym);
2043 }
2044 s.trim_end().to_string()
2045 }
2046
2047 #[cfg(feature = "bidi")]
2048 #[test]
2049 fn needs_bidi_reorder_false_for_pure_ltr() {
2050 assert!(!needs_bidi_reorder("Hello, world 123"));
2052 assert!(!needs_bidi_reorder(""));
2053 assert!(!needs_bidi_reorder("café résumé"));
2054 assert!(!needs_bidi_reorder("世界 CJK wide"));
2055 }
2056
2057 #[cfg(feature = "bidi")]
2058 #[test]
2059 fn needs_bidi_reorder_true_for_rtl_and_controls() {
2060 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}")); }
2066
2067 #[cfg(feature = "bidi")]
2068 #[test]
2069 fn set_string_ltr_unchanged_by_reorder_path() {
2070 let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
2072 buf.set_string(0, 0, "abcde", Style::new());
2073 assert_eq!(buf.get(0, 0).symbol, "a");
2074 assert_eq!(buf.get(1, 0).symbol, "b");
2075 assert_eq!(buf.get(2, 0).symbol, "c");
2076 assert_eq!(buf.get(3, 0).symbol, "d");
2077 assert_eq!(buf.get(4, 0).symbol, "e");
2078 }
2079
2080 #[cfg(feature = "bidi")]
2081 #[test]
2082 fn set_string_pure_rtl_reverses_to_visual_order() {
2083 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
2087 buf.set_string(0, 0, "\u{05E9}\u{05DC}\u{05D5}\u{05DD}", Style::new());
2088 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}");
2092 }
2093
2094 #[cfg(feature = "bidi")]
2095 #[test]
2096 fn set_string_mixed_ltr_rtl_run() {
2097 let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1));
2100 buf.set_string(0, 0, "abc \u{05D0}\u{05D1}\u{05D2}", Style::new());
2101 assert_eq!(line_visual(&buf, 0), "abc \u{05D2}\u{05D1}\u{05D0}");
2102 }
2103
2104 #[cfg(feature = "bidi")]
2105 #[test]
2106 fn set_string_numbers_inside_rtl_stay_ltr() {
2107 let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1));
2111 buf.set_string(0, 0, "123 \u{05D0}\u{05D1}\u{05D2}", Style::new());
2112 assert_eq!(line_visual(&buf, 0), "\u{05D2}\u{05D1}\u{05D0} 123");
2113 }
2114
2115 #[cfg(feature = "bidi")]
2116 #[test]
2117 fn set_string_wide_char_with_rtl_blanks_trailing_cell() {
2118 let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
2123 buf.set_string(0, 0, "\u{4E16} \u{05D0}\u{05D1}", Style::new());
2124 assert_eq!(buf.get(0, 0).symbol, "\u{4E16}"); assert!(buf.get(1, 0).symbol.is_empty(), "wide trailing must blank");
2126 assert_eq!(buf.get(3, 0).symbol, "\u{05D1}"); assert_eq!(buf.get(4, 0).symbol, "\u{05D0}"); }
2129
2130 #[cfg(feature = "bidi")]
2131 #[test]
2132 fn set_string_linked_hyperlink_survives_reorder() {
2133 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
2136 buf.set_string_linked(
2137 0,
2138 0,
2139 "\u{05E9}\u{05DC}\u{05D5}\u{05DD}",
2140 Style::new(),
2141 "https://example.com",
2142 );
2143 for x in 0..4 {
2144 let cell = buf.get(x, 0);
2145 assert!(
2146 cell.hyperlink.is_some(),
2147 "hyperlink missing at visual column {x}"
2148 );
2149 }
2150 }
2151
2152 #[cfg(feature = "bidi")]
2153 #[test]
2154 fn set_string_control_chars_filtered_in_rtl() {
2155 let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
2158 buf.set_string(0, 0, "\u{05D0}\x1b\u{05D1}", Style::new());
2159 let mut found_replacement = false;
2160 for x in 0..6 {
2161 let sym = buf.get(x, 0).symbol.as_str();
2162 assert!(!sym.contains('\x1b'), "ESC leaked into a cell");
2163 if sym.contains('\u{FFFD}') {
2164 found_replacement = true;
2165 }
2166 }
2167 assert!(found_replacement, "ESC was not replaced with U+FFFD");
2168 }
2169
2170 #[cfg(feature = "bidi")]
2171 #[test]
2172 fn reorder_line_visual_empty_is_noop() {
2173 assert_eq!(reorder_line_visual(""), "");
2174 }
2175
2176 mod geometry_proptest {
2177 use super::*;
2178 use proptest::prelude::*;
2179
2180 proptest! {
2181 #![proptest_config(ProptestConfig::with_cases(256))]
2182
2183 #[test]
2184 fn origin_writes_and_mismatched_diffs_never_panic(
2185 x in 0u32..200,
2186 y in 0u32..200,
2187 width in 0u32..32,
2188 height in 0u32..16,
2189 other_x in 0u32..200,
2190 other_y in 0u32..200,
2191 other_width in 0u32..32,
2192 other_height in 0u32..16,
2193 text in ".{0,48}",
2194 ) {
2195 let area = Rect::new(x, y, width, height);
2196 let other_area = Rect::new(other_x, other_y, other_width, other_height);
2197 let mut current = Buffer::try_empty(area).expect("small geometry is valid");
2198 let previous = Buffer::try_empty(other_area).expect("small geometry is valid");
2199
2200 current.set_string(x.saturating_sub(3), y.saturating_sub(3), &text, Style::new());
2201 let updates = current.diff(&previous);
2202 prop_assert!(updates.len() <= current.content.len());
2203 prop_assert!(updates.iter().all(|(cx, cy, _)| current.in_bounds(*cx, *cy)));
2204 }
2205 }
2206 }
2207
2208 #[cfg(feature = "bidi")]
2209 mod bidi_proptest {
2210 use super::{needs_bidi_reorder, reorder_line_visual};
2211 use proptest::prelude::*;
2212
2213 proptest! {
2214 #![proptest_config(ProptestConfig::with_cases(256))]
2215
2216 #[test]
2218 fn ascii_takes_fast_path_and_reorder_is_identity(s in "[ -~]{0,64}") {
2219 prop_assert!(!needs_bidi_reorder(&s));
2220 prop_assert_eq!(reorder_line_visual(&s), s);
2222 }
2223
2224 #[test]
2233 fn reorder_is_codepoint_permutation(
2234 s in "[a-z\\x{05D0}-\\x{05EA}\\x{0627}-\\x{064A}0-9 ]{0,48}"
2235 ) {
2236 let mut before: Vec<char> = s.chars().collect();
2237 let mut after: Vec<char> = reorder_line_visual(&s).chars().collect();
2238 before.sort_unstable();
2239 after.sort_unstable();
2240 prop_assert_eq!(before, after);
2241 }
2242 }
2243 }
2244}