Something went wrong. Try again.
cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 🍃
charm leaflet readability golang
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460package ui
import ( "context" "fmt" "io" "os" "strings"
"github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/stormlightlabs/noteleaf/internal/models")
// DataRecord represents a single row of data in a tabletype DataRecord interface { models.Model GetField(name string) any}
// DataSource provides data for the tabletype DataSource interface { Load(ctx context.Context, opts DataOptions) ([]DataRecord, error) Count(ctx context.Context, opts DataOptions) (int, error)}
// Field defines a column in the tabletype Field struct { Name string Title string Width int Formatter func(value any) string}
// DataOptions configures data loadingtype DataOptions struct { Filters map[string]any SortBy string SortOrder string Limit int Offset int}
// Action defines an action that can be performed on a recordtype Action struct { Key string Description string Handler func(record DataRecord) tea.Cmd}
// DataTableKeyMap defines key bindings for table navigationtype DataTableKeyMap struct { Up key.Binding Down key.Binding Enter key.Binding View key.Binding Refresh key.Binding Quit key.Binding Back key.Binding Help key.Binding Numbers []key.Binding Actions map[string]key.Binding}
func (k DataTableKeyMap) ShortHelp() []key.Binding { return []key.Binding{k.Up, k.Down, k.Enter, k.Help, k.Quit}}
func (k DataTableKeyMap) FullHelp() [][]key.Binding { bindings := [][]key.Binding{ {k.Up, k.Down, k.Enter, k.View}, {k.Refresh, k.Help, k.Quit, k.Back}, }
if len(k.Actions) > 0 { actionBindings := make([]key.Binding, 0, len(k.Actions)) for _, binding := range k.Actions { actionBindings = append(actionBindings, binding) } bindings = append(bindings, actionBindings) }
return bindings}
// DefaultDataTableKeys returns the default key bindingsfunc DefaultDataTableKeys() DataTableKeyMap { return DataTableKeyMap{ Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "move up")), Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "move down")), Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), View: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "view")), Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), Back: key.NewBinding(key.WithKeys("esc", "backspace"), key.WithHelp("esc", "back")), Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), Numbers: []key.Binding{ key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "jump to 1")), key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "jump to 2")), key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "jump to 3")), key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "jump to 4")), key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "jump to 5")), key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "jump to 6")), key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "jump to 7")), key.NewBinding(key.WithKeys("8"), key.WithHelp("8", "jump to 8")), key.NewBinding(key.WithKeys("9"), key.WithHelp("9", "jump to 9")), }, Actions: make(map[string]key.Binding), }}
// DataTableOptions configures table behaviortype DataTableOptions struct { Output io.Writer Input io.Reader Static bool Title string Fields []Field Actions []Action ViewHandler func(record DataRecord) string}
// DataTable handles table display and interactiontype DataTable struct { source DataSource opts DataTableOptions}
// NewDataTable creates a new data tablefunc NewDataTable(source DataSource, opts DataTableOptions) *DataTable { if opts.Output == nil { opts.Output = os.Stdout } if opts.Input == nil { opts.Input = os.Stdin } if opts.Title == "" { opts.Title = "Data" }
return &DataTable{ source: source, opts: opts, }}
type ( dataLoadedMsg []DataRecord dataViewMsg string dataErrorMsg error dataCountMsg int)
type dataTableModel struct { records []DataRecord selected int viewing bool viewContent string err error loading bool source DataSource opts DataTableOptions keys DataTableKeyMap help help.Model showingHelp bool totalCount int dataOpts DataOptions}
func (m dataTableModel) Init() tea.Cmd { return tea.Batch(m.loadData(), m.loadCount())}
func (m dataTableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: if m.showingHelp { switch { case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Help): m.showingHelp = false return m, nil } return m, nil }
if m.viewing { switch { case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit): m.viewing = false m.viewContent = "" return m, nil case key.Matches(msg, m.keys.Help): m.showingHelp = true return m, nil } return m, nil }
switch { case key.Matches(msg, m.keys.Quit): return m, tea.Quit case key.Matches(msg, m.keys.Up): if m.selected > 0 { m.selected-- } case key.Matches(msg, m.keys.Down): if m.selected < len(m.records)-1 { m.selected++ } case key.Matches(msg, m.keys.Enter) || key.Matches(msg, m.keys.View): if len(m.records) > 0 && m.selected < len(m.records) && m.opts.ViewHandler != nil { return m, m.viewRecord(m.records[m.selected]) } case key.Matches(msg, m.keys.Refresh): m.loading = true return m, tea.Batch(m.loadData(), m.loadCount()) case key.Matches(msg, m.keys.Help): m.showingHelp = true return m, nil default: for i, numKey := range m.keys.Numbers { if key.Matches(msg, numKey) && i < len(m.records) { m.selected = i break } }
for actionKey, binding := range m.keys.Actions { if key.Matches(msg, binding) && len(m.records) > 0 && m.selected < len(m.records) { for _, action := range m.opts.Actions { if action.Key == actionKey { return m, action.Handler(m.records[m.selected]) } } } } } case dataLoadedMsg: m.records = []DataRecord(msg) m.loading = false if m.selected >= len(m.records) && len(m.records) > 0 { m.selected = len(m.records) - 1 } case dataViewMsg: m.viewContent = string(msg) m.viewing = true case dataErrorMsg: m.err = error(msg) m.loading = false case dataCountMsg: m.totalCount = int(msg) } return m, nil}
func (m dataTableModel) View() string { var s strings.Builder
style := MutedStyle
if m.showingHelp { return m.help.View(m.keys) }
if m.viewing { s.WriteString(m.viewContent) s.WriteString("\n\n") s.WriteString(style.Render("Press q/esc/backspace to return to list, ? for help")) return s.String() }
s.WriteString(TableTitleStyle.Render(m.opts.Title)) if m.totalCount > 0 { s.WriteString(fmt.Sprintf(" (%d total)", m.totalCount)) } s.WriteString("\n\n")
if m.loading { s.WriteString("Loading...") return s.String() }
if m.err != nil { s.WriteString(fmt.Sprintf("Error: %s", m.err)) return s.String() }
if len(m.records) == 0 { s.WriteString("No records found") s.WriteString("\n\n") s.WriteString(style.Render("Press r to refresh, q to quit")) return s.String() }
headerParts := make([]string, len(m.opts.Fields)) for i, field := range m.opts.Fields { format := fmt.Sprintf("%%-%ds", field.Width) headerParts[i] = fmt.Sprintf(format, field.Title) } headerLine := fmt.Sprintf(" %s", strings.Join(headerParts, " ")) s.WriteString(TableHeaderStyle.Render(headerLine)) s.WriteString("\n")
totalWidth := 3 + len(strings.Join(headerParts, " ")) s.WriteString(TableHeaderStyle.Render(strings.Repeat("─", totalWidth))) s.WriteString("\n")
for i, record := range m.records { prefix := " " if i == m.selected { prefix = " > " }
rowParts := make([]string, len(m.opts.Fields)) for j, field := range m.opts.Fields { value := record.GetField(field.Name)
var displayValue string if field.Formatter != nil { displayValue = field.Formatter(value) } else { displayValue = fmt.Sprintf("%v", value) }
if len(displayValue) > field.Width-1 { displayValue = displayValue[:field.Width-4] + "..." }
format := fmt.Sprintf("%%-%ds", field.Width) rowParts[j] = fmt.Sprintf(format, displayValue) }
line := fmt.Sprintf("%s%s", prefix, strings.Join(rowParts, " "))
if i == m.selected { s.WriteString(TableSelectedStyle.Render(line)) } else { s.WriteString(style.Render(line)) }
s.WriteString("\n") }
s.WriteString("\n") s.WriteString(m.help.View(m.keys))
return s.String()}
func (m dataTableModel) loadData() tea.Cmd { return func() tea.Msg { records, err := m.source.Load(context.Background(), m.dataOpts) if err != nil { return dataErrorMsg(err) } return dataLoadedMsg(records) }}
func (m dataTableModel) loadCount() tea.Cmd { return func() tea.Msg { count, err := m.source.Count(context.Background(), m.dataOpts) if err != nil { return dataCountMsg(0) } return dataCountMsg(count) }}
func (m dataTableModel) viewRecord(record DataRecord) tea.Cmd { return func() tea.Msg { content := m.opts.ViewHandler(record) return dataViewMsg(content) }}
// Browse opens an interactive table interfacefunc (dt *DataTable) Browse(ctx context.Context) error { return dt.BrowseWithOptions(ctx, DataOptions{})}
// BrowseWithOptions opens an interactive table with custom data optionsfunc (dt *DataTable) BrowseWithOptions(ctx context.Context, dataOpts DataOptions) error { if dt.opts.Static { return dt.staticDisplay(ctx, dataOpts) }
keys := DefaultDataTableKeys() for _, action := range dt.opts.Actions { keys.Actions[action.Key] = key.NewBinding( key.WithKeys(action.Key), key.WithHelp(action.Key, action.Description), ) }
model := dataTableModel{ source: dt.source, opts: dt.opts, keys: keys, help: help.New(), dataOpts: dataOpts, loading: true, }
program := tea.NewProgram(model, tea.WithInput(dt.opts.Input), tea.WithOutput(dt.opts.Output)) _, err := program.Run() return err}
func (dt *DataTable) staticDisplay(ctx context.Context, dataOpts DataOptions) error { records, err := dt.source.Load(ctx, dataOpts) if err != nil { fmt.Fprintf(dt.opts.Output, "Error: %s\n", err) return err }
fmt.Fprintf(dt.opts.Output, "%s\n\n", dt.opts.Title)
if len(records) == 0 { fmt.Fprintf(dt.opts.Output, "No records found\n") return nil }
headerParts := make([]string, len(dt.opts.Fields)) for i, field := range dt.opts.Fields { format := fmt.Sprintf("%%-%ds", field.Width) headerParts[i] = fmt.Sprintf(format, field.Title) } fmt.Fprintf(dt.opts.Output, "%s\n", strings.Join(headerParts, " "))
totalWidth := len(strings.Join(headerParts, " ")) fmt.Fprintf(dt.opts.Output, "%s\n", strings.Repeat("─", totalWidth))
for _, record := range records { rowParts := make([]string, len(dt.opts.Fields)) for i, field := range dt.opts.Fields { value := record.GetField(field.Name)
var displayValue string if field.Formatter != nil { displayValue = field.Formatter(value) } else { displayValue = fmt.Sprintf("%v", value) }
if len(displayValue) > field.Width-1 { displayValue = displayValue[:field.Width-4] + "..." }
format := fmt.Sprintf("%%-%ds", field.Width) rowParts[i] = fmt.Sprintf(format, displayValue) }
fmt.Fprintf(dt.opts.Output, "%s\n", strings.Join(rowParts, " ")) }
return nil}