diff --git a/textarea/textarea.go b/textarea/textarea.go index 7e4508ab0..a6db7dbdf 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -921,7 +921,14 @@ func (m *Model) characterLeft(insideLine bool) { // cursor blink should be reset. If input is masked, move input to the start // so as not to reveal word breaks in the masked input. func (m *Model) wordLeft() { + // Skip spaces backward. characterLeft is a no-op at the very beginning + // of the buffer (row=0, col=0), so without an explicit check the loop + // spins forever on an empty textarea or one whose content is all + // trailing whitespace. See #1652 (filed against bubbletea, lives here). for { + if m.row == 0 && m.col == 0 { + return + } m.characterLeft(true /* insideLine */) if m.col < len(m.value[m.row]) && !unicode.IsSpace(m.value[m.row][m.col]) { break diff --git a/textarea/textarea_test.go b/textarea/textarea_test.go index d942dd40b..017fd8a06 100644 --- a/textarea/textarea_test.go +++ b/textarea/textarea_test.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" "testing" + "time" "unicode" tea "charm.land/bubbletea/v2" @@ -1974,6 +1975,28 @@ func TestWord(t *testing.T) { }) } +func TestWordLeftOnEmptyDoesNotHang(t *testing.T) { + // Regression test for charmbracelet/bubbletea#1652. Pressing alt+left + // (the WordBackward binding) on an empty textarea used to spin + // wordLeft's "skip spaces backward" loop forever because characterLeft + // is a no-op at (0,0) and the break condition can never become true. + textarea := newTextArea() + textarea.SetHeight(3) + textarea.SetWidth(20) + + done := make(chan struct{}) + go func() { + _, _ = textarea.Update(tea.KeyPressMsg{Code: tea.KeyLeft, Mod: tea.ModAlt, Text: "alt+left"}) + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("wordLeft never returned on an empty textarea (would have hung the event loop)") + } +} + func newTextArea() Model { textarea := New()