Repository: charmbracelet/bubbletea
Pull request #1303 (2025-02-03 15:50:38)

(v2) feat,refactor!: introduce cursor model
This introduces a new `CursorModel` interface that can be implemented by the main model to provide a cursor position and style. A nil cursor will hide the cursor. Otherwise, the cursor will be shown at the position and with the style specified. Use `NewCursor` to quickly create a cursor for a given position with default styles. This method is called in-sync with the `Model.View` method.

It also removes the previously introduced `Frame` type and `NewFrame` function. The main model can now return a `string` to render the program's UI. The main model can return a `Cursor` to set the cursor position and style.

The old `Init` method is now back and it returns a `Cmd` instead of a both a `Model` and a `Cmd`. This is to make the mental model more straight forward, and to keep things backwards compatible.


Patch:
diff --git a/cursed_renderer.go b/cursed_renderer.go
index 9c0a786..cb8492a 100644
--- a/cursed_renderer.go
+++ b/cursed_renderer.go
@@ -1,7 +1,6 @@
 package tea
 
 import (
-	"fmt"
 	"io"
 	"strings"
 	"sync"
@@ -14,7 +13,8 @@ import (
 type cursedRenderer struct {
 	w             io.Writer
 	scr           *cellbuf.Screen
-	lastFrame     *Frame
+	lastFrame     *string
+	lastCur       *Cursor
 	term          string // the terminal type $TERM
 	width, height int
 	mu            sync.Mutex
@@ -47,24 +47,23 @@ func (s *cursedRenderer) close() (err error) {
 func (s *cursedRenderer) flush() error {
 	s.mu.Lock()
 	defer s.mu.Unlock()
-	if s.lastFrame != nil && s.lastFrame.Cursor != nil {
-		cur := s.lastFrame.Cursor
-		s.scr.MoveTo(cur.Position.X, cur.Position.Y)
-		s.cursor.Position = cur.Position
+	if s.lastCur != nil {
+		s.scr.MoveTo(s.lastCur.Position.X, s.lastCur.Position.Y)
+		s.cursor.Position = s.lastCur.Position
 
-		if cur.Shape != s.cursor.Shape || cur.Blink != s.cursor.Blink {
-			cursorStyle := encodeCursorStyle(cur.Shape, cur.Blink)
+		if s.lastCur.Shape != s.cursor.Shape || s.lastCur.Blink != s.cursor.Blink {
+			cursorStyle := encodeCursorStyle(s.lastCur.Shape, s.lastCur.Blink)
 			io.WriteString(s.w, ansi.SetCursorStyle(cursorStyle)) //nolint:errcheck
-			s.cursor.Shape = cur.Shape
-			s.cursor.Blink = cur.Blink
+			s.cursor.Shape = s.lastCur.Shape
+			s.cursor.Blink = s.lastCur.Blink
 		}
-		if cur.Color != s.cursor.Color {
+		if s.lastCur.Color != s.cursor.Color {
 			seq := ansi.ResetCursorColor
-			if cur.Color != nil {
-				seq = ansi.SetCursorColor(cur.Color)
+			if s.lastCur.Color != nil {
+				seq = ansi.SetCursorColor(s.lastCur.Color)
 			}
 			io.WriteString(s.w, seq) //nolint:errcheck
-			s.cursor.Color = cur.Color
+			s.cursor.Color = s.lastCur.Color
 		}
 	}
 	s.scr.Render()
@@ -72,25 +71,17 @@ func (s *cursedRenderer) flush() error {
 }
 
 // render implements renderer.
-func (s *cursedRenderer) render(frame fmt.Stringer) {
+func (s *cursedRenderer) render(frame string, cur *Cursor) {
 	s.mu.Lock()
 	defer s.mu.Unlock()
 
-	var f Frame
-	switch frame := frame.(type) {
-	case Frame:
-		f = frame
-	default:
-		if frame != nil {
-			f.Content = frame.String()
-		}
-	}
-
-	if s.lastFrame != nil && f == *s.lastFrame {
+	if s.lastFrame != nil && frame == *s.lastFrame &&
+		(s.lastCur == nil && cur == nil || s.lastCur != nil && cur != nil && *s.lastCur == *cur) {
 		return
 	}
 
-	s.lastFrame = &f
+	s.lastFrame = &frame
+	s.lastCur = cur
 	if !s.altScreen {
 		// Inline mode resizes the screen based on the frame height and
 		// terminal width. This is because the frame height can change based on
@@ -98,12 +89,12 @@ func (s *cursedRenderer) render(frame fmt.Stringer) {
 		// 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 := strings.Count(f.Content, "\n") + 1
+		frameHeight := strings.Count(frame, "\n") + 1
 		s.scr.Resize(s.width, frameHeight)
 	}
 
-	s.scr.SetContent(f.Content)
-	if f.Cursor == nil {
+	s.scr.SetContent(frame)
+	if cur == nil {
 		hideCursor(s)
 	} else {
 		showCursor(s)
@@ -176,7 +167,7 @@ func (s *cursedRenderer) exitAltScreen() {
 	s.altScreen = false
 	s.scr.ExitAltScreen()
 	s.scr.SetRelativeCursor(!s.altScreen)
-	s.scr.Resize(s.width, strings.Count((*s.lastFrame).Content, "\n")+1)
+	s.scr.Resize(s.width, strings.Count((*s.lastFrame), "\n")+1)
 	repaint(s)
 	s.mu.Unlock()
 }
diff --git a/examples/go.mod b/examples/go.mod
index 48bc237..3fc14d0 100644
--- a/examples/go.mod
+++ b/examples/go.mod
@@ -2,10 +2,12 @@ module examples
 
 go 1.23.1
 
+replace github.com/charmbracelet/x/cellbuf => ../../x/cellbuf
+
 require (
 	github.com/charmbracelet/bubbles/v2 v2.0.0-alpha.2.0.20250131212117-b2e3cc537170
 	github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123211610-443afa6fa0c1
-	github.com/charmbracelet/colorprofile v0.1.10
+	github.com/charmbracelet/colorprofile v0.2.0
 	github.com/charmbracelet/glamour v0.8.0
 	github.com/charmbracelet/harmonica v0.2.0
 	github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250127174346-dc93e7a83a4b
@@ -23,7 +25,7 @@ require (
 	github.com/aymanbagabas/go-udiff v0.2.0 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/charmbracelet/lipgloss v0.13.0 // indirect
-	github.com/charmbracelet/x/cellbuf v0.0.7 // indirect
+	github.com/charmbracelet/x/cellbuf v0.0.9-0.20250203222631-bea22a7f0a07 // indirect
 	github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f // indirect
 	github.com/charmbracelet/x/input v0.3.1 // indirect
 	github.com/charmbracelet/x/term v0.2.1 // indirect
diff --git a/examples/go.sum b/examples/go.sum
index f26f8dc..5482120 100644
--- a/examples/go.sum
+++ b/examples/go.sum
@@ -16,8 +16,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
 github.com/charmbracelet/bubbles/v2 v2.0.0-alpha.2.0.20250131212117-b2e3cc537170 h1:IU4rSFIQ22i6QxMUvoEoPSg76ifSUFw6Jbw8eiESAwQ=
 github.com/charmbracelet/bubbles/v2 v2.0.0-alpha.2.0.20250131212117-b2e3cc537170/go.mod h1:DBfWo/ohdtbvjyDk/Q4UffdRd/rRKfYTwTqHdPFVHT8=
-github.com/charmbracelet/colorprofile v0.1.10 h1:k6jIGJg4bPWvHZqcoLjFxH1bm9uT28Ysxg8guonDJ1Y=
-github.com/charmbracelet/colorprofile v0.1.10/go.mod h1:6wPrSSR4QtwYtOY3h0bLRw5YOUAIKWlZIJ02CTAsZsk=
+github.com/charmbracelet/colorprofile v0.2.0 h1:iiIQlp3LSvoJPtR11KoDfIf9wqWm2mn/iU420rHOZ/A=
+github.com/charmbracelet/colorprofile v0.2.0/go.mod h1:6wPrSSR4QtwYtOY3h0bLRw5YOUAIKWlZIJ02CTAsZsk=
 github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs=
 github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw=
 github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
@@ -28,8 +28,6 @@ github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250127174346-dc93e7a83a4
 github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250127174346-dc93e7a83a4b/go.mod h1:wNdvvvSFeykFQxJ9rXcTA+IbZE9wgMEqhM4c8gV/faE=
 github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
 github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
-github.com/charmbracelet/x/cellbuf v0.0.7 h1:u+ArcmMqOuY+f0rFGHzTJxALno2kJAPxK8u2PVo5YIQ=
-github.com/charmbracelet/x/cellbuf v0.0.7/go.mod h1:WU1sKZkKCLaBjrRneV4AGFYygeFiGk5rFAKxqRyJuPE=
 github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w=
 github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
 github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20241016014612-3b4d04043233 h1:2bTR/MtnJuq9RrCZSPwCOO34YSDByKL6nzXQMnsKK6U=
diff --git a/frame.go b/frame.go
deleted file mode 100644
index c0787d7..0000000
--- a/frame.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package tea
-
-import "image/color"
-
-// Cursor represents a cursor on the terminal screen.
-type Cursor struct {
-	// Position is a [Position] that determines the cursor's position on the
-	// screen relative to the top left corner of the frame.
-	Position Position
-
-	// Color is a [color.Color] that determines the cursor's color.
-	Color color.Color
-
-	// Shape is a [CursorShape] that determines the cursor's shape.
-	Shape CursorShape
-
-	// Blink is a boolean that determines whether the cursor should blink.
-	Blink bool
-}
-
-// NewCursor returns a new cursor with the default settings and the given
-// position.
-func NewCursor(x, y int) *Cursor {
-	return &Cursor{
-		Position: Position{X: x, Y: y},
-		Color:    nil,
-		Shape:    CursorBlock,
-		Blink:    true,
-	}
-}
-
-// Frame represents a single frame of the program's output.
-type Frame struct {
-	// Content contains the frame's content. This is the only required field.
-	// It should be a string of text and ANSI escape codes.
-	Content string
-
-	// Cursor contains cursor settings for the frame. If nil, the cursor will
-	// be hidden.
-	Cursor *Cursor
-}
-
-// NewFrame creates a new frame with the given content.
-func NewFrame(content string) Frame {
-	return Frame{Content: content}
-}
-
-// String implements the fmt.Stringer interface for [Frame].
-func (f Frame) String() string {
-	return f.Content
-}
-
-// View represents a simple [fmt.Stringer] interface for a view.
-type View string
-
-// String implements the fmt.Stringer interface for [View].
-func (v View) String() string {
-	return string(v)
-}
diff --git a/go.mod b/go.mod
index 68223ce..6789755 100644
--- a/go.mod
+++ b/go.mod
@@ -3,9 +3,9 @@ module github.com/charmbracelet/bubbletea/v2
 go 1.18
 
 require (
-	github.com/charmbracelet/colorprofile v0.1.10
+	github.com/charmbracelet/colorprofile v0.2.0
 	github.com/charmbracelet/x/ansi v0.8.0
-	github.com/charmbracelet/x/cellbuf v0.0.7
+	github.com/charmbracelet/x/cellbuf v0.0.9-0.20250203222631-bea22a7f0a07
 	github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f
 	github.com/charmbracelet/x/input v0.3.1
 	github.com/charmbracelet/x/term v0.2.1
diff --git a/go.sum b/go.sum
index 974432d..5046625 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +1,11 @@
 github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
 github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
-github.com/charmbracelet/colorprofile v0.1.10 h1:k6jIGJg4bPWvHZqcoLjFxH1bm9uT28Ysxg8guonDJ1Y=
-github.com/charmbracelet/colorprofile v0.1.10/go.mod h1:6wPrSSR4QtwYtOY3h0bLRw5YOUAIKWlZIJ02CTAsZsk=
+github.com/charmbracelet/colorprofile v0.2.0 h1:iiIQlp3LSvoJPtR11KoDfIf9wqWm2mn/iU420rHOZ/A=
+github.com/charmbracelet/colorprofile v0.2.0/go.mod h1:6wPrSSR4QtwYtOY3h0bLRw5YOUAIKWlZIJ02CTAsZsk=
 github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
 github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
-github.com/charmbracelet/x/cellbuf v0.0.7 h1:u+ArcmMqOuY+f0rFGHzTJxALno2kJAPxK8u2PVo5YIQ=
-github.com/charmbracelet/x/cellbuf v0.0.7/go.mod h1:WU1sKZkKCLaBjrRneV4AGFYygeFiGk5rFAKxqRyJuPE=
+github.com/charmbracelet/x/cellbuf v0.0.9-0.20250203222631-bea22a7f0a07 h1:RFHEvURPMgGGd8epjjhi2UpXSKyFs39iRF4JTYCEdLg=
+github.com/charmbracelet/x/cellbuf v0.0.9-0.20250203222631-bea22a7f0a07/go.mod h1:dKfNBxLovpvzzxAP6/GZfs5eb7vNxHlUDnwGhRmvIdY=
 github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w=
 github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
 github.com/charmbracelet/x/input v0.3.1 h1:TE4s3fTRj+OUpJ86dKphrN99+NgBnto//EkWncMJQIg=
diff --git a/nil_renderer.go b/nil_renderer.go
index cea259b..8d3e5f4 100644
--- a/nil_renderer.go
+++ b/nil_renderer.go
@@ -1,8 +1,6 @@
 package tea
 
 import (
-	"fmt"
-
 	"github.com/charmbracelet/colorprofile"
 )
 
@@ -46,7 +44,7 @@ func (nilRenderer) flush() error { return nil }
 func (nilRenderer) close() error { return nil }
 
 // render implements the Renderer interface.
-func (nilRenderer) render(fmt.Stringer) {}
+func (nilRenderer) render(string, *Cursor) {}
 
 // reset implements the Renderer interface.
 func (nilRenderer) reset() {}
diff --git a/renderer.go b/renderer.go
index 333f838..eef799b 100644
--- a/renderer.go
+++ b/renderer.go
@@ -19,7 +19,7 @@ type renderer interface {
 	close() error
 
 	// render renders a frame to the output.
-	render(fmt.Stringer)
+	render(string, *Cursor)
 
 	// flush flushes the renderer's buffer to the output.
 	flush() error
diff --git a/tea.go b/tea.go
index cd95d5d..af8b4bf 100644
--- a/tea.go
+++ b/tea.go
@@ -47,17 +47,54 @@ type Msg interface{}
 type Model interface {
 	// Init is the first function that will be called. It returns an optional
 	// initial command. To not perform an initial command return nil.
-	Init() (Model, Cmd)
+	Init() Cmd
 
 	// Update is called when a message is received. Use it to inspect messages
 	// and, in response, update the model and/or send a command.
 	Update(Msg) (Model, Cmd)
 
-	// View renders the program's UI, which is just a [fmt.Stringer]. The view
-	// is rendered after every Update.
-	// The main model can return a [Frame] to set the cursor position and
-	// style.
-	View() fmt.Stringer
+	// View renders the program's UI, which is just a string. The view is
+	// rendered after every Update.
+	View() string
+}
+
+// Cursor represents a cursor on the terminal screen.
+type Cursor struct {
+	// Position is a [Position] that determines the cursor's position on the
+	// screen relative to the top left corner of the frame.
+	Position Position
+
+	// Color is a [color.Color] that determines the cursor's color.
+	Color color.Color
+
+	// Shape is a [CursorShape] that determines the cursor's shape.
+	Shape CursorShape
+
+	// Blink is a boolean that determines whether the cursor should blink.
+	Blink bool
+}
+
+// NewCursor returns a new cursor with the default settings and the given
+// position.
+func NewCursor(x, y int) *Cursor {
+	return &Cursor{
+		Position: Position{X: x, Y: y},
+		Color:    nil,
+		Shape:    CursorBlock,
+		Blink:    true,
+	}
+}
+
+// CursorModel is an optional interface that can be implemented by the main
+// model to provide a cursor position and style.
+type CursorModel interface {
+	// Cursor returns the cursor position and style. A nil cursor will hide the
+	// cursor. Otherwise, the cursor will be shown at the position and with the
+	// style specified.
+	// Use [NewCursor] to quickly create a cursor for a given position with
+	// default styles.
+	// This method is called in-sync with the [Model.View] method.
+	Cursor() *Cursor
 }
 
 // Cmd is an IO operation that returns a message when it's complete. If it's
@@ -696,18 +733,23 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 			model, cmd = model.Update(msg) // run update
 			cmds <- cmd                    // process command (if any)
 
-			view := model.View()
-			switch view := view.(type) {
-			case Frame:
-				// Ensure we reset the cursor color on exit.
-				if view.Cursor != nil {
-					p.setCc = view.Cursor.Color
-				}
-			}
+			p.render(model) // render view
+		}
+	}
+}
 
-			p.renderer.render(view) //nolint:errcheck // send view to renderer
+// render renders the given view to the renderer.
+func (p *Program) render(model Model) {
+	var cur *Cursor
+	if model, ok := model.(CursorModel); ok {
+		cur = model.Cursor()
+		// Ensure we reset the cursor color on exit.
+		if cur != nil {
+			p.setCc = cur.Color
 		}
 	}
+
+	p.renderer.render(model.View(), cur) //nolint:errcheck // send view to renderer
 }
 
 // Run initializes the program and runs its event loops, blocking until it gets
@@ -866,8 +908,7 @@ func (p *Program) Run() (Model, error) {
 	p.startRenderer()
 
 	// Initialize the program.
-	var initCmd Cmd
-	model, initCmd = model.Init()
+	initCmd := model.Init()
 	if initCmd != nil {
 		ch := make(chan struct{})
 		p.handlers.add(ch)
@@ -883,7 +924,7 @@ func (p *Program) Run() (Model, error) {
 	}
 
 	// Render the initial view.
-	p.renderer.render(model.View()) //nolint:errcheck
+	p.render(model)
 
 	// Handle resize events.
 	p.handlers.add(p.handleResize())
@@ -899,7 +940,7 @@ func (p *Program) Run() (Model, error) {
 	}
 	if err == nil {
 		// Ensure we rendered the final state of the model.
-		p.renderer.render(model.View()) //nolint:errcheck
+		p.render(model)
 	}
 
 	// Restore terminal state.


Test patch:
diff --git a/exec_test.go b/exec_test.go
index ac5b6c2..4cafa70 100644
--- a/exec_test.go
+++ b/exec_test.go
@@ -2,7 +2,6 @@ package tea
 
 import (
 	"bytes"
-	"fmt"
 	"os/exec"
 	"runtime"
 	"testing"
@@ -15,9 +14,9 @@ type testExecModel struct {
 	err error
 }
 
-func (m *testExecModel) Init() (Model, Cmd) {
+func (m *testExecModel) Init() Cmd {
 	c := exec.Command(m.cmd) //nolint:gosec
-	return m, ExecProcess(c, func(err error) Msg {
+	return ExecProcess(c, func(err error) Msg {
 		return execFinishedMsg{err}
 	})
 }
@@ -34,8 +33,8 @@ func (m *testExecModel) Update(msg Msg) (Model, Cmd) {
 	return m, nil
 }
 
-func (m *testExecModel) View() fmt.Stringer {
-	return NewFrame("\n")
+func (m *testExecModel) View() string {
+	return "\n"
 }
 
 func TestTeaExec(t *testing.T) {
diff --git a/tea_test.go b/tea_test.go
index 3477c80..809cd90 100644
--- a/tea_test.go
+++ b/tea_test.go
@@ -4,7 +4,6 @@ import (
 	"bytes"
 	"context"
 	"errors"
-	"fmt"
 	"sync/atomic"
 	"testing"
 	"time"
@@ -17,8 +16,8 @@ type testModel struct {
 	counter  atomic.Value
 }
 
-func (m *testModel) Init() (Model, Cmd) {
-	return m, nil
+func (m *testModel) Init() Cmd {
+	return nil
 }
 
 func (m *testModel) Update(msg Msg) (Model, Cmd) {
@@ -38,9 +37,9 @@ func (m *testModel) Update(msg Msg) (Model, Cmd) {
 	return m, nil
 }
 
-func (m *testModel) View() fmt.Stringer {
+func (m *testModel) View() string {
 	m.executed.Store(true)
-	return NewFrame("success\n")
+	return "success\n"
 }
 
 func TestTeaModel(t *testing.T) {