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 tea "github.com/charmbracelet/bubbletea"
13 "github.com/stormlightlabs/noteleaf/internal/models"
14)
15
16// DataRecord represents a single row of data in a table
17type DataRecord interface {
18 models.Model
19 GetField(name string) any
20}
21
22// DataSource provides data for the table
23type DataSource interface {
24 Load(ctx context.Context, opts DataOptions) ([]DataRecord, error)
25 Count(ctx context.Context, opts DataOptions) (int, error)
26}
27
28// Field defines a column in the table
29type Field struct {
30 Name string
31 Title string
32 Width int
33 Formatter func(value any) string
34}
35
36// DataOptions configures data loading
37type DataOptions struct {
38 Filters map[string]any
39 SortBy string
40 SortOrder string
41 Limit int
42 Offset int
43}
44
45// Action defines an action that can be performed on a record
46type Action struct {
47 Key string
48 Description string
49 Handler func(record DataRecord) tea.Cmd
50}
51
52// DataTableKeyMap defines key bindings for table navigation
53type DataTableKeyMap struct {
54 Up key.Binding
55 Down key.Binding
56 Enter key.Binding
57 View key.Binding
58 Refresh key.Binding
59 Quit key.Binding
60 Back key.Binding
61 Help key.Binding
62 Numbers []key.Binding
63 Actions map[string]key.Binding
64}
65
66func (k DataTableKeyMap) ShortHelp() []key.Binding {
67 return []key.Binding{k.Up, k.Down, k.Enter, k.Help, k.Quit}
68}
69
70func (k DataTableKeyMap) FullHelp() [][]key.Binding {
71 bindings := [][]key.Binding{
72 {k.Up, k.Down, k.Enter, k.View},
73 {k.Refresh, k.Help, k.Quit, k.Back},
74 }
75
76 if len(k.Actions) > 0 {
77 actionBindings := make([]key.Binding, 0, len(k.Actions))
78 for _, binding := range k.Actions {
79 actionBindings = append(actionBindings, binding)
80 }
81 bindings = append(bindings, actionBindings)
82 }
83
84 return bindings
85}
86
87// DefaultDataTableKeys returns the default key bindings
88func DefaultDataTableKeys() DataTableKeyMap {
89 return DataTableKeyMap{
90 Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "move up")),
91 Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "move down")),
92 Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
93 View: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "view")),
94 Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
95 Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
96 Back: key.NewBinding(key.WithKeys("esc", "backspace"), key.WithHelp("esc", "back")),
97 Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
98 Numbers: []key.Binding{
99 key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "jump to 1")),
100 key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "jump to 2")),
101 key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "jump to 3")),
102 key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "jump to 4")),
103 key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "jump to 5")),
104 key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "jump to 6")),
105 key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "jump to 7")),
106 key.NewBinding(key.WithKeys("8"), key.WithHelp("8", "jump to 8")),
107 key.NewBinding(key.WithKeys("9"), key.WithHelp("9", "jump to 9")),
108 },
109 Actions: make(map[string]key.Binding),
110 }
111}
112
113// DataTableOptions configures table behavior
114type DataTableOptions struct {
115 Output io.Writer
116 Input io.Reader
117 Static bool
118 Title string
119 Fields []Field
120 Actions []Action
121 ViewHandler func(record DataRecord) string
122}
123
124// DataTable handles table display and interaction
125type DataTable struct {
126 source DataSource
127 opts DataTableOptions
128}
129
130// NewDataTable creates a new data table
131func NewDataTable(source DataSource, opts DataTableOptions) *DataTable {
132 if opts.Output == nil {
133 opts.Output = os.Stdout
134 }
135 if opts.Input == nil {
136 opts.Input = os.Stdin
137 }
138 if opts.Title == "" {
139 opts.Title = "Data"
140 }
141
142 return &DataTable{
143 source: source,
144 opts: opts,
145 }
146}
147
148type (
149 dataLoadedMsg []DataRecord
150 dataViewMsg string
151 dataErrorMsg error
152 dataCountMsg int
153)
154
155type dataTableModel struct {
156 records []DataRecord
157 selected int
158 viewing bool
159 viewContent string
160 err error
161 loading bool
162 source DataSource
163 opts DataTableOptions
164 keys DataTableKeyMap
165 help help.Model
166 showingHelp bool
167 totalCount int
168 dataOpts DataOptions
169}
170
171func (m dataTableModel) Init() tea.Cmd {
172 return tea.Batch(m.loadData(), m.loadCount())
173}
174
175func (m dataTableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
176 switch msg := msg.(type) {
177 case tea.KeyMsg:
178 if m.showingHelp {
179 switch {
180 case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Help):
181 m.showingHelp = false
182 return m, nil
183 }
184 return m, nil
185 }
186
187 if m.viewing {
188 switch {
189 case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit):
190 m.viewing = false
191 m.viewContent = ""
192 return m, nil
193 case key.Matches(msg, m.keys.Help):
194 m.showingHelp = true
195 return m, nil
196 }
197 return m, nil
198 }
199
200 switch {
201 case key.Matches(msg, m.keys.Quit):
202 return m, tea.Quit
203 case key.Matches(msg, m.keys.Up):
204 if m.selected > 0 {
205 m.selected--
206 }
207 case key.Matches(msg, m.keys.Down):
208 if m.selected < len(m.records)-1 {
209 m.selected++
210 }
211 case key.Matches(msg, m.keys.Enter) || key.Matches(msg, m.keys.View):
212 if len(m.records) > 0 && m.selected < len(m.records) && m.opts.ViewHandler != nil {
213 return m, m.viewRecord(m.records[m.selected])
214 }
215 case key.Matches(msg, m.keys.Refresh):
216 m.loading = true
217 return m, tea.Batch(m.loadData(), m.loadCount())
218 case key.Matches(msg, m.keys.Help):
219 m.showingHelp = true
220 return m, nil
221 default:
222 for i, numKey := range m.keys.Numbers {
223 if key.Matches(msg, numKey) && i < len(m.records) {
224 m.selected = i
225 break
226 }
227 }
228
229 for actionKey, binding := range m.keys.Actions {
230 if key.Matches(msg, binding) && len(m.records) > 0 && m.selected < len(m.records) {
231 for _, action := range m.opts.Actions {
232 if action.Key == actionKey {
233 return m, action.Handler(m.records[m.selected])
234 }
235 }
236 }
237 }
238 }
239 case dataLoadedMsg:
240 m.records = []DataRecord(msg)
241 m.loading = false
242 if m.selected >= len(m.records) && len(m.records) > 0 {
243 m.selected = len(m.records) - 1
244 }
245 case dataViewMsg:
246 m.viewContent = string(msg)
247 m.viewing = true
248 case dataErrorMsg:
249 m.err = error(msg)
250 m.loading = false
251 case dataCountMsg:
252 m.totalCount = int(msg)
253 }
254 return m, nil
255}
256
257func (m dataTableModel) View() string {
258 var s strings.Builder
259
260 style := MutedStyle
261
262 if m.showingHelp {
263 return m.help.View(m.keys)
264 }
265
266 if m.viewing {
267 s.WriteString(m.viewContent)
268 s.WriteString("\n\n")
269 s.WriteString(style.Render("Press q/esc/backspace to return to list, ? for help"))
270 return s.String()
271 }
272
273 s.WriteString(TableTitleStyle.Render(m.opts.Title))
274 if m.totalCount > 0 {
275 s.WriteString(fmt.Sprintf(" (%d total)", m.totalCount))
276 }
277 s.WriteString("\n\n")
278
279 if m.loading {
280 s.WriteString("Loading...")
281 return s.String()
282 }
283
284 if m.err != nil {
285 s.WriteString(fmt.Sprintf("Error: %s", m.err))
286 return s.String()
287 }
288
289 if len(m.records) == 0 {
290 s.WriteString("No records found")
291 s.WriteString("\n\n")
292 s.WriteString(style.Render("Press r to refresh, q to quit"))
293 return s.String()
294 }
295
296 headerParts := make([]string, len(m.opts.Fields))
297 for i, field := range m.opts.Fields {
298 format := fmt.Sprintf("%%-%ds", field.Width)
299 headerParts[i] = fmt.Sprintf(format, field.Title)
300 }
301 headerLine := fmt.Sprintf(" %s", strings.Join(headerParts, " "))
302 s.WriteString(TableHeaderStyle.Render(headerLine))
303 s.WriteString("\n")
304
305 totalWidth := 3 + len(strings.Join(headerParts, " "))
306 s.WriteString(TableHeaderStyle.Render(strings.Repeat("─", totalWidth)))
307 s.WriteString("\n")
308
309 for i, record := range m.records {
310 prefix := " "
311 if i == m.selected {
312 prefix = " > "
313 }
314
315 rowParts := make([]string, len(m.opts.Fields))
316 for j, field := range m.opts.Fields {
317 value := record.GetField(field.Name)
318
319 var displayValue string
320 if field.Formatter != nil {
321 displayValue = field.Formatter(value)
322 } else {
323 displayValue = fmt.Sprintf("%v", value)
324 }
325
326 if len(displayValue) > field.Width-1 {
327 displayValue = displayValue[:field.Width-4] + "..."
328 }
329
330 format := fmt.Sprintf("%%-%ds", field.Width)
331 rowParts[j] = fmt.Sprintf(format, displayValue)
332 }
333
334 line := fmt.Sprintf("%s%s", prefix, strings.Join(rowParts, " "))
335
336 if i == m.selected {
337 s.WriteString(TableSelectedStyle.Render(line))
338 } else {
339 s.WriteString(style.Render(line))
340 }
341
342 s.WriteString("\n")
343 }
344
345 s.WriteString("\n")
346 s.WriteString(m.help.View(m.keys))
347
348 return s.String()
349}
350
351func (m dataTableModel) loadData() tea.Cmd {
352 return func() tea.Msg {
353 records, err := m.source.Load(context.Background(), m.dataOpts)
354 if err != nil {
355 return dataErrorMsg(err)
356 }
357 return dataLoadedMsg(records)
358 }
359}
360
361func (m dataTableModel) loadCount() tea.Cmd {
362 return func() tea.Msg {
363 count, err := m.source.Count(context.Background(), m.dataOpts)
364 if err != nil {
365 return dataCountMsg(0)
366 }
367 return dataCountMsg(count)
368 }
369}
370
371func (m dataTableModel) viewRecord(record DataRecord) tea.Cmd {
372 return func() tea.Msg {
373 content := m.opts.ViewHandler(record)
374 return dataViewMsg(content)
375 }
376}
377
378// Browse opens an interactive table interface
379func (dt *DataTable) Browse(ctx context.Context) error {
380 return dt.BrowseWithOptions(ctx, DataOptions{})
381}
382
383// BrowseWithOptions opens an interactive table with custom data options
384func (dt *DataTable) BrowseWithOptions(ctx context.Context, dataOpts DataOptions) error {
385 if dt.opts.Static {
386 return dt.staticDisplay(ctx, dataOpts)
387 }
388
389 keys := DefaultDataTableKeys()
390 for _, action := range dt.opts.Actions {
391 keys.Actions[action.Key] = key.NewBinding(
392 key.WithKeys(action.Key),
393 key.WithHelp(action.Key, action.Description),
394 )
395 }
396
397 model := dataTableModel{
398 source: dt.source,
399 opts: dt.opts,
400 keys: keys,
401 help: help.New(),
402 dataOpts: dataOpts,
403 loading: true,
404 }
405
406 program := tea.NewProgram(model, tea.WithInput(dt.opts.Input), tea.WithOutput(dt.opts.Output))
407 _, err := program.Run()
408 return err
409}
410
411func (dt *DataTable) staticDisplay(ctx context.Context, dataOpts DataOptions) error {
412 records, err := dt.source.Load(ctx, dataOpts)
413 if err != nil {
414 fmt.Fprintf(dt.opts.Output, "Error: %s\n", err)
415 return err
416 }
417
418 fmt.Fprintf(dt.opts.Output, "%s\n\n", dt.opts.Title)
419
420 if len(records) == 0 {
421 fmt.Fprintf(dt.opts.Output, "No records found\n")
422 return nil
423 }
424
425 headerParts := make([]string, len(dt.opts.Fields))
426 for i, field := range dt.opts.Fields {
427 format := fmt.Sprintf("%%-%ds", field.Width)
428 headerParts[i] = fmt.Sprintf(format, field.Title)
429 }
430 fmt.Fprintf(dt.opts.Output, "%s\n", strings.Join(headerParts, " "))
431
432 totalWidth := len(strings.Join(headerParts, " "))
433 fmt.Fprintf(dt.opts.Output, "%s\n", strings.Repeat("─", totalWidth))
434
435 for _, record := range records {
436 rowParts := make([]string, len(dt.opts.Fields))
437 for i, field := range dt.opts.Fields {
438 value := record.GetField(field.Name)
439
440 var displayValue string
441 if field.Formatter != nil {
442 displayValue = field.Formatter(value)
443 } else {
444 displayValue = fmt.Sprintf("%v", value)
445 }
446
447 if len(displayValue) > field.Width-1 {
448 displayValue = displayValue[:field.Width-4] + "..."
449 }
450
451 format := fmt.Sprintf("%%-%ds", field.Width)
452 rowParts[i] = fmt.Sprintf(format, displayValue)
453 }
454
455 fmt.Fprintf(dt.opts.Output, "%s\n", strings.Join(rowParts, " "))
456 }
457
458 return nil
459}