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 }