use crate::ansi::graphics::KittyPlacement;
use crate::ansi::kitty_graphics_protocol::{
self, DeleteRequest, KittyGraphicsState, PlacementRequest,
};
use crate::crosswords::pos::{Column, Line, Pos};
use crate::crosswords::Crosswords;
use crate::event::{EventListener, RioEvent, WindowId};
use crate::performer::handler::Handler;
use sugarloaf::{ColorType, GraphicData, GraphicId, ResizeCommand, ResizeParameter};
#[derive(Default)]
struct TestHandler {
graphics: Vec<GraphicData>,
placements: Vec<PlacementRequest>,
deletions: Vec<DeleteRequest>,
responses: Vec<String>,
}
impl Handler for TestHandler {
fn insert_graphic(
&mut self,
data: GraphicData,
_palette: Option<Vec<crate::config::colors::ColorRgb>>,
_cursor_movement: Option<u8>,
) {
self.graphics.push(data);
}
fn place_graphic(&mut self, placement: PlacementRequest) {
self.placements.push(placement);
}
fn delete_graphics(&mut self, delete: DeleteRequest) {
self.deletions.push(delete);
}
fn kitty_graphics_response(&mut self, response: String) {
self.responses.push(response);
}
}
#[derive(Clone)]
struct TestEventListener;
impl EventListener for TestEventListener {
fn event(&self) -> (Option<RioEvent>, bool) {
(None, false)
}
}
#[test]
fn test_direct_parse_transmit() {
let mut handler = TestHandler::default();
let mut state = KittyGraphicsState::default();
let params = vec![
b"G".as_ref(),
b"a=t,f=32,s=1,v=1,i=1".as_ref(),
b"/wAA/w==".as_ref(),
];
if let Some(response) = kitty_graphics_protocol::parse(¶ms, &mut state) {
if let Some(graphic_data) = response.graphic_data {
handler.insert_graphic(graphic_data, None, Some(0));
}
}
assert_eq!(handler.graphics.len(), 1, "Should capture one graphic");
let graphic = &handler.graphics[0];
assert_eq!(graphic.width, 1);
assert_eq!(graphic.height, 1);
assert_eq!(graphic.pixels.len(), 4); assert_eq!(graphic.id.get(), 1);
}
#[test]
fn test_parse_png_format() {
let mut handler = TestHandler::default();
let mut state = KittyGraphicsState::default();
let png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
let params = vec![
b"G".as_ref(),
b"a=t,f=100,i=2".as_ref(),
png_base64.as_bytes(),
];
if let Some(response) = kitty_graphics_protocol::parse(¶ms, &mut state) {
if let Some(graphic_data) = response.graphic_data {
handler.insert_graphic(graphic_data, None, Some(0));
}
}
assert_eq!(handler.graphics.len(), 1, "Should capture one PNG graphic");
let graphic = &handler.graphics[0];
assert_eq!(graphic.width, 1, "PNG should be decoded to 1x1");
assert_eq!(graphic.height, 1, "PNG should be decoded to 1x1");
assert_eq!(graphic.id.get(), 2);
assert!(
graphic.pixels.len() >= 4,
"PNG should decode to at least 4 bytes (RGBA)"
);
}
#[test]
fn test_png_transmit_and_display() {
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(80, 24),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
let png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
let params = vec![
b"G".as_ref(),
b"a=T,f=100,r=1,C=0,i=10".as_ref(),
png_base64.as_bytes(),
];
let mut state = KittyGraphicsState::default();
if let Some(response) = kitty_graphics_protocol::parse(¶ms, &mut state) {
if let Some(graphic_data) = response.graphic_data {
if let Some(placement) = response.placement_request {
term.store_graphic(graphic_data.clone());
term.place_graphic(placement);
} else {
term.insert_graphic(graphic_data, None, Some(0));
}
}
}
let final_row = term.grid.cursor.pos.row.0;
assert_eq!(
final_row, 0,
"PNG with r=1 should place cursor on row 0, got row {}",
final_row
);
}
#[test]
fn test_png_format_support() {
let mut handler = TestHandler::default();
let mut state = KittyGraphicsState::default();
let png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
let params = vec![
b"G".as_ref(),
b"a=t,f=100,i=100".as_ref(),
png_base64.as_bytes(),
];
if let Some(response) = kitty_graphics_protocol::parse(¶ms, &mut state) {
if let Some(graphic_data) = response.graphic_data {
handler.insert_graphic(graphic_data, None, Some(0));
let graphic = &handler.graphics[0];
assert_eq!(graphic.width, 1, "PNG should decode to 1x1");
assert_eq!(graphic.height, 1, "PNG should decode to 1x1");
assert_eq!(graphic.id.get(), 100);
} else {
panic!("PNG failed to decode");
}
} else {
panic!("PNG failed to parse");
}
}
#[test]
fn test_placement_request() {
let mut handler = TestHandler::default();
let mut state = KittyGraphicsState::default();
let params = vec![b"G".as_ref(), b"a=p,i=1,x=5,y=10,c=3,r=2".as_ref()];
if let Some(response) = kitty_graphics_protocol::parse(¶ms, &mut state) {
if let Some(placement) = response.placement_request {
handler.place_graphic(placement);
}
}
assert_eq!(handler.placements.len(), 1, "Should capture one placement");
let placement = &handler.placements[0];
assert_eq!(placement.image_id, 1);
assert_eq!(placement.x, 5);
assert_eq!(placement.y, 10);
assert_eq!(placement.columns, 3);
assert_eq!(placement.rows, 2);
}
#[test]
fn test_delete_request() {
let mut handler = TestHandler::default();
let mut state = KittyGraphicsState::default();
let params = vec![b"G".as_ref(), b"a=d,d=a".as_ref()];
if let Some(response) = kitty_graphics_protocol::parse(¶ms, &mut state) {
if let Some(delete) = response.delete_request {
handler.delete_graphics(delete);
}
}
assert_eq!(handler.deletions.len(), 1, "Should capture one deletion");
assert_eq!(handler.deletions[0].action, b'a');
}
#[test]
fn test_query_response() {
let mut handler = TestHandler::default();
let mut state = KittyGraphicsState::default();
let params = vec![b"G".as_ref(), b"a=q,i=1".as_ref()];
if let Some(response) = kitty_graphics_protocol::parse(¶ms, &mut state) {
if let Some(response_str) = response.response {
handler.kitty_graphics_response(response_str);
}
}
assert_eq!(handler.responses.len(), 1, "Should generate one response");
assert!(handler.responses[0].contains("Gi=1;OK"));
}
#[test]
fn test_chunked_transfer() {
let mut handler = TestHandler::default();
let mut state = KittyGraphicsState::default();
let params1 = vec![
b"G".as_ref(),
b"a=t,f=32,s=1,v=1,m=1,i=100".as_ref(),
b"/wAA".as_ref(),
];
let result1 = kitty_graphics_protocol::parse(¶ms1, &mut state)
.expect("intermediate chunks must produce a Some response");
assert!(result1.incomplete);
assert!(result1.graphic_data.is_none());
let params3 = vec![
b"G".as_ref(),
b"a=t,f=32,s=1,v=1,m=0,i=100".as_ref(),
b"/w==".as_ref(),
];
if let Some(response) = kitty_graphics_protocol::parse(¶ms3, &mut state) {
if let Some(graphic_data) = response.graphic_data {
handler.insert_graphic(graphic_data, None, Some(0));
}
}
assert_eq!(handler.graphics.len(), 1);
assert_eq!(handler.graphics[0].id.get(), 100);
assert_eq!(handler.graphics[0].width, 1);
assert_eq!(handler.graphics[0].height, 1);
}
#[test]
fn test_multiple_graphics_in_sequence() {
let mut handler = TestHandler::default();
let mut state = KittyGraphicsState::default();
let graphics_params = [
(
vec![
b"G".as_ref(),
b"a=t,f=32,s=1,v=1,i=1".as_ref(),
b"/wAA/w==".as_ref(),
],
1u64,
),
(
vec![
b"G".as_ref(),
b"a=t,f=32,s=1,v=1,i=2".as_ref(),
b"/wAA/w==".as_ref(),
],
2u64,
),
(
vec![
b"G".as_ref(),
b"a=t,f=32,s=1,v=1,i=3".as_ref(),
b"/wAA/w==".as_ref(),
],
3u64,
),
];
for (params, _) in &graphics_params {
if let Some(response) = kitty_graphics_protocol::parse(params, &mut state) {
if let Some(graphic_data) = response.graphic_data {
handler.insert_graphic(graphic_data, None, Some(0));
}
}
}
assert_eq!(handler.graphics.len(), 3);
assert_eq!(handler.graphics[0].id.get(), 1);
assert_eq!(handler.graphics[1].id.get(), 2);
assert_eq!(handler.graphics[2].id.get(), 3);
}
#[test]
fn test_cursor_movement_default() {
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(80, 24),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
let initial_cursor_row = term.grid.cursor.pos.row.0;
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
let pixels = vec![255u8; 100 * 100 * 4];
let graphic = GraphicData {
id: GraphicId::new(1),
width: 100,
height: 100,
color_type: ColorType::Rgba,
pixels,
is_opaque: true,
resize: Some(ResizeCommand {
width: ResizeParameter::Auto,
height: ResizeParameter::Cells(2),
preserve_aspect_ratio: true,
}),
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
term.store_graphic(graphic);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 1,
placement_id: 0,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 0,
rows: 2,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 0,
cell_x_offset: 0,
cell_y_offset: 0,
};
term.place_graphic(placement);
let final_cursor_row = term.grid.cursor.pos.row.0;
let final_cursor_col = term.grid.cursor.pos.col.0;
assert_eq!(
final_cursor_row, 1,
"Cursor should be at row 1 (last row of image) with cursor_movement=0. Initial: {}, Final: {}",
initial_cursor_row,
final_cursor_row
);
assert_eq!(
final_cursor_col, 4,
"Cursor should be at the first column after the image"
);
}
#[test]
fn test_cursor_movement_no_move() {
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(80, 24),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
term.grid.cursor.pos.row.0 = 5;
term.grid.cursor.pos.col.0 = 10;
let pixels = vec![255u8; 100 * 100 * 4];
let graphic = GraphicData {
id: GraphicId::new(2),
width: 100,
height: 100,
color_type: ColorType::Rgba,
pixels,
is_opaque: true,
resize: Some(ResizeCommand {
width: ResizeParameter::Auto,
height: ResizeParameter::Cells(2),
preserve_aspect_ratio: true,
}),
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
term.store_graphic(graphic);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 2,
placement_id: 0,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 0,
rows: 2,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 1, cell_x_offset: 0,
cell_y_offset: 0,
};
term.place_graphic(placement);
}
#[test]
fn test_protocol_parses_cursor_movement() {
let mut state = KittyGraphicsState::default();
let result = kitty_graphics_protocol::parse(&[b"G", b"a=p,i=1,C=0", b""], &mut state);
assert!(result.is_some());
let response = result.unwrap();
assert!(response.placement_request.is_some());
let placement = response.placement_request.unwrap();
assert_eq!(
placement.cursor_movement, 0,
"C=0 should parse as cursor_movement=0"
);
let result = kitty_graphics_protocol::parse(&[b"G", b"a=p,i=1,C=1", b""], &mut state);
assert!(result.is_some());
let response = result.unwrap();
assert!(response.placement_request.is_some());
let placement = response.placement_request.unwrap();
assert_eq!(
placement.cursor_movement, 1,
"C=1 should parse as cursor_movement=1"
);
let result = kitty_graphics_protocol::parse(&[b"G", b"a=p,i=1", b""], &mut state);
assert!(result.is_some());
let response = result.unwrap();
assert!(response.placement_request.is_some());
let placement = response.placement_request.unwrap();
assert_eq!(
placement.cursor_movement, 0,
"Default should be cursor_movement=0"
);
}
#[test]
fn test_image_row_occupation_exact_fit() {
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(80, 24),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
let initial_cursor_row = term.grid.cursor.pos.row.0;
assert_eq!(initial_cursor_row, 0, "Cursor should start at row 0");
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
let pixels = vec![255u8; 100 * 100 * 4];
let graphic = GraphicData {
id: GraphicId::new(1),
width: 100,
height: 100,
color_type: ColorType::Rgba,
pixels,
is_opaque: true,
resize: Some(ResizeCommand {
width: ResizeParameter::Auto,
height: ResizeParameter::Cells(2),
preserve_aspect_ratio: true,
}),
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
term.store_graphic(graphic);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 1,
placement_id: 0,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 0,
rows: 2,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 0,
cell_x_offset: 0,
cell_y_offset: 0,
};
term.place_graphic(placement);
let final_cursor_row = term.grid.cursor.pos.row.0;
assert_eq!(
final_cursor_row, 1,
"Cursor should be at row 1 (last row of image) after placing a 2-row image, but got row {}",
final_cursor_row
);
}
#[test]
fn test_subcell_offset_forwarded_and_clamped() {
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(80, 24),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
let pixels = vec![255u8; 40 * 40 * 4];
let graphic = GraphicData {
id: GraphicId::new(1),
width: 40,
height: 40,
color_type: ColorType::Rgba,
pixels,
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
term.store_graphic(graphic);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 1,
placement_id: 7,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 0,
rows: 0,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 1,
cell_x_offset: 7,
cell_y_offset: 9,
};
term.place_graphic(placement);
let stored = term
.graphics
.kitty_placements
.get(&(1, 7))
.expect("placement stored");
assert_eq!(stored.cell_x_offset, 7);
assert_eq!(stored.cell_y_offset, 9);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 1,
placement_id: 8,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 0,
rows: 0,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 1,
cell_x_offset: 999,
cell_y_offset: 999,
};
term.place_graphic(placement);
let stored = term
.graphics
.kitty_placements
.get(&(1, 8))
.expect("placement stored");
assert_eq!(stored.cell_x_offset, 999, "raw offset is kept");
assert_eq!(stored.cell_y_offset, 999);
assert_eq!(stored.columns, 5, "ceil((40 + 9) / 10)");
assert_eq!(stored.rows, 3, "ceil((40 + 19) / 20)");
}
#[test]
fn test_subcell_offset_extends_row_occupation() {
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(80, 24),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
let pixels = vec![255u8; 40 * 40 * 4];
let graphic = GraphicData {
id: GraphicId::new(1),
width: 40,
height: 40,
color_type: ColorType::Rgba,
pixels,
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
term.store_graphic(graphic);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 1,
placement_id: 7,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 0,
rows: 0,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 0,
cell_x_offset: 0,
cell_y_offset: 15,
};
term.place_graphic(placement);
let stored = term
.graphics
.kitty_placements
.get(&(1, 7))
.expect("placement stored");
assert_eq!(stored.rows, 3, "Y offset spills the image into a 3rd row");
assert_eq!(stored.columns, 4, "no X offset: 40px / 10px = 4 columns");
assert_eq!(
term.grid.cursor.pos.row.0, 2,
"cursor advances to the extra row created by the Y offset"
);
}
#[test]
fn test_image_row_occupation_single_row() {
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(80, 24),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
let _initial_cursor_row = term.grid.cursor.pos.row.0;
let pixels = vec![255u8; 50 * 20 * 4];
let graphic = GraphicData {
id: GraphicId::new(2),
width: 50,
height: 20,
color_type: ColorType::Rgba,
pixels,
is_opaque: true,
resize: Some(ResizeCommand {
width: ResizeParameter::Auto,
height: ResizeParameter::Cells(1),
preserve_aspect_ratio: true,
}),
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
term.store_graphic(graphic);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 2,
placement_id: 0,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 0,
rows: 1,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 0,
cell_x_offset: 0,
cell_y_offset: 0,
};
term.place_graphic(placement);
let final_cursor_row = term.grid.cursor.pos.row.0;
assert_eq!(
final_cursor_row, 0,
"Cursor should be at row 0 (last row of image) after placing a 1-row image, but got row {}",
final_cursor_row
);
}
#[test]
fn test_image_row_occupation_three_rows() {
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(80, 24),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
let initial_cursor_row = term.grid.cursor.pos.row.0;
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
let pixels = vec![255u8; 100 * 150 * 4];
let graphic = GraphicData {
id: GraphicId::new(3),
width: 100,
height: 150,
color_type: ColorType::Rgba,
pixels,
is_opaque: true,
resize: Some(ResizeCommand {
width: ResizeParameter::Auto,
height: ResizeParameter::Cells(3),
preserve_aspect_ratio: true,
}),
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
term.store_graphic(graphic);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 3,
placement_id: 0,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 0,
rows: 3,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 0,
cell_x_offset: 0,
cell_y_offset: 0,
};
term.place_graphic(placement);
let final_cursor_row = term.grid.cursor.pos.row.0;
assert_eq!(
final_cursor_row, 2,
"Cursor should be at row 2 (last row of image) after placing a 3-row image, but got row {}. \
Delta from start: {} (expected: 2)",
final_cursor_row,
final_cursor_row - initial_cursor_row
);
}
#[test]
fn test_image_row_occupation_from_middle() {
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(80, 24),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
term.grid.cursor.pos.row.0 = 5;
let initial_cursor_row = term.grid.cursor.pos.row.0;
assert_eq!(initial_cursor_row, 5);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
let pixels = vec![255u8; 100 * 100 * 4];
let graphic = GraphicData {
id: GraphicId::new(4),
width: 100,
height: 100,
color_type: ColorType::Rgba,
pixels,
is_opaque: true,
resize: Some(ResizeCommand {
width: ResizeParameter::Auto,
height: ResizeParameter::Cells(2),
preserve_aspect_ratio: true,
}),
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
term.store_graphic(graphic);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 4,
placement_id: 0,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 0,
rows: 2,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 0,
cell_x_offset: 0,
cell_y_offset: 0,
};
term.place_graphic(placement);
let final_cursor_row = term.grid.cursor.pos.row.0;
assert_eq!(
final_cursor_row, 6,
"Cursor should be at row 6 (last row of image) after placing a 2-row image from row 5, but got row {}",
final_cursor_row
);
}
#[test]
fn test_delete_all() {
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(80, 24),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
let delete = DeleteRequest {
action: b'a',
image_id: 0,
image_number: 0,
placement_id: 0,
x: 0,
y: 0,
z_index: 0,
delete_data: false,
};
term.delete_graphics(delete);
}
#[test]
fn test_store_graphic() {
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(80, 24),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
let pixels = vec![255u8, 0, 0, 255]; let graphic = GraphicData {
id: GraphicId::new(100),
width: 1,
height: 1,
color_type: ColorType::Rgba,
pixels,
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
term.store_graphic(graphic);
let stored = term.graphics.get_kitty_image(100);
assert!(stored.is_some(), "Image should be stored in cache");
assert_eq!(stored.unwrap().data.width, 1);
}
#[test]
fn test_place_nonexistent_graphic() {
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(80, 24),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 999, placement_id: 0,
x: 5,
y: 3,
width: 0,
height: 0,
columns: 2,
rows: 2,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 0,
cell_x_offset: 0,
cell_y_offset: 0,
};
term.place_graphic(placement);
}
#[test]
fn test_recount_releases_key_exactly_once() {
let mut graphics = crate::ansi::graphics::Graphics::default();
let key = crate::sugarloaf::atlas_image_key(99);
graphics
.atlas_placements
.push(crate::ansi::graphics::AtlasPlacement {
image_key: key,
abs_row: 0,
col: 0,
columns: 2,
rows: 1,
src_x: 0,
src_y: 0,
src_width: 20,
src_height: 20,
total_width: 20,
total_height: 20,
insert_cell_w: 10,
insert_cell_h: 20,
});
graphics.recount_atlas_keys();
assert!(graphics.texture_operations.lock().is_empty());
let p = graphics.atlas_placements.pop().unwrap();
let mut pieces = Vec::new();
p.subtract_rect(0, 1, 1, 2, &mut pieces).unwrap();
graphics.atlas_placements = pieces;
graphics.recount_atlas_keys();
assert!(graphics.texture_operations.lock().is_empty());
graphics.atlas_placements.clear();
graphics.recount_atlas_keys();
assert_eq!(graphics.texture_operations.lock().as_slice(), &[key]);
graphics.recount_atlas_keys();
assert_eq!(
graphics.texture_operations.lock().len(),
1,
"no double push on repeated recounts"
);
}
fn atlas_graphic() -> GraphicData {
GraphicData {
id: GraphicId::new(0),
width: 30,
height: 40,
color_type: ColorType::Rgba,
pixels: vec![255u8; 30 * 40 * 4],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
}
}
#[test]
fn test_collect_active_ids_from_placements() {
let mut term = geometry_test_term();
assert!(term.graphics.collect_active_graphic_ids().is_empty());
term.insert_graphic(atlas_graphic(), None, Some(1));
let active = term.graphics.collect_active_graphic_ids();
assert!(
active.contains(&1),
"placed image appears in active ids: {active:?}"
);
term.grid.cursor.pos = Pos::new(Line(0), Column(0));
let mut replacement = atlas_graphic();
replacement.id = GraphicId::new(0);
term.insert_graphic(replacement, None, Some(1));
let active = term.graphics.collect_active_graphic_ids();
assert!(
!active.contains(&1),
"fully covered image dropped: {active:?}"
);
assert!(active.contains(&2), "the covering image is active");
}
#[test]
fn test_store_kitty_image_increments_generation() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
let pixels = vec![255u8; 4 * 4 * 4];
let data1 = GraphicData {
id: GraphicId::new(1),
width: 4,
height: 4,
color_type: ColorType::Rgba,
pixels: pixels.clone(),
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
graphics.store_kitty_image(1, None, data1);
let time1 = graphics.get_kitty_image(1).unwrap().transmission_time;
std::thread::sleep(std::time::Duration::from_millis(1));
let data2 = GraphicData {
id: GraphicId::new(1),
width: 4,
height: 4,
color_type: ColorType::Rgba,
pixels: pixels.clone(),
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
graphics.store_kitty_image(1, None, data2);
let time2 = graphics.get_kitty_image(1).unwrap().transmission_time;
assert!(
time2 > time1,
"Transmit time must increase on re-transmission"
);
}
#[test]
fn test_kitty_placement_insert_and_delete() {
use crate::ansi::graphics::{Graphics, KittyPlacement};
let mut graphics = Graphics::default();
let placement = KittyPlacement {
image_id: 1,
placement_id: 0,
source_x: 0,
source_y: 0,
source_width: 0,
source_height: 0,
dest_col: 0,
dest_row: 0,
columns: 10,
rows: 5,
requested_columns: 0,
requested_rows: 0,
pixel_width: 100,
pixel_height: 50,
cell_x_offset: 0,
cell_y_offset: 0,
z_index: 0,
transmit_time: std::time::Instant::now(),
};
graphics.kitty_placements.insert((1, 0), placement);
assert_eq!(graphics.kitty_placements.len(), 1);
graphics.kitty_placements.retain(|k, _| k.0 != 1);
assert_eq!(graphics.kitty_placements.len(), 0);
}
#[test]
fn test_kitty_placement_delete_by_z_index() {
use crate::ansi::graphics::{Graphics, KittyPlacement};
let mut graphics = Graphics::default();
let make_placement = |image_id: u32, z: i32| KittyPlacement {
image_id,
placement_id: 0,
source_x: 0,
source_y: 0,
source_width: 0,
source_height: 0,
dest_col: 0,
dest_row: 0,
columns: 1,
rows: 1,
requested_columns: 0,
requested_rows: 0,
pixel_width: 10,
pixel_height: 10,
cell_x_offset: 0,
cell_y_offset: 0,
z_index: z,
transmit_time: std::time::Instant::now(),
};
graphics
.kitty_placements
.insert((1, 0), make_placement(1, 0));
graphics
.kitty_placements
.insert((2, 0), make_placement(2, -1));
graphics
.kitty_placements
.insert((3, 0), make_placement(3, 0));
assert_eq!(graphics.kitty_placements.len(), 3);
graphics.kitty_placements.retain(|_, p| p.z_index != 0);
assert_eq!(graphics.kitty_placements.len(), 1);
assert!(graphics.kitty_placements.contains_key(&(2, 0)));
}
#[test]
fn test_collect_active_ids_includes_overlay_placements() {
use crate::ansi::graphics::{Graphics, KittyPlacement};
let mut graphics = Graphics::default();
let placement = KittyPlacement {
image_id: 42,
placement_id: 0,
source_x: 0,
source_y: 0,
source_width: 0,
source_height: 0,
dest_col: 0,
dest_row: 0,
columns: 1,
rows: 1,
requested_columns: 0,
requested_rows: 0,
pixel_width: 10,
pixel_height: 10,
cell_x_offset: 0,
cell_y_offset: 0,
z_index: 0,
transmit_time: std::time::Instant::now(),
};
graphics.kitty_placements.insert((42, 0), placement);
let active = graphics.collect_active_graphic_ids();
assert!(
active.contains(&42u64),
"Overlay placements should be counted as active"
);
}
#[test]
fn test_eviction_removes_dangling_placements() {
use crate::ansi::graphics::{Graphics, KittyPlacement};
let mut graphics = Graphics {
total_limit: 100,
..Graphics::default()
};
let pixels = vec![255u8; 200]; let data = GraphicData {
id: GraphicId::new(1),
width: 10,
height: 5,
color_type: ColorType::Rgba,
pixels,
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
graphics.pending.push(data);
graphics.track_graphic(GraphicId::new(1), 200);
let placement = KittyPlacement {
image_id: 1,
placement_id: 0,
source_x: 0,
source_y: 0,
source_width: 0,
source_height: 0,
dest_col: 0,
dest_row: 0,
columns: 1,
rows: 1,
requested_columns: 0,
requested_rows: 0,
pixel_width: 10,
pixel_height: 10,
cell_x_offset: 0,
cell_y_offset: 0,
z_index: 0,
transmit_time: std::time::Instant::now(),
};
graphics.kitty_placements.insert((1, 0), placement);
let used_ids = std::collections::HashSet::new();
graphics.evict_images(100, &used_ids);
assert!(
graphics.kitty_placements.is_empty(),
"Dangling placements should be removed during eviction"
);
}
fn make_test_placement(
image_id: u32,
placement_id: u32,
dest_col: usize,
dest_row: i64,
columns: u32,
rows: u32,
z_index: i32,
) -> KittyPlacement {
KittyPlacement {
image_id,
placement_id,
source_x: 0,
source_y: 0,
source_width: 0,
source_height: 0,
dest_col,
dest_row,
columns,
rows,
requested_columns: 0,
requested_rows: 0,
pixel_width: columns * 10,
pixel_height: rows * 20,
cell_x_offset: 0,
cell_y_offset: 0,
z_index,
transmit_time: std::time::Instant::now(),
}
}
#[test]
fn test_placement_id_zero_creates_multiple() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
let p1 = make_test_placement(1, 0, 0, 0, 5, 3, 0);
let p2 = make_test_placement(1, 1, 5, 0, 5, 3, 0);
graphics.kitty_placements.insert((1, 0), p1);
graphics.kitty_placements.insert((1, 1), p2);
assert_eq!(graphics.kitty_placements.len(), 2);
}
#[test]
fn test_delete_all_placements_preserves_images() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
let data = GraphicData {
id: GraphicId::new(1),
width: 4,
height: 4,
color_type: ColorType::Rgba,
pixels: vec![255u8; 64],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
graphics.store_kitty_image(1, None, data);
graphics
.kitty_placements
.insert((1, 0), make_test_placement(1, 0, 0, 0, 5, 3, 0));
graphics
.kitty_placements
.insert((1, 1), make_test_placement(1, 1, 5, 0, 5, 3, 0));
graphics.kitty_placements.clear();
assert_eq!(graphics.kitty_placements.len(), 0, "All placements removed");
assert!(
graphics.get_kitty_image(1).is_some(),
"Image should still exist"
);
}
#[test]
fn test_delete_all_placements_and_images() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
let data = GraphicData {
id: GraphicId::new(1),
width: 4,
height: 4,
color_type: ColorType::Rgba,
pixels: vec![255u8; 64],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
graphics.store_kitty_image(1, None, data);
graphics
.kitty_placements
.insert((1, 0), make_test_placement(1, 0, 0, 0, 5, 3, 0));
graphics.kitty_placements.clear();
graphics.kitty_images.clear();
graphics.kitty_image_numbers.clear();
assert_eq!(graphics.kitty_placements.len(), 0);
assert!(graphics.get_kitty_image(1).is_none());
}
#[test]
fn test_delete_by_specific_placement_id() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
graphics
.kitty_placements
.insert((1, 0), make_test_placement(1, 0, 0, 0, 5, 3, 0));
graphics
.kitty_placements
.insert((1, 1), make_test_placement(1, 1, 5, 0, 5, 3, 0));
graphics
.kitty_placements
.insert((2, 0), make_test_placement(2, 0, 0, 5, 5, 3, 0));
assert_eq!(graphics.kitty_placements.len(), 3);
graphics.kitty_placements.remove(&(1, 1));
assert_eq!(graphics.kitty_placements.len(), 2);
assert!(graphics.kitty_placements.contains_key(&(1, 0)));
assert!(!graphics.kitty_placements.contains_key(&(1, 1)));
assert!(graphics.kitty_placements.contains_key(&(2, 0)));
}
#[test]
fn test_delete_by_image_id_removes_all_placements_for_image() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
graphics
.kitty_placements
.insert((1, 0), make_test_placement(1, 0, 0, 0, 5, 3, 0));
graphics
.kitty_placements
.insert((1, 1), make_test_placement(1, 1, 5, 0, 5, 3, 0));
graphics
.kitty_placements
.insert((2, 0), make_test_placement(2, 0, 0, 5, 5, 3, 0));
graphics.kitty_placements.retain(|k, _| k.0 != 1);
assert_eq!(graphics.kitty_placements.len(), 1);
assert!(graphics.kitty_placements.contains_key(&(2, 0)));
}
#[test]
fn test_delete_intersecting_cursor() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
graphics
.kitty_placements
.insert((1, 0), make_test_placement(1, 0, 0, 0, 5, 3, 0));
graphics
.kitty_placements
.insert((2, 0), make_test_placement(2, 0, 10, 10, 5, 3, 0));
let cursor_col = 2usize;
let cursor_abs_row = 1i64;
graphics.kitty_placements.retain(|_, p| {
!(p.dest_col <= cursor_col
&& cursor_col < p.dest_col + p.columns as usize
&& p.dest_row <= cursor_abs_row
&& cursor_abs_row < p.dest_row + p.rows as i64)
});
assert_eq!(graphics.kitty_placements.len(), 1);
assert!(graphics.kitty_placements.contains_key(&(2, 0)));
}
#[test]
fn test_delete_intersecting_cursor_hits_multiple() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
graphics
.kitty_placements
.insert((1, 0), make_test_placement(1, 0, 0, 0, 10, 10, 0));
graphics
.kitty_placements
.insert((2, 0), make_test_placement(2, 0, 0, 0, 5, 5, 1));
let cursor_col = 2usize;
let cursor_abs_row = 2i64;
graphics.kitty_placements.retain(|_, p| {
!(p.dest_col <= cursor_col
&& cursor_col < p.dest_col + p.columns as usize
&& p.dest_row <= cursor_abs_row
&& cursor_abs_row < p.dest_row + p.rows as i64)
});
assert_eq!(
graphics.kitty_placements.len(),
0,
"Both overlapping placements should be removed"
);
}
#[test]
fn test_delete_by_column() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
graphics
.kitty_placements
.insert((1, 0), make_test_placement(1, 0, 0, 0, 5, 3, 0));
graphics
.kitty_placements
.insert((2, 0), make_test_placement(2, 0, 10, 0, 5, 3, 0));
graphics
.kitty_placements
.insert((3, 0), make_test_placement(3, 0, 3, 5, 2, 1, 0));
let col = 3usize;
graphics
.kitty_placements
.retain(|_, p| !(p.dest_col <= col && col < p.dest_col + p.columns as usize));
assert_eq!(graphics.kitty_placements.len(), 1);
assert!(
graphics.kitty_placements.contains_key(&(2, 0)),
"Only placement at col 10 should survive"
);
}
#[test]
fn test_delete_by_row() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
graphics
.kitty_placements
.insert((1, 0), make_test_placement(1, 0, 0, 0, 5, 3, 0));
graphics
.kitty_placements
.insert((2, 0), make_test_placement(2, 0, 0, 10, 5, 2, 0));
let abs_row = 1i64;
graphics
.kitty_placements
.retain(|_, p| !(p.dest_row <= abs_row && abs_row < p.dest_row + p.rows as i64));
assert_eq!(graphics.kitty_placements.len(), 1);
assert!(graphics.kitty_placements.contains_key(&(2, 0)));
}
#[test]
fn test_delete_by_column_1x1() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
graphics
.kitty_placements
.insert((1, 0), make_test_placement(1, 0, 0, 0, 1, 1, 0));
graphics
.kitty_placements
.insert((2, 0), make_test_placement(2, 0, 1, 0, 1, 1, 0));
graphics
.kitty_placements
.insert((3, 0), make_test_placement(3, 0, 2, 0, 1, 1, 0));
let col = 1usize;
graphics
.kitty_placements
.retain(|_, p| !(p.dest_col <= col && col < p.dest_col + p.columns as usize));
assert_eq!(graphics.kitty_placements.len(), 2);
assert!(graphics.kitty_placements.contains_key(&(1, 0)));
assert!(!graphics.kitty_placements.contains_key(&(2, 0)));
assert!(graphics.kitty_placements.contains_key(&(3, 0)));
}
#[test]
fn test_delete_by_row_1x1() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
graphics
.kitty_placements
.insert((1, 0), make_test_placement(1, 0, 0, 0, 1, 1, 0));
graphics
.kitty_placements
.insert((2, 0), make_test_placement(2, 0, 0, 1, 1, 1, 0));
graphics
.kitty_placements
.insert((3, 0), make_test_placement(3, 0, 0, 2, 1, 1, 0));
let abs_row = 1i64;
graphics
.kitty_placements
.retain(|_, p| !(p.dest_row <= abs_row && abs_row < p.dest_row + p.rows as i64));
assert_eq!(graphics.kitty_placements.len(), 2);
assert!(graphics.kitty_placements.contains_key(&(1, 0)));
assert!(!graphics.kitty_placements.contains_key(&(2, 0)));
assert!(graphics.kitty_placements.contains_key(&(3, 0)));
}
#[test]
fn test_retransmit_same_image_id_updates_data() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
let data1 = GraphicData {
id: GraphicId::new(1),
width: 4,
height: 4,
color_type: ColorType::Rgba,
pixels: vec![0u8; 64],
is_opaque: false,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
graphics.store_kitty_image(1, None, data1);
let time1 = graphics.get_kitty_image(1).unwrap().transmission_time;
let pixels1 = graphics.get_kitty_image(1).unwrap().data.pixels[0];
let data2 = GraphicData {
id: GraphicId::new(1),
width: 4,
height: 4,
color_type: ColorType::Rgba,
pixels: vec![128u8; 64],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
graphics.store_kitty_image(1, None, data2);
let time2 = graphics.get_kitty_image(1).unwrap().transmission_time;
let pixels2 = graphics.get_kitty_image(1).unwrap().data.pixels[0];
assert!(time2 > time1, "Transmit time must increase");
assert_ne!(pixels1, pixels2, "Pixel data must be replaced");
assert_eq!(pixels2, 128);
}
#[test]
fn test_image_number_mapping() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
let data = GraphicData {
id: GraphicId::new(42),
width: 2,
height: 2,
color_type: ColorType::Rgba,
pixels: vec![255u8; 16],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
graphics.store_kitty_image(42, Some(7), data);
let stored = graphics.get_kitty_image_by_number(7);
assert!(stored.is_some(), "Should find image by number");
assert_eq!(stored.unwrap().data.id, GraphicId::new(42));
assert!(graphics.get_kitty_image_by_number(99).is_none());
}
#[test]
fn test_image_number_remapping_on_retransmit() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
let data1 = GraphicData {
id: GraphicId::new(1),
width: 2,
height: 2,
color_type: ColorType::Rgba,
pixels: vec![0u8; 16],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
graphics.store_kitty_image(1, Some(100), data1);
let data2 = GraphicData {
id: GraphicId::new(1),
width: 2,
height: 2,
color_type: ColorType::Rgba,
pixels: vec![255u8; 16],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
graphics.store_kitty_image(1, Some(100), data2);
let stored = graphics.get_kitty_image_by_number(100).unwrap();
assert_eq!(
stored.data.pixels[0], 255,
"Number mapping should point to newest data"
);
}
#[test]
fn test_placement_source_rect_tracking() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
let mut p = make_test_placement(1, 0, 0, 0, 10, 5, 0);
p.source_x = 10;
p.source_y = 20;
p.source_width = 100;
p.source_height = 50;
graphics.kitty_placements.insert((1, 0), p);
let stored = graphics.kitty_placements.get(&(1, 0)).unwrap();
assert_eq!(stored.source_x, 10);
assert_eq!(stored.source_y, 20);
assert_eq!(stored.source_width, 100);
assert_eq!(stored.source_height, 50);
}
#[test]
fn test_placement_z_ordering_sort() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
graphics
.kitty_placements
.insert((1, 0), make_test_placement(1, 0, 0, 0, 5, 3, 10));
graphics
.kitty_placements
.insert((2, 0), make_test_placement(2, 0, 0, 0, 5, 3, -1));
graphics
.kitty_placements
.insert((3, 0), make_test_placement(3, 0, 0, 0, 5, 3, 0));
let mut sorted: Vec<_> = graphics.kitty_placements.values().collect();
sorted.sort_by_key(|p| p.z_index);
assert_eq!(sorted[0].z_index, -1, "Negative z first");
assert_eq!(sorted[1].z_index, 0, "Zero z middle");
assert_eq!(sorted[2].z_index, 10, "Positive z last");
}
#[test]
fn test_delete_kitty_images_cleans_number_mapping() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
let data = GraphicData {
id: GraphicId::new(1),
width: 2,
height: 2,
color_type: ColorType::Rgba,
pixels: vec![255u8; 16],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
graphics.store_kitty_image(1, Some(7), data);
assert!(graphics.get_kitty_image_by_number(7).is_some());
graphics.delete_kitty_images(|id, _| *id == 1);
assert!(
graphics.get_kitty_image_by_number(7).is_none(),
"Number mapping should be cleaned up when image is deleted"
);
}
#[test]
fn test_both_columns_and_rows_no_aspect_ratio() {
let mut state = KittyGraphicsState::default();
let params: Vec<&[u8]> = vec![
b"G",
b"a=T,f=32,s=2,v=2,c=80,r=20,i=1",
b"/////////////////////w==",
];
let response = kitty_graphics_protocol::parse(¶ms, &mut state);
assert!(response.is_some());
let graphic_data = response.unwrap().graphic_data.unwrap();
assert!(graphic_data.resize.is_some());
let resize = graphic_data.resize.unwrap();
assert!(
!resize.preserve_aspect_ratio,
"Both c= and r= specified: should NOT preserve aspect ratio"
);
}
#[test]
fn test_only_columns_preserves_aspect_ratio() {
let mut state = KittyGraphicsState::default();
let params: Vec<&[u8]> = vec![
b"G",
b"a=T,f=32,s=2,v=2,c=80,i=1",
b"/////////////////////w==",
];
let response = kitty_graphics_protocol::parse(¶ms, &mut state);
assert!(response.is_some());
let graphic_data = response.unwrap().graphic_data.unwrap();
let resize = graphic_data.resize.unwrap();
assert!(
resize.preserve_aspect_ratio,
"Only c= specified: should preserve aspect ratio"
);
}
#[test]
fn test_only_rows_preserves_aspect_ratio() {
let mut state = KittyGraphicsState::default();
let params: Vec<&[u8]> = vec![
b"G",
b"a=T,f=32,s=2,v=2,r=20,i=1",
b"/////////////////////w==",
];
let response = kitty_graphics_protocol::parse(¶ms, &mut state);
assert!(response.is_some());
let graphic_data = response.unwrap().graphic_data.unwrap();
let resize = graphic_data.resize.unwrap();
assert!(
resize.preserve_aspect_ratio,
"Only r= specified: should preserve aspect ratio"
);
}
#[test]
fn test_delete_by_image_number() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
let data = GraphicData {
id: GraphicId::new(42),
width: 2,
height: 2,
color_type: ColorType::Rgba,
pixels: vec![255u8; 16],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
graphics.store_kitty_image(42, Some(7), data);
graphics
.kitty_placements
.insert((42, 0), make_test_placement(42, 0, 0, 0, 5, 3, 0));
assert!(graphics.get_kitty_image_by_number(7).is_some());
if let Some(&image_id) = graphics.kitty_image_numbers.get(&7) {
graphics.kitty_placements.retain(|k, _| k.0 != image_id);
}
assert_eq!(graphics.kitty_placements.len(), 0);
assert!(graphics.get_kitty_image(42).is_some());
}
#[test]
fn test_delete_at_cell_with_z_filter() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
graphics
.kitty_placements
.insert((1, 0), make_test_placement(1, 0, 0, 0, 5, 3, 0));
graphics
.kitty_placements
.insert((2, 0), make_test_placement(2, 0, 0, 0, 5, 3, -1));
let col = 2usize;
let abs_row = 1i64;
let z = 0i32;
graphics.kitty_placements.retain(|_, p| {
!(p.z_index == z
&& p.dest_col <= col
&& col < p.dest_col + p.columns as usize
&& p.dest_row <= abs_row
&& abs_row < p.dest_row + p.rows as i64)
});
assert_eq!(graphics.kitty_placements.len(), 1);
assert!(graphics.kitty_placements.contains_key(&(2, 0)));
}
#[test]
fn test_delete_by_image_range() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
graphics
.kitty_placements
.insert((1, 0), make_test_placement(1, 0, 0, 0, 5, 3, 0));
graphics
.kitty_placements
.insert((5, 0), make_test_placement(5, 0, 5, 0, 5, 3, 0));
graphics
.kitty_placements
.insert((10, 0), make_test_placement(10, 0, 0, 5, 5, 3, 0));
let range_start = 1u32;
let range_end = 5u32;
graphics
.kitty_placements
.retain(|k, _| k.0 < range_start || k.0 > range_end);
assert_eq!(graphics.kitty_placements.len(), 1);
assert!(graphics.kitty_placements.contains_key(&(10, 0)));
}
#[test]
fn test_implicit_id_no_response() {
let mut state = KittyGraphicsState::default();
let params: Vec<&[u8]> = vec![
b"G",
b"a=t,f=32,s=1,v=1",
b"/w==", ];
let response = kitty_graphics_protocol::parse(¶ms, &mut state);
if let Some(resp) = response {
assert!(
resp.response.is_none() || resp.response.as_deref() == Some(""),
"No response should be sent for implicit IDs"
);
}
}
#[test]
fn test_parse_transmission_with_format_and_dimensions() {
let mut state = KittyGraphicsState::default();
let params: Vec<&[u8]> = vec![b"G", b"f=24,s=1,v=1,i=1", b"AAAA"];
let resp = kitty_graphics_protocol::parse(¶ms, &mut state);
assert!(resp.is_some());
let data = resp.unwrap().graphic_data;
assert!(data.is_some());
}
#[test]
fn test_parse_display_command_with_columns_rows() {
let mut state = KittyGraphicsState::default();
let params: Vec<&[u8]> = vec![b"G", b"a=p,c=80,r=120,i=31", b""];
let resp = kitty_graphics_protocol::parse(¶ms, &mut state);
assert!(resp.is_some());
let placement = resp.unwrap().placement_request;
assert!(placement.is_some());
let p = placement.unwrap();
assert_eq!(p.columns, 80);
assert_eq!(p.rows, 120);
assert_eq!(p.image_id, 31);
}
#[test]
fn test_parse_delete_command_with_position() {
let mut state = KittyGraphicsState::default();
let params: Vec<&[u8]> = vec![b"G", b"a=d,d=p,x=3,y=4", b""];
let resp = kitty_graphics_protocol::parse(¶ms, &mut state);
assert!(resp.is_some());
let delete = resp.unwrap().delete_request;
assert!(delete.is_some());
let d = delete.unwrap();
assert_eq!(d.action, b'p');
assert_eq!(d.x, 3);
assert_eq!(d.y, 4);
}
#[test]
fn test_parse_ignores_unknown_keys() {
let mut state = KittyGraphicsState::default();
let params: Vec<&[u8]> = vec![b"G", b"f=24,s=1,v=1,hello=world,i=1", b"AAAA"];
let resp = kitty_graphics_protocol::parse(¶ms, &mut state);
assert!(resp.is_some());
}
#[test]
fn test_parse_large_negative_z_index() {
let mut state = KittyGraphicsState::default();
let params: Vec<&[u8]> = vec![b"G", b"a=p,z=-2000000000,i=1", b""];
let resp = kitty_graphics_protocol::parse(¶ms, &mut state);
assert!(resp.is_some());
let placement = resp.unwrap().placement_request.unwrap();
assert_eq!(placement.z_index, -2000000000);
}
#[test]
fn test_response_encoding_with_image_id() {
let mut state = KittyGraphicsState::default();
let params: Vec<&[u8]> = vec![b"G", b"a=T,f=32,s=1,v=1,i=4", b"/////w=="];
let resp = kitty_graphics_protocol::parse(¶ms, &mut state).unwrap();
assert!(resp.response.is_some());
let response_str = resp.response.unwrap();
assert!(
response_str.contains("i=4"),
"Response should contain image id: {}",
response_str
);
assert!(
response_str.contains("OK"),
"Response should contain OK: {}",
response_str
);
}
#[test]
fn test_response_encoding_with_image_number() {
let mut state = KittyGraphicsState::default();
let params: Vec<&[u8]> = vec![b"G", b"a=t,f=32,s=1,v=1,I=4", b"/////w=="];
let resp = kitty_graphics_protocol::parse(¶ms, &mut state).unwrap();
assert!(resp.response.is_some());
let response_str = resp.response.unwrap();
assert!(
response_str.contains("I=4"),
"Response should contain image number: {}",
response_str
);
}
#[test]
fn test_default_format_is_rgba() {
let mut state = KittyGraphicsState::default();
let params: Vec<&[u8]> = vec![
b"G",
b"a=t,s=1,v=1,i=1",
b"/////w==", ];
let resp = kitty_graphics_protocol::parse(¶ms, &mut state);
assert!(resp.is_some());
let data = resp.unwrap().graphic_data;
assert!(data.is_some(), "Should parse with default RGBA format");
}
#[test]
fn test_delete_range_multiple_variants() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics::default();
for id in 1..=3u32 {
graphics
.kitty_placements
.insert((id, 0), make_test_placement(id, 0, 0, id as i64, 5, 3, 0));
}
graphics.kitty_placements.retain(|k, _| k.0 < 1 || k.0 > 2);
assert_eq!(graphics.kitty_placements.len(), 1);
assert!(graphics.kitty_placements.contains_key(&(3, 0)));
graphics.kitty_placements.retain(|k, _| k.0 != 3);
assert_eq!(graphics.kitty_placements.len(), 0);
}
#[test]
fn test_delete_all_preserves_memory_limit() {
use crate::ansi::graphics::Graphics;
let mut graphics = Graphics {
total_limit: 5000,
..Graphics::default()
};
let data = GraphicData {
id: GraphicId::new(1),
width: 2,
height: 2,
color_type: ColorType::Rgba,
pixels: vec![255u8; 16],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
graphics.store_kitty_image(1, None, data);
graphics
.kitty_placements
.insert((1, 0), make_test_placement(1, 0, 0, 0, 5, 3, 0));
graphics.kitty_placements.clear();
graphics.kitty_images.clear();
assert_eq!(graphics.total_limit, 5000, "Limit should be preserved");
}
#[test]
fn test_chunked_quiet_flag_inheritance() {
let mut state = KittyGraphicsState::default();
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
let raw = vec![0xFFu8; 16];
let encoded = B64.encode(&raw);
assert_eq!(encoded.len() % 4, 0);
let (first, second) = encoded.split_at(encoded.len() / 2);
let (first_bytes, second_bytes) = (first.as_bytes(), second.as_bytes());
let ctrl1 = "a=t,f=32,s=2,v=2,i=1,m=1,q=1";
let params1: Vec<&[u8]> = vec![b"G", ctrl1.as_bytes(), first_bytes];
let resp1 = kitty_graphics_protocol::parse(¶ms1, &mut state)
.expect("pending chunk must return Some");
assert!(resp1.incomplete);
let ctrl2 = "m=0,i=1";
let params2: Vec<&[u8]> = vec![b"G", ctrl2.as_bytes(), second_bytes];
let resp2 = kitty_graphics_protocol::parse(¶ms2, &mut state)
.expect("final chunk must return Some");
assert!(resp2.graphic_data.is_some(), "image must decode");
assert!(
resp2.response.is_none(),
"q=1 must suppress OK response even after chunk merge: {:?}",
resp2.response
);
}
#[test]
fn test_aspect_ratio_with_only_columns() {
use sugarloaf::GraphicData;
let data = GraphicData {
id: GraphicId::new(1),
width: 160,
height: 90,
color_type: ColorType::Rgba,
pixels: vec![],
is_opaque: true,
resize: Some(sugarloaf::ResizeCommand {
width: sugarloaf::ResizeParameter::Cells(10),
height: sugarloaf::ResizeParameter::Auto,
preserve_aspect_ratio: true,
}),
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
let cell_w = 10;
let cell_h = 20;
let (w, h) = data.compute_display_dimensions(cell_w, cell_h, 800, 600);
assert_eq!(w, 100);
assert!(h > 50 && h < 60, "Height should be ~56, got {}", h);
}
#[test]
fn test_aspect_ratio_with_only_rows() {
use sugarloaf::GraphicData;
let data = GraphicData {
id: GraphicId::new(1),
width: 160,
height: 90,
color_type: ColorType::Rgba,
pixels: vec![],
is_opaque: true,
resize: Some(sugarloaf::ResizeCommand {
width: sugarloaf::ResizeParameter::Auto,
height: sugarloaf::ResizeParameter::Cells(5),
preserve_aspect_ratio: true,
}),
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
let cell_w = 10;
let cell_h = 20;
let (w, h) = data.compute_display_dimensions(cell_w, cell_h, 800, 600);
assert_eq!(h, 100);
assert!(w > 170 && w < 185, "Width should be ~178, got {}", w);
}
#[test]
fn test_grayscale_format_conversion() {
let mut state = KittyGraphicsState::default();
let params: Vec<&[u8]> = vec![b"G", b"a=t,f=8,s=2,v=1,i=1", b"gP8="];
let resp = kitty_graphics_protocol::parse(¶ms, &mut state);
assert!(resp.is_some());
let data = resp.unwrap().graphic_data.unwrap();
assert_eq!(data.pixels.len(), 8); assert_eq!(data.pixels[0], 128);
assert_eq!(data.pixels[1], 128);
assert_eq!(data.pixels[2], 128);
assert_eq!(data.pixels[3], 255);
assert_eq!(data.pixels[4], 255);
assert_eq!(data.pixels[7], 255);
}
#[test]
fn test_gray_alpha_format_conversion() {
let mut state = KittyGraphicsState::default();
let params: Vec<&[u8]> = vec![b"G", b"a=t,f=16,s=1,v=1,i=1", b"gMg="];
let resp = kitty_graphics_protocol::parse(¶ms, &mut state);
assert!(resp.is_some());
let data = resp.unwrap().graphic_data.unwrap();
assert_eq!(data.pixels.len(), 4); assert_eq!(data.pixels[0], 128);
assert_eq!(data.pixels[1], 128);
assert_eq!(data.pixels[2], 128);
assert_eq!(data.pixels[3], 200);
assert!(!data.is_opaque); }
fn make_test_term() -> Crosswords<TestEventListener> {
Crosswords::new(
crate::crosswords::CrosswordsSize::new(80, 24),
crate::ansi::CursorShape::Block,
TestEventListener,
unsafe { WindowId::dummy() },
0,
10_000,
)
}
fn store_red_pixel(term: &mut Crosswords<TestEventListener>, image_id: u32) {
let graphic = GraphicData {
id: GraphicId::new(image_id as u64),
width: 1,
height: 1,
color_type: ColorType::Rgba,
pixels: vec![255, 0, 0, 255],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
term.store_graphic(graphic);
}
#[test]
fn test_delete_uppercase_i_actually_frees_image_data() {
let mut term = make_test_term();
store_red_pixel(&mut term, 7);
assert!(term.graphics.get_kitty_image(7).is_some());
let mut state = KittyGraphicsState::default();
let params = vec![b"G".as_ref(), b"a=d,d=I,i=7"];
let resp = kitty_graphics_protocol::parse(¶ms, &mut state).unwrap();
let delete = resp.delete_request.expect("expected DeleteRequest");
assert_eq!(delete.action, b'i');
assert!(delete.delete_data, "uppercase I must set delete_data");
term.delete_graphics(delete);
assert!(
term.graphics.get_kitty_image(7).is_none(),
"d=I must free image data — the dispatcher should rely on \
delete.delete_data, not on a dead `action == b'I'` check"
);
}
#[test]
fn test_delete_uppercase_a_clears_all_image_data() {
let mut term = make_test_term();
store_red_pixel(&mut term, 1);
store_red_pixel(&mut term, 2);
store_red_pixel(&mut term, 3);
assert_eq!(term.graphics.kitty_images.len(), 3);
let delete = DeleteRequest {
action: b'a',
image_id: 0,
image_number: 0,
placement_id: 0,
x: 0,
y: 0,
z_index: 0,
delete_data: true, };
term.delete_graphics(delete);
assert!(
term.graphics.kitty_images.is_empty(),
"d=A must clear all image data, not just placements"
);
assert!(term.graphics.kitty_image_numbers.is_empty());
}
#[test]
fn test_delete_lowercase_a_keeps_image_data() {
let mut term = make_test_term();
store_red_pixel(&mut term, 1);
let delete = DeleteRequest {
action: b'a',
image_id: 0,
image_number: 0,
placement_id: 0,
x: 0,
y: 0,
z_index: 0,
delete_data: false, };
term.delete_graphics(delete);
assert!(
term.graphics.get_kitty_image(1).is_some(),
"Lowercase d=a must keep image data — only placements are removed"
);
}
#[test]
fn test_delete_uppercase_n_frees_image_via_number() {
let mut term = make_test_term();
let graphic = GraphicData {
id: GraphicId::new(42),
width: 1,
height: 1,
color_type: ColorType::Rgba,
pixels: vec![255, 0, 0, 255],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
term.graphics.store_kitty_image(42, Some(9), graphic);
assert!(term.graphics.get_kitty_image(42).is_some());
assert!(term.graphics.get_kitty_image_by_number(9).is_some());
let delete = DeleteRequest {
action: b'n',
image_id: 0,
image_number: 9, placement_id: 0,
x: 0,
y: 0,
z_index: 0,
delete_data: true,
};
term.delete_graphics(delete);
assert!(
term.graphics.get_kitty_image(42).is_none(),
"d=N must free image data resolved through the number map"
);
}
#[test]
fn test_delete_uppercase_r_frees_image_range() {
let mut term = make_test_term();
store_red_pixel(&mut term, 1);
store_red_pixel(&mut term, 5);
store_red_pixel(&mut term, 10);
assert_eq!(term.graphics.kitty_images.len(), 3);
let delete = DeleteRequest {
action: b'r',
image_id: 0,
image_number: 0,
placement_id: 0,
x: 1, y: 5, z_index: 0,
delete_data: true,
};
term.delete_graphics(delete);
assert!(term.graphics.get_kitty_image(1).is_none());
assert!(term.graphics.get_kitty_image(5).is_none());
assert!(
term.graphics.get_kitty_image(10).is_some(),
"Image outside range must survive"
);
}
#[test]
fn test_swap_alt_isolates_kitty_images() {
let mut term = make_test_term();
store_red_pixel(&mut term, 1);
store_red_pixel(&mut term, 2);
assert!(term.graphics.get_kitty_image(1).is_some());
assert!(term.graphics.get_kitty_image(2).is_some());
term.swap_alt();
assert!(
term.graphics.get_kitty_image(1).is_none(),
"Main-screen image 1 must be hidden after swapping to alt screen"
);
assert!(
term.graphics.get_kitty_image(2).is_none(),
"Main-screen image 2 must be hidden after swapping to alt screen"
);
store_red_pixel(&mut term, 3);
assert!(term.graphics.get_kitty_image(3).is_some());
assert!(term.graphics.get_kitty_image(1).is_none());
term.swap_alt();
assert!(
term.graphics.get_kitty_image(1).is_some(),
"Image 1 must reappear when swapping back to main screen"
);
assert!(term.graphics.get_kitty_image(2).is_some());
assert!(
term.graphics.get_kitty_image(3).is_none(),
"Alt-screen image 3 must not leak into main screen"
);
term.swap_alt();
assert!(
term.graphics.get_kitty_image(3).is_some(),
"Alt-screen image 3 must be preserved across screen swaps"
);
}
#[test]
fn test_swap_alt_isolates_placements() {
let mut term = make_test_term();
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
store_red_pixel(&mut term, 1);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 1,
placement_id: 0,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 0,
rows: 1,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 1,
cell_x_offset: 0,
cell_y_offset: 0,
};
term.place_graphic(placement);
assert!(
!term.graphics.kitty_placements.is_empty(),
"Main-screen placement should be present after place_graphic"
);
term.swap_alt();
assert!(
term.graphics.kitty_placements.is_empty(),
"Main-screen placements must not be visible on the alt screen"
);
term.swap_alt();
assert!(
!term.graphics.kitty_placements.is_empty(),
"Main-screen placements must reappear after swapping back"
);
}
#[test]
fn test_swap_alt_isolates_image_numbers() {
let mut term = make_test_term();
let g = GraphicData {
id: GraphicId::new(1),
width: 1,
height: 1,
color_type: ColorType::Rgba,
pixels: vec![255, 0, 0, 255],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
term.graphics.store_kitty_image(1, Some(50), g);
assert!(term.graphics.get_kitty_image_by_number(50).is_some());
term.swap_alt();
assert!(
term.graphics.get_kitty_image_by_number(50).is_none(),
"Image-number mapping must not bleed across screens"
);
term.swap_alt();
assert!(
term.graphics.get_kitty_image_by_number(50).is_some(),
"Image-number mapping must come back when we swap to its screen"
);
}
#[test]
fn test_swap_alt_marks_kitty_dirty() {
let mut term = make_test_term();
term.graphics.kitty_graphics_dirty = false;
term.swap_alt();
assert!(
term.graphics.kitty_graphics_dirty,
"swap_alt must mark kitty graphics dirty so the renderer rebuilds"
);
}
#[test]
fn test_full_reset_clears_both_screens() {
let mut term = make_test_term();
store_red_pixel(&mut term, 1);
term.swap_alt();
store_red_pixel(&mut term, 2);
assert!(term.graphics.get_kitty_image(2).is_some());
assert!(term.graphics.get_kitty_image(1).is_none());
term.reset_state();
assert!(term.graphics.get_kitty_image(1).is_none());
assert!(term.graphics.get_kitty_image(2).is_none());
assert!(term.graphics.kitty_inactive_screen.kitty_images.is_empty());
}
#[test]
fn test_eviction_prefers_inactive_screen_images() {
use crate::ansi::graphics::{Graphics, KittyScreenState, StoredImage};
let mut graphics = Graphics {
total_limit: 100, ..Graphics::default()
};
let active_data = GraphicData {
id: GraphicId::new(1),
width: 5,
height: 5,
color_type: ColorType::Rgba,
pixels: vec![1u8; 50],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
graphics.store_kitty_image(1, None, active_data);
let inactive_data = GraphicData {
id: GraphicId::new(2),
width: 5,
height: 5,
color_type: ColorType::Rgba,
pixels: vec![2u8; 50],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now() - std::time::Duration::from_secs(60),
};
graphics.kitty_inactive_screen = KittyScreenState::default();
graphics.kitty_inactive_screen.kitty_images.insert(
2,
StoredImage {
data: inactive_data,
transmission_time: std::time::Instant::now()
- std::time::Duration::from_secs(60),
},
);
graphics.total_bytes += 50;
let used = std::collections::HashSet::new();
let ok = graphics.evict_images(60, &used);
assert!(ok, "Eviction should free enough");
assert!(
!graphics.kitty_inactive_screen.kitty_images.contains_key(&2),
"Inactive image should be evicted before active images"
);
}
#[test]
fn test_eviction_keeps_active_used_image_when_inactive_available() {
use crate::ansi::graphics::{Graphics, KittyScreenState, StoredImage};
let mut graphics = Graphics {
total_limit: 100,
..Graphics::default()
};
let active = GraphicData {
id: GraphicId::new(1),
width: 5,
height: 5,
color_type: ColorType::Rgba,
pixels: vec![1u8; 50],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
graphics.store_kitty_image(1, None, active);
graphics
.kitty_placements
.insert((1, 0), make_test_placement(1, 0, 0, 0, 5, 1, 0));
let inactive = GraphicData {
id: GraphicId::new(2),
width: 5,
height: 5,
color_type: ColorType::Rgba,
pixels: vec![2u8; 50],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
graphics.kitty_inactive_screen = KittyScreenState::default();
graphics.kitty_inactive_screen.kitty_images.insert(
2,
StoredImage {
data: inactive,
transmission_time: std::time::Instant::now(),
},
);
graphics.total_bytes += 50;
let mut used = std::collections::HashSet::new();
used.insert(1u64);
let ok = graphics.evict_images(50, &used);
assert!(ok);
assert!(
graphics.kitty_images.contains_key(&1),
"Active visible image must not be evicted while an inactive \
alternative exists"
);
assert!(
!graphics.kitty_inactive_screen.kitty_images.contains_key(&2),
"Inactive image should be the eviction target"
);
}
fn icat_invocation(
term: &mut Crosswords<TestEventListener>,
payload: &[u8],
explicit_id: Option<u32>,
) {
let control = match explicit_id {
Some(id) => format!("a=T,f=32,s=1,v=1,i={id}"),
None => "a=T,f=32,s=1,v=1".to_string(),
};
let params = vec![b"G".as_ref(), control.as_bytes(), payload];
let mut state = std::mem::take(&mut term.graphics.kitty_chunking_state);
let resp = kitty_graphics_protocol::parse(¶ms, &mut state);
term.graphics.kitty_chunking_state = state;
let resp = resp.expect("transmit+display must produce a response struct");
if let Some(graphic_data) = resp.graphic_data {
if let Some(placement) = resp.placement_request {
term.kitty_transmit_and_display(graphic_data, placement);
} else {
term.insert_graphic(graphic_data, None, Some(0));
}
}
}
#[test]
fn test_kitten_icat_two_invocations_without_explicit_id_keep_both_images() {
let mut term = make_test_term();
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
icat_invocation(&mut term, b"/wAA/w==", None); icat_invocation(&mut term, b"AP8A/w==", None);
assert_eq!(
term.graphics.kitty_images.len(),
2,
"Both icat invocations should produce distinct stored images"
);
assert_eq!(
term.graphics.kitty_placements.len(),
2,
"Both icat placements should remain visible — only the last one \
survived before the fix"
);
}
#[test]
fn test_kitten_icat_two_invocations_with_same_explicit_id_each_get_unique_placement() {
let mut term = make_test_term();
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
icat_invocation(&mut term, b"/wAA/w==", Some(1));
icat_invocation(&mut term, b"/wAA/w==", Some(1));
assert_eq!(term.graphics.kitty_images.len(), 1);
assert_eq!(
term.graphics.kitty_placements.len(),
2,
"Two `a=T` calls with the same image_id must produce two \
placements, not collapse into one"
);
}
#[test]
fn test_implicit_image_ids_are_distinct() {
let mut state = KittyGraphicsState::default();
let p1 = vec![
b"G".as_ref(),
b"a=t,f=32,s=1,v=1".as_ref(),
b"/wAA/w==".as_ref(),
];
let r1 = kitty_graphics_protocol::parse(&p1, &mut state).unwrap();
let id1 = r1.graphic_data.unwrap().id.get();
let p2 = vec![
b"G".as_ref(),
b"a=t,f=32,s=1,v=1".as_ref(),
b"AP8A/w==".as_ref(),
];
let r2 = kitty_graphics_protocol::parse(&p2, &mut state).unwrap();
let id2 = r2.graphic_data.unwrap().id.get();
assert_ne!(
id1, id2,
"Two implicit-ID transmissions must get distinct allocated IDs"
);
assert!(id1 > 0, "Auto-assigned id must be non-zero");
assert!(id2 > 0, "Auto-assigned id must be non-zero");
}
#[test]
fn test_implicit_image_id_still_suppresses_response() {
let mut state = KittyGraphicsState::default();
let params = vec![
b"G".as_ref(),
b"a=t,f=32,s=1,v=1".as_ref(),
b"/wAA/w==".as_ref(),
];
let resp = kitty_graphics_protocol::parse(¶ms, &mut state).unwrap();
assert!(
resp.response.is_none() || resp.response.as_deref() == Some(""),
"Implicit-id transmissions must not produce a response"
);
}
#[test]
fn test_explicit_image_id_still_responds() {
let mut state = KittyGraphicsState::default();
let params = vec![
b"G".as_ref(),
b"a=t,f=32,s=1,v=1,i=42".as_ref(),
b"/wAA/w==".as_ref(),
];
let resp = kitty_graphics_protocol::parse(¶ms, &mut state).unwrap();
let body = resp.response.expect("explicit-id response must be present");
assert!(body.contains("i=42"));
assert!(body.contains("OK"));
}
#[derive(Debug, Clone, Copy)]
struct ReflowDim {
columns: usize,
lines: usize,
}
impl crate::crosswords::grid::Dimensions for ReflowDim {
fn columns(&self) -> usize {
self.columns
}
fn screen_lines(&self) -> usize {
self.lines
}
fn total_lines(&self) -> usize {
self.lines
}
fn square_width(&self) -> f32 {
10.0
}
fn square_height(&self) -> f32 {
20.0
}
}
fn type_text(term: &mut Crosswords<TestEventListener>, text: &str) {
use crate::performer::handler::Handler;
for c in text.chars() {
term.input(c);
}
}
#[test]
fn test_resize_widen_unwraps_command_image_follows() {
use crate::performer::handler::Handler;
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(20, 10),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
type_text(&mut term, "$ kitten icat /path/to/image.png");
term.linefeed();
term.carriage_return();
let cursor_before = term.grid.cursor.pos.row.0;
store_red_pixel(&mut term, 1);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 1,
placement_id: 0,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 1,
rows: 1,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 1,
cell_x_offset: 0,
cell_y_offset: 0,
};
term.place_graphic(placement);
let initial_dest_row = term
.graphics
.kitty_placements
.values()
.next()
.expect("placement must exist")
.dest_row;
assert_eq!(
initial_dest_row,
term.history_size() as i64 + cursor_before as i64,
"placement should anchor at the cursor's absolute row"
);
term.resize(ReflowDim {
columns: 50,
lines: 10,
});
let final_dest_row = term
.graphics
.kitty_placements
.values()
.next()
.expect("placement must still exist")
.dest_row;
assert_eq!(
final_dest_row,
initial_dest_row - 1,
"Widening should drop dest_row by 1 so the image follows the \
(now unwrapped) command. Got {final_dest_row}, expected {}",
initial_dest_row - 1
);
}
#[test]
fn test_resize_narrow_wraps_command_image_follows() {
use crate::performer::handler::Handler;
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(50, 10),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
type_text(&mut term, "$ kitten icat /path/to/image.png");
term.linefeed();
term.carriage_return();
let cursor_before = term.grid.cursor.pos.row.0;
store_red_pixel(&mut term, 1);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 1,
placement_id: 0,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 1,
rows: 1,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 1,
cell_x_offset: 0,
cell_y_offset: 0,
};
term.place_graphic(placement);
let initial_dest_row = term
.graphics
.kitty_placements
.values()
.next()
.unwrap()
.dest_row;
assert_eq!(
initial_dest_row,
term.history_size() as i64 + cursor_before as i64
);
term.resize(ReflowDim {
columns: 20,
lines: 10,
});
let final_dest_row = term
.graphics
.kitty_placements
.values()
.next()
.unwrap()
.dest_row;
assert_eq!(
final_dest_row,
initial_dest_row + 1,
"Narrowing should bump dest_row by 1 so the image follows the \
(now wrapped) command down. Got {final_dest_row}, expected {}",
initial_dest_row + 1
);
}
fn dump_grid(term: &Crosswords<TestEventListener>, label: &str) {
use crate::crosswords::grid::Dimensions;
eprintln!("=== {label} ===");
eprintln!(
" cursor.row={}, history={}, columns={}, screen_lines={}",
term.grid.cursor.pos.row.0,
term.history_size(),
Dimensions::columns(&term.grid),
Dimensions::screen_lines(&term.grid),
);
for placement in term.graphics.kitty_placements.values() {
eprintln!(
" placement: image_id={}, dest_row={}, dest_col={}, columns={}, rows={}",
placement.image_id,
placement.dest_row,
placement.dest_col,
placement.columns,
placement.rows,
);
}
use crate::crosswords::pos::{Column, Line};
let lines = Dimensions::screen_lines(&term.grid);
let cols = Dimensions::columns(&term.grid);
for r in 0..lines {
let line = Line(r as i32);
let mut s = String::new();
for c in 0..cols {
let cell = &term.grid[line][Column(c)];
let ch = cell.c();
if ch == '\0' || ch == ' ' {
s.push('.');
} else {
s.push(ch);
}
}
eprintln!(" row {:>2}: |{}|", r, s.trim_end_matches('.'));
}
}
#[test]
fn test_debug_widen_visible_layout() {
use crate::performer::handler::Handler;
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(20, 24),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
for _ in 0..18 {
term.linefeed();
}
term.carriage_return();
type_text(&mut term, "$ kitten icat /path/to/image.png");
term.linefeed();
term.carriage_return();
store_red_pixel(&mut term, 1);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 1,
placement_id: 0,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 1,
rows: 1,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 0,
cell_x_offset: 0,
cell_y_offset: 0,
};
term.place_graphic(placement);
term.linefeed();
term.carriage_return();
type_text(&mut term, "$ ");
dump_grid(&term, "BEFORE widen");
term.resize(ReflowDim {
columns: 50,
lines: 24,
});
dump_grid(&term, "AFTER widen");
}
#[test]
fn test_debug_narrow_visible_layout() {
use crate::performer::handler::Handler;
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(50, 24),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
for _ in 0..20 {
term.linefeed();
}
term.carriage_return();
type_text(&mut term, "$ kitten icat /path/to/image.png");
term.linefeed();
term.carriage_return();
store_red_pixel(&mut term, 1);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 1,
placement_id: 0,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 1,
rows: 1,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 0,
cell_x_offset: 0,
cell_y_offset: 0,
};
term.place_graphic(placement);
term.linefeed();
term.carriage_return();
type_text(&mut term, "$ ");
dump_grid(&term, "BEFORE narrow");
term.resize(ReflowDim {
columns: 20,
lines: 24,
});
dump_grid(&term, "AFTER narrow");
}
#[test]
fn test_resize_narrow_combined_col_and_row_change() {
use crate::performer::handler::Handler;
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(50, 24),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
for _ in 0..10 {
term.linefeed();
}
term.carriage_return();
type_text(&mut term, "$ kitten icat /path/to/image.png");
term.linefeed();
term.carriage_return();
store_red_pixel(&mut term, 1);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 1,
placement_id: 0,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 1,
rows: 1,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 0,
cell_x_offset: 0,
cell_y_offset: 0,
};
term.place_graphic(placement);
term.linefeed();
term.carriage_return();
type_text(&mut term, "$ ");
let initial_dest_row = term
.graphics
.kitty_placements
.values()
.next()
.unwrap()
.dest_row;
eprintln!(
"BEFORE combined: cursor.row={}, history={}, dest_row={}",
term.grid.cursor.pos.row.0,
term.history_size(),
initial_dest_row,
);
term.resize(ReflowDim {
columns: 20,
lines: 20,
});
let final_dest_row = term
.graphics
.kitty_placements
.values()
.next()
.unwrap()
.dest_row;
eprintln!(
"AFTER combined : cursor.row={}, history={}, dest_row={}, delta={}",
term.grid.cursor.pos.row.0,
term.history_size(),
final_dest_row,
final_dest_row - initial_dest_row,
);
}
#[test]
fn test_resize_narrow_with_multi_row_image() {
use crate::performer::handler::Handler;
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(50, 24),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
for _ in 0..10 {
term.linefeed();
}
term.carriage_return();
type_text(&mut term, "$ kitten icat /path/to/image.png");
term.linefeed();
term.carriage_return();
let placement_row = term.grid.cursor.pos.row.0;
store_red_pixel(&mut term, 1);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 1,
placement_id: 0,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 1,
rows: 8, z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 0, cell_x_offset: 0,
cell_y_offset: 0,
};
term.place_graphic(placement);
let cursor_after_image = term.grid.cursor.pos.row.0;
assert!(
cursor_after_image > placement_row,
"8-row image should advance cursor below placement_row \
(placement={placement_row}, cursor_after={cursor_after_image})"
);
term.linefeed();
term.carriage_return();
type_text(&mut term, "$ ");
let initial_dest_row = term
.graphics
.kitty_placements
.values()
.next()
.unwrap()
.dest_row;
eprintln!(
"BEFORE: cursor.row={}, history={}, dest_row={}, placement_row={}",
term.grid.cursor.pos.row.0,
term.history_size(),
initial_dest_row,
placement_row,
);
term.resize(ReflowDim {
columns: 20,
lines: 24,
});
let final_dest_row = term
.graphics
.kitty_placements
.values()
.next()
.unwrap()
.dest_row;
eprintln!(
"AFTER : cursor.row={}, history={}, dest_row={}, delta={}",
term.grid.cursor.pos.row.0,
term.history_size(),
final_dest_row,
final_dest_row - initial_dest_row,
);
assert_eq!(
final_dest_row - initial_dest_row,
1,
"8-row image should still follow the +1 wrap delta"
);
}
#[test]
fn test_resize_narrow_with_cursor_at_bottom_of_screen() {
use crate::performer::handler::Handler;
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(50, 24),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
for _ in 0..20 {
term.linefeed();
}
term.carriage_return();
type_text(&mut term, "$ kitten icat /path/to/image.png");
term.linefeed();
term.carriage_return();
let placement_row = term.grid.cursor.pos.row.0;
let placement_history = term.history_size();
store_red_pixel(&mut term, 1);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 1,
placement_id: 0,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 1,
rows: 1,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 0,
cell_x_offset: 0,
cell_y_offset: 0,
};
term.place_graphic(placement);
term.linefeed();
term.carriage_return();
type_text(&mut term, "$ ");
let initial_dest_row = term
.graphics
.kitty_placements
.values()
.next()
.unwrap()
.dest_row;
eprintln!(
"BEFORE RESIZE: cursor.row={}, history={}, placement.dest_row={}, placement_row_at_place={}, history_at_place={}",
term.grid.cursor.pos.row.0,
term.history_size(),
initial_dest_row,
placement_row,
placement_history,
);
term.resize(ReflowDim {
columns: 20,
lines: 24,
});
let final_dest_row = term
.graphics
.kitty_placements
.values()
.next()
.unwrap()
.dest_row;
eprintln!(
"AFTER RESIZE: cursor.row={}, history={}, placement.dest_row={}, delta={}",
term.grid.cursor.pos.row.0,
term.history_size(),
final_dest_row,
final_dest_row - initial_dest_row,
);
assert_eq!(
final_dest_row - initial_dest_row,
1,
"Image should follow the wrap-down by exactly 1 row (delta {})",
final_dest_row - initial_dest_row
);
}
#[test]
fn test_resize_narrow_with_prompt_after_image() {
use crate::performer::handler::Handler;
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(50, 10),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
type_text(&mut term, "$ kitten icat /path/to/image.png");
term.linefeed();
term.carriage_return();
let placement_row = term.grid.cursor.pos.row.0;
store_red_pixel(&mut term, 1);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 1,
placement_id: 0,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 1,
rows: 1,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 0, cell_x_offset: 0,
cell_y_offset: 0,
};
term.place_graphic(placement);
term.linefeed();
term.carriage_return();
type_text(&mut term, "$ ");
let cursor_before = term.grid.cursor.pos.row.0;
assert!(
cursor_before > placement_row,
"test setup: cursor should be below the image, got cursor={cursor_before} placement={placement_row}"
);
let initial_dest_row = term
.graphics
.kitty_placements
.values()
.next()
.unwrap()
.dest_row;
term.resize(ReflowDim {
columns: 20,
lines: 10,
});
let final_dest_row = term
.graphics
.kitty_placements
.values()
.next()
.unwrap()
.dest_row;
assert_eq!(
final_dest_row - initial_dest_row,
1,
"Narrowing with content below the image should still shift the \
placement down by 1 (got delta {})",
final_dest_row - initial_dest_row
);
}
#[test]
fn test_resize_widen_unwraps_sixel_follows() {
use crate::performer::handler::Handler;
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(20, 10),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
type_text(&mut term, "$ convert image.png sixel:- 1234");
term.linefeed();
term.carriage_return();
term.insert_graphic(atlas_graphic(), None, None);
let initial_abs_row = term.graphics.atlas_placements[0].abs_row;
term.graphics.kitty_graphics_dirty = false;
term.resize(ReflowDim {
columns: 50,
lines: 10,
});
let final_abs_row = term.graphics.atlas_placements[0].abs_row;
assert_eq!(
final_abs_row,
initial_abs_row - 1,
"widening should shift the sixel up with the unwrapped command"
);
assert!(
term.graphics.kitty_graphics_dirty,
"resize with sixel placements must mark the overlay dirty, or \
the renderer keeps painting from a stale placement snapshot"
);
}
#[test]
fn test_resize_narrow_wraps_sixel_follows() {
use crate::performer::handler::Handler;
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(50, 10),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
type_text(&mut term, "$ convert image.png sixel:- 1234");
term.linefeed();
term.carriage_return();
term.insert_graphic(atlas_graphic(), None, None);
let initial_abs_row = term.graphics.atlas_placements[0].abs_row;
term.graphics.kitty_graphics_dirty = false;
term.resize(ReflowDim {
columns: 20,
lines: 10,
});
let final_abs_row = term.graphics.atlas_placements[0].abs_row;
assert_eq!(
final_abs_row,
initial_abs_row + 1,
"narrowing should shift the sixel down with the wrapped command"
);
assert!(term.graphics.kitty_graphics_dirty);
}
#[test]
fn test_resize_widen_sixel_above_wrap_change_stays_put() {
use crate::performer::handler::Handler;
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(20, 10),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
type_text(&mut term, "img");
term.linefeed();
term.carriage_return();
term.insert_graphic(atlas_graphic(), None, None);
term.linefeed();
term.carriage_return();
type_text(&mut term, "$ convert image.png sixel:- 1234");
term.linefeed();
term.carriage_return();
let initial_abs_row = term.graphics.atlas_placements[0].abs_row;
let cursor_before = term.grid.cursor.pos.row.0;
term.resize(ReflowDim {
columns: 50,
lines: 10,
});
assert_eq!(
term.grid.cursor.pos.row.0,
cursor_before - 1,
"test setup: the line below the image should have unwrapped"
);
assert_eq!(
term.graphics.atlas_placements[0].abs_row, initial_abs_row,
"unwrapping below the image must not move it"
);
}
#[test]
fn test_resize_widen_kitty_above_wrap_change_stays_put() {
use crate::performer::handler::Handler;
let event_listener = TestEventListener;
let window_id = unsafe { WindowId::dummy() };
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(20, 10),
crate::ansi::CursorShape::Block,
event_listener,
window_id,
0,
10_000,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
type_text(&mut term, "img");
term.linefeed();
term.carriage_return();
store_red_pixel(&mut term, 1);
let placement = kitty_graphics_protocol::PlacementRequest {
image_id: 1,
placement_id: 0,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 1,
rows: 1,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 1,
cell_x_offset: 0,
cell_y_offset: 0,
};
term.place_graphic(placement);
term.linefeed();
term.carriage_return();
type_text(&mut term, "$ convert image.png sixel:- 1234");
term.linefeed();
term.carriage_return();
let initial_dest_row = term
.graphics
.kitty_placements
.values()
.next()
.unwrap()
.dest_row;
term.resize(ReflowDim {
columns: 50,
lines: 10,
});
assert_eq!(
term.graphics
.kitty_placements
.values()
.next()
.unwrap()
.dest_row,
initial_dest_row,
"unwrapping below the image must not move it"
);
}
#[test]
fn test_animation_action_surfaces_unsupported_response() {
let mut state = KittyGraphicsState::default();
let params = vec![
b"G".as_ref(),
b"a=f,i=1,r=2,s=1,v=1,f=32".as_ref(),
b"AAAA".as_ref(),
];
let resp = kitty_graphics_protocol::parse(¶ms, &mut state)
.expect("animation actions must produce a response");
let body = resp
.response
.expect("response body must contain EINVAL marker");
assert!(body.contains("EINVAL:unsupported action"));
assert!(body.contains("i=1"));
}
fn geometry_test_term() -> Crosswords<TestEventListener> {
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(80, 24),
crate::ansi::CursorShape::Block,
TestEventListener,
unsafe { WindowId::dummy() },
0,
10_000,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
let graphic = GraphicData {
id: GraphicId::new(1),
width: 100,
height: 100,
color_type: ColorType::Rgba,
pixels: vec![255u8; 100 * 100 * 4],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
term.store_graphic(graphic);
term
}
fn placement_request(placement_id: u32) -> PlacementRequest {
PlacementRequest {
image_id: 1,
placement_id,
x: 0,
y: 0,
width: 0,
height: 0,
columns: 0,
rows: 0,
z_index: 0,
virtual_placement: false,
unicode_placeholder: 0,
cursor_movement: 1,
cell_x_offset: 0,
cell_y_offset: 0,
}
}
#[test]
fn test_source_crop_keeps_placement_at_cursor() {
let mut term = geometry_test_term();
let mut placement = placement_request(5);
placement.x = 30;
placement.y = 40;
term.place_graphic(placement);
let stored = term
.graphics
.kitty_placements
.get(&(1, 5))
.expect("placement stored");
assert_eq!(stored.dest_col, 0, "placement lands at the cursor column");
assert_eq!(stored.dest_row, 0, "placement lands at the cursor row");
assert_eq!(stored.source_x, 30, "crop is stored raw");
assert_eq!(stored.source_y, 40);
assert_eq!(stored.source_width, 0, "raw zero means to the edge");
assert_eq!(stored.source_height, 0);
assert_eq!(stored.pixel_width, 70, "footprint shows only the crop");
assert_eq!(stored.pixel_height, 60);
assert_eq!(stored.columns, 7);
assert_eq!(stored.rows, 3);
}
#[test]
fn test_source_crop_clamped_and_degenerate_invisible() {
use crate::ansi::graphics::{kitty_overlay_geometry, OverlayViewport};
let mut term = geometry_test_term();
let viewport = OverlayViewport {
cell_width: 10.0,
cell_height: 20.0,
origin_x: 0.0,
origin_y: 0.0,
history_size: 0,
display_offset: 0,
screen_lines: 24,
};
let mut placement = placement_request(6);
placement.x = 90;
placement.width = 50;
term.place_graphic(placement);
let stored = term
.graphics
.kitty_placements
.get(&(1, 6))
.expect("placement stored");
assert_eq!(stored.source_x, 90, "raw request kept");
assert_eq!(stored.source_width, 50);
assert_eq!(stored.pixel_width, 10, "footprint uses the clamped crop");
assert_eq!(stored.pixel_height, 100);
let geometry = kitty_overlay_geometry(stored, 100, 100, &viewport).expect("visible");
assert_eq!(geometry.width, 10.0, "clamped to image width");
assert_eq!(geometry.source_rect, [0.9, 0.0, 1.0, 1.0]);
let mut placement = placement_request(7);
placement.y = 100;
placement.cursor_movement = 0;
let cursor_before = term.grid.cursor.pos;
term.place_graphic(placement);
let stored = term
.graphics
.kitty_placements
.get(&(1, 7))
.expect("degenerate placement still stored");
assert_eq!(stored.columns, 0, "no cell footprint");
assert_eq!(stored.rows, 0);
assert!(
kitty_overlay_geometry(stored, 100, 100, &viewport).is_none(),
"degenerate crop renders nothing"
);
assert_eq!(
term.grid.cursor.pos, cursor_before,
"invisible placement leaves the cursor untouched"
);
}
#[test]
fn test_crop_scaled_to_requested_columns() {
let mut term = geometry_test_term();
let mut placement = placement_request(8);
placement.width = 50;
placement.height = 25;
placement.columns = 10;
term.place_graphic(placement);
let stored = term
.graphics
.kitty_placements
.get(&(1, 8))
.expect("placement stored");
assert_eq!(stored.pixel_width, 100);
assert_eq!(stored.pixel_height, 50, "aspect follows the crop");
assert_eq!(stored.columns, 10);
assert_eq!(stored.rows, 3, "ceil(50 / 20)");
}
#[test]
fn test_overlay_geometry_scroll_and_pixel_position() {
use crate::ansi::graphics::{kitty_overlay_geometry, OverlayViewport};
let placement = KittyPlacement {
image_id: 1,
placement_id: 1,
source_x: 50,
source_y: 25,
source_width: 100,
source_height: 50,
dest_col: 4,
dest_row: 55,
columns: 2,
rows: 3,
requested_columns: 0,
requested_rows: 0,
pixel_width: 25,
pixel_height: 45,
cell_x_offset: 3,
cell_y_offset: 7,
z_index: 0,
transmit_time: std::time::Instant::now(),
};
let viewport = OverlayViewport {
cell_width: 10.0,
cell_height: 20.0,
origin_x: 100.0,
origin_y: 50.0,
history_size: 80,
display_offset: 30,
screen_lines: 24,
};
let geometry = kitty_overlay_geometry(&placement, 200, 100, &viewport)
.expect("visible placement");
assert_eq!(geometry.x, 100.0 + 4.0 * 10.0 + 3.0);
assert_eq!(geometry.y, 50.0 + 5.0 * 20.0 + 7.0);
assert_eq!(geometry.width, 100.0);
assert_eq!(geometry.height, 50.0);
assert_eq!(geometry.source_rect, [0.25, 0.25, 0.75, 0.75]);
let live = OverlayViewport {
display_offset: 0,
..viewport
};
assert!(kitty_overlay_geometry(&placement, 200, 100, &live).is_none());
let mut below = placement.clone();
below.dest_row = 80 + 24;
assert!(kitty_overlay_geometry(&below, 200, 100, &live).is_none());
}
#[test]
fn test_overlay_geometry_partial_visibility_and_full_source() {
use crate::ansi::graphics::{kitty_overlay_geometry, OverlayViewport};
let placement = KittyPlacement {
image_id: 1,
placement_id: 1,
source_x: 0,
source_y: 0,
source_width: 0,
source_height: 0,
dest_col: 0,
dest_row: 48,
columns: 2,
rows: 3,
requested_columns: 0,
requested_rows: 0,
pixel_width: 20,
pixel_height: 60,
cell_x_offset: 0,
cell_y_offset: 0,
z_index: 0,
transmit_time: std::time::Instant::now(),
};
let viewport = OverlayViewport {
cell_width: 10.0,
cell_height: 20.0,
origin_x: 0.0,
origin_y: 0.0,
history_size: 50,
display_offset: 0,
screen_lines: 24,
};
let geometry = kitty_overlay_geometry(&placement, 20, 60, &viewport)
.expect("partially visible placement");
assert_eq!(geometry.y, -40.0);
assert_eq!(geometry.source_rect, [0.0, 0.0, 1.0, 1.0]);
}
#[test]
fn test_rescale_keeps_native_size_and_tracks_cell_span() {
use crate::ansi::graphics::KittyPlacement;
let mut native = KittyPlacement {
image_id: 1,
placement_id: 1,
source_x: 0,
source_y: 0,
source_width: 25,
source_height: 45,
dest_col: 0,
dest_row: 0,
columns: 3,
rows: 3,
requested_columns: 0,
requested_rows: 0,
pixel_width: 25,
pixel_height: 45,
cell_x_offset: 0,
cell_y_offset: 0,
z_index: 0,
transmit_time: std::time::Instant::now(),
};
native.rescale(25, 45, 12, 24);
assert_eq!(native.pixel_width, 25, "native pixel size is kept");
assert_eq!(native.pixel_height, 45);
assert_eq!(native.columns, 3, "ceil(25 / 12)");
assert_eq!(native.rows, 2, "ceil(45 / 24)");
let mut cell_sized = KittyPlacement {
requested_columns: 4,
requested_rows: 2,
columns: 4,
rows: 2,
pixel_width: 40,
pixel_height: 40,
..native.clone()
};
cell_sized.rescale(25, 45, 12, 24);
assert_eq!(cell_sized.pixel_width, 48);
assert_eq!(cell_sized.pixel_height, 48);
assert_eq!(cell_sized.columns, 4);
assert_eq!(cell_sized.rows, 2);
}
#[test]
fn test_crop_scaled_to_requested_rows_and_both_axes() {
let mut term = geometry_test_term();
let mut placement = placement_request(9);
placement.width = 50;
placement.height = 25;
placement.rows = 2;
term.place_graphic(placement);
let stored = term
.graphics
.kitty_placements
.get(&(1, 9))
.expect("placement stored");
assert_eq!(stored.pixel_height, 40);
assert_eq!(stored.pixel_width, 80, "aspect follows the crop");
assert_eq!(stored.rows, 2);
assert_eq!(stored.columns, 8, "ceil(80 / 10)");
let mut placement = placement_request(10);
placement.width = 50;
placement.height = 25;
placement.columns = 3;
placement.rows = 4;
term.place_graphic(placement);
let stored = term
.graphics
.kitty_placements
.get(&(1, 10))
.expect("placement stored");
assert_eq!(stored.pixel_width, 30);
assert_eq!(stored.pixel_height, 80, "stretched to the exact span");
assert_eq!(stored.columns, 3);
assert_eq!(stored.rows, 4);
}
#[test]
fn test_overlay_geometry_clamps_raw_offsets() {
use crate::ansi::graphics::{kitty_overlay_geometry, OverlayViewport};
let placement = KittyPlacement {
image_id: 1,
placement_id: 1,
source_x: 0,
source_y: 0,
source_width: 0,
source_height: 0,
dest_col: 2,
dest_row: 0,
columns: 1,
rows: 1,
requested_columns: 0,
requested_rows: 0,
pixel_width: 8,
pixel_height: 8,
cell_x_offset: 999,
cell_y_offset: 999,
z_index: 0,
transmit_time: std::time::Instant::now(),
};
let viewport = OverlayViewport {
cell_width: 10.0,
cell_height: 20.0,
origin_x: 0.0,
origin_y: 0.0,
history_size: 0,
display_offset: 0,
screen_lines: 24,
};
let geometry =
kitty_overlay_geometry(&placement, 8, 8, &viewport).expect("visible placement");
assert_eq!(geometry.x, 2.0 * 10.0 + 9.0, "offset clamped to cell box");
assert_eq!(geometry.y, 19.0);
}
#[test]
fn test_retransmit_reclamps_crop_and_updates_footprint() {
use crate::ansi::graphics::{kitty_overlay_geometry, OverlayViewport};
let mut term = geometry_test_term();
let viewport = OverlayViewport {
cell_width: 10.0,
cell_height: 20.0,
origin_x: 0.0,
origin_y: 0.0,
history_size: 0,
display_offset: 0,
screen_lines: 24,
};
let mut placement = placement_request(11);
placement.y = 50;
term.place_graphic(placement);
let stored = term.graphics.kitty_placements.get(&(1, 11)).unwrap();
assert_eq!(stored.rows, 3, "ceil(50 / 20)");
let geometry = kitty_overlay_geometry(stored, 100, 100, &viewport).unwrap();
assert_eq!(geometry.height, 50.0);
assert_eq!(geometry.source_rect, [0.0, 0.5, 1.0, 1.0]);
term.graphics.kitty_graphics_dirty = false;
let graphic = GraphicData {
id: GraphicId::new(1),
width: 100,
height: 60,
color_type: ColorType::Rgba,
pixels: vec![255u8; 100 * 60 * 4],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
term.store_graphic(graphic);
let stored = term.graphics.kitty_placements.get(&(1, 11)).unwrap();
assert_eq!(stored.pixel_height, 10, "60 - 50 remaining below the crop");
assert_eq!(stored.rows, 1);
let geometry = kitty_overlay_geometry(stored, 100, 60, &viewport).unwrap();
assert_eq!(geometry.height, 10.0);
let [_, v0, _, v1] = geometry.source_rect;
assert!((v0 - 50.0 / 60.0).abs() < 1e-6);
assert_eq!(v1, 1.0);
assert!(term.graphics.kitty_graphics_dirty);
}
#[test]
fn test_retransmit_can_make_degenerate_placement_visible() {
let mut term = geometry_test_term();
let mut placement = placement_request(12);
placement.y = 100;
term.place_graphic(placement);
assert_eq!(
term.graphics.kitty_placements.get(&(1, 12)).unwrap().rows,
0
);
let graphic = GraphicData {
id: GraphicId::new(1),
width: 100,
height: 200,
color_type: ColorType::Rgba,
pixels: vec![255u8; 100 * 200 * 4],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
term.store_graphic(graphic);
let stored = term.graphics.kitty_placements.get(&(1, 12)).unwrap();
assert_eq!(stored.pixel_height, 100, "rows 100..200 of the new image");
assert_eq!(stored.rows, 5, "ceil(100 / 20)");
}
#[test]
fn test_cursor_movement_scrolls_at_screen_bottom() {
let mut term = geometry_test_term();
for _ in 0..23 {
term.linefeed();
}
assert_eq!(term.grid.cursor.pos.row.0, 23);
let mut placement = placement_request(13);
placement.cursor_movement = 0;
term.place_graphic(placement);
assert_eq!(term.grid.cursor.pos.row.0, 22);
assert_eq!(term.grid.cursor.pos.col.0, 10);
assert_eq!(term.history_size(), 5, "five rows scrolled into history");
}
#[test]
fn test_virtual_run_geometry_honors_source_crop() {
use crate::ansi::kitty_virtual::{compute_run_geometry, PlaceholderRun};
let run = PlaceholderRun {
image_id: 1,
placement_id: 1,
row: 0,
col: 0,
width: 5,
};
let g = compute_run_geometry(
&run,
5,
5,
100,
50,
(50, 0, 50, 50),
10.0,
10.0,
0.0,
0.0,
0,
0,
)
.expect("visible run");
assert_eq!(g.width, 50.0);
assert_eq!(g.height, 10.0, "one cell row");
assert_eq!(g.source_rect, [0.5, 0.0, 1.0, 0.2]);
assert!(compute_run_geometry(
&run,
5,
5,
100,
50,
(0, 0, 0, 0),
10.0,
10.0,
0.0,
0.0,
0,
0,
)
.is_none());
let second_row = PlaceholderRun { row: 1, ..run };
let g = compute_run_geometry(
&second_row,
5,
5,
100,
50,
(0, 0, 0, 0),
10.0,
10.0,
0.0,
0.0,
1,
0,
)
.expect("visible run");
assert_eq!(g.source_rect[0], 0.0, "full-image left edge");
assert_eq!(g.source_rect[1], 0.0, "crop origin maps to image top");
}
#[test]
fn test_cursor_movement_clears_pending_wrap() {
let mut term = geometry_test_term();
term.grid.cursor.pos.col = Column(79);
term.grid.cursor.should_wrap = true;
let mut placement = placement_request(14);
placement.cursor_movement = 0;
term.place_graphic(placement);
assert!(
!term.grid.cursor.should_wrap,
"cursor repositioning discards a pending wrap"
);
}
#[test]
fn test_transmit_and_display_refreshes_sibling_placements() {
let mut term = geometry_test_term();
let mut placement = placement_request(15);
placement.cursor_movement = 1;
term.place_graphic(placement);
assert_eq!(
term.graphics.kitty_placements.get(&(1, 15)).unwrap().rows,
5
);
let graphic = GraphicData {
id: GraphicId::new(1),
width: 100,
height: 200,
color_type: ColorType::Rgba,
pixels: vec![255u8; 100 * 200 * 4],
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
let mut second = placement_request(16);
second.cursor_movement = 1;
term.kitty_transmit_and_display(graphic, second);
assert_eq!(
term.graphics.kitty_placements.get(&(1, 15)).unwrap().rows,
10,
"sibling placement footprint follows the retransmit"
);
assert_eq!(
term.graphics.kitty_placements.get(&(1, 16)).unwrap().rows,
10
);
}
#[test]
fn test_image_key_namespaces_are_disjoint() {
use crate::sugarloaf::{atlas_image_key, kitty_image_key};
assert_eq!(kitty_image_key(u32::MAX), u32::MAX as u64);
assert_eq!(kitty_image_key(0x8000_0001), 0x8000_0001);
assert!(atlas_image_key(0) > u32::MAX as u64);
assert_eq!(atlas_image_key(7), (1u64 << 32) + 7);
}
#[test]
fn test_sixel_cursor_lands_on_last_row_start_column() {
let mut term = geometry_test_term();
term.grid.cursor.pos.col = Column(5);
term.insert_graphic(atlas_graphic(), None, None);
assert_eq!(term.grid.cursor.pos.row.0, 1, "last image row");
assert_eq!(term.grid.cursor.pos.col.0, 5, "image start column");
assert!(!term.grid.cursor.should_wrap);
}
#[test]
fn test_sixel_mode_8452_cursor_right_of_image() {
let mut term = geometry_test_term();
term.set_private_mode(crate::ansi::mode::PrivateMode::Unknown(8452));
term.grid.cursor.pos.col = Column(5);
term.insert_graphic(atlas_graphic(), None, None);
assert_eq!(term.grid.cursor.pos.row.0, 1, "last image row");
assert_eq!(
term.grid.cursor.pos.col.0, 8,
"first column right of the image"
);
}
#[test]
fn test_sixel_display_mode_leaves_cursor_untouched() {
let mut term = geometry_test_term();
term.set_private_mode(crate::ansi::mode::PrivateMode::Unknown(80));
term.grid.cursor.pos.col = Column(5);
term.insert_graphic(atlas_graphic(), None, None);
assert_eq!(term.grid.cursor.pos.row.0, 0);
assert_eq!(term.grid.cursor.pos.col.0, 5);
}
#[test]
fn test_iterm2_cursor_right_of_image_and_do_not_move() {
use crate::ansi::iterm2_image_protocol::{CURSOR_DO_NOT_MOVE, CURSOR_RIGHT_OF_IMAGE};
let mut term = geometry_test_term();
term.grid.cursor.pos.col = Column(2);
term.insert_graphic(atlas_graphic(), None, Some(CURSOR_RIGHT_OF_IMAGE));
assert_eq!(term.grid.cursor.pos.row.0, 1);
assert_eq!(term.grid.cursor.pos.col.0, 5, "right of the 3-cell image");
let mut term = geometry_test_term();
term.grid.cursor.pos.col = Column(2);
term.insert_graphic(atlas_graphic(), None, Some(CURSOR_DO_NOT_MOVE));
assert_eq!(term.grid.cursor.pos.row.0, 0);
assert_eq!(term.grid.cursor.pos.col.0, 2);
}
#[test]
fn test_iterm2_parse_cursor_movement_params() {
use crate::ansi::iterm2_image_protocol::{
self, CURSOR_DO_NOT_MOVE, CURSOR_RIGHT_OF_IMAGE,
};
const PNG_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
let default_params = format!("File=inline=1:{PNG_B64}");
let params: Vec<&[u8]> = vec![b"1337", default_params.as_bytes()];
let (_, movement) = iterm2_image_protocol::parse(¶ms).expect("parses");
assert_eq!(movement, CURSOR_RIGHT_OF_IMAGE);
let no_move = format!("doNotMoveCursor=1:{PNG_B64}");
let params: Vec<&[u8]> = vec![b"1337", b"File=inline=1", no_move.as_bytes()];
let (_, movement) = iterm2_image_protocol::parse(¶ms).expect("parses");
assert_eq!(movement, CURSOR_DO_NOT_MOVE);
}
#[test]
fn test_reclaim_cadence_trigger() {
let mut term = geometry_test_term();
assert!(!term.grid.extras_table.should_reclaim());
for _ in 0..4096 {
let id = term
.grid
.extras_table
.alloc(crate::crosswords::square::Extras::default());
term.grid.extras_table.free(id);
}
assert!(term.grid.extras_table.should_reclaim(), "cadence elapsed");
term.grid.reclaim_extras();
assert!(
!term.grid.extras_table.should_reclaim(),
"sweep resets the cadence"
);
}
#[test]
fn test_kitty_placement_glued_across_ring_saturation_and_expiry() {
use crate::ansi::graphics::{kitty_overlay_geometry, OverlayViewport};
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(80, 4),
crate::ansi::CursorShape::Block,
TestEventListener,
unsafe { WindowId::dummy() },
0,
3,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
let pixels = vec![255u8; 30 * 40 * 4];
let graphic = GraphicData {
id: GraphicId::new(1),
width: 30,
height: 40,
color_type: ColorType::Rgba,
pixels,
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
term.store_graphic(graphic);
let mut placement = placement_request(20);
placement.cursor_movement = 1;
term.place_graphic(placement);
assert_eq!(
term.graphics
.kitty_placements
.get(&(1, 20))
.unwrap()
.dest_row,
0
);
for _ in 0..6 {
term.linefeed();
}
assert_eq!(term.history_size(), 3);
assert_eq!(term.lines_evicted(), 0);
term.linefeed();
assert_eq!(term.lines_evicted(), 1);
let stored = term
.graphics
.kitty_placements
.get(&(1, 20))
.expect("placement survives while a row remains in the ring");
let viewport = OverlayViewport {
cell_width: 10.0,
cell_height: 20.0,
origin_x: 0.0,
origin_y: 0.0,
history_size: (term.lines_evicted() + term.history_size() as u64) as i64,
display_offset: term.history_size() as i64,
screen_lines: 4,
};
let geometry =
kitty_overlay_geometry(stored, 30, 40, &viewport).expect("partially visible");
assert_eq!(geometry.y, -20.0, "glued: first image row evicted");
term.linefeed();
assert_eq!(term.lines_evicted(), 2);
assert!(
!term.graphics.kitty_placements.contains_key(&(1, 20)),
"placement expires with its content"
);
}
#[test]
fn test_atlas_placement_created_on_insert() {
let mut term = geometry_test_term();
term.grid.cursor.pos.col = Column(5);
term.insert_graphic(atlas_graphic(), None, Some(1));
assert_eq!(term.graphics.atlas_placements.len(), 1);
let p = &term.graphics.atlas_placements[0];
assert_eq!(p.image_key, crate::sugarloaf::atlas_image_key(1));
assert_eq!(p.abs_row, 0);
assert_eq!(p.col, 5, "anchored at the cursor column");
assert_eq!((p.columns, p.rows), (3, 2), "30x40 at 10x20 cells");
assert_eq!(
(p.src_x, p.src_y, p.src_width, p.src_height),
(0, 0, 30, 40),
"initial crop covers the whole display"
);
assert_eq!((p.insert_cell_w, p.insert_cell_h), (10, 20));
assert_eq!(
term.graphics.atlas_key_refs.get(&p.image_key),
Some(&1),
"one placement references the key"
);
}
#[test]
fn test_atlas_overlay_geometry_scaling_and_scroll() {
use crate::ansi::graphics::{
atlas_overlay_geometry, AtlasPlacement, OverlayViewport,
};
let placement = AtlasPlacement {
image_key: 7,
abs_row: 55,
col: 4,
columns: 3,
rows: 2,
src_x: 0,
src_y: 20,
src_width: 30,
src_height: 20,
total_width: 30,
total_height: 40,
insert_cell_w: 10,
insert_cell_h: 20,
};
let viewport = OverlayViewport {
cell_width: 12.0,
cell_height: 24.0,
origin_x: 100.0,
origin_y: 50.0,
history_size: 80,
display_offset: 30,
screen_lines: 24,
};
let g = atlas_overlay_geometry(&placement, &viewport).expect("visible");
assert_eq!(g.x, 100.0 + 4.0 * 12.0);
assert_eq!(g.y, 50.0 + 5.0 * 24.0);
assert_eq!(g.width, 30.0 * 1.2);
assert_eq!(g.height, 20.0 * 1.2);
assert_eq!(g.source_rect, [0.0, 0.5, 1.0, 1.0]);
let live = OverlayViewport {
display_offset: 0,
..viewport
};
assert!(atlas_overlay_geometry(&placement, &live).is_none());
}
#[test]
fn test_atlas_placement_expires_off_the_ring() {
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(80, 4),
crate::ansi::CursorShape::Block,
TestEventListener,
unsafe { WindowId::dummy() },
0,
2,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
term.insert_graphic(atlas_graphic(), None, Some(1));
assert_eq!(term.graphics.atlas_placements.len(), 1);
for _ in 0..10 {
term.linefeed();
}
assert!(
term.graphics.atlas_placements.is_empty(),
"placement expires with its content"
);
assert!(
term.graphics.atlas_key_refs.is_empty(),
"last placement released the image key"
);
}
fn test_placement() -> crate::ansi::graphics::AtlasPlacement {
crate::ansi::graphics::AtlasPlacement {
image_key: 9,
abs_row: 10,
col: 2,
columns: 4,
rows: 3,
src_x: 0,
src_y: 0,
src_width: 38,
src_height: 55,
total_width: 38,
total_height: 55,
insert_cell_w: 10,
insert_cell_h: 20,
}
}
#[test]
fn test_subtract_rect_center_hole_yields_four_slices() {
let p = test_placement();
let mut out = Vec::new();
assert!(p.subtract_rect(11, 12, 4, 5, &mut out).is_some());
assert_eq!(out.len(), 4);
assert_eq!(
(out[0].abs_row, out[0].rows, out[0].col, out[0].columns),
(10, 1, 2, 4)
);
assert_eq!((out[0].src_y, out[0].src_height), (0, 20));
assert_eq!((out[1].abs_row, out[1].rows), (12, 1));
assert_eq!((out[1].src_y, out[1].src_height), (40, 15));
assert_eq!((out[2].abs_row, out[2].col, out[2].columns), (11, 2, 2));
assert_eq!(
(
out[2].src_x,
out[2].src_width,
out[2].src_y,
out[2].src_height
),
(0, 20, 20, 20)
);
assert_eq!((out[3].abs_row, out[3].col, out[3].columns), (11, 5, 1));
assert_eq!((out[3].src_x, out[3].src_width), (30, 8));
}
#[test]
fn test_subtract_rect_miss_and_full_cover() {
let p = test_placement();
let mut out = Vec::new();
assert!(p.subtract_rect(0, 5, 0, 100, &mut out).is_none(), "miss");
assert!(out.is_empty());
assert!(
p.subtract_rect(10, 13, 2, 6, &mut out).is_some(),
"swallowed"
);
assert!(out.is_empty(), "no survivors");
}
#[test]
fn test_text_over_image_clips_exactly_that_cell() {
let mut term = geometry_test_term();
term.insert_graphic(atlas_graphic(), None, Some(1));
assert_eq!(term.graphics.atlas_placements.len(), 1);
term.grid.cursor.pos = Pos::new(Line(1), Column(1));
term.write_at_cursor('x');
let placements = &term.graphics.atlas_placements;
assert!(placements.len() >= 2, "placement split: {placements:?}");
let covers = |row: i64, col: usize| {
placements.iter().any(|p| {
row >= p.abs_row
&& row < p.abs_row + p.rows as i64
&& col >= p.col
&& col < p.col + p.columns
})
};
assert!(!covers(1, 1), "written cell is clipped out");
assert!(covers(0, 0) && covers(0, 2) && covers(1, 0) && covers(1, 2));
assert_eq!(
term.graphics.atlas_key_refs.values().sum::<u32>() as usize,
placements.len(),
"key refs track the split pieces"
);
}
#[test]
fn test_region_scroll_shifts_and_clips_atlas_placements() {
let mut term = geometry_test_term();
term.set_scrolling_region(3, Some(10));
term.grid.cursor.pos = Pos::new(Line(4), Column(0));
term.insert_graphic(atlas_graphic(), None, Some(1));
let before = term.graphics.atlas_placements[0].clone();
assert_eq!(before.abs_row, 4);
term.scroll_up_relative(Line(2), 2);
let after = &term.graphics.atlas_placements[0];
assert_eq!(after.abs_row, 2, "image follows region content");
assert_eq!(after.rows, 2, "fully inside, no clipping yet");
term.scroll_up_relative(Line(2), 1);
let after = &term.graphics.atlas_placements[0];
assert_eq!(after.abs_row, 2, "clipped at the region top");
assert_eq!(after.rows, 1);
assert_eq!(after.src_y, 20, "surviving row shows the bottom slice");
}
#[test]
fn test_overlay_clips_to_panel_rect() {
use crate::ansi::graphics::clip_overlay_to_rect;
use crate::sugarloaf::GraphicOverlay;
let mut overlay = GraphicOverlay {
image_id: 1,
x: 100.0,
y: 50.0,
width: 600.0,
height: 200.0,
z_index: -1,
source_rect: [0.0, 0.0, 1.0, 1.0],
};
assert!(clip_overlay_to_rect(
&mut overlay,
100.0,
50.0,
400.0,
450.0
));
assert_eq!(overlay.x, 100.0);
assert_eq!(overlay.width, 300.0, "clipped at the divider");
assert_eq!(overlay.height, 200.0, "vertical untouched");
assert_eq!(overlay.source_rect, [0.0, 0.0, 0.5, 1.0]);
let mut outside = GraphicOverlay {
image_id: 1,
x: 500.0,
y: 0.0,
width: 100.0,
height: 100.0,
z_index: -1,
source_rect: [0.0, 0.0, 1.0, 1.0],
};
assert!(!clip_overlay_to_rect(&mut outside, 0.0, 0.0, 400.0, 400.0));
let mut scrolled = GraphicOverlay {
image_id: 1,
x: 0.0,
y: -50.0,
width: 100.0,
height: 100.0,
z_index: -1,
source_rect: [0.25, 0.5, 0.75, 1.0],
};
assert!(clip_overlay_to_rect(&mut scrolled, 0.0, 0.0, 400.0, 400.0));
assert_eq!(scrolled.y, 0.0);
assert_eq!(scrolled.height, 50.0);
assert_eq!(scrolled.source_rect, [0.25, 0.75, 0.75, 1.0]);
}
#[test]
fn test_narrowing_terminal_keeps_image_span_and_clips_at_edge() {
use crate::ansi::graphics::{
clip_overlay_to_rect, kitty_overlay_geometry, OverlayViewport,
};
use crate::sugarloaf::GraphicOverlay;
let mut term = geometry_test_term();
let mut placement = placement_request(30);
placement.columns = 50;
placement.rows = 2;
placement.cursor_movement = 1;
term.place_graphic(placement);
let stored = term.graphics.kitty_placements.get(&(1, 30)).unwrap();
assert_eq!(stored.pixel_width, 500, "50 cells at 10px");
term.resize(crate::crosswords::CrosswordsSize::new_with_dimensions(
40, 24, 400, 480, 10, 20,
));
let stored = term.graphics.kitty_placements.get(&(1, 30)).unwrap();
assert_eq!(
(stored.columns, stored.pixel_width),
(50, 500),
"cell span survives the narrow (font unchanged)"
);
let viewport = OverlayViewport {
cell_width: 10.0,
cell_height: 20.0,
origin_x: 0.0,
origin_y: 0.0,
history_size: (term.lines_evicted() + term.history_size() as u64) as i64,
display_offset: 0,
screen_lines: 24,
};
let geometry = kitty_overlay_geometry(stored, 100, 100, &viewport).unwrap();
let mut overlay = GraphicOverlay {
image_id: 1,
x: geometry.x,
y: geometry.y,
width: geometry.width,
height: geometry.height,
z_index: 0,
source_rect: geometry.source_rect,
};
assert!(clip_overlay_to_rect(&mut overlay, 0.0, 0.0, 400.0, 480.0));
assert_eq!(overlay.width, 400.0, "clipped at the new grid edge");
assert!(
(overlay.source_rect[2] - 0.8).abs() < 1e-6,
"shows the left 400/500 of the image: {:?}",
overlay.source_rect
);
term.grid.cursor.pos = Pos::new(Line(5), Column(0));
term.insert_graphic(atlas_graphic(), None, Some(1));
let before = term.graphics.atlas_placements[0].clone();
term.resize(crate::crosswords::CrosswordsSize::new_with_dimensions(
20, 24, 200, 480, 10, 20,
));
let after = &term.graphics.atlas_placements[0];
assert_eq!(
(after.columns, after.src_width),
(before.columns, before.src_width),
"sixel raster is immutable under window resizes"
);
}
#[test]
fn test_height_grow_keeps_absolute_base_stable() {
let mut term: Crosswords<TestEventListener> = Crosswords::new(
crate::crosswords::CrosswordsSize::new(80, 4),
crate::ansi::CursorShape::Block,
TestEventListener,
unsafe { WindowId::dummy() },
0,
10,
);
term.graphics.cell_width = 10.0;
term.graphics.cell_height = 20.0;
term.insert_graphic(atlas_graphic(), None, Some(1));
let anchor = term.graphics.atlas_placements[0].abs_row;
for _ in 0..5 {
term.linefeed();
}
assert_eq!(term.history_size(), 2);
assert_eq!(term.lines_evicted(), 0);
term.resize(crate::crosswords::CrosswordsSize::new_with_dimensions(
80, 6, 800, 120, 10, 20,
));
assert_eq!(term.lines_evicted(), 0, "height grow evicts nothing");
assert_eq!(
term.graphics.atlas_placements[0].abs_row, anchor,
"image stays glued to its content"
);
}
#[test]
fn test_full_reset_clears_atlas_placements() {
let mut term = geometry_test_term();
term.insert_graphic(atlas_graphic(), None, Some(1));
assert_eq!(term.graphics.atlas_placements.len(), 1);
let key = term.graphics.atlas_placements[0].image_key;
term.reset_state();
assert!(term.graphics.atlas_placements.is_empty());
assert!(term.graphics.atlas_key_refs.is_empty());
term.graphics.recount_atlas_keys();
assert!(term.graphics.texture_operations.lock().is_empty());
let _ = key;
}
#[test]
fn test_alt_screen_reentry_drops_stale_placements() {
let mut term = geometry_test_term();
term.swap_alt();
term.insert_graphic(atlas_graphic(), None, Some(1));
assert_eq!(term.graphics.atlas_placements.len(), 1);
term.swap_alt();
assert!(
term.graphics.atlas_placements.is_empty(),
"main screen has no image"
);
term.swap_alt();
assert!(
term.graphics.atlas_placements.is_empty(),
"previous alt session's image does not come back"
);
assert!(term.graphics.atlas_key_refs.is_empty());
}