menu.go

  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}