31 Commits

Author SHA1 Message Date
Christian Rocha
9cb8e8d90a Remove spaces after emoji spinners 2020-11-11 15:39:42 -05:00
Christian Rocha
e6219572e5 Add points spinner 2020-11-11 15:39:42 -05:00
Christian Rocha
81feaacf5b Remove bit8 spinner 2020-11-11 15:39:42 -05:00
Christian Rocha
9a25d8b8b9 Add pulse spinner 2020-11-11 15:39:42 -05:00
Christian Rocha
e8e052c64b Add mini dot spinner 2020-11-11 15:39:42 -05:00
Christian Rocha
3d7cd43046 Rework Spinner so that default spinners have FPS built-in 2020-11-11 15:39:42 -05:00
Christian Rocha
9c38e101d2 Remove internal msg in spinner so all tick msgs can be caught externally 2020-11-10 15:44:42 -05:00
Christian Rocha
84f7b047bb Make Viewport more idomatic BubbleTea (per v0.12.x) 2020-11-08 21:20:15 -05:00
Christian Rocha
9e1e435bba Make Spinner more idomatic Bubble Tea (per v0.12.x) 2020-11-08 21:20:15 -05:00
Christian Rocha
0fd072ddcc Make Paginator more idomatic Bubble Tea (per v0.12.x) 2020-11-08 21:20:15 -05:00
ololosha228
a07ab1d6af Added more spinners 2020-11-04 14:48:03 +01:00
Christian Rocha
8e49ee609e Bump Bubble Tea dependency to v0.12.2 2020-11-02 10:24:20 -05:00
Christian Rocha
d02641f6b5 Update textinput for multi-char (IME) input in Bubble Tea master 2020-11-01 09:11:18 -05:00
Christian Rocha
9b47f26bdd Doc and logic corrections per the linter 2020-10-28 21:27:15 -04:00
Christian Rocha
9c780011ff Underpinnings for always-on and always-hidden cursor modes 2020-10-28 21:27:15 -04:00
Christian Rocha
8148e61443 Correct ^F/^B keybindings for forward/back cursor movement 2020-10-28 21:27:15 -04:00
Christian Rocha
ce7d8da084 Fix lock-up that could occur with cursor blinking
Note that to do this we've replaced the blink timer with a context.
2020-10-28 21:27:15 -04:00
Christian Rocha
d14fdf585c Textinput Update and View functions are now methods on the model 2020-10-28 21:27:15 -04:00
Christian Rocha
bf7719e6c1 Handle paste via command/message since it's IO 2020-10-28 21:27:15 -04:00
Christian Rocha
1b530b293c Reset blink timer when moving the cursor 2020-10-28 21:27:15 -04:00
Nicolas Martin
7d1c04164e Return the value of paginator.TotalPages in SetTotalPages...
when the given number of items is below 1.
2020-10-28 10:40:23 -04:00
Christian Muehlhaeuser
97020cd0d2 Support Alt-Backspace to delete previous word 2020-10-27 08:50:22 +01:00
Christian Rocha
5dbcf95877 Fix situation where short viewports would panic (closes #20) 2020-10-25 15:21:49 -04:00
Christian Muehlhaeuser
f58fead10d Support Ctrl-W and Alt-D to delete words left/right of cursor
Ctrl-W: deletes word left of cursor
Alt-D: deletes word right of cursor
2020-10-25 14:52:16 -04:00
Christian Rocha
703de11da4 Fix regression where cursor was misplaced after a paste 2020-10-24 19:51:13 -04:00
Christian Rocha
03461d6804 Fix panic when pasting into a textinput with no char limit 2020-10-24 19:51:13 -04:00
Christian Muehlhaeuser
4445acbace Use the same badge order as in our other repos 2020-10-24 08:43:30 +02:00
Christian Muehlhaeuser
bd161e8ded Add release badge to README 2020-10-24 08:39:00 +02:00
Christian Muehlhaeuser
d9716a97f6 Fix link to example in README 2020-10-24 08:36:29 +02:00
Christian Muehlhaeuser
a0fe547fdb Make textinput cursor visible whenever it changes its position
This improves the UX because you never lose track of the cursor
moving around while it's currently hidden.
2020-10-24 00:12:27 -04:00
Christian Muehlhaeuser
1cb36774ed Split up workflows and automatically pick latest Go version 2020-10-23 13:18:52 +02:00
9 changed files with 419 additions and 203 deletions

View File

@@ -4,14 +4,14 @@ jobs:
test: test:
strategy: strategy:
matrix: matrix:
go-version: [1.13.x, 1.14.x, 1.15.x] go-version: [~1.13, ^1]
os: [ubuntu-latest, macos-latest, windows-latest] os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
env: env:
GO111MODULE: "on" GO111MODULE: "on"
steps: steps:
- name: Install Go - name: Install Go
uses: actions/setup-go@v1 uses: actions/setup-go@v2
with: with:
go-version: ${{ matrix.go-version }} go-version: ${{ matrix.go-version }}
@@ -26,12 +26,3 @@ jobs:
- name: Test - name: Test
run: go test ./... run: go test ./...
- name: Coverage
env:
COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
go test -race -covermode atomic -coverprofile=profile.cov ./...
GO111MODULE=off go get github.com/mattn/goveralls
$(go env GOPATH)/bin/goveralls -coverprofile=profile.cov -service=github
if: matrix.go-version == '1.15.x' && matrix.os == 'ubuntu-latest'

28
.github/workflows/coverage.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: coverage
on: [push, pull_request]
jobs:
coverage:
strategy:
matrix:
go-version: [^1]
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
env:
GO111MODULE: "on"
steps:
- name: Install Go
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go-version }}
- name: Checkout code
uses: actions/checkout@v2
- name: Coverage
env:
COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
go test -race -covermode atomic -coverprofile=profile.cov ./...
GO111MODULE=off go get github.com/mattn/goveralls
$(go env GOPATH)/bin/goveralls -coverprofile=profile.cov -service=github

