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/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/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 22480e7..3d5d470 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| { @@ -255,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/layout.rs b/src/layout.rs index 047dd0b..802e2d9 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; @@ -27,23 +38,38 @@ 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 + /// 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 + /// 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() pub fn iter_commit_index(&self) -> impl Iterator { self.source.clone() } + /// 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 + } pub fn track_visual(&self, track_inx: Binx) -> Option<&BranchVis> { self.track_visual .get(&track_inx) @@ -52,9 +78,28 @@ 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() + } } -/// 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, @@ -68,6 +113,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 { @@ -79,16 +149,23 @@ impl BranchVis { term_color, svg_color, column: None, + row_range: (0, 0), + open_end: (false, false), } } } -/// 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, 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(); @@ -98,14 +175,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 @@ -116,12 +192,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) --- @@ -129,6 +211,15 @@ 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 @@ -138,6 +229,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 @@ -205,11 +308,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; @@ -251,6 +356,8 @@ fn create_branch_visual( target_order_group: None, source_order_group: None, column: None, + row_range: (row, row), + open_end: (false, false), }) } @@ -275,13 +382,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 @@ -311,7 +424,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>>; 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, 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/grid.rs b/src/print/grid.rs new file mode 100644 index 0000000..17ef970 --- /dev/null +++ b/src/print/grid.rs @@ -0,0 +1,705 @@ +/*! 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 { + log::trace!("Grid::new({},{},_)", width, height); + 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/label.rs b/src/print/label.rs index 9ec4ebf..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 @@ -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 { diff --git a/src/print/mod.rs b/src/print/mod.rs index e29b3ea..b822687 100644 --- a/src/print/mod.rs +++ b/src/print/mod.rs @@ -1,6 +1,23 @@ -//! 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; +pub mod grid; pub mod label; pub mod unicode; diff --git a/src/print/unicode.rs b/src/print/unicode.rs index 24f3f47..efd4d65 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}; @@ -16,38 +35,32 @@ 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::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 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 @@ -71,6 +84,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; @@ -79,45 +93,40 @@ 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> { + inserts: &HashMap, +) -> Result< + ( + Vec>, + Vec, // index_map: from (commit index relative to layout) to grid row + ), + 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); @@ -169,8 +187,7 @@ fn build_commit_lines_and_map<'a>( let mut offset = 0; 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) { @@ -193,22 +210,26 @@ fn build_commit_lines_and_map<'a>( 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; @@ -223,14 +244,60 @@ fn build_commit_lines_and_map<'a>( 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 - layout.commit_index_start()); + + // 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 +/// * 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], + inserts: &HashMap, + index_map: &[usize], // map to grid row from relative commit index total_rows: usize, ) -> Grid { let mut grid = Grid::new( @@ -244,7 +311,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; }; @@ -253,7 +322,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( @@ -288,12 +357,13 @@ fn draw_parent_lines( branch_visual: &BranchVis, grid: &mut Grid, info: &CommitInfo, - inserts: &HashMap>>, - index_map: &[usize], - idx: usize, + inserts: &HashMap, + 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; @@ -313,7 +383,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]; @@ -334,9 +417,10 @@ 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; + // 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; let is_secondary_merge = info.is_merge() && p > 0; @@ -381,9 +465,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 { @@ -395,255 +483,51 @@ 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); -} +/** Make a plan for drawing commits and lines. -/// 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); - } - } - } -} + 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. -/// 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; - } + ## Arguments - let from_2 = from * 2; - let to_2 = to * 2; + * `tracks`: The track topology used for the layout. + * `layout`: The layout that should be drawn. + * `compact`: Enable merging insertions with commits to save place. - 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); - } -} + ## Returns -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); - } - } - } - } -} + 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] -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); - } - } -} + # Algorithm -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); - } - } -} + The internal rendering algorithm follows these steps: -/// 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 (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 // 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. @@ -661,7 +545,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. @@ -678,9 +564,19 @@ 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); @@ -701,37 +597,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; @@ -779,6 +653,71 @@ 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: &RowRenderPlan, + 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 allow overlap with the commit itself + // for non-primary parents of merge commits to keep the + // graph tighter. + 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 connection between commits. + Occ::Range(o_idx, o_par_idx, _, _) => { + // Ignore overlap with connections to the current commit. + 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. /// @@ -833,8 +772,127 @@ 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( + settings: &Settings, + tracks: &TrackMap, + 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(); + } + + // 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 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( + 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. -fn print_graph( +fn print_graph_and_text( characters: &Characters, grid: &Grid, text_lines: Vec>, @@ -992,7 +1050,12 @@ 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. +*/ +#[derive(Debug)] enum Occ { /// Horizontal position of commit markers // First field (usize): The index of a commit within the tracks.commits vector. @@ -1026,461 +1089,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); - } -} diff --git a/src/track.rs b/src/track.rs index cdc8daf..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; @@ -56,6 +57,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(), @@ -111,8 +113,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 +129,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 { @@ -334,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 { @@ -367,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(); @@ -409,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 // 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 { (