31 Commits

Author SHA1 Message Date
Christian Rocha
74e84eca55 Bump Bubbletea dependency 2020-07-14 18:26:59 -04:00
Christian Rocha
ccf4c90b6b Rework spinner to allow for user-defined custom spinners 2020-07-14 18:21:48 -04:00
Christian Rocha
f341e3c896 Correct mouse wheel behavior 2020-06-23 13:10:50 -04:00
Christian Rocha
c9196e5407 Add basic mouse wheel support to viewport 2020-06-23 12:00:17 -04:00
Christian Rocha
f967f6a87f Fix a bug where placeholder text would not reappear post-input 2020-06-22 15:28:38 -04:00
Christian Rocha
35c3cd626d Fix spinner frame skipping and remove custom message functionality 2020-06-22 14:49:21 -04:00
Christian Rocha
2525319d72 Bump dependencies 2020-06-22 13:51:51 -04:00
Christian Rocha
e9dd6b06e0 Fix duplicate character bug when deleting chars in long strings
If the string is longer than the width of the field and the horizontal
viewport (so to speak) was on the right edge, the last character would
repeat when deleting characters because the viewport offset wasn't being
corrected. This fixes that.
2020-06-20 23:34:19 -04:00
Christian Rocha
7cef3a6f59 Document comments 2020-06-20 13:59:13 -04:00
Christian Rocha
1cdc2045c7 Fix a potential out of bounds panic 2020-06-19 19:46:51 -04:00
Christian Rocha
cc480dd2f3 Comments 2020-06-19 19:02:14 -04:00
Christian Rocha
0c1781fbb3 Clamp scroll percentage 2020-06-19 15:04:23 -04:00
Christian Rocha
5255143e87 Rework high performance half-view up/down 2020-06-19 13:23:14 -04:00
Christian Rocha
1629afe087 Rework high-performance pgup/pgdown 2020-06-19 12:20:35 -04:00
Christian Rocha
da3150ded7 Rework high performance line-up/line-down 2020-06-19 11:51:44 -04:00
Christian Rocha
b82cf5071d Send newlines instead of cursor-downs in performance viewport 2020-06-18 18:31:30 -04:00
Christian Rocha
d095a6554c Retire the internal viewport renderer 2020-06-18 18:25:57 -04:00
Christian Rocha
34ac608122 Correct slice bounding 2020-06-18 16:29:05 -04:00
Christian Rocha
09ae5da7c3 Don't get/set size in viewport; that should happen in the parent 2020-06-18 14:14:21 -04:00
Christian Rocha
5572542e2e Add command to sync initial high performance view 2020-06-18 13:42:11 -04:00
Christian Rocha
3688351ddf Viewport now can use Bubble Tea high performance scroll renderer (ish) 2020-06-17 20:55:49 -04:00
Christian Rocha
d9c03fc0b0 Add cursor positioning functions to viewport renderder 2020-06-16 16:10:34 -04:00
Christian Rocha
3321ac12a9 Renderer comments 2020-06-16 14:34:46 -04:00
Christian Rocha
0eaea5cc5d Use as much terminal movement from termenv as we can 2020-06-16 14:32:00 -04:00
Christian Rocha
0243dff9d3 Remove irrelevant (and erroneous) width settings in viewport renderer 2020-06-16 14:28:01 -04:00
Christian Rocha
68ec6c7ffc Integrate viewport navigation controls with new renderer 2020-06-15 21:22:25 -04:00
Christian Rocha
f332bf2cc2 Simplify the render's write() method and it now takes a slice of lines 2020-06-15 21:21:08 -04:00
Christian Rocha
0b19d41e0a Broad (working) pass at viewport renderer 2020-06-15 21:21:08 -04:00
Christian Rocha
786ec557d4 Bumb Bubble Tea dependency 2020-06-15 12:02:14 -04:00
Christian Rocha
d3192a3e70 Update text input viewport scrolling to handle unicode characters 2020-06-11 21:24:41 -04:00
Christian Rocha
74cc86fce5 Use a slice of runes as the underlying textinput value 2020-06-11 18:35:18 -04:00
5 changed files with 414 additions and 154 deletions

