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