From 41d3dbeb3d836e1c9d33fbefa91bcd83a7353229 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Fri, 15 May 2026 02:16:47 +0200 Subject: [PATCH 1/5] Add ASCII graph rendering protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new ImageProtocol::Ascii variant that renders git commit graphs using Unicode box-drawing characters (─ │ ┌ ┐ └ ┘ ├ ┤ ┬ ┴ ┼) instead of terminal graphics protocols. Each graph column becomes a single character cell, with commit nodes rendered as `*` and colored per branch. Auto-detection now conservatively falls back to ASCII when the terminal doesn't support Kitty graphics or recognized iTerm2-compatible protocols (iTerm.app, WezTerm, mintty, VSCode, or LC_TERMINAL=iTerm2). This makes the tool work out-of-the-box in standard terminals like gnome-terminal, alacritty, xterm, and others, while preserving fancy image rendering for terminals that support it. Users can explicitly select ASCII mode with `--protocol ascii` or configure it in their serie config file. --- src/graph/image.rs | 155 +++++++++++++++++++++++++++++++++++++++++---- src/lib.rs | 13 +++- src/protocol.rs | 44 +++++++++++-- 3 files changed, 195 insertions(+), 17 deletions(-) diff --git a/src/graph/image.rs b/src/graph/image.rs index 0d3326d..badc067 100644 --- a/src/graph/image.rs +++ b/src/graph/image.rs @@ -6,6 +6,7 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; +use ratatui::style::{Color, Style}; use rustc_hash::{FxHashMap, FxHashSet}; use crate::{ @@ -15,7 +16,7 @@ use crate::{ geometry::{bounding_box_u32, Point}, Edge, EdgeType, Graph, }, - protocol::{ImageProtocol, PreparedImage}, + protocol::{ImageProtocol, PreparedImage, PreparedImageCell}, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -91,16 +92,24 @@ impl<'a> GraphImageManager<'a> { return; } let image_id = graph_image_id(self.session_nonce, commit_hash); - let graph_row_image = build_single_graph_row_image( - self.graph, - &self.image_params, - &self.drawing_pixels, - self.graph_style, - self.image_width_mode, - commit_hash, - ); - let mut image = - graph_row_image.prepare(self.cell_width_type, self.image_protocol, image_id); + let mut image = if matches!(self.image_protocol, ImageProtocol::Ascii) { + build_ascii_prepared_image( + self.graph, + &self.image_params, + self.image_width_mode, + commit_hash, + ) + } else { + let graph_row_image = build_single_graph_row_image( + self.graph, + &self.image_params, + &self.drawing_pixels, + self.graph_style, + self.image_width_mode, + commit_hash, + ); + graph_row_image.prepare(self.cell_width_type, self.image_protocol, image_id) + }; if let Some(upload_data) = image.take_upload_data() { self.pending_uploads.push(upload_data); } @@ -220,6 +229,130 @@ impl ImageParams { } } +#[derive(Default, Clone, Copy)] +struct AsciiDirections { + up: bool, + down: bool, + left: bool, + right: bool, +} + +fn ascii_symbol(d: AsciiDirections) -> char { + match (d.up, d.down, d.left, d.right) { + (true, true, true, true) => '\u{253C}', // ┼ + (true, true, true, false) => '\u{2524}', // ┤ + (true, true, false, true) => '\u{251C}', // ├ + (true, false, true, true) => '\u{2534}', // ┴ + (false, true, true, true) => '\u{252C}', // ┬ + (true, true, false, false) => '\u{2502}', // │ + (false, false, true, true) => '\u{2500}', // ─ + (true, false, false, true) => '\u{2514}', // └ + (true, false, true, false) => '\u{2518}', // ┘ + (false, true, false, true) => '\u{250C}', // ┌ + (false, true, true, false) => '\u{2510}', // ┐ + (true, false, false, false) => '\u{2502}', // │ (terminator → use full vertical) + (false, true, false, false) => '\u{2502}', // │ + (false, false, true, false) => '\u{2500}', // ─ + (false, false, false, true) => '\u{2500}', // ─ + (false, false, false, false) => ' ', + } +} + +fn edge_directions(edge_type: EdgeType) -> AsciiDirections { + let mut d = AsciiDirections::default(); + match edge_type { + EdgeType::Vertical => { + d.up = true; + d.down = true; + } + EdgeType::Horizontal => { + d.left = true; + d.right = true; + } + EdgeType::Up => d.up = true, + EdgeType::Down => d.down = true, + EdgeType::Left => d.left = true, + EdgeType::Right => d.right = true, + // Rounded-corner edges: the name describes which corner of the cell the curve + // occupies. ╭ (LeftTop) connects down + right, ╮ (RightTop) down + left, + // ╰ (LeftBottom) up + right, ╯ (RightBottom) up + left. + EdgeType::LeftTop => { + d.down = true; + d.right = true; + } + EdgeType::RightTop => { + d.down = true; + d.left = true; + } + EdgeType::LeftBottom => { + d.up = true; + d.right = true; + } + EdgeType::RightBottom => { + d.up = true; + d.left = true; + } + } + d +} + +fn image_color_to_ratatui(c: image::Rgba) -> Color { + Color::Rgb(c[0], c[1], c[2]) +} + +fn build_ascii_prepared_image( + graph: &Graph<'_>, + image_params: &ImageParams, + image_width_mode: GraphImageWidthMode, + commit_hash: &CommitHash, +) -> PreparedImage { + let (pos_x, pos_y) = graph.commit_pos_map[&commit_hash]; + let edges = &graph.edges[pos_y]; + + let max_pos_x = match image_width_mode { + GraphImageWidthMode::Compact => edges.iter().map(|e| e.pos_x).fold(pos_x, usize::max), + GraphImageWidthMode::Fixed => graph.max_pos_x, + }; + let cell_count = max_pos_x + 1; + + let mut cells = Vec::with_capacity(cell_count); + for x in 0..cell_count { + if x == pos_x { + let color = image_color_to_ratatui(image_params.edge_color(pos_x)); + cells.push(PreparedImageCell::new( + "*".to_string(), + Style::default().fg(color), + )); + continue; + } + + let mut dirs = AsciiDirections::default(); + let mut color_idx: Option = None; + for e in edges.iter().filter(|e| e.pos_x == x) { + let ed = edge_directions(e.edge_type); + dirs.up |= ed.up; + dirs.down |= ed.down; + dirs.left |= ed.left; + dirs.right |= ed.right; + // Prefer the color of vertical-running lines, since a "trunk" branch + // passing through is what the eye follows; horizontal hops just borrow + // the same cell. Fall back to whatever the first edge says otherwise. + if color_idx.is_none() || e.edge_type.is_vertically_related() { + color_idx = Some(e.associated_line_pos_x); + } + } + + let symbol = ascii_symbol(dirs); + let style = match color_idx { + Some(idx) => Style::default().fg(image_color_to_ratatui(image_params.edge_color(idx))), + None => Style::default(), + }; + cells.push(PreparedImageCell::new(symbol.to_string(), style)); + } + + PreparedImage::from_cells(cells) +} + fn build_single_graph_row_image( graph: &Graph<'_>, image_params: &ImageParams, diff --git a/src/lib.rs b/src/lib.rs index 0ec111a..3db8096 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,6 +55,7 @@ pub enum ImageProtocolType { Iterm, Kitty, KittyUnicode, + Ascii, } impl From> for protocol::ImageProtocol { @@ -66,6 +67,7 @@ impl From> for protocol::ImageProtocol { Some(ImageProtocolType::KittyUnicode) => protocol::ImageProtocol::KittyUnicode { tmux: protocol::detect_tmux(), }, + Some(ImageProtocolType::Ascii) => protocol::ImageProtocol::Ascii, None => protocol::auto_detect(), } } @@ -138,9 +140,16 @@ pub fn run() -> Result<()> { let keybind = keybind::KeyBind::new(keybind_patch); let max_count = args.max_count; - let image_protocol = args.protocol.or(core_config.option.protocol).into(); + let image_protocol: protocol::ImageProtocol = + args.protocol.or(core_config.option.protocol).into(); let order = args.order.or(core_config.option.order).into(); - let graph_width = args.graph_width.or(core_config.option.graph_width); + // ASCII rendering uses exactly one character per graph column, so override any + // requested "double" width and skip the layout calculation that adds a column. + let graph_width = if matches!(image_protocol, protocol::ImageProtocol::Ascii) { + Some(GraphWidthType::Single) + } else { + args.graph_width.or(core_config.option.graph_width) + }; let graph_style = args.graph_style.or(core_config.option.graph_style).into(); let graph_image_width_mode = graph_config.row_image_width; let initial_selection = args diff --git a/src/protocol.rs b/src/protocol.rs index 3c13309..c2f65ad 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -6,8 +6,9 @@ use std::{ use base64::Engine; use ratatui::style::{Color, Style}; -// 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. +// Detect the best image protocol for the current terminal. Falls back to a plain +// ASCII renderer when no graphics-capable terminal can be identified, so the tool +// still works in terminals like gnome-terminal, alacritty, xterm, etc. pub fn auto_detect() -> ImageProtocol { if detect_kitty_graphics_protocol() { if detect_tmux() { @@ -15,8 +16,10 @@ pub fn auto_detect() -> ImageProtocol { } else { ImageProtocol::Kitty } - } else { + } else if detect_iterm2_graphics_protocol() { ImageProtocol::Iterm2 + } else { + ImageProtocol::Ascii } } @@ -30,6 +33,16 @@ fn detect_kitty_graphics_protocol() -> bool { || env::var("GHOSTTY_RESOURCES_DIR").is_ok() } +fn detect_iterm2_graphics_protocol() -> bool { + // Only enable the iTerm2 inline-image protocol when we can identify a terminal + // that actually supports it. Otherwise the escape sequence is printed as garbage. + let term_program = env::var("TERM_PROGRAM").ok(); + matches!( + term_program.as_deref(), + Some("iTerm.app") | Some("WezTerm") | Some("mintty") | Some("vscode") + ) || env::var("LC_TERMINAL").ok().as_deref() == Some("iTerm2") +} + pub fn detect_tmux() -> bool { env::var("TMUX").is_ok_and(|tmux| !tmux.is_empty()) || env::var("TERM").is_ok_and(|term| term.starts_with("tmux")) @@ -41,6 +54,7 @@ pub enum ImageProtocol { Iterm2, Kitty, KittyUnicode { tmux: bool }, + Ascii, } #[derive(Debug, Clone)] @@ -51,6 +65,14 @@ pub struct PreparedImageCell { } impl PreparedImageCell { + pub fn new(symbol: String, style: Style) -> Self { + Self { + symbol, + style, + skip: false, + } + } + pub fn symbol(&self) -> &str { &self.symbol } @@ -83,6 +105,15 @@ impl PreparedImage { pub fn take_upload_data(&mut self) -> Option { self.upload_data.take() } + + pub fn from_cells(cells: Vec) -> Self { + let cell_width = cells.len(); + Self { + cells, + cell_width, + upload_data: None, + } + } } impl ImageProtocol { @@ -93,6 +124,9 @@ impl ImageProtocol { ImageProtocol::KittyUnicode { tmux } => { return kitty_unicode_prepare(bytes, cell_width, image_id, *tmux); } + // The ASCII renderer doesn't go through PNG bytes — callers build the + // PreparedImage directly from the graph edges via PreparedImage::from_cells. + ImageProtocol::Ascii => unreachable!("ASCII protocol uses PreparedImage::from_cells"), }; let mut cells = Vec::with_capacity(cell_width); cells.push(PreparedImageCell { @@ -119,6 +153,7 @@ impl ImageProtocol { ImageProtocol::Iterm2 => {} ImageProtocol::Kitty => kitty_clear_line(y), ImageProtocol::KittyUnicode { .. } => {} + ImageProtocol::Ascii => {} } } @@ -127,12 +162,13 @@ impl ImageProtocol { ImageProtocol::Iterm2 => {} ImageProtocol::Kitty => kitty_clear(), ImageProtocol::KittyUnicode { .. } => {} + ImageProtocol::Ascii => {} } } pub fn delete_images(&self, image_ids: &[u32]) -> Result<(), std::io::Error> { match self { - ImageProtocol::Iterm2 | ImageProtocol::Kitty => Ok(()), + ImageProtocol::Iterm2 | ImageProtocol::Kitty | ImageProtocol::Ascii => Ok(()), ImageProtocol::KittyUnicode { tmux } => kitty_unicode_delete_images(image_ids, *tmux), } } From 1c0a4ca831f62a330af451ed960a9aa579ee917a Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sun, 17 May 2026 00:21:17 +0200 Subject: [PATCH 2/5] Support rounded corners and bullet node in ASCII graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Honor the configured GraphStyle in ASCII rendering: use rounded corners (╭╮╰╯) for Rounded and angular (┌┐└┘) for Angular. Render commit nodes as ● instead of * for better visual weight. --- src/graph/image.rs | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/graph/image.rs b/src/graph/image.rs index badc067..1efe542 100644 --- a/src/graph/image.rs +++ b/src/graph/image.rs @@ -97,6 +97,7 @@ impl<'a> GraphImageManager<'a> { self.graph, &self.image_params, self.image_width_mode, + self.graph_style, commit_hash, ) } else { @@ -237,19 +238,25 @@ struct AsciiDirections { right: bool, } -fn ascii_symbol(d: AsciiDirections) -> char { +fn ascii_symbol(d: AsciiDirections, style: GraphStyle) -> char { + // T-junctions and crosses are the same in both styles — only the four single + // corners have a rounded variant (╭╮╰╯) versus an angular one (┌┐└┘). + let (top_left, top_right, bottom_left, bottom_right) = match style { + GraphStyle::Rounded => ('\u{256D}', '\u{256E}', '\u{2570}', '\u{256F}'), + GraphStyle::Angular => ('\u{250C}', '\u{2510}', '\u{2514}', '\u{2518}'), + }; match (d.up, d.down, d.left, d.right) { - (true, true, true, true) => '\u{253C}', // ┼ - (true, true, true, false) => '\u{2524}', // ┤ - (true, true, false, true) => '\u{251C}', // ├ - (true, false, true, true) => '\u{2534}', // ┴ - (false, true, true, true) => '\u{252C}', // ┬ - (true, true, false, false) => '\u{2502}', // │ - (false, false, true, true) => '\u{2500}', // ─ - (true, false, false, true) => '\u{2514}', // └ - (true, false, true, false) => '\u{2518}', // ┘ - (false, true, false, true) => '\u{250C}', // ┌ - (false, true, true, false) => '\u{2510}', // ┐ + (true, true, true, true) => '\u{253C}', // ┼ + (true, true, true, false) => '\u{2524}', // ┤ + (true, true, false, true) => '\u{251C}', // ├ + (true, false, true, true) => '\u{2534}', // ┴ + (false, true, true, true) => '\u{252C}', // ┬ + (true, true, false, false) => '\u{2502}', // │ + (false, false, true, true) => '\u{2500}', // ─ + (true, false, false, true) => bottom_left, // └ / ╰ + (true, false, true, false) => bottom_right, // ┘ / ╯ + (false, true, false, true) => top_left, // ┌ / ╭ + (false, true, true, false) => top_right, // ┐ / ╮ (true, false, false, false) => '\u{2502}', // │ (terminator → use full vertical) (false, true, false, false) => '\u{2502}', // │ (false, false, true, false) => '\u{2500}', // ─ @@ -304,6 +311,7 @@ fn build_ascii_prepared_image( graph: &Graph<'_>, image_params: &ImageParams, image_width_mode: GraphImageWidthMode, + graph_style: GraphStyle, commit_hash: &CommitHash, ) -> PreparedImage { let (pos_x, pos_y) = graph.commit_pos_map[&commit_hash]; @@ -320,7 +328,7 @@ fn build_ascii_prepared_image( if x == pos_x { let color = image_color_to_ratatui(image_params.edge_color(pos_x)); cells.push(PreparedImageCell::new( - "*".to_string(), + "\u{25CF}".to_string(), // ● Style::default().fg(color), )); continue; @@ -342,7 +350,7 @@ fn build_ascii_prepared_image( } } - let symbol = ascii_symbol(dirs); + let symbol = ascii_symbol(dirs, graph_style); let style = match color_idx { Some(idx) => Style::default().fg(image_color_to_ratatui(image_params.edge_color(idx))), None => Style::default(), From f153ea9d10c41a05e68d6469b396958a44570748 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sun, 17 May 2026 00:43:17 +0200 Subject: [PATCH 3/5] =?UTF-8?q?Render=20ASCII=20graph=20in=20double=20widt?= =?UTF-8?q?h=20with=20=E2=97=8F/=E2=97=8B=20and=20arrows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Honor --graph-width double in ASCII mode (was previously forced to single). In double-width mode each graph column spans two character cells: a symbol followed by a filler that becomes ─ when a horizontal edge crosses the gap, or a space otherwise. Distinguish merges from branch sources by the corner type in the row: LeftTop/RightTop corners signal a parent merging up from below, so the commit renders as ○ with a > or < arrow pointing into it from the adjacent filler slot. Branch sources (LeftBottom/RightBottom corners) keep the solid ● glyph and a plain ─ filler. Extract render_ascii_row from build_ascii_prepared_image so the row-level rendering can be unit-tested without constructing a full Graph. Adds 38 unit tests covering edge_directions, ascii_symbol for both styles, single and double width row rendering, merge-vs-branch-source discrimination, and > / < arrow placement. --- src/graph/image.rs | 354 +++++++++++++++++++++++++++++++++++++++++---- src/lib.rs | 8 +- 2 files changed, 329 insertions(+), 33 deletions(-) diff --git a/src/graph/image.rs b/src/graph/image.rs index 1efe542..2234134 100644 --- a/src/graph/image.rs +++ b/src/graph/image.rs @@ -98,6 +98,7 @@ impl<'a> GraphImageManager<'a> { &self.image_params, self.image_width_mode, self.graph_style, + self.cell_width_type, commit_hash, ) } else { @@ -307,11 +308,17 @@ fn image_color_to_ratatui(c: image::Rgba) -> Color { Color::Rgb(c[0], c[1], c[2]) } +struct AsciiCell { + dirs: AsciiDirections, + color_idx: Option, +} + fn build_ascii_prepared_image( graph: &Graph<'_>, image_params: &ImageParams, image_width_mode: GraphImageWidthMode, graph_style: GraphStyle, + cell_width_type: CellWidthType, commit_hash: &CommitHash, ) -> PreparedImage { let (pos_x, pos_y) = graph.commit_pos_map[&commit_hash]; @@ -323,39 +330,110 @@ fn build_ascii_prepared_image( }; let cell_count = max_pos_x + 1; - let mut cells = Vec::with_capacity(cell_count); - for x in 0..cell_count { + render_ascii_row( + pos_x, + cell_count, + edges, + image_params, + graph_style, + cell_width_type, + ) +} + +fn render_ascii_row( + pos_x: usize, + cell_count: usize, + edges: &[Edge], + image_params: &ImageParams, + graph_style: GraphStyle, + cell_width_type: CellWidthType, +) -> PreparedImage { + let columns: Vec = (0..cell_count) + .map(|x| { + let mut dirs = AsciiDirections::default(); + let mut color_idx: Option = None; + for e in edges.iter().filter(|e| e.pos_x == x) { + let ed = edge_directions(e.edge_type); + dirs.up |= ed.up; + dirs.down |= ed.down; + dirs.left |= ed.left; + dirs.right |= ed.right; + // Prefer the color of vertical-running lines, since a "trunk" branch + // passing through is what the eye follows; horizontal hops just borrow + // the same cell. Fall back to whatever the first edge says otherwise. + if color_idx.is_none() || e.edge_type.is_vertically_related() { + color_idx = Some(e.associated_line_pos_x); + } + } + AsciiCell { dirs, color_idx } + }) + .collect(); + + // Distinguish "merge into this commit" from "child branched off from this commit": + // both produce horizontal edges at this row, but the corner type tells them apart. + // Merge → LeftTop (╭) / RightTop (╮) — line comes UP from a parent below + // Branch off → LeftBottom (╰) / RightBottom (╯) — line goes UP to a child above + let is_merge_at_row = edges + .iter() + .any(|e| matches!(e.edge_type, EdgeType::LeftTop | EdgeType::RightTop)); + let commit_has_left_entry = is_merge_at_row && columns[pos_x].dirs.left; + let commit_has_right_entry = is_merge_at_row && columns[pos_x].dirs.right; + + let commit_color = image_color_to_ratatui(image_params.edge_color(pos_x)); + let commit_symbol = if is_merge_at_row { + "\u{25CB}" // ○ + } else { + "\u{25CF}" // ● + }; + let commit_style = Style::default().fg(commit_color); + + let mut cells = Vec::with_capacity(cell_count * 2); + for (x, col) in columns.iter().enumerate() { + // --- symbol cell --- if x == pos_x { - let color = image_color_to_ratatui(image_params.edge_color(pos_x)); cells.push(PreparedImageCell::new( - "\u{25CF}".to_string(), // ● - Style::default().fg(color), + commit_symbol.to_string(), + commit_style, )); - continue; + } else { + let symbol = ascii_symbol(col.dirs, graph_style); + let style = match col.color_idx { + Some(idx) => { + Style::default().fg(image_color_to_ratatui(image_params.edge_color(idx))) + } + None => Style::default(), + }; + cells.push(PreparedImageCell::new(symbol.to_string(), style)); } - let mut dirs = AsciiDirections::default(); - let mut color_idx: Option = None; - for e in edges.iter().filter(|e| e.pos_x == x) { - let ed = edge_directions(e.edge_type); - dirs.up |= ed.up; - dirs.down |= ed.down; - dirs.left |= ed.left; - dirs.right |= ed.right; - // Prefer the color of vertical-running lines, since a "trunk" branch - // passing through is what the eye follows; horizontal hops just borrow - // the same cell. Fall back to whatever the first edge says otherwise. - if color_idx.is_none() || e.edge_type.is_vertically_related() { - color_idx = Some(e.associated_line_pos_x); + // --- filler cell (only in double width) --- + if matches!(cell_width_type, CellWidthType::Double) { + // When a merge lands at this row, draw an arrow in the filler slot + // adjacent to the commit so the direction of the incoming branch reads. + // > points right into a commit receiving from the left. + // < points left into a commit receiving from the right. + let arrow_into_next = commit_has_left_entry && x + 1 == pos_x; + let arrow_into_prev = commit_has_right_entry && x == pos_x; + + if arrow_into_next { + cells.push(PreparedImageCell::new(">".to_string(), commit_style)); + } else if arrow_into_prev { + cells.push(PreparedImageCell::new("<".to_string(), commit_style)); + } else if col.dirs.right + || x + 1 < cell_count && columns[x + 1].dirs.left + { + // Horizontal line continues across the gap to the next column. + let style = match col.color_idx.or(columns.get(x + 1).and_then(|c| c.color_idx)) { + Some(idx) => { + Style::default().fg(image_color_to_ratatui(image_params.edge_color(idx))) + } + None => Style::default(), + }; + cells.push(PreparedImageCell::new("\u{2500}".to_string(), style)); // ─ + } else { + cells.push(PreparedImageCell::new(" ".to_string(), Style::default())); } } - - let symbol = ascii_symbol(dirs, graph_style); - let style = match color_idx { - Some(idx) => Style::default().fg(image_color_to_ratatui(image_params.edge_color(idx))), - None => Style::default(), - }; - cells.push(PreparedImageCell::new(symbol.to_string(), style)); } PreparedImage::from_cells(cells) @@ -1388,4 +1466,228 @@ mod tests { let path = Path::new(path); std::fs::create_dir_all(path).unwrap(); } + + // --------------------------------------------------------------------- + // ASCII renderer tests + // --------------------------------------------------------------------- + + fn ascii_image_params() -> ImageParams { + let graph_color_config = GraphColorConfig::default(); + let graph_color_set = GraphColorSet::new(&graph_color_config); + ImageParams::new(&graph_color_set, CellWidthType::Single) + } + + fn symbols(image: &PreparedImage) -> String { + image.cells().iter().map(|c| c.symbol()).collect() + } + + #[rstest] + #[case(EdgeType::Vertical, (true, true, false, false))] + #[case(EdgeType::Horizontal, (false, false, true, true))] + #[case(EdgeType::Up, (true, false, false, false))] + #[case(EdgeType::Down, (false, true, false, false))] + #[case(EdgeType::Left, (false, false, true, false))] + #[case(EdgeType::Right, (false, false, false, true))] + #[case(EdgeType::LeftTop, (false, true, false, true))] + #[case(EdgeType::RightTop, (false, true, true, false))] + #[case(EdgeType::LeftBottom, (true, false, false, true))] + #[case(EdgeType::RightBottom, (true, false, true, false))] + fn test_edge_directions( + #[case] edge: EdgeType, + #[case] expected: (bool, bool, bool, bool), + ) { + let d = edge_directions(edge); + assert_eq!((d.up, d.down, d.left, d.right), expected); + } + + fn dirs(up: bool, down: bool, left: bool, right: bool) -> AsciiDirections { + AsciiDirections { up, down, left, right } + } + + #[rstest] + // T-junctions and cross are style-independent. + #[case(dirs(true, true, true, true), GraphStyle::Rounded, '┼')] + #[case(dirs(true, true, true, false), GraphStyle::Rounded, '┤')] + #[case(dirs(true, true, false, true), GraphStyle::Rounded, '├')] + #[case(dirs(true, false, true, true), GraphStyle::Rounded, '┴')] + #[case(dirs(false, true, true, true), GraphStyle::Rounded, '┬')] + // Straight lines. + #[case(dirs(true, true, false, false), GraphStyle::Rounded, '│')] + #[case(dirs(false, false, true, true), GraphStyle::Rounded, '─')] + // Corners differ between styles. + #[case(dirs(true, false, false, true), GraphStyle::Rounded, '╰')] + #[case(dirs(true, false, true, false), GraphStyle::Rounded, '╯')] + #[case(dirs(false, true, false, true), GraphStyle::Rounded, '╭')] + #[case(dirs(false, true, true, false), GraphStyle::Rounded, '╮')] + #[case(dirs(true, false, false, true), GraphStyle::Angular, '└')] + #[case(dirs(true, false, true, false), GraphStyle::Angular, '┘')] + #[case(dirs(false, true, false, true), GraphStyle::Angular, '┌')] + #[case(dirs(false, true, true, false), GraphStyle::Angular, '┐')] + // Single-direction "stubs" promote to a full vertical or horizontal. + #[case(dirs(true, false, false, false), GraphStyle::Rounded, '│')] + #[case(dirs(false, true, false, false), GraphStyle::Rounded, '│')] + #[case(dirs(false, false, true, false), GraphStyle::Rounded, '─')] + #[case(dirs(false, false, false, true), GraphStyle::Rounded, '─')] + // Empty cell is a space. + #[case(dirs(false, false, false, false), GraphStyle::Rounded, ' ')] + fn test_ascii_symbol( + #[case] d: AsciiDirections, + #[case] style: GraphStyle, + #[case] expected: char, + ) { + assert_eq!(ascii_symbol(d, style), expected); + } + + #[test] + fn test_render_ascii_row_single_width_simple_branch() { + // Branch source at pos_x=1: a child branches off to col 3. + // col 0: ╰ (LeftBottom) + // col 1: ● commit (has Left + Down + Right at its col) + // col 2: ─ (Horizontal) + // col 3: ╯ (RightBottom) + let edges = vec![ + Edge::new(EdgeType::LeftBottom, 0, 0), + Edge::new(EdgeType::Left, 1, 0), + Edge::new(EdgeType::Down, 1, 1), + Edge::new(EdgeType::Right, 1, 3), + Edge::new(EdgeType::Horizontal, 2, 3), + Edge::new(EdgeType::RightBottom, 3, 3), + ]; + let params = ascii_image_params(); + let image = render_ascii_row( + 1, 4, &edges, ¶ms, GraphStyle::Rounded, CellWidthType::Single, + ); + assert_eq!(symbols(&image), "╰●─╯"); + } + + #[test] + fn test_render_ascii_row_single_width_angular_corners() { + let edges = vec![ + Edge::new(EdgeType::LeftBottom, 0, 0), + Edge::new(EdgeType::Left, 1, 0), + Edge::new(EdgeType::Down, 1, 1), + Edge::new(EdgeType::Right, 1, 3), + Edge::new(EdgeType::Horizontal, 2, 3), + Edge::new(EdgeType::RightBottom, 3, 3), + ]; + let params = ascii_image_params(); + let image = render_ascii_row( + 1, 4, &edges, ¶ms, GraphStyle::Angular, CellWidthType::Single, + ); + assert_eq!(symbols(&image), "└●─┘"); + } + + #[test] + fn test_render_ascii_row_single_width_straight_vertical() { + // A commit on a straight branch with another branch passing vertically next to it. + // col 0: │ (Vertical, unrelated branch) + // col 3: ● commit + let edges = vec![ + Edge::new(EdgeType::Vertical, 0, 0), + Edge::new(EdgeType::Up, 3, 3), + Edge::new(EdgeType::Down, 3, 3), + ]; + let params = ascii_image_params(); + let image = render_ascii_row( + 3, 4, &edges, ¶ms, GraphStyle::Rounded, CellWidthType::Single, + ); + assert_eq!(symbols(&image), "│ ●"); + } + + #[test] + fn test_render_ascii_row_branch_source_stays_solid_dot() { + // Branch source: only LeftBottom/RightBottom corners → commit must stay ●. + let edges = vec![ + Edge::new(EdgeType::LeftBottom, 0, 0), + Edge::new(EdgeType::Left, 1, 0), + Edge::new(EdgeType::Down, 1, 1), + Edge::new(EdgeType::Right, 1, 3), + Edge::new(EdgeType::Horizontal, 2, 3), + Edge::new(EdgeType::RightBottom, 3, 3), + ]; + let params = ascii_image_params(); + let image = render_ascii_row( + 1, 4, &edges, ¶ms, GraphStyle::Rounded, CellWidthType::Single, + ); + // Commit at index 1 should still be ● because there are no top corners. + assert_eq!(image.cells()[1].symbol(), "●"); + } + + #[test] + fn test_render_ascii_row_merge_uses_open_circle() { + // Merge into commit at pos_x=2 from a parent below at col 0. + // col 0: ╭ (LeftTop) — line comes up from below, then goes right + // col 1: ─ (Horizontal) + // col 2: ○ merge commit (has Left edge at its col) + let edges = vec![ + Edge::new(EdgeType::LeftTop, 0, 0), + Edge::new(EdgeType::Horizontal, 1, 0), + Edge::new(EdgeType::Left, 2, 0), + Edge::new(EdgeType::Up, 2, 2), + Edge::new(EdgeType::Down, 2, 2), + ]; + let params = ascii_image_params(); + let image = render_ascii_row( + 2, 3, &edges, ¶ms, GraphStyle::Rounded, CellWidthType::Single, + ); + assert_eq!(symbols(&image), "╭─○"); + } + + #[test] + fn test_render_ascii_row_double_width_branch_source() { + // Same branch-source row as above, in double width. Filler chars span the gap: + // `─` where a horizontal continues, space otherwise. No arrows because not a merge. + let edges = vec![ + Edge::new(EdgeType::LeftBottom, 0, 0), + Edge::new(EdgeType::Left, 1, 0), + Edge::new(EdgeType::Down, 1, 1), + Edge::new(EdgeType::Right, 1, 3), + Edge::new(EdgeType::Horizontal, 2, 3), + Edge::new(EdgeType::RightBottom, 3, 3), + ]; + let params = ascii_image_params(); + let image = render_ascii_row( + 1, 4, &edges, ¶ms, GraphStyle::Rounded, CellWidthType::Double, + ); + // 4 columns × 2 chars: ╰─ ●─ ── ╯ + assert_eq!(symbols(&image), "╰─●───╯ "); + } + + #[test] + fn test_render_ascii_row_double_width_merge_left_arrow() { + // Merge entering merge commit at col 2 from the left. Expect `>` arrow in the + // filler slot immediately before ○. + let edges = vec![ + Edge::new(EdgeType::LeftTop, 0, 0), + Edge::new(EdgeType::Horizontal, 1, 0), + Edge::new(EdgeType::Left, 2, 0), + Edge::new(EdgeType::Up, 2, 2), + Edge::new(EdgeType::Down, 2, 2), + ]; + let params = ascii_image_params(); + let image = render_ascii_row( + 2, 3, &edges, ¶ms, GraphStyle::Rounded, CellWidthType::Double, + ); + // 3 cols × 2 chars: ╭─ ─> ○_ — arrow at filler of col 1. + assert_eq!(symbols(&image), "╭──>○ "); + } + + #[test] + fn test_render_ascii_row_double_width_merge_right_arrow() { + // Merge entering merge commit at col 0 from the right. Expect `<` arrow in the + // filler slot immediately after ○. + let edges = vec![ + Edge::new(EdgeType::Right, 0, 2), + Edge::new(EdgeType::Horizontal, 1, 2), + Edge::new(EdgeType::RightTop, 2, 2), + Edge::new(EdgeType::Up, 0, 0), + Edge::new(EdgeType::Down, 0, 0), + ]; + let params = ascii_image_params(); + let image = render_ascii_row( + 0, 3, &edges, ¶ms, GraphStyle::Rounded, CellWidthType::Double, + ); + // ○<──╮ — arrow at index 1 (filler of commit's col). + assert_eq!(symbols(&image), "○<──╮ "); + } } diff --git a/src/lib.rs b/src/lib.rs index 3db8096..81d31c5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -143,13 +143,7 @@ pub fn run() -> Result<()> { let image_protocol: protocol::ImageProtocol = args.protocol.or(core_config.option.protocol).into(); let order = args.order.or(core_config.option.order).into(); - // ASCII rendering uses exactly one character per graph column, so override any - // requested "double" width and skip the layout calculation that adds a column. - let graph_width = if matches!(image_protocol, protocol::ImageProtocol::Ascii) { - Some(GraphWidthType::Single) - } else { - args.graph_width.or(core_config.option.graph_width) - }; + let graph_width = args.graph_width.or(core_config.option.graph_width); let graph_style = args.graph_style.or(core_config.option.graph_style).into(); let graph_image_width_mode = graph_config.row_image_width; let initial_selection = args From fbe5c972abab7c9ff3250fa7bac6b9babe6b1f84 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sun, 17 May 2026 01:40:58 +0200 Subject: [PATCH 4/5] Document the ASCII protocol and auto-detect fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update README, mdBook docs, and JSON config schema to cover the new ASCII rendering mode added in this branch. Also reframes the project tagline from "uses image-display protocols" to "uses image-display protocols where available, with a Unicode fallback otherwise", and rewrites the FAQ entry that previously stated there was no fallback. - README: add `ascii` to the protocol enum in the help block and to the "Supported terminals" list; soften the tagline. - docs/introduction: match README tagline. - docs/faq: replace "no fallback" answer with a list of the supported rendering modes and how to force ASCII. - docs/configurations/config-file-format: add `ascii` to the `core.option.protocol` enum and describe what each value does. - docs/getting-started/command-line-options: list `ascii`, document the new auto-detect order (Kitty → iTerm-family terminals → ASCII), and note that ASCII honors --graph-style and --graph-width. - docs/getting-started/compatibility: add an "ASCII fallback" section describing the supported glyph variants, expand the tmux entry to cover the ASCII case, and note that previously-unsupported environments (Sixel-only terminals, other multiplexers) still work via ASCII. - config.schema.json: add `ascii` to the protocol enum. --- README.md | 10 ++++--- config.schema.json | 5 ++-- docs/src/configurations/config-file-format.md | 11 ++++---- docs/src/faq/index.md | 10 +++---- .../getting-started/command-line-options.md | 12 ++++++-- docs/src/getting-started/compatibility.md | 28 +++++++++++++------ docs/src/introduction/index.md | 2 +- 7 files changed, 50 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 0e979d5..d43d383 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A rich git commit graph in your terminal, like magic 📚 ## About -Serie ([`/zéːriə/`](https://lusingander.github.io/serie/faq/index.html#how-do-i-pronounce-serie)) is a TUI application that uses the terminal emulators' image display protocol to render commit graphs like `git log --graph --all`. +Serie ([`/zéːriə/`](https://lusingander.github.io/serie/faq/index.html#how-do-i-pronounce-serie)) is a TUI application that renders commit graphs like `git log --graph --all`, using a terminal emulator's image-display protocol for high-quality output where available and a Unicode box-drawing fallback in any other terminal. ### Why? @@ -70,7 +70,7 @@ Usage: serie [OPTIONS] Options: -n, --max-count Maximum number of commits to render - -p, --protocol Image protocol to render graph [default: auto] [possible values: auto, iterm, kitty, kitty-unicode] + -p, --protocol Image protocol to render graph [default: auto] [possible values: auto, iterm, kitty, kitty-unicode, ascii] -o, --order Commit ordering algorithm [default: chrono] [possible values: chrono, topo] -g, --graph-width Commit graph image cell width [default: auto] [possible values: auto, double, single] -s, --graph-style Commit graph image edge style [default: rounded] [possible values: rounded, angular] @@ -112,17 +112,19 @@ For details on how to set commands, see [User Command](https://lusingander.githu ### Supported terminals -These image protocols are supported: +These rendering modes are supported: - [Inline Images Protocol (iTerm2)](https://iterm2.com/documentation-images.html) - [Terminal graphics protocol (kitty)](https://sw.kovidgoyal.net/kitty/graphics-protocol/) - Supports both the existing graphics protocol mode and the [Unicode placeholder](https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders) mode. +- ASCII / Unicode box-drawing fallback (works in any terminal) + - Used automatically when `auto` cannot identify a graphics-capable terminal, or selected explicitly with `--protocol ascii`. For more information, see [Compatibility](https://lusingander.github.io/serie/getting-started/compatibility.html). ### Partially supported environments -- tmux is supported only when using the kitty Unicode placeholder protocol. +- tmux is supported only when using the kitty Unicode placeholder protocol (image rendering) or the ASCII fallback. ### Unsupported environments diff --git a/config.schema.json b/config.schema.json index ba1068a..1f9edc2 100644 --- a/config.schema.json +++ b/config.schema.json @@ -14,12 +14,13 @@ "properties": { "protocol": { "type": "string", - "description": "The protocol type for rendering images of commit graphs. The value specified in the command line argument takes precedence.", + "description": "The protocol used to render commit graphs. 'auto' autodetects the best available mode for the current terminal and falls back to 'ascii' when no graphics protocol is detected. The value specified in the command line argument takes precedence.", "enum": [ "auto", "iterm", "kitty", - "kitty-unicode" + "kitty-unicode", + "ascii" ], "default": "auto" }, diff --git a/docs/src/configurations/config-file-format.md b/docs/src/configurations/config-file-format.md index 59ff6b9..6c96e22 100644 --- a/docs/src/configurations/config-file-format.md +++ b/docs/src/configurations/config-file-format.md @@ -109,15 +109,16 @@ divider_fg = "dark-gray" ### `core.option.protocol` -The protocol type for rendering images of commit graphs. +The protocol used to render commit graphs. - type: `string` (enum) - default: `auto` - possible values: - - `auto` - - `iterm` - - `kitty` - - `kitty-unicode` + - `auto` — autodetect the best available mode for the current terminal. Selects an image protocol when one is detected (Kitty, Ghostty, iTerm2, WezTerm, mintty, VSCode); otherwise falls back to `ascii`. + - `iterm` — [iTerm2 inline images protocol](https://iterm2.com/documentation-images.html) + - `kitty` — [kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) + - `kitty-unicode` — kitty graphics protocol with [Unicode placeholders](https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders) (works under tmux) + - `ascii` — Unicode box-drawing fallback (`● │ ─ ╭ ╮ ╰ ╯`). Works in any terminal. The value specified in the command line argument takes precedence. diff --git a/docs/src/faq/index.md b/docs/src/faq/index.md index 13e4df9..cb52a9c 100644 --- a/docs/src/faq/index.md +++ b/docs/src/faq/index.md @@ -2,18 +2,18 @@ ## Why doesn't the graph display? -Serie displays graphs using specific terminal graphics protocols (such as Kitty and iTerm2 inline images). +Serie renders graphs in one of two ways, depending on what your terminal supports: -**If your terminal emulator doesn't support one of these protocols, Serie will not work — there is no fallback or workaround.** +1. **High-quality images** via the Kitty or iTerm2 inline-image protocols, used automatically in terminals that support them. +2. **Unicode box-drawing characters** (`● │ ─ ╭ ╮ ╰ ╯`), used in any other terminal as an automatic fallback. -This is a fundamental requirement, not a limitation that can be worked around. -For a list of supported terminal emulators and compatible environments, see [Compatibility](../getting-started/compatibility.md). +If you see no graph at all, the most likely cause is that `auto` picked an image protocol your terminal does not actually support. Try `serie --protocol ascii` to force the Unicode fallback, or see [Compatibility](../getting-started/compatibility.md) for the list of terminals that support each image protocol. ## What are the advantages over other git TUI clients? Compared to other git TUI clients, Serie offers the following advantages: -- High-quality graph visualization using terminal graphics protocols +- High-quality graph visualization using terminal graphics protocols (with a Unicode fallback for terminals that lack them) - Simple and clean interface On the other hand, Serie may not be for you if: diff --git a/docs/src/getting-started/command-line-options.md b/docs/src/getting-started/command-line-options.md index 806306e..d174add 100644 --- a/docs/src/getting-started/command-line-options.md +++ b/docs/src/getting-started/command-line-options.md @@ -9,11 +9,17 @@ It behaves similarly to the `--max-count` option of `git log`. ## -p, --protocol \ -A protocol type for rendering images of commit graphs. +The protocol used to render commit graphs. -_Possible values:_ `auto`, `iterm`, `kitty`, `kitty-unicode` +_Possible values:_ `auto`, `iterm`, `kitty`, `kitty-unicode`, `ascii` -By default `auto` will guess the best supported protocol for the current terminal (if listed in [Supported terminal emulators](./compatibility.md#supported-terminal-emulators)). +By default `auto` will pick the best supported mode for the current terminal: + +1. Kitty graphics protocol if `KITTY_WINDOW_ID`, `GHOSTTY_RESOURCES_DIR`, or `TERM=xterm-ghostty` is set (uses the Unicode-placeholder variant under tmux). +2. iTerm2 inline images if `TERM_PROGRAM` is `iTerm.app`, `WezTerm`, `mintty`, or `vscode`, or if `LC_TERMINAL=iTerm2`. +3. Otherwise the `ascii` fallback, which uses Unicode box-drawing characters and works in any terminal. + +The `ascii` value can also be selected explicitly — useful if `auto` misidentifies your terminal or if you just want plain text. ASCII rendering honors `--graph-style rounded|angular` (corner glyphs) and `--graph-width single|double` (single character per branch column, or two characters with `●`/`○` and arrow markers on merge commits). ## -o, --order \ diff --git a/docs/src/getting-started/compatibility.md b/docs/src/getting-started/compatibility.md index 5526ea6..f817c9b 100644 --- a/docs/src/getting-started/compatibility.md +++ b/docs/src/getting-started/compatibility.md @@ -2,13 +2,14 @@ ## Supported terminal emulators -These image protocols are supported: +Serie supports three rendering modes: -- [Inline Images Protocol (iTerm2)](https://iterm2.com/documentation-images.html) -- [Terminal graphics protocol (kitty)](https://sw.kovidgoyal.net/kitty/graphics-protocol/) +- [Inline Images Protocol (iTerm2)](https://iterm2.com/documentation-images.html) — high-quality PNG images +- [Terminal graphics protocol (kitty)](https://sw.kovidgoyal.net/kitty/graphics-protocol/) — high-quality PNG images - Supports both the existing graphics protocol mode and [the Unicode placeholder](https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders) mode. +- **ASCII / Unicode box-drawing fallback** — works in any terminal (gnome-terminal, alacritty, xterm, Windows Terminal, etc.). Used automatically when `auto` cannot identify a graphics-capable terminal, or by passing `--protocol ascii`. -The terminals on which each has been confirmed to work are listed below. +The terminals on which each image protocol has been confirmed to work are listed below. ### Inline Images Protocol @@ -30,13 +31,24 @@ The terminals on which each has been confirmed to work are listed below. Rendering using Unicode Placeholder is available by explicitly specifying `kitty-unicode` as `protocol` option or config. +### ASCII fallback + +The `ascii` protocol renders graphs with Unicode box-drawing characters and works in any terminal — no image protocol required. Auto-detect falls back to this mode when none of the image protocols above can be identified. + +It honors: + +- `--graph-style rounded` (default) — uses `╭ ╮ ╰ ╯` for corners. +- `--graph-style angular` — uses `┌ ┐ └ ┘` for corners. +- `--graph-width single` — one character per branch column. +- `--graph-width double` — two characters per branch column, with `○` and `> / <` arrow markers on merge commits. + ### Partially supported environments -- tmux is supported only when using the kitty Unicode placeholder protocol. - - Requires `set -g allow-passthrough on` in tmux.conf (version 3.2+). +- tmux is supported when using the kitty Unicode placeholder protocol (`kitty-unicode`) or the ASCII fallback (`ascii`). It is not supported with the plain `kitty` or `iterm` image protocols. + - The kitty Unicode placeholder requires `set -g allow-passthrough on` in tmux.conf (version 3.2+). The ASCII fallback has no special requirements. ### Unsupported environments -- Sixel graphics is not supported. -- Other terminal multiplexers (screen, Zellij, etc.) other than those listed in [Partially supported environments](#partially-supported-environments) are not supported. +- Sixel graphics is not supported (for image rendering). The `ascii` fallback still works. +- Other terminal multiplexers (screen, Zellij, etc.) are not supported for image rendering. The `ascii` fallback still works. - Windows is not officially supported. Please refer to [the related issue](https://github.com/lusingander/serie/issues/147#issuecomment-4192875627). diff --git a/docs/src/introduction/index.md b/docs/src/introduction/index.md index 1b6de82..029bb24 100644 --- a/docs/src/introduction/index.md +++ b/docs/src/introduction/index.md @@ -1,6 +1,6 @@ # Introduction -**Serie** ([`/zéːriə/`](https://lusingander.github.io/serie/faq/index.html#how-do-i-pronounce-serie)) is a TUI application that uses the terminal emulators' image display protocol to render commit graphs like `git log --graph --all`. +**Serie** ([`/zéːriə/`](https://lusingander.github.io/serie/faq/index.html#how-do-i-pronounce-serie)) is a TUI application that renders commit graphs like `git log --graph --all`, using a terminal emulator's image-display protocol for high-quality output where available and a Unicode box-drawing fallback in any other terminal. From d34eb08522dc2a37a1cc7f935885170a4c57e982 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sun, 17 May 2026 02:01:16 +0200 Subject: [PATCH 5/5] Fall back to ASCII for iTerm2 inside tmux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auto_detect() correctly degraded the Kitty branch to KittyUnicode (which wraps escapes in a tmux passthrough envelope) when running under tmux, but the iTerm2 branch had no equivalent guard. Since iterm2_encode emits a raw \x1b]1337;...\x07 OSC sequence with no passthrough wrapping, a tmux session that leaks TERM_PROGRAM=iTerm.app (the common case) would get the iTerm2 protocol selected and silently render nothing — defeating the whole point of the ASCII fallback added in this branch. Gate iTerm2 on !detect_tmux(). Under tmux without Kitty, auto_detect now returns Ascii, which matches what compatibility.md already claims. Extract the if/else into decide_protocol(kitty, iterm, tmux) so the matrix is testable without process-global env-var mocking. Adds 7 unit tests covering every combination of signals. Reported in code review at jpeletier/serie#1. --- src/protocol.rs | 80 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/src/protocol.rs b/src/protocol.rs index c2f65ad..993b6c5 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -10,13 +10,24 @@ use ratatui::style::{Color, Style}; // ASCII renderer when no graphics-capable terminal can be identified, so the tool // still works in terminals like gnome-terminal, alacritty, xterm, etc. pub fn auto_detect() -> ImageProtocol { - if detect_kitty_graphics_protocol() { - if detect_tmux() { + decide_protocol( + detect_kitty_graphics_protocol(), + detect_iterm2_graphics_protocol(), + detect_tmux(), + ) +} + +fn decide_protocol(kitty: bool, iterm: bool, tmux: bool) -> ImageProtocol { + if kitty { + if tmux { ImageProtocol::KittyUnicode { tmux: true } } else { ImageProtocol::Kitty } - } else if detect_iterm2_graphics_protocol() { + } else if iterm && !tmux { + // The iTerm2 inline-image OSC sequence has no tmux-passthrough variant in + // this code path, so tmux would swallow it. Fall through to ASCII instead + // — this also matches what the compatibility docs promise for tmux users. ImageProtocol::Iterm2 } else { ImageProtocol::Ascii @@ -638,3 +649,66 @@ fn passthrough_escapes(tmux: bool) -> (&'static str, &'static str, &'static str) ("", "\x1b", "") } } + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_proto(actual: ImageProtocol, expected: ImageProtocol) { + match (actual, expected) { + (ImageProtocol::Kitty, ImageProtocol::Kitty) + | (ImageProtocol::Iterm2, ImageProtocol::Iterm2) + | (ImageProtocol::Ascii, ImageProtocol::Ascii) => {} + ( + ImageProtocol::KittyUnicode { tmux: a }, + ImageProtocol::KittyUnicode { tmux: b }, + ) if a == b => {} + (a, b) => panic!("expected {b:?}, got {a:?}"), + } + } + + #[test] + fn auto_detect_kitty_no_tmux_uses_plain_kitty() { + assert_proto(decide_protocol(true, false, false), ImageProtocol::Kitty); + } + + #[test] + fn auto_detect_kitty_in_tmux_uses_unicode_placeholder() { + // Kitty has a tmux-passthrough variant — use it. + assert_proto( + decide_protocol(true, false, true), + ImageProtocol::KittyUnicode { tmux: true }, + ); + } + + #[test] + fn auto_detect_kitty_takes_precedence_over_iterm() { + // If both signals fire, Kitty wins because its detection is more specific + // (KITTY_WINDOW_ID / Ghostty env vars) than iTerm's TERM_PROGRAM check. + assert_proto(decide_protocol(true, true, false), ImageProtocol::Kitty); + assert_proto( + decide_protocol(true, true, true), + ImageProtocol::KittyUnicode { tmux: true }, + ); + } + + #[test] + fn auto_detect_iterm_no_tmux_uses_iterm() { + assert_proto(decide_protocol(false, true, false), ImageProtocol::Iterm2); + } + + #[test] + fn auto_detect_iterm_in_tmux_falls_back_to_ascii() { + // The plain iTerm2 OSC has no tmux-passthrough wrapping in this codebase, + // so tmux would swallow the escape. Falling back to ASCII keeps the graph + // visible and matches what compatibility.md promises for tmux users. + assert_proto(decide_protocol(false, true, true), ImageProtocol::Ascii); + } + + #[test] + fn auto_detect_no_signals_uses_ascii() { + assert_proto(decide_protocol(false, false, false), ImageProtocol::Ascii); + // Tmux on its own doesn't change the answer when no image protocol fires. + assert_proto(decide_protocol(false, false, true), ImageProtocol::Ascii); + } +}