View File

@@ -1,9 +1,10 @@
Bubbles Bubbles
======= =======
[![Latest Release](https://img.shields.io/github/release/charmbracelet/bubbles.svg)](https://github.com/charmbracelet/bubbles/releases)
[![GoDoc](https://godoc.org/github.com/golang/gddo?status.svg)](https://pkg.go.dev/github.com/charmbracelet/bubbles)
[![Build Status](https://github.com/charmbracelet/bubbles/workflows/build/badge.svg)](https://github.com/charmbracelet/bubbles/actions) [![Build Status](https://github.com/charmbracelet/bubbles/workflows/build/badge.svg)](https://github.com/charmbracelet/bubbles/actions)
[![Go ReportCard](http://goreportcard.com/badge/charmbracelet/bubbles)](http://goreportcard.com/report/charmbracelet/bubbles) [![Go ReportCard](http://goreportcard.com/badge/charmbracelet/bubbles)](http://goreportcard.com/report/charmbracelet/bubbles)
[![GoDoc](https://godoc.org/github.com/golang/gddo?status.svg)](https://pkg.go.dev/github.com/charmbracelet/bubbles)
Some components for [Bubble Tea](https://github.com/charmbracelet/bubbletea) applications. Some components for [Bubble Tea](https://github.com/charmbracelet/bubbletea) applications.
@@ -32,7 +33,7 @@ pasting, in-place scrolling when the value exceeds the width of the element and
the common, and many customization options. the common, and many customization options.
* [Example code, one field](https://github.com/charmbracelet/tea/tree/master/examples/textinput/main.go) * [Example code, one field](https://github.com/charmbracelet/tea/tree/master/examples/textinput/main.go)
* [Example code, many fields](https://github.com/charmbracelet/tea/tree/master/examples/textinput/main.go) * [Example code, many fields](https://github.com/charmbracelet/tea/tree/master/examples/textinputs/main.go)
## Paginator ## Paginator

2
go.mod
View File

@@ -4,7 +4,7 @@ go 1.13
require ( require (
github.com/atotto/clipboard v0.1.2 github.com/atotto/clipboard v0.1.2
github.com/charmbracelet/bubbletea v0.12.1 github.com/charmbracelet/bubbletea v0.12.2
github.com/mattn/go-runewidth v0.0.9 github.com/mattn/go-runewidth v0.0.9
github.com/muesli/termenv v0.7.4 github.com/muesli/termenv v0.7.4
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 // indirect golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 // indirect

4
go.sum
View File

@@ -1,7 +1,7 @@
github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY= github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY=
github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/charmbracelet/bubbletea v0.12.1 h1:t21pkG2IDBRduPbt2J64Dx5yt8yIidAkXwhhrc11SzY= github.com/charmbracelet/bubbletea v0.12.2 h1:y9Yo2Pv8tcm3mAJsWONGsmHhzrbNxJVxpVtemikxE9A=
github.com/charmbracelet/bubbletea v0.12.1/go.mod h1:3gZkYELUOiEUOp0bTInkxguucy/xRbGSOcbMs1geLxg= github.com/charmbracelet/bubbletea v0.12.2/go.mod h1:3gZkYELUOiEUOp0bTInkxguucy/xRbGSOcbMs1geLxg=
github.com/containerd/console v1.0.1 h1:u7SFAJyRqWcG6ogaMAx3KjSTy1e3hT9QxqX7Jco7dRc= github.com/containerd/console v1.0.1 h1:u7SFAJyRqWcG6ogaMAx3KjSTy1e3hT9QxqX7Jco7dRc=
github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw= github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw=
github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f h1:5CjVwnuUcp5adK4gmY6i72gpVFVnZDP2h5TmPScB6u4= github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f h1:5CjVwnuUcp5adK4gmY6i72gpVFVnZDP2h5TmPScB6u4=

View File

@@ -19,7 +19,7 @@ const (
Dots Dots
) )
// Model is the Tea model for this user interface. // Model is the Bubble Tea model for this user interface.
type Model struct { type Model struct {
Type Type Type Type
Page int Page int
@@ -40,8 +40,8 @@ type Model struct {
// used for other things beyond navigating sets. Note that it both returns the // used for other things beyond navigating sets. Note that it both returns the
// number of total pages and alters the model. // number of total pages and alters the model.
func (m *Model) SetTotalPages(items int) int { func (m *Model) SetTotalPages(items int) int {
if items == 0 { if items < 1 {
return 0 return m.TotalPages
} }
n := items / m.PerPage n := items / m.PerPage
if items%m.PerPage > 0 { if items%m.PerPage > 0 {
@@ -115,7 +115,7 @@ func NewModel() Model {
} }
// Update is the Tea update function which binds keystrokes to pagination. // Update is the Tea update function which binds keystrokes to pagination.
func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
if m.UsePgUpPgDownKeys { if m.UsePgUpPgDownKeys {
@@ -164,16 +164,16 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
} }
// View renders the pagination to a string. // View renders the pagination to a string.
func View(m Model) string { func (m Model) View() string {
switch m.Type { switch m.Type {
case Dots: case Dots:
return dotsView(m) return m.dotsView()
default: default:
return arabicView(m) return m.arabicView()
} }
} }
func dotsView(m Model) string { func (m Model) dotsView() string {
var s string var s string
for i := 0; i < m.TotalPages; i++ { for i := 0; i < m.TotalPages; i++ {
if i == m.Page { if i == m.Page {
@@ -185,7 +185,7 @@ func dotsView(m Model) string {
return s return s
} }
func arabicView(m Model) string { func (m Model) arabicView() string {
return fmt.Sprintf(m.ArabicFormat, m.Page+1, m.TotalPages) return fmt.Sprintf(m.ArabicFormat, m.Page+1, m.TotalPages)
} }

View File

@@ -7,17 +7,52 @@ import (
"github.com/muesli/termenv" "github.com/muesli/termenv"
) )
const ( const defaultFPS = time.Second / 10
defaultFPS = time.Second / 10
)
// Spinner is a set of frames used in animating the spinner. // Spinner is a set of frames used in animating the spinner.
type Spinner = []string type Spinner struct {
Frames []string
FPS time.Duration
}
var ( var (
// Some spinners to choose from. You could also make your own. // Some spinners to choose from. You could also make your own.
Line = Spinner([]string{"|", "/", "-", "\\"}) Line = Spinner{
Dot = Spinner([]string{"", "", "", "", "⡿ ", "⣟ ", "⣯ ", "⣷ "}) Frames: []string{"|", "/", "-", "\\"},
FPS: time.Second / 10,
}
Dot = Spinner{
Frames: []string{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "},
FPS: time.Second / 10,
}
MiniDot = Spinner{
Frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"},
FPS: time.Second / 12,
}
Jump = Spinner{
Frames: []string{"⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"},
FPS: time.Second / 10,
}
Pulse = Spinner{
Frames: []string{"█", "▓", "▒", "░"},
FPS: time.Second / 8,
}
Points = Spinner{
Frames: []string{"∙∙∙", "●∙∙", "∙●∙", "∙∙●"},
FPS: time.Second / 7,
}
Globe = Spinner{
Frames: []string{"🌍", "🌎", "🌏"},
FPS: time.Second / 4,
}
Moon = Spinner{
Frames: []string{"🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"},
FPS: time.Second / 8,
}
Monkey = Spinner{
Frames: []string{"🙈", "🙉", "🙊"},
FPS: time.Second / 3,
}
color = termenv.ColorProfile().Color color = termenv.ColorProfile().Color
) )
@@ -26,22 +61,19 @@ var (
// rather than using Model as a struct literal. // rather than using Model as a struct literal.
type Model struct { type Model struct {
// Type is the set of frames to use. See Spinner. // Spinner settings to use. See type Spinner.
Frames Spinner Spinner Spinner
// FPS is the speed at which the ticker should tick. // ForegroundColor sets the background color of the spinner. It can be
FPS time.Duration // a hex code or one of the 256 ANSI colors. If the terminal emulator can't
// support the color specified it will automatically degrade (per
// ForegroundColor sets the background color of the spinner. It can be a // github.com/muesli/termenv).
// hex code or one of the 256 ANSI colors. If the terminal emulator can't
// doesn't support the color specified it will automatically degrade
// (per github.com/muesli/termenv).
ForegroundColor string ForegroundColor string
// BackgroundColor sets the background color of the spinner. It can be a // BackgroundColor sets the background color of the spinner. It can be
// hex code or one of the 256 ANSI colors. If the terminal emulator can't // a hex code or one of the 256 ANSI colors. If the terminal emulator can't
// doesn't support the color specified it will automatically degrade // support the color specified it will automatically degrade (per
// (per github.com/muesli/termenv). // github.com/muesli/termenv).
BackgroundColor string BackgroundColor string
// MinimumLifetime is the minimum amount of time the spinner can run. Any // MinimumLifetime is the minimum amount of time the spinner can run. Any
@@ -118,10 +150,7 @@ func (m Model) Visible() bool {
// NewModel returns a model with default values. // NewModel returns a model with default values.
func NewModel() Model { func NewModel() Model {
return Model{ return Model{Spinner: Line}
Frames: Line,
FPS: defaultFPS,
}
} }
// TickMsg indicates that the timer has ticked and we should render a frame. // TickMsg indicates that the timer has ticked and we should render a frame.
@@ -132,30 +161,32 @@ type TickMsg struct {
// Update is the Tea update function. This will advance the spinner one frame // Update is the Tea update function. This will advance the spinner one frame
// every time it's called, regardless the message passed, so be sure the logic // every time it's called, regardless the message passed, so be sure the logic
// is setup so as not to call this Update needlessly. // is setup so as not to call this Update needlessly.
func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if _, ok := msg.(TickMsg); ok { switch msg.(type) {
case TickMsg:
m.frame++ m.frame++
if m.frame >= len(m.Frames) { if m.frame >= len(m.Spinner.Frames) {
m.frame = 0 m.frame = 0
} }
return m, Tick(m) return m, m.tick()
default:
return m, nil
} }
return m, nil
} }
// View renders the model's view. // View renders the model's view.
func View(model Model) string { func (m Model) View() string {
if model.frame >= len(model.Frames) { if m.frame >= len(m.Spinner.Frames) {
return "error" return "(error)"
} }
frame := model.Frames[model.frame] frame := m.Spinner.Frames[m.frame]
if model.ForegroundColor != "" || model.BackgroundColor != "" { if m.ForegroundColor != "" || m.BackgroundColor != "" {
return termenv. return termenv.
String(frame). String(frame).
Foreground(color(model.ForegroundColor)). Foreground(color(m.ForegroundColor)).
Background(color(model.BackgroundColor)). Background(color(m.BackgroundColor)).
String() String()
} }
@@ -163,8 +194,12 @@ func View(model Model) string {
} }
// Tick is the command used to advance the spinner one frame. // Tick is the command used to advance the spinner one frame.
func Tick(m Model) tea.Cmd { func Tick() tea.Msg {
return tea.Tick(m.FPS, func(t time.Time) tea.Msg { return TickMsg{Time: time.Now()}
}
func (m Model) tick() tea.Cmd {
return tea.Tick(m.Spinner.FPS, func(t time.Time) tea.Msg {
return TickMsg{ return TickMsg{
Time: t, Time: t,
} }

View File

@@ -1,6 +1,7 @@
package textinput package textinput
import ( import (
"context"
"strings" "strings"
"time" "time"
"unicode" "unicode"
@@ -11,7 +12,21 @@ import (
"github.com/muesli/termenv" "github.com/muesli/termenv"
) )
const defaultBlinkSpeed = time.Millisecond * 600 const defaultBlinkSpeed = time.Millisecond * 530
// color is a helper for returning colors.
var color func(s string) termenv.Color = termenv.ColorProfile().Color
// blinkMsg and blinkCanceled are used to manage cursor blinking.
type blinkMsg struct{}
type blinkCanceled struct{}
// Messages for clipboard events.
type pasteMsg string
type pasteErrMsg struct{ error }
// EchoMode sets the input behavior of the text input field.
type EchoMode int
const ( const (
// EchoNormal displays text as is. This is the default behavior. // EchoNormal displays text as is. This is the default behavior.
@@ -28,31 +43,35 @@ const (
// EchoOnEdit // EchoOnEdit
) )
// EchoMode sets the input behavior of the text input field. // blinkCtx manages cursor blinking.
type EchoMode int type blinkCtx struct {
ctx context.Context
cancel context.CancelFunc
}
var ( type cursorMode int
// color is a helper for returning colors.
color func(s string) termenv.Color = termenv.ColorProfile().Color const (
cursorBlink = iota
cursorStatic
cursorHide
) )
// Model is the Bubble Tea model for this text input element. // Model is the Bubble Tea model for this text input element.
type Model struct { type Model struct {
Err error Err error
Prompt string // General settings
Placeholder string Prompt string
Placeholder string
Cursor string Cursor string
BlinkSpeed time.Duration BlinkSpeed time.Duration
TextColor string TextColor string
BackgroundColor string BackgroundColor string
PlaceholderColor string PlaceholderColor string
CursorColor string CursorColor string
EchoMode EchoMode
EchoMode EchoMode EchoCharacter rune
EchoCharacter rune
// CharLimit is the maximum amount of characters this input element will // CharLimit is the maximum amount of characters this input element will
// accept. If 0 or less, there's no limit. // accept. If 0 or less, there's no limit.
@@ -80,6 +99,36 @@ type Model struct {
// overflowing. // overflowing.
offset int offset int
offsetRight int offsetRight int
// Used to manage cursor blink
blinkCtx *blinkCtx
// cursorMode determines the behavior of the cursor
cursorMode cursorMode
}
// NewModel creates a new model with default settings.
func NewModel() Model {
return Model{
Prompt: "> ",
Placeholder: "",
BlinkSpeed: defaultBlinkSpeed,
TextColor: "",
PlaceholderColor: "240",
CursorColor: "",
EchoCharacter: '*',
CharLimit: 0,
value: nil,
focus: false,
blink: true,
pos: 0,
cursorMode: cursorBlink,
blinkCtx: &blinkCtx{
ctx: context.Background(),
},
}
} }
// SetValue sets the value of the text input. // SetValue sets the value of the text input.
@@ -91,7 +140,7 @@ func (m *Model) SetValue(s string) {
m.value = runes m.value = runes
} }
if m.pos > len(m.value) { if m.pos > len(m.value) {
m.pos = len(m.value) m.SetCursor(len(m.value))
} }
m.handleOverflow() m.handleOverflow()
} }
@@ -103,21 +152,28 @@ func (m Model) Value() string {
// SetCursor start moves the cursor to the given position. If the position is // SetCursor start 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. // out of bounds the cursor will be moved to the start or end accordingly.
func (m *Model) SetCursor(pos int) { // Returns whether or nor the cursor timer should be reset.
func (m *Model) SetCursor(pos int) bool {
m.pos = clamp(pos, 0, len(m.value)) m.pos = clamp(pos, 0, len(m.value))
m.handleOverflow() m.handleOverflow()
// Show the cursor unless it's been explicitly hidden
m.blink = m.cursorMode == cursorHide
// Reset cursor blink if necessary
return m.cursorMode == cursorBlink
} }
// CursorStart moves the cursor to the start of the field. // CursorStart moves the cursor to the start of the field. Returns whether or
func (m *Model) CursorStart() { // not the curosr blink should be reset.
m.pos = 0 func (m *Model) CursorStart() bool {
m.handleOverflow() return m.SetCursor(0)
} }
// CursorEnd moves the cursor to the end of the field. // CursorEnd moves the cursor to the end of the field. Returns whether or not
func (m *Model) CursorEnd() { // the cursor blink should be reset.
m.pos = len(m.value) func (m *Model) CursorEnd() bool {
m.handleOverflow() return m.SetCursor(len(m.value))
} }
// Focused returns the focus state on the model. // Focused returns the focus state on the model.
@@ -128,7 +184,7 @@ func (m Model) Focused() bool {
// Focus sets the focus state on the model. // Focus sets the focus state on the model.
func (m *Model) Focus() { func (m *Model) Focus() {
m.focus = true m.focus = true
m.blink = false m.blink = m.cursorMode == cursorHide // show the cursor unless we've explicitly hidden it
} }
// Blur removes the focus state on the model. // Blur removes the focus state on the model.
@@ -137,22 +193,22 @@ func (m *Model) Blur() {
m.blink = true m.blink = true
} }
// Reset sets the input to its default state with no input. // Reset sets the input to its default state with no input. Returns whether
func (m *Model) Reset() { // or not the cursor blink should reset.
func (m *Model) Reset() bool {
m.value = nil m.value = nil
m.pos = 0 return m.SetCursor(0)
m.blink = false
} }
// Paste pastes the contents of the clipboard into the text area (if supported). // handle a clipboard paste event, if supported. Returns whether or not the
func (m *Model) Paste() { // cursor blink should be reset.
pasteString, err := clipboard.ReadAll() func (m *Model) handlePaste(v string) (blink bool) {
if err != nil { paste := []rune(v)
m.Err = err
}
paste := []rune(pasteString)
availSpace := m.CharLimit - len(m.value) var availSpace int
if m.CharLimit > 0 {
availSpace = m.CharLimit - len(m.value)
}
// If the char limit's been reached cancel // If the char limit's been reached cancel
if m.CharLimit > 0 && availSpace <= 0 { if m.CharLimit > 0 && availSpace <= 0 {
@@ -161,7 +217,7 @@ func (m *Model) Paste() {
// If there's not enough space to paste the whole thing cut the pasted // If there's not enough space to paste the whole thing cut the pasted
// runes down so they'll fit // runes down so they'll fit
if availSpace < len(paste) { if m.CharLimit > 0 && availSpace < len(paste) {
paste = paste[:len(paste)-availSpace] paste = paste[:len(paste)-availSpace]
} }
@@ -174,15 +230,20 @@ func (m *Model) Paste() {
// Insert pasted runes // Insert pasted runes
for _, r := range paste { for _, r := range paste {
head = append(head, r) head = append(head, r)
availSpace--
m.pos++ m.pos++
if m.CharLimit > 0 && availSpace <= 0 { if m.CharLimit > 0 {
break availSpace--
if availSpace <= 0 {
break
}
} }
} }
// Put it all back together // Put it all back together
m.value = append(head, tail...) m.value = append(head, tail...)
// Reset blink state if necessary and run overflow checks
return m.SetCursor(m.pos)
} }
// If a max width is defined, perform some logic to treat the visible area // If a max width is defined, perform some logic to treat the visible area
@@ -250,7 +311,76 @@ func (m *Model) colorPlaceholder(s string) string {
String() String()
} }
func (m *Model) wordLeft() { // deleteWordLeft deletes the word left to the cursor. Returns whether or not
// the cursor blink should be reset.
func (m *Model) deleteWordLeft() (blink bool) {
if m.pos == 0 || len(m.value) == 0 {
return
}
i := m.pos
blink = m.SetCursor(m.pos - 1)
for unicode.IsSpace(m.value[m.pos]) {
// ignore series of whitespace before cursor
blink = m.SetCursor(m.pos - 1)
}
for m.pos > 0 {
if !unicode.IsSpace(m.value[m.pos]) {
blink = m.SetCursor(m.pos - 1)
} else {
if m.pos > 0 {
// keep the previous space
blink = m.SetCursor(m.pos + 1)
}
break
}
}
if i > len(m.value) {
m.value = m.value[:m.pos]
} else {
m.value = append(m.value[:m.pos], m.value[i:]...)
}
return
}
// deleteWordRight deletes the word right to the cursor. Returns whether or not
// the cursor blink should be reset.
func (m *Model) deleteWordRight() (blink bool) {
if m.pos >= len(m.value) || len(m.value) == 0 {
return
}
i := m.pos
blink = m.SetCursor(m.pos + 1)
for unicode.IsSpace(m.value[m.pos]) {
// ignore series of whitespace after cursor
blink = m.SetCursor(m.pos + 1)
}
for m.pos < len(m.value) {
if !unicode.IsSpace(m.value[m.pos]) {
blink = m.SetCursor(m.pos + 1)
} else {
break
}
}
if m.pos > len(m.value) {
m.value = m.value[:i]
} else {
m.value = append(m.value[:i], m.value[m.pos:]...)
}
blink = m.SetCursor(i)
return
}
// wordLeft moves the cursor one word to the left. Returns whether or not the
// cursor blink should be reset.
func (m *Model) wordLeft() (blink bool) {
if m.pos == 0 || len(m.value) == 0 { if m.pos == 0 || len(m.value) == 0 {
return return
} }
@@ -259,7 +389,7 @@ func (m *Model) wordLeft() {
for i >= 0 { for i >= 0 {
if unicode.IsSpace(m.value[i]) { if unicode.IsSpace(m.value[i]) {
m.pos-- blink = m.SetCursor(m.pos - 1)
i-- i--
} else { } else {
break break
@@ -268,15 +398,19 @@ func (m *Model) wordLeft() {
for i >= 0 { for i >= 0 {
if !unicode.IsSpace(m.value[i]) { if !unicode.IsSpace(m.value[i]) {
m.pos-- blink = m.SetCursor(m.pos - 1)
i-- i--
} else { } else {
break break
} }
} }
return
} }
func (m *Model) wordRight() { // wordRight moves the cursor one word to the right. Returns whether or not the
// cursor blink should be reset.
func (m *Model) wordRight() (blink bool) {
if m.pos >= len(m.value) || len(m.value) == 0 { if m.pos >= len(m.value) || len(m.value) == 0 {
return return
} }
@@ -285,7 +419,7 @@ func (m *Model) wordRight() {
for i < len(m.value) { for i < len(m.value) {
if unicode.IsSpace(m.value[i]) { if unicode.IsSpace(m.value[i]) {
m.pos++ blink = m.SetCursor(m.pos + 1)
i++ i++
} else { } else {
break break
@@ -294,12 +428,14 @@ func (m *Model) wordRight() {
for i < len(m.value) { for i < len(m.value) {
if !unicode.IsSpace(m.value[i]) { if !unicode.IsSpace(m.value[i]) {
m.pos++ blink = m.SetCursor(m.pos + 1)
i++ i++
} else { } else {
break break
} }
} }
return
} }
func (m Model) echoTransform(v string) string { func (m Model) echoTransform(v string) string {
@@ -314,121 +450,119 @@ func (m Model) echoTransform(v string) string {
} }
} }
// BlinkMsg is sent when the cursor should alternate it's blinking state. // Update is the Bubble Tea update loop.
type BlinkMsg struct{} func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
// NewModel creates a new model with default settings.
func NewModel() Model {
return Model{
Prompt: "> ",
Placeholder: "",
BlinkSpeed: defaultBlinkSpeed,
TextColor: "",
PlaceholderColor: "240",
CursorColor: "",
EchoCharacter: '*',
CharLimit: 0,
value: nil,
focus: false,
blink: true,
pos: 0,
}
}
// Update is the Tea update loop.
func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
if !m.focus { if !m.focus {
m.blink = true m.blink = true
return m, nil return m, nil
} }
var resetBlink bool
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
switch msg.Type { switch msg.Type {
case tea.KeyBackspace: // delete character before cursor case tea.KeyBackspace: // delete character before cursor
if len(m.value) > 0 { if msg.Alt {
m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...) resetBlink = m.deleteWordLeft()
if m.pos > 0 { } else {
m.pos-- if len(m.value) > 0 {
m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...)
if m.pos > 0 {
resetBlink = m.SetCursor(m.pos - 1)
}
} }
} }
case tea.KeyLeft: case tea.KeyLeft, tea.KeyCtrlB:
if msg.Alt { // alt+left arrow, back one word if msg.Alt { // alt+left arrow, back one word
m.wordLeft() resetBlink = m.wordLeft()
break break
} }
if m.pos > 0 { if m.pos > 0 { // left arrow, ^F, back one character
m.pos-- resetBlink = m.SetCursor(m.pos - 1)
} }
case tea.KeyRight: case tea.KeyRight, tea.KeyCtrlF:
if msg.Alt { // alt+right arrow, forward one word if msg.Alt { // alt+right arrow, forward one word
m.wordRight() resetBlink = m.wordRight()
break break
} }
if m.pos < len(m.value) { if m.pos < len(m.value) { // right arrow, ^F, forward one word
m.pos++ resetBlink = m.SetCursor(m.pos + 1)
} }
case tea.KeyCtrlF: // ^F, forward one character case tea.KeyCtrlW: // ^W, delete word left of cursor
fallthrough resetBlink = m.deleteWordLeft()
case tea.KeyCtrlB: // ^B, back one charcter
fallthrough
case tea.KeyHome, tea.KeyCtrlA: // ^A, go to beginning case tea.KeyHome, tea.KeyCtrlA: // ^A, go to beginning
m.CursorStart() resetBlink = m.CursorStart()
case tea.KeyDelete, tea.KeyCtrlD: // ^D, delete char under cursor case tea.KeyDelete, tea.KeyCtrlD: // ^D, delete char under cursor
if len(m.value) > 0 && m.pos < len(m.value) { if len(m.value) > 0 && m.pos < len(m.value) {
m.value = append(m.value[:m.pos], m.value[m.pos+1:]...) m.value = append(m.value[:m.pos], m.value[m.pos+1:]...)
} }
case tea.KeyCtrlE, tea.KeyEnd: // ^E, go to end case tea.KeyCtrlE, tea.KeyEnd: // ^E, go to end
m.CursorEnd() resetBlink = m.CursorEnd()
case tea.KeyCtrlK: // ^K, kill text after cursor case tea.KeyCtrlK: // ^K, kill text after cursor
m.value = m.value[:m.pos] m.value = m.value[:m.pos]
m.pos = len(m.value) resetBlink = m.SetCursor(len(m.value))
case tea.KeyCtrlU: // ^U, kill text before cursor case tea.KeyCtrlU: // ^U, kill text before cursor
m.value = m.value[m.pos:] m.value = m.value[m.pos:]
m.pos = 0 resetBlink = m.SetCursor(0)
m.offset = 0 m.offset = 0
case tea.KeyCtrlV: // ^V paste case tea.KeyCtrlV: // ^V paste
m.Paste() return m, Paste
case tea.KeyRune: // input a regular character case tea.KeyRunes: // input regular characters
if msg.Alt && len(msg.Runes) == 1 {
if msg.Alt { if msg.Runes[0] == 'd' { // alt+d, delete word right of cursor
if msg.Rune == 'b' { // alt+b, back one word resetBlink = m.deleteWordRight()
m.wordLeft()
break break
} }
if msg.Rune == 'f' { // alt+f, forward one word if msg.Runes[0] == 'b' { // alt+b, back one word
m.wordRight() resetBlink = m.wordLeft()
break
}
if msg.Runes[0] == 'f' { // alt+f, forward one word
resetBlink = m.wordRight()
break break
} }
} }
// Input a regular character // Input a regular character
if m.CharLimit <= 0 || len(m.value) < m.CharLimit { if m.CharLimit <= 0 || len(m.value) < m.CharLimit {
m.value = append(m.value[:m.pos], append([]rune{msg.Rune}, m.value[m.pos:]...)...) m.value = append(m.value[:m.pos], append(msg.Runes, m.value[m.pos:]...)...)
m.pos++ resetBlink = m.SetCursor(m.pos + len(msg.Runes))
} }
} }
case BlinkMsg: case blinkMsg:
m.blink = !m.blink var cmd tea.Cmd
return m, Blink(m) if m.cursorMode == cursorBlink {
m.blink = !m.blink
cmd = m.blinkCmd()
}
return m, cmd
case blinkCanceled: // no-op
return m, nil
case pasteMsg:
resetBlink = m.handlePaste(string(msg))
case pasteErrMsg:
m.Err = msg
}
var cmd tea.Cmd
if resetBlink {
cmd = m.blinkCmd()
} }
m.handleOverflow() m.handleOverflow()
return m, cmd
return m, nil
} }
// View renders the textinput in its current state. // View renders the textinput in its current state.
func View(m Model) string { func (m Model) View() string {
// Placeholder text // Placeholder text
if len(m.value) == 0 && m.Placeholder != "" { if len(m.value) == 0 && m.Placeholder != "" {
return placeholderView(m) return m.placeholderView()
} }
value := m.value[m.offset:m.offsetRight] value := m.value[m.offset:m.offsetRight]
@@ -436,10 +570,10 @@ func View(m Model) string {
v := m.colorText(m.echoTransform(string(value[:pos]))) v := m.colorText(m.echoTransform(string(value[:pos])))
if pos < len(value) { if pos < len(value) {
v += cursorView(m.echoTransform(string(value[pos])), m) // cursor and text under it v += m.cursorView(m.echoTransform(string(value[pos]))) // cursor and text under it
v += m.colorText(m.echoTransform(string(value[pos+1:]))) // text after cursor v += m.colorText(m.echoTransform(string(value[pos+1:]))) // text after cursor
} else { } else {
v += cursorView(" ", m) v += m.cursorView(" ")
} }
// If a max width and background color were set fill the empty spaces with // If a max width and background color were set fill the empty spaces with
@@ -459,8 +593,8 @@ func View(m Model) string {
return m.Prompt + v return m.Prompt + v
} }
// placeholderView. // placeholderView returns the prompt and placeholder view, if any.
func placeholderView(m Model) string { func (m Model) placeholderView() string {
var ( var (
v string v string
p = m.Placeholder p = m.Placeholder
@@ -468,9 +602,9 @@ func placeholderView(m Model) string {
// Cursor // Cursor
if m.blink && m.PlaceholderColor != "" { if m.blink && m.PlaceholderColor != "" {
v += cursorView(m.colorPlaceholder(p[:1]), m) v += m.cursorView(m.colorPlaceholder(p[:1]))
} else { } else {
v += cursorView(p[:1], m) v += m.cursorView(p[:1])
} }
// The rest of the placeholder text // The rest of the placeholder text
@@ -480,29 +614,54 @@ func placeholderView(m Model) string {
} }
// cursorView styles the cursor. // cursorView styles the cursor.
func cursorView(s string, m Model) string { func (m Model) cursorView(v string) string {
if m.blink { if m.blink {
if m.TextColor != "" || m.BackgroundColor != "" { if m.TextColor != "" || m.BackgroundColor != "" {
return termenv.String(s). return termenv.String(v).
Foreground(color(m.TextColor)). Foreground(color(m.TextColor)).
Background(color(m.BackgroundColor)). Background(color(m.BackgroundColor)).
String() String()
} }
return s return v
} }
return termenv.String(s). return termenv.String(v).
Foreground(color(m.CursorColor)). Foreground(color(m.CursorColor)).
Background(color(m.BackgroundColor)). Background(color(m.BackgroundColor)).
Reverse(). Reverse().
String() String()
} }
// Blink is a command used to time the cursor blinking. // blinkCmd is an internal command used to manage cursor blinking.
func Blink(model Model) tea.Cmd { func (m Model) blinkCmd() tea.Cmd {
return func() tea.Msg { if m.blinkCtx != nil && m.blinkCtx.cancel != nil {
time.Sleep(model.BlinkSpeed) m.blinkCtx.cancel()
return BlinkMsg{}
} }
ctx, cancel := context.WithTimeout(m.blinkCtx.ctx, m.BlinkSpeed)
m.blinkCtx.cancel = cancel
return func() tea.Msg {
defer cancel()
<-ctx.Done()
if ctx.Err() == context.DeadlineExceeded {
return blinkMsg{}
}
return blinkCanceled{}
}
}
// Blink is a command used to initialize cursor blinking.
func Blink() tea.Msg {
return blinkMsg{}
}
// 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 { func clamp(v, low, high int) int {

View File

@@ -7,9 +7,7 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
const ( const spacebar = " "
spacebar = " "
)
// MODEL // MODEL
@@ -77,7 +75,7 @@ func (m *Model) SetContent(s string) {
func (m Model) visibleLines() (lines []string) { func (m Model) visibleLines() (lines []string) {
if len(m.lines) > 0 { if len(m.lines) > 0 {
top := max(0, m.YOffset) top := max(0, m.YOffset)
bottom := min(len(m.lines), m.YOffset+m.Height) bottom := clamp(m.YOffset+m.Height, top, len(m.lines))
lines = m.lines[top:bottom] lines = m.lines[top:bottom]
} }
return lines return lines
@@ -125,7 +123,7 @@ func (m *Model) HalfViewDown() (lines []string) {
if len(m.lines) > 0 { if len(m.lines) > 0 {
top := max(m.YOffset+m.Height/2, 0) top := max(m.YOffset+m.Height/2, 0)
bottom := min(m.YOffset+m.Height, len(m.lines)-1) bottom := clamp(m.YOffset+m.Height, top, len(m.lines)-1)
lines = m.lines[top:bottom] lines = m.lines[top:bottom]
} }
@@ -145,7 +143,7 @@ func (m *Model) HalfViewUp() (lines []string) {
if len(m.lines) > 0 { if len(m.lines) > 0 {
top := max(m.YOffset, 0) top := max(m.YOffset, 0)
bottom := min(m.YOffset+m.Height/2, len(m.lines)-1) bottom := clamp(m.YOffset+m.Height/2, top, len(m.lines)-1)
lines = m.lines[top:bottom] lines = m.lines[top:bottom]
} }
@@ -171,7 +169,7 @@ func (m *Model) LineDown(n int) (lines []string) {
if len(m.lines) > 0 { if len(m.lines) > 0 {
top := max(m.YOffset+m.Height-n, 0) top := max(m.YOffset+m.Height-n, 0)
bottom := min(m.YOffset+m.Height, len(m.lines)-1) bottom := clamp(m.YOffset+m.Height, top, len(m.lines)-1)
lines = m.lines[top:bottom] lines = m.lines[top:bottom]
} }
@@ -193,7 +191,7 @@ func (m *Model) LineUp(n int) (lines []string) {
if len(m.lines) > 0 { if len(m.lines) > 0 {
top := max(0, m.YOffset) top := max(0, m.YOffset)
bottom := min(m.YOffset+n, len(m.lines)-1) bottom := clamp(m.YOffset+n, top, len(m.lines)-1)
lines = m.lines[top:bottom] lines = m.lines[top:bottom]
} }
@@ -210,7 +208,7 @@ func (m *Model) GotoTop() (lines []string) {
if len(m.lines) > 0 { if len(m.lines) > 0 {
top := m.YOffset top := m.YOffset
bottom := min(m.YOffset+m.Height, len(m.lines)-1) bottom := clamp(m.YOffset+m.Height, top, len(m.lines)-1)
lines = m.lines[top:bottom] lines = m.lines[top:bottom]
} }
@@ -245,7 +243,7 @@ func Sync(m Model) tea.Cmd {
// TODO: we should probably use m.visibleLines() rather than these two // TODO: we should probably use m.visibleLines() rather than these two
// expressions. // expressions.
top := max(m.YOffset, 0) top := max(m.YOffset, 0)
bottom := min(m.YOffset+m.Height, len(m.lines)-1) bottom := clamp(m.YOffset+m.Height, 0, len(m.lines)-1)
return tea.SyncScrollArea( return tea.SyncScrollArea(
m.lines[top:bottom], m.lines[top:bottom],
@@ -269,7 +267,7 @@ func ViewDown(m Model, lines []string) tea.Cmd {
} }
// ViewUp is a high performance command the moves the viewport down by a given // ViewUp is a high performance command the moves the viewport down by a given
// number of lines height. Use Model.ViewDown to get the lines that should be // number of lines height. Use Model.ViewUp to get the lines that should be
// rendered. // rendered.
func ViewUp(m Model, lines []string) tea.Cmd { func ViewUp(m Model, lines []string) tea.Cmd {
if len(lines) == 0 { if len(lines) == 0 {
@@ -283,7 +281,7 @@ func ViewUp(m Model, lines []string) tea.Cmd {
// Update runs the update loop with default keybindings similar to popular // Update runs the update loop with default keybindings similar to popular
// pagers. To define your own keybindings use the methods on Model (i.e. // pagers. To define your own keybindings use the methods on Model (i.e.
// Model.LineDown()) and define your own update function. // Model.LineDown()) and define your own update function.
func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
@@ -354,7 +352,7 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
// VIEW // VIEW
// View renders the viewport into a string. // View renders the viewport into a string.
func View(m Model) string { func (m Model) View() string {
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
// Just send newlines since we're doing to be rendering the actual // Just send newlines since we're doing to be rendering the actual
// content seprately. We still need send something that equals the // content seprately. We still need send something that equals the
@@ -376,6 +374,10 @@ func View(m Model) string {
// ETC // ETC
func clamp(v, low, high int) int {
return min(high, max(low, v))
}
func min(a, b int) int { func min(a, b int) int {
if a < b { if a < b {
return a return a