cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 🍃
charm leaflet readability golang
at main 459 lines 12 kB view raw
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}