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. diff --git a/src/graph/image.rs b/src/graph/image.rs index 0d3326d..2234134 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,26 @@ 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, + self.graph_style, + self.cell_width_type, + 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 +231,214 @@ impl ImageParams { } } +#[derive(Default, Clone, Copy)] +struct AsciiDirections { + up: bool, + down: bool, + left: bool, + right: bool, +} + +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) => 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}', // โ”€ + (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]) +} + +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]; + 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; + + 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 { + cells.push(PreparedImageCell::new( + commit_symbol.to_string(), + commit_style, + )); + } 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)); + } + + // --- 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())); + } + } + } + + PreparedImage::from_cells(cells) +} + fn build_single_graph_row_image( graph: &Graph<'_>, image_params: &ImageParams, @@ -1247,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 0ec111a..81d31c5 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,7 +140,8 @@ 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); let graph_style = args.graph_style.or(core_config.option.graph_style).into(); diff --git a/src/protocol.rs b/src/protocol.rs index 3c13309..993b6c5 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -6,17 +6,31 @@ 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() { + 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 { + } 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 } } @@ -30,6 +44,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 +65,7 @@ pub enum ImageProtocol { Iterm2, Kitty, KittyUnicode { tmux: bool }, + Ascii, } #[derive(Debug, Clone)] @@ -51,6 +76,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 +116,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 +135,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 +164,7 @@ impl ImageProtocol { ImageProtocol::Iterm2 => {} ImageProtocol::Kitty => kitty_clear_line(y), ImageProtocol::KittyUnicode { .. } => {} + ImageProtocol::Ascii => {} } } @@ -127,12 +173,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), } } @@ -602,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); + } +}