use std::ops::Range;
use ratatui::buffer::{Buffer as TuiBuf, Cell, CellDiffOption};
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui_image::{Resize, ResizeEncodeRender};
use crate::diff::DiffState;
use crate::editor::EditorState;
use crate::image::{paint_halfblocks_partial, ImageCache, NativePaint};
use crate::terminal::ImageProtocol;
#[derive(Debug, Clone)]
pub struct ImageLayoutSnapshot {
pub block_idx: usize,
#[allow(dead_code)]
pub alt: String,
pub url: String,
pub rect: Rect,
pub natural_top: isize,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageHit {
Body { block_idx: usize },
}
impl ImageLayoutSnapshot {
#[allow(dead_code)]
pub fn source_range(&self, state: &EditorState) -> Option<Range<usize>> {
state
.parsed
.source_map
.original_range_for_block(self.block_idx)
}
#[allow(dead_code)]
pub fn hit_test(&self, col: u16, row: u16) -> Option<ImageHit> {
if col >= self.rect.x
&& col < self.rect.x + self.rect.width
&& row >= self.rect.y
&& row < self.rect.y + self.rect.height
{
Some(ImageHit::Body {
block_idx: self.block_idx,
})
} else {
None
}
}
}
pub fn build_snapshots_cached(
state: &EditorState,
area: Rect,
scroll: usize,
snapshots: &mut Vec<ImageLayoutSnapshot>,
cache_key: &mut Option<(usize, Rect, u64)>,
) {
let key = (scroll, area, state.parsed_version);
if *cache_key == Some(key) {
return;
}
*snapshots = build_snapshots(state, area, scroll);
*cache_key = Some(key);
}
pub fn build_snapshots(state: &EditorState, area: Rect, scroll: usize) -> Vec<ImageLayoutSnapshot> {
let mut out = Vec::new();
if area.height == 0 {
return out;
}
let width = area.width as usize;
let total_rows = state.parsed.total_visual_rows(width);
if scroll >= total_rows {
return out;
}
for info in &state.parsed.image_blocks {
let rendered_range = state
.parsed
.source_map
.rendered_lines_for_block(info.block_idx);
if rendered_range.is_empty() {
continue;
}
let block_top = state.parsed.visual_rows_before(rendered_range.start, width);
let y_offset: isize = block_top as isize - scroll as isize;
let reserved = rendered_range.end.saturating_sub(rendered_range.start) as isize;
let image_top = area.y as isize + y_offset;
let image_bottom = image_top + reserved;
let viewport_top = area.y as isize;
let viewport_bottom = (area.y as isize) + area.height as isize;
if image_bottom <= viewport_top || image_top >= viewport_bottom {
continue;
}
let rect_y = image_top.max(0).min(u16::MAX as isize) as u16;
out.push(ImageLayoutSnapshot {
block_idx: info.block_idx,
alt: info.alt.clone(),
url: info.url.clone(),
rect: Rect {
x: area.x,
y: rect_y,
width: area.width,
height: reserved.max(0).min(u16::MAX as isize) as u16,
},
natural_top: image_top,
});
}
out
}
pub fn build_diff_snapshots_cached(
diff: &DiffState,
area: Rect,
scroll: usize,
snapshots: &mut Vec<ImageLayoutSnapshot>,
cache_key: &mut Option<(usize, Rect, u64)>,
) {
let key = (scroll, area, diff.layout_version());
if *cache_key == Some(key) {
return;
}
*snapshots = build_diff_snapshots(diff, area, scroll);
*cache_key = Some(key);
}
pub fn build_diff_snapshots(
diff: &DiffState,
area: Rect,
scroll: usize,
) -> Vec<ImageLayoutSnapshot> {
let mut out = Vec::new();
if area.height == 0 {
return out;
}
let Some(parsed) = diff.parsed_new.as_ref() else {
return out;
};
let width = area.width as usize;
diff.with_layout_index(width, |_lines, rc, index| {
if scroll >= rc.total() {
return;
}
for info in &parsed.image_blocks {
let rendered_range = parsed.source_map.rendered_lines_for_block(info.block_idx);
if rendered_range.is_empty() {
continue;
}
let (Some(&first), Some(&last)) = (
index.get(&rendered_range.start),
index.get(&(rendered_range.end - 1)),
) else {
continue;
};
let block_top = rc.before(first);
let y_offset: isize = block_top as isize - scroll as isize;
let reserved = rc.before(last + 1).saturating_sub(block_top) as isize;
let image_top = area.y as isize + y_offset;
let image_bottom = image_top + reserved;
let viewport_top = area.y as isize;
let viewport_bottom = (area.y as isize) + area.height as isize;
if image_bottom <= viewport_top || image_top >= viewport_bottom {
continue;
}
let rect_y = image_top.max(0).min(u16::MAX as isize) as u16;
out.push(ImageLayoutSnapshot {
block_idx: info.block_idx,
alt: info.alt.clone(),
url: info.url.clone(),
rect: Rect {
x: area.x,
y: rect_y,
width: area.width,
height: reserved.max(0).min(u16::MAX as isize) as u16,
},
natural_top: image_top,
});
}
});
out
}
pub struct PaintContext<'a> {
pub area: Rect,
pub buf: &'a mut TuiBuf,
pub images: &'a mut ImageCache,
pub native_picker: Option<&'a ratatui_image::picker::Picker>,
pub halfblocks_picker: Option<&'a ratatui_image::picker::Picker>,
pub native_protocol: Option<ImageProtocol>,
pub is_scrolling: bool,
pub modal_open: bool,
pub suppress_block_idx: Option<usize>,
pub bg: Color,
}
pub fn paint_images(snapshots: &[ImageLayoutSnapshot], ctx: PaintContext) {
if ctx.native_picker.is_none() || ctx.native_protocol.is_none() {
return;
}
let viewport_top = ctx.area.y as isize;
let viewport_bottom = (ctx.area.y as isize) + ctx.area.height as isize;
for snap in snapshots {
if Some(snap.block_idx) == ctx.suppress_block_idx {
continue;
}
let top = snap.natural_top;
let bottom = top + snap.rect.height as isize;
if bottom <= viewport_top || top >= viewport_bottom {
continue;
}
let fully_visible = top >= viewport_top && bottom <= viewport_bottom;
if ctx
.images
.get_protocol_pair(
&snap.url,
snap.rect.width,
snap.rect.height,
ctx.native_picker,
ctx.halfblocks_picker,
)
.is_none()
{
continue;
}
clear_visible_reserved_rect(snap, &ctx.area, ctx.buf, ctx.bg);
let use_native = fully_visible && !ctx.is_scrolling && !ctx.modal_open;
if use_native {
paint_native(ctx.images, snap, ctx.buf, ctx.bg);
} else {
paint_scratch_partial(ctx.images, snap, &ctx.area, ctx.buf, ctx.bg);
}
}
}
fn clear_visible_reserved_rect(
snap: &ImageLayoutSnapshot,
area: &Rect,
buf: &mut TuiBuf,
bg: Color,
) {
let viewport_top = area.y as isize;
let viewport_bottom = viewport_top + area.height as isize;
let top = snap.natural_top.max(viewport_top);
let bottom = (snap.natural_top + snap.rect.height as isize).min(viewport_bottom);
if bottom <= top {
return;
}
let y_start = top as u16;
let y_end = bottom as u16;
let x_start = snap.rect.x;
let x_end = snap.rect.x.saturating_add(snap.rect.width);
for y in y_start..y_end {
for x in x_start..x_end {
if let Some(cell) = buf.cell_mut((x, y)) {
*cell = Cell::default();
cell.set_bg(bg);
}
}
}
}
fn paint_native(images: &mut ImageCache, snap: &ImageLayoutSnapshot, buf: &mut TuiBuf, bg: Color) {
let resize = Resize::Fit(None);
let frame = images.frame_seq();
let needs_encode = {
let pair = match images.protocol_pair_mut(&snap.url, snap.rect.width, snap.rect.height) {
Some(p) => p,
None => return,
};
let full_rect = Rect::new(0, 0, snap.rect.width, snap.rect.height);
let generation = pair.native_generation;
let already_on_screen = pair.last_native_paint
== Some(NativePaint {
rect: snap.rect,
generation,
frame: frame.wrapping_sub(1),
});
let Some(native) = pair.native.as_mut() else {
pair.last_native_paint = None;
if let Some(scratch) = pair.halfblocks_scratch.as_ref() {
paint_halfblocks_partial(scratch, full_rect, 0, snap.rect, buf, bg);
}
return;
};
let new_size = native.needs_resize(&resize, snap.rect.into());
let needs = new_size.is_some();
if let Some(new_size) = new_size {
native.resize_encode(&resize, new_size);
}
let inner_present = native.protocol_type().is_some();
if pair.native_ready && inner_present {
if already_on_screen {
mark_rect_skipped(snap.rect, buf);
} else {
native.render(snap.rect, buf);
}
pair.last_native_paint = Some(NativePaint {
rect: snap.rect,
generation,
frame,
});
} else {
pair.last_native_paint = None;
if let Some(scratch) = pair.halfblocks_scratch.as_ref() {
paint_halfblocks_partial(scratch, full_rect, 0, snap.rect, buf, bg);
}
}
needs
};
if needs_encode {
images.track_pending_resize(&snap.url, snap.rect.width, snap.rect.height);
}
}
fn mark_rect_skipped(rect: Rect, buf: &mut TuiBuf) {
for y in rect.y..rect.y.saturating_add(rect.height) {
for x in rect.x..rect.x.saturating_add(rect.width) {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_diff_option(CellDiffOption::Skip);
}
}
}
}
fn paint_scratch_partial(
images: &mut ImageCache,
snap: &ImageLayoutSnapshot,
area: &Rect,
buf: &mut TuiBuf,
bg: Color,
) {
let pair = match images.protocol_pair_mut(&snap.url, snap.rect.width, snap.rect.height) {
Some(p) => p,
None => return,
};
pair.last_native_paint = None;
let Some(scratch) = pair.halfblocks_scratch.as_ref() else {
return;
};
let viewport_top = area.y as isize;
let viewport_bottom = (area.y as isize) + area.height as isize;
let top = snap.natural_top;
let bottom = top + snap.rect.height as isize;
let clip_top = if top < viewport_top {
(viewport_top - top) as u16
} else {
0
};
let visible_top = top.max(viewport_top);
let visible_bottom = bottom.min(viewport_bottom);
if visible_bottom <= visible_top {
return;
}
let visible_height = (visible_bottom - visible_top) as u16;
let full_rect = Rect::new(0, 0, snap.rect.width, snap.rect.height);
let dst_rect = Rect::new(
snap.rect.x,
visible_top as u16,
snap.rect.width,
visible_height,
);
paint_halfblocks_partial(scratch, full_rect, clip_top, dst_rect, buf, bg);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Theme;
use crate::diff::DiffState;
use crate::document::Buffer;
use crate::editor::EditorState;
fn theme() -> &'static Theme {
Box::leak(Box::new(Theme::default()))
}
mod native_reuse {
use std::sync::mpsc;
use image::{DynamicImage, RgbaImage};
use ratatui::style::Color;
use ratatui_image::picker::{Picker, ProtocolType};
use super::super::*;
use crate::terminal::ImageProtocol;
const AREA: Rect = Rect {
x: 0,
y: 0,
width: 20,
height: 20,
};
#[allow(deprecated)]
fn iterm2_picker() -> Picker {
let mut picker = Picker::from_fontsize((1, 2).into());
picker.set_protocol_type(ProtocolType::Iterm2);
picker
}
#[allow(deprecated)]
fn halfblocks_picker() -> Picker {
let mut picker = Picker::from_fontsize((1, 2).into());
picker.set_protocol_type(ProtocolType::Halfblocks);
picker
}
fn snap(url: &str, top: u16, height: u16) -> ImageLayoutSnapshot {
ImageLayoutSnapshot {
block_idx: 0,
alt: url.into(),
url: url.into(),
rect: Rect::new(0, top, AREA.width, height),
natural_top: top as isize,
}
}
struct Harness {
images: ImageCache,
rx: mpsc::Receiver<ratatui_image::thread::ResizeRequest>,
native: Picker,
halfblocks: Picker,
}
impl Harness {
fn new(urls: &[&str]) -> Self {
let (tx, rx) = mpsc::channel();
let mut images = ImageCache::new();
images.attach_resize_sender(tx);
for url in urls {
images.request(url);
images.set_decoded(
url,
DynamicImage::ImageRgba8(RgbaImage::from_pixel(
64,
64,
image::Rgba([10, 200, 90, 255]),
)),
);
}
Self {
images,
rx,
native: iterm2_picker(),
halfblocks: halfblocks_picker(),
}
}
fn drain_encoder(&mut self) {
while let Ok(req) = self.rx.try_recv() {
let resp = req.resize_encode().expect("encode succeeds");
self.images.apply_resize_response(resp);
}
}
fn frame(&mut self, snaps: &[ImageLayoutSnapshot], scrolling: bool) -> TuiBuf {
self.frame_suppressing(snaps, scrolling, None)
}
fn frame_suppressing(
&mut self,
snaps: &[ImageLayoutSnapshot],
scrolling: bool,
suppress_block_idx: Option<usize>,
) -> TuiBuf {
self.images.begin_frame();
let mut buf = TuiBuf::empty(AREA);
let ctx = PaintContext {
area: AREA,
buf: &mut buf,
images: &mut self.images,
native_picker: Some(&self.native),
halfblocks_picker: Some(&self.halfblocks),
native_protocol: Some(ImageProtocol::ITerm2),
is_scrolling: scrolling,
modal_open: false,
suppress_block_idx,
bg: Color::Reset,
};
paint_images(snaps, ctx);
buf
}
}
fn transmitted(buf: &TuiBuf, rect: Rect) -> bool {
buf.cell((rect.x, rect.y))
.is_some_and(|c| c.symbol().contains("]1337;File="))
}
#[test]
fn two_native_images_transmit_once_then_go_quiet() {
let snaps = vec![snap("a.png", 0, 6), snap("b.png", 8, 6)];
let mut h = Harness::new(&["a.png", "b.png"]);
h.frame(&snaps, false);
h.drain_encoder();
let transmit = h.frame(&snaps, false);
assert!(
transmitted(&transmit, snaps[0].rect),
"first image should carry its base64 payload"
);
assert!(
transmitted(&transmit, snaps[1].rect),
"second image should carry its base64 payload"
);
let idle_a = h.frame(&snaps, false);
let idle_b = h.frame(&snaps, false);
for s in &snaps {
assert!(
!transmitted(&idle_a, s.rect),
"idle frame re-sent the payload for {}",
s.url
);
}
assert!(
idle_a.diff(&idle_b).is_empty(),
"two consecutive idle frames must produce no terminal output"
);
}
#[test]
fn a_scratch_frame_forces_the_next_native_frame_to_retransmit() {
let snaps = vec![snap("a.png", 0, 6)];
let mut h = Harness::new(&["a.png"]);
h.frame(&snaps, false);
h.drain_encoder();
let transmit = h.frame(&snaps, false);
assert!(transmitted(&transmit, snaps[0].rect));
let scratch = h.frame(&snaps, true);
assert!(!transmitted(&scratch, snaps[0].rect));
let resent = h.frame(&snaps, false);
assert!(
transmitted(&resent, snaps[0].rect),
"native paint after a scratch frame must retransmit"
);
}
#[test]
fn a_suppressed_frame_forces_the_next_native_frame_to_retransmit() {
let snaps = vec![snap("a.png", 0, 6)];
let mut h = Harness::new(&["a.png"]);
h.frame(&snaps, false);
h.drain_encoder();
assert!(transmitted(&h.frame(&snaps, false), snaps[0].rect));
assert!(!transmitted(&h.frame(&snaps, false), snaps[0].rect));
let suppressed = h.frame_suppressing(&snaps, false, Some(snaps[0].block_idx));
assert!(!transmitted(&suppressed, snaps[0].rect));
assert!(
transmitted(&h.frame(&snaps, false), snaps[0].rect),
"native paint after a suppressed frame must retransmit"
);
}
#[test]
fn skipped_rect_still_diffs_against_the_same_cells_unskipped() {
let snaps = vec![snap("a.png", 0, 6)];
let mut h = Harness::new(&["a.png"]);
h.frame(&snaps, false);
h.drain_encoder();
h.frame(&snaps, false);
let skipped = h.frame(&snaps, false);
assert!(!transmitted(&skipped, snaps[0].rect));
let mut blank = TuiBuf::empty(AREA);
for y in 0..AREA.height {
for x in 0..AREA.width {
if let Some(cell) = blank.cell_mut((x, y)) {
cell.set_bg(Color::Reset);
}
}
}
let cleared: Vec<_> = skipped
.diff(&blank)
.into_iter()
.filter(|(x, y, _)| snaps[0].rect.contains((*x, *y).into()))
.collect();
assert_eq!(
cleared.len(),
(snaps[0].rect.width * snaps[0].rect.height) as usize,
"every skipped cell must re-emit once it stops being skipped, \
otherwise the image is stranded on screen"
);
}
#[test]
fn invalidate_native_paints_forces_a_retransmit() {
let snaps = vec![snap("a.png", 0, 6)];
let mut h = Harness::new(&["a.png"]);
h.frame(&snaps, false);
h.drain_encoder();
h.frame(&snaps, false);
assert!(!transmitted(&h.frame(&snaps, false), snaps[0].rect));
h.images.invalidate_native_paints();
assert!(transmitted(&h.frame(&snaps, false), snaps[0].rect));
}
}
fn state_from(src: &str, image_max_height: usize) -> EditorState {
EditorState::new_with_config(Buffer::from_str(src), theme(), true, true, image_max_height)
}
#[test]
fn build_snapshots_produces_one_snapshot_per_visible_image() {
let src = "Intro.\n\n\n\n\n\nFin.\n";
let state = state_from(src, 4);
let area = Rect::new(0, 0, 20, 30);
let snaps = build_snapshots(&state, area, 0);
assert_eq!(snaps.len(), 2);
assert_eq!(snaps[0].url, "cat.png");
assert_eq!(snaps[1].url, "dog.png");
assert_eq!(snaps[0].rect.height, 4);
assert_eq!(snaps[1].rect.height, 4);
}
#[test]
fn build_snapshots_cached_reuses_output_when_key_matches() {
let src = "Intro.\n\n\n\nOutro.\n";
let state = state_from(src, 4);
let area = Rect::new(0, 0, 20, 30);
let mut snapshots = Vec::new();
let mut key = None;
build_snapshots_cached(&state, area, 0, &mut snapshots, &mut key);
assert_eq!(snapshots.len(), 1);
let populated_key = key;
build_snapshots_cached(&state, area, 0, &mut snapshots, &mut key);
assert_eq!(key, populated_key);
assert_eq!(snapshots.len(), 1);
build_snapshots_cached(&state, area, 10, &mut snapshots, &mut key);
assert_ne!(key, populated_key);
}
#[test]
fn build_snapshots_skips_scrolled_off_blocks() {
let src = "\n\n\n";
let state = state_from(src, 3);
let area = Rect::new(0, 0, 20, 30);
let snaps = build_snapshots(&state, area, 4);
assert_eq!(snaps.len(), 1);
assert_eq!(snaps[0].url, "b.png");
}
#[test]
fn build_snapshots_keeps_full_reserved_height_for_overflow() {
let src = "\n";
let state = state_from(src, 20);
let area = Rect::new(0, 0, 20, 5);
let snaps = build_snapshots(&state, area, 0);
assert_eq!(snaps.len(), 1);
assert_eq!(snaps[0].rect.height, 20);
}
fn diff_from(old: &str, new: &str, image_max_height: usize) -> DiffState {
let mut diff = DiffState::new(old, new).expect("non-empty diff");
let parsed = crate::document::ParsedDoc::build(new, theme(), true, image_max_height);
diff.set_rendered_parse(Some(parsed));
diff
}
#[test]
fn diff_snapshot_matches_the_rows_the_layout_reserved() {
let old = "Intro.\n\n\n\nTail.\n";
let new = "Intro.\n\n\n\nTAIL!\n";
let diff = diff_from(old, new, 4);
let area = Rect::new(0, 0, 20, 30);
let snaps = build_diff_snapshots(&diff, area, 0);
assert_eq!(snaps.len(), 1);
assert_eq!(snaps[0].url, "cat.png");
assert_eq!(snaps[0].rect.height, 4);
let expected = diff.with_layout_index(area.width as usize, |_lines, rc, index| {
let parsed = diff.parsed_new.as_ref().expect("parse installed");
let block = parsed
.image_blocks
.first()
.expect("one image block")
.block_idx;
let range = parsed.source_map.rendered_lines_for_block(block);
rc.before(index[&range.start])
});
assert_eq!(snaps[0].rect.y as usize, expected);
}
#[test]
fn a_changed_image_block_yields_no_diff_snapshot() {
let old = "Intro.\n\n\n\nTail.\n";
let new = "Intro.\n\n\n\nTail.\n";
let diff = diff_from(old, new, 4);
let area = Rect::new(0, 0, 20, 30);
assert!(build_diff_snapshots(&diff, area, 0).is_empty());
}
#[test]
fn a_partly_scrolled_diff_snapshot_keeps_a_negative_natural_top() {
let old = "Intro.\n\n\n\nTail.\n";
let new = "Intro.\n\n\n\nTAIL!\n";
let diff = diff_from(old, new, 6);
let area = Rect::new(0, 0, 20, 30);
let top = build_diff_snapshots(&diff, area, 0)[0].rect.y as usize;
let snaps = build_diff_snapshots(&diff, area, top + 2);
assert_eq!(snaps.len(), 1);
assert!(snaps[0].natural_top < 0, "{:?}", snaps[0]);
assert_eq!(snaps[0].rect.height, 6, "reserved height stays full");
}
#[test]
fn no_diff_snapshots_without_a_rendered_parse() {
let diff = DiffState::new(
"Intro.\n\n\n\nTail.\n",
"Intro.\n\n\n\nTAIL!\n",
)
.expect("non-empty diff");
let area = Rect::new(0, 0, 20, 30);
assert!(build_diff_snapshots(&diff, area, 0).is_empty());
}
#[test]
fn build_diff_snapshots_cached_reuses_output_when_key_matches() {
let old = "Intro.\n\n\n\nTail.\n";
let new = "Intro.\n\n\n\nTAIL!\n";
let diff = diff_from(old, new, 4);
let area = Rect::new(0, 0, 20, 30);
let mut snapshots = Vec::new();
let mut key = None;
build_diff_snapshots_cached(&diff, area, 0, &mut snapshots, &mut key);
assert_eq!(snapshots.len(), 1);
let populated = key;
build_diff_snapshots_cached(&diff, area, 0, &mut snapshots, &mut key);
assert_eq!(key, populated);
build_diff_snapshots_cached(&diff, area, 3, &mut snapshots, &mut key);
assert_ne!(key, populated);
}
#[test]
fn hit_test_matches_inside_rect_only() {
let snap = ImageLayoutSnapshot {
block_idx: 3,
alt: "x".into(),
url: "x.png".into(),
rect: Rect::new(2, 4, 10, 6),
natural_top: 4,
};
assert_eq!(snap.hit_test(2, 4), Some(ImageHit::Body { block_idx: 3 }));
assert_eq!(snap.hit_test(11, 9), Some(ImageHit::Body { block_idx: 3 }));
assert_eq!(snap.hit_test(1, 4), None);
assert_eq!(snap.hit_test(12, 9), None);
assert_eq!(snap.hit_test(2, 10), None);
}
#[test]
fn no_snapshots_for_empty_area() {
let src = "\n";
let state = state_from(src, 3);
let area = Rect::new(0, 0, 20, 0);
assert!(build_snapshots(&state, area, 0).is_empty());
}
fn snap_with_top(natural_top: isize, width: u16, height: u16) -> ImageLayoutSnapshot {
ImageLayoutSnapshot {
block_idx: 0,
alt: "mermaid diagram".into(),
url: "diagram-mermaid-deadbeef".into(),
rect: Rect {
x: 0,
y: natural_top.max(0).min(u16::MAX as isize) as u16,
width,
height,
},
natural_top,
}
}
fn pre_populate_buf(area: Rect, placeholder_ch: char) -> TuiBuf {
let mut buf = TuiBuf::empty(area);
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_char(placeholder_ch);
}
}
}
buf
}
#[test]
fn clear_rect_blanks_every_cell_of_visible_reserved_area() {
let area = Rect::new(0, 0, 30, 20);
let mut buf = pre_populate_buf(area, 'X');
let snap = snap_with_top(2, 30, 4);
clear_visible_reserved_rect(&snap, &area, &mut buf, Color::Reset);
for y in 0..20u16 {
for x in 0..30u16 {
let expected = if (2..6).contains(&y) { ' ' } else { 'X' };
assert_eq!(
buf.cell((x, y)).unwrap().symbol(),
expected.to_string(),
"cell ({x},{y})"
);
}
}
}
#[test]
fn clear_rect_clips_to_area_when_scrolled_off_top() {
let area = Rect::new(0, 5, 30, 10);
let mut buf = pre_populate_buf(area, 'X');
let snap = snap_with_top(3, 30, 6);
clear_visible_reserved_rect(&snap, &area, &mut buf, Color::Reset);
for y in 5..15u16 {
for x in 0..30u16 {
let expected = if (5..9).contains(&y) { ' ' } else { 'X' };
assert_eq!(
buf.cell((x, y)).unwrap().symbol(),
expected.to_string(),
"cell ({x},{y})"
);
}
}
}
#[test]
fn clear_rect_noop_when_snap_fully_above_area() {
let area = Rect::new(0, 0, 10, 5);
let mut buf = pre_populate_buf(area, 'X');
let snap = snap_with_top(-10, 10, 4);
clear_visible_reserved_rect(&snap, &area, &mut buf, Color::Reset);
for y in 0..5u16 {
for x in 0..10u16 {
assert_eq!(buf.cell((x, y)).unwrap().symbol(), "X", "cell ({x},{y})");
}
}
}
}