todo editor
This commit is contained in:
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user