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
1 change: 1 addition & 0 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ func (app *app) loop() {
if curr := app.nav.currFile(); curr != nil {
if r.path == curr.path {
app.ui.sxScreen.forceClear = true
app.ui.ktScreen.forceClear = true
if gOpts.preload && r.volatile {
app.ui.loadFile(app, true)
}
Expand Down
1 change: 1 addition & 0 deletions eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ func (e *setExpr) eval(app *app, _ []string) {
if err == nil {
gOpts.preview = preview
app.ui.sxScreen.forceClear = true
app.ui.ktScreen.forceClear = true
app.ui.loadFile(app, true)
}
case "relativenumber", "norelativenumber", "relativenumber!":
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/djherbis/times v1.6.0
github.com/fsnotify/fsnotify v1.10.1
github.com/gdamore/tcell/v3 v3.4.0
golang.org/x/image v0.42.0
golang.org/x/sys v0.46.0
golang.org/x/term v0.44.0
)
Expand All @@ -15,5 +16,5 @@ require (
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/text v0.38.0 // indirect
)
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
Expand Down Expand Up @@ -43,8 +45,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
Expand Down
298 changes: 298 additions & 0 deletions kitty.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
package main

import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/png"
"os"
"path/filepath"
"strconv"
"strings"

_ "image/gif"
_ "image/jpeg"

"github.com/gdamore/tcell/v3"

_ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
)

// imageExtensions lists file extensions that are treated as images for
// built-in Kitty protocol previews.
var imageExtensions = map[string]bool{
".png": true, ".jpg": true, ".jpeg": true, ".gif": true,
".bmp": true, ".tiff": true, ".tif": true, ".webp": true,
}

func isImageFile(path string) bool {
return imageExtensions[strings.ToLower(filepath.Ext(path))]
}

type kittyScreen struct {
lastFile string
lastWin win
forceClear bool
}

func (ks *kittyScreen) clearKitty(win *win, screen tcell.Screen, filePath string) {
if ks.lastFile != "" && (filePath != ks.lastFile || *win != ks.lastWin || ks.forceClear) {
// Delete all kitty images so they don't linger on screen
// when the preview changes. Also unlock the old region
// so tcell can redraw the full pane for the new preview.
fmt.Fprint(os.Stderr, "\033_Ga=d,d=a,q=2;\033\\")
screen.LockRegion(ks.lastWin.x, ks.lastWin.y, ks.lastWin.w, ks.lastWin.h, false)
}
}

func (ks *kittyScreen) printKitty(win *win, screen tcell.Screen, reg *reg) {
if reg.path == ks.lastFile && *win == ks.lastWin && !ks.forceClear {
return
}

// Unlock any region locked by a previous kitty render so tcell
// can redraw the full pane before we place the new image.
if ks.lastFile != "" {
screen.LockRegion(ks.lastWin.x, ks.lastWin.y, ks.lastWin.w, ks.lastWin.h, false)
}

cw, ch, err := cellSize(screen)
if err != nil {
cw, ch = 10, 20
}

y := win.y
var b strings.Builder

// Collect consecutive Kitty frames so that chunked transmission
// (m=1 / m=0) is written as a single logical image at one position.
var kittyBuf []string
flushKitty := func() {
if len(kittyBuf) == 0 {
return
}
// Use the first frame that has dimension info for sizing.
sw, sh := 0, 0
for _, k := range kittyBuf {
sw, sh = kittyCellSize(k, cw, ch)
if sw > 0 && sh > 0 {
break
}
}
if sw <= 0 {
sw = win.w
}
if sh <= 0 {
sh = 1
}

for i, k := range kittyBuf {
if i == 0 {
fmt.Fprintf(&b, "\033[%d;%dH", y+1, win.x+1)
}
b.WriteString(k)
}
screen.LockRegion(win.x, y, sw, sh, true)
y += sh
kittyBuf = nil
}

for _, line := range reg.lines {
if !strings.HasPrefix(line, "\033_G") {
flushKitty()
if y >= win.y+win.h {
break
}
line = sanitizePreview(line)
fmt.Fprintf(&b, "\033[%d;%dH", y+1, win.x+1)
b.WriteString(line)
y++
continue
}
kittyBuf = append(kittyBuf, line)
}
flushKitty()

// Clear the preview pane in tcell's buffer so old text from
// a previous file doesn't linger around the image.
st := tcell.StyleDefault
for row := range win.h {
for col := range win.w {
screen.SetContent(win.x+col, win.y+row, ' ', nil, st)
}
}

// Also write clear-to-end-of-line for each row of the preview
// pane directly to the terminal, so old text is erased even if
// tcell's Show() doesn't fully clear it.
var clearBuf bytes.Buffer
for row := range win.h {
fmt.Fprintf(&clearBuf, "\033[%d;%dH\033[0K", win.y+row+1, win.x+1)
}
clearStr := clearBuf.String()

// Render: clear rows + kitty image together in one sync update.
fmt.Fprint(os.Stderr, "\033[?2026h")
fmt.Fprint(os.Stderr, "\0337")
screen.Show()
fmt.Fprint(os.Stderr, clearStr)
fmt.Fprint(os.Stderr, b.String())
fmt.Fprint(os.Stderr, "\0338")
fmt.Fprint(os.Stderr, "\033[?2026l")

ks.lastFile = reg.path
ks.lastWin = *win
ks.forceClear = false
}

// kittyCellSize parses a Kitty graphics APC command to extract the display
// size in terminal cells. The command has the form:
//
// \033_G<key=value,...>;<payload>\033\\
//
// S=/c=cols and V=/r=rows give the cell-based dimensions directly. If those
// are absent, s=w and v=h (pixel dimensions) are converted using cw and ch.
func kittyCellSize(cmd string, cw, ch int) (int, int) {
if cw <= 0 {
cw = 10
}
if ch <= 0 {
ch = 20
}

// The control section is between "\033_G" and the first ';'.
// "\033_G" is 3 bytes (\033, _, G).
start := strings.IndexByte(cmd, ';')
if start < 0 {
return 1, 1
}
control := cmd[3:start]

var sc, sr int // S= / V= (cells)
var pw, ph int // s= / v= (pixels)

for kv := range strings.SplitSeq(control, ",") {
k, v, ok := strings.Cut(kv, "=")
if !ok {
continue
}
switch k {
case "S", "c":
sc, _ = strconv.Atoi(v)
case "V", "r":
sr, _ = strconv.Atoi(v)
case "s":
pw, _ = strconv.Atoi(v)
case "v":
ph, _ = strconv.Atoi(v)
}
}

if sc > 0 && sr > 0 {
return sc, sr
}
if pw > 0 && ph > 0 {
return (pw + cw - 1) / cw, (ph + ch - 1) / ch
}

return 0, 0
}

// generateKittyPreview builds a Kitty protocol preview for the image file at
// path. It decodes the image, scales it to fit the preview window, encodes it
// as PNG, and returns the Kitty APC command as a single line.
func generateKittyPreview(path string, win *win) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()

img, _, err := image.Decode(f)
if err != nil {
return nil, fmt.Errorf("decoding image: %w", err)
}

bounds := img.Bounds()
iw, ih := bounds.Dx(), bounds.Dy()
if iw <= 0 || ih <= 0 {
return nil, fmt.Errorf("invalid image dimensions: %dx%d", iw, ih)
}

// Estimate cell size (the same fallback used by sixel). The preview
// goroutine does not have access to the tcell Screen, so we use the
// historically safe defaults of 10×20 pixels per cell.
const estCellW = 10
const estCellH = 20

maxW := win.w
maxH := win.h

// Compute the number of cells the image would occupy at its natural size.
natCW := (iw + estCellW - 1) / estCellW
natCH := (ih + estCellH - 1) / estCellH

// Scale down to fit the preview window.
scale := 1.0
if natCW > maxW {
scale = float64(maxW) / float64(natCW)
}
if float64(natCH)*scale > float64(maxH) {
scale = float64(maxH) / float64(natCH)
}

targetW := max(int(float64(iw)*scale), 1)
targetH := max(int(float64(ih)*scale), 1)

// Resize using nearest-neighbour (fast, no extra dependencies).
var resized image.Image
if targetW == iw && targetH == ih {
resized = img
} else {
resized = resizeNearest(img, targetW, targetH)
}

var buf bytes.Buffer
if err := png.Encode(&buf, resized); err != nil {
return nil, fmt.Errorf("encoding PNG: %w", err)
}

b64 := base64.StdEncoding.EncodeToString(buf.Bytes())

displayCW := (targetW + estCellW - 1) / estCellW
displayCH := (targetH + estCellH - 1) / estCellH

if displayCW > maxW {
displayCW = maxW
}
if displayCH > maxH {
displayCH = maxH
}

cmd := fmt.Sprintf(
"\033_Ga=T,f=100,s=%d,v=%d,S=%d,V=%d,C=1,q=2;%s\033\\",
targetW, targetH, displayCW, displayCH, b64,
)

return []string{cmd}, nil
}

// resizeNearest returns a new RGBA image that is a nearest-neighbour scaled
// copy of src.
func resizeNearest(src image.Image, dstW, dstH int) *image.RGBA {
rgba := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
sr := src.Bounds()
sw, sh := sr.Dx(), sr.Dy()

for y := range dstH {
srcY := y * sh / dstH
for x := range dstW {
srcX := x * sw / dstW
rgba.Set(x, y, src.At(sr.Min.X+srcX, sr.Min.Y+srcY))
}
}
return rgba
}
Loading