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
41 changes: 35 additions & 6 deletions cursed_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -618,16 +618,45 @@ func (s *cursedRenderer) setColorProfile(p colorprofile.Profile) {
// resize implements renderer.
func (s *cursedRenderer) resize(w, h int) {
s.mu.Lock()
defer s.mu.Unlock()

// In inline mode, eagerly erase the previous frame from the terminal
// before the next render. The renderer's clearUpdate path defers the
// physical erase to flush time and emits it as a relative move(0, 0)
// followed by ED-0. When [cursedRenderer.insertAbove] has just reset
// the renderer's tracked cursor to (0, 0), that move degenerates to a
// no-op and ED-0 fires at whatever physical position the cursor ends
// up at after terminal reflow, leaving the previous frame stranded
// above the new one and pushing it into scrollback on subsequent
// resizes. Anchoring the erase to the renderer's known column-0
// position before the reflow propagates avoids that drift. Alt-screen
// mode uses absolute positioning and its own clear semantics, so we
// skip this path there.
if s.lastView != nil && !s.lastView.AltScreen {
_, y := s.scr.Position()
var sb strings.Builder
sb.WriteByte('')
if y > 0 {
sb.WriteString(ansi.CursorUp(y))
}
sb.WriteString(ansi.EraseScreenBelow)
if s.logger != nil {
s.logger.Printf("resize erase: %q", sb.String())
}
_, _ = io.WriteString(s.w, sb.String())
// Force moveCursor's first-move safety on the next render so an
// explicit is emitted before the next cursor move regardless of
// whether the target happens to be (0, 0).
s.scr.SetPosition(-1, -1)
}

// We need to mark the screen for clear to force a redraw. However, we
// only do so if we're using alt screen or the width has changed.
// That's because redrawing is expensive and we can avoid it if the
// width hasn't changed in inline mode. On the other hand, when using
// alt screen mode, we always want to redraw because some terminals
// would scroll the screen and our content would be lost.
// That's because redrawing is expensive and we can avoid it if we're
// not using alt screen and the width hasn't changed.
s.scr.Erase()
s.width, s.height = w, h
s.scr.Resize(s.width, s.height)
s.mu.Unlock()
}

// clearScreen implements renderer.
Expand Down Expand Up @@ -851,4 +880,4 @@ func keyboardEnhancementsFlags(ke KeyboardEnhancements) int {
flags |= ansi.KittyReportAssociatedKeys
}
return flags
}
}
Expand Down
101 changes: 101 additions & 0 deletions cursed_renderer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package tea

import (
"bytes"
"testing"

"github.com/charmbracelet/x/ansi"
)

// TestResizeEmitsEagerEraseInline verifies that in inline mode,
// cursedRenderer.resize eagerly writes a physical erase sequence to the writer
// instead of deferring it to the next render. Otherwise, after
// [cursedRenderer.insertAbove] resets the renderer's tracked cursor to (0, 0),
// the next render's clearUpdate path emits a move(0, 0) that degenerates to a
// no-op, and the trailing ED-0 fires at the wrong physical row.
func TestResizeEmitsEagerEraseInline(t *testing.T) {
cases := []struct {
name string
// cursorY is the renderer's tracked cursor row at the moment resize
// fires. 0 mirrors the post-insertAbove state; >0 mirrors steady-state
// mid-render.
cursorY int
// want is a substring the resize output must contain.
want []byte
}{
{
name: "after_insert_above",
cursorY: 0,
want: []byte("\r" + ansi.EraseScreenBelow),
},
{
name: "mid_view",
cursorY: 3,
want: []byte("\r" + ansi.CursorUp(3) + ansi.EraseScreenBelow),
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
r := newCursedRenderer(&buf, []string{"TERM=xterm-256color"}, 80, 24)

r.render(NewView("a0\na1\na2\na3\na4\na5\na6"))
if err := r.flush(false); err != nil {
t.Fatalf("initial flush: %v", err)
}
// Pin the renderer's tracked cursor to a known position so the
// assertions are independent of transformLine's end-of-line
// behavior. (0, 0) mirrors the post-insertAbove state.
r.scr.SetPosition(0, tc.cursorY)
buf.Reset()

r.resize(60, 24)

got := buf.Bytes()
if !bytes.Contains(got, tc.want) {
t.Fatalf("resize did not emit the expected eager erase\n want substring %q\n got %q",
tc.want, got)
}
})
}
}

// TestResizeAltScreenSkipsEagerErase verifies that the inline-mode eager-erase
// path does not fire when the active view is alt-screen. Alt-screen uses
// absolute positioning and its own clear contract; the inline workaround would
// be both incorrect and unnecessary there.
func TestResizeAltScreenSkipsEagerErase(t *testing.T) {
var buf bytes.Buffer
r := newCursedRenderer(&buf, []string{"TERM=xterm-256color"}, 80, 24)

v := NewView("a0\na1")
v.AltScreen = true
r.render(v)
if err := r.flush(false); err != nil {
t.Fatalf("initial flush: %v", err)
}
buf.Reset()

r.resize(60, 24)

if buf.Len() != 0 {
t.Fatalf("resize wrote %d bytes in alt-screen mode (should write zero): %q",
buf.Len(), buf.Bytes())
}
}

// TestResizeBeforeFirstViewSkipsEagerErase verifies that resize before any
// view has been set (i.e. the initial resize during Program startup) does not
// write an erase sequence — there is nothing on the terminal yet to erase.
func TestResizeBeforeFirstViewSkipsErase(t *testing.T) {
var buf bytes.Buffer
r := newCursedRenderer(&buf, []string{"TERM=xterm-256color"}, 80, 24)

r.resize(60, 24)

if buf.Len() != 0 {
t.Fatalf("resize wrote %d bytes before any view was set: %q",
buf.Len(), buf.Bytes())
}
}