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