From 75d35ee3d9dcb676ec5ca7eee58b170615a310a1 Mon Sep 17 00:00:00 2001 From: meck Date: Thu, 6 Nov 2025 12:22:42 +0100 Subject: [PATCH 1/5] Use unicode placeholders for kitty image protocol This enables images in tmux. --- src/lib.rs | 6 +- src/protocol.rs | 152 +++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 142 insertions(+), 16 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 4f5b384..5335e2d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,8 @@ use clap::{Parser, ValueEnum}; use graph::GraphImageManager; use serde::Deserialize; +use crate::protocol::PassthruProtocol; + /// Serie - A rich git commit graph in your terminal, like magic 📚 #[derive(Parser)] #[command(version)] @@ -53,7 +55,9 @@ impl From> for protocol::ImageProtocol { match protocol { Some(ImageProtocolType::Auto) => protocol::auto_detect(), Some(ImageProtocolType::Iterm) => protocol::ImageProtocol::Iterm2, - Some(ImageProtocolType::Kitty) => protocol::ImageProtocol::Kitty, + Some(ImageProtocolType::Kitty) => protocol::ImageProtocol::Kitty { + passthru: PassthruProtocol::detect(), + }, None => protocol::auto_detect(), } } diff --git a/src/protocol.rs b/src/protocol.rs index 2efe078..04dd729 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1,39 +1,71 @@ -use std::env; - use base64::Engine; +use once_cell::sync::OnceCell; +use std::env; +use std::sync::atomic::AtomicU16; +use std::sync::atomic::Ordering; // By default assume the Iterm2 is the best protocol to use for all terminals *unless* an env // variable is set that suggests the terminal is probably Kitty. pub fn auto_detect() -> ImageProtocol { // https://sw.kovidgoyal.net/kitty/glossary/#envvar-KITTY_WINDOW_ID if env::var("KITTY_WINDOW_ID").is_ok() { - return ImageProtocol::Kitty; + return ImageProtocol::Kitty { + passthru: PassthruProtocol::detect(), + }; } // https://ghostty.org/docs/help/terminfo if env::var("TERM").is_ok_and(|t| t == "xterm-ghostty") { - return ImageProtocol::Kitty; + return ImageProtocol::Kitty { + passthru: PassthruProtocol::detect(), + }; } ImageProtocol::Iterm2 } +#[derive(Debug, Clone, Copy)] +pub enum PassthruProtocol { + Tmux, + NoPassthru, +} + +impl PassthruProtocol { + pub fn detect() -> Self { + if env::var("TERM").is_ok_and(|term| term.starts_with("tmux")) + || env::var("TERM_PROGRAM").is_ok_and(|term_program| term_program == "tmux") + { + return Self::Tmux; + } + + Self::NoPassthru + } + + fn escape_strings(&self) -> (&'static str, &'static str, &'static str) { + match self { + Self::NoPassthru => ("", "\x1b", ""), + // Tmux requires escapes to be escaped, and some special start/end sequences. + Self::Tmux => ("\x1bPtmux;", "\x1b\x1b", "\x1b\\"), + } + } +} + #[derive(Debug, Clone, Copy)] pub enum ImageProtocol { Iterm2, - Kitty, + Kitty { passthru: PassthruProtocol }, } impl ImageProtocol { pub fn encode(&self, bytes: &[u8], cell_width: usize) -> String { match self { ImageProtocol::Iterm2 => iterm2_encode(bytes, cell_width, 1), - ImageProtocol::Kitty => kitty_encode(bytes, cell_width, 1), + ImageProtocol::Kitty { passthru } => kitty_encode(bytes, cell_width, 1, *passthru), } } pub fn clear_line(&self, y: u16) { match self { ImageProtocol::Iterm2 => {} - ImageProtocol::Kitty => kitty_clear_line(y), + ImageProtocol::Kitty { passthru } => kitty_clear_line(y, *passthru), } } } @@ -53,21 +85,62 @@ fn iterm2_encode(bytes: &[u8], cell_width: usize, cell_height: usize) -> String ) } -// https://sw.kovidgoyal.net/kitty/graphics-protocol/ -fn kitty_encode(bytes: &[u8], cell_width: usize, cell_height: usize) -> String { +#[rustfmt::skip] +static KITTY_DIACRITICS: [char; 297] = [ + '\u{0305}', '\u{030D}', '\u{030E}', '\u{0310}', '\u{0312}', '\u{033D}', '\u{033E}', '\u{033F}', '\u{0346}', '\u{034A}', '\u{034B}', + '\u{034C}', '\u{0350}', '\u{0351}', '\u{0352}', '\u{0357}', '\u{035B}', '\u{0363}', '\u{0364}', '\u{0365}', '\u{0366}', '\u{0367}', + '\u{0368}', '\u{0369}', '\u{036A}', '\u{036B}', '\u{036C}', '\u{036D}', '\u{036E}', '\u{036F}', '\u{0483}', '\u{0484}', '\u{0485}', + '\u{0486}', '\u{0487}', '\u{0592}', '\u{0593}', '\u{0594}', '\u{0595}', '\u{0597}', '\u{0598}', '\u{0599}', '\u{059C}', '\u{059D}', + '\u{059E}', '\u{059F}', '\u{05A0}', '\u{05A1}', '\u{05A8}', '\u{05A9}', '\u{05AB}', '\u{05AC}', '\u{05AF}', '\u{05C4}', '\u{0610}', + '\u{0611}', '\u{0612}', '\u{0613}', '\u{0614}', '\u{0615}', '\u{0616}', '\u{0617}', '\u{0657}', '\u{0658}', '\u{0659}', '\u{065A}', + '\u{065B}', '\u{065D}', '\u{065E}', '\u{06D6}', '\u{06D7}', '\u{06D8}', '\u{06D9}', '\u{06DA}', '\u{06DB}', '\u{06DC}', '\u{06DF}', + '\u{06E0}', '\u{06E1}', '\u{06E2}', '\u{06E4}', '\u{06E7}', '\u{06E8}', '\u{06EB}', '\u{06EC}', '\u{0730}', '\u{0732}', '\u{0733}', + '\u{0735}', '\u{0736}', '\u{073A}', '\u{073D}', '\u{073F}', '\u{0740}', '\u{0741}', '\u{0743}', '\u{0745}', '\u{0747}', '\u{0749}', + '\u{074A}', '\u{07EB}', '\u{07EC}', '\u{07ED}', '\u{07EE}', '\u{07EF}', '\u{07F0}', '\u{07F1}', '\u{07F3}', '\u{0816}', '\u{0817}', + '\u{0818}', '\u{0819}', '\u{081B}', '\u{081C}', '\u{081D}', '\u{081E}', '\u{081F}', '\u{0820}', '\u{0821}', '\u{0822}', '\u{0823}', + '\u{0825}', '\u{0826}', '\u{0827}', '\u{0829}', '\u{082A}', '\u{082B}', '\u{082C}', '\u{082D}', '\u{0951}', '\u{0953}', '\u{0954}', + '\u{0F82}', '\u{0F83}', '\u{0F86}', '\u{0F87}', '\u{135D}', '\u{135E}', '\u{135F}', '\u{17DD}', '\u{193A}', '\u{1A17}', '\u{1A75}', + '\u{1A76}', '\u{1A77}', '\u{1A78}', '\u{1A79}', '\u{1A7A}', '\u{1A7B}', '\u{1A7C}', '\u{1B6B}', '\u{1B6D}', '\u{1B6E}', '\u{1B6F}', + '\u{1B70}', '\u{1B71}', '\u{1B72}', '\u{1B73}', '\u{1CD0}', '\u{1CD1}', '\u{1CD2}', '\u{1CDA}', '\u{1CDB}', '\u{1CE0}', '\u{1DC0}', + '\u{1DC1}', '\u{1DC3}', '\u{1DC4}', '\u{1DC5}', '\u{1DC6}', '\u{1DC7}', '\u{1DC8}', '\u{1DC9}', '\u{1DCB}', '\u{1DCC}', '\u{1DD1}', + '\u{1DD2}', '\u{1DD3}', '\u{1DD4}', '\u{1DD5}', '\u{1DD6}', '\u{1DD7}', '\u{1DD8}', '\u{1DD9}', '\u{1DDA}', '\u{1DDB}', '\u{1DDC}', + '\u{1DDD}', '\u{1DDE}', '\u{1DDF}', '\u{1DE0}', '\u{1DE1}', '\u{1DE2}', '\u{1DE3}', '\u{1DE4}', '\u{1DE5}', '\u{1DE6}', '\u{1DFE}', + '\u{20D0}', '\u{20D1}', '\u{20D4}', '\u{20D5}', '\u{20D6}', '\u{20D7}', '\u{20DB}', '\u{20DC}', '\u{20E1}', '\u{20E7}', '\u{20E9}', + '\u{20F0}', '\u{2CEF}', '\u{2CF0}', '\u{2CF1}', '\u{2DE0}', '\u{2DE1}', '\u{2DE2}', '\u{2DE3}', '\u{2DE4}', '\u{2DE5}', '\u{2DE6}', + '\u{2DE7}', '\u{2DE8}', '\u{2DE9}', '\u{2DEA}', '\u{2DEB}', '\u{2DEC}', '\u{2DED}', '\u{2DEE}', '\u{2DEF}', '\u{2DF0}', '\u{2DF1}', + '\u{2DF2}', '\u{2DF3}', '\u{2DF4}', '\u{2DF5}', '\u{2DF6}', '\u{2DF7}', '\u{2DF8}', '\u{2DF9}', '\u{2DFA}', '\u{2DFB}', '\u{2DFC}', + '\u{2DFD}', '\u{2DFE}', '\u{2DFF}', '\u{A66F}', '\u{A67C}', '\u{A67D}', '\u{A6F0}', '\u{A6F1}', '\u{A8E0}', '\u{A8E1}', '\u{A8E2}', + '\u{A8E3}', '\u{A8E4}', '\u{A8E5}', '\u{A8E6}', '\u{A8E7}', '\u{A8E8}', '\u{A8E9}', '\u{A8EA}', '\u{A8EB}', '\u{A8EC}', '\u{A8ED}', + '\u{A8EE}', '\u{A8EF}', '\u{A8F0}', '\u{A8F1}', '\u{AAB0}', '\u{AAB2}', '\u{AAB3}', '\u{AAB7}', '\u{AAB8}', '\u{AABE}', '\u{AABF}', + '\u{AAC1}', '\u{FE20}', '\u{FE21}', '\u{FE22}', '\u{FE23}', '\u{FE24}', '\u{FE25}', '\u{FE26}', '\u{10A0F}', '\u{10A38}', '\u{1D185}', + '\u{1D186}', '\u{1D187}', '\u{1D188}', '\u{1D189}', '\u{1D1AA}', '\u{1D1AB}', '\u{1D1AC}', '\u{1D1AD}', '\u{1D242}', '\u{1D243}', '\u{1D244}', +]; + +fn kitty_encode( + bytes: &[u8], + cell_width: usize, + cell_height: usize, + passthru: PassthruProtocol, +) -> String { let base64_str = to_base64_str(bytes); let chunk_size = 4096; + let (start, escape, end) = passthru.escape_strings(); + let mut s = String::new(); + s.push_str(start); + let chunks = base64_str.as_bytes().chunks(chunk_size); let total_chunks = chunks.len(); - s.push_str("\x1b_Ga=d,d=C;\x1b\\"); + let id = kitty_image_id(); for (i, chunk) in chunks.enumerate() { - s.push_str("\x1b_G"); + s.push_str(&format!("{escape}_G")); if i == 0 { - s.push_str(&format!("a=T,f=100,c={cell_width},r={cell_height},")); + s.push_str(&format!( + "q=2,a=T,f=100,C=1,U=1,c={cell_width},r={cell_height},i={id}," + )); } if i < total_chunks - 1 { s.push_str("m=1;"); @@ -75,13 +148,62 @@ fn kitty_encode(bytes: &[u8], cell_width: usize, cell_height: usize) -> String { s.push_str("m=0;"); } s.push_str(std::str::from_utf8(chunk).unwrap()); - s.push_str("\x1b\\"); + s.push_str(&format!("{escape}\\")); } + s.push_str(end); + + let (id_diacritic, id_r, id_g, id_b) = ( + (id >> 24) & 0xff, + (id >> 16) & 0xff, + (id >> 8) & 0xff, + id & 0xff, + ); + s.push_str(&format!("\x1b[38;2;{id_r};{id_g};{id_b}m")); + + for y in 0..cell_height { + for x in 0..cell_width { + s.push('\u{10EEEE}'); + + s.push_str(&format!( + "{}", + *KITTY_DIACRITICS.get(y).unwrap_or(&KITTY_DIACRITICS[0]) + )); + + s.push_str(&format!( + "{}", + *KITTY_DIACRITICS.get(x).unwrap_or(&KITTY_DIACRITICS[0]) + )); + s.push_str(&format!( + "{}", + *KITTY_DIACRITICS + .get(id_diacritic as usize) + .unwrap_or(&KITTY_DIACRITICS[0]) + )); + } + } s } -fn kitty_clear_line(y: u16) { +fn kitty_clear_line(y: u16, passthru: PassthruProtocol) { let y = y + 1; // 1-based - print!("\x1b_Ga=d,d=P,x=1,y={y};\x1b\\"); + let (start, escape, end) = passthru.escape_strings(); + print!("{start}{escape}_Ga=d,d=Y,y={y};{escape}\\{end}"); +} + +// If the app is rerun with diffrent images sometimes ghostty resuses the image with the same id +// from the previous run, so tie in the PID with the id +fn kitty_image_id() -> u32 { + + static COUNTER: AtomicU16 = AtomicU16::new(1); + let counter = COUNTER + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |x| { + Some(x.wrapping_add(1)) + }) + .unwrap(); + + static PID_CACHE: OnceCell = OnceCell::new(); + let pid = PID_CACHE.get_or_init(|| std::process::id() as u16); + + ((*pid as u32) << 16) | (counter as u32) } From 1137788db3005975f90a9deb61499847adb4206d Mon Sep 17 00:00:00 2001 From: meck Date: Thu, 6 Nov 2025 12:23:56 +0100 Subject: [PATCH 2/5] Use pixel sizes for cells when generating images The kitty unicode placeholder protocol does not change the aspect ratio when presenting images, this can lead to gaps between images --- src/check.rs | 20 +++++++++++++++++++- src/graph/image.rs | 46 ++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/check.rs b/src/check.rs index 73ceb1b..f00bbc4 100644 --- a/src/check.rs +++ b/src/check.rs @@ -1,4 +1,4 @@ -use ratatui::crossterm::terminal; +use ratatui::crossterm::terminal::{self, WindowSize}; use crate::{ graph::{CellWidthType, Graph}, @@ -53,3 +53,21 @@ fn decide_cell_width_type_from( } } } + +pub fn detect_cell_size() -> Option<(u16, u16)> { + let ws = terminal::window_size().ok()?; + match ws { + WindowSize { + rows, + columns, + width, + height, + } if width == 0 || height == 0 || rows == 0 || columns == 0 => None, + WindowSize { + rows, + columns, + width, + height, + } => Some((width / columns, height / rows)), + } +} diff --git a/src/graph/image.rs b/src/graph/image.rs index fd6ec09..d580670 100644 --- a/src/graph/image.rs +++ b/src/graph/image.rs @@ -133,13 +133,51 @@ pub enum CellWidthType { Single, } +pub struct CellSize { + pub width: u16, + pub height: u16, +} + +impl Default for CellSize { + fn default() -> Self { + Self { + width: 25, + height: 50, + } + } +} + +impl CellSize { + fn new() -> Self { + match crate::check::detect_cell_size() { + Some((width, height)) => Self { width, height }, + None => Self::default(), + } + } +} + impl ImageParams { pub fn new(graph_color_set: &GraphColorSet, cell_width_type: CellWidthType) -> Self { - let (width, height, line_width, circle_inner_radius, circle_outer_radius) = - match cell_width_type { - CellWidthType::Double => (50, 50, 5, 10, 13), - CellWidthType::Single => (25, 50, 3, 7, 10), + let mut cell_size = CellSize::new(); + + // Increase the cell width minimum of 25 while maintaining the aspect ratio. So we dont't + // end up with a too small image to draw into. + let aspect_ratio = cell_size.height as f32 / cell_size.width as f32; + cell_size.width = cell_size.width.max(25); + cell_size.height = (cell_size.width as f32 * aspect_ratio).round() as u16; + + let (width, height, line_width, circle_inner_radius, circle_outer_radius) = { + let calc_image_params = |width: u16, height: u16| -> (u16, u16, u16, u16, u16) { + let inner_radius = ((width as f32) * 0.12 + 4.0).round() as u16; + let line_width = width.div_ceil(10); + (width, height, line_width, inner_radius, inner_radius + 3) }; + match cell_width_type { + CellWidthType::Double => calc_image_params(cell_size.width * 2, cell_size.height), + CellWidthType::Single => calc_image_params(cell_size.width, cell_size.height), + } + }; + let edge_colors = graph_color_set .colors .iter() From af4c7e63444b6634ff1e6d72c30fc744b9d1b600 Mon Sep 17 00:00:00 2001 From: meck Date: Fri, 7 Nov 2025 11:49:20 +0100 Subject: [PATCH 3/5] Fix tests to use default terminal size Autodetection does not match pregenerated images --- src/graph/image.rs | 26 +++++++++++++++++--------- tests/graph.rs | 11 +++++++++-- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/graph/image.rs b/src/graph/image.rs index d580670..5327d39 100644 --- a/src/graph/image.rs +++ b/src/graph/image.rs @@ -157,15 +157,11 @@ impl CellSize { } impl ImageParams { - pub fn new(graph_color_set: &GraphColorSet, cell_width_type: CellWidthType) -> Self { - let mut cell_size = CellSize::new(); - - // Increase the cell width minimum of 25 while maintaining the aspect ratio. So we dont't - // end up with a too small image to draw into. - let aspect_ratio = cell_size.height as f32 / cell_size.width as f32; - cell_size.width = cell_size.width.max(25); - cell_size.height = (cell_size.width as f32 * aspect_ratio).round() as u16; - + pub fn new_with_cell_size( + graph_color_set: &GraphColorSet, + cell_width_type: CellWidthType, + cell_size: CellSize, + ) -> Self { let (width, height, line_width, circle_inner_radius, circle_outer_radius) = { let calc_image_params = |width: u16, height: u16| -> (u16, u16, u16, u16, u16) { let inner_radius = ((width as f32) * 0.12 + 4.0).round() as u16; @@ -197,6 +193,18 @@ impl ImageParams { } } + pub fn new(graph_color_set: &GraphColorSet, cell_width_type: CellWidthType) -> Self { + let mut cell_size = CellSize::new(); + + // Increase the cell width minimum of 25 while maintaining the aspect ratio. So we dont't + // end up with a too small image to draw into. + let aspect_ratio = cell_size.height as f32 / cell_size.width as f32; + cell_size.width = cell_size.width.max(25); + cell_size.height = (cell_size.width as f32 * aspect_ratio).round() as u16; + + Self::new_with_cell_size(graph_color_set, cell_width_type, cell_size) + } + fn edge_color(&self, index: usize) -> image::Rgba { self.edge_colors[index % self.edge_colors.len()] } diff --git a/tests/graph.rs b/tests/graph.rs index b1586e4..80f555b 100644 --- a/tests/graph.rs +++ b/tests/graph.rs @@ -2,7 +2,10 @@ use std::{path::Path, process::Command}; use chrono::{DateTime, Days, NaiveDate, TimeZone, Utc}; use image::{GenericImage, GenericImageView}; -use serie::{color, config, git, graph}; +use serie::{ + color, config, git, + graph::{self, CellSize}, +}; type TestResult = Result<(), Box>; @@ -1129,7 +1132,11 @@ fn generate_and_output_graph_image>(path: P, option: &GenerateGra let cell_width_type = graph::CellWidthType::Double; let repository = git::Repository::load(path.as_ref(), option.sort).unwrap(); let graph = graph::calc_graph(&repository); - let image_params = graph::ImageParams::new(&graph_color_set, cell_width_type); + let image_params = graph::ImageParams::new_with_cell_size( + &graph_color_set, + cell_width_type, + CellSize::default(), + ); let drawing_pixels = graph::DrawingPixels::new(&image_params); let graph_image = graph::build_graph_image(&graph, &image_params, &drawing_pixels); From bba1d3110fbaceaf2a21abfa04ea9da2f40ba5f7 Mon Sep 17 00:00:00 2001 From: meck Date: Fri, 7 Nov 2025 08:02:02 +0100 Subject: [PATCH 4/5] Use escape codes to detect kitty protocol This is much more reliable then checking for envars, especially if connected via SSH or tmux as the envars might be missing, and should work automatically with new terminals supporting the kitty protocol --- Cargo.lock | 5 ++- Cargo.toml | 1 + src/protocol.rs | 112 ++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 102 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ceb3178..d4199d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -933,9 +933,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.169" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libfuzzer-sys" @@ -1770,6 +1770,7 @@ dependencies = [ "fuzzy-matcher", "image", "laurier", + "libc", "once_cell", "ratatui", "rayon", diff --git a/Cargo.toml b/Cargo.toml index e07f7a0..3c4fde6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ image = { version = "0.25.8", default-features = false, features = [ "png", ] } laurier = "0.1.1" +libc = "0.2.177" once_cell = "1.21.3" ratatui = { version = "0.29.0", features = ["serde"] } rayon = "1.11.0" diff --git a/src/protocol.rs b/src/protocol.rs index 04dd729..3bed088 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1,23 +1,27 @@ use base64::Engine; +use libc::{fcntl, F_GETFL, F_SETFL, O_NONBLOCK}; use once_cell::sync::OnceCell; +use ratatui::crossterm::cursor::RestorePosition; +use ratatui::crossterm::cursor::SavePosition; +use ratatui::crossterm::execute; +use ratatui::crossterm::style::Print; +use ratatui::crossterm::terminal::disable_raw_mode; +use ratatui::crossterm::terminal::enable_raw_mode; use std::env; +use std::io; +use std::io::stdout; +use std::io::Read; +use std::os::unix::io::AsRawFd; use std::sync::atomic::AtomicU16; use std::sync::atomic::Ordering; +use std::time::Duration; +use std::time::Instant; -// By default assume the Iterm2 is the best protocol to use for all terminals *unless* an env -// variable is set that suggests the terminal is probably Kitty. +// Use kitty graphics protocol if we can detect it, otherwise fall back to iTerm2 protocol. pub fn auto_detect() -> ImageProtocol { - // https://sw.kovidgoyal.net/kitty/glossary/#envvar-KITTY_WINDOW_ID - if env::var("KITTY_WINDOW_ID").is_ok() { - return ImageProtocol::Kitty { - passthru: PassthruProtocol::detect(), - }; - } - // https://ghostty.org/docs/help/terminfo - if env::var("TERM").is_ok_and(|t| t == "xterm-ghostty") { - return ImageProtocol::Kitty { - passthru: PassthruProtocol::detect(), - }; + let passthru = PassthruProtocol::detect(); + if let Ok(true) = check_kitty_support(passthru) { + return ImageProtocol::Kitty { passthru }; } ImageProtocol::Iterm2 } @@ -194,7 +198,6 @@ fn kitty_clear_line(y: u16, passthru: PassthruProtocol) { // If the app is rerun with diffrent images sometimes ghostty resuses the image with the same id // from the previous run, so tie in the PID with the id fn kitty_image_id() -> u32 { - static COUNTER: AtomicU16 = AtomicU16::new(1); let counter = COUNTER .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |x| { @@ -207,3 +210,84 @@ fn kitty_image_id() -> u32 { ((*pid as u32) << 16) | (counter as u32) } + +struct RawStdIn { + stdin_fd: i32, + original_flags: i32, +} + +impl RawStdIn { + fn new() -> io::Result { + enable_raw_mode()?; + let stdin_fd = std::io::stdin().as_raw_fd(); + let original_flags = unsafe { fcntl(stdin_fd, F_GETFL) }; + unsafe { fcntl(stdin_fd, F_SETFL, original_flags | O_NONBLOCK) }; + Ok(RawStdIn { + stdin_fd, + original_flags, + }) + } +} + +impl Drop for RawStdIn { + fn drop(&mut self) { + unsafe { fcntl(self.stdin_fd, F_SETFL, self.original_flags) }; + disable_raw_mode().ok(); + } +} + +// https://sw.kovidgoyal.net/kitty/graphics-protocol/#querying-support-and-available-transmission-mediums +// All terminals emulators should respond to device attribute query, as a backup +// read in raw mode so we can timeout if no response is received +pub fn check_kitty_support(passthru: PassthruProtocol) -> io::Result { + let (start, escape, end) = passthru.escape_strings(); + let device_attr_query = format!("{start}{escape}[0c{end}"); + let kitty_support_query = + format!("{start}{escape}_Gi=9999,s=1,v=1,a=q,t=d,f=24;AAAA{escape}\\{end}"); + + let _raw_stdin_guard = RawStdIn::new()?; + + execute!( + stdout(), + SavePosition, + Print(kitty_support_query), + Print(device_attr_query), + RestorePosition + )?; + + let stdin = io::stdin(); + let mut response = Vec::new(); + let mut buffer = [0u8; 1]; + let start = Instant::now(); + + loop { + if start.elapsed() > Duration::from_millis(500) { + break; + } + + match stdin.lock().read(&mut buffer) { + Ok(0) => break, // EOF + Ok(_) => { + let byte = buffer[0]; + response.push(byte); + if byte == b'c' + && response.contains(&0x1b) + && response + .rsplitn(2, |&b| b == 0x1b) + .next() + .is_some_and(|s| s.starts_with(b"[?")) + { + break; + } + } + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(1)); + continue; + } + Err(e) => return Err(e), + } + } + + let response = String::from_utf8_lossy(&response).to_string(); + Ok(response.contains("\x1b_Gi=9999;OK")) +} From 08c7ea4d476008c5706e42c6fe6e5cc6dd5e3166 Mon Sep 17 00:00:00 2001 From: meck Date: Fri, 7 Nov 2025 12:32:03 +0100 Subject: [PATCH 5/5] Update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 698be10..79faf96 100644 --- a/README.md +++ b/README.md @@ -493,7 +493,7 @@ The terminals on which each has been confirmed to work are listed below. ### Unsupported environments - Sixel graphics is not supported. -- Terminal multiplexers (screen, tmux, Zellij, etc.) are not supported. +- Tmux is supported when using the Terminal graphics protocol (kitty, Ghosty), Other terminal multiplexers (screen, Zellij, etc.) are not supported. ## Contributing