feat(textarea): dynamic height (#910)

Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>
This commit is contained in:
Christian Rocha
2026-03-25 08:00:38 -04:00
committed by GitHub
co-authored by Andrey Nering
parent d2b804ead2
commit f1daacfa0c
2 changed files with 523 additions and 1 deletions
+107 -1
View File
@@ -287,6 +287,23 @@ type Model struct {
// there's no limit. // there's no limit.
MaxWidth int 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] // Styling. Styles are defined in [Styles]. Use [SetStyles] and [GetStyles]
// to work with this value publicly. // to work with this value publicly.
styles Styles styles Styles
@@ -464,16 +481,19 @@ func (m *Model) updateVirtualCursorStyle() {
func (m *Model) SetValue(s string) { func (m *Model) SetValue(s string) {
m.Reset() m.Reset()
m.InsertString(s) m.InsertString(s)
m.recalculateHeight()
} }
// InsertString inserts a string at the cursor position. // InsertString inserts a string at the cursor position.
func (m *Model) InsertString(s string) { func (m *Model) InsertString(s string) {
m.insertRunesFromUserInput([]rune(s)) m.insertRunesFromUserInput([]rune(s))
m.recalculateHeight()
} }
// InsertRune inserts a rune at the cursor position. // InsertRune inserts a rune at the cursor position.
func (m *Model) InsertRune(r rune) { func (m *Model) InsertRune(r rune) {
m.insertRunesFromUserInput([]rune{r}) m.insertRunesFromUserInput([]rune{r})
m.recalculateHeight()
} }
// insertRunesFromUserInput inserts runes at the current cursor position. // insertRunesFromUserInput inserts runes at the current cursor position.
@@ -521,6 +541,18 @@ func (m *Model) insertRunesFromUserInput(runes []rune) {
lines = lines[:allowedHeight] 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 { if len(lines) == 0 {
// Nothing left to insert. // Nothing left to insert.
return return
@@ -740,6 +772,7 @@ func (m *Model) Reset() {
m.row = 0 m.row = 0
m.viewport.GotoTop() m.viewport.GotoTop()
m.SetCursorColumn(0) m.SetCursorColumn(0)
m.recalculateHeight()
} }
// Word returns the word at the cursor position. // Word returns the word at the cursor position.
@@ -1134,6 +1167,7 @@ func (m *Model) SetWidth(w int) {
m.viewport.SetWidth(inputWidth - reservedOuter) m.viewport.SetWidth(inputWidth - reservedOuter)
m.width = inputWidth - reservedOuter - reservedInner m.width = inputWidth - reservedOuter - reservedInner
m.recalculateHeight()
} }
// SetPromptFunc supersedes the Prompt field and sets a dynamic prompt instead. // 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() m.deleteWordRight()
case key.Matches(msg, m.KeyMap.InsertNewline): case key.Matches(msg, m.KeyMap.InsertNewline):
if m.MaxHeight > 0 && len(m.value) >= m.MaxHeight { if m.atContentLimit() {
return m, nil return m, nil
} }
m.col = clamp(m.col, 0, len(m.value[m.row])) 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.Err = msg
} }
m.recalculateHeight()
// Make sure we set the content of the viewport before updating it. // Make sure we set the content of the viewport before updating it.
view := m.view() view := m.view()
m.viewport.SetContent(view) m.viewport.SetContent(view)
@@ -1627,6 +1663,76 @@ func (m Model) cursorLineNumber() int {
return line 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. // mergeLineBelow merges the current line the cursor is on with the line below.
func (m *Model) mergeLineBelow(row int) { func (m *Model) mergeLineBelow(row int) {
if row >= len(m.value)-1 { if row >= len(m.value)-1 {
+416
View File
@@ -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 { func newTextArea() Model {
textarea := New() textarea := New()