Files
lazygit/pkg/gui/presentation/graph/cell.go
T
Stefan HallerandClaude Opus 5.5 da5b9b54eb 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) <noreply@anthropic.com>
2026-10-04 18:47:01 +02:00

225 lines
5.1 KiB
Go

package graph
import (
"io"
"sync"
"github.com/gookit/color"
"github.com/jesseduffield/lazygit/pkg/gui/style"
)
const (
MergeSymbol = '◎'
CommitSymbol = '○'
)
// The characters that the commit graph is drawn with
type SymbolSet uint8
const (
BoxDrawingSymbols SymbolSet = iota
BranchDrawingSymbols
)
type cellType int
const (
CONNECTION cellType = iota
COMMIT
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 {
// 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
// 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, 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 {
rightStyle = cell.style
} else {
rightStyle = cell.rightStyle
}
// just doing this for the sake of easy testing, so that we don't need to
// assert on the style of a space given a space has no styling (assuming we
// stick to only using foreground styles)
var styledSecondChar string
if second == " " {
styledSecondChar = " "
} else {
styledSecondChar = cachedSprint(*rightStyle, second)
}
_, _ = 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
}
var (
rgbCache = make(map[rgbCacheKey]string)
rgbCacheMutex sync.RWMutex
)
func cachedSprint(style style.TextStyle, str string) string {
switch v := style.Style.(type) {
case *color.RGBStyle:
rgbCacheMutex.RLock()
key := rgbCacheKey{v, str}
value, ok := rgbCache[key]
rgbCacheMutex.RUnlock()
if ok {
return value
}
value = style.Sprint(str)
rgbCacheMutex.Lock()
rgbCache[key] = value
rgbCacheMutex.Unlock()
return value
case color.Basic:
return style.Sprint(str)
case color.Style:
value := style.Sprint(str)
return value
}
return style.Sprint(str)
}
func (cell *Cell) reset() {
cell.up = noLine
cell.down = noLine
cell.left = false
cell.right = false
cell.horizontal = false
cell.horizontalOnTop = false
}
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, 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 == noLine && cell.down == noLine {
// vertical trumps left
cell.style = style
}
return cell
}
func (cell *Cell) setRight(style *style.TextStyle, override bool) *Cell {
cell.right = true
if cell.rightStyle == nil || override {
cell.rightStyle = style
}
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
}
func (cell *Cell) setType(cellType cellType) *Cell {
cell.cellType = cellType
return cell
}
func getBoxDrawingChars(up, down, left, right bool) (string, string) {
if up && down && left && right {
return "│", "─"
} else if up && down && left && !right {
return "│", " "
} else if up && down && !left && right {
return "│", "─"
} else if up && down && !left && !right {
return "│", " "
} else if up && !down && left && right {
return "┴", "─"
} else if up && !down && left && !right {
return "╯", " "
} else if up && !down && !left && right {
return "╰", "─"
} else if up && !down && !left && !right {
return "╵", " "
} else if !up && down && left && right {
return "┬", "─"
} else if !up && down && left && !right {
return "╮", " "
} else if !up && down && !left && right {
return "╭", "─"
} else if !up && down && !left && !right {
return "╷", " "
} else if !up && !down && left && right {
return "─", "─"
} else if !up && !down && left && !right {
return "─", " "
} else if !up && !down && !left && right {
return "╶", "─"
} else if !up && !down && !left && !right {
return " ", " "
}
panic("should not be possible")
}