diff --git a/README.md b/README.md index 132278e..e8cf3a0 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,13 @@ Bubbles [![Build Status](https://github.com/charmbracelet/bubbles/workflows/build/badge.svg)](https://github.com/charmbracelet/bubbles/actions) [![Go ReportCard](https://goreportcard.com/badge/charmbracelet/bubbles)](https://goreportcard.com/report/charmbracelet/bubbles) -Some components for [Bubble Tea](https://github.com/charmbracelet/bubbletea) applications. - -These components are used in production in [Glow][glow] and [Charm][charm]. +Some components for [Bubble Tea](https://github.com/charmbracelet/bubbletea) +applications. These components are used in production in [Glow][glow], +[Charm][charm] and [many other applications][otherstuff]. [glow]: https://github.com/charmbracelet/glow [charm]: https://github.com/charmbracelet/charm +[otherstuff]: https://github.com/charmbracelet/bubbletea/#bubble-tea-in-the-wild ## Spinner @@ -25,7 +26,8 @@ These components are used in production in [Glow][glow] and [Charm][charm]. A spinner, useful for indicating that some kind an operation is happening. There are a couple default ones, but you can also pass your own ”frames.” -* [Example code](https://github.com/charmbracelet/tea/tree/master/examples/spinner/main.go) +* [Example code, basic spinner](https://github.com/charmbracelet/tea/tree/master/examples/spinner/main.go) +* [Example code, various spinners](https://github.com/charmbracelet/tea/tree/master/examples/spinners/main.go) ## Text Input @@ -60,8 +62,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. -This component is used in [Glow][glow] to browse documents and [Charm][charm] to -browse SSH keys. +* [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/pager/main.go) ## Viewport @@ -80,6 +81,20 @@ indenting and text wrapping. [reflow]: https://github.com/muesli/reflow +## List + +List Example + +A customizable, batteries-included component for browsing a set of items. +Features pagination, fuzzy filtering, auto-generated help, an activity spinner, +and status messages, all of which and be enabled and disabled as needed. +Extrapolated from [Glow][glow]. + +* [Example code, default list](https://github.com/charmbracelet/tea/tree/master/examples/list-default/main.go) +* [Example code, simple list](https://github.com/charmbracelet/tea/tree/master/examples/list-simple/main.go) +* [Example code, all features](https://github.com/charmbracelet/tea/tree/master/examples/list-fancy/main.go) + + ## Help A customizable horizontal mini help view that automatically generates itself diff --git a/go.mod b/go.mod index c631167..55bb4de 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,12 @@ require ( github.com/atotto/clipboard v0.1.2 github.com/charmbracelet/bubbletea v0.13.1 github.com/charmbracelet/harmonica v0.1.0 - github.com/charmbracelet/lipgloss v0.1.2 + github.com/charmbracelet/lipgloss v0.2.2-0.20210525180645-66eb23093aa6 + 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.2.1-0.20210115123740-9e1d0d53df68 github.com/muesli/termenv v0.8.1 + github.com/sahilm/fuzzy v0.1.0 golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 // indirect ) diff --git a/go.sum b/go.sum index e55e8b8..649db91 100644 --- a/go.sum +++ b/go.sum @@ -4,11 +4,13 @@ github.com/charmbracelet/bubbletea v0.13.1 h1:huvX8mPaeMZ8DLulT50iEWRF+iitY5FNED github.com/charmbracelet/bubbletea v0.13.1/go.mod h1:tp9tr9Dadh0PLhgiwchE5zZJXm5543JYjHG9oY+5qSg= 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.1.2 h1:D+LUMg34W7n2pkuMrevKVxT7HXqnoRHm7IoomkX3/ZU= -github.com/charmbracelet/lipgloss v0.1.2/go.mod h1:5D8zradw52m7QmxRF6QgwbwJi9je84g8MkWiGN07uKg= +github.com/charmbracelet/lipgloss v0.2.2-0.20210525180645-66eb23093aa6 h1:lAHD8PDu2W7USlmKEt2v1/BCfmShVXrijjbCQcofOmg= +github.com/charmbracelet/lipgloss v0.2.2-0.20210525180645-66eb23093aa6/go.mod h1:uiZUfrHLQN14I0lJ5591WtcHyY1X76pOIPSaRKPY6dE= github.com/containerd/console v1.0.1 h1:u7SFAJyRqWcG6ogaMAx3KjSTy1e3hT9QxqX7Jco7dRc= github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw= github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4= +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.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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= @@ -16,6 +18,7 @@ github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHX github.com/mattn/go-isatty v0.0.12/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/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 h1:y1p/ycavWjGT9FnmSjdbWUlLGvcxrY0Rw3ATltrxOhk= @@ -26,8 +29,13 @@ github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/f github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 h1:pLI5jrR7OSLijeIDcmRxNmw2api+jEfxLoykJVice/E= diff --git a/list/defaultitem.go b/list/defaultitem.go new file mode 100644 index 0000000..04dfe7b --- /dev/null +++ b/list/defaultitem.go @@ -0,0 +1,205 @@ +package list + +import ( + "fmt" + "io" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/reflow/truncate" +) + +// DefaultItemStyles defines styling for a default list item. +// See DefaultItemView for when these come into play. +type DefaultItemStyles struct { + // The Normal state. + NormalTitle lipgloss.Style + NormalDesc lipgloss.Style + + // The selected item state. + SelectedTitle lipgloss.Style + SelectedDesc lipgloss.Style + + // The dimmed state, for when the filter input is initially activated. + DimmedTitle lipgloss.Style + DimmedDesc lipgloss.Style + + // Charcters matching the current filter, if any. + FilterMatch lipgloss.Style +} + +// NewDefaultItemStyles returns style definitions for a default item. See +// DefaultItemView for when these come into play. +func NewDefaultItemStyles() (s DefaultItemStyles) { + s.NormalTitle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}). + Padding(0, 0, 0, 2) + + s.NormalDesc = s.NormalTitle.Copy(). + Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}) + + s.SelectedTitle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"}). + Foreground(lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}). + Padding(0, 0, 0, 1) + + s.SelectedDesc = s.SelectedTitle.Copy(). + Foreground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"}) + + s.DimmedTitle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}). + Padding(0, 0, 0, 2) + + s.DimmedDesc = s.DimmedTitle.Copy(). + Foreground(lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"}) + + s.FilterMatch = lipgloss.NewStyle().Underline(true) + + return s +} + +// DefaultItem describes an items designed to work with DefaultDelegate. +type DefaultItem interface { + Item + Title() string + Description() string +} + +// DefaultDelegate is a standard delegate designed to work in lists. It's +// styled by DefaultItemStyles, which can be customized as you like. +// +// The description line can be hidden by setting Description to false, which +// renders the list as single-line-items. The spacing between items can be set +// with the SetSpacing method. +// +// Setting UpdateFunc is optional. If it's set it will be called when the +// ItemDelegate called, which is called when the list's Update function is +// invoked. +// +// Settings ShortHelpFunc and FullHelpFunc is optional. They can can be set to +// include items in the list's default short and full help menus. +type DefaultDelegate struct { + ShowDescription bool + Styles DefaultItemStyles + UpdateFunc func(tea.Msg, *Model) tea.Cmd + ShortHelpFunc func() []key.Binding + FullHelpFunc func() [][]key.Binding + spacing int +} + +// NewDefaultDelegate creates a new delegate with default styles. +func NewDefaultDelegate() DefaultDelegate { + return DefaultDelegate{ + ShowDescription: true, + Styles: NewDefaultItemStyles(), + spacing: 1, + } +} + +// Height returns the delegate's preferred height. +func (d DefaultDelegate) Height() int { + if d.ShowDescription { + return 2 //nolint:gomnd + } + return 1 +} + +// SetSpacing set the delegate's spacing. +func (d *DefaultDelegate) SetSpacing(i int) { + d.spacing = i +} + +// Spacing returns the delegate's spacing. +func (d DefaultDelegate) Spacing() int { + return d.spacing +} + +// Update checks whether the delegate's UpdateFunc is set and calls it. +func (d DefaultDelegate) Update(msg tea.Msg, m *Model) tea.Cmd { + if d.UpdateFunc == nil { + return nil + } + return d.UpdateFunc(msg, m) +} + +// Render prints an item. +func (d DefaultDelegate) Render(w io.Writer, m Model, index int, item Item) { + var ( + title, desc string + matchedRunes []int + s = &d.Styles + ) + + if i, ok := item.(DefaultItem); ok { + title = i.Title() + desc = i.Description() + } else { + return + } + + // Prevent text from exceeding list width + if m.width > 0 { + textwidth := uint(m.width - s.NormalTitle.GetPaddingLeft() - s.NormalTitle.GetPaddingRight()) + title = truncate.StringWithTail(title, textwidth, ellipsis) + desc = truncate.StringWithTail(desc, textwidth, ellipsis) + } + + // Conditions + var ( + isSelected = index == m.Index() + emptyFilter = m.FilterState() == Filtering && m.FilterValue() == "" + isFiltered = m.FilterState() == Filtering || m.FilterState() == FilterApplied + ) + + if isFiltered && index < len(m.filteredItems) { + // Get indices of matched characters + matchedRunes = m.MatchesForItem(index) + } + + if emptyFilter { + title = s.DimmedTitle.Render(title) + desc = s.DimmedDesc.Render(desc) + } else if isSelected && m.FilterState() != Filtering { + if isFiltered { + // Highlight matches + unmatched := s.SelectedTitle.Inline(true) + matched := unmatched.Copy().Inherit(s.FilterMatch) + title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched) + } + title = s.SelectedTitle.Render(title) + desc = s.SelectedDesc.Render(desc) + } else { + if isFiltered { + // Highlight matches + unmatched := s.NormalTitle.Inline(true) + matched := unmatched.Copy().Inherit(s.FilterMatch) + title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched) + } + title = s.NormalTitle.Render(title) + desc = s.NormalDesc.Render(desc) + } + + if d.ShowDescription { + fmt.Fprintf(w, "%s\n%s", title, desc) + return + } + fmt.Fprintf(w, "%s", title) +} + +// ShortHelp returns the delegate's short help. +func (d DefaultDelegate) ShortHelp() []key.Binding { + if d.ShortHelpFunc != nil { + return d.ShortHelpFunc() + } + return nil +} + +// FullHelp returns the delegate's full help. +func (d DefaultDelegate) FullHelp() [][]key.Binding { + if d.FullHelpFunc != nil { + return d.FullHelpFunc() + } + return nil +} diff --git a/list/keys.go b/list/keys.go new file mode 100644 index 0000000..421a247 --- /dev/null +++ b/list/keys.go @@ -0,0 +1,97 @@ +package list + +import "github.com/charmbracelet/bubbles/key" + +// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which +// is used to render the menu menu. +type KeyMap struct { + // Keybindings used when browsing the list. + CursorUp key.Binding + CursorDown key.Binding + NextPage key.Binding + PrevPage key.Binding + GoToStart key.Binding + GoToEnd key.Binding + Filter key.Binding + ClearFilter key.Binding + + // Keybindings used when setting a filter. + CancelWhileFiltering key.Binding + AcceptWhileFiltering key.Binding + + // Help toggle keybindings. + ShowFullHelp key.Binding + CloseFullHelp key.Binding + + // The quit keybinding. This won't be caught when filtering. + Quit key.Binding + + // The quit-no-matter-what keybinding. This will be caught when filtering. + ForceQuit key.Binding +} + +// DefaultKeyMap returns a default set of keybindings. +func DefaultKeyMap() KeyMap { + return KeyMap{ + // Browsing. + CursorUp: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + CursorDown: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + PrevPage: key.NewBinding( + key.WithKeys("left", "h", "pgup", "b", "u"), + key.WithHelp("←/h/pgup", "prev page"), + ), + NextPage: key.NewBinding( + key.WithKeys("right", "l", "pgdown", "f", "d"), + key.WithHelp("→/l/pgdn", "next page"), + ), + GoToStart: key.NewBinding( + key.WithKeys("home", "g"), + key.WithHelp("g/home", "go to start"), + ), + GoToEnd: key.NewBinding( + key.WithKeys("end", "G"), + key.WithHelp("G/end", "go to end"), + ), + Filter: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter"), + ), + ClearFilter: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "clear filter"), + ), + + // Filtering. + CancelWhileFiltering: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + AcceptWhileFiltering: key.NewBinding( + key.WithKeys("enter", "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down"), + key.WithHelp("enter", "apply filter"), + ), + + // Toggle help. + ShowFullHelp: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "more"), + ), + CloseFullHelp: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "close help"), + ), + + // Quitting. + Quit: key.NewBinding( + key.WithKeys("q", "esc"), + key.WithHelp("q", "quit"), + ), + ForceQuit: key.NewBinding(key.WithKeys("ctrl+c")), + } +} diff --git a/list/list.go b/list/list.go new file mode 100644 index 0000000..181c707 --- /dev/null +++ b/list/list.go @@ -0,0 +1,1197 @@ +// Package list provides a feature-rich Bubble Tea component for browsing +// a general purpose list of items. It features optional filtering, pagination, +// help, status messages, and a spinner to indicate activity. +package list + +import ( + "fmt" + "io" + "sort" + "strings" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/paginator" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/reflow/ansi" + "github.com/muesli/reflow/truncate" + "github.com/sahilm/fuzzy" +) + +// Item is an item that appears in the list. +type Item interface { + // Filter value is the value we use when filtering against this item when + // we're filtering the list. + FilterValue() string +} + +// ItemDelegate encapsulates the general functionality for all list items. The +// benefit to separating this logic from the item itself is that you can change +// the functionality of items without changing the actual items themselves. +// +// Note that if the delegate also implements help.KeyMap delegate-related +// help items will be added to the help view. +type ItemDelegate interface { + // Render renders the item's view. + Render(w io.Writer, m Model, index int, item Item) + + // Height is the height of the list item. + Height() int + + // Spacing is the size of the horizontal gap between list items in cells. + Spacing() int + + // Update is the update loop for items. All messages in the list's update + // loop will pass through here except when the user is setting a filter. + // Use this method to perform item-level updates appropriate to this + // delegate. + Update(msg tea.Msg, m *Model) tea.Cmd +} + +type filteredItem struct { + item Item // item matched + matches []int // rune indices of matched items +} + +type filteredItems []filteredItem + +func (f filteredItems) items() []Item { + agg := make([]Item, len(f)) + for i, v := range f { + agg[i] = v.item + } + return agg +} + +func (f filteredItems) matches() [][]int { + agg := make([][]int, len(f)) + for i, v := range f { + agg[i] = v.matches + } + return agg +} + +type filterMatchesMsg []filteredItem + +type statusMessageTimeoutMsg struct{} + +// FilterState describes the current filtering state on the model. +type FilterState int + +// Possible filter states. +const ( + Unfiltered FilterState = iota // no filter set + Filtering // user is actively setting a filter + FilterApplied // a filter is applied and user is not editing filter +) + +// String returns a human-readable string of the current filter state. +func (f FilterState) String() string { + return [...]string{ + "unfiltered", + "filtering", + "filter applied", + }[f] +} + +// Model contains the state of this component. +type Model struct { + showTitle bool + showFilter bool + showStatusBar bool + showPagination bool + showHelp bool + filteringEnabled bool + + Title string + Styles Styles + + // Key mappings for navigating the list. + KeyMap KeyMap + + // Additional key mappings for the short and full help views. This allows + // you to add additional key mappings to the help menu without + // re-implementing the help component. Of course, you can also disable the + // list's help component and implement a new one if you need more + // flexibility. + AdditionalShortHelpKeys func() []key.Binding + AdditionalFullHelpKeys func() []key.Binding + + spinner spinner.Model + showSpinner bool + width int + height int + Paginator paginator.Model + cursor int + Help help.Model + FilterInput textinput.Model + filterState FilterState + + // How long status messages should stay visible. By default this is + // 1 second. + StatusMessageLifetime time.Duration + + statusMessage string + statusMessageTimer *time.Timer + + // The master set of items we're working with. + items []Item + + // Filtered items we're currently displaying. Filtering, toggles and so on + // will alter this slice so we can show what is relevant. For that reason, + // this field should be considered ephemeral. + filteredItems filteredItems + + delegate ItemDelegate +} + +// NewModel returns a new model with sensible defaults. +func NewModel(items []Item, delegate ItemDelegate, width, height int) Model { + styles := DefaultStyles() + + sp := spinner.NewModel() + sp.Spinner = spinner.Line + sp.Style = styles.Spinner + + filterInput := textinput.NewModel() + filterInput.Prompt = "Filter: " + filterInput.PromptStyle = styles.FilterPrompt + filterInput.CursorStyle = styles.FilterCursor + filterInput.CharLimit = 64 + filterInput.Focus() + + p := paginator.NewModel() + p.Type = paginator.Dots + p.ActiveDot = styles.ActivePaginationDot.String() + p.InactiveDot = styles.InactivePaginationDot.String() + + m := Model{ + showTitle: true, + showFilter: true, + showStatusBar: true, + showPagination: true, + showHelp: true, + filteringEnabled: true, + KeyMap: DefaultKeyMap(), + Styles: styles, + Title: "List", + FilterInput: filterInput, + StatusMessageLifetime: time.Second, + + width: width, + height: height, + delegate: delegate, + items: items, + Paginator: p, + spinner: sp, + Help: help.NewModel(), + } + + m.updatePagination() + m.updateKeybindings() + return m +} + +// SetFilteringEnabled enables or disables filtering. Note that this is different +// from ShowFilter, which merely hides or shows the input view. +func (m *Model) SetFilteringEnabled(v bool) { + m.filteringEnabled = v + if !v { + m.resetFiltering() + } + m.updateKeybindings() +} + +// FilteringEnabled returns whether or not filtering is enabled. +func (m Model) FilteringEnabled() bool { + return m.filteringEnabled +} + +// SetShowTitle shows or hides the title bar. +func (m *Model) SetShowTitle(v bool) { + m.showTitle = v + m.updatePagination() +} + +// ShowTitle returns whether or not the title bar is set to be rendered. +func (m Model) ShowTitle() bool { + return m.showTitle +} + +// SetShowFilter shows or hides the filer bar. Note that this does not disable +// filtering, it simply hides the built-in filter view. This allows you to +// use the FilterInput to render the filtering UI differently without having to +// re-implement filtering from scratch. +// +// To disable filtering entirely use EnableFiltering. +func (m *Model) SetShowFilter(v bool) { + m.showFilter = v + m.updatePagination() +} + +// ShowFilter returns whether or not the filter is set to be rendered. Note +// that this is separate from FilteringEnabled, so filtering can be hidden yet +// still invoked. This allows you to render filtering differently without +// having to re-implement it from scratch. +func (m Model) ShowFilter() bool { + return m.showFilter +} + +// SetShowStatusBar shows or hides the view that displays metadata about the +// list, such as item counts. +func (m *Model) SetShowStatusBar(v bool) { + m.showStatusBar = v + m.updatePagination() +} + +// ShowStatusBar returns whether or not the status bar is set to be rendered. +func (m Model) ShowStatusBar() bool { + return m.showStatusBar +} + +// 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 + m.updatePagination() +} + +// ShowPagination returns whether the pagination is visible. +func (m *Model) ShowPagination() bool { + return m.showPagination +} + +// SetShowHelp shows or hides the help view. +func (m *Model) SetShowHelp(v bool) { + m.showHelp = v + m.updatePagination() +} + +// ShowHelp returns whether or not the help is set to be rendered. +func (m Model) ShowHelp() bool { + return m.showHelp +} + +// Items returns the items in the list. +func (m Model) Items() []Item { + return m.items +} + +// Set the items available in the list. This returns a command. +func (m *Model) SetItems(i []Item) tea.Cmd { + var cmd tea.Cmd + m.items = i + + if m.filterState != Unfiltered { + m.filteredItems = nil + cmd = filterItems(*m) + } + + m.updatePagination() + return cmd +} + +// Select selects the given index of the list and goes to its respective page. +func (m *Model) Select(index int) { + m.Paginator.Page = index / m.Paginator.PerPage + m.cursor = index % m.Paginator.PerPage +} + +// ResetSelected resets the selected item to the first item in the first page of the list. +func (m *Model) ResetSelected() { + m.Select(0) +} + +// ResetFilter resets the current filtering state. +func (m *Model) ResetFilter() { + m.resetFiltering() +} + +// Replace an item at the given index. This returns a command. +func (m *Model) SetItem(index int, item Item) tea.Cmd { + var cmd tea.Cmd + m.items[index] = item + + if m.filterState != Unfiltered { + cmd = filterItems(*m) + } + + m.updatePagination() + return cmd +} + +// Insert an item at the given index. This returns a command. +func (m *Model) InsertItem(index int, item Item) tea.Cmd { + var cmd tea.Cmd + m.items = insertItemIntoSlice(m.items, item, index) + + if m.filterState != Unfiltered { + cmd = filterItems(*m) + } + + m.updatePagination() + return cmd +} + +// RemoveItem removes an item at the given index. If the index is out of bounds +// this will be a no-op. O(n) complexity, which probably won't matter in the +// case of a TUI. +func (m *Model) RemoveItem(index int) { + m.items = removeItemFromSlice(m.items, index) + if m.filterState != Unfiltered { + m.filteredItems = removeFilterMatchFromSlice(m.filteredItems, index) + if len(m.filteredItems) == 0 { + m.resetFiltering() + } + } + m.updatePagination() +} + +// Set the item delegate. +func (m *Model) SetDelegate(d ItemDelegate) { + m.delegate = d + m.updatePagination() +} + +// VisibleItems returns the total items available to be shown. +func (m Model) VisibleItems() []Item { + if m.filterState != Unfiltered { + return m.filteredItems.items() + } + return m.items +} + +// SelectedItems returns the current selected item in the list. +func (m Model) SelectedItem() Item { + i := m.Index() + + items := m.VisibleItems() + if i < 0 || len(items) == 0 || len(items) <= i { + return nil + } + + return items[i] +} + +// MatchesForItem returns rune positions matched by the current filter, if any. +// Use this to style runes matched by the active filter. +// +// See DefaultItemView for a usage example. +func (m Model) MatchesForItem(index int) []int { + if m.filteredItems == nil || index >= len(m.filteredItems) { + return nil + } + return m.filteredItems[index].matches +} + +// Index returns the index of the currently selected item as it appears in the +// entire slice of items. +func (m Model) Index() int { + return m.Paginator.Page*m.Paginator.PerPage + m.cursor +} + +// Cursor returns the index of the cursor on the current page. +func (m Model) Cursor() int { + return m.cursor +} + +// CursorUp moves the cursor up. This can also move the state to the previous +// page. +func (m *Model) CursorUp() { + m.cursor-- + + // If we're at the start, stop + if m.cursor < 0 && m.Paginator.Page == 0 { + m.cursor = 0 + return + } + + // Move the cursor as normal + if m.cursor >= 0 { + return + } + + // Go to the previous page + m.Paginator.PrevPage() + m.cursor = m.Paginator.ItemsOnPage(len(m.VisibleItems())) - 1 +} + +// CursorDown moves the cursor down. This can also advance the state to the +// next page. +func (m *Model) CursorDown() { + itemsOnPage := m.Paginator.ItemsOnPage(len(m.VisibleItems())) + + m.cursor++ + + // If we're at the end, stop + if m.cursor < itemsOnPage { + return + } + + // Go to the next page + if !m.Paginator.OnLastPage() { + m.Paginator.NextPage() + m.cursor = 0 + return + } + + // During filtering the cursor position can exceed the number of + // itemsOnPage. It's more intuitive to start the cursor at the + // topmost position when moving it down in this scenario. + if m.cursor > itemsOnPage { + m.cursor = 0 + return + } + + m.cursor = itemsOnPage - 1 +} + +// PrevPage moves to the previous page, if available. +func (m Model) PrevPage() { + m.Paginator.PrevPage() +} + +// NextPage moves to the next page, if available. +func (m Model) NextPage() { + m.Paginator.NextPage() +} + +// FilterState returns the current filter state. +func (m Model) FilterState() FilterState { + return m.filterState +} + +// FilterValue returns the current value of the filter. +func (m Model) FilterValue() string { + return m.FilterInput.Value() +} + +// SettingFilter returns whether or not the user is currently editing the +// filter value. It's purely a convenience method for the following: +// +// m.FilterState() == Filtering +// +// It's included here because it's a common thing to check for when +// implementing this component. +func (m Model) SettingFilter() bool { + return m.filterState == Filtering +} + +// Width returns the current width setting. +func (m Model) Width() int { + return m.width +} + +// Height returns the current height setting. +func (m Model) Height() int { + return m.height +} + +// SetSpinner allows to set the spinner style. +func (m *Model) SetSpinner(spinner spinner.Spinner) { + m.spinner.Spinner = spinner +} + +// Toggle the spinner. Note that this also returns a command. +func (m *Model) ToggleSpinner() tea.Cmd { + if !m.showSpinner { + return m.StartSpinner() + } + m.StopSpinner() + return nil +} + +// StartSpinner starts the spinner. Note that this returns a command. +func (m *Model) StartSpinner() tea.Cmd { + m.showSpinner = true + return spinner.Tick +} + +// StopSpinner stops the spinner. +func (m *Model) StopSpinner() { + m.showSpinner = false +} + +// Helper for disabling the keybindings used for quitting, incase you want to +// handle this elsewhere in your application. +func (m *Model) DisableQuitKeybindings() { + m.KeyMap.Quit.SetEnabled(false) + m.KeyMap.ForceQuit.SetEnabled(false) +} + +// NewStatusMessage sets a new status message, which will show for a limited +// amount of time. Note that this also returns a command. +func (m *Model) NewStatusMessage(s string) tea.Cmd { + m.statusMessage = s + if m.statusMessageTimer != nil { + m.statusMessageTimer.Stop() + } + + m.statusMessageTimer = time.NewTimer(m.StatusMessageLifetime) + + // Wait for timeout + return func() tea.Msg { + <-m.statusMessageTimer.C + return statusMessageTimeoutMsg{} + } +} + +// SetSize sets the width and height of this component. +func (m *Model) SetSize(width, height int) { + m.setSize(width, height) +} + +// SetWidth sets the width of this component. +func (m *Model) SetWidth(v int) { + m.setSize(v, m.height) +} + +// SetHeight sets the height of this component. +func (m *Model) SetHeight(v int) { + m.setSize(m.width, v) +} + +func (m *Model) setSize(width, height int) { + promptWidth := lipgloss.Width(m.Styles.Title.Render(m.FilterInput.Prompt)) + + m.width = width + m.height = height + m.Help.Width = width + m.FilterInput.Width = width - promptWidth - lipgloss.Width(m.spinnerView()) + m.updatePagination() +} + +func (m *Model) resetFiltering() { + if m.filterState == Unfiltered { + return + } + + m.filterState = Unfiltered + m.FilterInput.Reset() + m.filteredItems = nil + m.updatePagination() + m.updateKeybindings() +} + +func (m Model) itemsAsFilterItems() filteredItems { + fi := make([]filteredItem, len(m.items)) + for i, item := range m.items { + fi[i] = filteredItem{ + item: item, + } + } + return filteredItems(fi) +} + +// Set keybindings according to the filter state. +func (m *Model) updateKeybindings() { + switch m.filterState { + case Filtering: + m.KeyMap.CursorUp.SetEnabled(false) + m.KeyMap.CursorDown.SetEnabled(false) + m.KeyMap.NextPage.SetEnabled(false) + m.KeyMap.PrevPage.SetEnabled(false) + m.KeyMap.GoToStart.SetEnabled(false) + m.KeyMap.GoToEnd.SetEnabled(false) + m.KeyMap.Filter.SetEnabled(false) + m.KeyMap.ClearFilter.SetEnabled(false) + m.KeyMap.CancelWhileFiltering.SetEnabled(true) + m.KeyMap.AcceptWhileFiltering.SetEnabled(m.FilterInput.Value() != "") + m.KeyMap.Quit.SetEnabled(true) + m.KeyMap.ShowFullHelp.SetEnabled(false) + m.KeyMap.CloseFullHelp.SetEnabled(false) + + default: + hasItems := m.items != nil + m.KeyMap.CursorUp.SetEnabled(hasItems) + m.KeyMap.CursorDown.SetEnabled(hasItems) + + hasPages := m.Paginator.TotalPages > 1 + m.KeyMap.NextPage.SetEnabled(hasPages) + m.KeyMap.PrevPage.SetEnabled(hasPages) + + m.KeyMap.GoToStart.SetEnabled(hasItems) + m.KeyMap.GoToEnd.SetEnabled(hasItems) + + m.KeyMap.Filter.SetEnabled(m.filteringEnabled && hasItems) + m.KeyMap.ClearFilter.SetEnabled(m.filterState == FilterApplied) + m.KeyMap.CancelWhileFiltering.SetEnabled(false) + m.KeyMap.AcceptWhileFiltering.SetEnabled(false) + m.KeyMap.Quit.SetEnabled(true) + + if m.Help.ShowAll { + m.KeyMap.ShowFullHelp.SetEnabled(true) + m.KeyMap.CloseFullHelp.SetEnabled(true) + } else { + minHelp := countEnabledBindings(m.FullHelp()) > 1 + m.KeyMap.ShowFullHelp.SetEnabled(minHelp) + m.KeyMap.CloseFullHelp.SetEnabled(minHelp) + } + } +} + +// Update pagination according to the amount of items for the current state. +func (m *Model) updatePagination() { + index := m.Index() + availHeight := m.height + + if m.showTitle || (m.showFilter && m.filteringEnabled) { + availHeight -= lipgloss.Height(m.titleView()) + } + if m.showStatusBar { + availHeight -= lipgloss.Height(m.statusView()) + } + if m.showPagination { + availHeight -= lipgloss.Height(m.paginationView()) + } + if m.showHelp { + availHeight -= lipgloss.Height(m.helpView()) + } + + m.Paginator.PerPage = max(1, availHeight/(m.delegate.Height()+m.delegate.Spacing())) + + if pages := len(m.VisibleItems()); pages < 1 { + m.Paginator.SetTotalPages(1) + } else { + m.Paginator.SetTotalPages(pages) + } + + // Restore index + m.Paginator.Page = index / m.Paginator.PerPage + m.cursor = index % m.Paginator.PerPage + + // Make sure the page stays in bounds + if m.Paginator.Page >= m.Paginator.TotalPages-1 { + m.Paginator.Page = max(0, m.Paginator.TotalPages-1) + } +} + +func (m *Model) hideStatusMessage() { + m.statusMessage = "" + if m.statusMessageTimer != nil { + m.statusMessageTimer.Stop() + } +} + +// Update is the Bubble Tea update loop. +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + if key.Matches(msg, m.KeyMap.ForceQuit) { + return m, tea.Quit + } + + case filterMatchesMsg: + m.filteredItems = filteredItems(msg) + return m, nil + + case spinner.TickMsg: + newSpinnerModel, cmd := m.spinner.Update(msg) + m.spinner = newSpinnerModel + if m.showSpinner { + cmds = append(cmds, cmd) + } + + case statusMessageTimeoutMsg: + m.hideStatusMessage() + } + + if m.filterState == Filtering { + cmds = append(cmds, m.handleFiltering(msg)) + } else { + cmds = append(cmds, m.handleBrowsing(msg)) + } + + return m, tea.Batch(cmds...) +} + +// Updates for when a user is browsing the list. +func (m *Model) handleBrowsing(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + numItems := len(m.VisibleItems()) + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + // Note: we match clear filter before quit because, by default, they're + // both mapped to escape. + case key.Matches(msg, m.KeyMap.ClearFilter): + m.resetFiltering() + + case key.Matches(msg, m.KeyMap.Quit): + return tea.Quit + + case key.Matches(msg, m.KeyMap.CursorUp): + m.CursorUp() + + case key.Matches(msg, m.KeyMap.CursorDown): + m.CursorDown() + + case key.Matches(msg, m.KeyMap.PrevPage): + m.Paginator.PrevPage() + + case key.Matches(msg, m.KeyMap.NextPage): + m.Paginator.NextPage() + + case key.Matches(msg, m.KeyMap.GoToStart): + m.Paginator.Page = 0 + m.cursor = 0 + + case key.Matches(msg, m.KeyMap.GoToEnd): + m.Paginator.Page = m.Paginator.TotalPages - 1 + m.cursor = m.Paginator.ItemsOnPage(numItems) - 1 + + case key.Matches(msg, m.KeyMap.Filter): + m.hideStatusMessage() + if m.FilterInput.Value() == "" { + // Populate filter with all items only if the filter is empty. + m.filteredItems = m.itemsAsFilterItems() + } + m.Paginator.Page = 0 + m.cursor = 0 + m.filterState = Filtering + m.FilterInput.CursorEnd() + m.FilterInput.Focus() + m.updateKeybindings() + return textinput.Blink + + case key.Matches(msg, m.KeyMap.ShowFullHelp): + fallthrough + case key.Matches(msg, m.KeyMap.CloseFullHelp): + m.Help.ShowAll = !m.Help.ShowAll + m.updatePagination() + } + } + + cmd := m.delegate.Update(msg, m) + cmds = append(cmds, cmd) + + // Keep the index in bounds when paginating + itemsOnPage := m.Paginator.ItemsOnPage(len(m.VisibleItems())) + if m.cursor > itemsOnPage-1 { + m.cursor = max(0, itemsOnPage-1) + } + + return tea.Batch(cmds...) +} + +// Updates for when a user is in the filter editing interface. +func (m *Model) handleFiltering(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + + // Handle keys + if msg, ok := msg.(tea.KeyMsg); ok { + switch { + case key.Matches(msg, m.KeyMap.CancelWhileFiltering): + m.resetFiltering() + m.KeyMap.Filter.SetEnabled(true) + m.KeyMap.ClearFilter.SetEnabled(false) + + case key.Matches(msg, m.KeyMap.AcceptWhileFiltering): + m.hideStatusMessage() + + if len(m.items) == 0 { + break + } + + h := m.VisibleItems() + + // If we've filtered down to nothing, clear the filter + if len(h) == 0 { + m.resetFiltering() + break + } + + m.FilterInput.Blur() + m.filterState = FilterApplied + m.updateKeybindings() + + if m.FilterInput.Value() == "" { + m.resetFiltering() + } + } + } + + // Update the filter text input component + newFilterInputModel, inputCmd := m.FilterInput.Update(msg) + filterChanged := m.FilterInput.Value() != newFilterInputModel.Value() + m.FilterInput = newFilterInputModel + cmds = append(cmds, inputCmd) + + // If the filtering input has changed, request updated filtering + if filterChanged { + cmds = append(cmds, filterItems(*m)) + m.KeyMap.AcceptWhileFiltering.SetEnabled(m.FilterInput.Value() != "") + } + + // Update pagination + m.updatePagination() + + return tea.Batch(cmds...) +} + +// ShortHelp returns bindings to show in the abbreviated help view. It's part +// of the help.KeyMap interface. +func (m Model) ShortHelp() []key.Binding { + kb := []key.Binding{ + m.KeyMap.CursorUp, + m.KeyMap.CursorDown, + } + + filtering := m.filterState == Filtering + + // If the delegate implements the help.KeyMap interface add the short help + // items to the short help after the cursor movement keys. + if !filtering { + if b, ok := m.delegate.(help.KeyMap); ok { + kb = append(kb, b.ShortHelp()...) + } + } + + kb = append(kb, + m.KeyMap.Filter, + m.KeyMap.ClearFilter, + m.KeyMap.AcceptWhileFiltering, + m.KeyMap.CancelWhileFiltering, + ) + + if !filtering && m.AdditionalShortHelpKeys != nil { + kb = append(kb, m.AdditionalShortHelpKeys()...) + } + + return append(kb, + m.KeyMap.Quit, + m.KeyMap.ShowFullHelp, + ) +} + +// FullHelp returns bindings to show the full help view. It's part of the +// help.KeyMap interface. +func (m Model) FullHelp() [][]key.Binding { + kb := [][]key.Binding{{ + m.KeyMap.CursorUp, + m.KeyMap.CursorDown, + m.KeyMap.NextPage, + m.KeyMap.PrevPage, + m.KeyMap.GoToStart, + m.KeyMap.GoToEnd, + }} + + filtering := m.filterState == Filtering + + // If the delegate implements the help.KeyMap interface add full help + // keybindings to a special section of the full help. + if !filtering { + if b, ok := m.delegate.(help.KeyMap); ok { + kb = append(kb, b.FullHelp()...) + } + } + + listLevelBindings := []key.Binding{ + m.KeyMap.Filter, + m.KeyMap.ClearFilter, + m.KeyMap.AcceptWhileFiltering, + m.KeyMap.CancelWhileFiltering, + } + + if !filtering && m.AdditionalFullHelpKeys != nil { + listLevelBindings = append(listLevelBindings, m.AdditionalFullHelpKeys()...) + } + + return append(kb, + listLevelBindings, + []key.Binding{ + m.KeyMap.Quit, + m.KeyMap.CloseFullHelp, + }) +} + +// View renders the component. +func (m Model) View() string { + var ( + sections []string + availHeight = m.height + ) + + if m.showTitle || (m.showFilter && m.filteringEnabled) { + v := m.titleView() + sections = append(sections, v) + availHeight -= lipgloss.Height(v) + } + + if m.showStatusBar { + v := m.statusView() + sections = append(sections, v) + availHeight -= lipgloss.Height(v) + } + + var pagination string + if m.showPagination { + pagination = m.paginationView() + availHeight -= lipgloss.Height(pagination) + } + + var help string + if m.showHelp { + help = m.helpView() + availHeight -= lipgloss.Height(help) + } + + content := lipgloss.NewStyle().Height(availHeight).Render(m.populatedView()) + sections = append(sections, content) + + if m.showPagination { + sections = append(sections, pagination) + } + + if m.showHelp { + sections = append(sections, help) + } + + return lipgloss.JoinVertical(lipgloss.Left, sections...) +} + +func (m Model) titleView() string { + var ( + view string + titleBarStyle = m.Styles.TitleBar.Copy() + + // We need to account for the size of the spinner, even if we don't + // render it, to reserve some space for it should we turn it on later. + spinnerView = m.spinnerView() + spinnerWidth = lipgloss.Width(spinnerView) + spinnerLeftGap = " " + spinnerOnLeft = titleBarStyle.GetPaddingLeft() >= spinnerWidth+lipgloss.Width(spinnerLeftGap) && m.showSpinner + ) + + // If the filter's showing, draw that. Otherwise draw the title. + if m.showFilter && m.filterState == Filtering { + view += m.FilterInput.View() + } else if m.showTitle { + if m.showSpinner && spinnerOnLeft { + view += spinnerView + spinnerLeftGap + titleBarGap := titleBarStyle.GetPaddingLeft() + titleBarStyle = titleBarStyle.PaddingLeft(titleBarGap - spinnerWidth - lipgloss.Width(spinnerLeftGap)) + } + + view += m.Styles.Title.Render(m.Title) + + // Status message + if m.filterState != Filtering { + view += " " + m.statusMessage + view = truncate.StringWithTail(view, uint(m.width-spinnerWidth), ellipsis) + } + } + + // Spinner + if m.showSpinner && !spinnerOnLeft { + // Place spinner on the right + availSpace := m.width - lipgloss.Width(m.Styles.TitleBar.Render(view)) + if availSpace > spinnerWidth { + view += strings.Repeat(" ", availSpace-spinnerWidth) + view += spinnerView + } + } + + return titleBarStyle.Render(view) +} + +func (m Model) statusView() string { + var status string + + totalItems := len(m.items) + visibleItems := len(m.VisibleItems()) + + plural := "" + if visibleItems != 1 { + plural = "s" + } + + if m.filterState == Filtering { + // Filter results + if visibleItems == 0 { + status = m.Styles.StatusEmpty.Render("Nothing matched") + } else { + status = fmt.Sprintf("%d item%s", visibleItems, plural) + } + } else if len(m.items) == 0 { + // Not filtering: no items. + status = m.Styles.StatusEmpty.Render("No items") + } else { + // Normal + filtered := m.FilterState() == FilterApplied + + if filtered { + f := strings.TrimSpace(m.FilterInput.Value()) + f = truncate.StringWithTail(f, 10, "…") + status += fmt.Sprintf("“%s” ", f) + } + + status += fmt.Sprintf("%d item%s", visibleItems, plural) + } + + numFiltered := totalItems - visibleItems + if numFiltered > 0 { + status += m.Styles.DividerDot.String() + status += m.Styles.StatusBarFilterCount.Render(fmt.Sprintf("%d filtered", numFiltered)) + } + + return m.Styles.StatusBar.Render(status) +} + +func (m Model) paginationView() string { + if m.Paginator.TotalPages < 2 { //nolint:gomnd + return "" + } + + s := m.Paginator.View() + + // If the dot pagination is wider than the width of the window + // use the arabic paginator. + if ansi.PrintableRuneWidth(s) > m.width { + m.Paginator.Type = paginator.Arabic + s = m.Styles.ArabicPagination.Render(m.Paginator.View()) + } + + style := m.Styles.PaginationStyle + if m.delegate.Spacing() == 0 && style.GetMarginTop() == 0 { + style = style.Copy().MarginTop(1) + } + + return style.Render(s) +} + +func (m Model) populatedView() string { + items := m.VisibleItems() + + var b strings.Builder + + // Empty states + if len(items) == 0 { + if m.filterState == Filtering { + return "" + } + m.Styles.NoItems.Render("No items found.") + } + + if len(items) > 0 { + start, end := m.Paginator.GetSliceBounds(len(items)) + docs := items[start:end] + + for i, item := range docs { + m.delegate.Render(&b, m, i+start, item) + if i != len(docs)-1 { + fmt.Fprint(&b, strings.Repeat("\n", m.delegate.Spacing()+1)) + } + } + } + + // If there aren't enough items to fill up this page (always the last page) + // then we need to add some newlines to fill up the space where items would + // have been. + itemsOnPage := m.Paginator.ItemsOnPage(len(items)) + if itemsOnPage < m.Paginator.PerPage { + n := (m.Paginator.PerPage - itemsOnPage) * (m.delegate.Height() + m.delegate.Spacing()) + if len(items) == 0 { + n -= m.delegate.Height() - 1 + } + fmt.Fprint(&b, strings.Repeat("\n", n)) + } + + return b.String() +} + +func (m Model) helpView() string { + return m.Styles.HelpStyle.Render(m.Help.View(m)) +} + +func (m Model) spinnerView() string { + return m.spinner.View() +} + +func filterItems(m Model) tea.Cmd { + return func() tea.Msg { + if m.FilterInput.Value() == "" || m.filterState == Unfiltered { + return filterMatchesMsg(m.itemsAsFilterItems()) // return nothing + } + + targets := []string{} + items := m.items + + for _, t := range items { + targets = append(targets, t.FilterValue()) + } + + var ranks fuzzy.Matches = fuzzy.Find(m.FilterInput.Value(), targets) + sort.Stable(ranks) + + filterMatches := []filteredItem{} + for _, r := range ranks { + filterMatches = append(filterMatches, filteredItem{ + item: items[r.Index], + matches: r.MatchedIndexes, + }) + } + + return filterMatchesMsg(filterMatches) + } +} + +func insertItemIntoSlice(items []Item, item Item, index int) []Item { + if items == nil { + return []Item{item} + } + if index >= len(items) { + return append(items, item) + } + + index = max(0, index) + + items = append(items, nil) + copy(items[index+1:], items[index:]) + items[index] = item + return items +} + +// Remove an item from a slice of items at the given index. This runs in O(n). +func removeItemFromSlice(i []Item, index int) []Item { + if index >= len(i) { + return i // noop + } + copy(i[index:], i[index+1:]) + i[len(i)-1] = nil + return i[:len(i)-1] +} + +func removeFilterMatchFromSlice(i []filteredItem, index int) []filteredItem { + if index >= len(i) { + return i // noop + } + copy(i[index:], i[index+1:]) + i[len(i)-1] = filteredItem{} + return i[:len(i)-1] +} + +func countEnabledBindings(groups [][]key.Binding) (agg int) { + for _, group := range groups { + for _, kb := range group { + if kb.Enabled() { + agg++ + } + } + } + return agg +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/list/style.go b/list/style.go new file mode 100644 index 0000000..e4451f8 --- /dev/null +++ b/list/style.go @@ -0,0 +1,99 @@ +package list + +import ( + "github.com/charmbracelet/lipgloss" +) + +const ( + bullet = "•" + ellipsis = "…" +) + +// Styles contains style definitions for this list component. By default, these +// values are generated by DefaultStyles. +type Styles struct { + TitleBar lipgloss.Style + Title lipgloss.Style + Spinner lipgloss.Style + FilterPrompt lipgloss.Style + FilterCursor lipgloss.Style + + // Default styling for matched characters in a filter. This can be + // overridden by delegates. + DefaultFilterCharacterMatch lipgloss.Style + + StatusBar lipgloss.Style + StatusEmpty lipgloss.Style + StatusBarActiveFilter lipgloss.Style + StatusBarFilterCount lipgloss.Style + + NoItems lipgloss.Style + + PaginationStyle lipgloss.Style + HelpStyle lipgloss.Style + + // Styled characters. + ActivePaginationDot lipgloss.Style + InactivePaginationDot lipgloss.Style + ArabicPagination lipgloss.Style + DividerDot lipgloss.Style +} + +// DefaultStyles returns a set of default style definitions for this list +// component. +func DefaultStyles() (s Styles) { + verySubduedColor := lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"} + subduedColor := lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"} + + s.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 2) + + s.Title = lipgloss.NewStyle(). + Background(lipgloss.Color("62")). + Foreground(lipgloss.Color("230")). + Padding(0, 1) + + s.Spinner = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"}) + + s.FilterPrompt = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"}) + + s.FilterCursor = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}) + + s.DefaultFilterCharacterMatch = lipgloss.NewStyle().Underline(true) + + s.StatusBar = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}). + Padding(0, 0, 1, 2) + + s.StatusEmpty = lipgloss.NewStyle().Foreground(subduedColor) + + s.StatusBarActiveFilter = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}) + + s.StatusBarFilterCount = lipgloss.NewStyle().Foreground(verySubduedColor) + + s.NoItems = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"}) + + s.ArabicPagination = lipgloss.NewStyle().Foreground(subduedColor) + + s.PaginationStyle = lipgloss.NewStyle().PaddingLeft(2) //nolint:gomnd + + s.HelpStyle = lipgloss.NewStyle().Padding(1, 0, 0, 2) + + s.ActivePaginationDot = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"}). + SetString(bullet) + + s.InactivePaginationDot = lipgloss.NewStyle(). + Foreground(verySubduedColor). + SetString(bullet) + + s.DividerDot = lipgloss.NewStyle(). + Foreground(verySubduedColor). + SetString(" " + bullet + " ") + + return s +} diff --git a/paginator/paginator.go b/paginator/paginator.go index 51630d7..e825582 100644 --- a/paginator/paginator.go +++ b/paginator/paginator.go @@ -35,7 +35,7 @@ type Model struct { UseJKKeys bool } -// SetTotalPages is a helper function for calculatng the total number of pages +// SetTotalPages is a helper function for calculating the total number of pages // from a given number of items. It's use is optional since this pager can be // used for other things beyond navigating sets. Note that it both returns the // number of total pages and alters the model.