mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 08:01:06 +00:00
* fix: request a render on kitty graphics writes pty output containing a kitty graphics sequence only scheduled the deferred settle render. that render's wake-up notifies the render loop only when the dirty flag was not already set, so under a continuous stream of image updates the notify can be dropped and the pane keeps showing an old frame until something else forces a full render. request an immediate render like any other output. the dirty flag and the minimum render interval still coalesce chunked writes, and the settle render stays as a backstop. refs #947 * fix: fingerprint kitty images exactly across retransmissions the image fingerprint hashed only the first, middle, and last 4096 bytes of pixel data. a program that streams updates by retransmitting one image id keeps the length and dimensions constant, so a frame whose changed pixels all fall outside those three windows gets the same fingerprint as the frame before it. the encoder then treats the image as already uploaded, emits no graphics bytes, and the frame is dropped as identical to the previous render. the pane stays stuck on the last uploaded image until a resize resets the graphics surface. hash the full payload instead. to keep that off the render hot path, cache the fingerprint per image id and recompute it only when the image's transmit time changes. libghostty-vt already refreshes the transmit time on every transmission; vendor patch 0002 exposes it through a new GHOSTTY_KITTY_IMAGE_DATA_TRANSMIT_TIME_NS accessor. a static image costs one map lookup per render and a streaming image one hash per transmitted frame. refs #947 * fix: delete superseded host kitty images host image ids derive from image content, so a pane image whose pixels change maps to a new host id on every update. nothing deleted the host image it replaced, and a streaming pane left one full-size image behind in the host terminal per changed frame until the graphics surface was reset. track which host image backs each pane image and delete the replaced one once no source references it. refs #947 * fix: prune stale kitty graphics source entries a source entry whose pane closed or whose placement disappeared stayed in the cache forever. through the shared-reference check it could keep an old host image alive that nothing visible used anymore, and the entries themselves accumulated without bound. prune sources against the visible set at the start of every encode pass. refs #947 * docs: link issue and upstream discussion for vendor patch 0002 the transmit time accessor now has a removal path: the ghostty discussion proposing it upstream is linked in the patch ledger. refs #947 --------- Co-authored-by: Can Celik <ogulcancelik@gmail.com>
1345 lines
41 KiB
Rust
1345 lines
41 KiB
Rust
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::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};
|
|
use crate::layout::PaneId;
|
|
use crate::terminal::TerminalRuntimeRegistry;
|
|
|
|
const KITTY_CHUNK_BYTES: usize = 3072;
|
|
const HOST_IMAGE_ID_BASE: u32 = 10_000;
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
pub(crate) struct HostCellSize {
|
|
pub width_px: u32,
|
|
pub height_px: u32,
|
|
}
|
|
|
|
impl HostCellSize {
|
|
pub(crate) fn from_terminal(area: Rect) -> Self {
|
|
let Ok(size) = crossterm::terminal::window_size() else {
|
|
return Self::fallback_for_area(area);
|
|
};
|
|
if size.columns == 0 || size.rows == 0 {
|
|
return Self::fallback_for_area(area);
|
|
}
|
|
if size.width == 0 || size.height == 0 {
|
|
return Self::fallback_for_area(area);
|
|
}
|
|
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
|
|
}
|
|
|
|
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)]
|
|
struct HostPlacement {
|
|
pane_id: PaneId,
|
|
area: Rect,
|
|
cell_size: HostCellSize,
|
|
placement: KittyImagePlacement,
|
|
scrollback_offset: u32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
|
|
struct ImageSignature {
|
|
image_width: u32,
|
|
image_height: u32,
|
|
format_code: u32,
|
|
data_len: usize,
|
|
data_fingerprint: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
|
|
struct PlacementSignature {
|
|
x: u16,
|
|
y: u16,
|
|
cols: u32,
|
|
rows: u32,
|
|
source_x: u32,
|
|
source_y: u32,
|
|
source_width: u32,
|
|
source_height: u32,
|
|
x_offset: u32,
|
|
y_offset: u32,
|
|
z: i32,
|
|
scrollback_offset: u32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
struct ClippedPlacement {
|
|
x: u16,
|
|
y: u16,
|
|
cols: u32,
|
|
rows: u32,
|
|
source_x: u32,
|
|
source_y: u32,
|
|
source_width: u32,
|
|
source_height: u32,
|
|
x_offset: u32,
|
|
y_offset: u32,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
pub(crate) struct HostGraphicsCache {
|
|
images: HashMap<u32, ImageSignature>,
|
|
placements: HashMap<(u32, u32), PlacementSignature>,
|
|
/// Host image currently backing each (pane, source image id) pair.
|
|
sources: HashMap<(PaneId, u32), u32>,
|
|
view: Option<HostViewKey>,
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
pub(crate) fn is_enabled() -> bool {
|
|
KITTY_GRAPHICS_ENABLED.load(Ordering::Acquire)
|
|
}
|
|
|
|
pub(crate) fn paint_local_pane_graphics(
|
|
app: &AppState,
|
|
terminal_runtimes: &TerminalRuntimeRegistry,
|
|
cell_size: HostCellSize,
|
|
) -> 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 = encode_local_pane_graphics(app, terminal_runtimes, cell_size, &mut cache);
|
|
}
|
|
if bytes.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut framed = Vec::with_capacity(bytes.len() + 8);
|
|
framed.extend_from_slice(b"\x1b7");
|
|
framed.extend_from_slice(&bytes);
|
|
framed.extend_from_slice(b"\x1b8");
|
|
|
|
let mut stdout = io::stdout().lock();
|
|
stdout.write_all(&framed)?;
|
|
stdout.flush()
|
|
}
|
|
|
|
pub(crate) fn encode_local_pane_graphics(
|
|
app: &AppState,
|
|
terminal_runtimes: &TerminalRuntimeRegistry,
|
|
cell_size: HostCellSize,
|
|
cache: &mut HostGraphicsCache,
|
|
) -> Vec<u8> {
|
|
let mode_ok = app.mode == Mode::Terminal;
|
|
let cell_ok = cell_size.is_known();
|
|
tracing::debug!(
|
|
mode_ok,
|
|
cell_ok,
|
|
cell_width_px = cell_size.width_px,
|
|
cell_height_px = cell_size.height_px,
|
|
active = ?app.active,
|
|
pane_infos_len = app.view.pane_infos.len(),
|
|
"paint_local_pane_graphics entry"
|
|
);
|
|
if !mode_ok || !cell_ok {
|
|
tracing::debug!(
|
|
reason = if !mode_ok {
|
|
"not terminal mode"
|
|
} else {
|
|
"cell size unknown"
|
|
},
|
|
"paint_local_pane_graphics early return"
|
|
);
|
|
return cache.clear_bytes();
|
|
}
|
|
|
|
let view_key = active_view_key(app);
|
|
let uploaded_images = cache.images.clone();
|
|
let placements =
|
|
collect_visible_placements(app, terminal_runtimes, cell_size, &uploaded_images);
|
|
tracing::debug!(
|
|
placements_collected = placements.len(),
|
|
"collect_visible_placements result"
|
|
);
|
|
|
|
let mut bytes = Vec::new();
|
|
let view_changed = cache.update_view(view_key);
|
|
encode_graphics_update(
|
|
&mut bytes,
|
|
&placements,
|
|
view_changed,
|
|
&mut cache.images,
|
|
&mut cache.placements,
|
|
&mut cache.sources,
|
|
);
|
|
tracing::debug!(
|
|
placements = placements.len(),
|
|
bytes = bytes.len(),
|
|
cell_width_px = cell_size.width_px,
|
|
cell_height_px = cell_size.height_px,
|
|
"painting kitty graphics placements"
|
|
);
|
|
bytes
|
|
}
|
|
|
|
pub(crate) fn has_visible_pane_graphics(
|
|
app: &AppState,
|
|
terminal_runtimes: &TerminalRuntimeRegistry,
|
|
cell_size: HostCellSize,
|
|
) -> bool {
|
|
if app.mode != Mode::Terminal || !cell_size.is_known() {
|
|
return false;
|
|
}
|
|
|
|
let Some(ws_idx) = app.active else {
|
|
return false;
|
|
};
|
|
if app
|
|
.workspaces
|
|
.get(ws_idx)
|
|
.and_then(crate::workspace::Workspace::active_tab)
|
|
.is_none()
|
|
{
|
|
return false;
|
|
}
|
|
|
|
for info in &app.view.pane_infos {
|
|
let Some(runtime) = app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)
|
|
else {
|
|
continue;
|
|
};
|
|
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,
|
|
area: info.inner_rect,
|
|
cell_size,
|
|
placement,
|
|
scrollback_offset,
|
|
};
|
|
if clipped_placement(&host_placement).is_some() {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn encode_graphics_update(
|
|
bytes: &mut Vec<u8>,
|
|
placements: &[HostPlacement],
|
|
view_changed: bool,
|
|
host_images: &mut HashMap<u32, ImageSignature>,
|
|
host_placements: &mut HashMap<(u32, u32), PlacementSignature>,
|
|
sources: &mut HashMap<(PaneId, u32), u32>,
|
|
) {
|
|
// Prune sources that are no longer visible: a stale entry would keep its
|
|
// old host image referenced and block the superseded-image delete.
|
|
let current_sources: HashSet<(PaneId, u32)> = placements
|
|
.iter()
|
|
.map(|placement| (placement.pane_id, placement.placement.image_id))
|
|
.collect();
|
|
sources.retain(|source, _| current_sources.contains(source));
|
|
|
|
let mut current_placements = HashSet::new();
|
|
for placement in placements {
|
|
let clipped = clipped_placement(placement);
|
|
tracing::debug!(
|
|
pane_id = ?placement.pane_id,
|
|
has_clipped = clipped.is_some(),
|
|
grid_cols = placement.placement.render.grid_cols,
|
|
grid_rows = placement.placement.render.grid_rows,
|
|
viewport_col = placement.placement.render.viewport_col,
|
|
viewport_row = placement.placement.render.viewport_row,
|
|
area_w = placement.area.width,
|
|
area_h = placement.area.height,
|
|
"clipped_placement result"
|
|
);
|
|
let Some((clipped, format_code)) = clipped else {
|
|
continue;
|
|
};
|
|
let host_id = host_image_id(placement.pane_id, &placement.placement);
|
|
let host_placement_id = host_placement_id(placement.pane_id, &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, host_placement_id);
|
|
current_placements.insert(placement_key);
|
|
|
|
match host_images.get(&host_id).copied() {
|
|
Some(existing) if existing == image_signature => {}
|
|
Some(_) => {
|
|
encode_delete_image(bytes, host_id);
|
|
host_placements.retain(|(image_id, placement_id), _| {
|
|
if *image_id == host_id {
|
|
current_placements.remove(&(*image_id, *placement_id));
|
|
false
|
|
} else {
|
|
true
|
|
}
|
|
});
|
|
if !encode_upload_image(bytes, placement, format_code, host_id) {
|
|
continue;
|
|
}
|
|
host_images.insert(host_id, image_signature);
|
|
}
|
|
None => {
|
|
if !encode_upload_image(bytes, placement, format_code, host_id) {
|
|
continue;
|
|
}
|
|
host_images.insert(host_id, image_signature);
|
|
}
|
|
}
|
|
|
|
release_superseded_source_image(
|
|
bytes,
|
|
sources,
|
|
host_images,
|
|
host_placements,
|
|
&mut current_placements,
|
|
(placement.pane_id, placement.placement.image_id),
|
|
host_id,
|
|
);
|
|
|
|
// A different view can repaint the same cells with text or overlays and
|
|
// leave the host-side Kitty placement state out of sync with this cache.
|
|
// Re-emit the placement even when its geometry signature is unchanged.
|
|
match host_placements.get_mut(&placement_key) {
|
|
Some(existing) if !view_changed && *existing == placement_signature => {}
|
|
Some(existing) => {
|
|
encode_display_placement(
|
|
bytes,
|
|
clipped,
|
|
host_id,
|
|
host_placement_id,
|
|
placement.placement.z,
|
|
);
|
|
*existing = placement_signature;
|
|
}
|
|
None => {
|
|
encode_display_placement(
|
|
bytes,
|
|
clipped,
|
|
host_id,
|
|
host_placement_id,
|
|
placement.placement.z,
|
|
);
|
|
host_placements.insert(placement_key, placement_signature);
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut stale_placements = Vec::new();
|
|
for key in host_placements.keys() {
|
|
if current_placements.contains(key) {
|
|
continue;
|
|
}
|
|
stale_placements.push(*key);
|
|
}
|
|
for (host_id, host_placement_id) in stale_placements {
|
|
encode_delete_placement(bytes, host_id, host_placement_id);
|
|
host_placements.remove(&(host_id, host_placement_id));
|
|
}
|
|
}
|
|
|
|
/// Records that `source` is now backed by `host_id` and deletes the host
|
|
/// image it previously pointed at once no other source references it.
|
|
fn release_superseded_source_image(
|
|
bytes: &mut Vec<u8>,
|
|
sources: &mut HashMap<(PaneId, u32), u32>,
|
|
host_images: &mut HashMap<u32, ImageSignature>,
|
|
host_placements: &mut HashMap<(u32, u32), PlacementSignature>,
|
|
current_placements: &mut HashSet<(u32, u32)>,
|
|
source: (PaneId, u32),
|
|
host_id: u32,
|
|
) {
|
|
let Some(previous) = sources.insert(source, host_id) else {
|
|
return;
|
|
};
|
|
if previous == host_id || sources.values().any(|id| *id == previous) {
|
|
return;
|
|
}
|
|
encode_delete_image(bytes, previous);
|
|
host_images.remove(&previous);
|
|
// The `d=I` delete also removes the image's placements host-side.
|
|
host_placements.retain(|(image_id, placement_id), _| {
|
|
if *image_id == previous {
|
|
current_placements.remove(&(*image_id, *placement_id));
|
|
false
|
|
} else {
|
|
true
|
|
}
|
|
});
|
|
}
|
|
|
|
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 {
|
|
pub(crate) fn is_empty(&self) -> bool {
|
|
self.images.is_empty() && self.placements.is_empty()
|
|
}
|
|
|
|
#[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<_>>() {
|
|
encode_delete_image(&mut bytes, id);
|
|
}
|
|
self.images.clear();
|
|
self.placements.clear();
|
|
self.sources.clear();
|
|
self.view = None;
|
|
bytes
|
|
}
|
|
|
|
fn update_view(&mut self, view_key: Option<HostViewKey>) -> bool {
|
|
if self.view == view_key {
|
|
return false;
|
|
}
|
|
self.view = view_key;
|
|
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(
|
|
app: &AppState,
|
|
terminal_runtimes: &TerminalRuntimeRegistry,
|
|
cell_size: HostCellSize,
|
|
uploaded_images: &HashMap<u32, ImageSignature>,
|
|
) -> Vec<HostPlacement> {
|
|
let ws_idx = match app.active {
|
|
Some(idx) => idx,
|
|
None => {
|
|
tracing::debug!("collect_visible_placements: no active workspace");
|
|
return Vec::new();
|
|
}
|
|
};
|
|
if app
|
|
.workspaces
|
|
.get(ws_idx)
|
|
.and_then(crate::workspace::Workspace::active_tab)
|
|
.is_none()
|
|
{
|
|
tracing::debug!(ws_idx, "collect_visible_placements: no active tab");
|
|
return Vec::new();
|
|
}
|
|
|
|
tracing::debug!(
|
|
ws_idx,
|
|
terminal_runtimes_len = terminal_runtimes.len(),
|
|
pane_infos_len = app.view.pane_infos.len(),
|
|
"collect_visible_placements: starting iteration"
|
|
);
|
|
let mut placements = Vec::new();
|
|
for info in &app.view.pane_infos {
|
|
let runtime = match app.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) {
|
|
Some(rt) => rt,
|
|
None => {
|
|
tracing::debug!(pane_id = ?info.id, "collect_visible_placements: runtime not found");
|
|
continue;
|
|
}
|
|
};
|
|
for placement in runtime.kitty_image_placements_with_data_filter(|descriptor| {
|
|
let format_code = kitty_format_code(descriptor.format);
|
|
let signature = image_signature_from_descriptor(descriptor, format_code);
|
|
let host_id = host_image_id_for_signature(info.id, signature);
|
|
uploaded_images.get(&host_id).copied() != Some(signature)
|
|
}) {
|
|
let scrollback_offset = runtime
|
|
.scroll_metrics()
|
|
.map(|m| m.offset_from_bottom as u32)
|
|
.unwrap_or(0);
|
|
placements.push(HostPlacement {
|
|
pane_id: info.id,
|
|
area: info.inner_rect,
|
|
cell_size,
|
|
placement,
|
|
scrollback_offset,
|
|
});
|
|
}
|
|
}
|
|
tracing::debug!(
|
|
placements_len = placements.len(),
|
|
"collect_visible_placements: done"
|
|
);
|
|
placements
|
|
}
|
|
|
|
fn host_image_id(pane_id: PaneId, placement: &KittyImagePlacement) -> u32 {
|
|
let format_code = kitty_format_code(placement.format);
|
|
host_image_id_for_signature(
|
|
pane_id,
|
|
ImageSignature {
|
|
image_width: placement.image_width,
|
|
image_height: placement.image_height,
|
|
format_code,
|
|
data_len: placement.data_len,
|
|
data_fingerprint: placement.data_fingerprint,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn host_image_id_for_signature(pane_id: PaneId, signature: ImageSignature) -> u32 {
|
|
let mut hasher = DefaultHasher::new();
|
|
pane_id.raw().hash(&mut hasher);
|
|
signature.hash(&mut hasher);
|
|
HOST_IMAGE_ID_BASE + ((hasher.finish() as u32) % 900_000)
|
|
}
|
|
|
|
fn host_placement_id(pane_id: PaneId, placement: &KittyImagePlacement) -> u32 {
|
|
let mut hasher = DefaultHasher::new();
|
|
pane_id.raw().hash(&mut hasher);
|
|
placement.image_id.hash(&mut hasher);
|
|
placement.placement_id.hash(&mut hasher);
|
|
1 + ((hasher.finish() as u32) % 900_000)
|
|
}
|
|
|
|
fn encode_delete_image(out: &mut Vec<u8>, id: u32) {
|
|
let _ = write!(out, "\x1b_Ga=d,d=I,i={id},q=2;\x1b\\");
|
|
}
|
|
|
|
fn encode_delete_placement(out: &mut Vec<u8>, host_id: u32, host_placement_id: u32) {
|
|
let _ = write!(
|
|
out,
|
|
"\x1b_Ga=d,d=i,i={host_id},p={host_placement_id},q=2;\x1b\\"
|
|
);
|
|
}
|
|
|
|
fn encode_upload_image(
|
|
out: &mut Vec<u8>,
|
|
placement: &HostPlacement,
|
|
format_code: u32,
|
|
host_id: u32,
|
|
) -> bool {
|
|
if placement.placement.data.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
let control = format!(
|
|
"a=t,t=d,f={format_code},s={},v={},i={host_id},q=2",
|
|
placement.placement.image_width, placement.placement.image_height,
|
|
);
|
|
encode_kitty_data(out, &control, &placement.placement.data);
|
|
true
|
|
}
|
|
|
|
fn encode_display_placement(
|
|
out: &mut Vec<u8>,
|
|
clipped: ClippedPlacement,
|
|
host_id: u32,
|
|
host_placement_id: u32,
|
|
z: i32,
|
|
) {
|
|
let _ = write!(out, "\x1b[{};{}H", clipped.y + 1, clipped.x + 1);
|
|
let mut control = format!(
|
|
"a=p,i={host_id},p={host_placement_id},c={},r={},z={z},C=1,q=2",
|
|
clipped.cols, clipped.rows,
|
|
);
|
|
if clipped.source_x > 0 {
|
|
let _ = write!(control, ",x={}", clipped.source_x);
|
|
}
|
|
if clipped.source_y > 0 {
|
|
let _ = write!(control, ",y={}", clipped.source_y);
|
|
}
|
|
if clipped.source_width > 0 {
|
|
let _ = write!(control, ",w={}", clipped.source_width);
|
|
}
|
|
if clipped.source_height > 0 {
|
|
let _ = write!(control, ",h={}", clipped.source_height);
|
|
}
|
|
if clipped.x_offset > 0 {
|
|
let _ = write!(control, ",X={}", clipped.x_offset);
|
|
}
|
|
if clipped.y_offset > 0 {
|
|
let _ = write!(control, ",Y={}", clipped.y_offset);
|
|
}
|
|
|
|
let _ = write!(out, "\x1b_G{control};\x1b\\");
|
|
}
|
|
|
|
fn clipped_placement(placement: &HostPlacement) -> Option<(ClippedPlacement, u32)> {
|
|
if placement.area.width == 0 || placement.area.height == 0 {
|
|
tracing::debug!(
|
|
area_w = placement.area.width,
|
|
area_h = placement.area.height,
|
|
"clipped_placement: area zero"
|
|
);
|
|
return None;
|
|
}
|
|
let render = placement.placement.render;
|
|
if render.grid_cols == 0 || render.grid_rows == 0 {
|
|
tracing::debug!(
|
|
grid_cols = render.grid_cols,
|
|
grid_rows = render.grid_rows,
|
|
"clipped_placement: grid zero"
|
|
);
|
|
return None;
|
|
}
|
|
let format_code = kitty_format_code(placement.placement.format);
|
|
|
|
let left_clip_cells = if render.viewport_col < 0 {
|
|
render.viewport_col.saturating_neg() as u32
|
|
} else {
|
|
0
|
|
};
|
|
let top_clip_cells = if render.viewport_row < 0 {
|
|
render.viewport_row.saturating_neg() as u32
|
|
} else {
|
|
0
|
|
};
|
|
let viewport_col = render.viewport_col.max(0) as u32;
|
|
let viewport_row = render.viewport_row.max(0) as u32;
|
|
tracing::debug!(
|
|
viewport_col = viewport_col,
|
|
viewport_row = viewport_row,
|
|
area_w = placement.area.width,
|
|
area_h = placement.area.height,
|
|
scrollback_offset = placement.scrollback_offset,
|
|
raw_viewport_row = render.viewport_row,
|
|
cond1 = viewport_col >= placement.area.width as u32,
|
|
cond2 = viewport_row >= placement.area.height as u32,
|
|
"clipped_placement: viewport check"
|
|
);
|
|
if viewport_col >= placement.area.width as u32 || viewport_row >= placement.area.height as u32 {
|
|
return None;
|
|
}
|
|
|
|
let visible_cols = render
|
|
.grid_cols
|
|
.saturating_sub(left_clip_cells)
|
|
.min(placement.area.width as u32 - viewport_col);
|
|
let visible_rows = render
|
|
.grid_rows
|
|
.saturating_sub(top_clip_cells)
|
|
.min(placement.area.height as u32 - viewport_row);
|
|
tracing::debug!(
|
|
visible_cols = visible_cols,
|
|
visible_rows = visible_rows,
|
|
left_clip_cells = left_clip_cells,
|
|
top_clip_cells = top_clip_cells,
|
|
"clipped_placement: visible dims check"
|
|
);
|
|
if visible_cols == 0 || visible_rows == 0 {
|
|
return None;
|
|
}
|
|
|
|
let source_width = if render.source_width == 0 {
|
|
placement.placement.image_width
|
|
} else {
|
|
render.source_width
|
|
};
|
|
let source_height = if render.source_height == 0 {
|
|
placement.placement.image_height
|
|
} else {
|
|
render.source_height
|
|
};
|
|
let pixel_width = render
|
|
.pixel_width
|
|
.max(
|
|
render
|
|
.grid_cols
|
|
.saturating_mul(placement.cell_size.width_px),
|
|
)
|
|
.max(1);
|
|
let pixel_height = render
|
|
.pixel_height
|
|
.max(
|
|
render
|
|
.grid_rows
|
|
.saturating_mul(placement.cell_size.height_px),
|
|
)
|
|
.max(1);
|
|
|
|
let crop_left_px = left_clip_cells.saturating_mul(placement.cell_size.width_px);
|
|
let crop_top_px = top_clip_cells.saturating_mul(placement.cell_size.height_px);
|
|
let visible_width_px = visible_cols.saturating_mul(placement.cell_size.width_px);
|
|
let visible_height_px = visible_rows.saturating_mul(placement.cell_size.height_px);
|
|
|
|
let source_x = render.source_x + scale_pixels(crop_left_px, source_width, pixel_width);
|
|
let source_y = render.source_y + scale_pixels(crop_top_px, source_height, pixel_height);
|
|
let source_width = scale_pixels(visible_width_px, source_width, pixel_width)
|
|
.max(1)
|
|
.min(placement.placement.image_width.saturating_sub(source_x));
|
|
let source_height = scale_pixels(visible_height_px, source_height, pixel_height)
|
|
.max(1)
|
|
.min(placement.placement.image_height.saturating_sub(source_y));
|
|
|
|
if source_width == 0 || source_height == 0 {
|
|
tracing::debug!(
|
|
source_width = source_width,
|
|
source_height = source_height,
|
|
image_width = placement.placement.image_width,
|
|
image_height = placement.placement.image_height,
|
|
"clipped_placement: source dims zero"
|
|
);
|
|
return None;
|
|
}
|
|
|
|
tracing::debug!("clipped_placement: success");
|
|
Some((
|
|
ClippedPlacement {
|
|
x: placement.area.x + viewport_col as u16,
|
|
y: placement.area.y + viewport_row as u16,
|
|
cols: visible_cols,
|
|
rows: visible_rows,
|
|
source_x,
|
|
source_y,
|
|
source_width,
|
|
source_height,
|
|
x_offset: if left_clip_cells == 0 {
|
|
placement.placement.x_offset
|
|
} else {
|
|
0
|
|
},
|
|
y_offset: if top_clip_cells == 0 {
|
|
placement.placement.y_offset
|
|
} else {
|
|
0
|
|
},
|
|
},
|
|
format_code,
|
|
))
|
|
}
|
|
|
|
fn scale_pixels(value: u32, source: u32, dest: u32) -> u32 {
|
|
((value as u64).saturating_mul(source as u64) / dest.max(1) as u64).min(u32::MAX as u64) as u32
|
|
}
|
|
|
|
fn image_signature(placement: &HostPlacement, format_code: u32) -> ImageSignature {
|
|
ImageSignature {
|
|
image_width: placement.placement.image_width,
|
|
image_height: placement.placement.image_height,
|
|
format_code,
|
|
data_len: placement.placement.data_len,
|
|
data_fingerprint: placement.placement.data_fingerprint,
|
|
}
|
|
}
|
|
|
|
fn image_signature_from_descriptor(
|
|
descriptor: KittyImageDescriptor,
|
|
format_code: u32,
|
|
) -> ImageSignature {
|
|
ImageSignature {
|
|
image_width: descriptor.image_width,
|
|
image_height: descriptor.image_height,
|
|
format_code,
|
|
data_len: descriptor.data_len,
|
|
data_fingerprint: descriptor.data_fingerprint,
|
|
}
|
|
}
|
|
|
|
fn placement_signature(
|
|
clipped: ClippedPlacement,
|
|
z: i32,
|
|
scrollback_offset: u32,
|
|
) -> PlacementSignature {
|
|
PlacementSignature {
|
|
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,
|
|
scrollback_offset,
|
|
}
|
|
}
|
|
|
|
fn kitty_format_code(format: KittyImageFormat) -> u32 {
|
|
match format {
|
|
KittyImageFormat::Rgb => 24,
|
|
KittyImageFormat::Rgba => 32,
|
|
KittyImageFormat::Png => 100,
|
|
}
|
|
}
|
|
|
|
fn encode_kitty_data(out: &mut Vec<u8>, control: &str, data: &[u8]) {
|
|
let mut chunks = data.chunks(KITTY_CHUNK_BYTES).peekable();
|
|
let Some(first) = chunks.next() else {
|
|
return;
|
|
};
|
|
let more = if chunks.peek().is_some() { 1 } else { 0 };
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode(first);
|
|
let _ = write!(out, "\x1b_G{control},m={more};{encoded}\x1b\\");
|
|
|
|
while let Some(chunk) = chunks.next() {
|
|
let more = if chunks.peek().is_some() { 1 } else { 0 };
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode(chunk);
|
|
let _ = write!(out, "\x1b_Gm={more};{encoded}\x1b\\");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::ghostty::KittyPlacementRenderInfo;
|
|
|
|
fn test_placement(viewport_col: i32, viewport_row: i32) -> HostPlacement {
|
|
HostPlacement {
|
|
pane_id: PaneId::from_raw(1),
|
|
area: Rect::new(0, 0, 20, 10),
|
|
cell_size: HostCellSize {
|
|
width_px: 10,
|
|
height_px: 10,
|
|
},
|
|
scrollback_offset: 0,
|
|
placement: KittyImagePlacement {
|
|
image_id: 7,
|
|
placement_id: 3,
|
|
z: 0,
|
|
x_offset: 0,
|
|
y_offset: 0,
|
|
image_width: 30,
|
|
image_height: 30,
|
|
format: KittyImageFormat::Rgba,
|
|
data_len: 30 * 30 * 4,
|
|
data_fingerprint: 42,
|
|
data: vec![255; 30 * 30 * 4],
|
|
render: KittyPlacementRenderInfo {
|
|
pixel_width: 0,
|
|
pixel_height: 0,
|
|
grid_cols: 3,
|
|
grid_rows: 3,
|
|
viewport_col,
|
|
viewport_row,
|
|
source_x: 0,
|
|
source_y: 0,
|
|
source_width: 0,
|
|
source_height: 0,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn clipped_placement_handles_positive_viewport_without_wrapping() {
|
|
let placement = test_placement(2, 2);
|
|
let (clipped, _) = clipped_placement(&placement).expect("visible placement");
|
|
|
|
assert_eq!(clipped.x, 2);
|
|
assert_eq!(clipped.y, 2);
|
|
assert_eq!(clipped.cols, 3);
|
|
assert_eq!(clipped.rows, 3);
|
|
assert_eq!(clipped.source_x, 0);
|
|
assert_eq!(clipped.source_y, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn clipped_placement_crops_negative_viewport_offsets() {
|
|
let placement = test_placement(-1, -1);
|
|
let (clipped, _) = clipped_placement(&placement).expect("partially visible placement");
|
|
|
|
assert_eq!(clipped.x, 0);
|
|
assert_eq!(clipped.y, 0);
|
|
assert_eq!(clipped.cols, 2);
|
|
assert_eq!(clipped.rows, 2);
|
|
assert_eq!(clipped.source_x, 10);
|
|
assert_eq!(clipped.source_y, 10);
|
|
}
|
|
|
|
#[test]
|
|
fn graphics_update_uploads_once_then_repositions_only() {
|
|
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,
|
|
);
|
|
let first = String::from_utf8_lossy(&bytes);
|
|
assert!(first.contains("a=t"));
|
|
assert!(first.contains("a=p"));
|
|
|
|
bytes.clear();
|
|
let same = test_placement(0, 0);
|
|
encode_graphics_update(
|
|
&mut bytes,
|
|
&[same],
|
|
false,
|
|
&mut images,
|
|
&mut placements,
|
|
&mut sources,
|
|
);
|
|
assert!(bytes.is_empty());
|
|
|
|
let mut z_changed = test_placement(0, 0);
|
|
z_changed.placement.z = 1;
|
|
encode_graphics_update(
|
|
&mut bytes,
|
|
&[z_changed],
|
|
false,
|
|
&mut images,
|
|
&mut placements,
|
|
&mut sources,
|
|
);
|
|
let z_changed_bytes = String::from_utf8_lossy(&bytes);
|
|
assert!(!z_changed_bytes.contains("a=t"));
|
|
assert!(z_changed_bytes.contains("a=p"));
|
|
|
|
bytes.clear();
|
|
let moved = test_placement(0, 1);
|
|
encode_graphics_update(
|
|
&mut bytes,
|
|
&[moved],
|
|
false,
|
|
&mut images,
|
|
&mut placements,
|
|
&mut sources,
|
|
);
|
|
let moved_bytes = String::from_utf8_lossy(&bytes);
|
|
assert!(!moved_bytes.contains("a=t"));
|
|
assert!(moved_bytes.contains("a=p"));
|
|
}
|
|
|
|
#[test]
|
|
fn view_change_redisplays_unchanged_visible_placement() {
|
|
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();
|
|
let same = test_placement(0, 0);
|
|
encode_graphics_update(
|
|
&mut bytes,
|
|
&[same],
|
|
true,
|
|
&mut images,
|
|
&mut placements,
|
|
&mut sources,
|
|
);
|
|
let redisplay = String::from_utf8_lossy(&bytes);
|
|
assert!(!redisplay.contains("a=t"));
|
|
assert!(redisplay.contains("a=p"));
|
|
assert_eq!(placements.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn surface_reset_deletes_then_reuploads_and_redisplays_placement() {
|
|
let mut cache = HostGraphicsCache::default();
|
|
let mut bytes = Vec::new();
|
|
let placement = test_placement(0, 0);
|
|
|
|
encode_graphics_update(
|
|
&mut bytes,
|
|
&[placement],
|
|
false,
|
|
&mut cache.images,
|
|
&mut cache.placements,
|
|
&mut cache.sources,
|
|
);
|
|
assert_eq!(cache.images.len(), 1);
|
|
assert_eq!(cache.placements.len(), 1);
|
|
|
|
bytes = cache.clear_bytes();
|
|
let same = test_placement(0, 0);
|
|
encode_graphics_update(
|
|
&mut bytes,
|
|
&[same],
|
|
false,
|
|
&mut cache.images,
|
|
&mut cache.placements,
|
|
&mut cache.sources,
|
|
);
|
|
|
|
let redisplay = String::from_utf8_lossy(&bytes);
|
|
assert!(redisplay.contains("a=d,d=I"));
|
|
assert!(redisplay.contains("a=t"));
|
|
assert!(redisplay.contains("a=p"));
|
|
assert_eq!(cache.images.len(), 1);
|
|
assert_eq!(cache.placements.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn scrollback_offset_change_redisplays_placement() {
|
|
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,
|
|
);
|
|
|
|
bytes.clear();
|
|
let mut scrolled = test_placement(0, 0);
|
|
scrolled.scrollback_offset = 3;
|
|
encode_graphics_update(
|
|
&mut bytes,
|
|
&[scrolled],
|
|
false,
|
|
&mut images,
|
|
&mut placements,
|
|
&mut sources,
|
|
);
|
|
let redisplay = String::from_utf8_lossy(&bytes);
|
|
assert!(!redisplay.contains("a=t"));
|
|
assert!(redisplay.contains("a=p"));
|
|
}
|
|
|
|
#[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 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 view_change_deletes_stale_placement_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,
|
|
);
|
|
bytes.clear();
|
|
encode_graphics_update(
|
|
&mut bytes,
|
|
&[],
|
|
true,
|
|
&mut images,
|
|
&mut placements,
|
|
&mut sources,
|
|
);
|
|
|
|
let delete = String::from_utf8_lossy(&bytes);
|
|
assert!(delete.contains("a=d,d=i"));
|
|
assert!(placements.is_empty());
|
|
}
|
|
}
|