-
Notifications
You must be signed in to change notification settings - Fork 36
Expand file tree
/
Copy pathterminal.go
More file actions
191 lines (163 loc) · 5.59 KB
/
Copy pathterminal.go
File metadata and controls
191 lines (163 loc) · 5.59 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
package terminal
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/png"
"io"
"os"
"regexp"
"strconv"
"golang.org/x/term"
)
// RenderKittyImg renders an image using the Kitty protocol and aspect-ratio scaling.
func RenderKittyImg(filePath string) error {
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("opening image file: %w", err)
}
defer file.Close()
img, _, err := image.Decode(file)
if err != nil {
return fmt.Errorf("decoding image: %w", err)
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return fmt.Errorf("encoding PNG: %w", err)
}
intRows, intCols, err := textCells(img)
if err != nil {
return err
}
ttyWriter, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0)
if err != nil {
return fmt.Errorf("opening /dev/tty: %w", err)
}
defer ttyWriter.Close()
// Start Kitty image protocol sequence
header := fmt.Sprintf("\x1b_Gf=100,a=T,r=%d,c=%d;", intRows, intCols)
if _, err := io.WriteString(ttyWriter, header); err != nil {
return fmt.Errorf("writing header: %w", err)
}
encoder := base64.NewEncoder(base64.StdEncoding, ttyWriter)
if _, err := io.Copy(encoder, &buf); err != nil {
encoder.Close()
return fmt.Errorf("writing image data: %w", err)
}
if err := encoder.Close(); err != nil {
return fmt.Errorf("closing encoder: %w", err)
}
// Terminate the image sequence
if _, err := io.WriteString(ttyWriter, "\x1b\\\n"); err != nil {
return fmt.Errorf("writing footer: %w", err)
}
return nil
}
// getTerminalDimensions retrieves the terminal dimensions (text cells and pixel sizes) required for the Kitty protocol.
func getTerminalDimensions() (rows, cols, pxWidth, pxHeight int, err error) {
// Open tty for writing; try "CONOUT$" (Windows) then "/dev/tty" (Linux/MacOS)
ttyWrite, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0)
if err != nil {
ttyWrite, err = os.OpenFile("/dev/tty", os.O_WRONLY, 0)
if err != nil {
return 0, 0, 0, 0, fmt.Errorf("failed to open tty for writing: %w", err)
}
}
defer ttyWrite.Close()
// Open tty for reading
ttyRead, err := os.OpenFile("CONOUT$", os.O_RDONLY, 0)
if err != nil {
ttyRead, err = os.OpenFile("/dev/tty", os.O_RDONLY, 0)
if err != nil {
return 0, 0, 0, 0, fmt.Errorf("failed to open tty for reading: %w", err)
}
}
defer ttyRead.Close()
// Set raw mode so we can read terminal responses directly.
oldState, err := term.MakeRaw(int(ttyRead.Fd()))
if err != nil {
return 0, 0, 0, 0, fmt.Errorf("failed to set raw mode: %w", err)
}
defer term.Restore(int(ttyRead.Fd()), oldState)
// Query the terminal for its dimensions via ANSI escape codes.
// \033[18t requests text cell dimensions; \033[14t requests pixel dimensions.
if _, err = ttyWrite.Write([]byte("\033[18t\033[14t")); err != nil {
return 0, 0, 0, 0, fmt.Errorf("failed to send terminal query: %w", err)
}
// Read the terminal response.
var buf [32]byte
var response []byte
for {
n, err := ttyRead.Read(buf[:])
if err != nil {
return 0, 0, 0, 0, fmt.Errorf("failed to read terminal response: %w", err)
}
if n == 0 {
break
}
response = append(response, buf[:n]...)
// Break if expected escape sequences for text and pixel dimensions are detected.
if bytes.Contains(response, []byte("\033[8;")) && bytes.Contains(response, []byte("\033[4;")) {
break
}
}
// Parse text dimensions: "\033[8;<rows>;<cols>t"
reText := regexp.MustCompile(`\033\[8;(\d+);(\d+)t`)
matchesText := reText.FindStringSubmatch(string(response))
if len(matchesText) == 3 {
rows, err = strconv.Atoi(matchesText[1])
if err != nil {
return 0, 0, 0, 0, fmt.Errorf("converting rows: %w", err)
}
cols, err = strconv.Atoi(matchesText[2])
if err != nil {
return 0, 0, 0, 0, fmt.Errorf("converting cols: %w", err)
}
}
// Parse pixel dimensions: "\033[4;<pxHeight>;<pxWidth>t"
rePixel := regexp.MustCompile(`\033\[4;(\d+);(\d+)t`)
matchesPixel := rePixel.FindStringSubmatch(string(response))
if len(matchesPixel) == 3 {
pxHeight, err = strconv.Atoi(matchesPixel[1])
if err != nil {
return 0, 0, 0, 0, fmt.Errorf("converting pixel height: %w", err)
}
pxWidth, err = strconv.Atoi(matchesPixel[2])
if err != nil {
return 0, 0, 0, 0, fmt.Errorf("converting pixel width: %w", err)
}
}
return rows, cols, pxWidth, pxHeight, nil
}
// aspectRatio returns the aspect ratio of the image.
func aspectRatio(img image.Image) float64 {
return float64(img.Bounds().Dx()) / float64(img.Bounds().Dy())
}
// textCells calculates and returns the desired number of text cells (rows and columns)
// to display the image, preserving its aspect ratio.
func textCells(img image.Image) (int, int, error) {
termRows, termCols, termPxWidth, termPxHeight, err := getTerminalDimensions()
if err != nil {
return 0, 0, fmt.Errorf("while getting terminal dimensions: %w", err)
}
imgAspect := aspectRatio(img)
// Calculate the size of one text cell in pixels.
cellWidth := float64(termPxWidth) / float64(termCols)
cellHeight := float64(termPxHeight) / float64(termRows)
cellAspect := cellWidth / cellHeight
// Adjust image aspect ratio to account for non-square text cells.
effectiveAspect := imgAspect / cellAspect
// Use 90% of available text cells as the maximum area.
maxCols := float64(termCols) * 0.9
maxRows := float64(termRows) * 0.9
// Start with maximum width and compute the height using the effective aspect ratio.
desiredCols := maxCols
desiredRows := desiredCols / effectiveAspect
// If computed rows exceed the maximum available, recalc based on maximum rows.
if desiredRows > maxRows {
desiredRows = maxRows
desiredCols = desiredRows * effectiveAspect
}
return int(desiredRows), int(desiredCols), nil
}