From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Web Agent Date: Wed, 21 Oct 2026 22:37:54 +0200 Subject: [PATCH 3/3] feat(renderer): write queued output with the next frame Output a program queues for the terminal (tea.Raw, and Bubble Tea's own queries and mode changes) is written on each tick before the frame, in a write of its own, outside the frame's synchronized update. A terminal may draw between the two writes. A program that places graphics over its cells (kitty images, sixels, or a terminal's own overlays) and then draws the cells under them shows the graphics over the previous frame for a moment, or the new cells before their graphics. This writes the queued output and the frame together: in one write, the queued output first as before, inside the frame's synchronized update when the renderer uses synchronized updates. When the frame has not changed, the queued output goes in a synchronized update of its own. Without a renderer, the queued output is written as before. --- cursed_renderer.go | 43 +++++++++++++++++++++++++++---- cursed_renderer_test.go | 57 +++++++++++++++++++++++++++++++++++++++-- nil_renderer.go | 2 +- renderer.go | 7 +++-- tea.go | 26 ++++++++++++++++--- 5 files changed, 122 insertions(+), 13 deletions(-) diff --git a/cursed_renderer.go b/cursed_renderer.go index c029e63..3106d5f 100644 --- a/cursed_renderer.go +++ b/cursed_renderer.go @@ -286,7 +286,7 @@ func (s *cursedRenderer) writeString(str string) (int, error) { } // flush implements renderer. -func (s *cursedRenderer) flush(closing bool) error { +func (s *cursedRenderer) flush(queued []byte, closing bool) error { s.mu.Lock() defer s.mu.Unlock() @@ -317,8 +317,8 @@ func (s *cursedRenderer) flush(closing bool) error { } if !s.starting && !closing && !s.pendingErase && s.lastView != nil && viewEquals(s.lastView, &view) && frameArea == s.cellbuf.Bounds() { - // No changes, nothing to do. - return nil + // No changes, nothing to do but what the program queued. + return s.writeQueued(queued) } // We're no longer starting. @@ -557,7 +557,16 @@ func (s *cursedRenderer) flush(closing bool) error { // flickering in some terminals. It's the best effort we can do instead // of showing the cursor flying around the screen during updates. + // What the program queued goes first, as it did when the program wrote + // it on its own, but inside the frame's synchronized update: the + // terminal shows neither without the other. var buf bytes.Buffer + queuedSyncd := s.syncdUpdates && len(queued) > 0 + if queuedSyncd { + buf.WriteString(ansi.SetModeSynchronizedOutput) + } + buf.Write(queued) + if shouldUpdateAltScreen { // We always reset keyboard enhancements when switching screens // because the terminal is expected to have two different keyboard @@ -573,7 +582,7 @@ func (s *cursedRenderer) flush(closing bool) error { } if s.syncdUpdates { - if hasUpdates { + if hasUpdates && !queuedSyncd { // We have synchronized output updates enabled. buf.WriteString(ansi.SetModeSynchronizedOutput) } @@ -596,7 +605,7 @@ func (s *cursedRenderer) flush(closing bool) error { // it here after writing any updates to the buffer. _, _ = buf.WriteString(ansi.SetModeTextCursorEnable) } - if hasUpdates { + if hasUpdates || queuedSyncd { // Close synchronized output mode. buf.WriteString(ansi.ResetModeSynchronizedOutput) } @@ -622,6 +631,30 @@ func (s *cursedRenderer) flush(closing bool) error { return nil } +// writeQueued writes what the program queued when there is no frame to +// write with it, in a synchronized update of its own when the renderer +// uses them. The caller holds s.mu. +func (s *cursedRenderer) writeQueued(queued []byte) error { + if len(queued) == 0 { + return nil + } + var buf bytes.Buffer + if s.syncdUpdates { + buf.WriteString(ansi.SetModeSynchronizedOutput) + } + buf.Write(queued) + if s.syncdUpdates { + buf.WriteString(ansi.ResetModeSynchronizedOutput) + } + if s.logger != nil { + s.logger.Printf("output: %q", buf.String()) + } + if _, err := io.Copy(s.w, &buf); err != nil { + return fmt.Errorf("bubbletea: error flushing queued output to the writer: %w", err) + } + return nil +} + // render implements renderer. func (s *cursedRenderer) render(v View) { s.mu.Lock() diff --git a/cursed_renderer_test.go b/cursed_renderer_test.go index 8b2efa8..f43b504 100644 --- a/cursed_renderer_test.go +++ b/cursed_renderer_test.go @@ -110,7 +110,7 @@ func TestCursedRenderer_restoresKittyKeyboardStack(t *testing.T) { render := func(v View) { t.Helper() r.render(v) - if err := r.flush(false); err != nil { + if err := r.flush(nil, false); err != nil { t.Fatal(err) } } @@ -171,7 +171,7 @@ func TestCursedRenderer_updatesKittyKeyboardFlagsInPlace(t *testing.T) { render := func(v View) { t.Helper() r.render(v) - if err := r.flush(false); err != nil { + if err := r.flush(nil, false); err != nil { t.Fatal(err) } } @@ -201,3 +201,56 @@ func TestCursedRenderer_updatesKittyKeyboardFlagsInPlace(t *testing.T) { t.Fatalf("expected kitty keyboard protocol to be pushed once, got %d pushes in %q", n, got) } } + +func TestCursedRenderer_writesQueuedOutputWithTheFrame(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + r := newCursedRenderer(&out, []string{"TERM=xterm-256color"}, 80, 24) + r.setSyncdUpdates(true) + r.start() + + flush := func(queued string) string { + t.Helper() + out.Reset() + if err := r.flush([]byte(queued), false); err != nil { + t.Fatal(err) + } + return out.String() + } + + // The first frame: what was queued goes first, inside the frame's + // synchronized update, and the update is opened and closed once. + r.render(NewView("hello")) + got := flush("") + if !strings.HasPrefix(got, ansi.SetModeSynchronizedOutput+"") { + t.Fatalf("expected the queued output first inside a synchronized update, got %q", got) + } + if !strings.HasSuffix(got, ansi.ResetModeSynchronizedOutput) { + t.Fatalf("expected the synchronized update to end the write, got %q", got) + } + if n := strings.Count(got, ansi.SetModeSynchronizedOutput); n != 1 { + t.Fatalf("expected one synchronized update, got %d in %q", n, got) + } + assertInOrder(t, got, "", "hello") + + // An unchanged frame: what was queued goes on its own, in a + // synchronized update. + if got := flush(""); got != ansi.SetModeSynchronizedOutput+""+ansi.ResetModeSynchronizedOutput { + t.Fatalf("expected the queued output alone in a synchronized update, got %q", got) + } + // Nothing queued and nothing changed: nothing written. + if got := flush(""); got != "" { + t.Fatalf("expected nothing written, got %q", got) + } + + // Without synchronized updates, the queued output still goes first, in + // the same write. + r.setSyncdUpdates(false) + r.render(NewView("world")) + got = flush("") + if !strings.HasPrefix(got, "") || strings.Contains(got, ansi.SetModeSynchronizedOutput) { + t.Fatalf("expected the queued output first and no synchronized update, got %q", got) + } + assertInOrder(t, got, "", "world") +} diff --git a/nil_renderer.go b/nil_renderer.go index 1edafea..66b59cf 100644 --- a/nil_renderer.go +++ b/nil_renderer.go @@ -27,7 +27,7 @@ func (n nilRenderer) resize(int, int) {} func (n nilRenderer) setColorProfile(colorprofile.Profile) {} // flush implements the Renderer interface. -func (nilRenderer) flush(bool) error { return nil } +func (nilRenderer) flush([]byte, bool) error { return nil } // close implements the Renderer interface. func (nilRenderer) close() error { return nil } diff --git a/renderer.go b/renderer.go index e8d792b..2299583 100644 --- a/renderer.go +++ b/renderer.go @@ -25,8 +25,11 @@ type renderer interface { // render renders a frame to the output. render(View) - // flush flushes the renderer's buffer to the output. - flush(closing bool) error + // flush flushes the renderer's buffer to the output. What the program + // queued for the output (queued) goes out with the frame, ahead of it: + // in the same write, and inside its synchronized update when the + // renderer uses synchronized updates. + flush(queued []byte, closing bool) error // reset resets the renderer's state to its initial state. reset() diff --git a/tea.go b/tea.go index 59bdf59..f7db33f 100644 --- a/tea.go +++ b/tea.go @@ -1261,6 +1261,27 @@ func (p *Program) flush() error { return nil } +// flushFrame writes what the program queued for the output (execute) and +// the next frame together: in one write, and inside the frame's +// synchronized update when the renderer uses them. A terminal then never +// shows output the program queued with a frame, such as graphics it places +// over the cells, before that frame or after it. Without a renderer, it +// writes what was queued. +func (p *Program) flushFrame() error { + if p.disableRenderer { + return p.flush() + } + p.mu.Lock() + defer p.mu.Unlock() + + err := p.renderer.flush(p.outputBuf.Bytes(), false) + p.outputBuf.Reset() + if err != nil { + return fmt.Errorf("error writing to output: %w", err) + } + return nil +} + // shutdown performs operations to free up resources and restore the terminal // to its original state. func (p *Program) shutdown(kill bool) { @@ -1439,8 +1460,7 @@ func (p *Program) startRenderer() { return case <-p.ticker.C: - _ = p.flush() - _ = p.renderer.flush(false) + _ = p.flushFrame() } } }() @@ -1457,7 +1477,7 @@ func (p *Program) stopRenderer(kill bool) { if !kill { // flush locks the mutex - _ = p.renderer.flush(true) + _ = p.renderer.flush(nil, true) } _ = p.renderer.close()