Skip to main content

git_tailor/views/
hunk_groups.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// Fragmap (hunk-group) rendering helpers extracted from commit_list.
16//
17// These functions build the third column of the commit table — the
18// cluster-matrix visualization — plus its horizontal scrollbar.
19
20use crate::fragmap::{self, SquashableScope, TouchKind};
21use ratatui::{
22    Frame,
23    layout::Rect,
24    style::{Color, Style},
25    text::{Line, Span},
26    widgets::{Cell, Scrollbar, ScrollbarOrientation, ScrollbarState},
27};
28
29// Fragmap visualization symbols
30const CLUSTER_TOUCHED_CONFLICTING: &str = "█";
31const CLUSTER_TOUCHED_SQUASHABLE: &str = "█";
32const CLUSTER_CONNECTOR_CONFLICTING: &str = "│";
33const CLUSTER_CONNECTOR_SQUASHABLE: &str = "│";
34
35// Connector colors
36pub const COLOR_CONFLICTING: Color = Color::Red;
37pub const COLOR_SQUASHABLE: Color = Color::Yellow;
38
39// Cell colors
40const COLOR_TOUCHED_CONFLICTING: Color = Color::White;
41const COLOR_TOUCHED_SQUASHABLE: Color = Color::DarkGray;
42
43// Background applied to the fragmap matrix columns of the selected row.
44const COLOR_SELECTED_FRAGMAP_BG: Color = Color::Rgb(60, 60, 80);
45
46/// Determine a commit's relationship to the earliest earlier commit in a cluster.
47///
48/// Returns None if the commit doesn't touch the cluster or no earlier commit does.
49fn cluster_relation(
50    fragmap: &fragmap::FragMap,
51    commit_idx: usize,
52    cluster_idx: usize,
53) -> Option<fragmap::SquashRelation> {
54    if fragmap.matrix[commit_idx][cluster_idx] == TouchKind::None {
55        return None;
56    }
57    for earlier_idx in 0..commit_idx {
58        if fragmap.matrix[earlier_idx][cluster_idx] != TouchKind::None {
59            return Some(fragmap.cluster_relation(earlier_idx, commit_idx, cluster_idx));
60        }
61    }
62    None
63}
64
65/// Determine cell content and style for a commit-cluster intersection.
66///
67/// Returns None if the commit doesn't touch the cluster.
68/// With `Group` scope a square is grey when that group pair is squashable.
69/// With `Commit` scope a square is grey only when the entire commit is fully
70/// squashable into a single target commit.
71fn fragmap_cell_content(
72    fragmap: &fragmap::FragMap,
73    commit_idx: usize,
74    cluster_idx: usize,
75    scope: SquashableScope,
76) -> Option<(&'static str, Style)> {
77    if fragmap.matrix[commit_idx][cluster_idx] == TouchKind::None {
78        return None;
79    }
80
81    let is_squashable = match scope {
82        SquashableScope::Group => {
83            matches!(
84                cluster_relation(fragmap, commit_idx, cluster_idx),
85                Some(fragmap::SquashRelation::Squashable)
86            )
87        }
88        SquashableScope::Commit => fragmap.is_fully_squashable(commit_idx),
89    };
90
91    if is_squashable {
92        Some((
93            CLUSTER_TOUCHED_SQUASHABLE,
94            Style::new().fg(COLOR_TOUCHED_SQUASHABLE),
95        ))
96    } else {
97        Some((
98            CLUSTER_TOUCHED_CONFLICTING,
99            Style::new().fg(COLOR_TOUCHED_CONFLICTING),
100        ))
101    }
102}
103
104/// Determine connector content for a cell where the commit does NOT touch the cluster.
105///
106/// If there are touching commits both above and below this row in the same
107/// column, draw a vertical connector line colored by the relationship that
108/// the lower square has with an earlier commit.
109fn fragmap_connector_content(
110    fragmap: &fragmap::FragMap,
111    commit_idx: usize,
112    cluster_idx: usize,
113    scope: SquashableScope,
114) -> Option<(&'static str, Style)> {
115    let has_above = (0..commit_idx)
116        .rev()
117        .any(|i| fragmap.matrix[i][cluster_idx] != TouchKind::None);
118
119    let below = ((commit_idx + 1)..fragmap.commits.len())
120        .find(|&i| fragmap.matrix[i][cluster_idx] != TouchKind::None);
121
122    match (has_above, below) {
123        (true, Some(below_idx)) => {
124            match fragmap.connector_squashable(below_idx, cluster_idx, scope) {
125                Some(true) => Some((
126                    CLUSTER_CONNECTOR_SQUASHABLE,
127                    Style::new().fg(COLOR_SQUASHABLE),
128                )),
129                Some(false) => Some((
130                    CLUSTER_CONNECTOR_CONFLICTING,
131                    Style::new().fg(COLOR_CONFLICTING),
132                )),
133                None => None,
134            }
135        }
136        _ => None,
137    }
138}
139
140/// Compute the text style for a commit row based on its fragmap relationship
141/// to the currently selected commit.
142///
143/// Yellow: squash partner — either the selected commit can squash into this
144/// commit, or this commit can squash into the selected commit.
145/// Red: shares a cluster but not a squash partner.
146/// DarkGray: this commit is itself fully squashable (intrinsic property).
147pub fn commit_text_style(
148    fragmap: &fragmap::FragMap,
149    selection_idx: usize,
150    commit_idx: usize,
151) -> Style {
152    let is_squash_partner = fragmap
153        .squash_target(selection_idx)
154        .is_some_and(|t| t == commit_idx)
155        || fragmap
156            .squash_target(commit_idx)
157            .is_some_and(|t| t == selection_idx);
158
159    if is_squash_partner {
160        Style::new().fg(COLOR_SQUASHABLE)
161    } else if fragmap.shares_cluster_with(selection_idx, commit_idx) {
162        Style::new().fg(COLOR_CONFLICTING)
163    } else if fragmap.is_fully_squashable(commit_idx) {
164        Style::new().fg(COLOR_TOUCHED_SQUASHABLE)
165    } else {
166        Style::default()
167    }
168}
169
170/// Build a single fragmap cell from the visible cluster columns.
171///
172/// When `is_selected` is true, adds `COLOR_SELECTED_FRAGMAP_BG` as the
173/// background of every span so the row is visually highlighted without
174/// inverting the foreground colors of the symbols.
175pub fn build_fragmap_cell<'a>(
176    fragmap: &fragmap::FragMap,
177    commit_idx: usize,
178    display_clusters: &[usize],
179    is_selected: bool,
180    scope: SquashableScope,
181) -> Cell<'a> {
182    let spans: Vec<Span> = display_clusters
183        .iter()
184        .map(|&cluster_idx| {
185            let base_style = if is_selected {
186                Style::new().bg(COLOR_SELECTED_FRAGMAP_BG)
187            } else {
188                Style::new()
189            };
190            if let Some((symbol, style)) =
191                fragmap_cell_content(fragmap, commit_idx, cluster_idx, scope)
192            {
193                Span::styled(symbol, base_style.patch(style))
194            } else if let Some((symbol, style)) =
195                fragmap_connector_content(fragmap, commit_idx, cluster_idx, scope)
196            {
197                Span::styled(symbol, base_style.patch(style))
198            } else {
199                Span::styled(" ", base_style)
200            }
201        })
202        .collect();
203    let cell = Cell::from(Line::from(spans));
204    if is_selected {
205        cell.style(Style::new().bg(COLOR_SELECTED_FRAGMAP_BG))
206    } else {
207        cell
208    }
209}
210
211/// Render the horizontal scrollbar for the fragmap columns.
212pub fn render_horizontal_scrollbar(
213    frame: &mut Frame,
214    hs_area: Rect,
215    title_width: u16,
216    fragmap_col_width: u16,
217    total_clusters: usize,
218    fragmap_available_width: usize,
219    h_scroll_offset: usize,
220) {
221    let fragmap_x = hs_area.x + 10 + 1 + title_width + 1;
222    let area = Rect {
223        x: fragmap_x,
224        width: fragmap_col_width,
225        ..hs_area
226    };
227
228    let mut state = ScrollbarState::new(total_clusters.saturating_sub(fragmap_available_width))
229        .position(h_scroll_offset);
230
231    let scrollbar = Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
232        .begin_symbol(None)
233        .end_symbol(None)
234        .track_symbol(Some("─"));
235
236    frame.render_stateful_widget(scrollbar, area, &mut state);
237}