| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721 |
- package textinput
- import (
- "strings"
- "time"
- "unicode"
- "github.com/atotto/clipboard"
- "github.com/charmbracelet/bubbles/cursor"
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/runeutil"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- rw "github.com/mattn/go-runewidth"
- )
- // Internal messages for clipboard operations.
- type pasteMsg string
- type pasteErrMsg struct{ error }
- // EchoMode sets the input behavior of the text input field.
- type EchoMode int
- const (
- // EchoNormal displays text as is. This is the default behavior.
- EchoNormal EchoMode = iota
- // EchoPassword displays the EchoCharacter mask instead of actual
- // characters. This is commonly used for password fields.
- EchoPassword
- // EchoNone displays nothing as characters are entered. This is commonly
- // seen for password fields on the command line.
- EchoNone
- // EchoOnEdit.
- )
- // ValidateFunc is a function that returns an error if the input is invalid.
- type ValidateFunc func(string) error
- // KeyMap is the key bindings for different actions within the textinput.
- type KeyMap struct {
- CharacterForward key.Binding
- CharacterBackward key.Binding
- WordForward key.Binding
- WordBackward key.Binding
- DeleteWordBackward key.Binding
- DeleteWordForward key.Binding
- DeleteAfterCursor key.Binding
- DeleteBeforeCursor key.Binding
- DeleteCharacterBackward key.Binding
- DeleteCharacterForward key.Binding
- LineStart key.Binding
- LineEnd key.Binding
- Paste key.Binding
- }
- // DefaultKeyMap is the default set of key bindings for navigating and acting
- // upon the textinput.
- var DefaultKeyMap = KeyMap{
- CharacterForward: key.NewBinding(key.WithKeys("right", "ctrl+f")),
- CharacterBackward: key.NewBinding(key.WithKeys("left", "ctrl+b")),
- WordForward: key.NewBinding(key.WithKeys("alt+right", "alt+f")),
- WordBackward: key.NewBinding(key.WithKeys("alt+left", "alt+b")),
- DeleteWordBackward: key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w")),
- DeleteWordForward: key.NewBinding(key.WithKeys("alte+delete", "alt+d")),
- DeleteAfterCursor: key.NewBinding(key.WithKeys("ctrl+k")),
- DeleteBeforeCursor: key.NewBinding(key.WithKeys("ctrl+u")),
- DeleteCharacterBackward: key.NewBinding(key.WithKeys("backspace", "ctrl+h")),
- DeleteCharacterForward: key.NewBinding(key.WithKeys("delete", "ctrl+d")),
- LineStart: key.NewBinding(key.WithKeys("home", "ctrl+a")),
- LineEnd: key.NewBinding(key.WithKeys("end", "ctrl+e")),
- Paste: key.NewBinding(key.WithKeys("ctrl+v")),
- }
- // Model is the Bubble Tea model for this text input element.
- type Model struct {
- Err error
- // General settings.
- Prompt string
- Placeholder string
- EchoMode EchoMode
- EchoCharacter rune
- Cursor cursor.Model
- // Deprecated: use [cursor.BlinkSpeed] instead.
- BlinkSpeed time.Duration
- // 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
- BackgroundStyle lipgloss.Style
- PlaceholderStyle lipgloss.Style
- CursorStyle lipgloss.Style
- // CharLimit is the maximum amount of characters this input element will
- // accept. If 0 or less, there's no limit.
- CharLimit int
- // Width is the maximum number of characters that can be displayed at once.
- // It essentially treats the text field like a horizontally scrolling
- // viewport. If 0 or less this setting is ignored.
- Width int
- // KeyMap encodes the keybindings recognized by the widget.
- KeyMap KeyMap
- // Underlying text value.
- value []rune
- // focus indicates whether user input focus should be on this input
- // component. When false, ignore keyboard input and hide the cursor.
- focus bool
- // Cursor position.
- pos int
- // Used to emulate a viewport when width is set and the content is
- // overflowing.
- offset int
- offsetRight int
- // Validate is a function that checks whether or not the text within the
- // input is valid. If it is not valid, the `Err` field will be set to the
- // error returned by the function. If the function is not defined, all
- // input is considered valid.
- Validate ValidateFunc
- // rune sanitizer for input.
- rsan runeutil.Sanitizer
- }
- // New creates a new model with default settings.
- func New() Model {
- return Model{
- Prompt: "> ",
- EchoCharacter: '*',
- CharLimit: 0,
- PlaceholderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
- Cursor: cursor.New(),
- KeyMap: DefaultKeyMap,
- value: nil,
- focus: false,
- pos: 0,
- }
- }
- // NewModel creates a new model with default settings.
- //
- // Deprecated: Use [New] instead.
- var NewModel = New
- // SetValue sets the value of the text input.
- func (m *Model) SetValue(s string) {
- // Clean up any special characters in the input provided by the
- // caller. This avoids bugs due to e.g. tab characters and whatnot.
- runes := m.san().Sanitize([]rune(s))
- m.setValueInternal(runes)
- }
- func (m *Model) setValueInternal(runes []rune) {
- if m.Validate != nil {
- if err := m.Validate(string(runes)); err != nil {
- m.Err = err
- return
- }
- }
- empty := len(m.value) == 0
- m.Err = nil
- if m.CharLimit > 0 && len(runes) > m.CharLimit {
- m.value = runes[:m.CharLimit]
- } else {
- m.value = runes
- }
- if (m.pos == 0 && empty) || m.pos > len(m.value) {
- m.SetCursor(len(m.value))
- }
- m.handleOverflow()
- }
- // Value returns the value of the text input.
- func (m Model) Value() string {
- return string(m.value)
- }
- // Position returns the cursor position.
- func (m Model) Position() int {
- return m.pos
- }
- // SetCursor 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(pos int) {
- m.pos = clamp(pos, 0, len(m.value))
- m.handleOverflow()
- }
- // CursorStart moves the cursor to the start of the input field.
- func (m *Model) CursorStart() {
- m.SetCursor(0)
- }
- // CursorEnd moves the cursor to the end of the input field.
- func (m *Model) CursorEnd() {
- m.SetCursor(len(m.value))
- }
- // Focused returns the focus state on the model.
- func (m Model) Focused() bool {
- return m.focus
- }
- // Focus sets the focus state on the model. When the model is in focus it can
- // receive keyboard input and the cursor will be shown.
- func (m *Model) Focus() tea.Cmd {
- m.focus = true
- return m.Cursor.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()
- }
- // Reset sets the input to its default state with no input.
- func (m *Model) Reset() {
- m.value = nil
- m.SetCursor(0)
- }
- // rsan initializes or retrieves the rune sanitizer.
- func (m *Model) san() runeutil.Sanitizer {
- if m.rsan == nil {
- // Textinput has all its input on a single line so collapse
- // newlines/tabs to single spaces.
- m.rsan = runeutil.NewSanitizer(
- runeutil.ReplaceTabs(" "), runeutil.ReplaceNewlines(" "))
- }
- return m.rsan
- }
- func (m *Model) insertRunesFromUserInput(v []rune) {
- // Clean up any special characters in the input provided by the
- // clipboard. This avoids bugs due to e.g. tab characters and
- // whatnot.
- paste := m.san().Sanitize(v)
- var availSpace int
- if m.CharLimit > 0 {
- availSpace = m.CharLimit - len(m.value)
- // If the char limit's been reached, cancel.
- if availSpace <= 0 {
- return
- }
- // If there's not enough space to paste the whole thing cut the pasted
- // runes down so they'll fit.
- if availSpace < len(paste) {
- paste = paste[:len(paste)-availSpace]
- }
- }
- // Stuff before and after the cursor
- head := m.value[:m.pos]
- tailSrc := m.value[m.pos:]
- tail := make([]rune, len(tailSrc))
- copy(tail, tailSrc)
- oldPos := m.pos
- // Insert pasted runes
- for _, r := range paste {
- head = append(head, r)
- m.pos++
- if m.CharLimit > 0 {
- availSpace--
- if availSpace <= 0 {
- break
- }
- }
- }
- // Put it all back together
- value := append(head, tail...)
- m.setValueInternal(value)
- if m.Err != nil {
- m.pos = oldPos
- }
- }
- // If a max width is defined, perform some logic to treat the visible area
- // as a horizontally scrolling viewport.
- func (m *Model) handleOverflow() {
- if m.Width <= 0 || rw.StringWidth(string(m.value)) <= m.Width {
- m.offset = 0
- m.offsetRight = len(m.value)
- return
- }
- // Correct right offset if we've deleted characters
- m.offsetRight = min(m.offsetRight, len(m.value))
- if m.pos < m.offset {
- m.offset = m.pos
- w := 0
- i := 0
- runes := m.value[m.offset:]
- for i < len(runes) && w <= m.Width {
- w += rw.RuneWidth(runes[i])
- if w <= m.Width+1 {
- i++
- }
- }
- m.offsetRight = m.offset + i
- } else if m.pos >= m.offsetRight {
- m.offsetRight = m.pos
- w := 0
- runes := m.value[:m.offsetRight]
- i := len(runes) - 1
- for i > 0 && w < m.Width {
- w += rw.RuneWidth(runes[i])
- if w <= m.Width {
- i--
- }
- }
- m.offset = m.offsetRight - (len(runes) - 1 - i)
- }
- }
- // deleteBeforeCursor deletes all text before the cursor.
- func (m *Model) deleteBeforeCursor() {
- m.value = m.value[m.pos:]
- m.offset = 0
- m.SetCursor(0)
- }
- // deleteAfterCursor deletes all text after the cursor. If input is masked
- // delete everything after the cursor so as not to reveal word breaks in the
- // masked input.
- func (m *Model) deleteAfterCursor() {
- m.value = m.value[:m.pos]
- m.SetCursor(len(m.value))
- }
- // deleteWordBackward deletes the word left to the cursor.
- func (m *Model) deleteWordBackward() {
- if m.pos == 0 || len(m.value) == 0 {
- return
- }
- if m.EchoMode != EchoNormal {
- m.deleteBeforeCursor()
- return
- }
- // Linter note: it's critical that we acquire the initial cursor position
- // here prior to altering it via SetCursor() below. As such, moving this
- // call into the corresponding if clause does not apply here.
- oldPos := m.pos //nolint:ifshort
- m.SetCursor(m.pos - 1)
- for unicode.IsSpace(m.value[m.pos]) {
- if m.pos <= 0 {
- break
- }
- // ignore series of whitespace before cursor
- m.SetCursor(m.pos - 1)
- }
- for m.pos > 0 {
- if !unicode.IsSpace(m.value[m.pos]) {
- m.SetCursor(m.pos - 1)
- } else {
- if m.pos > 0 {
- // keep the previous space
- m.SetCursor(m.pos + 1)
- }
- break
- }
- }
- if oldPos > len(m.value) {
- m.value = m.value[:m.pos]
- } else {
- m.value = append(m.value[:m.pos], m.value[oldPos:]...)
- }
- }
- // deleteWordForward deletes the word right to the cursor If input is masked
- // delete everything after the cursor so as not to reveal word breaks in the
- // masked input.
- func (m *Model) deleteWordForward() {
- if m.pos >= len(m.value) || len(m.value) == 0 {
- return
- }
- if m.EchoMode != EchoNormal {
- m.deleteAfterCursor()
- return
- }
- oldPos := m.pos
- m.SetCursor(m.pos + 1)
- for unicode.IsSpace(m.value[m.pos]) {
- // ignore series of whitespace after cursor
- m.SetCursor(m.pos + 1)
- if m.pos >= len(m.value) {
- break
- }
- }
- for m.pos < len(m.value) {
- if !unicode.IsSpace(m.value[m.pos]) {
- m.SetCursor(m.pos + 1)
- } else {
- break
- }
- }
- if m.pos > len(m.value) {
- m.value = m.value[:oldPos]
- } else {
- m.value = append(m.value[:oldPos], m.value[m.pos:]...)
- }
- m.SetCursor(oldPos)
- }
- // wordBackward moves the cursor one word to the left. If input is masked, move
- // input to the start so as not to reveal word breaks in the masked input.
- func (m *Model) wordBackward() {
- if m.pos == 0 || len(m.value) == 0 {
- return
- }
- if m.EchoMode != EchoNormal {
- m.CursorStart()
- return
- }
- i := m.pos - 1
- for i >= 0 {
- if unicode.IsSpace(m.value[i]) {
- m.SetCursor(m.pos - 1)
- i--
- } else {
- break
- }
- }
- for i >= 0 {
- if !unicode.IsSpace(m.value[i]) {
- m.SetCursor(m.pos - 1)
- i--
- } else {
- break
- }
- }
- }
- // wordForward moves the cursor one word to the right. If the input is masked,
- // move input to the end so as not to reveal word breaks in the masked input.
- func (m *Model) wordForward() {
- if m.pos >= len(m.value) || len(m.value) == 0 {
- return
- }
- if m.EchoMode != EchoNormal {
- m.CursorEnd()
- return
- }
- i := m.pos
- for i < len(m.value) {
- if unicode.IsSpace(m.value[i]) {
- m.SetCursor(m.pos + 1)
- i++
- } else {
- break
- }
- }
- for i < len(m.value) {
- if !unicode.IsSpace(m.value[i]) {
- m.SetCursor(m.pos + 1)
- i++
- } else {
- break
- }
- }
- }
- func (m Model) echoTransform(v string) string {
- switch m.EchoMode {
- case EchoPassword:
- return strings.Repeat(string(m.EchoCharacter), rw.StringWidth(v))
- case EchoNone:
- return ""
- default:
- return v
- }
- }
- // Update is the Bubble Tea update loop.
- func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
- if !m.focus {
- return m, nil
- }
- // Let's remember where the position of the cursor currently is so that if
- // the cursor position changes, we can reset the blink.
- oldPos := m.pos //nolint
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, m.KeyMap.DeleteWordBackward):
- m.Err = nil
- m.deleteWordBackward()
- case key.Matches(msg, m.KeyMap.DeleteCharacterBackward):
- m.Err = nil
- if len(m.value) > 0 {
- m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...)
- if m.pos > 0 {
- m.SetCursor(m.pos - 1)
- }
- }
- case key.Matches(msg, m.KeyMap.WordBackward):
- m.wordBackward()
- case key.Matches(msg, m.KeyMap.CharacterBackward):
- if m.pos > 0 {
- m.SetCursor(m.pos - 1)
- }
- case key.Matches(msg, m.KeyMap.WordForward):
- m.wordForward()
- case key.Matches(msg, m.KeyMap.CharacterForward):
- if m.pos < len(m.value) {
- m.SetCursor(m.pos + 1)
- }
- case key.Matches(msg, m.KeyMap.DeleteWordBackward):
- m.deleteWordBackward()
- case key.Matches(msg, m.KeyMap.LineStart):
- m.CursorStart()
- case key.Matches(msg, m.KeyMap.DeleteCharacterForward):
- if len(m.value) > 0 && m.pos < len(m.value) {
- m.value = append(m.value[:m.pos], m.value[m.pos+1:]...)
- }
- case key.Matches(msg, m.KeyMap.LineEnd):
- m.CursorEnd()
- case key.Matches(msg, m.KeyMap.DeleteAfterCursor):
- m.deleteAfterCursor()
- case key.Matches(msg, m.KeyMap.DeleteBeforeCursor):
- m.deleteBeforeCursor()
- case key.Matches(msg, m.KeyMap.Paste):
- return m, Paste
- case key.Matches(msg, m.KeyMap.DeleteWordForward):
- m.deleteWordForward()
- default:
- // Input one or more regular characters.
- m.insertRunesFromUserInput(msg.Runes)
- }
- case pasteMsg:
- m.insertRunesFromUserInput([]rune(msg))
- case pasteErrMsg:
- m.Err = msg
- }
- var cmds []tea.Cmd
- var cmd tea.Cmd
- m.Cursor, cmd = m.Cursor.Update(msg)
- cmds = append(cmds, cmd)
- if oldPos != m.pos {
- m.Cursor.Blink = false
- cmds = append(cmds, m.Cursor.BlinkCmd())
- }
- m.handleOverflow()
- return m, tea.Batch(cmds...)
- }
- // View renders the textinput in its current state.
- func (m Model) View() string {
- // Placeholder text
- if len(m.value) == 0 && m.Placeholder != "" {
- return m.placeholderView()
- }
- styleText := m.TextStyle.Inline(true).Render
- value := m.value[m.offset:m.offsetRight]
- pos := max(0, m.pos-m.offset)
- v := styleText(m.echoTransform(string(value[:pos])))
- if pos < len(value) {
- char := m.echoTransform(string(value[pos]))
- m.Cursor.SetChar(char)
- v += m.Cursor.View() // cursor and text under it
- v += styleText(m.echoTransform(string(value[pos+1:]))) // text after cursor
- } else {
- m.Cursor.SetChar(" ")
- v += m.Cursor.View()
- }
- // If a max width and background color were set fill the empty spaces with
- // the background color.
- valWidth := rw.StringWidth(string(value))
- if m.Width > 0 && valWidth <= m.Width {
- padding := max(0, m.Width-valWidth)
- if valWidth+padding <= m.Width && pos < len(value) {
- padding++
- }
- v += styleText(strings.Repeat(" ", padding))
- }
- return m.PromptStyle.Render(m.Prompt) + v
- }
- // placeholderView returns the prompt and placeholder view, if any.
- func (m Model) placeholderView() string {
- var (
- v string
- p = m.Placeholder
- style = m.PlaceholderStyle.Inline(true).Render
- )
- m.Cursor.TextStyle = m.PlaceholderStyle
- m.Cursor.SetChar(p[:1])
- v += m.Cursor.View()
- // The rest of the placeholder text
- v += style(p[1:])
- return m.PromptStyle.Render(m.Prompt) + v
- }
- // Blink is a command used to initialize cursor blinking.
- func Blink() tea.Msg {
- return cursor.Blink()
- }
- // Paste is a command for pasting from the clipboard into the text input.
- func Paste() tea.Msg {
- str, err := clipboard.ReadAll()
- if err != nil {
- return pasteErrMsg{err}
- }
- return pasteMsg(str)
- }
- func clamp(v, low, high int) int {
- if high < low {
- low, high = high, low
- }
- return min(high, max(low, v))
- }
- func min(a, b int) int {
- if a < b {
- return a
- }
- return b
- }
- func max(a, b int) int {
- if a > b {
- return a
- }
- return b
- }
- // Deprecated.
- // Deprecated: use cursor.Mode.
- type CursorMode int
- const (
- // Deprecated: use cursor.CursorBlink.
- CursorBlink = CursorMode(cursor.CursorBlink)
- // Deprecated: use cursor.CursorStatic.
- CursorStatic = CursorMode(cursor.CursorStatic)
- // Deprecated: use cursor.CursorHide.
- CursorHide = CursorMode(cursor.CursorHide)
- )
- func (c CursorMode) String() string {
- return cursor.Mode(c).String()
- }
- // Deprecated: use cursor.Mode().
- func (m Model) CursorMode() CursorMode {
- return CursorMode(m.Cursor.Mode())
- }
- // Deprecated: use cursor.SetMode().
- func (m *Model) SetCursorMode(mode CursorMode) tea.Cmd {
- return m.Cursor.SetMode(cursor.Mode(mode))
- }
|