use super::*; fn receive_render(receiver: &std::sync::mpsc::Receiver>, timeout: Duration) -> Vec { receiver.recv_timeout(timeout).unwrap() } #[tokio::test] async fn unchanged_retained_graphics_leave_the_committed_surface_and_delivery_untouched() { let (mut server, _control_rx, client_rx, pane_id) = retained_test_server_with_control( b"populated terminal\r\n\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", ); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.cell_size = crate::kitty_graphics::HostCellSize { width_px: 10, height_px: 20, }; server.render_and_stream(); let _ = receive_render(&client_rx, Duration::from_millis(100)); let before = server.clients[&1] .render_state .last_pane_surface() .unwrap() .clone(); assert_eq!(before.graphics.placements.len(), 1); assert!(before.graphics.assets.is_empty()); let cells = server.clients[&1] .render_state .last_pane_surface() .unwrap() .frame .cells .as_ptr(); let mut hidden = crate::workspace::Workspace::test_new("hidden"); let hidden_pane = hidden.tabs[0].root_pane; hidden.insert_test_runtime( hidden_pane, crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"hidden output"), ); server.app.state.workspaces.push(hidden); for source in [pane_id, hidden_pane] { assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([source]))); assert!(client_rx.try_recv().is_err()); let after = server.clients[&1].render_state.last_pane_surface().unwrap(); assert_eq!(after, &before); assert_eq!(after.frame.cells.as_ptr(), cells); } // Skipping the no-op must not discard the upload cache for the next real update. write_shared_test_pane(&mut server, pane_id, b"\x1b[Hchanged"); assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); let ServerMessage::PaneSurface(updated) = read_server_message(receive_render(&client_rx, Duration::from_millis(100))) else { panic!("expected retained image with changed text"); }; assert!(updated.graphics.assets.is_empty()); assert_eq!(updated.graphics.placements, before.graphics.placements); assert!(frame_text(&updated.frame).contains("changed")); } #[tokio::test] async fn first_kitty_image_updates_retained_surface_without_full_redraw() { let (mut server, _control_rx, client_rx, pane_id) = retained_test_server_with_control(b"text before image"); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.render_state = crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); client.cell_size = crate::kitty_graphics::HostCellSize { width_px: 10, height_px: 20, }; server.render_and_stream(); let ServerMessage::PaneSurface(initial) = read_server_message(receive_render(&client_rx, Duration::from_millis(100))) else { panic!("expected text-only baseline"); }; assert!(initial.graphics.placements.is_empty()); write_shared_test_pane( &mut server, pane_id, b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", ); assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); let ServerMessage::PaneSurface(repainted) = read_server_message(receive_render(&client_rx, Duration::from_millis(100))) else { panic!("expected image update without a full redraw"); }; assert_eq!(repainted.graphics.placements.len(), 1); assert_eq!(repainted.graphics.assets[0].data, [255, 0, 0, 255]); assert_eq!(repainted.panes[0].inner_rect, initial.panes[0].inner_rect); // Text changes while an image is visible must reuse its uploaded pixels. write_shared_test_pane(&mut server, pane_id, b"\rupdated text"); assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); let ServerMessage::PaneSurface(text_update) = read_server_message(receive_render(&client_rx, Duration::from_millis(100))) else { panic!("expected retained text and image scene"); }; assert_eq!( text_update.graphics.placements, repainted.graphics.placements ); assert!(text_update.graphics.assets.is_empty()); assert!(frame_text(&text_update.frame).contains("updated text")); write_shared_test_pane(&mut server, pane_id, b"\x1b_Ga=d,d=A\x1b\\"); assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); let ServerMessage::PaneSurface(deleted) = read_server_message(receive_render(&client_rx, Duration::from_millis(100))) else { panic!("expected image removal"); }; assert!(deleted.graphics.placements.is_empty()); assert!(deleted.graphics.assets.is_empty()); write_shared_test_pane(&mut server, pane_id, b"\rtext only again"); assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); assert!(matches!( read_server_message(receive_render(&client_rx, Duration::from_millis(100))), ServerMessage::PaneSurfacePatch(_) )); // A full output queue must not mark unsent pixels as delivered. fill_render_lane(&server); write_shared_test_pane( &mut server, pane_id, b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", ); assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([pane_id]))); assert!(server.clients[&1] .render_state .last_pane_surface() .unwrap() .graphics .placements .is_empty()); let _ = receive_render(&client_rx, Duration::from_millis(100)); server.render_and_stream(); let ServerMessage::PaneSurface(recovered) = read_server_message(receive_render(&client_rx, Duration::from_millis(100))) else { panic!("expected deferred graphics recovery"); }; assert_eq!(recovered.graphics.assets[0].data, [255, 0, 0, 255]); } #[tokio::test] async fn offscreen_images_keep_text_updates_on_the_retained_path() { let (mut server, _control_rx, client_rx, pane_id) = retained_test_server_with_control( b"\x1b_Ga=T,f=32,t=d,i=7,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", ); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.render_state = crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); client.cell_size = crate::kitty_graphics::HostCellSize { width_px: 10, height_px: 20, }; let sources = HashSet::from([pane_id]); server.render_and_stream(); let _ = receive_render(&client_rx, Duration::from_millis(100)); write_shared_test_pane(&mut server, pane_id, b"\x1b_Ga=d,d=a,q=2\x1b\\"); assert!(server.render_retained_pane_surface_and_stream(&sources)); let ServerMessage::PaneSurface(hidden) = read_server_message(receive_render(&client_rx, Duration::from_millis(100))) else { panic!("expected hidden image scene"); }; assert!(hidden.graphics.placements.is_empty()); assert_eq!(hidden.graphics.retained_assets.len(), 1); // Clients reject row patches while images are retained, so the text must // arrive in a surface that keeps the hidden image without resending it. write_shared_test_pane(&mut server, pane_id, b"\rtext while hidden"); assert!(server.render_retained_pane_surface_and_stream(&sources)); let ServerMessage::PaneSurface(text_update) = read_server_message(receive_render(&client_rx, Duration::from_millis(100))) else { panic!("expected a surface the client accepts while images are retained"); }; assert!(frame_text(&text_update.frame).contains("text while hidden")); assert!(text_update.graphics.placements.is_empty()); assert!(text_update.graphics.assets.is_empty()); assert_eq!( text_update.graphics.retained_assets, hidden.graphics.retained_assets ); write_shared_test_pane(&mut server, pane_id, b"\x1b_Ga=p,i=7,c=1,r=1,q=2\x1b\\"); assert!(server.render_retained_pane_surface_and_stream(&sources)); let ServerMessage::PaneSurface(shown) = read_server_message(receive_render(&client_rx, Duration::from_millis(100))) else { panic!("expected the hidden image to return"); }; assert_eq!(shown.graphics.placements.len(), 1); assert!(shown.graphics.assets.is_empty()); write_shared_test_pane(&mut server, pane_id, b"\x1b_Ga=d,d=a,q=2\x1b\\"); assert!(server.render_retained_pane_surface_and_stream(&sources)); let _ = receive_render(&client_rx, Duration::from_millis(100)); write_shared_test_pane(&mut server, pane_id, b"\x1b_Ga=d,d=I,i=7,q=2\x1b\\"); assert!(server.render_retained_pane_surface_and_stream(&sources)); let ServerMessage::PaneSurface(deleted) = read_server_message(receive_render(&client_rx, Duration::from_millis(100))) else { panic!("expected the deleted image to be released"); }; assert!(deleted.graphics.retained_assets.is_empty()); } #[tokio::test] async fn retained_unicode_image_arrives_after_fragmented_upload_without_reupload() { let (mut server, _control_rx, client_rx, pane_id) = retained_test_server_with_control(b"\x1b[?1049h"); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.cell_size = crate::kitty_graphics::HostCellSize { width_px: 10, height_px: 20, }; server.render_and_stream(); let _ = receive_render(&client_rx, Duration::from_millis(100)); let sources = HashSet::from([pane_id]); // Yazi-style virtual placement: uploading the image and drawing its Unicode cell // can happen in separate PTY reads, with no text dirty rows when upload completes. for bytes in [ b"\x1b_Ga=t,f=32,t=d,i=1193046,s=1,v=1,q=2;/wAA/w".as_slice(), b"==\x1b\\", b"\x1b_Ga=p,U=1,i=1193046,c=1,r=1,q=2\x1b\\", ] { write_shared_test_pane(&mut server, pane_id, bytes); assert!(server.render_retained_pane_surface_and_stream(&sources)); for frame in client_rx.try_iter() { if let ServerMessage::PaneSurface(surface) = read_server_message(frame) { assert!(surface.graphics.placements.is_empty()); } } } write_shared_test_pane( &mut server, pane_id, "\x1b[2;3H\x1b[38;2;18;52;86m\u{10eeee}\u{0305}\u{0305}\x1b[0m".as_bytes(), ); assert!(server.render_retained_pane_surface_and_stream(&sources)); let ServerMessage::PaneSurface(surface) = read_server_message(receive_render(&client_rx, Duration::from_millis(100))) else { panic!("virtual image must arrive without a tab switch"); }; assert_eq!(surface.graphics.placements.len(), 1); assert_eq!(surface.graphics.assets[0].data, [255, 0, 0, 255]); // Retransmission removes placements; recreating the virtual placement must // invalidate the delivered asset without needing another text update. write_shared_test_pane( &mut server, pane_id, b"\x1b_Ga=t,f=32,t=d,i=1193046,s=1,v=1,q=2;AP8A/w==\x1b\\", ); assert!(server.render_retained_pane_surface_and_stream(&sources)); let ServerMessage::PaneSurface(removed) = read_server_message(receive_render(&client_rx, Duration::from_millis(100))) else { panic!("retransmission must remove the virtual placement"); }; assert!(removed.graphics.placements.is_empty()); write_shared_test_pane( &mut server, pane_id, b"\x1b_Ga=p,U=1,i=1193046,c=1,r=1,q=2\x1b\\", ); assert!(server.render_retained_pane_surface_and_stream(&sources)); let ServerMessage::PaneSurface(replaced) = read_server_message(receive_render(&client_rx, Duration::from_millis(100))) else { panic!("updated image pixels must arrive"); }; assert_eq!(replaced.graphics.assets[0].data, [0, 255, 0, 255]); assert_ne!( surface.graphics.assets[0].key, replaced.graphics.assets[0].key ); } #[tokio::test] #[ignore = "manual retained text/image scaling profile"] async fn render_scale_profile_retained_graphics() { use ratatui::layout::Direction; for retained in [false, true] { for image in ["none", "visible", "offscreen", "unchanged-image"] { for count in [1, 15] { let (mut server, _control_rx, client_rx, root) = retained_test_server_with_control(b"populated terminal\r\n"); let mut pane_ids = vec![root]; for index in 1..count { let workspace = &mut server.app.state.workspaces[0]; workspace.tabs[0] .layout .focus_pane(pane_ids[(index - 1) / 2]); let id = workspace.test_split(if index % 2 == 0 { Direction::Vertical } else { Direction::Horizontal }); workspace.insert_test_runtime( id, crate::terminal::TerminalRuntime::test_with_screen_bytes( 80, 24, b"populated terminal\r\n", ), ); pane_ids.push(id); } let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.cell_size = crate::kitty_graphics::HostCellSize { width_px: 10, height_px: 20, }; if image != "none" { write_shared_test_pane( &mut server, root, b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", ); } server.render_and_stream(); let _ = receive_render(&client_rx, Duration::from_millis(100)); if image == "offscreen" { write_shared_test_pane(&mut server, root, b"\x1b_Ga=d,d=a,q=2\x1b\\"); server.render_and_stream(); let _ = receive_render(&client_rx, Duration::from_millis(100)); } let sources = pane_ids.iter().copied().collect(); let mut samples = Vec::new(); for sample in 0..110 { if image != "unchanged-image" { for id in &pane_ids { write_shared_test_pane( &mut server, *id, format!("\x1b[H{sample:03}").as_bytes(), ); } } let started = Instant::now(); if retained { assert!(server.render_retained_pane_surface_and_stream(&sources)); } else { server.render_and_stream(); } let elapsed = started.elapsed(); for frame in client_rx.try_iter() { if let ServerMessage::PaneSurface(surface) = read_server_message(frame) { assert!(surface.graphics.assets.is_empty()); } } if sample >= 10 { samples.push(elapsed); } } samples.sort_unstable(); println!( "retained={retained} 80x24 panes={count} image={image} median_us={:.3} p95_us={:.3}", samples[50].as_secs_f64() * 1e6, samples[94].as_secs_f64() * 1e6 ); } } } } #[tokio::test] async fn client_shell_surface_projects_terminal_kitty_images_from_authoritative_runtime() { let (mut server, _control_rx, client_rx, _pane_id) = retained_test_server_with_control( b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", ); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.render_state = crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); client.cell_size = crate::kitty_graphics::HostCellSize { width_px: 10, height_px: 20, }; server.render_and_stream(); let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); let ServerMessage::PaneSurface(surface) = message else { panic!("expected client shell pane surface"); }; assert_eq!(surface.graphics.placements.len(), 1); assert_eq!(surface.graphics.assets.len(), 1); assert!(matches!( surface.graphics.placements[0].asset.source, crate::protocol::SurfaceGraphicsSource::Terminal { target: crate::protocol::SurfaceGraphicsTarget::Pane { .. }, image_id: 7, } )); assert_eq!(surface.graphics.assets[0].data, vec![255, 0, 0, 255]); } #[tokio::test] async fn client_shell_delivers_equal_pixels_for_distinct_terminal_image_ids() { let (mut server, _control_rx, client_rx, _pane_id) = retained_test_server_with_control( b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\\x1b_Ga=T,f=32,t=d,i=8,p=4,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\", ); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.render_state = crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); client.cell_size = crate::kitty_graphics::HostCellSize { width_px: 10, height_px: 20, }; server.render_and_stream(); let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); let ServerMessage::PaneSurface(surface) = message else { panic!("expected client shell pane surface"); }; assert_eq!(surface.graphics.placements.len(), 2); assert_eq!(surface.graphics.assets.len(), 2); assert_ne!( surface.graphics.assets[0].key, surface.graphics.assets[1].key ); assert_eq!( surface.graphics.assets[0].data, surface.graphics.assets[1].data ); server.clients.get_mut(&1).unwrap().request_repaint(); server.render_and_stream(); let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); let ServerMessage::PaneSurface(surface) = message else { panic!("expected replacement client shell pane surface"); }; assert_eq!(surface.graphics.placements.len(), 2); assert!(surface.graphics.assets.is_empty()); } #[tokio::test] async fn client_shell_keeps_offscreen_terminal_image_loaded_until_the_image_is_deleted() { let (mut server, _control_rx, client_rx, pane) = retained_test_server_with_control( b"\x1b_Ga=t,f=32,t=d,i=7,s=1,v=1,q=2;/wAA/w==\x1b\\\x1b_Ga=p,i=7,c=1,r=1,q=2\x1b\\", ); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.render_state = crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); client.cell_size = crate::kitty_graphics::HostCellSize { width_px: 10, height_px: 20, }; let render = |server: &mut HeadlessServer| { server.render_and_stream(); let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); let ServerMessage::PaneSurface(surface) = message else { panic!("expected client shell pane surface"); }; surface.graphics }; let shown = render(&mut server); assert_eq!(shown.placements.len(), 1); assert_eq!(shown.assets.len(), 1); let key = shown.assets[0].key.clone(); // Scrolling an image out of view removes its placement but keeps the image. write_shared_test_pane(&mut server, pane, b"\x1b_Ga=d,d=a,q=2\x1b\\"); let hidden = render(&mut server); assert!(hidden.placements.is_empty()); assert!(hidden.assets.is_empty()); assert_eq!(hidden.retained_assets, vec![key.clone()]); write_shared_test_pane(&mut server, pane, b"\x1b_Ga=p,i=7,c=1,r=1,q=2\x1b\\"); let returned = render(&mut server); assert_eq!(returned.placements.len(), 1); assert_eq!(returned.placements[0].asset, key); assert!( returned.assets.is_empty(), "an image the client still holds must not be sent again" ); write_shared_test_pane(&mut server, pane, b"\x1b_Ga=d,d=a,q=2\x1b\\"); assert_eq!(render(&mut server).retained_assets, vec![key.clone()]); // Replacing a hidden image with same-sized pixels releases the stale copy. write_shared_test_pane( &mut server, pane, b"\x1b_Ga=t,f=32,t=d,i=7,s=1,v=1,q=2;AAD//w==\x1b\\", ); assert!(render(&mut server).retained_assets.is_empty()); write_shared_test_pane(&mut server, pane, b"\x1b_Ga=p,i=7,c=1,r=1,q=2\x1b\\"); let replaced_hidden = render(&mut server); assert_eq!(replaced_hidden.assets.len(), 1); assert_eq!(replaced_hidden.assets[0].data, vec![0, 0, 255, 255]); write_shared_test_pane(&mut server, pane, b"\x1b_Ga=d,d=a,q=2\x1b\\"); assert_eq!(render(&mut server).retained_assets.len(), 1); // Deleting a hidden image releases it on the host. write_shared_test_pane(&mut server, pane, b"\x1b_Ga=d,d=I,i=7,q=2\x1b\\"); let deleted = render(&mut server); assert!(deleted.placements.is_empty()); assert!(deleted.retained_assets.is_empty()); // Reusing the id for new pixels delivers them instead of the old image. write_shared_test_pane( &mut server, pane, b"\x1b_Ga=T,f=32,t=d,i=7,s=1,v=1,c=1,r=1,q=2;AP8A/w==\x1b\\", ); let replaced = render(&mut server); assert_eq!(replaced.assets.len(), 1); assert_eq!(replaced.assets[0].data, vec![0, 255, 0, 255]); assert_ne!(replaced.assets[0].key, key); } #[tokio::test] async fn client_shell_evicts_least_recently_visible_offscreen_terminal_images() { let mut screen = Vec::new(); for image_id in 1..=17 { screen.extend_from_slice( format!("\x1b_Ga=T,f=32,t=d,i={image_id},s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\") .as_bytes(), ); } let (mut server, _control_rx, client_rx, pane) = retained_test_server_with_control(&screen); let client = server.clients.get_mut(&1).unwrap(); client.mode = ClientConnectionMode::ClientShell; client.render_state = crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame); client.cell_size = crate::kitty_graphics::HostCellSize { width_px: 10, height_px: 20, }; let render = |server: &mut HeadlessServer| { server.render_and_stream(); let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100))); let ServerMessage::PaneSurface(surface) = message else { panic!("expected client shell pane surface"); }; surface.graphics }; let retained_ids = |graphics: &crate::protocol::SurfaceGraphicsScene| { graphics .retained_assets .iter() .map(|key| match key.source { crate::protocol::SurfaceGraphicsSource::Terminal { image_id, .. } => image_id, _ => panic!("expected terminal image"), }) .collect::>() }; assert_eq!(render(&mut server).assets.len(), 17); write_shared_test_pane(&mut server, pane, b"\x1b_Ga=d,d=a,q=2\x1b\\"); let hidden = render(&mut server); assert!(hidden.placements.is_empty()); assert_eq!(retained_ids(&hidden), (1..=16).collect::>()); // Image 17 was evicted, so showing it sends its pixels again. write_shared_test_pane(&mut server, pane, b"\x1b_Ga=p,i=17,c=1,r=1,q=2\x1b\\"); assert_eq!(render(&mut server).assets.len(), 1); // The most recently visible image is kept first; the oldest is evicted. write_shared_test_pane(&mut server, pane, b"\x1b_Ga=d,d=a,q=2\x1b\\"); let rehidden = render(&mut server); let mut expected = vec![17]; expected.extend(1..=15); assert_eq!(retained_ids(&rehidden), expected); } fn fill_render_lane(server: &HeadlessServer) { let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig) .expect("dummy frame"); server.clients[&1] .writer .as_ref() .unwrap() .test_fill_render(queued); } #[tokio::test] async fn pixel_mouse_activation_follows_child_1016_without_graphics_demand() { let (mut server, _client_rx, _pane_id) = retained_test_server(b"\x1b[?1003h\x1b[?1006h\x1b[?1016h"); let (writer, control_rx, _render_rx) = test_client_writer(); let client = server.clients.get_mut(&1).unwrap(); client.writer = Some(writer); client.direct_graphics = false; client.pixel_mouse = true; client.cell_size = crate::kitty_graphics::HostCellSize { width_px: 10, height_px: 20, }; client.host_mouse_capture_active = None; client.host_sgr_pixels_active = None; server.stream_host_mouse_capture_mode(); assert!(matches!( read_server_message(control_rx.recv_timeout(Duration::from_millis(100)).unwrap()), ServerMessage::MouseCapture { enabled: true, sgr_pixels: true } )); }