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 defaultDmenuHistoryLimit = 500
68
69// Init implements tea.Model.
70func (m DmenuModel) Init() tea.Cmd {
71 return tea.Batch(
72 textinput.Blink,
73 m.loadEntries,
74 )
75}
76
77// loadEntries loads history entries.
78func (m DmenuModel) loadEntries() tea.Msg {
79 ctx := context.Background()
80
81 entries, err := m.historyUC.GetRecent(ctx, defaultDmenuHistoryLimit, 0)
82 if err != nil {
83 return dmenuLoadedMsg{err: err}
84 }
85
86 items := make([]styles.HistoryItem, len(entries))
87 for i, e := range entries {
88 items[i] = styles.HistoryItem{
89 ID: e.ID,
90 URL: e.URL,
91 Title: e.Title,
92 Domain: getDomain(e.URL),
93 VisitCount: int(e.VisitCount),
94 LastVisited: e.LastVisited,
95 }
96 }
97
98 return dmenuLoadedMsg{items: items}
99}
100
101// Update implements tea.Model.
102func (m DmenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
103 var cmds []tea.Cmd
104
105 switch msg := msg.(type) {
106 case tea.WindowSizeMsg:
107 m.width = msg.Width
108 m.height = msg.Height
109 m.help.Width = msg.Width
110 m.updateList()
111
112 case tea.KeyMsg:
113 switch {
114 case key.Matches(msg, m.keys.Cancel):
115 return m, tea.Quit
116
117 case key.Matches(msg, m.keys.Open):
118 // Select current item and quit
119 if item := m.list.SelectedItem(); item != nil {
120 if hi, ok := item.(styles.HistoryItem); ok {
121 m.selected = hi.URL
122 }
123 }
124 return m, tea.Quit
125
126 case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.Down):
127 // Pass to list
128 var cmd tea.Cmd
129 m.list, cmd = m.list.Update(msg)
130 cmds = append(cmds, cmd)
131
132 default:
133 // Update search input
134 var cmd tea.Cmd
135 m.search, cmd = m.search.Update(msg)
136 cmds = append(cmds, cmd)
137
138 // Filter list based on search
139 if m.search.Value() != m.searchQuery {
140 m.searchQuery = m.search.Value()
141 m.updateList()
142 }
143 }
144
145 case dmenuLoadedMsg:
146 if msg.err != nil {
147 m.err = msg.err
148 } else {
149 m.allItems = msg.items
150 m.updateList()
151 }
152 }
153
154 return m, tea.Batch(cmds...)
155}
156
157// updateList updates the list with filtered items.
158func (m *DmenuModel) updateList() {
159 items := m.allItems
160
161 // Apply search filter
162 if m.searchQuery != "" {
163 query := m.searchQuery
164 filtered := make([]styles.HistoryItem, 0)
165 for _, item := range items {
166 // Simple case-insensitive contains matching
167 if containsIgnoreCase(item.Title, query) || containsIgnoreCase(item.URL, query) {
168 filtered = append(filtered, item)
169 }
170 }
171 items = filtered
172 }
173
174 // Create list
175 listHeight := m.height - 6 // Account for search, help
176 if listHeight < 5 {
177 listHeight = 5
178 }
179
180 m.list = styles.NewHistoryList(m.theme, items, m.width, listHeight)
181}
182
183// containsIgnoreCase checks if s contains substr (case-insensitive).
184func containsIgnoreCase(s, substr string) bool {
185 sLower := toLower(s)
186 substrLower := toLower(substr)
187
188 for i := 0; i <= len(sLower)-len(substrLower); i++ {
189 if sLower[i:i+len(substrLower)] == substrLower {
190 return true
191 }
192 }
193 return false
194}
195
196// toLower converts string to lowercase without importing strings.
197func toLower(s string) string {
198 b := make([]byte, len(s))
199 for i := 0; i < len(s); i++ {
200 c := s[i]
201 if c >= 'A' && c <= 'Z' {
202 c += 'a' - 'A'
203 }
204 b[i] = c
205 }
206 return string(b)
207}
208
209// View implements tea.Model.
210func (m DmenuModel) View() string {
211 t := m.theme
212
213 // Search bar
214 searchBar := t.InputFocused.Render(m.search.View())
215
216 // List
217 listView := m.list.View()
218
219 if m.err != nil {
220 listView = t.ErrorStyle.Render("Error: " + m.err.Error())
221 }
222
223 // Help
224 helpView := m.help.View(m.keys)
225
226 // Combine
227 return lipgloss.JoinVertical(
228 lipgloss.Left,
229 searchBar,
230 "",
231 listView,
232 "",
233 helpView,
234 )
235}
236
237// SelectedURL returns the URL selected by the user.
238func (m DmenuModel) SelectedURL() string {
239 return m.selected
240}
241
242// Ensure interface compliance.
243var _ tea.Model = (*DmenuModel)(nil)