15 Commits

Author SHA1 Message Date
Christian Rocha
9449cc7e41 Don't allow word-based deletion when input is masked in textinput
alt+d and ctrl+w will now delete all the way to the beginning and end,
respectively, if EchoMode is not EchoNormal.
2021-04-26 12:03:57 -04:00
Christian Rocha
58a177394e Don't allow word-to-word movement when input is masked in textinput
When EchoMode is not set to EchoNormal, alt+left/alt+b and
alt+right/alt+b jumps to the beginning and the end, respectively, so as
not to reveal word breaks in the masked text.
2021-04-26 12:03:57 -04:00
Christian Rocha
f016c31d83 Add method for returning cursor position in textinput
Previously, Cursor was a member on the model that did absolutely
nothing.
2021-04-26 12:03:57 -04:00
Christian Rocha
74436326b9 Public cursor movement functions no longer return values in textinput
Previously, textinput.SetCursor, textinput.CursotStart, and
textinput.CursorEnd returned bools used interally for managing cursor
blinking. Those methods have been replaced with private counterparts.
2021-04-26 12:03:57 -04:00
Christian Rocha
158097df66 Keep y-offset in bounds when setting content 2021-04-19 19:47:50 -04:00
Christian Rocha
0d5d7e5acd Remove deadcode + small comment on Lip Gloss 2021-04-13 17:09:23 -04:00
Christian Rocha
ddd48d9ab7 TextInput now uses Lip Gloss for styling 2021-04-13 17:09:23 -04:00
Christian Rocha
7242bbe8dc Spinner now uses Lip Gloss for styling 2021-04-13 17:09:23 -04:00
Christian Muehlhaeuser
9e324205c2 Bump bubbletea, termenv, reflow, go-runewidth, and go-colorful deps 2021-04-03 01:56:59 +02:00
Christian Muehlhaeuser
b2f42066a2 Drop naked returns in textinput model
This actually uncovered a few ineffective assignments, particularly
in deleteWordRight.
2021-03-11 20:09:32 -05:00
Christian Muehlhaeuser
a0d7cb77a1 Fix godocs for various types and functions 2021-03-12 02:06:30 +01:00
Christian Muehlhaeuser
046b9ca129 Enable golint as linter 2021-03-12 02:00:32 +01:00
Christian Muehlhaeuser
da9a4049de Mark innocuous numbers as nolint 2021-03-12 01:47:40 +01:00
Christian Muehlhaeuser
490a599c05 Use a const for the mouse wheel delta in viewport 2021-03-12 01:33:51 +01:00
Kiyon
f719cc8cb1 Reset cursor in SetValue when initial pos is 0 2021-03-09 10:21:17 -05:00
8 changed files with 200 additions and 185 deletions

View File

@@ -14,6 +14,7 @@ linters:
- godot - godot
- godox - godox
- goimports - goimports
- golint
- gomnd - gomnd
- goprintffuncname - goprintffuncname
- gosec - gosec

12
go.mod
View File

@@ -4,11 +4,11 @@ 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.2 github.com/charmbracelet/bubbletea v0.13.1
github.com/lucasb-eyer/go-colorful v1.0.3 github.com/charmbracelet/lipgloss v0.1.2
github.com/mattn/go-runewidth v0.0.9 github.com/lucasb-eyer/go-colorful v1.2.0
github.com/muesli/reflow v0.2.0 github.com/mattn/go-runewidth v0.0.12
github.com/muesli/termenv v0.7.4 github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68
github.com/muesli/termenv v0.8.1
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 // indirect golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 // indirect
golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a // indirect
) )

32
go.sum
View File

@@ -1,27 +1,32 @@
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.2 h1:y9Yo2Pv8tcm3mAJsWONGsmHhzrbNxJVxpVtemikxE9A= github.com/charmbracelet/bubbletea v0.13.1 h1:huvX8mPaeMZ8DLulT50iEWRF+iitY5FNEDqDVLu69nM=
github.com/charmbracelet/bubbletea v0.12.2/go.mod h1:3gZkYELUOiEUOp0bTInkxguucy/xRbGSOcbMs1geLxg= github.com/charmbracelet/bubbletea v0.13.1/go.mod h1:tp9tr9Dadh0PLhgiwchE5zZJXm5543JYjHG9oY+5qSg=
github.com/charmbracelet/lipgloss v0.1.2 h1:D+LUMg34W7n2pkuMrevKVxT7HXqnoRHm7IoomkX3/ZU=
github.com/charmbracelet/lipgloss v0.1.2/go.mod h1:5D8zradw52m7QmxRF6QgwbwJi9je84g8MkWiGN07uKg=
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/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4= github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4=
github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac=
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/muesli/reflow v0.2.0 h1:2o0UBJPHHH4fa2GCXU4Rg4DwOtWPMekCeyc5EWbAQp0= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/muesli/reflow v0.2.0/go.mod h1:qT22vjVmM9MIUeLgsVYe/Ye7eZlbv9dZjL3dVhUqLX8= github.com/mattn/go-runewidth v0.0.12 h1:Y41i/hVW3Pgwr8gV+J23B9YEY0zxjptBuCWEaxmAOow=
github.com/muesli/termenv v0.7.2 h1:r1raklL3uKE7rOvWgSenmEm2px+dnc33OTisZ8YR1fw= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 h1:y1p/ycavWjGT9FnmSjdbWUlLGvcxrY0Rw3ATltrxOhk=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/termenv v0.7.2/go.mod h1:ct2L5N2lmix82RaY3bMWwVu/jUFc9Ule0KGDCiKYPh8= github.com/muesli/termenv v0.7.2/go.mod h1:ct2L5N2lmix82RaY3bMWwVu/jUFc9Ule0KGDCiKYPh8=
github.com/muesli/termenv v0.7.4 h1:/pBqvU5CpkY53tU0vVn+xgs2ZTX63aH5nY+SSps5Xa8= github.com/muesli/termenv v0.8.1 h1:9q230czSP3DHVpkaPDXGp0TOfAwyjyYwXlUCQxQSaBk=
github.com/muesli/termenv v0.7.4/go.mod h1:pZ7qY9l3F7e5xsAOS0zCew2tME+p7bWeBkotCEcIIcc= github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee h1:4yd7jl+vXjalO5ztz6Vc1VADv+S/80LGJmyl1ROJ2AI=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 h1:pLI5jrR7OSLijeIDcmRxNmw2api+jEfxLoykJVice/E= golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 h1:pLI5jrR7OSLijeIDcmRxNmw2api+jEfxLoykJVice/E=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -30,8 +35,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634 h1:bNEHhJCnrwMKNMmOx3yAynp5vs5/gRy+XWFtZFu7NBM=
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a h1:e3IU37lwO4aq3uoRKINC7JikojFmE5gO7xhfxs8VC34= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 h1:myAQVi0cGEoqQVR5POX+8RR2mrocKqNN1hmeMqhX27k=
golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@@ -1,4 +1,4 @@
// package paginator provides a Bubble Tea package for calulating pagination // Package paginator provides a Bubble Tea package for calulating pagination
// and rendering pagination info. Note that this package does not render actual // and rendering pagination info. Note that this package does not render actual
// pages: it's purely for handling keystrokes related to pagination, and // pages: it's purely for handling keystrokes related to pagination, and
// rendering pagination status. // rendering pagination status.
@@ -91,7 +91,7 @@ func (m *Model) NextPage() {
} }
} }
// LastPage returns whether or not we're on the last page. // OnLastPage returns whether or not we're on the last page.
func (m Model) OnLastPage() bool { func (m Model) OnLastPage() bool {
return m.Page == m.TotalPages-1 return m.Page == m.TotalPages-1
} }

View File

