diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
deleted file mode 100644
index df42f0f28..000000000
--- a/.github/CODEOWNERS
+++ /dev/null
@@ -1,14 +0,0 @@
-* @meowgorithm @bashbunni
-cursor/ @aymanbagabas
-filepicker/ @bashbunni
-help/ @meowgorithm
-key/ @meowgorithm
-list/ @meowgorithm
-paginator/ @meowgorithm
-progress/ @meowgorithm
-spinner/ @meowgorithm
-stopwatch/ @caarlos0
-table/ @aymanbagabas
-textarea/ @aymanbagabas
-textinput/ @meowgorithm
-viewport/ @meowgorithm
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a23325e2d..126a96528 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -36,7 +36,7 @@ jobs:
if: ${{ github.actor == 'dependabot[bot]' && github.event_name == 'pull_request'}}
steps:
- id: metadata
- uses: dependabot/fetch-metadata@v2
+ uses: dependabot/fetch-metadata@v3
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- run: |
diff --git a/README.md b/README.md
index 87109d2bb..e4936e098 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,13 @@
# Bubbles
-
+
[](https://github.com/charmbracelet/bubbles/releases)
[](https://pkg.go.dev/github.com/charmbracelet/bubbles)
[](https://github.com/charmbracelet/bubbles/actions)
[](https://goreportcard.com/report/charmbracelet/bubbles)
-Some components for [Bubble Tea](https://github.com/charmbracelet/bubbletea)
+Primitives for [Bubble Tea](https://github.com/charmbracelet/bubbletea)
applications. These components are used in production in [Crush][crush], and [many other applications][otherstuff].
> [!TIP]
@@ -194,7 +194,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
To check out community-maintained Bubbles see [Charm & Friends][charmandfriends].
Made a cool Bubble that you want to share? [PRs][prs] are welcome!
-[charmandfriends]:
+[charmandfriends]: https://github.com/charm-and-friends/additional-bubbles
[prs]: https://github.com/charm-and-friends/additional-bubbles?tab=readme-ov-file#what-is-a-complete-project
## Contributing
diff --git a/cursor/cursor.go b/cursor/cursor.go
index 662ad18fe..3ec5d8f2a 100644
--- a/cursor/cursor.go
+++ b/cursor/cursor.go
@@ -31,8 +31,8 @@ type BlinkMsg struct {
tag int
}
-// blinkCanceled is sent when a blink operation is canceled.
-type blinkCanceled struct{}
+// BlinkCanceled is sent when a blink operation is canceled.
+type BlinkCanceled struct{}
// blinkCtx manages cursor blinking.
type blinkCtx struct {
@@ -151,7 +151,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
}
return m, cmd
- case blinkCanceled: // no-op
+ case BlinkCanceled: // no-op
return m, nil
}
return m, nil
@@ -201,7 +201,7 @@ func (m *Model) Blink() tea.Cmd {
if ctx.Err() == context.DeadlineExceeded {
return blinkMsg
}
- return blinkCanceled{}
+ return BlinkCanceled{}
}
}
diff --git a/cursor/cursor_test.go b/cursor/cursor_test.go
index e2083f6d5..4f3fd92ee 100644
--- a/cursor/cursor_test.go
+++ b/cursor/cursor_test.go
@@ -16,7 +16,7 @@ import (
// if ctx.Err() == context.DeadlineExceeded {
// return BlinkMsg{id: m.id, tag: m.blinkTag}
// }
-// return blinkCanceled{}
+// return BlinkCanceled{}
// }
//
// A race on “m.blinkTag” will occur if:
diff --git a/go.mod b/go.mod
index 8f22a95e4..755ce650b 100644
--- a/go.mod
+++ b/go.mod
@@ -1,35 +1,34 @@
module charm.land/bubbles/v2
-go 1.24.2
+go 1.25.0
require (
- charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251106192006-06c0cda318b3
- charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106192539-4b304240aab7
+ charm.land/bubbletea/v2 v2.0.6
+ charm.land/lipgloss/v2 v2.0.3
github.com/MakeNowJust/heredoc v1.0.0
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/harmonica v0.2.0
- github.com/charmbracelet/x/ansi v0.11.6
+ github.com/charmbracelet/x/ansi v0.11.7
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
github.com/dustin/go-humanize v1.0.1
- github.com/mattn/go-runewidth v0.0.20
+ github.com/mattn/go-runewidth v0.0.23
github.com/rivo/uniseg v0.4.7
github.com/sahilm/fuzzy v0.1.1
)
require (
- github.com/aymanbagabas/go-udiff v0.3.1 // indirect
- github.com/charmbracelet/colorprofile v0.4.1 // indirect
- github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 // indirect
+ github.com/aymanbagabas/go-udiff v0.4.1 // indirect
+ github.com/charmbracelet/colorprofile v0.4.3 // indirect
+ github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
- github.com/clipperhouse/displaywidth v0.9.0 // indirect
- github.com/clipperhouse/stringish v0.1.1 // indirect
- github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
+ github.com/clipperhouse/displaywidth v0.11.0 // indirect
+ github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
- github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
+ github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
- golang.org/x/sync v0.17.0 // indirect
- golang.org/x/sys v0.38.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.43.0 // indirect
)
diff --git a/go.sum b/go.sum
index 38e03debc..022748cd3 100644
--- a/go.sum
+++ b/go.sum
@@ -1,21 +1,21 @@
-charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251106192006-06c0cda318b3 h1:kHeOXDvccLh2f0gH3qxyMhN07VEnmc/3gPAZ50wn8ko=
-charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251106192006-06c0cda318b3/go.mod h1:e3yWIY4Tl/LJnOMOv9H4YgvDwrknVDm5az5ep5QRLfk=
-charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106192539-4b304240aab7 h1:059k1h5vvZ4ASinki9nmBguxu9Rq0UDDSa6q8LOUphk=
-charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106192539-4b304240aab7/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU=
+charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
+charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
+charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
+charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
-github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
-github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
-github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
-github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
+github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
+github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
+github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
+github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
-github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 h1:r/3jQZ1LjWW6ybp8HHfhrKrwHIWiJhUuY7wwYIWZulQ=
-github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692/go.mod h1:Y8B4DzWeTb0ama8l3+KyopZtkE8fZjwRQ3aEAPEXHE0=
-github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
-github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
+github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
+github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
+github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
+github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -24,20 +24,18 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
-github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
-github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
-github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
-github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
-github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
-github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
+github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
+github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
-github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
-github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
+github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
+github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -48,7 +46,7 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
-golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
-golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
-golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
-golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
diff --git a/textarea/textarea.go b/textarea/textarea.go
index 7e4508ab0..f0c0ca54b 100644
--- a/textarea/textarea.go
+++ b/textarea/textarea.go
@@ -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 {
diff --git a/textarea/textarea_test.go b/textarea/textarea_test.go
index d942dd40b..41d51f744 100644
--- a/textarea/textarea_test.go
+++ b/textarea/textarea_test.go
@@ -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()