diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 7b691f6d7..ad8ba1e41 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -1345,7 +1345,7 @@ func (g *Gui) onKey(ev *GocuiEvent) error { } visibleLineWidth := 0 - for _, c := range v.lines[newY] { + for _, c := range v.lines[newY].cells { visibleLineWidth += c.width } if visibleLineWidth < newX { diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 166cb0e2c..77492b9b1 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -28,12 +28,12 @@ const ( // position. type View struct { name string - x0, y0, x1, y1 int // left top right bottom - ox, oy int // view offsets - cx, cy int // cursor position - rx, ry int // Read() offsets - wx, wy int // Write() offsets - lines [][]cell // All the data + x0, y0, x1, y1 int // left top right bottom + ox, oy int // view offsets + cx, cy int // cursor position + rx, ry int // Read() offsets + wx, wy int // Write() offsets + lines []lineType // All the data outMode OutputMode // The y position of the first line of a range selection. // This is not relative to the view's origin: it is relative to the first line @@ -444,6 +444,28 @@ type SearchPosition struct { type viewLine struct { linesX, linesY int // coordinates relative to v.lines line []cell + + // Colors used to extend the bg past this wrapped segment's content. + // Derived at wrap time from the source line — see refreshViewLinesIfNeeded + // for the per-segment rule. + trailingFillAttributes *trailingFillAttributes +} + +// lineType is one of v.lines: the cells of a source line, plus optional +// trailingFillAttributes recording the colors used to extend the bg +// past the line's content when the writer emitted '\x1b[K'. +type lineType struct { + cells cells + trailingFillAttributes *trailingFillAttributes +} + +// trailingFillAttributes describes the fg/bg colors that draw() should +// use for cells past the end of a wrapped segment's content. On a source +// line this records what the writer asked for via '\x1b[K' (and so opts +// the line in to trailing fill at all); the per-segment values on each +// viewLine are derived from it at wrap time. +type trailingFillAttributes struct { + fg, bg Attribute } type cell struct { @@ -453,7 +475,7 @@ type cell struct { hyperlink string } -type lineType []cell +type cells []cell func characterEquals(chr []byte, b byte) bool { return len(chr) == 1 && chr[0] == b @@ -464,7 +486,7 @@ func isCRLF(chr []byte) bool { } // String returns a string from a given cell slice. -func (l lineType) String() string { +func (l cells) String() string { var str strings.Builder for _, c := range l { str.WriteString(c.chr) @@ -738,20 +760,20 @@ func (v *View) makeWriteable(x, y int) { } v.lines = v.lines[:newLen] } else { - v.lines = append(v.lines, nil) + v.lines = append(v.lines, lineType{}) } } // cell `x` need not be index-able (that's why `<`) // append should be used by `lines[y]` user if he wants to write beyond `x` - for len(v.lines[y]) < x { - if cap(v.lines[y]) > len(v.lines[y]) { - newLen := cap(v.lines[y]) + for len(v.lines[y].cells) < x { + if cap(v.lines[y].cells) > len(v.lines[y].cells) { + newLen := cap(v.lines[y].cells) if newLen > x { newLen = x } - v.lines[y] = v.lines[y][:newLen] + v.lines[y].cells = v.lines[y].cells[:newLen] } else { - v.lines[y] = append(v.lines[y], cell{}) + v.lines[y].cells = append(v.lines[y].cells, cell{}) } } } @@ -761,7 +783,7 @@ func (v *View) makeWriteable(x, y int) { func (v *View) writeCells(cells []cell) { var newLen int // use maximum len available - line := v.lines[v.wy][:cap(v.lines[v.wy])] + line := v.lines[v.wy].cells[:cap(v.lines[v.wy].cells)] maxCopy := len(line) - v.wx if maxCopy < len(cells) { copy(line[v.wx:], cells[:maxCopy]) @@ -770,11 +792,11 @@ func (v *View) writeCells(cells []cell) { } else { // maxCopy >= len(cells) copy(line[v.wx:], cells) newLen = v.wx + len(cells) - if newLen < len(v.lines[v.wy]) { - newLen = len(v.lines[v.wy]) + if newLen < len(v.lines[v.wy].cells) { + newLen = len(v.lines[v.wy].cells) } } - v.lines[v.wy] = line[:newLen] + v.lines[v.wy].cells = line[:newLen] v.wx += len(cells) } @@ -800,21 +822,13 @@ func (v *View) write(p []byte) { finishLine := func() { v.autoRenderHyperlinksInCurrentLine() - if v.wx >= len(v.lines[v.wy]) { - v.writeCells([]cell{{ - chr: "", - width: 0, - fgColor: 0, - bgColor: 0, - }}) - } } advanceToNextLine := func() { v.wx = 0 v.wy++ if v.wy >= len(v.lines) { - v.lines = append(v.lines, nil) + v.lines = append(v.lines, lineType{}) } } @@ -851,7 +865,7 @@ func (v *View) write(p []byte) { } v.writeCells(cells) if truncateLine { - v.lines[v.wy] = v.lines[v.wy][:v.wx] + v.lines[v.wy].cells = v.lines[v.wy].cells[:v.wx] } } } @@ -910,7 +924,7 @@ func (v *View) autoRenderHyperlinksInCurrentLine() { return } - line := v.lines[v.wy] + line := v.lines[v.wy].cells start := 0 for { linkStart := findLinkStart(line[start:]) @@ -927,7 +941,7 @@ func (v *View) autoRenderHyperlinksInCurrentLine() { link.WriteString(line[linkEnd].chr) } for i := linkStart; i < linkEnd; i++ { - v.lines[v.wy][i].hyperlink = link.String() + v.lines[v.wy].cells[i].hyperlink = link.String() } start = linkEnd } @@ -955,16 +969,19 @@ func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) { } else { repeatCount := 1 if _, ok := v.ei.instruction.(eraseInLineFromCursor); ok { - // fill rest of line + // Discard any old content past the cursor and record the + // fill colors so draw() paints the trailing area with them. + // This extends the bg to the right edge in both the + // content-fits and content-wraps cases — for the latter, + // the metadata is what reaches every wrapped segment past + // the last word. v.ei.instructionRead() - cx := 0 - for _, cell := range v.lines[v.wy][0:v.wx] { - cx += cell.width - } - repeatCount = v.InnerWidth() - cx - ch = []byte{' '} - width = 1 truncateLine = true + v.lines[v.wy].trailingFillAttributes = &trailingFillAttributes{ + fg: v.ei.curFgColor, + bg: v.ei.curBgColor, + } + return truncateLine, []cell{} } else if isEscape { // do not output anything return truncateLine, nil @@ -1010,8 +1027,8 @@ func (v *View) Read(p []byte) (n int, err error) { v.readBuffer = nil } for v.ry < len(v.lines) { - for v.rx < len(v.lines[v.ry]) { - s := v.lines[v.ry][v.rx].chr + for v.rx < len(v.lines[v.ry].cells) { + s := v.lines[v.ry].cells[v.rx].chr count := len(s) copy(p[offset:], s) v.rx++ @@ -1175,9 +1192,9 @@ func (v *View) updateSearchPositions() { } // If a view line exists for this line index: - if v.lines[result.Y] != nil { + if v.lines[result.Y].cells != nil { // search this view line for the search string - positions := searchPositionsForLine(v.lines[result.Y], result.Y) + positions := searchPositionsForLine(v.lines[result.Y].cells, result.Y) if len(positions) > 0 { // If we found any occurrences, add them v.searcher.searchPositions = append(v.searcher.searchPositions, positions...) @@ -1248,13 +1265,21 @@ func (v *View) draw() { } emptyCell := cell{chr: " ", width: 1, fgColor: ColorDefault, bgColor: ColorDefault} - var prevFgColor Attribute for y, vline := range v.viewLines[start:] { if y >= maxY { break } + // Decide the colors used for cells past the end of vline.line: + // the source line's trailingFillAttributes (set by '\x1b[K') if + // any, otherwise plain defaults. + trailingCell := emptyCell + if attrs := vline.trailingFillAttributes; attrs != nil { + trailingCell.fgColor = attrs.fg + trailingCell.bgColor = attrs.bg + } + // 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. @@ -1277,14 +1302,9 @@ func (v *View) draw() { // if we're out of cells to write, we'll just print empty cells. if cellIdx > len(vline.line)-1 { - c = emptyCell - c.fgColor = prevFgColor + c = trailingCell } else { c = vline.line[cellIdx] - // capturing previous foreground colour so that if we're using the reverse - // attribute we honour the final character's colour and don't awkwardly switch - // to a new background colour for the remainder of the line - prevFgColor = c.fgColor } fgColor := c.fgColor @@ -1318,9 +1338,27 @@ func (v *View) refreshViewLinesIfNeeded() { wrap = maxX } - ls := lineWrap(line, wrap) + ls := lineWrap(line.cells, wrap) for j := range ls { - vline := viewLine{linesX: j, linesY: i, line: ls[j]} + // Per-segment trailing fill. When the source line opted in + // via '\x1b[K', the LAST wrapped segment uses those colors + // directly; earlier segments use the colors of their own + // last cell, so the trailing area matches the bg active + // where that segment ended rather than bleeding the + // '\x1b[K' bg back across color changes in the line. + var attrs *trailingFillAttributes + if line.trailingFillAttributes != nil { + if j == len(ls)-1 { + attrs = line.trailingFillAttributes + } else if len(ls[j]) > 0 { + last := ls[j][len(ls[j])-1] + attrs = &trailingFillAttributes{fg: last.fgColor, bg: last.bgColor} + } + } + vline := viewLine{ + linesX: j, linesY: i, line: ls[j], + trailingFillAttributes: attrs, + } if lineIdx > len(v.viewLines)-1 { v.viewLines = append(v.viewLines, vline) @@ -1411,9 +1449,7 @@ func (v *View) BufferLines() []string { lines := make([]string, len(v.lines)) for i, l := range v.lines { - str := lineType(l).String() - str = strings.ReplaceAll(str, "\x00", "") - lines[i] = str + lines[i] = l.cells.String() } return lines } @@ -1434,9 +1470,7 @@ func (v *View) ViewBufferLines() []string { lines := make([]string, len(v.viewLines)) for i, l := range v.viewLines { - str := lineType(l.line).String() - str = strings.ReplaceAll(str, "\x00", "") - lines[i] = str + lines[i] = cells(l.line).String() } return lines } @@ -1458,12 +1492,12 @@ func (v *View) ViewLinesHeight() int { // ViewBuffer returns a string with the contents of the view's buffer that is // shown to the user. func (v *View) ViewBuffer() string { - lines := make([][]cell, len(v.viewLines)) + strs := make([]string, len(v.viewLines)) for i := range v.viewLines { - lines[i] = v.viewLines[i].line + strs[i] = cells(v.viewLines[i].line).String() } - return linesToString(lines) + return strings.Join(strs, "\n") } // Line returns a string with the line of the view's internal buffer @@ -1478,7 +1512,7 @@ func (v *View) Line(y int) (string, bool) { return "", false } - return lineType(v.lines[y]).String(), true + return v.lines[y].cells.String(), true } // Word returns a string with the word of the view's internal buffer @@ -1489,11 +1523,11 @@ func (v *View) Word(x, y int) (string, bool) { return "", false } - if x < 0 || y < 0 || y >= len(v.lines) || x >= len(v.lines[y]) { + if x < 0 || y < 0 || y >= len(v.lines) || x >= len(v.lines[y].cells) { return "", false } - str := lineType(v.lines[y]).String() + str := v.lines[y].cells.String() nl := strings.LastIndexFunc(str[:x], indexFunc) if nl == -1 { @@ -1523,9 +1557,8 @@ func (v *View) SetHighlight(y int, on bool) { return } - line := v.lines[y] - cells := make([]cell, 0) - for _, c := range line { + cells := make([]cell, 0, len(v.lines[y].cells)) + for _, c := range v.lines[y].cells { if on { c.bgColor = v.SelBgColor c.fgColor = v.SelFgColor @@ -1536,7 +1569,7 @@ func (v *View) SetHighlight(y int, on bool) { cells = append(cells, c) } v.tainted = true - v.lines[y] = cells + v.lines[y].cells = cells v.clearHover() } @@ -1602,17 +1635,10 @@ func lineWrap(line []cell, columns int) [][]cell { return lines } -func linesToString(lines [][]cell) string { +func linesToString(lines []lineType) string { str := make([]string, len(lines)) for i := range lines { - rns := make([]rune, 0, len(lines[i])) - line := lineType(lines[i]).String() - for _, c := range line { - if c != '\x00' { - rns = append(rns, c) - } - } - str[i] = string(rns) + str[i] = lines[i].cells.String() } return strings.Join(str, "\n") @@ -1682,9 +1708,7 @@ func (v *View) SelectedLines() []string { } func (v *View) lineContentAtIdx(idx int) string { - line := v.lines[idx] - str := lineType(line).String() - return strings.ReplaceAll(str, "\x00", "") + return v.lines[idx].cells.String() } func (v *View) SelectedPoint() (int, int) { @@ -1788,11 +1812,11 @@ func (v *View) OverwriteLinesAndClearEverythingElse(lineCount int, y int, conten v.overwriteLines(y, content) for i := range y { - v.lines[i] = nil + v.lines[i] = lineType{} } for i := v.wy + 1; i < len(v.lines); i += 1 { - v.lines[i] = nil + v.lines[i] = lineType{} } } @@ -1935,7 +1959,7 @@ func (v *View) scrollMargin() int { // foreground color func (v *View) ContainsColoredText(fgColor string, text string) bool { for _, line := range v.lines { - if containsColoredTextInLine(fgColor, text, line) { + if containsColoredTextInLine(fgColor, text, line.cells) { return true } } diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index a7023be43..f65418821 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -8,10 +8,28 @@ import ( "strings" "testing" + "github.com/gdamore/tcell/v3" + "github.com/gdamore/tcell/v3/color" "github.com/rivo/uniseg" "github.com/stretchr/testify/assert" ) +// WithSimulationScreen swaps the package-level Screen for a tcell +// terminfo-backed mock terminal so tests can call view.draw() and +// inspect rendered cells via Screen.Get(). The previous Screen is +// restored on test cleanup. +func WithSimulationScreen(t *testing.T, width, height int) { + t.Helper() + saved := Screen + if err := (&Gui{}).tcellInitSimulation(width, height); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + Screen.Fini() + Screen = saved + }) +} + func TestWriteString(t *testing.T) { tests := []struct { existingLines []string @@ -26,17 +44,17 @@ func TestWriteString(t *testing.T) { { []string{}, []string{"1\n"}, - [][]string{{"1", ""}}, + [][]string{{"1"}}, }, { []string{}, []string{"1\n", "2\n"}, - [][]string{{"1", ""}, {"2", ""}}, + [][]string{{"1"}, {"2"}}, }, { []string{"a"}, []string{"1\n"}, - [][]string{{"1", ""}}, + [][]string{{"1"}}, }, { []string{"a\x00"}, @@ -56,12 +74,12 @@ func TestWriteString(t *testing.T) { { []string{}, []string{"1\r"}, - [][]string{{"1", ""}}, + [][]string{{"1"}}, }, { []string{"a"}, []string{"1\r"}, - [][]string{{"1", ""}}, + [][]string{{"1"}}, }, { []string{"a\x00"}, @@ -83,14 +101,14 @@ func TestWriteString(t *testing.T) { for _, test := range tests { v := NewView("name", 0, 0, 10, 10, OutputNormal) for _, l := range test.existingLines { - v.lines = append(v.lines, stringToCells(l)) + v.lines = append(v.lines, lineType{cells: stringToCells(l)}) } for _, s := range test.stringsToWrite { v.writeString(s) } var resultingLines [][]string for _, l := range v.lines { - resultingLines = append(resultingLines, cellsToStrings(l)) + resultingLines = append(resultingLines, cellsToStrings(l.cells)) } assert.Equal(t, test.expectedLines, resultingLines) } @@ -126,19 +144,19 @@ func TestAutoRenderingHyperlinks(t *testing.T) { v.writeString("htt") // No hyperlinks are generated for incomplete URLs - assert.Equal(t, "", v.lines[0][0].hyperlink) + assert.Equal(t, "", v.lines[0].cells[0].hyperlink) // Writing more characters to the same line makes the link complete (even // though we didn't see a newline yet) v.writeString("ps://example.com") - assert.Equal(t, "https://example.com", v.lines[0][0].hyperlink) + assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink) v.Clear() // Valid but incomplete URL v.writeString("https://exa") - assert.Equal(t, "https://exa", v.lines[0][0].hyperlink) + assert.Equal(t, "https://exa", v.lines[0].cells[0].hyperlink) // Writing more characters to the same fixes the link v.writeString("mple.com") - assert.Equal(t, "https://example.com", v.lines[0][0].hyperlink) + assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink) } func TestContainsColoredText(t *testing.T) { @@ -211,7 +229,11 @@ func TestContainsColoredText(t *testing.T) { } for i, test := range tests { - v := &View{lines: test.lines} + lines := make([]lineType, len(test.lines)) + for j, cells := range test.lines { + lines[j] = lineType{cells: cells} + } + v := &View{lines: lines} assert.Equal(t, test.expected, v.ContainsColoredText(test.fgColorStr, test.text), "Test %d failed", i) } } @@ -413,3 +435,147 @@ func TestLineWrap(t *testing.T) { }) } } + +// TestNewlineTerminatedLineClearsTrailingBg verifies that a '\n' resets +// any attributes (e.g. AttrReverse-driven background) past the line's +// content, so a reversed cell at the end doesn't bleed into the empty +// area to the right. +func TestNewlineTerminatedLineClearsTrailingBg(t *testing.T) { + WithSimulationScreen(t, 14, 5) + + v := NewView("name", 0, 0, 11, 4, OutputNormal) + + // \x1b[7m sets reverse; \x1b[31m sets fg=red. With reverse the cell + // renders with bg=red. The trailing area past "foo" must NOT extend + // the red bg because '\n' marks the line as cleanly terminated. + v.writeString("\x1b[7m\x1b[31mfoo\x1b[0m\n") + v.draw() + + // First row: cells 1..3 are "foo" (render with red bg via reverse), + // cells 4..10 are trailing and should be plain default. + for x := 4; x <= 10; x++ { + _, style, _ := Screen.Get(x, 1) + assert.Equal(t, tcell.ColorDefault, style.GetForeground(), + "trailing cell at (%d, 1) should have default fg", x) + assert.False(t, style.HasReverse(), + "trailing cell at (%d, 1) should not have reverse attribute", x) + } +} + +// TestUnterminatedReverseLineDoesNotExtend verifies that an unterminated +// line ending with an AttrReverse cell does NOT propagate the reversed +// background past the line's content — matching real terminal behavior +// (try `print '\x1b[7m\x1b[31mfoo'` in a shell). The trailing area +// is rendered as plain default. +func TestUnterminatedReverseLineDoesNotExtend(t *testing.T) { + WithSimulationScreen(t, 14, 5) + + v := NewView("name", 0, 0, 11, 4, OutputNormal) + + // Reverse + red fg, "foo", no termination. The trailing cells past + // "foo" should be plain default, NOT a continuation of the red bg. + v.writeString("\x1b[7m\x1b[31mfoo") + v.draw() + + // Cells 4..10 are trailing and should be default with no reverse. + for x := 4; x <= 10; x++ { + _, style, _ := Screen.Get(x, 1) + assert.Equal(t, tcell.ColorDefault, style.GetForeground(), + "trailing cell at (%d, 1) should have default fg", x) + assert.False(t, style.HasReverse(), + "trailing cell at (%d, 1) should not have reverse attribute", x) + } +} + +// TestShortFilledLineExtendsBgWithoutWrap verifies that '\x1b[K' fills +// the rest of the line with the current bg color for a line that's +// short enough to fit within the view's inner width. +func TestShortFilledLineExtendsBgWithoutWrap(t *testing.T) { + WithSimulationScreen(t, 14, 5) + + v := NewView("name", 0, 0, 11, 4, OutputNormal) + + // \x1b[41m sets bg=red. "hi" fits within InnerWidth=10; \x1b[K should + // fill the remaining 8 cells with red. + v.writeString("\x1b[41mhi\x1b[K\x1b[0m\n") + v.draw() + + // All ten cells at (1..10, 1) should have red bg. + for x := 1; x <= 10; x++ { + _, style, _ := Screen.Get(x, 1) + assert.Equal(t, color.Maroon, style.GetBackground(), + "cell at (%d, 1) should have red bg", x) + } +} + +// TestWrappedFilledLineExtendsBgToEdge verifies that when a line is +// filled to the edge with \x1b[K (the pattern used by `delta` for diff +// lines) but exceeds the view's inner width, every wrapped segment +// extends the fill background past its content to the right edge. +func TestWrappedFilledLineExtendsBgToEdge(t *testing.T) { + WithSimulationScreen(t, 14, 6) + + // View dimensions: Width=12 (x0=0..x1=11), Height=6; InnerWidth=10, + // InnerHeight=4. Frame inset of 1 places content cells at screen + // (1..10, 1..4). + v := NewView("name", 0, 0, 11, 5, OutputNormal) + v.Wrap = true + + // Content with spaces so word wrap ends each segment before the + // right edge: "aaa bbb ccc ddd eee" wraps at InnerWidth=10 to three + // segments — "aaa bbb" / "ccc ddd" / "eee". Each row's trailing area + // must pick up the red fill from \x1b[K. + v.writeString("\x1b[41m" + "aaa bbb ccc ddd eee" + "\x1b[0m\x1b[41m\x1b[K\x1b[0m\n") + v.draw() + + // All three wrapped rows should have the red fill background across + // the full InnerWidth, including the trailing cells past each row's + // last word. + for y := 1; y <= 3; y++ { + for x := 1; x <= 10; x++ { + _, style, _ := Screen.Get(x, y) + assert.Equal(t, color.Maroon, style.GetBackground(), + "cell at (%d, %d) should have red bg", x, y) + } + } +} + +// TestMulticolorWrappedFillUsesLastCellOfEachSegment demonstrates that +// when a wrapped line switches bg color part-way through and ends with +// \x1b[K, the trailing area on each wrapped row should match the bg +// that was active where that row's content ended — not the \x1b[K bg, +// which would bleed the color from the end of the logical line back +// into the earlier wrapped rows. +func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) { + WithSimulationScreen(t, 14, 6) + + // View dimensions: Width=12 (x0=0..x1=11), Height=6; InnerWidth=10, + // InnerHeight=4. Frame inset of 1 places content cells at screen + // (1..10, 1..4). + v := NewView("name", 0, 0, 11, 5, OutputNormal) + v.Wrap = true + + // Content "aaa bbb ccc" is 11 cells; lineWrap breaks at the space + // between "bbb" and "ccc" (index 7) so segment 1 is "aaa bbb" (red, + // last cell red) and segment 2 is "ccc" (green, last cell green). + // \x1b[K records the green bg on the source line. + v.writeString("\x1b[41maaa bbb\x1b[42m ccc\x1b[K\x1b[0m\n") + v.draw() + + // Row 1's content ends with a red cell at x=7, so trailing columns + // 8..10 should pick up red rather than the \x1b[K's green. + for x := 8; x <= 10; x++ { + _, style, _ := Screen.Get(x, 1) + assert.Equal(t, color.Maroon, style.GetBackground(), + "trailing cell at (%d, 1) should have red bg (matching segment's last cell)", x) + } + + // Row 2's content ends with a green cell at x=3, so trailing + // columns 4..10 should pick up green (matching both the segment's + // last cell and the \x1b[K bg — these happen to agree here). + for x := 4; x <= 10; x++ { + _, style, _ := Screen.Get(x, 2) + assert.Equal(t, color.Green, style.GetBackground(), + "trailing cell at (%d, 2) should have green bg", x) + } +}