11
go.mod
View File

@@ -3,8 +3,11 @@ module github.com/charmbracelet/bubbles
go 1.13 go 1.13
require ( require (
github.com/charmbracelet/bubbletea v0.8.0 github.com/charmbracelet/bubbletea v0.9.1-0.20200713153904-2f53eeb54b90
github.com/muesli/termenv v0.5.3-0.20200526053627-d728968dcf83 github.com/mattn/go-runewidth v0.0.9
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 // indirect github.com/muesli/termenv v0.5.3-0.20200625163851-04b5c30e4c04
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 // indirect golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 // indirect
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae // indirect
) )
replace github.com/charmbracelet/bubbletea => ../bubbletea

20
go.sum
View File

@@ -1,26 +1,26 @@
github.com/charmbracelet/bubbletea v0.8.0 h1:ruZFaFF+2kgCI1IwNG40KTYDW5ZvE2+hPy4odlBdUko=
github.com/charmbracelet/bubbletea v0.8.0/go.mod h1:DzNhKkQQJI30eb+kBcaOs1+z86zTSqcMgSHoFY+uCsg=
github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f h1:5CjVwnuUcp5adK4gmY6i72gpVFVnZDP2h5TmPScB6u4= 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 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/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/muesli/termenv v0.5.3-0.20200526053627-d728968dcf83 h1:AfshZBlqAwhCZ27NJ1aPlMcPBihF1squ1GpaollhLQk= github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/muesli/termenv v0.5.3-0.20200526053627-d728968dcf83/go.mod h1:O1/I6sw+6KcrgAmcs6uiUVr7Lui+DNVbHTzt9Lm/PlI= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/muesli/termenv v0.5.3-0.20200625163851-04b5c30e4c04 h1:Wr876oXlAk6avTWi0daXAriOr+r5fqIuyDmtNc/KwY0=
github.com/muesli/termenv v0.5.3-0.20200625163851-04b5c30e4c04/go.mod h1:O1/I6sw+6KcrgAmcs6uiUVr7Lui+DNVbHTzt9Lm/PlI=
github.com/pkg/term v0.0.0-20200520122047-c3ffed290a03 h1:pd4YKIqCB0U7O2I4gWHgEUA2mCEOENmco0l/bM957bU= github.com/pkg/term v0.0.0-20200520122047-c3ffed290a03 h1:pd4YKIqCB0U7O2I4gWHgEUA2mCEOENmco0l/bM957bU=
github.com/pkg/term v0.0.0-20200520122047-c3ffed290a03/go.mod h1:Z9+Ul5bCbBKnbCvdOWbLqTHhJiYV414CURZJba6L8qA= github.com/pkg/term v0.0.0-20200520122047-c3ffed290a03/go.mod h1:Z9+Ul5bCbBKnbCvdOWbLqTHhJiYV414CURZJba6L8qA=
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-20200510223506-06a226fb4e37 h1:cg5LA/zNPRzIXIWSCxQW10Rvpy94aQh3LT/ShoCpkHw=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 h1:vEg9joUBmeBcK9iSJftGNf3coIG4HqZElCPehJsfAYM= golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 h1:vEg9joUBmeBcK9iSJftGNf3coIG4HqZElCPehJsfAYM=
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 h1:DZhuSZLsGlFL4CmhA8BcRA0mnthyA/nZ00AqCUo7vHg=
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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-20200523222454-059865788121 h1:rITEj+UZHYC927n8GT97eC3zrpzXdb/voyeOuVKS46o= golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4 h1:5/PjkGUjvEU5Gl6BxmvKRPpqo2uNMv4rcHBMwzk/st8=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 h1:OjiUf46hAmXblsZdnoSXsEUSKU8r1UEzcL5RVZ4gO9Y= golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae h1:Ih9Yo4hSPImZOpfGuA4bR/ORKTAbhZo2AbWNRCnevdo=
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/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

