aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-20 03:43:53 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-20 03:47:29 +0300
commit38a3c3c5cdb31cae9dab930984d6bea115ede687 (patch)
tree5d58c4b0fe247a3c6d28d200b9c358627a8ccd2f /src
parent433d564d03a5d319b91740b5fe1c7c6504be0a12 (diff)
fix: support kitty unicode placeholders
refs #136
Diffstat (limited to 'src')
-rw-r--r--src/app/mod.rs5
-rw-r--r--src/ghostty/mod.rs487
-rw-r--r--src/kitty_graphics.rs34
-rw-r--r--src/pane/terminal.rs51
-rw-r--r--src/server/headless.rs21
5 files changed, 574 insertions, 24 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs
index ca053ba1..f33de78f 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -564,11 +564,14 @@ impl App {
if needs_render && self.can_render_now(now) {
self.render_dirty.swap(false, Ordering::AcqRel);
let _sync_output = SyncOutputGuard::begin()?;
+ let kitty_graphics_enabled = self.state.kitty_graphics_enabled;
if self.full_redraw_pending {
+ if kitty_graphics_enabled {
+ crate::kitty_graphics::clear_all_host_graphics()?;
+ }
terminal.clear()?;
self.full_redraw_pending = false;
}
- let kitty_graphics_enabled = self.state.kitty_graphics_enabled;
let mut cell_size = crate::kitty_graphics::HostCellSize::default();
terminal.draw(|frame| {
let area = frame.area();
diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs
index ef03716a..62d55835 100644
--- a/src/ghostty/mod.rs
+++ b/src/ghostty/mod.rs
@@ -11,6 +11,7 @@
pub mod bindings;
use std::collections::hash_map::DefaultHasher;
+use std::collections::HashMap;
use std::ffi::c_void;
use std::fmt;
use std::hash::{Hash, Hasher};
@@ -19,7 +20,7 @@ use std::mem;
use std::os::raw::c_char;
use std::ptr;
use std::slice;
-use std::sync::Once;
+use std::sync::{Once, OnceLock};
pub use bindings as ffi;
@@ -152,8 +153,16 @@ const APC_MAX_BYTES_KITTY: usize = 16 * 1024 * 1024;
// cryptographic identity. Sampling keeps redraws cheap for multi-megabyte
// images while still distinguishing normal screenshots/photos/diagrams.
const KITTY_FINGERPRINT_SAMPLE_BYTES: usize = 4096;
+pub(crate) const KITTY_UNICODE_PLACEHOLDER: u32 = 0x10EEEE;
+// The vendored C headers expose these placement fields, but the checked-in
+// generated bindings predate the names. Keep the explicit values aligned with
+// vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h.
+const KITTY_PLACEMENT_DATA_IS_VIRTUAL: ffi::GhosttyKittyGraphicsPlacementData = 3;
+const KITTY_PLACEMENT_DATA_COLUMNS: ffi::GhosttyKittyGraphicsPlacementData = 10;
+const KITTY_PLACEMENT_DATA_ROWS: ffi::GhosttyKittyGraphicsPlacementData = 11;
static INSTALL_PNG_DECODER: Once = Once::new();
+static KITTY_PLACEHOLDER_DIACRITICS: OnceLock<HashMap<u32, u32>> = OnceLock::new();
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
pub enum KittyImageFormat {
@@ -204,6 +213,45 @@ pub struct KittyPlacementRenderInfo {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct KittyVirtualPlacementSpec {
+ image_id: u32,
+ placement_id: u32,
+ columns: u32,
+ rows: u32,
+ z: i32,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct KittyVirtualCell {
+ x: u16,
+ y: u16,
+ image_id_low: u32,
+ image_id_high: Option<u32>,
+ placement_id: Option<u32>,
+ row: Option<u32>,
+ col: Option<u32>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct KittyVirtualRun {
+ x: u16,
+ y: u16,
+ image_id_low: u32,
+ image_id_high: Option<u32>,
+ placement_id: Option<u32>,
+ row: u32,
+ col: u32,
+ width: u32,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct KittyVirtualPlacementGeometry {
+ x_offset: u32,
+ y_offset: u32,
+ render: KittyPlacementRenderInfo,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CursorVisualStyle {
Bar,
Block,
@@ -275,6 +323,7 @@ pub enum CellColor {
pub struct CellStyle {
pub fg_color: Option<CellColor>,
pub bg_color: Option<CellColor>,
+ pub underline_color: Option<CellColor>,
pub bold: bool,
pub italic: bool,
pub faint: bool,
@@ -291,6 +340,7 @@ impl From<ffi::GhosttyStyle> for CellStyle {
Self {
fg_color: cell_color_from_style_color(value.fg_color),
bg_color: cell_color_from_style_color(value.bg_color),
+ underline_color: cell_color_from_style_color(value.underline_color),
bold: value.bold,
italic: value.italic,
faint: value.faint,
@@ -861,6 +911,14 @@ impl Terminal {
self.get_u16(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_ROWS)
}
+ fn width_px(&self) -> Result<u32, Error> {
+ self.get_u32(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_WIDTH_PX)
+ }
+
+ fn height_px(&self) -> Result<u32, Error> {
+ self.get_u32(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_HEIGHT_PX)
+ }
+
fn get_u16(&self, data: ffi::GhosttyTerminalData) -> Result<u16, Error> {
let mut out = 0u16;
// SAFETY: out points to a u16 matching the requested terminal data type.
@@ -871,6 +929,16 @@ impl Terminal {
Ok(out)
}
+ fn get_u32(&self, data: ffi::GhosttyTerminalData) -> Result<u32, Error> {
+ let mut out = 0u32;
+ // SAFETY: out points to a u32 matching the requested terminal data type.
+ unsafe {
+ ffi::ghostty_terminal_get(self.raw, data, (&mut out as *mut u32).cast())
+ .into_result()?;
+ }
+ Ok(out)
+ }
+
fn get_usize(&self, data: ffi::GhosttyTerminalData) -> Result<usize, Error> {
let mut out = 0usize;
unsafe {
@@ -925,6 +993,7 @@ impl Terminal {
placements.push(placement);
}
}
+ placements.extend(self.kitty_virtual_image_placements(graphics, &mut needs_data)?);
placements.sort_by_key(|placement| placement.z);
Ok(placements)
}
@@ -942,6 +1011,9 @@ impl Terminal {
iterator,
ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID,
)?;
+ if kitty_placement_bool(iterator, KITTY_PLACEMENT_DATA_IS_VIRTUAL)? {
+ return Ok(None);
+ }
let image = unsafe { ffi::ghostty_kitty_graphics_image(graphics, image_id) };
if image.is_null() {
return Ok(None);
@@ -1038,6 +1110,134 @@ impl Terminal {
}))
}
+ fn kitty_virtual_image_placements<F>(
+ &self,
+ graphics: ffi::GhosttyKittyGraphics,
+ needs_data: &mut F,
+ ) -> Result<Vec<KittyImagePlacement>, Error>
+ where
+ F: FnMut(KittyImageDescriptor) -> bool,
+ {
+ let specs = kitty_virtual_placement_specs(graphics)?;
+ if specs.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let cols = self.cols()?.max(1) as u32;
+ let rows = self.rows()?.max(1) as u32;
+ let cell_width = (self.width_px()? / cols).max(1);
+ let cell_height = (self.height_px()? / rows).max(1);
+ let mut render_state = RenderState::new()?;
+ render_state.update(self)?;
+ let mut row_iterator = RowIterator::new()?;
+ let mut row_cells = RowCells::new()?;
+ let mut row_iter = render_state.populate_row_iterator(&mut row_iterator)?;
+ let mut graphemes = Vec::new();
+ let mut runs = Vec::new();
+ let mut y = 0u16;
+ while row_iter.next() {
+ let mut cells = row_iter.populate_cells(&mut row_cells)?;
+ let mut current: Option<KittyVirtualRun> = None;
+ let mut x = 0u16;
+ while cells.next() {
+ let cell = kitty_virtual_cell(x, y, &cells, &mut graphemes)?;
+ match cell {
+ Some(cell) => {
+ if let Some(run) = current.as_mut() {
+ if run.append(cell) {
+ x = x.saturating_add(1);
+ continue;
+ }
+ runs.push(*run);
+ }
+ current = Some(KittyVirtualRun::from_cell(cell));
+ }
+ None => {
+ if let Some(run) = current.take() {
+ runs.push(run);
+ }
+ }
+ }
+ x = x.saturating_add(1);
+ }
+ if let Some(run) = current {
+ runs.push(run);
+ }
+ y = y.saturating_add(1);
+ }
+
+ let mut placements = Vec::new();
+ for run in runs {
+ let image_id = run.image_id();
+ let Some(spec) = find_virtual_placement_spec(&specs, image_id, run.placement_id())
+ else {
+ continue;
+ };
+ let image = unsafe { ffi::ghostty_kitty_graphics_image(graphics, image_id) };
+ if image.is_null() {
+ continue;
+ }
+ let image_width = kitty_image_u32(
+ image,
+ ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_WIDTH,
+ )?;
+ let image_height = kitty_image_u32(
+ image,
+ ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_HEIGHT,
+ )?;
+ let format = kitty_image_format(image)?;
+ let compression = kitty_image_compression(image)?;
+ if compression != ffi::GhosttyKittyImageCompression_GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE
+ {
+ continue;
+ }
+ let Some(geometry) = kitty_virtual_placement_geometry(
+ run,
+ *spec,
+ image_width,
+ image_height,
+ cell_width,
+ cell_height,
+ ) else {
+ continue;
+ };
+ let placement_id = run.synthetic_placement_id();
+ let (data_ptr, data_len) = kitty_image_data_ptr_len(image)?;
+ let data_fingerprint =
+ kitty_image_fingerprint(data_ptr, data_len, image_width, image_height, format);
+ let descriptor = KittyImageDescriptor {
+ image_id,
+ placement_id,
+ image_width,
+ image_height,
+ format,
+ data_len,
+ data_fingerprint,
+ };
+ let data = if needs_data(descriptor) {
+ kitty_image_data_from_ptr(data_ptr, data_len)
+ } else {
+ Vec::new()
+ };
+ placements.push(KittyImagePlacement {
+ image_id,
+ placement_id,
+ z: spec.z,
+ x_offset: geometry.x_offset,
+ y_offset: geometry.y_offset,
+ image_width,
+ image_height,
+ format,
+ data_len,
+ data_fingerprint,
+ data,
+ render: geometry.render,
+ });
+ }
+
+ Ok(placements)
+ }
+
fn raw(&self) -> ffi::GhosttyTerminal_ptr {
self.raw
}
@@ -1126,6 +1326,269 @@ fn kitty_placement_i32(
Ok(out)
}
+fn kitty_placement_bool(
+ iterator: ffi::GhosttyKittyGraphicsPlacementIterator,
+ data: ffi::GhosttyKittyGraphicsPlacementData,
+) -> Result<bool, Error> {
+ let mut out = false;
+ unsafe {
+ ffi::ghostty_kitty_graphics_placement_get(iterator, data, (&mut out as *mut bool).cast())
+ .into_result()?;
+ }
+ Ok(out)
+}
+
+fn kitty_virtual_placement_specs(
+ graphics: ffi::GhosttyKittyGraphics,
+) -> Result<Vec<KittyVirtualPlacementSpec>, Error> {
+ let mut iterator: ffi::GhosttyKittyGraphicsPlacementIterator = ptr::null_mut();
+ unsafe {
+ ffi::ghostty_kitty_graphics_placement_iterator_new(ptr::null(), &mut iterator)
+ .into_result()?;
+ ffi::ghostty_kitty_graphics_get(
+ graphics,
+ ffi::GhosttyKittyGraphicsData_GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR,
+ (&mut iterator as *mut ffi::GhosttyKittyGraphicsPlacementIterator).cast(),
+ )
+ .into_result()?;
+ }
+ let _guard = KittyPlacementIteratorGuard { raw: iterator };
+
+ let mut specs = Vec::new();
+ while unsafe { ffi::ghostty_kitty_graphics_placement_next(iterator) } {
+ if !kitty_placement_bool(iterator, KITTY_PLACEMENT_DATA_IS_VIRTUAL)? {
+ continue;
+ }
+ specs.push(KittyVirtualPlacementSpec {
+ image_id: kitty_placement_u32(
+ iterator,
+ ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID,
+ )?,
+ placement_id: kitty_placement_u32(
+ iterator,
+ ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID,
+ )?,
+ columns: kitty_placement_u32(iterator, KITTY_PLACEMENT_DATA_COLUMNS)?,
+ rows: kitty_placement_u32(iterator, KITTY_PLACEMENT_DATA_ROWS)?,
+ z: kitty_placement_i32(
+ iterator,
+ ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Z,
+ )?,
+ });
+ }
+ Ok(specs)
+}
+
+fn find_virtual_placement_spec(
+ specs: &[KittyVirtualPlacementSpec],
+ image_id: u32,
+ placement_id: u32,
+) -> Option<&KittyVirtualPlacementSpec> {
+ if placement_id > 0 {
+ specs
+ .iter()
+ .find(|spec| spec.image_id == image_id && spec.placement_id == placement_id)
+ } else {
+ specs.iter().find(|spec| spec.image_id == image_id)
+ }
+}
+
+fn kitty_virtual_cell(
+ x: u16,
+ y: u16,
+ cells: &RowCellIter<'_>,
+ graphemes: &mut Vec<u32>,
+) -> Result<Option<KittyVirtualCell>, Error> {
+ cells.graphemes_into(graphemes)?;
+ if graphemes.first().copied() != Some(KITTY_UNICODE_PLACEHOLDER) {
+ return Ok(None);
+ }
+ let style = cells.style()?;
+ let image_id_low = style
+ .fg_color
+ .map(kitty_placeholder_color_to_id)
+ .unwrap_or(0);
+ let placement_id = style
+ .underline_color
+ .map(kitty_placeholder_color_to_id)
+ .filter(|id| *id != 0);
+ let row = graphemes
+ .get(1)
+ .and_then(|codepoint| kitty_placeholder_diacritic_index(*codepoint));
+ let col = graphemes
+ .get(2)
+ .and_then(|codepoint| kitty_placeholder_diacritic_index(*codepoint));
+ let image_id_high = graphemes
+ .get(3)
+ .and_then(|codepoint| kitty_placeholder_diacritic_index(*codepoint))
+ .filter(|high| *high <= u32::from(u8::MAX));
+
+ Ok(Some(KittyVirtualCell {
+ x,
+ y,
+ image_id_low,
+ image_id_high,
+ placement_id,
+ row,
+ col,
+ }))
+}
+
+fn kitty_placeholder_color_to_id(color: CellColor) -> u32 {
+ match color {
+ CellColor::Palette(value) => value.into(),
+ CellColor::Rgb(color) => {
+ (u32::from(color.r) << 16) | (u32::from(color.g) << 8) | u32::from(color.b)
+ }
+ }
+}
+
+fn kitty_placeholder_diacritic_index(codepoint: u32) -> Option<u32> {
+ let map = KITTY_PLACEHOLDER_DIACRITICS.get_or_init(|| {
+ // Reuse Ghostty's vendored table so Herdr decodes the same placeholder
+ // row/column diacritics that libghostty accepts.
+ let source =
+ include_str!("../../vendor/libghostty-vt/src/terminal/kitty/graphics_unicode.zig");
+ let mut map = HashMap::new();
+ let mut in_table = false;
+ for line in source.lines() {
+ let line = line.trim();
+ if line.starts_with("const diacritics:") {
+ in_table = true;
+ continue;
+ }
+ if !in_table {
+ continue;
+ }
+ if line == "};" {
+ break;
+ }
+ let Some(hex) = line
+ .strip_prefix("0x")
+ .and_then(|value| value.strip_suffix(','))
+ else {
+ continue;
+ };
+ if let Ok(value) = u32::from_str_radix(hex, 16) {
+ map.insert(value, map.len() as u32);
+ }
+ }
+ map
+ });
+ map.get(&codepoint).copied()
+}
+
+fn kitty_virtual_placement_geometry(
+ run: KittyVirtualRun,
+ spec: KittyVirtualPlacementSpec,
+ image_width: u32,
+ image_height: u32,
+ cell_width: u32,
+ cell_height: u32,
+) -> Option<KittyVirtualPlacementGeometry> {
+ let grid_cols = if spec.columns == 0 {
+ image_width.saturating_add(cell_width - 1) / cell_width
+ } else {
+ spec.columns
+ }
+ .max(1);
+ let grid_rows = if spec.rows == 0 {
+ image_height.saturating_add(cell_height - 1) / cell_height
+ } else {
+ spec.rows
+ }
+ .max(1);
+
+ if run.col >= grid_cols || run.row >= grid_rows {
+ return None;
+ }
+ let visible_cols = run.width.min(grid_cols.saturating_sub(run.col)).max(1);
+ let visible_rows = 1;
+ let source_x = scale_u32(run.col, image_width, grid_cols);
+ let source_y = scale_u32(run.row, image_height, grid_rows);
+ let source_width = scale_u32(visible_cols, image_width, grid_cols)
+ .max(1)
+ .min(image_width.saturating_sub(source_x));
+ let source_height = scale_u32(visible_rows, image_height, grid_rows)
+ .max(1)
+ .min(image_height.saturating_sub(source_y));
+ if source_width == 0 || source_height == 0 {
+ return None;
+ }
+
+ Some(KittyVirtualPlacementGeometry {
+ x_offset: 0,
+ y_offset: 0,
+ render: KittyPlacementRenderInfo {
+ pixel_width: visible_cols.saturating_mul(cell_width).max(1),
+ pixel_height: visible_rows.saturating_mul(cell_height).max(1),
+ grid_cols: visible_cols,
+ grid_rows: visible_rows,
+ viewport_col: i32::from(run.x),
+ viewport_row: i32::from(run.y),
+ source_x,
+ source_y,
+ source_width,
+ source_height,
+ },
+ })
+}
+
+fn scale_u32(value: u32, source: u32, dest: u32) -> u32 {
+ ((u64::from(value)).saturating_mul(u64::from(source)) / u64::from(dest.max(1)))
+ .min(u64::from(u32::MAX)) as u32
+}
+
+impl KittyVirtualRun {
+ fn from_cell(cell: KittyVirtualCell) -> Self {
+ Self {
+ x: cell.x,
+ y: cell.y,
+ image_id_low: cell.image_id_low,
+ image_id_high: cell.image_id_high,
+ placement_id: cell.placement_id,
+ row: cell.row.unwrap_or(0),
+ col: cell.col.unwrap_or(0),
+ width: 1,
+ }
+ }
+
+ fn append(&mut self, cell: KittyVirtualCell) -> bool {
+ if self.image_id_low != cell.image_id_low
+ || self.placement_id != cell.placement_id
+ || cell.row.is_some_and(|row| row != self.row)
+ || cell.col.is_some_and(|col| col != self.col + self.width)
+ || cell
+ .image_id_high
+ .is_some_and(|high| Some(high) != self.image_id_high)
+ {
+ return false;
+ }
+ self.width += 1;
+ true
+ }
+
+ fn image_id(self) -> u32 {
+ self.image_id_low | (self.image_id_high.unwrap_or(0) << 24)
+ }
+
+ fn placement_id(self) -> u32 {
+ self.placement_id.unwrap_or(0)
+ }
+
+ fn synthetic_placement_id(self) -> u32 {
+ let mut hasher = DefaultHasher::new();
+ self.image_id().hash(&mut hasher);
+ self.placement_id().hash(&mut hasher);
+ self.row.hash(&mut hasher);
+ self.col.hash(&mut hasher);
+ self.width.hash(&mut hasher);
+ self.x.hash(&mut hasher);
+ self.y.hash(&mut hasher);
+ 1 + ((hasher.finish() as u32) % 900_000)
+ }
+}
+
fn kitty_image_u32(
image: ffi::GhosttyKittyGraphicsImage,
data: ffi::GhosttyKittyGraphicsImageData,
@@ -1986,6 +2449,28 @@ mod tests {
}
#[test]
+ fn kitty_graphics_unicode_placeholder_placement_is_queryable() {
+ let mut terminal = Terminal::new(10, 5, 0).unwrap();
+ terminal.enable_kitty_graphics().unwrap();
+ terminal.resize(10, 5, 8, 16).unwrap();
+ terminal.write(b"\x1b_Gq=2,a=T,C=1,U=1,f=32,s=1,v=1,i=1193046,c=2,r=1,m=0;/wAA/w==\x1b\\");
+ terminal.write("\x1b[2;3H\x1b[38;2;18;52;86m\u{10eeee}\u{0305}\u{0305}\u{10eeee}\u{0305}\u{030d}\x1b[0m".as_bytes());
+
+ let placements = terminal.kitty_image_placements().unwrap();
+ assert_eq!(placements.len(), 1);
+ assert_eq!(placements[0].image_id, 1193046);
+ assert_ne!(placements[0].placement_id, 0);
+ assert_eq!(placements[0].image_width, 1);
+ assert_eq!(placements[0].image_height, 1);
+ assert_eq!(placements[0].format, KittyImageFormat::Rgba);
+ assert_eq!(placements[0].data, [255, 0, 0, 255]);
+ assert_eq!(placements[0].render.viewport_col, 2);
+ assert_eq!(placements[0].render.viewport_row, 1);
+ assert_eq!(placements[0].render.grid_cols, 2);
+ assert_eq!(placements[0].render.grid_rows, 1);
+ }
+
+ #[test]
fn focus_encoding_matches_expected_sequences() {
assert_eq!(encode_focus(FocusEvent::Gained).unwrap(), b"\x1b[I");
assert_eq!(encode_focus(FocusEvent::Lost).unwrap(), b"\x1b[O");
diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs
index 47aba364..857bd241 100644
--- a/src/kitty_graphics.rs
+++ b/src/kitty_graphics.rs
@@ -859,6 +859,40 @@ mod tests {
}
#[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();
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index f25c70ef..6e29774a 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -6,7 +6,7 @@ use bytes::Bytes;
use ratatui::style::{Color, Modifier, Style};
use ratatui::{layout::Rect, Frame};
use tokio::sync::mpsc;
-use tracing::{debug, error, warn};
+use tracing::{debug, error};
use unicode_width::UnicodeWidthStr;
use crate::layout::PaneId;
@@ -390,21 +390,7 @@ impl GhosttyPaneTerminal {
let has_kitty_graphics_sequence = crate::kitty_graphics::is_enabled()
&& contains_kitty_graphics_sequence(filtered_bytes.as_ref());
if has_kitty_graphics_sequence {
- match core
- .terminal
- .kitty_image_placements_with_data_filter(|_| false)
- {
- Ok(placements) => debug!(
- pane = pane_id.raw(),
- placements = placements.len(),
- "processed kitty graphics sequence"
- ),
- Err(err) => warn!(
- pane = pane_id.raw(),
- err = %err,
- "failed to query kitty graphics after sequence"
- ),
- }
+ debug!(pane = pane_id.raw(), "processed kitty graphics sequence");
}
if let Ok(mut key_encoder) = self.key_encoder.lock() {
key_encoder.set_from_terminal(&core.terminal);
@@ -725,6 +711,7 @@ impl GhosttyPaneTerminal {
.and_then(|c| ghostty_default_fg(c.foreground, host_theme, initial_default_foreground));
let resolved_fg = colors.map(|c| ghostty_color(c.foreground));
let resolved_bg = colors.map(|c| ghostty_color(c.background));
+ let hide_kitty_placeholders = crate::kitty_graphics::is_enabled();
let mut row_iterator = match crate::ghostty::RowIterator::new() {
Ok(iterator) => iterator,
@@ -761,6 +748,7 @@ impl GhosttyPaneTerminal {
let symbol = match ghostty_buffer_symbol_into(
&cells,
wide,
+ hide_kitty_placeholders,
&mut grapheme_scratch,
&mut symbol_scratch,
) {
@@ -1016,6 +1004,7 @@ pub(super) fn ghostty_normalize_buffer_symbol(
fn ghostty_buffer_symbol_into<'a>(
cells: &crate::ghostty::RowCellIter<'_>,
wide: crate::ghostty::CellWide,
+ hide_kitty_placeholders: bool,
grapheme_scratch: &mut Vec<u32>,
symbol_scratch: &'a mut String,
) -> Result<&'a str, crate::ghostty::Error> {
@@ -1025,7 +1014,10 @@ fn ghostty_buffer_symbol_into<'a>(
crate::ghostty::CellWide::SpacerHead => symbol_scratch.push(' '),
crate::ghostty::CellWide::Narrow | crate::ghostty::CellWide::Wide => {
cells.graphemes_into(grapheme_scratch)?;
- if grapheme_scratch.is_empty() {
+ let hidden_kitty_placeholder = hide_kitty_placeholders
+ && grapheme_scratch.first().copied()
+ == Some(crate::ghostty::KITTY_UNICODE_PLACEHOLDER);
+ if hidden_kitty_placeholder || grapheme_scratch.is_empty() {
symbol_scratch.push(' ');
} else {
for &codepoint in grapheme_scratch.iter() {
@@ -1822,6 +1814,31 @@ mod tests {
}
#[test]
+ fn render_blanks_kitty_unicode_placeholders_when_graphics_enabled() {
+ crate::kitty_graphics::set_enabled(true);
+ let (tx, _rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+ {
+ let mut core = pane.core.lock().unwrap();
+ core.terminal
+ .write("before\u{10eeee}\u{0305}\u{0305}after".as_bytes());
+ }
+
+ let backend = ratatui::backend::TestBackend::new(20, 5);
+ let mut terminal = ratatui::Terminal::new(backend).unwrap();
+ terminal
+ .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false))
+ .unwrap();
+ crate::kitty_graphics::set_enabled(false);
+
+ let buffer = terminal.backend().buffer();
+ assert_eq!(buffer[(0, 0)].symbol(), "b");
+ assert_eq!(buffer[(6, 0)].symbol(), " ");
+ assert_eq!(buffer[(7, 0)].symbol(), "a");
+ }
+
+ #[test]
fn render_keeps_explicit_cell_foreground_when_host_is_unknown() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
diff --git a/src/server/headless.rs b/src/server/headless.rs
index c84d0abe..49724ffe 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -245,6 +245,8 @@ struct ClientConnection {
render_state: ClientRenderState,
/// Client-local host Kitty graphics cache.
graphics_cache: crate::kitty_graphics::HostGraphicsCache,
+ /// Whether the next graphics frame must clear and rebuild host-side Kitty state.
+ graphics_surface_reset_pending: bool,
/// Whether a render was skipped because the render channel was full.
render_pending: bool,
/// Last host mouse capture mode sent to this client.
@@ -297,6 +299,7 @@ impl ClientConnection {
last_activity,
render_state: ClientRenderState::new(render_encoding),
graphics_cache: crate::kitty_graphics::HostGraphicsCache::default(),
+ graphics_surface_reset_pending: false,
render_pending: false,
host_mouse_capture_active: None,
staged_clipboard_files: Vec::new(),
@@ -306,6 +309,7 @@ impl ClientConnection {
fn request_full_redraw(&mut self) {
self.render_state.reset_baseline();
+ self.graphics_surface_reset_pending = true;
}
fn request_semantic_redraw_after_input(&mut self) {
@@ -1934,12 +1938,18 @@ impl HeadlessServer {
continue;
};
let mut next_graphics_cache = client.graphics_cache.clone();
+ let graphics_surface_reset_pending = client.graphics_surface_reset_pending;
if is_app_client && self.app.state.kitty_graphics_enabled && cell_size.is_known() {
- frame.graphics = crate::kitty_graphics::encode_local_pane_graphics(
- &self.app.state,
- cell_size,
- &mut next_graphics_cache,
- );
+ if graphics_surface_reset_pending {
+ frame.graphics = next_graphics_cache.clear_bytes();
+ }
+ frame
+ .graphics
+ .extend(crate::kitty_graphics::encode_local_pane_graphics(
+ &self.app.state,
+ cell_size,
+ &mut next_graphics_cache,
+ ));
} else {
frame.graphics = next_graphics_cache.clear_bytes();
}
@@ -2023,6 +2033,7 @@ impl HeadlessServer {
client.render_pending = false;
if commit_graphics_cache {
client.graphics_cache = next_graphics_cache;
+ client.graphics_surface_reset_pending = false;
}
client
.render_state