help.go

  1// Package help provides a simple help view for Bubble Tea applications.
  2package help
  3
  4import (
  5	"strings"
  6
  7	"github.com/charmbracelet/bubbles/v2/key"
  8	tea "github.com/charmbracelet/bubbletea/v2"
  9	"github.com/charmbracelet/lipgloss/v2"
 10)
 11
 12// KeyMap is a map of keybindings used to generate help. Since it's an
 13// interface it can be any type, though struct or a map[string][]key.Binding
 14// are likely candidates.
 15//
 16// Note that if a key is disabled (via key.Binding.SetEnabled) it will not be
 17// rendered in the help view, so in theory generated help should self-manage.
 18type KeyMap interface {
 19	// ShortHelp returns a slice of bindings to be displayed in the short
 20	// version of the help. The help bubble will render help in the order in
 21	// which the help items are returned here.
 22	ShortHelp() []key.Binding
 23
 24	// FullHelp returns an extended group of help items, grouped by columns.
 25	// The help bubble will render the help in the order in which the help
 26	// items are returned here.
 27	FullHelp() [][]key.Binding
 28}
 29
 30// Styles is a set of available style definitions for the Help bubble.
 31type Styles struct {
 32	Ellipsis lipgloss.Style
 33
 34	// Styling for the short help
 35	ShortKey       lipgloss.Style
 36	ShortDesc      lipgloss.Style
 37	ShortSeparator lipgloss.Style
 38
 39	// Styling for the full help
 40	FullKey       lipgloss.Style
 41	FullDesc      lipgloss.Style
 42	FullSeparator lipgloss.Style
 43}
 44
 45// DefaultStyles returns a set of default styles for the help bubble. Light or
 46// dark styles can be selected by passing true or false to the isDark
 47// parameter.
 48func DefaultStyles(isDark bool) Styles {
 49	lightDark := lipgloss.LightDark(isDark)
 50
 51	keyStyle := lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("#909090"), lipgloss.Color("#626262")))
 52	descStyle := lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("#B2B2B2"), lipgloss.Color("#4A4A4A")))
 53	sepStyle := lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("#DADADA"), lipgloss.Color("#3C3C3C")))
 54
 55	return Styles{
 56		ShortKey:       keyStyle,
 57		ShortDesc:      descStyle,
 58		ShortSeparator: sepStyle,
 59		Ellipsis:       sepStyle,
 60		FullKey:        keyStyle,
 61		FullDesc:       descStyle,
 62		FullSeparator:  sepStyle,
 63	}
 64}
 65
 66// DefaultDarkStyles returns a set of default styles for dark backgrounds.
 67func DefaultDarkStyles() Styles {
 68	return DefaultStyles(true)
 69}
 70
 71// DefaultLightStyles returns a set of default styles for light backgrounds.
 72func DefaultLightStyles() Styles {
 73	return DefaultStyles(false)
 74}
 75
 76// Model contains the state of the help view.
 77type Model struct {
 78	Width   int
 79	ShowAll bool // if true, render the "full" help menu
 80
 81	ShortSeparator string
 82	FullSeparator  string
 83
 84	// The symbol we use in the short help when help items have been truncated
 85	// due to width. Periods of ellipsis by default.
 86	Ellipsis string
 87
 88	Styles Styles
 89}
 90
 91// New creates a new help view with some useful defaults.
 92func New() Model {
 93	return Model{
 94		ShortSeparator: " • ",
 95		FullSeparator:  "    ",
 96		Ellipsis:       "…",
 97		Styles:         DefaultDarkStyles(),
 98	}
 99}
100
101// Update helps satisfy the Bubble Tea Model interface. It's a no-op.
102func (m Model) Update(_ tea.Msg) (Model, tea.Cmd) {
103	return m, nil
104}
105
106// View renders the help view's current state.
107func (m Model) View(k KeyMap) string {
108	if m.ShowAll {
109		return m.FullHelpView(k.FullHelp())
110	}
111	return m.ShortHelpView(k.ShortHelp())
112}
113
114// ShortHelpView renders a single line help view from a slice of keybindings.
115// If the line is longer than the maximum width it will be gracefully
116// truncated, showing only as many help items as possible.
117func (m Model) ShortHelpView(bindings []key.Binding) string {
118	if len(bindings) == 0 {
119		return ""
120	}
121
122	var b strings.Builder
123	var totalWidth int
124	separator := m.Styles.ShortSeparator.Inline(true).Render(m.ShortSeparator)
125
126	for i, kb := range bindings {
127		if !kb.Enabled() {
128			continue
129		}
130
131		// Sep
132		var sep string
133		if totalWidth > 0 && i < len(bindings) {
134			sep = separator
135		}
136
137		// Item
138		str := sep +
139			m.Styles.ShortKey.Inline(true).Render(kb.Help().Key) + " " +
140			m.Styles.ShortDesc.Inline(true).Render(kb.Help().Desc)
141		w := lipgloss.Width(str)
142
143		// Tail
144		if tail, ok := m.shouldAddItem(totalWidth, w); !ok {
145			if tail != "" {
146				b.WriteString(tail)
147			}
148			break
149		}
150
151		totalWidth += w
152		b.WriteString(str)
153	}
154
155	return b.String()
156}
157
158// FullHelpView renders help columns from a slice of key binding slices. Each
159// top level slice entry renders into a column.
160func (m Model) FullHelpView(groups [][]key.Binding) string {
161	if len(groups) == 0 {
162		return ""
163	}
164
165	// Linter note: at this time we don't think it's worth the additional
166	// code complexity involved in preallocating this slice.
167	//nolint:prealloc
168	var (
169		out []string
170
171		totalWidth int
172		separator  = m.Styles.FullSeparator.Inline(true).Render(m.FullSeparator)
173	)
174
175	// Iterate over groups to build columns
176	for i, group := range groups {
177		if group == nil || !shouldRenderColumn(group) {
178			continue
179		}
180		var (
181			sep          string
182			keys         []string
183			descriptions []string
184		)
185
186		// Sep
187		if totalWidth > 0 && i < len(groups) {
188			sep = separator
189		}
190
191		// Separate keys and descriptions into different slices
192		for _, kb := range group {
193			if !kb.Enabled() {
194				continue
195			}
196			keys = append(keys, kb.Help().Key)
197			descriptions = append(descriptions, kb.Help().Desc)
198		}
199
200		// Column
201		col := lipgloss.JoinHorizontal(lipgloss.Top,
202			sep,
203			m.Styles.FullKey.Render(strings.Join(keys, "\n")),
204			" ",
205			m.Styles.FullDesc.Render(strings.Join(descriptions, "\n")),
206		)
207		w := lipgloss.Width(col)
208
209		// Tail
210		if tail, ok := m.shouldAddItem(totalWidth, w); !ok {
211			if tail != "" {
212				out = append(out, tail)
213			}
214			break
215		}
216
217		totalWidth += w
218		out = append(out, col)
219	}
220
221	return lipgloss.JoinHorizontal(lipgloss.Top, out...)
222}
223
224func (m Model) shouldAddItem(totalWidth, width int) (tail string, ok bool) {
225	// If there's room for an ellipsis, print that.
226	if m.Width > 0 && totalWidth+width > m.Width {
227		tail = " " + m.Styles.Ellipsis.Inline(true).Render(m.Ellipsis)
228
229		if totalWidth+lipgloss.Width(tail) < m.Width {
230			return tail, false
231		}
232	}
233	return "", true
234}
235
236func shouldRenderColumn(b []key.Binding) (ok bool) {
237	for _, v := range b {
238		if v.Enabled() {
239			return true
240		}
241	}
242	return false
243}