2026-04-24 21:51:56 +03:00
|
|
|
package ui
|
|
|
|
|
|
|
|
|
|
import (
|
2026-04-24 23:42:23 +03:00
|
|
|
"bytes"
|
|
|
|
|
"encoding/base64"
|
2026-04-24 21:51:56 +03:00
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
2026-04-24 23:42:23 +03:00
|
|
|
"image"
|
|
|
|
|
"image/png"
|
2026-04-24 21:51:56 +03:00
|
|
|
"io"
|
|
|
|
|
"os"
|
|
|
|
|
"os/exec"
|
|
|
|
|
"slices"
|
|
|
|
|
"strings"
|
2026-04-24 23:42:23 +03:00
|
|
|
|
|
|
|
|
_ "image/gif"
|
|
|
|
|
_ "image/jpeg"
|
|
|
|
|
_ "image/png"
|
2026-04-24 21:51:56 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type overlayRect struct {
|
|
|
|
|
x int
|
|
|
|
|
y int
|
|
|
|
|
width int
|
|
|
|
|
height int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type imageOverlayManager struct {
|
|
|
|
|
cmd *exec.Cmd
|
|
|
|
|
stdin io.WriteCloser
|
|
|
|
|
running bool
|
|
|
|
|
identifier string
|
|
|
|
|
visible bool
|
|
|
|
|
backend string
|
|
|
|
|
backends []string
|
|
|
|
|
lastPath string
|
|
|
|
|
lastRect overlayRect
|
|
|
|
|
kittyTried bool
|
|
|
|
|
kittyOK bool
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 23:42:23 +03:00
|
|
|
const kittyImageID = 31337
|
|
|
|
|
const assumedCellAspect = 0.5
|
|
|
|
|
const kittyPixelsPerCell = 16
|
|
|
|
|
|
|
|
|
|
func minFloat(a float64, b float64) float64 {
|
|
|
|
|
if a < b {
|
|
|
|
|
return a
|
|
|
|
|
}
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 21:51:56 +03:00
|
|
|
func newImageOverlayManager() *imageOverlayManager {
|
|
|
|
|
return &imageOverlayManager{identifier: "vcom-preview"}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *imageOverlayManager) isKittyTerminal() bool {
|
|
|
|
|
term := strings.ToLower(os.Getenv("TERM"))
|
|
|
|
|
termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
|
|
|
|
|
return os.Getenv("KITTY_WINDOW_ID") != "" || strings.Contains(term, "kitty") || strings.Contains(termProgram, "kitty")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *imageOverlayManager) canUseKitty() bool {
|
|
|
|
|
if !m.isKittyTerminal() {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if m.kittyTried {
|
|
|
|
|
return m.kittyOK
|
|
|
|
|
}
|
|
|
|
|
m.kittyTried = true
|
2026-04-24 23:42:23 +03:00
|
|
|
m.kittyOK = true
|
2026-04-24 21:51:56 +03:00
|
|
|
return m.kittyOK
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 23:42:23 +03:00
|
|
|
func writeKittyEscape(control string, payload []byte) error {
|
|
|
|
|
if payload == nil {
|
|
|
|
|
_, err := fmt.Fprintf(os.Stdout, "\x1b_G%s\x1b\\", control)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 21:51:56 +03:00
|
|
|
func (m *imageOverlayManager) showWithKitty(path string, rect overlayRect) error {
|
2026-04-24 23:42:23 +03:00
|
|
|
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
|
2026-04-24 21:51:56 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *imageOverlayManager) clearKitty() {
|
2026-04-24 23:42:23 +03:00
|
|
|
_ = writeKittyEscape(fmt.Sprintf("a=d,d=I,i=%d", kittyImageID), nil)
|
2026-04-24 21:51:56 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *imageOverlayManager) backendOutput() string {
|
|
|
|
|
term := strings.ToLower(os.Getenv("TERM"))
|
|
|
|
|
order := make([]string, 0, 5)
|
|
|
|
|
switch {
|
|
|
|
|
case strings.Contains(term, "kitty"):
|
|
|
|
|
order = append(order, "kitty")
|
|
|
|
|
case os.Getenv("WAYLAND_DISPLAY") != "":
|
|
|
|
|
order = append(order, "wayland")
|
|
|
|
|
case os.Getenv("DISPLAY") != "":
|
|
|
|
|
order = append(order, "x11")
|
|
|
|
|
}
|
2026-04-24 22:09:54 +03:00
|
|
|
order = append(order, "wayland", "x11", "sixel", "kitty")
|
2026-04-24 21:51:56 +03:00
|
|
|
|
|
|
|
|
unique := make([]string, 0, len(order))
|
|
|
|
|
for _, backend := range order {
|
|
|
|
|
if !slices.Contains(unique, backend) {
|
|
|
|
|
unique = append(unique, backend)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return strings.Join(unique, ",")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *imageOverlayManager) backendList() []string {
|
|
|
|
|
if len(m.backends) != 0 {
|
|
|
|
|
return m.backends
|
|
|
|
|
}
|
|
|
|
|
m.backends = strings.Split(m.backendOutput(), ",")
|
|
|
|
|
return m.backends
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *imageOverlayManager) startBackend(backend string) error {
|
|
|
|
|
cmd := exec.Command("ueberzugpp", "layer", "-o", backend)
|
|
|
|
|
stdin, err := cmd.StdinPipe()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
cmd.Stdout = io.Discard
|
|
|
|
|
cmd.Stderr = io.Discard
|
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
|
|
|
_ = stdin.Close()
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
m.cmd = cmd
|
|
|
|
|
m.stdin = stdin
|
|
|
|
|
m.running = true
|
|
|
|
|
m.backend = backend
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 22:53:12 +03:00
|
|
|
func (m *imageOverlayManager) startLegacyBackend() error {
|
|
|
|
|
cmd := exec.Command("ueberzug", "layer", "--parser", "json")
|
|
|
|
|
stdin, err := cmd.StdinPipe()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
cmd.Stdout = io.Discard
|
|
|
|
|
cmd.Stderr = io.Discard
|
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
|
|
|
_ = stdin.Close()
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
m.cmd = cmd
|
|
|
|
|
m.stdin = stdin
|
|
|
|
|
m.running = true
|
|
|
|
|
m.backend = "ueberzug"
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 21:51:56 +03:00
|
|
|
func (m *imageOverlayManager) ensureStarted() error {
|
|
|
|
|
if m.running {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-04-24 22:53:12 +03:00
|
|
|
|
|
|
|
|
if _, err := exec.LookPath("ueberzugpp"); err == nil {
|
|
|
|
|
var lastErr error
|
|
|
|
|
for _, backend := range m.backendList() {
|
|
|
|
|
if err := m.startBackend(backend); err != nil {
|
|
|
|
|
lastErr = err
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
// Probe command channel right away; some backends terminate instantly.
|
|
|
|
|
if err := m.send(map[string]any{
|
|
|
|
|
"action": "remove",
|
|
|
|
|
"identifier": m.identifier,
|
|
|
|
|
}); err != nil {
|
|
|
|
|
lastErr = err
|
|
|
|
|
m.stop()
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if lastErr != nil {
|
|
|
|
|
return lastErr
|
|
|
|
|
}
|
2026-04-24 21:51:56 +03:00
|
|
|
}
|
|
|
|
|
|
2026-04-24 22:53:12 +03:00
|
|
|
if _, err := exec.LookPath("ueberzug"); err == nil {
|
|
|
|
|
if err := m.startLegacyBackend(); err != nil {
|
|
|
|
|
return err
|
2026-04-24 21:51:56 +03:00
|
|
|
}
|
|
|
|
|
if err := m.send(map[string]any{
|
|
|
|
|
"action": "remove",
|
|
|
|
|
"identifier": m.identifier,
|
|
|
|
|
}); err != nil {
|
|
|
|
|
m.stop()
|
2026-04-24 22:53:12 +03:00
|
|
|
return err
|
2026-04-24 21:51:56 +03:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 22:53:12 +03:00
|
|
|
return fmt.Errorf("could not start image overlay backend")
|
2026-04-24 21:51:56 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *imageOverlayManager) send(payload map[string]any) error {
|
|
|
|
|
if !m.running || m.stdin == nil {
|
|
|
|
|
return fmt.Errorf("overlay not running")
|
|
|
|
|
}
|
|
|
|
|
data, err := json.Marshal(payload)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
_, err = io.WriteString(m.stdin, string(data)+"\n")
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *imageOverlayManager) show(path string, rect overlayRect) error {
|
|
|
|
|
if rect.width <= 1 || rect.height <= 1 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if m.visible && m.lastPath == path && m.lastRect == rect {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if m.canUseKitty() {
|
|
|
|
|
if m.backend == "ueberzugpp" {
|
|
|
|
|
m.hide()
|
|
|
|
|
}
|
|
|
|
|
if err := m.showWithKitty(path, rect); err == nil {
|
|
|
|
|
m.backend = "kitty"
|
|
|
|
|
m.visible = true
|
|
|
|
|
m.lastPath = path
|
|
|
|
|
m.lastRect = rect
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for i := 0; i < len(m.backendList()); i++ {
|
|
|
|
|
if err := m.ensureStarted(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
payload := map[string]any{
|
|
|
|
|
"action": "add",
|
|
|
|
|
"identifier": m.identifier,
|
|
|
|
|
"path": path,
|
|
|
|
|
"x": rect.x,
|
|
|
|
|
"y": rect.y,
|
2026-04-24 22:53:12 +03:00
|
|
|
}
|
|
|
|
|
if m.backend == "ueberzug" {
|
|
|
|
|
payload["width"] = rect.width
|
|
|
|
|
payload["height"] = rect.height
|
|
|
|
|
} else {
|
|
|
|
|
payload["max_width"] = rect.width
|
|
|
|
|
payload["max_height"] = rect.height
|
|
|
|
|
payload["scaler"] = "fit_contain"
|
2026-04-24 21:51:56 +03:00
|
|
|
}
|
|
|
|
|
if err := m.send(payload); err == nil {
|
|
|
|
|
m.visible = true
|
|
|
|
|
m.lastPath = path
|
|
|
|
|
m.lastRect = rect
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m.stop()
|
2026-04-24 22:53:12 +03:00
|
|
|
if m.backend != "ueberzug" && len(m.backends) > 0 {
|
2026-04-24 21:51:56 +03:00
|
|
|
m.backends = append(m.backends[1:], m.backends[0])
|
2026-04-24 22:53:12 +03:00
|
|
|
} else {
|
|
|
|
|
break
|
2026-04-24 21:51:56 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return fmt.Errorf("could not render image overlay")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *imageOverlayManager) hide() {
|
|
|
|
|
if !m.visible {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
switch m.backend {
|
|
|
|
|
case "kitty":
|
|
|
|
|
m.clearKitty()
|
2026-04-24 22:53:12 +03:00
|
|
|
case "ueberzugpp", "ueberzug":
|
2026-04-24 21:51:56 +03:00
|
|
|
if m.running {
|
|
|
|
|
_ = m.send(map[string]any{
|
|
|
|
|
"action": "remove",
|
|
|
|
|
"identifier": m.identifier,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
m.visible = false
|
|
|
|
|
m.lastPath = ""
|
|
|
|
|
m.lastRect = overlayRect{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *imageOverlayManager) stop() {
|
|
|
|
|
m.hide()
|
|
|
|
|
if m.stdin != nil {
|
|
|
|
|
_ = m.stdin.Close()
|
|
|
|
|
m.stdin = nil
|
|
|
|
|
}
|
|
|
|
|
if m.cmd != nil && m.cmd.Process != nil {
|
|
|
|
|
_ = m.cmd.Process.Kill()
|
|
|
|
|
_, _ = m.cmd.Process.Wait()
|
|
|
|
|
}
|
|
|
|
|
m.cmd = nil
|
|
|
|
|
m.running = false
|
|
|
|
|
m.backend = ""
|
|
|
|
|
}
|