diff options
| -rw-r--r-- | crates/ghostty-vt/src/lib.rs | 79 | ||||
| -rw-r--r-- | src/kitty_graphics.rs | 16 | ||||
| -rw-r--r-- | src/kitty_graphics/surface.rs | 107 | ||||
| -rw-r--r-- | src/pane.rs | 4 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 12 | ||||
| -rw-r--r-- | src/server/headless/tests/pane_graphics.rs | 217 | ||||
| -rw-r--r-- | src/terminal/runtime.rs | 4 |
7 files changed, 412 insertions, 27 deletions
diff --git a/crates/ghostty-vt/src/lib.rs b/crates/ghostty-vt/src/lib.rs index ef62ef1e..056ff142 100644 --- a/crates/ghostty-vt/src/lib.rs +++ b/crates/ghostty-vt/src/lib.rs @@ -12,7 +12,7 @@ pub mod bindings; use std::cell::Cell; use std::collections::hash_map::DefaultHasher; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::ffi::c_void; use std::fmt; use std::hash::{Hash, Hasher}; @@ -1824,6 +1824,37 @@ impl Terminal { Ok(generation != 0 && self.kitty_empty_generation.get() != Some(generation)) } + /// Current fingerprint of each stored image, placed or not, when it is known + /// without hashing pixels. `None` means deleted or replaced since last seen. + pub fn kitty_image_fingerprints(&self, image_ids: &[u32]) -> Result<Vec<Option<u64>>, Error> { + let graphics = self.kitty_graphics()?; + if graphics.is_null() { + return Ok(vec![None; image_ids.len()]); + } + image_ids + .iter() + .map(|&image_id| { + let image = unsafe { ffi::ghostty_kitty_graphics_image(graphics, image_id) }; + if image.is_null() { + return Ok(None); + } + if let Some(source) = native_source::image_source(image)? { + return Ok(Some(source.fingerprint())); + } + let generation = kitty_image_u64( + image, + ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_GENERATION, + )?; + Ok(self.kitty_fingerprints.lock().ok().and_then(|cache| { + cache + .get(&image_id) + .filter(|entry| entry.generation == generation) + .map(|entry| entry.fingerprint) + })) + }) + .collect() + } + pub fn kitty_image_placements(&self) -> Result<Vec<KittyImagePlacement>, Error> { self.kitty_image_placements_with_data_filter(|_| true) } @@ -1872,13 +1903,13 @@ impl Terminal { } if !storage_has_placements { self.kitty_empty_generation.set(Some(generation)); - self.prune_kitty_fingerprints(&[]); + self.prune_kitty_fingerprints(graphics); return Ok(Vec::new()); } placements.extend(self.kitty_virtual_image_placements(graphics, &mut needs_data)?); placements.sort_by_key(|placement| placement.z); - self.prune_kitty_fingerprints(&placements); + self.prune_kitty_fingerprints(graphics); Ok(placements) } @@ -1923,16 +1954,13 @@ impl Terminal { fingerprint } - fn prune_kitty_fingerprints(&self, placements: &[KittyImagePlacement]) { + /// Keeps fingerprints for hidden images so scrolling them back into view + /// does not re-hash their pixels; generation checks catch replacements. + fn prune_kitty_fingerprints(&self, graphics: ffi::GhosttyKittyGraphics) { if let Ok(mut cache) = self.kitty_fingerprints.lock() { - if cache.is_empty() { - return; - } - let live: HashSet<u32> = placements - .iter() - .map(|placement| placement.image_id) - .collect(); - cache.retain(|image_id, _| live.contains(image_id)); + cache.retain(|image_id, _| { + !unsafe { ffi::ghostty_kitty_graphics_image(graphics, *image_id) }.is_null() + }); } } @@ -3851,6 +3879,33 @@ mod tests { } #[test] + fn kitty_image_fingerprint_survives_hidden_placements_until_the_image_is_deleted() { + let mut terminal = Terminal::new(10, 5, 0).unwrap(); + let cached = + |terminal: &Terminal| terminal.kitty_fingerprints.lock().unwrap().contains_key(&7); + terminal.write(b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=10,r=5,q=2;/wAA/w==\x1b\\"); + assert_eq!( + terminal + .kitty_image_placements_with_data_filter(|_| false) + .unwrap() + .len(), + 1 + ); + assert!(cached(&terminal)); + + terminal.write(b"\x1b_Ga=d,d=a,q=2\x1b\\"); + assert!(terminal + .kitty_image_placements_with_data_filter(|_| false) + .unwrap() + .is_empty()); + assert!(cached(&terminal), "a hidden image must not be re-hashed"); + + terminal.write(b"\x1b_Ga=d,d=I,i=7,q=2\x1b\\"); + let _ = terminal.kitty_image_placements_with_data_filter(|_| false); + assert!(!cached(&terminal)); + } + + #[test] fn kitty_storage_generation_skips_only_proven_empty_storage() { let mut terminal = Terminal::new(10, 5, 1_000_000).unwrap(); terminal.enable_kitty_graphics().unwrap(); diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs index e69b9df3..f26cd8d9 100644 --- a/src/kitty_graphics.rs +++ b/src/kitty_graphics.rs @@ -335,16 +335,20 @@ fn collect_visible_placements( } }; let mut requested_images = HashSet::new(); - for placement in runtime.kitty_image_placements_with_data_filter(|descriptor| { + let pane_placements = runtime.kitty_image_placements_with_data_filter(|descriptor| { if descriptor.source_file { return false; } terminal_image_needs_data(info.id, descriptor, delivered_images, &mut requested_images) - }) { - let scrollback_offset = runtime - .scroll_metrics() - .map(|m| m.offset_from_bottom as u32) - .unwrap_or(0); + }); + if pane_placements.is_empty() { + continue; + } + let scrollback_offset = runtime + .scroll_metrics() + .map(|m| m.offset_from_bottom as u32) + .unwrap_or(0); + for placement in pane_placements { placements.push(HostPlacement { raw_data: None, pane_id: info.id, diff --git a/src/kitty_graphics/surface.rs b/src/kitty_graphics/surface.rs index ee6d6e97..09db2286 100644 --- a/src/kitty_graphics/surface.rs +++ b/src/kitty_graphics/surface.rs @@ -20,10 +20,17 @@ use crate::protocol::{ }; const MAX_SURFACE_GRAPHICS_PLACEMENTS: usize = 4_096; +// Apps such as pi scroll images out of view and back. Keeping a bounded set +// loaded on the host avoids resending their pixels on every pass. The limits +// match pi's own off-screen cache in decoded bytes, which is what hosts store. +const MAX_OFFSCREEN_IMAGES: usize = 16; +const MAX_OFFSCREEN_IMAGE_BYTES: u64 = 64 * 1024 * 1024; #[derive(Clone, Debug, Default)] pub(crate) struct DeliveryCache { assets: HashSet<SurfaceGraphicsAssetKey>, + /// Delivered terminal images without a placement, most recently visible first. + offscreen: Vec<SurfaceGraphicsAssetKey>, pending: bool, } @@ -33,6 +40,7 @@ pub(crate) type SourceFiles = impl DeliveryCache { pub(crate) fn forget_asset(&mut self, key: &SurfaceGraphicsAssetKey) { self.assets.remove(key); + self.offscreen.retain(|offscreen| offscreen != key); self.pending = true; } @@ -730,8 +738,15 @@ pub(crate) fn collect_scene( .iter() .map(|placement| placement.asset.clone()) .collect::<HashSet<_>>(); + let offscreen = offscreen_assets(app, workspace_index, &public_panes, delivered, &desired); let mut next = DeliveryCache { - assets: delivered.assets.intersection(&desired).cloned().collect(), + assets: delivered + .assets + .intersection(&desired) + .chain(&offscreen) + .cloned() + .collect(), + offscreen: offscreen.clone(), pending: false, }; let mut assets = Vec::new(); @@ -769,19 +784,105 @@ pub(crate) fn collect_scene( placement.x, ) }); - let retained_assets = Vec::new(); sources.retain(|key, _| next.assets.contains(key)); ( SurfaceGraphicsScene { assets, placements, - retained_assets, + retained_assets: offscreen, }, next, sources, ) } +/// Delivered pane images that left the view but still exist in their terminal, +/// bounded and ordered from most to least recently visible. +fn offscreen_assets( + app: &crate::app::App, + workspace_index: Option<usize>, + public_panes: &HashMap<String, PaneId>, + delivered: &DeliveryCache, + visible: &HashSet<SurfaceGraphicsAssetKey>, +) -> Vec<SurfaceGraphicsAssetKey> { + let Some(workspace_index) = workspace_index else { + return Vec::new(); + }; + fn pane_image(key: &SurfaceGraphicsAssetKey) -> Option<(&str, u32)> { + match &key.source { + SurfaceGraphicsSource::Terminal { + target: SurfaceGraphicsTarget::Pane { pane_id }, + image_id, + } => Some((pane_id.as_str(), *image_id)), + _ => None, + } + } + let mut newly_hidden = delivered + .assets + .iter() + .filter(|key| !visible.contains(*key) && !delivered.offscreen.contains(key)) + .collect::<Vec<_>>(); + if newly_hidden.is_empty() && delivered.offscreen.is_empty() { + return Vec::new(); + } + newly_hidden.sort_by_key(|key| pane_image(key)); + let visible_sources = visible + .iter() + .map(|key| &key.source) + .collect::<HashSet<_>>(); + let mut candidates_by_pane = HashMap::<PaneId, Vec<&SurfaceGraphicsAssetKey>>::new(); + let candidates = newly_hidden + .into_iter() + .chain(&delivered.offscreen) + .filter(|key| { + !visible_sources.contains(&key.source) && key.data_len <= MAX_OFFSCREEN_IMAGE_BYTES + }) + .filter_map(|key| { + let (public_id, _) = pane_image(key)?; + let pane_id = *public_panes.get(public_id)?; + candidates_by_pane.entry(pane_id).or_default().push(key); + Some(key) + }) + .collect::<Vec<_>>(); + + let mut live = HashSet::new(); + for (pane_id, keys) in candidates_by_pane { + let Some(runtime) = app.state.runtime_for_pane_in_workspace( + &app.terminal_runtimes, + workspace_index, + pane_id, + ) else { + continue; + }; + let image_ids = keys + .iter() + .filter_map(|key| pane_image(key).map(|(_, image_id)| image_id)) + .collect::<Vec<_>>(); + for (key, fingerprint) in keys + .into_iter() + .zip(runtime.kitty_image_fingerprints(&image_ids)) + { + if fingerprint == Some(key.data_fingerprint) { + live.insert(key); + } + } + } + + let mut bytes = 0u64; + let mut offscreen = Vec::new(); + for key in candidates { + if offscreen.len() == MAX_OFFSCREEN_IMAGES { + break; + } + if !live.contains(key) || bytes.saturating_add(key.data_len) > MAX_OFFSCREEN_IMAGE_BYTES { + continue; + } + bytes += key.data_len; + offscreen.push(key.clone()); + } + offscreen +} + fn image_signature_from_asset(key: &SurfaceGraphicsAssetKey) -> ImageSignature { ImageSignature { image_width: key.image_width, diff --git a/src/pane.rs b/src/pane.rs index e5695675..8fd8e396 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -3496,6 +3496,10 @@ impl PaneRuntime { .kitty_image_placements_with_data_filter(needs_data) } + pub(crate) fn kitty_image_fingerprints(&self, image_ids: &[u32]) -> Vec<Option<u64>> { + self.terminal.kitty_image_fingerprints(image_ids) + } + pub fn keyboard_protocol(&self) -> crate::input::KeyboardProtocol { let fallback = crate::input::KeyboardProtocol::from_kitty_flags( self.kitty_keyboard_flags.load(Ordering::Relaxed), diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 5eaddb21..8ac4bd1c 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -579,6 +579,10 @@ impl PaneTerminal { .kitty_image_placements_with_data_filter(needs_data) } + pub(crate) fn kitty_image_fingerprints(&self, image_ids: &[u32]) -> Vec<Option<u64>> { + self.ghostty.kitty_image_fingerprints(image_ids) + } + pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { self.ghostty.apply_host_terminal_theme(theme); } @@ -2349,6 +2353,14 @@ impl GhosttyPaneTerminal { .unwrap_or_default() } + pub(crate) fn kitty_image_fingerprints(&self, image_ids: &[u32]) -> Vec<Option<u64>> { + self.core + .lock() + .ok() + .and_then(|core| core.terminal.kitty_image_fingerprints(image_ids).ok()) + .unwrap_or_else(|| vec![None; image_ids.len()]) + } + pub fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) { let Ok(mut core) = self.core.lock() else { return; diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs index 190eaa80..1255f4eb 100644 --- a/src/server/headless/tests/pane_graphics.rs +++ b/src/server/headless/tests/pane_graphics.rs @@ -97,6 +97,73 @@ async fn first_kitty_image_updates_retained_surface_without_full_redraw() { } #[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"); @@ -174,7 +241,7 @@ async fn retained_unicode_image_arrives_after_fragmented_upload_without_reupload async fn render_scale_profile_retained_graphics() { use ratatui::layout::Direction; for retained in [false, true] { - for with_image in [false, true] { + for image in ["none", "visible", "offscreen"] { for count in [1, 15] { let (mut server, _control_rx, client_rx, root) = retained_test_server_with_control(b"populated terminal\r\n"); @@ -205,7 +272,7 @@ async fn render_scale_profile_retained_graphics() { width_px: 10, height_px: 20, }; - if with_image { + if image != "none" { write_shared_test_pane( &mut server, root, @@ -214,6 +281,11 @@ async fn render_scale_profile_retained_graphics() { } 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 { @@ -242,10 +314,10 @@ async fn render_scale_profile_retained_graphics() { } samples.sort_unstable(); println!( - "retained={retained} 80x24 panes={count} image={with_image} median_us={} p95_us={}", - samples[50].as_micros(), - samples[94].as_micros() - ); + "retained={retained} 80x24 panes={count} image={image} median_us={} p95_us={}", + samples[50].as_micros(), + samples[94].as_micros() + ); } } } @@ -322,6 +394,139 @@ async fn client_shell_delivers_equal_pixels_for_distinct_terminal_image_ids() { 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::<Vec<_>>() + }; + 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::<Vec<_>>()); + + // 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"); diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 5f1c8a85..91713409 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -458,6 +458,10 @@ impl TerminalRuntime { self.0.kitty_image_placements_with_data_filter(needs_data) } + pub(crate) fn kitty_image_fingerprints(&self, image_ids: &[u32]) -> Vec<Option<u64>> { + self.0.kitty_image_fingerprints(image_ids) + } + pub fn keyboard_protocol(&self) -> crate::input::KeyboardProtocol { self.0.keyboard_protocol() } |
