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