Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions packages/core/src/zig/renderer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ const CLEAR_CHAR = '\u{0a00}';
const MAX_STAT_SAMPLES = 30;
const STAT_SAMPLE_CAPACITY = 30;

/// kitty's Unicode image placeholder (U+10EEEE, bare or carrying row/column
/// diacritics). The terminal only treats plain cells of this codepoint as
/// image placements; wrapped in OSC 66 text-sizing it draws blank cells
/// where the image should be, so explicit-width output must skip them.
/// https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders
const KITTY_IMAGE_PLACEHOLDER_UTF8 = "\u{10EEEE}";

inline fn isKittyImagePlaceholder(bytes: []const u8) bool {
return std.mem.startsWith(u8, bytes, KITTY_IMAGE_PLACEHOLDER_UTF8);
}

pub const RendererError = error{
OutOfMemory,
InvalidDimensions,
Expand Down Expand Up @@ -1117,7 +1128,7 @@ pub const CliRenderer = struct {
if (bytes.len > 0) {
const capabilities = self.terminal.getCapabilities();
const graphemeWidth = gp.charRightExtent(cell.char) + 1;
if (capabilities.explicit_width) {
if (capabilities.explicit_width and !isKittyImagePlaceholder(bytes)) {
ansi.ANSI.explicitWidthOutput(writer, graphemeWidth, bytes) catch {};
} else {
writer.writeAll(bytes) catch {};
Expand Down Expand Up @@ -1427,7 +1438,7 @@ pub const CliRenderer = struct {
if (bytes.len > 0) {
const capabilities = self.terminal.getCapabilities();
const graphemeWidth = gp.charRightExtent(cell.char) + 1;
if (capabilities.explicit_width) {
if (capabilities.explicit_width and !isKittyImagePlaceholder(bytes)) {
ansi.ANSI.explicitWidthOutput(writer, graphemeWidth, bytes) catch {};
} else {
writer.writeAll(bytes) catch {};
Expand Down
44 changes: 44 additions & 0 deletions packages/core/src/zig/tests/renderer_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2602,3 +2602,47 @@ test "buffered backend frees grown buffers cleanly on deinit" {
}
try std.testing.expectEqual(@import("../renderer-output.zig").WriteStatus.ok, backend.endFrame());
}

test "renderer - explicit_width leaves kitty image placeholder cells unwrapped" {
const pool = gp.initGlobalPool(std.testing.allocator);
defer gp.deinitGlobalPool();
var local_link_pool = link.LinkPool.init(std.testing.allocator);
defer local_link_pool.deinit();

var tb = try TextBuffer.init(std.testing.allocator, pool, &local_link_pool, .unicode);
defer tb.deinit();

// An emoji as positive control, then a kitty Unicode-placeholder image
// row: U+10EEEE with a row diacritic, then a bare U+10EEEE continuation.
try tb.setText("👋\u{10EEEE}\u{0305}\u{10EEEE}");

var view = try TextBufferView.init(std.testing.allocator, tb);
defer view.deinit();

var test_cli_renderer = try TestRenderer.create(
std.testing.allocator,
80,
24,
pool,
);
defer test_cli_renderer.deinit();
const cli_renderer = test_cli_renderer.renderer;

cli_renderer.terminal.caps.explicit_width = true;

const next_buffer = cli_renderer.getNextBuffer();
next_buffer.drawTextBuffer(view, 0, 0);

_ = cli_renderer.render(false);

const output = test_cli_renderer.lastOutput();

// Ordinary graphemes still get OSC 66 text-sizing.
try std.testing.expect(std.mem.indexOf(u8, output, "\x1b]66;w=2;👋\x1b\\") != null);

// Placeholder cells must reach the terminal bare: kitty only treats
// plain U+10EEEE cells as image placements; wrapped in OSC 66 it
// draws nothing where the image should be.
try std.testing.expect(std.mem.indexOf(u8, output, "\u{10EEEE}") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "\x1b]66;w=1;\u{10EEEE}") == null);
}