@@ -7,25 +7,17 @@ import (
"github.com/muesli/termenv" "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 = int type Spinner = []string
// Available types of spinners
const (
Line Spinner = iota
Dot
)
const (
defaultFPS = 9
)
var ( var (
// Spinner frames // Some spinners to choose from. You could also make your own.
spinners = map[Spinner][]string{ Line = Spinner([]string{"|", "/", "-", "\\"})
Line: {"|", "/", "-", "\\"}, Dot = Spinner([]string{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "", "", "⣯ ", "⣷ "})
Dot: {"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "},
}
color = termenv.ColorProfile().Color color = termenv.ColorProfile().Color
) )
@@ -35,10 +27,10 @@ var (
type Model struct { type Model struct {
// Type is the set of frames to use. See Spinner. // Type is the set of frames to use. See Spinner.
Type Spinner Frames Spinner
// FPS is the speed at which the ticker should tick // FPS is the speed at which the ticker should tick
FPS int FPS time.Duration
// ForegroundColor sets the background color of the spinner. It can be a // ForegroundColor 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 // hex code or one of the 256 ANSI colors. If the terminal emulator can't
@@ -52,20 +44,14 @@ type Model struct {
// (per github.com/muesli/termenv). // (per github.com/muesli/termenv).
BackgroundColor string BackgroundColor string
// CustomMsgFunc can be used to a custom message on tick. This can be
// useful when you have spinners in different parts of your application and
// want to differentiate between the messages for clarity and simplicity.
// If nil, this setting is ignored.
CustomMsgFunc func() tea.Msg
frame int frame int
} }
// NewModel returns a model with default values. // NewModel returns a model with default values.
func NewModel() Model { func NewModel() Model {
return Model{ return Model{
Type: Line, Frames: Line,
FPS: defaultFPS, FPS: defaultFPS,
} }
} }
@@ -76,43 +62,38 @@ type TickMsg struct{}
// every time it's called, regardless the message passed, so be sure the logic // every time it's called, regardless the message passed, so be sure the logic
// is setup so as not to call this Update needlessly. // is setup so as not to call this Update needlessly.
func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
m.frame++ if _, ok := msg.(TickMsg); ok {
if m.frame >= len(spinners[m.Type]) { m.frame++
m.frame = 0 if m.frame >= len(m.Frames) {
} m.frame = 0
if m.CustomMsgFunc != nil { }
return m, Tick(m) return m, Tick(m)
} }
return m, Tick(m) return m, nil
} }
// View renders the model's view. // View renders the model's view.
func View(model Model) string { func View(model Model) string {
s := spinners[model.Type] if model.frame >= len(model.Frames) {
if model.frame >= len(s) { return "error"
return "[error]"
} }
str := s[model.frame] frame := model.Frames[model.frame]
if model.ForegroundColor != "" || model.BackgroundColor != "" { if model.ForegroundColor != "" || model.BackgroundColor != "" {
return termenv. return termenv.
String(str). String(frame).
Foreground(color(model.ForegroundColor)). Foreground(color(model.ForegroundColor)).
Background(color(model.BackgroundColor)). Background(color(model.BackgroundColor)).
String() String()
} }
return str return frame
} }
// Tick is the command used to advance the spinner one frame. // Tick is the command used to advance the spinner one frame.
func Tick(model Model) tea.Cmd { func Tick(m Model) tea.Cmd {
return func() tea.Msg { return tea.Tick(m.FPS, func(time.Time) tea.Msg {
time.Sleep(time.Second / time.Duration(model.FPS))
if model.CustomMsgFunc != nil {
return model.CustomMsgFunc()
}
return TickMsg{} return TickMsg{}
} })
} }

View File

@@ -6,6 +6,7 @@ import (
"unicode" "unicode"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
rw "github.com/mattn/go-runewidth"
"github.com/muesli/termenv" "github.com/muesli/termenv"
) )
@@ -45,7 +46,7 @@ type Model struct {
Width int Width int
// Underlying text value // Underlying text value
value string 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, don't blink and ignore keyboard input. // component. When false, don't blink and ignore keyboard input.
@@ -59,15 +60,17 @@ type Model struct {
// Used to emulate a viewport when width is set and the content is // Used to emulate a viewport when width is set and the content is
// overflowing // overflowing
offset int offset int
offsetRight int
} }
// SetValue sets the value of the text input. // SetValue sets the value of the text input.
func (m *Model) SetValue(s string) { func (m *Model) SetValue(s string) {
if m.CharLimit > 0 && len(s) > m.CharLimit { runes := []rune(s)
m.value = s[:m.CharLimit] if m.CharLimit > 0 && len(runes) > m.CharLimit {
m.value = runes[:m.CharLimit]
} else { } else {
m.value = s m.value = runes
} }
if m.pos > len(m.value) { if m.pos > len(m.value) {
m.pos = len(m.value) m.pos = len(m.value)
@@ -77,13 +80,13 @@ func (m *Model) SetValue(s string) {
// Value returns the value of the text input. // Value returns the value of the text input.
func (m Model) Value() string { func (m Model) Value() string {
return m.value return string(m.value)
} }
// Cursor start moves the cursor to the given position. If the position is out // Cursor start moves the cursor to the given position. If the position is out
// of bounds the cursor will be moved to the start or end accordingly. // of bounds the cursor will be moved to the start or end accordingly.
func (m *Model) SetCursor(pos int) { func (m *Model) SetCursor(pos int) {
m.pos = max(0, min(len(m.value), pos)) m.pos = clamp(pos, 0, len(m.value))
m.handleOverflow() m.handleOverflow()
} }
@@ -118,8 +121,7 @@ func (m *Model) Blur() {
// Reset sets the input to its default state with no input. // Reset sets the input to its default state with no input.
func (m *Model) Reset() { func (m *Model) Reset() {
m.value = "" m.value = nil
m.offset = 0
m.pos = 0 m.pos = 0
m.blink = false m.blink = false
} }
@@ -127,14 +129,49 @@ func (m *Model) Reset() {
// 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
// as a horizontally scrolling viewport. // as a horizontally scrolling viewport.
func (m *Model) handleOverflow() { func (m *Model) handleOverflow() {
if m.Width > 0 { if m.Width <= 0 || rw.StringWidth(string(m.value)) <= m.Width {
overflow := max(0, len(m.value)-m.Width) m.offset = 0
m.offsetRight = len(m.value)
return
}
if overflow > 0 && m.pos < m.offset { // Correct right offset if we've deleted characters
m.offset = max(0, min(len(m.value), m.pos)) m.offsetRight = min(m.offsetRight, len(m.value))
} else if overflow > 0 && m.pos >= m.offset+m.Width {
m.offset = max(0, m.pos-m.Width) if m.pos < m.offset {
m.offset = m.pos
w := 0
i := 0
runes := m.value[m.offset:]
for i < len(runes) && w <= m.Width {
w += rw.RuneWidth(runes[i])
if w <= m.Width+1 {
i++
}
} }
m.offsetRight = m.offset + i
} else if m.pos >= m.offsetRight {
m.offsetRight = m.pos
w := 0
runes := m.value[:m.offsetRight]
i := len(runes) - 1
for i > 0 && w < m.Width {
w += rw.RuneWidth(runes[i])
if w <= m.Width {
i--
}
}
m.offset = m.offsetRight - (len(runes) - 1 - i)
} }
} }
@@ -166,7 +203,7 @@ func (m *Model) wordLeft() {
i := m.pos - 1 i := m.pos - 1
for i >= 0 { for i >= 0 {
if unicode.IsSpace(rune(m.value[i])) { if unicode.IsSpace(m.value[i]) {
m.pos-- m.pos--
i-- i--
} else { } else {
@@ -175,7 +212,7 @@ func (m *Model) wordLeft() {
} }
for i >= 0 { for i >= 0 {
if !unicode.IsSpace(rune(m.value[i])) { if !unicode.IsSpace(m.value[i]) {
m.pos-- m.pos--
i-- i--
} else { } else {
@@ -224,7 +261,7 @@ func NewModel() Model {
CursorColor: "", CursorColor: "",
CharLimit: 0, CharLimit: 0,
value: "", value: nil,
focus: false, focus: false,
blink: true, blink: true,
pos: 0, pos: 0,
@@ -245,7 +282,7 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
fallthrough fallthrough
case tea.KeyDelete: case tea.KeyDelete:
if len(m.value) > 0 { if len(m.value) > 0 {
m.value = m.value[:m.pos-1] + m.value[m.pos:] m.value = append(m.value[:m.pos-1], m.value[m.pos:]...)
m.pos-- m.pos--
} }
case tea.KeyLeft: case tea.KeyLeft:
@@ -272,7 +309,7 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
m.CursorStart() m.CursorStart()
case tea.KeyCtrlD: // ^D, delete char under cursor case 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 = m.value[:m.pos] + m.value[m.pos+1:] m.value = append(m.value[:m.pos], m.value[m.pos+1:]...)
} }
case tea.KeyCtrlE: // ^E, go to end case tea.KeyCtrlE: // ^E, go to end
m.CursorEnd() m.CursorEnd()
@@ -298,7 +335,7 @@ func Update(msg tea.Msg, m Model) (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 = m.value[:m.pos] + string(msg.Rune) + m.value[m.pos:] m.value = append(m.value[:m.pos], append([]rune{msg.Rune}, m.value[m.pos:]...)...)
m.pos++ m.pos++
} }
} }
@@ -324,34 +361,28 @@ func View(model tea.Model) string {
} }
// Placeholder text // Placeholder text
if m.value == "" && m.Placeholder != "" { if len(m.value) == 0 && m.Placeholder != "" {
return placeholderView(m) return placeholderView(m)
} }
left := m.offset value := m.value[m.offset:m.offsetRight]
right := 0
if m.Width > 0 {
right = min(len(m.value), m.offset+m.Width+1)
} else {
right = len(m.value)
}
value := m.value[left:right]
pos := m.pos - m.offset pos := m.pos - m.offset
v := m.colorText(value[:pos]) v := m.colorText(string(value[:pos]))
if pos < len(value) { if pos < len(value) {
v += cursorView(string(value[pos]), m) // cursor and text under it v += cursorView(string(value[pos]), m) // cursor and text under it
v += m.colorText(value[pos+1:]) // text after cursor v += m.colorText(string(value[pos+1:])) // text after cursor
} else { } else {
v += cursorView(" ", m) v += cursorView(" ", m)
} }
// 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.
if m.Width > 0 && len(m.BackgroundColor) > 0 && len(value) <= m.Width { valWidth := rw.StringWidth(string(value))
padding := m.Width - len(value) if m.Width > 0 && len(m.BackgroundColor) > 0 && valWidth <= m.Width {
if len(value)+padding <= m.Width && pos < len(value) { padding := max(0, m.Width-valWidth)
if valWidth+padding <= m.Width && pos < len(value) {
padding++ padding++
} }
v += strings.Repeat( v += strings.Repeat(
@@ -412,6 +443,10 @@ func Blink(model Model) tea.Cmd {
} }
} }
func clamp(v, low, high int) int {
return min(high, max(low, v))
}
func min(a, b int) int { func min(a, b int) int {
if a < b { if a < b {
return a return a

View File

@@ -1,6 +1,7 @@
package viewport package viewport
import ( import (
"math"
"strings" "strings"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -9,32 +10,30 @@ import (
// MODEL // MODEL
type Model struct { type Model struct {
Err error
Width int Width int
Height int Height int
Y int
// YOffset is the vertical scroll position.
YOffset int
// YPosition is the position of the viewport in relation to the terminal
// window. It's used in high performance rendering.
YPosition int
// HighPerformanceRendering bypasses the normal Bubble Tea renderer to
// provide higher performance rendering. Most of the time the normal Bubble
// Tea rendering methods will suffice, but if you're passing content with
// a lot of ANSI escape codes you may see improved rendering in certain
// terminals with this enabled.
//
// This should only be used in program occupying the entire terminal,
// which is usually via the alternate screen buffer.
HighPerformanceRendering bool
lines []string lines []string
} }
// Scrollpercent returns the amount scrolled as a float between 0 and 1. // TODO: do we really need this?
func (m Model) ScrollPercent() float64 {
if m.Height >= len(m.lines) {
return 1.0
}
y := float64(m.Y)
h := float64(m.Height)
t := float64(len(m.lines))
return y / (t - h)
}
// SetContent set the pager's text content.
func (m *Model) SetContent(s string) {
s = strings.Replace(s, "\r\n", "\n", -1) // normalize line endings
m.lines = strings.Split(s, "\n")
}
// NewModel creates a new pager model. Pass the dimensions of the pager.
func NewModel(width, height int) Model { func NewModel(width, height int) Model {
return Model{ return Model{
Width: width, Width: width,
@@ -42,42 +41,253 @@ func NewModel(width, height int) Model {
} }
} }
// TODO: do we really need this?
func (m Model) SetSize(yPos, width, height int) {
m.YPosition = yPos
m.Width = width
m.Height = height
}
// AtTop returns whether or not the viewport is in the very top position.
func (m Model) AtTop() bool {
return m.YOffset <= 0
}
// AtBottom returns whether or not the viewport is at the very botom position.
func (m Model) AtBottom() bool {
return m.YOffset >= len(m.lines)-m.Height-1
}
// Scrollpercent returns the amount scrolled as a float between 0 and 1.
func (m Model) ScrollPercent() float64 {
if m.Height >= len(m.lines) {
return 1.0
}
y := float64(m.YOffset)
h := float64(m.Height)
t := float64(len(m.lines))
v := y / (t - h)
return math.Max(0.0, math.Min(1.0, v))
}
// SetContent set the pager's text content. For high performance rendering the
// Sync command should also be called.
func (m *Model) SetContent(s string) {
s = strings.Replace(s, "\r\n", "\n", -1) // normalize line endings
m.lines = strings.Split(s, "\n")
}
// Return the lines that should currently be visible in the viewport
func (m Model) visibleLines() (lines []string) {
if len(m.lines) > 0 {
top := max(0, m.YOffset)
bottom := min(len(m.lines), m.YOffset+m.Height)
lines = m.lines[top:bottom]
}
return lines
}
// ViewDown moves the view down by the number of lines in the viewport. // ViewDown moves the view down by the number of lines in the viewport.
// Basically, "page down". // Basically, "page down".
func (m *Model) ViewDown() { func (m *Model) ViewDown() []string {
m.Y = min(len(m.lines)-m.Height, m.Y+m.Height) if m.AtBottom() {
return nil
}
m.YOffset = min(
m.YOffset+m.Height, // target
len(m.lines)-m.Height, // fallback
)
return m.visibleLines()
} }
// ViewUp moves the view up by one height of the viewport. Basically, "page up". // ViewUp moves the view up by one height of the viewport. Basically, "page up".
func (m *Model) ViewUp() { func (m *Model) ViewUp() []string {
m.Y = max(0, m.Y-m.Height) if m.AtTop() {
} return nil
}
// HalfViewUp moves the view up by half the height of the viewport. m.YOffset = max(
func (m *Model) HalfViewUp() { m.YOffset-m.Height, // target
m.Y = max(0, m.Y-m.Height/2) 0, // fallback
)
return m.visibleLines()
} }
// HalfViewDown moves the view down by half the height of the viewport. // HalfViewDown moves the view down by half the height of the viewport.
func (m *Model) HalfViewDown() { func (m *Model) HalfViewDown() (lines []string) {
m.Y = min(len(m.lines)-m.Height, m.Y+m.Height/2) if m.AtBottom() {
return nil
}
m.YOffset = min(
m.YOffset+m.Height/2, // target
len(m.lines)-m.Height, // fallback
)
if len(m.lines) > 0 {
top := max(m.YOffset+m.Height/2, 0)
bottom := min(m.YOffset+m.Height, len(m.lines)-1)
lines = m.lines[top:bottom]
}
return lines
}
// HalfViewUp moves the view up by half the height of the viewport.
func (m *Model) HalfViewUp() (lines []string) {
if m.AtTop() {
return nil
}
m.YOffset = max(
m.YOffset-m.Height/2, // target
0, // fallback
)
if len(m.lines) > 0 {
top := max(m.YOffset, 0)
bottom := min(m.YOffset+m.Height/2, len(m.lines)-1)
lines = m.lines[top:bottom]
}
return lines
} }
// LineDown moves the view up by the given number of lines. // LineDown moves the view up by the given number of lines.
func (m *Model) LineDown(n int) { func (m *Model) LineDown(n int) (lines []string) {
m.Y = min(len(m.lines)-m.Height, m.Y+n) if m.AtBottom() || n == 0 {
return nil
}
m.YOffset = min(
m.YOffset+n, // target
len(m.lines)-m.Height, // fallback
)
if len(m.lines) > 0 {
top := max(0, m.YOffset+m.Height-n)
bottom := min(len(m.lines)-1, m.YOffset+m.Height)
lines = m.lines[top:bottom]
}
return lines
} }
// LineDown moves the view down by the given number of lines. // LineUp moves the view down by the given number of lines. Returns the new
func (m *Model) LineUp(n int) { // lines to show.
m.Y = max(0, m.Y-n) func (m *Model) LineUp(n int) (lines []string) {
if m.AtTop() || n == 0 {
return nil
}
m.YOffset = max(m.YOffset-n, 0)
if len(m.lines) > 0 {
top := max(0, m.YOffset)
bottom := min(len(m.lines)-1, m.YOffset+n)
lines = m.lines[top:bottom]
}
return lines
}
// COMMANDS
// Sync tells the renderer where the viewport will be located and requests
// a render of the current state of the viewport. It should be called for the
// first render and after a window resize.
//
// For high performance rendering only.
func Sync(m Model) tea.Cmd {
if len(m.lines) == 0 {
return nil
}
top := max(m.YOffset, 0)
bottom := min(m.YOffset+m.Height, len(m.lines)-1)
return tea.SyncScrollArea(
m.lines[top:bottom],
m.YPosition,
m.YPosition+m.Height,
)
}
// ViewDown is a high performance command that moves the viewport up by one
// viewport height. Use Model.ViewDown to get the lines that should be
// rendered. For example:
//
// lines := model.ViewDown(1)
// cmd := ViewDown(m, lines)
//
func ViewDown(m Model, lines []string) tea.Cmd {
if len(lines) == 0 {
return nil
}
return tea.ScrollDown(lines, m.YPosition, m.YPosition+m.Height)
}
// ViewUp is a high performance command the moves the viewport down by one
// viewport height. Use Model.ViewDown to get the lines that should be
// rendered.
func ViewUp(m Model, lines []string) tea.Cmd {
if len(lines) == 0 {
return nil
}
return tea.ScrollUp(lines, m.YPosition, m.YPosition+m.Height)
}
// HalfViewDown is a high performance command the moves the viewport down by
// half of the height of the viewport. Use Model.HalfViewDown to get the lines
// that should be rendered.
func HalfViewDown(m Model, lines []string) tea.Cmd {
if len(lines) == 0 {
return nil
}
return tea.ScrollDown(lines, m.YPosition, m.YPosition+m.Height)
}
// HalfViewUp is a high performance command the moves the viewport up by
// half of the height of the viewport. Use Model.HalfViewUp to get the lines
// that should be rendered.
func HalfViewUp(m Model, lines []string) tea.Cmd {
if len(lines) == 0 {
return nil
}
return tea.ScrollUp(lines, m.YPosition, m.YPosition+m.Height)
}
// LineDown is a high performance command the moves the viewport down by
// a given number of lines. Use Model.LineDown to get the lines that should be
// rendered.
func LineDown(m Model, lines []string) tea.Cmd {
if len(lines) == 0 {
return nil
}
return tea.ScrollDown(lines, m.YPosition, m.YPosition+m.Height)
}
// LineDown is a high performance command the moves the viewport up by a given
// number of lines. Use Model.LineDown to get the lines that should be
// rendered.
func LineUp(m Model, lines []string) tea.Cmd {
if len(lines) == 0 {
return nil
}
return tea.ScrollUp(lines, m.YPosition, m.YPosition+m.Height)
} }
// UPDATE // UPDATE
// Update runs the update loop with default keybindings. To define your own // Update runs the update loop with default keybindings similar to popular
// keybindings use the methods on Model. // pagers. To define your own keybindings use the methods on Model (i.e.
// Model.LineDown()) and define your own update function.
func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
@@ -88,60 +298,87 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
case " ": // spacebar case " ": // spacebar
fallthrough fallthrough
case "f": case "f":
m.ViewDown() lines := m.ViewDown()
return m, nil if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
// Up one page // Up one page
case "pgup": case "pgup":
fallthrough fallthrough
case "b": case "b":
m.ViewUp() lines := m.ViewUp()
return m, nil if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
// Down half page // Down half page
case "d": case "d":
m.HalfViewDown() lines := m.HalfViewDown()
return m, nil if m.HighPerformanceRendering {
cmd = HalfViewDown(m, lines)
}
// Up half page // Up half page
case "u": case "u":
m.HalfViewUp() lines := m.HalfViewUp()
return m, nil if m.HighPerformanceRendering {
cmd = HalfViewUp(m, lines)
}
// Down one line // Down one line
case "down": case "down":
fallthrough fallthrough
case "j": case "j":
m.LineDown(1) lines := m.LineDown(1)
return m, nil if m.HighPerformanceRendering {
cmd = LineDown(m, lines)
}
// Up one line // Up one line
case "up": case "up":
fallthrough fallthrough
case "k": case "k":
m.LineUp(1) lines := m.LineUp(1)
return m, nil if m.HighPerformanceRendering {
cmd = LineUp(m, lines)
}
}
case tea.MouseMsg:
switch msg.Button {
case tea.MouseWheelUp:
lines := m.LineUp(3)
if m.HighPerformanceRendering {
cmd = LineUp(m, lines)
}
case tea.MouseWheelDown:
lines := m.LineDown(3)
if m.HighPerformanceRendering {
cmd = LineDown(m, lines)
}
} }
} }
return m, nil return m, cmd
} }
// VIEW // VIEW
// View renders the viewport into a string. // View renders the viewport into a string.
func View(m Model) string { func View(m Model) string {
if m.Err != nil {
return m.Err.Error() if m.HighPerformanceRendering {
// Just send newlines since we're doing to be rendering the actual
// content seprately. We still need send something that equals the
// height of this view so that the Bubble Tea standard renderer can
// position anything below this view properly.
return strings.Repeat("\n", m.Height-1)
} }
var lines []string lines := m.visibleLines()
if len(m.lines) > 0 {
top := max(0, m.Y)
bottom := min(len(m.lines), m.Y+m.Height)
lines = m.lines[top:bottom]
}
// Fill empty space with newlines // Fill empty space with newlines
extraLines := "" extraLines := ""
@@ -167,3 +404,7 @@ func max(a, b int) int {
} }
return b return b
} }
func clamp(val, low, high int) int {
return max(low, min(high, val))
}