diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/kitty_graphics.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/kitty_graphics.rs')
| -rw-r--r-- | src/kitty_graphics.rs | 2001 |
1 files changed, 127 insertions, 1874 deletions
diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs index eece347c..1ce90eb3 100644 --- a/src/kitty_graphics.rs +++ b/src/kitty_graphics.rs @@ -2,21 +2,21 @@ use std::collections::hash_map::DefaultHasher; use std::collections::{HashMap, HashSet}; use std::fmt::Write as FmtWrite; use std::hash::{Hash, Hasher}; -use std::io::{self, Write}; +use std::io::Write; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Mutex, OnceLock}; use base64::Engine; use ratatui::layout::Rect; use crate::app::state::AppState; -use crate::app::Mode; use crate::ghostty::{ KittyImageDescriptor, KittyImageFormat, KittyImagePlacement, KittyPlacementRenderInfo, }; use crate::layout::{PaneId, PaneInfo}; use crate::terminal::TerminalRuntimeRegistry; +pub(crate) mod surface; + const KITTY_CHUNK_BYTES: usize = 3072; const MAX_OVERSIZED_SOURCES: usize = 256; pub(crate) const HEADLESS_GRAPHICS_TRANSACTION_BUDGET: usize = @@ -32,46 +32,9 @@ pub(crate) struct HostCellSize { } impl HostCellSize { - pub(crate) fn try_from_terminal(area: Rect) -> Option<Self> { - let Ok(size) = crossterm::terminal::window_size() else { - return None; - }; - if size.columns == 0 || size.rows == 0 || size.width == 0 || size.height == 0 { - return None; - } - Some( - Self { - width_px: (size.width as u32 / size.columns as u32).max(1), - height_px: (size.height as u32 / size.rows as u32).max(1), - } - .for_area(area), - ) - } - pub(crate) fn is_known(self) -> bool { self.width_px > 0 && self.height_px > 0 } - - pub(crate) fn fallback_for_area(area: Rect) -> Self { - Self { - width_px: 8, - height_px: 16, - } - .for_area(area) - } - - fn for_area(self, area: Rect) -> Self { - if area.width == 0 || area.height == 0 { - return Self::default(); - } - self - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct HostViewKey { - workspace_index: usize, - tab_index: usize, } #[derive(Debug)] @@ -87,8 +50,18 @@ struct HostPlacement { #[derive(Debug, Clone, Hash, PartialEq, Eq)] enum HostSourceKey { - Terminal { pane_id: PaneId, image_id: u32 }, - PaneLayer { pane_id: PaneId, layer_id: String }, + Terminal { + pane_id: PaneId, + image_id: u32, + }, + PaneLayer { + pane_id: PaneId, + layer_id: String, + }, + ClientSurface { + scope: String, + source: crate::protocol::SurfaceGraphicsSource, + }, } #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] @@ -138,13 +111,11 @@ pub(crate) struct HostGraphicsCache { sources: HashMap<HostSourceKey, u32>, oversized: HashMap<HostSourceKey, ImageSignature>, continuation: Option<(HostSourceKey, u32, usize)>, - view: Option<HostViewKey>, replay_placements: bool, replayed_placements: HashSet<(u32, u32)>, } static KITTY_GRAPHICS_ENABLED: AtomicBool = AtomicBool::new(false); -static LOCAL_HOST_GRAPHICS: OnceLock<Mutex<HostGraphicsCache>> = OnceLock::new(); pub(crate) fn set_enabled(enabled: bool) { KITTY_GRAPHICS_ENABLED.store(enabled, Ordering::Release); @@ -154,340 +125,131 @@ pub(crate) fn is_enabled() -> bool { KITTY_GRAPHICS_ENABLED.load(Ordering::Acquire) } -pub(crate) fn paint_local_pane_graphics( - app: &AppState, - graphics: &crate::app::pane_graphics::Runtime, - terminal_runtimes: &TerminalRuntimeRegistry, - cell_size: HostCellSize, -) -> io::Result<()> { - let cache = LOCAL_HOST_GRAPHICS.get_or_init(|| Mutex::new(HostGraphicsCache::default())); - let Ok(mut cache) = cache.lock() else { - return Ok(()); - }; - if graphics.slots.is_empty() && !cache.has_pane_sources() { - let encoded = encode_local_pane_graphics( - app, - graphics, - terminal_runtimes, - app.view.tab_surface(), - cell_size, - None, - &mut cache, - ); - drop(cache); - if encoded.bytes.is_empty() { - return Ok(()); - } - let mut stdout = io::stdout().lock(); - stdout.write_all(b"\x1b7")?; - stdout.write_all(&encoded.bytes)?; - stdout.write_all(b"\x1b8")?; - return stdout.flush(); - } - - let mut stdout = io::stdout().lock(); - loop { - let encoded = encode_local_pane_graphics( - app, - graphics, - terminal_runtimes, - app.view.tab_surface(), - cell_size, - None, - &mut cache, - ); - if !encoded.bytes.is_empty() { - stdout.write_all(b"\x1b7")?; - stdout.write_all(&encoded.bytes)?; - stdout.write_all(b"\x1b8")?; - } - if !encoded.incomplete { - break; - } - } - stdout.flush() -} - pub(crate) struct EncodedGraphics { pub(crate) bytes: Vec<u8>, pub(crate) incomplete: bool, } -pub(crate) fn encode_local_pane_graphics( - app: &AppState, - graphics: &crate::app::pane_graphics::Runtime, - terminal_runtimes: &TerminalRuntimeRegistry, - surface: crate::ui::TabSurfaceView<'_>, - cell_size: HostCellSize, +/// Whether appending `additional` bytes to the `current_len` bytes already +/// assembled keeps the transaction inside the caller's budget. Without a +/// budget the incremental path intentionally stays one transaction per call. +fn coalesced_transaction_fits( + current_len: usize, + additional: usize, transaction_budget: Option<usize>, - cache: &mut HostGraphicsCache, -) -> EncodedGraphics { - let visible = app.mode == Mode::Terminal && cell_size.is_known(); - if graphics.slots.is_empty() { - if !visible { - return EncodedGraphics { - bytes: cache.clear_bytes(), - incomplete: false, - }; - } - let mut bytes = if transaction_budget.is_none() && cache.has_pane_sources() { - cache.clear_pane_sources() - } else { - Vec::new() - }; - let placements = collect_visible_placements( - app, - graphics, - terminal_runtimes, - surface, - cell_size, - &cache.images, - &cache.oversized, - ); - let view_changed = cache.update_view(active_view_key(app)); - let mut encoded = - encode_terminal_graphics_update(cache, &placements, view_changed, transaction_budget); - if !bytes.is_empty() { - bytes.extend(encoded.bytes); - encoded.bytes = bytes; - } - return encoded; - } - - let live_pane_sources = graphics - .slots - .iter() - .filter(|(_, slot)| slot.layer.is_some()) - .map(|((pane_id, layer_id), _)| HostSourceKey::PaneLayer { - pane_id: *pane_id, - layer_id: layer_id.clone(), - }) - .collect::<HashSet<_>>(); - let placements = if visible { - collect_visible_placements( - app, - graphics, - terminal_runtimes, - surface, - cell_size, - &cache.images, - &cache.oversized, - ) - } else { - Vec::new() - }; - cache.update_view(visible.then(|| active_view_key(app)).flatten()); - // The host text blit overwrites Kitty placements, so every rendered frame must - // display cached images again even when their data and geometry are unchanged. - cache.request_placement_replay(); - encode_graphics_update_incremental( - cache, - &placements, - &live_pane_sources, - transaction_budget, - false, - ) -} - -pub(crate) fn has_visible_pane_graphics( - app: &AppState, - graphics: &crate::app::pane_graphics::Runtime, - terminal_runtimes: &TerminalRuntimeRegistry, - surface: crate::ui::TabSurfaceView<'_>, - cell_size: HostCellSize, ) -> bool { - if app.mode != Mode::Terminal || !cell_size.is_known() { + let Some(budget) = transaction_budget else { return false; - } + }; + current_len.saturating_add(additional) <= budget +} - let Some(ws_idx) = app.active else { - return false; +fn image_transaction_fits(placement: &HostPlacement, budget: Option<usize>) -> bool { + let Some(budget) = budget else { + return true; }; - if app - .workspaces - .get(ws_idx) - .and_then(crate::workspace::Workspace::active_tab) - .is_none() - { - return false; - } + image_transfer_estimated_size(placement.placement.data_len) <= budget +} - for info in surface.pane_infos { - let empty_uploaded = HashMap::new(); - if graphics.slots.iter().any(|((pane_id, layer_id), slot)| { - *pane_id == info.id - && slot.layer.as_ref().is_some_and(|layer| { - clipped_placement(&pane_graphics_host_placement( - info, - layer_id, - slot.host_image_id, - cell_size, - layer, - &empty_uploaded, - false, - )) - .is_some() - }) - }) { - return true; - } +pub(crate) fn image_transfer_estimated_size(data_len: usize) -> usize { + let encoded = data_len.div_ceil(3).saturating_mul(4); + let command_overhead = data_len.div_ceil(KITTY_CHUNK_BYTES).saturating_mul(16) + 1024; + encoded.saturating_add(command_overhead) +} - if let Some(runtime) = app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) - { - let scrollback_offset = runtime - .scroll_metrics() - .map(|m| m.offset_from_bottom as u32) - .unwrap_or(0); - for placement in runtime.kitty_image_placements_with_data_filter(|_| false) { - let host_placement = HostPlacement { - pane_id: info.id, - host_image_id: None, - area: info.inner_rect, - cell_size, - source_key: HostSourceKey::Terminal { - pane_id: info.id, - image_id: placement.image_id, - }, - placement, - scrollback_offset, - }; - if clipped_placement(&host_placement).is_some() { - return true; - } - } +fn placement_identity(placement: &HostPlacement) -> (HostSourceKey, u32) { + ( + placement.source_key.clone(), + host_placement_id(&placement.source_key, &placement.placement), + ) +} + +fn source_order(source: &HostSourceKey) -> (u32, String) { + match source { + HostSourceKey::Terminal { pane_id, .. } => (pane_id.raw(), String::new()), + HostSourceKey::PaneLayer { pane_id, layer_id } => (pane_id.raw(), layer_id.clone()), + HostSourceKey::ClientSurface { scope, source } => { + let mut hasher = DefaultHasher::new(); + scope.hash(&mut hasher); + source.hash(&mut hasher); + (hasher.finish() as u32, format!("{source:?}")) } } - false } -fn encode_terminal_graphics_update( +fn encode_placement_update( cache: &mut HostGraphicsCache, - placements: &[HostPlacement], - view_changed: bool, - transaction_budget: Option<usize>, -) -> EncodedGraphics { - if transaction_budget.is_some() { - cache.request_placement_replay(); - return encode_graphics_update_incremental( - cache, - placements, - &HashSet::new(), - transaction_budget, - true, - ); + placement: &HostPlacement, +) -> Option<Vec<u8>> { + let (clipped, format_code) = clipped_placement(placement)?; + let host_id = placement + .host_image_id + .unwrap_or_else(|| host_image_id(placement.pane_id, &placement.placement)); + let placement_id = host_placement_id(&placement.source_key, &placement.placement); + let key = (host_id, placement_id); + let image_signature = image_signature(placement, format_code); + let placement_signature = + placement_signature(clipped, placement.placement.z, placement.scrollback_offset); + let image_current = cache.images.get(&host_id) == Some(&image_signature); + let placement_current = cache.placements.get(&key) == Some(&placement_signature) + && (!cache.replay_placements || cache.replayed_placements.contains(&key)); + if image_current + && placement_current + && cache.sources.get(&placement.source_key) == Some(&host_id) + { + return None; } - cache.reset_incremental_state(); let mut bytes = Vec::new(); - encode_terminal_graphics_update_legacy(&mut bytes, placements, view_changed, cache); - EncodedGraphics { - bytes, - incomplete: false, - } -} - -fn encode_terminal_graphics_update_legacy( - bytes: &mut Vec<u8>, - placements: &[HostPlacement], - view_changed: bool, - cache: &mut HostGraphicsCache, -) { - let current_sources = placements - .iter() - .filter(|placement| matches!(placement.source_key, HostSourceKey::Terminal { .. })) - .map(|placement| placement.source_key.clone()) - .collect::<HashSet<_>>(); - cache - .sources - .retain(|source, _| current_sources.contains(source)); - - let mut current_placements = HashSet::new(); - for placement in placements { - let Some((clipped, format_code)) = clipped_placement(placement) else { - continue; - }; - let host_id = host_image_id(placement.pane_id, &placement.placement); - let placement_id = host_placement_id(&placement.source_key, &placement.placement); - let image_signature = image_signature(placement, format_code); - let placement_signature = - placement_signature(clipped, placement.placement.z, placement.scrollback_offset); - let placement_key = (host_id, placement_id); - current_placements.insert(placement_key); - - match cache.images.get(&host_id).copied() { - Some(existing) if existing == image_signature => {} - Some(_) => { - encode_delete_image(bytes, host_id); - cache.placements.retain(|(image_id, id), _| { - if *image_id == host_id { - current_placements.remove(&(*image_id, *id)); - false - } else { - true - } - }); - if !encode_upload_image(bytes, placement, format_code, host_id) { - continue; - } - cache.images.insert(host_id, image_signature); + let mut displayed = false; + if !image_current { + if cache.images.contains_key(&host_id) + && matches!(placement.source_key, HostSourceKey::PaneLayer { .. }) + { + if !encode_transmit_and_display( + &mut bytes, + placement, + clipped, + format_code, + host_id, + placement_id, + ) { + return None; } - None => { - if !encode_upload_image(bytes, placement, format_code, host_id) { - continue; - } - cache.images.insert(host_id, image_signature); + displayed = true; + } else { + if cache.images.contains_key(&host_id) { + encode_delete_image(&mut bytes, host_id); + cache.placements.retain(|(id, _), _| *id != host_id); + cache.replayed_placements.retain(|(id, _)| *id != host_id); + } + if !encode_upload_image(&mut bytes, placement, format_code, host_id) { + return None; } } + cache.images.insert(host_id, image_signature); + } - release_superseded_terminal_image_legacy( - bytes, - cache, - &mut current_placements, - placement.source_key.clone(), + release_superseded_source_image(&mut bytes, cache, placement.source_key.clone(), host_id); + if !displayed && !placement_current { + encode_display_placement( + &mut bytes, + clipped, host_id, + placement_id, + placement.placement.z, ); - - match cache.placements.get_mut(&placement_key) { - Some(existing) if !view_changed && *existing == placement_signature => {} - Some(existing) => { - encode_display_placement( - bytes, - clipped, - host_id, - placement_id, - placement.placement.z, - ); - *existing = placement_signature; - } - None => { - encode_display_placement( - bytes, - clipped, - host_id, - placement_id, - placement.placement.z, - ); - cache.placements.insert(placement_key, placement_signature); - } - } } - - let stale = cache - .placements - .keys() - .filter(|key| !current_placements.contains(key)) - .copied() - .collect::<Vec<_>>(); - for (host_id, placement_id) in stale { - encode_delete_placement(bytes, host_id, placement_id); - cache.placements.remove(&(host_id, placement_id)); + cache.placements.insert(key, placement_signature); + if cache.replay_placements { + cache.replayed_placements.insert(key); } + Some(bytes) } -fn release_superseded_terminal_image_legacy( +fn release_superseded_source_image( bytes: &mut Vec<u8>, cache: &mut HostGraphicsCache, - current_placements: &mut HashSet<(u32, u32)>, source: HostSourceKey, host_id: u32, ) { @@ -499,28 +261,8 @@ fn release_superseded_terminal_image_legacy( } encode_delete_image(bytes, previous); cache.images.remove(&previous); - cache.placements.retain(|(image_id, placement_id), _| { - if *image_id == previous { - current_placements.remove(&(*image_id, *placement_id)); - false - } else { - true - } - }); -} - -/// Whether appending `additional` bytes to the `current_len` bytes already -/// assembled keeps the transaction inside the caller's budget. Without a -/// budget the incremental path intentionally stays one transaction per call. -fn coalesced_transaction_fits( - current_len: usize, - additional: usize, - transaction_budget: Option<usize>, -) -> bool { - let Some(budget) = transaction_budget else { - return false; - }; - current_len.saturating_add(additional) <= budget + cache.placements.retain(|(id, _), _| *id != previous); + cache.replayed_placements.retain(|(id, _)| *id != previous); } fn encode_graphics_update_incremental( @@ -699,118 +441,6 @@ fn encode_graphics_update_incremental( } } -fn image_transaction_fits(placement: &HostPlacement, budget: Option<usize>) -> bool { - let Some(budget) = budget else { - return true; - }; - let data = placement.placement.data_len; - let encoded = data.div_ceil(3).saturating_mul(4); - let command_overhead = data.div_ceil(KITTY_CHUNK_BYTES).saturating_mul(16) + 1024; - encoded.saturating_add(command_overhead) <= budget -} - -fn placement_identity(placement: &HostPlacement) -> (HostSourceKey, u32) { - ( - placement.source_key.clone(), - host_placement_id(&placement.source_key, &placement.placement), - ) -} - -fn source_order(source: &HostSourceKey) -> (u32, String) { - match source { - HostSourceKey::Terminal { pane_id, .. } => (pane_id.raw(), String::new()), - HostSourceKey::PaneLayer { pane_id, layer_id } => (pane_id.raw(), layer_id.clone()), - } -} - -fn encode_placement_update( - cache: &mut HostGraphicsCache, - placement: &HostPlacement, -) -> Option<Vec<u8>> { - let (clipped, format_code) = clipped_placement(placement)?; - let host_id = placement - .host_image_id - .unwrap_or_else(|| host_image_id(placement.pane_id, &placement.placement)); - let placement_id = host_placement_id(&placement.source_key, &placement.placement); - let key = (host_id, placement_id); - let image_signature = image_signature(placement, format_code); - let placement_signature = - placement_signature(clipped, placement.placement.z, placement.scrollback_offset); - let image_current = cache.images.get(&host_id) == Some(&image_signature); - let placement_current = cache.placements.get(&key) == Some(&placement_signature) - && (!cache.replay_placements || cache.replayed_placements.contains(&key)); - if image_current - && placement_current - && cache.sources.get(&placement.source_key) == Some(&host_id) - { - return None; - } - - let mut bytes = Vec::new(); - let mut displayed = false; - if !image_current { - if cache.images.contains_key(&host_id) - && matches!(placement.source_key, HostSourceKey::PaneLayer { .. }) - { - if !encode_transmit_and_display( - &mut bytes, - placement, - clipped, - format_code, - host_id, - placement_id, - ) { - return None; - } - displayed = true; - } else { - if cache.images.contains_key(&host_id) { - encode_delete_image(&mut bytes, host_id); - cache.placements.retain(|(id, _), _| *id != host_id); - cache.replayed_placements.retain(|(id, _)| *id != host_id); - } - if !encode_upload_image(&mut bytes, placement, format_code, host_id) { - return None; - } - } - cache.images.insert(host_id, image_signature); - } - - release_superseded_source_image(&mut bytes, cache, placement.source_key.clone(), host_id); - if !displayed && !placement_current { - encode_display_placement( - &mut bytes, - clipped, - host_id, - placement_id, - placement.placement.z, - ); - } - cache.placements.insert(key, placement_signature); - if cache.replay_placements { - cache.replayed_placements.insert(key); - } - Some(bytes) -} - -fn release_superseded_source_image( - bytes: &mut Vec<u8>, - cache: &mut HostGraphicsCache, - source: HostSourceKey, - host_id: u32, -) { - let Some(previous) = cache.sources.insert(source, host_id) else { - return; - }; - if previous == host_id || cache.sources.values().any(|id| *id == previous) { - return; - } - encode_delete_image(bytes, previous); - cache.images.remove(&previous); - cache.placements.retain(|(id, _), _| *id != previous); - cache.replayed_placements.retain(|(id, _)| *id != previous); -} - #[cfg(test)] fn drain_graphics_updates( cache: &mut HostGraphicsCache, @@ -827,107 +457,13 @@ fn drain_graphics_updates( } } -#[cfg(test)] -fn encode_graphics_update( - bytes: &mut Vec<u8>, - placements: &[HostPlacement], - replay: bool, - images: &mut HashMap<u32, ImageSignature>, - host_placements: &mut HashMap<(u32, u32), PlacementSignature>, - sources: &mut HashMap<HostSourceKey, u32>, -) { - let mut cache = HostGraphicsCache { - images: std::mem::take(images), - placements: std::mem::take(host_placements), - sources: std::mem::take(sources), - ..HostGraphicsCache::default() - }; - let mut live = cache - .sources - .keys() - .filter(|source| matches!(source, HostSourceKey::PaneLayer { .. })) - .cloned() - .collect::<HashSet<_>>(); - live.extend( - placements - .iter() - .filter(|placement| matches!(placement.source_key, HostSourceKey::PaneLayer { .. })) - .map(|placement| placement.source_key.clone()), - ); - if live.is_empty() { - encode_terminal_graphics_update_legacy(bytes, placements, replay, &mut cache); - } else { - if replay { - cache.request_placement_replay(); - } - bytes.extend(drain_graphics_updates(&mut cache, placements, &live)); - } - *images = cache.images; - *host_placements = cache.placements; - *sources = cache.sources; -} - -pub(crate) fn clear_all_host_graphics() -> io::Result<()> { - let cache = LOCAL_HOST_GRAPHICS.get_or_init(|| Mutex::new(HostGraphicsCache::default())); - let mut bytes = Vec::new(); - if let Ok(mut cache) = cache.lock() { - bytes = cache.clear_bytes(); - } - if bytes.is_empty() { - return Ok(()); - } - let mut stdout = io::stdout().lock(); - stdout.write_all(&bytes)?; - stdout.flush() -} - impl HostGraphicsCache { - fn clear_pane_sources(&mut self) -> Vec<u8> { - let pane_sources = self - .sources - .keys() - .filter(|source| matches!(source, HostSourceKey::PaneLayer { .. })) - .cloned() - .collect::<Vec<_>>(); - let mut removed_images = HashSet::new(); - for source in pane_sources { - if let Some(image_id) = self.sources.remove(&source) { - removed_images.insert(image_id); - } - self.oversized.remove(&source); - } - - let mut bytes = Vec::new(); - for image_id in removed_images { - if self.sources.values().any(|id| *id == image_id) { - continue; - } - encode_delete_image(&mut bytes, image_id); - self.images.remove(&image_id); - self.placements.retain(|(id, _), _| *id != image_id); - self.replayed_placements.retain(|(id, _)| *id != image_id); - } - self.reset_incremental_progress(); - bytes - } - - fn has_pane_sources(&self) -> bool { - self.sources - .keys() - .any(|source| matches!(source, HostSourceKey::PaneLayer { .. })) - } - fn reset_incremental_progress(&mut self) { self.continuation = None; self.replay_placements = false; self.replayed_placements.clear(); } - fn reset_incremental_state(&mut self) { - self.oversized.clear(); - self.reset_incremental_progress(); - } - fn quarantine_oversized(&mut self, source: HostSourceKey, signature: ImageSignature) { if !self.oversized.contains_key(&source) && self.oversized.len() >= MAX_OVERSIZED_SOURCES { if let Some(evicted) = self.oversized.keys().next().cloned() { @@ -937,38 +473,6 @@ impl HostGraphicsCache { self.oversized.insert(source, signature); } - pub(crate) fn trust_pane_layer( - &mut self, - key: &crate::app::pane_graphics::Key, - host_id: u32, - layer: &crate::app::pane_graphics::Layer, - ) { - let source = HostSourceKey::PaneLayer { - pane_id: key.0, - layer_id: key.1.clone(), - }; - self.oversized.remove(&source); - self.sources.insert(source, host_id); - self.images - .insert(host_id, pane_layer_image_signature(layer)); - } - - pub(crate) fn forget_pane_layer(&mut self, key: &crate::app::pane_graphics::Key, host_id: u32) { - let source = HostSourceKey::PaneLayer { - pane_id: key.0, - layer_id: key.1.clone(), - }; - self.sources.remove(&source); - self.oversized.remove(&source); - self.images.remove(&host_id); - self.placements.retain(|(id, _), _| *id != host_id); - self.replayed_placements.retain(|(id, _)| *id != host_id); - } - - pub(crate) fn is_empty(&self) -> bool { - self.images.is_empty() && self.placements.is_empty() - } - pub(crate) fn request_placement_replay(&mut self) { if !self.replay_placements { self.replay_placements = true; @@ -976,30 +480,6 @@ impl HostGraphicsCache { } } - #[cfg(test)] - fn hide_except_live_pane_layers(&mut self, live: &HashSet<HostSourceKey>) -> Vec<u8> { - drain_graphics_updates(self, &[], live) - } - - #[cfg(test)] - pub(crate) fn test_image_count(&self) -> usize { - self.images.len() - } - - #[cfg(test)] - pub(crate) fn test_mark_non_empty(&mut self) { - self.images.insert( - HOST_IMAGE_ID_BASE, - ImageSignature { - image_width: 1, - image_height: 1, - format_code: 32, - data_len: 4, - data_fingerprint: 1, - }, - ); - } - pub(crate) fn clear_bytes(&mut self) -> Vec<u8> { let mut bytes = Vec::new(); for id in self.images.keys().copied().collect::<Vec<_>>() { @@ -1008,54 +488,10 @@ impl HostGraphicsCache { self.images.clear(); self.placements.clear(); self.sources.clear(); - self.reset_incremental_state(); - self.view = None; + self.oversized.clear(); + self.reset_incremental_progress(); bytes } - - pub(crate) fn clear_next(&mut self) -> EncodedGraphics { - self.continuation = None; - let mut bytes = Vec::new(); - if let Some(id) = self.images.keys().copied().min() { - encode_delete_image(&mut bytes, id); - self.images.remove(&id); - self.placements.retain(|(image, _), _| *image != id); - self.sources.retain(|_, image| *image != id); - self.replayed_placements.retain(|(image, _)| *image != id); - } else if let Some(key) = self.placements.keys().copied().min() { - encode_delete_placement(&mut bytes, key.0, key.1); - self.placements.remove(&key); - self.replayed_placements.remove(&key); - } else { - self.sources.clear(); - self.oversized.clear(); - self.view = None; - self.replay_placements = false; - self.replayed_placements.clear(); - } - EncodedGraphics { - bytes, - incomplete: !self.is_empty(), - } - } - - fn update_view(&mut self, view_key: Option<HostViewKey>) -> bool { - if self.view == view_key { - return false; - } - self.view = view_key; - self.continuation = None; - true - } -} - -fn active_view_key(app: &AppState) -> Option<HostViewKey> { - let ws_idx = app.active?; - let ws = app.workspaces.get(ws_idx)?; - Some(HostViewKey { - workspace_index: ws_idx, - tab_index: ws.active_tab_index(), - }) } fn collect_visible_placements( @@ -1284,6 +720,11 @@ fn host_placement_id(source_key: &HostSourceKey, placement: &KittyImagePlacement pane_id.raw().hash(&mut hasher); layer_id.hash(&mut hasher); } + HostSourceKey::ClientSurface { scope, source } => { + "client.surface".hash(&mut hasher); + scope.hash(&mut hasher); + source.hash(&mut hasher); + } } placement.image_id.hash(&mut hasher); placement.placement_id.hash(&mut hasher); @@ -1295,86 +736,6 @@ pub(crate) struct DirectFileCommand { pub(crate) control: String, } -pub(crate) fn prepare_direct_file( - app: &AppState, - graphics: &crate::app::pane_graphics::Runtime, - surface: crate::ui::TabSurfaceView<'_>, - cell_size: HostCellSize, - allow_placement: bool, - cache: &HostGraphicsCache, - key: &crate::app::pane_graphics::Key, -) -> Option<DirectFileCommand> { - let slot = graphics.slots.get(key)?; - let layer = slot.layer.as_ref()?; - layer.direct_lease()?; - - let info = allow_placement - .then(|| surface.pane_infos.iter().find(|info| info.id == key.0)) - .flatten() - .filter(|_| app.mode == Mode::Terminal && cell_size.is_known() && app.active.is_some()); - if let Some(command) = info - .map(|info| { - pane_graphics_host_placement( - info, - &key.1, - slot.host_image_id, - cell_size, - layer, - &cache.images, - false, - ) - }) - .and_then(|placement| direct_file_command(&placement, slot.host_image_id)) - .map(|(command, _, _, _)| command) - { - return Some(command); - } - - let inline_fallback_available = layer.data_len() - <= crate::api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES - && graphics.can_store_inline(key, layer.data_len()); - (!inline_fallback_available).then(|| direct_file_upload_command(layer, slot.host_image_id)) -} - -fn direct_file_upload_command( - layer: &crate::app::pane_graphics::Layer, - host_image_id: u32, -) -> DirectFileCommand { - DirectFileCommand { - leading: Vec::new(), - control: format!( - "a=t,f=32,s={},v={},i={host_image_id},q=0", - layer.image_width, layer.image_height - ), - } -} - -fn direct_file_command( - placement: &HostPlacement, - host_image_id: u32, -) -> Option<(DirectFileCommand, ClippedPlacement, u32, u32)> { - let (clipped, format_code) = clipped_placement(placement)?; - let placement_id = host_placement_id(&placement.source_key, &placement.placement); - let mut control = format!( - "a=T,f={format_code},s={},v={},i={host_image_id},p={placement_id},c={},r={},z={},C=1,q=0", - placement.placement.image_width, - placement.placement.image_height, - clipped.cols, - clipped.rows, - placement.placement.z, - ); - append_placement_controls(&mut control, clipped); - Some(( - DirectFileCommand { - leading: format!("\x1b[{};{}H", clipped.y + 1, clipped.x + 1).into_bytes(), - control, - }, - clipped, - format_code, - placement_id, - )) -} - #[cfg(unix)] pub(crate) fn encode_kitty_regular_file( out: &mut Vec<u8>, @@ -1710,18 +1071,6 @@ fn encode_kitty_data(out: &mut Vec<u8>, control: &str, data: &[u8]) { mod tests { use super::*; - #[test] - fn fallback_cell_size_is_usable_only_for_nonempty_areas() { - assert_eq!( - HostCellSize::fallback_for_area(Rect::new(0, 0, 80, 24)), - HostCellSize { - width_px: 8, - height_px: 16, - } - ); - assert!(!HostCellSize::fallback_for_area(Rect::default()).is_known()); - } - fn test_placement(viewport_col: i32, viewport_row: i32) -> HostPlacement { HostPlacement { pane_id: PaneId::from_raw(1), @@ -1779,57 +1128,17 @@ mod tests { replay: bool, ) -> Vec<u8> { let mut bytes = Vec::new(); - encode_graphics_update( - &mut bytes, - placements, - replay, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); - bytes - } - - #[test] - fn terminal_graphics_without_pane_layers_preserves_legacy_transcript() { - fn record(transcript: &mut Vec<u8>, bytes: &[u8]) { - transcript.extend_from_slice(&(bytes.len() as u64).to_le_bytes()); - transcript.extend_from_slice(bytes); - } - - fn fnv1a(bytes: &[u8]) -> u64 { - bytes.iter().fold(0xcbf29ce484222325, |hash, byte| { - (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) - }) + if replay { + cache.request_placement_replay(); } - - let mut cache = HostGraphicsCache::default(); - let mut transcript = Vec::new(); - - record( - &mut transcript, - &update(&mut cache, &[test_placement(0, 0)], false), - ); - record( - &mut transcript, - &update(&mut cache, &[test_placement(0, 0)], false), - ); - record( - &mut transcript, - &update(&mut cache, &[test_placement(-1, 2)], false), - ); - record( - &mut transcript, - &update(&mut cache, &[test_placement(-1, 2)], true), - ); - - let mut changed = test_placement(-1, 2); - changed.placement.data_fingerprint = 43; - record(&mut transcript, &update(&mut cache, &[changed], false)); - record(&mut transcript, &update(&mut cache, &[], false)); - - assert_eq!(transcript.len(), 10_084); - assert_eq!(fnv1a(&transcript), 0xc5bd_83e4_b039_870e); + let live = cache + .sources + .keys() + .filter(|source| matches!(source, HostSourceKey::PaneLayer { .. })) + .cloned() + .collect::<HashSet<_>>(); + bytes.extend(drain_graphics_updates(cache, placements, &live)); + bytes } #[test] @@ -1874,19 +1183,6 @@ mod tests { } #[test] - fn direct_file_uses_one_clipped_transmit_and_display_at_the_final_position() { - let mut placement = pane_layer_placement(-1, 2); - placement.area = Rect::new(10, 4, 8, 6); - let (command, _, _, _) = direct_file_command(&placement, (1 << 31) | 9).unwrap(); - let control = command.control; - - assert_eq!(command.leading, b"\x1b[7;11H"); - assert!(control.starts_with("a=T,f=32,s=30,v=30,i=2147483657,p=")); - assert!(control.contains(",c=2,r=3,z=0,C=1,q=0,x=10,w=20,h=30")); - assert!(direct_file_command(&pane_layer_placement(30, 0), 9).is_none()); - } - - #[test] fn pane_graphics_image_ids_are_disjoint_from_terminal_image_ids() { let placement = test_placement(0, 0); let signature = image_signature(&placement, kitty_format_code(placement.placement.format)); @@ -2034,530 +1330,6 @@ mod tests { } #[test] - fn empty_image_data_does_not_mark_image_uploaded() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - let mut placement = test_placement(0, 0); - placement.placement.data.clear(); - - encode_graphics_update( - &mut bytes, - &[placement], - false, - &mut images, - &mut placements, - &mut sources, - ); - - assert!(bytes.is_empty()); - assert!(images.is_empty()); - assert!(placements.is_empty()); - } - - #[test] - fn same_image_signature_reuses_host_upload_across_source_image_ids() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - let first = test_placement(0, 0); - - encode_graphics_update( - &mut bytes, - &[first], - false, - &mut images, - &mut placements, - &mut sources, - ); - assert_eq!(images.len(), 1); - assert_eq!(placements.len(), 1); - - bytes.clear(); - let mut same_image_new_source_id = test_placement(0, 0); - same_image_new_source_id.placement.image_id = 8; - same_image_new_source_id.placement.placement_id = 4; - same_image_new_source_id.placement.data.clear(); - encode_graphics_update( - &mut bytes, - &[same_image_new_source_id], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let reused = String::from_utf8_lossy(&bytes); - assert!(!reused.contains("a=t")); - assert!(reused.contains("a=p")); - assert_eq!(images.len(), 1); - assert_eq!(placements.len(), 1); - } - - #[test] - fn pane_layer_replacement_is_atomic_without_delete_to_blank() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - let mut first = pane_layer_placement(0, 0); - first.host_image_id = Some((1 << 31) | 7); - encode_graphics_update( - &mut bytes, - &[first], - false, - &mut images, - &mut placements, - &mut sources, - ); - - bytes.clear(); - let mut changed = pane_layer_placement(0, 0); - changed.host_image_id = Some((1 << 31) | 7); - changed.placement.data_fingerprint += 1; - encode_graphics_update( - &mut bytes, - &[changed], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(update.contains("a=T,t=d")); - assert!(update.contains(",p=") && update.contains(",C=1,q=2")); - assert!(!update.contains("a=d")); - } - - #[test] - fn replaced_image_content_deletes_superseded_host_image() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - let first = test_placement(0, 0); - - encode_graphics_update( - &mut bytes, - &[first], - false, - &mut images, - &mut placements, - &mut sources, - ); - assert_eq!(images.len(), 1); - let superseded_host_id = *images.keys().next().expect("uploaded host image"); - - // Same source image id, new pixel content: the fresh content maps to - // a fresh host image id, so the replaced one must be deleted. - bytes.clear(); - let mut changed = test_placement(0, 0); - changed.placement.data_fingerprint = 43; - encode_graphics_update( - &mut bytes, - &[changed], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(update.contains("a=t"), "changed content re-uploads"); - assert!( - update.contains(&format!("a=d,d=I,i={superseded_host_id}")), - "superseded host image is deleted" - ); - assert_eq!(images.len(), 1); - assert_eq!(placements.len(), 1); - } - - #[test] - fn shared_host_image_survives_while_another_source_references_it() { - fn twin_placement() -> HostPlacement { - let mut twin = test_placement(5, 5); - twin.placement.image_id = 8; - twin.placement.placement_id = 4; - twin.source_key = HostSourceKey::Terminal { - pane_id: twin.pane_id, - image_id: twin.placement.image_id, - }; - twin - } - - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - - encode_graphics_update( - &mut bytes, - &[test_placement(0, 0), twin_placement()], - false, - &mut images, - &mut placements, - &mut sources, - ); - assert_eq!(images.len(), 1, "same content dedups to one host image"); - - // One source moves to new content while the other still shows the - // old image: the shared host image must survive. - bytes.clear(); - let mut changed = test_placement(0, 0); - changed.placement.data_fingerprint = 43; - encode_graphics_update( - &mut bytes, - &[changed, twin_placement()], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(!update.contains("a=d,d=I"), "shared host image survives"); - assert_eq!(images.len(), 2); - } - - #[test] - fn stale_source_entry_does_not_block_superseded_image_delete() { - fn twin_placement() -> HostPlacement { - let mut twin = test_placement(5, 5); - twin.placement.image_id = 8; - twin.placement.placement_id = 4; - twin.source_key = HostSourceKey::Terminal { - pane_id: twin.pane_id, - image_id: twin.placement.image_id, - }; - twin - } - - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - - encode_graphics_update( - &mut bytes, - &[test_placement(0, 0), twin_placement()], - false, - &mut images, - &mut placements, - &mut sources, - ); - assert_eq!(images.len(), 1); - assert_eq!(sources.len(), 2); - let shared_host_id = *images.keys().next().expect("uploaded host image"); - - // The twin source is gone and the survivor changed content: the - // vanished source's stale entry must not keep the old host image - // alive. - bytes.clear(); - let mut changed = test_placement(0, 0); - changed.placement.data_fingerprint = 43; - encode_graphics_update( - &mut bytes, - &[changed], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!( - update.contains(&format!("a=d,d=I,i={shared_host_id}")), - "old host image is deleted once its last live source moves on" - ); - assert_eq!(images.len(), 1); - assert_eq!(sources.len(), 1); - } - - #[test] - fn stale_placement_deletes_placement_not_image_immediately() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - let placement = test_placement(0, 0); - - encode_graphics_update( - &mut bytes, - &[placement], - false, - &mut images, - &mut placements, - &mut sources, - ); - assert_eq!(placements.len(), 1); - - bytes.clear(); - encode_graphics_update( - &mut bytes, - &[], - false, - &mut images, - &mut placements, - &mut sources, - ); - let delete = String::from_utf8_lossy(&bytes); - assert!(delete.contains("a=d,d=i")); - assert!(!delete.contains("d=I")); - assert!(placements.is_empty()); - assert_eq!(images.len(), 1); - } - - #[test] - fn trusted_direct_image_uses_reserved_id_for_placement_without_upload() { - let key = (PaneId::from_raw(1), "primary".to_owned()); - let layer = crate::app::pane_graphics::Layer::inline( - crate::api::schema::PaneGraphicsFormat::Rgba, - 30, - 30, - vec![255; 30 * 30 * 4], - Default::default(), - 0, - ); - let reserved_id = (1 << 31) | 77; - let mut cache = HostGraphicsCache::default(); - cache.trust_pane_layer(&key, reserved_id, &layer); - let mut placement = pane_layer_placement(0, 0); - placement.host_image_id = Some(reserved_id); - placement.placement.data.clear(); - placement.placement.data_len = layer.data_len(); - placement.placement.data_fingerprint = layer.data_fingerprint; - let mut bytes = Vec::new(); - - encode_graphics_update( - &mut bytes, - &[placement], - false, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(update.contains(&format!("a=p,i={reserved_id}"))); - assert!(!update.contains("a=t")); - - let live = HashSet::from([HostSourceKey::PaneLayer { - pane_id: key.0, - layer_id: key.1.clone(), - }]); - let hidden = String::from_utf8(cache.hide_except_live_pane_layers(&live)).unwrap(); - assert!(hidden.contains("a=d,d=i")); - assert!(!hidden.contains("a=d,d=I")); - assert!(cache.images.contains_key(&reserved_id)); - assert!(cache.placements.is_empty()); - - let mut returning = pane_layer_placement(0, 0); - returning.host_image_id = Some(reserved_id); - returning.placement.data.clear(); - returning.placement.data_len = layer.data_len(); - returning.placement.data_fingerprint = layer.data_fingerprint; - let mut replay = Vec::new(); - encode_graphics_update( - &mut replay, - &[returning], - false, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); - let replay = String::from_utf8(replay).unwrap(); - assert!(replay.contains(&format!("a=p,i={reserved_id}"))); - assert!(!replay.contains("a=t")); - - cache.forget_pane_layer(&key, reserved_id); - let mut fallback = pane_layer_placement(0, 0); - fallback.host_image_id = Some(reserved_id); - let mut retransmit = Vec::new(); - encode_graphics_update( - &mut retransmit, - &[fallback], - false, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); - assert!(String::from_utf8_lossy(&retransmit).contains("a=t")); - } - - #[test] - fn hidden_layer_and_full_redraw_replay_placement_without_pixels() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - let placement = pane_layer_placement(0, 0); - encode_graphics_update( - &mut bytes, - &[placement], - false, - &mut images, - &mut placements, - &mut sources, - ); - - for (visible, replay) in [(false, false), (true, false), (true, true)] { - bytes.clear(); - let current = visible.then(|| pane_layer_placement(0, 0)); - encode_graphics_update( - &mut bytes, - current.as_slice(), - replay, - &mut images, - &mut placements, - &mut sources, - ); - let update = String::from_utf8_lossy(&bytes); - assert!(!update.contains("a=t")); - assert!(!update.contains("a=d,d=I")); - assert_eq!(update.contains("a=p"), visible); - } - assert_eq!(images.len(), 1); - assert_eq!(sources.len(), 1); - } - - #[test] - fn removed_pane_layer_deletes_unreferenced_host_image() { - let mut cache = HostGraphicsCache::default(); - let mut bytes = Vec::new(); - encode_graphics_update( - &mut bytes, - &[pane_layer_placement(0, 0)], - false, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); - let host_id = *cache.images.keys().next().expect("uploaded pane layer"); - - bytes = drain_graphics_updates(&mut cache, &[], &HashSet::new()); - - let delete = String::from_utf8_lossy(&bytes); - assert!(delete.contains(&format!("a=d,d=I,i={host_id}"))); - assert!(cache.images.is_empty()); - assert!(cache.placements.is_empty()); - assert!(cache.sources.is_empty()); - } - - #[test] - fn hidden_pane_layer_retains_image_and_removes_only_placement() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - encode_graphics_update( - &mut bytes, - &[pane_layer_placement(0, 0)], - false, - &mut images, - &mut placements, - &mut sources, - ); - let host_id = *images.keys().next().expect("uploaded pane layer"); - - bytes.clear(); - encode_graphics_update( - &mut bytes, - &[pane_layer_placement(100, 100)], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(update.contains("a=d,d=i")); - assert!(!update.contains(&format!("a=d,d=I,i={host_id}"))); - assert_eq!(images.len(), 1); - assert!(placements.is_empty()); - assert_eq!(sources.len(), 1); - } - - #[test] - fn clipped_terminal_source_retains_identity_for_later_content_replacement() { - let mut images = HashMap::new(); - let mut placements = HashMap::new(); - let mut sources = HashMap::new(); - let mut bytes = Vec::new(); - encode_graphics_update( - &mut bytes, - &[test_placement(0, 0)], - false, - &mut images, - &mut placements, - &mut sources, - ); - let original_host_id = *images.keys().next().expect("uploaded terminal image"); - - bytes.clear(); - encode_graphics_update( - &mut bytes, - &[test_placement(100, 100)], - false, - &mut images, - &mut placements, - &mut sources, - ); - assert_eq!(images.len(), 1); - assert_eq!(sources.len(), 1); - - bytes.clear(); - let mut changed = test_placement(0, 0); - changed.placement.data_fingerprint = 43; - encode_graphics_update( - &mut bytes, - &[changed], - false, - &mut images, - &mut placements, - &mut sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(update.contains(&format!("a=d,d=I,i={original_host_id}"))); - assert_eq!(images.len(), 1); - assert_eq!(sources.len(), 1); - } - - #[test] - fn removed_pane_layer_preserves_image_shared_with_terminal_source() { - let mut cache = HostGraphicsCache::default(); - let mut bytes = Vec::new(); - encode_graphics_update( - &mut bytes, - &[pane_layer_placement(0, 0), test_placement(4, 0)], - false, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); - assert_eq!(cache.images.len(), 1); - - bytes = drain_graphics_updates(&mut cache, &[], &HashSet::new()); - encode_graphics_update( - &mut bytes, - &[test_placement(4, 0)], - false, - &mut cache.images, - &mut cache.placements, - &mut cache.sources, - ); - - let update = String::from_utf8_lossy(&bytes); - assert!(!update.contains("a=d,d=I")); - assert_eq!(cache.images.len(), 1); - assert_eq!(cache.placements.len(), 1); - assert_eq!(cache.sources.len(), 1); - } - - #[test] fn changing_first_source_does_not_starve_second_source() { let layers = |first| { [(1, "a", first), (2, "b", 80)].map(|(id, name, fingerprint)| { @@ -2648,482 +1420,6 @@ mod tests { } #[test] - fn terminal_only_headless_budget_does_not_let_large_image_starve_small_image() { - let mut large = test_placement(0, 0); - large.placement.data_len = 24 * 1024 * 1024; - let mut small = test_placement(4, 0); - small.placement.image_id = 8; - small.source_key = HostSourceKey::Terminal { - pane_id: small.pane_id, - image_id: 8, - }; - let small_source = small.source_key.clone(); - let mut cache = HostGraphicsCache::default(); - - let large_only = encode_terminal_graphics_update( - &mut cache, - std::slice::from_ref(&large), - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(large_only.bytes.is_empty()); - assert_eq!(cache.oversized.len(), 1); - assert!(cache.images.is_empty()); - - let hidden = encode_terminal_graphics_update( - &mut cache, - &[], - true, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(hidden.bytes.is_empty()); - assert_eq!(cache.oversized.len(), 1); - - let with_small = encode_terminal_graphics_update( - &mut cache, - &[large, small], - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(String::from_utf8_lossy(&with_small.bytes).contains("a=t")); - assert!(with_small.bytes.len() <= HEADLESS_GRAPHICS_TRANSACTION_BUDGET); - assert!(!with_small.incomplete); - assert_eq!(cache.oversized.len(), 1); - assert_eq!(cache.images.len(), 1); - assert!(cache.sources.contains_key(&small_source)); - } - - /// A Unicode-placeholder image reaches `encode_terminal_graphics_update` as one - /// placement per viewport row, because `kitty_virtual_image_placements` scans the - /// viewport row by row. Build one image that covers `rows` rows that way. - fn image_covering_rows(rows: usize) -> Vec<HostPlacement> { - let cell_height = 10u32; - let image_height = (rows as u32) * cell_height; - let image_width = 30u32; - let data_len = (image_width * image_height * 4) as usize; - (0..rows) - .map(|row| { - let mut placement = test_placement(0, row as i32); - placement.area = Rect::new(0, 0, 120, 60); - placement.placement.placement_id = 100 + row as u32; - placement.placement.image_width = image_width; - placement.placement.image_height = image_height; - placement.placement.data_len = data_len; - placement.placement.data = vec![255; data_len]; - placement.placement.render.grid_rows = 1; - placement.placement.render.source_y = (row as u32) * cell_height; - placement.placement.render.source_height = cell_height; - placement.placement.render.source_width = image_width; - placement - }) - .collect() - } - - #[test] - fn budgeted_image_rows_upload_in_one_transaction() { - const IMAGE_ROWS: usize = 23; - let placements = image_covering_rows(IMAGE_ROWS); - let mut cache = HostGraphicsCache::default(); - - let upload = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(!upload.incomplete); - let upload = String::from_utf8(upload.bytes).unwrap(); - assert_eq!(upload.matches("a=t").count(), 1); - assert_eq!(upload.matches("a=p").count(), IMAGE_ROWS); - assert_eq!(cache.images.len(), 1, "one image backs every row"); - assert_eq!(cache.placements.len(), IMAGE_ROWS, "one placement per row"); - } - - #[test] - fn budgeted_image_rows_disappear_in_one_transaction() { - const IMAGE_ROWS: usize = 23; - let placements = image_covering_rows(IMAGE_ROWS); - let mut cache = HostGraphicsCache::default(); - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - if !encoded.incomplete { - break; - } - } - - let removed = encode_terminal_graphics_update( - &mut cache, - &[], - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(!removed.incomplete); - let removed = String::from_utf8(removed.bytes).unwrap(); - assert_eq!(removed.matches("a=d,d=i").count(), IMAGE_ROWS); - assert!(cache.placements.is_empty()); - } - - #[test] - fn budgeted_image_rows_delete_old_rows_together_before_replacement() { - const IMAGE_ROWS: usize = 23; - let old = image_covering_rows(IMAGE_ROWS); - let mut cache = HostGraphicsCache::default(); - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &old, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - if !encoded.incomplete { - break; - } - } - - let mut replacement = image_covering_rows(IMAGE_ROWS); - for placement in &mut replacement { - placement.placement.data_fingerprint += 1; - } - let cleanup = encode_terminal_graphics_update( - &mut cache, - &replacement, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(cleanup.incomplete); - let cleanup = String::from_utf8(cleanup.bytes).unwrap(); - assert_eq!(cleanup.matches("a=d,d=i").count(), IMAGE_ROWS); - assert!(!cleanup.contains("a=t")); - - let replaced = encode_terminal_graphics_update( - &mut cache, - &replacement, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(!replaced.incomplete); - let replaced = String::from_utf8(replaced.bytes).unwrap(); - assert_eq!(replaced.matches("a=t").count(), 1); - assert_eq!(replaced.matches("a=d,d=I").count(), 1); - assert_eq!(replaced.matches("a=p").count(), IMAGE_ROWS); - assert_eq!(cache.images.len(), 1); - assert_eq!(cache.placements.len(), IMAGE_ROWS); - } - - #[test] - fn budgeted_image_rows_redisplay_in_one_transaction() { - const IMAGE_ROWS: usize = 23; - let placements = image_covering_rows(IMAGE_ROWS); - let mut cache = HostGraphicsCache::default(); - - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - if !encoded.incomplete { - break; - } - } - - let replay = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(!replay.incomplete); - let replay = String::from_utf8(replay.bytes).unwrap(); - assert!(!replay.contains("a=t")); - assert_eq!(replay.matches("a=p").count(), IMAGE_ROWS); - } - - #[test] - fn budgeted_pixel_uploads_do_not_coalesce() { - let first = test_placement(0, 0); - let mut second = test_placement(4, 0); - second.placement.image_id = 8; - second.placement.placement_id = 4; - second.placement.data_fingerprint = 43; - second.source_key = HostSourceKey::Terminal { - pane_id: second.pane_id, - image_id: 8, - }; - let placements = [first, second]; - let mut cache = HostGraphicsCache::default(); - - let first_pass = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!( - first_pass.incomplete, - "the second upload must wait for its own transaction" - ); - let first_pass = String::from_utf8(first_pass.bytes).unwrap(); - assert_eq!(first_pass.matches("a=t").count(), 1); - - let second_pass = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(!second_pass.incomplete); - let second_pass = String::from_utf8(second_pass.bytes).unwrap(); - assert_eq!(second_pass.matches("a=t").count(), 1); - assert_eq!(cache.images.len(), 2); - - let replay = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(!replay.incomplete); - let replay = String::from_utf8(replay.bytes).unwrap(); - assert!(!replay.contains("a=t")); - assert_eq!(replay.matches("a=p").count(), 2); - } - - #[test] - fn budgeted_redisplay_coalescing_respects_budget() { - let placements = image_covering_rows(2); - let mut cache = HostGraphicsCache::default(); - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - if !encoded.incomplete { - break; - } - } - - // A budget that admits one re-display command but not two. - let tight = encode_terminal_graphics_update(&mut cache, &placements, false, Some(100)); - assert!(tight.incomplete); - let tight = String::from_utf8(tight.bytes).unwrap(); - assert!(!tight.contains("a=t")); - assert_eq!(tight.matches("a=p").count(), 1); - - let rest = encode_terminal_graphics_update(&mut cache, &placements, false, Some(100)); - assert!(!rest.incomplete); - let rest = String::from_utf8(rest.bytes).unwrap(); - assert!(!rest.contains("a=t")); - assert_eq!(rest.matches("a=p").count(), 1); - } - - #[test] - fn budgeted_upload_keeps_other_redisplays_out() { - let fresh = test_placement(0, 0); - let mut cached = test_placement(4, 0); - cached.placement.image_id = 8; - cached.placement.placement_id = 4; - cached.placement.data_fingerprint = 43; - cached.source_key = HostSourceKey::Terminal { - pane_id: cached.pane_id, - image_id: 8, - }; - let mut cache = HostGraphicsCache::default(); - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - std::slice::from_ref(&cached), - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - if !encoded.incomplete { - break; - } - } - - // A fresh image followed by the cached image at a new position. - cached.placement.render.viewport_col = 8; - let placements = [fresh, cached]; - let mut passes = 0; - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - passes += 1; - assert!(passes <= 4, "did not converge"); - let bytes = String::from_utf8(encoded.bytes).unwrap(); - if bytes.contains("a=t") { - assert_eq!( - bytes.matches("a=p").count(), - 1, - "an upload carries only its own placement" - ); - } - if !encoded.incomplete { - break; - } - } - assert_eq!(cache.images.len(), 2); - } - - #[test] - fn budgeted_superseded_image_delete_does_not_coalesce() { - fn pair() -> [HostPlacement; 2] { - let first = test_placement(0, 0); - let mut second = test_placement(4, 0); - second.placement.image_id = 8; - second.placement.placement_id = 4; - second.placement.data_fingerprint = 43; - second.source_key = HostSourceKey::Terminal { - pane_id: second.pane_id, - image_id: 8, - }; - [first, second] - } - let mut cache = HostGraphicsCache::default(); - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &pair(), - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - if !encoded.incomplete { - break; - } - } - assert_eq!(cache.images.len(), 2); - - // The first source now shows the second image's content, so its old - // image gets released, while the second placement moves. - let [mut first, mut second] = pair(); - first.placement.data_fingerprint = 43; - second.placement.render.viewport_col = 8; - let placements = [first, second]; - let mut passes = 0; - let mut saw_release = false; - loop { - let encoded = encode_terminal_graphics_update( - &mut cache, - &placements, - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - passes += 1; - assert!(passes <= 6, "did not converge"); - let bytes = String::from_utf8(encoded.bytes).unwrap(); - if bytes.contains("a=d,d=I") { - saw_release = true; - assert!( - bytes.matches("a=p").count() <= 1, - "a superseded-image delete carries at most its own placement" - ); - } - if !encoded.incomplete { - break; - } - } - assert!(saw_release, "the old image was released"); - assert_eq!(cache.images.len(), 1); - } - - #[test] - fn budgeted_pane_cleanup_precedes_terminal_image_upload() { - let mut cache = HostGraphicsCache::default(); - let pane_source = HostSourceKey::PaneLayer { - pane_id: PaneId::from_raw(1), - layer_id: "primary".into(), - }; - cache.sources.insert(pane_source, 99); - cache.images.insert( - 99, - ImageSignature { - image_width: 30, - image_height: 30, - format_code: 32, - data_len: 30 * 30 * 4, - data_fingerprint: 9, - }, - ); - let terminal = test_placement(0, 0); - - let cleanup = encode_terminal_graphics_update( - &mut cache, - std::slice::from_ref(&terminal), - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(cleanup.incomplete); - let cleanup = String::from_utf8(cleanup.bytes).unwrap(); - assert!(cleanup.contains("a=d,d=I,i=99")); - assert!(!cleanup.contains("a=t")); - assert!(cache.images.is_empty()); - - let upload = encode_terminal_graphics_update( - &mut cache, - &[terminal], - false, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - ); - assert!(String::from_utf8_lossy(&upload.bytes).contains("a=t")); - } - - #[test] - fn terminal_only_high_level_path_preserves_budget_quarantine() { - let mut app = crate::app::state::AppState::test_new(); - let workspace = crate::workspace::Workspace::test_new("graphics-budget-dispatch"); - let pane_id = workspace.tabs[0].root_pane; - app.workspaces = vec![workspace]; - app.active = Some(0); - app.selected = 0; - app.mode = Mode::Terminal; - crate::ui::compute_view(&mut app, Rect::new(0, 0, 80, 24)); - - let source = HostSourceKey::Terminal { - pane_id, - image_id: 7, - }; - let mut cache = HostGraphicsCache::default(); - cache.quarantine_oversized( - source.clone(), - ImageSignature { - image_width: 3456, - image_height: 2234, - format_code: 32, - data_len: 3456 * 2234 * 4, - data_fingerprint: 42, - }, - ); - - let encoded = encode_local_pane_graphics( - &app, - &crate::app::pane_graphics::Runtime::default(), - &TerminalRuntimeRegistry::new(), - app.view.tab_surface(), - HostCellSize { - width_px: 10, - height_px: 20, - }, - Some(HEADLESS_GRAPHICS_TRANSACTION_BUDGET), - &mut cache, - ); - - assert!(encoded.bytes.is_empty()); - assert!(cache.oversized.contains_key(&source)); - } - - #[test] fn terminal_image_data_requests_deduplicate_and_reconsider_changed_signatures() { let pane_id = PaneId::from_raw(1); let descriptor = KittyImageDescriptor { @@ -3179,49 +1475,6 @@ mod tests { } #[test] - fn terminal_quarantine_survives_pane_layer_cleanup_and_stays_bounded() { - let terminal_source = HostSourceKey::Terminal { - pane_id: PaneId::from_raw(1), - image_id: 7, - }; - let signature = ImageSignature { - image_width: 3456, - image_height: 2234, - format_code: 32, - data_len: 3456 * 2234 * 4, - data_fingerprint: 42, - }; - let pane_source = HostSourceKey::PaneLayer { - pane_id: PaneId::from_raw(1), - layer_id: "primary".into(), - }; - let mut cache = HostGraphicsCache::default(); - cache.quarantine_oversized(terminal_source.clone(), signature); - cache.sources.insert(pane_source, 99); - cache.images.insert(99, signature); - - let cleared = String::from_utf8(cache.clear_pane_sources()).unwrap(); - assert!(cleared.contains("a=d,d=I,i=99")); - assert!(cache.oversized.contains_key(&terminal_source)); - - for image_id in 0..=MAX_OVERSIZED_SOURCES as u32 { - cache.quarantine_oversized( - HostSourceKey::Terminal { - pane_id: PaneId::from_raw(2), - image_id, - }, - ImageSignature { - data_fingerprint: u64::from(image_id), - ..signature - }, - ); - } - assert_eq!(cache.oversized.len(), MAX_OVERSIZED_SOURCES); - cache.clear_bytes(); - assert!(cache.oversized.is_empty()); - } - - #[test] fn maximum_pane_graphics_stream_payload_fits_client_graphics_frame() { let mut placement = pane_layer_placement(0, 0); placement.placement.format = KittyImageFormat::Png; |
