mirror of
https://github.com/zoriya/bubbles.git
synced 2026-08-04 20:26:08 +00:00
feat(textarea): dynamic height (#910)
Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>
This commit is contained in:
co-authored by
Andrey Nering
parent
d2b804ead2
commit
f1daacfa0c
+107
-1
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user