Repository: charmbracelet/bubbletea (go)

Issue:
Rendering lines taller than the window height causes mis-renders
Currently, if you send more lines than the height of the window Bubble Tea rendering flips out. For cases like this we should _probably_ drop lines from the top and keep only the bottom ones.

Thanks to @fiws for flagging this and @maaslalani for reminding us about it.

Pull request description:
fix: prevent renderer from overflowing available height
Drops lines from the top when the render buffer is taller than the available height.

Fixes #297.

Patch:
diff --git a/standard_renderer.go b/standard_renderer.go
index c0b685d..54c0f5d 100644
--- a/standard_renderer.go
+++ b/standard_renderer.go
@@ -136,6 +136,15 @@ func (r *standardRenderer) flush() {
 	out := termenv.NewOutput(buf)
 
 	newLines := strings.Split(r.buf.String(), "\n")
+
+	// If we know the output's height, we can use it to determine how many
+	// lines we can render. We drop lines from the top of the render buffer if
+	// necessary, as we can't navigate the cursor into the terminal's scrollback
+	// buffer.
+	if r.height > 0 && len(newLines) > r.height {
+		newLines = newLines[len(newLines)-r.height:]
+	}
+
 	numLinesThisFlush := len(newLines)
 	oldLines := strings.Split(r.lastRender, "\n")
 	skipLines := make(map[int]struct{})
@@ -264,17 +273,20 @@ func (r *standardRenderer) clearScreen() {
 }
 
 func (r *standardRenderer) altScreen() bool {
+	r.mtx.Lock()
+	defer r.mtx.Unlock()
+
 	return r.altScreenActive
 }
 
 func (r *standardRenderer) enterAltScreen() {
+	r.mtx.Lock()
+	defer r.mtx.Unlock()
+
 	if r.altScreenActive {
 		return
 	}
 
-	r.mtx.Lock()
-	defer r.mtx.Unlock()
-
 	r.altScreenActive = true
 	r.out.AltScreen()
 
@@ -291,13 +303,13 @@ func (r *standardRenderer) enterAltScreen() {
 }
 
 func (r *standardRenderer) exitAltScreen() {
+	r.mtx.Lock()
+	defer r.mtx.Unlock()
+
 	if !r.altScreenActive {
 		return
 	}
 
-	r.mtx.Lock()
-	defer r.mtx.Unlock()
-
 	r.altScreenActive = false
 	r.out.ExitAltScreen()
 
diff --git a/tea.go b/tea.go
index b94ca4e..d6a19d5 100644
--- a/tea.go
+++ b/tea.go
@@ -142,6 +142,9 @@ func NewProgram(model Model, opts ...ProgramOption) *Program {
 		msgs:         make(chan Msg),
 	}
 
+	// Initialize context and teardown channel.
+	p.ctx, p.cancel = context.WithCancel(context.Background())
+
 	// Apply all options to the program.
 	for _, opt := range opts {
 		opt(p)
@@ -247,10 +250,8 @@ func (p *Program) handleCommands(cmds chan Cmd) chan struct{} {
 				// possible to cancel them so we'll have to leak the goroutine
 				// until Cmd returns.
 				go func() {
-					select {
-					case p.msgs <- cmd():
-					case <-p.ctx.Done():
-					}
+					msg := cmd() // this can be long.
+					p.Send(msg)
 				}()
 			}
 		}
@@ -315,10 +316,7 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 				go func() {
 					// Execute commands one at a time, in order.
 					for _, cmd := range msg {
-						select {
-						case p.msgs <- cmd():
-						case <-p.ctx.Done():
-						}
+						p.Send(cmd())
 					}
 				}()
 			}
@@ -344,7 +342,6 @@ func (p *Program) Run() (Model, error) {
 	cmds := make(chan Cmd)
 	p.errs = make(chan error)
 
-	p.ctx, p.cancel = context.WithCancel(context.Background())
 	defer p.cancel()
 
 	switch {
@@ -459,6 +456,9 @@ func (p *Program) Run() (Model, error) {
 	killed := p.ctx.Err() != nil
 	if killed {
 		err = ErrProgramKilled
+	} else {
+		// Ensure we rendered the final state of the model.
+		p.renderer.write(model.View())
 	}
 
 	// Tear down.
@@ -501,10 +501,14 @@ func (p *Program) Start() error {
 // messages to be injected from outside the program for interoperability
 // purposes.
 //
-// If the program is not running this will be a no-op, so it's safe to
-// send messages if the program is unstarted, or has exited.
+// If the program hasn't started yet this will be a blocking operation.
+// If the program has already been terminated this will be a no-op, so it's safe
+// to send messages after the program has exited.
 func (p *Program) Send(msg Msg) {
-	p.msgs <- msg
+	select {
+	case <-p.ctx.Done():
+	case p.msgs <- msg:
+	}
 }
 
 // Quit is a convenience function for quitting Bubble Tea programs. Use it


Test patch:
diff --git a/tea_test.go b/tea_test.go
index c61c518..4b087bd 100644
--- a/tea_test.go
+++ b/tea_test.go
@@ -149,3 +149,29 @@ func TestTeaSequenceMsg(t *testing.T) {
 		t.Fatalf("counter should be 2, got %d", m.counter.Load())
 	}
 }
+
+func TestTeaSend(t *testing.T) {
+	var buf bytes.Buffer
+	var in bytes.Buffer
+
+	m := &testModel{}
+	p := NewProgram(m, WithInput(&in), WithOutput(&buf))
+
+	// sending before the program is started is a blocking operation
+	go p.Send(Quit())
+
+	if _, err := p.Run(); err != nil {
+		t.Fatal(err)
+	}
+
+	// sending a message after program has quit is a no-op
+	p.Send(Quit())
+}
+
+func TestTeaNoRun(t *testing.T) {
+	var buf bytes.Buffer
+	var in bytes.Buffer
+
+	m := &testModel{}
+	NewProgram(m, WithInput(&in), WithOutput(&buf))
+}