mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-05 21:46:49 -04:00
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) <noreply@anthropic.com>
1230 lines
38 KiB
Go
1230 lines
38 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 TestOverwriteLinesAfterContentEndingInANewline(t *testing.T) {
|
|
v := NewView("name", 0, 0, 20, 10, OutputNormal)
|
|
// The trailing newline is held back until more content arrives, so that the
|
|
// view doesn't end in an empty line.
|
|
v.writeString("a\nb\n")
|
|
|
|
v.OverwriteLines(0, "x")
|
|
|
|
assert.Equal(t, []string{"x", "b"}, v.BufferLines())
|
|
}
|
|
|
|
// diffLineTexts returns the Text of each of the given contents.
|
|
func diffLineTexts(contents []DiffLineContent) []string {
|
|
return lo.Map(contents, func(c DiffLineContent, _ int) string { return c.Text })
|
|
}
|
|
|
|
func TestDiffLineContentsTextIsTheTextAsWritten(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
stringsToWrite []string
|
|
expectedShown []string
|
|
expectedAsWritten []string
|
|
}{
|
|
{
|
|
name: "a line the cells spell as written",
|
|
stringsToWrite: []string{"abc\n"},
|
|
expectedShown: []string{"abc"},
|
|
expectedAsWritten: []string{"abc"},
|
|
},
|
|
{
|
|
name: "a tab is kept rather than the spaces it fills",
|
|
stringsToWrite: []string{"a\tb\n"},
|
|
expectedShown: []string{"a b"},
|
|
expectedAsWritten: []string{"a\tb"},
|
|
},
|
|
{
|
|
name: "a carriage return is kept rather than the overwrite it causes",
|
|
stringsToWrite: []string{"abc\rde\n"},
|
|
expectedShown: []string{"dec"},
|
|
expectedAsWritten: []string{"abc\rde"},
|
|
},
|
|
{
|
|
// git writes a CRLF file's lines as "+foo\r", the color reset, "\n".
|
|
name: "escape sequences are left out",
|
|
stringsToWrite: []string{"\x1b[32m+foo\r\x1b[m\n"},
|
|
expectedShown: []string{"+foo"},
|
|
expectedAsWritten: []string{"+foo\r"},
|
|
},
|
|
{
|
|
// ConPTY writes a run of spaces as a cursor-forward escape.
|
|
name: "a cursor-forward escape stands for the spaces it skips",
|
|
stringsToWrite: []string{"\ta\x1b[2Cb\n"},
|
|
expectedShown: []string{" a b"},
|
|
expectedAsWritten: []string{"\ta b"},
|
|
},
|
|
{
|
|
name: "a line written in two parts",
|
|
stringsToWrite: []string{"a\t", "b\n"},
|
|
expectedShown: []string{"a b"},
|
|
expectedAsWritten: []string{"a\tb"},
|
|
},
|
|
{
|
|
name: "only the lines with a tab or a return are kept separately",
|
|
stringsToWrite: []string{"x\n", "y\tz\n", "w\n"},
|
|
expectedShown: []string{"x", "y z", "w"},
|
|
expectedAsWritten: []string{"x", "y\tz", "w"},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
v := NewView("name", 0, 0, 20, 10, OutputNormal)
|
|
for _, s := range test.stringsToWrite {
|
|
v.writeString(s)
|
|
}
|
|
assert.Equal(t, test.expectedShown, v.BufferLines())
|
|
assert.Equal(t, test.expectedAsWritten, diffLineTexts(v.DiffLineContents()))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDiffLineContentsTextOfAnOverwrittenLine(t *testing.T) {
|
|
v := NewView("name", 0, 0, 20, 10, OutputNormal)
|
|
v.writeString("a\tb\nc\td")
|
|
|
|
// Overwriting a line starts it over: what it kept of its earlier text goes,
|
|
// and the line below is left alone.
|
|
v.OverwriteLines(0, "xy")
|
|
|
|
assert.Equal(t, []string{"xy", "c d"}, v.BufferLines())
|
|
assert.Equal(t, []string{"xy", "c\td"}, diffLineTexts(v.DiffLineContents()))
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func TestSelectedBufferLineRange(t *testing.T) {
|
|
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
|
|
v.Wrap = true
|
|
|
|
_, _, ok := v.SelectedBufferLineRange()
|
|
assert.False(t, ok)
|
|
|
|
// Buffer line 1 wraps into view lines 1, 2 and 3.
|
|
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
|
|
|
|
// A range from the middle of the wrapped line to the line below it covers both
|
|
// lines.
|
|
v.FocusPoint(0, 2, false)
|
|
v.SetRangeSelectStart(4)
|
|
first, last, ok := v.SelectedBufferLineRange()
|
|
assert.True(t, ok)
|
|
assert.Equal(t, 1, first)
|
|
assert.Equal(t, 2, last)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
func TestApplySelTextColor(t *testing.T) {
|
|
scenarios := []struct {
|
|
name string
|
|
fgColor Attribute
|
|
selTextColor Attribute
|
|
expected Attribute
|
|
}{
|
|
{
|
|
name: "adds attributes",
|
|
fgColor: ColorRed | AttrUnderline,
|
|
selTextColor: AttrBold,
|
|
expected: ColorRed | AttrUnderline | AttrBold,
|
|
},
|
|
{
|
|
name: "replaces the color",
|
|
fgColor: ColorRed | AttrUnderline,
|
|
selTextColor: ColorWhite | AttrBold,
|
|
expected: ColorWhite | AttrUnderline | AttrBold,
|
|
},
|
|
{
|
|
name: "replaces an RGB color",
|
|
fgColor: NewRGBColor(0x12, 0x34, 0x56),
|
|
selTextColor: ColorBlue,
|
|
expected: ColorBlue,
|
|
},
|
|
{
|
|
name: "leaves the text alone if default",
|
|
fgColor: ColorRed | AttrUnderline,
|
|
selTextColor: ColorDefault,
|
|
expected: ColorRed | AttrUnderline,
|
|
},
|
|
}
|
|
|
|
for _, s := range scenarios {
|
|
t.Run(s.name, func(t *testing.T) {
|
|
assert.Equal(t, s.expected, applySelTextColor(s.fgColor, s.selTextColor))
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
|
|
// 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())
|
|
}
|