1 Commits

Author SHA1 Message Date
Christian Muehlhaeuser
34cd93d6b5 Add Button component 2020-10-30 08:31:16 +01:00
10 changed files with 338 additions and 650 deletions

View File

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

View File

@@ -1,14 +1,10 @@
Bubbles Bubbles
======= =======
<p>
<img src="https://stuff.charm.sh/bubbles/bubbles-github.png" width="233" alt="The Bubbles Logo">
</p>
[![Latest Release](https://img.shields.io/github/release/charmbracelet/bubbles.svg)](https://github.com/charmbracelet/bubbles/releases) [![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) [![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](https://goreportcard.com/badge/charmbracelet/bubbles)](https://goreportcard.com/report/charmbracelet/bubbles) [![Go ReportCard](http://goreportcard.com/badge/charmbracelet/bubbles)](http://goreportcard.com/report/charmbracelet/bubbles)
Some components for [Bubble Tea](https://github.com/charmbracelet/bubbletea) applications. Some components for [Bubble Tea](https://github.com/charmbracelet/bubbletea) applications.
@@ -40,15 +36,9 @@ the common, and many customization options.
* [Example code, many fields](https://github.com/charmbracelet/tea/tree/master/examples/textinputs/main.go) * [Example code, many fields](https://github.com/charmbracelet/tea/tree/master/examples/textinputs/main.go)
## Progress ## Button
<img src="https://stuff.charm.sh/bubbles-examples/progress.gif" width="800" alt="Progressbar Example"> A button component.
A simple, customizable progress meter. Supports solid and gradient fills. The
empty and filled runes can be set to whatever you'd like. The percentage readout
is customizable and can also be omitted entirely.
* [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/progress/main.go)
## Paginator ## Paginator
@@ -70,7 +60,7 @@ browse SSH keys.
A viewport for vertically scrolling content. Optionally includes standard A viewport for vertically scrolling content. Optionally includes standard
pager keybindings and mouse wheel support. A high performance mode is available pager keybindings and mouse wheel support. A high performance mode is available
for applications which make use of the alternate screen buffer. for applications which make use of the alterate screen buffer.
* [Example code](https://github.com/charmbracelet/tea/tree/master/examples/pager/main.go) * [Example code](https://github.com/charmbracelet/tea/tree/master/examples/pager/main.go)
@@ -89,6 +79,7 @@ indenting and text wrapping.
Part of [Charm](https://charm.sh). Part of [Charm](https://charm.sh).
<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge-unrounded.jpg" width="400"></a> <a href="https://charm.sh/"><img alt="the Charm logo" src="https://stuff.charm.sh/charm-badge.jpg" width="400"></a>
Charm热爱开源! / Charm loves open source!
Charm热爱开源 • Charm loves open source

90
button/button.go Normal file
View File

@@ -0,0 +1,90 @@
package button
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/muesli/termenv"
)
var (
// color is a helper for returning colors.
color func(s string) termenv.Color = termenv.ColorProfile().Color
)
// Model is the Bubble Tea model for a button element.
type Model struct {
Err error
Label string
Default bool
TextColor string
BackgroundColor string
FocusedTextColor string
FocusedBackgroundColor string
// Focus indicates whether user focus should be on this button component
focus bool
}
// NewModel creates a new model with default settings.
func NewModel() Model {
return Model{
Label: "Button",
}
}
// Update is the Tea update loop.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.focus {
return m, nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
// TODO: implement
}
}
return m, nil
}
// View renders the button in its current state.
func (m Model) View() string {
margin := m.styled(" ").String()
label := m.styled(m.Label)
if m.Default {
label = label.Underline()
}
return margin + label.String() + margin
}
// Focused returns the focus state on the model.
func (m Model) Focused() bool {
return m.focus
}
// Focus sets the focus state on the model.
func (m *Model) Focus() {
m.focus = true
}
// Blur removes the focus state on the model.
func (m *Model) Blur() {
m.focus = false
}
func (m Model) styled(s string) termenv.Style {
view := termenv.String(s)
if m.focus {
view = view.Foreground(color(m.FocusedTextColor)).
Background(color(m.FocusedBackgroundColor))
} else {
view = view.Foreground(color(m.TextColor)).
Background(color(m.BackgroundColor))
}
return view
}

10
go.mod
View File

@@ -4,11 +4,9 @@ go 1.13
require ( require (
github.com/atotto/clipboard v0.1.2 github.com/atotto/clipboard v0.1.2
github.com/charmbracelet/bubbletea v0.13.1 github.com/charmbracelet/bubbletea v0.12.1
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/termenv v0.7.4
github.com/mattn/go-runewidth v0.0.12
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
) )

30
go.sum
View File

@@ -1,32 +1,25 @@
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.13.1 h1:huvX8mPaeMZ8DLulT50iEWRF+iitY5FNEDqDVLu69nM= github.com/charmbracelet/bubbletea v0.12.1 h1:t21pkG2IDBRduPbt2J64Dx5yt8yIidAkXwhhrc11SzY=
github.com/charmbracelet/bubbletea v0.13.1/go.mod h1:tp9tr9Dadh0PLhgiwchE5zZJXm5543JYjHG9oY+5qSg= github.com/charmbracelet/bubbletea v0.12.1/go.mod h1:3gZkYELUOiEUOp0bTInkxguucy/xRbGSOcbMs1geLxg=
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/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/muesli/termenv v0.7.2 h1:r1raklL3uKE7rOvWgSenmEm2px+dnc33OTisZ8YR1fw=
github.com/mattn/go-runewidth v0.0.12 h1:Y41i/hVW3Pgwr8gV+J23B9YEY0zxjptBuCWEaxmAOow=
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.8.1 h1:9q230czSP3DHVpkaPDXGp0TOfAwyjyYwXlUCQxQSaBk= github.com/muesli/termenv v0.7.4 h1:/pBqvU5CpkY53tU0vVn+xgs2ZTX63aH5nY+SSps5Xa8=
github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0= github.com/muesli/termenv v0.7.4/go.mod h1:pZ7qY9l3F7e5xsAOS0zCew2tME+p7bWeBkotCEcIIcc=
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=
@@ -35,7 +28,8 @@ 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-20210119212857-b64e53b001e4 h1:myAQVi0cGEoqQVR5POX+8RR2mrocKqNN1hmeMqhX27k= golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a h1:e3IU37lwO4aq3uoRKINC7JikojFmE5gO7xhfxs8VC34=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a/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.
@@ -19,7 +19,7 @@ const (
Dots Dots
) )
// Model is the Bubble Tea model for this user interface. // Model is the Tea model for this user interface.
type Model struct { type Model struct {
Type Type Type Type
Page int Page int
@@ -91,7 +91,7 @@ func (m *Model) NextPage() {
} }
} }
// OnLastPage returns whether or not we're on the last page. // LastPage 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
} }
@@ -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 (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func Update(msg tea.Msg, m Model) (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 (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
} }
// View renders the pagination to a string. // View renders the pagination to a string.
func (m Model) View() string { func View(m Model) string {
switch m.Type { switch m.Type {
case Dots: case Dots:
return m.dotsView() return dotsView(m)
default: default:
return m.arabicView() return arabicView(m)
} }
} }
func (m Model) dotsView() string { func dotsView(m Model) 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 (m Model) dotsView() string {
return s return s
} }
func (m Model) arabicView() string { func arabicView(m Model) string {
return fmt.Sprintf(m.ArabicFormat, m.Page+1, m.TotalPages) return fmt.Sprintf(m.ArabicFormat, m.Page+1, m.TotalPages)
} }

View File

@@ -1,192 +0,0 @@
package progress
import (
"fmt"
"strings"
"github.com/lucasb-eyer/go-colorful"
"github.com/muesli/reflow/ansi"
"github.com/muesli/termenv"
)
const defaultWidth = 40
var color func(string) termenv.Color = termenv.ColorProfile().Color
// Option is used to set options in NewModel. For example:
//
// progress := NewModel(
// WithRamp("#ff0000", "#0000ff"),
// WithoutPercentage(),
// )
type Option func(*Model) error
// WithDefaultGradient sets a gradient fill with default colors.
func WithDefaultGradient() Option {
return WithGradient("#5A56E0", "#EE6FF8")
}
// WithGradient sets a gradient fill blending between two colors.
func WithGradient(colorA, colorB string) Option {
return func(m *Model) error {
return m.setRamp(colorA, colorB, false)
}
}
// WithDefaultScaledGradient sets a gradient with default colors, and scales the
// gradient to fit the filled portion of the ramp.
func WithDefaultScaledGradient() Option {
return WithScaledGradient("#5A56E0", "#EE6FF8")
}
// WithScaledGradient scales the gradient to fit the width of the filled portion of
// the progress bar.
func WithScaledGradient(colorA, colorB string) Option {
return func(m *Model) error {
return m.setRamp(colorA, colorB, true)
}
}
// WithSolidFill sets the progress to use a solid fill with the given color.
func WithSolidFill(color string) Option {
return func(m *Model) error {
m.FullColor = color
m.useRamp = false
return nil
}
}
// WithoutPercentage hides the numeric percentage.
func WithoutPercentage() Option {
return func(m *Model) error {
m.ShowPercentage = false
return nil
}
}
// WithWidth sets the initial width of the progress bar. Note that you can also
// set the width via the Width property, which can come in handy if you're
// waiting for a tea.WindowSizeMsg.
func WithWidth(w int) Option {
return func(m *Model) error {
m.Width = w
return nil
}
}
// Model stores values we'll use when rendering the progress bar.
type Model struct {
// Total width of the progress bar, including percentage, if set.
Width int
// "Filled" sections of the progress bar
Full rune
FullColor string
// "Empty" sections of progress bar
Empty rune
EmptyColor string
// Settings for rendering the numeric percentage
ShowPercentage bool
PercentFormat string // a fmt string for a float
PercentageStyle *termenv.Style
useRamp bool
rampColorA colorful.Color
rampColorB colorful.Color
// When true, we scale the gradient to fit the width of the filled section
// of the progress bar. When false, the width of the gradient will be set
// to the full width of the progress bar.
scaleRamp bool
}
// NewModel returns a model with default values.
func NewModel(opts ...Option) (*Model, error) {
m := &Model{
Width: defaultWidth,
Full: '█',
FullColor: "#7571F9",
Empty: '░',
EmptyColor: "#606060",
ShowPercentage: true,
PercentFormat: " %3.0f%%",
}
for _, opt := range opts {
if err := opt(m); err != nil {
return nil, err
}
}
return m, nil
}
// View renders the progress bar as a given percentage.
func (m Model) View(percent float64) string {
b := strings.Builder{}
if m.ShowPercentage {
percentage := fmt.Sprintf(m.PercentFormat, percent*100) //nolint:gomnd
if m.PercentageStyle != nil {
percentage = m.PercentageStyle.Styled(percentage)
}
m.bar(&b, percent, ansi.PrintableRuneWidth(percentage))
b.WriteString(percentage)
} else {
m.bar(&b, percent, 0)
}
return b.String()
}
func (m Model) bar(b *strings.Builder, percent float64, textWidth int) {
var (
tw = m.Width - textWidth // total width
fw = int(float64(tw) * percent) // filled width
p float64
)
if m.useRamp {
// Gradient fill
for i := 0; i < fw; i++ {
if m.scaleRamp {
p = float64(i) / float64(fw)
} else {
p = float64(i) / float64(tw)
}
c := m.rampColorA.BlendLuv(m.rampColorB, p).Hex()
b.WriteString(termenv.
String(string(m.Full)).
Foreground(color(c)).
String(),
)
}
} else {
// Solid fill
s := termenv.String(string(m.Full)).Foreground(color(m.FullColor)).String()
b.WriteString(strings.Repeat(s, fw))
}
// Empty fill
e := termenv.String(string(m.Empty)).Foreground(color(m.EmptyColor)).String()
b.WriteString(strings.Repeat(e, tw-fw))
}
func (m *Model) setRamp(colorA, colorB string, scaled bool) error {
a, err := colorful.Hex(colorA)
if err != nil {
return err
}
b, err := colorful.Hex(colorB)
if err != nil {
return err
}
m.useRamp = true
m.scaleRamp = scaled
m.rampColorA = a
m.rampColorB = b
return nil
}

View File

@@ -1,73 +1,48 @@
package spinner package spinner
import ( import (
"strings"
"time" "time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/muesli/termenv"
"github.com/muesli/reflow/ansi" )
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 = []string
Frames []string
FPS time.Duration
}
// Some spinners to choose from. You could also make your own.
var ( var (
Line = Spinner{ // Some spinners to choose from. You could also make your own.
Frames: []string{"|", "/", "-", "\\"}, Line = Spinner([]string{"|", "/", "-", "\\"})
FPS: time.Second / 10, //nolint:gomnd Dot = Spinner([]string{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "})
}
Dot = Spinner{ color = termenv.ColorProfile().Color
Frames: []string{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "},
FPS: time.Second / 10, //nolint:gomnd
}
MiniDot = Spinner{
Frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"},
FPS: time.Second / 12, //nolint:gomnd
}
Jump = Spinner{
Frames: []string{"⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"},
FPS: time.Second / 10, //nolint:gomnd
}
Pulse = Spinner{
Frames: []string{"█", "▓", "▒", "░"},
FPS: time.Second / 8, //nolint:gomnd
}
Points = Spinner{
Frames: []string{"∙∙∙", "●∙∙", "∙●∙", "∙∙●"},
FPS: time.Second / 7, //nolint:gomnd
}
Globe = Spinner{
Frames: []string{"🌍", "🌎", "🌏"},
FPS: time.Second / 4, //nolint:gomnd
}
Moon = Spinner{
Frames: []string{"🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"},
FPS: time.Second / 8, //nolint:gomnd
}
Monkey = Spinner{
Frames: []string{"🙈", "🙉", "🙊"},
FPS: time.Second / 3, //nolint:gomnd
}
) )
// 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
// rather than using Model as a struct literal. // rather than using Model as a struct literal.
type Model struct { type Model struct {
// Spinner settings to use. See type Spinner. // Type is the set of frames to use. See Spinner.
Spinner Spinner Frames Spinner
// Style sets the styling for the spinner. Most of the time you'll just // FPS is the speed at which the ticker should tick.
// want foreground and background coloring, and potentially some padding. FPS time.Duration
//
// For an introduction to styling with Lip Gloss see: // ForegroundColor sets the background color of the spinner. It can be a
// https://github.com/charmbracelet/lipgloss // hex code or one of the 256 ANSI colors. If the terminal emulator can't
Style lipgloss.Style // doesn't support the color specified it will automatically degrade
// (per github.com/muesli/termenv).
ForegroundColor string
// 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
// doesn'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
@@ -92,49 +67,17 @@ type Model struct {
frame int frame int
startTime time.Time startTime time.Time
tag int
} }
// Start resets resets the spinner start time. For use with MinimumLifetime and // Start resets resets the spinner start time. For use with MinimumLifetime and
// MinimumStartTime. Optional. // MinimumStartTime. Optional.
// //
// This function is optional and generally considered for advanced use only.
// Most of the time your application logic will obviate the need for this
// method.
//
// This is considered experimental and may not appear in future versions of // This is considered experimental and may not appear in future versions of
// this library. // this library.
func (m *Model) Start() { func (m *Model) Start() {
m.startTime = time.Now() m.startTime = time.Now()
} }
// Finish sets the internal timer to a completed state so long as the spinner
// isn't flagged to be showing. If it is showing, finish has no effect. The
// idea here is that you call Finish if your operation has completed and, if
// the spinner isn't showing yet (by virtue of HideFor) then Visible() doesn't
// show the spinner at all.
//
// This is intended to work in conjunction with MinimumLifetime and
// MinimumStartTime, is completely optional.
//
// This function is optional and generally considered for advanced use only.
// Most of the time your application logic will obviate the need for this
// method.
//
// This is considered experimental and may not appear in future versions of
// this library.
func (m *Model) Finish() {
if m.hidden() {
m.startTime = time.Time{}
}
}
// advancedMode returns whether or not the user is making use of HideFor and
// MinimumLifetime properties.
func (m Model) advancedMode() bool {
return m.HideFor > 0 && m.MinimumLifetime > 0
}
// hidden returns whether or not Model.HideFor is in effect. // hidden returns whether or not Model.HideFor is in effect.
func (m Model) hidden() bool { func (m Model) hidden() bool {
if m.startTime.IsZero() { if m.startTime.IsZero() {
@@ -146,10 +89,12 @@ func (m Model) hidden() bool {
return m.startTime.Add(m.HideFor).After(time.Now()) return m.startTime.Add(m.HideFor).After(time.Now())
} }
// finished returns whether the minimum lifetime of this spinner has been // finished returns whether Model.MinimumLifetimeReached has been met.
// exceeded.
func (m Model) finished() bool { func (m Model) finished() bool {
if m.startTime.IsZero() || m.MinimumLifetime == 0 { if m.startTime.IsZero() {
return true
}
if m.MinimumLifetime == 0 {
return true return true
} }
return m.startTime.Add(m.HideFor).Add(m.MinimumLifetime).Before(time.Now()) return m.startTime.Add(m.HideFor).Add(m.MinimumLifetime).Before(time.Now())
@@ -157,13 +102,13 @@ func (m Model) finished() bool {
// Visible returns whether or not the view should be rendered. Works in // Visible returns whether or not the view should be rendered. Works in
// conjunction with Model.HideFor and Model.MinimumLifetimeReached. You should // conjunction with Model.HideFor and Model.MinimumLifetimeReached. You should
// use this method directly to determine whether or not to render this view in // use this message directly to determine whether or not to render this view in
// the parent view and whether to continue sending spin messaging in the // the parent view and whether to continue sending spin messaging in the
// parent update function. // parent update function.
// //
// This function is optional and generally considered for advanced use only. // Also note that using this function is optional and generally considered for
// Most of the time your application logic will obviate the need for this // advanced use only. Most of the time your application logic will determine
// method. // whether or not this view should be used.
// //
// This is considered experimental and may not appear in future versions of // This is considered experimental and may not appear in future versions of
// this library. // this library.
@@ -173,69 +118,55 @@ 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{Spinner: Line} return Model{
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.
type TickMsg struct { type TickMsg struct {
Time time.Time Time time.Time
tag int
} }
// 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 (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
switch msg := msg.(type) { if _, ok := msg.(TickMsg); ok {
case TickMsg:
// If a tag is set, and it's not the one we expect, reject the message.
// This prevents the spinner from receiving too many messages and
// thus spinning too fast.
if msg.tag > 0 && msg.tag != m.tag {
return m, nil
}
m.frame++ m.frame++
if m.frame >= len(m.Spinner.Frames) { if m.frame >= len(m.Frames) {
m.frame = 0 m.frame = 0
} }
return m, Tick(m)
m.tag++
return m, m.tick(m.tag)
default:
return m, nil
} }
return m, nil
} }
// View renders the model's view. // View renders the model's view.
func (m Model) View() string { func View(model Model) string {
if m.frame >= len(m.Spinner.Frames) { if model.frame >= len(model.Frames) {
return "(error)" return "error"
} }
frame := m.Spinner.Frames[m.frame] frame := model.Frames[model.frame]
// If we're using the fine-grained hide/show spinner rules and those rules if model.ForegroundColor != "" || model.BackgroundColor != "" {
// deem that the spinner should be hidden, draw an empty space in place of return termenv.
// the spinner. String(frame).
if m.advancedMode() && !m.Visible() { Foreground(color(model.ForegroundColor)).
frame = strings.Repeat(" ", ansi.PrintableRuneWidth(frame)) Background(color(model.BackgroundColor)).
String()
} }
return m.Style.Render(frame) 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.
// to effectively start the spinner. 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(tag int) tea.Cmd {
return tea.Tick(m.Spinner.FPS, func(t time.Time) tea.Msg {
return TickMsg{ return TickMsg{
Time: t, Time: t,
tag: tag,
} }
}) })
} }

View File

@@ -3,47 +3,25 @@ package textinput
import ( import (
"context" "context"
"strings" "strings"
"sync"
"time" "time"
"unicode" "unicode"
"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
// Internal ID management for text inputs. Necessary for blink integrity when // color is a helper for returning colors.
// multiple text inputs are involved. var color func(s string) termenv.Color = termenv.ColorProfile().Color
var (
lastID int
idMtx sync.Mutex
)
// Return the next ID we should use on the Model. // blinkMsg and blinkCanceled are used to manage cursor blinking.
func nextID() int { type blinkMsg struct{}
idMtx.Lock()
defer idMtx.Unlock()
lastID++
return lastID
}
// initialBlinkMsg initializes cursor blinking.
type initialBlinkMsg struct{}
// blinkMsg signals that the cursor should blink. It contains metadata that
// allows us to tell if the blink message is the one we're expecting.
type blinkMsg struct {
id int
tag int
}
// blinkCanceled is sent when a blink operation is canceled.
type blinkCanceled struct{} type blinkCanceled struct{}
// Internal messages for clipboard operations. // Messages for clipboard events.
type pasteMsg string type pasteMsg string
type pasteErrMsg struct{ error } type pasteErrMsg struct{ error }
@@ -71,47 +49,30 @@ type blinkCtx struct {
cancel context.CancelFunc cancel context.CancelFunc
} }
// CursorMode describes the behavior of the cursor. type cursorMode int
type CursorMode int
// Available cursor modes.
const ( const (
CursorBlink CursorMode = iota cursorBlink = iota
CursorStatic cursorStatic
CursorHide cursorHide
) )
// String returns a the cursor mode in a human-readable format. This method is
// provisional and for informational purposes only.
func (c CursorMode) String() string {
return [...]string{
"blink",
"static",
"hidden",
}[c]
}
// 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
// General settings. // General settings
Prompt string Prompt string
Placeholder string Placeholder string
Cursor string
BlinkSpeed time.Duration BlinkSpeed time.Duration
TextColor string
BackgroundColor string
PlaceholderColor string
CursorColor string
EchoMode EchoMode EchoMode EchoMode
EchoCharacter rune EchoCharacter rune
// 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 // 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.
CharLimit int CharLimit int
@@ -121,17 +82,11 @@ type Model struct {
// viewport. If 0 or less this setting is ignored. // viewport. If 0 or less this setting is ignored.
Width int Width int
// The ID of this Model as it relates to other textinput Models.
id int
// The ID of the blink message we're expecting to receive.
blinkTag int
// Underlying text value. // Underlying text value.
value []rune value []rune
// focus indicates whether user input focus should be on this input // Focus indicates whether user input focus should be on this input
// component. When false, ignore keyboard input and hide the cursor. // component. When false, don't blink and ignore keyboard input.
focus bool focus bool
// Cursor blink state. // Cursor blink state.
@@ -149,24 +104,26 @@ type Model struct {
blinkCtx *blinkCtx blinkCtx *blinkCtx
// cursorMode determines the behavior of the cursor // cursorMode determines the behavior of the cursor
cursorMode CursorMode cursorMode cursorMode
} }
// NewModel creates a new model with default settings. // NewModel creates a new model with default settings.
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")),
id: nextID(),
value: nil, value: nil,
focus: false, focus: false,
blink: true, blink: true,
pos: 0, pos: 0,
cursorMode: CursorBlink, cursorMode: cursorBlink,
blinkCtx: &blinkCtx{ blinkCtx: &blinkCtx{
ctx: context.Background(), ctx: context.Background(),
@@ -182,8 +139,8 @@ func (m *Model) SetValue(s string) {
} else { } else {
m.value = runes m.value = runes
} }
if m.pos == 0 || m.pos > len(m.value) { if m.pos > len(m.value) {
m.setCursor(len(m.value)) m.SetCursor(len(m.value))
} }
m.handleOverflow() m.handleOverflow()
} }
@@ -193,69 +150,30 @@ func (m Model) Value() string {
return string(m.value) return string(m.value)
} }
// Cursor returns the cursor position. // SetCursor start moves the cursor to the given position. If the position is
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.
func (m *Model) SetCursor(pos int) { // Returns whether or nor the cursor timer should be reset.
m.setCursor(pos) func (m *Model) SetCursor(pos int) bool {
}
// 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()
// Show the cursor unless it's been explicitly hidden // Show the cursor unless it's been explicitly hidden
m.blink = m.cursorMode == CursorHide m.blink = m.cursorMode == cursorHide
// Reset cursor blink if necessary // Reset cursor blink if necessary
return m.cursorMode == CursorBlink return m.cursorMode == cursorBlink
} }
// CursorStart moves the cursor to the start of the input 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.cursorStart() func (m *Model) CursorStart() bool {
return m.SetCursor(0)
} }
// cursorStart moves the cursor to the start of the input field and returns // CursorEnd moves the cursor to the end of the field. Returns whether or not
// whether or not the curosr blink should be reset. // the cursor blink should be reset.
func (m *Model) cursorStart() bool { func (m *Model) CursorEnd() bool {
return m.setCursor(0) return m.SetCursor(len(m.value))
}
// CursorEnd moves the cursor to the end of the input field
func (m *Model) CursorEnd() {
m.cursorEnd()
}
// CursorMode returns the model's cursor mode. For available cursor modes, see
// type CursorMode.
func (m Model) CursorMode() CursorMode {
return m.cursorMode
}
// CursorMode sets the model's cursor mode. This method returns a command.
//
// For available cursor modes, see type CursorMode.
func (m *Model) SetCursorMode(mode CursorMode) tea.Cmd {
m.cursorMode = mode
m.blink = m.cursorMode == CursorHide || !m.focus
if mode == CursorBlink {
return Blink
}
return nil
}
// cursorEnd moves the cursor to the end of the input field and returns whether
// the cursor should blink should reset.
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.
@@ -263,20 +181,13 @@ func (m Model) Focused() bool {
return m.focus return m.focus
} }
// Focus sets the focus state on the model. When the model is in focus it can // Focus sets the focus state on the model.
// receive keyboard input and the cursor will be hidden. func (m *Model) Focus() {
func (m *Model) Focus() tea.Cmd {
m.focus = true m.focus = true
m.blink = m.cursorMode == CursorHide // show the cursor unless we've explicitly hidden it m.blink = m.cursorMode == cursorHide // show the cursor unless we've explicitly hidden it
if m.cursorMode == CursorBlink && m.focus {
return m.blinkCmd()
}
return nil
} }
// Blur removes the focus state on the model. When the model is blurred it can // Blur removes the focus state on the model.
// not receive keyboard input and the cursor will be hidden.
func (m *Model) Blur() { func (m *Model) Blur() {
m.focus = false m.focus = false
m.blink = true m.blink = true
@@ -286,12 +197,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 reset. // cursor blink should be reset.
func (m *Model) handlePaste(v string) bool { func (m *Model) handlePaste(v string) (blink bool) {
paste := []rune(v) paste := []rune(v)
var availSpace int var availSpace int
@@ -301,7 +212,7 @@ func (m *Model) handlePaste(v string) 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 false return
} }
// 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
@@ -332,7 +243,7 @@ func (m *Model) handlePaste(v string) 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
@@ -380,47 +291,47 @@ func (m *Model) handleOverflow() {
} }
} }
// deleteBeforeCursor deletes all text before the cursor. Returns whether or // colorText colorizes a given string according to the TextColor value of the
// not the cursor blink should be reset. // model.
func (m *Model) deleteBeforeCursor() bool { func (m *Model) colorText(s string) string {
m.value = m.value[m.pos:] return termenv.
m.offset = 0 String(s).
return m.setCursor(0) Foreground(color(m.TextColor)).
Background(color(m.BackgroundColor)).
String()
} }
// deleteAfterCursor deletes all text after the cursor. Returns whether or not // colorPlaceholder colorizes a given string according to the TextColor value
// the cursor blink should be reset. If input is masked delete everything after // of the model.
// the cursor so as not to reveal word breaks in the masked input. func (m *Model) colorPlaceholder(s string) string {
func (m *Model) deleteAfterCursor() bool { return termenv.
m.value = m.value[:m.pos] String(s).
return m.setCursor(len(m.value)) Foreground(color(m.PlaceholderColor)).
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() bool { func (m *Model) deleteWordLeft() (blink bool) {
if m.pos == 0 || len(m.value) == 0 { if m.pos == 0 || len(m.value) == 0 {
return false return
}
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
} }
@@ -432,31 +343,26 @@ func (m *Model) deleteWordLeft() bool {
m.value = append(m.value[:m.pos], m.value[i:]...) m.value = append(m.value[:m.pos], m.value[i:]...)
} }
return blink return
} }
// 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. If input is masked delete everything after // the cursor blink should be reset.
// the cursor so as not to reveal word breaks in the masked input. func (m *Model) deleteWordRight() (blink bool) {
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 false return
}
if m.EchoMode != EchoNormal {
return m.deleteAfterCursor()
} }
i := m.pos i := m.pos
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 after cursor // ignore series of whitespace after cursor
m.setCursor(m.pos + 1) blink = 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]) {
m.setCursor(m.pos + 1) blink = m.SetCursor(m.pos + 1)
} else { } else {
break break
} }
@@ -467,27 +373,23 @@ func (m *Model) deleteWordRight() 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 m.setCursor(i) return
} }
// 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. If input is masked, move input to the start // cursor blink should be reset.
// so as not to reveal word breaks in the masked input. func (m *Model) wordLeft() (blink bool) {
func (m *Model) wordLeft() bool {
if m.pos == 0 || len(m.value) == 0 { if m.pos == 0 || len(m.value) == 0 {
return false return
} }
if m.EchoMode != EchoNormal {
return m.cursorStart()
}
blink := false
i := m.pos - 1 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
@@ -496,33 +398,28 @@ func (m *Model) wordLeft() 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 blink return
} }
// 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. If the input is masked, move input to the end // cursor blink should be reset.
// so as not to reveal word breaks in the masked input. func (m *Model) wordRight() (blink bool) {
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 false return
} }
if m.EchoMode != EchoNormal {
return m.cursorEnd()
}
blink := false
i := m.pos 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
@@ -531,14 +428,14 @@ func (m *Model) wordRight() 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 blink return
} }
func (m Model) echoTransform(v string) string { func (m Model) echoTransform(v string) string {
@@ -572,7 +469,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)
} }
} }
} }
@@ -582,43 +479,46 @@ 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 character if m.pos < len(m.value) { // right arrow, ^F, forward one word
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
resetBlink = m.deleteAfterCursor() m.value = m.value[:m.pos]
resetBlink = m.SetCursor(len(m.value))
case tea.KeyCtrlU: // ^U, kill text before cursor case tea.KeyCtrlU: // ^U, kill text before cursor
resetBlink = m.deleteBeforeCursor() m.value = m.value[m.pos:]
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.KeyRune: // input a regular character
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 == 'd' { // alt+d, delete word right of cursor
resetBlink = m.deleteWordRight() resetBlink = m.deleteWordRight()
break break
} }
if msg.Runes[0] == 'b' { // alt+b, back one word if msg.Rune == 'b' { // alt+b, back one word
resetBlink = m.wordLeft() resetBlink = m.wordLeft()
break break
} }
if msg.Runes[0] == 'f' { // alt+f, forward one word if msg.Rune == 'f' { // alt+f, forward one word
resetBlink = m.wordRight() resetBlink = m.wordRight()
break break
} }
@@ -626,37 +526,14 @@ 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([]rune{msg.Rune}, m.value[m.pos:]...)...)
resetBlink = m.setCursor(m.pos + len(msg.Runes)) resetBlink = m.SetCursor(m.pos + 1)
} }
} }
case initialBlinkMsg:
// We accept all initialBlinkMsgs genrated by the Blink command.
if m.cursorMode != CursorBlink || !m.focus {
return m, nil
}
cmd := m.blinkCmd()
return m, cmd
case blinkMsg: case blinkMsg:
// We're choosy about whether to accept blinkMsgs so that our cursor
// only exactly when it should.
// Is this model blinkable?
if m.cursorMode != CursorBlink || !m.focus {
return m, nil
}
// Were we expecting this blink message?
if msg.id != m.id || msg.tag != m.blinkTag {
return m, nil
}
var cmd tea.Cmd var cmd tea.Cmd
if m.cursorMode == CursorBlink { if m.cursorMode == cursorBlink {
m.blink = !m.blink m.blink = !m.blink
cmd = m.blinkCmd() cmd = m.blinkCmd()
} }
@@ -688,15 +565,13 @@ 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 := styleText(m.echoTransform(string(value[:pos]))) v := m.colorText(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 += styleText(m.echoTransform(string(value[pos+1:]))) // text after cursor v += m.colorText(m.echoTransform(string(value[pos+1:]))) // text after cursor
} else { } else {
v += m.cursorView(" ") v += m.cursorView(" ")
} }
@@ -704,15 +579,18 @@ 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 && valWidth <= m.Width { if m.Width > 0 && len(m.BackgroundColor) > 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 += styleText(strings.Repeat(" ", padding)) v += strings.Repeat(
termenv.String(" ").Background(color(m.BackgroundColor)).String(),
padding,
)
} }
return m.PromptStyle.Render(m.Prompt) + v return m.Prompt + v
} }
// placeholderView returns the prompt and placeholder view, if any. // placeholderView returns the prompt and placeholder view, if any.
@@ -720,36 +598,41 @@ 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 { if m.blink && m.PlaceholderColor != "" {
v += m.cursorView(style(p[:1])) v += m.cursorView(m.colorPlaceholder(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 += style(p[1:]) v += m.colorPlaceholder(p[1:])
return m.PromptStyle.Render(m.Prompt) + v return 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 {
return m.TextStyle.Render(v) if m.TextColor != "" || m.BackgroundColor != "" {
return termenv.String(v).
Foreground(color(m.TextColor)).
Background(color(m.BackgroundColor)).
String()
} }
return m.CursorStyle.Inline(true).Reverse(true).Render(v) return v
}
return termenv.String(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.
func (m *Model) blinkCmd() tea.Cmd { func (m Model) blinkCmd() tea.Cmd {
if m.cursorMode != CursorBlink {
return nil
}
if m.blinkCtx != nil && m.blinkCtx.cancel != nil { if m.blinkCtx != nil && m.blinkCtx.cancel != nil {
m.blinkCtx.cancel() m.blinkCtx.cancel()
} }
@@ -757,13 +640,11 @@ func (m *Model) blinkCmd() tea.Cmd {
ctx, cancel := context.WithTimeout(m.blinkCtx.ctx, m.BlinkSpeed) ctx, cancel := context.WithTimeout(m.blinkCtx.ctx, m.BlinkSpeed)
m.blinkCtx.cancel = cancel m.blinkCtx.cancel = cancel
m.blinkTag++
return func() tea.Msg { return func() tea.Msg {
defer cancel() defer cancel()
<-ctx.Done() <-ctx.Done()
if ctx.Err() == context.DeadlineExceeded { if ctx.Err() == context.DeadlineExceeded {
return blinkMsg{id: m.id, tag: m.blinkTag} return blinkMsg{}
} }
return blinkCanceled{} return blinkCanceled{}
} }
@@ -771,7 +652,7 @@ func (m *Model) blinkCmd() tea.Cmd {
// Blink is a command used to initialize cursor blinking. // Blink is a command used to initialize cursor blinking.
func Blink() tea.Msg { func Blink() tea.Msg {
return initialBlinkMsg{} return blinkMsg{}
} }
// Paste is a command for pasting from the clipboard into the text input. // Paste is a command for pasting from the clipboard into the text input.

View File

@@ -9,10 +9,10 @@ import (
const ( const (
spacebar = " " spacebar = " "
mouseWheelDelta = 3
) )
// Model is the Bubble Tea model for this viewport element. // MODEL
type Model struct { type Model struct {
Width int Width int
Height int Height int
@@ -54,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
@@ -71,10 +71,6 @@ 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.
@@ -221,7 +217,7 @@ func (m *Model) GotoTop() (lines []string) {
return lines return lines
} }
// GotoBottom sets the viewport to the bottom position. // GotoTop 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)
@@ -273,7 +269,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.ViewUp to get the lines that should be // number of lines height. Use Model.ViewDown 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 {
@@ -287,7 +283,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 (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
@@ -339,13 +335,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(mouseWheelDelta) lines := m.LineUp(3)
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = ViewUp(m, lines) cmd = ViewUp(m, lines)
} }
case tea.MouseWheelDown: case tea.MouseWheelDown:
lines := m.LineDown(mouseWheelDelta) lines := m.LineDown(3)
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = ViewDown(m, lines) cmd = ViewDown(m, lines)
} }
@@ -358,7 +354,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
// VIEW // VIEW
// View renders the viewport into a string. // View renders the viewport into a string.
func (m Model) View() string { func View(m Model) 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