fix: ust list from bubbles

See toolshed/abra#691
This commit is contained in:
2025-10-15 13:57:48 +02:00
parent fc16a21f1c
commit 7c5a83bce2
51 changed files with 3043 additions and 3660 deletions

View File

@ -3,22 +3,22 @@ package app
import (
"encoding/json"
"fmt"
"slices"
"sort"
"strings"
"time"
"coopcloud.tech/abra/cli/internal"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/i18n"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/tagcmp"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/evertras/bubble-table/table"
"github.com/spf13/cobra"
)
@ -56,15 +56,15 @@ var AppListCommand = &cobra.Command{
Short: i18n.G("List all managed apps"),
Long: i18n.G(`Generate a report of all managed apps.
Use "--status/-S" flag to query all servers for the live deployment status.`),
Use "status/S" flag to query all servers for the live deployment status.`),
Example: i18n.G(` # list apps of all servers without live status
abra app ls
# list apps of a specific server with live status
abra app ls -s 1312.net -S
abra app ls s 1312.net S
# list apps of all servers which match a specific recipe
abra app ls -r gitea`),
abra app ls r gitea`),
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
appFiles, err := appPkg.LoadAppFiles(listAppServer)
@ -221,7 +221,7 @@ Use "--status/-S" flag to query all servers for the live deployment status.`),
return
}
if err := runTable(apps); err != nil {
if err := runTui(apps); err != nil {
log.Fatal(err)
}
},
@ -262,7 +262,7 @@ func init() {
i18n.G("machine"),
i18n.G("m"),
false,
i18n.G("print machine-readable output"),
i18n.G("print machinereadable output"),
)
AppListCommand.Flags().StringVarP(
@ -281,45 +281,127 @@ func init() {
)
}
func getNumServersAndRecipes(apps []appPkg.App) (int, int) {
var (
servers []string
recipes []string
)
var listRenderStyle = lipgloss.NewStyle().Margin(1, 2)
for _, app := range apps {
if !slices.Contains(servers, app.Server) {
servers = append(servers, app.Server)
}
if !slices.Contains(recipes, app.Recipe.Name) {
recipes = append(recipes, app.Recipe.Name)
}
}
return len(servers), len(recipes)
type appMeta struct {
status string
version string
updates string
chaos string
}
type errorMsg struct{ err error }
type listItem struct {
title string
app appPkg.App
appMetadata appMeta
}
func (e errorMsg) Error() string { return e.err.Error() }
func (li listItem) Title() string { return li.title }
func (li listItem) Description() string {
return fmt.Sprintf(
"server: %s • recipe: %s • status: %s • version: %s • updates: %s • chaos: %s",
li.app.Server,
li.app.Recipe.Name,
li.appMetadata.status,
li.appMetadata.version,
li.appMetadata.updates,
li.appMetadata.chaos,
)
}
func (li listItem) FilterValue() string {
return fmt.Sprintf(
"%s %s %s %s %s %s %s",
li.app.Domain,
li.app.Server,
li.app.Recipe.Name,
li.appMetadata.status,
li.appMetadata.version,
li.appMetadata.updates,
li.appMetadata.chaos,
)
}
type model struct {
apps []appPkg.App
list list.Model
initStatusGather bool
pollingStatus bool
err error
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "s":
if !m.pollingStatus {
m.pollingStatus = true
cmds = append(cmds, m.list.NewStatusMessage("Looking up app statuses 🧐"))
cmds = append(cmds, func() tea.Msg { return getAppsDeployStatus(m) })
} else {
cmds = append(cmds, m.list.NewStatusMessage("In progress, please hold ✋"))
}
case "q":
return m, tea.Quit
}
case appsDeployStatusMsg:
m.pollingStatus = false
cmds = append(cmds, m.list.SetItems(renderAppsDeployStatus(m, msg)))
case tea.WindowSizeMsg:
h, v := listRenderStyle.GetFrameSize()
m.list.SetSize(msg.Width-h, msg.Height-v)
}
m.list, cmd = m.list.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m model) View() string {
return listRenderStyle.Render(m.list.View())
}
type listKeyMap struct {
status key.Binding
}
func newListKeyMap() *listKeyMap {
return &listKeyMap{
status: key.NewBinding(
key.WithKeys("s"),
key.WithHelp("s", "status"),
),
}
}
type appsDeployStatusMsg map[string]map[string]string
func getAppsDeployStatus(m model) tea.Msg {
var apps []appPkg.App
for _, row := range m.table.GetVisibleRows() {
apps = append(apps, row.Data["app"].(appPkg.App))
for _, li := range m.list.VisibleItems() {
i := li.(listItem) // NOTE(d1): convert back to our custom item
apps = append(apps, i.app)
}
statuses, err := appPkg.GetAppStatuses(apps, true)
if err != nil {
return errorMsg{err}
errMsg := fmt.Sprintf("ERROR: %s", err.Error())
return func() tea.Msg { return m.list.NewStatusMessage(errMsg) }
}
catl, err := recipe.ReadRecipeCatalogue(true)
if err != nil {
return errorMsg{err}
errMsg := fmt.Sprintf("ERROR: %s", err.Error())
return func() tea.Msg { return m.list.NewStatusMessage(errMsg) }
}
for _, app := range apps {
@ -328,7 +410,8 @@ func getAppsDeployStatus(m model) tea.Msg {
if version, ok := status["version"]; ok {
updates, err := recipe.GetRecipeCatalogueVersions(app.Recipe.Name, catl)
if err != nil {
return errorMsg{err}
errMsg := fmt.Sprintf("ERROR: %s", err.Error())
return func() tea.Msg { return m.list.NewStatusMessage(errMsg) }
}
parsedVersion, err := tagcmp.Parse(version)
@ -357,238 +440,109 @@ func getAppsDeployStatus(m model) tea.Msg {
return appsDeployStatusMsg(statuses)
}
func renderAppsDeployStatus(m *model, appStatuses appsDeployStatusMsg) table.Model {
for _, row := range m.table.GetVisibleRows() {
app := row.Data["app"].(appPkg.App)
appStatus := appStatuses[app.StackName()]
func renderAppsDeployStatus(m model, appStatuses appsDeployStatusMsg) []list.Item {
itemsWithStatus := make(map[string]list.Item)
for _, li := range m.list.VisibleItems() {
i := li.(listItem) // NOTE(d1): convert back to our custom item
appStatus := appStatuses[i.app.StackName()]
var (
version = appStatus["version"]
updates = appStatus["updates"]
status = appStatus["status"]
chaos = appStatus["chaos"]
chaosVersion = appStatus["chaosVersion"]
autoUpdate = appStatus["autoUpdate"]
appVersion = appStatus["version"]
appUpdates = appStatus["updates"]
appDeploymentStatus = appStatus["status"]
appChaosVersion = appStatus["chaosVersion"]
)
if status != "" {
row.Data["status"] = status
if appVersion == "" {
appVersion = "-"
}
if version != "" {
row.Data["version"] = version
row.Data["updates"] = updates
if appDeploymentStatus == "" || appDeploymentStatus == config.UNKNOWN_DEFAULT {
appDeploymentStatus = "-"
}
if chaos != "" {
if chaosVersion != "" {
row.Data["chaos-version"] = chaosVersion
}
if appChaosVersion == "" {
appChaosVersion = "-"
}
if autoUpdate != "" {
row.Data["autoUpdate"] = autoUpdate
if len(appUpdates) == 0 {
appUpdates = "-"
} else {
appUpdates = fmt.Sprintf("%d", len(appUpdates))
}
newMetadata := appMeta{
status: appDeploymentStatus,
version: appVersion,
updates: appUpdates,
chaos: appChaosVersion,
}
itemsWithStatus[i.title] = listItem{
title: i.title,
app: i.app,
appMetadata: newMetadata,
}
}
return m.table
var items []list.Item
for _, li := range m.list.Items() {
i := li.(listItem) // NOTE(d1): convert back to our custom item
if is, ok := itemsWithStatus[i.title]; ok {
items = append(items, is)
continue
}
items = append(items, i)
}
return items
}
type initTableMsg struct{ table table.Model }
func runTui(apps []appPkg.App) error {
var items []list.Item
func initTable(m model) tea.Msg {
var rows []table.Row
sort.Sort(appPkg.ByServerAndDomain(apps))
for _, app := range m.apps {
rows = append(rows, table.NewRow(table.RowData{
"domain": app.Domain,
"server": app.Server,
"recipe": app.Recipe.Name,
"app": app,
}))
}
colStyle := lipgloss.NewStyle().Align(lipgloss.Left)
columns := []table.Column{
table.NewFlexColumn("domain", "DOMAIN", 2).WithFiltered(true).WithStyle(colStyle),
table.NewFlexColumn("server", "SERVER", 1).WithFiltered(true).WithStyle(colStyle),
table.NewFlexColumn("recipe", "RECIPE", 1).WithFiltered(true).WithStyle(colStyle),
table.NewFlexColumn("status", "STATUS", 1).WithFiltered(true).WithStyle(colStyle),
table.NewFlexColumn("version", "VERSION", 1).WithFiltered(true).WithStyle(colStyle),
table.NewFlexColumn("updates", "UPDATES", 1).WithFiltered(true).WithStyle(colStyle),
table.NewFlexColumn("chaos-version", "CHAOS", 1).WithFiltered(true).WithStyle(colStyle),
table.NewFlexColumn("auto-update", "AUTO-UPDATE", 1).WithFiltered(true).WithStyle(colStyle),
}
keymap := table.DefaultKeyMap()
keymap.Filter = key.NewBinding(key.WithKeys("/", "f"))
keymap.PageDown = key.NewBinding(key.WithKeys("right", "l", "pgdown", "ctrl+d"))
keymap.PageUp = key.NewBinding(key.WithKeys("left", "h", "pgup", "ctrl+u"))
t := table.
New(columns).
Filtered(true).
Focused(true).
WithRows([]table.Row(rows)).
WithKeyMap(keymap).
WithMultiline(true).
WithFuzzyFilter().
SortByAsc("domain").
WithNoPagination().
WithMissingDataIndicatorStyled(table.StyledCell{
Style: lipgloss.NewStyle().Foreground(lipgloss.Color("#faa")),
Data: "-",
for _, app := range apps {
items = append(items, listItem{
title: app.Domain,
app: app,
appMetadata: appMeta{
status: "-",
version: "-",
updates: "-",
chaos: "-",
},
})
return initTableMsg{table: t}
}
type model struct {
apps []appPkg.App
numApps int
numServers int
numRecipes int
numFilteredApps int
numFilteredServers int
numFilteredRecipes int
initStatusGather bool
table table.Model
spinner spinner.Model
pollingStatus bool
width int
height int
err error
}
func (m model) getFilteredApps() []appPkg.App {
var servers []appPkg.App
for _, row := range m.table.GetVisibleRows() {
servers = append(servers, row.Data["app"].(appPkg.App))
}
return servers
}
func (m *model) updateCount() {
if m.table.GetIsFilterActive() {
apps := m.getFilteredApps()
m.numFilteredApps = len(apps)
m.numFilteredServers, m.numFilteredRecipes = getNumServersAndRecipes(apps)
} else {
m.numFilteredApps = m.numApps
m.numFilteredServers = m.numServers
m.numFilteredRecipes = m.numRecipes
}
}
// NOTE(d1): disable standard styles on filter match since we
// hack the description to also become a target
delegate := list.NewDefaultDelegate()
delegateStyles := list.NewDefaultItemStyles()
delegateStyles.FilterMatch = delegateStyles.NormalTitle
delegate.Styles = delegateStyles
func (m model) Init() tea.Cmd {
return tea.Batch(
func() tea.Msg { return initTable(m) },
m.spinner.Tick,
)
}
l := list.New(items, delegate, 0, 0)
l.Title = config.ABRA_DIR
l.StatusMessageLifetime = time.Second * 3
l.SetStatusBarItemName("app", "apps")
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
m.updateCount()
switch msg.String() {
case "q":
return m, tea.Quit
case "s":
if !m.table.GetIsFilterInputFocused() {
m.pollingStatus = true
return m, func() tea.Msg { return getAppsDeployStatus(m) }
}
listKeys := newListKeyMap()
l.AdditionalShortHelpKeys = func() []key.Binding {
return []key.Binding{
listKeys.status,
}
case initTableMsg:
m.table = msg.table
m.table = m.table.WithTargetWidth(m.width)
m.table = m.table.WithPageSize(calculateHeight(m))
if m.initStatusGather {
m.pollingStatus = true
return m, func() tea.Msg { return getAppsDeployStatus(m) }
}
case appsDeployStatusMsg:
m.pollingStatus = false
m.table = renderAppsDeployStatus(&m, msg)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.table = m.table.WithTargetWidth(m.width)
m.table = m.table.WithPageSize(calculateHeight(m))
case errorMsg:
m.err = msg
}
m.table, cmd = m.table.Update(msg)
cmds = append(cmds, cmd)
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func calculateHeight(m model) int {
return m.height/2 - 5
}
func (m model) View() string {
if m.err != nil {
return fmt.Sprintf("FATA: %v", m.err)
}
body := strings.Builder{}
body.WriteString(m.table.View() + "\n")
stats := fmt.Sprintf(
"[servers] %v • [apps] %v • [recipes] %v",
m.numFilteredServers, m.numFilteredApps, m.numFilteredRecipes,
)
help := "[q] quit • [/] filter • [s] status • [ctrl+u/d] page up/down"
body.WriteString(lipgloss.JoinHorizontal(lipgloss.Center, stats, " | ", help))
if m.pollingStatus {
body.WriteString(fmt.Sprintf(" | %s querying app status", m.spinner.View()))
} else {
body.WriteString(" | -")
}
return body.String()
}
func runTable(apps []appPkg.App) error {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
numServers, numRecipes := getNumServersAndRecipes(apps)
numApps := len(apps)
m := model{
apps: apps,
numApps: numApps,
numServers: numServers,
numRecipes: numRecipes,
numFilteredApps: numApps,
numFilteredServers: numServers,
numFilteredRecipes: numRecipes,
spinner: s,
initStatusGather: status,
apps: apps,
list: l,
initStatusGather: status,
}
p := tea.NewProgram(m, tea.WithAltScreen())

3
go.mod
View File

@ -16,7 +16,6 @@ require (
github.com/docker/cli v28.4.0+incompatible
github.com/docker/docker v28.4.0+incompatible
github.com/docker/go-units v0.5.0
github.com/evertras/bubble-table v0.19.2
github.com/go-git/go-git/v5 v5.16.2
github.com/google/go-cmp v0.7.0
github.com/leonelquinteros/gotext v1.7.2
@ -94,7 +93,6 @@ require (
github.com/morikuni/aec v1.0.0 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
@ -107,6 +105,7 @@ require (
github.com/prometheus/procfs v0.17.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect

9
go.sum
View File

@ -373,8 +373,6 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/evertras/bubble-table v0.19.2 h1:u77oiM6JlRR+CvS5FZc3Hz+J6iEsvEDcR5kO8OFb1Yw=
github.com/evertras/bubble-table v0.19.2/go.mod h1:ifHujS1YxwnYSOgcR2+m3GnJ84f7CVU/4kUOxUCjEbQ=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
@ -640,7 +638,6 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
@ -702,8 +699,6 @@ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
@ -818,8 +813,6 @@ github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
@ -831,6 +824,8 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=

View File

@ -224,6 +224,18 @@ func (a ByServerAndRecipe) Less(i, j int) bool {
return strings.ToLower(a[i].Server) < strings.ToLower(a[j].Server)
}
// ByServerAndRecipe sort a slice of Apps
type ByServerAndDomain []App
func (a ByServerAndDomain) Len() int { return len(a) }
func (a ByServerAndDomain) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByServerAndDomain) Less(i, j int) bool {
if a[i].Server == a[j].Server {
return strings.ToLower(a[i].Domain) < strings.ToLower(a[j].Domain)
}
return strings.ToLower(a[i].Server) < strings.ToLower(a[j].Server)
}
// ByRecipe sort a slice of Apps
type ByRecipe []App

240
vendor/github.com/charmbracelet/bubbles/help/help.go generated vendored Normal file
View File

@ -0,0 +1,240 @@
// Package help provides a simple help view for Bubble Tea applications.
package help
import (
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// KeyMap is a map of keybindings used to generate help. Since it's an
// interface it can be any type, though struct or a map[string][]key.Binding
// are likely candidates.
//
// Note that if a key is disabled (via key.Binding.SetEnabled) it will not be
// rendered in the help view, so in theory generated help should self-manage.
type KeyMap interface {
// ShortHelp returns a slice of bindings to be displayed in the short
// version of the help. The help bubble will render help in the order in
// which the help items are returned here.
ShortHelp() []key.Binding
// FullHelp returns an extended group of help items, grouped by columns.
// The help bubble will render the help in the order in which the help
// items are returned here.
FullHelp() [][]key.Binding
}
// Styles is a set of available style definitions for the Help bubble.
type Styles struct {
Ellipsis lipgloss.Style
// Styling for the short help
ShortKey lipgloss.Style
ShortDesc lipgloss.Style
ShortSeparator lipgloss.Style
// Styling for the full help
FullKey lipgloss.Style
FullDesc lipgloss.Style
FullSeparator lipgloss.Style
}
// Model contains the state of the help view.
type Model struct {
Width int
ShowAll bool // if true, render the "full" help menu
ShortSeparator string
FullSeparator string
// The symbol we use in the short help when help items have been truncated
// due to width. Periods of ellipsis by default.
Ellipsis string
Styles Styles
}
// New creates a new help view with some useful defaults.
func New() Model {
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#909090",
Dark: "#626262",
})
descStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#B2B2B2",
Dark: "#4A4A4A",
})
sepStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#DDDADA",
Dark: "#3C3C3C",
})
return Model{
ShortSeparator: " • ",
FullSeparator: " ",
Ellipsis: "…",
Styles: Styles{
ShortKey: keyStyle,
ShortDesc: descStyle,
ShortSeparator: sepStyle,
Ellipsis: sepStyle,
FullKey: keyStyle,
FullDesc: descStyle,
FullSeparator: sepStyle,
},
}
}
// NewModel creates a new help view with some useful defaults.
//
// Deprecated: use [New] instead.
var NewModel = New
// Update helps satisfy the Bubble Tea Model interface. It's a no-op.
func (m Model) Update(_ tea.Msg) (Model, tea.Cmd) {
return m, nil
}
// View renders the help view's current state.
func (m Model) View(k KeyMap) string {
if m.ShowAll {
return m.FullHelpView(k.FullHelp())
}
return m.ShortHelpView(k.ShortHelp())
}
// ShortHelpView renders a single line help view from a slice of keybindings.
// If the line is longer than the maximum width it will be gracefully
// truncated, showing only as many help items as possible.
func (m Model) ShortHelpView(bindings []key.Binding) string {
if len(bindings) == 0 {
return ""
}
var b strings.Builder
var totalWidth int
separator := m.Styles.ShortSeparator.Inline(true).Render(m.ShortSeparator)
for i, kb := range bindings {
if !kb.Enabled() {
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)
// Tail
if tail, ok := m.shouldAddItem(totalWidth, w); !ok {
if tail != "" {
b.WriteString(tail)
}
break
}
totalWidth += w
b.WriteString(str)
}
return b.String()
}
// FullHelpView renders help columns from a slice of key binding slices. Each
// top level slice entry renders into a column.
func (m Model) FullHelpView(groups [][]key.Binding) string {
if len(groups) == 0 {
return ""
}
// Linter note: at this time we don't think it's worth the additional
// code complexity involved in preallocating this slice.
//nolint:prealloc
var (
out []string
totalWidth int
separator = m.Styles.FullSeparator.Inline(true).Render(m.FullSeparator)
)
// Iterate over groups to build columns
for i, group := range groups {
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() {
continue
}
keys = append(keys, kb.Help().Key)
descriptions = append(descriptions, kb.Help().Desc)
}
// Column
col := lipgloss.JoinHorizontal(lipgloss.Top,
sep,
m.Styles.FullKey.Render(strings.Join(keys, "\n")),
" ",
m.Styles.FullDesc.Render(strings.Join(descriptions, "\n")),
)
w := lipgloss.Width(col)
// Tail
if tail, ok := m.shouldAddItem(totalWidth, w); !ok {
if tail != "" {
out = append(out, tail)
}
break
}
totalWidth += w
out = append(out, col)
}
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() {
return true
}
}
return false
}

71
vendor/github.com/charmbracelet/bubbles/list/README.md generated vendored Normal file
View File

@ -0,0 +1,71 @@
# Frequently Asked Questions
These are some of the most commonly asked questions regarding the `list` bubble.
## Adding Custom Items
There are a few things you need to do to create custom items. First off, they
need to implement the `list.Item` and `list.DefaultItem` interfaces.
```go
// Item is an item that appears in the list.
type Item interface {
// FilterValue is the value we use when filtering against this item when
// we're filtering the list.
FilterValue() string
}
```
```go
// DefaultItem describes an item designed to work with DefaultDelegate.
type DefaultItem interface {
Item
Title() string
Description() string
}
```
You can see a working example in our [Kancli][kancli] project built
explicitly for a tutorial on lists and composite views in Bubble Tea.
[VIDEO](https://youtu.be/ZA93qgdLUzM)
## Customizing Styles
Rendering (and behavior) for list items is done via the
[`ItemDelegate`][itemDelegate]
interface. It can be a little confusing at first, but it allows the list to be
very flexible and powerful.
If you just want to alter the default style you could do something like:
```go
import "github.com/charmbracelet/bubbles/list"
// Create a new default delegate
d := list.NewDefaultDelegate()
// Change colors
c := lipgloss.Color("#6f03fc")
d.Styles.SelectedTitle = d.Styles.SelectedTitle.Foreground(c).BorderLeftForeground(c)
d.Styles.SelectedDesc = d.Styles.SelectedTitle.Copy() // reuse the title style here
// Initailize the list model with our delegate
width, height := 80, 40
l := list.New(listItems, d, width, height)
// You can also change the delegate on the fly
l.SetDelegate(d)
```
This code would replace [this line][replacedLine] in the [`list-default`
example][listDefault].
For full control over the way list items are rendered you can also define your
own `ItemDelegate` too ([example][customDelegate]).
[kancli]: https://github.com/charmbracelet/kancli/blob/main/main.go#L45
[itemDelegate]: https://pkg.go.dev/github.com/charmbracelet/bubbles/list#ItemDelegate
[replacedLine]: https://github.com/charmbracelet/bubbletea/blob/main/examples/list-default/main.go#L77
[listDefault]: https://github.com/charmbracelet/bubbletea/tree/main/examples/list-default
[customDelegate]: https://github.com/charmbracelet/bubbletea/blob/main/examples/list-simple/main.go#L29-L50

View File

@ -0,0 +1,229 @@
package list
import (
"fmt"
"io"
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
// 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
// Characters 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) //nolint:mnd
s.NormalDesc = s.NormalTitle.
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.
Foreground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"})
s.DimmedTitle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}).
Padding(0, 0, 0, 2) //nolint:mnd
s.DimmedDesc = s.DimmedTitle.
Foreground(lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"})
s.FilterMatch = lipgloss.NewStyle().Underline(true)
return s
}
// DefaultItem describes an item 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 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
height int
spacing int
}
// NewDefaultDelegate creates a new delegate with default styles.
func NewDefaultDelegate() DefaultDelegate {
const defaultHeight = 2
const defaultSpacing = 1
return DefaultDelegate{
ShowDescription: true,
Styles: NewDefaultItemStyles(),
height: defaultHeight,
spacing: defaultSpacing,
}
}
// SetHeight sets delegate's preferred height.
func (d *DefaultDelegate) SetHeight(i int) {
d.height = i
}
// Height returns the delegate's preferred height.
// This has effect only if ShowDescription is true,
// otherwise height is always 1.
func (d DefaultDelegate) Height() int {
if d.ShowDescription {
return d.height
}
return 1
}
// SetSpacing sets 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
}
if m.width <= 0 {
// short-circuit
return
}
// Prevent text from exceeding list width
textwidth := m.width - s.NormalTitle.GetPaddingLeft() - s.NormalTitle.GetPaddingRight()
title = ansi.Truncate(title, textwidth, ellipsis)
if d.ShowDescription {
var lines []string
for i, line := range strings.Split(desc, "\n") {
if i >= d.height-1 {
break
}
lines = append(lines, ansi.Truncate(line, textwidth, ellipsis))
}
desc = strings.Join(lines, "\n")
}
// 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.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.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) //nolint: errcheck
return
}
fmt.Fprintf(w, "%s", title) //nolint: errcheck
}
// 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
}

97
vendor/github.com/charmbracelet/bubbles/list/keys.go generated vendored Normal file
View File

@ -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.
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")),
}
}

1328
vendor/github.com/charmbracelet/bubbles/list/list.go generated vendored Normal file

File diff suppressed because it is too large Load Diff

99
vendor/github.com/charmbracelet/bubbles/list/style.go generated vendored Normal file
View File

@ -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) //nolint:mnd
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) //nolint:mnd
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:mnd
s.HelpStyle = lipgloss.NewStyle().Padding(1, 0, 0, 2) //nolint:mnd
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
}

View File

@ -0,0 +1,214 @@
// Package paginator provides a Bubble Tea package for calculating pagination
// and rendering pagination info. Note that this package does not render actual
// pages: it's purely for handling keystrokes related to pagination, and
// rendering pagination status.
package paginator
import (
"fmt"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
)
// Type specifies the way we render pagination.
type Type int
// Pagination rendering options.
const (
Arabic Type = iota
Dots
)
// KeyMap is the key bindings for different actions within the paginator.
type KeyMap struct {
PrevPage key.Binding
NextPage key.Binding
}
// DefaultKeyMap is the default set of key bindings for navigating and acting
// upon the paginator.
var DefaultKeyMap = KeyMap{
PrevPage: key.NewBinding(key.WithKeys("pgup", "left", "h")),
NextPage: key.NewBinding(key.WithKeys("pgdown", "right", "l")),
}
// Model is the Bubble Tea model for this user interface.
type Model struct {
// Type configures how the pagination is rendered (Arabic, Dots).
Type Type
// Page is the current page number.
Page int
// PerPage is the number of items per page.
PerPage int
// TotalPages is the total number of pages.
TotalPages int
// ActiveDot is used to mark the current page under the Dots display type.
ActiveDot string
// InactiveDot is used to mark inactive pages under the Dots display type.
InactiveDot string
// ArabicFormat is the printf-style format to use for the Arabic display type.
ArabicFormat string
// KeyMap encodes the keybindings recognized by the widget.
KeyMap KeyMap
// Deprecated: customize [KeyMap] instead.
UsePgUpPgDownKeys bool
// Deprecated: customize [KeyMap] instead.
UseLeftRightKeys bool
// Deprecated: customize [KeyMap] instead.
UseUpDownKeys bool
// Deprecated: customize [KeyMap] instead.
UseHLKeys bool
// Deprecated: customize [KeyMap] instead.
UseJKKeys bool
}
// SetTotalPages is a helper function for calculating the total number of pages
// from a given number of items. Its 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.
func (m *Model) SetTotalPages(items int) int {
if items < 1 {
return m.TotalPages
}
n := items / m.PerPage
if items%m.PerPage > 0 {
n++
}
m.TotalPages = n
return n
}
// ItemsOnPage is a helper function for returning the number of items on the
// current page given the total number of items passed as an argument.
func (m Model) ItemsOnPage(totalItems int) int {
if totalItems < 1 {
return 0
}
start, end := m.GetSliceBounds(totalItems)
return end - start
}
// GetSliceBounds is a helper function for paginating slices. Pass the length
// of the slice you're rendering and you'll receive the start and end bounds
// corresponding to the pagination. For example:
//
// bunchOfStuff := []stuff{...}
// start, end := model.GetSliceBounds(len(bunchOfStuff))
// sliceToRender := bunchOfStuff[start:end]
func (m *Model) GetSliceBounds(length int) (start int, end int) {
start = m.Page * m.PerPage
end = min(m.Page*m.PerPage+m.PerPage, length)
return start, end
}
// PrevPage is a helper function for navigating one page backward. It will not
// page beyond the first page (i.e. page 0).
func (m *Model) PrevPage() {
if m.Page > 0 {
m.Page--
}
}
// NextPage is a helper function for navigating one page forward. It will not
// page beyond the last page (i.e. totalPages - 1).
func (m *Model) NextPage() {
if !m.OnLastPage() {
m.Page++
}
}
// OnLastPage returns whether or not we're on the last page.
func (m Model) OnLastPage() bool {
return m.Page == m.TotalPages-1
}
// OnFirstPage returns whether or not we're on the first page.
func (m Model) OnFirstPage() bool {
return m.Page == 0
}
// Option is used to set options in New.
type Option func(*Model)
// New creates a new model with defaults.
func New(opts ...Option) Model {
m := Model{
Type: Arabic,
Page: 0,
PerPage: 1,
TotalPages: 1,
KeyMap: DefaultKeyMap,
ActiveDot: "•",
InactiveDot: "○",
ArabicFormat: "%d/%d",
}
for _, opt := range opts {
opt(&m)
}
return m
}
// NewModel creates a new model with defaults.
//
// Deprecated: use [New] instead.
var NewModel = New
// WithTotalPages sets the total pages.
func WithTotalPages(totalPages int) Option {
return func(m *Model) {
m.TotalPages = totalPages
}
}
// WithPerPage sets the total pages.
func WithPerPage(perPage int) Option {
return func(m *Model) {
m.PerPage = perPage
}
}
// Update is the Tea update function which binds keystrokes to pagination.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.NextPage):
m.NextPage()
case key.Matches(msg, m.KeyMap.PrevPage):
m.PrevPage()
}
}
return m, nil
}
// View renders the pagination to a string.
func (m Model) View() string {
switch m.Type { //nolint:exhaustive
case Dots:
return m.dotsView()
default:
return m.arabicView()
}
}
func (m Model) dotsView() string {
var s string
for i := 0; i < m.TotalPages; i++ {
if i == m.Page {
s += m.ActiveDot
continue
}
s += m.InactiveDot
}
return s
}
func (m Model) arabicView() string {
return fmt.Sprintf(m.ArabicFormat, m.Page+1, m.TotalPages)
}

View File

@ -1,439 +0,0 @@
package table
import "github.com/charmbracelet/lipgloss"
// Border defines the borders in and around the table.
type Border struct {
Top string
Left string
Right string
Bottom string
TopRight string
TopLeft string
BottomRight string
BottomLeft string
TopJunction string
LeftJunction string
RightJunction string
BottomJunction string
InnerJunction string
InnerDivider string
// Styles for 2x2 tables and larger
styleMultiTopLeft lipgloss.Style
styleMultiTop lipgloss.Style
styleMultiTopRight lipgloss.Style
styleMultiRight lipgloss.Style
styleMultiBottomRight lipgloss.Style
styleMultiBottom lipgloss.Style
styleMultiBottomLeft lipgloss.Style
styleMultiLeft lipgloss.Style
styleMultiInner lipgloss.Style
// Styles for a single column table
styleSingleColumnTop lipgloss.Style
styleSingleColumnInner lipgloss.Style
styleSingleColumnBottom lipgloss.Style
// Styles for a single row table
styleSingleRowLeft lipgloss.Style
styleSingleRowInner lipgloss.Style
styleSingleRowRight lipgloss.Style
// Style for a table with only one cell
styleSingleCell lipgloss.Style
// Style for the footer
styleFooter lipgloss.Style
}
var (
// https://www.w3.org/TR/xml-entity-names/025.html
borderDefault = Border{
Top: "━",
Left: "┃",
Right: "┃",
Bottom: "━",
TopRight: "┓",
TopLeft: "┏",
BottomRight: "┛",
BottomLeft: "┗",
TopJunction: "┳",
LeftJunction: "┣",
RightJunction: "┫",
BottomJunction: "┻",
InnerJunction: "╋",
InnerDivider: "┃",
}
borderRounded = Border{
Top: "─",
Left: "│",
Right: "│",
Bottom: "─",
TopRight: "╮",
TopLeft: "╭",
BottomRight: "╯",
BottomLeft: "╰",
TopJunction: "┬",
LeftJunction: "├",
RightJunction: "┤",
BottomJunction: "┴",
InnerJunction: "┼",
InnerDivider: "│",
}
)
func init() {
borderDefault.generateStyles()
borderRounded.generateStyles()
}
func (b *Border) generateStyles() {
b.generateMultiStyles()
b.generateSingleColumnStyles()
b.generateSingleRowStyles()
b.generateSingleCellStyle()
// The footer is a single cell with the top taken off... usually. We can
// re-enable the top if needed this way for certain format configurations.
b.styleFooter = b.styleSingleCell.Copy().
Align(lipgloss.Right).
BorderBottom(true).
BorderRight(true).
BorderLeft(true)
}
func (b *Border) styleLeftWithFooter(original lipgloss.Style) lipgloss.Style {
border := original.GetBorderStyle()
border.BottomLeft = b.LeftJunction
return original.Copy().BorderStyle(border)
}
func (b *Border) styleRightWithFooter(original lipgloss.Style) lipgloss.Style {
border := original.GetBorderStyle()
border.BottomRight = b.RightJunction
return original.Copy().BorderStyle(border)
}
func (b *Border) styleBothWithFooter(original lipgloss.Style) lipgloss.Style {
border := original.GetBorderStyle()
border.BottomLeft = b.LeftJunction
border.BottomRight = b.RightJunction
return original.Copy().BorderStyle(border)
}
// This function is long, but it's just repetitive...
//
//nolint:funlen
func (b *Border) generateMultiStyles() {
b.styleMultiTopLeft = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
TopLeft: b.TopLeft,
Top: b.Top,
TopRight: b.TopJunction,
Right: b.InnerDivider,
BottomRight: b.InnerJunction,
Bottom: b.Bottom,
BottomLeft: b.LeftJunction,
Left: b.Left,
},
)
b.styleMultiTop = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Right: b.InnerDivider,
Bottom: b.Bottom,
TopRight: b.TopJunction,
BottomRight: b.InnerJunction,
},
).BorderTop(true).BorderBottom(true).BorderRight(true)
b.styleMultiTopRight = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Right: b.Right,
Bottom: b.Bottom,
TopRight: b.TopRight,
BottomRight: b.RightJunction,
},
).BorderTop(true).BorderBottom(true).BorderRight(true)
b.styleMultiLeft = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Left: b.Left,
Right: b.InnerDivider,
},
).BorderRight(true).BorderLeft(true)
b.styleMultiRight = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Right: b.Right,
},
).BorderRight(true)
b.styleMultiInner = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Right: b.InnerDivider,
},
).BorderRight(true)
b.styleMultiBottomLeft = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Left: b.Left,
Right: b.InnerDivider,
Bottom: b.Bottom,
BottomLeft: b.BottomLeft,
BottomRight: b.BottomJunction,
},
).BorderLeft(true).BorderBottom(true).BorderRight(true)
b.styleMultiBottom = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Right: b.InnerDivider,
Bottom: b.Bottom,
BottomRight: b.BottomJunction,
},
).BorderBottom(true).BorderRight(true)
b.styleMultiBottomRight = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Right: b.Right,
Bottom: b.Bottom,
BottomRight: b.BottomRight,
},
).BorderBottom(true).BorderRight(true)
}
func (b *Border) generateSingleColumnStyles() {
b.styleSingleColumnTop = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Left: b.Left,
Right: b.Right,
Bottom: b.Bottom,
TopLeft: b.TopLeft,
TopRight: b.TopRight,
BottomLeft: b.LeftJunction,
BottomRight: b.RightJunction,
},
)
b.styleSingleColumnInner = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Left: b.Left,
Right: b.Right,
},
).BorderRight(true).BorderLeft(true)
b.styleSingleColumnBottom = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Left: b.Left,
Right: b.Right,
Bottom: b.Bottom,
BottomLeft: b.BottomLeft,
BottomRight: b.BottomRight,
},
).BorderRight(true).BorderLeft(true).BorderBottom(true)
}
func (b *Border) generateSingleRowStyles() {
b.styleSingleRowLeft = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Left: b.Left,
Right: b.InnerDivider,
Bottom: b.Bottom,
BottomLeft: b.BottomLeft,
BottomRight: b.BottomJunction,
TopRight: b.TopJunction,
TopLeft: b.TopLeft,
},
)
b.styleSingleRowInner = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Right: b.InnerDivider,
Bottom: b.Bottom,
BottomRight: b.BottomJunction,
TopRight: b.TopJunction,
},
).BorderTop(true).BorderBottom(true).BorderRight(true)
b.styleSingleRowRight = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Right: b.Right,
Bottom: b.Bottom,
BottomRight: b.BottomRight,
TopRight: b.TopRight,
},
).BorderTop(true).BorderBottom(true).BorderRight(true)
}
func (b *Border) generateSingleCellStyle() {
b.styleSingleCell = lipgloss.NewStyle().BorderStyle(
lipgloss.Border{
Top: b.Top,
Left: b.Left,
Right: b.Right,
Bottom: b.Bottom,
BottomLeft: b.BottomLeft,
BottomRight: b.BottomRight,
TopRight: b.TopRight,
TopLeft: b.TopLeft,
},
)
}
// BorderDefault uses the basic square border, useful to reset the border if
// it was changed somehow.
func (m Model) BorderDefault() Model {
// Already generated styles
m.border = borderDefault
return m
}
// BorderRounded uses a thin, rounded border.
func (m Model) BorderRounded() Model {
// Already generated styles
m.border = borderRounded
return m
}
// Border uses the given border components to render the table.
func (m Model) Border(border Border) Model {
border.generateStyles()
m.border = border
return m
}
type borderStyleRow struct {
left lipgloss.Style
inner lipgloss.Style
right lipgloss.Style
}
func (b *borderStyleRow) inherit(s lipgloss.Style) {
b.left = b.left.Copy().Inherit(s)
b.inner = b.inner.Copy().Inherit(s)
b.right = b.right.Copy().Inherit(s)
}
// There's a lot of branches here, but splitting it up further would make it
// harder to follow. So just be careful with comments and make sure it's tested!
//
//nolint:nestif
func (m Model) styleHeaders() borderStyleRow {
hasRows := len(m.GetVisibleRows()) > 0 || m.calculatePadding(0) > 0
singleColumn := len(m.columns) == 1
styles := borderStyleRow{}
// Possible configurations:
// - Single cell
// - Single row
// - Single column
// - Multi
if singleColumn {
if hasRows {
// Single column
styles.left = m.border.styleSingleColumnTop
styles.inner = styles.left
styles.right = styles.left
} else {
// Single cell
styles.left = m.border.styleSingleCell
styles.inner = styles.left
styles.right = styles.left
if m.hasFooter() {
styles.left = m.border.styleBothWithFooter(styles.left)
}
}
} else if !hasRows {
// Single row
styles.left = m.border.styleSingleRowLeft
styles.inner = m.border.styleSingleRowInner
styles.right = m.border.styleSingleRowRight
if m.hasFooter() {
styles.left = m.border.styleLeftWithFooter(styles.left)
styles.right = m.border.styleRightWithFooter(styles.right)
}
} else {
// Multi
styles.left = m.border.styleMultiTopLeft
styles.inner = m.border.styleMultiTop
styles.right = m.border.styleMultiTopRight
}
styles.inherit(m.headerStyle)
return styles
}
func (m Model) styleRows() (inner borderStyleRow, last borderStyleRow) {
if len(m.columns) == 1 {
inner.left = m.border.styleSingleColumnInner
inner.inner = inner.left
inner.right = inner.left
last.left = m.border.styleSingleColumnBottom
if m.hasFooter() {
last.left = m.border.styleBothWithFooter(last.left)
}
last.inner = last.left
last.right = last.left
} else {
inner.left = m.border.styleMultiLeft
inner.inner = m.border.styleMultiInner
inner.right = m.border.styleMultiRight
last.left = m.border.styleMultiBottomLeft
last.inner = m.border.styleMultiBottom
last.right = m.border.styleMultiBottomRight
if m.hasFooter() {
last.left = m.border.styleLeftWithFooter(last.left)
last.right = m.border.styleRightWithFooter(last.right)
}
}
return inner, last
}

View File

@ -1,36 +0,0 @@
package table
// Keep compatibility with Go 1.21 by re-declaring min.
//
//nolint:predeclared
func min(x, y int) int {
if x < y {
return x
}
return y
}
// Keep compatibility with Go 1.21 by re-declaring max.
//
//nolint:predeclared
func max(x, y int) int {
if x > y {
return x
}
return y
}
// These var names are fine for this little function
//
//nolint:varnamelen
func gcd(x, y int) int {
if x == 0 {
return y
} else if y == 0 {
return x
}
return gcd(y%x, x)
}

View File

@ -1,60 +0,0 @@
package table
import "github.com/charmbracelet/lipgloss"
// StyledCell represents a cell in the table that has a particular style applied.
// The cell style takes highest precedence and will overwrite more general styles
// from the row, column, or table as a whole. This style should be generally
// limited to colors, font style, and alignments - spacing style such as margin
// will break the table format.
type StyledCell struct {
// Data is the content of the cell.
Data any
// Style is the specific style to apply. This is ignored if StyleFunc is not nil.
Style lipgloss.Style
// StyleFunc is a function that takes the row/column of the cell and
// returns a lipgloss.Style allowing for dynamic styling based on the cell's
// content or position. Overrides Style if set.
StyleFunc StyledCellFunc
}
// StyledCellFuncInput is the input to the StyledCellFunc. Sent as a struct
// to allow for future additions without breaking changes.
type StyledCellFuncInput struct {
// Data is the data in the cell.
Data any
// Column is the column that the cell belongs to.
Column Column
// Row is the row that the cell belongs to.
Row Row
// GlobalMetadata is the global table metadata that's been set by WithGlobalMetadata
GlobalMetadata map[string]any
}
// StyledCellFunc is a function that takes various information about the cell and
// returns a lipgloss.Style allowing for easier dynamic styling based on the cell's
// content or position.
type StyledCellFunc = func(input StyledCellFuncInput) lipgloss.Style
// NewStyledCell creates an entry that can be set in the row data and show as
// styled with the given style.
func NewStyledCell(data any, style lipgloss.Style) StyledCell {
return StyledCell{
Data: data,
Style: style,
}
}
// NewStyledCellWithStyleFunc creates an entry that can be set in the row data and show as
// styled with the given style function.
func NewStyledCellWithStyleFunc(data any, styleFunc StyledCellFunc) StyledCell {
return StyledCell{
Data: data,
StyleFunc: styleFunc,
}
}

View File

@ -1,118 +0,0 @@
package table
import (
"github.com/charmbracelet/lipgloss"
)
// Column is a column in the table.
type Column struct {
title string
key string
width int
flexFactor int
filterable bool
style lipgloss.Style
fmtString string
}
// NewColumn creates a new fixed-width column with the given information.
func NewColumn(key, title string, width int) Column {
return Column{
key: key,
title: title,
width: width,
filterable: false,
}
}
// NewFlexColumn creates a new flexible width column that tries to fill in the
// total table width. If multiple flex columns exist, each will measure against
// each other depending on their flexFactor. For example, if both have a flexFactor
// of 1, they will have equal width. If one has a flexFactor of 1 and the other
// has a flexFactor of 3, the second will be 3 times larger than the first. You
// must use WithTargetWidth if you have any flex columns, so that the table knows
// how much width it should fill.
func NewFlexColumn(key, title string, flexFactor int) Column {
return Column{
key: key,
title: title,
flexFactor: max(flexFactor, 1),
}
}
// WithStyle applies a style to the column as a whole.
func (c Column) WithStyle(style lipgloss.Style) Column {
c.style = style.Copy().Width(c.width)
return c
}
// WithFiltered sets whether the column should be considered for filtering (true)
// or not (false).
func (c Column) WithFiltered(filterable bool) Column {
c.filterable = filterable
return c
}
// WithFormatString sets the format string used by fmt.Sprintf to display the data.
// If not set, the default is "%v" for all data types. Intended mainly for
// numeric formatting.
//
// Since data is of the any type, make sure that all data in the column
// is of the expected type or the format may fail. For example, hardcoding '3'
// instead of '3.0' and using '%.2f' will fail because '3' is an integer.
func (c Column) WithFormatString(fmtString string) Column {
c.fmtString = fmtString
return c
}
func (c *Column) isFlex() bool {
return c.flexFactor != 0
}
// Title returns the title of the column.
func (c Column) Title() string {
return c.title
}
// Key returns the key of the column.
func (c Column) Key() string {
return c.key
}
// Width returns the width of the column.
func (c Column) Width() int {
return c.width
}
// FlexFactor returns the flex factor of the column.
func (c Column) FlexFactor() int {
return c.flexFactor
}
// IsFlex returns whether the column is a flex column.
func (c Column) IsFlex() bool {
return c.isFlex()
}
// Filterable returns whether the column is filterable.
func (c Column) Filterable() bool {
return c.filterable
}
// Style returns the style of the column.
func (c Column) Style() lipgloss.Style {
return c.style
}
// FmtString returns the format string of the column.
func (c Column) FmtString() string {
return c.fmtString
}

View File

@ -1,67 +0,0 @@
package table
import "time"
// This is just a bunch of data type checks, so... no linting here
//
//nolint:cyclop
func asInt(data any) (int64, bool) {
switch val := data.(type) {
case int:
return int64(val), true
case int8:
return int64(val), true
case int16:
return int64(val), true
case int32:
return int64(val), true
case int64:
return val, true
case uint:
// #nosec: G115
return int64(val), true
case uint8:
return int64(val), true
case uint16:
return int64(val), true
case uint32:
return int64(val), true
case uint64:
// #nosec: G115
return int64(val), true
case time.Duration:
return int64(val), true
case StyledCell:
return asInt(val.Data)
}
return 0, false
}
func asNumber(data any) (float64, bool) {
switch val := data.(type) {
case float32:
return float64(val), true
case float64:
return val, true
case StyledCell:
return asNumber(val.Data)
}
intVal, isInt := asInt(data)
return float64(intVal), isInt
}

View File

@ -1,116 +0,0 @@
package table
import (
"github.com/charmbracelet/lipgloss"
)
func (m *Model) recalculateWidth() {
if m.targetTotalWidth != 0 {
m.totalWidth = m.targetTotalWidth
} else {
total := 0
for _, column := range m.columns {
total += column.width
}
m.totalWidth = total + len(m.columns) + 1
}
updateColumnWidths(m.columns, m.targetTotalWidth)
m.recalculateLastHorizontalColumn()
}
// Updates column width in-place. This could be optimized but should be called
// very rarely so we prioritize simplicity over performance here.
func updateColumnWidths(cols []Column, totalWidth int) {
totalFlexWidth := totalWidth - len(cols) - 1
totalFlexFactor := 0
flexGCD := 0
for index, col := range cols {
if !col.isFlex() {
totalFlexWidth -= col.width
cols[index].style = col.style.Width(col.width)
} else {
totalFlexFactor += col.flexFactor
flexGCD = gcd(flexGCD, col.flexFactor)
}
}
if totalFlexFactor == 0 {
return
}
// We use the GCD here because otherwise very large values won't divide
// nicely as ints
totalFlexFactor /= flexGCD
flexUnit := totalFlexWidth / totalFlexFactor
leftoverWidth := totalFlexWidth % totalFlexFactor
for index := range cols {
if !cols[index].isFlex() {
continue
}
width := flexUnit * (cols[index].flexFactor / flexGCD)
if leftoverWidth > 0 {
width++
leftoverWidth--
}
if index == len(cols)-1 {
width += leftoverWidth
leftoverWidth = 0
}
width = max(width, 1)
cols[index].width = width
// Take borders into account for the actual style
cols[index].style = cols[index].style.Width(width)
}
}
func (m *Model) recalculateHeight() {
header := m.renderHeaders()
headerHeight := 1 // Header always has the top border
if m.headerVisible {
headerHeight = lipgloss.Height(header)
}
footer := m.renderFooter(lipgloss.Width(header), false)
var footerHeight int
if footer != "" {
footerHeight = lipgloss.Height(footer)
}
m.metaHeight = headerHeight + footerHeight
}
func (m *Model) calculatePadding(numRows int) int {
if m.minimumHeight == 0 {
return 0
}
padding := m.minimumHeight - m.metaHeight - numRows - 1 // additional 1 for bottom border
if padding == 0 && numRows == 0 {
// This is an edge case where we want to add 1 additional line of height, i.e.
// add a border without an empty row. However, this is not possible, so we need
// to add an extra row which will result in the table being 1 row taller than
// the requested minimum height.
return 1
}
if padding < 0 {
// Table is already larger than minimum height, do nothing.
return 0
}
return padding
}

View File

@ -1,39 +0,0 @@
/*
Package table contains a Bubble Tea component for an interactive and customizable
table.
The simplest useful table can be created with table.New(...).WithRows(...). Row
data should map to the column keys, as shown below. Note that extra data will
simply not be shown, while missing data will be safely blank in the row's cell.
const (
// This is not necessary, but recommended to avoid typos
columnKeyName = "name"
columnKeyCount = "count"
)
// Define the columns and how they appear
columns := []table.Column{
table.NewColumn(columnKeyName, "Name", 10),
table.NewColumn(columnKeyCount, "Count", 6),
}
// Define the data that will be in the table, mapping to the column keys
rows := []table.Row{
table.NewRow(table.RowData{
columnKeyName: "Cheeseburger",
columnKeyCount: 3,
}),
table.NewRow(table.RowData{
columnKeyName: "Fries",
columnKeyCount: 2,
}),
}
// Create the table
tbl := table.New(columns).WithRows(rows)
// Use it like any Bubble Tea component in your view
tbl.View()
*/
package table

View File

@ -1,60 +0,0 @@
package table
// UserEvent is some state change that has occurred due to user input. These will
// ONLY be generated when a user has interacted directly with the table. These
// will NOT be generated when code programmatically changes values in the table.
type UserEvent any
func (m *Model) appendUserEvent(e UserEvent) {
m.lastUpdateUserEvents = append(m.lastUpdateUserEvents, e)
}
func (m *Model) clearUserEvents() {
m.lastUpdateUserEvents = nil
}
// GetLastUpdateUserEvents returns a list of events that happened due to user
// input in the last Update call. This is useful to look for triggers such as
// whether the user moved to a new highlighted row.
func (m *Model) GetLastUpdateUserEvents() []UserEvent {
// Most common case
if len(m.lastUpdateUserEvents) == 0 {
return nil
}
returned := make([]UserEvent, len(m.lastUpdateUserEvents))
// Slightly wasteful but helps guarantee immutability, and this should only
// have data very rarely so this is fine
copy(returned, m.lastUpdateUserEvents)
return returned
}
// UserEventHighlightedIndexChanged indicates that the user has scrolled to a new
// row.
type UserEventHighlightedIndexChanged struct {
// PreviousRow is the row that was selected before the change.
PreviousRowIndex int
// SelectedRow is the row index that is now selected
SelectedRowIndex int
}
// UserEventRowSelectToggled indicates that the user has either selected or
// deselected a row by toggling the selection. The event contains information
// about which row index was selected and whether it was selected or deselected.
type UserEventRowSelectToggled struct {
RowIndex int
IsSelected bool
}
// UserEventFilterInputFocused indicates that the user has focused the filter
// text input, so that any other typing will type into the filter field. Only
// activates for the built-in filter text box.
type UserEventFilterInputFocused struct{}
// UserEventFilterInputUnfocused indicates that the user has unfocused the filter
// text input, which means the user is done typing into the filter field. Only
// activates for the built-in filter text box.
type UserEventFilterInputUnfocused struct{}

View File

@ -1,164 +0,0 @@
package table
import (
"fmt"
"strings"
)
// FilterFuncInput is the input to a FilterFunc. It's a struct so we can add more things later
// without breaking compatibility.
type FilterFuncInput struct {
// Columns is a list of the columns of the table
Columns []Column
// Row is the row that's being considered for filtering
Row Row
// GlobalMetadata is an arbitrary set of metadata from the table set by WithGlobalMetadata
GlobalMetadata map[string]any
// Filter is the filter string input to consider
Filter string
}
// FilterFunc takes a FilterFuncInput and returns true if the row should be visible,
// or false if the row should be hidden.
type FilterFunc func(FilterFuncInput) bool
func (m Model) getFilteredRows(rows []Row) []Row {
filterInputValue := m.filterTextInput.Value()
if !m.filtered || filterInputValue == "" {
return rows
}
filteredRows := make([]Row, 0)
for _, row := range rows {
var availableFilterFunc FilterFunc
if m.filterFunc != nil {
availableFilterFunc = m.filterFunc
} else {
availableFilterFunc = filterFuncContains
}
if availableFilterFunc(FilterFuncInput{
Columns: m.columns,
Row: row,
Filter: filterInputValue,
GlobalMetadata: m.metadata,
}) {
filteredRows = append(filteredRows, row)
}
}
return filteredRows
}
// filterFuncContains returns a filterFunc that performs case-insensitive
// "contains" matching over all filterable columns in a row.
func filterFuncContains(input FilterFuncInput) bool {
if input.Filter == "" {
return true
}
checkedAny := false
filterLower := strings.ToLower(input.Filter)
for _, column := range input.Columns {
if !column.filterable {
continue
}
checkedAny = true
data, ok := input.Row.Data[column.key]
if !ok {
continue
}
// Extract internal StyledCell data
switch dataV := data.(type) {
case StyledCell:
data = dataV.Data
}
var target string
switch dataV := data.(type) {
case string:
target = dataV
case fmt.Stringer:
target = dataV.String()
default:
target = fmt.Sprintf("%v", data)
}
if strings.Contains(strings.ToLower(target), filterLower) {
return true
}
}
return !checkedAny
}
// filterFuncFuzzy returns a filterFunc that performs case-insensitive fuzzy
// matching (subsequence) over the concatenation of all filterable column values.
func filterFuncFuzzy(input FilterFuncInput) bool {
filter := strings.TrimSpace(input.Filter)
if filter == "" {
return true
}
var builder strings.Builder
for _, col := range input.Columns {
if !col.filterable {
continue
}
value, ok := input.Row.Data[col.key]
if !ok {
continue
}
if sc, ok := value.(StyledCell); ok {
value = sc.Data
}
builder.WriteString(fmt.Sprint(value)) // uses Stringer if implemented
builder.WriteByte(' ')
}
haystack := strings.ToLower(builder.String())
if haystack == "" {
return false
}
for _, token := range strings.Fields(strings.ToLower(filter)) {
if !fuzzySubsequenceMatch(haystack, token) {
return false
}
}
return true
}
// fuzzySubsequenceMatch returns true if all runes in needle appear in order
// within haystack (not necessarily contiguously). Case must be normalized by caller.
func fuzzySubsequenceMatch(haystack, needle string) bool {
if needle == "" {
return true
}
haystackIndex, needleIndex := 0, 0
haystackRunes := []rune(haystack)
needleRunes := []rune(needle)
for haystackIndex < len(haystackRunes) && needleIndex < len(needleRunes) {
if haystackRunes[haystackIndex] == needleRunes[needleIndex] {
needleIndex++
}
haystackIndex++
}
return needleIndex == len(needleRunes)
}

View File

@ -1,51 +0,0 @@
package table
import (
"fmt"
"strings"
)
func (m Model) hasFooter() bool {
return m.footerVisible && (m.staticFooter != "" || m.pageSize != 0 || m.filtered)
}
func (m Model) renderFooter(width int, includeTop bool) string {
if !m.hasFooter() {
return ""
}
const borderAdjustment = 2
styleFooter := m.baseStyle.Copy().Inherit(m.border.styleFooter).Width(width - borderAdjustment)
if includeTop {
styleFooter = styleFooter.BorderTop(true)
}
if m.staticFooter != "" {
return styleFooter.Render(m.staticFooter)
}
sections := []string{}
if m.filtered && (m.filterTextInput.Focused() || m.filterTextInput.Value() != "") {
sections = append(sections, m.filterTextInput.View())
}
// paged feature enabled
if m.pageSize != 0 {
str := fmt.Sprintf("%d/%d", m.CurrentPage(), m.MaxPages())
if m.filtered && m.filterTextInput.Focused() {
// Need to apply inline style here in case of filter input cursor, because
// the input cursor resets the style after rendering. Note that Inline(true)
// creates a copy, so it's safe to use here without mutating the underlying
// base style.
str = m.baseStyle.Inline(true).Render(str)
}
sections = append(sections, str)
}
footerText := strings.Join(sections, " ")
return styleFooter.Render(footerText)
}

View File

@ -1,93 +0,0 @@
package table
import "github.com/charmbracelet/lipgloss"
// This is long and could use some refactoring in the future, but unsure of how
// to pick it apart right now.
//
//nolint:funlen,cyclop
func (m Model) renderHeaders() string {
headerStrings := []string{}
totalRenderedWidth := 0
headerStyles := m.styleHeaders()
renderHeader := func(column Column, borderStyle lipgloss.Style) string {
borderStyle = borderStyle.Inherit(column.style).Inherit(m.baseStyle)
headerSection := limitStr(column.title, column.width)
return borderStyle.Render(headerSection)
}
for columnIndex, column := range m.columns {
var borderStyle lipgloss.Style
if m.horizontalScrollOffsetCol > 0 && columnIndex == m.horizontalScrollFreezeColumnsCount {
if columnIndex == 0 {
borderStyle = headerStyles.left.Copy()
} else {
borderStyle = headerStyles.inner.Copy()
}
rendered := renderHeader(genOverflowColumnLeft(1), borderStyle)
totalRenderedWidth += lipgloss.Width(rendered)
headerStrings = append(headerStrings, rendered)
}
if columnIndex >= m.horizontalScrollFreezeColumnsCount &&
columnIndex < m.horizontalScrollOffsetCol+m.horizontalScrollFreezeColumnsCount {
continue
}
if len(headerStrings) == 0 {
borderStyle = headerStyles.left.Copy()
} else if columnIndex < len(m.columns)-1 {
borderStyle = headerStyles.inner.Copy()
} else {
borderStyle = headerStyles.right.Copy()
}
rendered := renderHeader(column, borderStyle)
if m.maxTotalWidth != 0 {
renderedWidth := lipgloss.Width(rendered)
const (
borderAdjustment = 1
overflowColWidth = 2
)
targetWidth := m.maxTotalWidth - overflowColWidth
if columnIndex == len(m.columns)-1 {
// If this is the last header, we don't need to account for the
// overflow arrow column
targetWidth = m.maxTotalWidth
}
if totalRenderedWidth+renderedWidth > targetWidth {
overflowWidth := m.maxTotalWidth - totalRenderedWidth - borderAdjustment
overflowStyle := genOverflowStyle(headerStyles.right, overflowWidth)
overflowColumn := genOverflowColumnRight(overflowWidth)
overflowStr := renderHeader(overflowColumn, overflowStyle)
headerStrings = append(headerStrings, overflowStr)
break
}
totalRenderedWidth += renderedWidth
}
headerStrings = append(headerStrings, rendered)
}
headerBlock := lipgloss.JoinHorizontal(lipgloss.Bottom, headerStrings...)
return headerBlock
}

View File

@ -1,120 +0,0 @@
package table
import "github.com/charmbracelet/bubbles/key"
// KeyMap defines the keybindings for the table when it's focused.
type KeyMap struct {
RowDown key.Binding
RowUp key.Binding
RowSelectToggle key.Binding
PageDown key.Binding
PageUp key.Binding
PageFirst key.Binding
PageLast key.Binding
// Filter allows the user to start typing and filter the rows.
Filter key.Binding
// FilterBlur is the key that stops the user's input from typing into the filter.
FilterBlur key.Binding
// FilterClear will clear the filter while it's blurred.
FilterClear key.Binding
// ScrollRight will move one column to the right when overflow occurs.
ScrollRight key.Binding
// ScrollLeft will move one column to the left when overflow occurs.
ScrollLeft key.Binding
}
// DefaultKeyMap returns a set of sensible defaults for controlling a focused table with help text.
func DefaultKeyMap() KeyMap {
return KeyMap{
RowDown: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "move down"),
),
RowUp: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "move up"),
),
RowSelectToggle: key.NewBinding(
key.WithKeys(" ", "enter"),
key.WithHelp("<space>/enter", "select row"),
),
PageDown: key.NewBinding(
key.WithKeys("right", "l", "pgdown"),
key.WithHelp("→/h/page down", "next page"),
),
PageUp: key.NewBinding(
key.WithKeys("left", "h", "pgup"),
key.WithHelp("←/h/page up", "previous page"),
),
PageFirst: key.NewBinding(
key.WithKeys("home", "g"),
key.WithHelp("home/g", "first page"),
),
PageLast: key.NewBinding(
key.WithKeys("end", "G"),
key.WithHelp("end/G", "last page"),
),
Filter: key.NewBinding(
key.WithKeys("/"),
key.WithHelp("/", "filter"),
),
FilterBlur: key.NewBinding(
key.WithKeys("enter", "esc"),
key.WithHelp("enter/esc", "unfocus"),
),
FilterClear: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "clear filter"),
),
ScrollRight: key.NewBinding(
key.WithKeys("shift+right"),
key.WithHelp("shift+→", "scroll right"),
),
ScrollLeft: key.NewBinding(
key.WithKeys("shift+left"),
key.WithHelp("shift+←", "scroll left"),
),
}
}
// FullHelp returns a multi row view of all the helpkeys that are defined. Needed to fullfil the 'help.Model' interface.
// Also appends all user defined extra keys to the help.
func (m Model) FullHelp() [][]key.Binding {
keyBinds := [][]key.Binding{
{m.keyMap.RowDown, m.keyMap.RowUp, m.keyMap.RowSelectToggle},
{m.keyMap.PageDown, m.keyMap.PageUp, m.keyMap.PageFirst, m.keyMap.PageLast},
{m.keyMap.Filter, m.keyMap.FilterBlur, m.keyMap.FilterClear, m.keyMap.ScrollRight, m.keyMap.ScrollLeft},
}
if m.additionalFullHelpKeys != nil {
keyBinds = append(keyBinds, m.additionalFullHelpKeys())
}
return keyBinds
}
// ShortHelp just returns a single row of help views. Needed to fullfil the 'help.Model' interface.
// Also appends all user defined extra keys to the help.
func (m Model) ShortHelp() []key.Binding {
keyBinds := []key.Binding{
m.keyMap.RowDown,
m.keyMap.RowUp,
m.keyMap.RowSelectToggle,
m.keyMap.PageDown,
m.keyMap.PageUp,
m.keyMap.Filter,
m.keyMap.FilterBlur,
m.keyMap.FilterClear,
}
if m.additionalShortHelpKeys != nil {
keyBinds = append(keyBinds, m.additionalShortHelpKeys()...)
}
return keyBinds
}

View File

@ -1,148 +0,0 @@
package table
import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
columnKeySelect = "___select___"
)
var (
defaultHighlightStyle = lipgloss.NewStyle().Background(lipgloss.Color("#334"))
)
// Model is the main table model. Create using New().
type Model struct {
// Data
columns []Column
rows []Row
metadata map[string]any
// Caches for optimizations
visibleRowCacheUpdated bool
visibleRowCache []Row
// Shown when data is missing from a row
missingDataIndicator any
// Interaction
focused bool
keyMap KeyMap
// Taken from: 'Bubbles/List'
// 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.
// You have to supply a keybinding like this:
// key.NewBinding( key.WithKeys("shift+left"), key.WithHelp("shift+←", "scroll left"))
// It needs both 'WithKeys' and 'WithHelp'
additionalShortHelpKeys func() []key.Binding
additionalFullHelpKeys func() []key.Binding
selectableRows bool
rowCursorIndex int
// Events
lastUpdateUserEvents []UserEvent
// Styles
baseStyle lipgloss.Style
highlightStyle lipgloss.Style
headerStyle lipgloss.Style
rowStyleFunc func(RowStyleFuncInput) lipgloss.Style
border Border
selectedText string
unselectedText string
// Header
headerVisible bool
// Footers
footerVisible bool
staticFooter string
// Pagination
pageSize int
currentPage int
paginationWrapping bool
// Sorting, where a stable sort is applied from first element to last so
// that elements are grouped by the later elements.
sortOrder []SortColumn
// Filter
filtered bool
filterTextInput textinput.Model
filterFunc FilterFunc
// For flex columns
targetTotalWidth int
// The maximum total width for overflow/scrolling
maxTotalWidth int
// Internal cached calculations for reference, may be higher than
// maxTotalWidth. If this is the case, we need to adjust the view
totalWidth int
// How far to scroll to the right, in columns
horizontalScrollOffsetCol int
// How many columns to freeze when scrolling horizontally
horizontalScrollFreezeColumnsCount int
// Calculated maximum column we can scroll to before the last is displayed
maxHorizontalColumnIndex int
// Minimum total height of the table
minimumHeight int
// Internal cached calculation, the height of the header and footer
// including borders. Used to determine how many padding rows to add.
metaHeight int
// If true, the table will be multiline
multiline bool
}
// New creates a new table ready for further modifications.
func New(columns []Column) Model {
filterInput := textinput.New()
filterInput.Prompt = "/"
model := Model{
columns: make([]Column, len(columns)),
metadata: make(map[string]any),
highlightStyle: defaultHighlightStyle.Copy(),
border: borderDefault,
headerVisible: true,
footerVisible: true,
keyMap: DefaultKeyMap(),
selectedText: "[x]",
unselectedText: "[ ]",
filterTextInput: filterInput,
filterFunc: filterFuncContains,
baseStyle: lipgloss.NewStyle().Align(lipgloss.Right),
paginationWrapping: true,
}
// Do a full deep copy to avoid unexpected edits
copy(model.columns, columns)
model.recalculateWidth()
return model
}
// Init initializes the table per the Bubble Tea architecture.
func (m Model) Init() tea.Cmd {
return nil
}

View File

@ -1,510 +0,0 @@
package table
import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/lipgloss"
)
// RowStyleFuncInput is the input to the style function that can
// be applied to each row. This is useful for things like zebra
// striping or other data-based styles.
//
// Note that we use a struct here to allow for future expansion
// while keeping backwards compatibility.
type RowStyleFuncInput struct {
// Index is the index of the row, starting at 0.
Index int
// Row is the full row data.
Row Row
// IsHighlighted is true if the row is currently highlighted.
IsHighlighted bool
}
// WithRowStyleFunc sets a function that can be used to apply a style to each row
// based on the row data. This is useful for things like zebra striping or other
// data-based styles. It can be safely set to nil to remove it later.
// This style is applied after the base style and before individual row styles.
// This will override any HighlightStyle settings.
func (m Model) WithRowStyleFunc(f func(RowStyleFuncInput) lipgloss.Style) Model {
m.rowStyleFunc = f
return m
}
// WithHighlightedRow sets the highlighted row to the given index.
func (m Model) WithHighlightedRow(index int) Model {
m.rowCursorIndex = index
if m.rowCursorIndex >= len(m.GetVisibleRows()) {
m.rowCursorIndex = len(m.GetVisibleRows()) - 1
}
if m.rowCursorIndex < 0 {
m.rowCursorIndex = 0
}
m.currentPage = m.expectedPageForRowIndex(m.rowCursorIndex)
return m
}
// HeaderStyle sets the style to apply to the header text, such as color or bold.
func (m Model) HeaderStyle(style lipgloss.Style) Model {
m.headerStyle = style.Copy()
return m
}
// WithRows sets the rows to show as data in the table.
func (m Model) WithRows(rows []Row) Model {
m.rows = rows
m.visibleRowCacheUpdated = false
if m.rowCursorIndex >= len(m.rows) {
m.rowCursorIndex = len(m.rows) - 1
}
if m.rowCursorIndex < 0 {
m.rowCursorIndex = 0
}
if m.pageSize != 0 {
maxPage := m.MaxPages()
// MaxPages is 1-index, currentPage is 0 index
if maxPage <= m.currentPage {
m.pageLast()
}
}
return m
}
// WithKeyMap sets the key map to use for controls when focused.
func (m Model) WithKeyMap(keyMap KeyMap) Model {
m.keyMap = keyMap
return m
}
// KeyMap returns a copy of the current key map in use.
func (m Model) KeyMap() KeyMap {
return m.keyMap
}
// SelectableRows sets whether or not rows are selectable. If set, adds a column
// in the front that acts as a checkbox and responds to controls if Focused.
func (m Model) SelectableRows(selectable bool) Model {
m.selectableRows = selectable
hasSelectColumn := len(m.columns) > 0 && m.columns[0].key == columnKeySelect
if hasSelectColumn != selectable {
if selectable {
m.columns = append([]Column{
NewColumn(columnKeySelect, m.selectedText, len([]rune(m.selectedText))),
}, m.columns...)
} else {
m.columns = m.columns[1:]
}
}
m.recalculateWidth()
return m
}
// HighlightedRow returns the full Row that's currently highlighted by the user.
func (m Model) HighlightedRow() Row {
if len(m.GetVisibleRows()) > 0 {
return m.GetVisibleRows()[m.rowCursorIndex]
}
// TODO: Better way to do this without pointers/nil? Or should it be nil?
return Row{}
}
// SelectedRows returns all rows that have been set as selected by the user.
func (m Model) SelectedRows() []Row {
selectedRows := []Row{}
for _, row := range m.GetVisibleRows() {
if row.selected {
selectedRows = append(selectedRows, row)
}
}
return selectedRows
}
// HighlightStyle sets a custom style to use when the row is being highlighted
// by the cursor. This should not be used with WithRowStyleFunc. Instead, use
// the IsHighlighted field in the style function.
func (m Model) HighlightStyle(style lipgloss.Style) Model {
m.highlightStyle = style
return m
}
// Focused allows the table to show highlighted rows and take in controls of
// up/down/space/etc to let the user navigate the table and interact with it.
func (m Model) Focused(focused bool) Model {
m.focused = focused
return m
}
// Filtered allows the table to show rows that match the filter.
func (m Model) Filtered(filtered bool) Model {
m.filtered = filtered
m.visibleRowCacheUpdated = false
if m.minimumHeight > 0 {
m.recalculateHeight()
}
return m
}
// StartFilterTyping focuses the text input to allow user typing to filter.
func (m Model) StartFilterTyping() Model {
m.filterTextInput.Focus()
return m
}
// WithStaticFooter adds a footer that only displays the given text.
func (m Model) WithStaticFooter(footer string) Model {
m.staticFooter = footer
if m.minimumHeight > 0 {
m.recalculateHeight()
}
return m
}
// WithPageSize enables pagination using the given page size. This can be called
// again at any point to resize the height of the table.
func (m Model) WithPageSize(pageSize int) Model {
m.pageSize = pageSize
maxPages := m.MaxPages()
if m.currentPage >= maxPages {
m.currentPage = maxPages - 1
}
if m.minimumHeight > 0 {
m.recalculateHeight()
}
return m
}
// WithNoPagination disables pagination in the table.
func (m Model) WithNoPagination() Model {
m.pageSize = 0
if m.minimumHeight > 0 {
m.recalculateHeight()
}
return m
}
// WithPaginationWrapping sets whether to wrap around from the beginning to the
// end when navigating through pages. Defaults to true.
func (m Model) WithPaginationWrapping(wrapping bool) Model {
m.paginationWrapping = wrapping
return m
}
// WithSelectedText describes what text to show when selectable rows are enabled.
// The selectable column header will use the selected text string.
func (m Model) WithSelectedText(unselected, selected string) Model {
m.selectedText = selected
m.unselectedText = unselected
if len(m.columns) > 0 && m.columns[0].key == columnKeySelect {
m.columns[0] = NewColumn(columnKeySelect, m.selectedText, len([]rune(m.selectedText)))
m.recalculateWidth()
}
return m
}
// WithBaseStyle applies a base style as the default for everything in the table.
// This is useful for border colors, default alignment, default color, etc.
func (m Model) WithBaseStyle(style lipgloss.Style) Model {
m.baseStyle = style
return m
}
// WithTargetWidth sets the total target width of the table, including borders.
// This only takes effect when using flex columns. When using flex columns,
// columns will stretch to fill out to the total width given here.
func (m Model) WithTargetWidth(totalWidth int) Model {
m.targetTotalWidth = totalWidth
m.recalculateWidth()
return m
}
// WithMinimumHeight sets the minimum total height of the table, including borders.
func (m Model) WithMinimumHeight(minimumHeight int) Model {
m.minimumHeight = minimumHeight
m.recalculateHeight()
return m
}
// PageDown goes to the next page of a paginated table, wrapping to the first
// page if the table is already on the last page.
func (m Model) PageDown() Model {
m.pageDown()
return m
}
// PageUp goes to the previous page of a paginated table, wrapping to the
// last page if the table is already on the first page.
func (m Model) PageUp() Model {
m.pageUp()
return m
}
// PageLast goes to the last page of a paginated table.
func (m Model) PageLast() Model {
m.pageLast()
return m
}
// PageFirst goes to the first page of a paginated table.
func (m Model) PageFirst() Model {
m.pageFirst()
return m
}
// WithCurrentPage sets the current page (1 as the first page) of a paginated
// table, bounded to the total number of pages. The current selected row will
// be set to the top row of the page if the page changed.
func (m Model) WithCurrentPage(currentPage int) Model {
if m.pageSize == 0 || currentPage == m.CurrentPage() {
return m
}
if currentPage < 1 {
currentPage = 1
} else {
maxPages := m.MaxPages()
if currentPage > maxPages {
currentPage = maxPages
}
}
m.currentPage = currentPage - 1
m.rowCursorIndex = m.currentPage * m.pageSize
return m
}
// WithColumns sets the visible columns for the table, so that columns can be
// added/removed/resized or headers rewritten.
func (m Model) WithColumns(columns []Column) Model {
// Deep copy to avoid edits
m.columns = make([]Column, len(columns))
copy(m.columns, columns)
m.recalculateWidth()
if m.selectableRows {
// Re-add the selectable column
m = m.SelectableRows(true)
}
return m
}
// WithFilterInput makes the table use the provided text input bubble for
// filtering rather than using the built-in default. This allows for external
// text input controls to be used.
func (m Model) WithFilterInput(input textinput.Model) Model {
if m.filterTextInput.Value() != input.Value() {
m.pageFirst()
}
m.filterTextInput = input
m.visibleRowCacheUpdated = false
return m
}
// WithFilterInputValue sets the filter value to the given string, immediately
// applying it as if the user had typed it in. Useful for external filter inputs
// that are not necessarily a text input.
func (m Model) WithFilterInputValue(value string) Model {
if m.filterTextInput.Value() != value {
m.pageFirst()
}
m.filterTextInput.SetValue(value)
m.filterTextInput.Blur()
m.visibleRowCacheUpdated = false
return m
}
// WithFilterFunc adds a filter function to the model. If the function returns
// true, the row will be included in the filtered results. If the function
// is nil, the function won't be used and instead the default filtering will be applied,
// if any.
func (m Model) WithFilterFunc(shouldInclude FilterFunc) Model {
m.filterFunc = shouldInclude
m.visibleRowCacheUpdated = false
return m
}
// WithFuzzyFilter enables fuzzy filtering for the table.
func (m Model) WithFuzzyFilter() Model {
return m.WithFilterFunc(filterFuncFuzzy)
}
// WithFooterVisibility sets the visibility of the footer.
func (m Model) WithFooterVisibility(visibility bool) Model {
m.footerVisible = visibility
if m.minimumHeight > 0 {
m.recalculateHeight()
}
return m
}
// WithHeaderVisibility sets the visibility of the header.
func (m Model) WithHeaderVisibility(visibility bool) Model {
m.headerVisible = visibility
if m.minimumHeight > 0 {
m.recalculateHeight()
}
return m
}
// WithMaxTotalWidth sets the maximum total width that the table should render.
// If this width is exceeded by either the target width or by the total width
// of all the columns (including borders!), anything extra will be treated as
// overflow and horizontal scrolling will be enabled to see the rest.
func (m Model) WithMaxTotalWidth(maxTotalWidth int) Model {
m.maxTotalWidth = maxTotalWidth
m.recalculateWidth()
return m
}
// WithHorizontalFreezeColumnCount freezes the given number of columns to the
// left side. This is useful for things like ID or Name columns that should
// always be visible even when scrolling.
func (m Model) WithHorizontalFreezeColumnCount(columnsToFreeze int) Model {
m.horizontalScrollFreezeColumnsCount = columnsToFreeze
m.recalculateWidth()
return m
}
// ScrollRight moves one column to the right. Use with WithMaxTotalWidth.
func (m Model) ScrollRight() Model {
m.scrollRight()
return m
}
// ScrollLeft moves one column to the left. Use with WithMaxTotalWidth.
func (m Model) ScrollLeft() Model {
m.scrollLeft()
return m
}
// WithMissingDataIndicator sets an indicator to use when data for a column is
// not found in a given row. Note that this is for completely missing data,
// an empty string or other zero value that is explicitly set is not considered
// to be missing.
func (m Model) WithMissingDataIndicator(str string) Model {
m.missingDataIndicator = str
return m
}
// WithMissingDataIndicatorStyled sets a styled indicator to use when data for
// a column is not found in a given row. Note that this is for completely
// missing data, an empty string or other zero value that is explicitly set is
// not considered to be missing.
func (m Model) WithMissingDataIndicatorStyled(styled StyledCell) Model {
m.missingDataIndicator = styled
return m
}
// WithAllRowsDeselected deselects any rows that are currently selected.
func (m Model) WithAllRowsDeselected() Model {
rows := m.GetVisibleRows()
for i, row := range rows {
if row.selected {
rows[i] = row.Selected(false)
}
}
m.rows = rows
return m
}
// WithMultiline sets whether or not to wrap text in cells to multiple lines.
func (m Model) WithMultiline(multiline bool) Model {
m.multiline = multiline
return m
}
// WithAdditionalShortHelpKeys enables you to add more keybindings to the 'short help' view.
func (m Model) WithAdditionalShortHelpKeys(keys []key.Binding) Model {
m.additionalShortHelpKeys = func() []key.Binding {
return keys
}
return m
}
// WithAdditionalFullHelpKeys enables you to add more keybindings to the 'full help' view.
func (m Model) WithAdditionalFullHelpKeys(keys []key.Binding) Model {
m.additionalFullHelpKeys = func() []key.Binding {
return keys
}
return m
}
// WithGlobalMetadata applies the given metadata to the table. This metadata is passed to
// some functions in FilterFuncInput and StyleFuncInput to enable more advanced decisions,
// such as setting some global theme variable to reference, etc. Has no effect otherwise.
func (m Model) WithGlobalMetadata(metadata map[string]any) Model {
m.metadata = metadata
return m
}

View File

@ -1,18 +0,0 @@
package table
import "github.com/charmbracelet/lipgloss"
const columnKeyOverflowRight = "___overflow_r___"
const columnKeyOverflowLeft = "___overflow_l__"
func genOverflowStyle(base lipgloss.Style, width int) lipgloss.Style {
return base.Width(width).Align(lipgloss.Right)
}
func genOverflowColumnRight(width int) Column {
return NewColumn(columnKeyOverflowRight, ">", width)
}
func genOverflowColumnLeft(width int) Column {
return NewColumn(columnKeyOverflowLeft, "<", width)
}

View File

@ -1,112 +0,0 @@
package table
// PageSize returns the current page size for the table, or 0 if there is no
// pagination enabled.
func (m *Model) PageSize() int {
return m.pageSize
}
// CurrentPage returns the current page that the table is on, starting from an
// index of 1.
func (m *Model) CurrentPage() int {
return m.currentPage + 1
}
// MaxPages returns the maximum number of pages that are visible.
func (m *Model) MaxPages() int {
totalRows := len(m.GetVisibleRows())
if m.pageSize == 0 || totalRows == 0 {
return 1
}
return (totalRows-1)/m.pageSize + 1
}
// TotalRows returns the current total row count of the table. If the table is
// paginated, this is the total number of rows across all pages.
func (m *Model) TotalRows() int {
return len(m.GetVisibleRows())
}
// VisibleIndices returns the current visible rows by their 0 based index.
// Useful for custom pagination footers.
func (m *Model) VisibleIndices() (start, end int) {
totalRows := len(m.GetVisibleRows())
if m.pageSize == 0 {
start = 0
end = totalRows - 1
return start, end
}
start = m.pageSize * m.currentPage
end = start + m.pageSize - 1
if end >= totalRows {
end = totalRows - 1
}
return start, end
}
func (m *Model) pageDown() {
if m.pageSize == 0 || len(m.GetVisibleRows()) <= m.pageSize {
return
}
m.currentPage++
maxPageIndex := m.MaxPages() - 1
if m.currentPage > maxPageIndex {
if m.paginationWrapping {
m.currentPage = 0
} else {
m.currentPage = maxPageIndex
}
}
m.rowCursorIndex = m.currentPage * m.pageSize
}
func (m *Model) pageUp() {
if m.pageSize == 0 || len(m.GetVisibleRows()) <= m.pageSize {
return
}
m.currentPage--
maxPageIndex := m.MaxPages() - 1
if m.currentPage < 0 {
if m.paginationWrapping {
m.currentPage = maxPageIndex
} else {
m.currentPage = 0
}
}
m.rowCursorIndex = m.currentPage * m.pageSize
}
func (m *Model) pageFirst() {
m.currentPage = 0
m.rowCursorIndex = 0
}
func (m *Model) pageLast() {
m.currentPage = m.MaxPages() - 1
m.rowCursorIndex = m.currentPage * m.pageSize
}
func (m *Model) expectedPageForRowIndex(rowIndex int) int {
if m.pageSize == 0 {
return 0
}
expectedPage := rowIndex / m.pageSize
return expectedPage
}

View File

@ -1,96 +0,0 @@
package table
// GetColumnSorting returns the current sorting rules for the table as a list of
// SortColumns, which are applied from first to last. This means that data will
// be grouped by the later elements in the list. The returned list is a copy
// and modifications will have no effect.
func (m *Model) GetColumnSorting() []SortColumn {
c := make([]SortColumn, len(m.sortOrder))
copy(c, m.sortOrder)
return c
}
// GetCanFilter returns true if the table enables filtering at all. This does
// not say whether a filter is currently active, only that the feature is enabled.
func (m *Model) GetCanFilter() bool {
return m.filtered
}
// GetIsFilterActive returns true if the table is currently being filtered. This
// does not say whether the table CAN be filtered, only whether or not a filter
// is actually currently being applied.
func (m *Model) GetIsFilterActive() bool {
return m.filterTextInput.Value() != ""
}
// GetIsFilterInputFocused returns true if the table's built-in filter input is
// currently focused.
func (m *Model) GetIsFilterInputFocused() bool {
return m.filterTextInput.Focused()
}
// GetCurrentFilter returns the current filter text being applied, or an empty
// string if none is applied.
func (m *Model) GetCurrentFilter() string {
return m.filterTextInput.Value()
}
// GetVisibleRows returns sorted and filtered rows.
func (m *Model) GetVisibleRows() []Row {
if m.visibleRowCacheUpdated {
return m.visibleRowCache
}
rows := make([]Row, len(m.rows))
copy(rows, m.rows)
if m.filtered {
rows = m.getFilteredRows(rows)
}
rows = getSortedRows(m.sortOrder, rows)
m.visibleRowCache = rows
m.visibleRowCacheUpdated = true
return rows
}
// GetHighlightedRowIndex returns the index of the Row that's currently highlighted
// by the user.
func (m *Model) GetHighlightedRowIndex() int {
return m.rowCursorIndex
}
// GetFocused returns whether or not the table is focused and is receiving inputs.
func (m *Model) GetFocused() bool {
return m.focused
}
// GetHorizontalScrollColumnOffset returns how many columns to the right the table
// has been scrolled. 0 means the table is all the way to the left, which is
// the starting default.
func (m *Model) GetHorizontalScrollColumnOffset() int {
return m.horizontalScrollOffsetCol
}
// GetHeaderVisibility returns true if the header has been set to visible (default)
// or false if the header has been set to hidden.
func (m *Model) GetHeaderVisibility() bool {
return m.headerVisible
}
// GetFooterVisibility returns true if the footer has been set to
// visible (default) or false if the footer has been set to hidden.
// Note that even if the footer is visible it will only be rendered if
// it has contents.
func (m *Model) GetFooterVisibility() bool {
return m.footerVisible
}
// GetPaginationWrapping returns true if pagination wrapping is enabled, or false
// if disabled. If disabled, navigating through pages will stop at the first
// and last pages.
func (m *Model) GetPaginationWrapping() bool {
return m.paginationWrapping
}

View File

@ -1,252 +0,0 @@
package table
import (
"fmt"
"sync/atomic"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/reflow/wordwrap"
)
// RowData is a map of string column keys to arbitrary data. Data with a key
// that matches a column key will be displayed. Data with a key that does not
// match a column key will not be displayed, but will remain attached to the Row.
// This can be useful for attaching hidden metadata for future reference when
// retrieving rows.
type RowData map[string]any
// Row represents a row in the table with some data keyed to the table columns>
// Can have a style applied to it such as color/bold. Create using NewRow().
type Row struct {
Style lipgloss.Style
Data RowData
selected bool
// id is an internal unique ID to match rows after they're copied
id uint32
}
var lastRowID uint32 = 1
// NewRow creates a new row and copies the given row data.
func NewRow(data RowData) Row {
row := Row{
Data: make(map[string]any),
id: lastRowID,
}
atomic.AddUint32(&lastRowID, 1)
for key, val := range data {
// Doesn't deep copy val, but close enough for now...
row.Data[key] = val
}
return row
}
// WithStyle uses the given style for the text in the row.
func (r Row) WithStyle(style lipgloss.Style) Row {
r.Style = style.Copy()
return r
}
//nolint:cyclop,funlen // Breaking this up will be more complicated than it's worth for now
func (m Model) renderRowColumnData(row Row, column Column, rowStyle lipgloss.Style, borderStyle lipgloss.Style) string {
cellStyle := rowStyle.Copy().Inherit(column.style).Inherit(m.baseStyle)
var str string
switch column.key {
case columnKeySelect:
if row.selected {
str = m.selectedText
} else {
str = m.unselectedText
}
case columnKeyOverflowRight:
cellStyle = cellStyle.Align(lipgloss.Right)
str = ">"
case columnKeyOverflowLeft:
str = "<"
default:
fmtString := "%v"
var data any
if entry, exists := row.Data[column.key]; exists {
data = entry
if column.fmtString != "" {
fmtString = column.fmtString
}
} else if m.missingDataIndicator != nil {
data = m.missingDataIndicator
} else {
data = ""
}
switch entry := data.(type) {
case StyledCell:
str = fmt.Sprintf(fmtString, entry.Data)
if entry.StyleFunc != nil {
cellStyle = entry.StyleFunc(StyledCellFuncInput{
Column: column,
Data: entry.Data,
Row: row,
GlobalMetadata: m.metadata,
}).Copy().Inherit(cellStyle)
} else {
cellStyle = entry.Style.Copy().Inherit(cellStyle)
}
default:
str = fmt.Sprintf(fmtString, entry)
}
}
if m.multiline {
str = wordwrap.String(str, column.width)
cellStyle = cellStyle.Align(lipgloss.Top)
} else {
str = limitStr(str, column.width)
}
cellStyle = cellStyle.Inherit(borderStyle)
cellStr := cellStyle.Render(str)
return cellStr
}
func (m Model) renderRow(rowIndex int, last bool) string {
row := m.GetVisibleRows()[rowIndex]
highlighted := rowIndex == m.rowCursorIndex
rowStyle := row.Style.Copy()
if m.rowStyleFunc != nil {
styleResult := m.rowStyleFunc(RowStyleFuncInput{
Index: rowIndex,
Row: row,
IsHighlighted: m.focused && highlighted,
})
rowStyle = rowStyle.Inherit(styleResult)
} else if m.focused && highlighted {
rowStyle = rowStyle.Inherit(m.highlightStyle)
}
return m.renderRowData(row, rowStyle, last)
}
func (m Model) renderBlankRow(last bool) string {
return m.renderRowData(NewRow(nil), lipgloss.NewStyle(), last)
}
// This is long and could use some refactoring in the future, but not quite sure
// how to pick it apart yet.
//
//nolint:funlen, cyclop
func (m Model) renderRowData(row Row, rowStyle lipgloss.Style, last bool) string {
numColumns := len(m.columns)
columnStrings := []string{}
totalRenderedWidth := 0
stylesInner, stylesLast := m.styleRows()
maxCellHeight := 1
if m.multiline {
for _, column := range m.columns {
cellStr := m.renderRowColumnData(row, column, rowStyle, lipgloss.NewStyle())
maxCellHeight = max(maxCellHeight, lipgloss.Height(cellStr))
}
}
for columnIndex, column := range m.columns {
var borderStyle lipgloss.Style
var rowStyles borderStyleRow
if !last {
rowStyles = stylesInner
} else {
rowStyles = stylesLast
}
rowStyle = rowStyle.Copy().Height(maxCellHeight)
if m.horizontalScrollOffsetCol > 0 && columnIndex == m.horizontalScrollFreezeColumnsCount {
var borderStyle lipgloss.Style
if columnIndex == 0 {
borderStyle = rowStyles.left.Copy()
} else {
borderStyle = rowStyles.inner.Copy()
}
rendered := m.renderRowColumnData(row, genOverflowColumnLeft(1), rowStyle, borderStyle)
totalRenderedWidth += lipgloss.Width(rendered)
columnStrings = append(columnStrings, rendered)
}
if columnIndex >= m.horizontalScrollFreezeColumnsCount &&
columnIndex < m.horizontalScrollOffsetCol+m.horizontalScrollFreezeColumnsCount {
continue
}
if len(columnStrings) == 0 {
borderStyle = rowStyles.left
} else if columnIndex < numColumns-1 {
borderStyle = rowStyles.inner
} else {
borderStyle = rowStyles.right
}
cellStr := m.renderRowColumnData(row, column, rowStyle, borderStyle)
if m.maxTotalWidth != 0 {
renderedWidth := lipgloss.Width(cellStr)
const (
borderAdjustment = 1
overflowColWidth = 2
)
targetWidth := m.maxTotalWidth - overflowColWidth
if columnIndex == len(m.columns)-1 {
// If this is the last header, we don't need to account for the
// overflow arrow column
targetWidth = m.maxTotalWidth
}
if totalRenderedWidth+renderedWidth > targetWidth {
overflowWidth := m.maxTotalWidth - totalRenderedWidth - borderAdjustment
overflowStyle := genOverflowStyle(rowStyles.right, overflowWidth)
overflowColumn := genOverflowColumnRight(overflowWidth)
overflowStr := m.renderRowColumnData(row, overflowColumn, rowStyle, overflowStyle)
columnStrings = append(columnStrings, overflowStr)
break
}
totalRenderedWidth += renderedWidth
}
columnStrings = append(columnStrings, cellStr)
}
return lipgloss.JoinHorizontal(lipgloss.Bottom, columnStrings...)
}
// Selected returns a copy of the row that's set to be selected or deselected.
// The old row is not changed in-place.
func (r Row) Selected(selected bool) Row {
r.selected = selected
return r
}

View File

@ -1,50 +0,0 @@
package table
func (m *Model) scrollRight() {
if m.horizontalScrollOffsetCol < m.maxHorizontalColumnIndex {
m.horizontalScrollOffsetCol++
}
}
func (m *Model) scrollLeft() {
if m.horizontalScrollOffsetCol > 0 {
m.horizontalScrollOffsetCol--
}
}
func (m *Model) recalculateLastHorizontalColumn() {
if m.horizontalScrollFreezeColumnsCount >= len(m.columns) {
m.maxHorizontalColumnIndex = 0
return
}
if m.totalWidth <= m.maxTotalWidth {
m.maxHorizontalColumnIndex = 0
return
}
const (
leftOverflowWidth = 2
borderAdjustment = 1
)
// Always have left border
visibleWidth := borderAdjustment + leftOverflowWidth
for i := 0; i < m.horizontalScrollFreezeColumnsCount; i++ {
visibleWidth += m.columns[i].width + borderAdjustment
}
m.maxHorizontalColumnIndex = len(m.columns) - 1
// Work backwards from the right
for i := len(m.columns) - 1; i >= m.horizontalScrollFreezeColumnsCount && visibleWidth <= m.maxTotalWidth; i-- {
visibleWidth += m.columns[i].width + borderAdjustment
if visibleWidth <= m.maxTotalWidth {
m.maxHorizontalColumnIndex = i - m.horizontalScrollFreezeColumnsCount
}
}
}

View File

@ -1,178 +0,0 @@
package table
import (
"fmt"
"sort"
)
// SortDirection indicates whether a column should sort by ascending or descending.
type SortDirection int
const (
// SortDirectionAsc indicates the column should be in ascending order.
SortDirectionAsc SortDirection = iota
// SortDirectionDesc indicates the column should be in descending order.
SortDirectionDesc
)
// SortColumn describes which column should be sorted and how.
type SortColumn struct {
ColumnKey string
Direction SortDirection
}
// SortByAsc sets the main sorting column to the given key, in ascending order.
// If a previous sort was used, it is replaced by the given column each time
// this function is called. Values are sorted as numbers if possible, or just
// as simple string comparisons if not numbers.
func (m Model) SortByAsc(columnKey string) Model {
m.sortOrder = []SortColumn{
{
ColumnKey: columnKey,
Direction: SortDirectionAsc,
},
}
m.visibleRowCacheUpdated = false
return m
}
// SortByDesc sets the main sorting column to the given key, in descending order.
// If a previous sort was used, it is replaced by the given column each time
// this function is called. Values are sorted as numbers if possible, or just
// as simple string comparisons if not numbers.
func (m Model) SortByDesc(columnKey string) Model {
m.sortOrder = []SortColumn{
{
ColumnKey: columnKey,
Direction: SortDirectionDesc,
},
}
m.visibleRowCacheUpdated = false
return m
}
// ThenSortByAsc provides a secondary sort after the first, in ascending order.
// Can be chained multiple times, applying to smaller subgroups each time.
func (m Model) ThenSortByAsc(columnKey string) Model {
m.sortOrder = append([]SortColumn{
{
ColumnKey: columnKey,
Direction: SortDirectionAsc,
},
}, m.sortOrder...)
m.visibleRowCacheUpdated = false
return m
}
// ThenSortByDesc provides a secondary sort after the first, in descending order.
// Can be chained multiple times, applying to smaller subgroups each time.
func (m Model) ThenSortByDesc(columnKey string) Model {
m.sortOrder = append([]SortColumn{
{
ColumnKey: columnKey,
Direction: SortDirectionDesc,
},
}, m.sortOrder...)
m.visibleRowCacheUpdated = false
return m
}
type sortableTable struct {
rows []Row
byColumn SortColumn
}
func (s *sortableTable) Len() int {
return len(s.rows)
}
func (s *sortableTable) Swap(i, j int) {
old := s.rows[i]
s.rows[i] = s.rows[j]
s.rows[j] = old
}
func (s *sortableTable) extractString(i int, column string) string {
iData, exists := s.rows[i].Data[column]
if !exists {
return ""
}
switch iData := iData.(type) {
case StyledCell:
return fmt.Sprintf("%v", iData.Data)
case string:
return iData
default:
return fmt.Sprintf("%v", iData)
}
}
func (s *sortableTable) extractNumber(i int, column string) (float64, bool) {
iData, exists := s.rows[i].Data[column]
if !exists {
return 0, false
}
return asNumber(iData)
}
func (s *sortableTable) Less(first, second int) bool {
firstNum, firstNumIsValid := s.extractNumber(first, s.byColumn.ColumnKey)
secondNum, secondNumIsValid := s.extractNumber(second, s.byColumn.ColumnKey)
if firstNumIsValid && secondNumIsValid {
if s.byColumn.Direction == SortDirectionAsc {
return firstNum < secondNum
}
return firstNum > secondNum
}
firstVal := s.extractString(first, s.byColumn.ColumnKey)
secondVal := s.extractString(second, s.byColumn.ColumnKey)
if s.byColumn.Direction == SortDirectionAsc {
return firstVal < secondVal
}
return firstVal > secondVal
}
func getSortedRows(sortOrder []SortColumn, rows []Row) []Row {
var sortedRows []Row
if len(sortOrder) == 0 {
sortedRows = rows
return sortedRows
}
sortedRows = make([]Row, len(rows))
copy(sortedRows, rows)
for _, byColumn := range sortOrder {
sorted := &sortableTable{
rows: sortedRows,
byColumn: byColumn,
}
sort.Stable(sorted)
sortedRows = sorted.rows
}
return sortedRows
}

View File

@ -1,26 +0,0 @@
package table
import (
"strings"
"github.com/muesli/reflow/ansi"
"github.com/muesli/reflow/truncate"
)
func limitStr(str string, maxLen int) string {
if maxLen == 0 {
return ""
}
newLineIndex := strings.Index(str, "\n")
if newLineIndex > -1 {
str = str[:newLineIndex] + "…"
}
if ansi.PrintableRuneWidth(str) > maxLen {
// #nosec: G115
return truncate.StringWithTail(str, uint(maxLen), "…")
}
return str
}

View File

@ -1,154 +0,0 @@
package table
import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
)
func (m *Model) moveHighlightUp() {
m.rowCursorIndex--
if m.rowCursorIndex < 0 {
m.rowCursorIndex = len(m.GetVisibleRows()) - 1
}
m.currentPage = m.expectedPageForRowIndex(m.rowCursorIndex)
}
func (m *Model) moveHighlightDown() {
m.rowCursorIndex++
if m.rowCursorIndex >= len(m.GetVisibleRows()) {
m.rowCursorIndex = 0
}
m.currentPage = m.expectedPageForRowIndex(m.rowCursorIndex)
}
func (m *Model) toggleSelect() {
if !m.selectableRows || len(m.GetVisibleRows()) == 0 {
return
}
rows := m.GetVisibleRows()
rowID := rows[m.rowCursorIndex].id
currentSelectedState := false
for i := range m.rows {
if m.rows[i].id == rowID {
currentSelectedState = m.rows[i].selected
m.rows[i].selected = !m.rows[i].selected
}
}
m.visibleRowCacheUpdated = false
m.appendUserEvent(UserEventRowSelectToggled{
RowIndex: m.rowCursorIndex,
IsSelected: !currentSelectedState,
})
}
func (m Model) updateFilterTextInput(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if key.Matches(msg, m.keyMap.FilterBlur) {
m.filterTextInput.Blur()
}
}
m.filterTextInput, cmd = m.filterTextInput.Update(msg)
m.pageFirst()
m.visibleRowCacheUpdated = false
return m, cmd
}
// This is a series of Matches tests with minimal logic
//
//nolint:cyclop
func (m *Model) handleKeypress(msg tea.KeyMsg) {
previousRowIndex := m.rowCursorIndex
if key.Matches(msg, m.keyMap.RowDown) {
m.moveHighlightDown()
}
if key.Matches(msg, m.keyMap.RowUp) {
m.moveHighlightUp()
}
if key.Matches(msg, m.keyMap.RowSelectToggle) {
m.toggleSelect()
}
if key.Matches(msg, m.keyMap.PageDown) {
m.pageDown()
}
if key.Matches(msg, m.keyMap.PageUp) {
m.pageUp()
}
if key.Matches(msg, m.keyMap.PageFirst) {
m.pageFirst()
}
if key.Matches(msg, m.keyMap.PageLast) {
m.pageLast()
}
if key.Matches(msg, m.keyMap.Filter) {
m.filterTextInput.Focus()
m.appendUserEvent(UserEventFilterInputFocused{})
}
if key.Matches(msg, m.keyMap.FilterClear) {
m.visibleRowCacheUpdated = false
m.filterTextInput.Reset()
}
if key.Matches(msg, m.keyMap.ScrollRight) {
m.scrollRight()
}
if key.Matches(msg, m.keyMap.ScrollLeft) {
m.scrollLeft()
}
if m.rowCursorIndex != previousRowIndex {
m.appendUserEvent(UserEventHighlightedIndexChanged{
PreviousRowIndex: previousRowIndex,
SelectedRowIndex: m.rowCursorIndex,
})
}
}
// Update responds to input from the user or other messages from Bubble Tea.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.clearUserEvents()
if !m.focused {
return m, nil
}
if m.filterTextInput.Focused() {
var cmd tea.Cmd
m, cmd = m.updateFilterTextInput(msg)
if !m.filterTextInput.Focused() {
m.appendUserEvent(UserEventFilterInputUnfocused{})
}
return m, cmd
}
switch msg := msg.(type) {
case tea.KeyMsg:
m.handleKeypress(msg)
}
return m, nil
}

View File

@ -1,65 +0,0 @@
package table
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// View renders the table. It does not end in a newline, so that it can be
// composed with other elements more consistently.
//
//nolint:cyclop
func (m Model) View() string {
// Safety valve for empty tables
if len(m.columns) == 0 {
return ""
}
body := strings.Builder{}
rowStrs := make([]string, 0, 1)
headers := m.renderHeaders()
startRowIndex, endRowIndex := m.VisibleIndices()
numRows := endRowIndex - startRowIndex + 1
padding := m.calculatePadding(numRows)
if m.headerVisible {
rowStrs = append(rowStrs, headers)
} else if numRows > 0 || padding > 0 {
//nolint: mnd // This is just getting the first newlined substring
split := strings.SplitN(headers, "\n", 2)
rowStrs = append(rowStrs, split[0])
}
for i := startRowIndex; i <= endRowIndex; i++ {
rowStrs = append(rowStrs, m.renderRow(i, padding == 0 && i == endRowIndex))
}
for i := 1; i <= padding; i++ {
rowStrs = append(rowStrs, m.renderBlankRow(i == padding))
}
var footer string
if len(rowStrs) > 0 {
footer = m.renderFooter(lipgloss.Width(rowStrs[0]), false)
} else {
footer = m.renderFooter(lipgloss.Width(headers), true)
}
if footer != "" {
rowStrs = append(rowStrs, footer)
}
if len(rowStrs) == 0 {
return ""
}
body.WriteString(lipgloss.JoinVertical(lipgloss.Left, rowStrs...))
return body.String()
}

View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2019 Christian Muehlhaeuser
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,7 +0,0 @@
package ansi
const Marker = '\x1B'
func IsTerminator(c rune) bool {
return (c >= 0x40 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a)
}

View File

@ -1,40 +0,0 @@
package ansi
import (
"bytes"
"github.com/mattn/go-runewidth"
)
// Buffer is a buffer aware of ANSI escape sequences.
type Buffer struct {
bytes.Buffer
}
// PrintableRuneWidth returns the cell width of all printable runes in the
// buffer.
func (w Buffer) PrintableRuneWidth() int {
return PrintableRuneWidth(w.String())
}
// PrintableRuneWidth returns the cell width of the given string.
func PrintableRuneWidth(s string) int {
var n int
var ansi bool
for _, c := range s {
if c == Marker {
// ANSI escape sequence
ansi = true
} else if ansi {
if IsTerminator(c) {
// ANSI sequence terminated
ansi = false
}
} else {
n += runewidth.RuneWidth(c)
}
}
return n
}

View File

@ -1,76 +0,0 @@
package ansi
import (
"bytes"
"io"
"unicode/utf8"
)
type Writer struct {
Forward io.Writer
ansi bool
ansiseq bytes.Buffer
lastseq bytes.Buffer
seqchanged bool
runeBuf []byte
}
// Write is used to write content to the ANSI buffer.
func (w *Writer) Write(b []byte) (int, error) {
for _, c := range string(b) {
if c == Marker {
// ANSI escape sequence
w.ansi = true
w.seqchanged = true
_, _ = w.ansiseq.WriteRune(c)
} else if w.ansi {
_, _ = w.ansiseq.WriteRune(c)
if IsTerminator(c) {
// ANSI sequence terminated
w.ansi = false
if bytes.HasSuffix(w.ansiseq.Bytes(), []byte("[0m")) {
// reset sequence
w.lastseq.Reset()
w.seqchanged = false
} else if c == 'm' {
// color code
_, _ = w.lastseq.Write(w.ansiseq.Bytes())
}
_, _ = w.ansiseq.WriteTo(w.Forward)
}
} else {
_, err := w.writeRune(c)
if err != nil {
return 0, err
}
}
}
return len(b), nil
}
func (w *Writer) writeRune(r rune) (int, error) {
if w.runeBuf == nil {
w.runeBuf = make([]byte, utf8.UTFMax)
}
n := utf8.EncodeRune(w.runeBuf, r)
return w.Forward.Write(w.runeBuf[:n])
}
func (w *Writer) LastSequence() string {
return w.lastseq.String()
}
func (w *Writer) ResetAnsi() {
if !w.seqchanged {
return
}
_, _ = w.Forward.Write([]byte("\x1b[0m"))
}
func (w *Writer) RestoreAnsi() {
_, _ = w.Forward.Write(w.lastseq.Bytes())
}

View File

@ -1,120 +0,0 @@
package truncate
import (
"bytes"
"io"
"github.com/mattn/go-runewidth"
"github.com/muesli/reflow/ansi"
)
type Writer struct {
width uint
tail string
ansiWriter *ansi.Writer
buf bytes.Buffer
ansi bool
}
func NewWriter(width uint, tail string) *Writer {
w := &Writer{
width: width,
tail: tail,
}
w.ansiWriter = &ansi.Writer{
Forward: &w.buf,
}
return w
}
func NewWriterPipe(forward io.Writer, width uint, tail string) *Writer {
return &Writer{
width: width,
tail: tail,
ansiWriter: &ansi.Writer{
Forward: forward,
},
}
}
// Bytes is shorthand for declaring a new default truncate-writer instance,
// used to immediately truncate a byte slice.
func Bytes(b []byte, width uint) []byte {
return BytesWithTail(b, width, []byte(""))
}
// Bytes is shorthand for declaring a new default truncate-writer instance,
// used to immediately truncate a byte slice. A tail is then added to the
// end of the byte slice.
func BytesWithTail(b []byte, width uint, tail []byte) []byte {
f := NewWriter(width, string(tail))
_, _ = f.Write(b)
return f.Bytes()
}
// String is shorthand for declaring a new default truncate-writer instance,
// used to immediately truncate a string.
func String(s string, width uint) string {
return StringWithTail(s, width, "")
}
// StringWithTail is shorthand for declaring a new default truncate-writer instance,
// used to immediately truncate a string. A tail is then added to the end of the
// string.
func StringWithTail(s string, width uint, tail string) string {
return string(BytesWithTail([]byte(s), width, []byte(tail)))
}
// Write truncates content at the given printable cell width, leaving any
// ansi sequences intact.
func (w *Writer) Write(b []byte) (int, error) {
tw := ansi.PrintableRuneWidth(w.tail)
if w.width < uint(tw) {
return w.buf.WriteString(w.tail)
}
w.width -= uint(tw)
var curWidth uint
for _, c := range string(b) {
if c == ansi.Marker {
// ANSI escape sequence
w.ansi = true
} else if w.ansi {
if ansi.IsTerminator(c) {
// ANSI sequence terminated
w.ansi = false
}
} else {
curWidth += uint(runewidth.RuneWidth(c))
}
if curWidth > w.width {
n, err := w.buf.WriteString(w.tail)
if w.ansiWriter.LastSequence() != "" {
w.ansiWriter.ResetAnsi()
}
return n, err
}
_, err := w.ansiWriter.Write([]byte(string(c)))
if err != nil {
return 0, err
}
}
return len(b), nil
}
// Bytes returns the truncated result as a byte slice.
func (w *Writer) Bytes() []byte {
return w.buf.Bytes()
}
// String returns the truncated result as a string.
func (w *Writer) String() string {
return w.buf.String()
}

View File

@ -1,167 +0,0 @@
package wordwrap
import (
"bytes"
"strings"
"unicode"
"github.com/muesli/reflow/ansi"
)
var (
defaultBreakpoints = []rune{'-'}
defaultNewline = []rune{'\n'}
)
// WordWrap contains settings and state for customisable text reflowing with
// support for ANSI escape sequences. This means you can style your terminal
// output without affecting the word wrapping algorithm.
type WordWrap struct {
Limit int
Breakpoints []rune
Newline []rune
KeepNewlines bool
buf bytes.Buffer
space bytes.Buffer
word ansi.Buffer
lineLen int
ansi bool
}
// NewWriter returns a new instance of a word-wrapping writer, initialized with
// default settings.
func NewWriter(limit int) *WordWrap {
return &WordWrap{
Limit: limit,
Breakpoints: defaultBreakpoints,
Newline: defaultNewline,
KeepNewlines: true,
}
}
// Bytes is shorthand for declaring a new default WordWrap instance,
// used to immediately word-wrap a byte slice.
func Bytes(b []byte, limit int) []byte {
f := NewWriter(limit)
_, _ = f.Write(b)
_ = f.Close()
return f.Bytes()
}
// String is shorthand for declaring a new default WordWrap instance,
// used to immediately word-wrap a string.
func String(s string, limit int) string {
return string(Bytes([]byte(s), limit))
}
func (w *WordWrap) addSpace() {
w.lineLen += w.space.Len()
_, _ = w.buf.Write(w.space.Bytes())
w.space.Reset()
}
func (w *WordWrap) addWord() {
if w.word.Len() > 0 {
w.addSpace()
w.lineLen += w.word.PrintableRuneWidth()
_, _ = w.buf.Write(w.word.Bytes())
w.word.Reset()
}
}
func (w *WordWrap) addNewLine() {
_, _ = w.buf.WriteRune('\n')
w.lineLen = 0
w.space.Reset()
}
func inGroup(a []rune, c rune) bool {
for _, v := range a {
if v == c {
return true
}
}
return false
}
// Write is used to write more content to the word-wrap buffer.
func (w *WordWrap) Write(b []byte) (int, error) {
if w.Limit == 0 {
return w.buf.Write(b)
}
s := string(b)
if !w.KeepNewlines {
s = strings.Replace(strings.TrimSpace(s), "\n", " ", -1)
}
for _, c := range s {
if c == '\x1B' {
// ANSI escape sequence
_, _ = w.word.WriteRune(c)
w.ansi = true
} else if w.ansi {
_, _ = w.word.WriteRune(c)
if (c >= 0x40 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a) {
// ANSI sequence terminated
w.ansi = false
}
} else if inGroup(w.Newline, c) {
// end of current line
// see if we can add the content of the space buffer to the current line
if w.word.Len() == 0 {
if w.lineLen+w.space.Len() > w.Limit {
w.lineLen = 0
} else {
// preserve whitespace
_, _ = w.buf.Write(w.space.Bytes())
}
w.space.Reset()
}
w.addWord()
w.addNewLine()
} else if unicode.IsSpace(c) {
// end of current word
w.addWord()
_, _ = w.space.WriteRune(c)
} else if inGroup(w.Breakpoints, c) {
// valid breakpoint
w.addSpace()
w.addWord()
_, _ = w.buf.WriteRune(c)
} else {
// any other character
_, _ = w.word.WriteRune(c)
// add a line break if the current word would exceed the line's
// character limit
if w.lineLen+w.space.Len()+w.word.PrintableRuneWidth() > w.Limit &&
w.word.PrintableRuneWidth() < w.Limit {
w.addNewLine()
}
}
}
return len(b), nil
}
// Close will finish the word-wrap operation. Always call it before trying to
// retrieve the final result.
func (w *WordWrap) Close() error {
w.addWord()
return nil
}
// Bytes returns the word-wrapped result as a byte slice.
func (w *WordWrap) Bytes() []byte {
return w.buf.Bytes()
}
// String returns the word-wrapped result as a string.
func (w *WordWrap) String() string {
return w.buf.String()
}

18
vendor/github.com/sahilm/fuzzy/.editorconfig generated vendored Normal file
View File

@ -0,0 +1,18 @@
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.sh]
indent_size = 4
[Makefile]
indent_style = tab
indent_size = 4
[*.go]
indent_style = tab
indent_size = 4

2
vendor/github.com/sahilm/fuzzy/.gitignore generated vendored Normal file
View File

@ -0,0 +1,2 @@
vendor/
coverage/

8
vendor/github.com/sahilm/fuzzy/.travis.yml generated vendored Normal file
View File

@ -0,0 +1,8 @@
arch:
- amd64
- ppc64le
language: go
go:
- 1.x
script:
- make

1
vendor/github.com/sahilm/fuzzy/CONTRIBUTING.md generated vendored Normal file
View File

@ -0,0 +1 @@
Everyone is welcome to contribute. Please send me a pull request or file an issue. I promise to respond promptly.

20
vendor/github.com/sahilm/fuzzy/Gopkg.lock generated vendored Normal file
View File

@ -0,0 +1,20 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
branch = "master"
digest = "1:ee97ec8a00b2424570c1ce53d7b410e96fbd4c241b29df134276ff6aa3750335"
name = "github.com/kylelemons/godebug"
packages = [
"diff",
"pretty",
]
pruneopts = ""
revision = "d65d576e9348f5982d7f6d83682b694e731a45c6"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
input-imports = ["github.com/kylelemons/godebug/pretty"]
solver-name = "gps-cdcl"
solver-version = 1

4
vendor/github.com/sahilm/fuzzy/Gopkg.toml generated vendored Normal file
View File

@ -0,0 +1,4 @@
# Test dependency
[[constraint]]
branch = "master"
name = "github.com/kylelemons/godebug"

View File

@ -1,6 +1,6 @@
MIT License
The MIT License (MIT)
Copyright (c) 2022 Brandon Fulljames
Copyright (c) 2017 Sahil Muthoo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

57
vendor/github.com/sahilm/fuzzy/Makefile generated vendored Normal file
View File

@ -0,0 +1,57 @@
.PHONY: all
all: setup lint test
.PHONY: test
test: setup
go test -bench ./...
.PHONY: cover
cover: setup
mkdir -p coverage
gocov test ./... | gocov-html > coverage/coverage.html
sources = $(shell find . -name '*.go' -not -path './vendor/*')
.PHONY: goimports
goimports: setup
goimports -w $(sources)
.PHONY: lint
lint: setup
gometalinter ./... --enable=goimports --disable=gocyclo --vendor -t
.PHONY: install
install: setup
go install
BIN_DIR := $(GOPATH)/bin
GOIMPORTS := $(BIN_DIR)/goimports
GOMETALINTER := $(BIN_DIR)/gometalinter
DEP := $(BIN_DIR)/dep
GOCOV := $(BIN_DIR)/gocov
GOCOV_HTML := $(BIN_DIR)/gocov-html
$(GOIMPORTS):
go get -u golang.org/x/tools/cmd/goimports
$(GOMETALINTER):
go get -u github.com/alecthomas/gometalinter
gometalinter --install &> /dev/null
$(GOCOV):
go get -u github.com/axw/gocov/gocov
$(GOCOV_HTML):
go get -u gopkg.in/matm/v1/gocov-html
$(DEP):
go get -u github.com/golang/dep/cmd/dep
tools: $(GOIMPORTS) $(GOMETALINTER) $(GOCOV) $(GOCOV_HTML) $(DEP)
vendor: $(DEP)
dep ensure
setup: tools vendor
updatedeps:
dep ensure -update

186
vendor/github.com/sahilm/fuzzy/README.md generated vendored Normal file
View File

@ -0,0 +1,186 @@
<img src="assets/search-gopher-1.png" alt="gopher looking for stuff"> <img src="assets/search-gopher-2.png" alt="gopher found stuff">
# fuzzy
[![Build Status](https://travis-ci.org/sahilm/fuzzy.svg?branch=master)](https://travis-ci.org/sahilm/fuzzy)
[![Documentation](https://godoc.org/github.com/sahilm/fuzzy?status.svg)](https://godoc.org/github.com/sahilm/fuzzy)
Go library that provides fuzzy string matching optimized for filenames and code symbols in the style of Sublime Text,
VSCode, IntelliJ IDEA et al. This library is external dependency-free. It only depends on the Go standard library.
## Features
- Intuitive matching. Results are returned in descending order of match quality. Quality is determined by:
- The first character in the pattern matches the first character in the match string.
- The matched character is camel cased.
- The matched character follows a separator such as an underscore character.
- The matched character is adjacent to a previous match.
- Speed. Matches are returned in milliseconds. It's perfect for interactive search boxes.
- The positions of matches are returned. Allows you to highlight matching characters.
- Unicode aware.
## Demo
Here is a [demo](_example/main.go) of matching various patterns against ~16K files from the Unreal Engine 4 codebase.
![demo](assets/demo.gif)
You can run the demo yourself like so:
```
cd _example/
go get github.com/jroimartin/gocui
go run main.go
```
## Usage
The following example prints out matches with the matched chars in bold.
```go
package main
import (
"fmt"
"github.com/sahilm/fuzzy"
)
func main() {
const bold = "\033[1m%s\033[0m"
pattern := "mnr"
data := []string{"game.cpp", "moduleNameResolver.ts", "my name is_Ramsey"}
matches := fuzzy.Find(pattern, data)
for _, match := range matches {
for i := 0; i < len(match.Str); i++ {
if contains(i, match.MatchedIndexes) {
fmt.Print(fmt.Sprintf(bold, string(match.Str[i])))
} else {
fmt.Print(string(match.Str[i]))
}
}
fmt.Println()
}
}
func contains(needle int, haystack []int) bool {
for _, i := range haystack {
if needle == i {
return true
}
}
return false
}
```
If the data you want to match isn't a slice of strings, you can use `FindFrom` by implementing
the provided `Source` interface. Here's an example:
```go
package main
import (
"fmt"
"github.com/sahilm/fuzzy"
)
type employee struct {
name string
age int
}
type employees []employee
func (e employees) String(i int) string {
return e[i].name
}
func (e employees) Len() int {
return len(e)
}
func main() {
emps := employees{
{
name: "Alice",
age: 45,
},
{
name: "Bob",
age: 35,
},
{
name: "Allie",
age: 35,
},
}
results := fuzzy.FindFrom("al", emps)
for _, r := range results {
fmt.Println(emps[r.Index])
}
}
```
Check out the [godoc](https://godoc.org/github.com/sahilm/fuzzy) for detailed documentation.
## Installation
`go get github.com/sahilm/fuzzy` or use your favorite dependency management tool.
## Speed
Here are a few benchmark results on a normal laptop.
```
BenchmarkFind/with_unreal_4_(~16K_files)-4 100 12915315 ns/op
BenchmarkFind/with_linux_kernel_(~60K_files)-4 50 30885038 ns/op
```
Matching a pattern against ~60K files from the Linux kernel takes about 30ms.
## Contributing
Everyone is welcome to contribute. Please send me a pull request or file an issue. I promise
to respond promptly.
## Credits
* [@ericpauley](https://github.com/ericpauley) & [@lunixbochs](https://github.com/lunixbochs) contributed Unicode awareness and various performance optimisations.
* The algorithm is based of the awesome work of [forrestthewoods](https://github.com/forrestthewoods/lib_fts/blob/master/code/fts_fuzzy_match.js).
See [this](https://blog.forrestthewoods.com/reverse-engineering-sublime-text-s-fuzzy-match-4cffeed33fdb#.d05n81yjy)
blog post for details of the algorithm.
* The artwork is by my lovely wife Sanah. It's based on the Go Gopher.
* The Go gopher was designed by Renee French (http://reneefrench.blogspot.com/).
The design is licensed under the Creative Commons 3.0 Attributions license.
## License
The MIT License (MIT)
Copyright (c) 2017 Sahil Muthoo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

254
vendor/github.com/sahilm/fuzzy/fuzzy.go generated vendored Normal file
View File

@ -0,0 +1,254 @@
/*
Package fuzzy provides fuzzy string matching optimized
for filenames and code symbols in the style of Sublime Text,
VSCode, IntelliJ IDEA et al.
*/
package fuzzy
import (
"sort"
"unicode"
"unicode/utf8"
)
// Match represents a matched string.
type Match struct {
// The matched string.
Str string
// The index of the matched string in the supplied slice.
Index int
// The indexes of matched characters. Useful for highlighting matches.
MatchedIndexes []int
// Score used to rank matches
Score int
}
const (
firstCharMatchBonus = 10
matchFollowingSeparatorBonus = 20
camelCaseMatchBonus = 20
adjacentMatchBonus = 5
unmatchedLeadingCharPenalty = -5
maxUnmatchedLeadingCharPenalty = -15
)
var separators = []rune("/-_ .\\")
// Matches is a slice of Match structs
type Matches []Match
func (a Matches) Len() int { return len(a) }
func (a Matches) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a Matches) Less(i, j int) bool { return a[i].Score >= a[j].Score }
// Source represents an abstract source of a list of strings. Source must be iterable type such as a slice.
// The source will be iterated over till Len() with String(i) being called for each element where i is the
// index of the element. You can find a working example in the README.
type Source interface {
// The string to be matched at position i.
String(i int) string
// The length of the source. Typically is the length of the slice of things that you want to match.
Len() int
}
type stringSource []string
func (ss stringSource) String(i int) string {
return ss[i]
}
func (ss stringSource) Len() int { return len(ss) }
/*
Find looks up pattern in data and returns matches
in descending order of match quality. Match quality
is determined by a set of bonus and penalty rules.
The following types of matches apply a bonus:
* The first character in the pattern matches the first character in the match string.
* The matched character is camel cased.
* The matched character follows a separator such as an underscore character.
* The matched character is adjacent to a previous match.
Penalties are applied for every character in the search string that wasn't matched and all leading
characters upto the first match.
Results are sorted by best match.
*/
func Find(pattern string, data []string) Matches {
return FindFrom(pattern, stringSource(data))
}
/*
FindNoSort is an alternative Find implementation that does not sort
the results in the end.
*/
func FindNoSort(pattern string, data []string) Matches {
return FindFromNoSort(pattern, stringSource(data))
}
/*
FindFrom is an alternative implementation of Find using a Source
instead of a list of strings.
*/
func FindFrom(pattern string, data Source) Matches {
matches := FindFromNoSort(pattern, data)
sort.Stable(matches)
return matches
}
/*
FindFromNoSort is an alternative FindFrom implementation that does
not sort results in the end.
*/
func FindFromNoSort(pattern string, data Source) Matches {
if len(pattern) == 0 {
return nil
}
runes := []rune(pattern)
var matches Matches
var matchedIndexes []int
for i := 0; i < data.Len(); i++ {
var match Match
match.Str = data.String(i)
match.Index = i
if matchedIndexes != nil {
match.MatchedIndexes = matchedIndexes
} else {
match.MatchedIndexes = make([]int, 0, len(runes))
}
var score int
patternIndex := 0
bestScore := -1
matchedIndex := -1
currAdjacentMatchBonus := 0
var last rune
var lastIndex int
nextc, nextSize := utf8.DecodeRuneInString(data.String(i))
var candidate rune
var candidateSize int
for j := 0; j < len(data.String(i)); j += candidateSize {
candidate, candidateSize = nextc, nextSize
if equalFold(candidate, runes[patternIndex]) {
score = 0
if j == 0 {
score += firstCharMatchBonus
}
if unicode.IsLower(last) && unicode.IsUpper(candidate) {
score += camelCaseMatchBonus
}
if j != 0 && isSeparator(last) {
score += matchFollowingSeparatorBonus
}
if len(match.MatchedIndexes) > 0 {
lastMatch := match.MatchedIndexes[len(match.MatchedIndexes)-1]
bonus := adjacentCharBonus(lastIndex, lastMatch, currAdjacentMatchBonus)
score += bonus
// adjacent matches are incremental and keep increasing based on previous adjacent matches
// thus we need to maintain the current match bonus
currAdjacentMatchBonus += bonus
}
if score > bestScore {
bestScore = score
matchedIndex = j
}
}
var nextp rune
if patternIndex < len(runes)-1 {
nextp = runes[patternIndex+1]
}
if j+candidateSize < len(data.String(i)) {
if data.String(i)[j+candidateSize] < utf8.RuneSelf { // Fast path for ASCII
nextc, nextSize = rune(data.String(i)[j+candidateSize]), 1
} else {
nextc, nextSize = utf8.DecodeRuneInString(data.String(i)[j+candidateSize:])
}
} else {
nextc, nextSize = 0, 0
}
// We apply the best score when we have the next match coming up or when the search string has ended.
// Tracking when the next match is coming up allows us to exhaustively find the best match and not necessarily
// the first match.
// For example given the pattern "tk" and search string "The Black Knight", exhaustively matching allows us
// to match the second k thus giving this string a higher score.
if equalFold(nextp, nextc) || nextc == 0 {
if matchedIndex > -1 {
if len(match.MatchedIndexes) == 0 {
penalty := matchedIndex * unmatchedLeadingCharPenalty
bestScore += max(penalty, maxUnmatchedLeadingCharPenalty)
}
match.Score += bestScore
match.MatchedIndexes = append(match.MatchedIndexes, matchedIndex)
score = 0
bestScore = -1
patternIndex++
}
}
lastIndex = j
last = candidate
}
// apply penalty for each unmatched character
penalty := len(match.MatchedIndexes) - len(data.String(i))
match.Score += penalty
if len(match.MatchedIndexes) == len(runes) {
matches = append(matches, match)
matchedIndexes = nil
} else {
matchedIndexes = match.MatchedIndexes[:0] // Recycle match index slice
}
}
return matches
}
// Taken from strings.EqualFold
func equalFold(tr, sr rune) bool {
if tr == sr {
return true
}
if tr < sr {
tr, sr = sr, tr
}
// Fast check for ASCII.
if tr < utf8.RuneSelf {
// ASCII, and sr is upper case. tr must be lower case.
if 'A' <= sr && sr <= 'Z' && tr == sr+'a'-'A' {
return true
}
return false
}
// General case. SimpleFold(x) returns the next equivalent rune > x
// or wraps around to smaller values.
r := unicode.SimpleFold(sr)
for r != sr && r < tr {
r = unicode.SimpleFold(r)
}
return r == tr
}
func adjacentCharBonus(i int, lastMatch int, currentBonus int) int {
if lastMatch == i {
return currentBonus*2 + adjacentMatchBonus
}
return 0
}
func isSeparator(s rune) bool {
for _, sep := range separators {
if s == sep {
return true
}
}
return false
}
func max(x int, y int) int {
if x > y {
return x
}
return y
}

14
vendor/modules.txt vendored
View File

@ -71,7 +71,10 @@ github.com/cespare/xxhash/v2
# github.com/charmbracelet/bubbles v0.21.0
## explicit; go 1.23.0
github.com/charmbracelet/bubbles/cursor
github.com/charmbracelet/bubbles/help
github.com/charmbracelet/bubbles/key
github.com/charmbracelet/bubbles/list
github.com/charmbracelet/bubbles/paginator
github.com/charmbracelet/bubbles/runeutil
github.com/charmbracelet/bubbles/spinner
github.com/charmbracelet/bubbles/textinput
@ -283,9 +286,6 @@ github.com/emirpasic/gods/utils
# github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f
## explicit; go 1.16
github.com/erikgeiser/coninput
# github.com/evertras/bubble-table v0.19.2
## explicit; go 1.18
github.com/evertras/bubble-table/table
# github.com/felixge/httpsnoop v1.0.4
## explicit; go 1.13
github.com/felixge/httpsnoop
@ -493,11 +493,6 @@ github.com/muesli/ansi/compressor
# github.com/muesli/cancelreader v0.2.2
## explicit; go 1.17
github.com/muesli/cancelreader
# github.com/muesli/reflow v0.3.0
## explicit; go 1.13
github.com/muesli/reflow/ansi
github.com/muesli/reflow/truncate
github.com/muesli/reflow/wordwrap
# github.com/muesli/termenv v0.16.0
## explicit; go 1.17
github.com/muesli/termenv
@ -552,6 +547,9 @@ github.com/rivo/uniseg
# github.com/russross/blackfriday/v2 v2.1.0
## explicit
github.com/russross/blackfriday/v2
# github.com/sahilm/fuzzy v0.1.1
## explicit
github.com/sahilm/fuzzy
# github.com/schollz/progressbar/v3 v3.18.0
## explicit; go 1.22
github.com/schollz/progressbar/v3