todo editor

This commit is contained in:
2026-11-04 16:57:28 +01:00
parent 21694908a3
commit e6ca4aef8f
8 changed files with 763 additions and 46 deletions
+311
View File
@@ -0,0 +1,311 @@
package main
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/mattn/go-runewidth"
"todoapp/internal/todos"
)
type editor struct {
original todos.Todo
creating bool
fields [3]string // title, comma-separated tags, Markdown body
field int
cursor int // rune offset in active field
dirty bool
}
func (e *editor) selectField(field int) {
e.field = (field + len(e.fields)) % len(e.fields)
e.cursor = len([]rune(e.fields[e.field]))
}
func (m *model) selectedTodo() (todos.Todo, bool) {
matches := m.filtered()
if len(matches) == 0 || m.selected >= len(matches) {
return todos.Todo{}, false
}
return m.items[matches[m.selected]], true
}
func (m *model) openEditor(create bool) {
e := &editor{creating: create}
if !create {
selected, ok := m.selectedTodo()
if !ok {
return
}
current, err := todos.Read(m.todosDir, selected.ID)
if err != nil {
m.notice = err.Error()
return
}
e.original = current
e.fields = [3]string{current.Title, strings.Join(current.Tags, ", "), current.Body}
e.cursor = len([]rune(current.Title))
}
m.notice = ""
m.editor = e
}
func parseTags(text string) []string {
var tags []string
for _, tag := range strings.Split(text, ",") {
if trimmed := strings.TrimSpace(tag); trimmed != "" {
tags = append(tags, trimmed)
}
}
return tags
}
func (m *model) reload(id string) {
items, warnings, err := todos.Scan(m.todosDir)
if err != nil {
m.notice = err.Error()
return
}
m.items, m.warnings = items, warnings
if id != "" {
for i, index := range m.filtered() {
if m.items[index].ID == id {
m.selected = i
return
}
}
m.query = "" // a changed item may no longer match the old filter
for i, index := range m.filtered() {
if m.items[index].ID == id {
m.selected = i
return
}
}
}
m.clampSelection()
}
func (m *model) saveEditor() {
e := m.editor
title := strings.TrimSpace(e.fields[0])
if title == "" {
m.notice = "Title cannot be empty"
return
}
todo := e.original
todo.Title = title
todo.Tags = parseTags(e.fields[1])
todo.Body = e.fields[2]
var err error
if e.creating {
todo, err = todos.Create(m.todosDir, todo)
} else {
err = todos.Update(m.todosDir, todo)
}
if err != nil {
m.notice = err.Error()
return
}
m.editor = nil
m.notice = "Todo saved"
m.reload(todo.ID)
}
func (m *model) handleEditor(key tea.KeyMsg) {
e := m.editor
switch key.String() {
case "esc":
if e.dirty {
m.confirm = "discard"
} else {
m.editor = nil
}
return
case "ctrl+s":
m.saveEditor()
return
case "tab":
e.selectField(e.field + 1)
return
case "shift+tab":
e.selectField(e.field - 1)
return
case "enter":
if e.field < 2 {
e.selectField(e.field + 1)
return
}
key = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'\n'}}
case "left", "ctrl+b":
e.cursor = max(0, e.cursor-1)
return
case "right", "ctrl+f":
e.cursor = min(len([]rune(e.fields[e.field])), e.cursor+1)
return
case "home", "ctrl+a":
e.cursor = 0
return
case "end", "ctrl+e":
e.cursor = len([]rune(e.fields[e.field]))
return
case "ctrl+u":
e.fields[e.field], e.cursor, e.dirty = "", 0, true
return
case "backspace", "ctrl+h":
if e.cursor > 0 {
chars := []rune(e.fields[e.field])
e.fields[e.field] = string(append(chars[:e.cursor-1], chars[e.cursor:]...))
e.cursor--
e.dirty = true
}
return
case "delete":
chars := []rune(e.fields[e.field])
if e.cursor < len(chars) {
e.fields[e.field] = string(append(chars[:e.cursor], chars[e.cursor+1:]...))
e.dirty = true
}
return
case " ":
// Bubble Tea reports space as KeySpace, not KeyRunes.
key = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}
}
if key.Type == tea.KeyRunes {
chars := []rune(e.fields[e.field])
added := key.Runes
if e.field != 2 {
for _, r := range added {
if r == '\n' {
return
}
}
}
e.fields[e.field] = string(append(append([]rune{}, chars[:e.cursor]...), append(added, chars[e.cursor:]...)...))
e.cursor += len(added)
e.dirty = true
m.notice = ""
}
}
func (m *model) askStatusChange(key string) {
selected, ok := m.selectedTodo()
if !ok {
return
}
closed := selected.Status == "closed" || selected.Status == "done"
if key == "o" && !closed {
m.notice = "Todo is already open"
return
}
if key == "x" && closed {
m.notice = "Todo is already closed"
return
}
if key == "o" {
m.confirm = "reopen"
} else {
m.confirm = "close"
}
m.confirmID = selected.ID
}
func (m *model) handleConfirm(key tea.KeyMsg) {
switch key.String() {
case "n", "esc":
m.confirm = ""
case "y":
action, id := m.confirm, m.confirmID
m.confirm = ""
if action == "discard" {
m.editor = nil
m.notice = "Changes discarded"
return
}
todo, err := todos.Read(m.todosDir, id)
if err != nil {
m.notice = err.Error()
return
}
if action == "close" {
todo.Status = "closed"
} else {
todo.Status = "open"
}
if err := todos.Update(m.todosDir, todo); err != nil {
m.notice = err.Error()
return
}
if action == "close" {
m.notice = "Todo closed"
} else {
m.notice = "Todo reopened"
}
m.reload(id)
}
}
func (m model) editorView() string {
e := m.editor
mode := "New todo"
if !e.creating {
mode = "Edit " + e.original.ID
}
var out strings.Builder
fmt.Fprintf(&out, "TodoApp — %s\n\n", mode)
for i, label := range [2]string{"Title", "Tags (comma-separated)"} {
prefix := " "
if i == e.field {
prefix = "> "
}
value := e.fields[i]
if i == e.field {
chars := []rune(value)
value = string(chars[:e.cursor]) + "▏" + string(chars[e.cursor:])
}
fmt.Fprintln(&out, runewidth.Truncate(prefix+label+": "+safe(value), max(1, m.width), "…"))
}
out.WriteString("\nBody (Markdown; Enter adds a line):\n")
body := e.fields[2]
if e.field == 2 {
chars := []rune(body)
body = string(chars[:e.cursor]) + "▏" + string(chars[e.cursor:])
}
lines := strings.Split(safe(body), "\n")
page := max(1, m.height-11)
cursorLine := 0
if e.field == 2 {
cursorLine = strings.Count(string([]rune(e.fields[2])[:e.cursor]), "\n")
}
start := max(0, cursorLine-page+1)
if start+page > len(lines) {
start = max(0, len(lines)-page)
}
for _, line := range lines[start:min(start+page, len(lines))] {
fmt.Fprintln(&out, runewidth.Truncate(line, max(1, m.width), "…"))
}
if len(lines) > page {
fmt.Fprintf(&out, "(%d-%d of %d body lines)\n", start+1, min(start+page, len(lines)), len(lines))
}
out.WriteString(noticeLine(m.notice))
out.WriteString("\nTab/Shift+Tab fields • ←→ cursor • Ctrl+S save • Esc cancel\n")
return out.String()
}
func (m model) confirmView() string {
question := "Discard unsaved edits?"
if m.confirm == "close" {
question = "Close this todo?"
} else if m.confirm == "reopen" {
question = "Reopen this todo?"
}
return fmt.Sprintf("TodoApp\n\n%s\n\ny confirm • n/Esc cancel\n", question)
}
func noticeLine(text string) string {
if text == "" {
return ""
}
return "\n" + safe(text) + "\n"
}