@@ -128,7 +128,7 @@ func NewModel(opts ...Option) (*Model, error) {
func (m Model) View(percent float64) string { func (m Model) View(percent float64) string {
b := strings.Builder{} b := strings.Builder{}
if m.ShowPercentage { if m.ShowPercentage {
percentage := fmt.Sprintf(m.PercentFormat, percent*100) percentage := fmt.Sprintf(m.PercentFormat, percent*100) //nolint:gomnd
if m.PercentageStyle != nil { if m.PercentageStyle != nil {
percentage = m.PercentageStyle.Styled(percentage) percentage = m.PercentageStyle.Styled(percentage)
} }

View File

@@ -5,58 +5,54 @@ import (
"time" "time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/reflow/ansi" "github.com/muesli/reflow/ansi"
"github.com/muesli/termenv"
) )
const 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 struct { type Spinner struct {
Frames []string Frames []string
FPS time.Duration FPS time.Duration
} }
// Some spinners to choose from. You could also make your own.
var ( var (
// Some spinners to choose from. You could also make your own.
Line = Spinner{ Line = Spinner{
Frames: []string{"|", "/", "-", "\\"}, Frames: []string{"|", "/", "-", "\\"},
FPS: time.Second / 10, FPS: time.Second / 10, //nolint:gomnd
} }
Dot = Spinner{ Dot = Spinner{
Frames: []string{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "}, Frames: []string{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "},
FPS: time.Second / 10, FPS: time.Second / 10, //nolint:gomnd
} }
MiniDot = Spinner{ MiniDot = Spinner{
Frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, Frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"},
FPS: time.Second / 12, FPS: time.Second / 12, //nolint:gomnd
} }
Jump = Spinner{ Jump = Spinner{
Frames: []string{"⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"}, Frames: []string{"⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"},
FPS: time.Second / 10, FPS: time.Second / 10, //nolint:gomnd
} }
Pulse = Spinner{ Pulse = Spinner{
Frames: []string{"█", "▓", "▒", "░"}, Frames: []string{"█", "▓", "▒", "░"},
FPS: time.Second / 8, FPS: time.Second / 8, //nolint:gomnd
} }
Points = Spinner{ Points = Spinner{
Frames: []string{"∙∙∙", "●∙∙", "∙●∙", "∙∙●"}, Frames: []string{"∙∙∙", "●∙∙", "∙●∙", "∙∙●"},
FPS: time.Second / 7, FPS: time.Second / 7, //nolint:gomnd
} }
Globe = Spinner{ Globe = Spinner{
Frames: []string{"🌍", "🌎", "🌏"}, Frames: []string{"🌍", "🌎", "🌏"},
FPS: time.Second / 4, FPS: time.Second / 4, //nolint:gomnd
} }
Moon = Spinner{ Moon = Spinner{
Frames: []string{"🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"}, Frames: []string{"🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"},
FPS: time.Second / 8, FPS: time.Second / 8, //nolint:gomnd
} }
Monkey = Spinner{ Monkey = Spinner{
Frames: []string{"🙈", "🙉", "🙊"}, Frames: []string{"🙈", "🙉", "🙊"},
FPS: time.Second / 3, FPS: time.Second / 3, //nolint:gomnd
} }
color = termenv.ColorProfile().Color
) )
// Model contains the state for the spinner. Use NewModel to create new models // Model contains the state for the spinner. Use NewModel to create new models
@@ -66,17 +62,12 @@ type Model struct {
// Spinner settings to use. See type Spinner. // Spinner settings to use. See type Spinner.
Spinner Spinner Spinner Spinner
// ForegroundColor sets the background color of the spinner. It can be // Style sets the styling for the spinner. Most of the time you'll just
// a hex code or one of the 256 ANSI colors. If the terminal emulator can't // want foreground and background coloring, and potentially some padding.
// support the color specified it will automatically degrade (per //
// github.com/muesli/termenv). // For an introduction to styling with Lip Gloss see:
ForegroundColor string // https://github.com/charmbracelet/lipgloss
Style lipgloss.Style
// BackgroundColor sets the background color of the spinner. It can be
// 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
// github.com/muesli/termenv).
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
// logic around this can be implemented in view that implements this // logic around this can be implemented in view that implements this
@@ -232,15 +223,7 @@ func (m Model) View() string {
frame = strings.Repeat(" ", ansi.PrintableRuneWidth(frame)) frame = strings.Repeat(" ", ansi.PrintableRuneWidth(frame))
} }
if m.ForegroundColor != "" || m.BackgroundColor != "" { return m.Style.Render(frame)
return termenv.
String(frame).
Foreground(color(m.ForegroundColor)).
Background(color(m.BackgroundColor)).
String()
}
return frame
} }
// Tick is the command used to advance the spinner one frame. Use this command // Tick is the command used to advance the spinner one frame. Use this command

View File

@@ -8,15 +8,12 @@ import (
"github.com/atotto/clipboard" "github.com/atotto/clipboard"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
rw "github.com/mattn/go-runewidth" rw "github.com/mattn/go-runewidth"
"github.com/muesli/termenv"
) )
const defaultBlinkSpeed = time.Millisecond * 530 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. // blinkMsg and blinkCanceled are used to manage cursor blinking.
type blinkMsg struct{} type blinkMsg struct{}
type blinkCanceled struct{} type blinkCanceled struct{}
@@ -61,17 +58,22 @@ const (
type Model struct { type Model struct {
Err error Err error
// General settings // General settings.
Prompt string Prompt string
Placeholder string Placeholder string
Cursor string BlinkSpeed time.Duration
BlinkSpeed time.Duration EchoMode EchoMode
TextColor string EchoCharacter rune
BackgroundColor string
PlaceholderColor string // Styles. These will be applied as inline styles.
CursorColor string //
EchoMode EchoMode // For an introduction to styling with Lip Gloss see:
EchoCharacter rune // 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 // 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.
@@ -111,13 +113,10 @@ type Model struct {
func NewModel() Model { func NewModel() Model {
return Model{ return Model{
Prompt: "> ", Prompt: "> ",
Placeholder: "",
BlinkSpeed: defaultBlinkSpeed, BlinkSpeed: defaultBlinkSpeed,
TextColor: "",
PlaceholderColor: "240",
CursorColor: "",
EchoCharacter: '*', EchoCharacter: '*',
CharLimit: 0, CharLimit: 0,
PlaceholderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
value: nil, value: nil,
focus: false, focus: false,
@@ -139,8 +138,8 @@ func (m *Model) SetValue(s string) {
} else { } else {
m.value = runes m.value = runes
} }
if m.pos > len(m.value) { if m.pos == 0 || m.pos > len(m.value) {
m.SetCursor(len(m.value)) m.setCursor(len(m.value))
} }
m.handleOverflow() m.handleOverflow()
} }
@@ -150,10 +149,21 @@ func (m Model) Value() string {
return string(m.value) return string(m.value)
} }
// SetCursor start moves the cursor to the given position. If the position is // Cursor returns the cursor position.
func (m Model) Cursor() 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. // out of bounds the cursor will be moved to the start or end accordingly.
// Returns whether or nor the cursor timer should be reset. func (m *Model) SetCursor(pos int) {
func (m *Model) SetCursor(pos int) bool { m.setCursor(pos)
}
// setCursor moves the cursor to the given position and returns whether or not
// the cursor blink should be reset. If the position is out of bounds the
// cursor will be moved to the start or end accordingly.
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()
@@ -164,16 +174,26 @@ func (m *Model) SetCursor(pos int) bool {
return m.cursorMode == cursorBlink return m.cursorMode == cursorBlink
} }
// CursorStart moves the cursor to the start of the field. Returns whether or // CursorStart moves the cursor to the start of the input field.
// not the curosr blink should be reset. func (m *Model) CursorStart() {
func (m *Model) CursorStart() bool { m.cursorStart()
return m.SetCursor(0)
} }
// CursorEnd moves the cursor to the end of the field. Returns whether or not // cursorStart moves the cursor to the start of the input field and returns
// the cursor blink should be reset. // whether or not the curosr blink should be reset.
func (m *Model) CursorEnd() bool { func (m *Model) cursorStart() bool {
return m.SetCursor(len(m.value)) return m.setCursor(0)
}
// CursorEnd moves the cursor to the end of the input field
func (m *Model) CursorEnd() {
m.cursorEnd()
}
// cursorEnd moves the cursor to the end of the input field and returns whether
// or not
func (m *Model) cursorEnd() bool {
return m.setCursor(len(m.value))
} }
// Focused returns the focus state on the model. // Focused returns the focus state on the model.
@@ -197,12 +217,12 @@ func (m *Model) Blur() {
// or not the cursor blink should reset. // or not the cursor blink should reset.
func (m *Model) Reset() bool { func (m *Model) Reset() bool {
m.value = nil m.value = nil
return m.SetCursor(0) return m.setCursor(0)
} }
// handle a clipboard paste event, if supported. Returns whether or not the // handle a clipboard paste event, if supported. Returns whether or not the
// cursor blink should be reset. // cursor blink should be reset.
func (m *Model) handlePaste(v string) (blink bool) { func (m *Model) handlePaste(v string) bool {
paste := []rune(v) paste := []rune(v)
var availSpace int var availSpace int
@@ -212,7 +232,7 @@ func (m *Model) handlePaste(v string) (blink bool) {
// 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 {
return return false
} }
// 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
@@ -243,7 +263,7 @@ func (m *Model) handlePaste(v string) (blink bool) {
m.value = append(head, tail...) m.value = append(head, tail...)
// Reset blink state if necessary and run overflow checks // Reset blink state if necessary and run overflow checks
return m.SetCursor(m.pos) 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
@@ -291,47 +311,47 @@ func (m *Model) handleOverflow() {
} }
} }
// colorText colorizes a given string according to the TextColor value of the // deleteBeforeCursor deletes all text before the cursor. Returns whether or
// model. // not the cursor blink should be reset.
func (m *Model) colorText(s string) string { func (m *Model) deleteBeforeCursor() bool {
return termenv. m.value = m.value[m.pos:]
String(s). m.offset = 0
Foreground(color(m.TextColor)). return m.setCursor(0)
Background(color(m.BackgroundColor)).
String()
} }
// colorPlaceholder colorizes a given string according to the TextColor value // deleteAfterCursor deletes all text after the cursor. Returns whether or not
// of the model. // the cursor blink should be reset. If input is masked delete everything after
func (m *Model) colorPlaceholder(s string) string { // the cursor so as not to reveal word breaks in the masked input.
return termenv. func (m *Model) deleteAfterCursor() bool {
String(s). m.value = m.value[:m.pos]
Foreground(color(m.PlaceholderColor)). return m.setCursor(len(m.value))
Background(color(m.BackgroundColor)).
String()
} }
// deleteWordLeft deletes the word left to the cursor. Returns whether or not // deleteWordLeft deletes the word left to the cursor. Returns whether or not
// the cursor blink should be reset. // the cursor blink should be reset.
func (m *Model) deleteWordLeft() (blink bool) { func (m *Model) deleteWordLeft() bool {
if m.pos == 0 || len(m.value) == 0 { if m.pos == 0 || len(m.value) == 0 {
return return false
}
if m.EchoMode != EchoNormal {
return m.deleteBeforeCursor()
} }
i := m.pos i := m.pos
blink = m.SetCursor(m.pos - 1) blink := m.setCursor(m.pos - 1)
for unicode.IsSpace(m.value[m.pos]) { for unicode.IsSpace(m.value[m.pos]) {
// ignore series of whitespace before cursor // ignore series of whitespace before cursor
blink = m.SetCursor(m.pos - 1) blink = m.setCursor(m.pos - 1)
} }
for m.pos > 0 { for m.pos > 0 {
if !unicode.IsSpace(m.value[m.pos]) { if !unicode.IsSpace(m.value[m.pos]) {
blink = m.SetCursor(m.pos - 1) blink = m.setCursor(m.pos - 1)
} else { } else {
if m.pos > 0 { if m.pos > 0 {
// keep the previous space // keep the previous space
blink = m.SetCursor(m.pos + 1) blink = m.setCursor(m.pos + 1)
} }
break break
} }
@@ -343,26 +363,31 @@ func (m *Model) deleteWordLeft() (blink bool) {
m.value = append(m.value[:m.pos], m.value[i:]...) m.value = append(m.value[:m.pos], m.value[i:]...)
} }
return return blink
} }
// deleteWordRight deletes the word right to the cursor. Returns whether or not // deleteWordRight deletes the word right to the cursor. Returns whether or not
// the cursor blink should be reset. // the cursor blink should be reset. If input is masked delete everything after
func (m *Model) deleteWordRight() (blink bool) { // the cursor so as not to reveal word breaks in the masked input.
func (m *Model) deleteWordRight() bool {
if m.pos >= len(m.value) || len(m.value) == 0 { if m.pos >= len(m.value) || len(m.value) == 0 {
return return false
}
if m.EchoMode != EchoNormal {
return m.deleteAfterCursor()
} }
i := m.pos i := m.pos
blink = m.SetCursor(m.pos + 1) m.setCursor(m.pos + 1)
for unicode.IsSpace(m.value[m.pos]) { for unicode.IsSpace(m.value[m.pos]) {
// ignore series of whitespace after cursor // ignore series of whitespace after cursor
blink = m.SetCursor(m.pos + 1) m.setCursor(m.pos + 1)
} }
for m.pos < len(m.value) { for m.pos < len(m.value) {
if !unicode.IsSpace(m.value[m.pos]) { if !unicode.IsSpace(m.value[m.pos]) {
blink = m.SetCursor(m.pos + 1) m.setCursor(m.pos + 1)
} else { } else {
break break
} }
@@ -373,23 +398,27 @@ func (m *Model) deleteWordRight() (blink bool) {
} else { } else {
m.value = append(m.value[:i], m.value[m.pos:]...) m.value = append(m.value[:i], m.value[m.pos:]...)
} }
blink = m.SetCursor(i)
return return m.setCursor(i)
} }
// wordLeft moves the cursor one word to the left. Returns whether or not the // wordLeft moves the cursor one word to the left. Returns whether or not the
// cursor blink should be reset. // cursor blink should be reset. If input is masked, move input to the start
func (m *Model) wordLeft() (blink bool) { // so as not to reveal word breaks in the masked input.
func (m *Model) wordLeft() bool {
if m.pos == 0 || len(m.value) == 0 { if m.pos == 0 || len(m.value) == 0 {
return return false
} }
i := m.pos - 1 if m.EchoMode != EchoNormal {
return m.cursorStart()
}
blink := false
i := m.pos - 1
for i >= 0 { for i >= 0 {
if unicode.IsSpace(m.value[i]) { if unicode.IsSpace(m.value[i]) {
blink = m.SetCursor(m.pos - 1) blink = m.setCursor(m.pos - 1)
i-- i--
} else { } else {
break break
@@ -398,28 +427,33 @@ func (m *Model) wordLeft() (blink bool) {
for i >= 0 { for i >= 0 {
if !unicode.IsSpace(m.value[i]) { if !unicode.IsSpace(m.value[i]) {
blink = m.SetCursor(m.pos - 1) blink = m.setCursor(m.pos - 1)
i-- i--
} else { } else {
break break
} }
} }
return return blink
} }
// wordRight moves the cursor one word to the right. Returns whether or not the // wordRight moves the cursor one word to the right. Returns whether or not the
// cursor blink should be reset. // cursor blink should be reset. If the input is masked, move input to the end
func (m *Model) wordRight() (blink bool) { // so as not to reveal word breaks in the masked input.
func (m *Model) wordRight() bool {
if m.pos >= len(m.value) || len(m.value) == 0 { if m.pos >= len(m.value) || len(m.value) == 0 {
return return false
} }
i := m.pos if m.EchoMode != EchoNormal {
return m.cursorEnd()
}
blink := false
i := m.pos
for i < len(m.value) { for i < len(m.value) {
if unicode.IsSpace(m.value[i]) { if unicode.IsSpace(m.value[i]) {
blink = m.SetCursor(m.pos + 1) blink = m.setCursor(m.pos + 1)
i++ i++
} else { } else {
break break
@@ -428,14 +462,14 @@ func (m *Model) wordRight() (blink bool) {
for i < len(m.value) { for i < len(m.value) {
if !unicode.IsSpace(m.value[i]) { if !unicode.IsSpace(m.value[i]) {
blink = m.SetCursor(m.pos + 1) blink = m.setCursor(m.pos + 1)
i++ i++
} else { } else {
break break
} }
} }
return return blink
} }
func (m Model) echoTransform(v string) string { func (m Model) echoTransform(v string) string {
@@ -469,7 +503,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if len(m.value) > 0 { if len(m.value) > 0 {
m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...) m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...)
if m.pos > 0 { if m.pos > 0 {
resetBlink = m.SetCursor(m.pos - 1) resetBlink = m.setCursor(m.pos - 1)
} }
} }
} }
@@ -479,33 +513,30 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
break break
} }
if m.pos > 0 { // left arrow, ^F, back one character if m.pos > 0 { // left arrow, ^F, back one character
resetBlink = m.SetCursor(m.pos - 1) resetBlink = m.setCursor(m.pos - 1)
} }
case tea.KeyRight, tea.KeyCtrlF: case tea.KeyRight, tea.KeyCtrlF:
if msg.Alt { // alt+right arrow, forward one word if msg.Alt { // alt+right arrow, forward one word
resetBlink = m.wordRight() resetBlink = m.wordRight()
break break
} }
if m.pos < len(m.value) { // right arrow, ^F, forward one word if m.pos < len(m.value) { // right arrow, ^F, forward one character
resetBlink = m.SetCursor(m.pos + 1) resetBlink = m.setCursor(m.pos + 1)
} }
case tea.KeyCtrlW: // ^W, delete word left of cursor case tea.KeyCtrlW: // ^W, delete word left of cursor
resetBlink = m.deleteWordLeft() resetBlink = m.deleteWordLeft()
case tea.KeyHome, tea.KeyCtrlA: // ^A, go to beginning case tea.KeyHome, tea.KeyCtrlA: // ^A, go to beginning
resetBlink = 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
resetBlink = 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] resetBlink = m.deleteAfterCursor()
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:] resetBlink = m.deleteBeforeCursor()
resetBlink = m.SetCursor(0)
m.offset = 0
case tea.KeyCtrlV: // ^V paste case tea.KeyCtrlV: // ^V paste
return m, Paste return m, Paste
case tea.KeyRunes: // input regular characters case tea.KeyRunes: // input regular characters
@@ -527,7 +558,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
// 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(msg.Runes, m.value[m.pos:]...)...) m.value = append(m.value[:m.pos], append(msg.Runes, m.value[m.pos:]...)...)
resetBlink = m.SetCursor(m.pos + len(msg.Runes)) resetBlink = m.setCursor(m.pos + len(msg.Runes))
} }
} }
@@ -565,13 +596,15 @@ func (m Model) View() string {
return m.placeholderView() return m.placeholderView()
} }
styleText := m.TextStyle.Inline(true).Render
value := m.value[m.offset:m.offsetRight] value := m.value[m.offset:m.offsetRight]
pos := max(0, m.pos-m.offset) pos := max(0, m.pos-m.offset)
v := m.colorText(m.echoTransform(string(value[:pos]))) v := styleText(m.echoTransform(string(value[:pos])))
if pos < len(value) { if pos < len(value) {
v += m.cursorView(m.echoTransform(string(value[pos]))) // 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 += styleText(m.echoTransform(string(value[pos+1:]))) // text after cursor
} else { } else {
v += m.cursorView(" ") v += m.cursorView(" ")
} }
@@ -579,56 +612,44 @@ func (m Model) View() string {
// 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
// the background color. // the background color.
valWidth := rw.StringWidth(string(value)) valWidth := rw.StringWidth(string(value))
if m.Width > 0 && len(m.BackgroundColor) > 0 && valWidth <= m.Width { if m.Width > 0 && valWidth <= m.Width {
padding := max(0, m.Width-valWidth) padding := max(0, m.Width-valWidth)
if valWidth+padding <= m.Width && pos < len(value) { if valWidth+padding <= m.Width && pos < len(value) {
padding++ padding++
} }
v += strings.Repeat( v += styleText(strings.Repeat(" ", padding))
termenv.String(" ").Background(color(m.BackgroundColor)).String(),
padding,
)
} }
return m.Prompt + v return m.PromptStyle.Render(m.Prompt) + v
} }
// placeholderView returns the prompt and placeholder view, if any. // placeholderView returns the prompt and placeholder view, if any.
func (m Model) placeholderView() string { func (m Model) placeholderView() string {
var ( var (
v string v string
p = m.Placeholder p = m.Placeholder
style = m.PlaceholderStyle.Inline(true).Render
) )
// Cursor // Cursor
if m.blink && m.PlaceholderColor != "" { if m.blink {
v += m.cursorView(m.colorPlaceholder(p[:1])) v += m.cursorView(style(p[:1]))
} else { } else {
v += m.cursorView(p[:1]) v += m.cursorView(p[:1])
} }
// The rest of the placeholder text // The rest of the placeholder text
v += m.colorPlaceholder(p[1:]) v += style(p[1:])
return m.Prompt + v return m.PromptStyle.Render(m.Prompt) + v
} }
// cursorView styles the cursor. // cursorView styles the cursor.
func (m Model) cursorView(v string) string { func (m Model) cursorView(v string) string {
if m.blink { if m.blink {
if m.TextColor != "" || m.BackgroundColor != "" { return m.TextStyle.Render(v)
return termenv.String(v).
Foreground(color(m.TextColor)).
Background(color(m.BackgroundColor)).
String()
}
return v
} }
return termenv.String(v). return m.CursorStyle.Inline(true).Reverse(true).Render(v)
Foreground(color(m.CursorColor)).
Background(color(m.BackgroundColor)).
Reverse().
String()
} }
// blinkCmd is an internal command used to manage cursor blinking. // blinkCmd is an internal command used to manage cursor blinking.

