package terminal import ( "strings" "testing" "github.com/gdamore/tcell/v2" ) // writeString puts a run of characters onto a screen, as a program would. func writeString(s *Screen, text string) { for _, r := range text { s.WriteRune(r) } } // wantLines fails the test unless the screen's rows read as expected. Rows // beyond the ones given are not checked. func wantLines(t *testing.T, s *Screen, lines ...string) { t.Helper() for row, want := range lines { if got := s.LineText(row); got != want { t.Errorf("row %d = %q, want %q", row, got, want) } } } func TestANewScreenIsBlankWithTheCursorAtTheTopLeft(t *testing.T) { s := NewScreen(20, 5) width, height := s.Size() if width != 20 || height != 5 { t.Errorf("Size() = %dx%d, want 20x5", width, height) } if got := s.Cursor(); got != (Cursor{}) { t.Errorf("Cursor() = %+v, want the top left", got) } if !s.CursorVisible() { t.Error("CursorVisible() = false on a new screen") } for row := range 5 { if got := s.LineText(row); got != "" { t.Errorf("row %d = %q, want it blank", row, got) } } } func TestASizeBelowOneIsRaisedToOne(t *testing.T) { s := NewScreen(0, -3) if width, height := s.Size(); width != 1 || height != 1 { t.Errorf("Size() = %dx%d, want 1x1 — the cursor must have somewhere to be", width, height) } s.WriteRune('x') // must not panic } func TestWritingAdvancesTheCursor(t *testing.T) { s := NewScreen(20, 3) writeString(s, "hello") wantLines(t, s, "hello") if got := s.Cursor(); got != (Cursor{Row: 0, Col: 5}) { t.Errorf("Cursor() = %+v, want column 5", got) } } func TestTheCursorStopsInTheLastColumnUntilTheNextCharacter(t *testing.T) { // A character written in the last column must not scroll the screen by // itself. Only the character after it wraps. s := NewScreen(3, 2) writeString(s, "abc") if got := s.Cursor(); got != (Cursor{Row: 0, Col: 2}) { t.Fatalf("Cursor() = %+v, want it parked in the last column", got) } wantLines(t, s, "abc", "") s.WriteRune('d') wantLines(t, s, "abc", "d") } func TestWrappingCanBeTurnedOff(t *testing.T) { s := NewScreen(3, 2) s.SetAutoWrap(false) writeString(s, "abcdef") wantLines(t, s, "abf", "") if got := s.Cursor(); got != (Cursor{Row: 0, Col: 2}) { t.Errorf("Cursor() = %+v, want it held in the last column", got) } } func TestMoveToClampsToTheScreen(t *testing.T) { s := NewScreen(10, 4) tests := []struct { name string row, col int want Cursor }{ {"inside", 2, 3, Cursor{Row: 2, Col: 3}}, {"past the bottom", 99, 0, Cursor{Row: 3, Col: 0}}, {"past the right", 0, 99, Cursor{Row: 0, Col: 9}}, {"negative", -5, -5, Cursor{}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { s.MoveTo(tc.row, tc.col) if got := s.Cursor(); got != tc.want { t.Errorf("MoveTo(%d, %d) gave %+v, want %+v", tc.row, tc.col, got, tc.want) } }) } } func TestCarriageReturnAndLineFeed(t *testing.T) { s := NewScreen(10, 3) writeString(s, "one") s.CarriageReturn() if got := s.Cursor().Col; got != 0 { t.Errorf("after a carriage return the cursor is at column %d, want 0", got) } s.LineFeed() if got := s.Cursor(); got != (Cursor{Row: 1, Col: 0}) { t.Errorf("Cursor() = %+v, want the start of the next line", got) } } func TestALineFeedOnTheLastRowScrolls(t *testing.T) { s := NewScreen(10, 3) for _, text := range []string{"one", "two", "three"} { writeString(s, text) s.CarriageReturn() s.LineFeed() } wantLines(t, s, "two", "three", "") if got := s.ScrollbackLen(); got != 1 { t.Errorf("ScrollbackLen() = %d, want the scrolled-off line kept", got) } } func TestReverseLineFeedScrollsDownAtTheTop(t *testing.T) { s := NewScreen(10, 3) writeString(s, "one") s.MoveTo(0, 0) s.ReverseLineFeed() wantLines(t, s, "", "one") if got := s.Cursor().Row; got != 0 { t.Errorf("Cursor().Row = %d, want it held at the top", got) } } func TestBackspaceMovesWithoutErasing(t *testing.T) { s := NewScreen(10, 2) writeString(s, "abc") s.Backspace() if got := s.Cursor().Col; got != 2 { t.Errorf("Cursor().Col = %d, want 2", got) } wantLines(t, s, "abc") s.MoveTo(0, 0) s.Backspace() if got := s.Cursor().Col; got != 0 { t.Errorf("Cursor().Col = %d, want it held at the first column", got) } } func TestTabMovesToTheNextStop(t *testing.T) { s := NewScreen(30, 2) s.Tab() if got := s.Cursor().Col; got != TabWidth { t.Errorf("Cursor().Col = %d, want %d", got, TabWidth) } s.MoveTo(0, 3) s.Tab() if got := s.Cursor().Col; got != TabWidth { t.Errorf("Cursor().Col = %d, want the next stop at %d", got, TabWidth) } } func TestTabStopsAtTheLastColumn(t *testing.T) { s := NewScreen(10, 2) s.MoveTo(0, 9) s.Tab() if got := s.Cursor().Col; got != 9 { t.Errorf("Cursor().Col = %d, want it held in the last column", got) } } func TestSaveAndRestoreCursorCarryTheStyle(t *testing.T) { s := NewScreen(10, 4) s.MoveTo(2, 3) s.SetStyle(tcell.StyleDefault.Bold(true)) s.SaveCursor() s.MoveTo(0, 0) s.SetStyle(tcell.StyleDefault) s.RestoreCursor() if got := s.Cursor(); got != (Cursor{Row: 2, Col: 3}) { t.Errorf("Cursor() = %+v, want what was saved", got) } if _, _, attrs := s.Style().Decompose(); attrs&tcell.AttrBold == 0 { t.Error("the saved style was not restored") } } func TestEraseDisplay(t *testing.T) { fill := func() *Screen { s := NewScreen(5, 3) for row, text := range []string{"aaaaa", "bbbbb", "ccccc"} { s.MoveTo(row, 0) writeString(s, text) } s.MoveTo(1, 2) return s } tests := []struct { name string mode EraseMode lines []string }{ {"to the end", EraseToEnd, []string{"aaaaa", "bb", ""}}, {"to the start", EraseToStart, []string{"", " bb", "ccccc"}}, {"all of it", EraseAll, []string{"", "", ""}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { s := fill() s.EraseDisplay(tc.mode) wantLines(t, s, tc.lines...) if got := s.Cursor(); got != (Cursor{Row: 1, Col: 2}) { t.Errorf("Cursor() = %+v, want it left where it was", got) } }) } } func TestEraseLine(t *testing.T) { tests := []struct { name string mode EraseMode want string }{ {"to the end", EraseToEnd, "ab"}, {"to the start", EraseToStart, " de"}, {"all of it", EraseAll, ""}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { s := NewScreen(5, 2) writeString(s, "abcde") s.MoveTo(0, 2) s.EraseLine(tc.mode) wantLines(t, s, tc.want) }) } } func TestEraseCharsBlanksInPlace(t *testing.T) { s := NewScreen(6, 2) writeString(s, "abcdef") s.MoveTo(0, 1) s.EraseChars(3) wantLines(t, s, "a ef") } func TestInsertAndDeleteChars(t *testing.T) { s := NewScreen(6, 2) writeString(s, "abcdef") s.MoveTo(0, 2) s.DeleteChars(2) wantLines(t, s, "abef") s.MoveTo(0, 2) s.InsertChars(2) wantLines(t, s, "ab ef") } func TestInsertAndDeleteCharsAreClampedToTheLine(t *testing.T) { s := NewScreen(4, 2) writeString(s, "abcd") s.MoveTo(0, 2) s.DeleteChars(99) // must not read past the line wantLines(t, s, "ab") s.InsertChars(99) wantLines(t, s, "ab") } func TestInsertAndDeleteLines(t *testing.T) { build := func() *Screen { s := NewScreen(5, 4) for row, text := range []string{"one", "two", "three", "four"} { s.MoveTo(row, 0) writeString(s, text) } s.MoveTo(1, 0) return s } s := build() s.InsertLines(1) wantLines(t, s, "one", "", "two", "three") s = build() s.DeleteLines(1) wantLines(t, s, "one", "three", "four", "") } func TestInsertLinesOutsideTheScrollRegionDoesNothing(t *testing.T) { // A program with a status line on row 0 narrows the region so that its // status line stays put; an insert from outside must not push it about. s := NewScreen(5, 4) s.MoveTo(0, 0) writeString(s, "stay") s.SetScrollRegion(1, 3) s.MoveTo(0, 0) s.InsertLines(2) wantLines(t, s, "stay") } func TestTheScrollRegionLimitsScrolling(t *testing.T) { s := NewScreen(5, 4) for row, text := range []string{"head", "a", "b", "c"} { s.MoveTo(row, 0) writeString(s, text) } s.SetScrollRegion(1, 3) s.MoveTo(3, 0) s.LineFeed() wantLines(t, s, "head", "b", "c", "") } func TestSettingTheScrollRegionMovesTheCursorHome(t *testing.T) { s := NewScreen(5, 4) s.MoveTo(3, 3) s.SetScrollRegion(1, 3) if got := s.Cursor(); got != (Cursor{}) { t.Errorf("Cursor() = %+v, want the top left", got) } } func TestAnImpossibleScrollRegionIsIgnored(t *testing.T) { s := NewScreen(5, 4) s.SetScrollRegion(1, 3) s.MoveTo(2, 2) s.SetScrollRegion(3, 1) // the wrong way round s.SetScrollRegion(2, 2) // empty s.MoveTo(3, 0) s.LineFeed() if got := s.Cursor().Row; got != 3 { t.Errorf("Cursor().Row = %d, want the earlier region still in force", got) } } func TestScrollingANarrowRegionIsNotHistory(t *testing.T) { // Lines leaving a narrow region are part of a program redrawing its own // display, not lines the session has finished with. s := NewScreen(5, 4) s.SetScrollRegion(1, 3) s.MoveTo(3, 0) s.LineFeed() if got := s.ScrollbackLen(); got != 0 { t.Errorf("ScrollbackLen() = %d, want nothing remembered", got) } } func TestScrollbackKeepsWhatLeavesTheTop(t *testing.T) { s := NewScreen(10, 2) for _, text := range []string{"one", "two", "three", "four"} { writeString(s, text) s.CarriageReturn() s.LineFeed() } if got := s.ScrollbackLen(); got != 3 { t.Fatalf("ScrollbackLen() = %d, want 3", got) } if got := lineText(s.ScrollbackLine(0)); got != "one" { t.Errorf("the oldest scrollback line is %q, want %q", got, "one") } if s.ScrollbackLine(-1) != nil || s.ScrollbackLine(99) != nil { t.Error("ScrollbackLine outside the history returned something") } } func TestScrollbackIsCapped(t *testing.T) { s := NewScreen(10, 2) s.SetMaxScrollback(3) for i := range 20 { writeString(s, string(rune('a'+i%26))) s.CarriageReturn() s.LineFeed() } if got := s.ScrollbackLen(); got != 3 { t.Errorf("ScrollbackLen() = %d, want the cap of 3", got) } } func TestScrollbackCanBeTurnedOff(t *testing.T) { s := NewScreen(10, 2) s.SetMaxScrollback(0) for range 5 { s.LineFeed() } if got := s.ScrollbackLen(); got != 0 { t.Errorf("ScrollbackLen() = %d, want none kept", got) } } func TestTheAlternateScreenIsAScratchSurface(t *testing.T) { s := NewScreen(10, 3) writeString(s, "before") s.UseAlternate(true) if !s.Alternate() { t.Fatal("Alternate() = false after switching to it") } wantLines(t, s, "") if got := s.Cursor(); got != (Cursor{}) { t.Errorf("Cursor() = %+v, want the top left of a fresh screen", got) } writeString(s, "during") s.UseAlternate(false) if s.Alternate() { t.Fatal("Alternate() = true after switching back") } wantLines(t, s, "before") } func TestSwitchingToTheScreenAlreadyShowingDoesNothing(t *testing.T) { s := NewScreen(10, 3) writeString(s, "kept") s.UseAlternate(false) // already on the primary wantLines(t, s, "kept") } func TestTheAlternateScreenHasNoScrollback(t *testing.T) { s := NewScreen(10, 2) s.UseAlternate(true) for range 5 { s.LineFeed() } if got := s.ScrollbackLen(); got != 0 { t.Errorf("ScrollbackLen() = %d, want none on a scratch surface", got) } } func TestLeavingTheAlternateScreenBringsBackTheHistory(t *testing.T) { s := NewScreen(10, 2) for _, text := range []string{"one", "two", "three"} { writeString(s, text) s.CarriageReturn() s.LineFeed() } before := s.ScrollbackLen() s.UseAlternate(true) s.UseAlternate(false) if got := s.ScrollbackLen(); got != before { t.Errorf("ScrollbackLen() = %d, want the %d lines from before", got, before) } } func TestResizeKeepsWhatFits(t *testing.T) { s := NewScreen(10, 3) for row, text := range []string{"one", "two", "three"} { s.MoveTo(row, 0) writeString(s, text) } s.Resize(20, 5) if width, height := s.Size(); width != 20 || height != 5 { t.Fatalf("Size() = %dx%d, want 20x5", width, height) } wantLines(t, s, "one", "two", "three", "", "") } func TestShrinkingKeepsTheNewestLinesAndRemembersTheRest(t *testing.T) { s := NewScreen(10, 4) for row, text := range []string{"one", "two", "three", "four"} { s.MoveTo(row, 0) writeString(s, text) } s.Resize(10, 2) wantLines(t, s, "three", "four") if got := s.ScrollbackLen(); got != 2 { t.Fatalf("ScrollbackLen() = %d, want the two lost lines kept", got) } if got := lineText(s.ScrollbackLine(0)); got != "one" { t.Errorf("the oldest remembered line is %q, want %q", got, "one") } } func TestShrinkingAMostlyEmptyScreenKeepsWhatIsOnIt(t *testing.T) { // A command that prints one line and exits leaves the cursor near the top // and the rest of the screen blank. Dropping rows from the top to make // room then throws the output away while keeping blank rows below it — // which is how "echo TADA" in a tool window showed nothing at all. s := NewScreen(20, 24) writeString(s, "TADA") s.LineFeed() s.CarriageReturn() s.Resize(20, 20) if got := lineText(s.Line(0)); got != "TADA" { t.Errorf("the first row is %q, want the output still on screen", got) } if got := s.ScrollbackLen(); got != 0 { t.Errorf("ScrollbackLen() = %d; nothing had scrolled off, so nothing should be remembered", got) } } func TestShrinkingDropsOnlyAsManyRowsAsTheCursorNeeds(t *testing.T) { // Halfway between the two cases: content down to row 5, cursor on row 5, // shrunk to 4 rows. Two rows have to go for the cursor to fit, and only // two should. s := NewScreen(10, 10) for row, text := range []string{"a", "b", "c", "d", "e", "f"} { s.MoveTo(row, 0) writeString(s, text) } s.Resize(10, 4) wantLines(t, s, "c", "d", "e", "f") if got := s.ScrollbackLen(); got != 2 { t.Errorf("ScrollbackLen() = %d, want the two rows above the kept ones", got) } } func TestNarrowingCutsColumnsRatherThanReflowing(t *testing.T) { // No terminal reflows on resize, and pretending to would move text the // program believes it has already placed. s := NewScreen(10, 2) writeString(s, "abcdefghij") s.Resize(4, 2) wantLines(t, s, "abcd") } func TestResizingToTheSameSizeChangesNothing(t *testing.T) { s := NewScreen(10, 3) writeString(s, "kept") s.MoveTo(2, 5) s.Resize(10, 3) wantLines(t, s, "kept") if got := s.Cursor(); got != (Cursor{Row: 2, Col: 5}) { t.Errorf("Cursor() = %+v, want it untouched", got) } } func TestResizingWhileOnTheAlternateScreenKeepsThePrimaryUsable(t *testing.T) { s := NewScreen(10, 3) writeString(s, "primary") s.UseAlternate(true) s.Resize(20, 6) s.UseAlternate(false) if width, height := s.Size(); width != 20 || height != 6 { t.Fatalf("Size() = %dx%d, want 20x6", width, height) } wantLines(t, s, "primary") if got := len(s.Line(0)); got != 20 { t.Errorf("the restored line is %d cells wide, want 20", got) } } func TestResizeClampsTheCursor(t *testing.T) { s := NewScreen(10, 5) s.MoveTo(4, 9) s.Resize(4, 2) got := s.Cursor() if got.Row > 1 || got.Col > 3 { t.Errorf("Cursor() = %+v, want it inside a 4x2 screen", got) } } func TestResetPutsEverythingBack(t *testing.T) { s := NewScreen(10, 3) writeString(s, "text") s.SetStyle(tcell.StyleDefault.Bold(true)) s.SetScrollRegion(1, 2) s.SetCursorVisible(false) s.UseAlternate(true) s.Reset() wantLines(t, s, "", "", "") if s.Alternate() { t.Error("Alternate() = true after a reset") } if !s.CursorVisible() { t.Error("CursorVisible() = false after a reset") } if got := s.Cursor(); got != (Cursor{}) { t.Errorf("Cursor() = %+v, want the top left", got) } if _, _, attrs := s.Style().Decompose(); attrs&tcell.AttrBold != 0 { t.Error("the style survived a reset") } } func TestCellAtAndLineOutsideTheScreen(t *testing.T) { s := NewScreen(4, 2) if got := s.CellAt(99, 0).Rune; got != ' ' { t.Errorf("CellAt off the screen gave %q, want a space", got) } if s.Line(-1) != nil || s.Line(99) != nil { t.Error("Line off the screen returned something") } if got := s.LineText(99); got != "" { t.Errorf("LineText off the screen gave %q", got) } } func TestErasingKeepsTheCurrentBackground(t *testing.T) { // A program painting a coloured panel and then clearing a line inside it // expects the panel's colour to stay, not a hole. s := NewScreen(5, 2) panel := tcell.StyleDefault.Background(tcell.ColorNavy) s.SetStyle(panel) s.EraseLine(EraseAll) if _, background, _ := s.CellAt(0, 0).Style.Decompose(); background != tcell.ColorNavy { t.Errorf("the erased cell has background %v, want navy", background) } } // lineText renders a line as a string with its trailing blanks removed. func lineText(line Line) string { var out strings.Builder for _, cell := range line { out.WriteRune(cell.Rune) } return strings.TrimRight(out.String(), " ") }