43 Commits

Author SHA1 Message Date
Christian Rocha
f11ca377f4 Add clipboard paste support to textarea 2020-07-23 11:53:58 -04:00
Christian Rocha
10022c964c Fix slice out of bounds errors that could happen when deleting text 2020-07-21 18:22:18 -04:00
Christian Rocha
5357dd61bd Fix bug where scroll wheel could create duplicate lines on top and bottom 2020-07-20 17:46:15 -04:00
Christian Rocha
5a26cb0d8e Use pgup/pgdown as default paginator keystrokes 2020-07-20 12:13:53 -04:00
Christian Rocha
88469a499e Tidy up the key case switch in viewport 2020-07-20 11:56:35 -04:00
Christian Rocha
b130d96434 Add methods for jumping to the tom and bottom of the viewport 2020-07-17 19:22:08 -04:00
Christian Rocha
185a19e56f Fix cases where pager would add blank lines to bottom of document 2020-07-17 18:38:38 -04:00
Christian Rocha
dbb1d93970 Remove redundant viewport commands 2020-07-17 18:22:51 -04:00
Christian Rocha
2f909886c1 Be more consistent with max() argument order 2020-07-17 18:13:48 -04:00
Christian Rocha
5720cfb35a Correct scroll percentage reporting 2020-07-17 18:13:05 -04:00
Christian Rocha
3c07b97d37 Correct typo, add potential TODO 2020-07-17 17:02:37 -04:00
Christian Rocha
9b3e5476c3 Update README a little. 2020-07-17 14:03:02 -04:00
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
7 changed files with 542 additions and 195 deletions

View File