View File

@@ -7,10 +7,12 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
const spacebar = " " const (
spacebar = " "
// MODEL mouseWheelDelta = 3
)
// Model is the Bubble Tea model for this viewport element.
type Model struct { type Model struct {
Width int Width int
Height int Height int
@@ -52,7 +54,7 @@ func (m Model) PastBottom() bool {
return m.YOffset > len(m.lines)-1-m.Height return m.YOffset > len(m.lines)-1-m.Height
} }
// Scrollpercent returns the amount scrolled as a float between 0 and 1. // ScrollPercent returns the amount scrolled as a float between 0 and 1.
func (m Model) ScrollPercent() float64 { func (m Model) ScrollPercent() float64 {
if m.Height >= len(m.lines) { if m.Height >= len(m.lines) {
return 1.0 return 1.0
@@ -69,6 +71,10 @@ func (m Model) ScrollPercent() float64 {
func (m *Model) SetContent(s string) { func (m *Model) SetContent(s string) {
s = strings.Replace(s, "\r\n", "\n", -1) // normalize line endings s = strings.Replace(s, "\r\n", "\n", -1) // normalize line endings
m.lines = strings.Split(s, "\n") m.lines = strings.Split(s, "\n")
if m.YOffset > len(m.lines)-1 {
m.GotoBottom()
}
} }
// Return the lines that should currently be visible in the viewport. // Return the lines that should currently be visible in the viewport.
@@ -215,7 +221,7 @@ func (m *Model) GotoTop() (lines []string) {
return lines return lines
} }
// GotoTop sets the viewport to the bottom position. // GotoBottom sets the viewport to the bottom position.
func (m *Model) GotoBottom() (lines []string) { func (m *Model) GotoBottom() (lines []string) {
m.YOffset = max(len(m.lines)-1-m.Height, 0) m.YOffset = max(len(m.lines)-1-m.Height, 0)
@@ -333,13 +339,13 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
case tea.MouseMsg: case tea.MouseMsg:
switch msg.Type { switch msg.Type {
case tea.MouseWheelUp: case tea.MouseWheelUp:
lines := m.LineUp(3) lines := m.LineUp(mouseWheelDelta)
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = ViewUp(m, lines) cmd = ViewUp(m, lines)
} }
case tea.MouseWheelDown: case tea.MouseWheelDown:
lines := m.LineDown(3) lines := m.LineDown(mouseWheelDelta)
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = ViewDown(m, lines) cmd = ViewDown(m, lines)
} }