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}