menu.go

  1// Package screens provides Screen implementations that wrap keld's
  2// interactive UI components for use with the unified session.
  3package screens
  4
  5import (
  6	"fmt"
  7	"strings"
  8	"unicode"
  9
 10	"charm.land/bubbles/v2/key"
 11	tea "charm.land/bubbletea/v2"
 12	"charm.land/lipgloss/v2"
 13
 14	"git.secluded.site/keld/internal/theme"
 15	"git.secluded.site/keld/internal/ui"
 16)
 17
 18// MenuItem represents a single entry in the command menu.
 19type MenuItem struct {
 20	// Label is the display text (e.g. "backup").
 21	Label string
 22	// Hotkey is the single character that instantly selects this item.
 23	Hotkey rune
 24	// Value is the string returned by Selection() when chosen. If
 25	// empty, Label is used.
 26	Value string
 27}
 28
 29// itemValue returns the effective value for an item.
 30func (mi MenuItem) itemValue() string {
 31	if mi.Value != "" {
 32		return mi.Value
 33	}
 34	return mi.Label
 35}
 36
 37// menuKeys defines the key bindings for the menu screen.
 38var menuKeys = struct {
 39	Up    key.Binding
 40	Down  key.Binding
 41	Enter key.Binding
 42	Esc   key.Binding
 43}{
 44	Up:    key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/↓", "navigate")),
 45	Down:  key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↑/↓", "navigate")),
 46	Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
 47	Esc:   key.NewBinding(key.WithKeys("esc")),
 48}
 49
 50// Menu is a Screen adapter that presents a selectable list of
 51// commands. It replaces the standalone menu.Model with a version
 52// that integrates into the unified session.
 53type Menu struct {
 54	items     []MenuItem
 55	cursor    int
 56	selection string
 57	styles    *theme.Styles
 58}
 59
 60// NewMenu creates a menu screen for the given items. The styles
 61// pointer should come from Session.Styles() so theme updates
 62// propagate automatically.
 63func NewMenu(items []MenuItem, styles *theme.Styles) *Menu {
 64	owned := make([]MenuItem, len(items))
 65	copy(owned, items)
 66	return &Menu{
 67		items:  owned,
 68		styles: styles,
 69	}
 70}
 71
 72// Init is a no-op for the menu — it has no async startup work.
 73func (m *Menu) Init() tea.Cmd { return nil }
 74
 75// Update handles key presses for navigation, selection, and hotkeys.
 76func (m *Menu) Update(msg tea.Msg) (ui.Screen, tea.Cmd) {
 77	kp, ok := msg.(tea.KeyPressMsg)
 78	if !ok {
 79		return m, nil
 80	}
 81
 82	switch {
 83	case key.Matches(kp, menuKeys.Esc):
 84		return m, ui.BackCmd
 85
 86	case key.Matches(kp, menuKeys.Up):
 87		if m.cursor > 0 {
 88			m.cursor--
 89		}
 90		return m, nil
 91
 92	case key.Matches(kp, menuKeys.Down):
 93		if m.cursor < len(m.items)-1 {
 94			m.cursor++
 95		}
 96		return m, nil
 97
 98	case key.Matches(kp, menuKeys.Enter):
 99		m.selection = m.items[m.cursor].itemValue()
100		return m, ui.DoneCmd
101	}
102
103	// Check for hotkey match. Use rune decoding so multi-byte
104	// UTF-8 characters (e.g. 'ñ') are handled correctly.
105	runes := []rune(kp.Text)
106	if len(runes) == 1 {
107		for i, item := range m.items {
108			if item.Hotkey == runes[0] {
109				m.cursor = i
110				m.selection = item.itemValue()
111				return m, ui.DoneCmd
112			}
113		}
114	}
115
116	return m, nil
117}
118
119// View renders the menu as a vertical list with a cursor indicator.
120func (m *Menu) View() string {
121	accent := m.styles.Accent
122	hotStyle := lipgloss.NewStyle().Bold(true).Foreground(accent)
123	labelStyle := lipgloss.NewStyle()
124	cursorStyle := lipgloss.NewStyle().Foreground(accent).Bold(true)
125
126	var b strings.Builder
127	for i, item := range m.items {
128		cursor := "  "
129		if i == m.cursor {
130			cursor = cursorStyle.Render(theme.Cursor)
131		}
132
133		line := renderMenuItem(item, hotStyle, labelStyle)
134		fmt.Fprintf(&b, "%s%s\n", cursor, line)
135	}
136
137	return b.String()
138}
139
140// Title returns the menu's display title.
141func (m *Menu) Title() string { return "Select a command" }
142
143// KeyBindings returns the key bindings for the help bar.
144func (m *Menu) KeyBindings() []key.Binding {
145	return []key.Binding{menuKeys.Up, menuKeys.Enter}
146}
147
148// Selection returns the chosen command name, or "" if nothing has
149// been selected yet.
150func (m *Menu) Selection() string { return m.selection }
151
152// renderMenuItem formats a menu item with the hotkey highlighted.
153// For example, with hotkey 'b' and label "backup", it renders
154// "[b]ackup" where [b] is in the accent style.
155//
156// The search is rune-based so multi-byte characters are handled
157// correctly.
158func renderMenuItem(item MenuItem, hotStyle, labelStyle lipgloss.Style) string {
159	label := item.Label
160	hk := unicode.ToLower(item.Hotkey)
161
162	runes := []rune(label)
163	for i, r := range runes {
164		if unicode.ToLower(r) == hk {
165			before := string(runes[:i])
166			match := string(runes[i : i+1])
167			after := string(runes[i+1:])
168			return labelStyle.Render(before) +
169				hotStyle.Render("["+match+"]") +
170				labelStyle.Render(after)
171		}
172	}
173
174	// Hotkey not found in label — show as prefix.
175	return hotStyle.Render("["+string(item.Hotkey)+"]") + " " + labelStyle.Render(label)
176}