git_tailor/static_views/fragmap.rs
1// Copyright 2026 Thomas Johannesson
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// Static (non-TUI) fragmap renderer.
16//
17// Renders the fragmap matrix to a plain String, mimicking the output of the
18// original fragmap tool. Each row shows: short SHA, commit title, then one
19// character per cluster column.
20//
21// When `colors` is true the output uses ANSI escape codes (cyan SHA, grey
22// title for fully-squashable commits, white/yellow/red background cells).
23// When `colors` is false Unicode block characters are used instead, which
24// produces clean readable output suitable for snapshot tests or plain-text
25// piping.
26
27use crate::{
28 CommitDiff,
29 fragmap::{self as fm, SquashableScope},
30};
31
32/// Symbol set used when rendering a row.
33struct Symbols {
34 /// ANSI prefix for the short SHA (e.g. cyan).
35 sha_start: &'static str,
36 /// ANSI reset / empty string.
37 reset: &'static str,
38 /// ANSI prefix for a fully-squashable commit's title (grey).
39 grey_start: &'static str,
40 /// String rendered when a commit directly touches a cluster.
41 cell_touch: &'static str,
42 /// String rendered for a squashable connector (between two touching commits with no conflict).
43 cell_squashable: &'static str,
44 /// String rendered for a conflicting connector.
45 cell_conflicting: &'static str,
46 /// String rendered for an empty cell.
47 cell_empty: &'static str,
48}
49
50const COLORED: Symbols = Symbols {
51 sha_start: "\x1b[36m",
52 reset: "\x1b[0m",
53 grey_start: "\x1b[90m",
54 cell_touch: "\x1b[47m \x1b[0m",
55 cell_squashable: "\x1b[43m \x1b[0m",
56 cell_conflicting: "\x1b[41m \x1b[0m",
57 cell_empty: ".",
58};
59
60const PLAIN: Symbols = Symbols {
61 sha_start: "",
62 reset: "",
63 grey_start: "",
64 cell_touch: "#",
65 cell_squashable: "^",
66 cell_conflicting: "|",
67 cell_empty: ".",
68};
69
70const PLAIN_REVERSE: Symbols = Symbols {
71 cell_squashable: "v",
72 ..PLAIN
73};
74
75/// Render the fragmap matrix for all `commit_diffs` to a `String`.
76///
77/// `commit_diffs` contains all commits in display order (regular commits
78/// followed by any synthetic rows such as staged/unstaged changes).
79/// `full` controls whether identical cluster columns are deduplicated.
80/// `colors` selects ANSI color output (matching the original fragmap tool)
81/// versus plain Unicode output (suitable for tests and `--no-color` piping).
82/// `reverse` prints rows oldest-first (highest matrix index first).
83/// `scope` controls what the squashable connector indicator means.
84/// `term_width` is the terminal column count used to compute the title column
85/// width dynamically (matching the original fragmap tool's layout algorithm).
86/// When `None` a fixed 26-character title width is used.
87pub fn render(
88 commit_diffs: &[CommitDiff],
89 full: bool,
90 colors: bool,
91 reverse: bool,
92 scope: SquashableScope,
93 term_width: Option<u16>,
94) -> String {
95 let s = if colors {
96 &COLORED
97 } else if reverse {
98 &PLAIN_REVERSE
99 } else {
100 &PLAIN
101 };
102
103 let fmap = fm::build_fragmap(commit_diffs, !full);
104 let n_clusters = fmap.clusters.len();
105
106 // Compute the title column width following the original fragmap tool's
107 // layout algorithm (fragmap/console_ui.py):
108 //
109 // terminal_width = reported - 2 (ConEmu/Cmder wraps 2 cols early)
110 // title_width = max(MIN, min(
111 // longest_title + 1,
112 // terminal_width / 2,
113 // terminal_width - (8 + 1 + 1 + n_clusters),
114 // ))
115 //
116 // Fields: SHA(8) + space(1) + title(title_width) + space(1) + matrix(n_clusters)
117 const FIXED_TITLE_WIDTH: usize = 26;
118 const MIN_TITLE_WIDTH: usize = 10;
119 const SHA_WIDTH: usize = 8;
120
121 let title_width = if let Some(tw) = term_width {
122 let terminal_width = (tw as usize).saturating_sub(2);
123 let max_actual = commit_diffs
124 .iter()
125 .map(|d| d.commit.summary.chars().count())
126 .max()
127 .unwrap_or(0)
128 + 1;
129 // space between SHA and title (1); no separator before matrix (matches existing format)
130 let overhead = SHA_WIDTH + 1 + n_clusters;
131 let dynamic = max_actual
132 .min(terminal_width / 2)
133 .min(terminal_width.saturating_sub(overhead));
134 dynamic.max(MIN_TITLE_WIDTH)
135 } else {
136 FIXED_TITLE_WIDTH
137 };
138
139 let mut out = String::new();
140
141 let indices: Box<dyn Iterator<Item = usize>> = if reverse {
142 Box::new((0..commit_diffs.len()).rev())
143 } else {
144 Box::new(0..commit_diffs.len())
145 };
146
147 for commit_idx in indices {
148 let diff = &commit_diffs[commit_idx];
149 let commit = &diff.commit;
150 let sha8 = &commit.oid[..8.min(commit.oid.len())];
151 let title: String = commit.summary.chars().take(title_width).collect();
152 let title_padded = format!("{title:<title_width$}");
153
154 out.push_str(s.sha_start);
155 out.push_str(sha8);
156 out.push_str(s.reset);
157 out.push(' ');
158
159 if fmap.is_fully_squashable(commit_idx) {
160 out.push_str(s.grey_start);
161 out.push_str(&title_padded);
162 out.push_str(s.reset);
163 } else {
164 out.push_str(&title_padded);
165 }
166
167 for cluster_idx in 0..fmap.clusters.len() {
168 if fmap.matrix[commit_idx][cluster_idx] != fm::TouchKind::None {
169 out.push_str(s.cell_touch);
170 } else {
171 let has_above =
172 (0..commit_idx).any(|i| fmap.matrix[i][cluster_idx] != fm::TouchKind::None);
173 let below = ((commit_idx + 1)..commit_diffs.len())
174 .find(|&i| fmap.matrix[i][cluster_idx] != fm::TouchKind::None);
175
176 if has_above && let Some(below_idx) = below {
177 match fmap.connector_squashable(below_idx, cluster_idx, scope) {
178 Some(true) => out.push_str(s.cell_squashable),
179 Some(false) => out.push_str(s.cell_conflicting),
180 None => out.push_str(s.cell_empty),
181 }
182 } else {
183 out.push_str(s.cell_empty);
184 }
185 }
186 }
187
188 out.push('\n');
189 }
190
191 out
192}