Skip to content

Commit e54cf3d

Browse files
committed
feat: enhance Unicode placeholder handling and protocol detection for Kitty graphics
1 parent f4562dd commit e54cf3d

File tree

7 files changed

+235
-85
lines changed

7 files changed

+235
-85
lines changed

cmd/gallery/main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,8 @@ func (m *model) renderImageForTUI(filename string) (string, int) {
355355

356356
// Apply virtual mode if enabled
357357
if m.virtualMode && termimg.KittySupported() {
358-
img = img.Protocol(termimg.Kitty).Virtual(true)
358+
// Virtual mode rendering in gallery relies on Unicode placeholders for in-band display.
359+
img = img.Protocol(termimg.Kitty).UseUnicode(true)
359360
}
360361

361362
// Render the image to get the escape codes

detect.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,9 @@ func ParallelProtocolDetection() (kitty, sixel, iterm2 bool) {
312312
results.kitty = true
313313
}
314314

315+
// Populate Sixel from environment before early-returning for Kitty/iTerm hints.
316+
results.sixel = DetectSixelFromEnvironment()
317+
315318
// If we've already detected a graphics protocol from environment variables,
316319
// skip all terminal queries to avoid leaving garbage in the input buffer.
317320
// This prevents issues with TUI frameworks like bubbletea that read from stdin.

detect_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,29 @@ func TestParallelProtocolDetection(t *testing.T) {
163163
// (but this might fail in CI environments, so just check types)
164164
}
165165

166+
func TestParallelProtocolDetectionPreservesSixelWhenKittyDetectedFromEnv(t *testing.T) {
167+
wasTmuxForced := IsTmuxForced()
168+
defer ForceTmux(wasTmuxForced)
169+
ForceTmux(false)
170+
171+
// Normalize all relevant env hints so this assertion is deterministic.
172+
t.Setenv("TERM", "xterm-256color")
173+
t.Setenv("TERM_PROGRAM", "WezTerm")
174+
t.Setenv("KITTY_WINDOW_ID", "")
175+
t.Setenv("XTERM_VERSION", "")
176+
t.Setenv("TMUX", "")
177+
t.Setenv("GHOSTTY_RESOURCES_DIR", "")
178+
t.Setenv("WEZTERM_EXECUTABLE", "")
179+
t.Setenv("ITERM_SESSION_ID", "")
180+
t.Setenv("LC_TERMINAL", "")
181+
t.Setenv("TERM_SESSION_ID", "")
182+
183+
kitty, sixel, iterm2 := ParallelProtocolDetection()
184+
assert.True(t, kitty, "WezTerm should be detected as kitty-capable from env hints")
185+
assert.True(t, sixel, "Sixel env detection should still run before early return")
186+
assert.False(t, iterm2, "WezTerm env detection path should not force iTerm2 support")
187+
}
188+
166189
func TestProtocolStrings(t *testing.T) {
167190
tests := []struct {
168191
protocol Protocol

kitty.go

Lines changed: 39 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,9 @@ type KittyOptions struct {
8484
// Initialized with process ID + timestamp to prevent conflicts between program runs
8585
var globalKittyImageID = uint32(os.Getpid()<<16) + uint32(time.Now().UnixMicro()&0xFFFF)
8686

87-
// Global image number counter for Unicode placeholders - must fit in 24 bits for RGB encoding
88-
// Starts at 1 to avoid 0 (which would be invisible in some color modes)
87+
// Global image number counter for Unicode placeholders.
88+
// IDs are 32-bit and encoded as: high byte via optional id_extra diacritic, low 24 bits via RGB.
89+
// Starts at 1 to avoid 0 (which can be ambiguous in some color mappings).
8990
var globalKittyImageNum uint32 = 1
9091

9192
// KittyRenderer implements the Renderer interface for Kitty graphics protocol
@@ -355,12 +356,6 @@ func (r *KittyRenderer) Render(img image.Image, opts RenderOptions) (string, err
355356

356357
// Handle non-unicode Kitty options
357358
if kittyOpts != nil {
358-
if opts.Virtual && !kittyOpts.UseUnicode {
359-
// Non-unicode virtual placement - just generate simple placeholders
360-
placeholders := r.generateUnicodePlaceholders(imageID, cols, rows)
361-
output.WriteString(placeholders)
362-
}
363-
364359
// Handle animation after image transfer
365360
if kittyOpts.Animation != nil && len(kittyOpts.Animation.ImageIDs) > 0 {
366361
// TODO: Animation is handled separately after all images are transferred
@@ -497,11 +492,6 @@ func (r *KittyRenderer) PlaceImageWithSize(imageID string, xCells, yCells, zInde
497492
// specific newline behavior from shifting rows back to column 1.
498493
output.WriteString(renderAnchoredPlaceholderArea(imgID, xCells, yCells, widthCells, heightCells))
499494

500-
if inTmux() {
501-
result := wrapTmuxPassthrough(output.String())
502-
_, err := io.WriteString(os.Stdout, result)
503-
return err
504-
}
505495
_, err := io.WriteString(os.Stdout, output.String())
506496
return err
507497
}
@@ -514,20 +504,14 @@ func renderAnchoredPlaceholderArea(imageID uint32, xCells, yCells, widthCells, h
514504
return ""
515505
}
516506

517-
area := CreatePlaceholderArea(imageID, uint16(heightCells), uint16(widthCells))
518-
519-
r := (imageID >> 16) & 0xFF
520-
g := (imageID >> 8) & 0xFF
521-
b := imageID & 0xFF
522-
colorStart := fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r, g, b)
507+
idExtra := byte(imageID >> 24)
508+
colorStart := placeholderColorStart(imageID)
523509

524510
var builder strings.Builder
525-
for rowIdx, row := range area {
511+
for rowIdx := range heightCells {
526512
builder.WriteString(fmt.Sprintf("\x1b[%d;%dH", yCells+rowIdx+1, xCells+1))
527513
builder.WriteString(colorStart)
528-
for _, placeholder := range row {
529-
builder.WriteString(placeholder)
530-
}
514+
writeInheritedPlaceholderRow(&builder, CreatePlaceholder(uint16(rowIdx), 0, idExtra), widthCells)
531515
builder.WriteString("\x1b[39m")
532516
}
533517
return builder.String()
@@ -676,9 +660,9 @@ func diacritic(pos uint16) rune {
676660
return kittyDiacritics[pos]
677661
}
678662

679-
// CreatePlaceholder generates a Unicode placeholder for the given image position
680-
// placeholder_char + row_diacritic + column_diacritic + id_extra_diacritic
681-
func CreatePlaceholder(row, column uint16, id_extra byte) string {
663+
// CreatePlaceholder generates a Unicode placeholder for the given image position.
664+
// Kitty placeholder encoding uses row, column, and extra-id diacritics.
665+
func CreatePlaceholder(row, column uint16, idExtra byte) string {
682666
var builder strings.Builder
683667

684668
// Add the placeholder character
@@ -687,26 +671,42 @@ func CreatePlaceholder(row, column uint16, id_extra byte) string {
687671
// Add diacritical marks for row and column
688672
builder.WriteRune(diacritic(row))
689673
builder.WriteRune(diacritic(column))
690-
// Add diacritic for the extra ID byte
691-
builder.WriteRune(diacritic(uint16(id_extra)))
674+
builder.WriteRune(diacritic(uint16(idExtra)))
692675

693676
return builder.String()
694677
}
695678

696679
// CreatePlaceholderArea generates a grid of placeholders for an image
697680
func CreatePlaceholderArea(imageID uint32, rows, columns uint16) [][]string {
698-
id_extra := byte(imageID >> 24)
681+
idExtra := byte(imageID >> 24)
699682
area := make([][]string, rows)
700-
for r := range rows {
701-
area[r] = make([]string, columns)
702-
for c := range columns {
703-
// Use 0-based indexing for row and column positions
704-
area[r][c] = CreatePlaceholder(r, c, id_extra)
683+
for row := range rows {
684+
area[row] = make([]string, columns)
685+
for column := range columns {
686+
area[row][column] = CreatePlaceholder(row, column, idExtra)
705687
}
706688
}
707689
return area
708690
}
709691

692+
func placeholderColorStart(imageID uint32) string {
693+
r := (imageID >> 16) & 0xFF
694+
g := (imageID >> 8) & 0xFF
695+
b := imageID & 0xFF
696+
return fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r, g, b)
697+
}
698+
699+
func writeInheritedPlaceholderRow(builder *strings.Builder, firstPlaceholder string, width int) {
700+
if width <= 0 {
701+
return
702+
}
703+
704+
builder.WriteString(firstPlaceholder)
705+
for colIdx := 1; colIdx < width; colIdx++ {
706+
builder.WriteString(PLACEHOLDER_CHAR)
707+
}
708+
}
709+
710710
// RenderPlaceholderAreaWithImageID converts a placeholder area to a string with proper positioning.
711711
// The placeholders are rendered inline at the current cursor position and will scroll with content.
712712
// NOTE: We intentionally do NOT save/restore cursor position - the placeholders ARE the content
@@ -716,18 +716,17 @@ func RenderPlaceholderAreaWithImageID(area [][]string, imageID uint32) string {
716716

717717
// Encode ID in truecolor RGB: (R<<16 | G<<8 | B) == imageID&0xFFFFFF.
718718
// Using 38;2 avoids palette-index ambiguity from 38;5 in terminals.
719-
r := (imageID >> 16) & 0xFF
720-
g := (imageID >> 8) & 0xFF
721-
b := imageID & 0xFF
722-
colorStart := fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r, g, b)
719+
colorStart := placeholderColorStart(imageID)
723720

724721
// Set foreground color to encode image ID
725722
builder.WriteString(colorStart)
726723

727724
// Render each row
728725
for rowIdx, row := range area {
729-
for _, placeholder := range row {
730-
builder.WriteString(placeholder)
726+
if len(row) > 0 {
727+
// Use inherited row/column/id diacritics after the first placeholder
728+
// to match Kitty Unicode rendering behavior across terminals.
729+
writeInheritedPlaceholderRow(&builder, row[0], len(row))
731730
}
732731
// After each row (except last), reset color, newline, then restore color
733732
// This ensures proper color handling across line boundaries

kitty_test.go

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ import (
66
"encoding/base64"
77
"fmt"
88
"image"
9+
"io"
10+
"os"
911
"strconv"
1012
"strings"
1113
"testing"
14+
"unicode/utf8"
1215

1316
"github.com/stretchr/testify/assert"
1417
)
@@ -36,6 +39,51 @@ func extractFirstKittyImageID(output string) (uint32, error) {
3639
return uint32(id), nil
3740
}
3841

42+
func captureStdout(t *testing.T, fn func() error) (string, error) {
43+
t.Helper()
44+
45+
oldStdout := os.Stdout
46+
readPipe, writePipe, err := os.Pipe()
47+
if err != nil {
48+
return "", fmt.Errorf("failed to create stdout pipe: %w", err)
49+
}
50+
51+
os.Stdout = writePipe
52+
var runErr error
53+
var panicValue any
54+
func() {
55+
defer func() {
56+
panicValue = recover()
57+
}()
58+
runErr = fn()
59+
}()
60+
61+
os.Stdout = oldStdout
62+
closeErr := writePipe.Close()
63+
if closeErr != nil {
64+
_ = readPipe.Close()
65+
if panicValue != nil {
66+
panic(panicValue)
67+
}
68+
return "", fmt.Errorf("failed to close write pipe: %w", closeErr)
69+
}
70+
71+
data, readErr := io.ReadAll(readPipe)
72+
_ = readPipe.Close()
73+
if readErr != nil {
74+
if panicValue != nil {
75+
panic(panicValue)
76+
}
77+
return "", fmt.Errorf("failed to read stdout pipe: %w", readErr)
78+
}
79+
80+
if panicValue != nil {
81+
panic(panicValue)
82+
}
83+
84+
return string(data), runErr
85+
}
86+
3987
func TestKittyZlibCompression(t *testing.T) {
4088
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
4189
opts := RenderOptions{
@@ -170,7 +218,7 @@ func TestKittyUnicodeHonorsImageNumber(t *testing.T) {
170218
assert.Equal(t, uint32(42), renderer.GetLastImageID(), "last image ID should match caller-provided Unicode image number")
171219
}
172220

173-
func TestKittyUnicodeUsesPngTransferWithPlacementCommand(t *testing.T) {
221+
func TestKittyUnicodeUsesRawTransferWithPlacementCommandByDefault(t *testing.T) {
174222
img := image.NewRGBA(image.Rect(0, 0, 16, 16))
175223
opts := RenderOptions{
176224
KittyOpts: &KittyOptions{
@@ -186,10 +234,51 @@ func TestKittyUnicodeUsesPngTransferWithPlacementCommand(t *testing.T) {
186234
renderer := &KittyRenderer{}
187235
output, err := renderer.Render(img, opts)
188236
assert.NoError(t, err)
189-
assert.Contains(t, output, "f=100,t=d,i=42", "Unicode path should transmit image data using PNG transfer")
237+
assert.Contains(t, output, "f=32,s=16,v=16,t=d,i=42", "Unicode path should transmit image data using raw RGBA by default")
190238
assert.Contains(t, output, "a=p,U=1,i=42", "Unicode path should emit explicit virtual placement command")
191239
}
192240

241+
func TestKittyUnicodeUsesPngTransferWhenRequested(t *testing.T) {
242+
img := image.NewRGBA(image.Rect(0, 0, 16, 16))
243+
opts := RenderOptions{
244+
KittyOpts: &KittyOptions{
245+
UseUnicode: true,
246+
ImageNum: 42,
247+
PNG: true,
248+
},
249+
features: &TerminalFeatures{
250+
FontWidth: 8,
251+
FontHeight: 16,
252+
},
253+
}
254+
255+
renderer := &KittyRenderer{}
256+
output, err := renderer.Render(img, opts)
257+
assert.NoError(t, err)
258+
assert.Contains(t, output, "f=100,t=d,i=42", "Unicode path should transmit PNG when explicitly requested")
259+
assert.Contains(t, output, "a=p,U=1,i=42", "Unicode path should emit explicit virtual placement command")
260+
}
261+
262+
func TestKittyVirtualWithoutUnicodeDoesNotEmitPlaceholders(t *testing.T) {
263+
img := image.NewRGBA(image.Rect(0, 0, 16, 16))
264+
opts := RenderOptions{
265+
Virtual: true,
266+
KittyOpts: &KittyOptions{
267+
UseUnicode: false,
268+
},
269+
features: &TerminalFeatures{
270+
FontWidth: 8,
271+
FontHeight: 16,
272+
},
273+
}
274+
275+
renderer := &KittyRenderer{}
276+
output, err := renderer.Render(img, opts)
277+
assert.NoError(t, err)
278+
assert.Contains(t, output, "U=1", "virtual transfer should still create a virtual placement")
279+
assert.NotContains(t, output, PLACEHOLDER_CHAR, "non-unicode virtual transfer should not append placeholder text")
280+
}
281+
193282
func TestProcessImageUnicodeHonorsExplicitResize(t *testing.T) {
194283
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
195284
opts := RenderOptions{
@@ -278,6 +367,15 @@ func TestRenderPlaceholderAreaWithImageIDUsesTruecolorForLowIDs(t *testing.T) {
278367
assert.NotContains(t, rendered, "\x1b[38;5;1m", "palette mode should not be used for ID encoding")
279368
}
280369

370+
func TestRenderPlaceholderAreaWithImageIDUsesInheritedPlaceholders(t *testing.T) {
371+
area := CreatePlaceholderArea(1, 1, 3)
372+
rendered := RenderPlaceholderAreaWithImageID(area, 1)
373+
374+
assert.Contains(t, rendered, CreatePlaceholder(0, 0, 0)+PLACEHOLDER_CHAR+PLACEHOLDER_CHAR)
375+
assert.NotContains(t, rendered, CreatePlaceholder(0, 1, 0))
376+
assert.NotContains(t, rendered, CreatePlaceholder(0, 2, 0))
377+
}
378+
281379
func TestRenderAnchoredPlaceholderAreaPositionsEveryRow(t *testing.T) {
282380
rendered := renderAnchoredPlaceholderArea(1, 5, 5, 2, 3)
283381
assert.Contains(t, rendered, "\x1b[6;6H")
@@ -287,7 +385,38 @@ func TestRenderAnchoredPlaceholderAreaPositionsEveryRow(t *testing.T) {
287385
assert.Equal(t, 3, strings.Count(rendered, "\x1b[39m"))
288386
}
289387

388+
func TestRenderAnchoredPlaceholderAreaUsesInheritedPlaceholders(t *testing.T) {
389+
rendered := renderAnchoredPlaceholderArea(1, 0, 0, 3, 1)
390+
391+
assert.Contains(t, rendered, CreatePlaceholder(0, 0, 0)+PLACEHOLDER_CHAR+PLACEHOLDER_CHAR)
392+
assert.NotContains(t, rendered, CreatePlaceholder(0, 1, 0))
393+
assert.NotContains(t, rendered, CreatePlaceholder(0, 2, 0))
394+
}
395+
290396
func TestRenderAnchoredPlaceholderAreaEmptyWhenInvalidDimensions(t *testing.T) {
291397
assert.Equal(t, "", renderAnchoredPlaceholderArea(1, 0, 0, 0, 3))
292398
assert.Equal(t, "", renderAnchoredPlaceholderArea(1, 0, 0, 3, 0))
293399
}
400+
401+
func TestPlaceImageWithSizeDoesNotWrapPlaceholderTextInTmuxPassthrough(t *testing.T) {
402+
ForceTmux(true)
403+
defer ForceTmux(false)
404+
405+
renderer := &KittyRenderer{}
406+
output, err := captureStdout(t, func() error {
407+
return renderer.PlaceImageWithSize("42", 1, 2, 0, 2, 1)
408+
})
409+
assert.NoError(t, err)
410+
assert.Contains(t, output, "\x1b[3;2H", "place command should move cursor to requested absolute coordinates")
411+
assert.NotContains(t, output, "\x1bPtmux;\x1b", "placeholder text should remain in-band and not be tmux passthrough wrapped")
412+
}
413+
414+
func TestCreatePlaceholderIncludesExtraDiacriticFor24BitIDs(t *testing.T) {
415+
placeholder := CreatePlaceholder(1, 2, 0)
416+
assert.Equal(t, 4, utf8.RuneCountInString(placeholder), "24-bit IDs should still include the extra-id diacritic")
417+
}
418+
419+
func TestCreatePlaceholderIncludesExtraDiacriticFor32BitIDs(t *testing.T) {
420+
placeholder := CreatePlaceholder(1, 2, 1)
421+
assert.Equal(t, 4, utf8.RuneCountInString(placeholder), "32-bit IDs should include the high-byte diacritic")
422+
}

0 commit comments

Comments
 (0)