1090 lines
33 KiB
Rust
1090 lines
33 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>,
|
|
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,
|
|
);
|
|
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>,
|
|
) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
}
|
|
|
|
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.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 bytes = Vec::new();
|
|
let placement = test_placement(0, 0);
|
|
|
|
encode_graphics_update(
|
|
&mut bytes,
|
|
&[placement],
|
|
false,
|
|
&mut images,
|
|
&mut placements,
|
|
);
|
|
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);
|
|
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,
|
|
);
|
|
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);
|
|
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 bytes = Vec::new();
|
|
let placement = test_placement(0, 0);
|
|
|
|
encode_graphics_update(
|
|
&mut bytes,
|
|
&[placement],
|
|
false,
|
|
&mut images,
|
|
&mut placements,
|
|
);
|
|
assert_eq!(placements.len(), 1);
|
|
|
|
bytes.clear();
|
|
let same = test_placement(0, 0);
|
|
encode_graphics_update(&mut bytes, &[same], true, &mut images, &mut placements);
|
|
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,
|
|
);
|
|
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,
|
|
);
|
|
|
|
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 bytes = Vec::new();
|
|
let placement = test_placement(0, 0);
|
|
|
|
encode_graphics_update(
|
|
&mut bytes,
|
|
&[placement],
|
|
false,
|
|
&mut images,
|
|
&mut placements,
|
|
);
|
|
|
|
bytes.clear();
|
|
let mut scrolled = test_placement(0, 0);
|
|
scrolled.scrollback_offset = 3;
|
|
encode_graphics_update(&mut bytes, &[scrolled], false, &mut images, &mut placements);
|
|
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 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,
|
|
);
|
|
|
|
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 bytes = Vec::new();
|
|
let first = test_placement(0, 0);
|
|
|
|
encode_graphics_update(&mut bytes, &[first], false, &mut images, &mut placements);
|
|
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,
|
|
);
|
|
|
|
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 stale_placement_deletes_placement_not_image_immediately() {
|
|
let mut images = HashMap::new();
|
|
let mut placements = HashMap::new();
|
|
let mut bytes = Vec::new();
|
|
let placement = test_placement(0, 0);
|
|
|
|
encode_graphics_update(
|
|
&mut bytes,
|
|
&[placement],
|
|
false,
|
|
&mut images,
|
|
&mut placements,
|
|
);
|
|
assert_eq!(placements.len(), 1);
|
|
|
|
bytes.clear();
|
|
encode_graphics_update(&mut bytes, &[], false, &mut images, &mut placements);
|
|
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 bytes = Vec::new();
|
|
let placement = test_placement(0, 0);
|
|
|
|
encode_graphics_update(
|
|
&mut bytes,
|
|
&[placement],
|
|
false,
|
|
&mut images,
|
|
&mut placements,
|
|
);
|
|
bytes.clear();
|
|
encode_graphics_update(&mut bytes, &[], true, &mut images, &mut placements);
|
|
|
|
let delete = String::from_utf8_lossy(&bytes);
|
|
assert!(delete.contains("a=d,d=i"));
|
|
assert!(placements.is_empty());
|
|
}
|
|
}
|