cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 🍃
charm leaflet readability golang
at main 242 lines 6.6 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/charmbracelet/lipgloss" 15 "github.com/stormlightlabs/noteleaf/internal/models" 16 "github.com/stormlightlabs/noteleaf/internal/utils" 17) 18 19// TaskViewOptions configures the task view UI behavior 20type TaskViewOptions struct { 21 // Output destination (stdout for interactive, buffer for testing) 22 Output io.Writer 23 // Input source (stdin for interactive, strings reader for testing) 24 Input io.Reader 25 // Enable static mode (no interactive components) 26 Static bool 27 // Width and height for viewport sizing 28 Width int 29 Height int 30} 31 32// TaskView handles task detail viewing UI 33type TaskView struct { 34 task *models.Task 35 opts TaskViewOptions 36} 37 38// NewTaskView creates a new task view UI component 39func NewTaskView(task *models.Task, opts TaskViewOptions) *TaskView { 40 if opts.Output == nil { 41 opts.Output = os.Stdout 42 } 43 if opts.Input == nil { 44 opts.Input = os.Stdin 45 } 46 // Set default dimensions if not provided 47 if opts.Width == 0 { 48 opts.Width = 80 49 } 50 if opts.Height == 0 { 51 opts.Height = 24 52 } 53 return &TaskView{task: task, opts: opts} 54} 55 56// Task view specific key bindings 57type taskViewKeyMap struct { 58 Up key.Binding 59 Down key.Binding 60 PageUp key.Binding 61 PageDown key.Binding 62 Top key.Binding 63 Bottom key.Binding 64 Quit key.Binding 65 Back key.Binding 66 Help key.Binding 67} 68 69func (k taskViewKeyMap) ShortHelp() []key.Binding { 70 return []key.Binding{k.Up, k.Down, k.Back, k.Help, k.Quit} 71} 72 73func (k taskViewKeyMap) FullHelp() [][]key.Binding { 74 return [][]key.Binding{ 75 {k.Up, k.Down, k.PageUp, k.PageDown}, 76 {k.Top, k.Bottom}, 77 {k.Help, k.Back, k.Quit}, 78 } 79} 80 81var taskViewKeys = taskViewKeyMap{ 82 Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "scroll up")), 83 Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "scroll down")), 84 PageUp: key.NewBinding(key.WithKeys("pgup", "b"), key.WithHelp("pgup/b", "page up")), 85 PageDown: key.NewBinding(key.WithKeys("pgdown", "f"), key.WithHelp("pgdown/f", "page down")), 86 Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home/g", "go to top")), 87 Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end/G", "go to bottom")), 88 Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), 89 Back: key.NewBinding(key.WithKeys("esc", "backspace"), key.WithHelp("esc", "back")), 90 Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), 91} 92 93type taskViewModel struct { 94 task *models.Task 95 viewport viewport.Model 96 keys taskViewKeyMap 97 help help.Model 98 showingHelp bool 99 opts TaskViewOptions 100} 101 102func (m taskViewModel) Init() tea.Cmd { 103 return nil 104} 105 106func (m taskViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 107 var cmd tea.Cmd 108 109 switch msg := msg.(type) { 110 case tea.KeyMsg: 111 if m.showingHelp { 112 switch { 113 case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Help): 114 m.showingHelp = false 115 return m, nil 116 } 117 return m, nil 118 } 119 120 switch { 121 case key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Back): 122 return m, tea.Quit 123 case key.Matches(msg, m.keys.Help): 124 m.showingHelp = true 125 return m, nil 126 case key.Matches(msg, m.keys.Up): 127 m.viewport.ScrollUp(1) 128 case key.Matches(msg, m.keys.Down): 129 m.viewport.ScrollDown(1) 130 case key.Matches(msg, m.keys.PageUp): 131 m.viewport.HalfPageUp() 132 case key.Matches(msg, m.keys.PageDown): 133 m.viewport.HalfPageDown() 134 case key.Matches(msg, m.keys.Top): 135 m.viewport.GotoTop() 136 case key.Matches(msg, m.keys.Bottom): 137 m.viewport.GotoBottom() 138 } 139 140 case tea.WindowSizeMsg: 141 headerHeight := 3 // Title + spacing 142 footerHeight := 3 // Help + spacing 143 verticalMarginHeight := headerHeight + footerHeight 144 145 if !m.opts.Static { 146 m.viewport.Width = msg.Width - 2 // Account for padding 147 m.viewport.Height = msg.Height - verticalMarginHeight 148 } 149 } 150 151 m.viewport, cmd = m.viewport.Update(msg) 152 return m, cmd 153} 154 155func (m taskViewModel) View() string { 156 if m.showingHelp { 157 return m.help.View(m.keys) 158 } 159 160 title := TableTitleStyle.Render(fmt.Sprintf("Task %d", m.task.ID)) 161 content := m.viewport.View() 162 help := MutedStyle.Render(m.help.View(m.keys)) 163 164 return lipgloss.JoinVertical(lipgloss.Left, title, "", content, "", help) 165} 166 167func formatTaskContent(task *models.Task) string { 168 var content strings.Builder 169 170 content.WriteString(fmt.Sprintf("UUID: %s\n", task.UUID)) 171 content.WriteString(fmt.Sprintf("Description: %s\n", task.Description)) 172 content.WriteString(fmt.Sprintf("Status: %s\n", utils.Titlecase(task.Status))) 173 174 if task.Priority != "" { 175 content.WriteString(fmt.Sprintf("Priority: %s\n", utils.Titlecase(task.Priority))) 176 } 177 178 if task.Project != "" { 179 content.WriteString(fmt.Sprintf("Project: %s\n", task.Project)) 180 } 181 182 if len(task.Tags) > 0 { 183 content.WriteString(fmt.Sprintf("Tags: %s\n", strings.Join(task.Tags, ", "))) 184 } 185 186 content.WriteString("\nDates:\n") 187 content.WriteString(fmt.Sprintf("- Created: %s\n", task.Entry.Format("2006-01-02 15:04"))) 188 content.WriteString(fmt.Sprintf("- Modified: %s\n", task.Modified.Format("2006-01-02 15:04"))) 189 190 if task.Due != nil { 191 content.WriteString(fmt.Sprintf("- Due: %s\n", task.Due.Format("2006-01-02 15:04"))) 192 } 193 194 if task.Start != nil { 195 content.WriteString(fmt.Sprintf("- Started: %s\n", task.Start.Format("2006-01-02 15:04"))) 196 } 197 198 if task.End != nil { 199 content.WriteString(fmt.Sprintf("- Completed: %s\n", task.End.Format("2006-01-02 15:04"))) 200 } 201 202 if len(task.Annotations) > 0 { 203 content.WriteString("\nAnnotations:\n") 204 for i, annotation := range task.Annotations { 205 content.WriteString(fmt.Sprintf("%d. %s\n", i+1, annotation)) 206 } 207 } 208 209 return content.String() 210} 211 212// Show displays the task in interactive mode 213func (tv *TaskView) Show(ctx context.Context) error { 214 if tv.opts.Static { 215 return tv.staticShow(ctx) 216 } 217 218 vp := viewport.New(tv.opts.Width-2, tv.opts.Height-6) 219 vp.SetContent(formatTaskContent(tv.task)) 220 221 model := taskViewModel{ 222 task: tv.task, 223 viewport: vp, 224 keys: taskViewKeys, 225 help: help.New(), 226 opts: tv.opts, 227 } 228 229 program := tea.NewProgram(model, tea.WithInput(tv.opts.Input), tea.WithOutput(tv.opts.Output)) 230 231 _, err := program.Run() 232 return err 233} 234 235func (tv *TaskView) staticShow(context.Context) error { 236 content := formatTaskContent(tv.task) 237 238 title := fmt.Sprintf("Task %d\n\n", tv.task.ID) 239 240 fmt.Fprint(tv.opts.Output, title+content) 241 return nil 242}