Skip to content

Commit 58a3ef2

Browse files
authored
feat: inline Image preview with reszing (#40)
1 parent 2234193 commit 58a3ef2

2 files changed

Lines changed: 157 additions & 10 deletions

File tree

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,14 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
3636
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
3737
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
3838
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
39+
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
40+
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
3941
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
4042
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
4143
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
4244
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
45+
golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
46+
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
4347
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
4448
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
4549
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=

terminal/terminal.go

Lines changed: 153 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,37 +8,180 @@ import (
88
"image/png"
99
"io"
1010
"os"
11+
"regexp"
12+
"strconv"
13+
14+
"golang.org/x/term"
1115
)
1216

17+
// RenderKittyImg sends a file as a Kitty protocol image with aspect-ratio scaling,
18+
// writing directly to /dev/tty to avoid interference from terminal input/output.
1319
func RenderKittyImg(filePath string) error {
1420

1521
file, err := os.Open(filePath)
1622
if err != nil {
17-
return err
23+
return fmt.Errorf("error opening image file: %w", err)
1824
}
19-
2025
defer file.Close()
2126

2227
img, _, err := image.Decode(file)
2328
if err != nil {
24-
return err
29+
return fmt.Errorf("error decoding image: %w", err)
2530
}
2631

2732
var buf bytes.Buffer
2833
if err := png.Encode(&buf, img); err != nil {
29-
return fmt.Errorf("while encoding to buffer")
34+
return fmt.Errorf("error encoding PNG: %w", err)
35+
}
36+
37+
// Calculate dimensions in text cells
38+
intRows, intCols := computeDesiredTextSize(img)
39+
40+
// Open terminal for direct writing
41+
ttyWriter, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0)
42+
if err != nil {
43+
return fmt.Errorf("error opening /dev/tty: %w", err)
3044
}
45+
defer ttyWriter.Close()
3146

32-
// Begin the inline image sequence with the correct parameter termination.
33-
fmt.Printf("\x1b_Gf=100,a=T;")
47+
// Start Kitty image protocol sequence
48+
_, err = fmt.Fprintf(ttyWriter, "\x1b_Gf=100,a=T,r=%d,c=%d;", intRows, intCols)
49+
if err != nil {
50+
return fmt.Errorf("error writing header: %w", err)
51+
}
3452

35-
encoder := base64.NewEncoder(base64.StdEncoding, os.Stdout)
53+
// Stream base64-encoded image data
54+
encoder := base64.NewEncoder(base64.StdEncoding, ttyWriter)
3655
if _, err := io.Copy(encoder, &buf); err != nil {
37-
return fmt.Errorf("while base64 encoding")
56+
return fmt.Errorf("error writing image data: %w", err)
3857
}
3958
encoder.Close()
4059

41-
// Terminate the escape sequence including newline.
42-
fmt.Printf("\x1b\\\n")
60+
// Terminate the image sequence
61+
_, err = fmt.Fprintf(ttyWriter, "\x1b\\\n")
62+
if err != nil {
63+
return fmt.Errorf("error writing footer: %w", err)
64+
}
65+
4366
return nil
4467
}
68+
69+
// getTerminalDimensions retrieves terminal size in text cells and pixels
70+
func getTerminalDimensions() (rows, cols, pxWidth, pxHeight int) {
71+
// Open /dev/tty for Linux & MacOS , CONOUT$ on Windows
72+
// for writing
73+
ttyWrite, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0)
74+
if err != nil {
75+
ttyWrite, err = os.OpenFile("/dev/tty", os.O_WRONLY, 0)
76+
if err != nil {
77+
fmt.Fprintf(os.Stderr, "failed to open terminal for writing: %v\n", err)
78+
os.Exit(1)
79+
}
80+
}
81+
defer ttyWrite.Close()
82+
83+
// Open terminal for reading responses
84+
ttyRead, err := os.OpenFile("CONOUT$", os.O_RDONLY, 0)
85+
if err != nil {
86+
ttyRead, err = os.OpenFile("/dev/tty", os.O_RDONLY, 0)
87+
if err != nil {
88+
fmt.Fprintf(os.Stderr, "failed to open terminal for reading: %v\n", err)
89+
os.Exit(1)
90+
}
91+
}
92+
defer ttyRead.Close()
93+
94+
// Switch to raw mode and ensure it gets restored
95+
oldState, err := term.MakeRaw(int(ttyRead.Fd()))
96+
if err != nil {
97+
fmt.Fprintf(os.Stderr, "failed to set raw mode: %v\n", err)
98+
os.Exit(1)
99+
}
100+
defer term.Restore(int(ttyRead.Fd()), oldState)
101+
102+
// query the terminal for its dimensions via ANSI escape codes
103+
_, err = ttyWrite.Write([]byte("\033[18t\033[14t"))
104+
if err != nil {
105+
fmt.Fprintf(os.Stderr, "failed to write to terminal: %v\n", err)
106+
os.Exit(1)
107+
}
108+
109+
// Read responses
110+
var buf [32]byte
111+
var response []byte
112+
for {
113+
n, err := ttyRead.Read(buf[:])
114+
if err != nil || n == 0 {
115+
break
116+
}
117+
response = append(response, buf[:n]...)
118+
if bytes.Count(response, []byte("t")) >= 2 {
119+
break
120+
}
121+
}
122+
123+
// Parse text dimensions
124+
reText := regexp.MustCompile(`\033\[8;(\d+);(\d+)t`)
125+
matchesText := reText.FindStringSubmatch(string(response))
126+
if len(matchesText) == 3 {
127+
rows, _ = strconv.Atoi(matchesText[1])
128+
cols, _ = strconv.Atoi(matchesText[2])
129+
}
130+
131+
// Parse pixel dimensions
132+
rePixel := regexp.MustCompile(`\033\[4;(\d+);(\d+)t`)
133+
matchesPixel := rePixel.FindStringSubmatch(string(response))
134+
if len(matchesPixel) == 3 {
135+
pxHeight, _ = strconv.Atoi(matchesPixel[1])
136+
pxWidth, _ = strconv.Atoi(matchesPixel[2])
137+
}
138+
139+
return
140+
}
141+
142+
// getImageAspect returns the images og dimensions/aspect ratio
143+
func getImageAspect(img image.Image) float64 {
144+
145+
// Get original image dimensions (in pixels)
146+
imgWidth := img.Bounds().Dx()
147+
imgHeight := img.Bounds().Dy()
148+
149+
// image pixel aspect ratio.
150+
return float64(imgWidth) / float64(imgHeight)
151+
}
152+
153+
// computeDesiredTextSize calculates and returns the desired number of text cells (r and c)
154+
// that should be used for the image, preserving its aspect ratio.
155+
// It uses both the image dimensions and the terminal's text and pixel dimensions.
156+
func computeDesiredTextSize(img image.Image) (int, int) {
157+
// Get terminal size: text cells and pixel dimensions.
158+
termRows, termCols, termPxWidth, termPxHeight := getTerminalDimensions()
159+
160+
// Get image aspect ratio.
161+
imgAspect := getImageAspect(img)
162+
163+
// Compute the size of one cell (in pixels).
164+
cellWidth := float64(termPxWidth) / float64(termCols)
165+
cellHeight := float64(termPxHeight) / float64(termRows)
166+
cellAspect := cellWidth / cellHeight
167+
168+
// Adjust image aspect ratio to account for the non-square text cells.
169+
effectiveAspect := imgAspect / cellAspect
170+
171+
// Use 90% of available text cells as the maximum.
172+
maxCols := float64(termCols) * 0.9
173+
maxRows := float64(termRows) * 0.9
174+
175+
// Start with the maximum width and compute the height from the effective aspect ratio.
176+
desiredCols := maxCols
177+
desiredRows := desiredCols / effectiveAspect
178+
179+
// If the computed rows exceed the maximum available, recalc based on height.
180+
if desiredRows > maxRows {
181+
desiredRows = maxRows
182+
desiredCols = desiredRows * effectiveAspect
183+
}
184+
185+
// Return as integers.
186+
return int(desiredRows), int(desiredCols)
187+
}

0 commit comments

Comments
 (0)