12 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
6 changed files with 193 additions and 106 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 ## License

1
go.mod
View File

@@ -3,6 +3,7 @@ module github.com/charmbracelet/bubbles
go 1.13 go 1.13
require ( require (
github.com/atotto/clipboard v0.1.2 // indirect
github.com/charmbracelet/bubbletea v0.9.1-0.20200713153904-2f53eeb54b90 github.com/charmbracelet/bubbletea v0.9.1-0.20200713153904-2f53eeb54b90
github.com/mattn/go-runewidth v0.0.9 github.com/mattn/go-runewidth v0.0.9
github.com/muesli/termenv v0.5.3-0.20200625163851-04b5c30e4c04 github.com/muesli/termenv v0.5.3-0.20200625163851-04b5c30e4c04

2
go.sum
View File

@@ -1,3 +1,5 @@
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 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=

View File

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

View File

@@ -5,6 +5,7 @@ import (
"time" "time"
"unicode" "unicode"
"github.com/atotto/clipboard"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
rw "github.com/mattn/go-runewidth" rw "github.com/mattn/go-runewidth"
"github.com/muesli/termenv" "github.com/muesli/termenv"
@@ -126,6 +127,47 @@ func (m *Model) Reset() {
m.blink = false 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 // 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() {
@@ -282,8 +324,10 @@ 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 = append(m.value[:m.pos-1], m.value[m.pos:]...) m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...)
m.pos-- if m.pos > 0 {
m.pos--
}
} }
case tea.KeyLeft: case tea.KeyLeft:
if msg.Alt { // alt+left arrow, back one word if msg.Alt { // alt+left arrow, back one word
@@ -320,6 +364,8 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
m.value = m.value[m.pos:] m.value = m.value[m.pos:]
m.pos = 0 m.pos = 0
m.offset = 0 m.offset = 0
case tea.KeyCtrlV: // ^V paste
m.Paste()
case tea.KeyRune: // input a regular character case tea.KeyRune: // input a regular character
if msg.Alt { if msg.Alt {
@@ -366,7 +412,7 @@ func View(model tea.Model) string {
} }
value := m.value[m.offset:m.offsetRight] value := m.value[m.offset:m.offsetRight]
pos := m.pos - m.offset pos := max(0, m.pos-m.offset)
v := m.colorText(string(value[:pos])) v := m.colorText(string(value[:pos]))

View File

@@ -7,6 +7,10 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
const (
spacebar = " "
)
// MODEL // MODEL
type Model struct { type Model struct {
@@ -53,9 +57,16 @@ func (m Model) AtTop() bool {
return m.YOffset <= 0 return m.YOffset <= 0
} }
// AtBottom returns whether or not the viewport is at the very botom position. // AtBottom returns whether or not the viewport is at or past the very bottom
// position.
func (m Model) AtBottom() bool { func (m Model) AtBottom() bool {
return m.YOffset >= len(m.lines)-m.Height-1 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. // Scrollpercent returns the amount scrolled as a float between 0 and 1.
@@ -65,7 +76,7 @@ func (m Model) ScrollPercent() float64 {
} }
y := float64(m.YOffset) y := float64(m.YOffset)
h := float64(m.Height) h := float64(m.Height)
t := float64(len(m.lines)) t := float64(len(m.lines) - 1)
v := y / (t - h) v := y / (t - h)
return math.Max(0.0, math.Min(1.0, v)) return math.Max(0.0, math.Min(1.0, v))
} }
@@ -95,8 +106,8 @@ func (m *Model) ViewDown() []string {
} }
m.YOffset = min( m.YOffset = min(
m.YOffset+m.Height, // target m.YOffset+m.Height, // target
len(m.lines)-m.Height, // fallback len(m.lines)-1-m.Height, // fallback
) )
return m.visibleLines() return m.visibleLines()
@@ -123,8 +134,8 @@ func (m *Model) HalfViewDown() (lines []string) {
} }
m.YOffset = min( m.YOffset = min(
m.YOffset+m.Height/2, // target m.YOffset+m.Height/2, // target
len(m.lines)-m.Height, // fallback len(m.lines)-1-m.Height, // fallback
) )
if len(m.lines) > 0 { if len(m.lines) > 0 {
@@ -156,20 +167,26 @@ func (m *Model) HalfViewUp() (lines []string) {
return lines return lines
} }
// LineDown moves the view up by the given number of lines. // LineDown moves the view down by the given number of lines.
func (m *Model) LineDown(n int) (lines []string) { func (m *Model) LineDown(n int) (lines []string) {
if m.AtBottom() || n == 0 { if m.AtBottom() || n == 0 {
return nil 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 = min(
m.YOffset+n, // target m.YOffset+n, // target
len(m.lines)-m.Height, // fallback len(m.lines)-1-m.Height, // fallback
) )
if len(m.lines) > 0 { if len(m.lines) > 0 {
top := max(0, m.YOffset+m.Height-n) top := max(m.YOffset+m.Height-n, 0)
bottom := min(len(m.lines)-1, m.YOffset+m.Height) bottom := min(m.YOffset+m.Height, len(m.lines)-1)
lines = m.lines[top:bottom] lines = m.lines[top:bottom]
} }
@@ -183,11 +200,45 @@ func (m *Model) LineUp(n int) (lines []string) {
return nil 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) m.YOffset = max(m.YOffset-n, 0)
if len(m.lines) > 0 { if len(m.lines) > 0 {
top := max(0, m.YOffset) top := max(0, m.YOffset)
bottom := min(len(m.lines)-1, m.YOffset+n) 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] lines = m.lines[top:bottom]
} }
@@ -206,6 +257,8 @@ func Sync(m Model) tea.Cmd {
return nil return nil
} }
// TODO: we should probably use m.visibleLines() rather than these two
// expressions.
top := max(m.YOffset, 0) top := max(m.YOffset, 0)
bottom := min(m.YOffset+m.Height, len(m.lines)-1) bottom := min(m.YOffset+m.Height, len(m.lines)-1)
@@ -216,9 +269,9 @@ func Sync(m Model) tea.Cmd {
) )
} }
// ViewDown is a high performance command that moves the viewport up by one // ViewDown is a high performance command that moves the viewport up by a given
// viewport height. Use Model.ViewDown to get the lines that should be // numer of lines. Use Model.ViewDown to get the lines that should be rendered.
// rendered. For example: // For example:
// //
// lines := model.ViewDown(1) // lines := model.ViewDown(1)
// cmd := ViewDown(m, lines) // cmd := ViewDown(m, lines)
@@ -230,8 +283,8 @@ func ViewDown(m Model, lines []string) tea.Cmd {
return tea.ScrollDown(lines, m.YPosition, m.YPosition+m.Height) return tea.ScrollDown(lines, m.YPosition, m.YPosition+m.Height)
} }
// ViewUp is a high performance command the moves the viewport down by one // ViewUp is a high performance command the moves the viewport down by a given
// viewport height. Use Model.ViewDown 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 {
@@ -240,46 +293,6 @@ func ViewUp(m Model, lines []string) tea.Cmd {
return tea.ScrollUp(lines, m.YPosition, m.YPosition+m.Height) 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 similar to popular // Update runs the update loop with default keybindings similar to popular
@@ -293,20 +306,14 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
// Down one page // Down one page
case "pgdown": case "pgdown", spacebar, "f":
fallthrough
case " ": // spacebar
fallthrough
case "f":
lines := m.ViewDown() lines := m.ViewDown()
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = ViewDown(m, lines) cmd = ViewDown(m, lines)
} }
// Up one page // Up one page
case "pgup": case "pgup", "b":
fallthrough
case "b":
lines := m.ViewUp() lines := m.ViewUp()
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = ViewUp(m, lines) cmd = ViewUp(m, lines)
@@ -316,32 +323,28 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
case "d": case "d":
lines := m.HalfViewDown() lines := m.HalfViewDown()
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = HalfViewDown(m, lines) cmd = ViewDown(m, lines)
} }
// Up half page // Up half page
case "u": case "u":
lines := m.HalfViewUp() lines := m.HalfViewUp()
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = HalfViewUp(m, lines) cmd = ViewUp(m, lines)
} }
// Down one line // Down one line
case "down": case "down", "j":
fallthrough
case "j":
lines := m.LineDown(1) lines := m.LineDown(1)
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = LineDown(m, lines) cmd = ViewDown(m, lines)
} }
// Up one line // Up one line
case "up": case "up", "k":
fallthrough
case "k":
lines := m.LineUp(1) lines := m.LineUp(1)
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = LineUp(m, lines) cmd = ViewUp(m, lines)
} }
} }
@@ -351,13 +354,13 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
case tea.MouseWheelUp: case tea.MouseWheelUp:
lines := m.LineUp(3) lines := m.LineUp(3)
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = LineUp(m, lines) cmd = ViewUp(m, lines)
} }
case tea.MouseWheelDown: case tea.MouseWheelDown:
lines := m.LineDown(3) lines := m.LineDown(3)
if m.HighPerformanceRendering { if m.HighPerformanceRendering {
cmd = LineDown(m, lines) cmd = ViewDown(m, lines)
} }
} }
} }