From 12523c8a2099438da5863c8206bcc16eb01e96fc Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 19 Jun 2026 09:17:02 +0200 Subject: [PATCH] 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) --- pkg/gocui/view.go | 55 +++++++++++++++++++++++++++++-- pkg/gocui/view_test.go | 73 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index a23338c4c..64d446430 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -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 diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index 1b1b4b88b..c4618fe8c 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -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) +}