Skip to content

Commit f1daacf

Browse files
feat(textarea): dynamic height (#910)
Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>
1 parent d2b804e commit f1daacf

File tree

2 files changed

+523
-1
lines changed

2 files changed

+523
-1
lines changed

textarea/textarea.go

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,23 @@ type Model struct {
287287
// there's no limit.
288288
MaxWidth int
289289

290+
// DynamicHeight, when true, causes the textarea to automatically grow
291+
// and shrink its height to fit the content. The height is clamped between
292+
// MinHeight and MaxHeight.
293+
DynamicHeight bool
294+
295+
// MinHeight is the minimum height of the text area in rows when
296+
// DynamicHeight is enabled. If 0 or less, defaults to 1.
297+
MinHeight int
298+
299+
// MaxContentHeight is the maximum content height in visual rows
300+
// (accounting for soft wraps). When set (> 0), input is blocked once
301+
// the total visual lines reach this limit, while MaxHeight controls
302+
// only the visible viewport height. When 0, the content guard falls
303+
// back to the legacy MaxHeight behavior (blocking at MaxHeight
304+
// logical lines) for backward compatibility.
305+
MaxContentHeight int
306+
290307
// Styling. Styles are defined in [Styles]. Use [SetStyles] and [GetStyles]
291308
// to work with this value publicly.
292309
styles Styles
@@ -464,16 +481,19 @@ func (m *Model) updateVirtualCursorStyle() {
464481
func (m *Model) SetValue(s string) {
465482
m.Reset()
466483
m.InsertString(s)
484+
m.recalculateHeight()
467485
}
468486

469487
// InsertString inserts a string at the cursor position.
470488
func (m *Model) InsertString(s string) {
471489
m.insertRunesFromUserInput([]rune(s))
490+
m.recalculateHeight()
472491
}
473492

474493
// InsertRune inserts a rune at the cursor position.
475494
func (m *Model) InsertRune(r rune) {
476495
m.insertRunesFromUserInput([]rune{r})
496+
m.recalculateHeight()
477497
}
478498

479499
// insertRunesFromUserInput inserts runes at the current cursor position.
@@ -521,6 +541,18 @@ func (m *Model) insertRunesFromUserInput(runes []rune) {
521541
lines = lines[:allowedHeight]
522542
}
523543

544+
// Obey MaxContentHeight in visual rows when set.
545+
if m.MaxContentHeight > 0 {
546+
budget := m.MaxContentHeight - m.totalVisualLines()
547+
// Trim lines from the end until we fit within the budget.
548+
for len(lines) > 1 && m.visualLinesForInsert(lines) > budget {
549+
lines = lines[:len(lines)-1]
550+
}
551+
if m.visualLinesForInsert(lines) > budget {
552+
return
553+
}
554+
}
555+
524556
if len(lines) == 0 {
525557
// Nothing left to insert.
526558
return
@@ -740,6 +772,7 @@ func (m *Model) Reset() {
740772
m.row = 0
741773
m.viewport.GotoTop()
742774
m.SetCursorColumn(0)
775+
m.recalculateHeight()
743776
}
744777

745778
// Word returns the word at the cursor position.
@@ -1134,6 +1167,7 @@ func (m *Model) SetWidth(w int) {
11341167

11351168
m.viewport.SetWidth(inputWidth - reservedOuter)
11361169
m.width = inputWidth - reservedOuter - reservedInner
1170+
m.recalculateHeight()
11371171
}
11381172

11391173
// 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) {
12381272
}
12391273
m.deleteWordRight()
12401274
case key.Matches(msg, m.KeyMap.InsertNewline):
1241-
if m.MaxHeight > 0 && len(m.value) >= m.MaxHeight {
1275+
if m.atContentLimit() {
12421276
return m, nil
12431277
}
12441278
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) {
12891323
m.Err = msg
12901324
}
12911325

1326+
m.recalculateHeight()
1327+
12921328
// Make sure we set the content of the viewport before updating it.
12931329
view := m.view()
12941330
m.viewport.SetContent(view)
@@ -1627,6 +1663,76 @@ func (m Model) cursorLineNumber() int {
16271663
return line
16281664
}
16291665

1666+
// totalVisualLines returns the total number of display lines across all
1667+
// logical lines, accounting for soft wraps.
1668+
func (m *Model) totalVisualLines() int {
1669+
n := 0
1670+
for _, line := range m.value {
1671+
n += len(m.memoizedWrap(line, m.width))
1672+
}
1673+
return n
1674+
}
1675+
1676+
// recalculateHeight recomputes and applies the textarea height based on
1677+
// content when DynamicHeight is enabled. It is a no-op otherwise.
1678+
func (m *Model) recalculateHeight() {
1679+
if !m.DynamicHeight {
1680+
return
1681+
}
1682+
minH := max(m.MinHeight, minHeight)
1683+
total := m.totalVisualLines()
1684+
h := max(total, minH)
1685+
if m.MaxHeight > 0 {
1686+
h = min(h, m.MaxHeight)
1687+
}
1688+
if maxOffset := total - h; m.viewport.YOffset() > maxOffset {
1689+
m.viewport.SetYOffset(max(0, maxOffset))
1690+
}
1691+
m.SetHeight(h)
1692+
}
1693+
1694+
// atContentLimit reports whether the textarea has reached its content limit.
1695+
// When MaxContentHeight is set (> 0), it checks total visual lines.
1696+
// Otherwise it falls back to the legacy MaxHeight logical-line check for
1697+
// backward compatibility.
1698+
func (m *Model) atContentLimit() bool {
1699+
if m.MaxContentHeight > 0 {
1700+
return m.totalVisualLines() >= m.MaxContentHeight
1701+
}
1702+
return m.MaxHeight > 0 && len(m.value) >= m.MaxHeight
1703+
}
1704+
1705+
// visualLinesForInsert estimates how many additional visual lines would result
1706+
// from inserting the given lines at the current cursor position. The first
1707+
// element merges into the current line; subsequent elements become new lines.
1708+
func (m *Model) visualLinesForInsert(lines [][]rune) int {
1709+
if len(lines) == 0 {
1710+
return 0
1711+
}
1712+
1713+
// The current row's visual line count before insertion.
1714+
currentRowVisual := len(m.memoizedWrap(m.value[m.row], m.width))
1715+
1716+
// Simulate merging the first paste line into the current row.
1717+
merged := make([]rune, m.col+len(lines[0]))
1718+
copy(merged, m.value[m.row][:m.col])
1719+
copy(merged[m.col:], lines[0])
1720+
if len(lines) == 1 {
1721+
merged = append(merged, m.value[m.row][m.col:]...)
1722+
}
1723+
delta := len(m.memoizedWrap(merged, m.width)) - currentRowVisual
1724+
1725+
// Each additional line is a new logical line.
1726+
for i, content := range lines {
1727+
if i == len(lines)-1 {
1728+
content = append(content, m.value[m.row][m.col:]...)
1729+
}
1730+
delta += len(m.memoizedWrap(content, m.width))
1731+
}
1732+
1733+
return delta
1734+
}
1735+
16301736
// mergeLineBelow merges the current line the cursor is on with the line below.
16311737
func (m *Model) mergeLineBelow(row int) {
16321738
if row >= len(m.value)-1 {

0 commit comments

Comments
 (0)