/
githubmirror
/
lazygit
Обзор
Документация
Войти
/
githubmirror
/
lazygit
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
pkg/gocui/view_test.go
666 строк
18 KB
Stefan Haller
Materialize cursor-forward escapes as space runs
03 июл 2026, 19:47
03 июл 2026, 19:47
2fce9c9
Код
Авторство
О чём код?
// 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 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.lines)) for _, l := range v.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.lines)) for _, l := range v.lines { got = append(got, cellsToStrings(l.cells)) } assert.Equal(t, [][]string{ {"a"}, {"b"}, {"c"}, {}, {"d"}, }, 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.lines)) for _, l := range v.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.lines)) for _, l := range v.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 { 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) } }