From f1daacfa0cfee07e31a12498078426d275aa5286 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 25 Mar 2026 08:00:38 -0400 Subject: [PATCH] feat(textarea): dynamic height (#910) Co-authored-by: Andrey Nering --- textarea/textarea.go | 108 +++++++++- textarea/textarea_test.go | 416 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 523 insertions(+), 1 deletion(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 7e4508a..f0c0ca5 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -287,6 +287,23 @@ type Model struct { // there's no limit. MaxWidth int + // DynamicHeight, when true, causes the textarea to automatically grow + // and shrink its height to fit the content. The height is clamped between + // MinHeight and MaxHeight. + DynamicHeight bool + + // MinHeight is the minimum height of the text area in rows when + // DynamicHeight is enabled. If 0 or less, defaults to 1. + MinHeight int + + // MaxContentHeight is the maximum content height in visual rows + // (accounting for soft wraps). When set (> 0), input is blocked once + // the total visual lines reach this limit, while MaxHeight controls + // only the visible viewport height. When 0, the content guard falls + // back to the legacy MaxHeight behavior (blocking at MaxHeight + // logical lines) for backward compatibility. + MaxContentHeight int + // Styling. Styles are defined in [Styles]. Use [SetStyles] and [GetStyles] // to work with this value publicly. styles Styles @@ -464,16 +481,19 @@ func (m *Model) updateVirtualCursorStyle() { func (m *Model) SetValue(s string) { m.Reset() m.InsertString(s) + m.recalculateHeight() } // InsertString inserts a string at the cursor position. func (m *Model) InsertString(s string) { m.insertRunesFromUserInput([]rune(s)) + m.recalculateHeight() } // InsertRune inserts a rune at the cursor position. func (m *Model) InsertRune(r rune) { m.insertRunesFromUserInput([]rune{r}) + m.recalculateHeight() } // insertRunesFromUserInput inserts runes at the current cursor position. @@ -521,6 +541,18 @@ func (m *Model) insertRunesFromUserInput(runes []rune) { lines = lines[:allowedHeight] } + // Obey MaxContentHeight in visual rows when set. + if m.MaxContentHeight > 0 { + budget := m.MaxContentHeight - m.totalVisualLines() + // Trim lines from the end until we fit within the budget. + for len(lines) > 1 && m.visualLinesForInsert(lines) > budget { + lines = lines[:len(lines)-1] + } + if m.visualLinesForInsert(lines) > budget { + return + } + } + if len(lines) == 0 { // Nothing left to insert. return @@ -740,6 +772,7 @@ func (m *Model) Reset() { m.row = 0 m.viewport.GotoTop() m.SetCursorColumn(0) + m.recalculateHeight() } // Word returns the word at the cursor position. @@ -1134,6 +1167,7 @@ func (m *Model) SetWidth(w int) { m.viewport.SetWidth(inputWidth - reservedOuter) m.width = inputWidth - reservedOuter - reservedInner + m.recalculateHeight() } // SetPromptFunc supersedes the Prompt field and sets a dynamic prompt instead. @@ -1238,7 +1272,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } m.deleteWordRight() case key.Matches(msg, m.KeyMap.InsertNewline): - if m.MaxHeight > 0 && len(m.value) >= m.MaxHeight { + if m.atContentLimit() { return m, nil } m.col = clamp(m.col, 0, len(m.value[m.row])) @@ -1289,6 +1323,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.Err = msg } + m.recalculateHeight() + // Make sure we set the content of the viewport before updating it. view := m.view() m.viewport.SetContent(view) @@ -1627,6 +1663,76 @@ func (m Model) cursorLineNumber() int { return line } +// totalVisualLines returns the total number of display lines across all +// logical lines, accounting for soft wraps. +func (m *Model) totalVisualLines() int { + n := 0 + for _, line := range m.value { + n += len(m.memoizedWrap(line, m.width)) + } + return n +} + +// recalculateHeight recomputes and applies the textarea height based on +// content when DynamicHeight is enabled. It is a no-op otherwise. +func (m *Model) recalculateHeight() { + if !m.DynamicHeight { + return + } + minH := max(m.MinHeight, minHeight) + total := m.totalVisualLines() + h := max(total, minH) + if m.MaxHeight > 0 { + h = min(h, m.MaxHeight) + } + if maxOffset := total - h; m.viewport.YOffset() > maxOffset { + m.viewport.SetYOffset(max(0, maxOffset)) + } + m.SetHeight(h) +} + +// atContentLimit reports whether the textarea has reached its content limit. +// When MaxContentHeight is set (> 0), it checks total visual lines. +// Otherwise it falls back to the legacy MaxHeight logical-line check for +// backward compatibility. +func (m *Model) atContentLimit() bool { + if m.MaxContentHeight > 0 { + return m.totalVisualLines() >= m.MaxContentHeight + } + return m.MaxHeight > 0 && len(m.value) >= m.MaxHeight +} + +// visualLinesForInsert estimates how many additional visual lines would result +// from inserting the given lines at the current cursor position. The first +// element merges into the current line; subsequent elements become new lines. +func (m *Model) visualLinesForInsert(lines [][]rune) int { + if len(lines) == 0 { + return 0 + } + + // The current row's visual line count before insertion. + currentRowVisual := len(m.memoizedWrap(m.value[m.row], m.width)) + + // Simulate merging the first paste line into the current row. + merged := make([]rune, m.col+len(lines[0])) + copy(merged, m.value[m.row][:m.col]) + copy(merged[m.col:], lines[0]) + if len(lines) == 1 { + merged = append(merged, m.value[m.row][m.col:]...) + } + delta := len(m.memoizedWrap(merged, m.width)) - currentRowVisual + + // Each additional line is a new logical line. + for i, content := range lines { + if i == len(lines)-1 { + content = append(content, m.value[m.row][m.col:]...) + } + delta += len(m.memoizedWrap(content, m.width)) + } + + return delta +} + // mergeLineBelow merges the current line the cursor is on with the line below. func (m *Model) mergeLineBelow(row int) { if row >= len(m.value)-1 { diff --git a/textarea/textarea_test.go b/textarea/textarea_test.go index d942dd4..41d51f7 100644 --- a/textarea/textarea_test.go +++ b/textarea/textarea_test.go @@ -1974,6 +1974,422 @@ func TestWord(t *testing.T) { }) } +func newDynamicTextArea(minH, maxH int) Model { + ta := New() + ta.Prompt = "" + ta.ShowLineNumbers = false + ta.DynamicHeight = true + ta.MinHeight = minH + ta.MaxHeight = maxH + ta.SetWidth(20) + ta.Focus() + ta, _ = ta.Update(nil) + return ta +} + +func TestDynamicHeight_DefaultUnchanged(t *testing.T) { + ta := newTextArea() + ta.SetHeight(6) + ta.SetWidth(40) + + for _, k := range "hello\nworld\n" { + ta, _ = ta.Update(keyPress(k)) + } + + if ta.Height() != 6 { + t.Errorf("expected static height 6, got %d", ta.Height()) + } +} + +func TestDynamicHeight_GrowsOnNewline(t *testing.T) { + ta := newDynamicTextArea(1, 20) + + ta, _ = ta.Update(keyPress('a')) + if ta.Height() != 1 { + t.Errorf("expected height 1 after single char, got %d", ta.Height()) + } + + enter := tea.KeyPressMsg{Code: tea.KeyEnter} + ta, _ = ta.Update(enter) + if ta.Height() != 2 { + t.Errorf("expected height 2 after first newline, got %d", ta.Height()) + } + + ta, _ = ta.Update(enter) + if ta.Height() != 3 { + t.Errorf("expected height 3 after second newline, got %d", ta.Height()) + } +} + +func TestDynamicHeight_GrowsOnSoftWrap(t *testing.T) { + ta := newDynamicTextArea(1, 20) + // width=20, so typing >20 chars should cause a soft wrap + input := "abcdefghijklmnopqrstuvwxyz" + for _, k := range input { + ta, _ = ta.Update(keyPress(k)) + } + + if ta.Height() < 2 { + t.Errorf("expected height >= 2 after soft wrap, got %d", ta.Height()) + } +} + +func TestDynamicHeight_ShrinksOnLineDeletion(t *testing.T) { + ta := newDynamicTextArea(1, 20) + + enter := tea.KeyPressMsg{Code: tea.KeyEnter} + ta, _ = ta.Update(keyPress('a')) + ta, _ = ta.Update(enter) + ta, _ = ta.Update(keyPress('b')) + ta, _ = ta.Update(enter) + ta, _ = ta.Update(keyPress('c')) + + if ta.Height() != 3 { + t.Fatalf("expected height 3 before deletion, got %d", ta.Height()) + } + + // Backspace at start of line 3 merges with line 2 + ta.CursorStart() + backspace := tea.KeyPressMsg{Code: tea.KeyBackspace} + ta, _ = ta.Update(backspace) + + if ta.Height() != 2 { + t.Errorf("expected height 2 after line merge, got %d", ta.Height()) + } +} + +func TestDynamicHeight_RespectsMinHeight(t *testing.T) { + ta := newDynamicTextArea(5, 20) + + ta, _ = ta.Update(keyPress('a')) + + if ta.Height() != 5 { + t.Errorf("expected min height 5, got %d", ta.Height()) + } +} + +func TestDynamicHeight_RespectsMaxHeight(t *testing.T) { + ta := newDynamicTextArea(1, 5) + + enter := tea.KeyPressMsg{Code: tea.KeyEnter} + for range 10 { + ta, _ = ta.Update(keyPress('x')) + ta, _ = ta.Update(enter) + } + + if ta.Height() != 5 { + t.Errorf("expected max height 5, got %d", ta.Height()) + } +} + +func TestDynamicHeight_GrowsOnPaste(t *testing.T) { + ta := newDynamicTextArea(1, 20) + + paste := tea.PasteMsg{Content: "line1\nline2\nline3\nline4\nline5"} + ta, _ = ta.Update(paste) + + if ta.Height() != 5 { + t.Errorf("expected height 5 after pasting 5 lines, got %d", ta.Height()) + } +} + +func TestDynamicHeight_RecalculatesOnSetWidth(t *testing.T) { + ta := newDynamicTextArea(1, 50) + ta.SetWidth(40) + + // Insert a line that fits in 40 cols but wraps in 10 cols + ta.SetValue("abcdefghijklmnopqrstuvwxyz") + + if ta.Height() != 1 { + t.Fatalf("expected height 1 at width 40, got %d", ta.Height()) + } + + ta.SetWidth(10) + + if ta.Height() < 3 { + t.Errorf("expected height >= 3 after narrowing to width 10, got %d", ta.Height()) + } +} + +func TestDynamicHeight_RecalculatesOnSetValue(t *testing.T) { + ta := newDynamicTextArea(1, 20) + + ta.SetValue("a\nb\nc\nd\ne") + + if ta.Height() != 5 { + t.Errorf("expected height 5 after SetValue with 5 lines, got %d", ta.Height()) + } +} + +func TestDynamicHeight_CursorPositionAfterGrow(t *testing.T) { + ta := newDynamicTextArea(1, 20) + + enter := tea.KeyPressMsg{Code: tea.KeyEnter} + for i := range 5 { + ta, _ = ta.Update(keyPress(rune('a' + i))) + ta, _ = ta.Update(enter) + } + ta, _ = ta.Update(keyPress('f')) + + // Cursor should be on the last line (row 5, 0-indexed) + if ta.Line() != 5 { + t.Errorf("expected cursor on row 5, got %d", ta.Line()) + } + + // Cursor visual line should be within the viewport + cursorLine := ta.cursorLineNumber() + minVisible := ta.viewport.YOffset() + maxVisible := minVisible + ta.viewport.Height() - 1 + if cursorLine < minVisible || cursorLine > maxVisible { + t.Errorf("cursor line %d outside viewport [%d, %d]", cursorLine, minVisible, maxVisible) + } +} + +func TestDynamicHeight_CursorPositionAfterShrink(t *testing.T) { + ta := newDynamicTextArea(1, 20) + + enter := tea.KeyPressMsg{Code: tea.KeyEnter} + for i := range 5 { + ta, _ = ta.Update(keyPress(rune('a' + i))) + ta, _ = ta.Update(enter) + } + ta, _ = ta.Update(keyPress('f')) + + if ta.Height() != 6 { + t.Fatalf("expected height 6 before shrink, got %d", ta.Height()) + } + + // Delete lines by backspacing + backspace := tea.KeyPressMsg{Code: tea.KeyBackspace} + ta, _ = ta.Update(backspace) // delete 'f' + ta, _ = ta.Update(backspace) // merge line 5 into 4 + ta, _ = ta.Update(backspace) // delete 'e' + ta, _ = ta.Update(backspace) // merge line 4 into 3 + + cursorLine := ta.cursorLineNumber() + minVisible := ta.viewport.YOffset() + maxVisible := minVisible + ta.viewport.Height() - 1 + if cursorLine < minVisible || cursorLine > maxVisible { + t.Errorf("cursor line %d outside viewport [%d, %d] after shrink", cursorLine, minVisible, maxVisible) + } +} + +func TestDynamicHeight_CursorPositionAfterPaste(t *testing.T) { + ta := newDynamicTextArea(1, 20) + + paste := tea.PasteMsg{Content: "line1\nline2\nline3\nline4\nline5"} + ta, _ = ta.Update(paste) + + // Cursor should be at the end of the last pasted line + if ta.Line() != 4 { + t.Errorf("expected cursor on row 4, got %d", ta.Line()) + } + + cursorLine := ta.cursorLineNumber() + minVisible := ta.viewport.YOffset() + maxVisible := minVisible + ta.viewport.Height() - 1 + if cursorLine < minVisible || cursorLine > maxVisible { + t.Errorf("cursor line %d outside viewport [%d, %d] after paste", cursorLine, minVisible, maxVisible) + } +} + +func TestMaxContentHeight_ScrollsBeyondMaxHeight(t *testing.T) { + ta := newDynamicTextArea(1, 5) + ta.MaxContentHeight = 10 + + enter := tea.KeyPressMsg{Code: tea.KeyEnter} + for range 8 { + ta, _ = ta.Update(keyPress('x')) + ta, _ = ta.Update(enter) + } + + if ta.Height() != 5 { + t.Errorf("expected visible height capped at 5, got %d", ta.Height()) + } + + if ta.LineCount() != 9 { + t.Errorf("expected 9 logical lines, got %d", ta.LineCount()) + } +} + +func TestMaxContentHeight_BlocksAtLimit(t *testing.T) { + ta := New() + ta.Prompt = "" + ta.ShowLineNumbers = false + ta.MaxContentHeight = 5 + ta.SetWidth(20) + ta.Focus() + ta, _ = ta.Update(nil) + + enter := tea.KeyPressMsg{Code: tea.KeyEnter} + for range 10 { + ta, _ = ta.Update(keyPress('x')) + ta, _ = ta.Update(enter) + } + + if ta.totalVisualLines() > 5 { + t.Errorf("expected total visual lines <= 5, got %d", ta.totalVisualLines()) + } +} + +func TestMaxContentHeight_BackwardCompat(t *testing.T) { + ta := New() + ta.Prompt = "" + ta.ShowLineNumbers = false + ta.MaxHeight = 10 + ta.SetWidth(20) + ta.Focus() + ta, _ = ta.Update(nil) + + enter := tea.KeyPressMsg{Code: tea.KeyEnter} + for range 15 { + ta, _ = ta.Update(keyPress('x')) + ta, _ = ta.Update(enter) + } + + if ta.LineCount() > 10 { + t.Errorf("expected logical line count <= 10 (legacy behavior), got %d", ta.LineCount()) + } +} + +func TestMaxContentHeight_WithoutDynamicHeight(t *testing.T) { + ta := New() + ta.Prompt = "" + ta.ShowLineNumbers = false + ta.MaxContentHeight = 5 + ta.SetHeight(3) + ta.SetWidth(20) + ta.Focus() + ta, _ = ta.Update(nil) + + enter := tea.KeyPressMsg{Code: tea.KeyEnter} + for range 10 { + ta, _ = ta.Update(keyPress('x')) + ta, _ = ta.Update(enter) + } + + if ta.Height() != 3 { + t.Errorf("expected fixed height 3, got %d", ta.Height()) + } + + if ta.totalVisualLines() > 5 { + t.Errorf("expected content capped at 5 visual lines, got %d", ta.totalVisualLines()) + } +} + +func TestMaxContentHeight_CursorVisibleWhileScrolling(t *testing.T) { + ta := newDynamicTextArea(1, 5) + ta.MaxContentHeight = 10 + + enter := tea.KeyPressMsg{Code: tea.KeyEnter} + for range 8 { + ta, _ = ta.Update(keyPress('x')) + ta, _ = ta.Update(enter) + } + ta, _ = ta.Update(keyPress('y')) + + cursorLine := ta.cursorLineNumber() + minVisible := ta.viewport.YOffset() + maxVisible := minVisible + ta.viewport.Height() - 1 + if cursorLine < minVisible || cursorLine > maxVisible { + t.Errorf("cursor line %d outside viewport [%d, %d] while scrolling", cursorLine, minVisible, maxVisible) + } +} + +func TestMaxContentHeight_PasteCapped(t *testing.T) { + ta := New() + ta.Prompt = "" + ta.ShowLineNumbers = false + ta.MaxContentHeight = 5 + ta.SetWidth(20) + ta.Focus() + ta, _ = ta.Update(nil) + + paste := tea.PasteMsg{Content: "1\n2\n3\n4\n5\n6\n7\n8\n9\n10"} + ta, _ = ta.Update(paste) + + if ta.totalVisualLines() > 5 { + t.Errorf("expected paste capped at 5 visual lines, got %d", ta.totalVisualLines()) + } +} + +func TestDynamicHeight_ShrinksWhenScrolledAndLinesDeleted(t *testing.T) { + ta := newDynamicTextArea(1, 5) + ta.MaxContentHeight = 10 + + enter := tea.KeyPressMsg{Code: tea.KeyEnter} + // Type 8 lines so we exceed MaxHeight (5) and start scrolling + for range 7 { + ta, _ = ta.Update(keyPress('x')) + ta, _ = ta.Update(enter) + } + ta, _ = ta.Update(keyPress('x')) + + if ta.Height() != 5 { + t.Fatalf("expected height 5 (capped at MaxHeight), got %d", ta.Height()) + } + if ta.LineCount() != 8 { + t.Fatalf("expected 8 lines, got %d", ta.LineCount()) + } + + // Now delete lines from the bottom by selecting all on current line and backspacing + backspace := tea.KeyPressMsg{Code: tea.KeyBackspace} + for ta.LineCount() > 4 { + ta.CursorEnd() + for len(ta.value[ta.row]) > 0 { + ta, _ = ta.Update(backspace) + } + ta, _ = ta.Update(backspace) // merge with previous line + } + + // Now we have 4 lines, which is less than MaxHeight (5). + // Height should shrink to 4. + if ta.Height() != 4 { + t.Errorf("expected height to shrink to 4 (matching content), got %d", ta.Height()) + } + if ta.viewport.YOffset() != 0 { + t.Errorf("expected yOffset 0 after shrinking, got %d", ta.viewport.YOffset()) + } +} + +func TestDynamicHeight_ShrinksWhenScrolledNoMaxContent(t *testing.T) { + // DynamicHeight with MaxHeight but no MaxContentHeight + ta := newDynamicTextArea(1, 99) + + enter := tea.KeyPressMsg{Code: tea.KeyEnter} + // Type 8 lines + for range 7 { + ta, _ = ta.Update(keyPress('x')) + ta, _ = ta.Update(enter) + } + ta, _ = ta.Update(keyPress('x')) + + if ta.Height() != 8 { + t.Fatalf("expected height 8, got %d", ta.Height()) + } + + // Manually set a smaller MaxHeight to simulate scrolling scenario + ta.MaxHeight = 5 + ta, _ = ta.Update(nil) + + // Now delete lines from the bottom + backspace := tea.KeyPressMsg{Code: tea.KeyBackspace} + for ta.LineCount() > 3 { + ta.CursorEnd() + for len(ta.value[ta.row]) > 0 { + ta, _ = ta.Update(backspace) + } + ta, _ = ta.Update(backspace) + } + + if ta.Height() != 3 { + t.Errorf("expected height to shrink to 3 (matching content), got %d", ta.Height()) + } + if ta.viewport.YOffset() != 0 { + t.Errorf("expected yOffset 0 after shrinking, got %d", ta.viewport.YOffset()) + } +} + func newTextArea() Model { textarea := New()