From cee602c029f10e59b1e9eb0d4a11821ef0292036 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 28 Sep 2026 09:28:32 +0200 Subject: [PATCH 1/6] Demonstrate that the graph continues the line below a root commit If a root commit isn't the last one in the list, the graph draws a vertical line below it in every row that follows, although nothing comes after a root commit. This happens in repos with more than one root commit, for example when showing all branches of a repo with a gh-pages branch, or after merging an unrelated history. Also, a commit that no line leads to is placed to the right of the lines that ended in the row before it, although their columns are free again. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/presentation/graph/graph_test.go | 45 ++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/pkg/gui/presentation/graph/graph_test.go b/pkg/gui/presentation/graph/graph_test.go index f0cafca1c..35fc7a620 100644 --- a/pkg/gui/presentation/graph/graph_test.go +++ b/pkg/gui/presentation/graph/graph_test.go @@ -94,6 +94,48 @@ func TestRenderCommitGraph(t *testing.T) { 5 ○───╯ │ 6 ○ ╭───╯`, }, + { + name: "with a root commit followed by an unrelated history", + commitOpts: []models.NewCommitOpts{ + {Hash: "1", Parents: []string{"2"}}, + {Hash: "2"}, + {Hash: "A", Parents: []string{"B"}}, + {Hash: "B"}, + }, + /* EXPECTED: + expectedOutput: ` + 1 ○ + 2 ○ + A ○ + B ○`, + ACTUAL: */ + expectedOutput: ` + 1 ○ + 2 ○ + A │ ○ + B │ ○`, + }, + { + name: "with a merge of an unrelated history", + commitOpts: []models.NewCommitOpts{ + {Hash: "1", Parents: []string{"2", "A"}}, + {Hash: "2", Parents: []string{"3"}}, + {Hash: "A"}, + {Hash: "3"}, + }, + /* EXPECTED: + expectedOutput: ` + 1 ◎─╮ + 2 ○ │ + A │ ○ + 3 ○`, + ACTUAL: */ + expectedOutput: ` + 1 ◎─╮ + 2 ○ │ + A │ ○ + 3 ○ │`, + }, { name: "with a path that has room to move to the left and continues", commitOpts: []models.NewCommitOpts{ @@ -528,6 +570,9 @@ func TestGetNextPipes(t *testing.T) { Parents: []string{}, }), expected: []Pipe{ + /* EXPECTED: + {fromPos: 0, toPos: 0, fromHash: pool("root"), toHash: pool(models.EmptyTreeCommitHash), kind: STARTS, style: &style.FgDefault}, + ACTUAL: */ {fromPos: 1, toPos: 1, fromHash: pool("root"), toHash: pool(models.EmptyTreeCommitHash), kind: STARTS, style: &style.FgDefault}, }, }, From 91378b1e8ac67c3a77e4d7e7f08b58ed10cdf63a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 28 Sep 2026 09:31:55 +0200 Subject: [PATCH 2/6] Stop drawing a line below root commits in the commit graph If a root commit isn't the last one in the list, the graph draws a vertical line below it in every row that follows. A root commit's pipe to the empty tree never terminates, because no commit comes after it, so it is carried on from row to row. The line also takes up its column, and an unrelated commit after the root commit is placed to the right of it. Drop that pipe after the root commit's row. In its own row, it still gives the commit symbol the style of the commit. Also, place a commit that no line leads to next to the lines that go on into its row, not next to the lines that ended in the row before. Their columns are free again. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/presentation/graph/graph.go | 17 ++++++++--------- pkg/gui/presentation/graph/graph_test.go | 17 ----------------- 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/pkg/gui/presentation/graph/graph.go b/pkg/gui/presentation/graph/graph.go index 1639a62e6..c267bbbcb 100644 --- a/pkg/gui/presentation/graph/graph.go +++ b/pkg/gui/presentation/graph/graph.go @@ -107,19 +107,18 @@ func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPt } func getNextPipes(prevPipes []Pipe, commit *models.Commit, getStyle func(c *models.Commit) *style.TextStyle) []Pipe { - maxPos := int16(0) - for _, pipe := range prevPipes { - if pipe.toPos > maxPos { - maxPos = pipe.toPos - } - } - // a pipe that terminated in the previous line has no bearing on the current line - // so we'll filter those out + // so we'll filter those out. The same goes for the pipe from a root commit to + // the empty tree, because no commit comes after a root commit. currentPipes := lo.Filter(prevPipes, func(pipe Pipe, _ int) bool { - return pipe.kind != TERMINATES + return pipe.kind != TERMINATES && !equalHashes(pipe.toHash, &EmptyTreeCommitHash) }) + maxPos := int16(-1) + for _, pipe := range currentPipes { + maxPos = max(maxPos, pipe.toPos) + } + newPipes := make([]Pipe, 0, len(currentPipes)+len(commit.ParentPtrs())) // start by assuming that we've got a brand new commit not related to any preceding commit. // (this only happens when we're doing `git log --all`). These will be tacked onto the far end. diff --git a/pkg/gui/presentation/graph/graph_test.go b/pkg/gui/presentation/graph/graph_test.go index 35fc7a620..f05574881 100644 --- a/pkg/gui/presentation/graph/graph_test.go +++ b/pkg/gui/presentation/graph/graph_test.go @@ -102,18 +102,11 @@ func TestRenderCommitGraph(t *testing.T) { {Hash: "A", Parents: []string{"B"}}, {Hash: "B"}, }, - /* EXPECTED: expectedOutput: ` 1 ○ 2 ○ A ○ B ○`, - ACTUAL: */ - expectedOutput: ` - 1 ○ - 2 ○ - A │ ○ - B │ ○`, }, { name: "with a merge of an unrelated history", @@ -123,18 +116,11 @@ func TestRenderCommitGraph(t *testing.T) { {Hash: "A"}, {Hash: "3"}, }, - /* EXPECTED: expectedOutput: ` 1 ◎─╮ 2 ○ │ A │ ○ 3 ○`, - ACTUAL: */ - expectedOutput: ` - 1 ◎─╮ - 2 ○ │ - A │ ○ - 3 ○ │`, }, { name: "with a path that has room to move to the left and continues", @@ -570,10 +556,7 @@ func TestGetNextPipes(t *testing.T) { Parents: []string{}, }), expected: []Pipe{ - /* EXPECTED: {fromPos: 0, toPos: 0, fromHash: pool("root"), toHash: pool(models.EmptyTreeCommitHash), kind: STARTS, style: &style.FgDefault}, - ACTUAL: */ - {fromPos: 1, toPos: 1, fromHash: pool("root"), toHash: pool(models.EmptyTreeCommitHash), kind: STARTS, style: &style.FgDefault}, }, }, } From e5c5a62cd90b6b84ee159af13afcd4443e6efba3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 27 Sep 2026 15:13:34 +0200 Subject: [PATCH 3/6] Record how lines run through the cells of the commit graph A cell of the commit graph only records which of its edges are touched by lines. That's all the box drawing characters can show, but it doesn't say how the lines connect. A vertical line with a horizontal line passing behind it touches all four edges. So does a cell in which the line from above bends to the left, the line below comes in from the left, and a horizontal line passes through. For the lines at the top and bottom edges, record whether they run straight on or bend to the left or right, and record whether a line passes through horizontally. Nothing reads this yet. It prepares for drawing the graph with symbols that can show these differences. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/presentation/graph/cell.go | 51 ++++++++++++++++++++--------- pkg/gui/presentation/graph/graph.go | 19 +++++++++-- 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/pkg/gui/presentation/graph/cell.go b/pkg/gui/presentation/graph/cell.go index 618a2149d..5660c5745 100644 --- a/pkg/gui/presentation/graph/cell.go +++ b/pkg/gui/presentation/graph/cell.go @@ -21,17 +21,33 @@ const ( MERGE ) +// How a line that touches the top or bottom edge of a cell runs within it +type verticalLine uint8 + +const ( + noLine verticalLine = iota + // On to the opposite edge, or into the commit symbol + straightLine + // Bends towards the left edge + lineToLeft + // Bends towards the right edge + lineToRight +) + type Cell struct { - up, down, left, right bool - cellType cellType - rightStyle *style.TextStyle - style *style.TextStyle + // The lines that touch the top and bottom edges + up, down verticalLine + // Whether lines touch the left and right edges + left, right bool + // Whether a line passes through from the left edge to the right edge + horizontal bool + cellType cellType + rightStyle *style.TextStyle + style *style.TextStyle } func (cell *Cell) render(writer io.StringWriter) { - up, down, left, right := cell.up, cell.down, cell.left, cell.right - - first, second := getBoxDrawingChars(up, down, left, right) + first, second := getBoxDrawingChars(cell.up != noLine, cell.down != noLine, cell.left, cell.right) var adjustedFirst string switch cell.cellType { case CONNECTION: @@ -98,34 +114,34 @@ func cachedSprint(style style.TextStyle, str string) string { } func (cell *Cell) reset() { - cell.up = false - cell.down = false + cell.up = noLine + cell.down = noLine cell.left = false cell.right = false + cell.horizontal = false } -func (cell *Cell) setUp(style *style.TextStyle) *Cell { - cell.up = true +func (cell *Cell) setUp(style *style.TextStyle, line verticalLine) *Cell { + cell.up = line cell.style = style return cell } -func (cell *Cell) setDown(style *style.TextStyle) *Cell { - cell.down = true +func (cell *Cell) setDown(style *style.TextStyle, line verticalLine) *Cell { + cell.down = line cell.style = style return cell } func (cell *Cell) setLeft(style *style.TextStyle) *Cell { cell.left = true - if !cell.up && !cell.down { + if cell.up == noLine && cell.down == noLine { // vertical trumps left cell.style = style } return cell } -//nolint:unparam func (cell *Cell) setRight(style *style.TextStyle, override bool) *Cell { cell.right = true if cell.rightStyle == nil || override { @@ -134,6 +150,11 @@ func (cell *Cell) setRight(style *style.TextStyle, override bool) *Cell { return cell } +func (cell *Cell) setHorizontal(style *style.TextStyle, overrideRightStyle bool) *Cell { + cell.horizontal = true + return cell.setLeft(style).setRight(style, overrideRightStyle) +} + func (cell *Cell) setStyle(style *style.TextStyle) *Cell { cell.style = style return cell diff --git a/pkg/gui/presentation/graph/graph.go b/pkg/gui/presentation/graph/graph.go index c267bbbcb..996892d7b 100644 --- a/pkg/gui/presentation/graph/graph.go +++ b/pkg/gui/presentation/graph/graph.go @@ -303,17 +303,17 @@ func renderPipeSet( if left != right { for i := left + 1; i < right; i++ { - cells[i].setLeft(style).setRight(style, overrideRightStyle) + cells[i].setHorizontal(style, overrideRightStyle) } cells[left].setRight(style, overrideRightStyle) cells[right].setLeft(style) } if pipe.kind == STARTS || pipe.kind == CONTINUES { - cells[pipe.toPos].setDown(style) + cells[pipe.toPos].setDown(style, lineTowards(pipe.toPos, pipe.fromPos)) } if pipe.kind == TERMINATES || pipe.kind == CONTINUES { - cells[pipe.fromPos].setUp(style) + cells[pipe.fromPos].setUp(style, lineTowards(pipe.fromPos, pipe.toPos)) } } @@ -375,6 +375,19 @@ func renderPipeSet( return writer.String() } +// The course of a line through the top or bottom edge of the cell at pos, +// given the column of the pipe's other end +func lineTowards(pos, otherPos int16) verticalLine { + switch { + case otherPos < pos: + return lineToLeft + case otherPos > pos: + return lineToRight + default: + return straightLine + } +} + func equalHashes(a, b *string) bool { // if our selectedCommitHashPtr is nil, there is no selected commit if a == nil || b == nil { From 74e7572d7aa9a61c20d15f119b692021cfad4f70 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 27 Sep 2026 15:14:14 +0200 Subject: [PATCH 4/6] Extract the choice of a graph cell's box drawing characters This separates choosing the characters from writing them out with their styles, so that the next commit can add another set of characters. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/presentation/graph/cell.go | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/pkg/gui/presentation/graph/cell.go b/pkg/gui/presentation/graph/cell.go index 5660c5745..a16120322 100644 --- a/pkg/gui/presentation/graph/cell.go +++ b/pkg/gui/presentation/graph/cell.go @@ -47,16 +47,7 @@ type Cell struct { } func (cell *Cell) render(writer io.StringWriter) { - first, second := getBoxDrawingChars(cell.up != noLine, cell.down != noLine, cell.left, cell.right) - var adjustedFirst string - switch cell.cellType { - case CONNECTION: - adjustedFirst = first - case COMMIT: - adjustedFirst = string(CommitSymbol) - case MERGE: - adjustedFirst = string(MergeSymbol) - } + first, second := cell.boxDrawingChars() var rightStyle *style.TextStyle if cell.rightStyle == nil { @@ -75,10 +66,22 @@ func (cell *Cell) render(writer io.StringWriter) { styledSecondChar = cachedSprint(*rightStyle, second) } - _, _ = writer.WriteString(cachedSprint(*cell.style, adjustedFirst)) + _, _ = writer.WriteString(cachedSprint(*cell.style, first)) _, _ = writer.WriteString(styledSecondChar) } +func (cell *Cell) boxDrawingChars() (string, string) { + first, second := getBoxDrawingChars(cell.up != noLine, cell.down != noLine, cell.left, cell.right) + switch cell.cellType { + case COMMIT: + return string(CommitSymbol), second + case MERGE: + return string(MergeSymbol), second + default: + return first, second + } +} + type rgbCacheKey struct { *color.RGBStyle str string From da5b9b54eb4598079d3d56f128c1fe9fb57acd26 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 27 Sep 2026 15:23:42 +0200 Subject: [PATCH 5/6] Add an option to draw the commit graph with branch drawing symbols If a branch forks off a merge commit, the graph can't show both lines in the merge commit's row. The line from the branch's first commit ends in the same cell in which the merge's line to its second parent starts. The box drawing characters have no symbol for a line from above that bends to the left combined with a line from the left that bends down, so one of the two gets lost. Today the cell shows a plain vertical line, and the branch looks as if it sat on the merge's second parent (#5497). The branch drawing symbols in the Unicode Private Use Area (U+F5D0 to U+F60D) have a symbol for every way in which lines meet in a cell. kitty introduced them for git graph viewers like vim-flog, and Ghostty, Contour, the terminal of VS Code and nightly builds of WezTerm render them too. Other terminals need a font that contains them. Add the gui.commitGraphStyle config. Its 'detailed' style draws the graph with these symbols, and the commit symbols of the set connect to their lines. Commits are drawn as hollow circles and merge commits as filled ones. The default stays the 'classic' style with the box drawing characters, because not every terminal can display the others. A cell has only one colour. Where two lines meet, the symbol takes the colour of the line from above. With the box drawing characters, highlighting the lines of the selected commit clears the cells they run through. The branch drawing symbols keep the other lines. A cell in which a highlighted line bends is highlighted as a whole. Where a highlighted line crosses the vertical line of another commit, it is drawn over that line, so that it reads as one line. In a cell in which another line bends, the symbol keeps the colour of that line. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs-master/Config.md | 10 + pkg/config/user_config.go | 5 + pkg/config/user_config_validation.go | 4 + pkg/gui/context/local_commits_context.go | 9 + pkg/gui/context/sub_commits_context.go | 1 + pkg/gui/presentation/commits.go | 4 + pkg/gui/presentation/commits_test.go | 3 +- pkg/gui/presentation/graph/branch_drawing.go | 97 ++++++ pkg/gui/presentation/graph/cell.go | 27 +- pkg/gui/presentation/graph/graph.go | 62 +++- pkg/gui/presentation/graph/graph_test.go | 349 ++++++++++++++++++- schema-master/config.json | 9 + 12 files changed, 559 insertions(+), 21 deletions(-) create mode 100644 pkg/gui/presentation/graph/branch_drawing.go diff --git a/docs-master/Config.md b/docs-master/Config.md index b9b75f52e..16b6e2b5a 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -301,6 +301,16 @@ gui: # NerdFontsVersion is not empty. showFileIcons: true + # How the commit graph is drawn. + # One of: 'classic' (default) | 'detailed' + # 'detailed' connects the lines to the commit circles, and shows exactly where + # branches fork off and merge. It draws the graph with the git branch drawing + # symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself: + # kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with + # GPU acceleration. Other terminals need a font that contains them, such as + # https://github.com/rbong/flog-symbols. + commitGraphStyle: classic + # Length of author name in (non-expanded) commits view. 2 means show initials # only. commitAuthorShortLength: 2 diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 81a0b22da..b0279d011 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -179,6 +179,10 @@ type GuiConfig struct { NerdFontsVersion string `yaml:"nerdFontsVersion" jsonschema:"enum=2,enum=3,enum="` // If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty. ShowFileIcons bool `yaml:"showFileIcons"` + // How the commit graph is drawn. + // One of: 'classic' (default) | 'detailed' + // 'detailed' connects the lines to the commit circles, and shows exactly where branches fork off and merge. It draws the graph with the git branch drawing symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself: kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with GPU acceleration. Other terminals need a font that contains them, such as https://github.com/rbong/flog-symbols. + CommitGraphStyle string `yaml:"commitGraphStyle" jsonschema:"enum=classic,enum=detailed"` // Length of author name in (non-expanded) commits view. 2 means show initials only. CommitAuthorShortLength int `yaml:"commitAuthorShortLength"` // Length of author name in expanded commits view. 2 means show initials only. @@ -922,6 +926,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { ShowIcons: false, NerdFontsVersion: "", ShowFileIcons: true, + CommitGraphStyle: "classic", CommitAuthorShortLength: 2, CommitAuthorLongLength: 17, CommitHashLength: 8, diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index 6d993bd87..d733a9577 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -30,6 +30,10 @@ func (config *UserConfig) Validate() error { []string{"mixed", "filesFirst", "foldersFirst"}); err != nil { return err } + if err := validateEnum("gui.commitGraphStyle", config.Gui.CommitGraphStyle, + []string{"classic", "detailed"}); err != nil { + return err + } if err := validateEnum("git.autoForwardBranches", config.Git.AutoForwardBranches, []string{"none", "onlyMainBranches", "allBranches"}); err != nil { return err diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 4a99259fd..4259da43d 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -12,6 +12,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/presentation" + "github.com/jesseduffield/lazygit/pkg/gui/presentation/graph" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" @@ -73,6 +74,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { startIdx, endIdx, shouldShowGraph(c), + commitGraphSymbolSet(c), c.Model().BisectInfo, ) } @@ -349,6 +351,13 @@ func shouldShowGraph(c *ContextCommon) bool { return false } +func commitGraphSymbolSet(c *ContextCommon) graph.SymbolSet { + if c.UserConfig().Gui.CommitGraphStyle == "detailed" { + return graph.BranchDrawingSymbols + } + return graph.BoxDrawingSymbols +} + func searchModelCommits(caseSensitive bool, commits []*models.Commit, columnPositions []int, modelToViewIndex func(int) int, searchStr string, ) []gocui.SearchPosition { diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go index b0bcee30a..86938797a 100644 --- a/pkg/gui/context/sub_commits_context.go +++ b/pkg/gui/context/sub_commits_context.go @@ -75,6 +75,7 @@ func NewSubCommitsContext( startIdx, endIdx, shouldShowGraph(c), + commitGraphSymbolSet(c), git_commands.NewNullBisectInfo(), ) } diff --git a/pkg/gui/presentation/commits.go b/pkg/gui/presentation/commits.go index 8c212b0cb..7be1d1241 100644 --- a/pkg/gui/presentation/commits.go +++ b/pkg/gui/presentation/commits.go @@ -59,6 +59,7 @@ func GetCommitListDisplayStrings( startIdx int, endIdx int, showGraph bool, + graphSymbolSet graph.SymbolSet, bisectInfo *git_commands.BisectInfo, ) [][]string { mutex.Lock() @@ -107,6 +108,7 @@ func GetCommitListDisplayStrings( graphPipeSets, graphCommits, selectedCommitHashPtr, + graphSymbolSet, ) allGraphLines = append(allGraphLines, graphLines...) } @@ -124,6 +126,7 @@ func GetCommitListDisplayStrings( graphPipeSets, graphCommits, selectedCommitHashPtr, + graphSymbolSet, ) allGraphLines = append(allGraphLines, graphLines...) } @@ -145,6 +148,7 @@ func GetCommitListDisplayStrings( graphPipeSets, graphCommits, selectedCommitHashPtr, + graphSymbolSet, ) getGraphLine = func(idx int) string { if idx >= graphOffset { diff --git a/pkg/gui/presentation/commits_test.go b/pkg/gui/presentation/commits_test.go index b37ed73d3..079c8c4da 100644 --- a/pkg/gui/presentation/commits_test.go +++ b/pkg/gui/presentation/commits_test.go @@ -611,6 +611,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) { s.startIdx, s.endIdx, s.showGraph, + graph.BoxDrawingSymbols, s.bisectInfo, ) @@ -635,7 +636,7 @@ func TestGraphColorsFollowTheAuthorColors(t *testing.T) { models.NewCommit(hashPool, models.NewCommitOpts{Hash: "authorcolors2", AuthorName: "Jane Doe"}), } renderGraph := func() string { - return strings.Join(graph.RenderAux(loadPipesets(commits), commits, nil), "\n") + return strings.Join(graph.RenderAux(loadPipesets(commits), commits, nil, graph.BoxDrawingSymbols), "\n") } authors.SetCustomAuthors(map[string]string{"Jane Doe": "red"}) diff --git a/pkg/gui/presentation/graph/branch_drawing.go b/pkg/gui/presentation/graph/branch_drawing.go new file mode 100644 index 000000000..2e3537498 --- /dev/null +++ b/pkg/gui/presentation/graph/branch_drawing.go @@ -0,0 +1,97 @@ +package graph + +// The branch drawing symbols are characters in the Unicode Private Use Area +// (U+F5D0 to U+F60D) for drawing git graphs, introduced by kitty in +// https://github.com/kovidgoyal/kitty/pull/7681. Unlike the box drawing +// characters, they can show how the lines in a cell connect. For example, there +// is a symbol for a cell in which a line from above bends to the left and a +// line from the left bends down. + +const branchDrawingHorizontal = "\uf5d0" + +// The lines in a cell that isn't a commit +type lineCourses struct { + up, down verticalLine + horizontal bool +} + +var branchDrawingConnections = map[lineCourses]string{ + {}: " ", + {horizontal: true}: branchDrawingHorizontal, + // A horizontal line passing behind a vertical one only shows in the + // neighbouring cells + {up: straightLine, down: straightLine}: "\uf5d1", // │ + {up: straightLine, down: straightLine, horizontal: true}: "\uf5d1", // │ + {up: lineToLeft}: "\uf5d9", // ╯ + {up: lineToRight}: "\uf5d8", // ╰ + {down: lineToLeft}: "\uf5d7", // ╮ + {down: lineToRight}: "\uf5d6", // ╭ + {up: lineToLeft, horizontal: true}: "\uf5e3", // ╯ on ─ + {up: lineToRight, horizontal: true}: "\uf5e4", // ╰ on ─ + {down: lineToLeft, horizontal: true}: "\uf5e0", // ╮ on ─ + {down: lineToRight, horizontal: true}: "\uf5e1", // ╭ on ─ + {up: lineToLeft, down: lineToLeft}: "\uf5df", // ╯ and ╮ + {up: lineToRight, down: lineToRight}: "\uf5dc", // ╰ and ╭ + {up: lineToLeft, down: lineToLeft, horizontal: true}: "\uf5e8", // ╯ and ╮ on ─ + {up: lineToRight, down: lineToRight, horizontal: true}: "\uf5e9", // ╰ and ╭ on ─ + {up: lineToLeft, down: lineToRight, horizontal: true}: "\uf5ec", // ╯ and ╭ on ─ + {up: lineToRight, down: lineToLeft, horizontal: true}: "\uf5ed", // ╰ and ╮ on ─ +} + +// The edges of a commit's cell that its lines touch +type commitEdges struct { + up, down, left, right bool +} + +// Commits are drawn as hollow circles, merge commits as filled ones. Both +// connect to the lines at the given edges. +var branchDrawingCommitSymbols = map[commitEdges]struct{ commit, merge string }{ + {}: {"\uf5ef", "\uf5ee"}, + {right: true}: {"\uf5f1", "\uf5f0"}, + {left: true}: {"\uf5f3", "\uf5f2"}, + {left: true, right: true}: {"\uf5f5", "\uf5f4"}, + {down: true}: {"\uf5f7", "\uf5f6"}, + {up: true}: {"\uf5f9", "\uf5f8"}, + {up: true, down: true}: {"\uf5fb", "\uf5fa"}, + {down: true, right: true}: {"\uf5fd", "\uf5fc"}, + {down: true, left: true}: {"\uf5ff", "\uf5fe"}, + {up: true, right: true}: {"\uf601", "\uf600"}, + {up: true, left: true}: {"\uf603", "\uf602"}, + {up: true, down: true, right: true}: {"\uf605", "\uf604"}, + {up: true, down: true, left: true}: {"\uf607", "\uf606"}, + {down: true, left: true, right: true}: {"\uf609", "\uf608"}, + {up: true, left: true, right: true}: {"\uf60b", "\uf60a"}, + {up: true, down: true, left: true, right: true}: {"\uf60d", "\uf60c"}, +} + +func (cell *Cell) branchDrawingChars() (string, string) { + second := " " + if cell.right { + second = branchDrawingHorizontal + } + + switch cell.cellType { + case COMMIT, MERGE: + symbols := branchDrawingCommitSymbols[commitEdges{ + up: cell.up != noLine, + down: cell.down != noLine, + left: cell.left, + right: cell.right, + }] + if cell.cellType == MERGE { + return symbols.merge, second + } + return symbols.commit, second + default: + if cell.horizontalOnTop { + return branchDrawingHorizontal, second + } + if first, ok := branchDrawingConnections[lineCourses{cell.up, cell.down, cell.horizontal}]; ok { + return first, second + } + // There is no symbol for lines that the layout of the graph never + // produces, such as a lone half of a line + first, _ := getBoxDrawingChars(cell.up != noLine, cell.down != noLine, cell.left, cell.right) + return first, second + } +} diff --git a/pkg/gui/presentation/graph/cell.go b/pkg/gui/presentation/graph/cell.go index a16120322..c0aeb1221 100644 --- a/pkg/gui/presentation/graph/cell.go +++ b/pkg/gui/presentation/graph/cell.go @@ -13,6 +13,14 @@ const ( CommitSymbol = '○' ) +// The characters that the commit graph is drawn with +type SymbolSet uint8 + +const ( + BoxDrawingSymbols SymbolSet = iota + BranchDrawingSymbols +) + type cellType int const ( @@ -41,13 +49,21 @@ type Cell struct { left, right bool // Whether a line passes through from the left edge to the right edge horizontal bool - cellType cellType - rightStyle *style.TextStyle - style *style.TextStyle + // Whether that line is drawn over the vertical line that it crosses + horizontalOnTop bool + cellType cellType + rightStyle *style.TextStyle + style *style.TextStyle } -func (cell *Cell) render(writer io.StringWriter) { - first, second := cell.boxDrawingChars() +func (cell *Cell) render(writer io.StringWriter, symbolSet SymbolSet) { + var first, second string + switch symbolSet { + case BoxDrawingSymbols: + first, second = cell.boxDrawingChars() + case BranchDrawingSymbols: + first, second = cell.branchDrawingChars() + } var rightStyle *style.TextStyle if cell.rightStyle == nil { @@ -122,6 +138,7 @@ func (cell *Cell) reset() { cell.left = false cell.right = false cell.horizontal = false + cell.horizontalOnTop = false } func (cell *Cell) setUp(style *style.TextStyle, line verticalLine) *Cell { diff --git a/pkg/gui/presentation/graph/graph.go b/pkg/gui/presentation/graph/graph.go index 996892d7b..a8e0a1c67 100644 --- a/pkg/gui/presentation/graph/graph.go +++ b/pkg/gui/presentation/graph/graph.go @@ -45,13 +45,13 @@ func (self Pipe) right() int16 { return max(self.fromPos, self.toPos) } -func RenderCommitGraph(commits []*models.Commit, selectedCommitHashPtr *string, getStyle func(c *models.Commit) *style.TextStyle) []string { +func RenderCommitGraph(commits []*models.Commit, selectedCommitHashPtr *string, getStyle func(c *models.Commit) *style.TextStyle, symbolSet SymbolSet) []string { pipeSets := GetPipeSets(commits, getStyle) if len(pipeSets) == 0 { return nil } - lines := RenderAux(pipeSets, commits, selectedCommitHashPtr) + lines := RenderAux(pipeSets, commits, selectedCommitHashPtr, symbolSet) return lines } @@ -69,7 +69,7 @@ func GetPipeSets(commits []*models.Commit, getStyle func(c *models.Commit) *styl }) } -func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPtr *string) []string { +func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPtr *string, symbolSet SymbolSet) []string { maxProcs := runtime.GOMAXPROCS(0) // splitting up the rendering of the graph into multiple goroutines allows us to render the graph in parallel @@ -93,7 +93,7 @@ func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPt if k > 0 { prevCommit = commits[k-1] } - line := renderPipeSet(pipeSet, selectedCommitHashPtr, prevCommit) + line := renderPipeSet(pipeSet, selectedCommitHashPtr, prevCommit, symbolSet) innerLines = append(innerLines, line) } chunks[i] = innerLines @@ -275,6 +275,7 @@ func renderPipeSet( pipes []Pipe, selectedCommitHashPtr *string, prevCommit *models.Commit, + symbolSet SymbolSet, ) string { maxPos := int16(0) commitPos := int16(0) @@ -342,14 +343,31 @@ func renderPipeSet( } for _, pipe := range nonSelectedPipes { - if pipe.kind != STARTS && !(pipe.kind == TERMINATES && pipe.fromPos == commitPos && pipe.toPos == commitPos) { - renderPipe(&pipe, pipe.style, false) + if pipe.kind == STARTS { + continue } + if pipe.kind == TERMINATES && pipe.fromPos == commitPos && pipe.toPos == commitPos { + // Record this line without drawing it, so that the commit symbol + // keeps the style of the commit itself. The branch drawing commit + // symbols connect to it. The pipe from above the first commit + // doesn't come from a commit, so it has no line. + if !equalHashes(pipe.fromHash, &StartCommitHash) { + cells[commitPos].up = straightLine + } + continue + } + renderPipe(&pipe, pipe.style, false) } - for _, pipe := range selectedPipes { - for i := pipe.left(); i <= pipe.right(); i++ { - cells[i].reset() + // Box drawing characters can't show every combination of lines in a cell, + // so the cells that the selected commit's lines run through show only + // those lines. The branch drawing symbols can, so they keep the other + // lines too. + if symbolSet == BoxDrawingSymbols { + for _, pipe := range selectedPipes { + for i := pipe.left(); i <= pipe.right(); i++ { + cells[i].reset() + } } } for _, pipe := range selectedPipes { @@ -359,6 +377,30 @@ func renderPipeSet( } } + // The pipe from a root commit to the empty tree gives the commit symbol the + // style of the commit, but there is no line below a root commit + for _, pipe := range pipes { + if pipe.kind == STARTS && equalHashes(pipe.toHash, &EmptyTreeCommitHash) { + cells[pipe.toPos].down = noLine + } + } + + // Where a line of the selected commit crosses the vertical line of another + // commit, draw it over that line, so that it reads as one line. The + // vertical line visibly continues above and below. A vertical line in a + // cell that doesn't have the highlight style belongs to another commit. + if symbolSet == BranchDrawingSymbols { + for _, pipe := range selectedPipes { + for i := pipe.left() + 1; i < pipe.right(); i++ { + cell := cells[i] + if cell.up == straightLine && cell.down == straightLine && cell.style != &highlightStyle { + cell.horizontalOnTop = true + cell.style = &highlightStyle + } + } + } + } + cType := COMMIT if isMerge { cType = MERGE @@ -370,7 +412,7 @@ func renderPipeSet( writer := &strings.Builder{} writer.Grow(len(cells) * 2) for _, cell := range cells { - cell.render(writer) + cell.render(writer, symbolSet) } return writer.String() } diff --git a/pkg/gui/presentation/graph/graph_test.go b/pkg/gui/presentation/graph/graph_test.go index f05574881..68883e742 100644 --- a/pkg/gui/presentation/graph/graph_test.go +++ b/pkg/gui/presentation/graph/graph_test.go @@ -252,7 +252,7 @@ func TestRenderCommitGraph(t *testing.T) { getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault } commits := lo.Map(test.commitOpts, func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) }) - lines := RenderCommitGraph(commits, hashPool.Add("blah"), getStyle) + lines := RenderCommitGraph(commits, hashPool.Add("blah"), getStyle, BoxDrawingSymbols) trimmedExpectedOutput := "" for line := range strings.SplitSeq(strings.TrimPrefix(test.expectedOutput, "\n"), "\n") { @@ -275,6 +275,345 @@ func TestRenderCommitGraph(t *testing.T) { } } +// Box drawing look-alikes for the branch drawing symbols, so that the expected +// output in tests is readable. The box drawing characters of the graph never +// use ┤ and ┼; here they stand for the symbols that have two bends. Commit +// symbols are shown as ○ and ◎, whichever lines they connect to. +var branchDrawingLookAlikes = func() map[rune]rune { + lookAlikes := map[rune]rune{ + '\uf5d0': '─', + '\uf5d1': '│', + '\uf5d6': '╭', + '\uf5d7': '╮', + '\uf5d8': '╰', + '\uf5d9': '╯', + '\uf5e0': '┬', // ╮ on ─ + '\uf5e3': '┴', // ╯ on ─ + '\uf5df': '┤', // ╯ and ╮ + '\uf5e8': '┼', // ╯ and ╮ on ─ + } + for _, symbols := range branchDrawingCommitSymbols { + lookAlikes[[]rune(symbols.commit)[0]] = CommitSymbol + lookAlikes[[]rune(symbols.merge)[0]] = MergeSymbol + } + return lookAlikes +}() + +func TestRenderCommitGraphWithBranchDrawingSymbols(t *testing.T) { + tests := []struct { + name string + commitOpts []models.NewCommitOpts + selectedHash string + expectedOutput string + }{ + { + name: "branch forked off a merge commit", + commitOpts: []models.NewCommitOpts{ + {Hash: "1", Parents: []string{"2", "3"}}, + {Hash: "3", Parents: []string{"5"}}, + {Hash: "2", Parents: []string{"5", "4"}}, + {Hash: "4", Parents: []string{"5"}}, + {Hash: "5", Parents: []string{"6", "7"}}, + {Hash: "7", Parents: []string{"6"}}, + {Hash: "6", Parents: []string{"8"}}, + }, + expectedOutput: ` + 1 ◎─╮ + 3 │ ○ + 2 ◎─│─╮ + 4 │ │ ○ + 5 ◎─┼─╯ + 7 │ ○ + 6 ○─╯`, + }, + { + name: "branch forked off a merge commit, with the merge commit selected", + commitOpts: []models.NewCommitOpts{ + {Hash: "1", Parents: []string{"2", "3"}}, + {Hash: "3", Parents: []string{"5"}}, + {Hash: "2", Parents: []string{"5", "4"}}, + {Hash: "4", Parents: []string{"5"}}, + {Hash: "5", Parents: []string{"6", "7"}}, + {Hash: "7", Parents: []string{"6"}}, + {Hash: "6", Parents: []string{"8"}}, + }, + selectedHash: "5", + expectedOutput: ` + 1 ◎─╮ + 3 │ ○ + 2 ◎─│─╮ + 4 │ │ ○ + 5 ◎─┼─╯ + 7 │ ○ + 6 ○─╯`, + }, + { + name: "branch forked off a merge commit, with the merge commit above it selected", + commitOpts: []models.NewCommitOpts{ + {Hash: "1", Parents: []string{"2", "3"}}, + {Hash: "3", Parents: []string{"5"}}, + {Hash: "2", Parents: []string{"5", "4"}}, + {Hash: "4", Parents: []string{"5"}}, + {Hash: "5", Parents: []string{"6", "7"}}, + {Hash: "7", Parents: []string{"6"}}, + {Hash: "6", Parents: []string{"8"}}, + }, + selectedHash: "2", + expectedOutput: ` + 1 ◎─╮ + 3 │ ○ + 2 ◎───╮ + 4 │ │ ○ + 5 ◎─┼─╯ + 7 │ ○ + 6 ○─╯`, + }, + { + name: "branch forked off a merge commit, with no line passing through", + commitOpts: []models.NewCommitOpts{ + {Hash: "1", Parents: []string{"2", "3"}}, + {Hash: "3", Parents: []string{"2"}}, + {Hash: "2", Parents: []string{"4", "5"}}, + {Hash: "4", Parents: []string{"6", "7"}}, + {Hash: "6", Parents: []string{"8"}}, + }, + expectedOutput: ` + 1 ◎─╮ + 3 │ ○ + 2 ◎─┤ + 4 ◎─│─╮ + 6 ○ │ │`, + }, + { + name: "several lines ending in a commit", + commitOpts: []models.NewCommitOpts{ + {Hash: "1", Parents: []string{"2"}}, + {Hash: "2", Parents: []string{"3", "4"}}, + {Hash: "3", Parents: []string{"5", "4"}}, + {Hash: "5", Parents: []string{"7", "8"}}, + {Hash: "7", Parents: []string{"4", "A"}}, + {Hash: "4", Parents: []string{"B"}}, + {Hash: "B", Parents: []string{"C"}}, + }, + expectedOutput: ` + 1 ○ + 2 ◎─╮ + 3 ◎─│─╮ + 5 ◎─│─│─╮ + 7 ◎─│─│─│─╮ + 4 ○─┴─╯ │ │ + B ○ ╭───╯ │`, + }, + } + + oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions) + defer color.ForceSetColorLevel(oldColorLevel) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + hashPool := &utils.StringPool{} + + getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault } + commits := lo.Map(test.commitOpts, + func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) }) + lines := RenderCommitGraph(commits, hashPool.Add(test.selectedHash), getStyle, BranchDrawingSymbols) + + trimmedExpectedOutput := "" + for line := range strings.SplitSeq(strings.TrimPrefix(test.expectedOutput, "\n"), "\n") { + trimmedExpectedOutput += strings.TrimSpace(line) + "\n" + } + + output := "" + for i, line := range lines { + lookAlikes := strings.Map(func(r rune) rune { + return lo.ValueOr(branchDrawingLookAlikes, r, r) + }, utils.Decolorise(line)) + output += strings.TrimSpace(test.commitOpts[i].Hash+" "+lookAlikes) + "\n" + } + + assert.Equal(t, trimmedExpectedOutput, output) + }) + } +} + +func TestRenderPipeSetWithBranchDrawingSymbols(t *testing.T) { + cyan := style.FgCyan + red := style.FgRed + green := style.FgGreen + yellow := style.FgYellow + magenta := style.FgMagenta + nothing := style.Nothing + + hashPool := &utils.StringPool{} + pool := func(s string) *string { return hashPool.Add(s) } + + tests := []struct { + name string + pipes []Pipe + prevCommit *models.Commit + expectedStr string + expectedStyles []style.TextStyle + }{ + { + name: "commit with lines above and below", + pipes: []Pipe{ + {fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("b"), kind: TERMINATES, style: &cyan}, + {fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: pool("c"), kind: STARTS, style: &green}, + }, + prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}), + expectedStr: "\uf5fb", // ○ with lines up and down + expectedStyles: []style.TextStyle{green}, + }, + { + name: "first commit", + pipes: []Pipe{ + {fromPos: 0, toPos: 0, fromHash: &StartCommitHash, toHash: pool("b"), kind: TERMINATES, style: &cyan}, + {fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: pool("c"), kind: STARTS, style: &green}, + }, + expectedStr: "\uf5f7", // ○ with a line down + expectedStyles: []style.TextStyle{green}, + }, + { + name: "root commit", + pipes: []Pipe{ + {fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("b"), kind: TERMINATES, style: &cyan}, + {fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: &EmptyTreeCommitHash, kind: STARTS, style: &green}, + }, + prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}), + expectedStr: "\uf5f9", // ○ with a line up + expectedStyles: []style.TextStyle{green}, + }, + { + name: "selected root commit", + pipes: []Pipe{ + {fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("selected"), kind: TERMINATES, style: &cyan}, + {fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: &EmptyTreeCommitHash, kind: STARTS, style: &green}, + }, + prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}), + expectedStr: "\uf5f9", // ○ with a line up + expectedStyles: []style.TextStyle{highlightStyle}, + }, + { + name: "selected commit", + pipes: []Pipe{ + {fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("selected"), kind: TERMINATES, style: &cyan}, + {fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("c"), kind: STARTS, style: &green}, + }, + prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}), + expectedStr: "\uf5fb", // ○ with lines up and down + expectedStyles: []style.TextStyle{highlightStyle}, + }, + { + name: "commit whose previous commit is selected and is a merge commit", + pipes: []Pipe{ + {fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &red}, + {fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green}, + {fromPos: 1, toPos: 1, fromHash: pool("selected"), toHash: pool("b3"), kind: CONTINUES, style: &red}, + }, + prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "selected"}), + expectedStr: "\uf5fb \uf5d1", // ○ with lines up and down, │ + expectedStyles: []style.TextStyle{ + highlightStyle, nothing, highlightStyle, + }, + }, + { + name: "merge commit that a branch forks off", + pipes: []Pipe{ + {fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red}, + {fromPos: 1, toPos: 0, fromHash: pool("b1"), toHash: pool("a2"), kind: TERMINATES, style: &magenta}, + {fromPos: 2, toPos: 0, fromHash: pool("c1"), toHash: pool("a2"), kind: TERMINATES, style: &cyan}, + {fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green}, + {fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &green}, + }, + prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}), + expectedStr: "\uf604\uf5d0\uf5e8\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ╯ and ╮ on ─, ─, ╯ + expectedStyles: []style.TextStyle{ + green, green, magenta, cyan, cyan, + }, + }, + { + name: "merge commit that a branch forks off, with the branch's first commit selected", + pipes: []Pipe{ + {fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red}, + {fromPos: 1, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &magenta}, + {fromPos: 2, toPos: 0, fromHash: pool("c1"), toHash: pool("a2"), kind: TERMINATES, style: &cyan}, + {fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green}, + {fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &green}, + }, + prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}), + expectedStr: "\uf604\uf5d0\uf5e8\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ╯ and ╮ on ─, ─, ╯ + expectedStyles: []style.TextStyle{ + highlightStyle, highlightStyle, highlightStyle, cyan, cyan, + }, + }, + { + name: "line of the selected commit crossing another line", + pipes: []Pipe{ + {fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("selected"), kind: TERMINATES, style: &red}, + {fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("a3"), kind: STARTS, style: &yellow}, + {fromPos: 1, toPos: 1, fromHash: pool("b1"), toHash: pool("b2"), kind: CONTINUES, style: &magenta}, + {fromPos: 3, toPos: 0, fromHash: pool("e1"), toHash: pool("selected"), kind: TERMINATES, style: &green}, + {fromPos: 0, toPos: 2, fromHash: pool("selected"), toHash: pool("c3"), kind: STARTS, style: &yellow}, + }, + prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}), + expectedStr: "\uf604\uf5d0\uf5d0\uf5d0\uf5e0\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ─, ─, ╮ on ─, ─, ╯ + expectedStyles: []style.TextStyle{ + highlightStyle, highlightStyle, highlightStyle, highlightStyle, highlightStyle, green, green, + }, + }, + { + name: "line of the selected commit passing through a cell in which other lines bend", + pipes: []Pipe{ + {fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red}, + {fromPos: 1, toPos: 0, fromHash: pool("b1"), toHash: pool("a2"), kind: TERMINATES, style: &magenta}, + {fromPos: 2, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &cyan}, + {fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green}, + {fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &green}, + }, + prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}), + expectedStr: "\uf604\uf5d0\uf5e8\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ╯ and ╮ on ─, ─, ╯ + expectedStyles: []style.TextStyle{ + highlightStyle, highlightStyle, magenta, highlightStyle, highlightStyle, + }, + }, + } + + oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions) + defer color.ForceSetColorLevel(oldColorLevel) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actualStr := renderPipeSet(test.pipes, pool("selected"), test.prevCommit, BranchDrawingSymbols) + if len([]rune(test.expectedStr)) != len(test.expectedStyles) { + t.Fatalf("Error in test setup: you have %d characters in the expected output but have specified %d styles", len([]rune(test.expectedStr)), len(test.expectedStyles)) + } + expectedStr := "" + for i, char := range []rune(test.expectedStr) { + expectedStr += test.expectedStyles[i].Sprint(string(char)) + } + expectedStr += " " + + assert.Equal(t, expectedStr, actualStr) + }) + } +} + +func TestBranchDrawingSymbolsCoverAllCells(t *testing.T) { + hashPool := &utils.StringPool{} + commits := generateCommits(hashPool, 1000) + getStyle := func(commit *models.Commit) *style.TextStyle { return &style.FgDefault } + isBoxDrawingChar := func(r rune) bool { return r >= '\u2500' && r <= '\u257f' } + + for _, selectedHash := range []string{"none", commits[10].Hash(), commits[500].Hash()} { + lines := RenderCommitGraph(commits, hashPool.Add(selectedHash), getStyle, BranchDrawingSymbols) + for i, line := range lines { + line = utils.Decolorise(line) + assert.False(t, strings.ContainsFunc(line, isBoxDrawingChar), + "row %d falls back to box drawing characters: %q", i, line) + } + } +} + func TestRenderPipeSet(t *testing.T) { cyan := style.FgCyan red := style.FgRed @@ -490,7 +829,7 @@ func TestRenderPipeSet(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - actualStr := renderPipeSet(test.pipes, pool("selected"), test.prevCommit) + actualStr := renderPipeSet(test.pipes, pool("selected"), test.prevCommit, BoxDrawingSymbols) t.Log("actual cells:") t.Log(actualStr) expectedStr := "" @@ -568,8 +907,8 @@ func TestGetNextPipes(t *testing.T) { getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault } pipes := getNextPipes(test.prevPipes, test.commit, getStyle) // rendering cells so that it's easier to see what went wrong - actualStr := renderPipeSet(pipes, pool("selected"), nil) - expectedStr := renderPipeSet(test.expected, pool("selected"), nil) + actualStr := renderPipeSet(pipes, pool("selected"), nil, BoxDrawingSymbols) + expectedStr := renderPipeSet(test.expected, pool("selected"), nil, BoxDrawingSymbols) t.Log("expected cells:") t.Log(expectedStr) t.Log("actual cells:") @@ -590,7 +929,7 @@ func BenchmarkRenderCommitGraph(b *testing.B) { } b.ResetTimer() for b.Loop() { - RenderCommitGraph(commits, hashPool.Add("selected"), getStyle) + RenderCommitGraph(commits, hashPool.Add("selected"), getStyle, BoxDrawingSymbols) } } diff --git a/schema-master/config.json b/schema-master/config.json index 02eb3b6be..d93f1450a 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -1221,6 +1221,15 @@ "description": "If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty.", "default": true }, + "commitGraphStyle": { + "type": "string", + "enum": [ + "classic", + "detailed" + ], + "description": "How the commit graph is drawn.\nOne of: 'classic' (default) | 'detailed'\n'detailed' connects the lines to the commit circles, and shows exactly where branches fork off and merge. It draws the graph with the git branch drawing symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself: kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with GPU acceleration. Other terminals need a font that contains them, such as https://github.com/rbong/flog-symbols.", + "default": "classic" + }, "commitAuthorShortLength": { "type": "integer", "description": "Length of author name in (non-expanded) commits view. 2 means show initials only.", From 4780c8bb72df36b62f224aea2ffa70acfcf3563f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 27 Sep 2026 19:03:48 +0200 Subject: [PATCH 6/6] Use the detailed commit graph automatically in terminals that draw it The detailed commit graph is only drawn for users who set gui.commitGraphStyle to 'detailed'. Many users of terminals that can draw it will never find out about it. A terminal can't be asked whether it draws a given character, but it does tell us its name and version when tcell asks for them with XTVERSION at startup. Add an 'auto' value and make it the default. It uses the detailed graph in kitty from 0.36.2 and in Ghostty from 1.0.0 on. These are the first versions that draw all of the symbols. Everywhere else it stays with the classic graph. This includes tmux, because tmux answers XTVERSION itself. So far, WezTerm draws the symbols only in its nightly builds, so it isn't detected until a release has them. VS Code isn't detected either, because it only draws the symbols with GPU acceleration. The expected output of the integration tests has the classic graph, so pin it in their config. Otherwise they would fail when run with a visible UI in one of these terminals. Log the terminal's name and version at startup, to make it possible to find out why 'auto' picked what it did. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs-master/Config.md | 6 ++-- pkg/config/user_config.go | 7 ++-- pkg/config/user_config_validation.go | 2 +- pkg/gocui/gui.go | 6 ++++ pkg/gui/context/local_commits_context.go | 7 +++- pkg/gui/gui.go | 2 ++ pkg/gui/presentation/graph/branch_drawing.go | 35 +++++++++++++++++++ .../presentation/graph/branch_drawing_test.go | 33 +++++++++++++++++ schema-master/config.json | 5 +-- test/default_test_config/config.yml | 3 ++ 10 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 pkg/gui/presentation/graph/branch_drawing_test.go diff --git a/docs-master/Config.md b/docs-master/Config.md index 16b6e2b5a..32fbd0a4e 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -302,14 +302,16 @@ gui: showFileIcons: true # How the commit graph is drawn. - # One of: 'classic' (default) | 'detailed' + # One of: 'auto' (default) | 'classic' | 'detailed' # 'detailed' connects the lines to the commit circles, and shows exactly where # branches fork off and merge. It draws the graph with the git branch drawing # symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself: # kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with # GPU acceleration. Other terminals need a font that contains them, such as # https://github.com/rbong/flog-symbols. - commitGraphStyle: classic + # 'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws + # these symbols (kitty and Ghostty), and 'classic' otherwise. + commitGraphStyle: auto # Length of author name in (non-expanded) commits view. 2 means show initials # only. diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index b0279d011..c5e05ed94 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -180,9 +180,10 @@ type GuiConfig struct { // If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty. ShowFileIcons bool `yaml:"showFileIcons"` // How the commit graph is drawn. - // One of: 'classic' (default) | 'detailed' + // One of: 'auto' (default) | 'classic' | 'detailed' // 'detailed' connects the lines to the commit circles, and shows exactly where branches fork off and merge. It draws the graph with the git branch drawing symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself: kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with GPU acceleration. Other terminals need a font that contains them, such as https://github.com/rbong/flog-symbols. - CommitGraphStyle string `yaml:"commitGraphStyle" jsonschema:"enum=classic,enum=detailed"` + // 'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws these symbols (kitty and Ghostty), and 'classic' otherwise. + CommitGraphStyle string `yaml:"commitGraphStyle" jsonschema:"enum=auto,enum=classic,enum=detailed"` // Length of author name in (non-expanded) commits view. 2 means show initials only. CommitAuthorShortLength int `yaml:"commitAuthorShortLength"` // Length of author name in expanded commits view. 2 means show initials only. @@ -926,7 +927,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { ShowIcons: false, NerdFontsVersion: "", ShowFileIcons: true, - CommitGraphStyle: "classic", + CommitGraphStyle: "auto", CommitAuthorShortLength: 2, CommitAuthorLongLength: 17, CommitHashLength: 8, diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index d733a9577..cf5c85966 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -31,7 +31,7 @@ func (config *UserConfig) Validate() error { return err } if err := validateEnum("gui.commitGraphStyle", config.Gui.CommitGraphStyle, - []string{"classic", "detailed"}); err != nil { + []string{"auto", "classic", "detailed"}); err != nil { return err } if err := validateEnum("git.autoForwardBranches", config.Git.AutoForwardBranches, diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 87e7fb031..12968fa95 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -733,6 +733,12 @@ func (g *Gui) DetectedColorScheme() DetectedColorScheme { return g.colorScheme } +// Terminal returns the name and version that the terminal reported at +// startup. Both are empty if it didn't report them. +func (g *Gui) Terminal() (string, string) { + return g.screen.Terminal() +} + // SetColorSchemeChangeHandler sets a function to call on the UI thread whenever // the terminal's colors change after startup. func (g *Gui) SetColorSchemeChangeHandler(handler func(DetectedColorScheme) error) { diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 4259da43d..6f3049f24 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -352,8 +352,13 @@ func shouldShowGraph(c *ContextCommon) bool { } func commitGraphSymbolSet(c *ContextCommon) graph.SymbolSet { - if c.UserConfig().Gui.CommitGraphStyle == "detailed" { + switch c.UserConfig().Gui.CommitGraphStyle { + case "detailed": return graph.BranchDrawingSymbols + case "auto": + if graph.TerminalDrawsBranchDrawingSymbols(c.GocuiGui().Terminal()) { + return graph.BranchDrawingSymbols + } } return graph.BoxDrawingSymbols } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 8d66e99b1..3f6233fa7 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -934,6 +934,8 @@ func (gui *Gui) Run(startArgs appTypes.StartArgs) error { g.ErrorHandler = gui.PopupHandler.ErrorHandler + terminalName, terminalVersion := g.Terminal() + gui.c.Log.Infof("Terminal: %s %s", terminalName, terminalVersion) gui.c.Log.Infof("Terminal color scheme: %s", g.DetectedColorScheme()) g.SetColorSchemeChangeHandler(func(colorScheme gocui.DetectedColorScheme) error { gui.c.Log.Infof("Terminal color scheme changed: %s", colorScheme) diff --git a/pkg/gui/presentation/graph/branch_drawing.go b/pkg/gui/presentation/graph/branch_drawing.go index 2e3537498..60271ad76 100644 --- a/pkg/gui/presentation/graph/branch_drawing.go +++ b/pkg/gui/presentation/graph/branch_drawing.go @@ -1,5 +1,11 @@ package graph +import ( + "slices" + "strconv" + "strings" +) + // The branch drawing symbols are characters in the Unicode Private Use Area // (U+F5D0 to U+F60D) for drawing git graphs, introduced by kitty in // https://github.com/kovidgoyal/kitty/pull/7681. Unlike the box drawing @@ -95,3 +101,32 @@ func (cell *Cell) branchDrawingChars() (string, string) { return first, second } } + +// The terminals that draw the branch drawing symbols themselves, with the +// first version that draws all of them +var terminalsWithBranchDrawingSymbols = map[string][]int{ + "kitty": {0, 36, 2}, + "ghostty": {1, 0, 0}, +} + +// TerminalDrawsBranchDrawingSymbols says whether a terminal is known to draw +// the branch drawing symbols itself, given the name and version it reports +func TerminalDrawsBranchDrawingSymbols(name, version string) bool { + minVersion, ok := terminalsWithBranchDrawingSymbols[strings.ToLower(name)] + return ok && slices.Compare(versionNumbers(version), minVersion) >= 0 +} + +// The numbers at the start of the dot-separated parts of a version, e.g. +// [1 3 0] for "1.3.0-dev+abc" +func versionNumbers(version string) []int { + var numbers []int + for part := range strings.SplitSeq(version, ".") { + digits := part[:len(part)-len(strings.TrimLeft(part, "0123456789"))] + number, err := strconv.Atoi(digits) + if err != nil { + break + } + numbers = append(numbers, number) + } + return numbers +} diff --git a/pkg/gui/presentation/graph/branch_drawing_test.go b/pkg/gui/presentation/graph/branch_drawing_test.go new file mode 100644 index 000000000..651b31d23 --- /dev/null +++ b/pkg/gui/presentation/graph/branch_drawing_test.go @@ -0,0 +1,33 @@ +package graph + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTerminalDrawsBranchDrawingSymbols(t *testing.T) { + tests := []struct { + name string + version string + expected bool + }{ + {name: "kitty", version: "0.36.1", expected: false}, + {name: "kitty", version: "0.36.2", expected: true}, + {name: "kitty", version: "0.44.0", expected: true}, + {name: "ghostty", version: "0.9.0", expected: false}, + {name: "ghostty", version: "1.0.0", expected: true}, + {name: "ghostty", version: "1.3.0-main+0123abcd", expected: true}, + {name: "WezTerm", version: "20250601-102030-89abcdef", expected: false}, + {name: "tmux", version: "3.5a", expected: false}, + {name: "iTerm2", version: "3.6.4", expected: false}, + {name: "kitty", version: "", expected: false}, + {name: "", version: "", expected: false}, + } + + for _, test := range tests { + t.Run(test.name+" "+test.version, func(t *testing.T) { + assert.Equal(t, test.expected, TerminalDrawsBranchDrawingSymbols(test.name, test.version)) + }) + } +} diff --git a/schema-master/config.json b/schema-master/config.json index d93f1450a..88fb11f79 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -1224,11 +1224,12 @@ "commitGraphStyle": { "type": "string", "enum": [ + "auto", "classic", "detailed" ], - "description": "How the commit graph is drawn.\nOne of: 'classic' (default) | 'detailed'\n'detailed' connects the lines to the commit circles, and shows exactly where branches fork off and merge. It draws the graph with the git branch drawing symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself: kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with GPU acceleration. Other terminals need a font that contains them, such as https://github.com/rbong/flog-symbols.", - "default": "classic" + "description": "How the commit graph is drawn.\nOne of: 'auto' (default) | 'classic' | 'detailed'\n'detailed' connects the lines to the commit circles, and shows exactly where branches fork off and merge. It draws the graph with the git branch drawing symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself: kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with GPU acceleration. Other terminals need a font that contains them, such as https://github.com/rbong/flog-symbols.\n'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws these symbols (kitty and Ghostty), and 'classic' otherwise.", + "default": "auto" }, "commitAuthorShortLength": { "type": "integer", diff --git a/test/default_test_config/config.yml b/test/default_test_config/config.yml index 198fcbdd1..e5a893a13 100644 --- a/test/default_test_config/config.yml +++ b/test/default_test_config/config.yml @@ -12,6 +12,9 @@ gui: - black # Not important in tests but it creates clutter in demos showRandomTip: false + # The expected output of the tests draws the commit graph with box drawing + # characters, whichever terminal a test runs in + commitGraphStyle: classic animateExplosion: false # takes too long git: # We don't want to run any periodic background git commands because it'll introduce race conditions and flakiness.