Skip to main content

void_graph/graph/
calc.rs

1//! Graph calculation for commit visualization.
2//!
3//! This module is adapted from [Serie](https://github.com/lusingander/serie)
4//! by Kyosuke Fujimoto, licensed under the MIT License. The algorithm
5//! calculates x,y positions for commits and edges to visualize the commit DAG.
6//!
7//! Key changes from Serie:
8//! - Uses `CommitCid` instead of `CommitHash`
9//! - Uses `VoidCommit` instead of git `Commit`
10//! - Simplified repository interface via `GraphContext`
11
12use rustc_hash::FxHashMap;
13
14use crate::void_backend::{CommitCid, VoidCommit};
15
16/// Mapping from commit CID to (x, y) position in the graph.
17type CommitPosMap<'a> = FxHashMap<&'a CommitCid, (usize, usize)>;
18
19/// Calculated graph structure for rendering.
20#[derive(Debug)]
21pub struct Graph<'a> {
22    /// Commits in display order (y-axis).
23    pub commits: &'a [VoidCommit],
24    /// Position mapping: CID -> (x, y).
25    pub commit_pos_map: CommitPosMap<'a>,
26    /// Edges for each row (indexed by y position).
27    pub edges: Vec<Vec<Edge>>,
28    /// Maximum x position (graph width).
29    pub max_pos_x: usize,
30}
31
32/// A visual edge segment in the graph.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub struct Edge {
35    /// Type of edge (determines rendering character).
36    pub edge_type: EdgeType,
37    /// X position of this edge segment.
38    pub pos_x: usize,
39    /// X position of the associated vertical line (for coloring).
40    pub associated_line_pos_x: usize,
41}
42
43impl Edge {
44    /// Create a new edge.
45    pub fn new(edge_type: EdgeType, pos_x: usize, line_pos_x: usize) -> Self {
46        Self {
47            edge_type,
48            pos_x,
49            associated_line_pos_x: line_pos_x,
50        }
51    }
52}
53
54/// Edge type determines the character used for rendering.
55#[derive(Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Hash)]
56pub enum EdgeType {
57    /// Vertical line: │
58    Vertical,
59    /// Horizontal line: ─
60    Horizontal,
61    /// Up connector: ╵
62    Up,
63    /// Down connector: ╷
64    Down,
65    /// Left connector: ╴
66    Left,
67    /// Right connector: ╶
68    Right,
69    /// Right-top corner: ╮
70    RightTop,
71    /// Right-bottom corner: ╯
72    RightBottom,
73    /// Left-top corner: ╭
74    LeftTop,
75    /// Left-bottom corner: ╰
76    LeftBottom,
77}
78
79impl EdgeType {
80    /// Returns true if this edge is part of a vertical line.
81    pub fn is_vertically_related(&self) -> bool {
82        matches!(self, EdgeType::Vertical | EdgeType::Up | EdgeType::Down)
83    }
84}
85
86/// Context providing commit relationships for graph calculation.
87///
88/// This trait abstracts the repository interface so the graph algorithm
89/// can work with different commit sources.
90pub trait GraphContext {
91    /// Get children CIDs for a commit (commits that have this as parent).
92    fn children(&self, cid: &CommitCid) -> Vec<&CommitCid>;
93
94    /// Get parent CIDs for a commit.
95    fn parents(&self, cid: &CommitCid) -> Vec<&CommitCid>;
96
97    /// Get a commit by CID (returns None if not in the graph).
98    fn commit(&self, cid: &CommitCid) -> Option<&VoidCommit>;
99}
100
101/// Simple in-memory graph context built from a commit list.
102pub struct SimpleGraphContext<'a> {
103    /// All commits in the graph.
104    commits: &'a [VoidCommit],
105    /// CID -> commit index mapping.
106    cid_to_index: FxHashMap<&'a CommitCid, usize>,
107    /// CID -> children CIDs mapping.
108    children_map: FxHashMap<&'a CommitCid, Vec<&'a CommitCid>>,
109}
110
111impl<'a> SimpleGraphContext<'a> {
112    /// Build a graph context from a slice of commits.
113    pub fn new(commits: &'a [VoidCommit]) -> Self {
114        let mut cid_to_index = FxHashMap::default();
115        let mut children_map: FxHashMap<&CommitCid, Vec<&CommitCid>> = FxHashMap::default();
116
117        // Build index mapping
118        for (i, commit) in commits.iter().enumerate() {
119            cid_to_index.insert(&commit.cid, i);
120        }
121
122        // Build children mapping (reverse of parent relationship)
123        for commit in commits {
124            for parent_cid in &commit.parents {
125                children_map
126                    .entry(parent_cid)
127                    .or_default()
128                    .push(&commit.cid);
129            }
130        }
131
132        Self {
133            commits,
134            cid_to_index,
135            children_map,
136        }
137    }
138}
139
140impl<'a> GraphContext for SimpleGraphContext<'a> {
141    fn children(&self, cid: &CommitCid) -> Vec<&CommitCid> {
142        self.children_map.get(cid).cloned().unwrap_or_default()
143    }
144
145    fn parents(&self, cid: &CommitCid) -> Vec<&CommitCid> {
146        self.cid_to_index
147            .get(cid)
148            .and_then(|&i| self.commits.get(i))
149            .map(|c| c.parents.iter().collect())
150            .unwrap_or_default()
151    }
152
153    fn commit(&self, cid: &CommitCid) -> Option<&VoidCommit> {
154        self.cid_to_index
155            .get(cid)
156            .and_then(|&i| self.commits.get(i))
157    }
158}
159
160/// Calculate the graph layout for a list of commits.
161pub fn calc_graph<'a>(commits: &'a [VoidCommit], ctx: &'a impl GraphContext) -> Graph<'a> {
162    let commit_pos_map = calc_commit_positions(commits, ctx);
163    let (edges, max_pos_x) = calc_edges(&commit_pos_map, commits, ctx);
164
165    Graph {
166        commits,
167        commit_pos_map,
168        edges,
169        max_pos_x,
170    }
171}
172
173/// Owned graph structure (no lifetime parameter).
174///
175/// This allows storing the graph alongside commits without lifetime issues.
176#[derive(Debug)]
177pub struct OwnedGraph {
178    /// Position mapping: CID -> (x, y).
179    pub commit_pos_map: FxHashMap<CommitCid, (usize, usize)>,
180    /// Edges for each row (indexed by y position).
181    pub edges: Vec<Vec<Edge>>,
182    /// Maximum x position (graph width).
183    pub max_pos_x: usize,
184}
185
186/// Calculate graph layout and return an owned structure.
187///
188/// This is the preferred API for widget use since it doesn't require
189/// managing lifetimes between the graph and commit data.
190pub fn calc_graph_owned(commits: &[VoidCommit]) -> OwnedGraph {
191    let ctx = SimpleGraphContext::new(commits);
192    let graph = calc_graph(commits, &ctx);
193
194    OwnedGraph {
195        commit_pos_map: graph
196            .commit_pos_map
197            .into_iter()
198            .map(|(k, v)| (k.clone(), v))
199            .collect(),
200        edges: graph.edges,
201        max_pos_x: graph.max_pos_x,
202    }
203}
204
205/// Calculate x,y positions for each commit.
206fn calc_commit_positions<'a>(
207    commits: &'a [VoidCommit],
208    ctx: &impl GraphContext,
209) -> CommitPosMap<'a> {
210    let mut commit_pos_map: CommitPosMap = FxHashMap::default();
211    let mut commit_line_state: Vec<Option<&CommitCid>> = Vec::new();
212
213    for (pos_y, commit) in commits.iter().enumerate() {
214        let filtered_children = filtered_children_cid(commit, ctx);
215        if filtered_children.is_empty() {
216            // Leaf commit - find first vacant line
217            let pos_x = get_first_vacant_line(&commit_line_state);
218            add_commit_line(commit, &mut commit_line_state, pos_x);
219            commit_pos_map.insert(&commit.cid, (pos_x, pos_y));
220        } else {
221            // Has children - update line state
222            let pos_x = update_commit_line(commit, &mut commit_line_state, &filtered_children);
223            commit_pos_map.insert(&commit.cid, (pos_x, pos_y));
224        }
225    }
226
227    commit_pos_map
228}
229
230/// Get children that have this commit as their first parent.
231fn filtered_children_cid<'a>(
232    commit: &'a VoidCommit,
233    ctx: &'a impl GraphContext,
234) -> Vec<&'a CommitCid> {
235    ctx.children(&commit.cid)
236        .into_iter()
237        .filter(|child_cid| {
238            let child_parents = ctx.parents(child_cid);
239            !child_parents.is_empty() && *child_parents[0] == commit.cid
240        })
241        .collect()
242}
243
244/// Find the first vacant position in the line state.
245fn get_first_vacant_line(commit_line_state: &[Option<&CommitCid>]) -> usize {
246    commit_line_state
247        .iter()
248        .position(|c| c.is_none())
249        .unwrap_or(commit_line_state.len())
250}
251
252/// Add a commit to the line state at the given position.
253fn add_commit_line<'a>(
254    commit: &'a VoidCommit,
255    commit_line_state: &mut Vec<Option<&'a CommitCid>>,
256    pos_x: usize,
257) {
258    if commit_line_state.len() <= pos_x {
259        commit_line_state.push(Some(&commit.cid));
260    } else {
261        commit_line_state[pos_x] = Some(&commit.cid);
262    }
263}
264
265/// Update line state when a commit has children.
266fn update_commit_line<'a>(
267    commit: &'a VoidCommit,
268    commit_line_state: &mut [Option<&'a CommitCid>],
269    target_commit_cids: &[&CommitCid],
270) -> usize {
271    if commit_line_state.is_empty() {
272        return 0;
273    }
274
275    let mut min_pos_x = commit_line_state.len().saturating_sub(1);
276
277    for target_cid in target_commit_cids {
278        for (pos_x, commit_cid) in commit_line_state.iter().enumerate() {
279            if let Some(cid) = commit_cid {
280                if *cid == *target_cid {
281                    commit_line_state[pos_x] = None;
282                    if min_pos_x > pos_x {
283                        min_pos_x = pos_x;
284                    }
285                    break;
286                }
287            }
288        }
289    }
290
291    commit_line_state[min_pos_x] = Some(&commit.cid);
292    min_pos_x
293}
294
295/// Internal edge wrapper that tracks the parent commit for overlap detection.
296#[derive(Debug, Clone)]
297struct WrappedEdge<'a> {
298    edge: Edge,
299    edge_parent_cid: &'a CommitCid,
300}
301
302impl<'a> WrappedEdge<'a> {
303    fn new(
304        edge_type: EdgeType,
305        pos_x: usize,
306        line_pos_x: usize,
307        edge_parent_cid: &'a CommitCid,
308    ) -> Self {
309        Self {
310            edge: Edge::new(edge_type, pos_x, line_pos_x),
311            edge_parent_cid,
312        }
313    }
314}
315
316/// Calculate all edges for the graph.
317fn calc_edges<'a>(
318    commit_pos_map: &CommitPosMap<'a>,
319    commits: &'a [VoidCommit],
320    ctx: &impl GraphContext,
321) -> (Vec<Vec<Edge>>, usize) {
322    let mut max_pos_x = 0;
323    let mut edges: Vec<Vec<WrappedEdge<'a>>> = vec![vec![]; commits.len()];
324
325    // First pass: draw commit and branch edges
326    for commit in commits {
327        let (pos_x, pos_y) = commit_pos_map[&commit.cid];
328        let cid = &commit.cid;
329
330        for child_cid in ctx.children(cid) {
331            let (child_pos_x, child_pos_y) = commit_pos_map[child_cid];
332
333            if pos_x == child_pos_x {
334                // Straight commit line
335                draw_vertical_edge(&mut edges, pos_x, pos_y, child_pos_y, cid);
336            } else {
337                let child_commit = ctx.commit(child_cid);
338                let child_first_parent = child_commit.and_then(|c| c.parents.first());
339
340                if child_first_parent == Some(cid) {
341                    // Branch edge
342                    draw_branch_edge(
343                        &mut edges,
344                        pos_x,
345                        pos_y,
346                        child_pos_x,
347                        child_pos_y,
348                        cid,
349                    );
350                }
351                // Merge edges handled in second pass
352            }
353        }
354
355        if max_pos_x < pos_x {
356            max_pos_x = pos_x;
357        }
358
359        // Draw trailing edge if parent not in graph
360        if let Some(first_parent) = commit.parents.first() {
361            if ctx.commit(first_parent).is_none() {
362                edges[pos_y].push(WrappedEdge::new(EdgeType::Down, pos_x, pos_x, cid));
363                ((pos_y + 1)..commits.len()).for_each(|y| {
364                    edges[y].push(WrappedEdge::new(EdgeType::Vertical, pos_x, pos_x, cid));
365                });
366            }
367        }
368    }
369
370    // Second pass: draw merge edges with overlap detection
371    for commit in commits {
372        let (pos_x, pos_y) = commit_pos_map[&commit.cid];
373        let cid = &commit.cid;
374
375        for child_cid in ctx.children(cid) {
376            let (child_pos_x, child_pos_y) = commit_pos_map[child_cid];
377
378            if pos_x != child_pos_x {
379                let child_commit = ctx.commit(child_cid);
380                let child_first_parent = child_commit.and_then(|c| c.parents.first());
381
382                if child_first_parent != Some(cid) {
383                    // Merge edge
384                    draw_merge_edge(
385                        &mut edges,
386                        commit_pos_map,
387                        commits,
388                        pos_x,
389                        pos_y,
390                        child_pos_x,
391                        child_pos_y,
392                        cid,
393                        &mut max_pos_x,
394                    );
395                }
396            }
397        }
398
399        if max_pos_x < pos_x {
400            max_pos_x = pos_x;
401        }
402    }
403
404    // Convert wrapped edges to final edges, sort and deduplicate
405    let edges: Vec<Vec<Edge>> = edges
406        .into_iter()
407        .map(|es| {
408            let mut es: Vec<Edge> = es.into_iter().map(|e| e.edge).collect();
409            es.sort_by_key(|e| (e.associated_line_pos_x, e.pos_x, e.edge_type));
410            es.dedup();
411            es
412        })
413        .collect();
414
415    (edges, max_pos_x)
416}
417
418/// Draw a vertical edge between two y positions.
419fn draw_vertical_edge<'a>(
420    edges: &mut [Vec<WrappedEdge<'a>>],
421    pos_x: usize,
422    pos_y: usize,
423    child_pos_y: usize,
424    cid: &'a CommitCid,
425) {
426    edges[pos_y].push(WrappedEdge::new(EdgeType::Up, pos_x, pos_x, cid));
427    for y in ((child_pos_y + 1)..pos_y).rev() {
428        edges[y].push(WrappedEdge::new(EdgeType::Vertical, pos_x, pos_x, cid));
429    }
430    edges[child_pos_y].push(WrappedEdge::new(EdgeType::Down, pos_x, pos_x, cid));
431}
432
433/// Draw a branch edge (horizontal + vertical).
434fn draw_branch_edge<'a>(
435    edges: &mut [Vec<WrappedEdge<'a>>],
436    pos_x: usize,
437    pos_y: usize,
438    child_pos_x: usize,
439    child_pos_y: usize,
440    cid: &'a CommitCid,
441) {
442    if pos_x < child_pos_x {
443        // Branch goes right
444        edges[pos_y].push(WrappedEdge::new(EdgeType::Right, pos_x, child_pos_x, cid));
445        for x in (pos_x + 1)..child_pos_x {
446            edges[pos_y].push(WrappedEdge::new(EdgeType::Horizontal, x, child_pos_x, cid));
447        }
448        edges[pos_y].push(WrappedEdge::new(
449            EdgeType::RightBottom,
450            child_pos_x,
451            child_pos_x,
452            cid,
453        ));
454    } else {
455        // Branch goes left
456        edges[pos_y].push(WrappedEdge::new(EdgeType::Left, pos_x, child_pos_x, cid));
457        for x in (child_pos_x + 1)..pos_x {
458            edges[pos_y].push(WrappedEdge::new(EdgeType::Horizontal, x, child_pos_x, cid));
459        }
460        edges[pos_y].push(WrappedEdge::new(
461            EdgeType::LeftBottom,
462            child_pos_x,
463            child_pos_x,
464            cid,
465        ));
466    }
467
468    // Vertical portion
469    for y in ((child_pos_y + 1)..pos_y).rev() {
470        edges[y].push(WrappedEdge::new(
471            EdgeType::Vertical,
472            child_pos_x,
473            child_pos_x,
474            cid,
475        ));
476    }
477    edges[child_pos_y].push(WrappedEdge::new(
478        EdgeType::Down,
479        child_pos_x,
480        child_pos_x,
481        cid,
482    ));
483}
484
485/// Draw a merge edge with overlap detection and detouring.
486#[allow(clippy::too_many_arguments)]
487fn draw_merge_edge<'a>(
488    edges: &mut [Vec<WrappedEdge<'a>>],
489    commit_pos_map: &CommitPosMap,
490    commits: &'a [VoidCommit],
491    pos_x: usize,
492    pos_y: usize,
493    child_pos_x: usize,
494    child_pos_y: usize,
495    cid: &'a CommitCid,
496    max_pos_x: &mut usize,
497) {
498    let mut overlap = false;
499    let mut new_pos_x = pos_x;
500
501    // Check if we can draw straight or need to detour
502    let mut skip_judge_overlap = true;
503    for y in (child_pos_y + 1)..pos_y {
504        let processing_commit_pos_x = commit_pos_map.get(&commits[y].cid).unwrap().0;
505        if processing_commit_pos_x == new_pos_x {
506            skip_judge_overlap = false;
507            break;
508        }
509        if edges[y]
510            .iter()
511            .filter(|e| e.edge.pos_x == pos_x)
512            .filter(|e| matches!(e.edge.edge_type, EdgeType::Vertical))
513            .any(|e| e.edge_parent_cid != cid)
514        {
515            skip_judge_overlap = false;
516            break;
517        }
518    }
519
520    if !skip_judge_overlap {
521        // Check for overlaps and find detour position
522        for y in (child_pos_y + 1)..pos_y {
523            let processing_commit_pos_x = commit_pos_map.get(&commits[y].cid).unwrap().0;
524            if processing_commit_pos_x == new_pos_x {
525                overlap = true;
526                if new_pos_x < processing_commit_pos_x + 1 {
527                    new_pos_x = processing_commit_pos_x + 1;
528                }
529            }
530            for edge in &edges[y] {
531                if edge.edge.pos_x >= new_pos_x
532                    && edge.edge_parent_cid != cid
533                    && matches!(edge.edge.edge_type, EdgeType::Vertical)
534                {
535                    overlap = true;
536                    if new_pos_x < edge.edge.pos_x + 1 {
537                        new_pos_x = edge.edge.pos_x + 1;
538                    }
539                }
540            }
541        }
542    }
543
544    if overlap {
545        // Detour path: go right, up, then left to child
546        edges[pos_y].push(WrappedEdge::new(EdgeType::Right, pos_x, pos_x, cid));
547        for x in (pos_x + 1)..new_pos_x {
548            edges[pos_y].push(WrappedEdge::new(EdgeType::Horizontal, x, pos_x, cid));
549        }
550        edges[pos_y].push(WrappedEdge::new(
551            EdgeType::RightBottom,
552            new_pos_x,
553            pos_x,
554            cid,
555        ));
556        for y in ((child_pos_y + 1)..pos_y).rev() {
557            edges[y].push(WrappedEdge::new(EdgeType::Vertical, new_pos_x, pos_x, cid));
558        }
559        edges[child_pos_y].push(WrappedEdge::new(EdgeType::RightTop, new_pos_x, pos_x, cid));
560        for x in (child_pos_x + 1)..new_pos_x {
561            edges[child_pos_y].push(WrappedEdge::new(EdgeType::Horizontal, x, pos_x, cid));
562        }
563        edges[child_pos_y].push(WrappedEdge::new(EdgeType::Right, child_pos_x, pos_x, cid));
564
565        if *max_pos_x < new_pos_x {
566            *max_pos_x = new_pos_x;
567        }
568    } else {
569        // Straight path
570        edges[pos_y].push(WrappedEdge::new(EdgeType::Up, pos_x, pos_x, cid));
571        for y in ((child_pos_y + 1)..pos_y).rev() {
572            edges[y].push(WrappedEdge::new(EdgeType::Vertical, pos_x, pos_x, cid));
573        }
574
575        if pos_x < child_pos_x {
576            edges[child_pos_y].push(WrappedEdge::new(EdgeType::LeftTop, pos_x, pos_x, cid));
577            for x in (pos_x + 1)..child_pos_x {
578                edges[child_pos_y].push(WrappedEdge::new(EdgeType::Horizontal, x, pos_x, cid));
579            }
580            edges[child_pos_y].push(WrappedEdge::new(EdgeType::Left, child_pos_x, pos_x, cid));
581        } else {
582            edges[child_pos_y].push(WrappedEdge::new(EdgeType::RightTop, pos_x, pos_x, cid));
583            for x in (child_pos_x + 1)..pos_x {
584                edges[child_pos_y].push(WrappedEdge::new(EdgeType::Horizontal, x, pos_x, cid));
585            }
586            edges[child_pos_y].push(WrappedEdge::new(EdgeType::Right, child_pos_x, pos_x, cid));
587        }
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    fn make_commit(cid: &str, parents: Vec<&str>) -> VoidCommit {
596        VoidCommit {
597            cid: CommitCid(cid.to_string()),
598            parents: parents.into_iter().map(|p| CommitCid(p.to_string())).collect(),
599            message: String::new(),
600            timestamp_ms: 0,
601            is_signed: false,
602            signature_valid: None,
603            author: None,
604        }
605    }
606
607    #[test]
608    fn test_linear_graph() {
609        // c3 -> c2 -> c1
610        let commits = vec![
611            make_commit("c3", vec!["c2"]),
612            make_commit("c2", vec!["c1"]),
613            make_commit("c1", vec![]),
614        ];
615        let ctx = SimpleGraphContext::new(&commits);
616        let graph = calc_graph(&commits, &ctx);
617
618        // All commits should be on the same x position (0)
619        for commit in &commits {
620            let (x, _) = graph.commit_pos_map[&commit.cid];
621            assert_eq!(x, 0);
622        }
623    }
624
625    #[test]
626    fn test_branch_graph() {
627        // c3 (main) -> c1
628        // c2 (branch) -> c1
629        let commits = vec![
630            make_commit("c3", vec!["c1"]),
631            make_commit("c2", vec!["c1"]),
632            make_commit("c1", vec![]),
633        ];
634        let ctx = SimpleGraphContext::new(&commits);
635        let graph = calc_graph(&commits, &ctx);
636
637        // c3 and c2 should be on different x positions
638        let (x3, _) = graph.commit_pos_map[&commits[0].cid];
639        let (x2, _) = graph.commit_pos_map[&commits[1].cid];
640        assert_ne!(x3, x2);
641    }
642
643    #[test]
644    fn test_linear_edges() {
645        // Linear: c1 -> c2 -> c3
646        let commits = vec![
647            make_commit("c1", vec!["c2"]),
648            make_commit("c2", vec!["c3"]),
649            make_commit("c3", vec![]),
650        ];
651        let ctx = SimpleGraphContext::new(&commits);
652        let graph = calc_graph(&commits, &ctx);
653
654        // Check edges
655        println!("edges[0]: {:?}", graph.edges[0]);
656        println!("edges[1]: {:?}", graph.edges[1]);
657        println!("edges[2]: {:?}", graph.edges[2]);
658        println!("max_pos_x: {}", graph.max_pos_x);
659
660        // Row 1 should have a vertical edge at x=0
661        assert!(!graph.edges[1].is_empty(), "middle row should have edges");
662    }
663}