@@ -1,8 +1,33 @@
# Tea Party
# Bubbles
Components for [Tea](https://github.com/charmbraclet/tea)
Some components for [Bubble Tea](https://github.com/charmbraclet/bubbletea):
⚠️ This project is a pre-release! Check back later.
* Spinner
* Text Input
* Paginator
* Viewport
## Spinner
A spinner, useful for indicating that some kind of invisible operation is
happening. There are a couple default ones, but you can also pass your own
”frames.”
## Text Input
A text input field, akin to an `<input type="text">` in HTML.
## Paginator
A component for handling pagination logic and optionally drawing pagination UI.
## Viewport
A viewport for vertically scrolling content which optionally includes standard
pager keybindings and mouse wheel support. A high performance mode is available
for applications which make use of the alterate screen buffer. This is
generally only necessary when dealing with content with a very large amount of
ANSI escape sequences.
## License

12
go.mod
View File

@@ -3,8 +3,12 @@ module github.com/charmbracelet/bubbles
go 1.13
require (
github.com/charmbracelet/bubbletea v0.8.0
github.com/muesli/termenv v0.5.3-0.20200526053627-d728968dcf83
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 // indirect
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 // indirect
github.com/atotto/clipboard v0.1.2 // indirect
github.com/charmbracelet/bubbletea v0.9.1-0.20200713153904-2f53eeb54b90
github.com/mattn/go-runewidth v0.0.9
github.com/muesli/termenv v0.5.3-0.20200625163851-04b5c30e4c04
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

22
go.sum
View File

@@ -1,26 +1,28 @@
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/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY=
github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
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/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/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
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/muesli/termenv v0.5.3-0.20200526053627-d728968dcf83/go.mod h1:O1/I6sw+6KcrgAmcs6uiUVr7Lui+DNVbHTzt9Lm/PlI=
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/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/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-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/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/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-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-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 h1:OjiUf46hAmXblsZdnoSXsEUSKU8r1UEzcL5RVZ4gO9Y=
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4 h1:5/PjkGUjvEU5Gl6BxmvKRPpqo2uNMv4rcHBMwzk/st8=
golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae h1:Ih9Yo4hSPImZOpfGuA4bR/ORKTAbhZo2AbWNRCnevdo=
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=

View File

@@ -21,17 +21,18 @@ const (
// Model is the Tea model for this user interface.
type Model struct {
Type Type
Page int
PerPage int
TotalPages int
ActiveDot string
InactiveDot string
ArabicFormat string
UseLeftRightKeys bool
UseUpDownKeys bool
UseHLKeys bool
UseJKKeys bool
Type Type
Page int
PerPage int
TotalPages int
ActiveDot string
InactiveDot string
ArabicFormat string
UsePgUpPgDownKeys bool
UseLeftRightKeys bool
UseUpDownKeys bool
UseHLKeys bool
UseJKKeys bool
}
// SetTotalPages is a helper function for calculatng the total number of pages
@@ -98,17 +99,18 @@ func (m Model) OnLastPage() bool {
// NewModel creates a new model with defaults.
func NewModel() Model {
return Model{
Type: Arabic,
Page: 0,
PerPage: 1,
TotalPages: 1,
ActiveDot: "•",
InactiveDot: "○",
ArabicFormat: "%d/%d",
UseLeftRightKeys: true,
UseUpDownKeys: false,
UseHLKeys: true,
UseJKKeys: false,
Type: Arabic,
Page: 0,
PerPage: 1,
TotalPages: 1,
ActiveDot: "•",
InactiveDot: "○",
ArabicFormat: "%d/%d",
UsePgUpPgDownKeys: true,
UseLeftRightKeys: true,
UseUpDownKeys: false,
UseHLKeys: true,
UseJKKeys: false,
}
}
@@ -116,6 +118,14 @@ func NewModel() Model {
func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if m.UsePgUpPgDownKeys {
switch msg.String() {
case "pgup":
m.PrevPage()
case "pgdown":
m.NextPage()
}
}
if m.UseLeftRightKeys {
switch msg.String() {
case "left":

View File

@@ -7,25 +7,17 @@ import (
"github.com/muesli/termenv"
)
const (
defaultFPS = time.Second / 10
)
// Spinner is a set of frames used in animating the spinner.
type Spinner = int
// Available types of spinners
const (
Line Spinner = iota
Dot
)
const (
defaultFPS = 9
)
type Spinner = []string
var (
// Spinner frames
spinners = map[Spinner][]string{
Line: {"|", "/", "-", "\\"},
Dot: {"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "},
}
// Some spinners to choose from. You could also make your own.
Line = Spinner([]string{"|", "/", "-", "\\"})
Dot = Spinner([]string{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "", "", "⣯ ", "⣷ "})
color = termenv.ColorProfile().Color
)
@@ -35,10 +27,10 @@ var (
type Model struct {
// 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 int
FPS time.Duration
// 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
@@ -52,20 +44,14 @@ type Model struct {
// (per github.com/muesli/termenv).
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
}
// NewModel returns a model with default values.
func NewModel() Model {
return Model{
Type: Line,
FPS: defaultFPS,
Frames: Line,
FPS: defaultFPS,
}
}
@@ -76,43 +62,38 @@ type TickMsg struct{}
// every time it's called, regardless the message passed, so be sure the logic
// is setup so as not to call this Update needlessly.
func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
m.frame++
if m.frame >= len(spinners[m.Type]) {
m.frame = 0
}
if m.CustomMsgFunc != nil {
if _, ok := msg.(TickMsg); ok {
m.frame++
if m.frame >= len(m.Frames) {
m.frame = 0
}
return m, Tick(m)
}
return m, Tick(m)
return m, nil
}
// View renders the model's view.
func View(model Model) string {
s := spinners[model.Type]
if model.frame >= len(s) {
return "[error]"
if model.frame >= len(model.Frames) {
return "error"
}
str := s[model.frame]
frame := model.Frames[model.frame]
if model.ForegroundColor != "" || model.BackgroundColor != "" {
return termenv.
String(str).
String(frame).
Foreground(color(model.ForegroundColor)).
Background(color(model.BackgroundColor)).
String()
}
return str
return frame
}
// Tick is the command used to advance the spinner one frame.
func Tick(model Model) tea.Cmd {
return func() tea.Msg {
time.Sleep(time.Second / time.Duration(model.FPS))
if model.CustomMsgFunc != nil {
return model.CustomMsgFunc()
}
func Tick(m Model) tea.Cmd {
return tea.Tick(m.FPS, func(time.Time) tea.Msg {
return TickMsg{}
}
})
}

View File

@@ -5,7 +5,9 @@ import (
"time"
"unicode"
"github.com/atotto/clipboard"
tea "github.com/charmbracelet/bubbletea"
rw "github.com/mattn/go-runewidth"
"github.com/muesli/termenv"
)
@@ -45,7 +47,7 @@ type Model struct {
Width int
// Underlying text value
value string
value []rune
// Focus indicates whether user input focus should be on this input
// component. When false, don't blink and ignore keyboard input.
@@ -59,15 +61,17 @@ type Model struct {
// Used to emulate a viewport when width is set and the content is
// overflowing
offset int
offset int
offsetRight int
}
// SetValue sets the value of the text input.
func (m *Model) SetValue(s string) {
if m.CharLimit > 0 && len(s) > m.CharLimit {
m.value = s[:m.CharLimit]
runes := []rune(s)
if m.CharLimit > 0 && len(runes) > m.CharLimit {
m.value = runes[:m.CharLimit]
} else {
m.value = s
m.value = runes
}
if m.pos > len(m.value) {
m.pos = len(m.value)
@@ -77,13 +81,13 @@ func (m *Model) SetValue(s string) {
// Value returns the value of the text input.
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
// of bounds the cursor will be moved to the start or end accordingly.
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()
}
@@ -118,23 +122,98 @@ func (m *Model) Blur() {
// Reset sets the input to its default state with no input.
func (m *Model) Reset() {
m.value = ""
m.offset = 0
m.value = nil
m.pos = 0
m.blink = false
}
// Paste pastes the contents of the clipboard into the text area (if supported).
func (m *Model) Paste() {
pasteString, err := clipboard.ReadAll()
if err != nil {
m.Err = err
}
paste := []rune(pasteString)
availSpace := m.CharLimit - len(m.value)
// If the char limit's been reached cancel
if m.CharLimit > 0 && availSpace <= 0 {
return
}
// If there's not enough space to paste the whole thing cut the pasted
// runes down so they'll fit
if availSpace < len(paste) {
paste = paste[:len(paste)-availSpace]
}
// Stuff before and after the cursor
head := m.value[:m.pos]
tailSrc := m.value[m.pos:]
tail := make([]rune, len(tailSrc))
copy(tail, tailSrc)
// Insert pasted runes
for _, r := range paste {
head = append(head, r)
availSpace--
m.pos++
if m.CharLimit > 0 && availSpace <= 0 {
break
}
}
// Put it all back together
m.value = append(head, tail...)
}
// If a max width is defined, perform some logic to treat the visible area
// as a horizontally scrolling viewport.
func (m *Model) handleOverflow() {
if m.Width > 0 {
overflow := max(0, len(m.value)-m.Width)
if m.Width <= 0 || rw.StringWidth(string(m.value)) <= m.Width {
m.offset = 0
m.offsetRight = len(m.value)
return
}
if overflow > 0 && m.pos < m.offset {
m.offset = max(0, min(len(m.value), m.pos))
} else if overflow > 0 && m.pos >= m.offset+m.Width {
m.offset = max(0, m.pos-m.Width)
// Correct right offset if we've deleted characters
m.offsetRight = min(m.offsetRight, len(m.value))
if m.pos < m.offset {
m.offset = m.pos
w := 0
i := 0
runes := m.value[m.offset:]
for i < len(runes) && w <= m.Width {
w += rw.RuneWidth(runes[i])
if w <= m.Width+1 {
i++
}
}
m.offsetRight = m.offset + i
} else if m.pos >= m.offsetRight {
m.offsetRight = m.pos
w := 0
runes := m.value[:m.offsetRight]
i := len(runes) - 1
for i > 0 && w < m.Width {
w += rw.RuneWidth(runes[i])
if w <= m.Width {
i--
}
}
m.offset = m.offsetRight - (len(runes) - 1 - i)
}
}
@@ -166,7 +245,7 @@ func (m *Model) wordLeft() {
i := m.pos - 1
for i >= 0 {
if unicode.IsSpace(rune(m.value[i])) {
if unicode.IsSpace(m.value[i]) {
m.pos--
i--
} else {
@@ -175,7 +254,7 @@ func (m *Model) wordLeft() {
}
for i >= 0 {
if !unicode.IsSpace(rune(m.value[i])) {
if !unicode.IsSpace(m.value[i]) {
m.pos--
i--
} else {
@@ -224,7 +303,7 @@ func NewModel() Model {
CursorColor: "",
CharLimit: 0,
value: "",
value: nil,
focus: false,
blink: true,
pos: 0,
@@ -245,8 +324,10 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
fallthrough
case tea.KeyDelete:
if len(m.value) > 0 {
m.value = m.value[:m.pos-1] + m.value[m.pos:]
m.pos--
m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...)
if m.pos > 0 {
m.pos--
}
}
case tea.KeyLeft:
if msg.Alt { // alt+left arrow, back one word
@@ -272,7 +353,7 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
m.CursorStart()
case tea.KeyCtrlD: // ^D, delete char under cursor
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
m.CursorEnd()
@@ -283,6 +364,8 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
m.value = m.value[m.pos:]
m.pos = 0
m.offset = 0
case tea.KeyCtrlV: // ^V paste
m.Paste()
case tea.KeyRune: // input a regular character
if msg.Alt {
@@ -298,7 +381,7 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
// Input a regular character
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++
}
}
@@ -324,34 +407,28 @@ func View(model tea.Model) string {
}
// Placeholder text
if m.value == "" && m.Placeholder != "" {
if len(m.value) == 0 && m.Placeholder != "" {
return placeholderView(m)
}
left := m.offset
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
value := m.value[m.offset:m.offsetRight]
pos := max(0, m.pos-m.offset)
v := m.colorText(value[:pos])
v := m.colorText(string(value[:pos]))
if pos < len(value) {
v += cursorView(string(value[pos]), m) // cursor and text under it
v += m.colorText(value[pos+1:]) // text after cursor
v += cursorView(string(value[pos]), m) // cursor and text under it
v += m.colorText(string(value[pos+1:])) // text after cursor
} else {
v += cursorView(" ", m)
}
// If a max width and background color were set fill the empty spaces with
// the background color.
if m.Width > 0 && len(m.BackgroundColor) > 0 && len(value) <= m.Width {
padding := m.Width - len(value)
if len(value)+padding <= m.Width && pos < len(value) {
valWidth := rw.StringWidth(string(value))
if m.Width > 0 && len(m.BackgroundColor) > 0 && valWidth <= m.Width {
padding := max(0, m.Width-valWidth)
if valWidth+padding <= m.Width && pos < len(value) {
padding++
}
v += strings.Repeat(
@@ -412,6 +489,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 {
if a < b {
return a

View File

@@ -1,40 +1,43 @@
package viewport
import (
"math"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
const (
spacebar = " "
)
// MODEL
type Model struct {
Err error
Width 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
}
// 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.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.
// TODO: do we really need this?
func NewModel(width, height int) Model {
return Model{
Width: width,
@@ -42,106 +45,343 @@ 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 or past the very bottom
// position.
func (m Model) AtBottom() bool {
return m.YOffset >= len(m.lines)-1-m.Height
}
// PastBottom returns whether or not the viewport is scrolled beyond the last
// line. This can happen when adjusting the viewport height.
func (m Model) PastBottom() bool {
return m.YOffset > len(m.lines)-1-m.Height
}
// 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) - 1)
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.
// Basically, "page down".
func (m *Model) ViewDown() {
m.Y = min(len(m.lines)-m.Height, m.Y+m.Height)
func (m *Model) ViewDown() []string {
if m.AtBottom() {
return nil
}
m.YOffset = min(
m.YOffset+m.Height, // target
len(m.lines)-1-m.Height, // fallback
)
return m.visibleLines()
}
// ViewUp moves the view up by one height of the viewport. Basically, "page up".
func (m *Model) ViewUp() {
m.Y = max(0, m.Y-m.Height)
}
func (m *Model) ViewUp() []string {
if m.AtTop() {
return nil
}
// HalfViewUp moves the view up by half the height of the viewport.
func (m *Model) HalfViewUp() {
m.Y = max(0, m.Y-m.Height/2)
m.YOffset = max(
m.YOffset-m.Height, // target
0, // fallback
)
return m.visibleLines()
}
// HalfViewDown moves the view down by half the height of the viewport.
func (m *Model) HalfViewDown() {
m.Y = min(len(m.lines)-m.Height, m.Y+m.Height/2)
func (m *Model) HalfViewDown() (lines []string) {
if m.AtBottom() {
return nil
}
m.YOffset = min(
m.YOffset+m.Height/2, // target
len(m.lines)-1-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
}
// LineDown moves the view up by the given number of lines.
func (m *Model) LineDown(n int) {
m.Y = min(len(m.lines)-m.Height, m.Y+n)
// 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 down by the given number of lines.
func (m *Model) LineUp(n int) {
m.Y = max(0, m.Y-n)
func (m *Model) LineDown(n int) (lines []string) {
if m.AtBottom() || n == 0 {
return nil
}
// Make sure the number of lines by which we're going to scroll isn't
// greater than the number of lines we actually have left before we reach
// the bottom.
maxDelta := (len(m.lines) - 1) - (m.YOffset + m.Height) // number of lines - viewport bottom edge
n = min(n, maxDelta)
m.YOffset = min(
m.YOffset+n, // target
len(m.lines)-1-m.Height, // fallback
)
if len(m.lines) > 0 {
top := max(m.YOffset+m.Height-n, 0)
bottom := min(m.YOffset+m.Height, len(m.lines)-1)
lines = m.lines[top:bottom]
}
return lines
}
// LineUp moves the view down by the given number of lines. Returns the new
// lines to show.
func (m *Model) LineUp(n int) (lines []string) {
if m.AtTop() || n == 0 {
return nil
}
// Make sure the number of lines by which we're going to scroll isn't
// greater than the number of lines we are from the top.
n = min(n, m.YOffset)
m.YOffset = max(m.YOffset-n, 0)
if len(m.lines) > 0 {
top := max(0, m.YOffset)
bottom := min(m.YOffset+n, len(m.lines)-1)
lines = m.lines[top:bottom]
}
return lines
}
// GotoTop sets the viewport to the top position.
func (m *Model) GotoTop() (lines []string) {
if m.AtTop() {
return nil
}
m.YOffset = 0
if len(m.lines) > 0 {
top := m.YOffset
bottom := min(m.YOffset+m.Height, len(m.lines)-1)
lines = m.lines[top:bottom]
}
return lines
}
// GotoTop sets the viewport to the bottom position.
func (m *Model) GotoBottom() (lines []string) {
m.YOffset = max(len(m.lines)-1-m.Height, 0)
if len(m.lines) > 0 {
top := m.YOffset
bottom := max(len(m.lines)-1, 0)
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
}
// TODO: we should probably use m.visibleLines() rather than these two
// expressions.
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 a given
// numer of lines. 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 a given
// number of lines 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)
}
// UPDATE
// Update runs the update loop with default keybindings. To define your own
// keybindings use the methods on Model.
// Update runs the update loop with default keybindings similar to popular
// 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) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
// Down one page
case "pgdown":
fallthrough
case " ": // spacebar
fallthrough
case "f":
m.ViewDown()
return m, nil
case "pgdown", spacebar, "f":
lines := m.ViewDown()
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
// Up one page
case "pgup":
fallthrough
case "b":
m.ViewUp()
return m, nil
case "pgup", "b":
lines := m.ViewUp()
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
// Down half page
case "d":
m.HalfViewDown()
return m, nil
lines := m.HalfViewDown()
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
// Up half page
case "u":
m.HalfViewUp()
return m, nil
lines := m.HalfViewUp()
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
// Down one line
case "down":
fallthrough
case "j":
m.LineDown(1)
return m, nil
case "down", "j":
lines := m.LineDown(1)
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
// Up one line
case "up":
fallthrough
case "k":
m.LineUp(1)
return m, nil
case "up", "k":
lines := m.LineUp(1)
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
}
case tea.MouseMsg:
switch msg.Button {
case tea.MouseWheelUp:
lines := m.LineUp(3)
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
case tea.MouseWheelDown:
lines := m.LineDown(3)
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
}
}
return m, nil
return m, cmd
}
// VIEW
// View renders the viewport into a 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
if len(m.lines) > 0 {
top := max(0, m.Y)
bottom := min(len(m.lines), m.Y+m.Height)
lines = m.lines[top:bottom]
}
lines := m.visibleLines()
// Fill empty space with newlines
extraLines := ""
@@ -167,3 +407,7 @@ func max(a, b int) int {
}
return b
}
func clamp(val, low, high int) int {
return max(low, min(high, val))
}