bubbles/list/list.go

1265 lines
31 KiB
Go
Raw Normal View History

2021-03-02 15:44:53 +03:00
// 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
}
2022-01-19 01:15:38 +03:00
// FilterMatchesMsg contains data about items matched during filtering. The
// message should be routed to Update for processing.
2022-01-11 20:28:44 +03:00
type FilterMatchesMsg []filteredItem
2021-03-02 15:44:53 +03:00
// FilterFunc takes a term and a list of strings to search through
// (defined by Item#FilterValue).
// It should return a sorted list of ranks.
type FilterFunc func(string, []string) []Rank
// Rank defines a rank for a given item.
type Rank struct {
// The index of the item in the original input.
Index int
// Indices of the actual word that were matched against the filter term.
MatchedIndexes []int
}
// 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)
sort.Stable(ranks)
result := make([]Rank, len(ranks))
for i, r := range ranks {
result[i] = Rank{
Index: r.Index,
MatchedIndexes: r.MatchedIndexes,
}
}
return result
}
2021-03-02 15:44:53 +03:00
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
itemNameSingular string
itemNamePlural string
2021-03-02 15:44:53 +03:00
Title string
Styles Styles
// Key mappings for navigating the list.
KeyMap KeyMap
2022-03-30 22:17:23 +03:00
// Filter is used to filter the list.
Filter FilterFunc
disableQuitKeybindings bool
2021-03-02 15:44:53 +03:00
// 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
}
// New returns a new model with sensible defaults.
func New(items []Item, delegate ItemDelegate, width, height int) Model {
2021-03-02 15:44:53 +03:00
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,
itemNameSingular: "item",
itemNamePlural: "items",
2021-03-02 15:44:53 +03:00
filteringEnabled: true,
KeyMap: DefaultKeyMap(),
Filter: DefaultFilter,
2021-03-02 15:44:53 +03:00
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
}
// NewModel returns a new model with sensible defaults.
//
// Deprecated. Use New instead.
var NewModel = New
2021-03-02 15:44:53 +03:00
// 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
}
2022-06-17 01:30:36 +03:00
// 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
}
2022-06-17 01:30:36 +03:00
// StatusBarItemName returns singular and plural status bar item names.
func (m Model) StatusBarItemName() (string, string) {
return m.itemNameSingular, m.itemNamePlural
}
2022-06-17 01:30:36 +03:00
// SetShowPagination hides or shoes the paginator. Note that pagination will
2021-03-02 15:44:53 +03:00
// 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()
m.updateKeybindings()
2021-03-02 15:44:53 +03:00
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. If index is out of the upper bound, the
// item will be appended. This returns a command.
2021-03-02 15:44:53 +03:00
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()
m.updateKeybindings()
2021-03-02 15:44:53 +03:00
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
}
// 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
}
2021-03-02 15:44:53 +03:00
// 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
}
2022-06-17 01:30:36 +03:00
// Helper for disabling the keybindings used for quitting, in case you want to
2021-03-02 15:44:53 +03:00
// handle this elsewhere in your application.
func (m *Model) DisableQuitKeybindings() {
m.disableQuitKeybindings = true
2021-03-02 15:44:53 +03:00
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(false)
2021-03-02 15:44:53 +03:00
m.KeyMap.ShowFullHelp.SetEnabled(false)
m.KeyMap.CloseFullHelp.SetEnabled(false)
default:
hasItems := len(m.items) != 0
2021-03-02 15:44:53 +03:00
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(!m.disableQuitKeybindings)
2021-03-02 15:44:53 +03:00
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
}
2022-01-11 20:28:44 +03:00
case FilterMatchesMsg:
2021-03-02 15:44:53 +03:00
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
}
}
if len(view) > 0 {
return titleBarStyle.Render(view)
}
return view
2021-03-02 15:44:53 +03:00
}
func (m Model) statusView() string {
var status string
totalItems := len(m.items)
visibleItems := len(m.VisibleItems())
var itemName string
2021-03-02 15:44:53 +03:00
if visibleItems != 1 {
itemName = m.itemNamePlural
} else {
itemName = m.itemNameSingular
2021-03-02 15:44:53 +03:00
}
itemsDisplay := fmt.Sprintf("%d %s", visibleItems, itemName)
2021-03-02 15:44:53 +03:00
if m.filterState == Filtering {
// Filter results
if visibleItems == 0 {
status = m.Styles.StatusEmpty.Render("Nothing matched")
} else {
status = itemsDisplay
2021-03-02 15:44:53 +03:00
}
} else if len(m.items) == 0 {
// Not filtering: no items.
status = m.Styles.StatusEmpty.Render("No " + m.itemNamePlural)
2021-03-02 15:44:53 +03:00
} 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 += itemsDisplay
2021-03-02 15:44:53 +03:00
}
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 ""
}
return m.Styles.NoItems.Render("No " + m.itemNamePlural + " found.")
2021-03-02 15:44:53 +03:00
}
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 {
2022-01-11 20:28:44 +03:00
return FilterMatchesMsg(m.itemsAsFilterItems()) // return nothing
2021-03-02 15:44:53 +03:00
}
targets := []string{}
items := m.items
for _, t := range items {
targets = append(targets, t.FilterValue())
}
filterMatches := []filteredItem{}
for _, r := range m.Filter(m.FilterInput.Value(), targets) {
2021-03-02 15:44:53 +03:00
filterMatches = append(filterMatches, filteredItem{
item: items[r.Index],
matches: r.MatchedIndexes,
})
}
2022-01-11 20:28:44 +03:00
return FilterMatchesMsg(filterMatches)
2021-03-02 15:44:53 +03:00
}
}
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
}