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