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