Add an on-demand inclusion gutter to gocui views

A reserved left-hand column that draws a per-line marker glyph and shifts
the content right past it, for the custom-patch inclusion overlay (which
needs to show which change lines are in the patch over arbitrary pager
output, where it can't touch the rendered bytes).

It's pure draw-time decoration: the content buffer, and so the diff-line
metadata, click resolution and wrapping inputs, are untouched. The marker
is drawn on a line's first wrapped segment only, and the gutter narrows
the content wrap width while shown. Off by default; SetInclusionGutter
turns it on per view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-06-19 09:17:02 +02:00
parent 01882cf2fd
commit 12523c8a20
2 changed files with 126 additions and 2 deletions

View file

@ -158,6 +158,17 @@ type View struct {
HighlightInset int
// InclusionGutterMarker is the glyph drawn in the on-demand inclusion gutter
// (see SetInclusionGutter) on marked lines; InclusionGutterMarkerColor is its
// color. Both are set once at view creation.
InclusionGutterMarker string
InclusionGutterMarkerColor Attribute
// showInclusionGutter reserves the gutter column at the left of every line, and
// inclusionGutterMarks (indexed by buffer line) selects which lines get the
// marker. Set together via SetInclusionGutter.
showInclusionGutter bool
inclusionGutterMarks []bool
// If Frame is true, a border will be drawn around the view.
Frame bool
@ -654,6 +665,31 @@ func (v *View) Name() string {
return v.name
}
// SetInclusionGutter configures the on-demand inclusion marker gutter: a fixed-width
// column reserved at the left of every line, used by the custom-patch inclusion
// overlay to show which change lines are in the patch. When show is true the gutter
// is reserved and the content is shifted right to make room; marks, indexed by
// buffer line, selects which lines get InclusionGutterMarker drawn (on every wrapped
// segment of the line). It is pure draw-time decoration — the content buffer (and so the
// diff-line metadata, click resolution, etc.) is untouched. Toggling show changes the
// wrap width, so the view is re-wrapped.
func (v *View) SetInclusionGutter(show bool, marks []bool) {
if v.showInclusionGutter != show {
v.showInclusionGutter = show
v.tainted = true
}
v.inclusionGutterMarks = marks
}
// inclusionGutterWidth is the number of columns the inclusion gutter occupies when
// shown (the marker glyph plus a one-cell separator), or 0 when hidden.
func (v *View) inclusionGutterWidth() int {
if !v.showInclusionGutter {
return 0
}
return uniseg.StringWidth(v.InclusionGutterMarker) + 1
}
// setCharacter sets a character (grapheme cluster) at the given point relative to the view. It applies
// the specified colors, taking into account if the cell must be highlighted. Also, it checks if the
// position is valid.
@ -1500,6 +1536,10 @@ func (v *View) draw() {
emptyCell := cell{chr: " ", width: 1, fgColor: ColorDefault, bgColor: ColorDefault}
// The inclusion gutter (when shown) reserves the leftmost columns; content is
// drawn shifted right past it. See SetInclusionGutter.
gutterWidth := v.inclusionGutterWidth()
for y, vline := range v.viewLines[start:] {
if y >= maxY {
break
@ -1514,10 +1554,21 @@ func (v *View) draw() {
trailingCell.bgColor = attrs.bg
}
// Paint the inclusion gutter: blanks across its width, with the marker on
// every wrapped segment of a marked buffer line.
if gutterWidth > 0 {
for gx := range gutterWidth {
v.setCharacter(gx, y, " ", v.FgColor, v.BgColor)
}
if vline.linesY < len(v.inclusionGutterMarks) && v.inclusionGutterMarks[vline.linesY] {
v.setCharacter(0, y, v.InclusionGutterMarker, v.InclusionGutterMarkerColor, v.BgColor)
}
}
// x tracks the current x position in the view, and cellIdx tracks the
// index of the cell. If we print a double-sized rune, we increment cellIdx
// by one but x by two.
x := -v.ox
x := gutterWidth - v.ox
cellIdx := 0
var c cell
@ -1566,7 +1617,7 @@ func (v *View) refreshViewLinesIfNeeded() {
return
}
maxX := v.InnerWidth()
maxX := v.InnerWidth() - v.inclusionGutterWidth()
wrap := 0
if v.Wrap {
wrap = maxX

View file

@ -884,3 +884,76 @@ func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) {
"trailing cell at (%d, 2) should have green bg", x)
}
}
// TestInclusionGutter verifies the on-demand inclusion gutter reserves a
// left-hand column, draws the marker glyph on marked lines only, and shifts the
// content right past it.
func TestInclusionGutter(t *testing.T) {
WithSimulationScreen(t, 14, 6)
// InnerWidth=10; the frame inset of 1 places view x=0 at screen x=1.
v := NewView("name", 0, 0, 11, 5, OutputNormal)
v.Wrap = true
v.InclusionGutterMarker = "✓"
v.writeString("aaa\nbbb\nccc\n")
// The gutter is 2 columns wide (marker + separator); mark the middle line.
v.SetInclusionGutter(true, []bool{false, true, false})
v.draw()
// The marker appears at the gutter's first column (view x=0 → screen x=1) on
// the marked line only.
chr, _, _ := Screen.Get(1, 1)
assert.Equal(t, " ", chr, "unmarked line has no gutter marker")
chr, _, _ = Screen.Get(1, 2)
assert.Equal(t, "✓", chr, "marked line shows the gutter marker")
chr, _, _ = Screen.Get(1, 3)
assert.Equal(t, " ", chr, "unmarked line has no gutter marker")
// The content is shifted right past the 2-column gutter (view x=2 → screen x=3).
chr, _, _ = Screen.Get(3, 1)
assert.Equal(t, "a", chr, "content is shifted past the gutter")
chr, _, _ = Screen.Get(3, 2)
assert.Equal(t, "b", chr)
chr, _, _ = Screen.Get(3, 3)
assert.Equal(t, "c", chr)
// Hiding the gutter again returns the content flush left (view x=0 → screen x=1).
v.SetInclusionGutter(false, nil)
v.draw()
chr, _, _ = Screen.Get(1, 1)
assert.Equal(t, "a", chr, "content is flush left with no gutter")
}
// TestInclusionGutterMarkerOnEverySegment verifies that a marked buffer line that
// wraps shows the marker on every wrapped segment, and that the gutter narrows the
// content wrap width.
func TestInclusionGutterMarkerOnEverySegment(t *testing.T) {
WithSimulationScreen(t, 14, 6)
v := NewView("name", 0, 0, 11, 5, OutputNormal) // InnerWidth=10
v.Wrap = true
v.InclusionGutterMarker = "✓"
// 10 cells; with a 2-column gutter the content wrap width is 8, so this wraps
// to "01234567" / "89".
v.writeString("0123456789\n")
v.SetInclusionGutter(true, []bool{true})
v.draw()
// First segment: marker present, content starts at screen x=3 and the eighth
// content cell ("7") sits at the right edge (screen x=10).
chr, _, _ := Screen.Get(1, 1)
assert.Equal(t, "✓", chr)
chr, _, _ = Screen.Get(3, 1)
assert.Equal(t, "0", chr)
chr, _, _ = Screen.Get(10, 1)
assert.Equal(t, "7", chr)
// Continuation segment: marker too, content resumes at screen x=3.
chr, _, _ = Screen.Get(1, 2)
assert.Equal(t, "✓", chr, "continuation segment also shows the marker")
chr, _, _ = Screen.Get(3, 2)
assert.Equal(t, "8", chr)
}