Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: kitty graphics inline (doesn't rely on kitten icat) #38 #39

Closed
wants to merge 5 commits into from
Closed
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
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
Expand Down
8 changes: 6 additions & 2 deletions internal/image/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,28 +157,32 @@ func SaveUrlAsImg(url string) (string, error) {
if err != nil {
return "", fmt.Errorf("could not write to file: %w", err)
}

return path, nil
}

// Opens the image on the default viewing application of every operating system.
//
// If the terminal emulator "kitty" is running --> it will print the image on the terminal
func OpenImage(filePath string) error {
fmt.Println("Open image")

if !config.GowallConfig.EnableImagePreviewing {
return nil
}

var cmd *exec.Cmd

if utils.IsKittyTerminalRunning() || utils.IsKonsoleTerminalRunning() || utils.IsGhosttyTerminalRunning() {
if utils.IsKittyTerminalRunning() {
cmd = exec.Command("kitty", "icat", filePath)
cmd.Stdout = os.Stdout

return cmd.Run()
}

if utils.IsGhosttyTerminalRunning() || utils.IsKonsoleTerminalRunning() {
return utils.SendKittyImg(filePath)
}

if utils.IsWeztermTerminalRunning() {
cmd = exec.Command("wezterm", "imgcat", filePath)
cmd.Stdout = os.Stdout
Expand Down
24 changes: 5 additions & 19 deletions utils/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package utils
import (
"fmt"
"os"
"os/exec"
"strings"
)

Expand All @@ -22,33 +21,20 @@ func IsKonsoleTerminalRunning() bool {
terminal := os.Getenv("TERM")

if terminal == "xterm-256color" && os.Getenv("KONSOLE_VERSION") != "" {

path, err := exec.LookPath("kitten")
if err != nil {
return false
}

return path != ""

return true
}
return false
}

// Checks if the terminal running is Konsole and has
// Checks if the terminal running is Ghostty and has
func IsGhosttyTerminalRunning() bool {

terminal := os.Getenv("TERM")

if terminal == "xterm-ghostty" && os.Getenv("TERM_PROGRAM") == "ghostty" {

path, err := exec.LookPath("kitten")
if err != nil {
return false
}

return path != ""

if strings.Contains(terminal, "ghostty") {
return true
}

return false
}

Expand Down
135 changes: 135 additions & 0 deletions utils/sendKittyImg.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package utils

import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/png"
"io"
"os"

"golang.org/x/sys/unix"
)

// SendKittyImg sends a file as a Kitty protocol image with aspect-ratio scaling,
// writing directly to /dev/tty to avoid interference from terminal input/output.
func SendKittyImg(filePath string) error {
// Open the image file.
f, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("error opening image file: %w", err)
}
defer f.Close()
// Turns out the kitty graphics protocol prefers PNG
// https://sw.kovidgoyal.net/kitty/graphics-protocol/#png-data
// so we Decode the image (this works for JPEG, PNG, GIF, etc.) from the filePath
img, _, err := image.Decode(f)
if err != nil {
return fmt.Errorf("error decoding image: %w", err)
}

// Create an in-memory buffer to hold the PNG-encoded image.
// This will be a temporary place in memory simply to easily show an inline preview
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return fmt.Errorf("error encoding PNG: %w", err)
}

intRows, intCols := computeDesiredTextSize(img)

// Open /dev/tty for writing to avoid interference.
ttyWriter, err := os.OpenFile("/dev/tty", int(unix.O_WRONLY|unix.O_NOCTTY), 0)
if err != nil {
return fmt.Errorf("error opening /dev/tty for writing: %w", err)
}
defer ttyWriter.Close()

// Begin the Kitty image escape sequence.
// a=T is the documented mode (non-scrolling) and we use text-cell sizing (r and c).
_, err = fmt.Fprintf(ttyWriter, "\x1b_Gf=100,a=T,X=100,r=%d,c=%d;", intRows, intCols)
if err != nil {
return fmt.Errorf("error writing escape sequence: %w", err)
}

// Base64-encode the PNG data directly to ttyWriter.
encoder := base64.NewEncoder(base64.StdEncoding, ttyWriter)
if _, err := io.Copy(encoder, &buf); err != nil {
return fmt.Errorf("error base64 encoding image data: %w", err)
}
encoder.Close()

// Terminate the escape sequence including newline.
_, err = fmt.Fprintf(ttyWriter, "\x1b\\\n")
if err != nil {
return fmt.Errorf("error writing final escape sequence: %w", err)
}

return nil
}

// getTerminalDimensions returns the terminal's text cell dimensions (rows, cols)
// and its pixel dimensions (width, height) by querying /dev/tty.
func getTerminalDimensions() (rows, cols, pxWidth, pxHeight int) {
tty, err := os.OpenFile("/dev/tty", int(unix.O_RDWR|unix.O_NOCTTY), 0)
if err != nil {
fmt.Fprintf(os.Stderr, "error opening /dev/tty: %v\n", err)
os.Exit(1)
}
defer tty.Close()

sz, err := unix.IoctlGetWinsize(int(tty.Fd()), unix.TIOCGWINSZ)
if err != nil {
fmt.Fprintf(os.Stderr, "error getting window size: %v\n", err)
os.Exit(1)
}

return int(sz.Row), int(sz.Col), int(sz.Xpixel), int(sz.Ypixel)
}

// getImageAspect returns the images og dimensions/aspect ratio
func getImageAspect(img image.Image) float64 {

// Get original image dimensions (in pixels)
imgWidth := img.Bounds().Dx()
imgHeight := img.Bounds().Dy()

// image pixel aspect ratio.
return float64(imgWidth) / float64(imgHeight)
}

// computeDesiredTextSize calculates and returns the desired number of text cells (r and c)
// that should be used for the image, preserving its aspect ratio.
// It uses both the image dimensions and the terminal's text and pixel dimensions.
func computeDesiredTextSize(img image.Image) (int, int) {
// Get terminal size: text cells and pixel dimensions.
termRows, termCols, termPxWidth, termPxHeight := getTerminalDimensions()

// Get image aspect ratio.
imgAspect := getImageAspect(img)

// Compute the size of one cell (in pixels).
cellWidth := float64(termPxWidth) / float64(termCols)
cellHeight := float64(termPxHeight) / float64(termRows)
cellAspect := cellWidth / cellHeight

// Adjust image aspect ratio to account for the non-square text cells.
effectiveAspect := imgAspect / cellAspect

// Use 90% of available text cells as the maximum.
maxCols := float64(termCols) * 0.9
maxRows := float64(termRows) * 0.9

// Start with the maximum width and compute the height from the effective aspect ratio.
desiredCols := maxCols
desiredRows := desiredCols / effectiveAspect

// If the computed rows exceed the maximum available, recalc based on height.
if desiredRows > maxRows {
desiredRows = maxRows
desiredCols = desiredRows * effectiveAspect
}

// Return as integers.
return int(desiredRows), int(desiredCols)
}