@@ -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.
1319func 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