aboutsummaryrefslogtreecommitdiff
path: root/src/kitty_graphics.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/kitty_graphics.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (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.rs2001
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;