chore(merge): branch 'v2-exp' into v2-area-textarea-api-updates

This commit is contained in:
Christian Rocha
2025-01-31 16:50:34 -05:00
8 changed files with 355 additions and 211 deletions
+30 -68
View File
@@ -1,5 +1,4 @@
Bubbles
=======
# Bubbles
<p>
<img src="https://stuff.charm.sh/bubbles/bubbles-github.png" width="233" alt="The Bubbles Logo">
@@ -18,7 +17,6 @@ applications. These components are used in production in [Glow][glow],
[charm]: https://github.com/charmbracelet/charm
[otherstuff]: https://github.com/charmbracelet/bubbletea/#bubble-tea-in-the-wild
## Spinner
<img src="https://stuff.charm.sh/bubbles-examples/spinner.gif" width="400" alt="Spinner Example">
@@ -26,9 +24,8 @@ applications. These components are used in production in [Glow][glow],
A spinner, useful for indicating that some kind an operation is happening.
There are a couple default ones, but you can also pass your own ”frames.”
* [Example code, basic spinner](https://github.com/charmbracelet/bubbletea/tree/master/examples/spinner/main.go)
* [Example code, various spinners](https://github.com/charmbracelet/bubbletea/tree/master/examples/spinners/main.go)
- [Example code, basic spinner](https://github.com/charmbracelet/bubbletea/tree/master/examples/spinner/main.go)
- [Example code, various spinners](https://github.com/charmbracelet/bubbletea/tree/master/examples/spinners/main.go)
## Text Input
@@ -38,8 +35,8 @@ A text input field, akin to an `<input type="text">` in HTML. Supports unicode,
pasting, in-place scrolling when the value exceeds the width of the element and
the common, and many customization options.
* [Example code, one field](https://github.com/charmbracelet/bubbletea/tree/master/examples/textinput/main.go)
* [Example code, many fields](https://github.com/charmbracelet/bubbletea/tree/master/examples/textinputs/main.go)
- [Example code, one field](https://github.com/charmbracelet/bubbletea/tree/master/examples/textinput/main.go)
- [Example code, many fields](https://github.com/charmbracelet/bubbletea/tree/master/examples/textinputs/main.go)
## Text Area
@@ -47,11 +44,11 @@ the common, and many customization options.
A text area field, akin to an `<textarea />` in HTML. Allows for input that
spans multiple lines. Supports unicode, pasting, vertical scrolling when the
value exceeds the width and height of the element, and many customization
value exceeds the width and height of the element, and many customization
options.
* [Example code, chat input](https://github.com/charmbracelet/bubbletea/tree/master/examples/chat/main.go)
* [Example code, story time input](https://github.com/charmbracelet/bubbletea/tree/master/examples/textarea/main.go)
- [Example code, chat input](https://github.com/charmbracelet/bubbletea/tree/master/examples/chat/main.go)
- [Example code, story time input](https://github.com/charmbracelet/bubbletea/tree/master/examples/textarea/main.go)
## Table
@@ -60,7 +57,7 @@ options.
A component for displaying and navigating tabular data (columns and rows).
Supports vertical scrolling and many customization options.
* [Example code, countries and populations](https://github.com/charmbracelet/bubbletea/tree/master/examples/table/main.go)
- [Example code, countries and populations](https://github.com/charmbracelet/bubbletea/tree/master/examples/table/main.go)
## Progress
@@ -71,12 +68,11 @@ A simple, customizable progress meter, with optional animation via
runes can be set to whatever you'd like. The percentage readout is customizable
and can also be omitted entirely.
* [Animated example](https://github.com/charmbracelet/bubbletea/blob/master/examples/progress-animated/main.go)
* [Static example](https://github.com/charmbracelet/bubbletea/blob/master/examples/progress-static/main.go)
- [Animated example](https://github.com/charmbracelet/bubbletea/blob/master/examples/progress-animated/main.go)
- [Static example](https://github.com/charmbracelet/bubbletea/blob/master/examples/progress-static/main.go)
[harmonica]: https://github.com/charmbracelet/harmonica
## Paginator
<img src="https://stuff.charm.sh/bubbles-examples/pagination.gif" width="200" alt="Paginator Example">
@@ -86,8 +82,7 @@ Supports "dot-style" pagination (similar to what you might see on iOS) and
numeric page numbering, but you could also just use this component for the
logic and visualize pagination however you like.
* [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/paginator/main.go)
- [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/paginator/main.go)
## Viewport
@@ -97,14 +92,13 @@ A viewport for vertically scrolling content. Optionally includes standard
pager keybindings and mouse wheel support. A high performance mode is available
for applications which make use of the alternate screen buffer.
* [Example code](https://github.com/charmbracelet/bubbletea/tree/master/examples/pager/main.go)
- [Example code](https://github.com/charmbracelet/bubbletea/tree/master/examples/pager/main.go)
This component is well complemented with [Reflow][reflow] for ANSI-aware
indenting and text wrapping.
[reflow]: https://github.com/muesli/reflow
## List
<img src="https://stuff.charm.sh/bubbles-examples/list.gif" width="600" alt="List Example">
@@ -114,9 +108,9 @@ Features pagination, fuzzy filtering, auto-generated help, an activity spinner,
and status messages, all of which can be enabled and disabled as needed.
Extrapolated from [Glow][glow].
* [Example code, default list](https://github.com/charmbracelet/bubbletea/tree/master/examples/list-default/main.go)
* [Example code, simple list](https://github.com/charmbracelet/bubbletea/tree/master/examples/list-simple/main.go)
* [Example code, all features](https://github.com/charmbracelet/bubbletea/tree/master/examples/list-fancy/main.go)
- [Example code, default list](https://github.com/charmbracelet/bubbletea/tree/master/examples/list-default/main.go)
- [Example code, simple list](https://github.com/charmbracelet/bubbletea/tree/master/examples/list-simple/main.go)
- [Example code, all features](https://github.com/charmbracelet/bubbletea/tree/master/examples/list-fancy/main.go)
## File Picker
@@ -126,7 +120,7 @@ A customizable component for picking a file from the file system. Navigate
through directories and select files, optionally limit to certain file
extensions.
* [Example code](https://github.com/charmbracelet/bubbletea/tree/master/examples/file-picker/main.go)
- [Example code](https://github.com/charmbracelet/bubbletea/tree/master/examples/file-picker/main.go)
## Timer
@@ -135,8 +129,7 @@ can be customized as you like.
<img src="https://stuff.charm.sh/bubbles-examples/timer.gif" width="400" alt="Timer example">
* [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/timer/main.go)
- [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/timer/main.go)
## Stopwatch
@@ -145,8 +138,7 @@ can be customized as you like.
A simple, flexible component for counting up. The update frequency and output
can be customized as you see fit.
* [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/stopwatch/main.go)
- [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/stopwatch/main.go)
## Help
@@ -157,8 +149,7 @@ from your keybindings. It features single and multi-line modes, which the user
can optionally toggle between. It will truncate gracefully if the terminal is
too wide for the content.
* [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/help/main.go)
- [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/help/main.go)
## Key
@@ -197,42 +188,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
```
## Theres more where that came from
## Additional Bubbles
<!-- in alphabetical order by author -->
* [76creates/stickers](https://github.com/76creates/stickers): Responsive
flexbox and table components.
* [calyptia/go-bubble-table](https://github.com/calyptia/go-bubble-table): An
interactive, customizable, scrollable table component.
* [erikgeiser/promptkit](https://github.com/erikgeiser/promptkit): A collection
of common prompts for cases like selection, text input, and confirmation.
Each prompt comes with sensible defaults, remappable keybindings, any many
customization options.
* [evertras/bubble-table](https://github.com/Evertras/bubble-table): Interactive,
customizable, paginated tables.
* [knipferrc/teacup](https://github.com/knipferrc/teacup): Various handy
bubbles and utilities for building Bubble Tea applications.
* [mritd/bubbles](https://github.com/mritd/bubbles): Some general-purpose
bubbles. Inputs with validation, menu selection, a modified progressbar, and
so on.
* [kevm/bubbleo](https://github.com/KevM/bubbleo): A set of bubbles with a
focus on navigation: navigation stacks, breakcrumbs, menus and so on.
* [treilik/bubbleboxer](https://github.com/treilik/bubbleboxer): Layout
multiple bubbles side-by-side in a layout-tree.
* [treilik/bubblelister](https://github.com/treilik/bubblelister): An alternate
list that is scrollable without pagination and has the ability to contain
other bubbles as list items.
If youve built a Bubble you think should be listed here, please create a Pull
Request. Please note that for a project to be included, it must meet the
following requirements:
- The README has a demo GIF.
- The README clearly states the purpose of the bubble along with an example on how to use it.
- The bubble must *always* be in a working state on its `main` branch.
Thank you!
To check out community-maintained Bubbles see [Charm &
Friends](https://github.com/charm-and-friends/additional-bubbles). Made a cool Bubble
that you want to share?
[PRs](https://github.com/charm-and-friends/additional-bubbles?tab=readme-ov-file#what-is-a-complete-project)
are welcome!
## Contributing
@@ -244,15 +206,15 @@ See [contributing][contribute].
Wed love to hear your thoughts on this project. Feel free to drop us a note!
* [Twitter](https://twitter.com/charmcli)
* [The Fediverse](https://mastodon.social/@charmcli)
* [Discord](https://charm.sh/chat)
- [Twitter](https://twitter.com/charmcli)
- [The Fediverse](https://mastodon.social/@charmcli)
- [Discord](https://charm.sh/chat)
## License
[MIT](https://github.com/charmbracelet/bubbletea/raw/master/LICENSE)
***
---
Part of [Charm](https://charm.sh).
+18 -6
View File
@@ -1,3 +1,5 @@
// Package cursor provides a virtual cursor to support the textinput and
// textarea elements.
package cursor
import (
@@ -51,25 +53,35 @@ func (c Mode) String() string {
// Model is the Bubble Tea model for this cursor element.
type Model struct {
BlinkSpeed time.Duration
// Style for styling the cursor block.
// Style styles the cursor block.
Style lipgloss.Style
// TextStyle is the style used for the cursor when it is hidden (when blinking).
// I.e. displaying normal text.
// TextStyle is the style used for the cursor when it is blinking
// (hidden), i.e. displaying normal text.
TextStyle lipgloss.Style
// BlinkSpeed is the speed at which the cursor blinks. This has no effect
// unless [CursorMode] is not set to [CursorBlink].
BlinkSpeed time.Duration
// Blink is the state of the cursor blink. When true, the cursor is hidden.
Blink bool
// char is the character under the cursor
char string
// The ID of this Model as it relates to other cursors
id int
// focus indicates whether the containing input is focused
focus bool
// Cursor Blink state.
Blink bool
// Used to manage cursor blink
blinkCtx *blinkCtx
// The ID of the blink message we're expecting to receive.
blinkTag int
// mode determines the behavior of the cursor
mode Mode
}
+1 -1
View File
@@ -5,7 +5,7 @@ go 1.18
require (
github.com/MakeNowJust/heredoc v1.0.0
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250114201644-43a5b4dd0af0
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123211610-443afa6fa0c1
github.com/charmbracelet/harmonica v0.2.0
github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250114171829-b67eb015d607
github.com/charmbracelet/x/ansi v0.7.0
+8
View File
@@ -6,6 +6,14 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250114201644-43a5b4dd0af0 h1:BWjXQRSwBjoCpLeNu8zT93n+NHhZZhkQQLveXMmnkYc=
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250114201644-43a5b4dd0af0/go.mod h1:hT2875Ank3ylgW13kqu6cjDc9XIk9sE5JsOFYdl09b8=
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123204203-55f6f9f70bf6 h1:L2+Kl71AsucUpl32AqmbjVv/4Ha7dwlSFwqrU4sAeTE=
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123204203-55f6f9f70bf6/go.mod h1:hT2875Ank3ylgW13kqu6cjDc9XIk9sE5JsOFYdl09b8=
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123210727-80fa20da7d7b h1:QqN3KApDbHJl+B1lVSir6GyRbxH7EA6U1SCDoxz8xYU=
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123210727-80fa20da7d7b/go.mod h1:hT2875Ank3ylgW13kqu6cjDc9XIk9sE5JsOFYdl09b8=
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123210853-99e3bbf892cd h1:1WsMNlPUaDXgJprIvWg+ZsXmc4GiL4KsBEFNZ3ymKeA=
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123210853-99e3bbf892cd/go.mod h1:hT2875Ank3ylgW13kqu6cjDc9XIk9sE5JsOFYdl09b8=
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123211610-443afa6fa0c1 h1:tktnM4YimEWSYd58iZlPDB3Xz25/r94VYZZsHK5zWL0=
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123211610-443afa6fa0c1/go.mod h1:hT2875Ank3ylgW13kqu6cjDc9XIk9sE5JsOFYdl09b8=
github.com/charmbracelet/colorprofile v0.1.9 h1:5JnfvX+I9D6rRNu8xK3pgIqknaBVTXHU9pGu1jkZxLw=
github.com/charmbracelet/colorprofile v0.1.9/go.mod h1:+jpmObxZl1Dab3H3IMVIPSZTsKcFpjJUv97G0dLqM60=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
+2 -2
View File
@@ -182,7 +182,7 @@ func New(opts ...Option) Model {
}
// Init exists to satisfy the tea.Model interface.
func (m Model) Init() (tea.Model, tea.Cmd) {
func (m Model) Init() (Model, tea.Cmd) {
return m, nil
}
@@ -190,7 +190,7 @@ func (m Model) Init() (tea.Model, tea.Cmd) {
// SetPercent to create the command you'll need to trigger the animation.
//
// If you're rendering with ViewAs you won't need this.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case FrameMsg:
if msg.id != m.id || msg.tag != m.tag {
+1 -1
View File
@@ -86,7 +86,7 @@ func (m Model) Init() (Model, tea.Cmd) {
// Start starts the stopwatch.
func (m Model) Start() tea.Cmd {
return tea.Batch(func() tea.Msg {
return tea.Sequence(func() tea.Msg {
return StartStopMsg{ID: m.id, running: true}
}, tick(m.id, m.tag, m.Interval))
}
+294 -132
View File
@@ -3,9 +3,11 @@ package textarea
import (
"crypto/sha256"
"fmt"
"image/color"
"reflect"
"strconv"
"strings"
"time"
"unicode"
"github.com/atotto/clipboard"
@@ -108,19 +110,25 @@ func DefaultKeyMap() KeyMap {
type LineInfo struct {
// Width is the number of columns in the line.
Width int
// CharWidth is the number of characters in the line to account for
// double-width runes.
CharWidth int
// Height is the number of rows in the line.
Height int
// StartColumn is the index of the first column of the line.
StartColumn int
// ColumnOffset is the number of columns that the cursor is offset from the
// start of the line.
ColumnOffset int
// RowOffset is the number of rows that the cursor is offset from the start
// of the line.
RowOffset int
// CharOffset is the number of characters that the cursor is offset
// from the start of the line. This will generally be equivalent to
// ColumnOffset, but will be different there are double-width runes before
@@ -128,12 +136,41 @@ type LineInfo struct {
CharOffset int
}
// CursorStyle is the style for real and virtual cursors.
type CursorStyle struct {
// Style styles the cursor block.
//
// For real cursors, the foreground color set here will be used as the
// cursor color.
Color color.Color
// Shape is the cursor shape. The following shapes are available:
//
// - tea.CursorBlock
// - tea.CursorUnderline
// - tea.CursorBar
//
// This is only used for real cursors.
Shape tea.CursorShape
// CursorBlink determines whether or not the cursor should blink.
Blink bool
// BlinkSpeed is the speed at which the virtual cursor blinks. This has no
// effect on real cursors as well as no effect if the cursor is set not to
// [CursorBlink].
//
// By default, the blink speed is set to about 500ms.
BlinkSpeed time.Duration
}
// Styles are the styles for the textarea, separated into focused and blurred
// states. The appropriate styles will be chosen based on the focus state of
// the textarea.
type Styles struct {
Focused StyleState
Blurred StyleState
Cursor CursorStyle
}
// StyleState that will be applied to the text area.
@@ -145,13 +182,13 @@ type Styles struct {
// https://github.com/charmbracelet/lipgloss
type StyleState struct {
Base lipgloss.Style
CursorLine lipgloss.Style
CursorLineNumber lipgloss.Style
EndOfBuffer lipgloss.Style
Text lipgloss.Style
LineNumber lipgloss.Style
CursorLineNumber lipgloss.Style
CursorLine lipgloss.Style
EndOfBuffer lipgloss.Style
Placeholder lipgloss.Style
Prompt lipgloss.Style
Text lipgloss.Style
}
func (s StyleState) computedCursorLine() lipgloss.Style {
@@ -210,7 +247,7 @@ type Model struct {
// When changing the value of Prompt after the model has been
// initialized, ensure that SetWidth() gets called afterwards.
//
// See also SetPromptFunc().
// See also [SetPromptFunc] for a dynamic prompt.
Prompt string
// Placeholder is the text displayed when the user
@@ -231,14 +268,12 @@ type Model struct {
// focused and blurred states.
Styles Styles
// activeStyle is the current styling to use.
// It is used to abstract the differences in focus state when styling the
// model, since we can simply assign the set of activeStyle to this variable
// when switching focus states.
activeStyle *StyleState
// virtualCursor manages the virtual cursor.
virtualCursor cursor.Model
// Cursor is the text area cursor.
Cursor cursor.Model
// VirtualCursor determines whether or not to use the virtual cursor. If
// set to false, use [Model.Cursor] to return a real cursor for rendering.
VirtualCursor bool
// CharLimit is the maximum number of characters this input element will
// accept. If 0 or less, there's no limit.
@@ -319,11 +354,11 @@ func New() Model {
MaxWidth: defaultMaxWidth,
Prompt: lipgloss.ThickBorder().Left + " ",
Styles: styles,
activeStyle: &styles.Blurred,
cache: memoization.NewMemoCache[line, [][]rune](maxLines),
EndOfBufferCharacter: ' ',
ShowLineNumbers: true,
Cursor: cur,
VirtualCursor: true,
virtualCursor: cur,
KeyMap: DefaultKeyMap(),
suggestions: [][][]rune{},
@@ -367,6 +402,11 @@ func DefaultStyles(isDark bool) Styles {
Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
Text: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))),
}
s.Cursor = CursorStyle{
Color: lipgloss.Color("7"),
Shape: tea.CursorBlock,
Blink: true,
}
return s
}
@@ -380,6 +420,33 @@ func DefaultDarkStyles() Styles {
return DefaultStyles(true)
}
// updateVirtualCursorStyle sets styling on the virtual cursor based on the
// textarea's style settings.
func (m *Model) updateVirtualCursorStyle() {
if !m.VirtualCursor {
m.virtualCursor.SetMode(cursor.CursorHide)
return
}
m.virtualCursor.Style = lipgloss.NewStyle().Foreground(m.Styles.Cursor.Color)
// By default, the blink speed of the cursor is set to a default
// internally.
if m.Styles.Cursor.BlinkSpeed > 0 {
m.virtualCursor.BlinkSpeed = m.Styles.Cursor.BlinkSpeed
}
if !m.VirtualCursor {
m.virtualCursor.SetMode(cursor.CursorHide)
return
}
if m.Styles.Cursor.Blink {
m.virtualCursor.SetMode(cursor.CursorBlink)
return
}
m.virtualCursor.SetMode(cursor.CursorStatic)
}
// SetValue sets the value of the text input.
func (m *Model) SetValue(s string) {
m.Reset()
@@ -495,7 +562,7 @@ func (m *Model) insertRunesFromUserInput(runes []rune) {
m.value[m.row] = append(m.value[m.row], tail...)
m.format()
m.SetCursor(m.col)
m.SetCursorColumn(m.col)
}
// Value returns the value of the text input.
@@ -603,9 +670,9 @@ func (m *Model) CursorUp() {
}
}
// SetCursor moves the cursor to the given position. If the position is
// SetCursorColumn moves the cursor to the given position. If the position is
// out of bounds the cursor will be moved to the start or end accordingly.
func (m *Model) SetCursor(col int) {
func (m *Model) SetCursorColumn(col int) {
m.col = clamp(col, 0, len(m.value[m.row]))
// Any time that we move the cursor horizontally we need to reset the last
// offset so that the horizontal position when navigating is adjusted.
@@ -614,12 +681,12 @@ func (m *Model) SetCursor(col int) {
// CursorStart moves the cursor to the start of the input field.
func (m *Model) CursorStart() {
m.SetCursor(0)
m.SetCursorColumn(0)
}
// CursorEnd moves the cursor to the end of the input field.
func (m *Model) CursorEnd() {
m.SetCursor(len(m.value[m.row]))
m.SetCursorColumn(len(m.value[m.row]))
}
// Focused returns the focus state on the model.
@@ -627,20 +694,27 @@ func (m Model) Focused() bool {
return m.focus
}
// activeStyle returns the appropriate set of styles to use depending on
// whether the textarea is focused or blurred.
func (m Model) activeStyle() *StyleState {
if m.focus {
return &m.Styles.Focused
}
return &m.Styles.Blurred
}
// Focus sets the focus state on the model. When the model is in focus it can
// receive keyboard input and the cursor will be hidden.
func (m *Model) Focus() tea.Cmd {
m.focus = true
m.activeStyle = &m.Styles.Focused
return m.Cursor.Focus()
return m.virtualCursor.Focus()
}
// Blur removes the focus state on the model. When the model is blurred it can
// not receive keyboard input and the cursor will be hidden.
func (m *Model) Blur() {
m.focus = false
m.activeStyle = &m.Styles.Blurred
m.Cursor.Blur()
m.virtualCursor.Blur()
}
// Reset sets the input to its default state with no input.
@@ -649,7 +723,7 @@ func (m *Model) Reset() {
m.col = 0
m.row = 0
m.viewport.GotoTop()
m.SetCursor(0)
m.SetCursorColumn(0)
}
// san initializes or retrieves the rune sanitizer.
@@ -666,7 +740,7 @@ func (m *Model) san() runeutil.Sanitizer {
// not the cursor blink should be reset.
func (m *Model) deleteBeforeCursor() {
m.value[m.row] = m.value[m.row][m.col:]
m.SetCursor(0)
m.SetCursorColumn(0)
}
// deleteAfterCursor deletes all text after the cursor. Returns whether or not
@@ -674,7 +748,7 @@ func (m *Model) deleteBeforeCursor() {
// the cursor so as not to reveal word breaks in the masked input.
func (m *Model) deleteAfterCursor() {
m.value[m.row] = m.value[m.row][:m.col]
m.SetCursor(len(m.value[m.row]))
m.SetCursorColumn(len(m.value[m.row]))
}
// transposeLeft exchanges the runes at the cursor and immediately
@@ -686,11 +760,11 @@ func (m *Model) transposeLeft() {
return
}
if m.col >= len(m.value[m.row]) {
m.SetCursor(m.col - 1)
m.SetCursorColumn(m.col - 1)
}
m.value[m.row][m.col-1], m.value[m.row][m.col] = m.value[m.row][m.col], m.value[m.row][m.col-1]
if m.col < len(m.value[m.row]) {
m.SetCursor(m.col + 1)
m.SetCursorColumn(m.col + 1)
}
}
@@ -706,22 +780,22 @@ func (m *Model) deleteWordLeft() {
// call into the corresponding if clause does not apply here.
oldCol := m.col //nolint:ifshort
m.SetCursor(m.col - 1)
m.SetCursorColumn(m.col - 1)
for unicode.IsSpace(m.value[m.row][m.col]) {
if m.col <= 0 {
break
}
// ignore series of whitespace before cursor
m.SetCursor(m.col - 1)
m.SetCursorColumn(m.col - 1)
}
for m.col > 0 {
if !unicode.IsSpace(m.value[m.row][m.col]) {
m.SetCursor(m.col - 1)
m.SetCursorColumn(m.col - 1)
} else {
if m.col > 0 {
// keep the previous space
m.SetCursor(m.col + 1)
m.SetCursorColumn(m.col + 1)
}
break
}
@@ -744,12 +818,12 @@ func (m *Model) deleteWordRight() {
for m.col < len(m.value[m.row]) && unicode.IsSpace(m.value[m.row][m.col]) {
// ignore series of whitespace after cursor
m.SetCursor(m.col + 1)
m.SetCursorColumn(m.col + 1)
}
for m.col < len(m.value[m.row]) {
if !unicode.IsSpace(m.value[m.row][m.col]) {
m.SetCursor(m.col + 1)
m.SetCursorColumn(m.col + 1)
} else {
break
}
@@ -761,13 +835,13 @@ func (m *Model) deleteWordRight() {
m.value[m.row] = append(m.value[m.row][:oldCol], m.value[m.row][m.col:]...)
}
m.SetCursor(oldCol)
m.SetCursorColumn(oldCol)
}
// characterRight moves the cursor one character to the right.
func (m *Model) characterRight() {
if m.col < len(m.value[m.row]) {
m.SetCursor(m.col + 1)
m.SetCursorColumn(m.col + 1)
} else {
if m.row < len(m.value)-1 {
m.row++
@@ -788,7 +862,7 @@ func (m *Model) characterLeft(insideLine bool) {
}
}
if m.col > 0 {
m.SetCursor(m.col - 1)
m.SetCursorColumn(m.col - 1)
}
}
@@ -807,7 +881,7 @@ func (m *Model) wordLeft() {
if unicode.IsSpace(m.value[m.row][m.col-1]) {
break
}
m.SetCursor(m.col - 1)
m.SetCursorColumn(m.col - 1)
}
}
@@ -834,7 +908,7 @@ func (m *Model) doWordRight(fn func(charIdx int, pos int)) {
break
}
fn(charIdx, m.col)
m.SetCursor(m.col + 1)
m.SetCursorColumn(m.col + 1)
charIdx++
}
}
@@ -924,13 +998,13 @@ func (m Model) Width() int {
// moveToBegin moves the cursor to the beginning of the input.
func (m *Model) moveToBegin() {
m.row = 0
m.SetCursor(0)
m.SetCursorColumn(0)
}
// moveToEnd moves the cursor to the end of the input.
func (m *Model) moveToEnd() {
m.row = len(m.value) - 1
m.SetCursor(len(m.value[m.row]))
m.SetCursorColumn(len(m.value[m.row]))
}
// SetWidth sets the width of the textarea to fit exactly within the given width.
@@ -941,22 +1015,29 @@ func (m *Model) moveToEnd() {
// It is important that the width of the textarea be exactly the given width
// and no more.
func (m *Model) SetWidth(w int) {
// Update prompt width only if there is no prompt function as SetPromptFunc
// updates the prompt width when it is called.
// Update prompt width only if there is no prompt function as
// [SetPromptFunc] updates the prompt width when it is called.
if m.promptFunc == nil {
// XXX: Do we even need this or can we calculate the prompt width
// at render time?
m.promptWidth = uniseg.StringWidth(m.Prompt)
}
// Add base style borders and padding to reserved outer width.
reservedOuter := m.activeStyle.Base.GetHorizontalFrameSize()
reservedOuter := m.activeStyle().Base.GetHorizontalFrameSize()
// Add prompt width to reserved inner width.
reservedInner := m.promptWidth
// Add line number width to reserved inner width.
if m.ShowLineNumbers {
const lnWidth = 4 // Up to 3 digits for line number plus 1 margin.
reservedInner += lnWidth
// XXX: this was originally documented as needing "1 cell" but was,
// in practice, effectively hardcoded to 2 cells. We can, and should,
// reduce this to one gap and update the tests accordingly.
const gap = 2
// Number of digits plus 1 cell for the margin.
reservedInner += numDigits(m.MaxHeight) + gap
}
// Input width must be at least one more than the reserved inner and outer
@@ -977,14 +1058,13 @@ func (m *Model) SetWidth(w int) {
m.width = inputWidth - reservedOuter - reservedInner
}
// SetPromptFunc supersedes the Prompt field and sets a dynamic prompt
// instead.
// If the function returns a prompt that is shorter than the
// specified promptWidth, it will be padded to the left.
// If it returns a prompt that is longer, display artifacts
// may occur; the caller is responsible for computing an adequate
// promptWidth.
func (m *Model) SetPromptFunc(promptWidth int, fn func(lineIdx int) string) {
// SetPromptFunc supersedes the Prompt field and sets a dynamic prompt instead.
//
// If the function returns a prompt that is shorter than the specified
// promptWidth, it will be padded to the left. If it returns a prompt that is
// longer, display artifacts may occur; the caller is responsible for computing
// an adequate promptWidth.
func (m *Model) SetPromptFunc(promptWidth int, fn func(lineIndex int) string) {
m.promptFunc = fn
m.promptWidth = promptWidth
}
@@ -1008,7 +1088,7 @@ func (m *Model) SetHeight(h int) {
// Update is the Bubble Tea update loop.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.focus {
m.Cursor.Blur()
m.virtualCursor.Blur()
return m, nil
}
@@ -1064,7 +1144,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if len(m.value[m.row]) > 0 {
m.value[m.row] = append(m.value[m.row][:max(0, m.col-1)], m.value[m.row][m.col:]...)
if m.col > 0 {
m.SetCursor(m.col - 1)
m.SetCursorColumn(m.col - 1)
}
}
case key.Matches(msg, m.KeyMap.DeleteCharacterForward):
@@ -1157,10 +1237,10 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
cmds = append(cmds, cmd)
newRow, newCol := m.cursorLineNumber(), m.col
m.Cursor, cmd = m.Cursor.Update(msg)
if (newRow != oldRow || newCol != oldCol) && m.Cursor.Mode() == cursor.CursorBlink {
m.Cursor.Blink = false
cmd = m.Cursor.BlinkCmd()
m.virtualCursor, cmd = m.virtualCursor.Update(msg)
if (newRow != oldRow || newCol != oldCol) && m.virtualCursor.Mode() == cursor.CursorBlink {
m.virtualCursor.Blink = false
cmd = m.virtualCursor.BlinkCmd()
}
cmds = append(cmds, cmd)
@@ -1182,7 +1262,7 @@ func (m Model) suggestionView(offset int) string {
var lines []string
for _, line := range strings.Split(suggestion[len(m.Value())+offset:], "\n") {
lines = append(lines, m.activeStyle.Placeholder.Inline(true).Render(line))
lines = append(lines, m.activeStyle().Placeholder.Inline(true).Render(line))
}
if len(lines) > m.Height() {
m.SetHeight(len(lines) + 1)
@@ -1192,10 +1272,11 @@ func (m Model) suggestionView(offset int) string {
// View renders the text area in its current state.
func (m Model) View() string {
m.updateVirtualCursorStyle()
if m.Value() == "" && m.row == 0 && m.col == 0 && m.Placeholder != "" {
return m.placeholderView()
}
m.Cursor.TextStyle = m.activeStyle.computedCursorLine()
m.virtualCursor.TextStyle = m.activeStyle().computedCursorLine()
var (
s strings.Builder
@@ -1203,6 +1284,7 @@ func (m Model) View() string {
newLines int
widestLineNumber int
lineInfo = m.LineInfo()
styles = m.activeStyle()
)
displayLine := 0
@@ -1210,40 +1292,30 @@ func (m Model) View() string {
wrappedLines := m.memoizedWrap(line, m.width)
if m.row == l {
style = m.activeStyle.computedCursorLine()
style = styles.computedCursorLine()
} else {
style = m.activeStyle.computedText()
style = styles.computedText()
}
for wl, wrappedLine := range wrappedLines {
prompt := m.getPromptString(displayLine)
prompt = m.activeStyle.computedPrompt().Render(prompt)
prompt := m.promptView(displayLine)
prompt = styles.computedPrompt().Render(prompt)
s.WriteString(style.Render(prompt))
displayLine++
var ln string
if m.ShowLineNumbers { //nolint:nestif
if wl == 0 {
if m.row == l {
ln = style.Render(m.activeStyle.computedCursorLineNumber().Render(m.formatLineNumber(l + 1)))
s.WriteString(ln)
} else {
ln = style.Render(m.activeStyle.computedLineNumber().Render(m.formatLineNumber(l + 1)))
s.WriteString(ln)
}
} else {
if m.row == l {
ln = style.Render(m.activeStyle.computedCursorLineNumber().Render(m.formatLineNumber(" ")))
s.WriteString(ln)
} else {
ln = style.Render(m.activeStyle.computedLineNumber().Render(m.formatLineNumber(" ")))
s.WriteString(ln)
}
if wl == 0 { // normal line
isCursorLine := m.row == l
s.WriteString(m.lineNumberView(l+1, isCursorLine))
} else { // soft wrapped line
isCursorLine := m.row == l
s.WriteString(m.lineNumberView(-1, isCursorLine))
}
}
// Note the widest line number for padding purposes later.
lnw := lipgloss.Width(ln)
lnw := uniseg.StringWidth(ln)
if lnw > widestLineNumber {
widestLineNumber = lnw
}
@@ -1271,22 +1343,22 @@ func (m Model) View() string {
s.WriteString(ln)
if m.col >= len(line) && lineInfo.CharOffset >= m.width {
m.Cursor.SetChar(" ")
s.WriteString(m.Cursor.View())
m.virtualCursor.SetChar(" ")
s.WriteString(m.virtualCursor.View())
// XXX: suggestions?
} else {
m.Cursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset]))
m.virtualCursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset]))
if m.canAcceptSuggestion() && len(m.matchedSuggestions) > 0 {
suggestion := m.matchedSuggestions[m.currentSuggestionIndex]
if len(suggestion) >= m.row {
suggestion = suggestion[m.row:]
}
m.Cursor.TextStyle = m.activeStyle.Placeholder
m.virtualCursor.TextStyle = m.activeStyle().Placeholder
if len(suggestion) > m.row && len(suggestion[m.row]) > m.col {
m.Cursor.SetChar(string(suggestion[m.row][m.col]))
m.virtualCursor.SetChar(string(suggestion[m.row][m.col]))
}
}
s.WriteString(style.Render(m.Cursor.View()))
s.WriteString(style.Render(m.virtualCursor.View()))
s.WriteString(style.Render(string(wrappedLine[lineInfo.ColumnOffset+1:])))
s.WriteString(m.suggestionView(1))
// XXX: suggestions
@@ -1315,71 +1387,94 @@ func (m Model) View() string {
// Always show at least `m.Height` lines at all times.
// To do this we can simply pad out a few extra new lines in the view.
for i := 0; i < m.height; i++ {
prompt := m.getPromptString(displayLine)
prompt = m.activeStyle.computedPrompt().Render(prompt)
s.WriteString(prompt)
s.WriteString(m.promptView(displayLine))
displayLine++
// Write end of buffer content
leftGutter := string(m.EndOfBufferCharacter)
rightGapWidth := m.Width() - lipgloss.Width(leftGutter) + widestLineNumber
rightGapWidth := m.Width() - uniseg.StringWidth(leftGutter) + widestLineNumber
rightGap := strings.Repeat(" ", max(0, rightGapWidth))
s.WriteString(m.activeStyle.computedEndOfBuffer().Render(leftGutter + rightGap))
s.WriteString(styles.computedEndOfBuffer().Render(leftGutter + rightGap))
s.WriteRune('\n')
}
m.viewport.SetContent(s.String())
return m.activeStyle.Base.Render(m.viewport.View())
return styles.Base.Render(m.viewport.View())
}
// formatLineNumber formats the line number for display dynamically based on
// the maximum number of lines.
func (m Model) formatLineNumber(x any) string {
// XXX: ultimately we should use a max buffer height, which has yet to be
// implemented.
digits := len(strconv.Itoa(m.MaxHeight))
return fmt.Sprintf(" %*v ", digits, x)
}
func (m Model) getPromptString(displayLine int) (prompt string) {
// promptView renders a single line of the prompt.
func (m Model) promptView(displayLine int) (prompt string) {
prompt = m.Prompt
if m.promptFunc == nil {
return prompt
}
prompt = m.promptFunc(displayLine)
pl := uniseg.StringWidth(prompt)
if pl < m.promptWidth {
prompt = fmt.Sprintf("%*s%s", m.promptWidth-pl, "", prompt)
width := lipgloss.Width(prompt)
if width < m.promptWidth {
prompt = fmt.Sprintf("%*s%s", m.promptWidth-width, "", prompt)
}
return prompt
return m.activeStyle().computedPrompt().Render(prompt)
}
// placeholderView returns the prompt and placeholder view, if any.
// lineNumberView renders the line number.
//
// If the argument is less than 0, a space styled as a line number is returned
// instead. Such cases are used for soft-wrapped lines.
//
// The second argument indicates whether this line number is for a 'cursorline'
// line number.
func (m Model) lineNumberView(n int, isCursorLine bool) (str string) {
if !m.ShowLineNumbers {
return ""
}
if n <= 0 {
str = " "
} else {
str = strconv.Itoa(n)
}
// XXX: is textStyle really necessary here?
textStyle := m.activeStyle().computedText()
lineNumberStyle := m.activeStyle().computedLineNumber()
if isCursorLine {
textStyle = m.activeStyle().computedCursorLine()
lineNumberStyle = m.activeStyle().computedCursorLineNumber()
}
// Format line number dynamically based on the maximum number of lines.
digits := len(strconv.Itoa(m.MaxHeight))
str = fmt.Sprintf(" %*v ", digits, str)
return textStyle.Render(lineNumberStyle.Render(str))
}
// placeholderView returns the prompt and placeholder, if any.
func (m Model) placeholderView() string {
var (
s strings.Builder
p = m.Placeholder
style = m.activeStyle.computedPlaceholder()
s strings.Builder
p = m.Placeholder
styles = m.activeStyle()
)
// word wrap lines
pwordwrap := ansi.Wordwrap(p, m.width, "")
// wrap lines (handles lines that could not be word wrapped)
// hard wrap lines (handles lines that could not be word wrapped)
pwrap := ansi.Hardwrap(pwordwrap, m.width, true)
// split string by new lines
plines := strings.Split(strings.TrimSpace(pwrap), "\n")
for i := 0; i < m.height; i++ {
lineStyle := m.activeStyle.computedPlaceholder()
lineNumberStyle := m.activeStyle.computedLineNumber()
isLineNumber := len(plines) > i
lineStyle := styles.computedPlaceholder()
if len(plines) > i {
lineStyle = m.activeStyle.computedCursorLine()
lineNumberStyle = m.activeStyle.computedCursorLineNumber()
lineStyle = styles.computedCursorLine()
}
// render prompt
prompt := m.getPromptString(i)
prompt = m.activeStyle.computedPrompt().Render(prompt)
prompt := m.promptView(i)
prompt = styles.computedPrompt().Render(prompt)
s.WriteString(lineStyle.Render(prompt))
// when show line numbers enabled:
@@ -1387,14 +1482,14 @@ func (m Model) placeholderView() string {
// - indent other placeholder lines
// this is consistent with vim with line numbers enabled
if m.ShowLineNumbers {
var ln string
var ln int
switch {
case i == 0:
ln = strconv.Itoa(i + 1)
ln = i + 1
fallthrough
case len(plines) > i:
s.WriteString(lineStyle.Render(lineNumberStyle.Render(m.formatLineNumber(ln))))
s.WriteString(m.lineNumberView(ln, isLineNumber))
default:
}
}
@@ -1403,21 +1498,26 @@ func (m Model) placeholderView() string {
// first line
case i == 0:
// first character of first line as cursor with character
m.Cursor.TextStyle = m.activeStyle.computedPlaceholder()
m.Cursor.SetChar(string(plines[0][0]))
s.WriteString(lineStyle.Render(m.Cursor.View()))
m.virtualCursor.TextStyle = styles.computedPlaceholder()
m.virtualCursor.SetChar(string(plines[0][0]))
s.WriteString(lineStyle.Render(m.virtualCursor.View()))
// the rest of the first line
s.WriteString(lineStyle.Render(style.Render(plines[0][1:] + strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[0]))))))
placeholderTail := plines[0][1:]
gap := strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[0])))
renderedPlaceholder := styles.computedPlaceholder().Render(placeholderTail + gap)
s.WriteString(lineStyle.Render(renderedPlaceholder))
// remaining lines
case len(plines) > i:
// current line placeholder text
if len(plines) > i {
s.WriteString(lineStyle.Render(style.Render(plines[i] + strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[i]))))))
placeholderLine := plines[i]
gap := strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[i])))
s.WriteString(lineStyle.Render(placeholderLine + gap))
}
default:
// end of line buffer character
eob := m.activeStyle.computedEndOfBuffer().Render(string(m.EndOfBufferCharacter))
eob := styles.computedEndOfBuffer().Render(string(m.EndOfBufferCharacter))
s.WriteString(eob)
}
@@ -1426,14 +1526,55 @@ func (m Model) placeholderView() string {
}
m.viewport.SetContent(s.String())
return m.activeStyle.Base.Render(m.viewport.View())
return styles.Base.Render(m.viewport.View())
}
// Blink returns the blink command for the cursor.
// Blink returns the blink command for the virtual cursor.
func Blink() tea.Msg {
return cursor.Blink()
}
// Cursor returns a [tea.Cursor] for rendering a real cursor in a Bubble Tea
// program.
//
// Example:
//
// // In your top-level View function:
// f := tea.NewFrame(m.textarea.View())
// f.Cursor = m.textarea.Cursor()
// f.Cursor.Position.X += offsetX
// f.Cursor.Position.Y += offsetY
//
// Note that you will almost certainly also need to adjust the offset
// position of the textarea to properly set the cursor position.
//
// If you're using a real cursor, you should also set [Model.VirtualCursor] to
// false.
func (m Model) Cursor() *tea.Cursor {
lineInfo := m.LineInfo()
w := lipgloss.Width
baseStyle := m.activeStyle().Base
xOffset := lineInfo.CharOffset +
w(m.promptView(0)) +
w(m.lineNumberView(0, false)) +
baseStyle.GetMarginLeft() +
baseStyle.GetPaddingLeft() +
baseStyle.GetBorderLeftSize()
yOffset := m.cursorLineNumber() +
m.viewport.YOffset +
baseStyle.GetMarginTop() +
baseStyle.GetPaddingTop() +
baseStyle.GetBorderTopSize()
c := tea.NewCursor(xOffset, yOffset)
c.Blink = m.Styles.Cursor.Blink
c.Color = m.Styles.Cursor.Color
c.Shape = m.Styles.Cursor.Shape
return c
}
func (m Model) memoizedWrap(runes []rune, width int) [][]rune {
input := line{runes: runes, width: width}
if v, ok := m.cache.Get(input); ok {
@@ -1655,6 +1796,20 @@ func repeatSpaces(n int) []rune {
return []rune(strings.Repeat(string(' '), n))
}
// numDigits returns the number of digits in an integer.
func numDigits(n int) int {
if n == 0 {
return 1
}
count := 0
num := abs(n)
for num > 0 {
count++
num /= 10
}
return count
}
func clamp(v, low, high int) int {
if high < low {
low, high = high, low
@@ -1676,6 +1831,13 @@ func max(a, b int) int {
return b
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
func stringToLines(s string) [][]rune {
var r [][]rune
for _, line := range strings.Split(s, "\n") {
+1 -1
View File
@@ -171,7 +171,7 @@ func (m *Model) SetContent(s string) {
// maxYOffset returns the maximum possible value of the y-offset based on the
// viewport's content and set height.
func (m Model) maxYOffset() int {
return max(0, len(m.lines)-m.Height())
return max(0, len(m.lines)-m.Height()+m.Style.GetVerticalFrameSize())
}
// visibleLines returns the lines that should currently be visible in the