diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c2012d..5de6756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## next - Add `Konsole` as a available terminal for iterm2 images - Add `stdin` internal argument to make printer utils testable +- Ignore Error in Kitty if the temporary file has been deleted by the terminal. Fixes `KittySupport::Local` +- Check for Kitty support on terminals via protocol query, instead of static `TERM` environment variable checking ## 0.10.0 - Add `icy_sixel` feature that uses a Rust implementation of Sixel diff --git a/src/printer/kitty.rs b/src/printer/kitty.rs index db693d3..1d02459 100644 --- a/src/printer/kitty.rs +++ b/src/printer/kitty.rs @@ -1,10 +1,11 @@ use crate::error::{ViuError, ViuResult}; use crate::printer::{adjust_offset, find_best_fit, Printer, ReadKey}; +use crate::utils::terminal_size; use crate::Config; use base64::{engine::general_purpose, Engine}; use console::{Key, Term}; -use std::io::Error; use std::io::Write; +use std::io::{Error, ErrorKind}; use std::sync::LazyLock; use tempfile::NamedTempFile; @@ -27,7 +28,7 @@ impl Printer for KittyPrinter { img: &image::DynamicImage, config: &Config, ) -> ViuResult<(u32, u32)> { - match get_kitty_support() { + let result = match get_kitty_support() { KittySupport::None => Err(ViuError::KittyNotSupported), KittySupport::Local => { // print from file @@ -37,7 +38,11 @@ impl Printer for KittyPrinter { // print through escape codes print_remote(stdin, stdout, img, config) } - } + }?; + + print_newline(stdout, config, result.0)?; + + Ok(result) } // TODO: guess_format() here in order to treat PNGs specially (f=100). @@ -45,6 +50,21 @@ impl Printer for KittyPrinter { // fn print_from_file(&self, filename: &str, config: &Config) -> ViuResult<(u32, u32)> {} } +/// The cursor is pushed to the next line by Kitty if the image reaches the terminal's boundary. +/// We must do it only if the image is smaller, otherwise we end up with a blank line. +/// See +/// +/// Could be done with a cursor check through `crossterm::cursor::position`, +/// but that alone doesn't justify enabling the `events` feature. +fn print_newline(stdout: &mut impl Write, config: &Config, width: u32) -> ViuResult { + let (term_w, _) = terminal_size(); + if config.x + (width as u16) < term_w { + writeln!(stdout)?; + } + + Ok(()) +} + #[derive(PartialEq, Eq, Copy, Clone)] /// The extend to which the Kitty graphics protocol can be used. pub enum KittySupport { @@ -58,21 +78,132 @@ pub enum KittySupport { /// Check if Kitty protocol can be used fn check_kitty_support() -> KittySupport { - if let Ok(term) = std::env::var("TERM") { - if term.contains("kitty") || term.contains("ghostty") { - let mut stdout = std::io::stdout(); - let stdin = Term::stdout(); - if has_local_support(&stdin, &mut stdout).is_ok() { - return KittySupport::Local; + let mut stdout = std::io::stdout(); + let term = Term::stdout(); + + // first check if kitty protocol base-line(inline images / remote) is available + if has_remote_support(&term, &mut stdout).is_ok() { + // then test if the current terminal supports reading from a file(local) (for example this is not possible via ssh) + if has_local_support(&term, &mut stdout).is_ok() { + return KittySupport::Local; + } + + return KittySupport::Remote; + } + + KittySupport::None +} + +/// Close the temporary file that was created, filtering out [`NotFound`](ErrorKind::NotFound) errors. +fn close_tmp_file(temp_file: NamedTempFile) -> ViuResult { + // Explicitly clean up when finished with the file because destructor, OS and Kitty are not deterministic. + if let Err(err) = temp_file.close() { + // Proper Kitty terminals *will delete* the file after fully reading it, if it is in a known temporary directory + // so we dont want to error if the file does not exist anymore + if err.kind() != ErrorKind::NotFound { + return Err(err.into()); + } + } + + Ok(()) +} + +/// Send & Wait for the DSR(Device Status Report) query. +fn wait_for_dsr(stdin: &impl ReadKey, stdout: &mut impl Write) -> ViuResult { + write!(stdout, "\x1b[5n")?; + stdout.flush()?; + + let mut response = Vec::new(); + + // assign it once instead of having to allocate a vector with static content in each loop + let end_seq = Key::UnknownEscSeq(vec!['[', '0', 'n']); + + while let Ok(key) = stdin.read_key() { + // The response will end with Esc('x1b'), followed by Backslash('\'). + // Also, break if the Unknown key is found, which is returned when we're not in a tty + // https://sw.kovidgoyal.net/kitty/graphics-protocol/#display-images-on-screen + let should_break = key == end_seq || key == Key::Unknown; + response.push(key); + if should_break { + break; + } + } + + if response.len() == 1 && response.last().unwrap() == &end_seq { + return Ok(()); + } + + Err(ViuError::KittyResponse(response)) +} + +/// Query the terminal whether it can display inline images (the kitty image protocol base-line) +fn has_remote_support(stdin: &impl ReadKey, stdout: &mut impl Write) -> ViuResult { + // send the query + write!( + stdout, + // First query send a query for kitty image protocol + "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\", + )?; + // Then, send "primary device attributes" as a fall-back if kitty protocol is not supported. + // This is practically implemented in all terminals and prevents a infinite loop if kitty is not supported. + write!(stdout, "\x1b[c")?; + stdout.flush()?; + + let mut response = Vec::new(); + + // determine if we had the "primary device attributes" reply, as otherwise "c" *could* be part of another query response beforehand + let mut had_pda: bool = false; + + while let Ok(key) = stdin.read_key() { + // this sequenece is also called "CSI ? 6" in "Terminal Response" at https://vt100.net/docs/vt510-rm/DA1.html + // Though it seems the "6" (and "4") is not actually required if the vt is at a different level + // meaning if we test explicitly for "6" it would never exit + if let Key::UnknownEscSeq(esc_seq) = &key { + if esc_seq.starts_with(&['[', '?']) { + had_pda = true; } + } - return KittySupport::Remote; + // The "primary device attributes" response will end with a "c" character + // see "Terminal Response" at https://vt100.net/docs/vt510-rm/DA1.html + // Alternatively, terminate on unknown keys, this could for example happen in cargo test with a `console::Term` read_key, for some reason + let should_break = (had_pda && key == Key::Char('c')) || key == Key::Unknown; + + response.push(key); + + if should_break { + break; } } - KittySupport::None + + // The Graphics query response + let expected = [ + Key::UnknownEscSeq(['_'].into()), + Key::Char('G'), + Key::Char('i'), + Key::Char('='), + Key::Char('3'), + Key::Char('1'), + Key::Char(';'), + Key::Char('O'), + Key::Char('K'), + Key::UnknownEscSeq(vec!['\\']), + ]; + + // The Graphics query and the device attributes response could theoretically be in any order + // but most terminals will reply in a FIFO order + if response.len() >= expected.len() && response[..expected.len()] == expected { + return Ok(()); + } + + Err(ViuError::KittyResponse(response)) } -/// Query the terminal whether it can display an image from a file +/// Query the terminal whether it can display an image from a file. +/// +/// Note that this function expects at least the base-line kitty support. +/// If this function is run first, this could lead to a infinite loop if kitty is not supported. +/// Run [`has_remote_support`] first and only run this function if that returns `Ok`. fn has_local_support(stdin: &impl ReadKey, stdout: &mut impl Write) -> ViuResult { // create a temp file that will hold a 1x1 image let x = image::RgbaImage::new(1, 1); @@ -93,12 +224,12 @@ fn has_local_support(stdin: &impl ReadKey, stdout: &mut impl Write) -> ViuResult )?; stdout.flush()?; - // collect Kitty's response after the query let mut response = Vec::new(); while let Ok(key) = stdin.read_key() { // The response will end with Esc('x1b'), followed by Backslash('\'). // Also, break if the Unknown key is found, which is returned when we're not in a tty + // https://sw.kovidgoyal.net/kitty/graphics-protocol/#display-images-on-screen let should_break = key == Key::UnknownEscSeq(vec!['\\']) || key == Key::Unknown; response.push(key); if should_break { @@ -106,17 +237,25 @@ fn has_local_support(stdin: &impl ReadKey, stdout: &mut impl Write) -> ViuResult } } - // Explicitly clean up when finished with the file because destructor, OS and Kitty are not deterministic. - temp_file.close()?; + close_tmp_file(temp_file)?; - // Kitty response should end with these 3 Keys if it was successful + // The Graphics query response let expected = [ + Key::UnknownEscSeq(['_'].into()), + Key::Char('G'), + Key::Char('i'), + Key::Char('='), + Key::Char('3'), + Key::Char('1'), + Key::Char(';'), Key::Char('O'), Key::Char('K'), Key::UnknownEscSeq(vec!['\\']), ]; - if response.len() >= expected.len() && response[response.len() - 3..] == expected { + // The Graphics query and the device attributes response could theoretically be in any order + // but most terminals will reply in a FIFO order + if response.len() >= expected.len() && response[..expected.len()] == expected { return Ok(()); } @@ -126,7 +265,7 @@ fn has_local_support(stdin: &impl ReadKey, stdout: &mut impl Write) -> ViuResult /// Print with kitty graphics protocol through a temp file // TODO: try with kitty's supported compression fn print_local( - _stdin: &impl ReadKey, + stdin: &impl ReadKey, stdout: &mut impl Write, img: &image::DynamicImage, config: &Config, @@ -154,11 +293,12 @@ fn print_local( .ok_or_else(|| ViuError::Io(Error::other("Could not convert path to &str")))? ) )?; - writeln!(stdout)?; stdout.flush()?; - // Explicitly clean up when finished with the file because destructor, OS and Kitty are not deterministic. - temp_file.close()?; + // prevent race condition of removing the file before the terminal is finished reading it. + wait_for_dsr(stdin, stdout)?; + + close_tmp_file(temp_file)?; Ok((w, h)) } @@ -199,7 +339,6 @@ fn print_remote( let m = if iter.peek().is_some() { 1 } else { 0 }; write!(stdout, "\x1b_Gm={};{}\x1b\\", m, chunk)?; } - writeln!(stdout)?; stdout.flush()?; Ok((w, h)) } @@ -235,7 +374,7 @@ mod tests { let mut vec = Vec::new(); - let test_data = []; + let test_data = [Key::UnknownEscSeq(vec!['[', '0', 'n'])]; let test_response = TestKeys::new(&test_data); assert_eq!( @@ -245,7 +384,7 @@ mod tests { let result = std::str::from_utf8(&vec).unwrap(); assert!(result.starts_with("\x1b[4;5H\x1b_Gf=32,s=40,v=25,c=40,r=13,a=T,t=t;")); - assert!(result.ends_with("\x1b\\\n")); + assert!(result.ends_with("\x1b\\\x1b[5n")); assert!(test_response.reached_end()); } @@ -273,21 +412,20 @@ mod tests { assert_eq!( result, - "\x1b[6;3H\x1b_Gf=32,a=T,t=d,s=1,v=2,c=1,r=1,m=1;AAAAAAIEBgg=\x1b\\\n" + "\x1b[6;3H\x1b_Gf=32,a=T,t=d,s=1,v=2,c=1,r=1,m=1;AAAAAAIEBgg=\x1b\\" ); assert!(test_response.reached_end()); } #[test] - fn test_kitty_protocol_supported() { + fn test_kitty_supported_remote_and_local() { + // output collected on kitty 0.42.2 + // test kitty protocol support let mut stdout = Vec::new(); - // data returned by the terminal from the query - // Captured from kitty 0.42.2 - let test_stdin_data = [ - // the following response indicated a successful graphical request - Key::UnknownEscSeq(['_'].into()), + let test_data = [ + Key::UnknownEscSeq(vec!['_']), Key::Char('G'), Key::Char('i'), Key::Char('='), @@ -296,16 +434,159 @@ mod tests { Key::Char(';'), Key::Char('O'), Key::Char('K'), - Key::UnknownEscSeq(['\\'].into()), + Key::UnknownEscSeq(vec!['\\']), + Key::UnknownEscSeq(vec!['[', '?', '6']), + Key::Char('2'), + Key::Char(';'), + Key::Char('5'), + Key::Char('2'), + Key::Char(';'), + Key::Char('c'), ]; - let test_stdin = TestKeys::new(&test_stdin_data); + let test_response = TestKeys::new(&test_data); - has_local_support(&test_stdin, &mut stdout).unwrap(); + has_remote_support(&test_response, &mut stdout).unwrap(); + let result = std::str::from_utf8(&stdout).unwrap(); + + assert_eq!(result, "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[c"); + assert!(test_response.reached_end()); + + stdout.clear(); + + // test kitty local protocol support + let mut stdout = Vec::new(); + + let test_data = [ + Key::UnknownEscSeq(vec!['_']), + Key::Char('G'), + Key::Char('i'), + Key::Char('='), + Key::Char('3'), + Key::Char('1'), + Key::Char(';'), + Key::Char('O'), + Key::Char('K'), + Key::UnknownEscSeq(vec!['\\']), + ]; + let test_response = TestKeys::new(&test_data); + + has_local_support(&test_response, &mut stdout).unwrap(); + let result = std::str::from_utf8(&stdout).unwrap(); + + assert!(result.starts_with("\x1b_Gi=31,s=1,v=1,a=q,t=t;")); + assert!(result.ends_with("\x1b\\")); + assert!(test_response.reached_end()); + } + + #[test] + fn test_remote_support_tmux() { + // output collected on tmux 3.5_a (kitty & Konsole) + + // test kitty protocol support + let mut stdout = Vec::new(); + + let test_data = [ + Key::UnknownEscSeq(vec!['[', '?', '1']), + Key::Char(';'), + Key::Char('2'), + Key::Char(';'), + Key::Char('4'), + Key::Char('c'), + ]; + let test_response = TestKeys::new(&test_data); + + has_remote_support(&test_response, &mut stdout).unwrap_err(); + let result = std::str::from_utf8(&stdout).unwrap(); + + assert_eq!(result, "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[c"); + assert!(test_response.reached_end()); + } + + #[test] + fn test_kitty_supported_but_not_local() { + // output collected on konsole 25.08.1 + + // test kitty protocol support + let mut stdout = Vec::new(); + + let test_data = [ + Key::UnknownEscSeq(vec!['_']), + Key::Char('G'), + Key::Char('i'), + Key::Char('='), + Key::Char('3'), + Key::Char('1'), + Key::Char(';'), + Key::Char('O'), + Key::Char('K'), + Key::UnknownEscSeq(vec!['\\']), + Key::UnknownEscSeq(vec!['[', '?', '6']), + Key::Char('2'), + Key::Char(';'), + Key::Char('1'), + Key::Char(';'), + Key::Char('4'), + Key::Char('c'), + ]; + let test_response = TestKeys::new(&test_data); + + has_remote_support(&test_response, &mut stdout).unwrap(); + let result = std::str::from_utf8(&stdout).unwrap(); + + assert_eq!(result, "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[c"); + assert!(test_response.reached_end()); + + stdout.clear(); + + // test kitty local protocol support + let mut stdout = Vec::new(); + + let test_data = [ + Key::UnknownEscSeq(vec!['_']), + Key::Char('G'), + Key::Char('i'), + Key::Char('='), + Key::Char('3'), + Key::Char('1'), + Key::Char(';'), + Key::Char('E'), + Key::Char('N'), + Key::Char('O'), + Key::Char('T'), + Key::Char('S'), + Key::Char('U'), + Key::Char('P'), + Key::Char('P'), + Key::Char('O'), + Key::Char('R'), + Key::Char('T'), + Key::Char('E'), + Key::Char('D'), + Key::Char(':'), + Key::UnknownEscSeq(vec!['\\']), + ]; + let test_response = TestKeys::new(&test_data); + + has_local_support(&test_response, &mut stdout).unwrap_err(); let result = std::str::from_utf8(&stdout).unwrap(); - // assert_eq!(result, "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[c"); assert!(result.starts_with("\x1b_Gi=31,s=1,v=1,a=q,t=t;")); assert!(result.ends_with("\x1b\\")); - assert!(test_stdin.reached_end()); + assert!(test_response.reached_end()); + } + + #[test] + fn test_no_kitty_support() { + let mut stdout = Vec::new(); + + // only the "primary device attributes" + let test_data = [Key::UnknownEscSeq(['[', '?', '6'].into()), Key::Char('c')]; + let test_response = TestKeys::new(&test_data); + + has_remote_support(&test_response, &mut stdout).unwrap_err(); + let result = std::str::from_utf8(&stdout).unwrap(); + + assert_eq!(result, "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[c"); + assert!(test_response.reached_end()); } }