310 lines
8.2 KiB
Go
310 lines
8.2 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-11)
|
|
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-11))
|
|
case "pgdown":
|
|
m.selected = min(max(0, len(matches)-1), m.selected+max(1, m.height-11))
|
|
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, m.contentWidth()), "\n")...)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func (m model) contentWidth() int {
|
|
if m.width < 50 || m.height < 14 || newTheme().plain {
|
|
return max(1, min(m.width, 100))
|
|
}
|
|
return max(1, min(m.width, 100)-6) // border and horizontal padding
|
|
}
|
|
|
|
func (m model) View() string {
|
|
if m.confirm != "" {
|
|
return m.confirmView()
|
|
}
|
|
if m.editor != nil {
|
|
return m.editorView()
|
|
}
|
|
t := newTheme()
|
|
width := m.contentWidth()
|
|
matches := m.filtered()
|
|
if m.detail && len(matches) > 0 {
|
|
todo := m.items[matches[m.selected]]
|
|
lines := m.detailLines()
|
|
page := max(1, m.height-11)
|
|
start := min(m.scroll, max(0, len(lines)-page))
|
|
end := min(start+page, len(lines))
|
|
header := t.heading("DETAIL / "+safe(todo.Title), todo.ID+" • "+safe(todo.CreatedAt), width)
|
|
meta := t.status(todo.Status) + " " + t.muted.Render(safe(strings.Join(todo.Tags, ", ")))
|
|
hint := fmt.Sprintf("%d-%d/%d ↑↓ scroll · e edit · x/o status · Esc back · q quit", start+1, end, len(lines))
|
|
if width < 60 {
|
|
hint = "↑↓ scroll · e edit · Esc back · q quit"
|
|
}
|
|
content := header + "\n" + meta + "\n\n" + strings.Join(lines[start:end], "\n") +
|
|
fmt.Sprintf("\n\n%s\n", t.hint(hint, width))
|
|
if m.notice != "" {
|
|
content += t.alert(m.notice, width) + "\n"
|
|
}
|
|
return t.box(content, m.width, m.height)
|
|
}
|
|
var out strings.Builder
|
|
fmt.Fprintln(&out, t.heading("TODOS", fmt.Sprintf("%d shown / %d total · %s", len(matches), len(m.items), safe(m.project)), width))
|
|
if len(m.warnings) > 0 {
|
|
fmt.Fprintln(&out, t.alert(fmt.Sprintf("Warning: %d unreadable todo(s): %s", len(m.warnings), m.warnings[0]), width))
|
|
}
|
|
fmt.Fprintln(&out, t.muted.Render(runewidth.Truncate(m.todosDir, width, "…")))
|
|
out.WriteString("\n")
|
|
if len(matches) == 0 {
|
|
if len(m.items) == 0 {
|
|
fmt.Fprintln(&out, t.muted.Render("No todos yet. Pi stores todo items in this directory."))
|
|
} else {
|
|
fmt.Fprintln(&out, t.muted.Render("No matching todos."))
|
|
}
|
|
} else {
|
|
page := max(1, m.height-11)
|
|
start := max(0, m.selected-page+1)
|
|
end := min(start+page, len(matches))
|
|
for i := start; i < end; i++ {
|
|
todo := m.items[matches[i]]
|
|
status := runewidth.Truncate(safe(todo.Status), max(1, min(16, width/3)), "…")
|
|
tags := ""
|
|
if width >= 60 {
|
|
tags = runewidth.Truncate(safe(strings.Join(todo.Tags, ", ")), min(16, width/5), "…")
|
|
}
|
|
tagsWidth := 0
|
|
if tags != "" {
|
|
tagsWidth = 2 + runewidth.StringWidth(tags)
|
|
}
|
|
title := runewidth.Truncate(safe(todo.Title), max(1, width-16-runewidth.StringWidth(status)-tagsWidth), "…")
|
|
line := todo.ID + " [" + status + "] " + title
|
|
if tags != "" {
|
|
line += " " + tags
|
|
}
|
|
if i == m.selected {
|
|
fmt.Fprintln(&out, t.row(line, true, width))
|
|
} else {
|
|
row := t.muted.Render(" "+todo.ID) + " " + t.status(status) + " " + title
|
|
if tags != "" {
|
|
row += " " + t.muted.Render(tags)
|
|
}
|
|
fmt.Fprintln(&out, row)
|
|
}
|
|
}
|
|
}
|
|
search := "Search: " + safe(m.query)
|
|
if m.search {
|
|
search += "▏"
|
|
}
|
|
fmt.Fprintln(&out, "\n"+t.accent.Render(runewidth.Truncate(search, width, "…")))
|
|
if m.notice != "" {
|
|
fmt.Fprintln(&out, t.alert(m.notice, width))
|
|
}
|
|
hint := "↑↓ select · Enter view · / search · n new · e edit · x/o status · r refresh · q quit"
|
|
if width < 40 {
|
|
hint = "↑↓ select · Enter view · q quit"
|
|
} else if width < 70 {
|
|
hint = "↑↓ move · Enter view · / find · n new · e edit · q quit"
|
|
}
|
|
fmt.Fprintln(&out, t.hint(hint, width))
|
|
return t.box(out.String(), m.width, m.height)
|
|
}
|