diff --git a/.gitignore b/.gitignore index 8a93261..77b9145 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ examples/fullscreen/fullscreen examples/help/help examples/http/http +examples/list-default/list-default +examples/list-fancy/list-fancy +examples/list-simple/list-simple examples/mouse/mouse examples/pager/pager examples/simple/simple diff --git a/examples/go.sum b/examples/go.sum index 43d85bb..bc808f6 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -29,6 +29,7 @@ github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55k github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776 h1:VRIbnDWRmAh5yBdz+J6yFMF5vso1It6vn+WmM/5l7MA= github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776/go.mod h1:9wvnDu3YOfxzWM9Cst40msBF1C2UdQgDv962oTxSuMs= github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -63,6 +64,7 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= diff --git a/examples/list-default/main.go b/examples/list-default/main.go new file mode 100644 index 0000000..f9a3274 --- /dev/null +++ b/examples/list-default/main.go @@ -0,0 +1,87 @@ +package main + +import ( + "fmt" + "os" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var docStyle = lipgloss.NewStyle().Margin(1, 2) + +type item struct { + title, desc string +} + +func (i item) Title() string { return i.title } +func (i item) Description() string { return i.desc } +func (i item) FilterValue() string { return i.title } + +type model struct { + list list.Model +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + return m, nil + } + case tea.WindowSizeMsg: + top, right, bottom, left := docStyle.GetMargin() + m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom) + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m model) View() string { + return docStyle.Render(m.list.View()) +} + +func main() { + items := []list.Item{ + item{title: "Raspberry Pi’s", desc: "I have ’em all over my house"}, + item{title: "Nutella", desc: "It's good on toast"}, + item{title: "Bitter melon", desc: "It cools you down"}, + item{title: "Nice socks", desc: "And by that I mean socks without holes"}, + item{title: "Eight hours of sleep", desc: "I had this once"}, + item{title: "Cats", desc: "Usually"}, + item{title: "Plantasia, the album", desc: "My plants love it too"}, + item{title: "Pour over coffee", desc: "It takes forever to make though"}, + item{title: "VR", desc: "Virtual reality...what is there to say?"}, + item{title: "Noguchi Lamps", desc: "Such pleasing organic forms"}, + item{title: "Linux", desc: "Pretty much the best OS"}, + item{title: "Business school", desc: "Just kidding"}, + item{title: "Pottery", desc: "Wet clay is a great feeling"}, + item{title: "Shampoo", desc: "Nothing like clean hair"}, + item{title: "Table tennis", desc: "It’s surprisingly exhausting"}, + item{title: "Milk crates", desc: "Great for packing in your extra stuff"}, + item{title: "Afternoon tea", desc: "Especially the tea sandwich part"}, + item{title: "Stickers", desc: "The thicker the vinyl the better"}, + item{title: "20° Weather", desc: "Celsius, not Fahrenheit"}, + item{title: "Warm light", desc: "Like around 2700 Kelvin"}, + item{title: "The vernal equinox", desc: "The autumnal equinox is pretty good too"}, + item{title: "Gaffer’s tape", desc: "Basically sticky fabric"}, + item{title: "Terrycloth", desc: "In other words, towel fabric"}, + } + + m := model{list: list.NewModel(items, list.NewDefaultDelegate(), 0, 0)} + m.list.Title = "My Fave Things" + + p := tea.NewProgram(m) + p.EnterAltScreen() + + if err := p.Start(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } +} diff --git a/examples/list-fancy/delegate.go b/examples/list-fancy/delegate.go new file mode 100644 index 0000000..e332b83 --- /dev/null +++ b/examples/list-fancy/delegate.go @@ -0,0 +1,89 @@ +package main + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +func newItemDelegate(keys *delegateKeyMap) list.DefaultDelegate { + d := list.NewDefaultDelegate() + + d.UpdateFunc = func(msg tea.Msg, m *list.Model) tea.Cmd { + var title string + + if i, ok := m.SelectedItem().(item); ok { + title = i.Title() + } else { + return nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, keys.choose): + return m.NewStatusMessage(statusMessageStyle("You chose " + title)) + + case key.Matches(msg, keys.remove): + index := m.Index() + m.RemoveItem(index) + if len(m.Items()) == 0 { + keys.remove.SetEnabled(false) + } + return m.NewStatusMessage(statusMessageStyle("Deleted " + title)) + } + } + + return nil + } + + help := []key.Binding{keys.choose, keys.remove} + + d.ShortHelpFunc = func() []key.Binding { + return help + } + + d.FullHelpFunc = func() [][]key.Binding { + return [][]key.Binding{help} + } + + return d +} + +type delegateKeyMap struct { + choose key.Binding + remove key.Binding +} + +// Additional short help entries. This satisfies the help.KeyMap interface and +// is entirely optional. +func (d delegateKeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + d.choose, + d.remove, + } +} + +// Additional full help entries. This satisfies the help.KeyMap interface and +// is entirely optional. +func (d delegateKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + { + d.choose, + d.remove, + }, + } +} + +func newDelegateKeyMap() *delegateKeyMap { + return &delegateKeyMap{ + choose: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "choose"), + ), + remove: key.NewBinding( + key.WithKeys("x", "backspace"), + key.WithHelp("x", "delete"), + ), + } +} diff --git a/examples/list-fancy/main.go b/examples/list-fancy/main.go new file mode 100644 index 0000000..7ae9caf --- /dev/null +++ b/examples/list-fancy/main.go @@ -0,0 +1,190 @@ +package main + +import ( + "fmt" + "math/rand" + "os" + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + appStyle = lipgloss.NewStyle().Padding(1, 2) + + titleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFDF5")). + Background(lipgloss.Color("#25A065")). + Padding(0, 1) + + statusMessageStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#04B575"}). + Render +) + +type item struct { + title string + description string +} + +func (i item) Title() string { return i.title } +func (i item) Description() string { return i.description } +func (i item) FilterValue() string { return i.title } + +type listKeyMap struct { + toggleSpinner key.Binding + toggleTitleBar key.Binding + toggleStatusBar key.Binding + togglePagination key.Binding + toggleHelpMenu key.Binding + insertItem key.Binding +} + +func newListKeyMap() *listKeyMap { + return &listKeyMap{ + insertItem: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "add item"), + ), + toggleSpinner: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "toggle spinner"), + ), + toggleTitleBar: key.NewBinding( + key.WithKeys("T"), + key.WithHelp("T", "toggle title"), + ), + toggleStatusBar: key.NewBinding( + key.WithKeys("S"), + key.WithHelp("S", "toggle status"), + ), + togglePagination: key.NewBinding( + key.WithKeys("P"), + key.WithHelp("P", "toggle pagination"), + ), + toggleHelpMenu: key.NewBinding( + key.WithKeys("H"), + key.WithHelp("H", "toggle help"), + ), + } +} + +type model struct { + list list.Model + itemGenerator *randomItemGenerator + keys *listKeyMap + delegateKeys *delegateKeyMap +} + +func newModel() model { + var ( + itemGenerator randomItemGenerator + delegateKeys = newDelegateKeyMap() + listKeys = newListKeyMap() + ) + + // Make initial list of items + const numItems = 24 + items := make([]list.Item, numItems) + for i := 0; i < numItems; i++ { + items[i] = itemGenerator.next() + } + + // Setup list + delegate := newItemDelegate(delegateKeys) + groceryList := list.NewModel(items, delegate, 0, 0) + groceryList.Title = "Groceries" + groceryList.Styles.Title = titleStyle + groceryList.AdditionalFullHelpKeys = func() []key.Binding { + return []key.Binding{ + listKeys.toggleSpinner, + listKeys.insertItem, + listKeys.toggleTitleBar, + listKeys.toggleStatusBar, + listKeys.togglePagination, + listKeys.toggleHelpMenu, + } + } + + return model{ + list: groceryList, + keys: listKeys, + delegateKeys: delegateKeys, + itemGenerator: &itemGenerator, + } +} + +func (m model) Init() tea.Cmd { + return tea.EnterAltScreen +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + topGap, rightGap, bottomGap, leftGap := appStyle.GetPadding() + m.list.SetSize(msg.Width-leftGap-rightGap, msg.Height-topGap-bottomGap) + + case tea.KeyMsg: + // Don't match any of the keys below if we're actively filtering. + if m.list.FilterState() == list.Filtering { + break + } + + switch { + case key.Matches(msg, m.keys.toggleSpinner): + cmd := m.list.ToggleSpinner() + return m, cmd + + case key.Matches(msg, m.keys.toggleTitleBar): + v := !m.list.ShowTitle() + m.list.SetShowTitle(v) + m.list.SetShowFilter(v) + m.list.SetFilteringEnabled(v) + return m, nil + + case key.Matches(msg, m.keys.toggleStatusBar): + m.list.SetShowStatusBar(!m.list.ShowStatusBar()) + return m, nil + + case key.Matches(msg, m.keys.togglePagination): + m.list.SetShowPagination(!m.list.ShowPagination()) + return m, nil + + case key.Matches(msg, m.keys.toggleHelpMenu): + m.list.SetShowHelp(!m.list.ShowHelp()) + return m, nil + + case key.Matches(msg, m.keys.insertItem): + m.delegateKeys.remove.SetEnabled(true) + newItem := m.itemGenerator.next() + insCmd := m.list.InsertItem(0, newItem) + statusCmd := m.list.NewStatusMessage(statusMessageStyle("Added " + newItem.Title())) + return m, tea.Batch(insCmd, statusCmd) + } + } + + // This will also call our delegate's update function. + newListModel, cmd := m.list.Update(msg) + m.list = newListModel + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m model) View() string { + return appStyle.Render(m.list.View()) +} + +func main() { + rand.Seed(time.Now().UTC().UnixNano()) + + if err := tea.NewProgram(newModel()).Start(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } +} diff --git a/examples/list-fancy/randomitems.go b/examples/list-fancy/randomitems.go new file mode 100644 index 0000000..9a06cd9 --- /dev/null +++ b/examples/list-fancy/randomitems.go @@ -0,0 +1,164 @@ +package main + +import ( + "math/rand" + "sync" +) + +type randomItemGenerator struct { + titles []string + descs []string + titleIndex int + descIndex int + mtx *sync.Mutex + shuffle *sync.Once +} + +func (r *randomItemGenerator) reset() { + r.mtx = &sync.Mutex{} + r.shuffle = &sync.Once{} + + r.titles = []string{ + "Artichoke", + "Baking Flour", + "Bananas", + "Barley", + "Bean Sprouts", + "Bitter Melon", + "Black Cod", + "Blood Orange", + "Brown Sugar", + "Cashew Apple", + "Cashews", + "Cat Food", + "Coconut Milk", + "Cucumber", + "Curry Paste", + "Currywurst", + "Dill", + "Dragonfruit", + "Dried Shrimp", + "Eggs", + "Fish Cake", + "Furikake", + "Garlic", + "Gherkin", + "Ginger", + "Granulated Sugar", + "Grapefruit", + "Green Onion", + "Hazelnuts", + "Heavy whipping cream", + "Honey Dew", + "Horseradish", + "Jicama", + "Kohlrabi", + "Leeks", + "Lentils", + "Licorice Root", + "Meyer Lemons", + "Milk", + "Molasses", + "Muesli", + "Nectarine", + "Niagamo Root", + "Nopal", + "Nutella", + "Oat Milk", + "Oatmeal", + "Olives", + "Papaya", + "Party Gherkin", + "Peppers", + "Persian Lemons", + "Pickle", + "Pineapple", + "Plantains", + "Pocky", + "Powdered Sugar", + "Quince", + "Radish", + "Ramps", + "Star Anise", + "Sweet Potato", + "Tamarind", + "Unsalted Butter", + "Watermelon", + "Weißwurst", + "Yams", + "Yeast", + "Yuzu", + "Snow Peas", + } + + r.descs = []string{ + "A little weird", + "Bold flavor", + "Can’t get enough", + "Delectable", + "Expensive", + "Expired", + "Exquisite", + "Fresh", + "Gimme", + "In season", + "Kind of spicy", + "Looks fresh", + "Looks good to me", + "Maybe not", + "My favorite", + "Oh my", + "On sale", + "Organic", + "Questionable", + "Really fresh", + "Refreshing", + "Salty", + "Scrumptious", + "Delectable", + "Slightly sweet", + "Smells great", + "Tasty", + "Too ripe", + "At last", + "What?", + "Wow", + "Yum", + "Maybe", + "Sure, why not?", + } + + r.shuffle.Do(func() { + shuf := func(x []string) { + rand.Shuffle(len(x), func(i, j int) { x[i], x[j] = x[j], x[i] }) + } + shuf(r.titles) + shuf(r.descs) + }) +} + +func (r *randomItemGenerator) next() item { + if r.mtx == nil { + r.reset() + } + + r.mtx.Lock() + defer r.mtx.Unlock() + + i := item{ + title: r.titles[r.titleIndex], + description: r.descs[r.descIndex], + } + + r.titleIndex++ + if r.titleIndex >= len(r.titles) { + r.titleIndex = 0 + } + + r.descIndex++ + if r.descIndex >= len(r.descs) { + r.descIndex = 0 + } + + return i +} diff --git a/examples/list-simple/main.go b/examples/list-simple/main.go new file mode 100644 index 0000000..d9f88a2 --- /dev/null +++ b/examples/list-simple/main.go @@ -0,0 +1,137 @@ +package main + +import ( + "fmt" + "io" + "os" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const listHeight = 14 + +var ( + titleStyle = lipgloss.NewStyle().MarginLeft(2) + itemStyle = lipgloss.NewStyle().PaddingLeft(4) + selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170")) + paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) + helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) + quitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 4) +) + +type item string + +func (i item) FilterValue() string { return string(i) } + +type itemDelegate struct{} + +func (d itemDelegate) Height() int { return 1 } +func (d itemDelegate) Spacing() int { return 0 } +func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } +func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(item) + if !ok { + return + } + + str := fmt.Sprintf("%d. %s", index+1, i) + + fn := itemStyle.Render + if index == m.Index() { + fn = func(s string) string { + return selectedItemStyle.Render("> " + s) + } + } + + fmt.Fprintf(w, fn(str)) +} + +type model struct { + list list.Model + items []item + choice string + quitting bool +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.list.SetWidth(msg.Width) + return m, nil + + case tea.KeyMsg: + switch keypress := msg.String(); keypress { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + + case "enter": + i, ok := m.list.SelectedItem().(item) + if ok { + m.choice = string(i) + } + return m, tea.Quit + + default: + if !m.list.SettingFilter() && (keypress == "q" || keypress == "esc") { + m.quitting = true + return m, tea.Quit + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd + } + + default: + return m, nil + } +} + +func (m model) View() string { + if m.choice != "" { + return quitTextStyle.Render(fmt.Sprintf("%s? Sounds good to me.", m.choice)) + } + if m.quitting { + return quitTextStyle.Render("Not hungry? That’s cool.") + } + return "\n" + m.list.View() +} + +func main() { + items := []list.Item{ + item("Ramen"), + item("Tomato Soup"), + item("Hamburgers"), + item("Cheeseburgers"), + item("Currywurst"), + item("Okonomiyaki"), + item("Pasta"), + item("Fillet Mignon"), + item("Caviar"), + item("Just Wine"), + } + + const defaultWidth = 20 + + l := list.NewModel(items, itemDelegate{}, defaultWidth, listHeight) + l.Title = "What do you want for dinner?" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.Styles.Title = titleStyle + l.Styles.PaginationStyle = paginationStyle + l.Styles.HelpStyle = helpStyle + + m := model{list: l} + + if err := tea.NewProgram(m).Start(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } +}