use crate::ansi::graphics::KittyPlacement;
use crate::ansi::kitty_graphics_protocol::{
self, DeleteRequest, KittyGraphicsState, PlacementRequest,
};
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,
};
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, 0,
"Cursor should be at column 0 after carriage return"
);
}
#[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, };
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,
};
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_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,
};
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,
};
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,
};
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,
};
term.place_graphic(placement);
}
#[test]
fn test_no_double_push_on_graphic_cell_drop() {
use crate::ansi::graphics::{GraphicCell, TextureRef};
use parking_lot::Mutex;
use std::sync::Arc;
let texture_ops: Arc<Mutex<Vec<GraphicId>>> = Arc::new(Mutex::new(Vec::new()));
let texture = Arc::new(TextureRef {
id: GraphicId::new(99),
width: 10,
height: 20,
cell_height: 20,
texture_operations: Arc::downgrade(&texture_ops),
});
let cell1 = GraphicCell {
texture: texture.clone(),
offset_x: 0,
offset_y: 0,
};
let cell2 = GraphicCell {
texture: texture.clone(),
offset_x: 10,
offset_y: 0,
};
drop(cell1);
drop(cell2);
assert!(
texture_ops.lock().is_empty(),
"GraphicCell drop should NOT push to texture_operations"
);
drop(texture);
let ops = texture_ops.lock();
assert_eq!(
ops.len(),
1,
"TextureRef drop should push exactly once, got {}",
ops.len()
);
assert_eq!(ops[0], GraphicId::new(99));
}
#[test]
fn test_placed_textures_tracks_inserts() {
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;
assert!(
term.graphics.placed_textures.is_empty(),
"Should start with no placed textures"
);
let pixels = vec![255u8; 10 * 20 * 4];
let graphic = GraphicData {
id: GraphicId::new(1),
width: 10,
height: 20,
color_type: ColorType::Rgba,
pixels,
is_opaque: true,
resize: None,
display_width: None,
display_height: None,
transmit_time: std::time::Instant::now(),
};
term.insert_graphic(graphic, None, Some(0));
assert_eq!(
term.graphics.placed_textures.len(),
1,
"Should track 1 placed texture after insert"
);
}
#[test]
fn test_collect_active_ids_uses_weak_refs() {
use crate::ansi::graphics::TextureRef;
use std::sync::Arc;
let mut graphics = crate::ansi::graphics::Graphics::default();
let texture_ops = graphics.texture_operations.clone();
let texture = Arc::new(TextureRef {
id: GraphicId::new(1),
width: 10,
height: 20,
cell_height: 20,
texture_operations: Arc::downgrade(&texture_ops),
});
graphics.register_placed_texture(GraphicId::new(1), Arc::downgrade(&texture));
let active = graphics.collect_active_graphic_ids();
assert!(
active.contains(&1),
"Active texture should appear in collect_active_graphic_ids"
);
drop(texture);
let active = graphics.collect_active_graphic_ids();
assert!(
!active.contains(&1),
"Dropped texture should NOT appear in collect_active_graphic_ids"
);
assert!(
graphics.placed_textures.is_empty(),
"Stale entry should be cleaned up"
);
}
#[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,
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,
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,
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,
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,
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,
};
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,
};
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,
};
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,
};
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,
};
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,
};
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, };
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,
};
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, };
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_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"));
}