diff --git a/README.md b/README.md index b0a021f..cdc9179 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,12 @@ following requirements: Thank you! +## Contributing + +See [contributing][contribute]. + +[contribute]: https://github.com/charmbracelet/bubbles/contribute + ## Feedback We’d love to hear your thoughts on this project. Feel free to drop us a note! diff --git a/filepicker/filepicker.go b/filepicker/filepicker.go index b8a6eb5..8402e16 100644 --- a/filepicker/filepicker.go +++ b/filepicker/filepicker.go @@ -7,7 +7,7 @@ import ( "sort" "strconv" "strings" - "sync" + "sync/atomic" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" @@ -15,17 +15,10 @@ import ( "github.com/dustin/go-humanize" ) -var ( - lastID int - idMtx sync.Mutex -) +var lastID int64 -// Return the next ID we should use on the Model. func nextID() int { - idMtx.Lock() - defer idMtx.Unlock() - lastID++ - return lastID + return int(atomic.AddInt64(&lastID, 1)) } // New returns a new filepicker model with default styling and key bindings. diff --git a/go.mod b/go.mod index 223b8db..bc71369 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/harmonica v0.2.0 github.com/charmbracelet/lipgloss v0.13.0 github.com/charmbracelet/x/ansi v0.3.2 - github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 github.com/dustin/go-humanize v1.0.1 github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mattn/go-runewidth v0.0.16 diff --git a/go.sum b/go.sum index 58df9b8..527a35c 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= github.com/charmbracelet/x/ansi v0.3.2 h1:wsEwgAN+C9U06l9dCVMX0/L3x7ptvY1qmjMwyfE6USY= github.com/charmbracelet/x/ansi v0.3.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= -github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= diff --git a/help/help.go b/help/help.go index fca0927..cd75ba9 100644 --- a/help/help.go +++ b/help/help.go @@ -119,28 +119,23 @@ func (m Model) ShortHelpView(bindings []key.Binding) string { continue } + // Sep var sep string if totalWidth > 0 && i < len(bindings) { sep = separator } + // Item str := sep + m.Styles.ShortKey.Inline(true).Render(kb.Help().Key) + " " + m.Styles.ShortDesc.Inline(true).Render(kb.Help().Desc) - w := lipgloss.Width(str) - // If adding this help item would go over the available width, stop - // drawing. - if m.Width > 0 && totalWidth+w > m.Width { - // Although if there's room for an ellipsis, print that. - tail := " " + m.Styles.Ellipsis.Inline(true).Render(m.Ellipsis) - tailWidth := lipgloss.Width(tail) - - if totalWidth+tailWidth < m.Width { + // Tail + if tail, ok := m.shouldAddItem(totalWidth, w); !ok { + if tail != "" { b.WriteString(tail) } - break } @@ -165,8 +160,7 @@ func (m Model) FullHelpView(groups [][]key.Binding) string { out []string totalWidth int - sep = m.Styles.FullSeparator.Render(m.FullSeparator) - sepWidth = lipgloss.Width(sep) + separator = m.Styles.FullSeparator.Inline(true).Render(m.FullSeparator) ) // Iterate over groups to build columns @@ -174,12 +168,17 @@ func (m Model) FullHelpView(groups [][]key.Binding) string { if group == nil || !shouldRenderColumn(group) { continue } - var ( + sep string keys []string descriptions []string ) + // Sep + if totalWidth > 0 && i < len(groups) { + sep = separator + } + // Separate keys and descriptions into different slices for _, kb := range group { if !kb.Enabled() { @@ -189,33 +188,42 @@ func (m Model) FullHelpView(groups [][]key.Binding) string { descriptions = append(descriptions, kb.Help().Desc) } + // Column col := lipgloss.JoinHorizontal(lipgloss.Top, + sep, m.Styles.FullKey.Render(strings.Join(keys, "\n")), - m.Styles.FullKey.Render(" "), + " ", m.Styles.FullDesc.Render(strings.Join(descriptions, "\n")), ) + w := lipgloss.Width(col) - // Column - totalWidth += lipgloss.Width(col) - if m.Width > 0 && totalWidth > m.Width { + // Tail + if tail, ok := m.shouldAddItem(totalWidth, w); !ok { + if tail != "" { + out = append(out, tail) + } break } + totalWidth += w out = append(out, col) - - // Separator - if i < len(group)-1 { - totalWidth += sepWidth - if m.Width > 0 && totalWidth > m.Width { - break - } - out = append(out, sep) - } } return lipgloss.JoinHorizontal(lipgloss.Top, out...) } +func (m Model) shouldAddItem(totalWidth, width int) (tail string, ok bool) { + // If there's room for an ellipsis, print that. + if m.Width > 0 && totalWidth+width > m.Width { + tail = " " + m.Styles.Ellipsis.Inline(true).Render(m.Ellipsis) + + if totalWidth+lipgloss.Width(tail) < m.Width { + return tail, false + } + } + return "", true +} + func shouldRenderColumn(b []key.Binding) (ok bool) { for _, v := range b { if v.Enabled() { diff --git a/help/help_test.go b/help/help_test.go new file mode 100644 index 0000000..79601d7 --- /dev/null +++ b/help/help_test.go @@ -0,0 +1,38 @@ +package help + +import ( + "fmt" + "testing" + + "github.com/charmbracelet/x/exp/golden" + + "github.com/charmbracelet/bubbles/key" +) + +func TestFullHelp(t *testing.T) { + m := New() + m.FullSeparator = " | " + k := key.WithKeys("x") + kb := [][]key.Binding{ + { + key.NewBinding(k, key.WithHelp("enter", "continue")), + }, + { + key.NewBinding(k, key.WithHelp("esc", "back")), + key.NewBinding(k, key.WithHelp("?", "help")), + }, + { + key.NewBinding(k, key.WithHelp("H", "home")), + key.NewBinding(k, key.WithHelp("ctrl+c", "quit")), + key.NewBinding(k, key.WithHelp("ctrl+l", "log")), + }, + } + + for _, w := range []int{20, 30, 40} { + t.Run(fmt.Sprintf("full help %d width", w), func(t *testing.T) { + m.Width = w + s := m.FullHelpView(kb) + golden.RequireEqual(t, []byte(s)) + }) + } +} diff --git a/help/testdata/TestFullHelp/full_help_20_width.golden b/help/testdata/TestFullHelp/full_help_20_width.golden new file mode 100644 index 0000000..e8c569b --- /dev/null +++ b/help/testdata/TestFullHelp/full_help_20_width.golden @@ -0,0 +1 @@ +enter continue … \ No newline at end of file diff --git a/help/testdata/TestFullHelp/full_help_30_width.golden b/help/testdata/TestFullHelp/full_help_30_width.golden new file mode 100644 index 0000000..1183a10 --- /dev/null +++ b/help/testdata/TestFullHelp/full_help_30_width.golden @@ -0,0 +1,2 @@ +enter continue | esc back … + ? help \ No newline at end of file diff --git a/help/testdata/TestFullHelp/full_help_40_width.golden b/help/testdata/TestFullHelp/full_help_40_width.golden new file mode 100644 index 0000000..e0227d0 --- /dev/null +++ b/help/testdata/TestFullHelp/full_help_40_width.golden @@ -0,0 +1,3 @@ +enter continue | esc back | H home + ? help ctrl+c quit + ctrl+l log \ No newline at end of file diff --git a/list/README.md b/list/README.md index bff5b50..f5e1751 100644 --- a/list/README.md +++ b/list/README.md @@ -17,7 +17,7 @@ type Item interface { ``` ```go -// DefaultItem describes an items designed to work with DefaultDelegate. +// DefaultItem describes an item designed to work with DefaultDelegate. type DefaultItem interface { Item Title() string diff --git a/list/defaultitem.go b/list/defaultitem.go index 4806b3c..31b55bb 100644 --- a/list/defaultitem.go +++ b/list/defaultitem.go @@ -61,7 +61,7 @@ func NewDefaultItemStyles() (s DefaultItemStyles) { return s } -// DefaultItem describes an items designed to work with DefaultDelegate. +// DefaultItem describes an item designed to work with DefaultDelegate. type DefaultItem interface { Item Title() string diff --git a/list/list.go b/list/list.go index 97d8c0c..171729c 100644 --- a/list/list.go +++ b/list/list.go @@ -53,6 +53,7 @@ type ItemDelegate interface { } type filteredItem struct { + index int // index in the unfiltered list item Item // item matched matches []int // rune indices of matched items } @@ -479,12 +480,26 @@ func (m Model) MatchesForItem(index int) []int { return m.filteredItems[index].matches } -// Index returns the index of the currently selected item as it appears in the -// entire slice of items. +// Index returns the index of the currently selected item as it is stored in the +// filtered list of items. +// Using this value with SetItem() might be incorrect, consider using +// GlobalIndex() instead. func (m Model) Index() int { return m.Paginator.Page*m.Paginator.PerPage + m.cursor } +// GlobalIndex returns the index of the currently selected item as it is stored +// in the unfiltered list of items. This value can be used with SetItem(). +func (m Model) GlobalIndex() int { + index := m.Index() + + if m.filteredItems == nil || index >= len(m.filteredItems) { + return index + } + + return m.filteredItems[index].index +} + // Cursor returns the index of the cursor on the current page. func (m Model) Cursor() int { return m.cursor @@ -1251,6 +1266,7 @@ func filterItems(m Model) tea.Cmd { filterMatches := []filteredItem{} for _, r := range m.Filter(m.FilterInput.Value(), targets) { filterMatches = append(filterMatches, filteredItem{ + index: r.Index, item: items[r.Index], matches: r.MatchedIndexes, }) diff --git a/progress/progress.go b/progress/progress.go index 9bb7992..1a568b1 100644 --- a/progress/progress.go +++ b/progress/progress.go @@ -4,7 +4,7 @@ import ( "fmt" "math" "strings" - "sync" + "sync/atomic" "time" tea "github.com/charmbracelet/bubbletea/v2" @@ -17,17 +17,10 @@ import ( // Internal ID management. Used during animating to assure that frame messages // can only be received by progress components that sent them. -var ( - lastID int - idMtx sync.Mutex -) +var lastID int64 -// Return the next ID we should use on the model. func nextID() int { - idMtx.Lock() - defer idMtx.Unlock() - lastID++ - return lastID + return int(atomic.AddInt64(&lastID, 1)) } const ( diff --git a/spinner/spinner.go b/spinner/spinner.go index 17d3fa0..b88585c 100644 --- a/spinner/spinner.go +++ b/spinner/spinner.go @@ -1,7 +1,7 @@ package spinner import ( - "sync" + "sync/atomic" "time" tea "github.com/charmbracelet/bubbletea/v2" @@ -10,17 +10,10 @@ import ( // Internal ID management. Used during animating to ensure that frame messages // are received only by spinner components that sent them. -var ( - lastID int - idMtx sync.Mutex -) +var lastID int64 -// Return the next ID we should use on the Model. func nextID() int { - idMtx.Lock() - defer idMtx.Unlock() - lastID++ - return lastID + return int(atomic.AddInt64(&lastID, 1)) } // Spinner is a set of frames used in animating the spinner. diff --git a/stopwatch/stopwatch.go b/stopwatch/stopwatch.go index a37a0f2..861b719 100644 --- a/stopwatch/stopwatch.go +++ b/stopwatch/stopwatch.go @@ -2,22 +2,16 @@ package stopwatch import ( - "sync" + "sync/atomic" "time" tea "github.com/charmbracelet/bubbletea/v2" ) -var ( - lastID int - idMtx sync.Mutex -) +var lastID int64 func nextID() int { - idMtx.Lock() - defer idMtx.Unlock() - lastID++ - return lastID + return int(atomic.AddInt64(&lastID, 1)) } // TickMsg is a message that is sent on every timer tick. diff --git a/table/table.go b/table/table.go index 3f5306e..37be133 100644 --- a/table/table.go +++ b/table/table.go @@ -218,8 +218,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.MoveUp(m.viewport.Height / 2) case key.Matches(msg, m.KeyMap.HalfPageDown): m.MoveDown(m.viewport.Height / 2) - case key.Matches(msg, m.KeyMap.LineDown): - m.MoveDown(1) case key.Matches(msg, m.KeyMap.GotoTop): m.GotoTop() case key.Matches(msg, m.KeyMap.GotoBottom): diff --git a/textinput/textinput.go b/textinput/textinput.go index 37fc15d..2359d92 100644 --- a/textinput/textinput.go +++ b/textinput/textinput.go @@ -776,16 +776,29 @@ func (m Model) completionView(offset int) string { return "" } -// AvailableSuggestions returns the list of available suggestions. -func (m *Model) AvailableSuggestions() []string { - suggestions := make([]string, len(m.suggestions)) - for i, s := range m.suggestions { +func (m *Model) getSuggestions(sugs [][]rune) []string { + suggestions := make([]string, len(sugs)) + for i, s := range sugs { suggestions[i] = string(s) } - return suggestions } +// AvailableSuggestions returns the list of available suggestions. +func (m *Model) AvailableSuggestions() []string { + return m.getSuggestions(m.suggestions) +} + +// MatchedSuggestions returns the list of matched suggestions. +func (m *Model) MatchedSuggestions() []string { + return m.getSuggestions(m.matchedSuggestions) +} + +// CurrentSuggestion returns the currently selected suggestion index. +func (m *Model) CurrentSuggestionIndex() int { + return m.currentSuggestionIndex +} + // CurrentSuggestion returns the currently selected suggestion. func (m *Model) CurrentSuggestion() string { if m.currentSuggestionIndex >= len(m.matchedSuggestions) { diff --git a/timer/timer.go b/timer/timer.go index fa56305..cb9ea2a 100644 --- a/timer/timer.go +++ b/timer/timer.go @@ -2,22 +2,16 @@ package timer import ( - "sync" + "sync/atomic" "time" tea "github.com/charmbracelet/bubbletea/v2" ) -var ( - lastID int - idMtx sync.Mutex -) +var lastID int64 func nextID() int { - idMtx.Lock() - defer idMtx.Unlock() - lastID++ - return lastID + return int(atomic.AddInt64(&lastID, 1)) } // Authors note with regard to start and stop commands: