This commit is contained in:
2024-06-23 09:49:14 -05:00
parent 716b7424c4
commit d96fae1276
13 changed files with 810 additions and 568 deletions

72
internal/tui/cmdlist.go Normal file
View File

@@ -0,0 +1,72 @@
package tui
import (
"fmt"
"io"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
)
// screen length of the list
const listHeight = 10
// item is the object that will appear in our list
type item struct {
cmd *cobra.Command
}
func (i item) FilterValue() string { return i.cmd.Name() }
// itemDelegate encapsulates the general functionality for all list items
type itemDelegate struct {
styles *Styles
}
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 := i.cmd.Name() + lipgloss.NewStyle().Bold(true).
PaddingLeft(i.cmd.NamePadding()-len(i.cmd.Name())+1).Render(i.cmd.Short)
fn := d.styles.Item.Render
if index == m.Index() {
fn = func(strs ...string) string {
return d.styles.SelectedItem.Render("> " + fmt.Sprint(strs))
}
}
fmt.Fprint(w, fn(str))
}
// newSubCmdsList returns a new list.Model filled with the values in []list.Items
func newSubCmdsList(styles *Styles, items []list.Item) list.Model {
l := list.New(items, itemDelegate{styles: styles}, 0, listHeight)
l.Styles.TitleBar.Padding(0, 0)
l.Styles.Title = styles.Section
l.Title = "Available Sub Commands:"
l.SetShowHelp(false)
l.SetShowStatusBar(false)
l.SetShowPagination(false)
return l
}
// getSubCommands returns a []list.Item filled with any available sub command from the supplied *cobra.Command.
// This does not follow the command chain past a depth of 1.
func getSubCommands(c *cobra.Command) []list.Item {
subs := make([]list.Item, 0)
if c.HasAvailableSubCommands() {
for _, subcmd := range c.Commands() {
if subcmd.Name() == "help" || subcmd.IsAvailableCommand() {
subs = append(subs, item{cmd: subcmd})
}
}
}
return subs
}

44
internal/tui/helper.go Normal file
View File

@@ -0,0 +1,44 @@
package tui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
)
type Boa struct {
options *options
}
// Create a new instance of Boa with custom Options
func New(options ...Options) *Boa {
opts := defaultOptions()
for _, opt := range options {
opt.apply(opts)
}
return &Boa{
options: opts,
}
}
func (b *Boa) HelpFunc(cmd *cobra.Command, s []string) {
model := newCmdModel(b.options, cmd)
p := tea.NewProgram(model, b.options.altScreen, b.options.mouseCellMotion)
p.Run()
if model.print {
fmt.Println(b.options.styles.Border.Render(b.options.styles.CmdPrint.Render(model.cmdChain)))
}
}
func (b *Boa) UsageFunc(cmd *cobra.Command) error {
model := newCmdModel(b.options, cmd)
p := tea.NewProgram(model, b.options.altScreen, b.options.mouseCellMotion)
p.Run()
if model.print {
fmt.Println(b.options.styles.Border.Render(b.options.styles.CmdPrint.Render(model.cmdChain)))
}
return nil
}

View File

@@ -0,0 +1,77 @@
package tui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
cursor int
choices []string
selected map[int]struct{}
}
func InitialModel() model {
return model{
choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
// A map which indicates which choices are selected. We're using
// the map like a mathematical set. The keys refer to the indexes
// of the `choices` slice, above.
selected: make(map[int]struct{}),
}
}
func (m model) Init() tea.Cmd {
return tea.SetWindowTitle("Grocery List")
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.choices)-1 {
m.cursor++
}
case "enter", " ":
_, ok := m.selected[m.cursor]
if ok {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = struct{}{}
}
}
}
return m, nil
}
func (m model) View() string {
s := "What should we buy at the market?\n\n"
for i, choice := range m.choices {
cursor := " "
if m.cursor == i {
cursor = ">"
}
checked := " "
if _, ok := m.selected[i]; ok {
checked = "x"
}
s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
}
s += "\nPress q to quit.\n"
return s
}

246
internal/tui/model.go Normal file
View File

