jesseduffield.lazygit/pkg/gocui/view_test.go
Stefan Haller 51b409383c Extend the fill background to wrapped tail segments
Tools like delta paint each diff line's background with '\x1b[K' so
the color reaches the right edge. Up to now the '\x1b[K' handler
appended (InnerWidth - cx) explicit padding cells with the fill bg
so rendering picked up the color. That worked for short lines but
silently degraded once content exceeded InnerWidth: the repeat
count went non-positive, no cells were added, and after wrapping
the partial tail segment was left without any cells carrying the
fill color, so draw() fell back to the view's default bg.

Record the fill colors on the source line as optional
trailingFillAttributes. In the '\x1b[K' handler set them (and drop
the padding-cell loop — the metadata covers both the wrap and the
non-wrap cases). In draw(), once per source line, pick the trailing
cell's fg/bg from the metadata if present and otherwise from the
view defaults; then the inner-loop fills past-content cells with
that.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-18 17:22:31 +02:00

582 lines
15 KiB
Go

// Copyright 2014 The gocui Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package gocui
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
stringsToWrite []string
expectedLines [][]string
}{
{
[]string{},
[]string{""},
[][]string{{}},
},
{
[]string{},
[]string{"1\n"},
[][]string{{"1"}},
},
{
[]string{},
[]string{"1\n", "2\n"},
[][]string{{"1"}, {"2"}},
},
{
[]string{"a"},
[]string{"1\n"},
[][]string{{"1"}},
},
{
[]string{"a\x00"},
[]string{"1\n"},
[][]string{{"1", "\x00"}},
},
{
[]string{"ab"},
[]string{"1\n"},
[][]string{{"1", "b"}},
},
{
[]string{"abc"},
[]string{"1\n"},
[][]string{{"1", "b", "c"}},
},
{
[]string{},
[]string{"1\r"},
[][]string{{"1"}},
},
{
[]string{"a"},
[]string{"1\r"},
[][]string{{"1"}},
},
{
[]string{"a\x00"},
[]string{"1\r"},
[][]string{{"1", "\x00"}},
},
{
[]string{"ab"},
[]string{"1\r"},
[][]string{{"1", "b"}},
},
{
[]string{"abc"},
[]string{"1\r"},
[][]string{{"1", "b", "c"}},
},
}
for _, test := range tests {
v := NewView("name", 0, 0, 10, 10, OutputNormal)
for _, l := range test.existingLines {
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.cells))
}
assert.Equal(t, test.expectedLines, resultingLines)
}
}
func TestUpdatedCursorAndOrigin(t *testing.T) {
tests := []struct {
prevOrigin int
size int
cursor int
expectedCursor int
expectedOrigin int
}{
{0, 10, 0, 0, 0},
{0, 10, 9, 9, 0},
{0, 10, 10, 9, 1},
{0, 10, 19, 9, 10},
{0, 10, 20, 9, 11},
{20, 10, 19, 0, 19},
{20, 10, 25, 5, 20},
}
for _, test := range tests {
cursor, origin := updatedCursorAndOrigin(test.prevOrigin, test.size, test.cursor)
assert.EqualValues(t, test.expectedCursor, cursor, "Cursor is wrong")
assert.EqualValues(t, test.expectedOrigin, origin, "Origin in wrong")
}
}
func TestAutoRenderingHyperlinks(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal)
v.AutoRenderHyperLinks = true
v.writeString("htt")
// No hyperlinks are generated for incomplete URLs
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].cells[0].hyperlink)
v.Clear()
// Valid but incomplete URL
v.writeString("https://exa")
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].cells[0].hyperlink)
}
func TestContainsColoredText(t *testing.T) {
hexColor := func(text string, hexStr string) []cell {
cells := make([]cell, len(text))
hex := GetColor(hexStr)
for i, chr := range text {
cells[i] = cell{fgColor: hex, chr: string(chr)}
}
return cells
}
red := "#ff0000"
green := "#00ff00"
redStr := func(text string) []cell { return hexColor(text, red) }
greenStr := func(text string) []cell { return hexColor(text, green) }
concat := func(lines ...[]cell) []cell {
var cells []cell
for _, line := range lines {
cells = append(cells, line...)
}
return cells
}
tests := []struct {
lines [][]cell
fgColorStr string
text string
expected bool
}{
{
lines: [][]cell{concat(redStr("a"))},
fgColorStr: red,
text: "a",
expected: true,
},
{
lines: [][]cell{concat(redStr("a"))},
fgColorStr: red,
text: "b",
expected: false,
},
{
lines: [][]cell{concat(redStr("a"))},
fgColorStr: green,
text: "b",
expected: false,
},
{
lines: [][]cell{concat(redStr("hel"), greenStr("lo"), redStr(" World!"))},
fgColorStr: red,
text: "hello",
expected: false,
},
{
lines: [][]cell{concat(redStr("hel"), greenStr("lo"), redStr(" World!"))},
fgColorStr: green,
text: "lo",
expected: true,
},
{
lines: [][]cell{
redStr("hel"),
redStr("lo"),
},
fgColorStr: red,
text: "hello",
expected: false,
},
}
for i, test := range tests {
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)
}
}
func stringToCells(s string) []cell {
var cells []cell
state := -1
for len(s) > 0 {
var c string
var w int
c, s, w, state = uniseg.FirstGraphemeClusterInString(s, state)
cells = append(cells, cell{chr: c, width: w})
}
return cells
}
func cellsToString(cells []cell) string {
var s strings.Builder
for _, c := range cells {
s.WriteString(c.chr)
}
return s.String()
}
func cellsToStrings(cells []cell) []string {
s := []string{}
for _, c := range cells {
s = append(s, c.chr)
}
return s
}
func TestLineWrap(t *testing.T) {
testCases := []struct {
name string
line string
columns int
expected []string
}{
{
name: "Wrap on space",
line: "Hello World",
columns: 5,
expected: []string{
"Hello",
"World",
},
},
{
name: "Wrap on hyphen",
line: "Hello-World",
columns: 6,
expected: []string{
"Hello-",
"World",
},
},
{
name: "Wrap on hyphen 2",
line: "Blah Hello-World",
columns: 12,
expected: []string{
"Blah Hello-",
"World",
},
},
{
name: "Wrap on hyphen 3",
line: "Blah Hello-World",
columns: 11,
expected: []string{
"Blah Hello-",
"World",
},
},
{
name: "Wrap on hyphen 4",
line: "Blah Hello-World",
columns: 10,
expected: []string{
"Blah Hello",
"-World",
},
},
{
name: "Wrap on space 2",
line: "Blah Hello World",
columns: 10,
expected: []string{
"Blah Hello",
"World",
},
},
{
name: "Wrap on space with more words",
line: "Longer word here",
columns: 10,
expected: []string{
"Longer",
"word here",
},
},
{
name: "Split word that's too long",
line: "ThisWordIsWayTooLong",
columns: 10,
expected: []string{
"ThisWordIs",
"WayTooLong",
},
},
{
name: "Split word that's too long over multiple lines",
line: "ThisWordIsWayTooLong",
columns: 5,
expected: []string{
"ThisW",
"ordIs",
"WayTo",
"oLong",
},
},
{
name: "Lots of hyphens",
line: "one-two-three-four-five",
columns: 8,
expected: []string{
"one-two-",
"three-",
"four-",
"five",
},
},
{
name: "Several lines using all the available width",
line: "aaa bb cc ddd-ee ff",
columns: 5,
expected: []string{
"aaa",
"bb cc",
"ddd-",
"ee ff",
},
},
{
name: "Multi-cell runes",
line: "🐤🐤🐤 🐝🐝 🙉 🦊🦊🦊-🐬🐬 🦢🦢",
columns: 9,
expected: []string{
"🐤🐤🐤",
"🐝🐝 🙉",
"🦊🦊🦊-",
"🐬🐬 🦢🦢",
},
},
{
name: "Space in last column",
line: "hello world",
columns: 6,
expected: []string{
"hello",
"world",
},
},
{
name: "Hyphen in last column",
line: "hello-world",
columns: 6,
expected: []string{
"hello-",
"world",
},
},
{
name: "English text",
line: "+The sea reach of the Thames stretched before us like the bedinnind of an interminable waterway. In the offind the sea and the sky were welded todether without a joint, and in the luminous space the tanned sails of the bardes drifting blah blah",
columns: 81,
expected: []string{
"+The sea reach of the Thames stretched before us like the bedinnind of an",
"interminable waterway. In the offind the sea and the sky were welded todether",
"without a joint, and in the luminous space the tanned sails of the bardes",
"drifting blah blah",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
lineCells := stringToCells(tc.line)
result := lineWrap(lineCells, tc.columns)
resultStrings := make([]string, len(result))
for i, line := range result {
resultStrings[i] = cellsToString(line)
}
assert.EqualValues(t, tc.expected, resultStrings)
})
}
}
// 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)
}
}