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
74 changes: 51 additions & 23 deletions cursed_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,66 +105,86 @@ func (s *cursedRenderer) start() {
// starting the renderer again after it was stopped.
s.starting = true

if s.lastView == nil {
s.restoreModes(true)
}

// restoreModes replays the terminal modes from the last rendered view. It is
// used when the renderer starts again after being stopped and when recovering
// from external writes to the terminal, e.g. a stray alt-screen exit.
//
// The caller must hold s.mu. When pushKitty is true a fresh Kitty keyboard
// stack entry is pushed, which is what [cursedRenderer.start] wants since
// [cursedRenderer.close] popped the previous one. Otherwise the topmost entry
// is updated in place so repeated calls, such as one per ClearScreen, don't
// grow the stack.
func (s *cursedRenderer) restoreModes(pushKitty bool) {
lv := s.lastView
if lv == nil {
return
}

if s.lastView.AltScreen {
if lv.AltScreen {
enableAltScreen(s, true, true)
}
enableTextCursor(s, s.lastView.Cursor != nil)
if s.lastView.Cursor != nil {
if s.lastView.Cursor.Color != nil {
col, ok := colorful.MakeColor(s.lastView.Cursor.Color)
enableTextCursor(s, lv.Cursor != nil)
if lv.Cursor != nil {
if lv.Cursor.Color != nil {
col, ok := colorful.MakeColor(lv.Cursor.Color)
if ok {
_, _ = s.scr.WriteString(ansi.SetCursorColor(col.Hex()))
}
}
curStyle := encodeCursorStyle(s.lastView.Cursor.Shape, s.lastView.Cursor.Blink)
curStyle := encodeCursorStyle(lv.Cursor.Shape, lv.Cursor.Blink)
if curStyle != 0 && curStyle != 1 {
_, _ = s.scr.WriteString(ansi.SetCursorStyle(curStyle))
}
}
if s.lastView.ForegroundColor != nil {
col, ok := colorful.MakeColor(s.lastView.ForegroundColor)
if lv.ForegroundColor != nil {
col, ok := colorful.MakeColor(lv.ForegroundColor)
if ok {
_, _ = s.scr.WriteString(ansi.SetForegroundColor(col.Hex()))
}
}
if s.lastView.BackgroundColor != nil {
col, ok := colorful.MakeColor(s.lastView.BackgroundColor)
if lv.BackgroundColor != nil {
col, ok := colorful.MakeColor(lv.BackgroundColor)
if ok {
_, _ = s.scr.WriteString(ansi.SetBackgroundColor(col.Hex()))
}
}
if !s.lastView.DisableBracketedPasteMode {
if !lv.DisableBracketedPasteMode {
_, _ = s.scr.WriteString(ansi.SetModeBracketedPaste)
}
if s.lastView.ReportFocus {
if lv.ReportFocus {
_, _ = s.scr.WriteString(ansi.SetModeFocusEvent)
}
switch s.lastView.MouseMode {
switch lv.MouseMode {
case MouseModeNone:
case MouseModeCellMotion:
_, _ = s.scr.WriteString(ansi.SetModeMouseButtonEvent + ansi.SetModeMouseExtSgr)
case MouseModeAllMotion:
_, _ = s.scr.WriteString(ansi.SetModeMouseAnyEvent + ansi.SetModeMouseExtSgr)
}
if s.lastView.WindowTitle != "" {
_, _ = s.scr.WriteString(ansi.SetWindowTitle(s.lastView.WindowTitle))
if lv.WindowTitle != "" {
_, _ = s.scr.WriteString(ansi.SetWindowTitle(lv.WindowTitle))
}
if s.lastView.ProgressBar != nil {
setProgressBar(s, s.lastView.ProgressBar)
if lv.ProgressBar != nil {
setProgressBar(s, lv.ProgressBar)
}
if !s.noInput {
// Enable modifyOtherKeys and Kitty keyboard protocol.
// Both can coexist; terminals ignore what they don't support.
_, _ = s.scr.WriteString(ansi.SetModifyOtherKeys2)

kittyFlags := keyboardEnhancementsFlags(s.lastView.KeyboardEnhancements)
// The entry was popped when the renderer was stopped, so push a fresh
// one for the screen we're about to restore.
_, _ = s.scr.WriteString(ansi.PushKittyKeyboard(kittyFlags))
kittyFlags := keyboardEnhancementsFlags(lv.KeyboardEnhancements)
if pushKitty {
// The entry was popped when the renderer was stopped, so push a
// fresh one for the screen we're about to restore.
_, _ = s.scr.WriteString(ansi.PushKittyKeyboard(kittyFlags))
} else {
// Update the topmost stack entry in place instead of pushing so
// repeated calls don't grow the stack.
_, _ = s.scr.WriteString(ansi.KittyKeyboard(kittyFlags, 1))
}
}
}

Expand Down Expand Up @@ -682,12 +702,20 @@ func (s *cursedRenderer) resize(w, h int) {
// clearScreen implements renderer.
func (s *cursedRenderer) clearScreen() {
s.mu.Lock()
defer s.mu.Unlock()

// The terminal may have been modified from the outside, e.g. a child
// process exiting the alt screen. Reset the cell diff so the next flush
// repaints everything, then replay the modes from the last view so the
// terminal is fully restored.
reset(s)
s.restoreModes(false)

// Move the cursor to the top left corner of the screen and trigger a full
// screen redraw.
s.scr.MoveTo(0, 0)
s.scr.Erase()
s.pendingErase = true
s.mu.Unlock()
}

// enableAltScreen sets the alt screen mode.
Expand Down
123 changes: 123 additions & 0 deletions cursed_renderer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,126 @@ func TestCursedRenderer_updatesKittyKeyboardFlagsInPlace(t *testing.T) {
t.Fatalf("expected kitty keyboard protocol to be pushed once, got %d pushes in %q", n, got)
}
}

// clearScreenTestRenderer returns a renderer wired to a buffer together with a
// render function that pushes a view and flushes it.
func clearScreenTestRenderer(t *testing.T) (*cursedRenderer, *bytes.Buffer, func(View)) {
t.Helper()

var out bytes.Buffer
r := newCursedRenderer(&out, []string{"TERM=xterm-256color"}, 80, 24)
render := func(v View) {
t.Helper()
r.render(v)
if err := r.flush(false); err != nil {
t.Fatal(err)
}
}
return r, &out, render
}

// After external tty damage ClearScreen must replay the alt screen and repaint
// the whole view on a normal (non-closing) flush.
func TestCursedRenderer_clearScreenRestoresAltScreen(t *testing.T) {
t.Parallel()

r, out, render := clearScreenTestRenderer(t)

view := NewView("hello")
view.AltScreen = true
render(view)

out.Reset()
r.clearScreen()
// A normal flush, not a closing one, must not skip the redraw.
if err := r.flush(false); err != nil {
t.Fatal(err)
}

got := out.String()
assertInOrder(t, got, ansi.SetModeAltScreenSaveCursor, "hello")
}

// Inline mode must keep working: ClearScreen must not enter the alt screen.
func TestCursedRenderer_clearScreenInline(t *testing.T) {
t.Parallel()

r, out, render := clearScreenTestRenderer(t)

view := NewView("hello")
render(view)

out.Reset()
r.clearScreen()
if err := r.flush(false); err != nil {
t.Fatal(err)
}

got := out.String()
if strings.Contains(got, ansi.SetModeAltScreenSaveCursor) {
t.Fatalf("expected inline mode not to enter the alt screen, got %q", got)
}
if !strings.Contains(got, "hello") {
t.Fatalf("expected the full frame to be repainted, got %q", got)
}
}

// Calling ClearScreen repeatedly must update the Kitty keyboard flags in place
// rather than pushing a new stack entry every time.
func TestCursedRenderer_clearScreenDoesNotGrowKittyStack(t *testing.T) {
t.Parallel()

r, out, render := clearScreenTestRenderer(t)

view := NewView("hello")
view.KeyboardEnhancements.ReportEventTypes = true
flags := keyboardEnhancementsFlags(view.KeyboardEnhancements)
render(view)

const clears = 3
out.Reset()
for range clears {
r.clearScreen()
if err := r.flush(false); err != nil {
t.Fatal(err)
}
}

got := out.String()
if n := strings.Count(got, ansi.PushKittyKeyboard(flags)); n != 0 {
t.Fatalf("expected no kitty keyboard pushes, got %d in %q", n, got)
}
if n := strings.Count(got, ansi.KittyKeyboard(flags, 1)); n != clears {
t.Fatalf("expected kitty keyboard flags to be set in place %d times, got %d in %q", clears, n, got)
}
}

// ClearScreen must re-emit the modes carried by the view, e.g. mouse tracking,
// bracketed paste, and focus reporting, which an external writer may have reset.
func TestCursedRenderer_clearScreenRestoresModes(t *testing.T) {
t.Parallel()

r, out, render := clearScreenTestRenderer(t)

view := NewView("hello")
view.MouseMode = MouseModeCellMotion
view.ReportFocus = true
render(view)

out.Reset()
r.clearScreen()
if err := r.flush(false); err != nil {
t.Fatal(err)
}

got := out.String()
for _, want := range []string{
ansi.SetModeMouseButtonEvent + ansi.SetModeMouseExtSgr,
ansi.SetModeBracketedPaste,
ansi.SetModeFocusEvent,
} {
if !strings.Contains(got, want) {
t.Fatalf("expected %q to be replayed by ClearScreen, got %q", want, got)
}
}
}
5 changes: 5 additions & 0 deletions screen.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ type WindowSizeMsg struct {
// before the next update. This can be used to move the cursor to the top left
// of the screen and clear visual clutter when the alt screen is not in use.
//
// ClearScreen also restores the terminal modes from the last view and forces a
// full redraw. This makes it suitable for recovering the terminal after
// something else wrote to the tty and reset its modes, for example a child
// process that left the alt screen.
//
// Note that it should never be necessary to call ClearScreen() for regular
// redraws.
func ClearScreen() Msg {
Expand Down
Loading