Speed up kitty image previews

This commit is contained in:
vrubelroman 2026-04-24 23:42:23 +03:00
parent e229cbc6cc
commit cb5c98834e
4 changed files with 150 additions and 30 deletions

View file

@ -1,5 +1,5 @@
pkgname=vcom
pkgver=0.1.7
pkgver=0.1.8
pkgrel=1
pkgdesc="Terminal file manager inspired by Midnight Commander"
arch=("x86_64" "aarch64")

View file

@ -64,23 +64,23 @@ go build -o vcom ./cmd/vcom
Run directly from the flake:
```bash
nix run github:vrubelroman/vcom?ref=v0.1.7
nix run github:vrubelroman/vcom?ref=v0.1.8
```
Install into user profile:
```bash
nix profile add github:vrubelroman/vcom?ref=v0.1.7
nix profile add github:vrubelroman/vcom?ref=v0.1.8
```
The Nix package wraps `vcom` with `ueberzugpp` in `PATH`, so image preview works in non-`kitty` terminals out of the box.
### Debian / Ubuntu
Download the release `.deb` for `v0.1.7`, then install:
Download the release `.deb` for `v0.1.8`, then install:
```bash
sudo apt install ./vcom_0.1.7_amd64.deb
sudo apt install ./vcom_0.1.8_amd64.deb
```
The Debian package declares `ueberzug` (or `ueberzugpp` where available) as a dependency for image preview outside `kitty`.
@ -136,7 +136,7 @@ Built-in themes:
## Releases
Pushing a tag like `v0.1.7` triggers GitHub Actions release workflow (`.github/workflows/release.yml`) which:
Pushing a tag like `v0.1.8` triggers GitHub Actions release workflow (`.github/workflows/release.yml`) which:
- runs tests
- vendors Go modules
@ -146,9 +146,9 @@ Pushing a tag like `v0.1.7` triggers GitHub Actions release workflow (`.github/w
Release artifacts:
- `vcom-v0.1.7-x86_64-unknown-linux-gnu.tar.gz`
- `vcom_0.1.7_amd64.deb`
- `vcom-v0.1.7-checksums.txt`
- `vcom-v0.1.8-x86_64-unknown-linux-gnu.tar.gz`
- `vcom_0.1.8_amd64.deb`
- `vcom-v0.1.8-checksums.txt`
## Notes

View file

@ -13,7 +13,7 @@
lib = pkgs.lib;
packageBase = pkgs.buildGoModule {
pname = "vcom";
version = "0.1.7";
version = "0.1.8";
src = ./.;
vendorHash = null;

View file

@ -1,13 +1,21 @@
package ui
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"image"
"image/png"
"io"
"os"
"os/exec"
"slices"
"strings"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
)
type overlayRect struct {
@ -31,6 +39,17 @@ type imageOverlayManager struct {
kittyOK bool
}
const kittyImageID = 31337
const assumedCellAspect = 0.5
const kittyPixelsPerCell = 16
func minFloat(a float64, b float64) float64 {
if a < b {
return a
}
return b
}
func newImageOverlayManager() *imageOverlayManager {
return &imageOverlayManager{identifier: "vcom-preview"}
}
@ -49,33 +68,134 @@ func (m *imageOverlayManager) canUseKitty() bool {
return m.kittyOK
}
m.kittyTried = true
_, err := exec.LookPath("kitty")
m.kittyOK = err == nil
m.kittyOK = true
return m.kittyOK
}
func (m *imageOverlayManager) showWithKitty(path string, rect overlayRect) error {
args := []string{
"+kitten", "icat",
"--stdin=no",
"--transfer-mode=stream",
"--image-id=31337",
"--place", fmt.Sprintf("%dx%d@%dx%d", rect.width, rect.height, rect.x, rect.y),
"--scale-up",
"--no-trailing-newline",
path,
func writeKittyEscape(control string, payload []byte) error {
if payload == nil {
_, err := fmt.Fprintf(os.Stdout, "\x1b_G%s\x1b\\", control)
return err
}
cmd := exec.Command("kitty", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = io.Discard
return cmd.Run()
encoded := base64.StdEncoding.EncodeToString(payload)
_, err := fmt.Fprintf(os.Stdout, "\x1b_G%s;%s\x1b\\", control, encoded)
return err
}
func resizeNearest(src image.Image, target image.Point) image.Image {
bounds := src.Bounds()
srcSize := bounds.Size()
if target.X <= 0 || target.Y <= 0 || srcSize.X <= target.X && srcSize.Y <= target.Y {
return src
}
dst := image.NewRGBA(image.Rect(0, 0, target.X, target.Y))
for y := 0; y < target.Y; y++ {
srcY := bounds.Min.Y + (y * srcSize.Y / target.Y)
for x := 0; x < target.X; x++ {
srcX := bounds.Min.X + (x * srcSize.X / target.X)
dst.Set(x, y, src.At(srcX, srcY))
}
}
return dst
}
func scaleImageToRect(img image.Image, rect overlayRect) image.Image {
size := img.Bounds().Size()
if size.X <= 0 || size.Y <= 0 || rect.width <= 0 || rect.height <= 0 {
return img
}
maxWidth := rect.width * kittyPixelsPerCell
maxHeight := rect.height * kittyPixelsPerCell
if maxWidth <= 0 || maxHeight <= 0 {
return img
}
scale := minFloat(float64(maxWidth)/float64(size.X), float64(maxHeight)/float64(size.Y))
if scale >= 1 {
return img
}
target := image.Point{
X: max(int(float64(size.X)*scale), 1),
Y: max(int(float64(size.Y)*scale), 1),
}
return resizeNearest(img, target)
}
func loadKittyPayload(path string, rect overlayRect) ([]byte, image.Point, error) {
file, err := os.Open(path)
if err != nil {
return nil, image.Point{}, err
}
defer file.Close()
img, _, err := image.Decode(file)
if err != nil {
return nil, image.Point{}, err
}
img = scaleImageToRect(img, rect)
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, image.Point{}, err
}
return buf.Bytes(), img.Bounds().Size(), nil
}
func kittyPlacementControl(rect overlayRect, size image.Point) string {
if size.X <= 0 || size.Y <= 0 {
return fmt.Sprintf("a=T,f=100,t=d,i=%d,c=%d,C=1", kittyImageID, rect.width)
}
availableAspect := (float64(rect.width) * assumedCellAspect) / float64(rect.height)
imageAspect := float64(size.X) / float64(size.Y)
if imageAspect >= availableAspect {
return fmt.Sprintf("a=T,f=100,t=d,i=%d,c=%d,C=1", kittyImageID, rect.width)
}
return fmt.Sprintf("a=T,f=100,t=d,i=%d,r=%d,C=1", kittyImageID, rect.height)
}
func (m *imageOverlayManager) showWithKitty(path string, rect overlayRect) error {
data, size, err := loadKittyPayload(path, rect)
if err != nil {
return err
}
if err := writeKittyEscape(fmt.Sprintf("a=d,d=I,i=%d", kittyImageID), nil); err != nil {
return err
}
if _, err := fmt.Fprintf(os.Stdout, "\x1b7\x1b[%d;%dH", rect.y+1, rect.x+1); err != nil {
return err
}
const chunkSize = 4096
for offset := 0; offset < len(data); offset += chunkSize {
end := min(offset+chunkSize, len(data))
chunk := data[offset:end]
more := 0
if end < len(data) {
more = 1
}
control := fmt.Sprintf("m=%d", more)
if offset == 0 {
control = fmt.Sprintf("%s,m=%d", kittyPlacementControl(rect, size), more)
}
if err := writeKittyEscape(control, chunk); err != nil {
return err
}
}
_, err = fmt.Fprint(os.Stdout, "\x1b8")
return err
}
func (m *imageOverlayManager) clearKitty() {
cmd := exec.Command("kitty", "+kitten", "icat", "--stdin=no", "--clear")
cmd.Stdout = os.Stdout
cmd.Stderr = io.Discard
_ = cmd.Run()
_ = writeKittyEscape(fmt.Sprintf("a=d,d=I,i=%d", kittyImageID), nil)
}
func (m *imageOverlayManager) backendOutput() string {