feat: real cursor in textinput (#727)

This commit is contained in:
Carlos Alexandro Becker
2025-02-07 10:48:26 -03:00
committed by GitHub
parent 64c16e990e
commit 605d06c42b
5 changed files with 232 additions and 78 deletions
-2
View File
@@ -207,8 +207,6 @@ func New(items []Item, delegate ItemDelegate, width, height int) Model {
filterInput := textinput.New()
filterInput.Prompt = "Filter: "
filterInput.PromptStyle = styles.FilterPrompt
filterInput.Cursor.Style = styles.FilterCursor
filterInput.CharLimit = 64
filterInput.Focus()
+10 -9
View File
@@ -1,6 +1,7 @@
package list
import (
"github.com/charmbracelet/bubbles/v2/textinput"
"github.com/charmbracelet/lipgloss/v2"
)
@@ -12,11 +13,10 @@ const (
// Styles contains style definitions for this list component. By default, these
// values are generated by DefaultStyles.
type Styles struct {
TitleBar lipgloss.Style
Title lipgloss.Style
Spinner lipgloss.Style
FilterPrompt lipgloss.Style
FilterCursor lipgloss.Style
TitleBar lipgloss.Style
Title lipgloss.Style
Spinner lipgloss.Style
Filter textinput.Styles
// Default styling for matched characters in a filter. This can be
// overridden by delegates.
@@ -57,11 +57,12 @@ func DefaultStyles(isDark bool) (s Styles) {
s.Spinner = lipgloss.NewStyle().
Foreground(lightDark(lipgloss.Color("#8E8E8E"), lipgloss.Color("#747373")))
s.FilterPrompt = lipgloss.NewStyle().
prompt := lipgloss.NewStyle().
Foreground(lightDark(lipgloss.Color("#04B575"), lipgloss.Color("#ECFD65")))
s.FilterCursor = lipgloss.NewStyle().
Foreground(lightDark(lipgloss.Color("#EE6FF8"), lipgloss.Color("#EE6FF8")))
s.Filter = textinput.DefaultStyles(isDark)
s.Filter.Cursor.Color = lightDark(lipgloss.Color("#EE6FF8"), lipgloss.Color("#EE6FF8"))
s.Filter.Blurred.Prompt = prompt
s.Filter.Focused.Prompt = prompt
s.DefaultFilterCharacterMatch = lipgloss.NewStyle().Underline(true)
+3 -8
View File
@@ -413,15 +413,10 @@ func (m *Model) updateVirtualCursorStyle() {
// 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 {
if m.Styles.Cursor.BlinkSpeed > 0 {
m.virtualCursor.BlinkSpeed = m.Styles.Cursor.BlinkSpeed
}
m.virtualCursor.SetMode(cursor.CursorBlink)
return
}
+96
View File
@@ -0,0 +1,96 @@
package textinput
import (
"image/color"
"time"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
)
// DefaultStyles returns the default styles for focused and blurred states for
// the textarea.
func DefaultStyles(isDark bool) Styles {
lightDark := lipgloss.LightDark(isDark)
var s Styles
s.Focused = StyleState{
Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
Suggestion: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
Text: lipgloss.NewStyle(),
}
s.Blurred = StyleState{
Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
Suggestion: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
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
}
// DefaultLightStyles returns the default styles for a light background.
func DefaultLightStyles() Styles {
return DefaultStyles(false)
}
// DefaultDarkStyles returns the default styles for a dark background.
func DefaultDarkStyles() Styles {
return DefaultStyles(true)
}
// 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.
//
// StyleState can be applied to focused and unfocused states to change the styles
// depending on the focus state.
//
// For an introduction to styling with Lip Gloss see:
// https://github.com/charmbracelet/lipgloss
type StyleState struct {
Text lipgloss.Style
Placeholder lipgloss.Style
Suggestion lipgloss.Style
Prompt lipgloss.Style
}
// 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
}
+123 -59
View File
@@ -92,16 +92,15 @@ type Model struct {
Placeholder string
EchoMode EchoMode
EchoCharacter rune
Cursor cursor.Model
// Styles. These will be applied as inline styles.
//
// For an introduction to styling with Lip Gloss see:
// https://github.com/charmbracelet/lipgloss
PromptStyle lipgloss.Style
TextStyle lipgloss.Style
PlaceholderStyle lipgloss.Style
CompletionStyle lipgloss.Style
// 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
virtualCursor cursor.Model
// Styling. FocusedStyle and BlurredStyle are used to style the textarea in
// focused and blurred states.
Styles Styles
// CharLimit is the maximum amount of characters this input element will
// accept. If 0 or less, there's no limit.
@@ -152,19 +151,17 @@ type Model struct {
// New creates a new model with default settings.
func New() Model {
return Model{
Prompt: "> ",
EchoCharacter: '*',
CharLimit: 0,
PlaceholderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
ShowSuggestions: false,
CompletionStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
Cursor: cursor.New(),
KeyMap: DefaultKeyMap(),
suggestions: [][]rune{},
value: nil,
focus: false,
pos: 0,
Prompt: "> ",
EchoCharacter: '*',
CharLimit: 0,
Styles: DefaultDarkStyles(),
ShowSuggestions: false,
virtualCursor: cursor.New(),
KeyMap: DefaultKeyMap(),
suggestions: [][]rune{},
value: nil,
focus: false,
pos: 0,
}
}
@@ -239,14 +236,14 @@ func (m Model) Focused() bool {
// receive keyboard input and the cursor will be shown.
func (m *Model) Focus() tea.Cmd {
m.focus = true
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.Cursor.Blur()
m.virtualCursor.Blur()
}
// Reset sets the input to its default state with no input.
@@ -550,6 +547,7 @@ func (m Model) echoTransform(v string) string {
// Update is the Bubble Tea update loop.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.updateVirtualCursorStyle()
if !m.focus {
return m, nil
}
@@ -636,12 +634,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
m.Cursor, cmd = m.Cursor.Update(msg)
m.virtualCursor, cmd = m.virtualCursor.Update(msg)
cmds = append(cmds, cmd)
if oldPos != m.pos && m.Cursor.Mode() == cursor.CursorBlink {
m.Cursor.Blink = false
cmds = append(cmds, m.Cursor.BlinkCmd())
if oldPos != m.pos && m.virtualCursor.Mode() == cursor.CursorBlink {
m.virtualCursor.Blink = false
cmds = append(cmds, m.virtualCursor.BlinkCmd())
}
m.handleOverflow()
@@ -655,7 +653,9 @@ func (m Model) View() string {
return m.placeholderView()
}
styleText := m.TextStyle.Inline(true).Render
styles := m.activeStyle()
styleText := styles.Text.Inline(true).Render
value := m.value[m.offset:m.offsetRight]
pos := max(0, m.pos-m.offset)
@@ -663,25 +663,25 @@ func (m Model) View() string {
if pos < len(value) { //nolint:nestif
char := m.echoTransform(string(value[pos]))
m.Cursor.SetChar(char)
v += m.Cursor.View() // cursor and text under it
m.virtualCursor.SetChar(char)
v += m.virtualCursor.View() // cursor and text under it
v += styleText(m.echoTransform(string(value[pos+1:]))) // text after cursor
v += m.completionView(0) // suggested completion
} else {
if m.canAcceptSuggestion() {
suggestion := m.matchedSuggestions[m.currentSuggestionIndex]
if len(value) < len(suggestion) {
m.Cursor.TextStyle = m.CompletionStyle
m.Cursor.SetChar(m.echoTransform(string(suggestion[pos])))
v += m.Cursor.View()
m.virtualCursor.TextStyle = styles.Suggestion
m.virtualCursor.SetChar(m.echoTransform(string(suggestion[pos])))
v += m.virtualCursor.View()
v += m.completionView(1)
} else {
m.Cursor.SetChar(" ")
v += m.Cursor.View()
m.virtualCursor.SetChar(" ")
v += m.virtualCursor.View()
}
} else {
m.Cursor.SetChar(" ")
v += m.Cursor.View()
m.virtualCursor.SetChar(" ")
v += m.virtualCursor.View()
}
}
@@ -696,26 +696,31 @@ func (m Model) View() string {
v += styleText(strings.Repeat(" ", padding))
}
return m.PromptStyle.Render(m.Prompt) + v
return m.promptView() + v
}
func (m Model) promptView() string {
return m.activeStyle().Prompt.Render(m.Prompt)
}
// placeholderView returns the prompt and placeholder view, if any.
func (m Model) placeholderView() string {
var (
v string
style = m.PlaceholderStyle.Inline(true).Render
v string
styles = m.activeStyle()
render = styles.Placeholder.Render
)
p := make([]rune, m.Width()+1)
copy(p, []rune(m.Placeholder))
m.Cursor.TextStyle = m.PlaceholderStyle
m.Cursor.SetChar(string(p[:1]))
v += m.Cursor.View()
m.virtualCursor.TextStyle = styles.Placeholder
m.virtualCursor.SetChar(string(p[:1]))
v += m.virtualCursor.View()
// If the entire placeholder is already set and no padding is needed, finish
if m.Width() < 1 && len(p) <= 1 {
return m.PromptStyle.Render(m.Prompt) + v
return styles.Prompt.Render(m.Prompt) + v
}
// If Width is set then size placeholder accordingly
@@ -730,14 +735,14 @@ func (m Model) placeholderView() string {
availWidth = 0
}
// append placeholder[len] - cursor, append padding
v += style(string(p[1:minWidth]))
v += style(strings.Repeat(" ", availWidth))
v += render(string(p[1:minWidth]))
v += render(strings.Repeat(" ", availWidth))
} else {
// if there is no width, the placeholder can be any length
v += style(string(p[1:]))
v += render(string(p[1:]))
}
return m.PromptStyle.Render(m.Prompt) + v
return styles.Prompt.Render(m.Prompt) + v
}
// Blink is a command used to initialize cursor blinking.
@@ -762,16 +767,14 @@ func clamp(v, low, high int) int {
}
func (m Model) completionView(offset int) string {
var (
value = m.value
style = m.PlaceholderStyle.Inline(true).Render
)
if m.canAcceptSuggestion() {
suggestion := m.matchedSuggestions[m.currentSuggestionIndex]
if len(value) < len(suggestion) {
return style(string(suggestion[len(value)+offset:]))
}
if !m.canAcceptSuggestion() {
return ""
}
value := m.value
suggestion := m.matchedSuggestions[m.currentSuggestionIndex]
if len(value) < len(suggestion) {
return m.activeStyle().Suggestion.Inline(true).
Render(string(suggestion[len(value)+offset:]))
}
return ""
}
@@ -862,3 +865,64 @@ func (m Model) validate(v []rune) error {
}
return nil
}
// 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 {
w := lipgloss.Width
xOffset := m.Position() +
w(m.promptView())
style := m.Styles.Cursor
c := tea.NewCursor(xOffset, 0)
c.Blink = style.Blink
c.Color = style.Color
c.Shape = style.Shape
return c
}
// 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.Blink {
if m.Styles.Cursor.BlinkSpeed > 0 {
m.virtualCursor.BlinkSpeed = m.Styles.Cursor.BlinkSpeed
}
m.virtualCursor.SetMode(cursor.CursorBlink)
return
}
m.virtualCursor.SetMode(cursor.CursorStatic)
}
// 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
}