@@ -0,0 +1,246 @@
package tui
import (
"bytes"
"strings"
"unicode"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
)
const (
spacebar = " "
)
// cmdModel Implements tea.Model. It provides an interactive
// help and usage tui component for bubbletea programs.
type cmdModel struct {
styles *Styles
list list.Model
viewport *viewport.Model
cmd *cobra.Command
subCmds []list.Item
print bool
cmdChain string
// Store window height to adjust viewport on command selection changes
windowHeight int
// Store full height of content for given view, updated on command change
contentHeight int
errorWriter *bytes.Buffer
}
// newCmdModel initializes a based on values supplied from cmd *cobra.Command
func newCmdModel(options *options, cmd *cobra.Command) *cmdModel {
subCmds := getSubCommands(cmd)
l := newSubCmdsList(options.styles, subCmds)
vp := viewport.New(0, 0)
vp.KeyMap = viewPortKeyMap()
m := &cmdModel{
styles: options.styles,
cmd: cmd,
subCmds: subCmds,
list: l,
viewport: &vp,
errorWriter: options.errorWriter,
}
m.contentHeight = lipgloss.Height(m.usage())
return m
}
// Init is the initial cmd to be executed which is nil for this component.
func (m *cmdModel) Init() tea.Cmd {
return nil
}
// Update is called when a message is received.
func (m *cmdModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var listCmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.windowHeight = msg.Height
m.viewport.Width = msg.Width
m.viewport.Height = m.windowHeight - lipgloss.Height(m.footer())
if m.viewport.Height > m.contentHeight {
m.viewport.Height = m.contentHeight
}
// Scroll viewport back to top for new screen
m.viewport.SetYOffset(0)
return m, nil
case tea.KeyMsg:
switch keypress := msg.String(); keypress {
case "q", "ctrl+c":
return m, tea.Quit
case "enter":
i, ok := m.list.SelectedItem().(item)
if ok {
m.cmd = i.cmd
subCmds := getSubCommands(m.cmd)
m.list = newSubCmdsList(m.styles, subCmds)
m.viewport.Height = m.windowHeight - lipgloss.Height(m.footer())
// Update new content height and check viewport size
m.contentHeight = lipgloss.Height(m.usage())
if m.viewport.Height > m.contentHeight {
m.viewport.Height = m.contentHeight
}
// Scroll viewport back to top for new screen
m.viewport.SetYOffset(0)
}
return m, nil
case "b":
if m.cmd.HasParent() {
m.cmd = m.cmd.Parent()
subCmds := getSubCommands(m.cmd)
m.list = newSubCmdsList(m.styles, subCmds)
m.viewport.Height = m.windowHeight - lipgloss.Height(m.footer())
// Update new content height and check viewport size
m.contentHeight = lipgloss.Height(m.usage())
if m.viewport.Height > m.contentHeight {
m.viewport.Height = m.contentHeight
}
// Scroll viewport back to top for new screen
m.viewport.SetYOffset(0)
}
return m, nil
case "p":
m.print = true
m.cmdChain = print("", m.cmd)
return m, tea.Quit
}
}
m.list, listCmd = m.list.Update(msg)
newViewport, viewPortCmd := m.viewport.Update(msg)
// point to new viewport
m.viewport = &newViewport
m.viewport.KeyMap = viewPortKeyMap()
if m.viewport.Height > m.contentHeight {
m.viewport.Height = m.contentHeight
}
cmds = append(cmds, listCmd, viewPortCmd)
return m, tea.Batch(cmds...)
}
// View renders the program's UI, which is just a string.
func (m *cmdModel) View() string {
m.viewport.SetContent(m.usage())
return lipgloss.JoinVertical(lipgloss.Top, m.viewport.View(), m.footer())
}
// usage builds the usage body from a cobra command
func (m *cmdModel) usage() string {
usageText := strings.Builder{}
if m.errorWriter != nil && m.errorWriter.Len() > 0 {
usageText.WriteString(m.styles.ErrorText.Render(m.errorWriter.String() + "\n"))
}
cmdTitle := ""
cmdName := m.cmd.Name()
if m.cmd.Version != "" {
cmdName += " " + m.cmd.Version
}
cmdName = m.styles.Section.Render(cmdName)
cmdLong := m.styles.SubTitle.Render(m.cmd.Long)
cmdTitle = m.styles.Title.Foreground(lipgloss.AdaptiveColor{Light: darkGrey, Dark: white}).
Render(lipgloss.JoinVertical(lipgloss.Center, cmdName, cmdLong))
usageText.WriteString(cmdTitle + "\n")
cmdSection := m.styles.Section.Render("Cmd Description:")
short := m.styles.Text.Render(m.cmd.Short)
usageText.WriteString(lipgloss.JoinVertical(lipgloss.Left, cmdSection, short) + "\n")
if m.cmd.Runnable() {
usage := m.styles.Section.Render("Usage:")
useLine := m.styles.Text.Render(m.cmd.UseLine())
usageText.WriteString(lipgloss.JoinVertical(lipgloss.Left, usage, useLine) + "\n")
if m.cmd.HasAvailableSubCommands() {
commandPath := m.styles.Text.Render(m.cmd.CommandPath() + " [command]")
usageText.WriteString(lipgloss.JoinVertical(lipgloss.Left, commandPath) + "\n")
}
}
if len(m.cmd.Aliases) > 0 {
aliases := m.styles.Section.Render("Aliases:")
nameAndAlias := m.styles.Text.Render(m.cmd.NameAndAliases())
usageText.WriteString(lipgloss.JoinVertical(lipgloss.Left, aliases, nameAndAlias) + "\n")
}
if m.cmd.HasAvailableLocalFlags() {
localFlags := m.styles.Section.Render("Flags:")
flagUsage := m.styles.Text.Render(strings.TrimRightFunc(m.cmd.LocalFlags().FlagUsages(), unicode.IsSpace))
usageText.WriteString(lipgloss.JoinVertical(lipgloss.Left, localFlags, flagUsage) + "\n")
}
if m.cmd.HasAvailableInheritedFlags() {
globalFlags := m.styles.Section.Render("Global Flags:")
flagUsage := m.styles.Text.Render(strings.TrimRightFunc(m.cmd.InheritedFlags().FlagUsages(), unicode.IsSpace))
usageText.WriteString(lipgloss.JoinVertical(lipgloss.Left, globalFlags, flagUsage) + "\n")
}
if m.cmd.HasExample() {
examples := m.styles.Section.Render("Examples:")
example := m.styles.Text.Render(m.cmd.Example)
usageText.WriteString(lipgloss.JoinVertical(lipgloss.Left, examples, example) + "\n")
}
if m.cmd.HasAvailableSubCommands() {
usageText.WriteString(lipgloss.JoinVertical(lipgloss.Left, m.list.View()))
}
return m.styles.Border.Render(usageText.String() + "\n")
}
// footer outputs the footer of the viewport and contains help text.
func (m *cmdModel) footer() string {
var help, scroll string
help = m.styles.Info.Render("↑/k up • ↓/j down • / to filter • p to print • b to go back • enter to select • q, ctrl+c to quit")
// If content is larger than the window minus the size of the necessary footer then it will be in a scrollable viewport
if m.contentHeight > m.windowHeight-2 {
scroll = m.styles.Info.Render("ctrl+k up • ctrl+j down • mouse to scroll")
}
return lipgloss.JoinVertical(lipgloss.Left, help, scroll)
}
// print outputs the command chain for a given cobra command.
func print(v string, cmd *cobra.Command) string {
if cmd != nil {
v = cmd.Name() + " " + v
if !cmd.HasParent() {
// final result
return "Command: " + v
}
// recursively walk cmd chain
return print(v, cmd.Parent())
}
return v
}
func viewPortKeyMap() viewport.KeyMap {
return viewport.KeyMap{
PageDown: key.NewBinding(
key.WithKeys("pgdown", spacebar, "f"),
),
PageUp: key.NewBinding(
key.WithKeys("pgup", "v"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("u", "ctrl+u"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("d", "ctrl+d"),
),
Up: key.NewBinding(
key.WithKeys("ctrl+k"),
),
Down: key.NewBinding(
key.WithKeys("ctrl+j"),
),
}
}

71
internal/tui/options.go Normal file
View File

@@ -0,0 +1,71 @@
package tui
import (
"bytes"
tea "github.com/charmbracelet/bubbletea"
)
var ErrorWriter = &bytes.Buffer{}
type options struct {
// public
altScreen tea.ProgramOption
styles *Styles
// private (not capable of being set)
mouseCellMotion tea.ProgramOption
errorWriter *bytes.Buffer
}
type Options interface {
apply(*options)
}
type funcOption struct {
f func(*options)
}
func (fo *funcOption) apply(opt *options) {
fo.f(opt)
}
func newFuncOption(f func(*options)) *funcOption {
return &funcOption{f: f}
}
func WithAltScreen(b bool) Options {
return newFuncOption(func(opt *options) {
if !b {
opt.altScreen = noOpt
return
}
opt.altScreen = tea.WithAltScreen()
})
}
func WithStyles(styles *Styles) Options {
return newFuncOption(func(opt *options) {
if styles != nil {
opt.styles = styles
}
})
}
func WithErrWriter(b *bytes.Buffer) Options {
return newFuncOption(func(opt *options) {
if b != nil {
opt.errorWriter = b
}
})
}
func defaultOptions() *options {
return &options{
altScreen: tea.WithAltScreen(),
styles: DefaultStyles(),
mouseCellMotion: tea.WithMouseCellMotion(),
errorWriter: ErrorWriter,
}
}
func noOpt(*tea.Program) {}

83
internal/tui/styles.go Normal file
View File

@@ -0,0 +1,83 @@
package tui
import (
"github.com/charmbracelet/lipgloss"
)
const (
defaultWidth = 100
//default colors
purple = `#7e2fcc`
darkGrey = `#353C3B`
lightTeal = `#03DAC5`
darkTeal = `#01A299`
white = `#e5e5e5`
red = `#FF3333`
)
type Styles struct {
Border lipgloss.Style
Title lipgloss.Style
SubTitle lipgloss.Style
Section lipgloss.Style
Text lipgloss.Style
ErrorText lipgloss.Style
SelectedItem lipgloss.Style
Item lipgloss.Style
Info lipgloss.Style
CmdPrint lipgloss.Style
}
func DefaultStyles() *Styles {
s := &Styles{}
// Style of the border
s.Border = lipgloss.NewStyle().
Padding(0, 1, 0, 1).
Width(defaultWidth).
BorderForeground(lipgloss.AdaptiveColor{Light: darkTeal, Dark: lightTeal}).
Border(lipgloss.ThickBorder())
// Style of the title
s.Title = lipgloss.NewStyle().Bold(true).
Border(lipgloss.DoubleBorder()).
BorderForeground(lipgloss.AdaptiveColor{Light: purple, Dark: purple}).
Width(defaultWidth - 4).
Align(lipgloss.Center)
// Style of the SubTitle
s.SubTitle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: white, Dark: white}).Align(lipgloss.Center)
// Style of the individual help sections (Exaple, Usage, Flags etc.. )
s.Section = lipgloss.NewStyle().Bold(true).
Foreground(lipgloss.AdaptiveColor{Light: darkTeal, Dark: lightTeal}).
Underline(true).
BorderBottom(true).
Margin(1, 0, 1, 0).
Padding(0, 1, 0, 1).Align(lipgloss.Center)
// Style of the text output
s.Text = lipgloss.NewStyle().Bold(true).Padding(0, 0, 0, 5).Align(lipgloss.Left).
Foreground(lipgloss.AdaptiveColor{Light: darkGrey, Dark: white})
s.ErrorText = lipgloss.NewStyle().Underline(true).Bold(true).Align(lipgloss.Center).Width(defaultWidth - 4).
Foreground(lipgloss.AdaptiveColor{Light: red, Dark: red})
// Style of the selection list items
s.SelectedItem = lipgloss.NewStyle().PaddingLeft(2).Background(lipgloss.AdaptiveColor{Light: purple, Dark: purple}).
Foreground(lipgloss.AdaptiveColor{Light: white, Dark: white})
// Style of the list items
s.Item = lipgloss.NewStyle().PaddingLeft(2).Bold(true).Foreground(lipgloss.AdaptiveColor{Light: white, Dark: white})
// Style of the info text
s.Info = lipgloss.NewStyle().Bold(true).Width(defaultWidth).Align(lipgloss.Center).
Foreground(lipgloss.AdaptiveColor{Light: darkGrey, Dark: white})
// Style of the Cmd Print text
s.CmdPrint = lipgloss.NewStyle().Bold(true).Width(defaultWidth).Margin(1).Align(lipgloss.Center).
Foreground(lipgloss.AdaptiveColor{Light: darkGrey, Dark: white})
return s
}