Skip to content

Commit 10d43d3

Browse files
committed
feat: enhance Kitty protocol support with Unicode image handling and resizing improvements
1 parent 50045d4 commit 10d43d3

File tree

7 files changed

+244
-75
lines changed

7 files changed

+244
-75
lines changed

detect.go

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -345,9 +345,7 @@ func ParallelProtocolDetection() (kitty, sixel, iterm2 bool) {
345345

346346
// Kitty detection
347347
if !results.kitty {
348-
wg.Add(1)
349-
go func() {
350-
defer wg.Done()
348+
wg.Go(func() {
351349
query := "\x1b_Gi=42,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\"
352350
if resp, err := querier.Query(query, QueryTimeout); err == nil {
353351
mu.Lock()
@@ -358,13 +356,11 @@ func ParallelProtocolDetection() (kitty, sixel, iterm2 bool) {
358356
} else {
359357
logDetection("kitty", false, err, false)
360358
}
361-
}()
359+
})
362360
}
363361

364362
// Sixel detection
365-
wg.Add(1)
366-
go func() {
367-
defer wg.Done()
363+
wg.Go(func() {
368364
query := "\x1b[?1;1;0S" // XTSMGRAPHICS query
369365
if resp, err := querier.Query(query, QueryTimeout); err == nil {
370366
mu.Lock()
@@ -375,13 +371,11 @@ func ParallelProtocolDetection() (kitty, sixel, iterm2 bool) {
375371
} else {
376372
logDetection("sixel", false, err, false)
377373
}
378-
}()
374+
})
379375

380376
// iTerm2 detection
381377
if !results.iterm2 {
382-
wg.Add(1)
383-
go func() {
384-
defer wg.Done()
378+
wg.Go(func() {
385379
query := "\x1b]1337;ReportCellSize\x07"
386380
if resp, err := querier.Query(query, QueryTimeout); err == nil {
387381
mu.Lock()
@@ -392,7 +386,7 @@ func ParallelProtocolDetection() (kitty, sixel, iterm2 bool) {
392386
} else {
393387
logDetection("iterm2", false, err, false)
394388
}
395-
}()
389+
})
396390
}
397391

398392
wg.Wait()

encoding.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,13 @@ func ParallelBase64Encode(data []byte, chunkSize int) []string {
7272

7373
// Start workers
7474
for range numWorkers {
75-
wg.Add(1)
76-
go func() {
77-
defer wg.Done()
75+
wg.Go(func() {
7876
for chunkIdx := range jobs {
7977
start := chunkIdx * chunkSize
8078
end := min(start+chunkSize, len(data))
8179
results[chunkIdx] = Base64Encode(data[start:end])
8280
}
83-
}()
81+
})
8482
}
8583

8684
// Send jobs

go.mod

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,32 @@
11
module github.com/blacktop/go-termimg
22

3-
go 1.24.2
3+
go 1.26
44

55
require (
6-
github.com/charmbracelet/x/cellbuf v0.0.14
7-
github.com/charmbracelet/x/mosaic v0.0.0-20260112120226-d84da2a4022f
6+
github.com/charmbracelet/x/cellbuf v0.0.15
7+
github.com/charmbracelet/x/mosaic v0.0.0-20260216111343-536eb63c1f4c
88
github.com/makeworld-the-better-one/dither/v2 v2.4.0
99
github.com/mattn/go-sixel v0.0.8
1010
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
1111
github.com/stretchr/testify v1.11.1
12-
golang.org/x/term v0.39.0
12+
golang.org/x/term v0.40.0
1313
)
1414

1515
require (
16-
github.com/charmbracelet/colorprofile v0.3.3 // indirect
17-
github.com/charmbracelet/x/ansi v0.11.3 // indirect
16+
github.com/charmbracelet/colorprofile v0.4.1 // indirect
17+
github.com/charmbracelet/x/ansi v0.11.6 // indirect
1818
github.com/charmbracelet/x/term v0.2.2 // indirect
19-
github.com/clipperhouse/displaywidth v0.6.1 // indirect
19+
github.com/clipperhouse/displaywidth v0.9.0 // indirect
2020
github.com/clipperhouse/stringish v0.1.1 // indirect
21-
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
21+
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
2222
github.com/davecgh/go-spew v1.1.1 // indirect
2323
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
2424
github.com/mattn/go-runewidth v0.0.19 // indirect
2525
github.com/pmezard/go-difflib v1.0.0 // indirect
2626
github.com/rivo/uniseg v0.4.7 // indirect
2727
github.com/soniakeys/quant v1.0.0 // indirect
2828
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
29-
golang.org/x/image v0.34.0 // indirect
30-
golang.org/x/sys v0.40.0 // indirect
29+
golang.org/x/image v0.36.0 // indirect
30+
golang.org/x/sys v0.41.0 // indirect
3131
gopkg.in/yaml.v3 v3.0.1 // indirect
3232
)

go.sum

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
1-
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
2-
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
3-
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
4-
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
5-
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
6-
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
7-
github.com/charmbracelet/x/mosaic v0.0.0-20260112120226-d84da2a4022f h1:asNF5m5X9qA/ZRFiijOKMxN0MFWJ5KVWbfOpEx4Vqks=
8-
github.com/charmbracelet/x/mosaic v0.0.0-20260112120226-d84da2a4022f/go.mod h1:r+fiJS0jb0Z5XKO+1mgKbwbPWzTy8e2dMjBMqa+XqsY=
1+
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
2+
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
3+
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
4+
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
5+
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
6+
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
7+
github.com/charmbracelet/x/mosaic v0.0.0-20260216111343-536eb63c1f4c h1:nKHcPj3oj1zD8zq9HFhOZnonoMMdPOuM21Yo8dq5XfM=
8+
github.com/charmbracelet/x/mosaic v0.0.0-20260216111343-536eb63c1f4c/go.mod h1:KiC0LDz54wnn4PcCtoUujxia6NjFyuD3BgXpbqh0EGU=
99
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
1010
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
11-
github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY=
12-
github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
11+
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
12+
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
1313
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
1414
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
15-
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
16-
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
15+
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
16+
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
1717
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1818
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
1919
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -41,12 +41,12 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
4141
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
4242
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
4343
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
44-
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
45-
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
46-
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
47-
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
48-
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
49-
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
44+
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
45+
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
46+
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
47+
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
48+
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
49+
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
5050
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
5151
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
5252
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

kitty.go

Lines changed: 48 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,25 @@ func (r *KittyRenderer) GetLastImageID() uint32 {
106106
return r.lastID
107107
}
108108

109+
func reserveUnicodeImageNum(opts *KittyOptions) (uint32, error) {
110+
const maxUnicodeImageNum = 0xFFFFFF
111+
112+
if opts != nil && opts.ImageNum > 0 {
113+
if opts.ImageNum > maxUnicodeImageNum {
114+
return 0, fmt.Errorf("unicode placeholders require image number <= 0xFFFFFF")
115+
}
116+
return uint32(opts.ImageNum), nil
117+
}
118+
119+
imageNum := atomic.AddUint32(&globalKittyImageNum, 1)
120+
if imageNum > maxUnicodeImageNum {
121+
atomic.StoreUint32(&globalKittyImageNum, 1)
122+
imageNum = 1
123+
}
124+
125+
return imageNum, nil
126+
}
127+
109128
// Render generates the escape sequence for displaying the image
110129
func (r *KittyRenderer) Render(img image.Image, opts RenderOptions) (string, error) {
111130
// Process the image (resize, dither, etc.)
@@ -121,7 +140,6 @@ func (r *KittyRenderer) Render(img image.Image, opts RenderOptions) (string, err
121140

122141
// Generate unique image ID using atomic counter to ensure uniqueness across all instances
123142
imageID := atomic.AddUint32(&globalKittyImageID, 1)
124-
r.lastID = imageID
125143

126144
// Get image bounds
127145
bounds := processed.Bounds()
@@ -147,22 +165,21 @@ func (r *KittyRenderer) Render(img image.Image, opts RenderOptions) (string, err
147165
rows = opts.Height
148166
}
149167

168+
kittyOpts := opts.KittyOpts
169+
150170
// Check if using Unicode placeholders - this requires a different two-step approach
151-
useUnicode := opts.KittyOpts != nil && opts.KittyOpts.UseUnicode
171+
useUnicode := kittyOpts != nil && kittyOpts.UseUnicode
152172

153173
if useUnicode {
154174
// Two-step process for Unicode placeholders (matches old termimg behavior):
155175
// 1. Transmit image data (no display) using PNG format
156176
// 2. Create placement with U=1 and explicit cols/rows
157177
// 3. Generate placeholder characters
158178

159-
// Use a small image ID that fits in 24 bits for RGB foreground color encoding
160-
imageNum := atomic.AddUint32(&globalKittyImageNum, 1)
161-
if imageNum > 0xFFFFFF {
162-
atomic.StoreUint32(&globalKittyImageNum, 1)
163-
imageNum = 1
179+
imageNum, err := reserveUnicodeImageNum(kittyOpts)
180+
if err != nil {
181+
return "", err
164182
}
165-
r.lastNum = imageNum
166183

167184
// Encode as PNG for transmission
168185
var buf bytes.Buffer
@@ -212,13 +229,16 @@ func (r *KittyRenderer) Render(img image.Image, opts RenderOptions) (string, err
212229
placeholders := r.generateUnicodePlaceholders(imageNum, cols, rows)
213230
output.WriteString(placeholders)
214231

232+
r.lastNum = imageNum
233+
r.lastID = imageNum
234+
215235
return output.String(), nil
216236
}
217237

218238
// Standard (non-Unicode) rendering path
219239
var data []byte
220240
var format string
221-
if opts.KittyOpts != nil && opts.KittyOpts.PNG {
241+
if kittyOpts != nil && kittyOpts.PNG {
222242
format = "f=100"
223243
var b bytes.Buffer
224244
if err := png.Encode(&b, processed); err != nil {
@@ -234,7 +254,7 @@ func (r *KittyRenderer) Render(img image.Image, opts RenderOptions) (string, err
234254

235255
// Check for compression
236256
var compressed bool
237-
if opts.KittyOpts != nil && opts.KittyOpts.Compression {
257+
if kittyOpts != nil && kittyOpts.Compression {
238258
compressed = true
239259
var b bytes.Buffer
240260
w, err := zlib.NewWriterLevel(&b, zlib.BestSpeed)
@@ -268,13 +288,13 @@ func (r *KittyRenderer) Render(img image.Image, opts RenderOptions) (string, err
268288
// Build control data
269289
var control string
270290
var transferType string
271-
if opts.KittyOpts != nil && opts.KittyOpts.TempFile {
291+
if kittyOpts != nil && kittyOpts.TempFile {
272292
transferType = ",t=t"
273293
}
274294

275295
imageIDStr := fmt.Sprintf("i=%d", imageID)
276-
if opts.KittyOpts != nil && opts.KittyOpts.ImageNum > 0 {
277-
imageIDStr = fmt.Sprintf("I=%d", opts.KittyOpts.ImageNum)
296+
if kittyOpts != nil && kittyOpts.ImageNum > 0 {
297+
imageIDStr = fmt.Sprintf("I=%d", kittyOpts.ImageNum)
278298
}
279299

280300
if opts.Virtual {
@@ -327,33 +347,36 @@ func (r *KittyRenderer) Render(img image.Image, opts RenderOptions) (string, err
327347
}
328348

329349
// Handle non-unicode Kitty options
330-
if opts.KittyOpts != nil {
331-
if opts.Virtual && !opts.KittyOpts.UseUnicode {
350+
if kittyOpts != nil {
351+
if opts.Virtual && !kittyOpts.UseUnicode {
332352
// Non-unicode virtual placement - just generate simple placeholders
333353
placeholders := r.generateUnicodePlaceholders(imageID, cols, rows)
334354
output.WriteString(placeholders)
335355
}
336356

337357
// Handle animation after image transfer
338-
if opts.KittyOpts.Animation != nil && len(opts.KittyOpts.Animation.ImageIDs) > 0 {
358+
if kittyOpts.Animation != nil && len(kittyOpts.Animation.ImageIDs) > 0 {
339359
// TODO: Animation is handled separately after all images are transferred
340360
// This is just to validate the option structure
341361
}
342362

343363
// Handle positioning after image transfer
344-
if opts.KittyOpts.Position != nil {
364+
if kittyOpts.Position != nil {
345365
// TODO: Positioning is handled separately via PlaceImage method
346366
// This is just to validate the option structure
347367
}
348368
}
349369

370+
r.lastID = imageID
350371
return output.String(), nil
351372
}
352373

353374
// Print outputs the image directly to stdout
354375
func (r *KittyRenderer) Print(img image.Image, opts RenderOptions) error {
376+
kittyOpts := opts.KittyOpts
377+
355378
// Check if we should use file transfer optimization
356-
if opts.KittyOpts != nil && opts.KittyOpts.FileTransfer {
379+
if kittyOpts != nil && kittyOpts.FileTransfer {
357380
// TODO: File transfer would require knowing the source file path
358381
// This is best handled at a higher level in the Image API
359382
// For now, fall back to regular rendering
@@ -366,18 +389,18 @@ func (r *KittyRenderer) Print(img image.Image, opts RenderOptions) error {
366389
_, err = io.WriteString(os.Stdout, output)
367390

368391
// Handle post-render operations
369-
if err == nil && opts.KittyOpts != nil {
392+
if err == nil && kittyOpts != nil {
370393
// Handle positioning if specified
371-
if opts.KittyOpts.Position != nil {
394+
if kittyOpts.Position != nil {
372395
imageID := fmt.Sprintf("%d", r.lastID)
373-
err = r.PlaceImage(imageID, opts.KittyOpts.Position.X,
374-
opts.KittyOpts.Position.Y, opts.KittyOpts.Position.ZIndex)
396+
err = r.PlaceImage(imageID, kittyOpts.Position.X,
397+
kittyOpts.Position.Y, kittyOpts.Position.ZIndex)
375398
}
376399

377400
// Handle animation if specified
378-
if opts.KittyOpts.Animation != nil && len(opts.KittyOpts.Animation.ImageIDs) > 0 {
379-
err = r.AnimateImages(opts.KittyOpts.Animation.ImageIDs,
380-
opts.KittyOpts.Animation.DelayMs, opts.KittyOpts.Animation.Loops)
401+
if kittyOpts.Animation != nil && len(kittyOpts.Animation.ImageIDs) > 0 {
402+
err = r.AnimateImages(kittyOpts.Animation.ImageIDs,
403+
kittyOpts.Animation.DelayMs, kittyOpts.Animation.Loops)
381404
}
382405
}
383406

0 commit comments

Comments
 (0)