From 394d91d1476b1ed54e852592fe01fe61a3986442 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 19 Aug 2026 16:48:08 +0200 Subject: [PATCH] Draw a gutter of inclusion marks over a view's content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lines of a commit's diff that are in the custom patch being built have to be shown as such over whatever a diff renderer made of that diff, whose bytes we can't touch — and mustn't, since everything we know about a row is keyed by where it sits in the content. So the marks are a decoration drawn over the content instead: a column reserved at the left of every line, blank but for the lines that are in, with the content shifted along past it. Nothing about the content changes but the width it has to wrap in. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gocui/view.go | 64 ++++++++++++++++++++++++++++-- pkg/gocui/view_test.go | 88 ++++++++++++++++++++++++++++++++++++++++++ pkg/gui/views.go | 7 ++++ 3 files changed, 155 insertions(+), 4 deletions(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 383664cc2..f48d46e8b 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -170,6 +170,17 @@ type View struct { // For content that conveys meaning by color of its own. SelectedLineColorWidth int + // InclusionGutterMarker is the glyph the inclusion gutter draws on a marked line + // (see SetInclusionGutter), and InclusionGutterMarkerColor its color. Both are + // set once, when the view is created. + InclusionGutterMarker string + InclusionGutterMarkerColor Attribute + // showInclusionGutter reserves the gutter's columns at the left of every line, + // and inclusionGutterMarks, indexed by line of the content, says 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 @@ -860,6 +871,38 @@ func (v *View) Name() string { return v.name } +// SetInclusionGutter shows or hides a column reserved at the left of every line, in +// which marks — indexed by line of the content — say which lines get +// InclusionGutterMarker drawn, on every segment of a line the view wrapped. The +// content is drawn shifted past it. +// +// It is drawn over the content rather than written into it, so the content itself — +// and with it what each line of the view means, where a click lands, and how the +// lines wrap — is untouched but for the width the gutter takes. +func (v *View) SetInclusionGutter(show bool, marks []bool) { + v.writeMutex.Lock() + changed := v.showInclusionGutter != show + v.showInclusionGutter = show + v.inclusionGutterMarks = marks + v.writeMutex.Unlock() + + if changed { + // The gutter takes its columns from the content, so what is left of it wraps + // differently, and everything pointing into it has to come along. + v.RewrapContent() + } +} + +// inclusionGutterWidth is how many columns the inclusion gutter takes while it is +// shown — the marker plus a column of space before the content — and 0 while it is +// not. Only call this with a lock on writeMutex. +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. @@ -1764,6 +1807,8 @@ func (v *View) draw(isWindowFocused bool) { emptyCell := cell{chr: " ", width: 1, fgColor: ColorDefault, bgColor: ColorDefault} + gutterWidth := v.inclusionGutterWidth() + for y, vline := range v.viewLines[start:] { if y >= maxY { break @@ -1778,10 +1823,20 @@ func (v *View) draw(isWindowFocused bool) { trailingCell.bgColor = attrs.bg } + // The inclusion gutter is blank but for the marker on a marked line, and the + // content begins after it. The blanks go through setCharacter like everything + // else, so that a selection reaching the left edge covers the gutter too. + for gx := range gutterWidth { + v.setCharacter(gx, y, " ", v.FgColor, v.BgColor, isWindowFocused) + } + if gutterWidth > 0 && vline.linesY < len(v.inclusionGutterMarks) && v.inclusionGutterMarks[vline.linesY] { + v.setCharacter(0, y, v.InclusionGutterMarker, v.InclusionGutterMarkerColor, v.BgColor, isWindowFocused) + } + // 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 @@ -1795,7 +1850,7 @@ func (v *View) draw(isWindowFocused bool) { // no more characters to write so we're only going to be printing empty cells // past this point - x = 0 + x = gutterWidth } // if we're out of cells to write, we'll just print empty cells. @@ -1830,10 +1885,11 @@ func (v *View) refreshViewLinesIfNeeded() { return } - maxX := v.InnerWidth() wrap := 0 if v.Wrap { - wrap = maxX + // The inclusion gutter, while it is shown, takes its columns out of the width + // the content has to wrap in. + wrap = max(0, v.InnerWidth()-v.inclusionGutterWidth()) } lineIdx := 0 diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index bfa79f5a5..0ce688aa6 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -1139,3 +1139,91 @@ func TestResizingAWrappingViewKeepsItsPlaceInTheContent(t *testing.T) { assert.Equal(t, []string{"another wrapping line"}, v.SelectedLines()) } + +// The inclusion gutter reserves columns at the left of every line, draws its marker +// on the marked lines only, and moves the content out of the way. +func TestInclusionGutter(t *testing.T) { + WithSimulationScreen(t, 14, 6) + + // InnerWidth 10; the frame puts 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 two columns wide — the marker and a space; mark the middle line. + v.SetInclusionGutter(true, []bool{false, true, false}) + v.draw(true) + + chr, _, _ := Screen.Get(1, 1) + assert.Equal(t, " ", chr, "an unmarked line has no marker") + chr, _, _ = Screen.Get(1, 2) + assert.Equal(t, "✓", chr, "a marked line has one") + chr, _, _ = Screen.Get(1, 3) + assert.Equal(t, " ", chr, "an unmarked line has no marker") + + // The content begins after the gutter: view x=2, i.e. screen x=3. + chr, _, _ = Screen.Get(3, 1) + assert.Equal(t, "a", chr) + chr, _, _ = Screen.Get(3, 2) + assert.Equal(t, "b", chr) + chr, _, _ = Screen.Get(3, 3) + assert.Equal(t, "c", chr) + + // Hiding the gutter puts the content back at the left edge. + v.SetInclusionGutter(false, nil) + v.draw(true) + chr, _, _ = Screen.Get(1, 1) + assert.Equal(t, "a", chr) +} + +// A marked line the view wraps is marked on every segment it is drawn as, so that +// the mark doesn't look like it belongs to the first part of the line alone. The +// gutter takes its columns out of the width the content wraps in. +func TestInclusionGutterMarksEverySegmentOfAWrappedLine(t *testing.T) { + WithSimulationScreen(t, 14, 6) + + v := NewView("name", 0, 0, 11, 5, OutputNormal) // InnerWidth 10 + v.Wrap = true + v.InclusionGutterMarker = "✓" + + // Ten cells, wrapping at eight once the two-column gutter is shown. + v.writeString("0123456789\n") + v.SetInclusionGutter(true, []bool{true}) + v.draw(true) + + 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, "the content wraps at the width the gutter leaves it") + + chr, _, _ = Screen.Get(1, 2) + assert.Equal(t, "✓", chr, "the line's second segment is marked too") + chr, _, _ = Screen.Get(3, 2) + assert.Equal(t, "8", chr) +} + +// Showing the gutter narrows the content, so the content wraps again — and the +// positions into it, which count the segments lines are drawn as, have to come +// along, as they do for any other change of width. +func TestShowingTheInclusionGutterKeepsThePlaceInTheContent(t *testing.T) { + v := NewView("name", 0, 0, 11, 10, OutputNormal) // InnerWidth 10 + v.Wrap = true + v.Highlight = true + v.InclusionGutterMarker = "✓" + + v.writeString("one\ntwo\nthree\nsomethingfartoolong\n") + assert.Equal(t, 5, v.ViewLinesHeight()) + + v.FocusPoint(0, 2, false) + assert.Equal(t, "three", v.SelectedLine()) + + // With eight columns left for the content, the last line wraps into three + // segments rather than two. + v.SetInclusionGutter(true, []bool{false, false, true, false}) + assert.Equal(t, 6, v.ViewLinesHeight()) + assert.Equal(t, "three", v.SelectedLine()) +} diff --git a/pkg/gui/views.go b/pkg/gui/views.go index fbe4eb444..0c363b7c6 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -177,6 +177,13 @@ func (gui *Gui) createAllViews() error { gui.Views.Main.SelectedLineColorWidth = 2 gui.Views.Secondary.SelectedLineColorWidth = 2 + // A tick, for the lines of a commit's diff that are in the custom patch being + // built. A plus would collide with a diff's own plus column. + gui.Views.Main.InclusionGutterMarker = "✓" + gui.Views.Main.InclusionGutterMarkerColor = gocui.ColorGreen + gui.Views.Secondary.InclusionGutterMarker = "✓" + gui.Views.Secondary.InclusionGutterMarkerColor = gocui.ColorGreen + return nil }