From 3eca7dc11c2944633c41bf948da9c3a6a7eaf2d0 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Wed, 10 Jun 2026 13:17:27 +0200 Subject: [PATCH 01/24] Future: Move fields from track::BranchInfo to layout Mark branch range and relations as used for layout This means they should move from track.rs to layout.rs at some point. --- src/track.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/track.rs b/src/track.rs index cdc8daf..86cd6c3 100644 --- a/src/track.rs +++ b/src/track.rs @@ -111,8 +111,11 @@ pub enum BranchInfoType { pub struct BranchInfo { /// The Object ID that the branch/tag points at. Used as the grand-child to start tracing the branch towards grand-parent. pub target: Oid, + /// The merge commit that defined this branch. It has target as a parent. pub merge_target: Option, + /// Layout: A branch that this branch was forked from pub source_branch: Option, + /// Layout: A branch that merged/continues this branch pub target_branch: Option, /// Name of branch. Either the branch/tag name, or derived from a merge-commit message. pub name: String, @@ -124,6 +127,9 @@ pub struct BranchInfo { pub is_merged: bool, /// Is branch a tag reference pub is_tag: bool, + /** Layout: Row range occucpied by branch. It is defined as low and + high index into TrackMap.commits, but in Layout this is used as an + abstract grid. */ pub range: (Option, Option), } impl BranchInfo { From 71182aa049107e9bcad034f6e899b1f214141639 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Thu, 28 May 2026 06:01:58 +0200 Subject: [PATCH 02/24] Improve TrackLayout documentation --- src/layout.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/layout.rs b/src/layout.rs index 047dd0b..deb9d4e 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -27,8 +27,9 @@ define_u32_index!( ); /** - Given a range of commits in a [TrackMap] you can construct a [TrackLayout] - which will assign columns and colours to the tracks. + A layout of tracks assigns columns and colours to them. + + Use [layout_track_range] to construct a [TrackLayout] */ pub struct TrackLayout { // Specifies which commits are rendered @@ -82,8 +83,12 @@ impl BranchVis { } } } -/// Generates a TrackLayout by extracting and calculating visual data for -/// branches active within a specific commit range. +/** + Construct a [TrackLayout] from a range of commits in a [TrackMap]. + + This will assign columns and colours to the tracks and store that + information in the layout. +*/ pub fn layout_track_range( track_map: &TrackMap, range: Range, @@ -311,7 +316,7 @@ pub fn assign_branch_columns( finalize_absolute_columns(&mut layout.branch_visual, occupied); } -// For each order group, for each column inside that group, trach which rows are occupied. +// For each order group, for each column inside that group, trace which rows are occupied. // occupied[group_idx][column_idx] = Vec<(start_commit_idx, end_commit_idx)> type Occupation = Vec>>; From bf73eec441a3823a36fc22321a8bd5a1aa2a8710 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Sat, 30 May 2026 07:06:01 +0200 Subject: [PATCH 03/24] Move low level print code away from high level function This makes it easier to read the high level function. --- src/print/unicode.rs | 50 +++++++++++++++++++------------------------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/src/print/unicode.rs b/src/print/unicode.rs index 24f3f47..3630f3b 100644 --- a/src/print/unicode.rs +++ b/src/print/unicode.rs @@ -83,24 +83,15 @@ pub fn print_unicode(graph: &GitGraph, settings: &Settings) -> Result usize { 2 * max_column + 1 } -/// Prepares wrapping options, returning the options structure. -// 'a now refers to the lifetime of the indent strings passed in. -fn get_wrapping_options<'a>( - settings: &Settings, - num_cols: usize, - indent1: &'a str, // Takes reference to owned string - indent2: &'a str, // Takes reference to owned string -) -> Result>, String> { - if let Some((width, _, _)) = settings.wrapping { - // We now pass the references directly to create_wrapping_options - create_wrapping_options(width, indent1, indent2, num_cols + 4) - } else { - Ok(None) - } -} - /// Iterates through commits to compute text lines, blank line inserts, and the index map. -fn build_commit_lines_and_map<'a>( +fn build_commit_lines_and_map( settings: &Settings, repository: &Repository, tracks: &TrackMap, layout: &TrackLayout, + num_cols: usize, the_head: &HeadInfo, inserts: &HashMap>>, - wrap_options: &Option>, ) -> Result<(Vec>, Vec), String> { + // Compute textwrap options + let indent1 = settings + .wrapping + .map(|(_, ind1, _)| " ".repeat(ind1.unwrap_or(0))); + let indent2 = settings + .wrapping + .map(|(_, _, ind2)| " ".repeat(ind2.unwrap_or(0))); + let wrap_options_owned: Option = settings.wrapping + .map(|(width, _, _)| { + let indent1 = indent1.as_ref().unwrap(); + let indent2 = indent2.as_ref().unwrap(); + create_wrapping_options(width, indent1, indent2, num_cols + 4) + }) + .transpose()? // Return if we got an Err + .flatten() // reduce OptionOption to Option + ; + let wrap_options: &Option = &wrap_options_owned; + + // Compute decorating labels let labels = list_labels(settings, repository)?; let head_idx = tracks.indices.get(&the_head.oid); From 799df2776a6d638346f7d8a7f52382a9e4e6106b Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Sun, 31 May 2026 07:43:36 +0200 Subject: [PATCH 04/24] Docs for print module Give general introduction and mention future and legacy functions. --- src/print/mod.rs | 18 +++++++++++++++++- src/print/unicode.rs | 6 ++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/print/mod.rs b/src/print/mod.rs index e29b3ea..8aba6af 100644 --- a/src/print/mod.rs +++ b/src/print/mod.rs @@ -1,4 +1,20 @@ -//! Create visual representations of git graphs. +/*! Create visual representations of git graphs. + +Printing is the final step that transforms a layout to the representation +where it will be used. This means colouring, exact placement, text wrapping +and similar issues are dealt with here. + +The original git-graph had two print functions: + +* [unicode::print_unicode] for printing to a terminal using fixed font and ansi colour codes. + This is candidate for deprecation along with the commit format code. +* print_svg for printing to SVG XML for display in a browser or elsewhere. + This has been kept in git-graph so not available from gleisbau. + +The gleisbau library adds one new function: + +* [unicode::print_graph_terminal] for printing only the graph to a terminal +*/ pub mod colors; pub mod format; diff --git a/src/print/unicode.rs b/src/print/unicode.rs index 3630f3b..79738b2 100644 --- a/src/print/unicode.rs +++ b/src/print/unicode.rs @@ -827,6 +827,12 @@ fn get_deviate_index( } } +/** Print a graph as lines for a terminal +*/ +pub fn print_graph_terminal() { + todo!(); +} + /// Creates the complete graph visualization, incl. formatter commits. fn print_graph( characters: &Characters, From 738cc512c3d6e9625b86b34f1fd045292b9722ed Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Sat, 30 May 2026 08:25:12 +0200 Subject: [PATCH 05/24] Rename print_graph to print_graph_and_text A new API for print_graph (only) will be added, so it is important that the curent API is properly named to disinguish them. --- src/print/unicode.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/print/unicode.rs b/src/print/unicode.rs index 79738b2..713f645 100644 --- a/src/print/unicode.rs +++ b/src/print/unicode.rs @@ -108,7 +108,7 @@ pub fn print_unicode(graph: &GitGraph, settings: &Settings) -> Result>, From 53fb5a79908a5a14bb4d8cefcf75b22684408154 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Sun, 31 May 2026 15:22:45 +0200 Subject: [PATCH 06/24] Extract inner function of get_inserts --- src/print/unicode.rs | 100 +++++++++++++++++++++++++++++-------------- 1 file changed, 69 insertions(+), 31 deletions(-) diff --git a/src/print/unicode.rs b/src/print/unicode.rs index 713f645..adb8ed9 100644 --- a/src/print/unicode.rs +++ b/src/print/unicode.rs @@ -695,37 +695,15 @@ fn get_inserts( // where the new range doesn't overlap with existing occupations. let mut insert_at = entry.get().len(); for (insert_idx, sub_entry) in entry.get().iter().enumerate() { - let mut occ = false; - // Check for overlaps with existing `Occ` in the current row. - for other_range in sub_entry { - // Check if the current column range overlaps with the other range. - if other_range.overlaps(&column_range) { - match other_range { - // If the other occupation is a commit. - Occ::Commit(target_index, _) => { - // In compact mode, we might allow overlap with the commit itself - // for merge commits (specifically the second parent) to keep the - // graph tighter. - if !compact - || !info.is_merge() - || idx != *target_index - || p == 0 - { - occ = true; - break; - } - } - // If the other occupation is a range (another connection). - Occ::Range(o_idx, o_par_idx, _, _) => { - // Avoid overlap with connections between the same commits. - if idx != *o_idx && par_idx != o_par_idx { - occ = true; - break; - } - } - } - } - } + let occ = has_overlap( + sub_entry, + column_range, + compact, + info.is_merge(), + idx, + p, + par_idx, + ); // If no overlap is found in this row, we can insert here. if !occ { insert_at = insert_idx; @@ -773,6 +751,66 @@ fn get_inserts( inserts } +/// Checks if a proposed horizontal connection (range) overlaps or conflicts with +/// existing elements in a specific layout row. +/// +/// In standard layout modes, any overlap with an existing commit or an existing +/// connection range constitutes a conflict. However, in `compact` mode, an overlap +/// with a commit is permitted if the current commit is a merge commit and the connection +/// belongs to its second (or subsequent) parent. Overlaps between the exact same +/// commit-to-parent connection paths are also ignored to prevent redundant blocking. +/// +/// # Arguments +/// +/// * `sub_entry` - The current row of visual elements (`Occ`) to check against. +/// * `column_range` - A tuple `(min_col, max_col)` representing the horizontal span of the new connection. +/// * `compact` - A boolean flag; if true, enables tighter graph spacing rule exceptions. +/// * `info_is_merge` - True if the current commit being processed is a merge. +/// * `idx` - The index of the current commit in the track list. +/// * `p` - The parent index currently being evaluated +/// (e.g., `0` for first parent, `1` for second, etc). +/// * `par_idx` - The index of the parent commit in the track list. +/// +/// # Returns +/// +/// Returns `true` if there is an unallowable visual collision in this row, +/// and `false` if the connection can safely occupy this row. +fn has_overlap( + sub_entry: &[Occ], + column_range: (usize, usize), + compact: bool, + info_is_merge: bool, + idx: usize, + p: usize, + par_idx: &usize, +) -> bool { + // Check for overlaps with existing `Occ` in the current row. + for other_range in sub_entry { + // Check if the current column range overlaps with the other range. + if other_range.overlaps(&column_range) { + match other_range { + // If the other occupation is a commit. + Occ::Commit(target_index, _) => { + // In compact mode, we might allow overlap with the commit itself + // for merge commits (specifically the second parent) to keep the + // graph tighter. + if !compact || !info_is_merge || idx != *target_index || p == 0 { + return true; + } + } + // If the other occupation is a range (another connection). + Occ::Range(o_idx, o_par_idx, _, _) => { + // Avoid overlap with connections between the same commits. + if idx != *o_idx && par_idx != o_par_idx { + return true; + } + } + } + } + } + false +} + /// Find the index at which a between-branch connection /// has to deviate from the current branch's column. /// From 0183ad2b13ef5793c5313e1ba7b357cada4406ae Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Mon, 1 Jun 2026 06:01:56 +0200 Subject: [PATCH 07/24] Improve documentation on main print function --- src/print/unicode.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/print/unicode.rs b/src/print/unicode.rs index adb8ed9..96d10e2 100644 --- a/src/print/unicode.rs +++ b/src/print/unicode.rs @@ -79,11 +79,12 @@ pub fn print_unicode(graph: &GitGraph, settings: &Settings) -> Result Result Date: Sat, 30 May 2026 08:25:12 +0200 Subject: [PATCH 08/24] Split unicode print Text Formatting must be optional, so provide new functions that return data at a lower level. --- src/layout.rs | 7 ++ src/print/unicode.rs | 160 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 164 insertions(+), 3 deletions(-) diff --git a/src/layout.rs b/src/layout.rs index deb9d4e..a8a1845 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -45,6 +45,10 @@ impl TrackLayout { pub fn iter_commit_index(&self) -> impl Iterator { self.source.clone() } + /// First index into TrackMap in this layout + pub fn commit_index_start(&self) -> usize { + self.source.start + } pub fn track_visual(&self, track_inx: Binx) -> Option<&BranchVis> { self.track_visual .get(&track_inx) @@ -53,6 +57,9 @@ impl TrackLayout { pub fn track_visual_vec(&self) -> &Vec { &self.branch_visual } + pub fn commit_count(&self) -> usize { + self.source.len() + } } /// Branch properties for visualization. diff --git a/src/print/unicode.rs b/src/print/unicode.rs index 96d10e2..ab09776 100644 --- a/src/print/unicode.rs +++ b/src/print/unicode.rs @@ -217,6 +217,49 @@ fn build_commit_lines_and_map( Ok((text_lines, index_map)) } +/// Iterates through commits to compute the index map. +fn build_commit_map( + layout: &TrackLayout, + inserts: &HashMap>>, // graphical extra lines + commit_height: &[usize], // text lines required +) -> Result, String> { + // Compute commit index to output row map + let mut index_map = vec![]; + let mut offset = 0; + + for idx in layout.iter_commit_index() { + index_map.push(idx + offset); + + // Calculate needed graph inserts (for ranges only) + let cnt_inserts = if let Some(inserts) = inserts.get(&idx) { + inserts + .iter() + .filter(|vec| { + vec.iter().all(|occ| match occ { + Occ::Commit(_, _) => false, + Occ::Range(_, _, _, _) => true, + }) + }) + .count() + } else { + 0 + }; + + // Calculate needed text inserts + let commit_height_index = idx - layout.commit_index_start(); + let num_lines = commit_height + .get(commit_height_index) + .unwrap_or(&1) + .saturating_sub(1); + + // Make room for the largest number of inserts + let max_inserts = max(cnt_inserts, num_lines); + offset += max_inserts; + } + + Ok(index_map) +} + /// Initializes the grid and draws all commit/branch connections. /// /// # Arguments @@ -227,7 +270,7 @@ fn draw_graph_lines( layout: &TrackLayout, num_cols: usize, inserts: &HashMap>>, - index_map: &[usize], + index_map: &[usize], // map commit index to row total_rows: usize, ) -> Grid { let mut grid = Grid::new( @@ -868,10 +911,121 @@ fn get_deviate_index( } } +// +// Graph only printing from TrackMap +// + /** Print a graph as lines for a terminal +# Arguments +- settings +- tracks +- layout +- commit_text_height : a text height for each commit in the layout. + If a commit has height > 1 then extra graph lines will be added + to match this. The grapy may determine that a commit require two + lines, even though the commit_text only asked for 1. +# Returns + [GraphLines], which is a list of graph output and the output row where + a specific row starts. */ -pub fn print_graph_terminal() { - todo!(); +pub fn print_graph_terminal( + settings: &Settings, + tracks: &TrackMap, + layout: &TrackLayout, + commit_text_height: &[usize], // [0] corresponds to track commit layout.commit_index_start() +) -> GraphLines { + if tracks.all_branches.is_empty() { + return GraphLines::empty(); + } + + // inserts are extra lines needed when the layout cannot be drawn on + // a single line. They influence the number of rows needed + let inserts = get_inserts(tracks, layout, settings.compact); + + // The index map gives the row number from a commit index, relative + // to layout.commit_index_start() + let index_map = + build_commit_map(layout, &inserts, commit_text_height).expect("valid commit_text_height"); + + // Compute grid size + let num_cols = calculate_graph_dimensions(layout); + let min_row_height = 1; + let rows_without_commit_text = layout + .commit_count() + .saturating_sub(commit_text_height.len()); + let total_rows = commit_text_height + .iter() + .map(|&x| max(min_row_height, x)) + .take(layout.commit_count()) + .sum::() + + rows_without_commit_text * min_row_height; + + // Draw graph as lines on a grid + let mut grid = draw_graph_lines( + settings, tracks, layout, num_cols, &inserts, &index_map, total_rows, + ); + + // Handle reverse order + if settings.reverse_commit_order { + grid.reverse(); + } + + // 6. Final printing and result + let lines = grid_print_terminal(&settings.characters, &grid, settings.colored); + + GraphLines { + graph_lines: lines, + commit2line: index_map, + } +} + +/// Printed lines of graph along with he commit index +pub struct GraphLines { + /// The graph printed as lines + pub graph_lines: Vec, + /// Map from commit index in [TrackLayout] to line number in 'graph_lines'. + /// To find the commit index in [TrackMap] add [TrackLayout::commit_index_start]. + pub commit2line: Vec, +} + +impl GraphLines { + pub fn empty() -> Self { + Self { + graph_lines: vec![], + commit2line: vec![], + } + } +} + +/// Print a grid as ansi coloured strings. Optionally removing colour. +/// Grid uses symbols, they are rendered according to the provided +/// Characters map. +fn grid_print_terminal(characters: &Characters, grid: &Grid, color: bool) -> Vec { + let mut g_lines = vec![]; + + let cell2string = |cell: &GridCell| -> String { + if color { + let chars = cell.char(characters); + if cell.character == SPACE { + chars.to_string() + } else { + chars.to_string().fixed(cell.color).to_string() + } + } else { + cell.char(characters).to_string() + } + }; + + for row in grid.data.chunks(grid.width) { + let mut g_out = String::new(); + + let str = row.iter().map(&cell2string).collect::(); + write!(g_out, "{}", str).unwrap(); + + g_lines.push(g_out); + } + + g_lines } /// Creates the complete graph visualization, incl. formatter commits. From 61b6b4ca907e6e3579e7120dce3e433dd443a62e Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Mon, 8 Jun 2026 05:00:25 +0200 Subject: [PATCH 09/24] Handle range out of bounds --- src/layout.rs | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/layout.rs b/src/layout.rs index a8a1845..c1e3d57 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -32,20 +32,26 @@ define_u32_index!( Use [layout_track_range] to construct a [TrackLayout] */ pub struct TrackLayout { - // Specifies which commits are rendered + /// Specifies which commits are rendered. + /// + /// *Note*: The range may be larger than what is valid in [TrackMap] source: Range, - // Map a TrackMap.branch index to a TrackLayout.branch_visual index + /// Map a TrackMap.branch index to a TrackLayout.branch_visual index track_visual: HashMap, - // Visuals for all tracks in the rendered range + /// Visuals for all tracks in the rendered range branch_visual: Vec, } impl TrackLayout { - /// Iterate all index into TrackMap.commits used for this layout + /// Iterate all index into TrackMap.commits specified for this layout. + /// + /// *Note*: This include out-of-range index, so use TrackMap.commits.get() pub fn iter_commit_index(&self) -> impl Iterator { self.source.clone() } - /// First index into TrackMap in this layout + /// First index into TrackMap in this layout. + /// + /// *Note*: Not checked for out-of-range, so use TrackMap.commits.get() pub fn commit_index_start(&self) -> usize { self.source.start } @@ -57,6 +63,9 @@ impl TrackLayout { pub fn track_visual_vec(&self) -> &Vec { &self.branch_visual } + /// Number of commits specified for this layout. + /// + /// *Note*: The specification may include invalid index. pub fn commit_count(&self) -> usize { self.source.len() } @@ -110,14 +119,13 @@ pub fn layout_track_range( // --- Pass 1: Create initial BranchVis (Colors and Order Groups) --- for i in range.clone() { // Find track assigned to commit - let commit = &track_map.commits[i]; + let Some(commit) = &track_map.commits.get(i) else { + // Ignore requests for commits outside the valid range + continue; + }; let Some(b_idx) = commit.branch_trace else { - todo!("Decide how to handle commit without track"); - /* - Do I want to show it? - Perhaps to panic? - Do I want to autogenerate a branch named "anonymous"? - */ + log::error!("Commit #{} does not have a branch_trace", i); + return Err("All commits in track map must have a track".into()); }; // If the track does not yet have a visualization, create it From 3b87056d6167434afd980dd78f10be3b2d0423da Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Wed, 10 Jun 2026 22:31:26 +0200 Subject: [PATCH 10/24] Document coordinate systems used for layout and print --- src/layout.rs | 11 +++++++++++ src/print/unicode.rs | 21 ++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/layout.rs b/src/layout.rs index c1e3d57..e02186d 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -3,6 +3,17 @@ which order tracks should be placed. It is intended as last step before printing. Decoration of commits, e.g. with tags and branch labels, should be done during printing. + +# Coordinate system +The layout is done in a 2D coordinate system: +- row is defined as an index into TrackMap.commits +- column is defined per branch track. + +Printing may also add extra rows or columns as the output format +requires. For example, the unicode print will have twice the number +of columns, and occasionally an extra row if lines cannot be drawn +on a single line without colliding. For more information, see +the [print](crate::print) module. */ use std::cmp::max; diff --git a/src/print/unicode.rs b/src/print/unicode.rs index ab09776..297b814 100644 --- a/src/print/unicode.rs +++ b/src/print/unicode.rs @@ -1,4 +1,23 @@ -//! Create graphs in Unicode format with ANSI X3.64 / ISO 6429 colour codes +/*! Create graphs in Unicode format with ANSI X3.64 / ISO 6429 colour codes + +Terminals usuallly have very tall characters, so to get a square ratio +we need to double the number of rows. Although unicode allows drawing +of lines, it does not support parallel lines inside the same symbol. +To fix this we add extra "inserts", extra lines per commit as needed +to draw lines without unwanted overlap. + +The main functions are: +- [print_unicode] - Legacy API. Prints both graph and commit text from a [GitGraph] +- [print_graph_terminal] - Print graph only from [TrackMap] and [TrackLayout] + +## Coordinate system + +The final output is rendered onto a private 2D struct 'Grid' before printing. +This is in the final coordinate system including extra columns and rows. +[TrackLayout] uses the abstract coordinate system of commit row and +track column, so you need to keep track of which coordinate system a +function uses. +*/ use std::cmp::max; use std::collections::hash_map::Entry::{Occupied, Vacant}; From 616fe21c9ee15aaa76e88a5b89adfa076f41bc18 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Fri, 12 Jun 2026 05:48:53 +0200 Subject: [PATCH 11/24] Describe algorithm for new API Document terms used throughout Gleisbau. Describe how branch assignment works --- docs/algorithm_2.md | 110 ++++++++++++++++++++++++++++++++++++++++++++ docs/refactoring.md | 29 +++++++----- src/lib.rs | 6 +++ 3 files changed, 134 insertions(+), 11 deletions(-) create mode 100644 docs/algorithm_2.md diff --git a/docs/algorithm_2.md b/docs/algorithm_2.md new file mode 100644 index 0000000..d138cdd --- /dev/null +++ b/docs/algorithm_2.md @@ -0,0 +1,110 @@ +This section describes the algorithm introduced by the 0.7.x series + +# Overview + +The goal of Gleisbau is to present visually pleasing graphs of git +repositories. It is based on the idea that each commit should be assigned +a single track, which then forms a vertical line. These tracks are then +sorted and coloured according to user configuration. + +The term track is used to avoid the confusion found in the term branch. +In git a branch means a label on a single commit. One commit can have +several branch labels. A commit can also have a tag label. All labels +can be moved around. When people discuss the structure of a git +repository they also use the term branch, but in this case it means +sequence of commits, starting at a fork point. + +## Terms +Lets define some terms, first for graph topology: + +- *git graph*: A directed graph where each node represents a commit and + each edge points from child to parent. +- *merge commit*: + A commit with more than one parent. +- *fork commit*: + A commit with more than one child. +- *primary parent*: + The first parent of a commit. Git orders parents. +- *head commit*: + A commit where no other commit has it as primary parent. +- *track*: + A path in a git graph where every edge in the path is to a primary parent. +- *track start*: + The commit in a track that has no child in the track. +- *track end*: + The commit in a track that has no parent in the track. +- *track merge target*: + A specific child of track start chosen as the one that merged the track. +- *track fork source*: + The primary parent to the track end. +- *track map*: + A covering of a git graph with tracks, such that every commit belongs + to exactly one track. + +Terms for geometry: + +- *layout*: + An illustration of a graph. Two different layouts can represent the + same graph, but two graphs with different topology will always have + different layouts. Note that some special handling is done if the + layout-graph is a subset of a larger graph. +- *label*: + Gleisbau refers to the movable references to commits in git as labels. + This covers branch labels as well as tag labels. +- *branch visualisation*: (TODO rename -> track visualisation) + A Track is presented geometrically as vertical line with some colour. +- *line*: + An edge is represented as a line. Lines between two commits in the same + track are vertical, the rest are mostly horizontal but it is possible + for two tracks A and B to link vertical if the edge is between track A end + and track B start. + +Terms for algorithm: + +- *Build tracks*: + The algorithm that assigns commits to tracks. +- *Layout tracks*: + The algorithm that produces a [TrackLayout](crate::layout::TrackLayout) + from a [TrackMap](crate::track::TrackMap) + +- *track persistence*: (topology) + Each track is assigned a persistence order, represented by a number. + It is used by the algorithm that assigns commits to tracks. If two tracks + has the same parent, the parent is assigned to the track with the lowest + preceedence number. +- *Abstract Grid*: (layout) + A grid where each row represents exactly one commit, and each track + occupies a line in exactly one column. +- *Terminal Grid*: (layout) + When making a layout for terminal usage, the narrow characters make it + more visually pleasing if only every second column is used for lines - + thus double width relative to the abstract grid. The horizontal lines + should be kept apart if they link unrelated commits, so sometimes we + need to insert one extra line per commit. + +## Algorithm + +### When to start a track/branch? +A commit with no children is a head commit. This starts a branch. + +A commit merged into another commit is a head commit, but only if it is not +continued (used as primary parent) +It is possible for a head commit to be merged into multiple other commits. +The strongest suggestion is used for the new branch. + +A non-head commit can start a branch if the merge identifies it as a higher +persistence branch than provided via primary parent relations. + +A branch starts if explicitly started by the user. Via a branch label or +a tag label. Only the most persistent user branch at a commit is used to +start the branch. +A branch may start implicitly at secondary parents of a merge commit. +This does not happen, if a more persistent branch can claim the parent. + + +### Which branch does a commit A belong to? +The following canddidate branches are considered: +- (explicit) User specified labels +- (continuation) Any commit B that has A as primary parent provide a canddiate branch +- (derived) Any commit B that has A as non-primary parent is a merge commit. + This can generate branch candidates from the merge message. diff --git a/docs/refactoring.md b/docs/refactoring.md index e89a374..09d3d7a 100644 --- a/docs/refactoring.md +++ b/docs/refactoring.md @@ -56,17 +56,24 @@ reverse the link between BranchInfo and BranchViz. anything about visualization. - BranchViz will be built repeatedly when rendering, from BranchInfo. -## AS-IS - -GitGraph --> BranchInfo --> BranchVis - -## TO-BE - - -TrackMap --> BranchInfo -GitGraph --> TrackMap -GitGraph --> BranchVis -GitGraph --> LabelMap +AS-IS + +```mermaid +classDiagram + GitGraph --> BranchInfo + BranchInfo --> BranchVis +``` + +TO-BE + +```mermaid +classDiagram + TrackMap --> BranchInfo + TrackLayout --> BranchVis + GitGraph --> TrackMap + GitGraph --> TrackLayout + GitGraph --> LabelMap +``` # Step: Threading diff --git a/src/lib.rs b/src/lib.rs index 21dd5b3..211a268 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,12 @@ pub mod _0_7_x_refactor { #![doc = include_str!("../docs/refactoring.md")] } +// Documentation only module, +// see https://rustprojectprimer.com/documentation/rustdoc.html +pub mod _0_7_x_algorithm { + #![doc = include_str!("../docs/algorithm_2.md")] +} + pub fn get_repo>( path: P, skip_repo_owner_validation: bool, From 6d828e974b594d7e86841d72d5b93dba7172c9e5 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Mon, 6 Jul 2026 17:51:56 +0200 Subject: [PATCH 12/24] Hide u32_index macro This is intended for internal usage only --- src/u32_index.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/u32_index.rs b/src/u32_index.rs index 463db8f..57652ad 100644 --- a/src/u32_index.rs +++ b/src/u32_index.rs @@ -1,3 +1,4 @@ +#![doc(hidden)] /*! 32 bit index type macro. This module defines a macro [crate::define_u32_index]. @@ -28,6 +29,7 @@ Example: let result = &my_vec[small_inx]; */ +#[doc(hidden)] #[macro_export] macro_rules! define_u32_index { ( From 67fc736bb49ea4fa4412663cf4b2b841fbc732a7 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Wed, 10 Jun 2026 23:08:54 +0200 Subject: [PATCH 13/24] Add layout::BranchVis.row_range This field defines the start and end row used for layout of a branch. Add a flag to indicate if the track continues outside what is visualized. The old version used An Option where None meant undetermined. The new representation has the benefit that it can always be drawn, even if we know that it should be continued. Some visualsations need to know where last visible commit was, which was not possible with the old structure. --- src/layout.rs | 75 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 6 deletions(-) diff --git a/src/layout.rs b/src/layout.rs index e02186d..a1b7f21 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -96,6 +96,31 @@ pub struct BranchVis { pub svg_color: String, /// The column the branch is located in pub column: Option, + /** Row range occupied by branch. It is defined as low and + high index into TrackMap.commits, but in Layout this index is used + as one of the axis. + + - .0 is low value (youngest commit, near top) + - .1 is high value (oldest commit, near bottom) + + This includes the row occupied by track fork (below oldest commit in track) + and the row occupied by track merge (above youngest commit). + The merge is done at the exact row of the merge target, but the fork + could be done at any row after fork commit as long as no child of the fork + has been passed yet. + Track end is a child of the fork commit, but there may be others as well. + + This definition means two tracks can exist in the same column if they touch + at the ends, but not if they overlap. + */ + pub row_range: (usize, usize), + + /** Flag if branch continues outside visible range + + - .0 is low end (youngest commit, near top) + - .1 is high end (oldest commit, near bottom) + */ + pub open_end: (bool, bool), } impl BranchVis { @@ -107,6 +132,8 @@ impl BranchVis { term_color, svg_color, column: None, + row_range: (0, 0), + open_end: (false, false), } } } @@ -147,12 +174,18 @@ pub fn layout_track_range( color_counter += 1; let visual_data = - create_branch_visual(color_counter, branch_info, track_map, settings)?; + create_branch_visual(color_counter, branch_info, track_map, settings, i)?; let vis_idx = Vinx::new(branch_visuals.len()); branch_visuals.push(visual_data); e.insert(vis_idx); } + + // Update visualisation of track. This will continue a track in the layout + // so row_range.1 always points at the end row (highest index = oldest commit) + let vis_idx = track_visual_map[&b_idx]; + let vis = &mut branch_visuals[vis_idx]; + vis.row_range.1 = i; } // --- Pass 2: Connect Visual Groups (Target/Source Order Groups) --- @@ -160,6 +193,14 @@ pub fn layout_track_range( for (b_idx, &vis_idx) in track_visual_map.iter() { let branch = &track_map.all_branches[*b_idx]; + // Extend low end of row range to include the merge target + if let Some(&merge_target_index) = branch + .merge_target + .as_ref() + .and_then(|oid| track_map.indices.get(oid)) + { + branch_visuals[vis_idx].row_range.0 = merge_target_index; + } // Resolve Target Order Group if let Some(target_idx) = branch.target_branch { // Check if the target branch has a visual in our current layout @@ -169,6 +210,18 @@ pub fn layout_track_range( } } + // Extend high end of row range to include fork commit + if let Some(&fork_index) = Some(branch_visuals[vis_idx].row_range.1) + .map(|commit_inx| &track_map.commits[commit_inx]) + .and_then(|commit| commit.parents.first()) + .and_then(|oid| track_map.indices.get(oid)) + { + // row_range.1 is inclusive, but we don't want to occupy a column + // used by a track that was merged here. By subtracting one, we move + // the fork point one commit up, which gives room to a merge, while + // preventing fork overlap. + branch_visuals[vis_idx].row_range.1 = fork_index - 1; + } // Resolve Source Order Group if let Some(source_idx) = branch.source_branch { // Check if the source branch has a visual in our current layout @@ -236,11 +289,13 @@ pub fn get_deviate_index( } } +/// Create a new [BranchVis] for a single commit fn create_branch_visual( - idx: usize, - branch: &BranchInfo, + idx: usize, // Colour counter + branch: &BranchInfo, // Branch to visualise track_map: &TrackMap, // Now we pass the map to look up other branches settings: &Settings, + row: usize, // Start visualisation as a dot on this row ) -> Result { let mut name_to_color = &branch.name; @@ -282,6 +337,8 @@ fn create_branch_visual( target_order_group: None, source_order_group: None, column: None, + row_range: (row, row), + open_end: (false, false), }) } @@ -306,13 +363,19 @@ pub fn assign_branch_columns( .track_visual .iter() .map(|(&branch_idx, &vis_idx)| { - let br = &track_map.all_branches[branch_idx]; let vis = &layout.branch_visual[vis_idx]; + // Open ends expand to capture all visible area. + let row_start = if vis.open_end.0 { 0 } else { vis.row_range.0 }; + let row_end = if vis.open_end.1 { + layout.commit_count() - 1 + } else { + vis.row_range.1 + }; ( branch_idx, vis_idx, - br.range.0.unwrap_or(0), - br.range.1.unwrap_or(track_map.commits.len() - 1), + row_start, + row_end, vis.source_order_group .unwrap_or(settings.branches.order.len() + 1), vis.target_order_group From 3e5335c8edefd9cc94aa31ec4ce3327be6132ae9 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Sun, 14 Jun 2026 06:19:04 +0200 Subject: [PATCH 14/24] Print a commit with parent that has no visuals This means the parent is outside the layout range. Just ignore it, or make a line all the way to the bottom for the current branch visualisation. Each such parent should have a line terminating outside range. which can be a 1-char marker or a line. --- src/layout.rs | 4 ++++ src/print/unicode.rs | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/layout.rs b/src/layout.rs index a1b7f21..63bc966 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -54,6 +54,10 @@ pub struct TrackLayout { } impl TrackLayout { + /// Ask if a commit index is in the layout + pub fn contains_commit_index(&self, commit_index: usize) -> bool { + self.source.contains(&commit_index) + } /// Iterate all index into TrackMap.commits specified for this layout. /// /// *Note*: This include out-of-range index, so use TrackMap.commits.get() diff --git a/src/print/unicode.rs b/src/print/unicode.rs index 297b814..88ecb05 100644 --- a/src/print/unicode.rs +++ b/src/print/unicode.rs @@ -737,9 +737,21 @@ fn get_inserts( if let Some(par_idx) = tracks.indices.get(&par_oid) { let par_info = &tracks.commits[*par_idx]; let par_track_idx = par_info.branch_trace.unwrap(); - let par_branch_visual = layout - .track_visual(par_track_idx) - .expect("Parent track must have visuals"); + let par_branch_visual_opt = layout + .track_visual(par_track_idx); + let Some(par_branch_visual) = par_branch_visual_opt + else { + // Parent does not have visuals. + if layout.contains_commit_index(*par_idx) { + // Parent should have been visualized + // as it is inside the visualisation range + panic!("Parent track must have visuals") + } else { + // Ignore parent outside layout + // TODO visualize this relation + continue; + } + }; let par_column = par_branch_visual.column.unwrap(); // Determine the sorted range of columns between the current commit and its parent. let column_range = sorted(column, par_column); From dfc4bc7e7e2897573e0261eef6b9165bec2de6b5 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Sun, 14 Jun 2026 22:39:44 +0200 Subject: [PATCH 15/24] Extract grid.rs from unicode.rs --- src/print/grid.rs | 709 +++++++++++++++++++++++++++++++++++++++++++ src/print/mod.rs | 1 + src/print/unicode.rs | 700 +----------------------------------------- 3 files changed, 717 insertions(+), 693 deletions(-) create mode 100644 src/print/grid.rs diff --git a/src/print/grid.rs b/src/print/grid.rs new file mode 100644 index 0000000..8e7cd9d --- /dev/null +++ b/src/print/grid.rs @@ -0,0 +1,709 @@ +/*! 2D structure for drawing lines before generating terminal text. + +The [Grid] holds a 2 dimensional array of [GridCell] each holding +a symbol (character), a colour and a z-order (persistence level). + +The symbols are defined abstractly here as constants, +whereas the mapping to an acctual character used for printing a symbol +is defined by [Characters]. +*/ + +use crate::settings::Characters; + +// Symbols used in [Grid] + +pub const SPACE: u8 = 0; +pub const DOT: u8 = 1; +pub const CIRCLE: u8 = 2; +const VER: u8 = 3; +const HOR: u8 = 4; +const CROSS: u8 = 5; +const R_U: u8 = 6; +const R_D: u8 = 7; +const L_D: u8 = 8; +const L_U: u8 = 9; +const VER_L: u8 = 10; +const VER_R: u8 = 11; +const HOR_U: u8 = 12; +const HOR_D: u8 = 13; + +const ARR_L: u8 = 14; +const ARR_R: u8 = 15; + +/// One cell in a [Grid] +#[derive(Clone, Copy)] +pub struct GridCell { + /// The symbol shown, encoded as in index into settings::Characters + pub character: u8, + /// Standard 8-bit terminal colour code + pub color: u8, + /// Persistence level. z-order, lower numbers take preceedence. + pub pers: u8, +} + +impl GridCell { + pub fn char(&self, characters: &Characters) -> char { + characters.chars[self.character as usize] + } +} + +/// Two-dimensional grid used to hold the graph layout. +/// +/// This can be rendered as unicode text or as SVG. +pub struct Grid { + pub width: usize, + pub height: usize, + + /// Grid cells are stored in row-major order. + pub data: Vec, +} + +impl Grid { + pub fn new(width: usize, height: usize, initial: GridCell) -> Self { + Grid { + width, + height, + data: vec![initial; width * height], + } + } + + pub fn reverse(&mut self) { + self.data.reverse(); + } + /// Turn a 2D coordinate into an index of Grid.data + pub fn index(&self, x: usize, y: usize) -> usize { + y * self.width + x + } + pub fn get_tuple(&self, x: usize, y: usize) -> (u8, u8, u8) { + let v = self.data[self.index(x, y)]; + (v.character, v.color, v.pers) + } + pub fn set(&mut self, x: usize, y: usize, character: u8, color: u8, pers: u8) { + let idx = self.index(x, y); + self.data[idx] = GridCell { + character, + color, + pers, + }; + } + pub fn set_opt( + &mut self, + x: usize, + y: usize, + character: Option, + color: Option, + pers: Option, + ) { + let idx = self.index(x, y); + let cell = &mut self.data[idx]; + if let Some(character) = character { + cell.character = character; + } + if let Some(color) = color { + cell.color = color; + } + if let Some(pers) = pers { + cell.pers = pers; + } + } +} + +/// Draw a line that connects two commits on different columns +pub fn zig_zag_line( + grid: &mut Grid, + row123: (usize, usize, usize), + col12: (usize, usize), + is_merge: bool, + color: u8, + pers: u8, +) { + let (row1, row2, row3) = row123; + let (col1, col2) = col12; + vline(grid, (row1, row2), col1, color, pers); + hline(grid, row2, (col2, col1), is_merge, color, pers); + vline(grid, (row2, row3), col2, color, pers); +} + +/// Draws a vertical line +pub fn vline(grid: &mut Grid, (from, to): (usize, usize), column: usize, color: u8, pers: u8) { + for i in (from + 1)..to { + let (curr, _, old_pers) = grid.get_tuple(column * 2, i); + let (new_col, new_pers) = if pers < old_pers { + (Some(color), Some(pers)) + } else { + (None, None) + }; + match curr { + DOT | CIRCLE => {} + HOR => { + grid.set_opt(column * 2, i, Some(CROSS), Some(color), Some(pers)); + } + HOR_U | HOR_D => { + grid.set_opt(column * 2, i, Some(CROSS), Some(color), Some(pers)); + } + CROSS | VER | VER_L | VER_R => grid.set_opt(column * 2, i, None, new_col, new_pers), + L_D | L_U => { + grid.set_opt(column * 2, i, Some(VER_L), new_col, new_pers); + } + R_D | R_U => { + grid.set_opt(column * 2, i, Some(VER_R), new_col, new_pers); + } + _ => { + grid.set_opt(column * 2, i, Some(VER), new_col, new_pers); + } + } + } +} + +/// Draw a horizontal line. +/// If from > to, this will cause a backward draw. +fn hline( + grid: &mut Grid, + index: usize, + (from, to): (usize, usize), + merge: bool, + color: u8, + pers: u8, +) { + if from == to { + return; + } + + let from_2 = from * 2; + let to_2 = to * 2; + + if from < to { + update_range_forward(grid, index, from_2, to_2, merge, color, pers); + update_left_cell_forward(grid, index, from_2, color, pers); + update_right_cell_forward(grid, index, to_2, color, pers); + } else { + update_range_backward(grid, index, from_2, to_2, merge, color, pers); + update_left_cell_backward(grid, index, to_2, color, pers); + update_right_cell_backward(grid, index, from_2, color, pers); + } +} + +fn update_range_forward( + grid: &mut Grid, + index: usize, + from_2: usize, + to_2: usize, + merge: bool, + color: u8, + pers: u8, +) { + for column in (from_2 + 1)..to_2 { + if merge && column == to_2 - 1 { + grid.set(column, index, ARR_R, color, pers); + } else { + let (curr, _, old_pers) = grid.get_tuple(column, index); + let (new_col, new_pers) = if pers < old_pers { + (Some(color), Some(pers)) + } else { + (None, None) + }; + match curr { + DOT | CIRCLE => {} + VER => grid.set_opt(column, index, Some(CROSS), None, None), + HOR | CROSS | HOR_U | HOR_D => grid.set_opt(column, index, None, new_col, new_pers), + L_U | R_U => grid.set_opt(column, index, Some(HOR_U), new_col, new_pers), + L_D | R_D => grid.set_opt(column, index, Some(HOR_D), new_col, new_pers), + _ => { + grid.set_opt(column, index, Some(HOR), new_col, new_pers); + } + } + } + } +} + +fn update_left_cell_forward(grid: &mut Grid, index: usize, from_2: usize, color: u8, pers: u8) { + let (left, _, old_pers) = grid.get_tuple(from_2, index); + let (new_col, new_pers) = if pers < old_pers { + (Some(color), Some(pers)) + } else { + (None, None) + }; + match left { + DOT | CIRCLE => {} + VER => grid.set_opt(from_2, index, Some(VER_R), new_col, new_pers), + VER_L => grid.set_opt(from_2, index, Some(CROSS), None, None), + VER_R => {} + HOR | L_U => grid.set_opt(from_2, index, Some(HOR_U), new_col, new_pers), + _ => { + grid.set_opt(from_2, index, Some(R_D), new_col, new_pers); + } + } +} + +fn update_right_cell_forward(grid: &mut Grid, index: usize, to_2: usize, color: u8, pers: u8) { + let (right, _, old_pers) = grid.get_tuple(to_2, index); + let (new_col, new_pers) = if pers < old_pers { + (Some(color), Some(pers)) + } else { + (None, None) + }; + match right { + DOT | CIRCLE => {} + VER => grid.set_opt(to_2, index, Some(VER_L), None, None), + VER_L | HOR_U => grid.set_opt(to_2, index, None, new_col, new_pers), + HOR | R_U => grid.set_opt(to_2, index, Some(HOR_U), new_col, new_pers), + _ => { + grid.set_opt(to_2, index, Some(L_U), new_col, new_pers); + } + } +} + +fn update_range_backward( + grid: &mut Grid, + index: usize, + from_2: usize, + to_2: usize, + merge: bool, + color: u8, + pers: u8, +) { + for column in (to_2 + 1)..from_2 { + if merge && column == to_2 + 1 { + grid.set(column, index, ARR_L, color, pers); + } else { + let (curr, _, old_pers) = grid.get_tuple(column, index); + let (new_col, new_pers) = if pers < old_pers { + (Some(color), Some(pers)) + } else { + (None, None) + }; + match curr { + DOT | CIRCLE => {} + VER => grid.set_opt(column, index, Some(CROSS), None, None), + HOR | CROSS | HOR_U | HOR_D => grid.set_opt(column, index, None, new_col, new_pers), + L_U | R_U => grid.set_opt(column, index, Some(HOR_U), new_col, new_pers), + L_D | R_D => grid.set_opt(column, index, Some(HOR_D), new_col, new_pers), + _ => { + grid.set_opt(column, index, Some(HOR), new_col, new_pers); + } + } + } + } +} + +fn update_left_cell_backward(grid: &mut Grid, index: usize, to_2: usize, color: u8, pers: u8) { + let (left, _, old_pers) = grid.get_tuple(to_2, index); + let (new_col, new_pers) = if pers < old_pers { + (Some(color), Some(pers)) + } else { + (None, None) + }; + match left { + DOT | CIRCLE => {} + VER => grid.set_opt(to_2, index, Some(VER_R), None, None), + VER_R => grid.set_opt(to_2, index, None, new_col, new_pers), + HOR | L_U => grid.set_opt(to_2, index, Some(HOR_U), new_col, new_pers), + _ => { + grid.set_opt(to_2, index, Some(R_U), new_col, new_pers); + } + } +} + +fn update_right_cell_backward(grid: &mut Grid, index: usize, from_2: usize, color: u8, pers: u8) { + let (right, _, old_pers) = grid.get_tuple(from_2, index); + let (new_col, new_pers) = if pers < old_pers { + (Some(color), Some(pers)) + } else { + (None, None) + }; + match right { + DOT | CIRCLE => {} + VER => grid.set_opt(from_2, index, Some(VER_L), new_col, new_pers), + VER_R => grid.set_opt(from_2, index, Some(CROSS), None, None), + VER_L => grid.set_opt(from_2, index, None, new_col, new_pers), + HOR | R_D => grid.set_opt(from_2, index, Some(HOR_D), new_col, new_pers), + _ => { + grid.set_opt(from_2, index, Some(L_D), new_col, new_pers); + } + } +} + + + + + + +#[cfg(test)] +mod tests { + use super::*; + // A dummy `Characters` struct is needed for `GridCell::char` but is not + // directly used in `hline` tests, so we can omit it by not calling `char()`. + + // --- Test Cases --- + + /* Testing hline + + Note that hline is given a graph column as input, + which indexes a grid column at 2*graph_col + // Graph column: 0 1 2 3 4 5 + // Grid columns: 0 1 2 3 4 5 6 7 8 9 + // Grid row 0: _ _ _ _ _ _ _ _ _ _ + // Grid row 1: _ _ _ _ _ _ _ _ _ _ + // Grid row 2: _ _ _ _ _ _ _ _ _ _ + + A horizontal line from 1 to 3, would occupy columns 2, 3, 4, 5, 6 inclusive + + */ + + const DEF_CH: u8 = SPACE; + const DEF_COL: u8 = 0; + const DEF_PERS: u8 = 10; // low persistence, will always be overwritten + const DEFAULT_CELL: GridCell = GridCell { + character: DEF_CH, + color: DEF_COL, + pers: DEF_PERS, + }; + const ROW_INDEX: usize = 1; + const LINE_COLOR: u8 = 14; + const LINE_PERS: u8 = 5; + + #[test] + fn hline_skip() { + let (width, height) = (10, 3); + let mut grid = Grid::new(width, height, DEFAULT_CELL); + // Graph column: 0 1 2 3 4 5 + // Grid columns: 0 1 2 3 4 5 6 7 8 9 + // Grid row 0: _ _ _ _ _ _ _ _ _ _ + // Grid row 1: _ _ _ _ _ _ _ _ _ _ + // Grid row 2: _ _ _ _ _ _ _ _ _ _ + + // Case 1: from == to (should do nothing) + let initial_char = grid.get_tuple(4 * 2, ROW_INDEX).0; + super::hline(&mut grid, ROW_INDEX, (4, 4), true, LINE_COLOR, LINE_PERS); + // Graph column: 0 1 2 3 4 5 + // Grid columns: 0 1 2 3 4 5 6 7 8 9 + // Grid row 0: _ _ _ _ _ _ _ _ _ _ + // Grid row 1: _ _ _ _ _ _ _ _X_ _ + // Grid row 2: _ _ _ _ _ _ _ _ _ _ + + assert_eq!( + grid.get_tuple(4 * 2, ROW_INDEX).0, + initial_char, + "Same index call should not modify grid" + ); + } + + /// Case 2: Forward draw (from < to), no merge + /// Case 2a: out of bounds + #[test] + fn hline_forward_no_merge_out_of_bounds() { + let (width, height) = (10, 3); + let mut grid = Grid::new(width, height, DEFAULT_CELL); + super::hline(&mut grid, ROW_INDEX, (2, 5), false, LINE_COLOR, LINE_PERS); + // Graph column: 0 1 2 3 4 5 + // Grid columns: 0 1 2 3 4 5 6 7 8 9 + // Grid row 0: _ _ _ _ _ _ _ _ _ _ + // Grid row 1: _ _ _ _F- - - - - - *T (F=from, T=to) + // Grid row 2: _ _ _ _ _ _ _ _ _ _ + + // from: 2, to: 5 + // Start: from*2 = 4, End: to*2 = 10. + // Range: start+1..end = 5..=9. Grid columns updated: 5, 6, 7, 8, 9. (HOR) + // Ends updated: start=4, end=10. (VER_R) + + // Columns outside the line range (before start) should be default + assert_eq!( + grid.get_tuple(0, ROW_INDEX).0, + SPACE, + "SPACE at start of row" + ); + assert_eq!(grid.get_tuple(3, ROW_INDEX).0, SPACE, "SPACE before hline"); + + // Start (column 4): Should be R_D - assuming a vline below + assert_eq!(grid.get_tuple(4, ROW_INDEX).0, R_D, "R_D at start of hline"); + assert_eq!( + grid.get_tuple(4, ROW_INDEX).1, + LINE_COLOR, + "line_color at start of hline" + ); + assert_eq!( + grid.get_tuple(4, ROW_INDEX).2, + LINE_PERS, + "line_pers at start of hline" + ); + + // End (column 10) is out of bounds for width 10 (index 0-9). The `Grid` + // implementation should handle this (or it's an expected panic/logic error). + // *Assuming* the provided `Grid` is simplified for this example and we should + // test only within bounds. Let's adjust the indices to be safe and meaningful. + } + + /// Case 2: Forward draw (from < to), no merge + /// Case 2b: Inside bounds + #[test] + fn hline_forward_no_merge_at_bounds() { + let safe_width = 7; // Max column index 6, max graph column 2 = grid col 5 + let height = 3; + let mut grid = Grid::new(safe_width, height, DEFAULT_CELL); + // Graph column: 0 1 2 3 + // Grid columns: 0 1 2 3 4 5 6 + // Grid row 0: _ _ _ _ _ _ _ + // Grid row 1: _ _ _ _ _ _ _ + // Grid row 2: _ _ _ _ _ _ _ + + let from_idx = 1; + let to_idx = 3; + // Index: 0 1 2 3 + // Cell: - F - T + // From: 1, To: 3. + // Start: 2, End: 6. + // Range: 3..5 (Columns 3, 4, 5) -> HOR + // Ends: 2, 6 -> R_D, L_U + + assert_eq!( + grid.get_tuple(2, ROW_INDEX).0, + SPACE, + "SPACE at start of line, before written" + ); + super::hline( + &mut grid, + ROW_INDEX, + (from_idx, to_idx), + false, + LINE_COLOR, + LINE_PERS, + ); + // Graph column: 0 1 2 3 + // Grid columns: 0 1 2 3 4 5 6 + // Grid row 0: _ _ _ _ _ _ _ + // Grid row 1: _ _(╭ ─ ─ ─ ┘) + // Grid row 2: _ _ _ _ _ _ _ + + // Check column before start + let grid_cell = grid.get_tuple(1, ROW_INDEX); + assert_eq!(grid_cell.0, SPACE, "SPACE before hline"); + assert_eq!(grid_cell.1, DEF_COL, "default colour before hline"); + assert_eq!(grid_cell.2, DEF_PERS, "default persistence before hline"); + + // Start (column 2): R_D + let grid_cell = grid.get_tuple(2, ROW_INDEX); + assert_eq!(grid_cell.0, R_D, "R_D at start of hline"); + assert_eq!(grid_cell.1, LINE_COLOR, "line_color at start of hline"); + assert_eq!(grid_cell.2, LINE_PERS, "line_pers at start of hline"); + + // Range (columns 3, 4, 5): HOR + let grid_cell = grid.get_tuple(3, ROW_INDEX); + assert_eq!(grid_cell.0, HOR, "HOR in range of hline"); + assert_eq!(grid_cell.1, LINE_COLOR, "line_color in range of hline"); + assert_eq!(grid_cell.2, LINE_PERS, "line_pers in range of hline"); + + let grid_cell = grid.get_tuple(4, ROW_INDEX); + assert_eq!(grid_cell.0, HOR, "HOR in range of hline"); + assert_eq!(grid_cell.1, LINE_COLOR, "line_color in range of hline"); + assert_eq!(grid_cell.2, LINE_PERS, "line_pers in range of hline"); + + let grid_cell = grid.get_tuple(5, ROW_INDEX); + assert_eq!(grid_cell.0, HOR, "HOR in range of hline"); + assert_eq!(grid_cell.1, LINE_COLOR, "line_color in range of hline"); + assert_eq!(grid_cell.2, LINE_PERS, "line_pers in range of hline"); + + // End (column 6): L_U + let grid_cell = grid.get_tuple(6, ROW_INDEX); + assert_eq!(grid_cell.0, L_U, "L_U at end of hline"); + assert_eq!(grid_cell.1, LINE_COLOR, "line_color at end of hline"); + assert_eq!(grid_cell.2, LINE_PERS, "line_pers at end of hline"); + + // Check column after end + // This is undefined, as max grid col is 6 + // TODO make expected panic + let grid_cell = grid.get_tuple(7, ROW_INDEX); + assert_eq!(grid_cell.0, SPACE, "SPACE before hline"); + assert_eq!(grid_cell.1, DEF_COL, "default colour before hline"); + assert_eq!(grid_cell.2, DEF_PERS, "default persistence before hline"); + } + + /// Case 3: Backward draw (from > to), with merge + #[test] + fn hline_backward() { + let (width, height) = (10, 3); + let mut grid = Grid::new(width, height, DEFAULT_CELL); + // Set an existing symbol at an end for better coverage: + grid.set(4, ROW_INDEX, VER, 10, 10); // Start/From pos + grid.set(8, ROW_INDEX, HOR, 10, 10); // End/To pos + + // Graph column: 0 1 2 3 4 + // Grid columns: 0 1 2 3 4 5 6 7 8 9 + // Grid row 0: _ _ _ _ _ _ _ _ _ _ + // Grid row 1: _ _ _ _ │ _ _ _ ─ _ + // Grid row 2: _ _ _ _ _ _ _ _ _ _ + + let from_idx = 4; + let to_idx = 2; + let merge = true; + // Index: 0 1 2 3 4 + // Cell: - - T - F + // Forward is false. + // start (orig from*2) = 8, end (orig to*2) = 4. Swapped: start=4, end=8. + // Range: start+1..end = 5..8. Columns updated: 5, 6, 7 -> HOR + // Merge: column = start = 4. Symbol = ARR_L. + // Ends: start=4 (backward), end=8 (forward). (Both should be L_D/R_U if they weren't SPACE) + + super::hline( + &mut grid, + ROW_INDEX, + (from_idx, to_idx), + merge, + LINE_COLOR, + LINE_PERS, + ); + // Graph column: 0 1 2 3 4 + // Grid columns: 0 1 2 3 4 5 6 7 8 9 + // Grid row 0: _ _ _ _ _ _ _ _ _ _ + // Grid row 1: _ _ _ _ ├ < ─ ─ ┬ _ + // Grid row 2: _ _ _ _ _ _ _ _ _ _ + + // Check columns before start + assert_eq!(grid.get_tuple(3, ROW_INDEX).0, SPACE, "SPACE before hline"); + assert_eq!( + grid.get_tuple(3, ROW_INDEX).1, + DEF_COL, + "default colour before hline" + ); + assert_eq!( + grid.get_tuple(3, ROW_INDEX).2, + DEF_PERS, + "default persistence before hline" + ); + + // Merge: column 4 (start). Should be VER_R. + assert_eq!(grid.get_tuple(4, ROW_INDEX).0, VER_R, "VER_R at hline 'to'"); + assert_eq!( + grid.get_tuple(4, ROW_INDEX).1, + 10, + "unchanged color at hline 'to'" + ); + assert_eq!( + grid.get_tuple(4, ROW_INDEX).2, + 10, + "unchanged pers at hline 'to'" + ); + + // Merge (column 5): ARR_l + assert_eq!( + grid.get_tuple(5, ROW_INDEX).0, + ARR_L, + "ARR_L before hline 'to'" + ); + assert_eq!( + grid.get_tuple(5, ROW_INDEX).1, + LINE_COLOR, + "line_color in hline" + ); + assert_eq!( + grid.get_tuple(5, ROW_INDEX).2, + LINE_PERS, + "line_pers in hline" + ); + + // Range (columns 5, 6): HOR + assert_eq!(grid.get_tuple(6, ROW_INDEX).0, HOR, "HOR in hline"); + assert_eq!( + grid.get_tuple(6, ROW_INDEX).1, + LINE_COLOR, + "line_color in hline" + ); + assert_eq!( + grid.get_tuple(6, ROW_INDEX).2, + LINE_PERS, + "line_pers in hline" + ); + + assert_eq!(grid.get_tuple(7, ROW_INDEX).0, HOR, "HOR in hline"); + assert_eq!( + grid.get_tuple(7, ROW_INDEX).1, + LINE_COLOR, + "line_color in hline" + ); + assert_eq!( + grid.get_tuple(7, ROW_INDEX).2, + LINE_PERS, + "line_pers in hline" + ); + + // Cell 8 (end/from): HOR_D + assert_eq!( + grid.get_tuple(8, ROW_INDEX).0, + HOR_D, + "HOR_D at hline 'from'" + ); + assert_eq!( + grid.get_tuple(8, ROW_INDEX).1, + LINE_COLOR, + "line_color at hline 'from'" + ); + assert_eq!( + grid.get_tuple(8, ROW_INDEX).2, + LINE_PERS, + "line_pers at hline 'from'" + ); + } + + /// Case 4: Forward draw, with merge, onto a crossing symbol + #[test] + fn hline_forward_merge() { + let merge = true; + let (width, height) = (7, 3); + let mut grid = Grid::new(width, height, DEFAULT_CELL); + grid.set(5, ROW_INDEX, R_U, 10, 10); // Set a symbol that changes range + grid.set(6, ROW_INDEX, VER, 11, 10); // Set symbol for merge target + + // Graph column: 0 1 2 3 + // Grid columns: 0 1 2 3 4 5 6 + // Grid row 0: _ _ _ _ _ _ _ + // Grid row 1: _ _ _ _ _ └ │ + // Grid row 2: _ _ _ _ _ _ _ + + let from_idx = 1; + let to_idx = 3; + // Start: 2, End: 6. + // Index: 0 1 2 3 4 5 6 + // Cell: - - F - - R_U T + // Range: 3..6. Columns: 3, 4, 5. + // Column 5: R_U -> HOR_D (in update_range) + // Merge: column = end - 1 = 5. Symbol = ARR_R. Overwrites HOR_D. + // Ends: 2 (forward), 6 (forward). + + super::hline( + &mut grid, + ROW_INDEX, + (from_idx, to_idx), + merge, + LINE_COLOR, + LINE_PERS, + ); + // Graph column: 0 1 2 3 + // Grid columns: 0 1 2 3 4 5 6 + // Grid row 0: _ _ _ _ _ _ _ + // Grid row 1: _ _(╭ ─ ─ > ┤) + // Grid row 2: _ _ _ _ _ _ _ + + // Start (column 2): R_D + assert_eq!(grid.get_tuple(2, ROW_INDEX).0, R_D); + assert_eq!(grid.get_tuple(2, ROW_INDEX).1, LINE_COLOR); + assert_eq!(grid.get_tuple(2, ROW_INDEX).2, LINE_PERS); + + // Range (column 3, 4): HOR + assert_eq!(grid.get_tuple(3, ROW_INDEX).0, HOR); + assert_eq!(grid.get_tuple(3, ROW_INDEX).1, LINE_COLOR); + assert_eq!(grid.get_tuple(3, ROW_INDEX).2, LINE_PERS); + + assert_eq!(grid.get_tuple(4, ROW_INDEX).0, HOR); + assert_eq!(grid.get_tuple(4, ROW_INDEX).1, LINE_COLOR); + assert_eq!(grid.get_tuple(4, ROW_INDEX).2, LINE_PERS); + + // Merge column (end - 1 = 5): ARR_R (Merge overwrites update_range) + assert_eq!(grid.get_tuple(5, ROW_INDEX).0, ARR_R); + assert_eq!(grid.get_tuple(5, ROW_INDEX).1, LINE_COLOR); + assert_eq!(grid.get_tuple(5, ROW_INDEX).2, LINE_PERS); + + // End (column 6): VER_L + assert_eq!(grid.get_tuple(6, ROW_INDEX).0, VER_L); + assert_eq!(grid.get_tuple(6, ROW_INDEX).1, 11); + assert_eq!(grid.get_tuple(6, ROW_INDEX).2, 10); + } +} diff --git a/src/print/mod.rs b/src/print/mod.rs index 8aba6af..b822687 100644 --- a/src/print/mod.rs +++ b/src/print/mod.rs @@ -18,5 +18,6 @@ The gleisbau library adds one new function: pub mod colors; pub mod format; +pub mod grid; pub mod label; pub mod unicode; diff --git a/src/print/unicode.rs b/src/print/unicode.rs index 88ecb05..5358a9e 100644 --- a/src/print/unicode.rs +++ b/src/print/unicode.rs @@ -35,32 +35,19 @@ use crate::graph::{BranchInfo, CommitInfo, GitGraph, HeadInfo}; use crate::layout::BranchVis; use crate::layout::TrackLayout; use crate::print::format::CommitFormat; +use crate::print::grid::Grid; +use crate::print::grid::GridCell; +use crate::print::grid::CIRCLE; +use crate::print::grid::DOT; +use crate::print::grid::SPACE; +use crate::print::grid::vline; +use crate::print::grid::zig_zag_line; use crate::print::label::list_labels; use crate::print::label::Label; use crate::print::label::LabelMap; use crate::print::label::LabelType; use crate::settings::{Characters, Settings}; -// Symbols used in [Grid] - -const SPACE: u8 = 0; -const DOT: u8 = 1; -const CIRCLE: u8 = 2; -const VER: u8 = 3; -const HOR: u8 = 4; -const CROSS: u8 = 5; -const R_U: u8 = 6; -const R_D: u8 = 7; -const L_D: u8 = 8; -const L_U: u8 = 9; -const VER_L: u8 = 10; -const VER_R: u8 = 11; -const HOR_U: u8 = 12; -const HOR_D: u8 = 13; - -const ARR_L: u8 = 14; -const ARR_R: u8 = 15; - // Color index used by yansi const WHITE: u8 = 7; // Normal white @@ -454,221 +441,6 @@ fn find_insert_idx(inserts: &[Vec], child_idx: usize, parent_idx: usize) -> None } -/// Draw a line that connects two commits on different columns -fn zig_zag_line( - grid: &mut Grid, - row123: (usize, usize, usize), - col12: (usize, usize), - is_merge: bool, - color: u8, - pers: u8, -) { - let (row1, row2, row3) = row123; - let (col1, col2) = col12; - vline(grid, (row1, row2), col1, color, pers); - hline(grid, row2, (col2, col1), is_merge, color, pers); - vline(grid, (row2, row3), col2, color, pers); -} - -/// Draws a vertical line -fn vline(grid: &mut Grid, (from, to): (usize, usize), column: usize, color: u8, pers: u8) { - for i in (from + 1)..to { - let (curr, _, old_pers) = grid.get_tuple(column * 2, i); - let (new_col, new_pers) = if pers < old_pers { - (Some(color), Some(pers)) - } else { - (None, None) - }; - match curr { - DOT | CIRCLE => {} - HOR => { - grid.set_opt(column * 2, i, Some(CROSS), Some(color), Some(pers)); - } - HOR_U | HOR_D => { - grid.set_opt(column * 2, i, Some(CROSS), Some(color), Some(pers)); - } - CROSS | VER | VER_L | VER_R => grid.set_opt(column * 2, i, None, new_col, new_pers), - L_D | L_U => { - grid.set_opt(column * 2, i, Some(VER_L), new_col, new_pers); - } - R_D | R_U => { - grid.set_opt(column * 2, i, Some(VER_R), new_col, new_pers); - } - _ => { - grid.set_opt(column * 2, i, Some(VER), new_col, new_pers); - } - } - } -} - -/// Draw a horizontal line. -/// If from > to, this will cause a backward draw. -fn hline( - grid: &mut Grid, - index: usize, - (from, to): (usize, usize), - merge: bool, - color: u8, - pers: u8, -) { - if from == to { - return; - } - - let from_2 = from * 2; - let to_2 = to * 2; - - if from < to { - update_range_forward(grid, index, from_2, to_2, merge, color, pers); - update_left_cell_forward(grid, index, from_2, color, pers); - update_right_cell_forward(grid, index, to_2, color, pers); - } else { - update_range_backward(grid, index, from_2, to_2, merge, color, pers); - update_left_cell_backward(grid, index, to_2, color, pers); - update_right_cell_backward(grid, index, from_2, color, pers); - } -} - -fn update_range_forward( - grid: &mut Grid, - index: usize, - from_2: usize, - to_2: usize, - merge: bool, - color: u8, - pers: u8, -) { - for column in (from_2 + 1)..to_2 { - if merge && column == to_2 - 1 { - grid.set(column, index, ARR_R, color, pers); - } else { - let (curr, _, old_pers) = grid.get_tuple(column, index); - let (new_col, new_pers) = if pers < old_pers { - (Some(color), Some(pers)) - } else { - (None, None) - }; - match curr { - DOT | CIRCLE => {} - VER => grid.set_opt(column, index, Some(CROSS), None, None), - HOR | CROSS | HOR_U | HOR_D => grid.set_opt(column, index, None, new_col, new_pers), - L_U | R_U => grid.set_opt(column, index, Some(HOR_U), new_col, new_pers), - L_D | R_D => grid.set_opt(column, index, Some(HOR_D), new_col, new_pers), - _ => { - grid.set_opt(column, index, Some(HOR), new_col, new_pers); - } - } - } - } -} - -fn update_left_cell_forward(grid: &mut Grid, index: usize, from_2: usize, color: u8, pers: u8) { - let (left, _, old_pers) = grid.get_tuple(from_2, index); - let (new_col, new_pers) = if pers < old_pers { - (Some(color), Some(pers)) - } else { - (None, None) - }; - match left { - DOT | CIRCLE => {} - VER => grid.set_opt(from_2, index, Some(VER_R), new_col, new_pers), - VER_L => grid.set_opt(from_2, index, Some(CROSS), None, None), - VER_R => {} - HOR | L_U => grid.set_opt(from_2, index, Some(HOR_U), new_col, new_pers), - _ => { - grid.set_opt(from_2, index, Some(R_D), new_col, new_pers); - } - } -} - -fn update_right_cell_forward(grid: &mut Grid, index: usize, to_2: usize, color: u8, pers: u8) { - let (right, _, old_pers) = grid.get_tuple(to_2, index); - let (new_col, new_pers) = if pers < old_pers { - (Some(color), Some(pers)) - } else { - (None, None) - }; - match right { - DOT | CIRCLE => {} - VER => grid.set_opt(to_2, index, Some(VER_L), None, None), - VER_L | HOR_U => grid.set_opt(to_2, index, None, new_col, new_pers), - HOR | R_U => grid.set_opt(to_2, index, Some(HOR_U), new_col, new_pers), - _ => { - grid.set_opt(to_2, index, Some(L_U), new_col, new_pers); - } - } -} - -fn update_range_backward( - grid: &mut Grid, - index: usize, - from_2: usize, - to_2: usize, - merge: bool, - color: u8, - pers: u8, -) { - for column in (to_2 + 1)..from_2 { - if merge && column == to_2 + 1 { - grid.set(column, index, ARR_L, color, pers); - } else { - let (curr, _, old_pers) = grid.get_tuple(column, index); - let (new_col, new_pers) = if pers < old_pers { - (Some(color), Some(pers)) - } else { - (None, None) - }; - match curr { - DOT | CIRCLE => {} - VER => grid.set_opt(column, index, Some(CROSS), None, None), - HOR | CROSS | HOR_U | HOR_D => grid.set_opt(column, index, None, new_col, new_pers), - L_U | R_U => grid.set_opt(column, index, Some(HOR_U), new_col, new_pers), - L_D | R_D => grid.set_opt(column, index, Some(HOR_D), new_col, new_pers), - _ => { - grid.set_opt(column, index, Some(HOR), new_col, new_pers); - } - } - } - } -} - -fn update_left_cell_backward(grid: &mut Grid, index: usize, to_2: usize, color: u8, pers: u8) { - let (left, _, old_pers) = grid.get_tuple(to_2, index); - let (new_col, new_pers) = if pers < old_pers { - (Some(color), Some(pers)) - } else { - (None, None) - }; - match left { - DOT | CIRCLE => {} - VER => grid.set_opt(to_2, index, Some(VER_R), None, None), - VER_R => grid.set_opt(to_2, index, None, new_col, new_pers), - HOR | L_U => grid.set_opt(to_2, index, Some(HOR_U), new_col, new_pers), - _ => { - grid.set_opt(to_2, index, Some(R_U), new_col, new_pers); - } - } -} - -fn update_right_cell_backward(grid: &mut Grid, index: usize, from_2: usize, color: u8, pers: u8) { - let (right, _, old_pers) = grid.get_tuple(from_2, index); - let (new_col, new_pers) = if pers < old_pers { - (Some(color), Some(pers)) - } else { - (None, None) - }; - match right { - DOT | CIRCLE => {} - VER => grid.set_opt(from_2, index, Some(VER_L), new_col, new_pers), - VER_R => grid.set_opt(from_2, index, Some(CROSS), None, None), - VER_L => grid.set_opt(from_2, index, None, new_col, new_pers), - HOR | R_D => grid.set_opt(from_2, index, Some(HOR_D), new_col, new_pers), - _ => { - grid.set_opt(from_2, index, Some(L_D), new_col, new_pers); - } - } -} - /// Calculates required additional rows to visually connect commits that /// are not direct descendants in the main commit list. These "inserts" // represent the horizontal lines in the graph. @@ -1252,461 +1024,3 @@ fn sorted(v1: usize, v2: usize) -> (usize, usize) { (v2, v1) } } - -/// One cell in a [Grid] -#[derive(Clone, Copy)] -struct GridCell { - /// The symbol shown, encoded as in index into settings::Characters - character: u8, - /// Standard 8-bit terminal colour code - color: u8, - /// Persistence level. z-order, lower numbers take preceedence. - pers: u8, -} - -impl GridCell { - pub fn char(&self, characters: &Characters) -> char { - characters.chars[self.character as usize] - } -} - -/// Two-dimensional grid used to hold the graph layout. -/// -/// This can be rendered as unicode text or as SVG. -struct Grid { - width: usize, - height: usize, - - /// Grid cells are stored in row-major order. - data: Vec, -} - -impl Grid { - pub fn new(width: usize, height: usize, initial: GridCell) -> Self { - Grid { - width, - height, - data: vec![initial; width * height], - } - } - - pub fn reverse(&mut self) { - self.data.reverse(); - } - /// Turn a 2D coordinate into an index of Grid.data - pub fn index(&self, x: usize, y: usize) -> usize { - y * self.width + x - } - pub fn get_tuple(&self, x: usize, y: usize) -> (u8, u8, u8) { - let v = self.data[self.index(x, y)]; - (v.character, v.color, v.pers) - } - pub fn set(&mut self, x: usize, y: usize, character: u8, color: u8, pers: u8) { - let idx = self.index(x, y); - self.data[idx] = GridCell { - character, - color, - pers, - }; - } - pub fn set_opt( - &mut self, - x: usize, - y: usize, - character: Option, - color: Option, - pers: Option, - ) { - let idx = self.index(x, y); - let cell = &mut self.data[idx]; - if let Some(character) = character { - cell.character = character; - } - if let Some(color) = color { - cell.color = color; - } - if let Some(pers) = pers { - cell.pers = pers; - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - // A dummy `Characters` struct is needed for `GridCell::char` but is not - // directly used in `hline` tests, so we can omit it by not calling `char()`. - - // --- Test Cases --- - - /* Testing hline - - Note that hline is given a graph column as input, - which indexes a grid column at 2*graph_col - // Graph column: 0 1 2 3 4 5 - // Grid columns: 0 1 2 3 4 5 6 7 8 9 - // Grid row 0: _ _ _ _ _ _ _ _ _ _ - // Grid row 1: _ _ _ _ _ _ _ _ _ _ - // Grid row 2: _ _ _ _ _ _ _ _ _ _ - - A horizontal line from 1 to 3, would occupy columns 2, 3, 4, 5, 6 inclusive - - */ - - const DEF_CH: u8 = SPACE; - const DEF_COL: u8 = 0; - const DEF_PERS: u8 = 10; // low persistence, will always be overwritten - const DEFAULT_CELL: GridCell = GridCell { - character: DEF_CH, - color: DEF_COL, - pers: DEF_PERS, - }; - const ROW_INDEX: usize = 1; - const LINE_COLOR: u8 = 14; - const LINE_PERS: u8 = 5; - - #[test] - fn hline_skip() { - let (width, height) = (10, 3); - let mut grid = Grid::new(width, height, DEFAULT_CELL); - // Graph column: 0 1 2 3 4 5 - // Grid columns: 0 1 2 3 4 5 6 7 8 9 - // Grid row 0: _ _ _ _ _ _ _ _ _ _ - // Grid row 1: _ _ _ _ _ _ _ _ _ _ - // Grid row 2: _ _ _ _ _ _ _ _ _ _ - - // Case 1: from == to (should do nothing) - let initial_char = grid.get_tuple(4 * 2, ROW_INDEX).0; - super::hline(&mut grid, ROW_INDEX, (4, 4), true, LINE_COLOR, LINE_PERS); - // Graph column: 0 1 2 3 4 5 - // Grid columns: 0 1 2 3 4 5 6 7 8 9 - // Grid row 0: _ _ _ _ _ _ _ _ _ _ - // Grid row 1: _ _ _ _ _ _ _ _X_ _ - // Grid row 2: _ _ _ _ _ _ _ _ _ _ - - assert_eq!( - grid.get_tuple(4 * 2, ROW_INDEX).0, - initial_char, - "Same index call should not modify grid" - ); - } - - /// Case 2: Forward draw (from < to), no merge - /// Case 2a: out of bounds - #[test] - fn hline_forward_no_merge_out_of_bounds() { - let (width, height) = (10, 3); - let mut grid = Grid::new(width, height, DEFAULT_CELL); - super::hline(&mut grid, ROW_INDEX, (2, 5), false, LINE_COLOR, LINE_PERS); - // Graph column: 0 1 2 3 4 5 - // Grid columns: 0 1 2 3 4 5 6 7 8 9 - // Grid row 0: _ _ _ _ _ _ _ _ _ _ - // Grid row 1: _ _ _ _F- - - - - - *T (F=from, T=to) - // Grid row 2: _ _ _ _ _ _ _ _ _ _ - - // from: 2, to: 5 - // Start: from*2 = 4, End: to*2 = 10. - // Range: start+1..end = 5..=9. Grid columns updated: 5, 6, 7, 8, 9. (HOR) - // Ends updated: start=4, end=10. (VER_R) - - // Columns outside the line range (before start) should be default - assert_eq!( - grid.get_tuple(0, ROW_INDEX).0, - SPACE, - "SPACE at start of row" - ); - assert_eq!(grid.get_tuple(3, ROW_INDEX).0, SPACE, "SPACE before hline"); - - // Start (column 4): Should be R_D - assuming a vline below - assert_eq!(grid.get_tuple(4, ROW_INDEX).0, R_D, "R_D at start of hline"); - assert_eq!( - grid.get_tuple(4, ROW_INDEX).1, - LINE_COLOR, - "line_color at start of hline" - ); - assert_eq!( - grid.get_tuple(4, ROW_INDEX).2, - LINE_PERS, - "line_pers at start of hline" - ); - - // End (column 10) is out of bounds for width 10 (index 0-9). The `Grid` - // implementation should handle this (or it's an expected panic/logic error). - // *Assuming* the provided `Grid` is simplified for this example and we should - // test only within bounds. Let's adjust the indices to be safe and meaningful. - } - - /// Case 2: Forward draw (from < to), no merge - /// Case 2b: Inside bounds - #[test] - fn hline_forward_no_merge_at_bounds() { - let safe_width = 7; // Max column index 6, max graph column 2 = grid col 5 - let height = 3; - let mut grid = Grid::new(safe_width, height, DEFAULT_CELL); - // Graph column: 0 1 2 3 - // Grid columns: 0 1 2 3 4 5 6 - // Grid row 0: _ _ _ _ _ _ _ - // Grid row 1: _ _ _ _ _ _ _ - // Grid row 2: _ _ _ _ _ _ _ - - let from_idx = 1; - let to_idx = 3; - // Index: 0 1 2 3 - // Cell: - F - T - // From: 1, To: 3. - // Start: 2, End: 6. - // Range: 3..5 (Columns 3, 4, 5) -> HOR - // Ends: 2, 6 -> R_D, L_U - - assert_eq!( - grid.get_tuple(2, ROW_INDEX).0, - SPACE, - "SPACE at start of line, before written" - ); - super::hline( - &mut grid, - ROW_INDEX, - (from_idx, to_idx), - false, - LINE_COLOR, - LINE_PERS, - ); - // Graph column: 0 1 2 3 - // Grid columns: 0 1 2 3 4 5 6 - // Grid row 0: _ _ _ _ _ _ _ - // Grid row 1: _ _(╭ ─ ─ ─ ┘) - // Grid row 2: _ _ _ _ _ _ _ - - // Check column before start - let grid_cell = grid.get_tuple(1, ROW_INDEX); - assert_eq!(grid_cell.0, SPACE, "SPACE before hline"); - assert_eq!(grid_cell.1, DEF_COL, "default colour before hline"); - assert_eq!(grid_cell.2, DEF_PERS, "default persistence before hline"); - - // Start (column 2): R_D - let grid_cell = grid.get_tuple(2, ROW_INDEX); - assert_eq!(grid_cell.0, R_D, "R_D at start of hline"); - assert_eq!(grid_cell.1, LINE_COLOR, "line_color at start of hline"); - assert_eq!(grid_cell.2, LINE_PERS, "line_pers at start of hline"); - - // Range (columns 3, 4, 5): HOR - let grid_cell = grid.get_tuple(3, ROW_INDEX); - assert_eq!(grid_cell.0, HOR, "HOR in range of hline"); - assert_eq!(grid_cell.1, LINE_COLOR, "line_color in range of hline"); - assert_eq!(grid_cell.2, LINE_PERS, "line_pers in range of hline"); - - let grid_cell = grid.get_tuple(4, ROW_INDEX); - assert_eq!(grid_cell.0, HOR, "HOR in range of hline"); - assert_eq!(grid_cell.1, LINE_COLOR, "line_color in range of hline"); - assert_eq!(grid_cell.2, LINE_PERS, "line_pers in range of hline"); - - let grid_cell = grid.get_tuple(5, ROW_INDEX); - assert_eq!(grid_cell.0, HOR, "HOR in range of hline"); - assert_eq!(grid_cell.1, LINE_COLOR, "line_color in range of hline"); - assert_eq!(grid_cell.2, LINE_PERS, "line_pers in range of hline"); - - // End (column 6): L_U - let grid_cell = grid.get_tuple(6, ROW_INDEX); - assert_eq!(grid_cell.0, L_U, "L_U at end of hline"); - assert_eq!(grid_cell.1, LINE_COLOR, "line_color at end of hline"); - assert_eq!(grid_cell.2, LINE_PERS, "line_pers at end of hline"); - - // Check column after end - // This is undefined, as max grid col is 6 - // TODO make expected panic - let grid_cell = grid.get_tuple(7, ROW_INDEX); - assert_eq!(grid_cell.0, SPACE, "SPACE before hline"); - assert_eq!(grid_cell.1, DEF_COL, "default colour before hline"); - assert_eq!(grid_cell.2, DEF_PERS, "default persistence before hline"); - } - - /// Case 3: Backward draw (from > to), with merge - #[test] - fn hline_backward() { - let (width, height) = (10, 3); - let mut grid = Grid::new(width, height, DEFAULT_CELL); - // Set an existing symbol at an end for better coverage: - grid.set(4, ROW_INDEX, VER, 10, 10); // Start/From pos - grid.set(8, ROW_INDEX, HOR, 10, 10); // End/To pos - - // Graph column: 0 1 2 3 4 - // Grid columns: 0 1 2 3 4 5 6 7 8 9 - // Grid row 0: _ _ _ _ _ _ _ _ _ _ - // Grid row 1: _ _ _ _ │ _ _ _ ─ _ - // Grid row 2: _ _ _ _ _ _ _ _ _ _ - - let from_idx = 4; - let to_idx = 2; - let merge = true; - // Index: 0 1 2 3 4 - // Cell: - - T - F - // Forward is false. - // start (orig from*2) = 8, end (orig to*2) = 4. Swapped: start=4, end=8. - // Range: start+1..end = 5..8. Columns updated: 5, 6, 7 -> HOR - // Merge: column = start = 4. Symbol = ARR_L. - // Ends: start=4 (backward), end=8 (forward). (Both should be L_D/R_U if they weren't SPACE) - - super::hline( - &mut grid, - ROW_INDEX, - (from_idx, to_idx), - merge, - LINE_COLOR, - LINE_PERS, - ); - // Graph column: 0 1 2 3 4 - // Grid columns: 0 1 2 3 4 5 6 7 8 9 - // Grid row 0: _ _ _ _ _ _ _ _ _ _ - // Grid row 1: _ _ _ _ ├ < ─ ─ ┬ _ - // Grid row 2: _ _ _ _ _ _ _ _ _ _ - - // Check columns before start - assert_eq!(grid.get_tuple(3, ROW_INDEX).0, SPACE, "SPACE before hline"); - assert_eq!( - grid.get_tuple(3, ROW_INDEX).1, - DEF_COL, - "default colour before hline" - ); - assert_eq!( - grid.get_tuple(3, ROW_INDEX).2, - DEF_PERS, - "default persistence before hline" - ); - - // Merge: column 4 (start). Should be VER_R. - assert_eq!(grid.get_tuple(4, ROW_INDEX).0, VER_R, "VER_R at hline 'to'"); - assert_eq!( - grid.get_tuple(4, ROW_INDEX).1, - 10, - "unchanged color at hline 'to'" - ); - assert_eq!( - grid.get_tuple(4, ROW_INDEX).2, - 10, - "unchanged pers at hline 'to'" - ); - - // Merge (column 5): ARR_l - assert_eq!( - grid.get_tuple(5, ROW_INDEX).0, - ARR_L, - "ARR_L before hline 'to'" - ); - assert_eq!( - grid.get_tuple(5, ROW_INDEX).1, - LINE_COLOR, - "line_color in hline" - ); - assert_eq!( - grid.get_tuple(5, ROW_INDEX).2, - LINE_PERS, - "line_pers in hline" - ); - - // Range (columns 5, 6): HOR - assert_eq!(grid.get_tuple(6, ROW_INDEX).0, HOR, "HOR in hline"); - assert_eq!( - grid.get_tuple(6, ROW_INDEX).1, - LINE_COLOR, - "line_color in hline" - ); - assert_eq!( - grid.get_tuple(6, ROW_INDEX).2, - LINE_PERS, - "line_pers in hline" - ); - - assert_eq!(grid.get_tuple(7, ROW_INDEX).0, HOR, "HOR in hline"); - assert_eq!( - grid.get_tuple(7, ROW_INDEX).1, - LINE_COLOR, - "line_color in hline" - ); - assert_eq!( - grid.get_tuple(7, ROW_INDEX).2, - LINE_PERS, - "line_pers in hline" - ); - - // Cell 8 (end/from): HOR_D - assert_eq!( - grid.get_tuple(8, ROW_INDEX).0, - HOR_D, - "HOR_D at hline 'from'" - ); - assert_eq!( - grid.get_tuple(8, ROW_INDEX).1, - LINE_COLOR, - "line_color at hline 'from'" - ); - assert_eq!( - grid.get_tuple(8, ROW_INDEX).2, - LINE_PERS, - "line_pers at hline 'from'" - ); - } - - /// Case 4: Forward draw, with merge, onto a crossing symbol - #[test] - fn hline_forward_merge() { - let merge = true; - let (width, height) = (7, 3); - let mut grid = Grid::new(width, height, DEFAULT_CELL); - grid.set(5, ROW_INDEX, R_U, 10, 10); // Set a symbol that changes range - grid.set(6, ROW_INDEX, VER, 11, 10); // Set symbol for merge target - - // Graph column: 0 1 2 3 - // Grid columns: 0 1 2 3 4 5 6 - // Grid row 0: _ _ _ _ _ _ _ - // Grid row 1: _ _ _ _ _ └ │ - // Grid row 2: _ _ _ _ _ _ _ - - let from_idx = 1; - let to_idx = 3; - // Start: 2, End: 6. - // Index: 0 1 2 3 4 5 6 - // Cell: - - F - - R_U T - // Range: 3..6. Columns: 3, 4, 5. - // Column 5: R_U -> HOR_D (in update_range) - // Merge: column = end - 1 = 5. Symbol = ARR_R. Overwrites HOR_D. - // Ends: 2 (forward), 6 (forward). - - super::hline( - &mut grid, - ROW_INDEX, - (from_idx, to_idx), - merge, - LINE_COLOR, - LINE_PERS, - ); - // Graph column: 0 1 2 3 - // Grid columns: 0 1 2 3 4 5 6 - // Grid row 0: _ _ _ _ _ _ _ - // Grid row 1: _ _(╭ ─ ─ > ┤) - // Grid row 2: _ _ _ _ _ _ _ - - // Start (column 2): R_D - assert_eq!(grid.get_tuple(2, ROW_INDEX).0, R_D); - assert_eq!(grid.get_tuple(2, ROW_INDEX).1, LINE_COLOR); - assert_eq!(grid.get_tuple(2, ROW_INDEX).2, LINE_PERS); - - // Range (column 3, 4): HOR - assert_eq!(grid.get_tuple(3, ROW_INDEX).0, HOR); - assert_eq!(grid.get_tuple(3, ROW_INDEX).1, LINE_COLOR); - assert_eq!(grid.get_tuple(3, ROW_INDEX).2, LINE_PERS); - - assert_eq!(grid.get_tuple(4, ROW_INDEX).0, HOR); - assert_eq!(grid.get_tuple(4, ROW_INDEX).1, LINE_COLOR); - assert_eq!(grid.get_tuple(4, ROW_INDEX).2, LINE_PERS); - - // Merge column (end - 1 = 5): ARR_R (Merge overwrites update_range) - assert_eq!(grid.get_tuple(5, ROW_INDEX).0, ARR_R); - assert_eq!(grid.get_tuple(5, ROW_INDEX).1, LINE_COLOR); - assert_eq!(grid.get_tuple(5, ROW_INDEX).2, LINE_PERS); - - // End (column 6): VER_L - assert_eq!(grid.get_tuple(6, ROW_INDEX).0, VER_L); - assert_eq!(grid.get_tuple(6, ROW_INDEX).1, 11); - assert_eq!(grid.get_tuple(6, ROW_INDEX).2, 10); - } -} From a654e177ef0e67d4d753ac039dc1ef8cb64f73dd Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Mon, 15 Jun 2026 03:24:48 +0200 Subject: [PATCH 16/24] Add entry logging to major API functions Logging is a debugging tool which usually generates lots of data, but this change only adds call notification at important API points at tracing level. --- src/graph.rs | 2 ++ src/layout.rs | 1 + src/print/grid.rs | 1 + src/print/unicode.rs | 2 ++ src/track.rs | 1 + 5 files changed, 7 insertions(+) diff --git a/src/graph.rs b/src/graph.rs index 22480e7..c26fc22 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -61,6 +61,7 @@ pub struct Builder { impl Builder { pub fn new() -> Self { + log::trace!("Builder::new()"); Builder::default() } pub fn with_repository(mut self, repository: Repository) -> Self { @@ -108,6 +109,7 @@ impl GitGraph { refspecs: Vec, ) -> Result { #![doc = include_str!("../docs/branch_assignment.md")] + log::trace!("GitGraph::new() - legacy API"); let mut stashes = HashSet::new(); repository .stash_foreach(|_, _, oid| { diff --git a/src/layout.rs b/src/layout.rs index 63bc966..5896ebc 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -152,6 +152,7 @@ pub fn layout_track_range( range: Range, settings: &Settings, ) -> Result { + log::trace!("layout_track_range(_,({},{}),_)", &range.start, &range.end); let mut branch_visuals = Vec::new(); let mut track_visual_map = HashMap::new(); diff --git a/src/print/grid.rs b/src/print/grid.rs index 8e7cd9d..7e25ff7 100644 --- a/src/print/grid.rs +++ b/src/print/grid.rs @@ -60,6 +60,7 @@ pub struct Grid { impl Grid { pub fn new(width: usize, height: usize, initial: GridCell) -> Self { + log::trace!("Grid::new({},{},_)", width, height); Grid { width, height, diff --git a/src/print/unicode.rs b/src/print/unicode.rs index 5358a9e..a267958 100644 --- a/src/print/unicode.rs +++ b/src/print/unicode.rs @@ -77,6 +77,7 @@ pub type UnicodeGraphInfo = (Vec, Vec, Vec); /// Creates a text-based visual representation of a graph. pub fn print_unicode(graph: &GitGraph, settings: &Settings) -> Result { + log::trace!("print_unicode - legacy API"); let repo = &graph.repository; let tracks = &graph.tracks; let layout = &graph.layout; @@ -737,6 +738,7 @@ pub fn print_graph_terminal( layout: &TrackLayout, commit_text_height: &[usize], // [0] corresponds to track commit layout.commit_index_start() ) -> GraphLines { + log::trace!("print_graph_terminal(_,_,_,_)"); if tracks.all_branches.is_empty() { return GraphLines::empty(); } diff --git a/src/track.rs b/src/track.rs index 86cd6c3..636964b 100644 --- a/src/track.rs +++ b/src/track.rs @@ -56,6 +56,7 @@ impl Default for TrackMap { impl TrackMap { /// Create an empty TrackMap pub fn new() -> Self { + log::trace!("TrackMap::new()"); Self { commits: vec![], indices: HashMap::new(), From 81b7b4a13adb1471e84b094668dcf7a0c84ea457 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Mon, 15 Jun 2026 04:48:03 +0200 Subject: [PATCH 17/24] Better docs for get_inserts Explain what inserts are and how they fit in the algorithm. --- src/print/grid.rs | 5 -- src/print/unicode.rs | 116 ++++++++++++++++++++++++++----------------- 2 files changed, 70 insertions(+), 51 deletions(-) diff --git a/src/print/grid.rs b/src/print/grid.rs index 7e25ff7..17ef970 100644 --- a/src/print/grid.rs +++ b/src/print/grid.rs @@ -324,11 +324,6 @@ fn update_right_cell_backward(grid: &mut Grid, index: usize, from_2: usize, colo } } - - - - - #[cfg(test)] mod tests { use super::*; diff --git a/src/print/unicode.rs b/src/print/unicode.rs index a267958..e1b5401 100644 --- a/src/print/unicode.rs +++ b/src/print/unicode.rs @@ -35,13 +35,13 @@ use crate::graph::{BranchInfo, CommitInfo, GitGraph, HeadInfo}; use crate::layout::BranchVis; use crate::layout::TrackLayout; use crate::print::format::CommitFormat; +use crate::print::grid::vline; +use crate::print::grid::zig_zag_line; use crate::print::grid::Grid; use crate::print::grid::GridCell; use crate::print::grid::CIRCLE; use crate::print::grid::DOT; use crate::print::grid::SPACE; -use crate::print::grid::vline; -use crate::print::grid::zig_zag_line; use crate::print::label::list_labels; use crate::print::label::Label; use crate::print::label::LabelMap; @@ -54,6 +54,13 @@ const WHITE: u8 = 7; // Normal white const HEAD_COLOR: u8 = 14; // Bright cyan const HASH_COLOR: u8 = 11; // Bright yellow +/// A set of occupations planned for a row of output. +/// The ordering is not important. +type RowRenderPlan = Vec; + +/// A list of rows planned for output of a commit and lines associated with it. +type CommitRenderPlan = Vec; + /** UnicodeGraphInfo is a type alias for a tuple containing three elements: graph-lines, text-lines, start-row @@ -139,7 +146,7 @@ fn build_commit_lines_and_map( layout: &TrackLayout, num_cols: usize, the_head: &HeadInfo, - inserts: &HashMap>>, + inserts: &HashMap, ) -> Result<(Vec>, Vec), String> { // Compute textwrap options let indent1 = settings @@ -227,8 +234,8 @@ fn build_commit_lines_and_map( /// Iterates through commits to compute the index map. fn build_commit_map( layout: &TrackLayout, - inserts: &HashMap>>, // graphical extra lines - commit_height: &[usize], // text lines required + inserts: &HashMap, // graphical extra lines + commit_height: &[usize], // text lines required ) -> Result, String> { // Compute commit index to output row map let mut index_map = vec![]; @@ -276,7 +283,7 @@ fn draw_graph_lines( tracks: &TrackMap, layout: &TrackLayout, num_cols: usize, - inserts: &HashMap>>, + inserts: &HashMap, index_map: &[usize], // map commit index to row total_rows: usize, ) -> Grid { @@ -335,8 +342,8 @@ fn draw_parent_lines( branch_visual: &BranchVis, grid: &mut Grid, info: &CommitInfo, - inserts: &HashMap>>, - index_map: &[usize], + inserts: &HashMap, + index_map: &[usize], // map commit index to row idx: usize, ) { let column = branch_visual.column.unwrap(); @@ -382,8 +389,8 @@ fn draw_parent_lines( } else { let split_index = get_deviate_index(tracks, layout, idx, *par_idx); let split_idx_map = index_map[split_index]; - let insert_idx = find_insert_idx(&inserts[&split_index], idx, *par_idx).unwrap(); - let idx_split = split_idx_map + insert_idx; + let insert_ofs = find_insert_ofs(&inserts[&split_index], idx, *par_idx).unwrap(); + let idx_split = split_idx_map + insert_ofs; let is_secondary_merge = info.is_merge() && p > 0; @@ -428,9 +435,13 @@ fn create_wrapping_options<'a>( Ok(wrapping) } -/// Find the index of the insert that connects the two commits -fn find_insert_idx(inserts: &[Vec], child_idx: usize, parent_idx: usize) -> Option { - for (insert_idx, sub_entry) in inserts.iter().enumerate() { +/// Find the relative row of the insert that connects the two commits +fn find_insert_ofs( + commit_inserts: &CommitRenderPlan, + child_idx: usize, + parent_idx: usize, +) -> Option { + for (insert_idx, sub_entry) in commit_inserts.iter().enumerate() { for occ in sub_entry { if let Occ::Range(i1, i2, _, _) = occ { if *i1 == child_idx && *i2 == parent_idx { @@ -442,34 +453,40 @@ fn find_insert_idx(inserts: &[Vec], child_idx: usize, parent_idx: usize) -> None } -/// Calculates required additional rows to visually connect commits that -/// are not direct descendants in the main commit list. These "inserts" -// represent the horizontal lines in the graph. -/// -/// # Arguments (TODO update this) -/// -/// * `graph`: A reference to the `GitGraph` structure containing the -// commit and branch information. -/// * `compact`: A boolean indicating whether to use a compact layout, -// potentially merging some insertions with commits. -/// -/// # Returns -/// -/// A `HashMap` where the keys are the indices of commits in the -/// `tracks.commits` vector, and the values are vectors of vectors -/// of `Occ`. Each inner vector represents a potential row of -/// insertions needed *before* the commit at the key index. The -/// `Occ` enum describes what occupies a cell in that row -/// (either a commit or a range representing a connection). -/// +/** Make a plan for drawing commits and lines. + + Calculates required additional rows to visually connect commits that + are not direct descendants in the main commit list. These "inserts" + represent the horizontal lines in the graph. + + ## Arguments + + * `tracks`: The track topology used for the layout. + * `layout`: The layout that should be drawn. + * `compact`: Enable merging insertions with commits to save place. + + ## Returns + + A `HashMap` where the keys are the indices of commits in the + `tracks.commits` vector, and the values are a plan for rendering + that commit. See [CommitRenderPlan] + + # Algorithm + + The internal rendering algorithm follows these steps: + + * Make a plan (called "inserts") for where to draw lines. + * Follow the plan to draw lines and symbols on a [Grid]. + * Print the grid as ANSI formatted text for a terminal. +*/ fn get_inserts( tracks: &TrackMap, layout: &TrackLayout, compact: bool, -) -> HashMap>> { +) -> HashMap { // Initialize an empty HashMap to store the required insertions. The key is the commit // index, and the value is a vector of rows, where each row is a vector of Occupations (`Occ`). - let mut inserts: HashMap>> = HashMap::new(); + let mut inserts: HashMap = HashMap::new(); // First, for each commit, we initialize an entry in the `inserts` // map with a single row containing the commit itself. This ensures @@ -510,10 +527,8 @@ fn get_inserts( if let Some(par_idx) = tracks.indices.get(&par_oid) { let par_info = &tracks.commits[*par_idx]; let par_track_idx = par_info.branch_trace.unwrap(); - let par_branch_visual_opt = layout - .track_visual(par_track_idx); - let Some(par_branch_visual) = par_branch_visual_opt - else { + let par_branch_visual_opt = layout.track_visual(par_track_idx); + let Some(par_branch_visual) = par_branch_visual_opt else { // Parent does not have visuals. if layout.contains_commit_index(*par_idx) { // Parent should have been visualized @@ -626,7 +641,7 @@ fn get_inserts( /// Returns `true` if there is an unallowable visual collision in this row, /// and `false` if the connection can safely occupy this row. fn has_overlap( - sub_entry: &[Occ], + sub_entry: &RowRenderPlan, column_range: (usize, usize), compact: bool, info_is_merge: bool, @@ -641,16 +656,21 @@ fn has_overlap( match other_range { // If the other occupation is a commit. Occ::Commit(target_index, _) => { - // In compact mode, we might allow overlap with the commit itself - // for merge commits (specifically the second parent) to keep the + // In compact mode, we allow overlap with the commit itself + // for non-primary parents of merge commits to keep the // graph tighter. - if !compact || !info_is_merge || idx != *target_index || p == 0 { + if !compact // in non-compact mode a commit always collides + || !info_is_merge // non-merge commit always collide + || idx != *target_index // other commits always collide + || p == 0 + // primary parent always collide + { return true; } } - // If the other occupation is a range (another connection). + // If the other occupation is a connection between commits. Occ::Range(o_idx, o_par_idx, _, _) => { - // Avoid overlap with connections between the same commits. + // Ignore overlap with connections to the current commit. if idx != *o_idx && par_idx != o_par_idx { return true; } @@ -992,7 +1012,11 @@ pub fn format_branches( branch_str } -/// Occupied row ranges +/** Occupied columns. + +The occupation can either be a Commit, which takes just one column, +or a Range, which takes a range of columns. Range is used for horizontal lines. +*/ enum Occ { /// Horizontal position of commit markers // First field (usize): The index of a commit within the tracks.commits vector. From cdefb31a614ac1f8247c733bd80f3f6c029b543d Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Mon, 15 Jun 2026 03:48:32 +0200 Subject: [PATCH 18/24] Adjust index_map to scoped layout As layout is a subset of the full topology, we need to take the start offset into account. index_map previously mapped from (absolute commit index) to (grid row) Now it maps from (commit index relative to layout) to (grid row) --- src/print/unicode.rs | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/src/print/unicode.rs b/src/print/unicode.rs index e1b5401..49a579e 100644 --- a/src/print/unicode.rs +++ b/src/print/unicode.rs @@ -147,7 +147,13 @@ fn build_commit_lines_and_map( num_cols: usize, the_head: &HeadInfo, inserts: &HashMap, -) -> Result<(Vec>, Vec), String> { +) -> Result< + ( + Vec>, + Vec, // index_map: from (commit index relative to layout) to grid row + ), + String, +> { // Compute textwrap options let indent1 = settings .wrapping @@ -178,7 +184,7 @@ fn build_commit_lines_and_map( for idx in layout.iter_commit_index() { let info = &tracks.commits[idx]; - index_map.push(idx + offset); + index_map.push(idx + offset - layout.commit_index_start()); // Calculate needed graph inserts (for ranges only) let cnt_inserts = if let Some(inserts) = inserts.get(&idx) { @@ -242,7 +248,7 @@ fn build_commit_map( let mut offset = 0; for idx in layout.iter_commit_index() { - index_map.push(idx + offset); + index_map.push(idx + offset - layout.commit_index_start()); // Calculate needed graph inserts (for ranges only) let cnt_inserts = if let Some(inserts) = inserts.get(&idx) { @@ -277,14 +283,14 @@ fn build_commit_map( /// Initializes the grid and draws all commit/branch connections. /// /// # Arguments -/// * index_map map commit relative to layout start, to a row in the grid +/// * index_map map commit index relative to layout start, to a row in the grid fn draw_graph_lines( settings: &Settings, tracks: &TrackMap, layout: &TrackLayout, num_cols: usize, inserts: &HashMap, - index_map: &[usize], // map commit index to row + index_map: &[usize], // map to grid row from relative commit index total_rows: usize, ) -> Grid { let mut grid = Grid::new( @@ -307,7 +313,7 @@ fn draw_graph_lines( .track_visual(trace) .expect("All commits in range has precomputed visuals"); let column = branch_visual.column.unwrap(); - let idx_map = index_map[idx]; + let idx_map = index_map[idx - layout.commit_index_start()]; // Draw commit point (DOT or CIRCLE) grid.set( @@ -343,11 +349,12 @@ fn draw_parent_lines( grid: &mut Grid, info: &CommitInfo, inserts: &HashMap, - index_map: &[usize], // map commit index to row - idx: usize, + index_map: &[usize], // map relative commit index to row + idx: usize, // absolute commit index ) { let column = branch_visual.column.unwrap(); - let idx_map = index_map[idx]; + // index_map is from commit index relative to layout start + let idx_map = index_map[idx - layout.commit_index_start()]; let branch_color = branch_visual.term_color; @@ -367,7 +374,20 @@ fn draw_parent_lines( continue; }; - let par_idx_map = index_map[*par_idx]; + // index_map is from relative commit index to row + let Some(&par_idx_map) = index_map.get(*par_idx - layout.commit_index_start()) else { + // Parent was outside layout + // so draw a vertical line to the bottom + let idx_bottom = grid.height; + vline( + grid, + (idx_map, idx_bottom), + column, + branch_color, + branch.persistence, + ); + continue; + }; let par_info = &tracks.commits[*par_idx]; let par_track_idx = par_info.branch_trace.unwrap(); let par_branch = &tracks.all_branches[par_track_idx]; @@ -388,7 +408,8 @@ fn draw_parent_lines( } } else { let split_index = get_deviate_index(tracks, layout, idx, *par_idx); - let split_idx_map = index_map[split_index]; + // index_map is from relative commit index to row + let split_idx_map = index_map[split_index - layout.commit_index_start()]; let insert_ofs = find_insert_ofs(&inserts[&split_index], idx, *par_idx).unwrap(); let idx_split = split_idx_map + insert_ofs; From 7339ad0779aa2af7384266d1c9f77897deb1b10f Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Sat, 27 Jun 2026 13:52:30 +0200 Subject: [PATCH 19/24] Fail if commits are not in topological order. --- src/track.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/track.rs b/src/track.rs index 636964b..577b2a1 100644 --- a/src/track.rs +++ b/src/track.rs @@ -6,6 +6,7 @@ Fortunately it can be computed incrementally. use std::cell::RefCell; use std::collections::HashMap; +use std::fmt::Debug; use std::hash::Hash; use std::rc::Rc; @@ -341,7 +342,7 @@ pub struct Builder { /// that should be added when it is found. missing_parents: HashMap>, } -impl Builder { +impl Builder { /// Create a builder for the specified TrackMap pub fn new(target: Rc>>) -> Self { Self { @@ -374,6 +375,9 @@ impl Builder { /// - message: Child commit message. If child is a merge this may give /// a name to a merged branch. /// - parents: List of parent Oid. + /// + /// *Note*: The function panics if a parent is added before its child. + /// You must walk the commit graph in topological order from leaf towards root. pub fn add_commit(&mut self, id: Oid, message: String, parents: Vec) { let merge_patterns = self.merge_patterns.clone(); @@ -416,6 +420,24 @@ impl Builder { .unwrap_or_default() .set_commit_branch(&mut *self.tracks.borrow_mut(), commit_index); + // 1.3. Check commit order + + for (p, parent_oid) in parents.iter().enumerate() { + if let Some(parent_index) = self.tracks.borrow().indices.get(parent_oid) { + if *parent_index >= commit_index { + log::error!( + "Commit #{} {:?} has parent[{}] which points back at commit #{} {:?}", + commit_index, + &id, + p, + parent_index, + parent_oid, + ); + panic!("A parent was added before its child"); + } + } + } + // // 2. Parents // From 49fb4a573caab8046d2b394e6b8da549de274092 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Mon, 22 Jun 2026 00:07:04 +0200 Subject: [PATCH 20/24] Upgrade to git2 0.21 --- CHANGELOG.md | 1 + Cargo.lock | 277 +------------------------------------------- Cargo.toml | 2 +- src/backend/git2.rs | 5 +- src/graph.rs | 2 +- src/print/format.rs | 11 +- src/print/label.rs | 2 +- 7 files changed, 17 insertions(+), 283 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 523d2fd..6a2a654 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - (BREAKING) TrackLayout::track_visual now take Binx as argument - (BREAKING) CommitInfo::is_merge is now a function. - (BREAKING) track::BranchInfo::new arguments simplified. +- Upgrade to git2 0.21 ### Deprecated diff --git a/Cargo.lock b/Cargo.lock index 2c1e74f..4d671dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,17 +144,6 @@ dependencies = [ "syn", ] -[[package]] -name = "displaydoc" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "document-features" version = "0.2.12" @@ -192,15 +181,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - [[package]] name = "futures-core" version = "0.3.32" @@ -239,15 +219,14 @@ dependencies = [ [[package]] name = "git2" -version = "0.20.4" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ "bitflags", "libc", "libgit2-sys", "log", - "url", ] [[package]] @@ -308,109 +287,6 @@ dependencies = [ "cc", ] -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -493,12 +369,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - [[package]] name = "litrs" version = "1.0.0" @@ -576,12 +446,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -594,15 +458,6 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -792,12 +647,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "syn" version = "2.0.117" @@ -809,17 +658,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "textwrap" version = "0.16.2" @@ -829,16 +667,6 @@ dependencies = [ "unicode-width", ] -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -896,24 +724,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "vcpkg" version = "0.2.15" @@ -1088,91 +898,8 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - [[package]] name = "yansi" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/Cargo.toml b/Cargo.toml index 34c8586..9cfa6b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ debug-assertions = false overflow-checks = false [dependencies] -git2 = {version = "0.20", default-features = false, optional = false} +git2 = {version = "0.21", default-features = false, optional = false} regex = {version = "1.7", default-features = false, optional = false, features = ["std"]} serde = "1.0" serde_derive = {version = "1.0", default-features = false, optional = false} diff --git a/src/backend/git2.rs b/src/backend/git2.rs index fefa30b..8a4ffdf 100644 --- a/src/backend/git2.rs +++ b/src/backend/git2.rs @@ -294,7 +294,7 @@ fn extract_actual_branches( .iter() .filter_map(|(br, tp)| { let reference = br.get(); - let name_full = reference.name()?; + let name_full = reference.name().ok()?; let target_oid = reference.target()?; // Strip prefix: "refs/heads/" (11) or "refs/remotes/" (13) @@ -357,12 +357,13 @@ fn extract_merge_branches( let commit = repository .find_commit(*child_oid) .map_err(|err| err.message().to_string())?; + let summary = commit.summary().map_err(|err| err.message().to_string())?; merge_branches.extend(create_merge_branches( &settings.merge_patterns, &settings.branches.persistence, child_oid, - commit.summary().unwrap_or(""), + summary.unwrap_or(""), &info.parents, idx + 1, // End index typically points to the commit )); diff --git a/src/graph.rs b/src/graph.rs index c26fc22..3d5d470 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -257,7 +257,7 @@ pub struct HeadInfo { } impl HeadInfo { fn new(head: &Reference) -> Result { - let name = head.name().ok_or_else(|| "No name for HEAD".to_string())?; + let name = head.name().unwrap_or("No name for HEAD"); let name = if name == "HEAD" { name.to_string() } else { diff --git a/src/print/format.rs b/src/print/format.rs index ea0df64..69262ad 100644 --- a/src/print/format.rs +++ b/src/print/format.rs @@ -256,7 +256,11 @@ impl<'a> CommitFieldFormatter<'a> { } fn format_subject(&mut self, mode: usize) -> Result<(), String> { - let summary = self.commit.summary().unwrap_or(""); + let summary = self + .commit + .summary() + .map_err(|err| err.to_string())? + .unwrap_or(""); self.handle_mode(mode, !summary.is_empty()); self.out.push_str(summary); Ok(()) @@ -378,7 +382,8 @@ pub fn format_oneline( } .unwrap(); - write!(out, "{} {}", branches, commit.summary().unwrap_or("")).unwrap(); + let summary = commit.summary().unwrap_or(None).unwrap_or(""); + write!(out, "{} {}", branches, summary).unwrap(); if let Some(wrap) = wrapping { textwrap::fill(&out, wrap) @@ -469,7 +474,7 @@ pub fn format_commit_metadata( out_vec.push("".to_string()); append_wrapped( &mut out_vec, - format!(" {}", commit.summary().unwrap_or("")), + format!(" {}", commit.summary().unwrap_or(None).unwrap_or("")), wrapping, ); out_vec.push("".to_string()); diff --git a/src/print/label.rs b/src/print/label.rs index 9ec4ebf..465ab1b 100644 --- a/src/print/label.rs +++ b/src/print/label.rs @@ -109,7 +109,7 @@ fn extract_branches( let mut counter: usize = 0; for (br, tp) in actual_branches { - let Some(n) = br.get().name() else { + let Some(n) = br.get().name().ok() else { continue; }; let Some(t) = br.get().target() else { From cd8a432cb4051bdc86ee5ba61ea2c33895d939fe Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Wed, 17 Jun 2026 22:28:32 +0200 Subject: [PATCH 21/24] Handle layout range outside TrackMap For edge layouts, it may occur that it lists too many commits. I assume it never goes before commit 0. For those fake commit-index, return an empty inserts value. --- src/print/unicode.rs | 52 +++++++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/src/print/unicode.rs b/src/print/unicode.rs index 49a579e..4cf2c90 100644 --- a/src/print/unicode.rs +++ b/src/print/unicode.rs @@ -183,7 +183,6 @@ fn build_commit_lines_and_map( let mut offset = 0; for idx in layout.iter_commit_index() { - let info = &tracks.commits[idx]; index_map.push(idx + offset - layout.commit_index_start()); // Calculate needed graph inserts (for ranges only) @@ -207,22 +206,26 @@ fn build_commit_lines_and_map( None }; - let commit = &repository - .find_commit(info.oid) - .map_err(|err| err.message().to_string())?; - - // Format the commit message lines - let lines = format( - &settings.format, - layout, - &labels, - commit, - info, - head, - settings.colored, - wrap_options, - )?; - + let lines; + if let Some(info) = tracks.commits.get(idx) { + let commit = &repository + .find_commit(info.oid) + .map_err(|err| err.message().to_string())?; + + // Format the commit message lines + lines = format( + &settings.format, + layout, + &labels, + commit, + info, + head, + settings.colored, + wrap_options, + )?; + } else { + lines = vec![]; + } let num_lines = if lines.is_empty() { 0 } else { lines.len() - 1 }; let max_inserts = max(cnt_inserts, num_lines); let add_lines = max_inserts - num_lines; @@ -304,7 +307,9 @@ fn draw_graph_lines( ); for idx in layout.iter_commit_index() { - let info = &tracks.commits[idx]; + let Some(info) = tracks.commits.get(idx) else { + continue; + }; let Some(trace) = info.branch_trace else { continue; }; @@ -513,7 +518,12 @@ fn get_inserts( // map with a single row containing the commit itself. This ensures // that every commit has a position in the grid. for idx in layout.iter_commit_index() { - let info = &tracks.commits[idx]; + let Some(info) = tracks.commits.get(idx) else { + // layout is too far down, so it includes index that are not + // in TrackMap. Provide an empty render plan for that row. + inserts.insert(idx, vec![vec![]]); + continue; + }; // Get the visual column assigned to the branch of this commit. Unwrap is safe here // because `branch_trace` should always point to a valid branch with an assigned column // for commits that are included in the filtered graph. @@ -531,7 +541,9 @@ fn get_inserts( // needed between parents that are not directly adjacent in the // `tracks.commits` list. for idx in layout.iter_commit_index() { - let info = &tracks.commits[idx]; + let Some(info) = tracks.commits.get(idx) else { + continue; + }; // If the commit has a branch trace (meaning it belongs to a visualized branch). if let Some(trace) = info.branch_trace { // Get the `BranchInfo` for the current commit's branch. From 905cf017d46898efe93fbc8af7683a407a574b18 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Thu, 18 Jun 2026 06:09:48 +0200 Subject: [PATCH 22/24] Fix: Grid height depends on inserts, not on commit count Apparently there are two locations that create the Grid - legacy code - new code Maybe I should adapt get_inserts so it computes grid heigt? Maybe a separate function to compute needed height from inserts and text heights. --- src/print/unicode.rs | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/print/unicode.rs b/src/print/unicode.rs index 4cf2c90..efd4d65 100644 --- a/src/print/unicode.rs +++ b/src/print/unicode.rs @@ -110,7 +110,11 @@ pub fn print_unicode(graph: &GitGraph, settings: &Settings) -> Result() - + rows_without_commit_text * min_row_height; + let total_rows = layout + .iter_commit_index() + .map(|cinx| { + let commit_height = inserts[&cinx].len(); + let text_height = commit_text_height + .get(cinx - layout.commit_index_start()) + .unwrap_or(&0); + max(max(*text_height, commit_height), min_row_height) + }) + .sum(); // Draw graph as lines on a grid let mut grid = draw_graph_lines( @@ -1050,6 +1055,7 @@ pub fn format_branches( The occupation can either be a Commit, which takes just one column, or a Range, which takes a range of columns. Range is used for horizontal lines. */ +#[derive(Debug)] enum Occ { /// Horizontal position of commit markers // First field (usize): The index of a commit within the tracks.commits vector. From 2498ac3cb06cd61b6fc8fa253fd5fac8f5fd4602 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Sat, 20 Jun 2026 18:10:47 +0200 Subject: [PATCH 23/24] Elaborate docs for layout::BranchVis --- src/layout.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/layout.rs b/src/layout.rs index 5896ebc..802e2d9 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -86,7 +86,20 @@ impl TrackLayout { } } -/// Branch properties for visualization. +/** Branch properties for visualization. + +A track / branch is visualized as a vertical line, with some kind of +continuation at the ends. This can be a simple stop, a link to some other +track or some indicator that the track continues outside the commit range +that was visualized. + +## Order Group +Placing a track is a two step process: First find the track placement +within an order group of tracks. When all tracks have been placed within +their order group, compute the absolute column. Order group definition and their +order is determined by a sequence of regex from [BranchSettings.order](crate::settings::BranchSettings.order) +The first regex to match the track name is the order group for that track. +*/ pub struct BranchVis { /// The branch's column group (left to right) pub order_group: usize, @@ -206,6 +219,7 @@ pub fn layout_track_range( { branch_visuals[vis_idx].row_range.0 = merge_target_index; } + // Resolve Target Order Group if let Some(target_idx) = branch.target_branch { // Check if the target branch has a visual in our current layout From e7430ec5e526ae6d0ca5c3ff47c7d98406f7f737 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Wed, 8 Jul 2026 07:16:03 +0200 Subject: [PATCH 24/24] Fix spelling in docs for module label --- src/print/label.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/print/label.rs b/src/print/label.rs index 465ab1b..1b981ec 100644 --- a/src/print/label.rs +++ b/src/print/label.rs @@ -2,7 +2,7 @@ a UI to show, even though only one of them will determine the looks used. This module provides add-on decoration not present in [TrackMap](crate::track::TrackMap) -or [TraclLayout](crate::layout::TrackLayout). +or [TrackLayout](crate::layout::TrackLayout). */ // TODO The current implementation preserves the pre 0.7 term+svg color