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