From 6b88066b20f709f5e1a1e6487bb69ff430e41193 Mon Sep 17 00:00:00 2001 From: Rasmus Wejlgaard Date: Fri, 7 Nov 2025 21:37:34 +0000 Subject: [PATCH] pre-refactor --- go.mod | 29 ++ go.sum | 49 ++ main.go | 49 ++ org.go | 443 ++++++++++++++++++ ui.go | 1357 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1927 insertions(+) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 org.go create mode 100644 ui.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bf2d31b --- /dev/null +++ b/go.mod @@ -0,0 +1,29 @@ +module github.com/rwejlgaard/org + +go 1.25.3 + +require ( + github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v0.21.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.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.16 // 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.36.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1cc269c --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +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 v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +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/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/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/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +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/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/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/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 new file mode 100644 index 0000000..5bb0033 --- /dev/null +++ b/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" +) + +func main() { + var filePath string + flag.StringVar(&filePath, "file", "", "Path to org file (default: ./todo.org)") + flag.StringVar(&filePath, "f", "", "Path to org file (shorthand)") + flag.Parse() + + // Check for positional argument first + if filePath == "" && len(flag.Args()) > 0 { + filePath = flag.Args()[0] + } + + // Default to ./todo.org if no file specified + if filePath == "" { + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err) + os.Exit(1) + } + filePath = filepath.Join(cwd, "todo.org") + } + + // Parse the org file + orgFile, err := ParseOrgFile(filePath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing org file: %v\n", err) + os.Exit(1) + } + + // Run the UI + if err := runUI(orgFile); err != nil { + fmt.Fprintf(os.Stderr, "Error running UI: %v\n", err) + os.Exit(1) + } + + // Save on exit + if err := orgFile.Save(); err != nil { + fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err) + os.Exit(1) + } +} diff --git a/org.go b/org.go new file mode 100644 index 0000000..c1cf54a --- /dev/null +++ b/org.go @@ -0,0 +1,443 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "regexp" + "strings" + "time" +) + +// TodoState represents the state of a todo item +type TodoState string + +const ( + StateTODO TodoState = "TODO" + StatePROG TodoState = "PROG" + StateBLOCK TodoState = "BLOCK" + StateDONE TodoState = "DONE" + StateNone TodoState = "" +) + +// ClockEntry represents a single clock entry +type ClockEntry struct { + Start time.Time + End *time.Time // nil if currently clocked in +} + +// Item represents a single org-mode item (heading) +type Item struct { + Level int // Heading level (number of *) + State TodoState // TODO, PROG, BLOCK, DONE, or empty + Title string // The main title text + Scheduled *time.Time + Deadline *time.Time + Notes []string // Notes/content under the heading + Children []*Item // Sub-items + Folded bool // Whether the item is folded (hides notes and children) + ClockEntries []ClockEntry // Clock in/out entries +} + +// OrgFile represents a parsed org-mode file +type OrgFile struct { + Path string + Items []*Item +} + +// Parser patterns +var ( + headingPattern = regexp.MustCompile(`^(\*+)\s+(?:(TODO|PROG|BLOCK|DONE)\s+)?(.+)$`) + scheduledPattern = regexp.MustCompile(`SCHEDULED:\s*<([^>]+)>`) + deadlinePattern = regexp.MustCompile(`DEADLINE:\s*<([^>]+)>`) + clockPattern = regexp.MustCompile(`CLOCK:\s*\[([^\]]+)\](?:--\[([^\]]+)\])?`) + drawerStart = regexp.MustCompile(`^\s*:LOGBOOK:\s*$`) + drawerEnd = regexp.MustCompile(`^\s*:END:\s*$`) + codeBlockStart = regexp.MustCompile(`^\s*#\+BEGIN_SRC`) + codeBlockEnd = regexp.MustCompile(`^\s*#\+END_SRC`) +) + +// ParseOrgFile reads and parses an org-mode file +func ParseOrgFile(path string) (*OrgFile, error) { + file, err := os.Open(path) + if err != nil { + // If file doesn't exist, return empty org file + if os.IsNotExist(err) { + return &OrgFile{Path: path, Items: []*Item{}}, nil + } + return nil, err + } + defer file.Close() + + orgFile := &OrgFile{Path: path, Items: []*Item{}} + scanner := bufio.NewScanner(file) + + var currentItem *Item + var itemStack []*Item // Stack to track parent items + var inCodeBlock bool + var inLogbookDrawer bool + + for scanner.Scan() { + line := scanner.Text() + + // Check for drawer boundaries + if drawerStart.MatchString(line) { + inLogbookDrawer = true + if currentItem != nil { + currentItem.Notes = append(currentItem.Notes, line) + } + continue + } + if drawerEnd.MatchString(line) && inLogbookDrawer { + inLogbookDrawer = false + if currentItem != nil { + currentItem.Notes = append(currentItem.Notes, line) + } + continue + } + + // Check for code block boundaries + if codeBlockStart.MatchString(line) { + inCodeBlock = true + if currentItem != nil { + currentItem.Notes = append(currentItem.Notes, line) + } + continue + } + if codeBlockEnd.MatchString(line) { + inCodeBlock = false + if currentItem != nil { + currentItem.Notes = append(currentItem.Notes, line) + } + continue + } + + // If in code block, add line to notes + if inCodeBlock { + if currentItem != nil { + currentItem.Notes = append(currentItem.Notes, line) + } + continue + } + + // Try to match heading + if matches := headingPattern.FindStringSubmatch(line); matches != nil { + level := len(matches[1]) + state := TodoState(matches[2]) + title := matches[3] + + item := &Item{ + Level: level, + State: state, + Title: title, + Notes: []string{}, + Children: []*Item{}, + } + + // Find parent based on level + for len(itemStack) > 0 && itemStack[len(itemStack)-1].Level >= level { + itemStack = itemStack[:len(itemStack)-1] + } + + if len(itemStack) == 0 { + // Top-level item + orgFile.Items = append(orgFile.Items, item) + } else { + // Child item + parent := itemStack[len(itemStack)-1] + parent.Children = append(parent.Children, item) + } + + itemStack = append(itemStack, item) + currentItem = item + } else if currentItem != nil { + // This is content under the current item + trimmed := strings.TrimSpace(line) + + // Check for SCHEDULED + if matches := scheduledPattern.FindStringSubmatch(line); matches != nil { + if t, err := parseOrgDate(matches[1]); err == nil { + currentItem.Scheduled = &t + } + } + + // Check for DEADLINE + if matches := deadlinePattern.FindStringSubmatch(line); matches != nil { + if t, err := parseOrgDate(matches[1]); err == nil { + currentItem.Deadline = &t + } + } + + // Check for CLOCK (can be inside or outside drawer) + if matches := clockPattern.FindStringSubmatch(line); matches != nil { + if startTime, err := parseClockTimestamp(matches[1]); err == nil { + entry := ClockEntry{Start: startTime} + if len(matches) > 2 && matches[2] != "" { + if endTime, err := parseClockTimestamp(matches[2]); err == nil { + entry.End = &endTime + } + } + currentItem.ClockEntries = append(currentItem.ClockEntries, entry) + } + } + + // Add all lines as notes (including scheduling lines and drawer content for proper serialization) + if trimmed != "" || len(currentItem.Notes) > 0 { + currentItem.Notes = append(currentItem.Notes, line) + } + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return orgFile, nil +} + +// parseOrgDate parses org-mode date format +func parseOrgDate(dateStr string) (time.Time, error) { + // Org-mode format: 2024-01-15 Mon 10:00 + formats := []string{ + "2006-01-02 Mon 15:04", + "2006-01-02 Mon", + "2006-01-02", + } + + for _, format := range formats { + if t, err := time.Parse(format, dateStr); err == nil { + return t, nil + } + } + + return time.Time{}, fmt.Errorf("unable to parse date: %s", dateStr) +} + +// parseClockTimestamp parses org-mode clock timestamp format +func parseClockTimestamp(timestampStr string) (time.Time, error) { + // Org-mode clock format: [2024-01-15 Mon 10:00] + formats := []string{ + "2006-01-02 Mon 15:04", + "2006-01-02 Mon 15:04:05", + } + + for _, format := range formats { + if t, err := time.Parse(format, timestampStr); err == nil { + return t, nil + } + } + + return time.Time{}, fmt.Errorf("unable to parse clock timestamp: %s", timestampStr) +} + +// formatClockTimestamp formats a time as org-mode clock timestamp +func formatClockTimestamp(t time.Time) string { + return t.Format("2006-01-02 Mon 15:04") +} + +// formatOrgDate formats a time as org-mode date +func formatOrgDate(t time.Time) string { + return t.Format("2006-01-02 Mon") +} + +// Save writes the org file back to disk +func (of *OrgFile) Save() error { + file, err := os.Create(of.Path) + if err != nil { + return err + } + defer file.Close() + + writer := bufio.NewWriter(file) + defer writer.Flush() + + for _, item := range of.Items { + if err := writeItem(writer, item); err != nil { + return err + } + } + + return nil +} + +// writeItem recursively writes an item and its children +func writeItem(writer *bufio.Writer, item *Item) error { + // Write heading + stars := strings.Repeat("*", item.Level) + line := stars + if item.State != StateNone { + line += " " + string(item.State) + } + line += " " + item.Title + "\n" + + if _, err := writer.WriteString(line); err != nil { + return err + } + + // Write scheduling info if not already in notes + hasScheduled := false + hasDeadline := false + hasLogbook := false + for _, note := range item.Notes { + if strings.Contains(note, "SCHEDULED:") { + hasScheduled = true + } + if strings.Contains(note, "DEADLINE:") { + hasDeadline = true + } + if strings.Contains(note, ":LOGBOOK:") { + hasLogbook = true + } + } + + if item.Scheduled != nil && !hasScheduled { + scheduledLine := fmt.Sprintf("SCHEDULED: <%s>\n", formatOrgDate(*item.Scheduled)) + if _, err := writer.WriteString(scheduledLine); err != nil { + return err + } + } + + if item.Deadline != nil && !hasDeadline { + deadlineLine := fmt.Sprintf("DEADLINE: <%s>\n", formatOrgDate(*item.Deadline)) + if _, err := writer.WriteString(deadlineLine); err != nil { + return err + } + } + + // Write clock entries in :LOGBOOK: drawer if not already in notes + if len(item.ClockEntries) > 0 && !hasLogbook { + if _, err := writer.WriteString(":LOGBOOK:\n"); err != nil { + return err + } + for _, entry := range item.ClockEntries { + clockLine := fmt.Sprintf("CLOCK: [%s]", formatClockTimestamp(entry.Start)) + if entry.End != nil { + clockLine += fmt.Sprintf("--[%s]", formatClockTimestamp(*entry.End)) + } + clockLine += "\n" + if _, err := writer.WriteString(clockLine); err != nil { + return err + } + } + if _, err := writer.WriteString(":END:\n"); err != nil { + return err + } + } + + // Write notes + for _, note := range item.Notes { + if _, err := writer.WriteString(note + "\n"); err != nil { + return err + } + } + + // Write children + for _, child := range item.Children { + if err := writeItem(writer, child); err != nil { + return err + } + } + + return nil +} + +// GetAllItems returns a flattened list of all items (for UI display) +// Respects folding - folded items don't show their children +func (of *OrgFile) GetAllItems() []*Item { + var items []*Item + var flatten func([]*Item) + flatten = func(list []*Item) { + for _, item := range list { + items = append(items, item) + if !item.Folded { + flatten(item.Children) + } + } + } + flatten(of.Items) + return items +} + +// ToggleFold toggles the folded state of an item +func (item *Item) ToggleFold() { + item.Folded = !item.Folded +} + +// CycleState cycles through todo states +func (item *Item) CycleState() { + switch item.State { + case StateNone: + item.State = StateTODO + case StateTODO: + item.State = StatePROG + case StatePROG: + item.State = StateBLOCK + case StateBLOCK: + item.State = StateDONE + case StateDONE: + item.State = StateNone + } +} + +// ClockIn starts a new clock entry +func (item *Item) ClockIn() bool { + // Check if already clocked in + if item.IsClockedIn() { + return false + } + + entry := ClockEntry{ + Start: time.Now(), + End: nil, + } + item.ClockEntries = append(item.ClockEntries, entry) + return true +} + +// ClockOut ends the current clock entry +func (item *Item) ClockOut() bool { + // Find the most recent open clock entry + for i := len(item.ClockEntries) - 1; i >= 0; i-- { + if item.ClockEntries[i].End == nil { + now := time.Now() + item.ClockEntries[i].End = &now + return true + } + } + return false +} + +// IsClockedIn returns true if there's an active clock entry +func (item *Item) IsClockedIn() bool { + for _, entry := range item.ClockEntries { + if entry.End == nil { + return true + } + } + return false +} + +// GetCurrentClockDuration returns the duration of the current clock entry +func (item *Item) GetCurrentClockDuration() time.Duration { + for _, entry := range item.ClockEntries { + if entry.End == nil { + return time.Since(entry.Start) + } + } + return 0 +} + +// GetTotalClockDuration returns the total duration of all clock entries +func (item *Item) GetTotalClockDuration() time.Duration { + var total time.Duration + for _, entry := range item.ClockEntries { + if entry.End != nil { + // Completed clock entry + total += entry.End.Sub(entry.Start) + } else { + // Currently clocked in + total += time.Since(entry.Start) + } + } + return total +} diff --git a/ui.go b/ui.go new file mode 100644 index 0000000..5108d08 --- /dev/null +++ b/ui.go @@ -0,0 +1,1357 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/alecthomas/chroma/v2/quick" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type viewMode int + +const ( + modeList viewMode = iota + modeAgenda + modeEdit + modeConfirmDelete + modeCapture + modeAddSubTask + modeSetDeadline +) + +type model struct { + orgFile *OrgFile + cursor int + mode viewMode + help help.Model + keys keyMap + width int + height int + statusMsg string + statusExpiry time.Time + editingItem *Item + textarea textarea.Model + textinput textinput.Model + itemToDelete *Item + reorderMode bool +} + +type keyMap struct { + Up key.Binding + Down key.Binding + Left key.Binding + Right key.Binding + ShiftUp key.Binding + ShiftDown key.Binding + CycleState key.Binding + ToggleView key.Binding + Quit key.Binding + Help key.Binding + Capture key.Binding + AddSubTask key.Binding + Delete key.Binding + Save key.Binding + ToggleFold key.Binding + EditNotes key.Binding + ToggleReorder key.Binding + ClockIn key.Binding + ClockOut key.Binding + SetDeadline key.Binding +} + +var keys = keyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "move up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "move down"), + ), + Left: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "cycle state backward"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "cycle state forward"), + ), + ShiftUp: key.NewBinding( + key.WithKeys("shift+up"), + key.WithHelp("shift+↑", "move item up"), + ), + ShiftDown: key.NewBinding( + key.WithKeys("shift+down"), + key.WithHelp("shift+↓", "move item down"), + ), + CycleState: key.NewBinding( + key.WithKeys("t", " "), + key.WithHelp("t/space", "cycle todo state"), + ), + ToggleFold: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "fold/unfold"), + ), + EditNotes: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "edit notes"), + ), + ToggleView: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "toggle agenda view"), + ), + Capture: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "capture TODO"), + ), + AddSubTask: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "add sub-task"), + ), + Delete: key.NewBinding( + key.WithKeys("shift+d"), + key.WithHelp("shift+d", "delete item"), + ), + Save: key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "save"), + ), + ToggleReorder: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "reorder mode"), + ), + ClockIn: key.NewBinding( + key.WithKeys("i"), + key.WithHelp("i", "clock in"), + ), + ClockOut: key.NewBinding( + key.WithKeys("o"), + key.WithHelp("o", "clock out"), + ), + SetDeadline: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "set deadline"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "toggle help"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Help} +} + +func (k keyMap) FullHelp() [][]key.Binding { + // This will be overridden by custom rendering in viewFullHelp + return [][]key.Binding{ + {k.Up, k.Down, k.Left, k.Right}, + {k.ToggleFold, k.EditNotes, k.ToggleReorder}, + {k.Capture, k.AddSubTask, k.Delete, k.Save}, + {k.ToggleView, k.Help, k.Quit}, + } +} + +// getAllBindings returns all keybindings as a flat list +func (k keyMap) getAllBindings() []key.Binding { + return []key.Binding{ + k.Up, k.Down, k.Left, k.Right, + k.ToggleFold, k.EditNotes, k.ToggleReorder, + k.Capture, k.AddSubTask, k.Delete, k.Save, + k.ClockIn, k.ClockOut, k.SetDeadline, + k.ToggleView, k.Help, k.Quit, + } +} + +// Styles +var ( + todoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("202")) // Orange + progStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")) // Yellow + blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) // Red + doneStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("34")) // Green + cursorStyle = lipgloss.NewStyle().Background(lipgloss.Color("240")) + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("99")) + scheduledStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("141")) // Purple + overdueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) // Red + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Italic(true) + noteStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("246")).Italic(true) + foldedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243")) +) + +func initialModel(orgFile *OrgFile) model { + ta := textarea.New() + ta.Placeholder = "Enter notes here (code blocks supported)..." + ta.ShowLineNumbers = false + + ti := textinput.New() + ti.Placeholder = "What needs doing?" + ti.CharLimit = 200 + + h := help.New() + h.ShowAll = false + + return model{ + orgFile: orgFile, + cursor: 0, + mode: modeList, + help: h, + keys: keys, + textarea: ta, + textinput: ti, + } +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Handle special modes + switch m.mode { + case modeEdit: + return m.updateEditMode(msg) + case modeConfirmDelete: + return m.updateConfirmDelete(msg) + case modeCapture: + return m.updateCapture(msg) + case modeAddSubTask: + return m.updateAddSubTask(msg) + case modeSetDeadline: + return m.updateSetDeadline(msg) + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.help.Width = msg.Width + m.textarea.SetWidth(msg.Width - 4) + m.textarea.SetHeight(msg.Height - 10) + return m, nil + + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + + case key.Matches(msg, m.keys.Help): + m.help.ShowAll = !m.help.ShowAll + return m, nil + + case key.Matches(msg, m.keys.Up): + if m.reorderMode { + m.moveItemUp() + } else { + if m.cursor > 0 { + m.cursor-- + } + } + + case key.Matches(msg, m.keys.Down): + if m.reorderMode { + m.moveItemDown() + } else { + items := m.getVisibleItems() + if m.cursor < len(items)-1 { + m.cursor++ + } + } + + case key.Matches(msg, m.keys.Left): + items := m.getVisibleItems() + if len(items) > 0 && m.cursor < len(items) { + m.cycleStateBackward(items[m.cursor]) + // Auto clock out when changing to DONE + if items[m.cursor].State == StateDONE && items[m.cursor].IsClockedIn() { + items[m.cursor].ClockOut() + } + m.setStatus("State changed") + } + + case key.Matches(msg, m.keys.Right): + items := m.getVisibleItems() + if len(items) > 0 && m.cursor < len(items) { + items[m.cursor].CycleState() + // Auto clock out when changing to DONE + if items[m.cursor].State == StateDONE && items[m.cursor].IsClockedIn() { + items[m.cursor].ClockOut() + } + m.setStatus("State changed") + } + + case key.Matches(msg, m.keys.ShiftUp): + m.moveItemUp() + + case key.Matches(msg, m.keys.ShiftDown): + m.moveItemDown() + + case key.Matches(msg, m.keys.CycleState): + items := m.getVisibleItems() + if len(items) > 0 && m.cursor < len(items) { + items[m.cursor].CycleState() + // Auto clock out when changing to DONE + if items[m.cursor].State == StateDONE && items[m.cursor].IsClockedIn() { + items[m.cursor].ClockOut() + } + m.setStatus("State changed") + } + + case key.Matches(msg, m.keys.ToggleFold): + items := m.getVisibleItems() + if len(items) > 0 && m.cursor < len(items) { + items[m.cursor].ToggleFold() + if items[m.cursor].Folded { + m.setStatus("Folded") + } else { + m.setStatus("Unfolded") + } + } + + case key.Matches(msg, m.keys.EditNotes): + items := m.getVisibleItems() + if len(items) > 0 && m.cursor < len(items) { + m.editingItem = items[m.cursor] + m.mode = modeEdit + m.textarea.SetValue(strings.Join(m.editingItem.Notes, "\n")) + m.textarea.Focus() + return m, textarea.Blink + } + + case key.Matches(msg, m.keys.Capture): + m.mode = modeCapture + m.textinput.SetValue("") + m.textinput.Placeholder = "What needs doing?" + m.textinput.Focus() + return m, textinput.Blink + + case key.Matches(msg, m.keys.AddSubTask): + items := m.getVisibleItems() + if len(items) > 0 && m.cursor < len(items) { + m.editingItem = items[m.cursor] + m.mode = modeAddSubTask + m.textinput.SetValue("") + m.textinput.Placeholder = "Sub-task title" + m.textinput.Focus() + return m, textinput.Blink + } + + case key.Matches(msg, m.keys.Delete): + items := m.getVisibleItems() + if len(items) > 0 && m.cursor < len(items) { + m.itemToDelete = items[m.cursor] + m.mode = modeConfirmDelete + } + + case key.Matches(msg, m.keys.ToggleView): + if m.mode == modeList { + m.mode = modeAgenda + } else { + m.mode = modeList + } + m.cursor = 0 + + case key.Matches(msg, m.keys.Save): + if err := m.orgFile.Save(); err != nil { + m.setStatus(fmt.Sprintf("Error saving: %v", err)) + } else { + m.setStatus("Saved!") + } + + case key.Matches(msg, m.keys.ToggleReorder): + m.reorderMode = !m.reorderMode + if m.reorderMode { + m.setStatus("Reorder mode ON - Use ↑/↓ to move items, 'r' to exit") + } else { + m.setStatus("Reorder mode OFF") + } + + case key.Matches(msg, m.keys.ClockIn): + items := m.getVisibleItems() + if len(items) > 0 && m.cursor < len(items) { + if items[m.cursor].ClockIn() { + m.setStatus("Clocked in!") + } else { + m.setStatus("Already clocked in") + } + } + + case key.Matches(msg, m.keys.ClockOut): + items := m.getVisibleItems() + if len(items) > 0 && m.cursor < len(items) { + if items[m.cursor].ClockOut() { + m.setStatus("Clocked out!") + } else { + m.setStatus("Not clocked in") + } + } + + case key.Matches(msg, m.keys.SetDeadline): + items := m.getVisibleItems() + if len(items) > 0 && m.cursor < len(items) { + m.editingItem = items[m.cursor] + m.mode = modeSetDeadline + m.textinput.SetValue("") + m.textinput.Placeholder = "YYYY-MM-DD or +N (days from today)" + m.textinput.Focus() + return m, textinput.Blink + } + } + } + + return m, nil +} + +func (m model) updateEditMode(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.textarea.SetWidth(msg.Width - 4) + m.textarea.SetHeight(msg.Height - 10) + + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEsc: + // Save notes and exit edit mode + if m.editingItem != nil { + noteText := m.textarea.Value() + if noteText == "" { + m.editingItem.Notes = []string{} + } else { + m.editingItem.Notes = strings.Split(noteText, "\n") + } + } + m.mode = modeList + m.textarea.Blur() + m.setStatus("Notes saved") + return m, nil + } + } + + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd +} + +func (m model) updateConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "y", "Y": + // Delete the item + m.deleteItem(m.itemToDelete) + m.mode = modeList + m.itemToDelete = nil + m.setStatus("Item deleted") + // Adjust cursor if needed + items := m.getVisibleItems() + if m.cursor >= len(items) && len(items) > 0 { + m.cursor = len(items) - 1 + } + case "n", "N", "esc": + m.mode = modeList + m.itemToDelete = nil + m.setStatus("Cancelled") + } + } + return m, nil +} + +func (m model) updateCapture(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + title := strings.TrimSpace(m.textinput.Value()) + if title != "" { + // Create new TODO at top level + newItem := &Item{ + Level: 1, + State: StateTODO, + Title: title, + Notes: []string{}, + Children: []*Item{}, + } + // Insert at beginning + m.orgFile.Items = append([]*Item{newItem}, m.orgFile.Items...) + m.setStatus("TODO captured!") + } + m.mode = modeList + m.textinput.Blur() + m.cursor = 0 + return m, nil + case tea.KeyEsc: + m.mode = modeList + m.textinput.Blur() + m.setStatus("Cancelled") + return m, nil + } + } + + m.textinput, cmd = m.textinput.Update(msg) + return m, cmd +} + +func (m model) updateAddSubTask(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + title := strings.TrimSpace(m.textinput.Value()) + if title != "" && m.editingItem != nil { + // Create new sub-task + newItem := &Item{ + Level: m.editingItem.Level + 1, + State: StateTODO, + Title: title, + Notes: []string{}, + Children: []*Item{}, + } + m.editingItem.Children = append(m.editingItem.Children, newItem) + m.editingItem.Folded = false // Unfold to show new sub-task + m.setStatus("Sub-task added!") + } + m.mode = modeList + m.textinput.Blur() + m.editingItem = nil + return m, nil + case tea.KeyEsc: + m.mode = modeList + m.textinput.Blur() + m.editingItem = nil + m.setStatus("Cancelled") + return m, nil + } + } + + m.textinput, cmd = m.textinput.Update(msg) + return m, cmd +} + +func (m model) updateSetDeadline(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + input := strings.TrimSpace(m.textinput.Value()) + if m.editingItem != nil { + if input == "" { + // Empty input clears the deadline + m.editingItem.Deadline = nil + // Remove DEADLINE line from notes (only lines starting with DEADLINE:) + var filteredNotes []string + for _, note := range m.editingItem.Notes { + trimmedNote := strings.TrimSpace(note) + if !strings.HasPrefix(trimmedNote, "DEADLINE:") { + filteredNotes = append(filteredNotes, note) + } + } + m.editingItem.Notes = filteredNotes + m.setStatus("Deadline cleared!") + } else { + deadline, err := parseDeadlineInput(input) + if err != nil { + m.setStatus(fmt.Sprintf("Invalid date: %v", err)) + } else { + m.editingItem.Deadline = &deadline + // Also update or add DEADLINE line in notes + updatedNotes := false + for i, note := range m.editingItem.Notes { + trimmedNote := strings.TrimSpace(note) + if strings.HasPrefix(trimmedNote, "DEADLINE:") { + m.editingItem.Notes[i] = fmt.Sprintf("DEADLINE: <%s>", formatOrgDate(deadline)) + updatedNotes = true + break + } + } + // If DEADLINE wasn't in notes, it will be added by writeItem + if !updatedNotes { + // Remove old deadline lines just to be safe + var filteredNotes []string + for _, note := range m.editingItem.Notes { + trimmedNote := strings.TrimSpace(note) + if !strings.HasPrefix(trimmedNote, "DEADLINE:") { + filteredNotes = append(filteredNotes, note) + } + } + m.editingItem.Notes = filteredNotes + } + m.setStatus("Deadline set!") + } + } + } + m.mode = modeList + m.textinput.Blur() + m.editingItem = nil + return m, nil + case tea.KeyEsc: + m.mode = modeList + m.textinput.Blur() + m.editingItem = nil + m.setStatus("Cancelled") + return m, nil + } + } + + m.textinput, cmd = m.textinput.Update(msg) + return m, cmd +} + +// parseDeadlineInput parses deadline input like "2024-01-15" or "+3" (3 days from now) +func parseDeadlineInput(input string) (time.Time, error) { + // Check if it's a relative date (+N days) + if strings.HasPrefix(input, "+") { + daysStr := strings.TrimPrefix(input, "+") + days := 0 + _, err := fmt.Sscanf(daysStr, "%d", &days) + if err != nil { + return time.Time{}, fmt.Errorf("invalid relative date format: %s", input) + } + return time.Now().AddDate(0, 0, days), nil + } + + // Try parsing as absolute date + formats := []string{ + "2006-01-02", + "2006/01/02", + "01/02/2006", + } + + for _, format := range formats { + if t, err := time.Parse(format, input); err == nil { + return t, nil + } + } + + return time.Time{}, fmt.Errorf("unable to parse date: %s (use YYYY-MM-DD or +N)", input) +} + +func (m *model) cycleStateBackward(item *Item) { + switch item.State { + case StateNone: + item.State = StateDONE + case StateTODO: + item.State = StateNone + case StatePROG: + item.State = StateTODO + case StateBLOCK: + item.State = StatePROG + case StateDONE: + item.State = StateBLOCK + } +} + +func (m *model) deleteItem(item *Item) { + var removeFromList func([]*Item, *Item) []*Item + removeFromList = func(items []*Item, target *Item) []*Item { + result := []*Item{} + for _, it := range items { + if it == target { + continue + } + it.Children = removeFromList(it.Children, target) + result = append(result, it) + } + return result + } + m.orgFile.Items = removeFromList(m.orgFile.Items, item) +} + +func (m *model) moveItemUp() { + items := m.getVisibleItems() + if len(items) == 0 || m.cursor == 0 { + return + } + + currentItem := items[m.cursor] + prevItem := items[m.cursor-1] + + // Can only swap items at the same level + if currentItem.Level != prevItem.Level { + m.setStatus("Cannot move across different levels") + return + } + + m.swapItems(currentItem, prevItem) + m.cursor-- + m.setStatus("Item moved up") +} + +func (m *model) moveItemDown() { + items := m.getVisibleItems() + if len(items) == 0 || m.cursor >= len(items)-1 { + return + } + + currentItem := items[m.cursor] + nextItem := items[m.cursor+1] + + // Can only swap items at the same level + if currentItem.Level != nextItem.Level { + m.setStatus("Cannot move across different levels") + return + } + + m.swapItems(currentItem, nextItem) + m.cursor++ + m.setStatus("Item moved down") +} + +func (m *model) swapItems(item1, item2 *Item) { + // Find parent list containing both items + var swapInList func([]*Item) bool + swapInList = func(items []*Item) bool { + for i := 0; i < len(items)-1; i++ { + if items[i] == item1 && items[i+1] == item2 { + items[i], items[i+1] = items[i+1], items[i] + return true + } + if items[i] == item2 && items[i+1] == item1 { + items[i], items[i+1] = items[i+1], items[i] + return true + } + if swapInList(items[i].Children) { + return true + } + } + if len(items) > 0 && swapInList(items[len(items)-1].Children) { + return true + } + return false + } + swapInList(m.orgFile.Items) +} + +func (m *model) setStatus(msg string) { + m.statusMsg = msg + m.statusExpiry = time.Now().Add(3 * time.Second) +} + +// dynamicKeyMap is a helper type for rendering keybindings with dynamic layout +type dynamicKeyMap struct { + rows [][]key.Binding +} + +// ShortHelp for dynamicKeyMap +func (d dynamicKeyMap) ShortHelp() []key.Binding { + return []key.Binding{} +} + +// FullHelp for dynamicKeyMap +func (d dynamicKeyMap) FullHelp() [][]key.Binding { + return d.rows +} + +// renderFullHelp renders the help with width-aware layout +func (m model) renderFullHelp() string { + bindings := m.keys.getAllBindings() + + // Estimate the width needed for each keybinding (key + desc + padding) + // Average is roughly 20-25 chars per binding + const estimatedBindingWidth = 22 + const minWidth = 40 // Minimum width before stacking + + var columnsPerRow int + if m.width < minWidth { + columnsPerRow = 1 // Stack vertically on very narrow terminals + } else if m.width < 80 { + columnsPerRow = 2 // Two columns on narrow terminals + } else if m.width < 120 { + columnsPerRow = 3 // Three columns on medium terminals + } else { + columnsPerRow = 4 // Four columns on wide terminals + } + + // Build rows based on columns per row + var rows [][]key.Binding + for i := 0; i < len(bindings); i += columnsPerRow { + end := i + columnsPerRow + if end > len(bindings) { + end = len(bindings) + } + rows = append(rows, bindings[i:end]) + } + + // Use the help model to render with our dynamic layout + h := help.New() + h.Width = m.width + h.ShowAll = true + + // Create a temporary keyMap for rendering + dkm := dynamicKeyMap{rows: rows} + + return h.View(dkm) +} + +func (m model) getVisibleItems() []*Item { + if m.mode == modeAgenda { + return m.getAgendaItems() + } + return m.orgFile.GetAllItems() +} + +func (m model) getAgendaItems() []*Item { + var items []*Item + now := time.Now() + startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + endOfWeek := startOfDay.AddDate(0, 0, 7) + + // Get all items regardless of folding for agenda view + var getAllItems func([]*Item) + getAllItems = func(list []*Item) { + for _, item := range list { + if item.Scheduled != nil && item.Scheduled.Before(endOfWeek) { + items = append(items, item) + } + if item.Deadline != nil && item.Deadline.Before(endOfWeek) { + items = append(items, item) + } + getAllItems(item.Children) + } + } + getAllItems(m.orgFile.Items) + + return items +} + +func (m model) View() string { + switch m.mode { + case modeEdit: + return m.viewEditMode() + case modeConfirmDelete: + return m.viewConfirmDelete() + case modeCapture: + return m.viewCapture() + case modeAddSubTask: + return m.viewAddSubTask() + case modeSetDeadline: + return m.viewSetDeadline() + } + + // Build footer (status + help) + var footer strings.Builder + + // Status message + if time.Now().Before(m.statusExpiry) { + footer.WriteString(statusStyle.Render(m.statusMsg)) + footer.WriteString("\n") + } + + // Help + if m.help.ShowAll { + footer.WriteString(m.renderFullHelp()) + } else { + footer.WriteString(m.help.View(m.keys)) + } + + footerHeight := lipgloss.Height(footer.String()) + + // Build main content + var content strings.Builder + + // Title + title := "Org Mode - List View" + if m.mode == modeAgenda { + title = "Org Mode - Agenda View (Next 7 Days)" + } + if m.reorderMode { + reorderIndicator := lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Render(" [REORDER MODE]") + content.WriteString(titleStyle.Render(title)) + content.WriteString(reorderIndicator) + } else { + content.WriteString(titleStyle.Render(title)) + } + content.WriteString("\n\n") + + // Calculate available height for items (total - title - footer) + availableHeight := m.height - 3 - footerHeight // 3 for title + spacing + if availableHeight < 5 { + availableHeight = 5 // Minimum height + } + + // Items + items := m.getVisibleItems() + if len(items) == 0 { + content.WriteString("No items. Press 'c' to capture a new TODO.\n") + } + + itemLines := 0 + for i, item := range items { + if itemLines >= availableHeight { + break // Don't render more items than fit + } + + line := m.renderItem(item, i == m.cursor) + content.WriteString(line) + content.WriteString("\n") + itemLines++ + + // Show notes if not folded + if !item.Folded && len(item.Notes) > 0 && m.mode == modeList { + indent := strings.Repeat(" ", item.Level) + // Filter out LOGBOOK drawer and apply syntax highlighting to notes + filteredNotes := filterLogbookDrawer(item.Notes) + highlightedNotes := renderNotesWithHighlighting(filteredNotes) + for _, note := range highlightedNotes { + if itemLines >= availableHeight { + break + } + content.WriteString(indent) + content.WriteString(" " + note) + content.WriteString("\n") + itemLines++ + } + } + } + + // Combine content and footer with padding + contentHeight := lipgloss.Height(content.String()) + paddingNeeded := m.height - contentHeight - footerHeight + if paddingNeeded < 0 { + paddingNeeded = 0 + } + + var result strings.Builder + result.WriteString(content.String()) + if paddingNeeded > 0 { + result.WriteString(strings.Repeat("\n", paddingNeeded)) + } + result.WriteString(footer.String()) + + return result.String() +} + +func (m model) viewConfirmDelete() string { + var b strings.Builder + + dialogStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("196")). + Padding(1, 2). + Width(60) + + var content strings.Builder + content.WriteString(titleStyle.Render("⚠ Delete Item")) + content.WriteString("\n\n") + + if m.itemToDelete != nil { + itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("202")).Bold(true) + content.WriteString(itemStyle.Render(m.itemToDelete.Title)) + content.WriteString("\n") + } + + content.WriteString("\n") + content.WriteString(statusStyle.Render("This will delete the item and all sub-tasks.")) + content.WriteString("\n\n") + content.WriteString("Press Y to confirm • N or ESC to cancel") + + dialog := dialogStyle.Render(content.String()) + + // Center the dialog + if m.height > 0 { + verticalPadding := (m.height - lipgloss.Height(dialog)) / 2 + if verticalPadding > 0 { + b.WriteString(strings.Repeat("\n", verticalPadding)) + } + } + b.WriteString(dialog) + + return b.String() +} + +func (m model) viewCapture() string { + var b strings.Builder + + dialogStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("99")). + Padding(1, 2). + Width(60) + + var content strings.Builder + content.WriteString(titleStyle.Render("Capture TODO")) + content.WriteString("\n\n") + content.WriteString(m.textinput.View()) + content.WriteString("\n\n") + content.WriteString(statusStyle.Render("Press Enter to save • ESC to cancel")) + + dialog := dialogStyle.Render(content.String()) + + // Center the dialog + if m.height > 0 { + verticalPadding := (m.height - lipgloss.Height(dialog)) / 2 + if verticalPadding > 0 { + b.WriteString(strings.Repeat("\n", verticalPadding)) + } + } + b.WriteString(dialog) + + return b.String() +} + +func (m model) viewAddSubTask() string { + var b strings.Builder + + dialogStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("99")). + Padding(1, 2). + Width(60) + + var content strings.Builder + content.WriteString(titleStyle.Render("Add Sub-Task")) + content.WriteString("\n") + if m.editingItem != nil { + content.WriteString(statusStyle.Render(fmt.Sprintf("Under: %s", m.editingItem.Title))) + } + content.WriteString("\n\n") + content.WriteString(m.textinput.View()) + content.WriteString("\n\n") + content.WriteString(statusStyle.Render("Press Enter to save • ESC to cancel")) + + dialog := dialogStyle.Render(content.String()) + + // Center the dialog + if m.height > 0 { + verticalPadding := (m.height - lipgloss.Height(dialog)) / 2 + if verticalPadding > 0 { + b.WriteString(strings.Repeat("\n", verticalPadding)) + } + } + b.WriteString(dialog) + + return b.String() +} + +func (m model) viewSetDeadline() string { + var b strings.Builder + + dialogStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("141")). + Padding(1, 2). + Width(60) + + var content strings.Builder + content.WriteString(titleStyle.Render("Set Deadline")) + content.WriteString("\n") + if m.editingItem != nil { + content.WriteString(statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title))) + } + content.WriteString("\n\n") + content.WriteString(m.textinput.View()) + content.WriteString("\n\n") + content.WriteString(statusStyle.Render("Examples: 2025-12-31, +7 (7 days from now)")) + content.WriteString("\n") + content.WriteString(statusStyle.Render("Leave empty to clear deadline")) + content.WriteString("\n") + content.WriteString(statusStyle.Render("Press Enter to save • ESC to cancel")) + + dialog := dialogStyle.Render(content.String()) + + // Center the dialog + if m.height > 0 { + verticalPadding := (m.height - lipgloss.Height(dialog)) / 2 + if verticalPadding > 0 { + b.WriteString(strings.Repeat("\n", verticalPadding)) + } + } + b.WriteString(dialog) + + return b.String() +} + +func (m model) viewEditMode() string { + var b strings.Builder + + b.WriteString(titleStyle.Render("Editing Notes")) + b.WriteString("\n") + if m.editingItem != nil { + b.WriteString(fmt.Sprintf("Item: %s\n", m.editingItem.Title)) + } + b.WriteString(statusStyle.Render("Press ESC to save and exit")) + b.WriteString("\n\n") + + b.WriteString(m.textarea.View()) + + return b.String() +} + +// filterLogbookDrawer removes LOGBOOK drawer content and scheduling metadata from notes +func filterLogbookDrawer(notes []string) []string { + var filtered []string + inLogbook := false + + for _, note := range notes { + trimmed := strings.TrimSpace(note) + + // Check for start of LOGBOOK drawer + if trimmed == ":LOGBOOK:" { + inLogbook = true + continue + } + + // Check for end of LOGBOOK drawer + if trimmed == ":END:" && inLogbook { + inLogbook = false + continue + } + + // Skip lines inside LOGBOOK drawer + if inLogbook { + continue + } + + // Skip SCHEDULED and DEADLINE lines + if strings.HasPrefix(trimmed, "SCHEDULED:") || strings.HasPrefix(trimmed, "DEADLINE:") { + continue + } + + filtered = append(filtered, note) + } + + return filtered +} + +// renderNotesWithHighlighting renders notes with syntax highlighting for code blocks +func renderNotesWithHighlighting(notes []string) []string { + if len(notes) == 0 { + return notes + } + + var result []string + var inCodeBlock bool + var codeLanguage string + var codeLines []string + var codeBlockDelimiter string // Track whether we're in #+BEGIN_SRC or ``` block + + for _, note := range notes { + trimmed := strings.TrimSpace(note) + + // Check for org-mode style code block start + if strings.HasPrefix(trimmed, "#+BEGIN_SRC") { + inCodeBlock = true + codeBlockDelimiter = "org" + // Extract language + parts := strings.Fields(trimmed) + if len(parts) > 1 { + codeLanguage = strings.ToLower(parts[1]) + } else { + codeLanguage = "text" + } + result = append(result, note) // Keep the delimiter visible + codeLines = []string{} + continue + } + + // Check for markdown style code block start + if strings.HasPrefix(trimmed, "```") { + if !inCodeBlock { + // Starting a code block + inCodeBlock = true + codeBlockDelimiter = "markdown" + // Extract language + lang := strings.TrimPrefix(trimmed, "```") + if lang != "" { + codeLanguage = strings.ToLower(lang) + } else { + codeLanguage = "text" + } + result = append(result, note) // Keep the delimiter visible + codeLines = []string{} + continue + } else if codeBlockDelimiter == "markdown" { + // Ending a markdown code block + inCodeBlock = false + // Highlight and add the code + if len(codeLines) > 0 { + highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage) + highlightedLines := strings.Split(highlighted, "\n") + result = append(result, highlightedLines...) + } + result = append(result, note) // Keep the delimiter visible + codeLines = []string{} + codeLanguage = "" + codeBlockDelimiter = "" + continue + } + } + + // Check for org-mode style code block end + if strings.HasPrefix(trimmed, "#+END_SRC") { + inCodeBlock = false + // Highlight and add the code + if len(codeLines) > 0 { + highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage) + highlightedLines := strings.Split(highlighted, "\n") + result = append(result, highlightedLines...) + } + result = append(result, note) // Keep the delimiter visible + codeLines = []string{} + codeLanguage = "" + codeBlockDelimiter = "" + continue + } + + // If in code block, accumulate lines + if inCodeBlock { + codeLines = append(codeLines, note) + } else { + result = append(result, note) + } + } + + // Handle case where code block wasn't closed + if inCodeBlock && len(codeLines) > 0 { + highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage) + highlightedLines := strings.Split(highlighted, "\n") + result = append(result, highlightedLines...) + } + + return result +} + +// highlightCode applies syntax highlighting to code +func highlightCode(code, language string) string { + if code == "" { + return code + } + + var buf strings.Builder + err := quick.Highlight(&buf, code, language, "terminal256", "monokai") + if err != nil { + // If highlighting fails, return the original code + return code + } + + return strings.TrimRight(buf.String(), "\n") +} + +func (m model) renderItem(item *Item, isCursor bool) string { + var b strings.Builder + + // Indentation for level + indent := strings.Repeat(" ", item.Level-1) + b.WriteString(indent) + + // Fold indicator + if len(item.Children) > 0 || len(item.Notes) > 0 { + if item.Folded { + b.WriteString(foldedStyle.Render("▶ ")) + } else { + b.WriteString(foldedStyle.Render("▼ ")) + } + } else { + b.WriteString(" ") + } + + // State + stateStr := "" + switch item.State { + case StateTODO: + stateStr = todoStyle.Render("[TODO] ") + case StatePROG: + stateStr = progStyle.Render("[PROG] ") + case StateBLOCK: + stateStr = blockStyle.Render("[BLOCK]") + case StateDONE: + stateStr = doneStyle.Render("[DONE] ") + default: + stateStr = " " // Empty space for alignment + } + b.WriteString(stateStr) + b.WriteString(" ") + + // Title + b.WriteString(item.Title) + + // Clock status + if item.IsClockedIn() { + duration := item.GetCurrentClockDuration() + hours := int(duration.Hours()) + minutes := int(duration.Minutes()) % 60 + clockStr := fmt.Sprintf(" [CLOCKED IN: %dh %dm]", hours, minutes) + clockStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Bold(true) // Bright green + b.WriteString(clockStyle.Render(clockStr)) + } + + // Total clocked time (show if there are any clock entries) + if len(item.ClockEntries) > 0 { + totalDuration := item.GetTotalClockDuration() + totalHours := int(totalDuration.Hours()) + totalMinutes := int(totalDuration.Minutes()) % 60 + + // Format the time display based on magnitude + var timeStr string + if totalHours > 0 { + timeStr = fmt.Sprintf("%dh %dm", totalHours, totalMinutes) + } else { + timeStr = fmt.Sprintf("%dm", totalMinutes) + } + + totalTimeStr := fmt.Sprintf(" (Time: %s)", timeStr) + totalTimeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("141")) // Purple, similar to scheduled + b.WriteString(totalTimeStyle.Render(totalTimeStr)) + } + + // Scheduling info + now := time.Now() + if item.Scheduled != nil { + schedStr := fmt.Sprintf(" (Scheduled: %s)", formatOrgDate(*item.Scheduled)) + if item.Scheduled.Before(now) { + b.WriteString(overdueStyle.Render(schedStr)) + } else { + b.WriteString(scheduledStyle.Render(schedStr)) + } + } + if item.Deadline != nil { + deadlineStr := fmt.Sprintf(" (Deadline: %s)", formatOrgDate(*item.Deadline)) + if item.Deadline.Before(now) { + b.WriteString(overdueStyle.Render(deadlineStr)) + } else { + b.WriteString(scheduledStyle.Render(deadlineStr)) + } + } + + line := b.String() + if isCursor { + return cursorStyle.Render(line) + } + return line +} + +func runUI(orgFile *OrgFile) error { + p := tea.NewProgram(initialModel(orgFile), tea.WithAltScreen()) + _, err := p.Run() + return err +}