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}