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" }