mirror of
https://github.com/zoriya/bubbles.git
synced 2026-08-05 04:36:07 +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.
|
// 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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user