mirror of
https://github.com/zoriya/bubbles.git
synced 2026-08-05 12:46:14 +00:00
feat: real cursor in textinput (#727)
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user