Skip to main content

kitty_graphics_protocol/
terminal.rs

1//! Terminal utilities for the Kitty graphics protocol
2
3use crate::error::{Error, Result};
4use std::io::{self, Read, Write};
5
6/// Terminal window size information
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct WindowSize {
9    /// Number of rows (lines)
10    pub rows: u16,
11    /// Number of columns (characters)
12    pub cols: u16,
13    /// Screen width in pixels
14    pub width: u16,
15    /// Screen height in pixels
16    pub height: u16,
17}
18
19impl WindowSize {
20    /// Get the cell width in pixels
21    pub fn cell_width(&self) -> u16 {
22        if self.cols > 0 {
23            self.width / self.cols
24        } else {
25            0
26        }
27    }
28
29    /// Get the cell height in pixels
30    pub fn cell_height(&self) -> u16 {
31        if self.rows > 0 {
32            self.height / self.rows
33        } else {
34            0
35        }
36    }
37
38    /// Calculate how many cells are needed for an image of given pixel dimensions
39    pub fn cells_for_image(&self, img_width: u32, img_height: u32) -> (u32, u32) {
40        let cell_w = self.cell_width() as u32;
41        let cell_h = self.cell_height() as u32;
42
43        if cell_w == 0 || cell_h == 0 {
44            return (0, 0);
45        }
46
47        let cols = img_width.div_ceil(cell_w);
48        let rows = img_height.div_ceil(cell_h);
49
50        (cols, rows)
51    }
52}
53
54#[cfg(unix)]
55mod unix {
56    use super::*;
57    use libc::{STDOUT_FILENO, TIOCGWINSZ, ioctl, winsize};
58
59    /// Get the terminal window size using TIOCGWINSZ ioctl
60    pub fn get_window_size() -> Result<WindowSize> {
61        unsafe {
62            let mut ws: winsize = std::mem::zeroed();
63            let result = ioctl(STDOUT_FILENO, TIOCGWINSZ, &mut ws);
64
65            if result == -1 {
66                return Err(Error::Io(io::Error::last_os_error()));
67            }
68
69            Ok(WindowSize {
70                rows: ws.ws_row,
71                cols: ws.ws_col,
72                width: ws.ws_xpixel,
73                height: ws.ws_ypixel,
74            })
75        }
76    }
77}
78
79#[cfg(not(unix))]
80mod other {
81    use super::*;
82
83    /// Get the terminal window size (stub for non-Unix systems)
84    pub fn get_window_size() -> Result<WindowSize> {
85        Err(Error::protocol(
86            "get_window_size is only supported on Unix systems",
87        ))
88    }
89}
90
91#[cfg(not(unix))]
92pub use other::get_window_size;
93#[cfg(unix)]
94pub use unix::get_window_size;
95
96/// Query the terminal for window size using CSI 14 t escape code
97/// This works across more terminals but requires terminal interaction
98pub fn query_window_size() -> Result<WindowSize> {
99    let mut stdout = io::stdout();
100    let mut stdin = io::stdin();
101
102    // Save current terminal settings
103    #[cfg(unix)]
104    {
105        use std::os::unix::io::AsRawFd;
106        let fd = stdin.as_raw_fd();
107        let mut termios = std::mem::MaybeUninit::uninit();
108        if unsafe { libc::tcgetattr(fd, termios.as_mut_ptr()) } == 0 {
109            let termios = unsafe { termios.assume_init() };
110            let _ = unsafe { libc::tcsetattr(fd, libc::TCSAFLUSH, &termios) };
111        }
112    }
113
114    // Send CSI 14 t query
115    write!(stdout, "\x1b[14t")?;
116    stdout.flush()?;
117
118    // Read response: ESC [ 4 ; <height> ; <width> t
119    let mut response = Vec::new();
120    let mut buf = [0u8; 1];
121
122    loop {
123        let n = stdin.read(&mut buf)?;
124        if n == 0 {
125            break;
126        }
127        response.push(buf[0]);
128        if buf[0] == b't' {
129            break;
130        }
131        if response.len() > 100 {
132            break; // Safety limit
133        }
134    }
135
136    // Parse response
137    let response_str = String::from_utf8(response).map_err(Error::from)?;
138    parse_size_response(&response_str)
139}
140
141fn parse_size_response(response: &str) -> Result<WindowSize> {
142    // Expected format: ESC[4;<height>;<width>t
143    if !response.starts_with("\x1b[4;") {
144        return Err(Error::InvalidResponse(response.to_string()));
145    }
146
147    let parts: Vec<&str> = response[4..].trim_end_matches('t').split(';').collect();
148    if parts.len() < 2 {
149        return Err(Error::InvalidResponse(response.to_string()));
150    }
151
152    let height: u16 = parts[0]
153        .parse()
154        .map_err(|_| Error::InvalidResponse(response.to_string()))?;
155    let width: u16 = parts[1]
156        .parse()
157        .map_err(|_| Error::InvalidResponse(response.to_string()))?;
158
159    // Get rows/cols using stty or default values
160    let (rows, cols) = get_terminal_size_from_stty()?;
161
162    Ok(WindowSize {
163        rows,
164        cols,
165        width,
166        height,
167    })
168}
169
170#[cfg(unix)]
171fn get_terminal_size_from_stty() -> Result<(u16, u16)> {
172    use std::process::Command;
173
174    let output = Command::new("stty").arg("size").output()?;
175
176    if !output.status.success() {
177        return Err(Error::Io(io::Error::other("stty size failed")));
178    }
179
180    let size_str = String::from_utf8_lossy(&output.stdout);
181    let size_owned = size_str.into_owned();
182    let parts: Vec<&str> = size_owned.split_whitespace().collect();
183
184    if parts.len() < 2 {
185        return Err(Error::InvalidResponse(size_owned));
186    }
187
188    let rows: u16 = parts[0]
189        .parse()
190        .map_err(|_| Error::InvalidResponse(size_owned.clone()))?;
191    let cols: u16 = parts[1]
192        .parse()
193        .map_err(|_| Error::InvalidResponse(size_owned))?;
194
195    Ok((rows, cols))
196}
197
198#[cfg(not(unix))]
199fn get_terminal_size_from_stty() -> Result<(u16, u16)> {
200    // Default values for non-Unix systems
201    Ok((24, 80))
202}
203
204/// Check if the terminal supports the Kitty graphics protocol
205pub fn check_protocol_support() -> Result<bool> {
206    #[cfg(unix)]
207    {
208        use std::os::unix::io::AsRawFd;
209
210        let stdin = io::stdin();
211        let fd = stdin.as_raw_fd();
212
213        // Check if stdin is a TTY
214        if unsafe { libc::isatty(fd) } != 1 {
215            // Not a TTY, can't reliably check - assume supported
216            // This happens when running through cargo run or pipes
217            return Ok(true);
218        }
219
220        let mut stdout = io::stdout();
221
222        // Save original terminal settings
223        let mut original_termios: libc::termios = unsafe { std::mem::zeroed() };
224        if unsafe { libc::tcgetattr(fd, &mut original_termios) } != 0 {
225            // Can't get terminal attributes - assume supported
226            return Ok(true);
227        }
228
229        // Set terminal to raw mode
230        let mut raw_termios = original_termios;
231        unsafe { libc::cfmakeraw(&mut raw_termios) };
232        if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw_termios) } != 0 {
233            // Can't set raw mode - assume supported
234            return Ok(true);
235        }
236
237        // Send query command
238        // a=q means query, i=31 is image ID, s=1,v=1 is 1x1 pixel, f=24 is RGB format
239        let _ = write!(stdout, "\x1b_Ga=q,i=31,s=1,v=1,f=24;AAAA\x1b\\");
240        let _ = stdout.flush();
241
242        // Read response with timeout
243        let mut response = Vec::new();
244        let mut buf = [0u8; 256];
245
246        let start = std::time::Instant::now();
247
248        loop {
249            if start.elapsed() > std::time::Duration::from_millis(200) {
250                break;
251            }
252
253            // Use select for timeout
254            let mut tv = libc::timeval {
255                tv_sec: 0,
256                tv_usec: 50_000, // 50ms
257            };
258
259            // Set up fd_set for select
260            let mut read_fds: libc::fd_set = unsafe { std::mem::zeroed() };
261            unsafe { libc::FD_SET(fd, &mut read_fds) };
262
263            let ready = unsafe {
264                libc::select(
265                    fd + 1,
266                    &mut read_fds,
267                    std::ptr::null_mut(),
268                    std::ptr::null_mut(),
269                    &mut tv,
270                )
271            };
272
273            if ready > 0 {
274                let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
275                if n > 0 {
276                    response.extend_from_slice(&buf[..n as usize]);
277                    // Check if we got the complete response (ends with ESC \)
278                    if response.windows(2).any(|w| *w == [0x1b, b'\\']) {
279                        break;
280                    }
281                } else {
282                    break;
283                }
284            }
285        }
286
287        // Restore original terminal settings
288        unsafe { libc::tcsetattr(fd, libc::TCSANOW, &original_termios) };
289
290        let response_str = String::from_utf8_lossy(&response);
291
292        // Check for valid Kitty graphics protocol response
293        // Response format: ESC _ G i=31;OK ESC \
294        let has_apc = response.windows(3).any(|w| *w == [0x1b, b'_', b'G']);
295        let has_ok = response_str.contains("OK");
296        let has_error = response_str.contains("ENO");
297
298        Ok(has_apc && (has_ok || has_error))
299    }
300
301    #[cfg(not(unix))]
302    {
303        // On non-Unix systems, assume supported
304        Ok(true)
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn test_window_size_calculation() {
314        let ws = WindowSize {
315            rows: 40,
316            cols: 120,
317            width: 1200,
318            height: 800,
319        };
320
321        assert_eq!(ws.cell_width(), 10);
322        assert_eq!(ws.cell_height(), 20);
323
324        let (cols, rows) = ws.cells_for_image(100, 100);
325        assert_eq!(cols, 10);
326        assert_eq!(rows, 5);
327    }
328
329    #[test]
330    fn test_window_size_edge_cases() {
331        let ws = WindowSize {
332            rows: 0,
333            cols: 0,
334            width: 0,
335            height: 0,
336        };
337
338        assert_eq!(ws.cell_width(), 0);
339        assert_eq!(ws.cell_height(), 0);
340        assert_eq!(ws.cells_for_image(100, 100), (0, 0));
341    }
342}