aboutsummaryrefslogtreecommitdiff
path: root/src/kitty_graphics
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
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')
-rw-r--r--src/kitty_graphics/surface.rs1067
1 files changed, 1067 insertions, 0 deletions
diff --git a/src/kitty_graphics/surface.rs b/src/kitty_graphics/surface.rs
new file mode 100644
index 00000000..4cd55a75
--- /dev/null
+++ b/src/kitty_graphics/surface.rs
@@ -0,0 +1,1067 @@
+use std::collections::{HashMap, HashSet};
+use std::hash::{Hash, Hasher};
+
+use ratatui::layout::Rect;
+
+use super::{
+ clipped_placement, collect_visible_placements, encode_graphics_update_incremental,
+ HostCellSize, HostGraphicsCache, HostPlacement, HostSourceKey, ImageSignature,
+};
+use crate::ghostty::{
+ KittyImageDescriptor, KittyImageFormat, KittyImagePlacement, KittyPlacementRenderInfo,
+};
+use crate::layout::PaneId;
+use crate::protocol::{
+ SurfaceGraphicsAsset, SurfaceGraphicsAssetKey, SurfaceGraphicsFormat, SurfaceGraphicsPlacement,
+ SurfaceGraphicsScene, SurfaceGraphicsSource, SurfaceGraphicsTarget,
+};
+
+const MAX_SURFACE_GRAPHICS_PLACEMENTS: usize = 4_096;
+
+#[derive(Clone, Debug, Default)]
+pub(crate) struct DeliveryCache {
+ assets: HashSet<SurfaceGraphicsAssetKey>,
+ pending: bool,
+}
+
+impl DeliveryCache {
+ pub(crate) fn has_pending(&self) -> bool {
+ self.pending
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum Visibility {
+ Main,
+ Popup,
+ Hidden,
+}
+
+#[derive(Debug, Default)]
+pub(crate) struct ClientState {
+ scope: String,
+ scene: SurfaceGraphicsScene,
+ assets: HashMap<SurfaceGraphicsAssetKey, Vec<u8>>,
+ host: HostGraphicsCache,
+ trusted_direct: HashMap<SurfaceGraphicsAssetKey, u32>,
+ reset_pending: bool,
+ stale_images: Vec<u32>,
+ forced_delete_images: Vec<u32>,
+}
+
+impl ClientState {
+ pub(crate) fn set_scope(&mut self, scope: &str) {
+ if self.scope == scope {
+ return;
+ }
+ self.scope = scope.to_owned();
+ self.scene = SurfaceGraphicsScene::default();
+ self.assets.clear();
+ self.trusted_direct.clear();
+ self.stale_images.clear();
+ self.forced_delete_images.clear();
+ self.reset_pending = true;
+ }
+
+ #[cfg(unix)]
+ pub(crate) fn trust_direct_asset(
+ &mut self,
+ key: &SurfaceGraphicsAssetKey,
+ image_id: u32,
+ ) -> bool {
+ if image_id != host_image_id(&self.scope, key) {
+ return false;
+ }
+ self.host
+ .images
+ .insert(image_id, image_signature_from_asset(key));
+ if self
+ .scene
+ .placements
+ .iter()
+ .all(|placement| &placement.asset != key)
+ && !self.scene.retained_assets.contains(key)
+ {
+ self.trusted_direct.insert(key.clone(), image_id);
+ }
+ true
+ }
+
+ #[cfg(unix)]
+ pub(crate) fn retire_direct_image(&mut self, image_id: u32) {
+ self.trusted_direct
+ .retain(|_, trusted| *trusted != image_id);
+ self.forced_delete_images.push(image_id);
+ }
+
+ pub(crate) fn take_pending_cleanup(&mut self) -> Vec<u8> {
+ let mut bytes = if self.reset_pending {
+ self.reset_pending = false;
+ self.stale_images.clear();
+ self.host.clear_bytes()
+ } else {
+ Vec::new()
+ };
+ self.forced_delete_images.sort_unstable();
+ self.forced_delete_images.dedup();
+ for image_id in self.forced_delete_images.drain(..) {
+ self.host.images.remove(&image_id);
+ self.host.placements.retain(|(id, _), _| *id != image_id);
+ self.host.sources.retain(|_, id| *id != image_id);
+ self.host
+ .replayed_placements
+ .retain(|(id, _)| *id != image_id);
+ super::encode_delete_image(&mut bytes, image_id);
+ }
+ bytes
+ }
+
+ pub(crate) fn set_scene(&mut self, mut scene: SurfaceGraphicsScene) {
+ let desired = scene
+ .placements
+ .iter()
+ .map(|placement| placement.asset.clone())
+ .chain(scene.retained_assets.iter().cloned())
+ .collect::<HashSet<_>>();
+ let previous = self
+ .scene
+ .placements
+ .iter()
+ .map(|placement| placement.asset.clone())
+ .chain(self.scene.retained_assets.iter().cloned())
+ .collect::<HashSet<_>>();
+ self.stale_images.extend(
+ previous
+ .difference(&desired)
+ .map(|key| host_image_id(&self.scope, key)),
+ );
+ let unclaimed = self
+ .trusted_direct
+ .iter()
+ .filter_map(|(key, image_id)| (!desired.contains(key)).then_some(*image_id))
+ .collect::<Vec<_>>();
+ self.stale_images.extend(unclaimed);
+ self.trusted_direct.clear();
+ let placed = scene
+ .placements
+ .iter()
+ .map(|placement| placement.asset.clone())
+ .collect::<HashSet<_>>();
+ self.assets.retain(|key, _| placed.contains(key));
+ for asset in std::mem::take(&mut scene.assets) {
+ if asset.data.len() as u64 == asset.key.data_len && placed.contains(&asset.key) {
+ self.assets.insert(asset.key, asset.data);
+ }
+ }
+ self.scene = scene;
+ }
+
+ pub(crate) fn encode(
+ &mut self,
+ visibility: Visibility,
+ main_origin: (u16, u16),
+ popup_origin: Option<(u16, u16)>,
+ cell_size: HostCellSize,
+ ) -> Vec<u8> {
+ let mut bytes = self.take_pending_cleanup();
+ self.stale_images.sort_unstable();
+ self.stale_images.dedup();
+ for image_id in self.stale_images.drain(..) {
+ if self.host.images.remove(&image_id).is_some() {
+ super::encode_delete_image(&mut bytes, image_id);
+ }
+ self.host.placements.retain(|(id, _), _| *id != image_id);
+ self.host.sources.retain(|_, id| *id != image_id);
+ self.host
+ .replayed_placements
+ .retain(|(id, _)| *id != image_id);
+ }
+ if !cell_size.is_known() || self.scope.is_empty() {
+ bytes.extend(self.host.clear_bytes());
+ return bytes;
+ }
+
+ let placements = self
+ .scene
+ .placements
+ .iter()
+ .filter_map(|placement| {
+ client_host_placement(
+ &self.scope,
+ placement,
+ self.assets.get(&placement.asset).map(Vec::as_slice),
+ visibility,
+ main_origin,
+ popup_origin,
+ cell_size,
+ )
+ })
+ .collect::<Vec<_>>();
+ self.host.request_placement_replay();
+ loop {
+ let encoded = encode_graphics_update_incremental(
+ &mut self.host,
+ &placements,
+ &HashSet::new(),
+ None,
+ false,
+ );
+ bytes.extend(encoded.bytes);
+ if !encoded.incomplete {
+ return bytes;
+ }
+ }
+ }
+}
+
+pub(crate) fn pane_layer_asset_key(
+ app: &crate::app::App,
+ key: &crate::app::pane_graphics::Key,
+ layer: &crate::app::pane_graphics::Layer,
+) -> Option<SurfaceGraphicsAssetKey> {
+ let workspace_index = app
+ .state
+ .workspaces
+ .iter()
+ .position(|workspace| workspace.pane_state(key.0).is_some())?;
+ Some(SurfaceGraphicsAssetKey {
+ source: SurfaceGraphicsSource::PaneLayer {
+ pane_id: app.public_pane_id(workspace_index, key.0)?,
+ layer_id: key.1.clone(),
+ },
+ image_width: layer.image_width,
+ image_height: layer.image_height,
+ format: match layer.format {
+ crate::api::schema::PaneGraphicsFormat::Rgb => SurfaceGraphicsFormat::Rgb,
+ crate::api::schema::PaneGraphicsFormat::Rgba
+ | crate::api::schema::PaneGraphicsFormat::Bgra => SurfaceGraphicsFormat::Rgba,
+ crate::api::schema::PaneGraphicsFormat::Png => SurfaceGraphicsFormat::Png,
+ },
+ data_len: layer.data_len() as u64,
+ data_fingerprint: layer.data_fingerprint,
+ })
+}
+
+pub(crate) fn host_image_id(scope: &str, key: &SurfaceGraphicsAssetKey) -> u32 {
+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
+ scope.hash(&mut hasher);
+ key.hash(&mut hasher);
+ 10_000 + ((hasher.finish() as u32) % 900_000)
+}
+
+pub(crate) fn direct_upload_control(scope: &str, key: &SurfaceGraphicsAssetKey) -> (u32, String) {
+ let image_id = host_image_id(scope, key);
+ (
+ image_id,
+ format!(
+ "a=t,f={},s={},v={},i={image_id},q=0",
+ format_code(key.format),
+ key.image_width,
+ key.image_height
+ ),
+ )
+}
+
+pub(crate) fn collect_scene(
+ app: &crate::app::App,
+ surface: crate::ui::TabSurfaceView<'_>,
+ popup_content_size: Option<(u16, u16)>,
+ cell_size: HostCellSize,
+ delivered: &DeliveryCache,
+ client_id: u64,
+) -> (SurfaceGraphicsScene, DeliveryCache) {
+ if !cell_size.is_known() {
+ return (SurfaceGraphicsScene::default(), DeliveryCache::default());
+ }
+ let workspace_index = app.state.active;
+ let mut targets = HashMap::new();
+ let mut public_panes = HashMap::new();
+ if let Some(workspace_index) = workspace_index {
+ for pane in surface.pane_infos {
+ if let Some(public_id) = app.public_pane_id(workspace_index, pane.id) {
+ public_panes.insert(public_id.clone(), pane.id);
+ targets.insert(pane.id, SurfaceGraphicsTarget::Pane { pane_id: public_id });
+ }
+ }
+ }
+ let popup_target = app.state.popup_pane.as_ref().map(|popup| {
+ (
+ popup.pane_id,
+ SurfaceGraphicsTarget::Popup {
+ terminal_id: popup.terminal_id.to_string(),
+ },
+ )
+ });
+ if let Some((pane_id, target)) = popup_target.as_ref() {
+ targets.insert(*pane_id, target.clone());
+ }
+
+ // Reconstruct only the small image-signature index expected by the existing
+ // collector. This prevents copying already-delivered image payloads on each
+ // pane-scaled render while keeping Ghostty as the authoritative image store.
+ let mut uploaded_images = HashMap::new();
+ let mut delivered_terminal_images = HashMap::new();
+ for key in &delivered.assets {
+ let signature = image_signature_from_asset(key);
+ match &key.source {
+ SurfaceGraphicsSource::Terminal {
+ target: SurfaceGraphicsTarget::Pane { pane_id },
+ image_id,
+ } => {
+ if let Some(pane_id) = public_panes.get(pane_id) {
+ delivered_terminal_images.insert(
+ HostSourceKey::Terminal {
+ pane_id: *pane_id,
+ image_id: *image_id,
+ },
+ signature,
+ );
+ }
+ }
+ SurfaceGraphicsSource::PaneLayer { pane_id, layer_id } => {
+ if let Some(pane_id) = public_panes.get(pane_id) {
+ if let Some(slot) = app.pane_graphics.slots.get(&(*pane_id, layer_id.clone())) {
+ uploaded_images.insert(slot.host_image_id, signature);
+ }
+ }
+ }
+ SurfaceGraphicsSource::Terminal {
+ target: SurfaceGraphicsTarget::Popup { .. },
+ ..
+ } => {}
+ }
+ }
+ let mut host_placements = collect_visible_placements(
+ &app.state,
+ &app.pane_graphics,
+ &app.terminal_runtimes,
+ surface,
+ cell_size,
+ &uploaded_images,
+ &delivered_terminal_images,
+ );
+
+ if let (Some(popup), Some((width, height)), Some((_, target))) = (
+ app.state.popup_pane.as_ref(),
+ popup_content_size,
+ popup_target.as_ref(),
+ ) {
+ if let Some(runtime) = app.terminal_runtimes.get(&popup.terminal_id) {
+ let mut requested = HashSet::new();
+ for placement in runtime.kitty_image_placements_with_data_filter(|descriptor| {
+ let key = asset_key_from_descriptor(
+ SurfaceGraphicsSource::Terminal {
+ target: target.clone(),
+ image_id: descriptor.image_id,
+ },
+ descriptor,
+ );
+ !delivered.assets.contains(&key) && requested.insert(key)
+ }) {
+ host_placements.push(HostPlacement {
+ pane_id: popup.pane_id,
+ host_image_id: None,
+ area: Rect::new(0, 0, width, height),
+ cell_size,
+ source_key: HostSourceKey::Terminal {
+ pane_id: popup.pane_id,
+ image_id: placement.image_id,
+ },
+ placement,
+ scrollback_offset: runtime
+ .scroll_metrics()
+ .map(|metrics| metrics.offset_from_bottom as u32)
+ .unwrap_or(0),
+ });
+ }
+ }
+ }
+
+ let mut placements = Vec::new();
+ let mut asset_data = HashMap::<SurfaceGraphicsAssetKey, Vec<u8>>::new();
+ for mut placement in host_placements {
+ if placements.len() == MAX_SURFACE_GRAPHICS_PLACEMENTS {
+ break;
+ }
+ let Some(target) = targets.get(&placement.pane_id).cloned() else {
+ continue;
+ };
+ let source = match &placement.source_key {
+ HostSourceKey::Terminal { image_id, .. } => SurfaceGraphicsSource::Terminal {
+ target,
+ image_id: *image_id,
+ },
+ HostSourceKey::PaneLayer { layer_id, .. } => {
+ let SurfaceGraphicsTarget::Pane { pane_id } = target else {
+ continue;
+ };
+ SurfaceGraphicsSource::PaneLayer {
+ pane_id,
+ layer_id: layer_id.clone(),
+ }
+ }
+ HostSourceKey::ClientSurface { .. } => continue,
+ };
+ let Some((clipped, _)) = clipped_placement(&placement) else {
+ continue;
+ };
+ let asset = asset_key(source, &placement.placement);
+ if !placement.placement.data.is_empty() {
+ asset_data
+ .entry(asset.clone())
+ .or_insert_with(|| std::mem::take(&mut placement.placement.data));
+ }
+ placements.push(SurfaceGraphicsPlacement {
+ asset,
+ logical_placement_id: placement.placement.placement_id,
+ x: clipped.x,
+ y: clipped.y,
+ cols: clipped.cols,
+ rows: clipped.rows,
+ source_x: clipped.source_x,
+ source_y: clipped.source_y,
+ source_width: clipped.source_width,
+ source_height: clipped.source_height,
+ x_offset: clipped.x_offset,
+ y_offset: clipped.y_offset,
+ z: placement.placement.z,
+ scrollback_offset: placement.scrollback_offset,
+ });
+ }
+
+ let desired = placements
+ .iter()
+ .map(|placement| placement.asset.clone())
+ .collect::<HashSet<_>>();
+ let mut next = DeliveryCache {
+ assets: delivered.assets.intersection(&desired).cloned().collect(),
+ pending: false,
+ };
+ let mut assets = Vec::new();
+ let mut available = asset_data.into_iter().collect::<Vec<_>>();
+ available.sort_by_key(|(key, _)| format!("{:?}", key.source));
+ let mut payload_bytes = 0usize;
+ for (key, data) in available {
+ if next.assets.contains(&key) {
+ continue;
+ }
+ let encoded_size = super::image_transfer_estimated_size(data.len());
+ if encoded_size > super::HEADLESS_GRAPHICS_TRANSACTION_BUDGET {
+ continue;
+ }
+ if payload_bytes.saturating_add(encoded_size) > super::HEADLESS_GRAPHICS_TRANSACTION_BUDGET
+ {
+ next.pending = true;
+ continue;
+ }
+ payload_bytes = payload_bytes.saturating_add(encoded_size);
+ assets.push(SurfaceGraphicsAsset {
+ key: key.clone(),
+ data,
+ });
+ next.assets.insert(key);
+ }
+ assets.sort_by_key(|asset| format!("{:?}", asset.key.source));
+ placements.sort_by_key(|placement| {
+ (
+ format!("{:?}", placement.asset.source),
+ placement.logical_placement_id,
+ placement.y,
+ placement.x,
+ )
+ });
+ let mut retained_assets = app
+ .pane_graphics
+ .slots
+ .iter()
+ .filter_map(|(key, slot)| {
+ let layer = slot.layer.as_ref()?;
+ let retained_for_client = layer.resident_client() == Some(client_id)
+ || slot
+ .direct_gate
+ .as_ref()
+ .is_some_and(|gate| gate.client_id == client_id);
+ retained_for_client.then(|| pane_layer_asset_key(app, key, layer))?
+ })
+ .collect::<Vec<_>>();
+ retained_assets.sort_by_key(|key| format!("{:?}", key.source));
+ (
+ SurfaceGraphicsScene {
+ assets,
+ placements,
+ retained_assets,
+ },
+ next,
+ )
+}
+
+fn image_signature_from_asset(key: &SurfaceGraphicsAssetKey) -> ImageSignature {
+ ImageSignature {
+ image_width: key.image_width,
+ image_height: key.image_height,
+ format_code: format_code(key.format),
+ data_len: usize::try_from(key.data_len).unwrap_or(usize::MAX),
+ data_fingerprint: key.data_fingerprint,
+ }
+}
+
+fn asset_key_from_descriptor(
+ source: SurfaceGraphicsSource,
+ descriptor: KittyImageDescriptor,
+) -> SurfaceGraphicsAssetKey {
+ SurfaceGraphicsAssetKey {
+ source,
+ image_width: descriptor.image_width,
+ image_height: descriptor.image_height,
+ format: match descriptor.format {
+ KittyImageFormat::Rgb => SurfaceGraphicsFormat::Rgb,
+ KittyImageFormat::Rgba => SurfaceGraphicsFormat::Rgba,
+ KittyImageFormat::Png => SurfaceGraphicsFormat::Png,
+ },
+ data_len: descriptor.data_len as u64,
+ data_fingerprint: descriptor.data_fingerprint,
+ }
+}
+
+fn asset_key(
+ source: SurfaceGraphicsSource,
+ placement: &KittyImagePlacement,
+) -> SurfaceGraphicsAssetKey {
+ SurfaceGraphicsAssetKey {
+ source,
+ image_width: placement.image_width,
+ image_height: placement.image_height,
+ format: match placement.format {
+ KittyImageFormat::Rgb => SurfaceGraphicsFormat::Rgb,
+ KittyImageFormat::Rgba => SurfaceGraphicsFormat::Rgba,
+ KittyImageFormat::Png => SurfaceGraphicsFormat::Png,
+ },
+ data_len: placement.data_len as u64,
+ data_fingerprint: placement.data_fingerprint,
+ }
+}
+
+fn client_host_placement(
+ scope: &str,
+ placement: &SurfaceGraphicsPlacement,
+ data: Option<&[u8]>,
+ visibility: Visibility,
+ main_origin: (u16, u16),
+ popup_origin: Option<(u16, u16)>,
+ cell_size: HostCellSize,
+) -> Option<HostPlacement> {
+ let origin = match (&placement.asset.source, visibility) {
+ (
+ SurfaceGraphicsSource::Terminal {
+ target: SurfaceGraphicsTarget::Popup { .. },
+ ..
+ },
+ Visibility::Popup,
+ ) => popup_origin?,
+ (
+ SurfaceGraphicsSource::Terminal {
+ target: SurfaceGraphicsTarget::Pane { .. },
+ ..
+ },
+ Visibility::Main | Visibility::Popup,
+ )
+ | (SurfaceGraphicsSource::PaneLayer { .. }, Visibility::Main | Visibility::Popup) => {
+ main_origin
+ }
+ _ => return None,
+ };
+ let source_key = HostSourceKey::ClientSurface {
+ scope: scope.to_owned(),
+ source: placement.asset.source.clone(),
+ };
+ let signature = ImageSignature {
+ image_width: placement.asset.image_width,
+ image_height: placement.asset.image_height,
+ format_code: format_code(placement.asset.format),
+ data_len: usize::try_from(placement.asset.data_len).unwrap_or(usize::MAX),
+ data_fingerprint: placement.asset.data_fingerprint,
+ };
+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
+ scope.hash(&mut hasher);
+ placement.asset.source.hash(&mut hasher);
+ signature.hash(&mut hasher);
+ let raw = hasher.finish();
+ let pane_id = PaneId::from_raw((raw as u32).max(1));
+ let host_image_id = host_image_id(scope, &placement.asset);
+ let cols = placement.cols.min(u32::from(u16::MAX)) as u16;
+ let rows = placement.rows.min(u32::from(u16::MAX)) as u16;
+ Some(HostPlacement {
+ pane_id,
+ host_image_id: Some(host_image_id),
+ area: Rect::new(
+ origin.0.saturating_add(placement.x),
+ origin.1.saturating_add(placement.y),
+ cols,
+ rows,
+ ),
+ cell_size,
+ source_key,
+ placement: KittyImagePlacement {
+ image_id: 1,
+ placement_id: placement.logical_placement_id,
+ z: placement.z,
+ x_offset: placement.x_offset,
+ y_offset: placement.y_offset,
+ image_width: placement.asset.image_width,
+ image_height: placement.asset.image_height,
+ format: match placement.asset.format {
+ SurfaceGraphicsFormat::Rgb => KittyImageFormat::Rgb,
+ SurfaceGraphicsFormat::Rgba => KittyImageFormat::Rgba,
+ SurfaceGraphicsFormat::Png => KittyImageFormat::Png,
+ },
+ data_len: usize::try_from(placement.asset.data_len).unwrap_or(usize::MAX),
+ data_fingerprint: placement.asset.data_fingerprint,
+ data: data.unwrap_or_default().to_vec(),
+ render: KittyPlacementRenderInfo {
+ pixel_width: placement.cols.saturating_mul(cell_size.width_px),
+ pixel_height: placement.rows.saturating_mul(cell_size.height_px),
+ grid_cols: placement.cols,
+ grid_rows: placement.rows,
+ viewport_col: 0,
+ viewport_row: 0,
+ source_x: placement.source_x,
+ source_y: placement.source_y,
+ source_width: placement.source_width,
+ source_height: placement.source_height,
+ },
+ },
+ scrollback_offset: placement.scrollback_offset,
+ })
+}
+
+fn format_code(format: SurfaceGraphicsFormat) -> u32 {
+ match format {
+ SurfaceGraphicsFormat::Rgb => 24,
+ SurfaceGraphicsFormat::Rgba => 32,
+ SurfaceGraphicsFormat::Png => 100,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn asset(
+ target: SurfaceGraphicsTarget,
+ fingerprint: u64,
+ data: Vec<u8>,
+ ) -> SurfaceGraphicsAsset {
+ SurfaceGraphicsAsset {
+ key: SurfaceGraphicsAssetKey {
+ source: SurfaceGraphicsSource::Terminal {
+ target,
+ image_id: 7,
+ },
+ image_width: 1,
+ image_height: 1,
+ format: SurfaceGraphicsFormat::Rgba,
+ data_len: data.len() as u64,
+ data_fingerprint: fingerprint,
+ },
+ data,
+ }
+ }
+
+ fn scene(asset: SurfaceGraphicsAsset, x: u16, y: u16) -> SurfaceGraphicsScene {
+ SurfaceGraphicsScene {
+ placements: vec![SurfaceGraphicsPlacement {
+ asset: asset.key.clone(),
+ logical_placement_id: 3,
+ x,
+ y,
+ cols: 1,
+ rows: 1,
+ source_x: 0,
+ source_y: 0,
+ source_width: 1,
+ source_height: 1,
+ x_offset: 0,
+ y_offset: 0,
+ z: 0,
+ scrollback_offset: 0,
+ }],
+ assets: vec![asset],
+ retained_assets: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn client_encodes_final_main_origin_upload_once_and_replays_placement() {
+ let mut state = ClientState::default();
+ state.set_scope("endpoint-a:boot-1");
+ let image = asset(
+ SurfaceGraphicsTarget::Pane {
+ pane_id: "w1:p1".into(),
+ },
+ 11,
+ vec![1, 2, 3, 4],
+ );
+ state.set_scene(scene(image, 1, 2));
+
+ let first = state.encode(
+ Visibility::Main,
+ (10, 5),
+ None,
+ HostCellSize {
+ width_px: 8,
+ height_px: 16,
+ },
+ );
+ assert!(String::from_utf8_lossy(&first).contains("a=t,t=d"));
+ assert!(String::from_utf8_lossy(&first).contains("\u{1b}[8;12H"));
+
+ let second = state.encode(
+ Visibility::Main,
+ (10, 5),
+ None,
+ HostCellSize {
+ width_px: 8,
+ height_px: 16,
+ },
+ );
+ let second = String::from_utf8_lossy(&second);
+ assert!(!second.contains("a=t,t=d"));
+ assert!(second.contains("a=p"));
+ assert!(second.contains("\u{1b}[8;12H"));
+ }
+
+ #[test]
+ fn client_hides_and_restores_without_reuploading_pixels() {
+ let mut state = ClientState::default();
+ state.set_scope("endpoint-a:boot-1");
+ let image = asset(
+ SurfaceGraphicsTarget::Pane {
+ pane_id: "w1:p1".into(),
+ },
+ 12,
+ vec![4, 3, 2, 1],
+ );
+ state.set_scene(scene(image, 0, 0));
+ let cell = HostCellSize {
+ width_px: 8,
+ height_px: 16,
+ };
+ let _ = state.encode(Visibility::Main, (4, 2), None, cell);
+
+ let hidden = state.encode(Visibility::Hidden, (4, 2), None, cell);
+ assert!(String::from_utf8_lossy(&hidden).contains("a=d,d=i"));
+
+ let restored = state.encode(Visibility::Main, (4, 2), None, cell);
+ let restored = String::from_utf8_lossy(&restored);
+ assert!(restored.contains("a=p"));
+ assert!(!restored.contains("a=t,t=d"));
+ }
+
+ #[test]
+ fn popup_candidates_use_client_resolved_popup_inner_origin() {
+ let mut state = ClientState::default();
+ state.set_scope("endpoint-a:boot-1");
+ let image = asset(
+ SurfaceGraphicsTarget::Popup {
+ terminal_id: "terminal-popup".into(),
+ },
+ 13,
+ vec![9, 8, 7, 6],
+ );
+ state.set_scene(scene(image, 2, 1));
+
+ let bytes = state.encode(
+ Visibility::Popup,
+ (20, 4),
+ Some((30, 10)),
+ HostCellSize {
+ width_px: 8,
+ height_px: 16,
+ },
+ );
+ assert!(String::from_utf8_lossy(&bytes).contains("\u{1b}[12;33H"));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn trusted_direct_asset_is_placed_without_inline_reupload() {
+ let mut state = ClientState::default();
+ state.set_scope("endpoint-a:boot-1");
+ let _ = state.encode(
+ Visibility::Hidden,
+ (0, 0),
+ None,
+ HostCellSize {
+ width_px: 8,
+ height_px: 16,
+ },
+ );
+ let image = asset(
+ SurfaceGraphicsTarget::Pane {
+ pane_id: "w1:p1".into(),
+ },
+ 15,
+ vec![1, 2, 3, 4],
+ );
+ let image_id = host_image_id("endpoint-a:boot-1", &image.key);
+ assert!(state.trust_direct_asset(&image.key, image_id));
+ let mut direct_scene = scene(image, 0, 0);
+ direct_scene.assets.clear();
+ state.set_scene(direct_scene);
+
+ let bytes = state.encode(
+ Visibility::Main,
+ (0, 0),
+ None,
+ HostCellSize {
+ width_px: 8,
+ height_px: 16,
+ },
+ );
+ let bytes = String::from_utf8_lossy(&bytes);
+ assert!(bytes.contains("a=p"));
+ assert!(!bytes.contains("a=t,t=d"));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn direct_asset_trusted_after_scene_arrival_is_immediately_placeable() {
+ let mut state = ClientState::default();
+ state.set_scope("endpoint-a:boot-1");
+ let _ = state.take_pending_cleanup();
+ let image = asset(
+ SurfaceGraphicsTarget::Pane {
+ pane_id: "w1:p1".into(),
+ },
+ 18,
+ vec![1, 2, 3, 4],
+ );
+ let image_id = host_image_id("endpoint-a:boot-1", &image.key);
+ let mut direct_scene = scene(image.clone(), 0, 0);
+ direct_scene.assets.clear();
+ state.set_scene(direct_scene);
+ assert!(state.trust_direct_asset(&image.key, image_id));
+
+ let bytes = state.encode(
+ Visibility::Main,
+ (0, 0),
+ None,
+ HostCellSize {
+ width_px: 8,
+ height_px: 16,
+ },
+ );
+ let bytes = String::from_utf8_lossy(&bytes);
+ assert!(bytes.contains("a=p"), "{bytes}");
+ assert!(!bytes.contains("a=t,t=d"), "{bytes}");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn retained_direct_asset_survives_hidden_scene_and_replays_without_upload() {
+ let mut state = ClientState::default();
+ state.set_scope("endpoint-a:boot-1");
+ let _ = state.take_pending_cleanup();
+ let image = asset(
+ SurfaceGraphicsTarget::Pane {
+ pane_id: "w1:p1".into(),
+ },
+ 19,
+ vec![1, 2, 3, 4],
+ );
+ let image_id = host_image_id("endpoint-a:boot-1", &image.key);
+ let mut active = scene(image.clone(), 0, 0);
+ active.assets.clear();
+ state.set_scene(active.clone());
+ assert!(state.trust_direct_asset(&image.key, image_id));
+ let _ = state.encode(
+ Visibility::Main,
+ (0, 0),
+ None,
+ HostCellSize {
+ width_px: 8,
+ height_px: 16,
+ },
+ );
+
+ state.set_scene(SurfaceGraphicsScene {
+ retained_assets: vec![image.key.clone()],
+ ..SurfaceGraphicsScene::default()
+ });
+ let hidden = String::from_utf8(state.encode(
+ Visibility::Main,
+ (0, 0),
+ None,
+ HostCellSize {
+ width_px: 8,
+ height_px: 16,
+ },
+ ))
+ .unwrap();
+ assert!(!hidden.contains(&format!("a=d,d=I,i={image_id}")));
+
+ active.retained_assets.push(image.key.clone());
+ state.set_scene(active);
+ let restored = String::from_utf8(state.encode(
+ Visibility::Main,
+ (0, 0),
+ None,
+ HostCellSize {
+ width_px: 8,
+ height_px: 16,
+ },
+ ))
+ .unwrap();
+ assert!(restored.contains("a=p"), "{restored}");
+ assert!(!restored.contains("a=t,t=d"), "{restored}");
+
+ state.set_scene(SurfaceGraphicsScene::default());
+ let removed = String::from_utf8(state.encode(
+ Visibility::Main,
+ (0, 0),
+ None,
+ HostCellSize {
+ width_px: 8,
+ height_px: 16,
+ },
+ ))
+ .unwrap();
+ assert!(removed.contains(&format!("a=d,d=I,i={image_id}")));
+ }
+
+ #[test]
+ fn popup_visibility_keeps_uncovered_main_scene_placements() {
+ let mut state = ClientState::default();
+ state.set_scope("endpoint-a:boot-1");
+ let main = asset(
+ SurfaceGraphicsTarget::Pane {
+ pane_id: "w1:p1".into(),
+ },
+ 20,
+ vec![1, 2, 3, 4],
+ );
+ let popup = asset(
+ SurfaceGraphicsTarget::Popup {
+ terminal_id: "popup-1".into(),
+ },
+ 21,
+ vec![4, 3, 2, 1],
+ );
+ let mut graphics = scene(main, 0, 0);
+ let popup_scene = scene(popup, 0, 0);
+ graphics.assets.extend(popup_scene.assets);
+ graphics.placements.extend(popup_scene.placements);
+ state.set_scene(graphics);
+
+ let bytes = String::from_utf8(state.encode(
+ Visibility::Popup,
+ (2, 1),
+ Some((20, 10)),
+ HostCellSize {
+ width_px: 8,
+ height_px: 16,
+ },
+ ))
+ .unwrap();
+ assert!(bytes.contains("\u{1b}[2;3H"), "{bytes}");
+ assert!(bytes.contains("\u{1b}[11;21H"), "{bytes}");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn retired_pending_direct_asset_is_deleted_even_before_cache_adoption() {
+ let mut state = ClientState::default();
+ state.set_scope("endpoint-a:boot-1");
+ let _ = state.take_pending_cleanup();
+ state.retire_direct_image(4242);
+ let cleanup = String::from_utf8(state.take_pending_cleanup()).unwrap();
+ assert!(cleanup.contains("a=d,d=I,i=4242"), "{cleanup}");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn unclaimed_direct_asset_is_deleted_by_the_next_authoritative_scene() {
+ let mut state = ClientState::default();
+ state.set_scope("endpoint-a:boot-1");
+ let _ = state.take_pending_cleanup();
+ let image = asset(
+ SurfaceGraphicsTarget::Pane {
+ pane_id: "w1:p1".into(),
+ },
+ 16,
+ vec![1, 2, 3, 4],
+ );
+ let image_id = host_image_id("endpoint-a:boot-1", &image.key);
+ assert!(state.trust_direct_asset(&image.key, image_id));
+ state.set_scene(SurfaceGraphicsScene::default());
+
+ let bytes = state.encode(
+ Visibility::Hidden,
+ (0, 0),
+ None,
+ HostCellSize {
+ width_px: 8,
+ height_px: 16,
+ },
+ );
+ let bytes = String::from_utf8_lossy(&bytes);
+ assert!(bytes.contains(&format!("a=d,d=I,i={image_id}")), "{bytes}");
+ }
+
+ #[test]
+ fn boot_scope_cleanup_does_not_wait_for_a_coherent_surface() {
+ let mut state = ClientState::default();
+ state.set_scope("endpoint-a:boot-1");
+ let image = asset(
+ SurfaceGraphicsTarget::Pane {
+ pane_id: "w1:p1".into(),
+ },
+ 17,
+ vec![1, 2, 3, 4],
+ );
+ state.set_scene(scene(image, 0, 0));
+ let _ = state.encode(
+ Visibility::Main,
+ (0, 0),
+ None,
+ HostCellSize {
+ width_px: 8,
+ height_px: 16,
+ },
+ );
+
+ state.set_scope("endpoint-a:boot-2");
+ let cleanup = String::from_utf8(state.take_pending_cleanup()).unwrap();
+ assert!(cleanup.contains("a=d,d=I"), "{cleanup}");
+ assert!(state.take_pending_cleanup().is_empty());
+ }
+
+ #[test]
+ fn replacement_scene_without_repeated_asset_bytes_keeps_resident_data() {
+ let mut state = ClientState::default();
+ state.set_scope("endpoint-a:boot-1");
+ let image = asset(
+ SurfaceGraphicsTarget::Pane {
+ pane_id: "w1:p1".into(),
+ },
+ 14,
+ vec![1, 1, 1, 1],
+ );
+ let first_scene = scene(image, 0, 0);
+ let mut replacement = first_scene.clone();
+ replacement.assets.clear();
+ state.set_scene(first_scene);
+ state.set_scene(replacement);
+
+ let bytes = state.encode(
+ Visibility::Main,
+ (0, 0),
+ None,
+ HostCellSize {
+ width_px: 8,
+ height_px: 16,
+ },
+ );
+ assert!(String::from_utf8_lossy(&bytes).contains("a=t,t=d"));
+ }
+}