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 - + [![Latest Release](https://img.shields.io/github/release/charmbracelet/bubbles.svg)](https://github.com/charmbracelet/bubbles/releases) [![GoDoc](https://godoc.org/github.com/golang/gddo?status.svg)](https://pkg.go.dev/github.com/charmbracelet/bubbles) [![Build Status](https://github.com/charmbracelet/bubbles/workflows/build/badge.svg)](https://github.com/charmbracelet/bubbles/actions) [![Go ReportCard](https://goreportcard.com/badge/charmbracelet/bubbles)](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()