diff --git a/README.md b/README.md index cdc9179..bb93a71 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -Bubbles -======= +# Bubbles
@@ -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
@@ -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 `` 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 `` 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
@@ -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
@@ -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.
-* [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) {
}
```
+## There’s more where that came from
-## Additional Bubbles
-
-
-
-* [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 you’ve 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].
We’d 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).
diff --git a/cursor/cursor.go b/cursor/cursor.go
index c49781e..824b450 100644
--- a/cursor/cursor.go
+++ b/cursor/cursor.go
@@ -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
}
diff --git a/go.mod b/go.mod
index 4c08477..5bb6b70 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 2346187..7a9bc21 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/progress/progress.go b/progress/progress.go
index b596c30..512aaa8 100644
--- a/progress/progress.go
+++ b/progress/progress.go
@@ -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 {
diff --git a/stopwatch/stopwatch.go b/stopwatch/stopwatch.go
index c6b91a3..3c79a91 100644
--- a/stopwatch/stopwatch.go
+++ b/stopwatch/stopwatch.go
@@ -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))
}
diff --git a/textarea/textarea.go b/textarea/textarea.go
index 42f063f..9baed93 100644
--- a/textarea/textarea.go
+++ b/textarea/textarea.go
@@ -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") {
diff --git a/viewport/viewport.go b/viewport/viewport.go
index d4b28e0..cbabc28 100644
--- a/viewport/viewport.go
+++ b/viewport/viewport.go
@@ -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