1package menu
2
3import (
4 "fmt"
5 "strings"
6
7 tea "charm.land/bubbletea/v2"
8 "charm.land/lipgloss/v2"
9)
10
11// Item represents a single menu entry.
12type Item struct {
13 // Label is the full display text (e.g. "backup").
14 Label string
15 // Hotkey is the single character that instantly selects this item (e.g. 'b').
16 Hotkey rune
17 // Value is the string returned by Choice() when this item is selected.
18 // If empty, Label is used.
19 Value string
20}
21
22// Model is a hand-rolled BubbleTea v2 model for an interactive hotkey menu.
23type Model struct {
24 items []Item
25 cursor int
26 choice string
27 quitting bool
28
29 hasDarkBG bool
30 lightDark lipgloss.LightDarkFunc
31}
32
33// New creates a menu Model with the given items.
34func New(items []Item) Model {
35 return Model{
36 items: items,
37 hasDarkBG: true, // sensible default until we hear from the terminal
38 lightDark: lipgloss.LightDark(true),
39 }
40}
41
42// Choice returns the selected item's value, or "" if nothing was chosen.
43func (m Model) Choice() string {
44 return m.choice
45}
46
47// Init requests the terminal background color so we can adapt styling.
48func (m Model) Init() tea.Cmd {
49 return tea.RequestBackgroundColor
50}
51
52// Update handles key presses and background color detection.
53func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
54 switch msg := msg.(type) {
55 case tea.BackgroundColorMsg:
56 m.hasDarkBG = msg.IsDark()
57 m.lightDark = lipgloss.LightDark(m.hasDarkBG)
58 return m, nil
59
60 case tea.KeyPressMsg:
61 switch msg.String() {
62 case "ctrl+c", "q":
63 m.quitting = true
64 return m, tea.Quit
65
66 case "up", "k":
67 if m.cursor > 0 {
68 m.cursor--
69 }
70 return m, nil
71
72 case "down", "j":
73 if m.cursor < len(m.items)-1 {
74 m.cursor++
75 }
76 return m, nil
77
78 case "enter":
79 m.choice = m.itemValue(m.cursor)
80 return m, tea.Quit
81
82 default:
83 // Check if the keypress matches any item's hotkey.
84 if len(msg.Text) == 1 {
85 r := rune(msg.Text[0])
86 for i, item := range m.items {
87 if item.Hotkey == r {
88 m.cursor = i
89 m.choice = m.itemValue(i)
90 return m, tea.Quit
91 }
92 }
93 }
94 }
95 }
96
97 return m, nil
98}
99
100// View renders the menu as an inline vertical list.
101func (m Model) View() tea.View {
102 if m.quitting || m.choice != "" {
103 return tea.NewView("")
104 }
105
106 accentColor := m.lightDark(
107 lipgloss.Color("#7D56F4"),
108 lipgloss.Color("#AD8AFF"),
109 )
110 normalColor := m.lightDark(
111 lipgloss.Color("#333333"),
112 lipgloss.Color("#DDDDDD"),
113 )
114 cursorColor := m.lightDark(
115 lipgloss.Color("#7D56F4"),
116 lipgloss.Color("#AD8AFF"),
117 )
118
119 hotStyle := lipgloss.NewStyle().
120 Bold(true).
121 Foreground(accentColor)
122 labelStyle := lipgloss.NewStyle().
123 Foreground(normalColor)
124 cursorStyle := lipgloss.NewStyle().
125 Foreground(cursorColor).
126 Bold(true)
127
128 var b strings.Builder
129 for i, item := range m.items {
130 cursor := " "
131 if i == m.cursor {
132 cursor = cursorStyle.Render("▸ ")
133 }
134
135 line := renderItem(item, hotStyle, labelStyle)
136 fmt.Fprintf(&b, "%s%s\n", cursor, line)
137 }
138
139 b.WriteString("\n")
140 b.WriteString(labelStyle.Render("↑/↓ navigate • hotkey or enter to select • q to quit"))
141 b.WriteString("\n")
142
143 return tea.NewView(b.String())
144}
145
146// renderItem formats a single menu item with the hotkey character styled
147// differently from the rest of the label. For example, with hotkey 'b' and
148// label "backup", it renders "[b]ackup" where [b] is in the accent style.
149func renderItem(item Item, hotStyle, labelStyle lipgloss.Style) string {
150 label := item.Label
151 hk := string(item.Hotkey)
152 idx := strings.Index(strings.ToLower(label), strings.ToLower(hk))
153
154 if idx < 0 {
155 // Hotkey not in label — show it as a prefix.
156 return hotStyle.Render("["+hk+"]") + " " + labelStyle.Render(label)
157 }
158
159 before := label[:idx]
160 match := label[idx : idx+len(hk)]
161 after := label[idx+len(hk):]
162
163 return labelStyle.Render(before) +
164 hotStyle.Render("["+match+"]") +
165 labelStyle.Render(after)
166}
167
168// itemValue returns the value for the item at index i.
169func (m Model) itemValue(i int) string {
170 if i < 0 || i >= len(m.items) {
171 return ""
172 }
173 if m.items[i].Value != "" {
174 return m.items[i].Value
175 }
176 return m.items[i].Label
177}