cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 🍃
charm leaflet readability golang
29
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 17ba836c74ebd128fed81eb3a2b59aadc0ec887e 460 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/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}