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