1package model
2
3import (
4 "context"
5
6 "github.com/charmbracelet/bubbles/help"
7 "github.com/charmbracelet/bubbles/key"
8 "github.com/charmbracelet/bubbles/list"
9 "github.com/charmbracelet/bubbles/textinput"
10 tea "github.com/charmbracelet/bubbletea"
11 "github.com/charmbracelet/lipgloss"
12
13 "github.com/bnema/dumber/internal/application/usecase"
14 "github.com/bnema/dumber/internal/cli/styles"
15)
16
17// DmenuModel is the Bubble Tea model for interactive dmenu.
18type DmenuModel struct {
19 // UI components
20 list list.Model
21 search textinput.Model
22 help help.Model
23 keys styles.DmenuKeyMap
24
25 // State
26 allItems []styles.HistoryItem
27 selected string // Selected URL
28 searchQuery string
29 width int
30 height int
31 err error
32
33 // Dependencies
34 historyUC *usecase.SearchHistoryUseCase
35 theme *styles.Theme
36}
37
38// NewDmenuModel creates a new dmenu model.
39func NewDmenuModel(theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) DmenuModel {
40 // Create search input
41 search := styles.NewSearchInput(theme)
42 search.Focus()
43
44 // Create help
45 helpModel := styles.NewStyledHelp(theme)
46
47 // Create keybindings
48 keys := styles.DefaultDmenuKeyMap()
49
50 return DmenuModel{
51 search: search,
52 help: helpModel,
53 keys: keys,
54 historyUC: historyUC,
55 theme: theme,
56 width: 80,
57 height: 24,
58 }
59}
60
61// dmenuLoadedMsg is sent when entries are loaded.
62type dmenuLoadedMsg struct {
63 items []styles.HistoryItem
64 err error
65}
66
67const (
68 defaultDmenuHistoryLimit = 500
69 dmenuListChromeHeight = 6
70)
71
72// Init implements tea.Model.
73func (m DmenuModel) Init() tea.Cmd {
74 return tea.Batch(
75 textinput.Blink,
76 m.loadEntries,
77 )
78}
79
80// loadEntries loads history entries.
81func (m DmenuModel) loadEntries() tea.Msg {
82 ctx := context.Background()
83
84 entries, err := m.historyUC.GetRecent(ctx, defaultDmenuHistoryLimit, 0)
85 if err != nil {
86 return dmenuLoadedMsg{err: err}
87 }
88
89 items := make([]styles.HistoryItem, len(entries))
90 for i, e := range entries {
91 items[i] = styles.HistoryItem{
92 ID: e.ID,
93 URL: e.URL,
94 Title: e.Title,
95 Domain: getDomain(e.URL),
96 VisitCount: int(e.VisitCount),
97 LastVisited: e.LastVisited,
98 }
99 }
100
101 return dmenuLoadedMsg{items: items}
102}
103
104// Update implements tea.Model.
105func (m DmenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
106 var cmds []tea.Cmd
107
108 switch msg := msg.(type) {
109 case tea.WindowSizeMsg:
110 m.width = msg.Width
111 m.height = msg.Height
112 m.help.Width = msg.Width
113 m.updateList()
114
115 case tea.KeyMsg:
116 switch {
117 case key.Matches(msg, m.keys.Cancel):
118 return m, tea.Quit
119
120 case key.Matches(msg, m.keys.Open):
121 // Select current item and quit
122 if item := m.list.SelectedItem(); item != nil {
123 if hi, ok := item.(styles.HistoryItem); ok {
124 m.selected = hi.URL
125 }
126 }
127 return m, tea.Quit
128
129 case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.Down):
130 // Pass to list
131 var cmd tea.Cmd
132 m.list, cmd = m.list.Update(msg)
133 cmds = append(cmds, cmd)
134
135 default:
136 // Update search input
137 var cmd tea.Cmd
138 m.search, cmd = m.search.Update(msg)
139 cmds = append(cmds, cmd)
140
141 // Filter list based on search
142 if m.search.Value() != m.searchQuery {
143 m.searchQuery = m.search.Value()
144 m.updateList()
145 }
146 }
147
148 case dmenuLoadedMsg:
149 if msg.err != nil {
150 m.err = msg.err
151 } else {
152 m.allItems = msg.items
153 m.updateList()
154 }
155 }
156
157 return m, tea.Batch(cmds...)
158}
159
160// updateList updates the list with filtered items.
161func (m *DmenuModel) updateList() {
162 items := m.allItems
163
164 // Apply search filter
165 if m.searchQuery != "" {
166 query := m.searchQuery
167 filtered := make([]styles.HistoryItem, 0)
168 for _, item := range items {
169 // Simple case-insensitive contains matching
170 if containsIgnoreCase(item.Title, query) || containsIgnoreCase(item.URL, query) {
171 filtered = append(filtered, item)
172 }
173 }
174 items = filtered
175 }
176
177 // Create list
178 listHeight := max(
179 // Account for search, help
180 m.height-dmenuListChromeHeight, 5)
181
182 m.list = styles.NewHistoryList(m.theme, items, m.width, listHeight)
183}
184
185// containsIgnoreCase checks if s contains substr (case-insensitive).
186func containsIgnoreCase(s, substr string) bool {
187 sLower := toLower(s)
188 substrLower := toLower(substr)
189
190 for i := 0; i <= len(sLower)-len(substrLower); i++ {
191 if sLower[i:i+len(substrLower)] == substrLower {
192 return true
193 }
194 }
195 return false
196}
197
198// toLower converts string to lowercase without importing strings.
199func toLower(s string) string {
200 b := make([]byte, len(s))
201 for i := 0; i < len(s); i++ {
202 c := s[i]
203 if c >= 'A' && c <= 'Z' {
204 c += 'a' - 'A'
205 }
206 b[i] = c
207 }
208 return string(b)
209}
210
211// View implements tea.Model.
212func (m DmenuModel) View() string {
213 t := m.theme
214
215 // Search bar
216 searchBar := t.InputFocused.Render(m.search.View())
217
218 // List
219 listView := m.list.View()
220
221 if m.err != nil {
222 listView = t.ErrorStyle.Render("Error: " + m.err.Error())
223 }
224
225 // Help
226 helpView := m.help.View(m.keys)
227
228 // Combine
229 return lipgloss.JoinVertical(
230 lipgloss.Left,
231 searchBar,
232 "",
233 listView,
234 "",
235 helpView,
236 )
237}
238
239// SelectedURL returns the URL selected by the user.
240func (m DmenuModel) SelectedURL() string {
241 return m.selected
242}
243
244// Ensure interface compliance.
245var _ tea.Model = (*DmenuModel)(nil)