mirror of
https://github.com/Maks1mS/bubbles.git
synced 2025-10-17 16:15:58 +03:00
Compare commits
1 Commits
go-bump-11
...
filter-tex
Author | SHA1 | Date | |
---|---|---|---|
|
ad8d2fac86 |
14
README.md
14
README.md
@@ -41,18 +41,6 @@ the common, and many customization options.
|
||||
* [Example code, one field](https://github.com/charmbracelet/tea/tree/master/examples/textinput/main.go)
|
||||
* [Example code, many fields](https://github.com/charmbracelet/tea/tree/master/examples/textinputs/main.go)
|
||||
|
||||
## Text Area
|
||||
|
||||
<img src="https://stuff.charm.sh/bubbles-examples/textarea.gif" width="400" alt="Text Area Example">
|
||||
|
||||
A text area field, akin to an `<textarea />` in HTML. Allows for input that
|
||||
spans multiple lines. Supports unicode, pasting, vertical scrolling when the
|
||||
value exceeds the width and height of the element, and many customization
|
||||
options.
|
||||
|
||||
* [Example code, chat input](https://github.com/charmbracelet/tea/tree/master/examples/chat/main.go)
|
||||
* [Example code, story time input](https://github.com/charmbracelet/tea/tree/master/examples/textarea/main.go)
|
||||
|
||||
|
||||
## Progress
|
||||
|
||||
@@ -78,7 +66,7 @@ Supports "dot-style" pagination (similar to what you might see on iOS) and
|
||||
numeric page numbering, but you could also just use this component for the
|
||||
logic and visualize pagination however you like.
|
||||
|
||||
* [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/paginator/main.go)
|
||||
* [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/pager/main.go)
|
||||
|
||||
|
||||
## Viewport
|
||||
|
207
cursor/cursor.go
207
cursor/cursor.go
@@ -1,207 +0,0 @@
|
||||
package cursor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
const defaultBlinkSpeed = time.Millisecond * 530
|
||||
|
||||
// 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{}
|
||||
|
||||
// blinkCtx manages cursor blinking.
|
||||
type blinkCtx struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// Mode describes the behavior of the cursor.
|
||||
type Mode int
|
||||
|
||||
// Available cursor modes.
|
||||
const (
|
||||
CursorBlink Mode = iota
|
||||
CursorStatic
|
||||
CursorHide
|
||||
)
|
||||
|
||||
// String returns the cursor mode in a human-readable format. This method is
|
||||
// provisional and for informational purposes only.
|
||||
func (c Mode) String() string {
|
||||
return [...]string{
|
||||
"blink",
|
||||
"static",
|
||||
"hidden",
|
||||
}[c]
|
||||
}
|
||||
|
||||
// Model is the Bubble Tea model for this cursor element.
|
||||
type Model struct {
|
||||
BlinkSpeed time.Duration
|
||||
// Style for styling the cursor block.
|
||||
Style lipgloss.Style
|
||||
// TextStyle is the style used for the cursor when it is hidden (when blinking).
|
||||
// I.e. displaying normal text.
|
||||
TextStyle lipgloss.Style
|
||||
|
||||
// char is the character under the cursor
|
||||
char string
|
||||
// The ID of this Model as it relates to other cursors
|
||||
id int
|
||||
// focus indicates whether the containing input is focused
|
||||
focus bool
|
||||
// Cursor Blink state.
|
||||
Blink bool
|
||||
// Used to manage cursor blink
|
||||
blinkCtx *blinkCtx
|
||||
// The ID of the blink message we're expecting to receive.
|
||||
blinkTag int
|
||||
// cursorMode determines the behavior of the cursor
|
||||
cursorMode Mode
|
||||
}
|
||||
|
||||
// New creates a new model with default settings.
|
||||
func New() Model {
|
||||
return Model{
|
||||
BlinkSpeed: defaultBlinkSpeed,
|
||||
|
||||
Blink: true,
|
||||
cursorMode: CursorBlink,
|
||||
|
||||
blinkCtx: &blinkCtx{
|
||||
ctx: context.Background(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Update updates the cursor.
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case initialBlinkMsg:
|
||||
// We accept all initialBlinkMsgs generated by the Blink command.
|
||||
|
||||
if m.cursorMode != CursorBlink || !m.focus {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
cmd := m.BlinkCmd()
|
||||
return m, cmd
|
||||
|
||||
case BlinkMsg:
|
||||
// We're choosy about whether to accept blinkMsgs so that our cursor
|
||||
// only exactly when it should.
|
||||
|
||||
// Is this model blink-able?
|
||||
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
|
||||
if m.cursorMode == CursorBlink {
|
||||
m.Blink = !m.Blink
|
||||
cmd = m.BlinkCmd()
|
||||
}
|
||||
return m, cmd
|
||||
|
||||
case blinkCanceled: // no-op
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// CursorMode returns the model's cursor mode. For available cursor modes, see
|
||||
// type CursorMode.
|
||||
func (m Model) CursorMode() Mode {
|
||||
return m.cursorMode
|
||||
}
|
||||
|
||||
// SetCursorMode sets the model's cursor mode. This method returns a command.
|
||||
//
|
||||
// For available cursor modes, see type CursorMode.
|
||||
func (m *Model) SetCursorMode(mode Mode) tea.Cmd {
|
||||
m.cursorMode = mode
|
||||
m.Blink = m.cursorMode == CursorHide || !m.focus
|
||||
if mode == CursorBlink {
|
||||
return Blink
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BlinkCmd is an command used to manage cursor blinking.
|
||||
func (m *Model) BlinkCmd() tea.Cmd {
|
||||
if m.cursorMode != CursorBlink {
|
||||
return nil
|
||||
}
|
||||
|
||||
if m.blinkCtx != nil && m.blinkCtx.cancel != nil {
|
||||
m.blinkCtx.cancel()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(m.blinkCtx.ctx, m.BlinkSpeed)
|
||||
m.blinkCtx.cancel = cancel
|
||||
|
||||
m.blinkTag++
|
||||
|
||||
return func() tea.Msg {
|
||||
defer cancel()
|
||||
<-ctx.Done()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return BlinkMsg{id: m.id, tag: m.blinkTag}
|
||||
}
|
||||
return blinkCanceled{}
|
||||
}
|
||||
}
|
||||
|
||||
// Blink is a command used to initialize cursor blinking.
|
||||
func Blink() tea.Msg {
|
||||
return initialBlinkMsg{}
|
||||
}
|
||||
|
||||
// Focus focuses the cursor to allow it to blink if desired.
|
||||
func (m *Model) Focus() tea.Cmd {
|
||||
m.focus = true
|
||||
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 blurs the cursor.
|
||||
func (m *Model) Blur() {
|
||||
m.focus = false
|
||||
m.Blink = true
|
||||
}
|
||||
|
||||
// SetChar sets the character under the cursor.
|
||||
func (m *Model) SetChar(char string) {
|
||||
m.char = char
|
||||
}
|
||||
|
||||
// View displays the cursor.
|
||||
func (m Model) View() string {
|
||||
if m.Blink {
|
||||
return m.TextStyle.Render(m.char)
|
||||
}
|
||||
return m.Style.Inline(true).Reverse(true).Render(m.char)
|
||||
}
|
24
go.mod
24
go.mod
@@ -1,26 +1,18 @@
|
||||
module github.com/charmbracelet/bubbles
|
||||
|
||||
go 1.18
|
||||
go 1.13
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/charmbracelet/bubbletea v0.22.0
|
||||
github.com/charmbracelet/harmonica v0.2.0
|
||||
github.com/charmbracelet/lipgloss v0.5.0
|
||||
github.com/charmbracelet/bubbletea v0.19.3
|
||||
github.com/charmbracelet/glamour v0.5.0
|
||||
github.com/charmbracelet/harmonica v0.1.0
|
||||
github.com/charmbracelet/lipgloss v0.4.0
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||
github.com/mattn/go-runewidth v0.0.13
|
||||
github.com/muesli/reflow v0.3.0
|
||||
github.com/muesli/termenv v0.12.0
|
||||
github.com/muesli/termenv v0.9.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/sahilm/fuzzy v0.1.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/containerd/console v1.0.3 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
|
||||
github.com/muesli/cancelreader v0.2.1 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
|
||||
)
|
||||
|
84
go.sum
84
go.sum
@@ -1,44 +1,82 @@
|
||||
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
|
||||
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/charmbracelet/bubbletea v0.22.0 h1:E1BTNSE3iIrq0G0X6TjGAmrQ32cGCbFDPcIuImikrUc=
|
||||
github.com/charmbracelet/bubbletea v0.22.0/go.mod h1:aoVIwlNlr5wbCB26KhxfrqAn0bMp4YpJcoOelbxApjs=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
|
||||
github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
|
||||
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
|
||||
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/charmbracelet/bubbletea v0.19.3 h1:OKeO/Y13rQQqt4snX+lePB0QrnW80UdrMNolnCcmoAw=
|
||||
github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA=
|
||||
github.com/charmbracelet/glamour v0.5.0 h1:wu15ykPdB7X6chxugG/NNfDUbyyrCLV9XBalj5wdu3g=
|
||||
github.com/charmbracelet/glamour v0.5.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc=
|
||||
github.com/charmbracelet/harmonica v0.1.0 h1:lFKeSd6OAckQ/CEzPVd2mqj+YMEubQ/3FM2IYY3xNm0=
|
||||
github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g=
|
||||
github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM=
|
||||
github.com/containerd/console v1.0.2 h1:Pi6D+aZXM+oUw1czuKgH5IJ+y0jhYcwBJfx5/Ghn9dE=
|
||||
github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
|
||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
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.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA=
|
||||
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
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/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
|
||||
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
|
||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
|
||||
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
|
||||
github.com/muesli/cancelreader v0.2.1 h1:Xzd1B4U5bWQOuSKuN398MyynIGTNT89dxzpEDsalXZs=
|
||||
github.com/muesli/cancelreader v0.2.1/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
|
||||
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
|
||||
github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc=
|
||||
github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A=
|
||||
github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8=
|
||||
github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
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 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
|
||||
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.4 h1:zNWRjYUW32G9KirMXYHQHVNFkXvMI7LpgNW2AgYAoIs=
|
||||
github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
|
||||
github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
|
||||
github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
|
||||
golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q=
|
||||
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
|
||||
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0=
|
||||
golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
@@ -106,7 +106,7 @@ func (b Binding) Help() Help {
|
||||
// keybindings won't be activated and won't show up in help. Keybindings are
|
||||
// enabled by default.
|
||||
func (b Binding) Enabled() bool {
|
||||
return !b.disabled || b.keys == nil
|
||||
return !b.disabled
|
||||
}
|
||||
|
||||
// SetEnabled enables or disables the keybinding.
|
||||
|
48
list/list.go
48
list/list.go
@@ -87,7 +87,7 @@ type Rank struct {
|
||||
// DefaultFilter uses the sahilm/fuzzy to filter through the list.
|
||||
// This is set by default.
|
||||
func DefaultFilter(term string, targets []string) []Rank {
|
||||
var ranks = fuzzy.Find(term, targets)
|
||||
var ranks fuzzy.Matches = fuzzy.Find(term, targets)
|
||||
sort.Stable(ranks)
|
||||
result := make([]Rank, len(ranks))
|
||||
for i, r := range ranks {
|
||||
@@ -129,9 +129,6 @@ type Model struct {
|
||||
showHelp bool
|
||||
filteringEnabled bool
|
||||
|
||||
itemNameSingular string
|
||||
itemNamePlural string
|
||||
|
||||
Title string
|
||||
Styles Styles
|
||||
|
||||
@@ -205,8 +202,6 @@ func New(items []Item, delegate ItemDelegate, width, height int) Model {
|
||||
showStatusBar: true,
|
||||
showPagination: true,
|
||||
showHelp: true,
|
||||
itemNameSingular: "item",
|
||||
itemNamePlural: "items",
|
||||
filteringEnabled: true,
|
||||
KeyMap: DefaultKeyMap(),
|
||||
Filter: DefaultFilter,
|
||||
@@ -291,19 +286,7 @@ func (m Model) ShowStatusBar() bool {
|
||||
return m.showStatusBar
|
||||
}
|
||||
|
||||
// SetStatusBarItemName defines a replacement for the items identifier.
|
||||
// Defaults to item/items.
|
||||
func (m *Model) SetStatusBarItemName(singular, plural string) {
|
||||
m.itemNameSingular = singular
|
||||
m.itemNamePlural = plural
|
||||
}
|
||||
|
||||
// StatusBarItemName returns singular and plural status bar item names.
|
||||
func (m Model) StatusBarItemName() (string, string) {
|
||||
return m.itemNameSingular, m.itemNamePlural
|
||||
}
|
||||
|
||||
// SetShowPagination hides or shoes the paginator. Note that pagination will
|
||||
// ShowingPagination hides or shoes the paginator. Note that pagination will
|
||||
// still be active, it simply won't be displayed.
|
||||
func (m *Model) SetShowPagination(v bool) {
|
||||
m.showPagination = v
|
||||
@@ -534,15 +517,6 @@ func (m Model) SettingFilter() bool {
|
||||
return m.filterState == Filtering
|
||||
}
|
||||
|
||||
// IsFiltered returns whether or not the list is currently filtered.
|
||||
// It's purely a convenience method for the following:
|
||||
//
|
||||
// m.FilterState() == FilterApplied
|
||||
//
|
||||
func (m Model) IsFiltered() bool {
|
||||
return m.filterState == FilterApplied
|
||||
}
|
||||
|
||||
// Width returns the current width setting.
|
||||
func (m Model) Width() int {
|
||||
return m.width
|
||||
@@ -578,7 +552,7 @@ func (m *Model) StopSpinner() {
|
||||
m.showSpinner = false
|
||||
}
|
||||
|
||||
// Helper for disabling the keybindings used for quitting, in case you want to
|
||||
// Helper for disabling the keybindings used for quitting, incase you want to
|
||||
// handle this elsewhere in your application.
|
||||
func (m *Model) DisableQuitKeybindings() {
|
||||
m.disableQuitKeybindings = true
|
||||
@@ -1074,25 +1048,21 @@ func (m Model) statusView() string {
|
||||
totalItems := len(m.items)
|
||||
visibleItems := len(m.VisibleItems())
|
||||
|
||||
var itemName string
|
||||
plural := ""
|
||||
if visibleItems != 1 {
|
||||
itemName = m.itemNamePlural
|
||||
} else {
|
||||
itemName = m.itemNameSingular
|
||||
plural = "s"
|
||||
}
|
||||
|
||||
itemsDisplay := fmt.Sprintf("%d %s", visibleItems, itemName)
|
||||
|
||||
if m.filterState == Filtering {
|
||||
// Filter results
|
||||
if visibleItems == 0 {
|
||||
status = m.Styles.StatusEmpty.Render("Nothing matched")
|
||||
} else {
|
||||
status = itemsDisplay
|
||||
status = fmt.Sprintf("%d item%s", visibleItems, plural)
|
||||
}
|
||||
} else if len(m.items) == 0 {
|
||||
// Not filtering: no items.
|
||||
status = m.Styles.StatusEmpty.Render("No " + m.itemNamePlural)
|
||||
status = m.Styles.StatusEmpty.Render("No items")
|
||||
} else {
|
||||
// Normal
|
||||
filtered := m.FilterState() == FilterApplied
|
||||
@@ -1103,7 +1073,7 @@ func (m Model) statusView() string {
|
||||
status += fmt.Sprintf("“%s” ", f)
|
||||
}
|
||||
|
||||
status += itemsDisplay
|
||||
status += fmt.Sprintf("%d item%s", visibleItems, plural)
|
||||
}
|
||||
|
||||
numFiltered := totalItems - visibleItems
|
||||
@@ -1147,7 +1117,7 @@ func (m Model) populatedView() string {
|
||||
if m.filterState == Filtering {
|
||||
return ""
|
||||
}
|
||||
return m.Styles.NoItems.Render("No " + m.itemNamePlural + " found.")
|
||||
return m.Styles.NoItems.Render("No items found.")
|
||||
}
|
||||
|
||||
if len(items) > 0 {
|
||||
|
@@ -1,74 +0,0 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type item string
|
||||
|
||||
func (i item) FilterValue() string { return "" }
|
||||
|
||||
type itemDelegate struct{}
|
||||
|
||||
func (d itemDelegate) Height() int { return 1 }
|
||||
func (d itemDelegate) Spacing() int { return 0 }
|
||||
func (d itemDelegate) Update(msg tea.Msg, m *Model) tea.Cmd { return nil }
|
||||
func (d itemDelegate) Render(w io.Writer, m Model, index int, listItem Item) {
|
||||
i, ok := listItem.(item)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
str := fmt.Sprintf("%d. %s", index+1, i)
|
||||
fmt.Fprint(w, m.Styles.TitleBar.Render(str))
|
||||
}
|
||||
|
||||
func TestStatusBarItemName(t *testing.T) {
|
||||
list := New([]Item{item("foo"), item("bar")}, itemDelegate{}, 10, 10)
|
||||
expected := "2 items"
|
||||
if !strings.Contains(list.statusView(), expected) {
|
||||
t.Fatalf("Error: expected view to contain %s", expected)
|
||||
}
|
||||
|
||||
list.SetItems([]Item{item("foo")})
|
||||
expected = "1 item"
|
||||
if !strings.Contains(list.statusView(), expected) {
|
||||
t.Fatalf("Error: expected view to contain %s", expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusBarWithoutItems(t *testing.T) {
|
||||
list := New([]Item{}, itemDelegate{}, 10, 10)
|
||||
|
||||
expected := "No items"
|
||||
if !strings.Contains(list.statusView(), expected) {
|
||||
t.Fatalf("Error: expected view to contain %s", expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomStatusBarItemName(t *testing.T) {
|
||||
list := New([]Item{item("foo"), item("bar")}, itemDelegate{}, 10, 10)
|
||||
list.SetStatusBarItemName("connection", "connections")
|
||||
|
||||
expected := "2 connections"
|
||||
if !strings.Contains(list.statusView(), expected) {
|
||||
t.Fatalf("Error: expected view to contain %s", expected)
|
||||
}
|
||||
|
||||
list.SetItems([]Item{item("foo")})
|
||||
expected = "1 connection"
|
||||
if !strings.Contains(list.statusView(), expected) {
|
||||
t.Fatalf("Error: expected view to contain %s", expected)
|
||||
}
|
||||
|
||||
list.SetItems([]Item{})
|
||||
expected = "No connections"
|
||||
if !strings.Contains(list.statusView(), expected) {
|
||||
t.Fatalf("Error: expected view to contain %s", expected)
|
||||
}
|
||||
}
|
82
pager/pager.go
Normal file
82
pager/pager.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package pager
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
/* TODO:
|
||||
- add rendered markdown
|
||||
- show status (similar to paginator?)
|
||||
- add search functionality - similar to neovim
|
||||
*/
|
||||
|
||||
const useHighPerformanceRenderer = false
|
||||
|
||||
type Model struct {
|
||||
content string
|
||||
ready bool
|
||||
viewport viewport.Model
|
||||
errors []error
|
||||
}
|
||||
|
||||
func New(content string) Model {
|
||||
return Model{content: content}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var (
|
||||
cmd tea.Cmd
|
||||
cmds []tea.Cmd
|
||||
)
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
if !m.ready {
|
||||
m.viewport = viewport.New(msg.Width, msg.Height)
|
||||
m.viewport.HighPerformanceRendering = useHighPerformanceRenderer
|
||||
renderedContent, err := m.renderContent(msg.Width)
|
||||
if err != nil {
|
||||
m.errors = append(m.errors, err)
|
||||
}
|
||||
m.viewport.SetContent(renderedContent)
|
||||
m.ready = true
|
||||
} else {
|
||||
m.viewport.Width = msg.Width
|
||||
m.viewport.Height = msg.Height
|
||||
}
|
||||
|
||||
if useHighPerformanceRenderer {
|
||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||
}
|
||||
}
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
return m, tea.Batch(cmds...)
|
||||
// TODO: scrolling
|
||||
// TODO: filtering
|
||||
}
|
||||
|
||||
func (m Model) renderContent(width int) (string, error) {
|
||||
r, err := glamour.NewTermRenderer(
|
||||
glamour.WithAutoStyle(),
|
||||
glamour.WithWordWrap(width),
|
||||
)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "could not init glamour renderer")
|
||||
}
|
||||
rendered, err := r.Render(m.content)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "could not render content")
|
||||
}
|
||||
return rendered, nil
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
return m.viewport.View()
|
||||
}
|
@@ -37,6 +37,8 @@ const (
|
||||
defaultDamping = 1.0
|
||||
)
|
||||
|
||||
var color func(string) termenv.Color = termenv.ColorProfile().Color
|
||||
|
||||
// Option is used to set options in NewModel. For example:
|
||||
//
|
||||
// progress := NewModel(
|
||||
@@ -108,13 +110,6 @@ func WithSpringOptions(frequency, damping float64) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithColorProfile sets the color profile to use for the progress bar.
|
||||
func WithColorProfile(p termenv.Profile) Option {
|
||||
return func(m *Model) {
|
||||
m.colorProfile = p
|
||||
}
|
||||
}
|
||||
|
||||
// FrameMsg indicates that an animation step should occur.
|
||||
type FrameMsg struct {
|
||||
id int
|
||||
@@ -162,9 +157,6 @@ type Model struct {
|
||||
// of the progress bar. When false, the width of the gradient will be set
|
||||
// to the full width of the progress bar.
|
||||
scaleRamp bool
|
||||
|
||||
// Color profile for the progress bar.
|
||||
colorProfile termenv.Profile
|
||||
}
|
||||
|
||||
// New returns a model with default values.
|
||||
@@ -178,7 +170,6 @@ func New(opts ...Option) Model {
|
||||
EmptyColor: "#606060",
|
||||
ShowPercentage: true,
|
||||
PercentFormat: " %3.0f%%",
|
||||
colorProfile: termenv.ColorProfile(),
|
||||
}
|
||||
if !m.springCustomized {
|
||||
m.SetSpringOptions(defaultFrequency, defaultDamping)
|
||||
@@ -308,18 +299,18 @@ func (m Model) barView(b *strings.Builder, percent float64, textWidth int) {
|
||||
c := m.rampColorA.BlendLuv(m.rampColorB, p).Hex()
|
||||
b.WriteString(termenv.
|
||||
String(string(m.Full)).
|
||||
Foreground(m.color(c)).
|
||||
Foreground(color(c)).
|
||||
String(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Solid fill
|
||||
s := termenv.String(string(m.Full)).Foreground(m.color(m.FullColor)).String()
|
||||
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(m.color(m.EmptyColor)).String()
|
||||
e := termenv.String(string(m.Empty)).Foreground(color(m.EmptyColor)).String()
|
||||
n := max(0, tw-fw)
|
||||
b.WriteString(strings.Repeat(e, n))
|
||||
}
|
||||
@@ -347,10 +338,6 @@ func (m *Model) setRamp(colorA, colorB string, scaled bool) {
|
||||
m.rampColorB = b
|
||||
}
|
||||
|
||||
func (m Model) color(c string) termenv.Color {
|
||||
return m.colorProfile.Color(c)
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
|
@@ -8,8 +8,8 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Internal ID management. Used during animating to ensure that frame messages
|
||||
// are received only by spinner components that sent them.
|
||||
// Internal ID management for text inputs. Necessary for blink integrity when
|
||||
// multiple text inputs are involved.
|
||||
var (
|
||||
lastID int
|
||||
idMtx sync.Mutex
|
||||
@@ -67,22 +67,6 @@ var (
|
||||
Frames: []string{"🙈", "🙉", "🙊"},
|
||||
FPS: time.Second / 3, //nolint:gomnd
|
||||
}
|
||||
Meter = Spinner{
|
||||
Frames: []string{
|
||||
"▱▱▱",
|
||||
"▰▱▱",
|
||||
"▰▰▱",
|
||||
"▰▰▰",
|
||||
"▰▰▱",
|
||||
"▰▱▱",
|
||||
"▱▱▱",
|
||||
},
|
||||
FPS: time.Second / 7, //nolint:gomnd
|
||||
}
|
||||
Hamburger = Spinner{
|
||||
Frames: []string{"☱", "☲", "☴", "☲"},
|
||||
FPS: time.Second / 3, //nolint:gomnd
|
||||
}
|
||||
)
|
||||
|
||||
// Model contains the state for the spinner. Use NewModel to create new models
|
||||
@@ -109,17 +93,11 @@ func (m Model) ID() int {
|
||||
}
|
||||
|
||||
// New returns a model with default values.
|
||||
func New(opts ...Option) Model {
|
||||
m := Model{
|
||||
func New() Model {
|
||||
return Model{
|
||||
Spinner: Line,
|
||||
id: nextID(),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(&m)
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// NewModel returns a model with default values.
|
||||
@@ -134,7 +112,9 @@ type TickMsg struct {
|
||||
ID int
|
||||
}
|
||||
|
||||
// Update is the Tea update function.
|
||||
// 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
|
||||
// is setup so as not to call this Update needlessly.
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case TickMsg:
|
||||
@@ -205,23 +185,3 @@ func (m Model) tick(id, tag int) tea.Cmd {
|
||||
func Tick() tea.Msg {
|
||||
return TickMsg{Time: time.Now()}
|
||||
}
|
||||
|
||||
// Option is used to set options in New. For example:
|
||||
//
|
||||
// spinner := New(WithSpinner(Dot))
|
||||
//
|
||||
type Option func(*Model)
|
||||
|
||||
// WithSpinner is an option to set the spinner.
|
||||
func WithSpinner(spinner Spinner) Option {
|
||||
return func(m *Model) {
|
||||
m.Spinner = spinner
|
||||
}
|
||||
}
|
||||
|
||||
// WithStyle is an option to set the spinner style.
|
||||
func WithStyle(style lipgloss.Style) Option {
|
||||
return func(m *Model) {
|
||||
m.Style = style
|
||||
}
|
||||
}
|
||||
|
@@ -1,61 +0,0 @@
|
||||
package spinner_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
)
|
||||
|
||||
func TestSpinnerNew(t *testing.T) {
|
||||
assertEqualSpinner := func(t *testing.T, exp, got spinner.Spinner) {
|
||||
t.Helper()
|
||||
|
||||
if exp.FPS != got.FPS {
|
||||
t.Errorf("expecting %d FPS, got %d", exp.FPS, got.FPS)
|
||||
}
|
||||
|
||||
if e, g := len(exp.Frames), len(got.Frames); e != g {
|
||||
t.Fatalf("expecting %d frames, got %d", e, g)
|
||||
}
|
||||
|
||||
for i, e := range exp.Frames {
|
||||
if g := got.Frames[i]; e != g {
|
||||
t.Errorf("expecting frame index %d with value %q, got %q", i, e, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Run("default", func(t *testing.T) {
|
||||
s := spinner.New()
|
||||
|
||||
assertEqualSpinner(t, spinner.Line, s.Spinner)
|
||||
})
|
||||
|
||||
t.Run("WithSpinner", func(t *testing.T) {
|
||||
customSpinner := spinner.Spinner{
|
||||
Frames: []string{"a", "b", "c", "d"},
|
||||
FPS: 16,
|
||||
}
|
||||
|
||||
s := spinner.New(spinner.WithSpinner(customSpinner))
|
||||
|
||||
assertEqualSpinner(t, customSpinner, s.Spinner)
|
||||
})
|
||||
|
||||
tests := map[string]spinner.Spinner{
|
||||
"Line": spinner.Line,
|
||||
"Dot": spinner.Dot,
|
||||
"MiniDot": spinner.MiniDot,
|
||||
"Jump": spinner.Jump,
|
||||
"Pulse": spinner.Pulse,
|
||||
"Points": spinner.Points,
|
||||
"Globe": spinner.Globe,
|
||||
"Moon": spinner.Moon,
|
||||
"Monkey": spinner.Monkey,
|
||||
}
|
||||
|
||||
for name, s := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assertEqualSpinner(t, spinner.New(spinner.WithSpinner(s)).Spinner, s)
|
||||
})
|
||||
}
|
||||
}
|
1130
textarea/textarea.go
1130
textarea/textarea.go
File diff suppressed because it is too large
Load Diff
@@ -1,446 +0,0 @@
|
||||
package textarea
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
textarea := newTextArea()
|
||||
view := textarea.View()
|
||||
|
||||
if !strings.Contains(view, ">") {
|
||||
t.Log(view)
|
||||
t.Error("Text area did not render the prompt")
|
||||
}
|
||||
|
||||
if !strings.Contains(view, "World!") {
|
||||
t.Log(view)
|
||||
t.Error("Text area did not render the placeholder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInput(t *testing.T) {
|
||||
textarea := newTextArea()
|
||||
|
||||
input := "foo"
|
||||
|
||||
for _, k := range []rune(input) {
|
||||
textarea, _ = textarea.Update(keyPress(k))
|
||||
}
|
||||
|
||||
view := textarea.View()
|
||||
|
||||
if !strings.Contains(view, input) {
|
||||
t.Log(view)
|
||||
t.Error("Text area did not render the input")
|
||||
}
|
||||
|
||||
if textarea.col != len(input) {
|
||||
t.Log(view)
|
||||
t.Error("Text area did not move the cursor to the correct position")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftWrap(t *testing.T) {
|
||||
textarea := newTextArea()
|
||||
textarea.Prompt = ""
|
||||
textarea.ShowLineNumbers = false
|
||||
textarea.SetWidth(5)
|
||||
textarea.SetHeight(5)
|
||||
textarea.CharLimit = 60
|
||||
|
||||
textarea, _ = textarea.Update(nil)
|
||||
|
||||
input := "foo bar baz"
|
||||
|
||||
for _, k := range []rune(input) {
|
||||
textarea, _ = textarea.Update(keyPress(k))
|
||||
}
|
||||
|
||||
view := textarea.View()
|
||||
|
||||
for _, word := range strings.Split(input, " ") {
|
||||
if !strings.Contains(view, word) {
|
||||
t.Log(view)
|
||||
t.Error("Text area did not render the input")
|
||||
}
|
||||
}
|
||||
|
||||
// Due to the word wrapping, each word will be on a new line and the
|
||||
// text area will look like this:
|
||||
//
|
||||
// > foo
|
||||
// > bar
|
||||
// > baz█
|
||||
//
|
||||
// However, due to soft-wrapping the column will still be at the end of the line.
|
||||
if textarea.row != 0 || textarea.col != len(input) {
|
||||
t.Log(view)
|
||||
t.Error("Text area did not move the cursor to the correct position")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharLimit(t *testing.T) {
|
||||
textarea := newTextArea()
|
||||
|
||||
// First input (foo bar) should be accepted as it will fall within the
|
||||
// CharLimit. Second input (baz) should not appear in the input.
|
||||
input := []string{"foo bar", "baz"}
|
||||
textarea.CharLimit = len(input[0])
|
||||
|
||||
for _, k := range []rune(strings.Join(input, " ")) {
|
||||
textarea, _ = textarea.Update(keyPress(k))
|
||||
}
|
||||
|
||||
view := textarea.View()
|
||||
if strings.Contains(view, input[1]) {
|
||||
t.Log(view)
|
||||
t.Error("Text area should not include input past the character limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerticalScrolling(t *testing.T) {
|
||||
textarea := newTextArea()
|
||||
textarea.Prompt = ""
|
||||
textarea.ShowLineNumbers = false
|
||||
textarea.SetHeight(1)
|
||||
textarea.SetWidth(20)
|
||||
textarea.CharLimit = 100
|
||||
|
||||
textarea, _ = textarea.Update(nil)
|
||||
|
||||
input := "This is a really long line that should wrap around the text area."
|
||||
|
||||
for _, k := range []rune(input) {
|
||||
textarea, _ = textarea.Update(keyPress(k))
|
||||
}
|
||||
|
||||
view := textarea.View()
|
||||
|
||||
// The view should contain the first "line" of the input.
|
||||
if !strings.Contains(view, "This is a really") {
|
||||
t.Log(view)
|
||||
t.Error("Text area did not render the input")
|
||||
}
|
||||
|
||||
// But we should be able to scroll to see the next line.
|
||||
// Let's scroll down for each line to view the full input.
|
||||
lines := []string{
|
||||
"long line that",
|
||||
"should wrap around",
|
||||
"the text area.",
|
||||
}
|
||||
for _, line := range lines {
|
||||
textarea.viewport.LineDown(1)
|
||||
view = textarea.View()
|
||||
if !strings.Contains(view, line) {
|
||||
t.Log(view)
|
||||
t.Error("Text area did not render the correct scrolled input")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordWrapOverflowing(t *testing.T) {
|
||||
// An interesting edge case is when the user enters many words that fill up
|
||||
// the text area and then goes back up and inserts a few words which causes
|
||||
// a cascading wrap and causes an overflow of the last line.
|
||||
//
|
||||
// In this case, we should not let the user insert more words if, after the
|
||||
// entire wrap is complete, the last line is overflowing.
|
||||
textarea := newTextArea()
|
||||
|
||||
textarea.SetHeight(3)
|
||||
textarea.SetWidth(20)
|
||||
textarea.CharLimit = 500
|
||||
|
||||
textarea, _ = textarea.Update(nil)
|
||||
|
||||
input := "Testing Testing Testing Testing Testing Testing Testing Testing"
|
||||
|
||||
for _, k := range []rune(input) {
|
||||
textarea, _ = textarea.Update(keyPress(k))
|
||||
textarea.View()
|
||||
}
|
||||
|
||||
// We have essentially filled the text area with input.
|
||||
// Let's see if we can cause wrapping to overflow the last line.
|
||||
textarea.row = 0
|
||||
textarea.col = 0
|
||||
|
||||
input = "Testing"
|
||||
|
||||
for _, k := range []rune(input) {
|
||||
textarea, _ = textarea.Update(keyPress(k))
|
||||
textarea.View()
|
||||
}
|
||||
|
||||
lastLineWidth := textarea.LineInfo().Width
|
||||
if lastLineWidth > 20 {
|
||||
t.Log(lastLineWidth)
|
||||
t.Log(textarea.View())
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestValueSoftWrap(t *testing.T) {
|
||||
textarea := newTextArea()
|
||||
textarea.SetWidth(16)
|
||||
textarea.SetHeight(10)
|
||||
textarea.CharLimit = 500
|
||||
|
||||
textarea, _ = textarea.Update(nil)
|
||||
|
||||
input := "Testing Testing Testing Testing Testing Testing Testing Testing"
|
||||
|
||||
for _, k := range []rune(input) {
|
||||
textarea, _ = textarea.Update(keyPress(k))
|
||||
textarea.View()
|
||||
}
|
||||
|
||||
value := textarea.Value()
|
||||
if value != input {
|
||||
t.Log(value)
|
||||
t.Log(input)
|
||||
t.Fatal("The text area does not have the correct value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetValue(t *testing.T) {
|
||||
textarea := newTextArea()
|
||||
textarea.SetValue(strings.Join([]string{"Foo", "Bar", "Baz"}, "\n"))
|
||||
|
||||
if textarea.row != 2 && textarea.col != 3 {
|
||||
t.Log(textarea.row, textarea.col)
|
||||
t.Fatal("Cursor Should be on row 2 column 3 after inserting 2 new lines")
|
||||
}
|
||||
|
||||
value := textarea.Value()
|
||||
if value != "Foo\nBar\nBaz" {
|
||||
t.Fatal("Value should be Foo\nBar\nBaz")
|
||||
}
|
||||
|
||||
// SetValue should reset text area
|
||||
textarea.SetValue("Test")
|
||||
value = textarea.Value()
|
||||
if value != "Test" {
|
||||
t.Log(value)
|
||||
t.Fatal("Text area was not reset when SetValue() was called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertString(t *testing.T) {
|
||||
textarea := newTextArea()
|
||||
|
||||
// Insert some text
|
||||
input := "foo baz"
|
||||
|
||||
for _, k := range []rune(input) {
|
||||
textarea, _ = textarea.Update(keyPress(k))
|
||||
}
|
||||
|
||||
// Put cursor in the middle of the text
|
||||
textarea.col = 4
|
||||
|
||||
textarea.InsertString("bar ")
|
||||
|
||||
value := textarea.Value()
|
||||
if value != "foo bar baz" {
|
||||
t.Log(value)
|
||||
t.Fatal("Expected insert string to insert bar between foo and baz")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanHandleEmoji(t *testing.T) {
|
||||
textarea := newTextArea()
|
||||
input := "🧋"
|
||||
|
||||
for _, k := range []rune(input) {
|
||||
textarea, _ = textarea.Update(keyPress(k))
|
||||
}
|
||||
|
||||
value := textarea.Value()
|
||||
if value != input {
|
||||
t.Log(value)
|
||||
t.Fatal("Expected emoji to be inserted")
|
||||
}
|
||||
|
||||
input = "🧋🧋🧋"
|
||||
|
||||
textarea.SetValue(input)
|
||||
|
||||
value = textarea.Value()
|
||||
if value != input {
|
||||
t.Log(value)
|
||||
t.Fatal("Expected emoji to be inserted")
|
||||
}
|
||||
|
||||
if textarea.col != 3 {
|
||||
t.Log(textarea.col)
|
||||
t.Fatal("Expected cursor to be on the third character")
|
||||
}
|
||||
|
||||
if charOffset := textarea.LineInfo().CharOffset; charOffset != 6 {
|
||||
t.Log(charOffset)
|
||||
t.Fatal("Expected cursor to be on the sixth character")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerticalNavigationKeepsCursorHorizontalPosition(t *testing.T) {
|
||||
textarea := newTextArea()
|
||||
textarea.SetWidth(20)
|
||||
|
||||
textarea.SetValue(strings.Join([]string{"你好你好", "Hello"}, "\n"))
|
||||
|
||||
textarea.row = 0
|
||||
textarea.col = 2
|
||||
|
||||
// 你好|你好
|
||||
// Hell|o
|
||||
// 1234|
|
||||
|
||||
// Let's imagine our cursor is on the first line where the pipe is.
|
||||
// We press the down arrow to get to the next line.
|
||||
// The issue is that if we keep the cursor on the same column, the cursor will jump to after the `e`.
|
||||
//
|
||||
// 你好|你好
|
||||
// He|llo
|
||||
//
|
||||
// But this is wrong because visually we were at the 4th character due to
|
||||
// the first line containing double-width runes.
|
||||
// We want to keep the cursor on the same visual column.
|
||||
//
|
||||
// 你好|你好
|
||||
// Hell|o
|
||||
//
|
||||
// This test ensures that the cursor is kept on the same visual column by
|
||||
// ensuring that the column offset goes from 2 -> 4.
|
||||
|
||||
lineInfo := textarea.LineInfo()
|
||||
if lineInfo.CharOffset != 4 || lineInfo.ColumnOffset != 2 {
|
||||
t.Log(lineInfo.CharOffset)
|
||||
t.Log(lineInfo.ColumnOffset)
|
||||
t.Fatal("Expected cursor to be on the fourth character because there two double width runes on the first line.")
|
||||
}
|
||||
|
||||
downMsg := tea.KeyMsg{Type: tea.KeyDown, Alt: false, Runes: []rune{}}
|
||||
textarea, _ = textarea.Update(downMsg)
|
||||
|
||||
lineInfo = textarea.LineInfo()
|
||||
if lineInfo.CharOffset != 4 || lineInfo.ColumnOffset != 4 {
|
||||
t.Log(lineInfo.CharOffset)
|
||||
t.Log(lineInfo.ColumnOffset)
|
||||
t.Fatal("Expected cursor to be on the fourth character because we came down from the first line.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerticalNavigationShouldRememberPositionWhileTraversing(t *testing.T) {
|
||||
textarea := newTextArea()
|
||||
textarea.SetWidth(40)
|
||||
|
||||
// Let's imagine we have a text area with the following content:
|
||||
//
|
||||
// Hello
|
||||
// World
|
||||
// This is a long line.
|
||||
//
|
||||
// If we are at the end of the last line and go up, we should be at the end
|
||||
// of the second line.
|
||||
// And, if we go up again we should be at the end of the first line.
|
||||
// But, if we go back down twice, we should be at the end of the last line
|
||||
// again and not the fifth (length of second line) character of the last line.
|
||||
//
|
||||
// In other words, we should remember the last horizontal position while
|
||||
// traversing vertically.
|
||||
|
||||
textarea.SetValue(strings.Join([]string{"Hello", "World", "This is a long line."}, "\n"))
|
||||
|
||||
// We are at the end of the last line.
|
||||
if textarea.col != 20 || textarea.row != 2 {
|
||||
t.Log(textarea.col)
|
||||
t.Fatal("Expected cursor to be on the 20th character of the last line")
|
||||
}
|
||||
|
||||
// Let's go up.
|
||||
upMsg := tea.KeyMsg{Type: tea.KeyUp, Alt: false, Runes: []rune{}}
|
||||
textarea, _ = textarea.Update(upMsg)
|
||||
|
||||
// We should be at the end of the second line.
|
||||
if textarea.col != 5 || textarea.row != 1 {
|
||||
t.Log(textarea.col)
|
||||
t.Fatal("Expected cursor to be on the 5th character of the second line")
|
||||
}
|
||||
|
||||
// And, again.
|
||||
textarea, _ = textarea.Update(upMsg)
|
||||
|
||||
// We should be at the end of the first line.
|
||||
if textarea.col != 5 || textarea.row != 0 {
|
||||
t.Log(textarea.col)
|
||||
t.Fatal("Expected cursor to be on the 5th character of the first line")
|
||||
}
|
||||
|
||||
// Let's go down, twice.
|
||||
downMsg := tea.KeyMsg{Type: tea.KeyDown, Alt: false, Runes: []rune{}}
|
||||
textarea, _ = textarea.Update(downMsg)
|
||||
textarea, _ = textarea.Update(downMsg)
|
||||
|
||||
// We should be at the end of the last line.
|
||||
if textarea.col != 20 || textarea.row != 2 {
|
||||
t.Log(textarea.col)
|
||||
t.Fatal("Expected cursor to be on the 20th character of the last line")
|
||||
}
|
||||
|
||||
// Now, for correct behavior, if we move right or left, we should forget
|
||||
// (reset) the saved horizontal position. Since we assume the user wants to
|
||||
// keep the cursor where it is horizontally. This is how most text areas
|
||||
// work.
|
||||
|
||||
textarea, _ = textarea.Update(upMsg)
|
||||
leftMsg := tea.KeyMsg{Type: tea.KeyLeft, Alt: false, Runes: []rune{}}
|
||||
textarea, _ = textarea.Update(leftMsg)
|
||||
|
||||
if textarea.col != 4 || textarea.row != 1 {
|
||||
t.Log(textarea.col)
|
||||
t.Fatal("Expected cursor to be on the 5th character of the second line")
|
||||
}
|
||||
|
||||
// Going down now should keep us at the 4th column since we moved left and
|
||||
// reset the horizontal position saved state.
|
||||
textarea, _ = textarea.Update(downMsg)
|
||||
if textarea.col != 4 || textarea.row != 2 {
|
||||
t.Log(textarea.col)
|
||||
t.Fatal("Expected cursor to be on the 4th character of the last line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRendersEndOfLineBuffer(t *testing.T) {
|
||||
textarea := newTextArea()
|
||||
textarea.ShowLineNumbers = true
|
||||
textarea.SetWidth(20)
|
||||
|
||||
view := textarea.View()
|
||||
if !strings.Contains(view, "~") {
|
||||
t.Log(view)
|
||||
t.Fatal("Expected to see a tilde at the end of the line")
|
||||
}
|
||||
}
|
||||
|
||||
func newTextArea() Model {
|
||||
textarea := New()
|
||||
|
||||
textarea.Prompt = "> "
|
||||
textarea.Placeholder = "Hello, World!"
|
||||
|
||||
textarea.Focus()
|
||||
|
||||
textarea, _ = textarea.Update(nil)
|
||||
|
||||
return textarea
|
||||
}
|
||||
|
||||
func keyPress(key rune) tea.Msg {
|
||||
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{key}, Alt: false}
|
||||
}
|
@@ -91,9 +91,6 @@ func (c CursorMode) String() string {
|
||||
}[c]
|
||||
}
|
||||
|
||||
// ValidateFunc is a function that returns an error if the input is invalid.
|
||||
type ValidateFunc func(string) error
|
||||
|
||||
// Model is the Bubble Tea model for this text input element.
|
||||
type Model struct {
|
||||
Err error
|
||||
@@ -153,15 +150,9 @@ type Model struct {
|
||||
|
||||
// cursorMode determines the behavior of the cursor
|
||||
cursorMode CursorMode
|
||||
|
||||
// Validate is a function that checks whether or not the text within the
|
||||
// input is valid. If it is not valid, the `Err` field will be set to the
|
||||
// error returned by the function. If the function is not defined, all
|
||||
// input is considered valid.
|
||||
Validate ValidateFunc
|
||||
}
|
||||
|
||||
// New creates a new model with default settings.
|
||||
// NewModel creates a new model with default settings.
|
||||
func New() Model {
|
||||
return Model{
|
||||
Prompt: "> ",
|
||||
@@ -190,15 +181,6 @@ var NewModel = New
|
||||
|
||||
// SetValue sets the value of the text input.
|
||||
func (m *Model) SetValue(s string) {
|
||||
if m.Validate != nil {
|
||||
if err := m.Validate(s); err != nil {
|
||||
m.Err = err
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
m.Err = nil
|
||||
|
||||
runes := []rune(s)
|
||||
if m.CharLimit > 0 && len(runes) > m.CharLimit {
|
||||
m.value = runes[:m.CharLimit]
|
||||
@@ -268,7 +250,7 @@ func (m Model) CursorMode() CursorMode {
|
||||
return m.cursorMode
|
||||
}
|
||||
|
||||
// SetCursorMode sets the model's cursor mode. This method returns a command.
|
||||
// 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 {
|
||||
@@ -344,8 +326,6 @@ func (m *Model) handlePaste(v string) bool {
|
||||
tail := make([]rune, len(tailSrc))
|
||||
copy(tail, tailSrc)
|
||||
|
||||
oldPos := m.pos
|
||||
|
||||
// Insert pasted runes
|
||||
for _, r := range paste {
|
||||
head = append(head, r)
|
||||
@@ -359,12 +339,7 @@ func (m *Model) handlePaste(v string) bool {
|
||||
}
|
||||
|
||||
// Put it all back together
|
||||
value := append(head, tail...)
|
||||
m.SetValue(string(value))
|
||||
|
||||
if m.Err != nil {
|
||||
m.pos = oldPos
|
||||
}
|
||||
m.value = append(head, tail...)
|
||||
|
||||
// Reset blink state if necessary and run overflow checks
|
||||
return m.setCursor(m.pos)
|
||||
@@ -612,8 +587,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyBackspace: // delete character before cursor
|
||||
m.Err = nil
|
||||
|
||||
if msg.Alt {
|
||||
resetBlink = m.deleteWordLeft()
|
||||
} else {
|
||||
@@ -656,7 +629,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
resetBlink = m.deleteBeforeCursor()
|
||||
case tea.KeyCtrlV: // ^V paste
|
||||
return m, Paste
|
||||
case tea.KeyRunes, tea.KeySpace: // input regular characters
|
||||
case tea.KeyRunes: // input regular characters
|
||||
if msg.Alt && len(msg.Runes) == 1 {
|
||||
if msg.Runes[0] == 'd' { // alt+d, delete word right of cursor
|
||||
resetBlink = m.deleteWordRight()
|
||||
@@ -674,15 +647,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
|
||||
// Input a regular character
|
||||
if m.CharLimit <= 0 || len(m.value) < m.CharLimit {
|
||||
runes := msg.Runes
|
||||
|
||||
value := make([]rune, len(m.value))
|
||||
copy(value, m.value)
|
||||
value = append(value[:m.pos], append(runes, value[m.pos:]...)...)
|
||||
m.SetValue(string(value))
|
||||
if m.Err == nil {
|
||||
resetBlink = m.setCursor(m.pos + len(runes))
|
||||
}
|
||||
m.value = append(m.value[:m.pos], append(msg.Runes, m.value[m.pos:]...)...)
|
||||
resetBlink = m.setCursor(m.pos + len(msg.Runes))
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -162,7 +162,9 @@ func (m *Model) Start() tea.Cmd {
|
||||
|
||||
// Stop pauses the timer. Has no effect if the timer has timed out.
|
||||
func (m *Model) Stop() tea.Cmd {
|
||||
return m.startStop(false)
|
||||
return func() tea.Msg {
|
||||
return m.startStop(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle stops the timer if it's running and starts it if it's stopped.
|
||||
|
Reference in New Issue
Block a user