Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 43 additions & 17 deletions cursed_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,16 @@ func (s *cursedRenderer) close() (err error) {
if lv.AltScreen {
enableAltScreen(s, false, true)
} else {
// A view without a trailing newline has content on its last row,
// unlike a view ending in a newline whose last row is the
// intentional blank line below the frame. Move the cursor one row
// further down (scrolling the screen if the frame fills it, just
// like printing a trailing newline would) so the closing erase and
// the shell prompt that follows start below the frame instead of
// overwriting its last line.
if lv.Content != "" && !strings.HasSuffix(lv.Content, "\n") {
_, _ = s.scr.WriteString("\r\n")
}
_, _ = s.scr.WriteString(ansi.EraseScreenBelow)
}
if lv.Cursor == nil {
Expand Down Expand Up @@ -299,14 +309,21 @@ func (s *cursedRenderer) flush(closing bool) error {
}

content := uv.NewStyledString(view.Content)
contentHeight := content.Height()
if !view.AltScreen {
// We need to resizes the screen based on the frame height and
// terminal width. This is because the frame height can change based on
// the content of the frame. For example, if the frame contains a list
// of items, the height of the frame will be the number of items in the
// list. This is different from the alt screen buffer, which has a
// fixed height and width.
frameHeight := content.Height()
//
// Frames taller than the screen can only show their bottom rows, so
// the frame area is capped at the screen height. Keeping the frame
// area equal to what's actually on screen also keeps it equal to the
// screen buffer's bounds across flushes, which lets unchanged frames
// render as no-ops instead of forcing a full redraw every flush.
frameHeight := min(contentHeight, s.height)
if frameHeight != frameArea.Dy() {
frameArea.Max.Y = frameHeight
}
Expand Down Expand Up @@ -342,13 +359,16 @@ func (s *cursedRenderer) flush(closing bool) error {
// Clear our screen buffer before copying the new frame into it to ensure
// we erase any old content.
s.cellbuf.Clear()
content.Draw(s.cellbuf, s.cellbuf.Bounds())

// If the frame height is greater than the screen height, we drop the
// lines from the top of the buffer.
if frameHeight := frameArea.Dy(); frameHeight > s.height {
s.cellbuf.Lines = s.cellbuf.Lines[frameHeight-s.height:]
// Frames taller than the screen are drawn with their top rows shifted
// above the buffer so they get clipped away, and the buffer keeps only
// the bottom, visible rows. Drawing the frame this way, instead of
// resizing the buffer to the full frame height and slicing its lines,
// keeps the buffer's bounds stable across flushes.
drawArea := s.cellbuf.Bounds()
if !view.AltScreen && contentHeight > drawArea.Dy() {
drawArea.Min.Y -= contentHeight - drawArea.Dy()
}
content.Draw(s.cellbuf, drawArea)

// Alt screen mode.
shouldUpdateAltScreen := (s.lastView == nil && view.AltScreen) || (s.lastView != nil && s.lastView.AltScreen != view.AltScreen)
Expand Down Expand Up @@ -514,16 +534,22 @@ func (s *cursedRenderer) flush(closing bool) error {
// cursor position might get updated during rendering.
s.scr.MoveTo(view.Cursor.X, view.Cursor.Y)
} else if !view.AltScreen {
// We don't want the cursor to be dangling at the end of the line in
// inline mode because it can cause unwanted line wraps in some
// terminals. So we move it to the beginning of the next line if
// necessary.
// This is only needed when the cursor is hidden because when it's
// visible, we already set its position above.
x, y := s.scr.Position()
if x >= s.width-1 {
s.scr.MoveTo(0, y)
}
// Park the cursor at the beginning of the frame's last line. The
// cursor is hidden in this branch (a visible cursor is positioned
// above), so parking it never moves anything the user can see, and
// like before, it keeps the cursor from dangling at the end of the
// last drawn line, which can cause unwanted line wraps in some
// terminals.
//
// The frame's height counts the view's trailing blank lines, so
// parking here also commits the renderer to them: a frame ending
// in intentional blank lines keeps its bottom margin, and output
// printed after the program exits starts below the frame rather
// than on its last visible line. The frame area is capped at the
// screen height, so frames taller than the screen park on the
// screen's last row, which is their last visible row.
y := max(frameArea.Dy()-1, 0)
s.scr.MoveTo(0, y)
}

if err := s.scr.Flush(); err != nil {
Expand Down
147 changes: 147 additions & 0 deletions cursed_renderer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,153 @@ func assertInOrder(t *testing.T, got string, wants ...string) {
}
}

// Views ending in blank lines should keep them: the frame's height counts
// the view's trailing blank lines, and the cursor must be parked below the
// frame after rendering. That way the blank lines survive the closing
// erase, and output printed after the program exits starts below the frame
// instead of on its last visible line.
func TestCursedRenderer_trailingBlankLines(t *testing.T) {
t.Parallel()

var out bytes.Buffer
r := newCursedRenderer(&out, []string{"TERM=xterm-256color"}, 80, 24)
r.start()

// A 3-line frame: one visible line, two intentional blank lines. The
// cursor must be parked on the third row (0-indexed row 2).
r.render(NewView("hello\n\n"))
if err := r.flush(false); err != nil {
t.Fatal(err)
}
if x, y := r.scr.Position(); x != 0 || y != 2 {
t.Fatalf("cursor parked at (%d, %d), want (0, 2)", x, y)
}

// The parked position must survive a repaint that redraws the same
// content (e.g. after a suspend/resize clears the buffer), so the
// frame keeps its trailing blank lines across flushes.
r.pendingErase = true
r.render(NewView("hello\n\n"))
if err := r.flush(false); err != nil {
t.Fatal(err)
}
if x, y := r.scr.Position(); x != 0 || y != 2 {
t.Fatalf("cursor parked at (%d, %d) after repaint, want (0, 2)", x, y)
}

// Frames without trailing blank lines park the cursor at the start of
// their last line, matching the previous renderer's behavior.
r.render(NewView("hello"))
if err := r.flush(false); err != nil {
t.Fatal(err)
}
if x, y := r.scr.Position(); x != 0 || y != 0 {
t.Fatalf("cursor parked at (%d, %d), want (0, 0)", x, y)
}
if err := r.close(); err != nil {
t.Fatal(err)
}
}

// Frames taller than the screen drop their top rows, so the parked cursor
// must land on the screen's last visible row, not beyond it.
func TestCursedRenderer_trailingBlankLinesTallFrame(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this checks the parked position but not that a second unchanged flush is a no-op. that's why the regression above was missed

t.Parallel()

var out bytes.Buffer
r := newCursedRenderer(&out, []string{"TERM=xterm-256color"}, 80, 24)
r.start()

// A 30-line frame (26 content lines + 4 trailing blank lines) inside a
// 24-row terminal. The renderer drops the top 6 rows, leaving the
// screen's last visible row as the frame's last row.
content := strings.Repeat("line\n", 30)
r.render(NewView(content))
if err := r.flush(false); err != nil {
t.Fatal(err)
}
if x, y := r.scr.Position(); x != 0 || y != 23 {
t.Fatalf("cursor parked at (%d, %d), want (0, 23)", x, y)
}

// An unchanged flush must be a no-op. The frame area is capped at the
// screen height, so it stays equal to the screen buffer's bounds across
// flushes. Otherwise every flush redraws the whole frame, which scrolls
// the screen each time in terminals where the frame exceeds the visible
// area, flooding the scrollback (notably in tmux).
out.Reset()
r.render(NewView(content))
if err := r.flush(false); err != nil {
t.Fatal(err)
}
if got := out.String(); got != "" {
t.Fatalf("expected no output for unchanged tall frame, got %q", got)
}

// A changed flush of a tall frame must redraw only the changed lines,
// not the whole frame.
out.Reset()
changed := strings.Repeat("line\n", 29) + "CHANGED\n"
r.render(NewView(changed))
if err := r.flush(false); err != nil {
t.Fatal(err)
}
got := out.String()
if !strings.Contains(got, "CHANGED") {
t.Fatalf("expected changed line to be rendered, got %q", got)
}
if strings.Contains(got, ansi.EraseScreenBelow) || strings.Contains(got, "line\r\nline") {
t.Fatalf("expected partial redraw for tall frame, got %q", got)
}
if err := r.close(); err != nil {
t.Fatal(err)
}
}

// A view without a trailing newline has content on its last row. On exit
// the cursor must move below that row so the closing erase and the shell
// prompt that follows don't overwrite the frame's last line.
func TestCursedRenderer_closeBelowFrameWithoutTrailingNewline(t *testing.T) {
t.Parallel()

var out bytes.Buffer
r := newCursedRenderer(&out, []string{"TERM=xterm-256color"}, 80, 24)
r.start()

r.render(NewView("hello"))
if err := r.flush(false); err != nil {
t.Fatal(err)
}
out.Reset()
if err := r.close(); err != nil {
t.Fatal(err)
}
got := out.String()
// The cursor must go one row below the frame's last row before the
// closing erase-below, so the erase (and the shell prompt) start below
// the frame rather than on its last line.
assertInOrder(t, got, "\r\n", ansi.EraseScreenBelow)
if x, y := r.scr.Position(); x != 0 || y != 0 {
t.Fatalf("cursor parked at (%d, %d), want (0, 0)", x, y)
}

// A view ending in a newline already parks the cursor on the blank row
// below the frame, so no extra newline is needed.
out.Reset()
r.start()
r.render(NewView("hello\n"))
if err := r.flush(false); err != nil {
t.Fatal(err)
}
out.Reset()
if err := r.close(); err != nil {
t.Fatal(err)
}
if got := out.String(); strings.Contains(got, "\r\n"+ansi.EraseScreenBelow) {
t.Fatalf("expected no extra newline before closing erase, got %q", got)
}
}

func TestCursedRenderer_restoresKittyKeyboardStack(t *testing.T) {
t.Parallel()

Expand Down
3 changes: 2 additions & 1 deletion testdata/TestClearMsg/bg_fg_cur_color.golden
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
[?25l[?2004h[>4;2m[>1usuccess[>4m[<1u[?25h[?2004l[?2026$p[?2027$p]10;?]11;?]12;?
[?25l[?2004h[>4;2m[>1usuccess[>4m[<1u
[?25h[?2004l[?2026$p[?2027$p]10;?]11;?]12;?
Expand Down
3 changes: 2 additions & 1 deletion testdata/TestClearMsg/clear_screen.golden
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
[?25l[?2004h[>4;2m[>1usuccess[>4m[<1u[?25h[?2004l[?2026$p[?2027$p
[?25l[?2004h[>4;2m[>1usuccess[>4m[<1u
[?25h[?2004l[?2026$p[?2027$p
Expand Down
3 changes: 2 additions & 1 deletion testdata/TestClearMsg/read_set_clipboard.golden
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
[?25l[?2004h[>4;2m[>1usuccess[>4m[<1u[?25h[?2004l[?2026$p[?2027$p]52;c;?]52;c;c3VjY2Vzcw==
[?25l[?2004h[>4;2m[>1usuccess[>4m[<1u
[?25h[?2004l[?2026$p[?2027$p]52;c;?]52;c;c3VjY2Vzcw==
Expand Down
3 changes: 2 additions & 1 deletion testdata/TestViewModel/altscreen.golden
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
[?25l[?2004h[>4;2m[>1usuccess[>4m[<1u[?25h[?2004l[?2026$p[?2027$p
[?25l[?2004h[>4;2m[>1usuccess[>4m[<1u
[?25h[?2004l[?2026$p[?2027$p
Expand Down
3 changes: 2 additions & 1 deletion testdata/TestViewModel/bg_set_color.golden
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
[?25l[?2004h[>4;2m[>1u]11;#ffffffsuccess[>4m[<1u[?25h[?2004l]111[?2026$p[?2027$p
[?25l[?2004h[>4;2m[>1u]11;#ffffffsuccess[>4m[<1u
[?25h[?2004l]111[?2026$p[?2027$p
Expand Down
3 changes: 2 additions & 1 deletion testdata/TestViewModel/bp_stop_start.golden
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
[?25l[?2004h[>4;2m[>1usuccess[>4m[<1u[?25h[?2004l[?2026$p[?2027$p
[?25l[?2004h[>4;2m[>1usuccess[>4m[<1u
[?25h[?2004l[?2026$p[?2027$p
Expand Down
3 changes: 2 additions & 1 deletion testdata/TestViewModel/cursor_hide.golden
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
[?25l[?2004h[>4;2m[>1usuccess[>4m[<1u[?25h[?2004l[?2026$p[?2027$p
[?25l[?2004h[>4;2m[>1usuccess[>4m[<1u
[?25h[?2004l[?2026$p[?2027$p
Expand Down
3 changes: 2 additions & 1 deletion testdata/TestViewModel/cursor_hideshow.golden
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
[?2004h[>4;2m[>1u[1 qsuccess[?25h[>4m[<1u[?2004l[?2026$p[?2027$p
[?2004h[>4;2m[>1u[1 qsuccess[?25h[>4m[<1u
[?2004l[?2026$p[?2027$p
Expand Down
3 changes: 2 additions & 1 deletion testdata/TestViewModel/kitty_stop_startreleases.golden
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
[?25l[?2004h[>4;2m[>3usuccess[>4m[<1u[?25h[?2004l[?2026$p[?2027$p
[?25l[?2004h[>4;2m[>3usuccess[>4m[<1u
[?25h[?2004l[?2026$p[?2027$p
Expand Down
3 changes: 2 additions & 1 deletion testdata/TestViewModel/mouse_allmotion.golden
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
[?25l[?2004h[?1003h[?1006h[>4;2m[>1usuccess[>4m[<1u[?25h[?2004l[?1002l[?1003l[?1006l[?2026$p[?2027$p
[?25l[?2004h[?1003h[?1006h[>4;2m[>1usuccess[>4m[<1u
[?25h[?2004l[?1002l[?1003l[?1006l[?2026$p[?2027$p
Expand Down
3 changes: 2 additions & 1 deletion testdata/TestViewModel/mouse_cellmotion.golden
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
[?25l[?2004h[?1002h[?1006h[>4;2m[>1usuccess[>4m[<1u[?25h[?2004l[?1002l[?1003l[?1006l[?2026$p[?2027$p
[?25l[?2004h[?1002h[?1006h[>4;2m[>1usuccess[>4m[<1u
[?25h[?2004l[?1002l[?1003l[?1006l[?2026$p[?2027$p
Expand Down
3 changes: 2 additions & 1 deletion testdata/TestViewModel/mouse_disable.golden
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
[?25l[?2004h[>4;2m[>1usuccess[>4m[<1u[?25h[?2004l[?2026$p[?2027$p
[?25l[?2004h[>4;2m[>1usuccess[>4m[<1u
[?25h[?2004l[?2026$p[?2027$p
Expand Down
Loading