1use std::hash::{Hash, Hasher};
8use std::sync::Arc;
9
10use crate::cell::Cell;
11use crate::rect::Rect;
12use crate::style::Style;
13use unicode_width::UnicodeWidthChar;
14
15const MAX_CELL_SYMBOL_BYTES: usize = 32;
21
22pub(crate) const MAX_IMAGE_PIXELS: u64 = 16_777_216;
28
29#[inline]
37fn sanitize_cell_char(ch: char) -> char {
38 let c = ch as u32;
39 if c < 0x20 || c == 0x7f || (0x80..=0x9f).contains(&c) {
40 '\u{FFFD}'
41 } else {
42 ch
43 }
44}
45
46#[derive(Clone, Debug)]
52#[allow(dead_code)]
53pub(crate) struct KittyPlacement {
54 pub content_hash: u64,
56 pub rgba: Arc<Vec<u8>>,
58 pub src_width: u32,
60 pub src_height: u32,
62 pub x: u32,
64 pub y: u32,
65 pub cols: u32,
67 pub rows: u32,
68 pub crop_y: u32,
70 pub crop_h: u32,
72}
73
74pub(crate) fn hash_rgba(data: &[u8]) -> u64 {
76 let mut hasher = std::collections::hash_map::DefaultHasher::new();
77 data.hash(&mut hasher);
78 hasher.finish()
79}
80
81impl PartialEq for KittyPlacement {
82 fn eq(&self, other: &Self) -> bool {
83 self.content_hash == other.content_hash
84 && self.x == other.x
85 && self.y == other.y
86 && self.cols == other.cols
87 && self.rows == other.rows
88 && self.crop_y == other.crop_y
89 && self.crop_h == other.crop_h
90 }
91}
92
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99pub(crate) struct KittyClipInfo {
100 pub top_clip_rows: u32,
102 pub original_height: u32,
104}
105
106pub struct Buffer {
115 pub area: Rect,
117 pub content: Vec<Cell>,
119 pub(crate) clip_stack: Vec<Rect>,
120 pub(crate) raw_sequences: Vec<(u32, u32, String)>,
121 pub(crate) kitty_placements: Vec<KittyPlacement>,
122 pub(crate) cursor_pos: Option<(u32, u32)>,
123 pub(crate) kitty_clip_info_stack: Vec<KittyClipInfo>,
127 pub(crate) line_hashes: Vec<u64>,
135 pub(crate) line_dirty: Vec<bool>,
145}
146
147impl Buffer {
148 pub fn empty(area: Rect) -> Self {
150 let size = area.area() as usize;
151 let height = area.height as usize;
152 Self {
153 area,
154 content: vec![Cell::default(); size],
155 clip_stack: Vec::new(),
156 raw_sequences: Vec::new(),
157 kitty_placements: Vec::new(),
158 cursor_pos: None,
159 kitty_clip_info_stack: Vec::new(),
160 line_hashes: vec![0; height],
164 line_dirty: vec![true; height],
165 }
166 }
167
168 pub(crate) fn push_kitty_clip(&mut self, info: KittyClipInfo) {
170 self.kitty_clip_info_stack.push(info);
171 }
172
173 pub(crate) fn pop_kitty_clip(&mut self) -> Option<KittyClipInfo> {
175 self.kitty_clip_info_stack.pop()
176 }
177
178 pub(crate) fn current_kitty_clip(&self) -> Option<&KittyClipInfo> {
180 self.kitty_clip_info_stack.last()
181 }
182
183 pub(crate) fn set_cursor_pos(&mut self, x: u32, y: u32) {
184 self.cursor_pos = Some((x, y));
185 }
186
187 #[cfg(feature = "crossterm")]
188 pub(crate) fn cursor_pos(&self) -> Option<(u32, u32)> {
189 self.cursor_pos
190 }
191
192 pub fn raw_sequence(&mut self, x: u32, y: u32, seq: String) {
197 if let Some(clip) = self.effective_clip() {
198 if x >= clip.right() || y >= clip.bottom() {
199 return;
200 }
201 }
202 self.raw_sequences.push((x, y, seq));
203 }
204
205 pub(crate) fn kitty_place(&mut self, mut p: KittyPlacement) {
212 if let Some(clip) = self.effective_clip() {
214 if p.x >= clip.right()
215 || p.y >= clip.bottom()
216 || p.x + p.cols <= clip.x
217 || p.y + p.rows <= clip.y
218 {
219 return;
220 }
221 }
222
223 if let Some(info) = self.current_kitty_clip() {
225 let top_clip_rows = info.top_clip_rows;
226 let original_height = info.original_height;
227 if original_height > 0 && (top_clip_rows > 0 || p.rows < original_height) {
228 let ratio = p.src_height as f64 / original_height as f64;
229 p.crop_y = (top_clip_rows as f64 * ratio) as u32;
230 let bottom_clip = original_height.saturating_sub(top_clip_rows + p.rows);
231 let bottom_pixels = (bottom_clip as f64 * ratio) as u32;
232 p.crop_h = p.src_height.saturating_sub(p.crop_y + bottom_pixels);
233 }
234 }
235
236 self.kitty_placements.push(p);
237 }
238
239 pub fn push_clip(&mut self, rect: Rect) {
245 let effective = if let Some(current) = self.clip_stack.last() {
246 intersect_rects(*current, rect)
247 } else {
248 rect
249 };
250 self.clip_stack.push(effective);
251 }
252
253 pub fn pop_clip(&mut self) {
258 self.clip_stack.pop();
259 }
260
261 fn effective_clip(&self) -> Option<&Rect> {
262 self.clip_stack.last()
263 }
264
265 #[inline]
266 fn index_of(&self, x: u32, y: u32) -> usize {
267 ((y - self.area.y) * self.area.width + (x - self.area.x)) as usize
268 }
269
270 #[inline]
272 pub fn in_bounds(&self, x: u32, y: u32) -> bool {
273 x >= self.area.x && x < self.area.right() && y >= self.area.y && y < self.area.bottom()
274 }
275
276 #[inline]
281 pub fn get(&self, x: u32, y: u32) -> &Cell {
282 assert!(
283 self.in_bounds(x, y),
284 "Buffer::get({x}, {y}) out of bounds for area {:?}",
285 self.area
286 );
287 &self.content[self.index_of(x, y)]
288 }
289
290 #[inline]
295 pub fn get_mut(&mut self, x: u32, y: u32) -> &mut Cell {
296 assert!(
297 self.in_bounds(x, y),
298 "Buffer::get_mut({x}, {y}) out of bounds for area {:?}",
299 self.area
300 );
301 let idx = self.index_of(x, y);
302 &mut self.content[idx]
303 }
304
305 #[inline]
311 pub fn try_get(&self, x: u32, y: u32) -> Option<&Cell> {
312 if self.in_bounds(x, y) {
313 Some(&self.content[self.index_of(x, y)])
314 } else {
315 None
316 }
317 }
318
319 #[inline]
324 pub fn try_get_mut(&mut self, x: u32, y: u32) -> Option<&mut Cell> {
325 if self.in_bounds(x, y) {
326 let idx = self.index_of(x, y);
327 Some(&mut self.content[idx])
328 } else {
329 None
330 }
331 }
332
333 pub fn set_string(&mut self, x: u32, y: u32, s: &str, style: Style) {
340 self.set_string_inner(x, y, s, style, None);
341 }
342
343 pub fn set_string_linked(&mut self, x: u32, y: u32, s: &str, style: Style, url: &str) {
348 let link = sanitize_osc8_url(url).map(compact_str::CompactString::new);
349 self.set_string_inner(x, y, s, style, link.as_ref());
350 }
351
352 fn set_string_inner(
360 &mut self,
361 mut x: u32,
362 y: u32,
363 s: &str,
364 style: Style,
365 link: Option<&compact_str::CompactString>,
366 ) {
367 if y >= self.area.bottom() {
368 return;
369 }
370 self.mark_row_dirty(y);
375 let clip = self.effective_clip().copied();
376 for ch in s.chars() {
377 if x >= self.area.right() {
378 break;
379 }
380 let ch = sanitize_cell_char(ch);
381 let char_width = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
382 if char_width == 0 {
383 if x > self.area.x {
386 let prev_in_clip = clip.map_or(true, |clip| {
387 (x - 1) >= clip.x
388 && (x - 1) < clip.right()
389 && y >= clip.y
390 && y < clip.bottom()
391 });
392 if prev_in_clip {
393 let prev = self.get_mut(x - 1, y);
394 if prev.symbol.len() + ch.len_utf8() <= MAX_CELL_SYMBOL_BYTES {
395 prev.symbol.push(ch);
396 }
397 }
398 }
399 continue;
400 }
401
402 let in_clip = clip.map_or(true, |clip| {
403 x >= clip.x && x < clip.right() && y >= clip.y && y < clip.bottom()
404 });
405
406 if !in_clip {
407 x = x.saturating_add(char_width);
408 continue;
409 }
410
411 let cell = self.get_mut(x, y);
412 cell.set_char(ch);
413 cell.set_style(style);
414 cell.hyperlink = link.cloned();
415
416 if char_width > 1 {
418 let next_x = x + 1;
419 if next_x < self.area.right() {
420 let next = self.get_mut(next_x, y);
421 next.symbol.clear();
422 next.style = style;
423 next.hyperlink = link.cloned();
424 }
425 }
426
427 x = x.saturating_add(char_width);
428 }
429 }
430
431 pub fn set_char(&mut self, x: u32, y: u32, ch: char, style: Style) {
435 let in_clip = self.effective_clip().map_or(true, |clip| {
436 x >= clip.x && x < clip.right() && y >= clip.y && y < clip.bottom()
437 });
438 if !self.in_bounds(x, y) || !in_clip {
439 return;
440 }
441 self.mark_row_dirty(y);
444 let cell = self.get_mut(x, y);
445 cell.set_char(ch);
446 cell.set_style(style);
447 }
448
449 #[inline]
455 pub(crate) fn mark_row_dirty(&mut self, y: u32) {
456 if y < self.area.y {
457 return;
458 }
459 let idx = (y - self.area.y) as usize;
460 if let Some(slot) = self.line_dirty.get_mut(idx) {
461 *slot = true;
462 }
463 }
464
465 #[cfg(any(feature = "crossterm", test))]
479 pub(crate) fn recompute_line_hashes(&mut self) {
480 let height = self.area.height;
481 if height == 0 {
482 return;
483 }
484 let expected_len = height as usize;
489 if self.line_hashes.len() != expected_len {
490 self.line_hashes.resize(expected_len, 0);
491 }
492 if self.line_dirty.len() != expected_len {
493 self.line_dirty.resize(expected_len, true);
494 }
495
496 let width = self.area.width as usize;
497 for (idx, dirty) in self.line_dirty.iter_mut().enumerate() {
498 if !*dirty {
499 continue;
500 }
501 let row_start = idx * width;
502 let row_end = row_start + width;
503 let mut hasher = std::collections::hash_map::DefaultHasher::new();
504 for cell in &self.content[row_start..row_end] {
505 cell.symbol.as_str().hash(&mut hasher);
506 cell.style.hash(&mut hasher);
507 cell.hyperlink.as_deref().hash(&mut hasher);
508 }
509 self.line_hashes[idx] = hasher.finish();
510 *dirty = false;
511 }
512 }
513
514 #[inline]
524 #[cfg(any(feature = "crossterm", test))]
525 pub(crate) fn row_clean(&self, y: u32) -> bool {
526 if y < self.area.y {
527 return false;
528 }
529 let idx = (y - self.area.y) as usize;
530 self.line_dirty
531 .get(idx)
532 .copied()
533 .map(|d| !d)
534 .unwrap_or(false)
535 }
536
537 #[inline]
543 #[cfg(any(feature = "crossterm", test))]
544 pub(crate) fn row_hash(&self, y: u32) -> Option<u64> {
545 if y < self.area.y {
546 return None;
547 }
548 let idx = (y - self.area.y) as usize;
549 self.line_hashes.get(idx).copied()
550 }
551
552 pub fn diff<'a>(&'a self, other: &'a Buffer) -> Vec<(u32, u32, &'a Cell)> {
570 let mut updates = Vec::new();
571 for y in self.area.y..self.area.bottom() {
572 for x in self.area.x..self.area.right() {
573 let cur = self.get(x, y);
574 let prev = other.get(x, y);
575 if cur != prev {
576 updates.push((x, y, cur));
577 }
578 }
579 }
580 updates
581 }
582
583 pub fn reset(&mut self) {
585 for cell in &mut self.content {
586 cell.reset();
587 }
588 self.clip_stack.clear();
589 self.raw_sequences.clear();
590 self.kitty_placements.clear();
591 self.cursor_pos = None;
592 self.kitty_clip_info_stack.clear();
593 for d in &mut self.line_dirty {
596 *d = true;
597 }
598 }
599
600 pub fn reset_with_bg(&mut self, bg: crate::style::Color) {
602 for cell in &mut self.content {
603 cell.reset();
604 cell.style.bg = Some(bg);
605 }
606 self.clip_stack.clear();
607 self.raw_sequences.clear();
608 self.kitty_placements.clear();
609 self.cursor_pos = None;
610 self.kitty_clip_info_stack.clear();
611 for d in &mut self.line_dirty {
613 *d = true;
614 }
615 }
616
617 pub fn resize(&mut self, area: Rect) {
622 self.area = area;
623 let size = area.area() as usize;
624 self.content.resize(size, Cell::default());
625 let height = area.height as usize;
629 self.line_hashes.resize(height, 0);
630 self.line_dirty.resize(height, true);
631 self.reset();
632 }
633
634 pub fn snapshot_format(&self) -> String {
693 let mut out = String::new();
694 let width = self.area.width;
695 let height = self.area.height;
696 if width == 0 || height == 0 {
697 return out;
698 }
699
700 for y in self.area.y..self.area.bottom() {
701 if y > self.area.y {
702 out.push('\n');
703 }
704
705 let mut current_style: Option<Style> = None;
707 let mut run_text = String::new();
708
709 for x in self.area.x..self.area.right() {
710 let cell = self.get(x, y);
711 let style = cell.style;
712 let sym: &str = if cell.symbol.is_empty() {
714 " "
715 } else {
716 cell.symbol.as_str()
717 };
718
719 match current_style {
720 Some(s) if s == style => {
721 run_text.push_str(sym);
722 }
723 _ => {
724 if let Some(s) = current_style.take() {
725 flush_run(&mut out, s, &run_text);
726 run_text.clear();
727 }
728 current_style = Some(style);
729 run_text.push_str(sym);
730 }
731 }
732 }
733
734 if let Some(s) = current_style {
735 flush_run(&mut out, s, &run_text);
736 }
737 }
738
739 out
740 }
741}
742
743fn flush_run(out: &mut String, style: Style, text: &str) {
750 if style == Style::default() {
751 out.push_str(text);
752 return;
753 }
754 out.push('[');
755 let mut first = true;
756 if let Some(fg) = style.fg {
757 out.push_str("fg=");
758 write_color(out, fg);
759 first = false;
760 }
761 if let Some(bg) = style.bg {
762 if !first {
763 out.push(',');
764 }
765 out.push_str("bg=");
766 write_color(out, bg);
767 first = false;
768 }
769 let mods = style.modifiers;
770 let pairs: [(crate::style::Modifiers, &str); 6] = [
772 (crate::style::Modifiers::BOLD, "bold"),
773 (crate::style::Modifiers::DIM, "dim"),
774 (crate::style::Modifiers::ITALIC, "italic"),
775 (crate::style::Modifiers::UNDERLINE, "underline"),
776 (crate::style::Modifiers::REVERSED, "reversed"),
777 (crate::style::Modifiers::STRIKETHROUGH, "strikethrough"),
778 ];
779 for (bit, name) in pairs {
780 if mods.contains(bit) {
781 if !first {
782 out.push(',');
783 }
784 out.push_str(name);
785 first = false;
786 }
787 }
788 out.push(']');
789 out.push('"');
790 for ch in text.chars() {
791 match ch {
792 '"' => out.push_str("\\\""),
793 '\\' => out.push_str("\\\\"),
794 other => out.push(other),
795 }
796 }
797 out.push('"');
798 out.push_str("[/]");
799}
800
801fn write_color(out: &mut String, color: crate::style::Color) {
806 use crate::style::Color;
807 match color {
808 Color::Reset => out.push_str("reset"),
809 Color::Black => out.push_str("black"),
810 Color::Red => out.push_str("red"),
811 Color::Green => out.push_str("green"),
812 Color::Yellow => out.push_str("yellow"),
813 Color::Blue => out.push_str("blue"),
814 Color::Magenta => out.push_str("magenta"),
815 Color::Cyan => out.push_str("cyan"),
816 Color::White => out.push_str("white"),
817 Color::DarkGray => out.push_str("dark_gray"),
818 Color::LightRed => out.push_str("light_red"),
819 Color::LightGreen => out.push_str("light_green"),
820 Color::LightYellow => out.push_str("light_yellow"),
821 Color::LightBlue => out.push_str("light_blue"),
822 Color::LightMagenta => out.push_str("light_magenta"),
823 Color::LightCyan => out.push_str("light_cyan"),
824 Color::LightWhite => out.push_str("light_white"),
825 Color::Rgb(r, g, b) => {
826 use std::fmt::Write;
827 let _ = write!(out, "#{:02x}{:02x}{:02x}", r, g, b);
828 }
829 Color::Indexed(idx) => {
830 use std::fmt::Write;
831 let _ = write!(out, "idx{}", idx);
832 }
833 }
834}
835
836const MAX_OSC8_URL_BYTES: usize = 2048;
842
843#[inline]
850pub(crate) fn is_valid_osc8_url(url: &str) -> bool {
851 if url.is_empty() || url.len() > MAX_OSC8_URL_BYTES {
852 return false;
853 }
854 url.bytes().all(|b| b >= 0x20 && b != 0x7f)
860}
861
862pub(crate) fn sanitize_osc8_url(url: &str) -> Option<String> {
872 if is_valid_osc8_url(url) {
873 Some(url.to_string())
874 } else {
875 None
876 }
877}
878
879fn intersect_rects(a: Rect, b: Rect) -> Rect {
880 let x = a.x.max(b.x);
881 let y = a.y.max(b.y);
882 let right = a.right().min(b.right());
883 let bottom = a.bottom().min(b.bottom());
884 let width = right.saturating_sub(x);
885 let height = bottom.saturating_sub(y);
886 Rect::new(x, y, width, height)
887}
888
889#[cfg(test)]
890mod tests {
891 use super::*;
892
893 #[test]
894 fn clip_stack_intersects_nested_regions() {
895 let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
896 buf.push_clip(Rect::new(1, 1, 6, 3));
897 buf.push_clip(Rect::new(4, 0, 6, 4));
898
899 buf.set_char(3, 2, 'x', Style::new());
900 buf.set_char(4, 2, 'y', Style::new());
901
902 assert_eq!(buf.get(3, 2).symbol, " ");
903 assert_eq!(buf.get(4, 2).symbol, "y");
904 }
905
906 #[test]
907 fn set_string_advances_even_when_clipped() {
908 let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1));
909 buf.push_clip(Rect::new(2, 0, 6, 1));
910
911 buf.set_string(0, 0, "abcd", Style::new());
912
913 assert_eq!(buf.get(2, 0).symbol, "c");
914 assert_eq!(buf.get(3, 0).symbol, "d");
915 }
916
917 #[test]
918 fn pop_clip_restores_previous_clip() {
919 let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
920 buf.push_clip(Rect::new(0, 0, 2, 1));
921 buf.push_clip(Rect::new(4, 0, 2, 1));
922
923 buf.set_char(1, 0, 'a', Style::new());
924 buf.pop_clip();
925 buf.set_char(1, 0, 'b', Style::new());
926
927 assert_eq!(buf.get(1, 0).symbol, "b");
928 }
929
930 #[test]
931 fn reset_clears_clip_stack() {
932 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
933 buf.push_clip(Rect::new(0, 0, 0, 0));
934 buf.reset();
935 buf.set_char(0, 0, 'z', Style::new());
936
937 assert_eq!(buf.get(0, 0).symbol, "z");
938 }
939
940 #[test]
941 fn set_string_replaces_control_chars_with_replacement() {
942 let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
943 buf.set_string(0, 0, "a\x1bbc", Style::new());
946 assert_eq!(buf.get(0, 0).symbol, "a");
947 assert_eq!(buf.get(1, 0).symbol, "\u{FFFD}");
948 assert_eq!(buf.get(2, 0).symbol, "b");
949 assert_eq!(buf.get(3, 0).symbol, "c");
950 }
951
952 #[test]
953 fn zero_width_combining_does_not_append_control_bytes() {
954 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
955 buf.set_char(0, 0, 'a', Style::new());
956 buf.set_string(1, 0, "\x07", Style::new());
960 let symbol = buf.get(1, 0).symbol.as_str();
961 assert!(!symbol.contains('\x07'), "BEL leaked into cell symbol");
962 }
963
964 #[test]
965 fn set_string_caps_combining_overflow() {
966 let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
967 buf.set_char(0, 0, 'a', Style::new());
968 let combining: String = "\u{0301}".repeat(200);
972 buf.set_string(1, 0, &combining, Style::new());
973 assert!(
974 buf.get(0, 0).symbol.len() <= MAX_CELL_SYMBOL_BYTES,
975 "cell symbol exceeded MAX_CELL_SYMBOL_BYTES cap"
976 );
977 }
978
979 #[test]
980 fn sanitize_osc8_url_rejects_control_chars_and_esc() {
981 assert!(sanitize_osc8_url("https://example.com").is_some());
982 assert!(sanitize_osc8_url("https://example.com?q=1&r=2").is_some());
983 assert!(sanitize_osc8_url("https://example.com\x07attack").is_none());
985 assert!(sanitize_osc8_url("https://example.com\x1b]52;c;hi\x1b\\").is_none());
987 assert!(sanitize_osc8_url("").is_none());
989 assert!(sanitize_osc8_url(&"a".repeat(2049)).is_none());
990 }
991
992 #[test]
993 fn is_valid_osc8_url_matches_sanitize() {
994 let oversize = "x".repeat(2049);
998 let cases: &[&str] = &[
999 "https://example.com",
1000 "http://localhost:8080/path?q=1#frag",
1001 "ftp://[::1]/file",
1002 "",
1003 &oversize,
1004 "https://evil.com\x1b]52;c;inject\x1b\\",
1005 "https://evil.com\x07bel",
1006 "https://example.com\x7f",
1007 "https://example.com\x00",
1008 ];
1009 for url in cases {
1010 assert_eq!(
1011 is_valid_osc8_url(url),
1012 sanitize_osc8_url(url).is_some(),
1013 "is_valid_osc8_url and sanitize_osc8_url disagree on {url:?}"
1014 );
1015 }
1016 }
1017
1018 #[test]
1019 fn set_string_inner_parity_no_link() {
1020 let area = Rect::new(0, 0, 20, 1);
1023 let mut buf_a = Buffer::empty(area);
1024 let mut buf_b = Buffer::empty(area);
1025 let style = Style::new();
1026
1027 buf_a.set_string(0, 0, "Hello wide世界", style);
1028 buf_b.set_string_linked(0, 0, "Hello wide世界", style, "");
1029
1030 for x in 0..20 {
1031 let ca = buf_a.get(x, 0);
1032 let cb = buf_b.get(x, 0);
1033 assert_eq!(ca.symbol, cb.symbol, "symbol mismatch at x={x}");
1034 assert_eq!(ca.style, cb.style, "style mismatch at x={x}");
1035 assert_eq!(
1036 cb.hyperlink, None,
1037 "invalid URL must produce None hyperlink at x={x}"
1038 );
1039 }
1040 }
1041
1042 #[test]
1043 fn set_string_linked_attaches_hyperlink_to_wide_char_pair() {
1044 let area = Rect::new(0, 0, 4, 1);
1046 let mut buf = Buffer::empty(area);
1047 buf.set_string_linked(0, 0, "世", Style::new(), "https://example.com");
1048 let leading = buf.get(0, 0);
1049 let trailing = buf.get(1, 0);
1050 assert_eq!(leading.symbol, "世");
1051 assert!(trailing.symbol.is_empty(), "wide-char trailing must blank");
1052 assert!(leading.hyperlink.is_some());
1053 assert_eq!(leading.hyperlink, trailing.hyperlink);
1054 }
1055
1056 #[test]
1057 fn try_get_out_of_bounds_returns_none() {
1058 let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
1059 assert!(buf.try_get(0, 0).is_some());
1060 assert!(buf.try_get(2, 0).is_none());
1061 assert!(buf.try_get(0, 2).is_none());
1062 assert!(buf.try_get_mut(5, 5).is_none());
1063 }
1064
1065 #[test]
1066 fn kitty_clip_stack_restores_outer_on_pop() {
1067 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 4));
1068 assert!(buf.current_kitty_clip().is_none());
1069
1070 let outer = KittyClipInfo {
1071 top_clip_rows: 2,
1072 original_height: 10,
1073 };
1074 let inner = KittyClipInfo {
1075 top_clip_rows: 5,
1076 original_height: 20,
1077 };
1078
1079 buf.push_kitty_clip(outer);
1080 assert_eq!(buf.current_kitty_clip(), Some(&outer));
1081
1082 buf.push_kitty_clip(inner);
1084 assert_eq!(buf.current_kitty_clip(), Some(&inner));
1085
1086 let popped_inner = buf.pop_kitty_clip();
1089 assert_eq!(popped_inner, Some(inner));
1090 assert_eq!(buf.current_kitty_clip(), Some(&outer));
1091
1092 let popped_outer = buf.pop_kitty_clip();
1093 assert_eq!(popped_outer, Some(outer));
1094 assert!(buf.current_kitty_clip().is_none());
1095 }
1096
1097 #[test]
1098 fn kitty_clip_stack_cleared_on_reset() {
1099 let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
1100 buf.push_kitty_clip(KittyClipInfo {
1101 top_clip_rows: 1,
1102 original_height: 2,
1103 });
1104 buf.push_kitty_clip(KittyClipInfo {
1105 top_clip_rows: 3,
1106 original_height: 4,
1107 });
1108 buf.reset();
1109 assert!(buf.kitty_clip_info_stack.is_empty());
1110 assert!(buf.current_kitty_clip().is_none());
1111 }
1112
1113 #[test]
1114 fn kitty_clip_pop_on_empty_stack_is_none() {
1115 let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
1116 assert!(buf.pop_kitty_clip().is_none());
1117 }
1118
1119 #[test]
1122 fn snapshot_format_default_style_unannotated() {
1123 let mut buf = Buffer::empty(Rect::new(0, 0, 5, 1));
1124 buf.set_string(0, 0, "abc", Style::new());
1125 assert_eq!(buf.snapshot_format(), "abc ");
1127 }
1128
1129 #[test]
1130 fn snapshot_format_color_runs_grouped() {
1131 use crate::style::Color;
1132 let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
1133 buf.set_string(0, 0, "abc", Style::new().fg(Color::Red));
1134 buf.set_string(3, 0, "def", Style::new().fg(Color::Blue));
1135 let snap = buf.snapshot_format();
1136 assert_eq!(snap, "[fg=red]\"abc\"[/][fg=blue]\"def\"[/]");
1137 }
1138
1139 #[test]
1140 fn snapshot_format_modifier_transitions() {
1141 let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
1142 buf.set_string(0, 0, "ab", Style::new().bold());
1143 buf.set_string(2, 0, "cd", Style::new());
1145 buf.set_string(4, 0, "ef", Style::new().bold());
1146 let snap = buf.snapshot_format();
1147 assert_eq!(snap, "[bold]\"ab\"[/]cd[bold]\"ef\"[/]");
1148 }
1149
1150 #[test]
1151 fn snapshot_format_deterministic() {
1152 use crate::style::Color;
1153 let mut buf = Buffer::empty(Rect::new(0, 0, 8, 2));
1154 buf.set_string(0, 0, "hello", Style::new().fg(Color::Cyan).bold());
1155 buf.set_string(0, 1, "world", Style::new().bg(Color::Rgb(10, 20, 30)));
1156 let a = buf.snapshot_format();
1157 let b = buf.snapshot_format();
1158 assert_eq!(a, b, "snapshot_format must be deterministic");
1159 assert_eq!(a.len(), b.len());
1161 }
1162
1163 #[test]
1164 fn snapshot_format_empty_buffer_is_spaces() {
1165 let buf = Buffer::empty(Rect::new(0, 0, 4, 2));
1166 assert_eq!(buf.snapshot_format(), " \n ");
1168 }
1169
1170 #[test]
1171 fn snapshot_format_zero_dim_returns_empty() {
1172 let buf_a = Buffer::empty(Rect::new(0, 0, 0, 4));
1173 let buf_b = Buffer::empty(Rect::new(0, 0, 4, 0));
1174 assert_eq!(buf_a.snapshot_format(), "");
1175 assert_eq!(buf_b.snapshot_format(), "");
1176 }
1177
1178 #[test]
1179 fn snapshot_format_rgb_uses_hex_codes() {
1180 use crate::style::Color;
1181 let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1182 buf.set_string(0, 0, "x", Style::new().fg(Color::Rgb(0xff, 0x00, 0xab)));
1183 let snap = buf.snapshot_format();
1184 assert!(
1185 snap.contains("fg=#ff00ab"),
1186 "expected hex RGB code, got {snap:?}"
1187 );
1188 }
1189
1190 #[test]
1191 fn snapshot_format_indexed_color() {
1192 use crate::style::Color;
1193 let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1194 buf.set_string(0, 0, "x", Style::new().fg(Color::Indexed(42)));
1195 assert!(buf.snapshot_format().contains("fg=idx42"));
1196 }
1197
1198 #[test]
1199 fn snapshot_format_modifiers_canonical_order() {
1200 let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
1202 let style = Style::new().strikethrough().italic().bold();
1203 buf.set_string(0, 0, "x", style);
1204 let snap = buf.snapshot_format();
1205 let bold_idx = snap.find("bold").expect("bold present");
1207 let italic_idx = snap.find("italic").expect("italic present");
1208 let strike_idx = snap.find("strikethrough").expect("strikethrough present");
1209 assert!(bold_idx < italic_idx);
1210 assert!(italic_idx < strike_idx);
1211 }
1212
1213 #[test]
1214 fn snapshot_format_escapes_quote_and_backslash() {
1215 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1216 buf.set_string(0, 0, "a\"b\\", Style::new().bold());
1217 let snap = buf.snapshot_format();
1218 assert!(
1220 snap.contains("\"a\\\"b\\\\\""),
1221 "expected escapes, got {snap:?}"
1222 );
1223 }
1224
1225 #[test]
1226 fn snapshot_format_multi_row_uses_newlines() {
1227 let mut buf = Buffer::empty(Rect::new(0, 0, 3, 3));
1228 buf.set_string(0, 0, "aaa", Style::new());
1229 buf.set_string(0, 1, "bbb", Style::new());
1230 buf.set_string(0, 2, "ccc", Style::new());
1231 assert_eq!(buf.snapshot_format(), "aaa\nbbb\nccc");
1232 }
1233
1234 #[test]
1237 fn line_dirty_initial_state_is_all_dirty() {
1238 let buf = Buffer::empty(Rect::new(0, 0, 4, 3));
1241 assert_eq!(buf.line_dirty.len(), 3);
1242 assert!(buf.line_dirty.iter().all(|d| *d));
1243 }
1244
1245 #[test]
1246 fn set_string_marks_row_dirty() {
1247 let mut buf = Buffer::empty(Rect::new(0, 0, 8, 4));
1250 buf.recompute_line_hashes();
1251 assert!(buf.line_dirty.iter().all(|d| !*d));
1252
1253 buf.set_string(0, 1, "hello", Style::new());
1254 assert!(!buf.line_dirty[0]);
1255 assert!(buf.line_dirty[1]);
1256 assert!(!buf.line_dirty[2]);
1257 assert!(!buf.line_dirty[3]);
1258 }
1259
1260 #[test]
1261 fn set_char_marks_row_dirty() {
1262 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 3));
1263 buf.recompute_line_hashes();
1264 buf.set_char(2, 2, 'X', Style::new());
1265 assert!(!buf.line_dirty[0]);
1266 assert!(!buf.line_dirty[1]);
1267 assert!(buf.line_dirty[2]);
1268 }
1269
1270 #[test]
1271 fn recompute_line_hashes_clears_dirty_and_caches_hashes() {
1272 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 2));
1273 buf.set_string(0, 0, "abcd", Style::new());
1274 buf.set_string(0, 1, "wxyz", Style::new());
1275 buf.recompute_line_hashes();
1276
1277 assert!(buf.line_dirty.iter().all(|d| !*d));
1278 assert_ne!(buf.line_hashes[0], buf.line_hashes[1]);
1280 assert!(buf.row_clean(0));
1281 assert!(buf.row_clean(1));
1282 }
1283
1284 #[test]
1285 fn row_clean_returns_false_for_unrecomputed_or_dirty_row() {
1286 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 2));
1287 assert!(!buf.row_clean(0));
1289 buf.recompute_line_hashes();
1290 assert!(buf.row_clean(0));
1291 buf.set_string(0, 0, "z", Style::new());
1293 assert!(!buf.row_clean(0));
1294 }
1295
1296 #[test]
1297 fn identical_buffers_share_line_hashes_after_recompute() {
1298 let area = Rect::new(0, 0, 5, 3);
1301 let mut a = Buffer::empty(area);
1302 let mut b = Buffer::empty(area);
1303 a.set_string(0, 0, "hello", Style::new());
1304 b.set_string(0, 0, "hello", Style::new());
1305 a.set_string(0, 1, "world", Style::new());
1306 b.set_string(0, 1, "world", Style::new());
1307 a.recompute_line_hashes();
1308 b.recompute_line_hashes();
1309
1310 assert_eq!(a.row_hash(0), b.row_hash(0));
1311 assert_eq!(a.row_hash(1), b.row_hash(1));
1312 assert_eq!(a.row_hash(2), b.row_hash(2));
1314 }
1315
1316 #[test]
1317 fn different_styles_yield_different_line_hashes() {
1318 use crate::style::Color;
1322 let area = Rect::new(0, 0, 3, 1);
1323 let mut a = Buffer::empty(area);
1324 let mut b = Buffer::empty(area);
1325 a.set_string(0, 0, "abc", Style::new().fg(Color::Red));
1326 b.set_string(0, 0, "abc", Style::new().fg(Color::Blue));
1327 a.recompute_line_hashes();
1328 b.recompute_line_hashes();
1329
1330 assert_ne!(a.row_hash(0), b.row_hash(0));
1331 }
1332
1333 #[test]
1334 fn resize_keeps_line_arrays_in_sync() {
1335 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 3));
1336 buf.recompute_line_hashes();
1337 buf.resize(Rect::new(0, 0, 4, 5));
1339 assert_eq!(buf.line_dirty.len(), 5);
1340 assert_eq!(buf.line_hashes.len(), 5);
1341 assert!(buf.line_dirty.iter().all(|d| *d));
1342 buf.resize(Rect::new(0, 0, 4, 2));
1344 assert_eq!(buf.line_dirty.len(), 2);
1345 assert_eq!(buf.line_hashes.len(), 2);
1346 assert!(buf.line_dirty.iter().all(|d| *d));
1347 }
1348}