cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 🍃
charm
leaflet
readability
golang
1package ui
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "os"
8 "strings"
9
10 "github.com/charmbracelet/bubbles/help"
11 "github.com/charmbracelet/bubbles/key"
12 "github.com/charmbracelet/bubbles/viewport"
13 tea "github.com/charmbracelet/bubbletea"
14 "github.com/stormlightlabs/noteleaf/internal/models"
15)
16
17// ListItem represents a single item in a list
18type ListItem interface {
19 models.Model
20 GetTitle() string
21 GetDescription() string
22 GetFilterValue() string
23}
24
25// ListSource provides data for the list
26type ListSource interface {
27 Load(ctx context.Context, opts ListOptions) ([]ListItem, error)
28 Count(ctx context.Context, opts ListOptions) (int, error)
29 Search(ctx context.Context, query string, opts ListOptions) ([]ListItem, error)
30}
31
32// ListOptions configures data loading for lists
33type ListOptions struct {
34 Filters map[string]any
35 SortBy string
36 SortOrder string
37 Limit int
38 Offset int
39 Search string
40}
41
42// ListAction defines an action that can be performed on a list item
43type ListAction struct {
44 Key string
45 Description string
46 Handler func(item ListItem) tea.Cmd
47}
48
49// DataListKeyMap defines key bindings for list navigation
50type DataListKeyMap struct {
51 Up key.Binding
52 Down key.Binding
53 Enter key.Binding
54 View key.Binding
55 Search key.Binding
56 Refresh key.Binding
57 Quit key.Binding
58 Back key.Binding
59 Help key.Binding
60 Numbers []key.Binding
61 Actions map[string]key.Binding
62}
63
64func (k DataListKeyMap) ShortHelp() []key.Binding {
65 return []key.Binding{k.Up, k.Down, k.Enter, k.Search, k.Help, k.Quit}
66}
67
68func (k DataListKeyMap) FullHelp() [][]key.Binding {
69 bindings := [][]key.Binding{
70 {k.Up, k.Down, k.Enter, k.View},
71 {k.Search, k.Refresh, k.Help, k.Quit, k.Back},
72 }
73
74 if len(k.Actions) > 0 {
75 actionBindings := make([]key.Binding, 0, len(k.Actions))
76 for _, binding := range k.Actions {
77 actionBindings = append(actionBindings, binding)
78 }
79 bindings = append(bindings, actionBindings)
80 }
81
82 return bindings
83}
84
85// DefaultDataListKeys returns the default key bindings for lists
86func DefaultDataListKeys() DataListKeyMap {
87 return DataListKeyMap{
88 Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "move up")),
89 Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "move down")),
90 Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
91 View: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "view")),
92 Search: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")),
93 Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
94 Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
95 Back: key.NewBinding(key.WithKeys("esc", "backspace"), key.WithHelp("esc", "back")),
96 Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
97 Numbers: []key.Binding{
98 key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "jump to 1")),
99 key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "jump to 2")),
100 key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "jump to 3")),
101 key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "jump to 4")),
102 key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "jump to 5")),
103 key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "jump to 6")),
104 key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "jump to 7")),
105 key.NewBinding(key.WithKeys("8"), key.WithHelp("8", "jump to 8")),
106 key.NewBinding(key.WithKeys("9"), key.WithHelp("9", "jump to 9")),
107 },
108 Actions: make(map[string]key.Binding),
109 }
110}
111
112// DataListOptions configures list behavior
113type DataListOptions struct {
114 Output io.Writer
115 Input io.Reader
116 Static bool
117 Title string
118 Actions []ListAction
119 ViewHandler func(item ListItem) string
120 ItemRenderer func(item ListItem, selected bool) string
121 ShowSearch bool
122 Searchable bool
123}
124
125// DataList handles list display and interaction
126type DataList struct {
127 source ListSource
128 opts DataListOptions
129}
130
131// NewDataList creates a new data list
132func NewDataList(source ListSource, opts DataListOptions) *DataList {
133 if opts.Output == nil {
134 opts.Output = os.Stdout
135 }
136 if opts.Input == nil {
137 opts.Input = os.Stdin
138 }
139 if opts.Title == "" {
140 opts.Title = "Items"
141 }
142 if opts.ItemRenderer == nil {
143 opts.ItemRenderer = defaultItemRenderer
144 }
145
146 return &DataList{
147 source: source,
148 opts: opts,
149 }
150}
151
152type (
153 listLoadedMsg []ListItem
154 listViewMsg string
155 listErrorMsg error
156 listCountMsg int
157)
158
159type dataListModel struct {
160 items []ListItem
161 selected int
162 viewing bool
163 viewContent string
164 viewViewport viewport.Model
165 searching bool
166 searchQuery string
167 err error
168 loading bool
169 source ListSource
170 opts DataListOptions
171 keys DataListKeyMap
172 help help.Model
173 showingHelp bool
174 totalCount int
175 listOpts ListOptions
176}
177
178func (m dataListModel) Init() tea.Cmd {
179 return tea.Batch(m.loadItems(), m.loadCount())
180}
181
182func (m dataListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
183 switch msg := msg.(type) {
184 case tea.KeyMsg:
185 if m.showingHelp {
186 switch {
187 case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Help):
188 m.showingHelp = false
189 return m, nil
190 }
191 return m, nil
192 }
193
194 if m.viewing {
195 switch {
196 case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit):
197 m.viewing = false
198 m.viewContent = ""
199 return m, nil
200 case key.Matches(msg, m.keys.Help):
201 m.showingHelp = true
202 return m, nil
203 case key.Matches(msg, m.keys.Up):
204 m.viewViewport.ScrollUp(1)
205 case key.Matches(msg, m.keys.Down):
206 m.viewViewport.ScrollDown(1)
207 case msg.String() == "pgup", msg.String() == "b":
208 m.viewViewport.HalfPageUp()
209 case msg.String() == "pgdown", msg.String() == "f":
210 m.viewViewport.HalfPageDown()
211 case msg.String() == "g", msg.String() == "home":
212 m.viewViewport.GotoTop()
213 case msg.String() == "G", msg.String() == "end":
214 m.viewViewport.GotoBottom()
215 }
216 return m, nil
217 }
218
219 if m.searching {
220 switch msg.String() {
221 case "esc", "enter":
222 m.searching = false
223 if msg.String() == "enter" && m.opts.Searchable {
224 m.loading = true
225 return m, m.searchItems(m.searchQuery)
226 }
227 return m, nil
228 case "backspace", "ctrl+h":
229 if len(m.searchQuery) > 0 {
230 m.searchQuery = m.searchQuery[:len(m.searchQuery)-1]
231 }
232 return m, nil
233 default:
234 if len(msg.Runes) > 0 && msg.Runes[0] >= 32 {
235 m.searchQuery += string(msg.Runes)
236 }
237 return m, nil
238 }
239 }
240
241 switch {
242 case key.Matches(msg, m.keys.Quit):
243 return m, tea.Quit
244 case key.Matches(msg, m.keys.Up):
245 if m.selected > 0 {
246 m.selected--
247 }
248 case key.Matches(msg, m.keys.Down):
249 if m.selected < len(m.items)-1 {
250 m.selected++
251 }
252 case key.Matches(msg, m.keys.Enter) || key.Matches(msg, m.keys.View):
253 if len(m.items) > 0 && m.selected < len(m.items) && m.opts.ViewHandler != nil {
254 return m, m.viewItem(m.items[m.selected])
255 }
256 case key.Matches(msg, m.keys.Search):
257 if m.opts.ShowSearch {
258 m.searching = true
259 m.searchQuery = ""
260 return m, nil
261 }
262 case key.Matches(msg, m.keys.Refresh):
263 m.loading = true
264 return m, tea.Batch(m.loadItems(), m.loadCount())
265 case key.Matches(msg, m.keys.Help):
266 m.showingHelp = true
267 return m, nil
268 default:
269 for i, numKey := range m.keys.Numbers {
270 if key.Matches(msg, numKey) && i < len(m.items) {
271 m.selected = i
272 break
273 }
274 }
275
276 for actionKey, binding := range m.keys.Actions {
277 if key.Matches(msg, binding) && len(m.items) > 0 && m.selected < len(m.items) {
278 for _, action := range m.opts.Actions {
279 if action.Key == actionKey {
280 return m, action.Handler(m.items[m.selected])
281 }
282 }
283 }
284 }
285 }
286 case listLoadedMsg:
287 m.items = []ListItem(msg)
288 m.loading = false
289 if m.selected >= len(m.items) && len(m.items) > 0 {
290 m.selected = len(m.items) - 1
291 }
292 case listViewMsg:
293 m.viewContent = string(msg)
294 m.viewing = true
295 m.viewViewport = viewport.New(80, 20)
296 m.viewViewport.SetContent(m.viewContent)
297 case listErrorMsg:
298 m.err = error(msg)
299 m.loading = false
300 case listCountMsg:
301 m.totalCount = int(msg)
302 case tea.WindowSizeMsg:
303 if m.viewing {
304 headerHeight := 2
305 footerHeight := 3
306 verticalMarginHeight := headerHeight + footerHeight
307 m.viewViewport.Width = msg.Width
308 m.viewViewport.Height = msg.Height - verticalMarginHeight
309 }
310 }
311 return m, nil
312}
313
314func (m dataListModel) View() string {
315 var s strings.Builder
316
317 style := MutedStyle
318
319 if m.showingHelp {
320 return m.help.View(m.keys)
321 }
322
323 if m.viewing {
324 s.WriteString(m.viewViewport.View())
325 s.WriteString("\n\n")
326 s.WriteString(style.Render("↑/↓/pgup/pgdn: scroll | g/G: top/bottom | q/esc: back | ?: help"))
327 return s.String()
328 }
329
330 s.WriteString(TableTitleStyle.Render(m.opts.Title))
331 if m.totalCount > 0 {
332 s.WriteString(fmt.Sprintf(" (%d total)", m.totalCount))
333 }
334 if m.searchQuery != "" {
335 s.WriteString(fmt.Sprintf(" - Search: %s", m.searchQuery))
336 }
337 s.WriteString("\n\n")
338
339 if m.searching {
340 s.WriteString("Search: " + m.searchQuery + "▎")
341 s.WriteString("\n")
342 s.WriteString(style.Render("Press Enter to search, Esc to cancel"))
343 return s.String()
344 }
345
346 if m.loading {
347 s.WriteString("Loading...")
348 return s.String()
349 }
350
351 if m.err != nil {
352 s.WriteString(fmt.Sprintf("Error: %s", m.err))
353 return s.String()
354 }
355
356 if len(m.items) == 0 {
357 message := "No items found"
358 if m.searchQuery != "" {
359 message = "No items found for search: " + m.searchQuery
360 }
361 s.WriteString(message)
362 s.WriteString("\n\n")
363 s.WriteString(style.Render("Press r to refresh, q to quit"))
364 if m.opts.ShowSearch {
365 s.WriteString(style.Render(", / to search"))
366 }
367 return s.String()
368 }
369
370 for i, item := range m.items {
371 selected := i == m.selected
372 itemView := m.opts.ItemRenderer(item, selected)
373 s.WriteString(itemView)
374 s.WriteString("\n")
375 }
376
377 s.WriteString("\n")
378 s.WriteString(m.help.View(m.keys))
379
380 return s.String()
381}
382
383func (m dataListModel) loadItems() tea.Cmd {
384 return func() tea.Msg {
385 items, err := m.source.Load(context.Background(), m.listOpts)
386 if err != nil {
387 return listErrorMsg(err)
388 }
389 return listLoadedMsg(items)
390 }
391}
392
393func (m dataListModel) loadCount() tea.Cmd {
394 return func() tea.Msg {
395 count, err := m.source.Count(context.Background(), m.listOpts)
396 if err != nil {
397 return listCountMsg(0)
398 }
399 return listCountMsg(count)
400 }
401}
402
403func (m dataListModel) searchItems(query string) tea.Cmd {
404 return func() tea.Msg {
405 items, err := m.source.Search(context.Background(), query, m.listOpts)
406 if err != nil {
407 return listErrorMsg(err)
408 }
409 return listLoadedMsg(items)
410 }
411}
412
413func (m dataListModel) viewItem(item ListItem) tea.Cmd {
414 return func() tea.Msg {
415 content := m.opts.ViewHandler(item)
416 return listViewMsg(content)
417 }
418}
419
420// defaultItemRenderer provides a default rendering for list items
421func defaultItemRenderer(item ListItem, selected bool) string {
422 prefix := " "
423 if selected {
424 prefix = "> "
425 }
426
427 title := item.GetTitle()
428 description := item.GetDescription()
429
430 line := fmt.Sprintf("%s%s", prefix, title)
431 if description != "" {
432 line += fmt.Sprintf(" - %s", description)
433 }
434
435 if selected {
436 return TableSelectedStyle.Render(line)
437 }
438 return line
439}
440
441// Browse opens an interactive list interface
442func (dl *DataList) Browse(ctx context.Context) error {
443 return dl.BrowseWithOptions(ctx, ListOptions{})
444}
445
446// BrowseWithOptions opens an interactive list with custom options
447func (dl *DataList) BrowseWithOptions(ctx context.Context, listOpts ListOptions) error {
448 if dl.opts.Static {
449 return dl.staticDisplay(ctx, listOpts)
450 }
451
452 keys := DefaultDataListKeys()
453 for _, action := range dl.opts.Actions {
454 keys.Actions[action.Key] = key.NewBinding(
455 key.WithKeys(action.Key),
456 key.WithHelp(action.Key, action.Description),
457 )
458 }
459
460 model := dataListModel{
461 source: dl.source,
462 opts: dl.opts,
463 keys: keys,
464 help: help.New(),
465 listOpts: listOpts,
466 loading: true,
467 }
468
469 program := tea.NewProgram(model, tea.WithInput(dl.opts.Input), tea.WithOutput(dl.opts.Output))
470 _, err := program.Run()
471 return err
472}
473
474func (dl *DataList) staticDisplay(ctx context.Context, listOpts ListOptions) error {
475 items, err := dl.source.Load(ctx, listOpts)
476 if err != nil {
477 fmt.Fprintf(dl.opts.Output, "Error: %s\n", err)
478 return err
479 }
480
481 fmt.Fprintf(dl.opts.Output, "%s\n\n", dl.opts.Title)
482
483 if len(items) == 0 {
484 fmt.Fprintf(dl.opts.Output, "No items found\n")
485 return nil
486 }
487
488 for _, item := range items {
489 itemView := dl.opts.ItemRenderer(item, false)
490 fmt.Fprintf(dl.opts.Output, "%s\n", itemView)
491 }
492
493 return nil
494}