Files
lazygit/pkg/gocui/view_test.go

1103 lines
34 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/samber/lo"
"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.buf.lines = append(v.buf.lines, lineType{cells: stringToCells(l)})
}
for _, s := range test.stringsToWrite {
v.writeString(s)
}
resultingLines := lo.Map(v.buf.lines,
func(l lineType, _ int) []string { return 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.buf.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.buf.lines[0].cells[0].hyperlink)
v.Clear()
// Valid but incomplete URL
v.writeString("https://exa")
assert.Equal(t, "https://exa", v.buf.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.buf.lines[0].cells[0].hyperlink)
}
// osc1717 wraps an OSC 1717 payload in the sequence a diff renderer emits it in:
// the ESC ] introducer with the OSC number, and ESC \ as the terminator.
func osc1717(payload string) string {
return "\x1b]1717;" + payload + "\x1b\\"
}
func TestDiffLineContents(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
// A diff renderer prefixes each line it renders with a record naming the
// file and the line's position in it: version;type;new-line;old-line;file.
v.writeString(strings.Join([]string{
osc1717("1;c;1;;foo.txt") + "line1",
osc1717("1;d;2;2;foo.txt") + "old2",
osc1717("1;a;2;;foo.txt") + "new2",
"@@ a hunk header, which carries no record @@",
}, "\n"))
assert.Equal(t, []DiffLineContent{
{Text: "line1", Metadata: []string{"1;c;1;;foo.txt"}},
{Text: "old2", Metadata: []string{"1;d;2;2;foo.txt"}},
{Text: "new2", Metadata: []string{"1;a;2;;foo.txt"}},
// The record of the line before doesn't bleed onto this one.
{Text: "@@ a hunk header, which carries no record @@"},
}, v.DiffLineContents())
}
func TestDiffLineContentsWithSideBySideRecords(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
// A side-by-side renderer puts two diff lines on one rendered line, and so
// emits a record before each half.
v.writeString(strings.Join([]string{
osc1717("1;c;1;;foo.txt") + "context " + osc1717("1;c;1;;foo.txt") + "context",
osc1717("1;d;2;2;foo.txt") + "old2 " + osc1717("1;a;2;;foo.txt") + "new2",
}, "\n"))
assert.Equal(t, []DiffLineContent{
// The two halves of a context line are the same diff line, stated twice.
{Text: "context context", Metadata: []string{"1;c;1;;foo.txt"}},
{Text: "old2 new2", Metadata: []string{"1;d;2;2;foo.txt", "1;a;2;;foo.txt"}},
}, v.DiffLineContents())
}
func TestDiffLineContentsOfWrappedLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// A line that gocui wraps is still one buffer line, so its record covers
// every view line it is displayed on.
v.writeString(osc1717("1;a;1;;foo.txt") + "a line too long to fit")
assert.Equal(t, []DiffLineContent{
{Text: "a line too long to fit", Metadata: []string{"1;a;1;;foo.txt"}},
}, v.DiffLineContents())
assert.Equal(t, 3, v.ViewLinesHeight())
for viewLine := range 3 {
bufferLine, ok := v.BufferLineForViewLine(viewLine)
assert.True(t, ok)
assert.Equal(t, 0, bufferLine)
}
}
func TestDiffLineContentsWithRecordsCoveringNoCell(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
v.writeString(strings.Join([]string{
// A banner announcing a file and its first hunk at once carries both
// records back to back.
osc1717("1;f;;;foo.txt") + osc1717("1;h;5;;foo.txt") + "foo.txt --- Go",
// So does a modification whose deletion and addition are collapsed into
// a single rendered line.
osc1717("1;d;5;5;foo.txt") + osc1717("1;a;5;;foo.txt") + "595 new content",
// A changed line that is empty is rendered as its record and nothing else.
osc1717("1;a;6;;foo.txt"),
}, "\n") + "\n")
assert.Equal(t, []DiffLineContent{
{Text: "foo.txt --- Go", Metadata: []string{"1;f;;;foo.txt", "1;h;5;;foo.txt"}},
{Text: "595 new content", Metadata: []string{"1;d;5;5;foo.txt", "1;a;5;;foo.txt"}},
{Text: "", Metadata: []string{"1;a;6;;foo.txt"}},
}, v.DiffLineContents())
}
func TestDiffLineContentsSwallowsHandshake(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
// A diff renderer announces itself with a version-only record before the
// diff. It must leave no trace: no visible bytes, no line of its own, and
// above all no record on the line that follows it.
v.writeString(osc1717("1") + strings.Join([]string{
"diff --git a/foo.txt b/foo.txt",
osc1717("1;a;1;;foo.txt") + "added",
}, "\n"))
assert.Equal(t, []DiffLineContent{
{Text: "diff --git a/foo.txt b/foo.txt"},
{Text: "added", Metadata: []string{"1;a;1;;foo.txt"}},
}, v.DiffLineContents())
}
// An async re-render builds into an off-screen buffer and swaps it in once it
// has enough to paint, so readers keep seeing the previous render — coherent and
// consistent — until the new content appears in one step. See View.offscreen.
func TestOffscreenRender(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
v.writeString("a\nb\nc")
assert.Equal(t, []string{"a", "b", "c"}, v.ViewBufferLines())
// Render new, longer content off-screen.
v.BeginOffscreenRender()
v.writeString("w\nx\ny\nz")
// The displayed buffer is untouched: readers still see the previous render.
assert.Equal(t, []string{"a", "b", "c"}, v.ViewBufferLines())
// Swapping in reveals the new content in one step.
v.SwapInOffscreenRender()
assert.Equal(t, []string{"w", "x", "y", "z"}, v.ViewBufferLines())
// A further write now appends to the displayed buffer directly.
v.writeString("\nmore")
assert.Equal(t, []string{"w", "x", "y", "z", "more"}, v.ViewBufferLines())
}
// When a render produces fewer view lines than the previous one,
// refreshViewLinesIfNeeded must truncate viewLines to the new content rather
// than leaving the previous render's entries in the tail: with the off-screen
// render there is no half-loaded buffer whose tail we'd want to keep showing,
// and a leftover tail is just stale lines describing content that is gone.
func TestViewLinesTruncatedByShorterRender(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// Two lines of 27 characters each wrap into 3 view lines apiece.
v.writeString(strings.Repeat("a", 27) + "\n" + strings.Repeat("b", 27))
assert.Equal(t, 6, v.ViewLinesHeight())
// Re-render with three short, unwrapped lines: only 3 view lines remain.
v.BeginOffscreenRender()
v.writeString("aaa\nbbb\nccc")
v.SwapInOffscreenRender()
assert.Equal(t, 3, v.ViewLinesHeight())
assert.Equal(t, []string{"aaa", "bbb", "ccc"}, v.ViewBufferLines())
}
func TestBufferLineForViewLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// Buffer line 0 is short (view line 0); buffer line 1 wraps into three view
// lines (1, 2, 3); buffer line 2 is short again (view line 4).
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
for viewLine, wantBufferLine := range []int{0, 1, 1, 1, 2} {
bufferLine, ok := v.BufferLineForViewLine(viewLine)
assert.True(t, ok)
assert.Equal(t, wantBufferLine, bufferLine)
}
_, ok := v.BufferLineForViewLine(5)
assert.False(t, ok)
_, ok = v.BufferLineForViewLine(-1)
assert.False(t, ok)
}
func TestViewLineForBufferLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// A wrapped buffer line maps to the first of the view lines it spans.
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
for bufferLine, wantViewLine := range []int{0, 1, 4} {
viewLine, ok := v.ViewLineForBufferLine(bufferLine)
assert.True(t, ok)
assert.Equal(t, wantViewLine, viewLine)
}
_, ok := v.ViewLineForBufferLine(3)
assert.False(t, ok)
}
func TestLastViewLineForBufferLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// A wrapped buffer line maps to the last of the view lines it spans.
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
for bufferLine, wantViewLine := range []int{0, 3, 4} {
viewLine, ok := v.LastViewLineForBufferLine(bufferLine)
assert.True(t, ok)
assert.Equal(t, wantViewLine, viewLine)
}
_, ok := v.LastViewLineForBufferLine(3)
assert.False(t, ok)
}
// While an async re-render loads, it swaps in only a partially-filled buffer at
// its first paint and keeps appending lines afterwards. The scrollbar must keep
// using the pre-load height until the load ends, so the thumb doesn't shrink and
// snap back as the rest streams in. See View.scrollbarHeightFloor.
func TestScrollbarHeightHeldWhileLoading(t *testing.T) {
v := NewView("name", 0, 0, 80, 12, OutputNormal)
// Initial render: 100 lines, scrolled well down.
v.writeString(strings.Repeat("x\n", 100))
v.SetOrigin(0, 80)
assert.Equal(t, 100, v.scrollbarContentHeight())
// A re-render begins while the previous render is still shown: hold the
// scrollbar height at the current value.
v.FreezeScrollbarHeight()
// The off-screen render swaps in only a screenful at its first paint.
v.BeginOffscreenRender()
v.writeString(strings.Repeat("y\n", 30))
v.SwapInOffscreenRender()
// The displayed buffer is now short, but the scrollbar height stays held, so
// the thumb keeps its position instead of jumping.
assert.Equal(t, 30, v.ViewLinesHeight())
assert.Equal(t, 100, v.scrollbarContentHeight())
// The rest of the content streams in.
v.writeString(strings.Repeat("y\n", 70))
assert.Equal(t, 100, v.scrollbarContentHeight())
// Once the load ends, the scrollbar tracks the real content directly again.
v.UnfreezeScrollbarHeight()
assert.Equal(t, 100, v.scrollbarContentHeight())
}
// If a synchronous render (e.g. a string render) supersedes a still-loading diff
// before it reaches its end, the held scrollbar height must be released, so the
// scrollbar reflects the new content rather than the abandoned load's height.
func TestScrollbarHeightReleasedWhenContentReplaced(t *testing.T) {
v := NewView("name", 0, 0, 80, 12, OutputNormal)
v.writeString(strings.Repeat("x\n", 100))
v.FreezeScrollbarHeight()
assert.Equal(t, 100, v.scrollbarContentHeight())
// A synchronous render replaces the content before the (notional) load ends.
v.SetContent("just a few\nshort lines\nhere")
assert.Equal(t, 3, v.scrollbarContentHeight())
}
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{buf: &viewBuffer{lines: lines}}
assert.Equal(t, test.expected, v.ContainsColoredText(test.fgColorStr, test.text), "Test %d failed", i)
}
}
func TestWriteCursorPositionEscape(t *testing.T) {
// ConPTY presents its child's output as a screen buffer and uses cursor
// positioning escapes (CUP, `\x1b[<row>;<col>H`) to skip over blank rows
// rather than emitting empty LFs for them. The escape interpreter must
// synthesize the row advances those CUPs imply; otherwise non-blank rows
// that the child separated with blank lines end up adjacent in the view.
v := NewView("name", 0, 0, 20, 10, OutputNormal)
// "a", then "skip to row 3" (i.e. one blank row), then "b".
v.writeString("a\r\n\x1b[3;1Hb\r\n")
got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells))
}
assert.Equal(t, [][]string{{"a"}, {}, {"b"}}, got)
}
func TestWriteCursorPositionEscapeAcrossWrites(t *testing.T) {
// Mirrors the production flow: bufio.Scanner splits the pty output on
// LF and feeds the view one Write per line (each with a trailing \n
// appended). The parser's screen-row counter must keep ticking across
// writes; otherwise CUPs are evaluated against a stale row and
// overshoot, producing too many blank lines instead of the right
// number.
v := NewView("name", 0, 0, 30, 30, OutputNormal)
v.writeString("a\n")
v.writeString("b\n")
// ConPTY is on row 3 here; CUP to row 5 should skip exactly one row.
v.writeString("c\x1b[5;1Hd\n")
got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells))
}
assert.Equal(t, [][]string{
{"a"},
{"b"},
{"c"},
{},
{"d"},
}, got)
}
func TestWriteCursorPositionEscapeInOffscreenRender(t *testing.T) {
// Soft-wrap counting has to work in an off-screen render too: the content
// width the parser counts wraps against is set by SetContentWidth before the
// render starts, so the off-screen buffer's parser has to pick it up. If it
// doesn't, no wraps are counted and the CUP below is evaluated against a
// stale row, overshooting into an extra blank line.
v := NewView("name", 0, 0, 30, 30, OutputNormal)
v.SetContentWidth(5)
v.BeginOffscreenRender()
// Seven characters soft-wrap once on a 5-column screen, putting ConPTY on
// row 2; CUP to row 3 should then skip no rows at all.
v.writeString("aaaaaaa\x1b[3;1Hb\n")
v.SwapInOffscreenRender()
got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells))
}
assert.Equal(t, [][]string{
{"a", "a", "a", "a", "a", "a", "a"},
{"b"},
}, got)
}
func TestWriteCursorForwardEscape(t *testing.T) {
// ConPTY compresses runs of default-colored spaces into ECH (\x1b[NX,
// "clear N cells, cursor stationary") + CUF (\x1b[NC, "cursor forward
// N") rather than emitting them literally. The interpreter has to
// materialize CUF as N visible spaces; otherwise the gap collapses
// and content that followed the indentation slides left.
v := NewView("name", 0, 0, 20, 10, OutputNormal)
// "a" + ECH 5 + CUF 5 + "b" — visually "a b".
v.writeString("a\x1b[5X\x1b[5Cb\n")
got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells))
}
assert.Equal(t, [][]string{{"a", " ", " ", " ", " ", " ", "b"}}, got)
}
func TestWriteCursorPositionEscapeWithSoftWraps(t *testing.T) {
// If a logical line is longer than ConPTY's terminal width, ConPTY
// soft-wraps it onto multiple physical rows in its screen, and any
// subsequent CUP is addressed against the post-wrap row count. To
// keep our screen-row counter accurate we have to count those wraps
// as we write the cells. InnerWidth here is 5; "abcdefghij" (10
// cells) wraps onto 2 rows, so ConPTY is on row 3 after the LF and a
// CUP to row 4 should skip exactly one row.
v := NewView("name", 0, 0, 6, 30, OutputNormal) // Width=7, InnerWidth=5
v.writeString("abcdefghij\n")
v.writeString("\x1b[4;1Hxyz\n")
got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells))
}
assert.Equal(t, [][]string{
{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"},
{},
{"x", "y", "z"},
}, got)
}
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 {
return lo.Map(cells, func(c cell, _ int) string { return c.chr })
}
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(true)
// 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(true)
// 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(true)
// 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(true)
// 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(true)
// 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)
}
}
// A view that wraps draws one line of its content as several view lines, and the
// cursor and the range anchor count those. What is asked about a selection is
// which lines of the content it covers, so those are what it has to be reported
// in.
func TestSelectedLinesOfWrappedContent(t *testing.T) {
v := NewView("name", 0, 0, 11, 10, OutputNormal) // InnerWidth 10
v.Wrap = true
v.Highlight = true
// "a line that wraps" takes two view lines, so the four lines of content are
// drawn as five: "one", "two", "a line th", "at wraps", "four".
v.writeString("one\ntwo\na line that wraps\nfour\n")
assert.Equal(t, 5, v.ViewLinesHeight())
// The cursor on the wrapped line's second half is on that line.
v.FocusPoint(0, 3, false)
assert.Equal(t, "a line that wraps", v.SelectedLine())
// A range over both halves of the wrapped line covers one line of content.
v.SetRangeSelectStart(2)
assert.Equal(t, []string{"a line that wraps"}, v.SelectedLines())
}
func TestLineFlashReversesTheSelectionBarWithoutChangingSelection(t *testing.T) {
WithSimulationScreen(t, 14, 6)
v := NewView("name", 0, 0, 11, 5, OutputNormal)
v.Highlight = true
v.SelBgColor = ColorBlue
v.SelectedLineColorWidth = 2
v.writeString("one\ntwo\nthree\n")
v.FocusPoint(0, 1, false)
v.SetLineFlash(1)
v.draw(true)
for x := 1; x <= 2; x++ {
_, style, _ := Screen.Get(x, 2)
assert.True(t, style.HasReverse(), "selection-bar cell at (%d, 2) should flash", x)
}
_, style, _ := Screen.Get(3, 2)
assert.False(t, style.HasReverse(), "the flash should stop after the selection bar")
assert.Equal(t, "two", v.SelectedLine(), "flashing should not change the selection")
v.ClearLineFlash()
v.draw(true)
_, style, _ = Screen.Get(1, 2)
assert.False(t, style.HasReverse(), "clearing should remove the flash")
}
// Resizing a view throws away the wrapping of its content and wraps it again for
// the new width, which moves every line of it to a different view line. The
// positions into the view count view lines, so they all have to come along.
func TestResizingAWrappingViewKeepsItsPlaceInTheContent(t *testing.T) {
g := &Gui{}
v, _ := g.SetView("name", 0, 0, 11, 10, 0) // InnerWidth 10
v.Wrap = true
v.Highlight = true
// Two wrapping lines, with a single line between them: eight view lines for
// five lines of content.
v.writeString("one\na line that wraps\ntwo\nanother wrapping line\nthree\n")
assert.Equal(t, 8, v.ViewLinesHeight())
// A range over the whole of the second wrapping line, which is drawn as view
// lines 4 to 6.
v.SetRangeSelectStart(4)
v.FocusPoint(0, 6, false)
assert.Equal(t, []string{"another wrapping line"}, v.SelectedLines())
// Widen the view so that nothing wraps any more.
_, _ = g.SetView("name", 0, 0, 31, 10, 0) // InnerWidth 30
assert.Equal(t, 5, v.ViewLinesHeight())
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())
}