mirror of
https://github.com/Maks1mS/bubbles.git
synced 2025-10-18 16:38:56 +03:00
Compare commits
7 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
42f85b4a1b | ||
|
4d0a0ea9d8 | ||
|
658a4febc7 | ||
|
93e464296e | ||
|
57d79daf4d | ||
|
e57fd292cc | ||
|
54869f7a1d |
@@ -66,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
|
numeric page numbering, but you could also just use this component for the
|
||||||
logic and visualize pagination however you like.
|
logic and visualize pagination however you like.
|
||||||
|
|
||||||
* [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/pager/main.go)
|
* [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/paginator/main.go)
|
||||||
|
|
||||||
|
|
||||||
## Viewport
|
## Viewport
|
||||||
|
39
list/list.go
39
list/list.go
@@ -87,7 +87,7 @@ type Rank struct {
|
|||||||
// DefaultFilter uses the sahilm/fuzzy to filter through the list.
|
// DefaultFilter uses the sahilm/fuzzy to filter through the list.
|
||||||
// This is set by default.
|
// This is set by default.
|
||||||
func DefaultFilter(term string, targets []string) []Rank {
|
func DefaultFilter(term string, targets []string) []Rank {
|
||||||
var ranks fuzzy.Matches = fuzzy.Find(term, targets)
|
var ranks = fuzzy.Find(term, targets)
|
||||||
sort.Stable(ranks)
|
sort.Stable(ranks)
|
||||||
result := make([]Rank, len(ranks))
|
result := make([]Rank, len(ranks))
|
||||||
for i, r := range ranks {
|
for i, r := range ranks {
|
||||||
@@ -129,6 +129,9 @@ type Model struct {
|
|||||||
showHelp bool
|
showHelp bool
|
||||||
filteringEnabled bool
|
filteringEnabled bool
|
||||||
|
|
||||||
|
itemNameSingular string
|
||||||
|
itemNamePlural string
|
||||||
|
|
||||||
Title string
|
Title string
|
||||||
Styles Styles
|
Styles Styles
|
||||||
|
|
||||||
@@ -202,6 +205,8 @@ func New(items []Item, delegate ItemDelegate, width, height int) Model {
|
|||||||
showStatusBar: true,
|
showStatusBar: true,
|
||||||
showPagination: true,
|
showPagination: true,
|
||||||
showHelp: true,
|
showHelp: true,
|
||||||
|
itemNameSingular: "item",
|
||||||
|
itemNamePlural: "items",
|
||||||
filteringEnabled: true,
|
filteringEnabled: true,
|
||||||
KeyMap: DefaultKeyMap(),
|
KeyMap: DefaultKeyMap(),
|
||||||
Filter: DefaultFilter,
|
Filter: DefaultFilter,
|
||||||
@@ -286,7 +291,19 @@ func (m Model) ShowStatusBar() bool {
|
|||||||
return m.showStatusBar
|
return m.showStatusBar
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShowingPagination hides or shoes the paginator. Note that pagination will
|
// 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
|
||||||
// still be active, it simply won't be displayed.
|
// still be active, it simply won't be displayed.
|
||||||
func (m *Model) SetShowPagination(v bool) {
|
func (m *Model) SetShowPagination(v bool) {
|
||||||
m.showPagination = v
|
m.showPagination = v
|
||||||
@@ -552,7 +569,7 @@ func (m *Model) StopSpinner() {
|
|||||||
m.showSpinner = false
|
m.showSpinner = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper for disabling the keybindings used for quitting, incase you want to
|
// Helper for disabling the keybindings used for quitting, in case you want to
|
||||||
// handle this elsewhere in your application.
|
// handle this elsewhere in your application.
|
||||||
func (m *Model) DisableQuitKeybindings() {
|
func (m *Model) DisableQuitKeybindings() {
|
||||||
m.disableQuitKeybindings = true
|
m.disableQuitKeybindings = true
|
||||||
@@ -1048,21 +1065,25 @@ func (m Model) statusView() string {
|
|||||||
totalItems := len(m.items)
|
totalItems := len(m.items)
|
||||||
visibleItems := len(m.VisibleItems())
|
visibleItems := len(m.VisibleItems())
|
||||||
|
|
||||||
plural := ""
|
var itemName string
|
||||||
if visibleItems != 1 {
|
if visibleItems != 1 {
|
||||||
plural = "s"
|
itemName = m.itemNamePlural
|
||||||
|
} else {
|
||||||
|
itemName = m.itemNameSingular
|
||||||
}
|
}
|
||||||
|
|
||||||
|
itemsDisplay := fmt.Sprintf("%d %s", visibleItems, itemName)
|
||||||
|
|
||||||
if m.filterState == Filtering {
|
if m.filterState == Filtering {
|
||||||
// Filter results
|
// Filter results
|
||||||
if visibleItems == 0 {
|
if visibleItems == 0 {
|
||||||
status = m.Styles.StatusEmpty.Render("Nothing matched")
|
status = m.Styles.StatusEmpty.Render("Nothing matched")
|
||||||
} else {
|
} else {
|
||||||
status = fmt.Sprintf("%d item%s", visibleItems, plural)
|
status = itemsDisplay
|
||||||
}
|
}
|
||||||
} else if len(m.items) == 0 {
|
} else if len(m.items) == 0 {
|
||||||
// Not filtering: no items.
|
// Not filtering: no items.
|
||||||
status = m.Styles.StatusEmpty.Render("No items")
|
status = m.Styles.StatusEmpty.Render("No " + m.itemNamePlural)
|
||||||
} else {
|
} else {
|
||||||
// Normal
|
// Normal
|
||||||
filtered := m.FilterState() == FilterApplied
|
filtered := m.FilterState() == FilterApplied
|
||||||
@@ -1073,7 +1094,7 @@ func (m Model) statusView() string {
|
|||||||
status += fmt.Sprintf("“%s” ", f)
|
status += fmt.Sprintf("“%s” ", f)
|
||||||
}
|
}
|
||||||
|
|
||||||
status += fmt.Sprintf("%d item%s", visibleItems, plural)
|
status += itemsDisplay
|
||||||
}
|
}
|
||||||
|
|
||||||
numFiltered := totalItems - visibleItems
|
numFiltered := totalItems - visibleItems
|
||||||
@@ -1117,7 +1138,7 @@ func (m Model) populatedView() string {
|
|||||||
if m.filterState == Filtering {
|
if m.filterState == Filtering {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return m.Styles.NoItems.Render("No items found.")
|
return m.Styles.NoItems.Render("No " + m.itemNamePlural + " found.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(items) > 0 {
|
if len(items) > 0 {
|
||||||
|
74
list/list_test.go
Normal file
74
list/list_test.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
@@ -8,8 +8,8 @@ import (
|
|||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Internal ID management for text inputs. Necessary for blink integrity when
|
// Internal ID management. Used during animating to ensure that frame messages
|
||||||
// multiple text inputs are involved.
|
// are received only by spinner components that sent them.
|
||||||
var (
|
var (
|
||||||
lastID int
|
lastID int
|
||||||
idMtx sync.Mutex
|
idMtx sync.Mutex
|
||||||
@@ -67,6 +67,22 @@ var (
|
|||||||
Frames: []string{"🙈", "🙉", "🙊"},
|
Frames: []string{"🙈", "🙉", "🙊"},
|
||||||
FPS: time.Second / 3, //nolint:gomnd
|
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
|
// Model contains the state for the spinner. Use NewModel to create new models
|
||||||
@@ -112,9 +128,7 @@ type TickMsg struct {
|
|||||||
ID int
|
ID int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update is the Tea update function. This will advance the spinner one frame
|
// Update is the Tea update function.
|
||||||
// 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) {
|
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case TickMsg:
|
case TickMsg:
|
||||||
|
@@ -91,6 +91,9 @@ func (c CursorMode) String() string {
|
|||||||
}[c]
|
}[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.
|
// Model is the Bubble Tea model for this text input element.
|
||||||
type Model struct {
|
type Model struct {
|
||||||
Err error
|
Err error
|
||||||
@@ -150,9 +153,15 @@ type Model struct {
|
|||||||
|
|
||||||
// cursorMode determines the behavior of the cursor
|
// cursorMode determines the behavior of the cursor
|
||||||
cursorMode CursorMode
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewModel creates a new model with default settings.
|
// New creates a new model with default settings.
|
||||||
func New() Model {
|
func New() Model {
|
||||||
return Model{
|
return Model{
|
||||||
Prompt: "> ",
|
Prompt: "> ",
|
||||||
@@ -181,13 +190,22 @@ var NewModel = New
|
|||||||
|
|
||||||
// SetValue sets the value of the text input.
|
// SetValue sets the value of the text input.
|
||||||
func (m *Model) SetValue(s string) {
|
func (m *Model) SetValue(s string) {
|
||||||
|
if m.Validate != nil {
|
||||||
|
if err := m.Validate(s); err != nil {
|
||||||
|
m.Err = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Err = nil
|
||||||
|
|
||||||
runes := []rune(s)
|
runes := []rune(s)
|
||||||
if m.CharLimit > 0 && len(runes) > m.CharLimit {
|
if m.CharLimit > 0 && len(runes) > m.CharLimit {
|
||||||
m.value = runes[:m.CharLimit]
|
m.value = runes[:m.CharLimit]
|
||||||
} else {
|
} else {
|
||||||
m.value = runes
|
m.value = runes
|
||||||
}
|
}
|
||||||
if m.pos == 0 || m.pos > len(m.value) {
|
if (m.pos == 0 && len(m.value) == 0) || m.pos > len(m.value) {
|
||||||
m.setCursor(len(m.value))
|
m.setCursor(len(m.value))
|
||||||
}
|
}
|
||||||
m.handleOverflow()
|
m.handleOverflow()
|
||||||
@@ -250,7 +268,7 @@ func (m Model) CursorMode() CursorMode {
|
|||||||
return m.cursorMode
|
return m.cursorMode
|
||||||
}
|
}
|
||||||
|
|
||||||
// CursorMode sets the model's cursor mode. This method returns a command.
|
// SetCursorMode sets the model's cursor mode. This method returns a command.
|
||||||
//
|
//
|
||||||
// For available cursor modes, see type CursorMode.
|
// For available cursor modes, see type CursorMode.
|
||||||
func (m *Model) SetCursorMode(mode CursorMode) tea.Cmd {
|
func (m *Model) SetCursorMode(mode CursorMode) tea.Cmd {
|
||||||
@@ -326,6 +344,8 @@ func (m *Model) handlePaste(v string) bool {
|
|||||||
tail := make([]rune, len(tailSrc))
|
tail := make([]rune, len(tailSrc))
|
||||||
copy(tail, tailSrc)
|
copy(tail, tailSrc)
|
||||||
|
|
||||||
|
oldPos := m.pos
|
||||||
|
|
||||||
// Insert pasted runes
|
// Insert pasted runes
|
||||||
for _, r := range paste {
|
for _, r := range paste {
|
||||||
head = append(head, r)
|
head = append(head, r)
|
||||||
@@ -339,7 +359,12 @@ func (m *Model) handlePaste(v string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Put it all back together
|
// Put it all back together
|
||||||
m.value = append(head, tail...)
|
value := append(head, tail...)
|
||||||
|
m.SetValue(string(value))
|
||||||
|
|
||||||
|
if m.Err != nil {
|
||||||
|
m.pos = oldPos
|
||||||
|
}
|
||||||
|
|
||||||
// Reset blink state if necessary and run overflow checks
|
// Reset blink state if necessary and run overflow checks
|
||||||
return m.setCursor(m.pos)
|
return m.setCursor(m.pos)
|
||||||
@@ -587,6 +612,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case tea.KeyBackspace: // delete character before cursor
|
case tea.KeyBackspace: // delete character before cursor
|
||||||
|
m.Err = nil
|
||||||
|
|
||||||
if msg.Alt {
|
if msg.Alt {
|
||||||
resetBlink = m.deleteWordLeft()
|
resetBlink = m.deleteWordLeft()
|
||||||
} else {
|
} else {
|
||||||
@@ -647,8 +674,15 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
|
|
||||||
// Input a regular character
|
// Input a regular character
|
||||||
if m.CharLimit <= 0 || len(m.value) < m.CharLimit {
|
if m.CharLimit <= 0 || len(m.value) < m.CharLimit {
|
||||||
m.value = append(m.value[:m.pos], append(msg.Runes, m.value[m.pos:]...)...)
|
runes := msg.Runes
|
||||||
resetBlink = m.setCursor(m.pos + len(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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user