initial commit: local TUI functionality
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1 @@
+dew
diff --git a/actions.go b/actions.go
@@ -0,0 +1,265 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+)
+
+func compileSmartDate(val string, now time.Time) (string, error) {
+ val = strings.ToLower(val)
+ if val == "today" || val == "tod" {
+ return now.Format("2006-01-02"), nil
+ }
+ if val == "tomorrow" || val == "tom" {
+ return now.AddDate(0, 0, 1).Format("2006-01-02"), nil
+ }
+ if strings.HasPrefix(val, "+") {
+ unit := string(val[len(val)-1])
+ amount, err := strconv.Atoi(val[1 : len(val)-1])
+ if err != nil {
+ return "", err
+ }
+ switch unit {
+ case "d": return now.AddDate(0, 0, amount).Format("2006-01-02"), nil
+ case "w": return now.AddDate(0, 0, amount*7).Format("2006-01-02"), nil
+ case "m": return now.AddDate(0, amount, 0).Format("2006-01-02"), nil
+ case "y": return now.AddDate(amount, 0, 0).Format("2006-01-02"), nil
+ }
+ }
+ return "", fmt.Errorf("not a smart date")
+}
+
+func AddTask(filePath string, args []string) (string, error) {
+ if len(args) == 0 { return "", fmt.Errorf("no task description provided") }
+
+ now := time.Now()
+ for i, arg := range args {
+ if strings.HasPrefix(arg, "due:") {
+ compiled, err := compileSmartDate(arg[4:], now)
+ if err == nil {
+ args[i] = "due:" + compiled
+ }
+ }
+ }
+
+ taskLine := strings.Join(args, " ")
+
+ file, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+ if err != nil { return "", err }
+ defer file.Close()
+
+ _, err = file.WriteString(taskLine + "\n")
+ if err != nil { return "", err }
+
+ return fmt.Sprintf("added task: %s", taskLine), nil
+}
+
+func CompleteTask(cfg *Config, args []string) (string, error) {
+ if len(args) == 0 { return "", fmt.Errorf("no task ID provided") }
+ taskID, err := strconv.Atoi(args[0])
+ if err != nil { return "", fmt.Errorf("invalid task ID: %s", args[0]) }
+
+ tasks, err := ReadTasks(cfg.TodoFile)
+ if err != nil { return "", err }
+
+ var targetTask *Task
+ for i, t := range tasks {
+ if t.ID == taskID && t.IsValid {
+ targetTask = &tasks[i]
+ break
+ }
+ }
+
+ if targetTask == nil { return "", fmt.Errorf("task %d not found", taskID) }
+
+ now := time.Now()
+ dateString := now.Format("2006-01-02")
+
+ var newRecurLine string
+ var recurMsg string
+ if targetTask.Recurrence != "" {
+ newRecurLine, err = generateRecurringTask(*targetTask, now)
+ if err != nil { return "", fmt.Errorf("error generating recurring task: %v", err) }
+ recurMsg = fmt.Sprintf("\ngenerated next recurring task for %s", targetTask.Recurrence)
+ }
+
+ doneLine := fmt.Sprintf("x %s %s\n", dateString, targetTask.RawLine)
+ doneFile, err := os.OpenFile(cfg.DoneFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+ if err != nil { return "", fmt.Errorf("could not open done file: %v", err) }
+ defer doneFile.Close()
+
+ _, err = doneFile.WriteString(doneLine)
+ if err != nil { return "", fmt.Errorf("could not write to done file: %v", err) }
+
+ todoFile, err := os.Create(cfg.TodoFile)
+ if err != nil { return "", fmt.Errorf("could not rewrite todo file: %v", err) }
+ defer todoFile.Close()
+
+ for _, t := range tasks {
+ if t.ID != taskID && t.IsValid {
+ todoFile.WriteString(t.RawLine + "\n")
+ }
+ }
+
+ if newRecurLine != "" { todoFile.WriteString(newRecurLine + "\n") }
+
+ finalMsg := fmt.Sprintf("marked task %d as done and archived to done.txt!%s", taskID, recurMsg)
+ return finalMsg, nil
+}
+
+func generateRecurringTask(t Task, now time.Time) (string, error) {
+ recStr := strings.Trim(t.Recurrence, "[]+")
+ if len(recStr) == 0 { return "", fmt.Errorf("invalid recurrence format") }
+
+ var amount int
+ var unit string
+ lastChar := string(recStr[len(recStr)-1])
+ if strings.ContainsAny(lastChar, "dwmy") {
+ unit = lastChar
+ amount, _ = strconv.Atoi(recStr[:len(recStr)-1])
+ } else {
+ unit = "d"
+ amount, _ = strconv.Atoi(recStr)
+ }
+
+ baseDate := now
+ if strings.Contains(t.Recurrence, "+") && t.HasDueDate { baseDate = t.DueDate }
+
+ var nextDate time.Time
+ switch unit {
+ case "d": nextDate = baseDate.AddDate(0, 0, amount)
+ case "w": nextDate = baseDate.AddDate(0, 0, amount*7)
+ case "m": nextDate = baseDate.AddDate(0, amount, 0)
+ case "y": nextDate = baseDate.AddDate(amount, 0, 0)
+ default: return "", fmt.Errorf("unknown recurrence unit: %s", unit)
+ }
+
+ newLine := t.RawLine
+ newDueStr := nextDate.Format("2006-01-02")
+ if t.HasDueDate {
+ oldDueStr := t.DueDate.Format("2006-01-02")
+ newLine = strings.Replace(newLine, "due:"+oldDueStr, "due:"+newDueStr, 1)
+ } else {
+ newLine += " due:" + newDueStr
+ }
+ return newLine, nil
+}
+
+func DeleteTask(cfg *Config, args []string) (string, error) {
+ if len(args) == 0 { return "", fmt.Errorf("no task ID provided") }
+ taskID, err := strconv.Atoi(args[0])
+ if err != nil { return "", fmt.Errorf("invalid task ID: %s", args[0]) }
+
+ tasks, err := ReadTasks(cfg.TodoFile)
+ if err != nil { return "", err }
+
+ found := false
+ todoFile, err := os.Create(cfg.TodoFile)
+ if err != nil { return "", fmt.Errorf("could not rewrite todo file: %v", err) }
+ defer todoFile.Close()
+
+ for _, t := range tasks {
+ if t.ID == taskID && t.IsValid {
+ found = true
+ continue
+ }
+ if t.IsValid {
+ todoFile.WriteString(t.RawLine + "\n")
+ }
+ }
+
+ if !found { return "", fmt.Errorf("task %d not found", taskID) }
+ return fmt.Sprintf("deleted task %d completely.", taskID), nil
+}
+
+func ToggleModifier(cfg *Config, args []string, mod string) (string, error) {
+ if len(args) == 0 { return "", fmt.Errorf("no task ID provided") }
+ taskID, err := strconv.Atoi(args[0])
+ if err != nil { return "", fmt.Errorf("invalid task ID: %s", args[0]) }
+
+ tasks, err := ReadTasks(cfg.TodoFile)
+ if err != nil { return "", err }
+
+ var targetIndex = -1
+ for i, t := range tasks {
+ if t.ID == taskID && t.IsValid {
+ targetIndex = i
+ break
+ }
+ }
+
+ if targetIndex == -1 { return "", fmt.Errorf("task %d not found", taskID) }
+ targetTask := tasks[targetIndex]
+
+ words := strings.Fields(targetTask.RawLine)
+ firstWordIndex := 0
+ if len(words) > 0 && strings.HasPrefix(words[0], "(") && strings.HasSuffix(words[0], ")") && len(words[0]) == 3 {
+ firstWordIndex = 1
+ }
+
+ if len(words) > firstWordIndex {
+ w := words[firstWordIndex]
+ if strings.HasPrefix(w, "*") || strings.HasPrefix(w, "~") {
+ if len(w) > 1 {
+ words[firstWordIndex] = w[1:]
+ } else {
+ words = append(words[:firstWordIndex], words[firstWordIndex+1:]...)
+ }
+ }
+ }
+
+ wasAlreadyMod := false
+ if mod == "*" && targetTask.IsPinned { wasAlreadyMod = true }
+ if mod == "~" && targetTask.IsWaiting { wasAlreadyMod = true }
+
+ if !wasAlreadyMod {
+ newWords := make([]string, 0, len(words)+1)
+ newWords = append(newWords, words[:firstWordIndex]...)
+ newWords = append(newWords, mod)
+ newWords = append(newWords, words[firstWordIndex:]...)
+ words = newWords
+ }
+
+ tasks[targetIndex].RawLine = strings.Join(words, " ")
+
+ todoFile, err := os.Create(cfg.TodoFile)
+ if err != nil { return "", err }
+ defer todoFile.Close()
+
+ for _, t := range tasks {
+ if t.IsValid { todoFile.WriteString(t.RawLine + "\n") }
+ }
+
+ return fmt.Sprintf("toggled %s for task %d", mod, taskID), nil
+}
+
+// EditTask replaces the exact raw string of a given task ID
+func EditTask(cfg *Config, taskID int, newText string) (string, error) {
+ tasks, err := ReadTasks(cfg.TodoFile)
+ if err != nil { return "", err }
+
+ found := false
+ for i, t := range tasks {
+ if t.ID == taskID && t.IsValid {
+ tasks[i].RawLine = newText
+ found = true
+ break
+ }
+ }
+
+ if !found { return "", fmt.Errorf("task %d not found", taskID) }
+
+ todoFile, err := os.Create(cfg.TodoFile)
+ if err != nil { return "", err }
+ defer todoFile.Close()
+
+ for _, t := range tasks {
+ if t.IsValid {
+ todoFile.WriteString(t.RawLine + "\n")
+ }
+ }
+ return fmt.Sprintf("updated task %d", taskID), nil
+}
diff --git a/config.go b/config.go
@@ -0,0 +1,103 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+type Config struct {
+ ProjectOrder []string `json:"project_order"`
+ ContextOrder []string `json:"context_order"`
+ TodoFile string `json:"todo_file"`
+ DoneFile string `json:"done_file"`
+ CenterTUI bool `json:"center_tui"`
+ DefaultView string `json:"default_view"`
+
+ BackendMode string `json:"backend_mode"`
+ APIURL string `json:"api_url"`
+ APIToken string `json:"api_token"`
+
+ CursorIcon string `json:"cursor_icon"`
+ ProjectIcon string `json:"project_icon"`
+ HideWaiting bool `json:"hide_waiting_tasks"`
+ HideHints bool `json:"hide_hints"`
+
+ ColorAccent string `json:"color_accent"`
+ ColorMuted string `json:"color_muted"`
+ ColorProject string `json:"color_project"`
+ ColorContext string `json:"color_context"`
+ ColorOverdue string `json:"color_overdue"`
+ ColorToday string `json:"color_today"`
+ ColorSoon string `json:"color_soon"`
+ ColorFar string `json:"color_far"`
+ ColorVeryFar string `json:"color_very_far"`
+}
+
+func getConfigPath() (string, error) {
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("could not find home directory: %v", err)
+ }
+ return filepath.Join(homeDir, ".config", "dew", "config.json"), nil
+}
+
+func loadConfig() (*Config, error) {
+ cfg := Config{
+ CenterTUI: true,
+ DefaultView: "grouped",
+ BackendMode: "local",
+ APIURL: "https://todo.myn.sh/api",
+ APIToken: "",
+ CursorIcon: "> ",
+ ProjectIcon: "\u21b4",
+ HideWaiting: false,
+ HideHints: false,
+ ColorAccent: "6",
+ ColorMuted: "8",
+ ColorProject: "4",
+ ColorContext: "5",
+ ColorOverdue: "1",
+ ColorToday: "3",
+ ColorSoon: "3",
+ ColorFar: "2",
+ ColorVeryFar: "4",
+ }
+
+ configPath, err := getConfigPath()
+ if err != nil { return nil, err }
+
+ file, err := os.Open(configPath)
+ if err != nil {
+ return nil, fmt.Errorf("could not open config file: %v", err)
+ }
+ defer file.Close()
+
+ if err := json.NewDecoder(file).Decode(&cfg); err != nil {
+ return nil, fmt.Errorf("could not parse config file: %v", err)
+ }
+
+ if cfg.DefaultView != "flat" && cfg.DefaultView != "grouped" {
+ cfg.DefaultView = "grouped"
+ }
+
+ if cfg.BackendMode != "local" && cfg.BackendMode != "server" {
+ cfg.BackendMode = "local"
+ }
+
+ return &cfg, nil
+}
+
+func saveConfig(cfg *Config) error {
+ configPath, err := getConfigPath()
+ if err != nil { return err }
+
+ file, err := os.Create(configPath)
+ if err != nil { return err }
+ defer file.Close()
+
+ encoder := json.NewEncoder(file)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(cfg)
+}
diff --git a/go.mod b/go.mod
@@ -0,0 +1,30 @@
+module dew
+
+go 1.26.4
+
+require (
+ github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/charmbracelet/bubbles v1.0.0 // indirect
+ github.com/charmbracelet/bubbletea v1.3.10 // indirect
+ github.com/charmbracelet/colorprofile v0.4.1 // indirect
+ github.com/charmbracelet/lipgloss v1.1.0 // indirect
+ github.com/charmbracelet/x/ansi v0.11.6 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
+ github.com/charmbracelet/x/term v0.2.2 // indirect
+ github.com/clipperhouse/displaywidth v0.9.0 // indirect
+ github.com/clipperhouse/stringish v0.1.1 // indirect
+ github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-localereader v0.0.1 // indirect
+ github.com/mattn/go-runewidth v0.0.19 // indirect
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/termenv v0.16.0 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/sys v0.38.0 // indirect
+ golang.org/x/text v0.3.8 // indirect
+)
diff --git a/go.sum b/go.sum
@@ -0,0 +1,65 @@
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
+github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
+github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
+github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
+github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
+github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
+github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
+github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
+github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
+github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
+golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
diff --git a/main.go b/main.go
@@ -0,0 +1,250 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+func main() {
+ var newArgs []string
+ silent := false
+ for _, arg := range os.Args {
+ if arg == "-s" || arg == "-q" || arg == "--silent" || arg == "--quiet" {
+ silent = true
+ } else {
+ newArgs = append(newArgs, arg)
+ }
+ }
+ os.Args = newArgs
+
+ if silent {
+ devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0666)
+ if err == nil {
+ os.Stdout = devNull
+ }
+ }
+
+ cfg, err := loadConfig()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
+ os.Exit(1)
+ }
+
+ if len(os.Args) < 2 {
+ err := LaunchTUI(cfg)
+ if err != nil { fmt.Fprintf(os.Stderr, "error launching tui: %v\n", err); os.Exit(1) }
+ return
+ }
+
+ command := strings.ToLower(os.Args[1])
+
+ switch command {
+ case "add", "a":
+ msg, err := AddTask(cfg.TodoFile, os.Args[2:])
+ if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err); os.Exit(1) }
+ fmt.Println(msg)
+ case "do", "done":
+ msg, err := CompleteTask(cfg, os.Args[2:])
+ if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err); os.Exit(1) }
+ fmt.Println(msg)
+ case "rm", "del":
+ msg, err := DeleteTask(cfg, os.Args[2:])
+ if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err); os.Exit(1) }
+ fmt.Println(msg)
+ case "pin":
+ msg, err := ToggleModifier(cfg, os.Args[2:], "*")
+ if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err); os.Exit(1) }
+ fmt.Println(msg)
+ case "wait":
+ msg, err := ToggleModifier(cfg, os.Args[2:], "~")
+ if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err); os.Exit(1) }
+ fmt.Println(msg)
+ case "ls", "list":
+ listTasks(cfg, os.Args[2:], "grouped")
+ case "flat", "raw":
+ listTasks(cfg, os.Args[2:], "flat")
+ case "tui", "ui":
+ err := LaunchTUI(cfg)
+ if err != nil { fmt.Fprintf(os.Stderr, "error launching tui: %v\n", err); os.Exit(1) }
+ default:
+ fmt.Fprintf(os.Stderr, "unknown command: %s\n", command)
+ os.Exit(1)
+ }
+}
+
+func listTasks(cfg *Config, filters []string, viewMode string) {
+ allTasks, err := ReadTasks(cfg.TodoFile)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error reading todo file: %v\n", err)
+ os.Exit(1)
+ }
+
+ var tasks []Task
+ for _, t := range allTasks {
+ if !t.IsValid { continue }
+ if cfg.HideWaiting && t.IsWaiting && len(filters) == 0 { continue }
+
+ match := true
+ rawLower := strings.ToLower(t.RawLine)
+ for _, f := range filters {
+ if !strings.Contains(rawLower, strings.ToLower(f)) {
+ match = false
+ break
+ }
+ }
+ if match { tasks = append(tasks, t) }
+ }
+
+ sort.Slice(tasks, func(i, j int) bool {
+ t1, t2 := tasks[i], tasks[j]
+
+ s1Weight, s2Weight := 1, 1
+ if t1.IsPinned { s1Weight = 0 }
+ if t1.IsWaiting { s1Weight = 2 }
+ if t2.IsPinned { s2Weight = 0 }
+ if t2.IsWaiting { s2Weight = 2 }
+
+ if viewMode == "flat" {
+ if s1Weight != s2Weight { return s1Weight < s2Weight }
+ return t1.ID < t2.ID
+ }
+
+ p1Weight := getProjectWeight(t1, cfg.ProjectOrder)
+ p2Weight := getProjectWeight(t2, cfg.ProjectOrder)
+ if p1Weight != p2Weight { return p1Weight < p2Weight }
+
+ c1Weight := getContextWeight(t1, cfg.ContextOrder)
+ c2Weight := getContextWeight(t2, cfg.ContextOrder)
+ if c1Weight != c2Weight { return c1Weight < c2Weight }
+
+ if s1Weight != s2Weight { return s1Weight < s2Weight }
+
+ d1, d2 := 999999, 999999
+ if t1.HasDueDate { d1 = t1.DaysUntilDue() }
+ if t2.HasDueDate { d2 = t2.DaysUntilDue() }
+ if d1 != d2 { return d1 < d2 }
+
+ return t1.ID < t2.ID
+ })
+
+ mDesc, mProj, mCtx, mDue := 4, 7, 7, 3
+ for _, t := range tasks {
+ if len(t.Description) > mDesc { mDesc = len(t.Description) }
+ pStr := strings.Join(t.Projects, " ")
+ if len(pStr) > mProj { mProj = len(pStr) }
+ cStr := strings.Join(t.Contexts, " ")
+ if len(cStr) > mCtx { mCtx = len(cStr) }
+ dueLen := 5
+ if t.HasDueDate {
+ d := t.DaysUntilDue()
+ if d < 0 { dueLen = len(fmt.Sprintf("(%dd overdue)", -d)) } else if d == 0 { dueLen = 7 } else { dueLen = len(fmt.Sprintf("(%dd)", d)) }
+ }
+ if dueLen > mDue { mDue = dueLen }
+ }
+
+ if len(tasks) == 0 {
+ fmt.Println(lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.ColorSoon)).Render("no tasks match your filters."))
+ return
+ }
+
+ fmt.Println()
+ descHead := fmt.Sprintf("%-*s", mDesc, "TASK")
+ projHead := fmt.Sprintf("%-*s", mProj, "PROJECT")
+ ctxHead := fmt.Sprintf("%-*s", mCtx, "CONTEXT")
+ dueHead := fmt.Sprintf("%-*s", mDue, "DUE")
+
+ headerLine := fmt.Sprintf("ID %s %s %s %s RECUR", descHead, projHead, ctxHead, dueHead)
+ fmt.Println(lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.ColorMuted)).Underline(true).Render(headerLine))
+
+ currProj := ""
+ total, today, overdue := 0, 0, 0
+
+ for _, t := range tasks {
+ total++
+
+ if viewMode != "flat" {
+ pPrimary := "untagged"
+ if len(t.Projects) > 0 { pPrimary = strings.ToLower(t.Projects[0]) }
+
+ if pPrimary != currProj {
+ pLine := fmt.Sprintf("%s %s", pPrimary, cfg.ProjectIcon)
+ fmt.Printf("\n%s\n", lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.ColorMuted)).Render(pLine))
+ currProj = pPrimary
+ }
+ }
+
+ styleDesc := lipgloss.NewStyle().Foreground(lipgloss.Color("15"))
+ styleProj := lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.ColorProject))
+ styleCtx := lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.ColorContext))
+ styleDue := lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.ColorMuted))
+
+ if t.IsPinned { styleDesc = styleDesc.Bold(true) }
+
+ dueText := "(n/a)"
+ if t.IsWaiting {
+ styleDesc = lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.ColorMuted))
+ styleProj, styleCtx, styleDue = styleDesc, styleDesc, styleDesc
+ } else if t.HasDueDate {
+ d := t.DaysUntilDue()
+ if d < 0 {
+ overdue++
+ styleDue = lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.ColorOverdue))
+ dueText = fmt.Sprintf("(%dd overdue)", -d)
+ } else if d == 0 {
+ today++
+ styleDue = lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.ColorToday)).Bold(true)
+ dueText = "(today)"
+ } else if d <= 3 {
+ styleDue = lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.ColorSoon)).Bold(true)
+ dueText = fmt.Sprintf("(%dd)", d)
+ } else if d <= 14 {
+ styleDue = lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.ColorFar))
+ dueText = fmt.Sprintf("(%dd)", d)
+ } else {
+ styleDue = lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.ColorVeryFar))
+ dueText = fmt.Sprintf("(%dd)", d)
+ }
+ }
+
+ descFmt := styleDesc.Render(fmt.Sprintf("%-*s", mDesc, t.Description))
+ projFmt := styleProj.Render(fmt.Sprintf("%-*s", mProj, strings.Join(t.Projects, " ")))
+ ctxFmt := styleCtx.Render(fmt.Sprintf("%-*s", mCtx, strings.Join(t.Contexts, " ")))
+ dueFmt := styleDue.Render(fmt.Sprintf("%-*s", mDue, dueText))
+
+ recFmt := ""
+ if t.Recurrence != "" { recFmt = lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.ColorMuted)).Render(t.Recurrence) }
+
+ strID := lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.ColorMuted)).Render(fmt.Sprintf("%02d", t.ID))
+ fmt.Printf("%s %s %s %s %s %s\n", strID, descFmt, projFmt, ctxFmt, dueFmt, recFmt)
+ }
+
+ fmt.Println()
+ footer := []string{fmt.Sprintf("%d open tasks", total)}
+ if len(filters) > 0 { footer[0] = fmt.Sprintf("%d filtered tasks", total) }
+ if today > 0 { footer = append(footer, fmt.Sprintf("%d due today", today)) }
+ if overdue > 0 { footer = append(footer, fmt.Sprintf("%d overdue", overdue)) }
+
+ fmt.Println(lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.ColorMuted)).Render(strings.Join(footer, " | ")))
+}
+
+func getProjectWeight(t Task, order []string) int {
+ if len(t.Projects) == 0 { return 999 }
+ primary := strings.ToLower(t.Projects[0])
+ for i, p := range order {
+ if strings.ToLower(p) == primary { return i }
+ }
+ return 999
+}
+
+func getContextWeight(t Task, order []string) int {
+ if len(t.Contexts) == 0 { return 999 }
+ primary := strings.ToLower(t.Contexts[0])
+ for i, c := range order {
+ if strings.ToLower(c) == primary { return i }
+ }
+ return 999
+}
diff --git a/task.go b/task.go
@@ -0,0 +1,125 @@
+package main
+
+import (
+ "bufio"
+ "os"
+ "strings"
+ "time"
+)
+
+type Task struct {
+ ID int
+ RawLine string
+ Description string
+ Projects []string
+ Contexts []string
+ DueDate time.Time
+ HasDueDate bool
+ Recurrence string
+ IsPinned bool
+ IsWaiting bool
+ IsValid bool
+}
+
+// DaysUntilDue calculates the exact calendar days remaining
+func (t Task) DaysUntilDue() int {
+ if !t.HasDueDate {
+ return 9999
+ }
+ now := time.Now()
+
+ // strip timezones by forcing both today and the due date into raw UTC midnights
+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
+ due := time.Date(t.DueDate.Year(), t.DueDate.Month(), t.DueDate.Day(), 0, 0, 0, 0, time.UTC)
+
+ return int(due.Sub(today).Hours() / 24)
+}
+
+func ReadTasks(filePath string) ([]Task, error) {
+ file, err := os.Open(filePath)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ var tasks []Task
+ scanner := bufio.NewScanner(file)
+ id := 1
+
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ continue
+ }
+ tasks = append(tasks, ParseTask(id, line))
+ id++
+ }
+
+ return tasks, scanner.Err()
+}
+
+func ParseTask(id int, line string) Task {
+ task := Task{
+ ID: id,
+ RawLine: line,
+ IsValid: true,
+ }
+
+ words := strings.Fields(line)
+ if len(words) == 0 {
+ task.IsValid = false
+ return task
+ }
+
+ firstWordIndex := 0
+ // skip priority tag like (A) if it exists
+ if len(words) > 0 && strings.HasPrefix(words[0], "(") && strings.HasSuffix(words[0], ")") && len(words[0]) == 3 {
+ firstWordIndex = 1
+ }
+
+ // handle modifiers
+ if len(words) > firstWordIndex {
+ w := words[firstWordIndex]
+ if strings.HasPrefix(w, "*") {
+ task.IsPinned = true
+ if len(w) > 1 {
+ words[firstWordIndex] = w[1:]
+ } else {
+ words[firstWordIndex] = ""
+ }
+ } else if strings.HasPrefix(w, "~") {
+ task.IsWaiting = true
+ if len(w) > 1 {
+ words[firstWordIndex] = w[1:]
+ } else {
+ words[firstWordIndex] = ""
+ }
+ }
+ }
+
+ var descWords []string
+
+ for _, w := range words {
+ if w == "" {
+ continue
+ }
+ if strings.HasPrefix(w, "+") && len(w) > 1 {
+ task.Projects = append(task.Projects, w[1:])
+ } else if strings.HasPrefix(w, "@") && len(w) > 1 {
+ task.Contexts = append(task.Contexts, w[1:])
+ } else if strings.HasPrefix(w, "due:") {
+ parsedDate, err := time.Parse("2006-01-02", w[4:])
+ if err == nil {
+ task.DueDate = parsedDate
+ task.HasDueDate = true
+ }
+ } else if strings.HasPrefix(w, "rec:") {
+ task.Recurrence = "[" + w[4:] + "]"
+ } else {
+ descWords = append(descWords, w)
+ }
+ }
+
+ task.Description = strings.Join(descWords, " ")
+ return task
+}
diff --git a/tui.go b/tui.go
@@ -0,0 +1,970 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+type editorFinishedMsg struct {
+ err error
+ isBulk bool
+}
+
+type model struct {
+ cfg *Config
+ tasks []Task
+ cursor int
+ status string
+ viewMode string
+ inputMode string
+ filterStr string
+ textInput textinput.Model
+ undoBuffer []byte
+ pendingG bool
+ viewingHistory bool
+
+ selected map[int]bool
+ bulkEditFile string
+
+ activeModal string
+ managerCursor int
+ helpTab int
+
+ width int
+ height int
+ viewportStart int
+}
+
+func LaunchTUI(cfg *Config) error {
+ ti := textinput.New()
+ ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.ColorAccent))
+ ti.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(cfg.ColorAccent))
+ ti.CharLimit = 256
+ ti.Width = 60
+
+ m := model{
+ cfg: cfg,
+ viewMode: cfg.DefaultView,
+ textInput: ti,
+ selected: make(map[int]bool),
+ }
+
+ if cfg.BackendMode != "server" {
+ refreshTasks(&m)
+ }
+
+ p := tea.NewProgram(m, tea.WithAltScreen())
+ _, err := p.Run()
+ return err
+}
+
+func refreshTasks(m *model) {
+ if m.cfg.BackendMode == "server" { return }
+
+ filePath := m.cfg.TodoFile
+ if m.viewingHistory {
+ filePath = m.cfg.DoneFile
+ }
+
+ tasks, _ := ReadTasks(filePath)
+ var validTasks []Task
+
+ filterWords := strings.Fields(strings.ToLower(m.filterStr))
+
+ for _, t := range tasks {
+ if !t.IsValid { continue }
+ if m.cfg.HideWaiting && t.IsWaiting && len(filterWords) == 0 && !m.viewingHistory { continue }
+
+ match := true
+ rawLower := strings.ToLower(t.RawLine)
+ for _, f := range filterWords {
+ if !strings.Contains(rawLower, f) {
+ match = false
+ break
+ }
+ }
+ if match { validTasks = append(validTasks, t) }
+ }
+
+ sort.Slice(validTasks, func(i, j int) bool {
+ t1, t2 := validTasks[i], validTasks[j]
+
+ s1Weight, s2Weight := 1, 1
+ if t1.IsPinned { s1Weight = 0 }
+ if t1.IsWaiting { s1Weight = 2 }
+ if t2.IsPinned { s2Weight = 0 }
+ if t2.IsWaiting { s2Weight = 2 }
+
+ if m.viewMode == "flat" {
+ if s1Weight != s2Weight { return s1Weight < s2Weight }
+ return t1.ID < t2.ID
+ }
+
+ p1Weight := getProjectWeight(t1, m.cfg.ProjectOrder)
+ p2Weight := getProjectWeight(t2, m.cfg.ProjectOrder)
+ if p1Weight != p2Weight { return p1Weight < p2Weight }
+
+ c1Weight := getContextWeight(t1, m.cfg.ContextOrder)
+ c2Weight := getContextWeight(t2, m.cfg.ContextOrder)
+ if c1Weight != c2Weight { return c1Weight < c2Weight }
+
+ if s1Weight != s2Weight { return s1Weight < s2Weight }
+
+ d1, d2 := 999999, 999999
+ if t1.HasDueDate { d1 = t1.DaysUntilDue() }
+ if t2.HasDueDate { d2 = t2.DaysUntilDue() }
+ if d1 != d2 { return d1 < d2 }
+
+ return t1.ID < t2.ID
+ })
+
+ m.tasks = validTasks
+
+ if len(m.tasks) == 0 {
+ m.cursor = 0
+ } else if m.cursor >= len(m.tasks) {
+ m.cursor = len(m.tasks) - 1
+ } else if m.cursor < 0 {
+ m.cursor = 0
+ }
+}
+
+func (m *model) saveUndoState() {
+ b, err := os.ReadFile(m.cfg.TodoFile)
+ if err == nil { m.undoBuffer = b }
+}
+
+func (m model) Init() tea.Cmd {
+ return textinput.Blink
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmd tea.Cmd
+
+ if wMsg, ok := msg.(tea.WindowSizeMsg); ok {
+ m.width = wMsg.Width
+ m.height = wMsg.Height
+ }
+
+ if m.cfg.BackendMode == "server" {
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ switch keyMsg.String() {
+ case "q", "ctrl+c", "esc":
+ return m, tea.Quit
+ }
+ }
+ return m, nil
+ }
+
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ if keyMsg.String() != "g" {
+ m.pendingG = false
+ }
+ }
+
+ switch msg := msg.(type) {
+ case editorFinishedMsg:
+ if msg.isBulk {
+ newBytes, err := os.ReadFile(m.bulkEditFile)
+ if err == nil {
+ newLines := strings.Split(strings.TrimSpace(string(newBytes)), "\n")
+ m.saveUndoState()
+ tasks, _ := ReadTasks(m.cfg.TodoFile)
+ f, _ := os.Create(m.cfg.TodoFile)
+
+ for _, t := range tasks {
+ if !m.selected[t.ID] && t.IsValid {
+ f.WriteString(t.RawLine + "\n")
+ }
+ }
+ for _, l := range newLines {
+ if strings.TrimSpace(l) != "" {
+ f.WriteString(l + "\n")
+ }
+ }
+ f.Close()
+ }
+ os.Remove(m.bulkEditFile)
+ m.bulkEditFile = ""
+ m.selected = make(map[int]bool)
+ m.status = "edit applied"
+ refreshTasks(&m)
+ return m, nil
+ }
+
+ if msg.err != nil {
+ m.status = fmt.Sprintf("editor error: %v", msg.err)
+ } else {
+ m.status = "reloaded from editor"
+ }
+ refreshTasks(&m)
+ return m, nil
+ }
+
+ if m.activeModal != "" && m.inputMode == "" {
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ if m.activeModal == "help" {
+ switch keyMsg.String() {
+ case "q", "esc", "?", "ctrl+c", "enter":
+ m.activeModal = ""
+ case "tab", "right", "l":
+ m.helpTab = (m.helpTab + 1) % 3
+ case "shift+tab", "left", "h":
+ m.helpTab--
+ if m.helpTab < 0 { m.helpTab = 2 }
+ }
+ return m, nil
+ }
+
+ if m.activeModal == "manager" {
+ switch keyMsg.String() {
+ case "q", "esc", "s", "enter", "ctrl+c":
+ m.activeModal = ""
+ case "j", "down":
+ if m.managerCursor < len(m.cfg.ProjectOrder)-1 { m.managerCursor++ }
+ case "k", "up":
+ if m.managerCursor > 0 { m.managerCursor-- }
+ case "J":
+ if m.managerCursor < len(m.cfg.ProjectOrder)-1 {
+ m.cfg.ProjectOrder[m.managerCursor], m.cfg.ProjectOrder[m.managerCursor+1] = m.cfg.ProjectOrder[m.managerCursor+1], m.cfg.ProjectOrder[m.managerCursor]
+ m.managerCursor++
+ saveConfig(m.cfg)
+ refreshTasks(&m)
+ }
+ case "K":
+ if m.managerCursor > 0 {
+ m.cfg.ProjectOrder[m.managerCursor], m.cfg.ProjectOrder[m.managerCursor-1] = m.cfg.ProjectOrder[m.managerCursor-1], m.cfg.ProjectOrder[m.managerCursor]
+ m.managerCursor--
+ saveConfig(m.cfg)
+ refreshTasks(&m)
+ }
+ }
+ return m, nil
+ }
+ }
+ }
+
+ if m.inputMode != "" {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch msg.Type {
+ case tea.KeyEnter:
+ val := m.textInput.Value()
+
+ if m.inputMode == "add" {
+ if strings.TrimSpace(val) != "" {
+ m.saveUndoState()
+ statusMsg, _ := AddTask(m.cfg.TodoFile, strings.Fields(val))
+ m.status = statusMsg
+ } else { m.status = "add cancelled" }
+ } else if m.inputMode == "filter" {
+ m.status = "filter locked. press esc to clear."
+ } else if strings.HasPrefix(m.inputMode, "bulk_") {
+ m.saveUndoState()
+ tasks, _ := ReadTasks(m.cfg.TodoFile)
+ f, _ := os.Create(m.cfg.TodoFile)
+
+ prefix := ""
+ mode := m.inputMode
+ if mode == "bulk_project" { prefix = "+" }
+ if mode == "bulk_context" { prefix = "@" }
+ if mode == "bulk_due" { prefix = "due:" }
+ if mode == "bulk_recur" { prefix = "rec:" }
+
+ finalVal := strings.TrimSpace(val)
+
+ if mode == "bulk_due" && finalVal != "" {
+ compiled, err := compileSmartDate(finalVal, time.Now())
+ if err == nil {
+ finalVal = compiled
+ }
+ }
+
+ for _, t := range tasks {
+ if m.selected[t.ID] {
+ var newWords []string
+ for _, w := range strings.Fields(t.RawLine) {
+ if mode == "bulk_due" {
+ if !strings.HasPrefix(strings.ToLower(w), "due:") { newWords = append(newWords, w) }
+ } else if mode == "bulk_recur" {
+ if !strings.HasPrefix(strings.ToLower(w), "rec:") { newWords = append(newWords, w) }
+ } else {
+ if !strings.HasPrefix(w, prefix) { newWords = append(newWords, w) }
+ }
+ }
+ if finalVal != "" {
+ newWords = append(newWords, prefix+finalVal)
+ }
+ f.WriteString(strings.Join(newWords, " ") + "\n")
+ } else if t.IsValid {
+ f.WriteString(t.RawLine + "\n")
+ }
+ }
+ f.Close()
+ m.selected = make(map[int]bool)
+ m.status = "tags updated"
+ }
+
+ m.inputMode = ""
+ m.textInput.Blur()
+ refreshTasks(&m)
+ return m, nil
+
+ case tea.KeyEsc, tea.KeyCtrlC:
+ if m.inputMode == "filter" { m.filterStr = "" }
+ m.inputMode = ""
+ m.textInput.Blur()
+ m.status = "cancelled"
+ refreshTasks(&m)
+ return m, nil
+ }
+ }
+
+ m.textInput, cmd = m.textInput.Update(msg)
+ if m.inputMode == "filter" {
+ m.filterStr = m.textInput.Value()
+ refreshTasks(&m)
+ }
+ return m, cmd
+ }
+
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "ctrl+c", "q":
+ return m, tea.Quit
+ case " ":
+ if m.viewingHistory { return m, nil }
+ if len(m.tasks) > 0 {
+ id := m.tasks[m.cursor].ID
+ if m.selected[id] {
+ delete(m.selected, id)
+ } else {
+ m.selected[id] = true
+ }
+ if m.cursor < len(m.tasks)-1 { m.cursor++ }
+ }
+ case "P", "C", "D", "R":
+ if m.viewingHistory { return m, nil }
+
+ if len(m.selected) == 0 && len(m.tasks) > 0 {
+ m.selected[m.tasks[m.cursor].ID] = true
+ }
+
+ if len(m.selected) > 0 {
+ if msg.String() == "P" {
+ m.inputMode = "bulk_project"
+ m.textInput.Prompt = "set project (+): "
+ m.textInput.Placeholder = "leave blank to clear projects"
+ } else if msg.String() == "C" {
+ m.inputMode = "bulk_context"
+ m.textInput.Prompt = "set context (@): "
+ m.textInput.Placeholder = "leave blank to clear contexts"
+ } else if msg.String() == "D" {
+ m.inputMode = "bulk_due"
+ m.textInput.Prompt = "set due date (due:): "
+ m.textInput.Placeholder = "YYYY-MM-DD, today, +1w, blank to clear"
+ } else if msg.String() == "R" {
+ m.inputMode = "bulk_recur"
+ m.textInput.Prompt = "set recurrence (rec:): "
+ m.textInput.Placeholder = "1w, +1m, blank to clear"
+ }
+ m.textInput.SetValue("")
+ m.textInput.Focus()
+ return m, textinput.Blink
+ }
+ case "up", "k":
+ if m.cursor > 0 { m.cursor-- }
+ case "down", "j":
+ if m.cursor < len(m.tasks)-1 { m.cursor++ }
+ case "g":
+ if m.pendingG {
+ m.cursor = 0
+ m.pendingG = false
+ } else { m.pendingG = true }
+ case "G":
+ m.cursor = len(m.tasks) - 1
+ case "?":
+ m.activeModal = "help"
+ m.helpTab = 0
+ case "s":
+ m.activeModal = "manager"
+ m.managerCursor = 0
+
+ allTasks, _ := ReadTasks(m.cfg.TodoFile)
+ existing := make(map[string]bool)
+ for _, p := range m.cfg.ProjectOrder {
+ existing[strings.ToLower(p)] = true
+ }
+
+ addedNew := false
+ for _, t := range allTasks {
+ if !t.IsValid { continue }
+ for _, p := range t.Projects {
+ pLower := strings.ToLower(p)
+ if !existing[pLower] {
+ m.cfg.ProjectOrder = append(m.cfg.ProjectOrder, pLower)
+ existing[pLower] = true
+ addedNew = true
+ }
+ }
+ }
+ if addedNew { saveConfig(m.cfg) }
+
+ case "E":
+ if m.viewingHistory {
+ m.status = "editor disabled in history view"
+ return m, nil
+ }
+ editor := os.Getenv("EDITOR")
+ if editor == "" { editor = "vi" }
+ c := exec.Command(editor, m.cfg.TodoFile)
+ return m, tea.ExecProcess(c, func(err error) tea.Msg {
+ return editorFinishedMsg{err: err, isBulk: false}
+ })
+ case "e":
+ if m.viewingHistory { return m, nil }
+ if len(m.tasks) > 0 {
+ tempFile, _ := os.CreateTemp("", "dew-*.txt")
+ m.bulkEditFile = tempFile.Name()
+
+ if len(m.selected) == 0 {
+ m.selected[m.tasks[m.cursor].ID] = true
+ }
+
+ for _, t := range m.tasks {
+ if m.selected[t.ID] {
+ tempFile.WriteString(t.RawLine + "\n")
+ }
+ }
+ tempFile.Close()
+ editor := os.Getenv("EDITOR")
+ if editor == "" { editor = "vi" }
+ c := exec.Command(editor, m.bulkEditFile)
+ return m, tea.ExecProcess(c, func(err error) tea.Msg {
+ return editorFinishedMsg{err: err, isBulk: true}
+ })
+ }
+ case "c":
+ m.cfg.CenterTUI = !m.cfg.CenterTUI
+ saveConfig(m.cfg)
+ if m.cfg.CenterTUI {
+ m.status = "tui centered"
+ } else {
+ m.status = "tui aligned left"
+ }
+ case "h":
+ m.viewingHistory = !m.viewingHistory
+ if m.viewingHistory {
+ m.status = "viewing history (read-only)"
+ } else { m.status = "viewing active tasks" }
+ m.cursor = 0
+ m.selected = make(map[int]bool)
+ refreshTasks(&m)
+ case "v":
+ if m.viewMode == "grouped" { m.viewMode = "flat" } else { m.viewMode = "grouped" }
+ m.status = "switched to " + m.viewMode + " view"
+ refreshTasks(&m)
+ case "tab":
+ if m.filterStr != "" {
+ m.filterStr = ""
+ m.status = "filter cleared"
+ refreshTasks(&m)
+ } else if len(m.tasks) > 0 {
+ t := m.tasks[m.cursor]
+ if len(t.Projects) > 0 {
+ m.filterStr = "+" + strings.ToLower(t.Projects[0])
+ m.status = "pivoted to " + m.filterStr
+ refreshTasks(&m)
+ } else if len(t.Contexts) > 0 {
+ m.filterStr = "@" + strings.ToLower(t.Contexts[0])
+ m.status = "pivoted to " + m.filterStr
+ refreshTasks(&m)
+ } else { m.status = "no tags to pivot on" }
+ }
+ case "esc":
+ if m.filterStr != "" {
+ m.filterStr = ""
+ m.status = "filter cleared"
+ refreshTasks(&m)
+ } else if len(m.selected) > 0 {
+ m.selected = make(map[int]bool)
+ m.status = "selection cleared"
+ }
+ case "u":
+ if m.viewingHistory {
+ m.status = "actions are disabled in history view"
+ return m, nil
+ }
+ if len(m.undoBuffer) > 0 {
+ err := os.WriteFile(m.cfg.TodoFile, m.undoBuffer, 0644)
+ if err == nil {
+ m.status = "undid last action!"
+ m.selected = make(map[int]bool)
+ refreshTasks(&m)
+ }
+ } else { m.status = "nothing to undo." }
+ case "a":
+ if m.viewingHistory {
+ m.status = "actions are disabled in history view"
+ return m, nil
+ }
+ m.inputMode = "add"
+ m.textInput.Prompt = "add: "
+ m.textInput.Placeholder = "buy catnip +metsa @chores due:today"
+ m.textInput.SetValue("")
+ m.textInput.Focus()
+ m.status = ""
+ return m, textinput.Blink
+ case "/":
+ m.inputMode = "filter"
+ m.textInput.Prompt = "filter: "
+ m.textInput.Placeholder = "search tags or descriptions..."
+ m.textInput.SetValue(m.filterStr)
+ m.textInput.CursorEnd()
+ m.textInput.Focus()
+ m.status = ""
+ return m, textinput.Blink
+ case "d":
+ if m.viewingHistory { return m, nil }
+ if len(m.selected) > 0 {
+ m.saveUndoState()
+ for id := range m.selected {
+ CompleteTask(m.cfg, []string{strconv.Itoa(id)})
+ }
+ count := len(m.selected)
+ m.selected = make(map[int]bool)
+ m.status = fmt.Sprintf("marked %d tasks as done", count)
+ refreshTasks(&m)
+ } else if len(m.tasks) > 0 {
+ m.saveUndoState()
+ idStr := strconv.Itoa(m.tasks[m.cursor].ID)
+ statusMsg, _ := CompleteTask(m.cfg, []string{idStr})
+ m.status = statusMsg
+ refreshTasks(&m)
+ }
+ case "p":
+ if m.viewingHistory { return m, nil }
+ if len(m.selected) > 0 {
+ m.saveUndoState()
+ for id := range m.selected {
+ ToggleModifier(m.cfg, []string{strconv.Itoa(id)}, "*")
+ }
+ count := len(m.selected)
+ m.selected = make(map[int]bool)
+ m.status = fmt.Sprintf("pinned %d tasks", count)
+ refreshTasks(&m)
+ } else if len(m.tasks) > 0 {
+ m.saveUndoState()
+ idStr := strconv.Itoa(m.tasks[m.cursor].ID)
+ statusMsg, _ := ToggleModifier(m.cfg, []string{idStr}, "*")
+ m.status = statusMsg
+ refreshTasks(&m)
+ }
+ case "w":
+ if m.viewingHistory { return m, nil }
+ if len(m.selected) > 0 {
+ m.saveUndoState()
+ for id := range m.selected {
+ ToggleModifier(m.cfg, []string{strconv.Itoa(id)}, "~")
+ }
+ count := len(m.selected)
+ m.selected = make(map[int]bool)
+ m.status = fmt.Sprintf("waited %d tasks", count)
+ refreshTasks(&m)
+ } else if len(m.tasks) > 0 {
+ m.saveUndoState()
+ idStr := strconv.Itoa(m.tasks[m.cursor].ID)
+ statusMsg, _ := ToggleModifier(m.cfg, []string{idStr}, "~")
+ m.status = statusMsg
+ refreshTasks(&m)
+ }
+ case "r", "x":
+ if m.viewingHistory { return m, nil }
+ if len(m.selected) > 0 {
+ m.saveUndoState()
+ for id := range m.selected {
+ DeleteTask(m.cfg, []string{strconv.Itoa(id)})
+ }
+ count := len(m.selected)
+ m.selected = make(map[int]bool)
+ m.status = fmt.Sprintf("deleted %d tasks", count)
+ refreshTasks(&m)
+ } else if len(m.tasks) > 0 {
+ m.saveUndoState()
+ idStr := strconv.Itoa(m.tasks[m.cursor].ID)
+ statusMsg, _ := DeleteTask(m.cfg, []string{idStr})
+ m.status = statusMsg
+ refreshTasks(&m)
+ }
+ }
+ }
+ return m, nil
+}
+
+func (m model) View() string {
+ styleCursor := lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorAccent)).Bold(true)
+ styleStatus := lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorMuted)).Italic(true)
+ styleHelp := lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorMuted))
+ styleKey := lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorAccent))
+ styleDesc := lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorMuted))
+ styleTitle := lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true)
+ styleBox := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color(m.cfg.ColorMuted)).
+ Padding(1, 2)
+
+ if m.cfg.BackendMode == "server" {
+ cat := ` /\_/\
+ ( ^.^ )
+ > ^ <`
+ msg := lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorAccent)).Render(cat) +
+ "\n\n " + styleTitle.Render("server mode coming soon!") +
+ "\n " + styleDesc.Render("dew api integration is currently under construction.") +
+ "\n " + styleDesc.Render("please set backend_mode to 'local' in config.json.") +
+ "\n\n " + styleDesc.Render("[q] quit")
+
+ if !m.cfg.CenterTUI { return "\n" + msg + "\n" }
+ return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, lipgloss.NewStyle().Width(lipgloss.Width(msg)).Align(lipgloss.Left).Render(msg))
+ }
+
+ if m.activeModal == "help" {
+ var b strings.Builder
+ b.WriteString(styleTitle.Render("KEYMAP"))
+ b.WriteString("\n\n")
+
+ tabs := []string{" NAV ", " EDIT ", " META "}
+ activeTabStyle := lipgloss.NewStyle().Background(lipgloss.Color(m.cfg.ColorAccent)).Foreground(lipgloss.Color("0")).Bold(true)
+
+ for i, t := range tabs {
+ if i == m.helpTab {
+ b.WriteString(activeTabStyle.Render(t) + " ")
+ } else {
+ b.WriteString(styleDesc.Render(t) + " ")
+ }
+ }
+ b.WriteString("\n\n")
+
+ var helpItems []struct{ k, d string }
+ if m.helpTab == 0 {
+ helpItems = []struct{ k, d string }{
+ {"j/k, down/up", "move cursor"},
+ {"gg / G", "jump to top / bottom"},
+ {"space", "toggle visual selection"},
+ {"tab", "pivot by tag (or clear)"},
+ {"/", "live filter"},
+ }
+ } else if m.helpTab == 1 {
+ helpItems = []struct{ k, d string }{
+ {"a", "add new task"},
+ {"e / E", "edit task(s) in $EDITOR"},
+ {"P/C/D/R", "set proj/ctx/due/recur"},
+ {"d", "mark done (archives)"},
+ {"p / w", "toggle pin(*) / wait(~)"},
+ {"x", "delete task"},
+ {"u", "undo last action"},
+ }
+ } else {
+ helpItems = []struct{ k, d string }{
+ {"s", "sort project order"},
+ {"h", "toggle history (done.txt)"},
+ {"v", "toggle flat/grouped view"},
+ {"c", "toggle tui centering"},
+ {"?", "close help"},
+ {"q / esc", "quit / close popups"},
+ }
+ }
+
+ for i := 0; i < 7; i++ {
+ if i < len(helpItems) {
+ item := helpItems[i]
+ k := styleKey.Render(fmt.Sprintf("%14s", item.k))
+ sep := styleDesc.Render(" : ")
+ d := styleDesc.Render(item.d)
+ b.WriteString(k + sep + d + "\n")
+ } else {
+ b.WriteString("\n")
+ }
+ }
+
+ b.WriteString("\n" + styleDesc.Render("[tab] switch • [esc] close"))
+
+ styledBox := styleBox.Render(b.String())
+ if !m.cfg.CenterTUI { return "\n" + styledBox + "\n" }
+ return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, styledBox)
+ }
+
+ if m.activeModal == "manager" {
+ var b strings.Builder
+ b.WriteString(styleTitle.Render("PROJECT ORDER"))
+ b.WriteString("\n\n")
+
+ if len(m.cfg.ProjectOrder) == 0 {
+ b.WriteString(styleDesc.Render(" no projects found.\n"))
+ } else {
+ for i, item := range m.cfg.ProjectOrder {
+ cursorLine := " "
+ if i == m.managerCursor { cursorLine = styleCursor.Render(fmt.Sprintf("%-2s", m.cfg.CursorIcon)) }
+
+ itemName := styleDesc.Render(item)
+ if i == m.managerCursor { itemName = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Render(item) }
+
+ b.WriteString(fmt.Sprintf("%s%s\n", cursorLine, itemName))
+ }
+ }
+
+ b.WriteString("\n" + styleDesc.Render("[J/K] shift up/down • [esc] close"))
+
+ styledBox := styleBox.Render(b.String())
+ if !m.cfg.CenterTUI { return "\n" + styledBox + "\n" }
+ return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, styledBox)
+ }
+
+ if len(m.tasks) == 0 && m.inputMode != "filter" {
+ cat := ` /\_/\
+ ( o.o )
+ > ^ <`
+ emptyView := lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorAccent)).Render(cat) +
+ "\n\n " + styleTitle.Render("all caught up!") +
+ "\n " + styleDesc.Render("press 'a' to add a task, or 'q' to exit.") + "\n"
+
+ if m.viewingHistory {
+ cat = ` /\_/\
+ ( -.- )
+ > ^ <`
+ emptyView = lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorMuted)).Render(cat) +
+ "\n\n " + styleTitle.Render("no history yet.") +
+ "\n " + styleDesc.Render("press 'h' to return to active tasks.") + "\n"
+ }
+
+ if !m.cfg.CenterTUI { return "\n" + emptyView }
+ return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, lipgloss.NewStyle().Width(lipgloss.Width(emptyView)).Align(lipgloss.Left).Render(emptyView))
+ }
+
+ maxVisible := m.height - 12
+ if maxVisible < 5 { maxVisible = 5 }
+
+ if m.cursor < m.viewportStart {
+ m.viewportStart = m.cursor
+ } else if m.cursor >= m.viewportStart+maxVisible {
+ m.viewportStart = m.cursor - maxVisible + 1
+ }
+
+ if m.viewportStart > len(m.tasks)-maxVisible && len(m.tasks) > maxVisible { m.viewportStart = len(m.tasks) - maxVisible }
+ if m.viewportStart < 0 { m.viewportStart = 0 }
+
+ endIdx := m.viewportStart + maxVisible
+ if endIdx > len(m.tasks) { endIdx = len(m.tasks) }
+
+ visibleTasks := m.tasks[m.viewportStart:endIdx]
+
+ mDesc, mProj, mCtx, mDue := 4, 7, 7, 3
+ if m.viewingHistory { mDue = 9 }
+
+ for _, t := range m.tasks {
+ desc := t.Description
+ if m.viewingHistory {
+ parts := strings.Fields(t.RawLine)
+ if len(parts) >= 2 && parts[0] == "x" {
+ if len(parts[1]) == 10 {
+ desc = strings.Replace(desc, "x "+parts[1], "", 1)
+ desc = strings.TrimSpace(desc)
+ }
+ }
+ }
+ if len(desc) > mDesc { mDesc = len(desc) }
+
+ pStr := strings.Join(t.Projects, " ")
+ if len(pStr) > mProj { mProj = len(pStr) }
+ cStr := strings.Join(t.Contexts, " ")
+ if len(cStr) > mCtx { mCtx = len(cStr) }
+
+ if m.viewingHistory {
+ if 10 > mDue { mDue = 10 }
+ } else {
+ dueLen := 5
+ if t.HasDueDate {
+ d := t.DaysUntilDue()
+ if d < 0 { dueLen = len(fmt.Sprintf("(%dd overdue)", -d)) } else if d == 0 { dueLen = 7 } else { dueLen = len(fmt.Sprintf("(%dd)", d)) }
+ }
+ if dueLen > mDue { mDue = dueLen }
+ }
+ }
+
+ reservedSpace := mProj + mCtx + mDue + 30
+ maxDesc := m.width - reservedSpace
+ if maxDesc < 15 { maxDesc = 15 }
+ if mDesc > maxDesc { mDesc = maxDesc }
+
+ var b strings.Builder
+
+ descStr := "TASK"
+ if m.viewingHistory { descStr = "TASK (HISTORY)" }
+
+ descHead := fmt.Sprintf("%-*s", mDesc, descStr)
+ projHead := fmt.Sprintf("%-*s", mProj, "PROJECT")
+ ctxHead := fmt.Sprintf("%-*s", mCtx, "CONTEXT")
+
+ dueLabel := "DUE"
+ if m.viewingHistory { dueLabel = "COMPLETED" }
+ dueHead := fmt.Sprintf("%-*s", mDue, dueLabel)
+
+ headerLine := fmt.Sprintf("ID %s %s %s %s RECUR", descHead, projHead, ctxHead, dueHead)
+ b.WriteString(" " + lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorMuted)).Underline(true).Render(headerLine) + "\n")
+
+ currProj := ""
+ if m.viewportStart > 0 && m.viewMode != "flat" {
+ prev := m.tasks[m.viewportStart-1]
+ if len(prev.Projects) > 0 {
+ currProj = strings.ToLower(prev.Projects[0])
+ } else {
+ currProj = "untagged"
+ }
+ }
+
+ for i, t := range visibleTasks {
+ actualIdx := m.viewportStart + i
+
+ if m.viewMode != "flat" {
+ pPrimary := "untagged"
+ if len(t.Projects) > 0 { pPrimary = strings.ToLower(t.Projects[0]) }
+
+ if pPrimary != currProj || actualIdx == m.viewportStart {
+ pLine := fmt.Sprintf("%s %s", pPrimary, m.cfg.ProjectIcon)
+ b.WriteString("\n " + lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorMuted)).Render(pLine) + "\n")
+ currProj = pPrimary
+ }
+ }
+
+ styleItemDesc := lipgloss.NewStyle().Foreground(lipgloss.Color("15"))
+ styleItemProj := lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorProject))
+ styleItemCtx := lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorContext))
+ styleItemDue := lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorMuted))
+
+ if t.IsPinned { styleItemDesc = styleItemDesc.Bold(true) }
+
+ descText := t.Description
+ dueText := "(n/a)"
+
+ if m.viewingHistory {
+ parts := strings.Fields(t.RawLine)
+ if len(parts) >= 2 && parts[0] == "x" {
+ if len(parts[1]) == 10 {
+ dueText = parts[1]
+ descText = strings.Replace(descText, "x "+parts[1], "", 1)
+ descText = strings.TrimSpace(descText)
+ }
+ }
+ styleItemDesc = lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorMuted))
+ styleItemDue = lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorMuted))
+ } else {
+ if t.IsWaiting {
+ styleItemDesc = lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorMuted))
+ styleItemProj, styleItemCtx, styleItemDue = styleItemDesc, styleItemDesc, styleItemDesc
+ } else if t.HasDueDate {
+ d := t.DaysUntilDue()
+ if d < 0 {
+ styleItemDue = lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorOverdue))
+ dueText = fmt.Sprintf("(%dd overdue)", -d)
+ } else if d == 0 {
+ styleItemDue = lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorToday)).Bold(true)
+ dueText = "(today)"
+ } else if d <= 3 {
+ styleItemDue = lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorSoon)).Bold(true)
+ dueText = fmt.Sprintf("(%dd)", d)
+ } else if d <= 14 {
+ styleItemDue = lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorFar))
+ dueText = fmt.Sprintf("(%dd)", d)
+ } else {
+ styleItemDue = lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorVeryFar))
+ dueText = fmt.Sprintf("(%dd)", d)
+ }
+ }
+ }
+
+ descRunes := []rune(descText)
+ if len(descRunes) > mDesc {
+ descText = string(descRunes[:mDesc-1]) + "…"
+ }
+
+ descFmt := styleItemDesc.Render(fmt.Sprintf("%-*s", mDesc, descText))
+ projFmt := styleItemProj.Render(fmt.Sprintf("%-*s", mProj, strings.Join(t.Projects, " ")))
+ ctxFmt := styleItemCtx.Render(fmt.Sprintf("%-*s", mCtx, strings.Join(t.Contexts, " ")))
+ dueFmt := styleItemDue.Render(fmt.Sprintf("%-*s", mDue, dueText))
+ recFmt := ""
+ if t.Recurrence != "" { recFmt = lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorMuted)).Render(t.Recurrence) }
+ strID := lipgloss.NewStyle().Foreground(lipgloss.Color(m.cfg.ColorMuted)).Render(fmt.Sprintf("%02d", t.ID))
+
+ if m.selected[t.ID] {
+ selStyle := lipgloss.NewStyle().Background(lipgloss.Color(m.cfg.ColorAccent)).Foreground(lipgloss.Color("0"))
+ descFmt = selStyle.Render(fmt.Sprintf("%-*s", mDesc, descText))
+ projFmt = selStyle.Render(fmt.Sprintf("%-*s", mProj, strings.Join(t.Projects, " ")))
+ ctxFmt = selStyle.Render(fmt.Sprintf("%-*s", mCtx, strings.Join(t.Contexts, " ")))
+ dueFmt = selStyle.Render(fmt.Sprintf("%-*s", mDue, dueText))
+ if t.Recurrence != "" { recFmt = selStyle.Render(t.Recurrence) }
+ strID = selStyle.Render(fmt.Sprintf("%02d", t.ID))
+ }
+
+ cursorLine := " "
+ if actualIdx == m.cursor {
+ cursorLine = styleCursor.Render(fmt.Sprintf("%-2s", m.cfg.CursorIcon))
+ }
+
+ b.WriteString(fmt.Sprintf(" %s%s %s %s %s %s %s\n", cursorLine, strID, descFmt, projFmt, ctxFmt, dueFmt, recFmt))
+ }
+
+ b.WriteString("\n\n")
+
+ tableWidth := mDesc + mProj + mCtx + mDue + 21
+
+ if m.inputMode != "" {
+ b.WriteString(" " + m.textInput.View())
+ } else {
+ var footerLeft string
+ if len(m.selected) > 0 {
+ footerLeft = styleHelp.Render("[space] toggle • [d] done • [x] del • [e] edit • [P/C/D/R] tags • [esc] clear")
+ } else if !m.cfg.HideHints {
+ footerLeft = styleHelp.Render("[?] help • [a] add • [e] edit • [d] done • [u] undo")
+ }
+
+ posStr := styleStatus.Render(fmt.Sprintf("[%d/%d]", m.cursor+1, len(m.tasks)))
+
+ gap := tableWidth - lipgloss.Width(footerLeft) - lipgloss.Width(posStr)
+ if gap < 1 { gap = 1 }
+
+ b.WriteString(" " + footerLeft + strings.Repeat(" ", gap) + posStr)
+
+ if m.filterStr != "" {
+ b.WriteString("\n ")
+ b.WriteString(styleStatus.Render(fmt.Sprintf("active filter: '%s' (press esc to clear)", m.filterStr)))
+ } else if m.status != "" {
+ cleanStatus := strings.ReplaceAll(m.status, "\n", " • ")
+ b.WriteString("\n ")
+ b.WriteString(styleStatus.Render(cleanStatus))
+ }
+ }
+ b.WriteString("\n")
+
+ content := b.String()
+
+ if !m.cfg.CenterTUI {
+ return content
+ }
+
+ contentWidth := lipgloss.Width(content)
+
+ if contentWidth < 90 {
+ contentWidth = 90
+ }
+
+ return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, lipgloss.NewStyle().Width(contentWidth).Align(lipgloss.Left).Render(content))
+}