help.go

  1package dialog
  2
  3import (
  4	"strings"
  5
  6	"github.com/charmbracelet/bubbles/v2/key"
  7	tea "github.com/charmbracelet/bubbletea/v2"
  8	"github.com/charmbracelet/lipgloss/v2"
  9	"github.com/opencode-ai/opencode/internal/tui/styles"
 10	"github.com/opencode-ai/opencode/internal/tui/theme"
 11	"github.com/opencode-ai/opencode/internal/tui/util"
 12)
 13
 14type helpCmp struct {
 15	width  int
 16	height int
 17	keys   []key.Binding
 18}
 19
 20func (h *helpCmp) Init() tea.Cmd {
 21	return nil
 22}
 23
 24func (h *helpCmp) SetBindings(k []key.Binding) {
 25	h.keys = k
 26}
 27
 28func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 29	switch msg := msg.(type) {
 30	case tea.WindowSizeMsg:
 31		h.width = 90
 32		h.height = msg.Height
 33	}
 34	return h, nil
 35}
 36
 37func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
 38	seen := make(map[string]struct{})
 39	result := make([]key.Binding, 0, len(bindings))
 40
 41	// Process bindings in reverse order
 42	for i := len(bindings) - 1; i >= 0; i-- {
 43		b := bindings[i]
 44		k := strings.Join(b.Keys(), " ")
 45		if _, ok := seen[k]; ok {
 46			// duplicate, skip
 47			continue
 48		}
 49		seen[k] = struct{}{}
 50		// Add to the beginning of result to maintain original order
 51		result = append([]key.Binding{b}, result...)
 52	}
 53
 54	return result
 55}
 56
 57func (h *helpCmp) render() string {
 58	t := theme.CurrentTheme()
 59	baseStyle := styles.BaseStyle()
 60
 61	helpKeyStyle := styles.Bold().
 62		Background(t.Background()).
 63		Foreground(t.Text()).
 64		Padding(0, 1, 0, 0)
 65
 66	helpDescStyle := styles.Regular().
 67		Background(t.Background()).
 68		Foreground(t.TextMuted())
 69
 70	// Compile list of bindings to render
 71	bindings := removeDuplicateBindings(h.keys)
 72
 73	// Enumerate through each group of bindings, populating a series of
 74	// pairs of columns, one for keys, one for descriptions
 75	var (
 76		pairs []string
 77		width int
 78		rows  = 12 - 2
 79	)
 80
 81	for i := 0; i < len(bindings); i += rows {
 82		var (
 83			keys  []string
 84			descs []string
 85		)
 86		for j := i; j < min(i+rows, len(bindings)); j++ {
 87			keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
 88			descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
 89		}
 90
 91		// Render pair of columns; beyond the first pair, render a three space
 92		// left margin, in order to visually separate the pairs.
 93		var cols []string
 94		if len(pairs) > 0 {
 95			cols = []string{baseStyle.Render("   ")}
 96		}
 97
 98		maxDescWidth := 0
 99		for _, desc := range descs {
100			if maxDescWidth < lipgloss.Width(desc) {
101				maxDescWidth = lipgloss.Width(desc)
102			}
103		}
104		for i := range descs {
105			remainingWidth := maxDescWidth - lipgloss.Width(descs[i])
106			if remainingWidth > 0 {
107				descs[i] = descs[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
108			}
109		}
110		maxKeyWidth := 0
111		for _, key := range keys {
112			if maxKeyWidth < lipgloss.Width(key) {
113				maxKeyWidth = lipgloss.Width(key)
114			}
115		}
116		for i := range keys {
117			remainingWidth := maxKeyWidth - lipgloss.Width(keys[i])
118			if remainingWidth > 0 {
119				keys[i] = keys[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
120			}
121		}
122
123		cols = append(cols,
124			strings.Join(keys, "\n"),
125			strings.Join(descs, "\n"),
126		)
127
128		pair := baseStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, cols...))
129		// check whether it exceeds the maximum width avail (the width of the
130		// terminal, subtracting 2 for the borders).
131		width += lipgloss.Width(pair)
132		if width > h.width-2 {
133			break
134		}
135		pairs = append(pairs, pair)
136	}
137
138	// https://github.com/charmbracelet/lipgloss/v2/issues/209
139	if len(pairs) > 1 {
140		prefix := pairs[:len(pairs)-1]
141		lastPair := pairs[len(pairs)-1]
142		prefix = append(prefix, lipgloss.Place(
143			lipgloss.Width(lastPair),   // width
144			lipgloss.Height(prefix[0]), // height
145			lipgloss.Left,              // x
146			lipgloss.Top,               // y
147			lastPair,                   // content
148			lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())),
149		))
150		content := baseStyle.Width(h.width).Render(
151			lipgloss.JoinHorizontal(
152				lipgloss.Top,
153				prefix...,
154			),
155		)
156		return content
157	}
158
159	// Join pairs of columns and enclose in a border
160	content := baseStyle.Width(h.width).Render(
161		lipgloss.JoinHorizontal(
162			lipgloss.Top,
163			pairs...,
164		),
165	)
166	return content
167}
168
169func (h *helpCmp) View() tea.View {
170	t := theme.CurrentTheme()
171	baseStyle := styles.BaseStyle()
172
173	content := h.render()
174	header := baseStyle.
175		Bold(true).
176		Width(lipgloss.Width(content)).
177		Foreground(t.Primary()).
178		Render("Keyboard Shortcuts")
179
180	return tea.NewView(
181		baseStyle.Padding(1).
182			Border(lipgloss.RoundedBorder()).
183			BorderForeground(t.TextMuted()).
184			Width(h.width).
185			BorderBackground(t.Background()).
186			Render(
187				lipgloss.JoinVertical(lipgloss.Center,
188					header,
189					baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
190					content,
191				),
192			),
193	)
194}
195
196type HelpCmp interface {
197	util.Model
198	SetBindings([]key.Binding)
199}
200
201func NewHelpCmp() HelpCmp {
202	return &helpCmp{}
203}