From 45be45e4d16d9e9b6316fb6972d50e393107c30b Mon Sep 17 00:00:00 2001 From: RenovZ Date: Sun, 14 Jun 2026 02:44:28 +0800 Subject: [PATCH 1/6] tmp commit --- app.go | 1 + eval.go | 5 + kitty.go | 270 +++++++++++++++++++++++++++++++++++++++++++++++++++ misc.go | 86 +++++++++++++++- misc_test.go | 158 +++++++++++++++++++++++------- nav.go | 19 +++- ui.go | 18 +++- 7 files changed, 514 insertions(+), 43 deletions(-) create mode 100644 kitty.go diff --git a/app.go b/app.go index 1ee75766e..10fa4e11a 100644 --- a/app.go +++ b/app.go @@ -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) } diff --git a/eval.go b/eval.go index 07b157098..ee66a6cc2 100644 --- a/eval.go +++ b/eval.go @@ -117,6 +117,10 @@ func (e *setExpr) eval(app *app, _ []string) { app.nav.position() app.ui.loadFile(app, true) } + case "kitty", "nokitty", "kitty!": + // Kitty graphics protocol is always active; this option exists + // only so that "set kitty true" in the config does not error. + err = nil case "incfilter", "noincfilter", "incfilter!": err = applyBoolOpt(&gOpts.incfilter, e) case "incsearch", "noincsearch", "incsearch!": @@ -145,6 +149,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!": diff --git a/kitty.go b/kitty.go new file mode 100644 index 000000000..e847b7276 --- /dev/null +++ b/kitty.go @@ -0,0 +1,270 @@ +package main + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + "image/png" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/gdamore/tcell/v3" +) + +// 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, +} + +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) { + fmt.Fprint(os.Stderr, "\033_Ga=d,d=a,q=2;\033\\") + } +} + +func (ks *kittyScreen) printKitty(win *win, screen tcell.Screen, reg *reg) { + if reg.path == ks.lastFile && *win == ks.lastWin && !ks.forceClear { + return + } + + 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. + var kittyBuf []string + var imageY, imageH int + + flushKitty := func() { + if len(kittyBuf) == 0 { + return + } + 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) + } + imageY = y + imageH = sh + 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 to erase old text. + st := tcell.StyleDefault + for row := range win.h { + for col := range win.w { + screen.SetContent(win.x+col, win.y+row, ' ', nil, st) + } + } + + // Lock the image rows so Show() won't overwrite them. + if imageH > 0 { + screen.LockRegion(win.x, imageY, win.w, imageH, true) + } + + // Render pane clear and kitty image atomically via sync update. + fmt.Fprint(os.Stderr, "\033[?2026h") + fmt.Fprint(os.Stderr, "\0337") + screen.Show() + 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;\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 + } + + start := strings.IndexByte(cmd, ';') + if start < 0 { + return 1, 1 + } + control := cmd[3:start] + + var sc, sr int + var pw, ph int + + 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) + } + + const estCellW = 10 + const estCellH = 20 + + maxW := win.w + maxH := win.h + + natCW := (iw + estCellW - 1) / estCellW + natCH := (ih + estCellH - 1) / estCellH + + 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) + + 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 +} diff --git a/misc.go b/misc.go index 2fe153104..50b5ddcc2 100644 --- a/misc.go +++ b/misc.go @@ -465,9 +465,9 @@ func deletePathRecursive[T any](m map[string]T, path string) { // Lines are split on `\n` characters, and `\r` characters are discarded. // Individual lines are truncated to avoid unbounded memory usage on files // with very long or no newlines. -// Sixel images are also detected and stored as separate lines. +// Sixel and Kitty images are also detected and stored as separate lines. // C0 control bytes outside of \a \b \t \n \v \f \r \033 and DEL indicate binary content. -func readLines(reader io.ByteReader, maxLines int) (lines []string, binary bool, sixel bool) { +func readLines(reader io.ByteReader, maxLines int) (lines []string, binary bool, sixel bool, kitty bool) { const maxLineBytes = 1 << 16 // 64 KiB per line type state int @@ -476,8 +476,12 @@ func readLines(reader io.ByteReader, maxLines int) (lines []string, binary bool, stateEsc stateSixel stateSixelEsc + stateAPC + stateKitty + stateKittyEsc ) currState := stateNormal + seenSemi := false // track whether we have passed the ';' in a Kitty APC frame var buf bytes.Buffer maxLinesReached := false @@ -502,7 +506,7 @@ func readLines(reader io.ByteReader, maxLines int) (lines []string, binary bool, case stateNormal: // C0 control bytes outside of \a \b \t \n \v \f \r \033 and DEL indicate binary content. if b < 0x07 || (b > 0x0D && b < 0x1B) || (b > 0x1B && b < 0x20) || b == 0x7F { - return nil, true, false + return nil, true, false, false } switch b { case '\033': @@ -522,6 +526,10 @@ func readLines(reader io.ByteReader, maxLines int) (lines []string, binary bool, flush(false) buf.WriteString("\033P") currState = stateSixel + } else if b == '_' { + flush(false) + buf.WriteString("\033_") + currState = stateAPC } else { buf.WriteByte('\033') buf.WriteByte(b) @@ -553,6 +561,78 @@ func readLines(reader io.ByteReader, maxLines int) (lines []string, binary bool, buf.Reset() currState = stateNormal } + case stateAPC: + // Kitty graphics uses APC (ESC _) followed by 'G'. + // Accept only printable bytes until we confirm it's + // a Kitty command or the sequence is terminated. + if b == 'G' { + buf.WriteByte(b) + kitty = true + currState = stateKitty + } else if b >= 0x20 && b <= 0x7E { + buf.WriteByte(b) + } else if b == '\033' { + // Early ST: not a Kitty command (no 'G'), flush as text. + buf.WriteByte(b) + currState = stateKittyEsc + } else { + // Unexpected byte: abort and treat as text. + currState = stateNormal + } + case stateKitty: + // Inside the Kitty APC frame. The payload can be either + // base64-encoded (f=24, f=100, t=t) or raw binary (f=32 + // with t=d). Before the first ';' we are in the control + // section where \r/\n may appear for readability; after + // ';' every byte is payload and must be preserved. + // + // IMPORTANT: we do NOT terminate on \033\ inside the + // payload because raw compressed data may contain these + // bytes. Instead, the frame ends naturally when we + // encounter a non-printable byte that is not part of a + // valid continuation (i.e. the next byte is not \033 or a + // printable character). + switch { + case b == '\033': + buf.WriteByte(b) + if seenSemi { + // Inside payload: \033 is just data. + continue + } + currState = stateKittyEsc + case !seenSemi && (b == '\r' || b == '\n'): + // dropped (readability formatting in control section) + case !seenSemi: + if b == ';' { + seenSemi = true + } + // Accept only printable bytes in control section. + // Non-printable bytes abort the frame for security. + if b >= 0x20 && b <= 0x7E { + buf.WriteByte(b) + } else { + buf.Reset() + seenSemi = false + currState = stateNormal + } + default: + // Payload section: accept all bytes (raw image data). + buf.WriteByte(b) + } + case stateKittyEsc: + if b == '\\' { + buf.WriteByte(b) + flush(true) + seenSemi = false + currState = stateNormal + } else if seenSemi { + buf.WriteByte(b) + currState = stateKitty + } else { + buf.Reset() + seenSemi = false + currState = stateNormal + } } } diff --git a/misc_test.go b/misc_test.go index 5474fbae6..fdeedb256 100644 --- a/misc_test.go +++ b/misc_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "math" "os" "reflect" @@ -511,50 +512,63 @@ func TestReadLines(t *testing.T) { lines []string binary bool sixel bool + kitty bool }{ - {"", 10, nil, false, false}, - {"\r", 10, nil, false, false}, - {"\r\n", 10, []string{""}, false, false}, - {"\r\r\n", 10, []string{""}, false, false}, - {"\n\n", 10, []string{"", ""}, false, false}, - {"foo", 10, []string{"foo"}, false, false}, - {"foo\n", 10, []string{"foo"}, false, false}, - {"foo\r\n", 10, []string{"foo"}, false, false}, - {"foo\nbar", 10, []string{"foo", "bar"}, false, false}, - {"foo\nbar\n", 10, []string{"foo", "bar"}, false, false}, - {"foo\r\nbar", 10, []string{"foo", "bar"}, false, false}, - {"foo\r\nbar\r\n", 10, []string{"foo", "bar"}, false, false}, - {"\033[31mfoo\033[0m", 10, []string{"\033[31mfoo\033[0m"}, false, false}, - {"\000", 10, nil, true, false}, - {"foo\r\n\000\r\nbar\r\n", 10, nil, true, false}, - {"\033P\033\\", 10, []string{"\033P\033\\"}, false, true}, - {"\033Pq\"1;1;1;1#0@\033\\", 10, []string{"\033Pq\"1;1;1;1#0@\033\\"}, false, true}, - {"\033P\000\033\\", 10, []string{"\033\\"}, false, false}, - {"\033P\n\033\\", 10, []string{"\033P\033\\"}, false, true}, - {"\033P\r\n\033\\", 10, []string{"\033P\033\\"}, false, true}, - {"\033P\033\\\033P\033\\", 10, []string{"\033P\033\\", "\033P\033\\"}, false, true}, - {"foo\033P\033\\bar", 10, []string{"foo", "\033P\033\\", "bar"}, false, true}, - {"foo\033P\033\\bar\033P\033\\baz", 10, []string{"foo", "\033P\033\\", "bar", "\033P\033\\", "baz"}, false, true}, - {"foo\nbar\nbaz", 2, []string{"foo", "bar"}, false, false}, - {"foo\nbar\nbaz\n", 2, []string{"foo", "bar"}, false, false}, - {"foo\nbar\033P\033\\", 2, []string{"foo", "bar"}, false, false}, - {"foo\nbar\nbaz", 3, []string{"foo", "bar", "baz"}, false, false}, - {"foo\nbar\nbaz\n", 3, []string{"foo", "bar", "baz"}, false, false}, - {"foo\nbar\033P\033\\", 3, []string{"foo", "bar", "\033P\033\\"}, false, true}, + {"", 10, nil, false, false, false}, + {"\r", 10, nil, false, false, false}, + {"\r\n", 10, []string{""}, false, false, false}, + {"\r\r\n", 10, []string{""}, false, false, false}, + {"\n\n", 10, []string{"", ""}, false, false, false}, + {"foo", 10, []string{"foo"}, false, false, false}, + {"foo\n", 10, []string{"foo"}, false, false, false}, + {"foo\r\n", 10, []string{"foo"}, false, false, false}, + {"foo\nbar", 10, []string{"foo", "bar"}, false, false, false}, + {"foo\nbar\n", 10, []string{"foo", "bar"}, false, false, false}, + {"foo\r\nbar", 10, []string{"foo", "bar"}, false, false, false}, + {"foo\r\nbar\r\n", 10, []string{"foo", "bar"}, false, false, false}, + {"\033[31mfoo\033[0m", 10, []string{"\033[31mfoo\033[0m"}, false, false, false}, + {"\000", 10, nil, true, false, false}, + {"foo\r\n\000\r\nbar\r\n", 10, nil, true, false, false}, + {"\033P\033\\", 10, []string{"\033P\033\\"}, false, true, false}, + {"\033Pq\"1;1;1;1#0@\033\\", 10, []string{"\033Pq\"1;1;1;1#0@\033\\"}, false, true, false}, + {"\033P\000\033\\", 10, []string{"\033\\"}, false, false, false}, + {"\033P\n\033\\", 10, []string{"\033P\033\\"}, false, true, false}, + {"\033P\r\n\033\\", 10, []string{"\033P\033\\"}, false, true, false}, + {"\033P\033\\\033P\033\\", 10, []string{"\033P\033\\", "\033P\033\\"}, false, true, false}, + {"foo\033P\033\\bar", 10, []string{"foo", "\033P\033\\", "bar"}, false, true, false}, + {"foo\033P\033\\bar\033P\033\\baz", 10, []string{"foo", "\033P\033\\", "bar", "\033P\033\\", "baz"}, false, true, false}, + {"foo\nbar\nbaz", 2, []string{"foo", "bar"}, false, false, false}, + {"foo\nbar\nbaz\n", 2, []string{"foo", "bar"}, false, false, false}, + {"foo\nbar\033P\033\\", 2, []string{"foo", "bar"}, false, false, false}, + {"foo\nbar\nbaz", 3, []string{"foo", "bar", "baz"}, false, false, false}, + {"foo\nbar\nbaz\n", 3, []string{"foo", "bar", "baz"}, false, false, false}, + {"foo\nbar\033P\033\\", 3, []string{"foo", "bar", "\033P\033\\"}, false, true, false}, // Inside the DCS body, ESC must be followed by '\\' (ST) for the // frame to be accepted. Any other byte aborts, so an attacker // cannot embed CSI/OSC/nested-DCS through the sixel path. - {"\033P\033]52;c;x\033\\", 10, []string{"52;c;x\033\\"}, false, false}, + {"\033P\033]52;c;x\033\\", 10, []string{"52;c;x\033\\"}, false, false, false}, + // Kitty graphics protocol: \033_G...\033\\ + {"\033_Ga=T,f=100,s=1,v=1;AA==\033\\", 10, []string{"\033_Ga=T,f=100,s=1,v=1;AA==\033\\"}, false, false, true}, + {"foo\033_Ga=T;data\033\\bar", 10, []string{"foo", "\033_Ga=T;data\033\\bar"}, false, false, true}, + {"\033_G\n;\033\\", 10, []string{"\033_G;\033\\"}, false, false, true}, + {"\033_G\r\n;\033\\", 10, []string{"\033_G;\033\\"}, false, false, true}, + // Kitty with raw binary payload (simulating chafa -f kitty f=32,t=d). + // The payload after ';' may contain any byte including \000, \033, etc. + {"\033_Ga=T,f=32,s=1,v=1;\x00\x01\x02\033\\", 10, []string{"\033_Ga=T,f=32,s=1,v=1;\x00\x01\x02\033\\"}, false, false, true}, + // \033 byte in raw payload followed by non-\\ must NOT abort the frame. + {"\033_Ga=T,f=32,s=1,v=1;\x00\033@more\033\\", 10, []string{"\033_Ga=T,f=32,s=1,v=1;\x00\033@more\033\\"}, false, false, true}, + // Verify non-kitty APC (no G after ESC _) is not flagged as kitty. + {"\033_Xhello\033\\", 10, []string{"\033_Xhello\033\\"}, false, false, false}, } for _, test := range tests { - lines, binary, sixel := readLines(strings.NewReader(test.s), test.maxLines) - if !reflect.DeepEqual(lines, test.lines) || binary != test.binary || sixel != test.sixel { + lines, binary, sixel, kitty := readLines(strings.NewReader(test.s), test.maxLines) + if !reflect.DeepEqual(lines, test.lines) || binary != test.binary || sixel != test.sixel || kitty != test.kitty { t.Errorf( - "at input (%q, %v) expected (%#v, %v, %v) but got (%#v, %v, %v)", + "at input (%q, %v) expected (%#v, %v, %v, %v) but got (%#v, %v, %v, %v)", test.s, test.maxLines, - test.lines, test.binary, test.sixel, - lines, binary, sixel, + test.lines, test.binary, test.sixel, test.kitty, + lines, binary, sixel, kitty, ) } } @@ -594,3 +608,77 @@ func TestGetWidths(t *testing.T) { } } } + +func TestKittyCellSize(t *testing.T) { + tests := []struct { + cmd string + cw int + ch int + expW int + expH int + }{ + // S=/V= (cell-based sizing) takes priority + {"\033_Ga=T,f=100,s=300,v=200,S=30,V=15;AA==\033\\", 10, 20, 30, 15}, + // c=/r= (chafa's cell-based sizing) + {"\033_Ga=T,f=32,s=270,v=150,c=54,r=30;AA==\033\\", 10, 20, 54, 30}, + // s=/v= (pixel sizing) with conversion + {"\033_Ga=T,f=100,s=100,v=100;AA==\033\\", 10, 20, 10, 5}, + {"\033_Ga=T,f=32,s=80,v=40;\x00\033\\", 8, 16, 10, 3}, + // Neither S/V/c/r nor s/v → fallback + {"\033_Ga=T;data\033\\", 10, 20, 0, 0}, + // No semicolon → fallback + {"\033_Gincomplete", 10, 20, 1, 1}, + } + + for _, test := range tests { + w, h := kittyCellSize(test.cmd, test.cw, test.ch) + if w != test.expW || h != test.expH { + t.Errorf("kittyCellSize(%q, %d, %d) = %d, %d; want %d, %d", + test.cmd, test.cw, test.ch, w, h, test.expW, test.expH) + } + } +} + +func TestReadLinesChafa(t *testing.T) { + // Read actual chafa output and verify Kitty detection. + data, err := os.ReadFile("/tmp/chafa_test.bin") + if err != nil { + t.Skipf("skipping: chafa test data not found (%s)", err) + } + + lines, binary, sixel, kitty := readLines(bytes.NewReader(data), 100) + if binary { + t.Fatal("unexpected binary detection") + } + if sixel { + t.Fatal("unexpected sixel detection") + } + if !kitty { + t.Fatalf("expected kitty=true, got kitty=%v (lines=%d)", kitty, len(lines)) + } + + // The first line should be \033[?25l (cursor hide CSI) + // followed by one or more \033_G... kitty frames. + kittyCount := 0 + for _, l := range lines { + if strings.HasPrefix(l, "\033_G") { + kittyCount++ + } + } + t.Logf("total lines=%d, kitty frames=%d", len(lines), kittyCount) + if kittyCount < 2 { + t.Errorf("expected at least 2 kitty frames (chunked), got %d", kittyCount) + } + + // Verify the first kitty frame has c=54,r=30 + found := false + for _, l := range lines { + if strings.Contains(l, "c=54") && strings.Contains(l, "r=30") { + found = true + break + } + } + if !found { + t.Error("first kitty frame missing c=54,r=30") + } +} diff --git a/nav.go b/nav.go index c140c8370..7e7055f19 100644 --- a/nav.go +++ b/nav.go @@ -690,7 +690,7 @@ func (nav *nav) resize(ui *ui) { } else { // drop entries that no longer match the new pane height for path, r := range nav.regCache { - if r.loading || r.sixel || (previewWin.h > len(r.lines) && len(r.lines) == r.height) { + if r.loading || r.sixel || r.kitty || (previewWin.h > len(r.lines) && len(r.lines) == r.height) { delete(nav.regCache, path) } } @@ -931,6 +931,19 @@ func (nav *nav) preview(path string, win *win, mode string) { return } + // Built-in image preview using the Kitty graphics protocol. + if isImageFile(path) { + kittyLines, err := generateKittyPreview(path, win) + if err != nil { + log.Printf("kitty: %s", err) + reg.lines = []string{"\033[7mpreview error\033[0m"} + } else { + reg.lines = kittyLines + reg.kitty = true + } + return + } + f, err := os.Open(path) if err != nil { log.Printf("opening file: %s", err) @@ -941,7 +954,7 @@ func (nav *nav) preview(path string, win *win, mode string) { reader = bufio.NewReader(f) } - lines, binary, sixel := readLines(reader, win.h) + lines, binary, sixel, kitty := readLines(reader, win.h) if binary { lines = []string{"\033[7mbinary\033[0m"} } @@ -952,6 +965,7 @@ func (nav *nav) preview(path string, win *win, mode string) { // U+FFFD so they are visible but cannot form escape sequences. if len(gOpts.previewer) == 0 && !binary { sixel = false + kitty = false for i, l := range lines { lines[i] = sanitizePreview(l) } @@ -959,6 +973,7 @@ func (nav *nav) preview(path string, win *win, mode string) { reg.lines = lines reg.sixel = sixel + reg.kitty = kitty } func (nav *nav) loadReg(path string, volatile bool) *reg { diff --git a/ui.go b/ui.go index d55a06664..c5fa08c15 100644 --- a/ui.go +++ b/ui.go @@ -135,7 +135,7 @@ func (win *win) printMsg(screen tcell.Screen, s string) { win.print(screen, pad, 0, st, s) } -func (win *win) printReg(screen tcell.Screen, reg *reg, sxs *sixelScreen, previewTimer *time.Timer) { +func (win *win) printReg(screen tcell.Screen, reg *reg, sxs *sixelScreen, kts *kittyScreen, previewTimer *time.Timer) { switch { case reg.loading: if time.Since(reg.loadTime) > previewLoadingDelay { @@ -145,6 +145,8 @@ func (win *win) printReg(screen tcell.Screen, reg *reg, sxs *sixelScreen, previe } case reg.sixel: sxs.printSixel(win, screen, reg) + case reg.kitty: + kts.printKitty(win, screen, reg) default: st := tcell.StyleDefault for i, l := range reg.lines { @@ -159,6 +161,9 @@ func (win *win) printReg(screen tcell.Screen, reg *reg, sxs *sixelScreen, previe if !reg.sixel { sxs.lastFile = "" } + if !reg.kitty { + kts.lastFile = "" + } } var gThisYear = time.Now().Year() @@ -518,6 +523,7 @@ type menuSelect struct { type ui struct { screen tcell.Screen // primary screen used for drawing and event polling sxScreen sixelScreen // sixel preview state + ktScreen kittyScreen // kitty preview state wins []*win // pane windows from `ratios` (last is `preview` when enabled) promptWin *win // prompt line window msgWin *win // status line window @@ -556,6 +562,7 @@ func newUI(screen tcell.Screen) *ui { icons: parseIcons(), currentFile: "", sxScreen: sixelScreen{}, + ktScreen: kittyScreen{}, } ui.ruler, ui.rulerErr = parseRuler(gOpts.rulerfile) @@ -612,6 +619,7 @@ type reg struct { path string lines []string sixel bool + kitty bool height int } @@ -1026,14 +1034,16 @@ func (ui *ui) drawPreview(nav *nav, context *dirContext) { win := ui.wins[len(ui.wins)-1] ui.sxScreen.clearSixel(win, ui.screen, curr.path) + ui.ktScreen.clearKitty(win, ui.screen, curr.path) if gOpts.preview { if curr.isPreviewable() { if reg, ok := nav.regCache[curr.path]; ok { - win.printReg(ui.screen, reg, &ui.sxScreen, nav.previewTimer) + win.printReg(ui.screen, reg, &ui.sxScreen, &ui.ktScreen, nav.previewTimer) } } else if curr.IsDir() { ui.sxScreen.lastFile = "" + ui.ktScreen.lastFile = "" dir := nav.getDir(curr.path) dirStyle := &dirStyle{colors: ui.styles, icons: ui.icons, role: Preview} win.printDir(ui, dir, context, dirStyle, nav.previewTimer) @@ -1105,9 +1115,10 @@ func (ui *ui) drawMenu() { ui.menuWin.h = len(lines) ui.menuWin.y = ui.msgWin.y - ui.menuWin.h - // clear sixel image if it overlaps with the menu + // clear sixel/kitty image if it overlaps with the menu ui.screen.LockRegion(ui.menuWin.x, ui.menuWin.y, ui.menuWin.w, ui.menuWin.h, false) ui.sxScreen.forceClear = true + ui.ktScreen.forceClear = true for i, line := range lines { var st tcell.Style @@ -1587,6 +1598,7 @@ func (ui *ui) readEvents() { func (ui *ui) suspend() error { ui.sxScreen.forceClear = true + ui.ktScreen.forceClear = true return ui.screen.Suspend() } From 6ff72edb7558c2305ef7dd8ee9bbe2af561a33ad Mon Sep 17 00:00:00 2001 From: RenovZ Date: Sun, 14 Jun 2026 03:00:39 +0800 Subject: [PATCH 2/6] tmp commit --- kitty.go | 44 +++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/kitty.go b/kitty.go index e847b7276..0ec0a0620 100644 --- a/kitty.go +++ b/kitty.go @@ -34,6 +34,8 @@ type kittyScreen struct { 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. fmt.Fprint(os.Stderr, "\033_Ga=d,d=a,q=2;\033\\") } } @@ -43,6 +45,12 @@ func (ks *kittyScreen) printKitty(win *win, screen tcell.Screen, reg *reg) { 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 @@ -52,14 +60,13 @@ func (ks *kittyScreen) printKitty(win *win, screen tcell.Screen, reg *reg) { var b strings.Builder // Collect consecutive Kitty frames so that chunked transmission - // (m=1 / m=0) is written as a single logical image. + // (m=1 / m=0) is written as a single logical image at one position. var kittyBuf []string - var imageY, imageH int - 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) @@ -80,8 +87,7 @@ func (ks *kittyScreen) printKitty(win *win, screen tcell.Screen, reg *reg) { } b.WriteString(k) } - imageY = y - imageH = sh + screen.LockRegion(win.x, y, sw, sh, true) y += sh kittyBuf = nil } @@ -102,20 +108,8 @@ func (ks *kittyScreen) printKitty(win *win, screen tcell.Screen, reg *reg) { } flushKitty() - // Clear the preview pane in tcell's buffer to erase old text. - st := tcell.StyleDefault - for row := range win.h { - for col := range win.w { - screen.SetContent(win.x+col, win.y+row, ' ', nil, st) - } - } - - // Lock the image rows so Show() won't overwrite them. - if imageH > 0 { - screen.LockRegion(win.x, imageY, win.w, imageH, true) - } - - // Render pane clear and kitty image atomically via sync update. + // Write all output directly to stderr with synchronized update + // so the image renders atomically without flickering. fmt.Fprint(os.Stderr, "\033[?2026h") fmt.Fprint(os.Stderr, "\0337") screen.Show() @@ -143,14 +137,16 @@ func kittyCellSize(cmd string, cw, ch int) (int, int) { 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 - var pw, ph int + 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, "=") @@ -200,15 +196,20 @@ func generateKittyPreview(path string, win *win) ([]string, error) { 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) @@ -220,6 +221,7 @@ func generateKittyPreview(path string, win *win) ([]string, error) { 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 From 4a202f10512265ee86b43d0c7525dbdcca58780a Mon Sep 17 00:00:00 2001 From: RenovZ Date: Sun, 14 Jun 2026 03:47:18 +0800 Subject: [PATCH 3/6] fix: clear stale text and unlock region when switching kitty previews Prevent old text from previous file previews lingering on screen when switching to a new image. **image preview (kitty.go)** - Unlock the old region in clearKitty after deleting images so tcell can redraw the full pane for the new preview - Fill preview pane cells with spaces in tcells buffer to erase old text before rendering the new image - Emit clear-to-end-of-line ANSI codes for each row directly to the terminal for additional terminal-level cleanup - Merge clear sequence and kitty image output into one synchronized update to avoid flickering --- kitty.go | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/kitty.go b/kitty.go index 0ec0a0620..7e917e603 100644 --- a/kitty.go +++ b/kitty.go @@ -35,8 +35,10 @@ type kittyScreen struct { 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. + // 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) } } @@ -108,11 +110,29 @@ func (ks *kittyScreen) printKitty(win *win, screen tcell.Screen, reg *reg) { } flushKitty() - // Write all output directly to stderr with synchronized update - // so the image renders atomically without flickering. + // 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") From d656a2c971d05eecfc7bdd06a120b3e677f48717 Mon Sep 17 00:00:00 2001 From: RenovZ Date: Mon, 15 Jun 2026 02:26:34 +0800 Subject: [PATCH 4/6] feat: add BMP, TIFF, and WebP image preview support for Kitty protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the built-in Kitty graphics protocol preview to support additional image formats (BMP, TIFF, WebP) using the golang.org/x/image package. Remove the dead no-op "kitty" config option handler. **Kitty image preview (kitty.go)** - Register BMP, TIFF, and WebP decoders via blank imports from golang.org/x/image - Add ".bmp", ".tiff", ".tif", ".webp" to the imageExtensions map - Reorganize imports into standard-library, tcell, and x/image groups **Config (eval.go)** - Remove the "kitty"/"nokitty"/"kitty!" case in setExpr.eval() — this was a no-op (err = nil) that existed only to silently accept the option in config files; Kitty protocol is now always active **Dependencies (go.mod, go.sum)** - Add golang.org/x/image v0.42.0 (direct) - Bump golang.org/x/text v0.37.0 → v0.38.0 (indirect) --- eval.go | 4 ---- go.mod | 3 ++- go.sum | 6 ++++-- kitty.go | 10 ++++++++-- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/eval.go b/eval.go index ee66a6cc2..67fe766d9 100644 --- a/eval.go +++ b/eval.go @@ -117,10 +117,6 @@ func (e *setExpr) eval(app *app, _ []string) { app.nav.position() app.ui.loadFile(app, true) } - case "kitty", "nokitty", "kitty!": - // Kitty graphics protocol is always active; this option exists - // only so that "set kitty true" in the config does not error. - err = nil case "incfilter", "noincfilter", "incfilter!": err = applyBoolOpt(&gOpts.incfilter, e) case "incsearch", "noincsearch", "incsearch!": diff --git a/go.mod b/go.mod index 4ae0edef1..34265fc73 100644 --- a/go.mod +++ b/go.mod @@ -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 ) @@ -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 ) diff --git a/go.sum b/go.sum index 856683160..7c8f4bc61 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= diff --git a/kitty.go b/kitty.go index 7e917e603..a44f6fe76 100644 --- a/kitty.go +++ b/kitty.go @@ -5,21 +5,27 @@ import ( "encoding/base64" "fmt" "image" - _ "image/gif" - _ "image/jpeg" "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 { From e9285716af703c6a74a9642e5ae2140c8e136b69 Mon Sep 17 00:00:00 2001 From: RenovZ Date: Mon, 15 Jun 2026 02:41:14 +0800 Subject: [PATCH 5/6] refactor: replace sixel/kitty booleans with previewKind enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the separate `sixel` and `kitty` bool fields on the `reg` struct into a single `previewKind` enum, eliminating the impossible state where both could be true simultaneously. **previewKind type (ui.go)** - Introduce `previewKind` enum with `previewText`, `previewSixel`, and `previewKitty` constants - Replace `reg.sixel bool` + `reg.kitty bool` with `reg.kind previewKind` - Update `printReg()` conditions from field access to comparison (`reg.sixel` → `reg.kind == previewSixel`) **readLines (misc.go)** - Change return signature from `(lines, binary, sixel, kitty)` to `(lines, binary, kind previewKind)` - Set `kind = previewSixel` / `kind = previewKitty` in place of separate bool assignments **Tests (misc_test.go)** - Update all 30+ test cases: replace `sixel`/`kitty` bool fields with single `kind` field using the new constants - Update `TestReadLinesChafa` assertions accordingly **Preview / nav (nav.go)** - Update `readLines()` callsites to use single `kind` return value - Replace `reg.sixel = true` / `reg.kitty = true` assignments with `reg.kind = previewKitty` - Simplify resize cache eviction: `r.sixel || r.kitty` → `r.kind != previewText` --- misc.go | 8 ++--- misc_test.go | 95 ++++++++++++++++++++++++++-------------------------- nav.go | 12 +++---- ui.go | 20 +++++++---- 4 files changed, 70 insertions(+), 65 deletions(-) diff --git a/misc.go b/misc.go index 50b5ddcc2..e7e3f556d 100644 --- a/misc.go +++ b/misc.go @@ -467,7 +467,7 @@ func deletePathRecursive[T any](m map[string]T, path string) { // with very long or no newlines. // Sixel and Kitty images are also detected and stored as separate lines. // C0 control bytes outside of \a \b \t \n \v \f \r \033 and DEL indicate binary content. -func readLines(reader io.ByteReader, maxLines int) (lines []string, binary bool, sixel bool, kitty bool) { +func readLines(reader io.ByteReader, maxLines int) (lines []string, binary bool, kind previewKind) { const maxLineBytes = 1 << 16 // 64 KiB per line type state int @@ -506,7 +506,7 @@ func readLines(reader io.ByteReader, maxLines int) (lines []string, binary bool, case stateNormal: // C0 control bytes outside of \a \b \t \n \v \f \r \033 and DEL indicate binary content. if b < 0x07 || (b > 0x0D && b < 0x1B) || (b > 0x1B && b < 0x20) || b == 0x7F { - return nil, true, false, false + return nil, true, previewText } switch b { case '\033': @@ -555,7 +555,7 @@ func readLines(reader io.ByteReader, maxLines int) (lines []string, binary bool, if b == '\\' { buf.WriteByte(b) flush(true) - sixel = true + kind = previewSixel currState = stateNormal } else { buf.Reset() @@ -567,7 +567,7 @@ func readLines(reader io.ByteReader, maxLines int) (lines []string, binary bool, // a Kitty command or the sequence is terminated. if b == 'G' { buf.WriteByte(b) - kitty = true + kind = previewKitty currState = stateKitty } else if b >= 0x20 && b <= 0x7E { buf.WriteByte(b) diff --git a/misc_test.go b/misc_test.go index fdeedb256..525ce33f0 100644 --- a/misc_test.go +++ b/misc_test.go @@ -511,64 +511,63 @@ func TestReadLines(t *testing.T) { maxLines int lines []string binary bool - sixel bool - kitty bool + kind previewKind }{ - {"", 10, nil, false, false, false}, - {"\r", 10, nil, false, false, false}, - {"\r\n", 10, []string{""}, false, false, false}, - {"\r\r\n", 10, []string{""}, false, false, false}, - {"\n\n", 10, []string{"", ""}, false, false, false}, - {"foo", 10, []string{"foo"}, false, false, false}, - {"foo\n", 10, []string{"foo"}, false, false, false}, - {"foo\r\n", 10, []string{"foo"}, false, false, false}, - {"foo\nbar", 10, []string{"foo", "bar"}, false, false, false}, - {"foo\nbar\n", 10, []string{"foo", "bar"}, false, false, false}, - {"foo\r\nbar", 10, []string{"foo", "bar"}, false, false, false}, - {"foo\r\nbar\r\n", 10, []string{"foo", "bar"}, false, false, false}, - {"\033[31mfoo\033[0m", 10, []string{"\033[31mfoo\033[0m"}, false, false, false}, - {"\000", 10, nil, true, false, false}, - {"foo\r\n\000\r\nbar\r\n", 10, nil, true, false, false}, - {"\033P\033\\", 10, []string{"\033P\033\\"}, false, true, false}, - {"\033Pq\"1;1;1;1#0@\033\\", 10, []string{"\033Pq\"1;1;1;1#0@\033\\"}, false, true, false}, - {"\033P\000\033\\", 10, []string{"\033\\"}, false, false, false}, - {"\033P\n\033\\", 10, []string{"\033P\033\\"}, false, true, false}, - {"\033P\r\n\033\\", 10, []string{"\033P\033\\"}, false, true, false}, - {"\033P\033\\\033P\033\\", 10, []string{"\033P\033\\", "\033P\033\\"}, false, true, false}, - {"foo\033P\033\\bar", 10, []string{"foo", "\033P\033\\", "bar"}, false, true, false}, - {"foo\033P\033\\bar\033P\033\\baz", 10, []string{"foo", "\033P\033\\", "bar", "\033P\033\\", "baz"}, false, true, false}, - {"foo\nbar\nbaz", 2, []string{"foo", "bar"}, false, false, false}, - {"foo\nbar\nbaz\n", 2, []string{"foo", "bar"}, false, false, false}, - {"foo\nbar\033P\033\\", 2, []string{"foo", "bar"}, false, false, false}, - {"foo\nbar\nbaz", 3, []string{"foo", "bar", "baz"}, false, false, false}, - {"foo\nbar\nbaz\n", 3, []string{"foo", "bar", "baz"}, false, false, false}, - {"foo\nbar\033P\033\\", 3, []string{"foo", "bar", "\033P\033\\"}, false, true, false}, + {"", 10, nil, false, previewText}, + {"\r", 10, nil, false, previewText}, + {"\r\n", 10, []string{""}, false, previewText}, + {"\r\r\n", 10, []string{""}, false, previewText}, + {"\n\n", 10, []string{"", ""}, false, previewText}, + {"foo", 10, []string{"foo"}, false, previewText}, + {"foo\n", 10, []string{"foo"}, false, previewText}, + {"foo\r\n", 10, []string{"foo"}, false, previewText}, + {"foo\nbar", 10, []string{"foo", "bar"}, false, previewText}, + {"foo\nbar\n", 10, []string{"foo", "bar"}, false, previewText}, + {"foo\r\nbar", 10, []string{"foo", "bar"}, false, previewText}, + {"foo\r\nbar\r\n", 10, []string{"foo", "bar"}, false, previewText}, + {"\033[31mfoo\033[0m", 10, []string{"\033[31mfoo\033[0m"}, false, previewText}, + {"\000", 10, nil, true, previewText}, + {"foo\r\n\000\r\nbar\r\n", 10, nil, true, previewText}, + {"\033P\033\\", 10, []string{"\033P\033\\"}, false, previewSixel}, + {"\033Pq\"1;1;1;1#0@\033\\", 10, []string{"\033Pq\"1;1;1;1#0@\033\\"}, false, previewSixel}, + {"\033P\000\033\\", 10, []string{"\033\\"}, false, previewText}, + {"\033P\n\033\\", 10, []string{"\033P\033\\"}, false, previewSixel}, + {"\033P\r\n\033\\", 10, []string{"\033P\033\\"}, false, previewSixel}, + {"\033P\033\\\033P\033\\", 10, []string{"\033P\033\\", "\033P\033\\"}, false, previewSixel}, + {"foo\033P\033\\bar", 10, []string{"foo", "\033P\033\\", "bar"}, false, previewSixel}, + {"foo\033P\033\\bar\033P\033\\baz", 10, []string{"foo", "\033P\033\\", "bar", "\033P\033\\", "baz"}, false, previewSixel}, + {"foo\nbar\nbaz", 2, []string{"foo", "bar"}, false, previewText}, + {"foo\nbar\nbaz\n", 2, []string{"foo", "bar"}, false, previewText}, + {"foo\nbar\033P\033\\", 2, []string{"foo", "bar"}, false, previewText}, + {"foo\nbar\nbaz", 3, []string{"foo", "bar", "baz"}, false, previewText}, + {"foo\nbar\nbaz\n", 3, []string{"foo", "bar", "baz"}, false, previewText}, + {"foo\nbar\033P\033\\", 3, []string{"foo", "bar", "\033P\033\\"}, false, previewSixel}, // Inside the DCS body, ESC must be followed by '\\' (ST) for the // frame to be accepted. Any other byte aborts, so an attacker // cannot embed CSI/OSC/nested-DCS through the sixel path. - {"\033P\033]52;c;x\033\\", 10, []string{"52;c;x\033\\"}, false, false, false}, + {"\033P\033]52;c;x\033\\", 10, []string{"52;c;x\033\\"}, false, previewText}, // Kitty graphics protocol: \033_G...\033\\ - {"\033_Ga=T,f=100,s=1,v=1;AA==\033\\", 10, []string{"\033_Ga=T,f=100,s=1,v=1;AA==\033\\"}, false, false, true}, - {"foo\033_Ga=T;data\033\\bar", 10, []string{"foo", "\033_Ga=T;data\033\\bar"}, false, false, true}, - {"\033_G\n;\033\\", 10, []string{"\033_G;\033\\"}, false, false, true}, - {"\033_G\r\n;\033\\", 10, []string{"\033_G;\033\\"}, false, false, true}, + {"\033_Ga=T,f=100,s=1,v=1;AA==\033\\", 10, []string{"\033_Ga=T,f=100,s=1,v=1;AA==\033\\"}, false, previewKitty}, + {"foo\033_Ga=T;data\033\\bar", 10, []string{"foo", "\033_Ga=T;data\033\\bar"}, false, previewKitty}, + {"\033_G\n;\033\\", 10, []string{"\033_G;\033\\"}, false, previewKitty}, + {"\033_G\r\n;\033\\", 10, []string{"\033_G;\033\\"}, false, previewKitty}, // Kitty with raw binary payload (simulating chafa -f kitty f=32,t=d). // The payload after ';' may contain any byte including \000, \033, etc. - {"\033_Ga=T,f=32,s=1,v=1;\x00\x01\x02\033\\", 10, []string{"\033_Ga=T,f=32,s=1,v=1;\x00\x01\x02\033\\"}, false, false, true}, + {"\033_Ga=T,f=32,s=1,v=1;\x00\x01\x02\033\\", 10, []string{"\033_Ga=T,f=32,s=1,v=1;\x00\x01\x02\033\\"}, false, previewKitty}, // \033 byte in raw payload followed by non-\\ must NOT abort the frame. - {"\033_Ga=T,f=32,s=1,v=1;\x00\033@more\033\\", 10, []string{"\033_Ga=T,f=32,s=1,v=1;\x00\033@more\033\\"}, false, false, true}, + {"\033_Ga=T,f=32,s=1,v=1;\x00\033@more\033\\", 10, []string{"\033_Ga=T,f=32,s=1,v=1;\x00\033@more\033\\"}, false, previewKitty}, // Verify non-kitty APC (no G after ESC _) is not flagged as kitty. - {"\033_Xhello\033\\", 10, []string{"\033_Xhello\033\\"}, false, false, false}, + {"\033_Xhello\033\\", 10, []string{"\033_Xhello\033\\"}, false, previewText}, } for _, test := range tests { - lines, binary, sixel, kitty := readLines(strings.NewReader(test.s), test.maxLines) - if !reflect.DeepEqual(lines, test.lines) || binary != test.binary || sixel != test.sixel || kitty != test.kitty { + lines, binary, kind := readLines(strings.NewReader(test.s), test.maxLines) + if !reflect.DeepEqual(lines, test.lines) || binary != test.binary || kind != test.kind { t.Errorf( - "at input (%q, %v) expected (%#v, %v, %v, %v) but got (%#v, %v, %v, %v)", + "at input (%q, %v) expected (%#v, %v, %v) but got (%#v, %v, %v)", test.s, test.maxLines, - test.lines, test.binary, test.sixel, test.kitty, - lines, binary, sixel, kitty, + test.lines, test.binary, test.kind, + lines, binary, kind, ) } } @@ -646,15 +645,15 @@ func TestReadLinesChafa(t *testing.T) { t.Skipf("skipping: chafa test data not found (%s)", err) } - lines, binary, sixel, kitty := readLines(bytes.NewReader(data), 100) + lines, binary, kind := readLines(bytes.NewReader(data), 100) if binary { t.Fatal("unexpected binary detection") } - if sixel { + if kind == previewSixel { t.Fatal("unexpected sixel detection") } - if !kitty { - t.Fatalf("expected kitty=true, got kitty=%v (lines=%d)", kitty, len(lines)) + if kind != previewKitty { + t.Fatalf("expected kitty, got kind=%v (lines=%d)", kind, len(lines)) } // The first line should be \033[?25l (cursor hide CSI) diff --git a/nav.go b/nav.go index 7e7055f19..5f9fcb0f1 100644 --- a/nav.go +++ b/nav.go @@ -690,7 +690,7 @@ func (nav *nav) resize(ui *ui) { } else { // drop entries that no longer match the new pane height for path, r := range nav.regCache { - if r.loading || r.sixel || r.kitty || (previewWin.h > len(r.lines) && len(r.lines) == r.height) { + if r.loading || r.kind != previewText || (previewWin.h > len(r.lines) && len(r.lines) == r.height) { delete(nav.regCache, path) } } @@ -939,7 +939,7 @@ func (nav *nav) preview(path string, win *win, mode string) { reg.lines = []string{"\033[7mpreview error\033[0m"} } else { reg.lines = kittyLines - reg.kitty = true + reg.kind = previewKitty } return } @@ -954,7 +954,7 @@ func (nav *nav) preview(path string, win *win, mode string) { reader = bufio.NewReader(f) } - lines, binary, sixel, kitty := readLines(reader, win.h) + lines, binary, kind := readLines(reader, win.h) if binary { lines = []string{"\033[7mbinary\033[0m"} } @@ -964,16 +964,14 @@ func (nav *nav) preview(path string, win *win, mode string) { // (e.g. OSC 52 clipboard writes). Replace control characters with // U+FFFD so they are visible but cannot form escape sequences. if len(gOpts.previewer) == 0 && !binary { - sixel = false - kitty = false + kind = previewText for i, l := range lines { lines[i] = sanitizePreview(l) } } reg.lines = lines - reg.sixel = sixel - reg.kitty = kitty + reg.kind = kind } func (nav *nav) loadReg(path string, volatile bool) *reg { diff --git a/ui.go b/ui.go index c5fa08c15..bd7533b9b 100644 --- a/ui.go +++ b/ui.go @@ -143,9 +143,9 @@ func (win *win) printReg(screen tcell.Screen, reg *reg, sxs *sixelScreen, kts *k } else { previewTimer.Reset(previewLoadingDelay) } - case reg.sixel: + case reg.kind == previewSixel: sxs.printSixel(win, screen, reg) - case reg.kitty: + case reg.kind == previewKitty: kts.printKitty(win, screen, reg) default: st := tcell.StyleDefault @@ -158,10 +158,10 @@ func (win *win) printReg(screen tcell.Screen, reg *reg, sxs *sixelScreen, kts *k } } - if !reg.sixel { + if reg.kind != previewSixel { sxs.lastFile = "" } - if !reg.kitty { + if reg.kind != previewKitty { kts.lastFile = "" } } @@ -612,14 +612,22 @@ func (ui *ui) echoerrf(format string, a ...any) { // // Note: the name `reg` is historical. It originally meant "regular" // file preview, but `previewer` now also supports non-regular files. +// previewKind describes the content type of a preview region. +type previewKind int + +const ( + previewText previewKind = iota // plain text (not an image) + previewSixel // sixel graphics + previewKitty // kitty graphics protocol +) + type reg struct { loading bool volatile bool loadTime time.Time path string lines []string - sixel bool - kitty bool + kind previewKind height int } From de38827b2dfbb91fd03afab8faa9835303409308 Mon Sep 17 00:00:00 2001 From: RenovZ Date: Mon, 15 Jun 2026 02:48:58 +0800 Subject: [PATCH 6/6] fix: clear preview pane before rendering sixel images Old text from a previous file preview would linger around or behind the sixel image because nothing explicitly cleared the pane beforehand. **sixelScreen (sixel.go)** - Clear the preview pane in tcell's buffer by writing spaces to every cell in `printSixel()` before rendering - Emit `\033[Y;XH\033[0K` sequences directly to the terminal for each preview row to erase any leftover text outside tcell's control - Flush tcell with `screen.Show()` before writing sixel data so the cleared buffer is visible --- sixel.go | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/sixel.go b/sixel.go index f8bedc847..ab1d99ddb 100644 --- a/sixel.go +++ b/sixel.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "fmt" "log" "os" @@ -72,9 +73,29 @@ func (sxs *sixelScreen) printSixel(win *win, screen tcell.Screen, reg *reg) { y += sh } + // Clear the preview pane in tcell's buffer so old text from + // a previous file doesn't linger around or behind 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() + fmt.Fprint(os.Stderr, "\033[?2026h") // Begin synchronized update fmt.Fprint(os.Stderr, "\0337") // Save cursor position - fmt.Fprint(os.Stderr, b.String()) // Write data + screen.Show() // Flush tcell's cleared buffer + fmt.Fprint(os.Stderr, clearStr) // Clear terminal rows + fmt.Fprint(os.Stderr, b.String()) // Write sixel + text data fmt.Fprint(os.Stderr, "\0338") // Restore cursor position fmt.Fprint(os.Stderr, "\033[?2026l") // End synchronized update