diff --git a/cursed_renderer.go b/cursed_renderer.go index 8eaafb23d4..23b8c13a63 100644 --- a/cursed_renderer.go +++ b/cursed_renderer.go @@ -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. @@ -851,4 +880,4 @@ func keyboardEnhancementsFlags(ke KeyboardEnhancements) int { flags |= ansi.KittyReportAssociatedKeys } return flags -} +} \ No newline at end of file diff --git a/cursed_renderer_test.go b/cursed_renderer_test.go new file mode 100644 index 0000000000..f2c16bf82a --- /dev/null +++ b/cursed_renderer_test.go @@ -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()) + } +}