Files
bubbles/textarea/textarea_test.go

2432 lines
50 KiB
Go

package textarea
import (
"fmt"
"strings"
"testing"
"unicode"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/MakeNowJust/heredoc"
"github.com/charmbracelet/x/ansi"
)
func TestVerticalScrolling(t *testing.T) {
textarea := newTextArea()
textarea.Prompt = ""
textarea.ShowLineNumbers = false
textarea.SetHeight(1)
textarea.SetWidth(20)
textarea.CharLimit = 100
textarea, _ = textarea.Update(nil)
input := "This is a really long line that should wrap around the text area."
for _, k := range input {
textarea, _ = textarea.Update(keyPress(k))
}
view := textarea.View()
// The view should contain the end of "line" of the input.
if !strings.Contains(view, "the text area.") {
t.Log(view)
t.Error("Text area did not render the input")
}
// But we should be able to scroll to see the next line.
// Let's scroll down for each line to view the full input.
lines := []string{
"This is a really",
"long line that",
"should wrap around",
"the text area.",
}
textarea.viewport.GotoTop()
for _, line := range lines {
view = textarea.View()
if !strings.Contains(view, line) {
t.Log(view)
t.Error("Text area did not render the correct scrolled input")
}
textarea.viewport.ScrollDown(1)
}
}
func TestWordWrapOverflowing(t *testing.T) {
// An interesting edge case is when the user enters many words that fill up
// the text area and then goes back up and inserts a few words which causes
// a cascading wrap and causes an overflow of the last line.
//
// In this case, we should not let the user insert more words if, after the
// entire wrap is complete, the last line is overflowing.
textarea := newTextArea()
textarea.SetHeight(3)
textarea.SetWidth(20)
textarea.CharLimit = 500
textarea, _ = textarea.Update(nil)
input := "Testing Testing Testing Testing Testing Testing Testing Testing"
for _, k := range input {
textarea, _ = textarea.Update(keyPress(k))
textarea.View()
}
// We have essentially filled the text area with input.
// Let's see if we can cause wrapping to overflow the last line.
textarea.row = 0
textarea.col = 0
input = "Testing"
for _, k := range input {
textarea, _ = textarea.Update(keyPress(k))
textarea.View()
}
lastLineWidth := textarea.LineInfo().Width
if lastLineWidth > 20 {
t.Log(lastLineWidth)
t.Log(textarea.View())
t.Fail()
}
}
func TestValueSoftWrap(t *testing.T) {
textarea := newTextArea()
textarea.SetWidth(16)
textarea.SetHeight(10)
textarea.CharLimit = 500
textarea, _ = textarea.Update(nil)
input := "Testing Testing Testing Testing Testing Testing Testing Testing"
for _, k := range []rune(input) {
textarea, _ = textarea.Update(keyPress(k))
textarea.View()
}
value := textarea.Value()
if value != input {
t.Log(value)
t.Log(input)
t.Fatal("The text area does not have the correct value")
}
}
func TestSetValue(t *testing.T) {
textarea := newTextArea()
textarea.SetValue(strings.Join([]string{"Foo", "Bar", "Baz"}, "\n"))
if textarea.row != 2 && textarea.col != 3 {
t.Log(textarea.row, textarea.col)
t.Fatal("Cursor Should be on row 2 column 3 after inserting 2 new lines")
}
value := textarea.Value()
if value != "Foo\nBar\nBaz" {
t.Fatal("Value should be Foo\nBar\nBaz")
}
// SetValue should reset text area
textarea.SetValue("Test")
value = textarea.Value()
if value != "Test" {
t.Log(value)
t.Fatal("Text area was not reset when SetValue() was called")
}
}
func TestInsertString(t *testing.T) {
textarea := newTextArea()
// Insert some text
input := "foo baz"
for _, k := range []rune(input) {
textarea, _ = textarea.Update(keyPress(k))
}
// Put cursor in the middle of the text
textarea.col = 4
textarea.InsertString("bar ")
value := textarea.Value()
if value != "foo bar baz" {
t.Log(value)
t.Fatal("Expected insert string to insert bar between foo and baz")
}
}
func TestCanHandleEmoji(t *testing.T) {
textarea := newTextArea()
input := "🧋"
for _, k := range []rune(input) {
textarea, _ = textarea.Update(keyPress(k))
}
value := textarea.Value()
if value != input {
t.Log(value)
t.Fatal("Expected emoji to be inserted")
}
input = "🧋🧋🧋"
textarea.SetValue(input)
value = textarea.Value()
if value != input {
t.Log(value)
t.Fatal("Expected emoji to be inserted")
}
if textarea.col != 3 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the third character")
}
if charOffset := textarea.LineInfo().CharOffset; charOffset != 6 {
t.Log(charOffset)
t.Fatal("Expected cursor to be on the sixth character")
}
}
func TestVerticalNavigationKeepsCursorHorizontalPosition(t *testing.T) {
textarea := newTextArea()
textarea.SetWidth(20)
textarea.SetValue(strings.Join([]string{"你好你好", "Hello"}, "\n"))
textarea.row = 0
textarea.col = 2
// 你好|你好
// Hell|o
// 1234|
// Let's imagine our cursor is on the first line where the pipe is.
// We press the down arrow to get to the next line.
// The issue is that if we keep the cursor on the same column, the cursor will jump to after the `e`.
//
// 你好|你好
// He|llo
//
// But this is wrong because visually we were at the 4th character due to
// the first line containing double-width runes.
// We want to keep the cursor on the same visual column.
//
// 你好|你好
// Hell|o
//
// This test ensures that the cursor is kept on the same visual column by
// ensuring that the column offset goes from 2 -> 4.
lineInfo := textarea.LineInfo()
if lineInfo.CharOffset != 4 || lineInfo.ColumnOffset != 2 {
t.Log(lineInfo.CharOffset)
t.Log(lineInfo.ColumnOffset)
t.Fatal("Expected cursor to be on the fourth character because there are two double width runes on the first line.")
}
downMsg := tea.KeyPressMsg{Code: tea.KeyDown}
textarea, _ = textarea.Update(downMsg)
lineInfo = textarea.LineInfo()
if lineInfo.CharOffset != 4 || lineInfo.ColumnOffset != 4 {
t.Log(lineInfo.CharOffset)
t.Log(lineInfo.ColumnOffset)
t.Fatal("Expected cursor to be on the fourth character because we came down from the first line.")
}
}
func TestVerticalNavigationShouldRememberPositionWhileTraversing(t *testing.T) {
textarea := newTextArea()
textarea.SetWidth(40)
// Let's imagine we have a text area with the following content:
//
// Hello
// World
// This is a long line.
//
// If we are at the end of the last line and go up, we should be at the end
// of the second line.
// And, if we go up again we should be at the end of the first line.
// But, if we go back down twice, we should be at the end of the last line
// again and not the fifth (length of second line) character of the last line.
//
// In other words, we should remember the last horizontal position while
// traversing vertically.
textarea.SetValue(strings.Join([]string{"Hello", "World", "This is a long line."}, "\n"))
// We are at the end of the last line.
if textarea.col != 20 || textarea.row != 2 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the 20th character of the last line")
}
// Let's go up.
upMsg := tea.KeyPressMsg{Code: tea.KeyUp}
textarea, _ = textarea.Update(upMsg)
// We should be at the end of the second line.
if textarea.col != 5 || textarea.row != 1 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the 5th character of the second line")
}
// And, again.
textarea, _ = textarea.Update(upMsg)
// We should be at the end of the first line.
if textarea.col != 5 || textarea.row != 0 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the 5th character of the first line")
}
// Let's go down, twice.
downMsg := tea.KeyPressMsg{Code: tea.KeyDown}
textarea, _ = textarea.Update(downMsg)
textarea, _ = textarea.Update(downMsg)
// We should be at the end of the last line.
if textarea.col != 20 || textarea.row != 2 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the 20th character of the last line")
}
// Now, for correct behavior, if we move right or left, we should forget
// (reset) the saved horizontal position. Since we assume the user wants to
// keep the cursor where it is horizontally. This is how most text areas
// work.
textarea, _ = textarea.Update(upMsg)
leftMsg := tea.KeyPressMsg{Code: tea.KeyLeft}
textarea, _ = textarea.Update(leftMsg)
if textarea.col != 4 || textarea.row != 1 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the 5th character of the second line")
}
// Going down now should keep us at the 4th column since we moved left and
// reset the horizontal position saved state.
textarea, _ = textarea.Update(downMsg)
if textarea.col != 4 || textarea.row != 2 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the 4th character of the last line")
}
}
func TestView(t *testing.T) {
t.Parallel()
type want struct {
view string
cursorRow int
cursorCol int
}
tests := []struct {
name string
modelFunc func(Model) Model
want want
}{
{
name: "placeholder",
want: want{
view: heredoc.Doc(`
> 1 Hello, World!
>
>
>
>
>
`),
},
},
{
name: "single line",
modelFunc: func(m Model) Model {
m.SetValue("the first line")
return m
},
want: want{
view: heredoc.Doc(`
> 1 the first line
>
>
>
>
>
`),
cursorRow: 0,
cursorCol: 14,
},
},
{
name: "multiple lines",
modelFunc: func(m Model) Model {
m.SetValue("the first line\nthe second line\nthe third line")
return m
},
want: want{
view: heredoc.Doc(`
> 1 the first line
> 2 the second line
> 3 the third line
>
>
>
`),
cursorRow: 2,
cursorCol: 14,
},
},
{
name: "single line without line numbers",
modelFunc: func(m Model) Model {
m.SetValue("the first line")
m.ShowLineNumbers = false
return m
},
want: want{
view: heredoc.Doc(`
> the first line
>
>
>
>
>
`),
cursorRow: 0,
cursorCol: 14,
},
},
{
name: "multipline lines without line numbers",
modelFunc: func(m Model) Model {
m.SetValue("the first line\nthe second line\nthe third line")
m.ShowLineNumbers = false
return m
},
want: want{
view: heredoc.Doc(`
> the first line
> the second line
> the third line
>
>
>
`),
cursorRow: 2,
cursorCol: 14,
},
},
{
name: "single line and custom end of buffer character",
modelFunc: func(m Model) Model {
m.SetValue("the first line")
m.EndOfBufferCharacter = '*'
return m
},
want: want{
view: heredoc.Doc(`
> 1 the first line
> *
> *
> *
> *
> *
`),
cursorRow: 0,
cursorCol: 14,
},
},
{
name: "multiple lines and custom end of buffer character",
modelFunc: func(m Model) Model {
m.SetValue("the first line\nthe second line\nthe third line")
m.EndOfBufferCharacter = '*'
return m
},
want: want{
view: heredoc.Doc(`
> 1 the first line
> 2 the second line
> 3 the third line
> *
> *
> *
`),
cursorRow: 2,
cursorCol: 14,
},
},
{
name: "single line without line numbers and custom end of buffer character",
modelFunc: func(m Model) Model {
m.SetValue("the first line")
m.ShowLineNumbers = false
m.EndOfBufferCharacter = '*'
return m
},
want: want{
view: heredoc.Doc(`
> the first line
> *
> *
> *
> *
> *
`),
cursorRow: 0,
cursorCol: 14,
},
},
{
name: "multiple lines without line numbers and custom end of buffer character",
modelFunc: func(m Model) Model {
m.SetValue("the first line\nthe second line\nthe third line")
m.ShowLineNumbers = false
m.EndOfBufferCharacter = '*'
return m
},
want: want{
view: heredoc.Doc(`
> the first line
> the second line
> the third line
> *
> *
> *
`),
cursorRow: 2,
cursorCol: 14,
},
},
{
name: "single line and custom prompt",
modelFunc: func(m Model) Model {
m.SetValue("the first line")
m.Prompt = "* "
return m
},
want: want{
view: heredoc.Doc(`
* 1 the first line
*
*
*
*
*
`),
cursorRow: 0,
cursorCol: 14,
},
},
{
name: "multiple lines and custom prompt",
modelFunc: func(m Model) Model {
m.SetValue("the first line\nthe second line\nthe third line")
m.Prompt = "* "
return m
},
want: want{
view: heredoc.Doc(`
* 1 the first line
* 2 the second line
* 3 the third line
*
*
*
`),
cursorRow: 2,
cursorCol: 14,
},
},
{
name: "type single line",
modelFunc: func(m Model) Model {
input := "foo"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
> 1 foo
>
>
>
>
>
`),
cursorRow: 0,
cursorCol: 3,
},
},
{
name: "type multiple lines",
modelFunc: func(m Model) Model {
input := "foo\nbar\nbaz"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
> 1 foo
> 2 bar
> 3 baz
>
>
>
`),
cursorRow: 2,
cursorCol: 3,
},
},
{
name: "softwrap",
modelFunc: func(m Model) Model {
m.ShowLineNumbers = false
m.Prompt = ""
m.SetWidth(5)
input := "foo bar baz"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
foo
bar
baz
`),
cursorRow: 2,
cursorCol: 3,
},
},
{
name: "single line character limit",
modelFunc: func(m Model) Model {
m.CharLimit = 7
input := "foo bar baz"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
> 1 foo bar
>
>
>
>
>
`),
cursorRow: 0,
cursorCol: 7,
},
},
{
name: "multiple lines character limit",
modelFunc: func(m Model) Model {
m.CharLimit = 19
input := "foo bar baz\nfoo bar baz"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
> 1 foo bar baz
> 2 foo bar
>
>
>
>
`),
cursorRow: 1,
cursorCol: 7,
},
},
{
name: "set width",
modelFunc: func(m Model) Model {
m.SetWidth(10)
input := "12"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
> 1 12
>
>
>
>
>
`),
cursorRow: 0,
cursorCol: 2,
},
},
{
name: "set width max length text minus one",
modelFunc: func(m Model) Model {
m.SetWidth(10)
input := "123"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
> 1 123
>
>
>
>
>
`),
cursorRow: 0,
cursorCol: 3,
},
},
{
name: "set width max length text",
modelFunc: func(m Model) Model {
m.SetWidth(10)
input := "1234"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
> 1 1234
>
>
>
>
>
`),
cursorRow: 1,
cursorCol: 0,
},
},
{
name: "set width max length text plus one",
modelFunc: func(m Model) Model {
m.SetWidth(10)
input := "12345"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
> 1 1234
> 5
>
>
>
>
`),
cursorRow: 1,
cursorCol: 1,
},
},
{
name: "set width set max width minus one",
modelFunc: func(m Model) Model {
m.MaxWidth = 10
m.SetWidth(11)
input := "123"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
> 1 123
>
>
>
>
>
`),
cursorRow: 0,
cursorCol: 3,
},
},
{
name: "set width set max width",
modelFunc: func(m Model) Model {
m.MaxWidth = 10
m.SetWidth(11)
input := "1234"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
> 1 1234
>
>
>
>
>
`),
cursorRow: 1,
cursorCol: 0,
},
},
{
name: "set width set max width plus one",
modelFunc: func(m Model) Model {
m.MaxWidth = 10
m.SetWidth(11)
input := "12345"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
> 1 1234
> 5
>
>
>
>
`),
cursorRow: 1,
cursorCol: 1,
},
},
{
name: "set width min width minus one",
modelFunc: func(m Model) Model {
m.SetWidth(6)
input := "123"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
> 1 1
> 2
> 3
>
>
>
`),
cursorRow: 3,
cursorCol: 0,
},
},
{
name: "set width min width",
modelFunc: func(m Model) Model {
m.SetWidth(7)
input := "123"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
> 1 1
> 2
> 3
>
>
>
`),
cursorRow: 3,
cursorCol: 0,
},
},
{
name: "set width min width no line numbers",
modelFunc: func(m Model) Model {
m.ShowLineNumbers = false
m.SetWidth(0)
input := "123"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
> 1
> 2
> 3
>
>
>
`),
cursorRow: 3,
cursorCol: 0,
},
},
{
name: "set width min width no line numbers no prompt",
modelFunc: func(m Model) Model {
m.ShowLineNumbers = false
m.Prompt = ""
m.SetWidth(0)
input := "123"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
1
2
3
`),
cursorRow: 3,
cursorCol: 0,
},
},
{
name: "set width min width plus one",
modelFunc: func(m Model) Model {
m.SetWidth(8)
input := "123"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
> 1 12
> 3
>
>
>
>
`),
cursorRow: 1,
cursorCol: 1,
},
},
{
name: "set width without line numbers max length text minus one",
modelFunc: func(m Model) Model {
m.ShowLineNumbers = false
m.SetWidth(6)
input := "123"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
> 123
>
>
>
>
>
`),
cursorRow: 0,
cursorCol: 3,
},
},
{
name: "set width without line numbers max length text",
modelFunc: func(m Model) Model {
m.ShowLineNumbers = false
m.SetWidth(6)
input := "1234"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
> 1234
>
>
>
>
>
`),
cursorRow: 1,
cursorCol: 0,
},
},
{
name: "set width without line numbers max length text plus one",
modelFunc: func(m Model) Model {
m.ShowLineNumbers = false
m.SetWidth(6)
input := "12345"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
> 1234
> 5
>
>
>
>
`),
cursorRow: 1,
cursorCol: 1,
},
},
{
name: "set width with style",
modelFunc: func(m Model) Model {
s := m.Styles()
s.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder())
m.SetStyles(s)
m.Focus()
m.SetWidth(12)
input := "1"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
┌──────────┐
│> 1 1 │
│> │
│> │
│> │
│> │
│> │
└──────────┘
`),
cursorRow: 0,
cursorCol: 1,
},
},
{
name: "set width with style max width minus one",
modelFunc: func(m Model) Model {
s := m.Styles()
s.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder())
m.SetStyles(s)
m.Focus()
m.SetWidth(12)
input := "123"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
┌──────────┐
│> 1 123 │
│> │
│> │
│> │
│> │
│> │
└──────────┘
`),
cursorRow: 0,
cursorCol: 3,
},
},
{
name: "set width with style max width",
modelFunc: func(m Model) Model {
s := m.Styles()
s.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder())
m.SetStyles(s)
m.Focus()
m.SetWidth(12)
input := "1234"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
┌──────────┐
│> 1 1234│
│> │
│> │
│> │
│> │
│> │
└──────────┘
`),
cursorRow: 1,
cursorCol: 0,
},
},
{
name: "set width with style max width plus one",
modelFunc: func(m Model) Model {
s := m.Styles()
s.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder())
m.SetStyles(s)
m.Focus()
m.SetWidth(12)
input := "12345"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
┌──────────┐
│> 1 1234│
│> 5 │
│> │
│> │
│> │
│> │
└──────────┘
`),
cursorRow: 1,
cursorCol: 1,
},
},
{
name: "set width without line numbers with style",
modelFunc: func(m Model) Model {
s := m.Styles()
s.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder())
m.SetStyles(s)
m.Focus()
m.ShowLineNumbers = false
m.SetWidth(12)
input := "123456"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
┌──────────┐
│> 123456 │
│> │
│> │
│> │
│> │
│> │
└──────────┘
`),
cursorRow: 0,
cursorCol: 6,
},
},
{
name: "set width without line numbers with style max width minus one",
modelFunc: func(m Model) Model {
s := m.Styles()
s.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder())
m.SetStyles(s)
m.Focus()
m.ShowLineNumbers = false
m.SetWidth(12)
input := "1234567"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
┌──────────┐
│> 1234567 │
│> │
│> │
│> │
│> │
│> │
└──────────┘
`),
cursorRow: 0,
cursorCol: 7,
},
},
{
name: "set width without line numbers with style max width",
modelFunc: func(m Model) Model {
s := m.Styles()
s.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder())
m.SetStyles(s)
m.Focus()
m.ShowLineNumbers = false
m.SetWidth(12)
input := "12345678"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
┌──────────┐
│> 12345678│
│> │
│> │
│> │
│> │
│> │
└──────────┘
`),
cursorRow: 1,
cursorCol: 0,
},
},
{
name: "set width without line numbers with style max width plus one",
modelFunc: func(m Model) Model {
s := m.Styles()
s.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder())
m.SetStyles(s)
m.Focus()
m.ShowLineNumbers = false
m.SetWidth(12)
input := "123456789"
m = sendString(m, input)
return m
},
want: want{
view: heredoc.Doc(`
┌──────────┐
│> 12345678│
│> 9 │
│> │
│> │
│> │
│> │
└──────────┘
`),
cursorRow: 1,
cursorCol: 1,
},
},
{
name: "placeholder min width",
modelFunc: func(m Model) Model {
m.SetWidth(0)
return m
},
want: want{
view: heredoc.Doc(`
> 1 H
> e
> l
> l
> o
> ,
`),
},
},
{
name: "placeholder single line",
modelFunc: func(m Model) Model {
m.Placeholder = "placeholder the first line"
m.ShowLineNumbers = false
return m
},
want: want{
view: heredoc.Doc(`
> placeholder the first line
>
>
>
>
>
`),
},
},
{
name: "placeholder multiple lines",
modelFunc: func(m Model) Model {
m.Placeholder = "placeholder the first line\nplaceholder the second line\nplaceholder the third line"
m.ShowLineNumbers = false
return m
},
want: want{
view: heredoc.Doc(`
> placeholder the first line
> placeholder the second line
> placeholder the third line
>
>
>
`),
},
},
{
name: "placeholder single line with line numbers",
modelFunc: func(m Model) Model {
m.Placeholder = "placeholder the first line"
m.ShowLineNumbers = true
return m
},
want: want{
view: heredoc.Doc(`
> 1 placeholder the first line
>
>
>
>
>
`),
},
},
{
name: "placeholder multiple lines with line numbers",
modelFunc: func(m Model) Model {
m.Placeholder = "placeholder the first line\nplaceholder the second line\nplaceholder the third line"
m.ShowLineNumbers = true
return m
},
want: want{
view: heredoc.Doc(`
> 1 placeholder the first line
> placeholder the second line
> placeholder the third line
>
>
>
`),
},
},
{
name: "placeholder single line with end of buffer character",
modelFunc: func(m Model) Model {
m.Placeholder = "placeholder the first line"
m.ShowLineNumbers = false
m.EndOfBufferCharacter = '*'
return m
},
want: want{
view: heredoc.Doc(`
> placeholder the first line
> *
> *
> *
> *
> *
`),
},
},
{
name: "placeholder multiple lines with with end of buffer character",
modelFunc: func(m Model) Model {
m.Placeholder = "placeholder the first line\nplaceholder the second line\nplaceholder the third line"
m.ShowLineNumbers = false
m.EndOfBufferCharacter = '*'
return m
},
want: want{
view: heredoc.Doc(`
> placeholder the first line
> placeholder the second line
> placeholder the third line
> *
> *
> *
`),
},
},
{
name: "placeholder single line with line numbers and end of buffer character",
modelFunc: func(m Model) Model {
m.Placeholder = "placeholder the first line"
m.ShowLineNumbers = true
m.EndOfBufferCharacter = '*'
return m
},
want: want{
view: heredoc.Doc(`
> 1 placeholder the first line
> *
> *
> *
> *
> *
`),
},
},
{
name: "placeholder multiple lines with line numbers and end of buffer character",
modelFunc: func(m Model) Model {
m.Placeholder = "placeholder the first line\nplaceholder the second line\nplaceholder the third line"
m.ShowLineNumbers = true
m.EndOfBufferCharacter = '*'
return m
},
want: want{
view: heredoc.Doc(`
> 1 placeholder the first line
> placeholder the second line
> placeholder the third line
> *
> *
> *
`),
},
},
{
name: "placeholder single line that is longer than max width",
modelFunc: func(m Model) Model {
m.Placeholder = "placeholder the first line that is longer than the max width"
m.SetWidth(40)
m.ShowLineNumbers = false
return m
},
want: want{
view: heredoc.Doc(`
> placeholder the first line that is
> longer than the max width
>
>
>
>
`),
},
},
{
name: "placeholder multiple lines that are longer than max width",
modelFunc: func(m Model) Model {
m.Placeholder = "placeholder the first line that is longer than the max width\nplaceholder the second line that is longer than the max width"
m.ShowLineNumbers = false
m.SetWidth(40)
return m
},
want: want{
view: heredoc.Doc(`
> placeholder the first line that is
> longer than the max width
> placeholder the second line that is
> longer than the max width
>
>
`),
},
},
{
name: "placeholder single line that is longer than max width with line numbers",
modelFunc: func(m Model) Model {
m.Placeholder = "placeholder the first line that is longer than the max width"
m.ShowLineNumbers = true
m.SetWidth(40)
return m
},
want: want{
view: heredoc.Doc(`
> 1 placeholder the first line that is
> longer than the max width
>
>
>
>
`),
},
},
{
name: "placeholder multiple lines that are longer than max width with line numbers",
modelFunc: func(m Model) Model {
m.Placeholder = "placeholder the first line that is longer than the max width\nplaceholder the second line that is longer than the max width"
m.ShowLineNumbers = true
m.SetWidth(40)
return m
},
want: want{
view: heredoc.Doc(`
> 1 placeholder the first line that is
> longer than the max width
> placeholder the second line that
> is longer than the max width
>
>
`),
},
},
{
name: "placeholder single line that is longer than max width at limit",
modelFunc: func(m Model) Model {
m.Placeholder = "123456789012345678"
m.ShowLineNumbers = false
m.SetWidth(20)
return m
},
want: want{
view: heredoc.Doc(`
> 123456789012345678
>
>
>
>
>
`),
},
},
{
name: "placeholder single line that is longer than max width at limit plus one",
modelFunc: func(m Model) Model {
m.Placeholder = "1234567890123456789"
m.ShowLineNumbers = false
m.SetWidth(20)
return m
},
want: want{
view: heredoc.Doc(`
> 123456789012345678
> 9
>
>
>
>
`),
},
},
{
name: "placeholder single line that is longer than max width with line numbers at limit",
modelFunc: func(m Model) Model {
m.Placeholder = "12345678901234"
m.ShowLineNumbers = true
m.SetWidth(20)
return m
},
want: want{
view: heredoc.Doc(`
> 1 12345678901234
>
>
>
>
>
`),
},
},
{
name: "placeholder single line that is longer than max width with line numbers at limit plus one",
modelFunc: func(m Model) Model {
m.Placeholder = "123456789012345"
m.ShowLineNumbers = true
m.SetWidth(20)
return m
},
want: want{
view: heredoc.Doc(`
> 1 12345678901234
> 5
>
>
>
>
`),
},
},
{
name: "placeholder multiple lines that are longer than max width at limit",
modelFunc: func(m Model) Model {
m.Placeholder = "123456789012345678\n123456789012345678"
m.ShowLineNumbers = false
m.SetWidth(20)
return m
},
want: want{
view: heredoc.Doc(`
> 123456789012345678
> 123456789012345678
>
>
>
>
`),
},
},
{
name: "placeholder multiple lines that are longer than max width at limit plus one",
modelFunc: func(m Model) Model {
m.Placeholder = "1234567890123456789\n1234567890123456789"
m.ShowLineNumbers = false
m.SetWidth(20)
return m
},
want: want{
view: heredoc.Doc(`
> 123456789012345678
> 9
> 123456789012345678
> 9
>
>
`),
},
},
{
name: "placeholder multiple lines that are longer than max width with line numbers at limit",
modelFunc: func(m Model) Model {
m.Placeholder = "12345678901234\n12345678901234"
m.ShowLineNumbers = true
m.SetWidth(20)
return m
},
want: want{
view: heredoc.Doc(`
> 1 12345678901234
> 12345678901234
>
>
>
>
`),
},
},
{
name: "placeholder multiple lines that are longer than max width with line numbers at limit plus one",
modelFunc: func(m Model) Model {
m.Placeholder = "123456789012345\n123456789012345"
m.ShowLineNumbers = true
m.SetWidth(20)
return m
},
want: want{
view: heredoc.Doc(`
> 1 12345678901234
> 5
> 12345678901234
> 5
>
>
`),
},
},
{
name: "placeholder chinese character",
modelFunc: func(m Model) Model {
m.Placeholder = "输入消息..."
m.ShowLineNumbers = true
m.SetWidth(20)
return m
},
want: want{
view: heredoc.Doc(`
> 1 输入消息...
>
>
>
>
>
`),
},
},
{
name: "page up moves to beginning when near top",
modelFunc: func(m Model) Model {
m.ShowLineNumbers = true
m.SetHeight(4)
m.SetWidth(20)
lines := make([]string, 10)
for i := range 10 {
lines[i] = fmt.Sprintf("Line %d", i+1)
}
m.SetValue(strings.Join(lines, "\n"))
m.viewport.SetContent(m.view()) // force setting of viewport content.
m.row = 3
m.col = 0
m.viewport.SetYOffset(0)
m.PageUp()
return m
},
want: want{
view: heredoc.Doc(`
> 1 Line 1
> 2 Line 2
> 3 Line 3
> 4 Line 4
`),
cursorRow: 0,
},
},
{
name: "page up snaps to first visible line when not on it",
modelFunc: func(m Model) Model {
m.ShowLineNumbers = true
m.SetHeight(4)
m.SetWidth(20)
lines := make([]string, 10)
for i := range 10 {
lines[i] = fmt.Sprintf("Line %d", i+1)
}
m.SetValue(strings.Join(lines, "\n"))
m.viewport.SetContent(m.view()) // force setting of viewport content.
m.row = 5
m.col = 0
m.viewport.SetYOffset(3)
m.PageUp()
return m
},
want: want{
view: heredoc.Doc(`
> 4 Line 4
> 5 Line 5
> 6 Line 6
> 7 Line 7
`),
cursorRow: 3,
},
},
{
name: "page up moves up by full page when on first visible line",
modelFunc: func(m Model) Model {
m.ShowLineNumbers = true
m.SetHeight(3)
m.SetWidth(20)
lines := make([]string, 10)
for i := range 10 {
lines[i] = fmt.Sprintf("Line %d", i+1)
}
m.SetValue(strings.Join(lines, "\n"))
m.viewport.SetContent(m.view()) // force setting of viewport content.
m.row = 5
m.col = 0
m.viewport.SetYOffset(5)
m.PageUp()
return m
},
want: want{
view: heredoc.Doc(`
> 3 Line 3
> 4 Line 4
> 5 Line 5
`),
cursorRow: 2,
},
},
{
name: "page down moves to end when near bottom",
modelFunc: func(m Model) Model {
m.SetHeight(3)
m.SetWidth(20)
lines := make([]string, 10)
for i := range 10 {
lines[i] = fmt.Sprintf("Line %d", i+1)
}
m.SetValue(strings.Join(lines, "\n"))
m.viewport.SetContent(m.view()) // force setting of viewport content.
m.row = 8
m.col = 0
m.viewport.SetYOffset(7)
m.PageDown()
return m
},
want: want{
view: heredoc.Doc(`
> 8 Line 8
> 9 Line 9
> 10 Line 10
`),
cursorRow: 9,
},
},
{
name: "page down snaps to last visible line when not on it",
modelFunc: func(m Model) Model {
m.SetHeight(3)
m.SetWidth(20)
lines := make([]string, 10)
for i := range 10 {
lines[i] = fmt.Sprintf("Line %d", i+1)
}
m.SetValue(strings.Join(lines, "\n"))
m.viewport.SetContent(m.view()) // force setting of viewport content.
m.row = 3
m.col = 0
m.viewport.SetYOffset(3)
m.PageDown()
return m
},
want: want{
view: heredoc.Doc(`
> 4 Line 4
> 5 Line 5
> 6 Line 6
`),
cursorRow: 5,
},
},
{
name: "page down moves down by full page when on last visible line",
modelFunc: func(m Model) Model {
m.SetHeight(3)
m.SetWidth(20)
lines := make([]string, 10)
for i := range 10 {
lines[i] = fmt.Sprintf("Line %d", i+1)
}
m.SetValue(strings.Join(lines, "\n"))
m.viewport.SetContent(m.view()) // force setting of viewport content.
m.row = 4
m.col = 0
m.viewport.SetYOffset(2)
m.PageDown()
return m
},
want: want{
view: heredoc.Doc(`
> 6 Line 6
> 7 Line 7
> 8 Line 8
`),
cursorRow: 7,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
textarea := newTextArea()
if tt.modelFunc != nil {
textarea = tt.modelFunc(textarea)
}
view := stripString(textarea.View())
wantView := stripString(tt.want.view)
if view != wantView {
t.Fatalf("Want:\n%v\nGot:\n%v\n", wantView, view)
}
cursorRow := textarea.cursorLineNumber()
cursorCol := textarea.LineInfo().ColumnOffset
if tt.want.cursorRow != cursorRow || tt.want.cursorCol != cursorCol {
format := "Want cursor at row: %v, col: %v Got: row: %v col: %v\n"
t.Fatalf(format, tt.want.cursorRow, tt.want.cursorCol, cursorRow, cursorCol)
}
})
}
}
func TestWord(t *testing.T) {
textarea := newTextArea()
textarea.SetHeight(3)
textarea.SetWidth(20)
textarea.CharLimit = 500
textarea, _ = textarea.Update(nil)
t.Run("regular input", func(t *testing.T) {
input := "Word1 Word2 Word3 Word4"
for _, k := range input {
textarea, _ = textarea.Update(keyPress(k))
textarea.View()
}
expect := "Word4"
if word := textarea.Word(); word != expect {
t.Fatalf("Expected last word to be '%s', got '%s'", expect, word)
}
})
t.Run("navigate", func(t *testing.T) {
for _, k := range []tea.KeyPressMsg{
{Code: tea.KeyLeft, Mod: tea.ModAlt, Text: "alt+left"},
{Code: tea.KeyLeft, Mod: tea.ModAlt, Text: "alt+left"},
{Code: tea.KeyRight, Text: "right"},
} {
textarea, _ = textarea.Update(k)
textarea.View()
}
expect := "Word3"
if word := textarea.Word(); word != expect {
t.Fatalf("Expected last word to be '%s', got '%s'", expect, word)
}
})
t.Run("delete", func(t *testing.T) {
for _, k := range []tea.KeyPressMsg{
{Code: tea.KeyEnd, Text: "end"},
{Code: tea.KeyBackspace, Mod: tea.ModAlt, Text: "alt+backspace"},
{Code: tea.KeyBackspace, Mod: tea.ModAlt, Text: "alt+backspace"},
{Code: tea.KeyBackspace, Text: "backspace"},
} {
textarea, _ = textarea.Update(k)
textarea.View()
}
expect := "Word2"
if word := textarea.Word(); word != expect {
t.Fatalf("Expected last word to be '%s', got '%s'", expect, word)
}
})
}
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()
textarea.Prompt = "> "
textarea.Placeholder = "Hello, World!"
textarea.Focus()
textarea, _ = textarea.Update(nil)
return textarea
}
func keyPress(key rune) tea.Msg {
return tea.KeyPressMsg{Code: key, Text: string(key)}
}
func sendString(m Model, str string) Model {
for _, k := range []rune(str) {
m, _ = m.Update(keyPress(k))
}
return m
}
func stripString(str string) string {
s := ansi.Strip(str)
ss := strings.Split(s, "\n")
var lines []string
for _, l := range ss {
trim := strings.TrimRightFunc(l, unicode.IsSpace)
if trim != "" {
lines = append(lines, trim)
}
}
return strings.Join(lines, "\n")
}