Files
pitodo/app.go
T
2026-11-04 16:57:28 +01:00

266 lines
6.9 KiB
Go

package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
tea "github.com/charmbracelet/bubbletea"
"github.com/mattn/go-runewidth"
"todoapp/internal/todos"
)
type model struct {
project string
todosDir string
items []todos.Todo
warnings []error
query string
search bool
selected int // index into the filtered list
detail bool
scroll int
width int
height int
editor *editor
confirm string
confirmID string
notice string
}
func newModel(project string) (model, error) {
absolute, err := filepath.Abs(project)
if err != nil {
return model{}, fmt.Errorf("resolve project directory %q: %w", project, err)
}
info, err := os.Stat(absolute)
if err != nil {
return model{}, fmt.Errorf("project directory %q: %w", absolute, err)
}
if !info.IsDir() {
return model{}, fmt.Errorf("project path %q is not a directory", absolute)
}
dir := todos.Dir(absolute)
items, warnings, err := todos.Scan(dir)
if err != nil {
return model{}, fmt.Errorf("load todos: %w", err)
}
return model{project: absolute, todosDir: dir, items: items, warnings: warnings, width: 80, height: 24}, nil
}
func (m model) Init() tea.Cmd { return nil }
func (m model) filtered() []int {
query := strings.ToLower(strings.TrimSpace(m.query))
var matches []int
for i, todo := range m.items {
haystack := strings.ToLower(strings.Join([]string{todo.ID, todo.Title, todo.Status, strings.Join(todo.Tags, " "), todo.Body}, " "))
if query == "" || strings.Contains(haystack, query) {
matches = append(matches, i)
}
}
return matches
}
func (m *model) clampSelection() {
matches := m.filtered()
if len(matches) == 0 {
m.selected = 0
} else if m.selected >= len(matches) {
m.selected = len(matches) - 1
}
}
func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
switch msg := message.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m, nil
case tea.KeyMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
if m.confirm != "" {
m.handleConfirm(msg)
return m, nil
}
if m.editor != nil {
m.handleEditor(msg)
return m, nil
}
if m.search {
switch msg.String() {
case "esc", "enter":
m.search = false
case "backspace", "ctrl+h":
runes := []rune(m.query)
if len(runes) > 0 {
m.query = string(runes[:len(runes)-1])
}
m.selected = 0
case "ctrl+u":
m.query = ""
m.selected = 0
default:
if msg.Type == tea.KeySpace {
m.query += " "
m.selected = 0
} else if msg.Type == tea.KeyRunes {
m.query += string(msg.Runes)
m.selected = 0
}
}
return m, nil
}
if msg.String() == "q" {
return m, tea.Quit
}
if m.detail {
if msg.String() == "e" {
m.openEditor(false)
return m, nil
}
if msg.String() == "x" || msg.String() == "o" {
m.askStatusChange(msg.String())
return m, nil
}
lines := m.detailLines()
page := max(1, m.height-9)
last := max(0, len(lines)-page)
switch msg.String() {
case "esc", "left":
m.detail, m.scroll = false, 0
case "up", "k":
m.scroll = max(0, m.scroll-1)
case "down", "j":
m.scroll = min(last, m.scroll+1)
case "pgup":
m.scroll = max(0, m.scroll-page)
case "pgdown", " ":
m.scroll = min(last, m.scroll+page)
case "home", "g":
m.scroll = 0
case "end", "G":
m.scroll = last
}
return m, nil
}
matches := m.filtered()
switch msg.String() {
case "up", "k":
m.selected = max(0, m.selected-1)
case "down", "j":
m.selected = min(max(0, len(matches)-1), m.selected+1)
case "pgup":
m.selected = max(0, m.selected-max(1, m.height-8))
case "pgdown":
m.selected = min(max(0, len(matches)-1), m.selected+max(1, m.height-8))
case "home", "g":
m.selected = 0
case "end", "G":
m.selected = max(0, len(matches)-1)
case "enter":
if len(matches) > 0 {
m.detail, m.scroll = true, 0
}
case "/":
m.search = true
case "esc":
m.query = ""
m.selected = 0
case "r":
m.reload("")
case "n":
m.openEditor(true)
case "e":
m.openEditor(false)
case "x", "o":
m.askStatusChange(msg.String())
}
}
return m, nil
}
// Remove terminal control characters from content read from Markdown files.
func safe(text string) string {
return strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' || !unicode.IsControl(r) {
return r
}
return -1
}, text)
}
func (m model) detailLines() []string {
matches := m.filtered()
if len(matches) == 0 || m.selected >= len(matches) {
return nil
}
body := safe(m.items[matches[m.selected]].Body)
if strings.TrimSpace(body) == "" {
return []string{"(No description)"}
}
var lines []string
for _, line := range strings.Split(strings.TrimRight(body, "\n"), "\n") {
lines = append(lines, strings.Split(runewidth.Wrap(line, max(1, m.width-2)), "\n")...)
}
return lines
}
func (m model) View() string {
if m.confirm != "" {
return m.confirmView()
}
if m.editor != nil {
return m.editorView()
}
width := max(1, m.width)
matches := m.filtered()
if m.detail && len(matches) > 0 {
todo := m.items[matches[m.selected]]
lines := m.detailLines()
page := max(1, m.height-9)
start := min(m.scroll, max(0, len(lines)-page))
end := min(start+page, len(lines))
header := fmt.Sprintf("TodoApp — %s [%s]\n%s • %s • %s\n\n", safe(todo.Title), todo.ID, safe(todo.Status), safe(strings.Join(todo.Tags, ", ")), todo.CreatedAt)
return header + strings.Join(lines[start:end], "\n") + fmt.Sprintf("\n\n%d-%d/%d ↑↓/PgUp/PgDn scroll • e edit • x/o close/reopen • Esc back • q quit\n%s", start+1, end, len(lines), noticeLine(m.notice))
}
var out strings.Builder
fmt.Fprintf(&out, "TodoApp — %s\nTodos: %s\n", m.project, m.todosDir)
if len(m.warnings) > 0 {
fmt.Fprintf(&out, "Warning: %d unreadable todo(s): %s\n", len(m.warnings), safe(m.warnings[0].Error()))
}
fmt.Fprintf(&out, "\n%d of %d todo(s)\n", len(matches), len(m.items))
if len(matches) == 0 {
if len(m.items) == 0 {
out.WriteString("No todos yet. Pi stores todo items in this directory.\n")
} else {
out.WriteString("No matching todos.\n")
}
} else {
page := max(1, m.height-9)
start := max(0, m.selected-page+1)
end := min(start+page, len(matches))
for i := start; i < end; i++ {
todo := m.items[matches[i]]
prefix := " "
if i == m.selected {
prefix = "> "
}
line := fmt.Sprintf("%s%s %s [%s] %s", prefix, todo.ID, safe(todo.Title), safe(todo.Status), safe(strings.Join(todo.Tags, ", ")))
out.WriteString(runewidth.Truncate(line, width, "…") + "\n")
}
}
if m.search {
fmt.Fprintf(&out, "\nSearch: %s_ (Enter/Esc finish, Ctrl+U clear)\n", safe(m.query))
} else {
fmt.Fprintf(&out, "\nSearch: %s (/ to edit, Esc clear)\n", safe(m.query))
}
out.WriteString("↑↓ navigate • Enter view • n new • e edit • x/o close/reopen • r refresh • q quit\n")
out.WriteString(noticeLine(m.notice))
return out.String()
}