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/textinput"
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
19type TaskEditOptions struct {
20 Output io.Writer
21 Input io.Reader
22 Width int
23 Height int
24}
25
26type TaskEditor struct {
27 task *models.Task
28 repo utils.TestTaskRepository
29 opts TaskEditOptions
30}
31
32func NewTaskEditor(task *models.Task, repo utils.TestTaskRepository, opts TaskEditOptions) *TaskEditor {
33 if opts.Output == nil {
34 opts.Output = os.Stdout
35 }
36 if opts.Input == nil {
37 opts.Input = os.Stdin
38 }
39 if opts.Width == 0 {
40 opts.Width = 80
41 }
42 if opts.Height == 0 {
43 opts.Height = 24
44 }
45 return &TaskEditor{
46 task: task,
47 repo: repo,
48 opts: opts,
49 }
50}
51
52type (
53 editMode int
54 priorityMode int
55)
56
57const (
58 fieldNavigation editMode = iota
59 statusPicker
60 priorityPicker
61 textInput
62)
63
64const (
65 priorityModeText priorityMode = iota
66 priorityModeNumeric
67 priorityModeLegacy
68)
69
70var (
71 statusOptions = []string{
72 models.StatusTodo,
73 models.StatusInProgress,
74 models.StatusBlocked,
75 models.StatusDone,
76 models.StatusAbandoned,
77 }
78
79 textPriorityOptions = []string{
80 "",
81 models.PriorityLow,
82 models.PriorityMedium,
83 models.PriorityHigh,
84 }
85
86 numericPriorityOptions = []string{"", "1", "2", "3", "4", "5"}
87 legacyPriorityOptions = []string{"", "A", "B", "C", "D", "E"}
88)
89
90type taskEditKeyMap struct {
91 Up key.Binding
92 Down key.Binding
93 Left key.Binding
94 Right key.Binding
95 Enter key.Binding
96 Tab key.Binding
97 ShiftTab key.Binding
98 Escape key.Binding
99 Save key.Binding
100 Cancel key.Binding
101 Help key.Binding
102 StatusEdit key.Binding
103 Priority key.Binding
104 PriorityMode key.Binding
105}
106
107func (k taskEditKeyMap) ShortHelp() []key.Binding {
108 return []key.Binding{k.Up, k.Down, k.Enter, k.Save, k.Cancel, k.Help}
109}
110
111func (k taskEditKeyMap) FullHelp() [][]key.Binding {
112 return [][]key.Binding{
113 {k.Up, k.Down, k.Left, k.Right},
114 {k.Tab, k.ShiftTab, k.Enter},
115 {k.StatusEdit, k.Priority, k.PriorityMode},
116 {k.Save, k.Cancel, k.Help},
117 }
118}
119
120var taskEditKeys = taskEditKeyMap{
121 Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "move up")),
122 Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "move down")),
123 Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "previous")),
124 Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next")),
125 Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select/edit")),
126 Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next field")),
127 ShiftTab: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev field")),
128 Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel/back")),
129 Save: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "save")),
130 Cancel: key.NewBinding(key.WithKeys("ctrl+c", "q"), key.WithHelp("q", "quit")),
131 Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
132 StatusEdit: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "edit status")),
133 Priority: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "edit priority")),
134 PriorityMode: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "priority mode")),
135}
136
137type taskEditModel struct {
138 task *models.Task
139 originalTask *models.Task
140 repo utils.TestTaskRepository
141 opts TaskEditOptions
142 keys taskEditKeyMap
143 help help.Model
144
145 mode editMode
146 currentField int
147 statusIndex int
148 priorityIndex int
149 priorityMode priorityMode
150
151 descInput textinput.Model
152 projectInput textinput.Model
153
154 showingHelp bool
155 saved bool
156 cancelled bool
157
158 fields []string
159}
160
161func (m taskEditModel) Init() tea.Cmd {
162 return textinput.Blink
163}
164
165func (m taskEditModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
166 var cmds []tea.Cmd
167
168 switch msg := msg.(type) {
169 case tea.KeyMsg:
170 if m.showingHelp {
171 switch {
172 case key.Matches(msg, m.keys.Escape) || key.Matches(msg, m.keys.Help):
173 m.showingHelp = false
174 return m, nil
175 }
176 return m, nil
177 }
178
179 switch m.mode {
180 case fieldNavigation:
181 return m.updateFieldNavigation(msg)
182 case statusPicker:
183 return m.updateStatusPicker(msg)
184 case priorityPicker:
185 return m.updatePriorityPicker(msg)
186 case textInput:
187 return m.updateTextInput(msg)
188 }
189
190 case tea.WindowSizeMsg:
191 m.opts.Width = msg.Width
192 m.opts.Height = msg.Height
193 m.descInput.Width = msg.Width - 20
194 m.projectInput.Width = msg.Width - 20
195 }
196
197 return m, tea.Batch(cmds...)
198}
199
200func (m taskEditModel) updateFieldNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
201 switch {
202 case key.Matches(msg, m.keys.Help):
203 m.showingHelp = true
204 return m, nil
205 case key.Matches(msg, m.keys.Cancel):
206 m.cancelled = true
207 return m, tea.Quit
208 case key.Matches(msg, m.keys.Save):
209 return m.saveTask()
210 case key.Matches(msg, m.keys.Up) || key.Matches(msg, m.keys.ShiftTab):
211 m.currentField = (m.currentField - 1 + len(m.fields)) % len(m.fields)
212 case key.Matches(msg, m.keys.Down) || key.Matches(msg, m.keys.Tab):
213 m.currentField = (m.currentField + 1) % len(m.fields)
214 case key.Matches(msg, m.keys.Enter):
215 return m.enterField()
216 case key.Matches(msg, m.keys.StatusEdit):
217 m.mode = statusPicker
218 return m, nil
219 case key.Matches(msg, m.keys.Priority):
220 m.mode = priorityPicker
221 return m, nil
222 case key.Matches(msg, m.keys.PriorityMode):
223 m.priorityMode = (m.priorityMode + 1) % 3
224 m.updatePriorityIndex()
225 return m, nil
226 }
227 return m, nil
228}
229
230func (m taskEditModel) updateStatusPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
231 switch {
232 case key.Matches(msg, m.keys.Escape):
233 m.mode = fieldNavigation
234 case key.Matches(msg, m.keys.Up) || key.Matches(msg, m.keys.Left):
235 m.statusIndex = (m.statusIndex - 1 + len(statusOptions)) % len(statusOptions)
236 case key.Matches(msg, m.keys.Down) || key.Matches(msg, m.keys.Right):
237 m.statusIndex = (m.statusIndex + 1) % len(statusOptions)
238 case key.Matches(msg, m.keys.Enter):
239 m.task.Status = statusOptions[m.statusIndex]
240 m.mode = fieldNavigation
241 }
242 return m, nil
243}
244
245func (m taskEditModel) updatePriorityPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
246 var options []string
247 switch m.priorityMode {
248 case priorityModeText:
249 options = textPriorityOptions
250 case priorityModeNumeric:
251 options = numericPriorityOptions
252 case priorityModeLegacy:
253 options = legacyPriorityOptions
254 }
255
256 switch {
257 case key.Matches(msg, m.keys.Escape):
258 m.mode = fieldNavigation
259 case key.Matches(msg, m.keys.Up) || key.Matches(msg, m.keys.Left):
260 m.priorityIndex = (m.priorityIndex - 1 + len(options)) % len(options)
261 case key.Matches(msg, m.keys.Down) || key.Matches(msg, m.keys.Right):
262 m.priorityIndex = (m.priorityIndex + 1) % len(options)
263 case key.Matches(msg, m.keys.Enter):
264 m.task.Priority = options[m.priorityIndex]
265 m.mode = fieldNavigation
266 case key.Matches(msg, m.keys.PriorityMode):
267 m.priorityMode = (m.priorityMode + 1) % 3
268 m.updatePriorityIndex()
269 }
270 return m, nil
271}
272
273func (m taskEditModel) updateTextInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
274 var cmd tea.Cmd
275
276 switch {
277 case key.Matches(msg, m.keys.Escape):
278 m.mode = fieldNavigation
279 return m, nil
280 case key.Matches(msg, m.keys.Enter):
281 switch m.fields[m.currentField] {
282 case "Description":
283 m.task.Description = m.descInput.Value()
284 case "Project":
285 m.task.Project = m.projectInput.Value()
286 }
287 m.mode = fieldNavigation
288 return m, nil
289 }
290
291 switch m.fields[m.currentField] {
292 case "Description":
293 m.descInput, cmd = m.descInput.Update(msg)
294 case "Project":
295 m.projectInput, cmd = m.projectInput.Update(msg)
296 }
297
298 return m, cmd
299}
300
301func (m taskEditModel) enterField() (tea.Model, tea.Cmd) {
302 switch m.fields[m.currentField] {
303 case "Description":
304 m.mode = textInput
305 m.descInput.Focus()
306 return m, textinput.Blink
307 case "Status":
308 m.mode = statusPicker
309 return m, nil
310 case "Priority":
311 m.mode = priorityPicker
312 return m, nil
313 case "Project":
314 m.mode = textInput
315 m.projectInput.Focus()
316 return m, textinput.Blink
317 }
318 return m, nil
319}
320
321func (m *taskEditModel) updatePriorityIndex() {
322 var options []string
323 switch m.priorityMode {
324 case priorityModeText:
325 options = textPriorityOptions
326 case priorityModeNumeric:
327 options = numericPriorityOptions
328 case priorityModeLegacy:
329 options = legacyPriorityOptions
330 }
331
332 for i, opt := range options {
333 if opt == m.task.Priority {
334 m.priorityIndex = i
335 return
336 }
337 }
338 m.priorityIndex = 0
339}
340
341func (m taskEditModel) saveTask() (tea.Model, tea.Cmd) {
342 m.saved = true
343 return m, tea.Quit
344}
345
346func (m taskEditModel) View() string {
347 if m.showingHelp {
348 return m.help.View(m.keys)
349 }
350
351 var content strings.Builder
352
353 title := TableTitleStyle.Render("Edit Task")
354 content.WriteString(title + "\n\n")
355
356 for i, field := range m.fields {
357 fieldStyle := lipgloss.NewStyle()
358 if i == m.currentField && m.mode == fieldNavigation {
359 fieldStyle = TableSelectedStyle
360 }
361
362 switch field {
363 case "Description":
364 value := m.task.Description
365 if m.mode == textInput && i == m.currentField {
366 value = m.descInput.View()
367 }
368 content.WriteString(fieldStyle.Render(fmt.Sprintf("Description: %s", value)) + "\n")
369
370 case "Status":
371 statusStr := m.renderStatusField()
372 content.WriteString(fieldStyle.Render(fmt.Sprintf("Status: %s", statusStr)) + "\n")
373
374 case "Priority":
375 priorityStr := m.renderPriorityField()
376 content.WriteString(fieldStyle.Render(fmt.Sprintf("Priority: %s", priorityStr)) + "\n")
377
378 case "Project":
379 value := m.task.Project
380 if m.mode == textInput && i == m.currentField {
381 value = m.projectInput.View()
382 }
383 content.WriteString(fieldStyle.Render(fmt.Sprintf("Project: %s", value)) + "\n")
384 }
385 content.WriteString("\n")
386 }
387
388 switch m.mode {
389 case statusPicker:
390 content.WriteString(m.renderStatusPicker())
391 case priorityPicker:
392 content.WriteString(m.renderPriorityPicker())
393 }
394
395 help := m.help.View(m.keys)
396
397 return lipgloss.JoinVertical(lipgloss.Left, content.String(), help)
398}
399
400func (m taskEditModel) renderStatusField() string {
401 if m.mode == statusPicker {
402 return StatusLegend()
403 }
404 return FormatStatusWithText(m.task.Status)
405}
406
407func (m taskEditModel) renderPriorityField() string {
408 if m.mode == priorityPicker {
409 modeStr := ""
410 switch m.priorityMode {
411 case priorityModeText:
412 modeStr = "Text"
413 case priorityModeNumeric:
414 modeStr = "Numeric"
415 case priorityModeLegacy:
416 modeStr = "Legacy"
417 }
418 return fmt.Sprintf("%s (Mode: %s)", PriorityLegend(), modeStr)
419 }
420 return FormatPriorityWithText(m.task.Priority)
421}
422
423func (m taskEditModel) renderStatusPicker() string {
424 var content strings.Builder
425 content.WriteString("Select Status:\n")
426
427 for i, status := range statusOptions {
428 style := lipgloss.NewStyle()
429 if i == m.statusIndex {
430 style = TableSelectedStyle
431 }
432
433 line := fmt.Sprintf("%s %s", FormatStatusIndicator(status), status)
434 content.WriteString(style.Render(line) + "\n")
435 }
436
437 return content.String()
438}
439
440func (m taskEditModel) renderPriorityPicker() string {
441 var content strings.Builder
442
443 modeStr := ""
444 var options []string
445
446 switch m.priorityMode {
447 case priorityModeText:
448 modeStr = "Text"
449 options = textPriorityOptions
450 case priorityModeNumeric:
451 modeStr = "Numeric (1=Low, 5=High)"
452 options = numericPriorityOptions
453 case priorityModeLegacy:
454 modeStr = "Legacy (A=High, E=Low)"
455 options = legacyPriorityOptions
456 }
457
458 content.WriteString(fmt.Sprintf("Select Priority (%s - Press 'm' to switch modes):\n", modeStr))
459
460 for i, priority := range options {
461 style := lipgloss.NewStyle()
462 if i == m.priorityIndex {
463 style = TableSelectedStyle
464 }
465
466 var line string
467 if priority == "" {
468 line = fmt.Sprintf("%s None", FormatPriorityIndicator(priority))
469 } else {
470 line = fmt.Sprintf("%s %s - %s", FormatPriorityIndicator(priority), priority, GetPriorityDescription(priority))
471 }
472 content.WriteString(style.Render(line) + "\n")
473 }
474
475 return content.String()
476}
477
478func (te *TaskEditor) Edit(ctx context.Context) (*models.Task, error) {
479 descInput := textinput.New()
480 descInput.SetValue(te.task.Description)
481 descInput.Width = te.opts.Width - 20
482
483 projectInput := textinput.New()
484 projectInput.SetValue(te.task.Project)
485 projectInput.Width = te.opts.Width - 20
486
487 originalTask := *te.task
488
489 statusIndex := 0
490 for i, status := range statusOptions {
491 if status == te.task.Status {
492 statusIndex = i
493 break
494 }
495 }
496
497 priorityMode := priorityModeText
498 if te.task.Priority != "" {
499 switch GetPriorityDisplayType(te.task.Priority) {
500 case "numeric":
501 priorityMode = priorityModeNumeric
502 case "legacy":
503 priorityMode = priorityModeLegacy
504 }
505 }
506
507 model := taskEditModel{
508 task: te.task,
509 originalTask: &originalTask,
510 repo: te.repo,
511 opts: te.opts,
512 keys: taskEditKeys,
513 help: help.New(),
514
515 mode: fieldNavigation,
516 currentField: 0,
517 statusIndex: statusIndex,
518 priorityMode: priorityMode,
519
520 descInput: descInput,
521 projectInput: projectInput,
522
523 fields: []string{"Description", "Status", "Priority", "Project"},
524 }
525
526 model.updatePriorityIndex()
527
528 program := tea.NewProgram(model, tea.WithInput(te.opts.Input), tea.WithOutput(te.opts.Output))
529
530 finalModel, err := program.Run()
531 if err != nil {
532 return nil, fmt.Errorf("failed to run task editor: %w", err)
533 }
534
535 editModel := finalModel.(taskEditModel)
536
537 if editModel.cancelled {
538 return nil, fmt.Errorf("edit cancelled")
539 }
540
541 if editModel.saved {
542 err := te.repo.Update(ctx, te.task)
543 if err != nil {
544 return nil, fmt.Errorf("failed to save task: %w", err)
545 }
546 }
547
548 return te.task, nil
549}