make it more formal

This commit is contained in:
Rasmus Wejlgaard 2025-11-07 21:53:32 +00:00
parent 6b88066b20
commit c16bee05df
19 changed files with 1890 additions and 1807 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
bin/

View file

@ -5,6 +5,9 @@ import (
"fmt"
"os"
"path/filepath"
"github.com/rwejlgaard/org/internal/parser"
"github.com/rwejlgaard/org/internal/ui"
)
func main() {
@ -29,20 +32,20 @@ func main() {
}
// Parse the org file
orgFile, err := ParseOrgFile(filePath)
orgFile, err := parser.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 {
if err := ui.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 {
if err := parser.Save(orgFile); err != nil {
fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err)
os.Exit(1)
}

11
go.mod
View file

@ -3,13 +3,16 @@ module github.com/rwejlgaard/org
go 1.25.3
require (
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/alecthomas/chroma/v2 v2.20.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
)
require (
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

14
go.sum
View file

@ -1,9 +1,17 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
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/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
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/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
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=
@ -16,12 +24,16 @@ github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7
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/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
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/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
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=
@ -41,6 +53,8 @@ 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/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
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=

9
internal/model/clock.go Normal file
View file

@ -0,0 +1,9 @@
package model
import "time"
// ClockEntry represents a single clock entry
type ClockEntry struct {
Start time.Time
End *time.Time // nil if currently clocked in
}

123
internal/model/item.go Normal file
View file

@ -0,0 +1,123 @@
package model
import "time"
// 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
}
// 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
}
// 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
}

12
internal/model/state.go Normal file
View file

@ -0,0 +1,12 @@
package model
// 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 = ""
)

View file

@ -0,0 +1,51 @@
package parser
import (
"fmt"
"time"
)
// 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")
}

160
internal/parser/parser.go Normal file
View file

@ -0,0 +1,160 @@
package parser
import (
"bufio"
"os"
"regexp"
"strings"
"github.com/rwejlgaard/org/internal/model"
)
// 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) (*model.OrgFile, error) {
file, err := os.Open(path)
if err != nil {
// If file doesn't exist, return empty org file
if os.IsNotExist(err) {
return &model.OrgFile{Path: path, Items: []*model.Item{}}, nil
}
return nil, err
}
defer file.Close()
orgFile := &model.OrgFile{Path: path, Items: []*model.Item{}}
scanner := bufio.NewScanner(file)
var currentItem *model.Item
var itemStack []*model.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 := model.TodoState(matches[2])
title := matches[3]
item := &model.Item{
Level: level,
State: state,
Title: title,
Notes: []string{},
Children: []*model.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 := model.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
}

111
internal/parser/writer.go Normal file
View file

@ -0,0 +1,111 @@
package parser
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/rwejlgaard/org/internal/model"
)
// Save writes the org file back to disk
func Save(orgFile *model.OrgFile) error {
file, err := os.Create(orgFile.Path)
if err != nil {
return err
}
defer file.Close()
writer := bufio.NewWriter(file)
defer writer.Flush()
for _, item := range orgFile.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 *model.Item) error {
// Write heading
stars := strings.Repeat("*", item.Level)
line := stars
if item.State != model.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
}

32
internal/ui/agenda.go Normal file
View file

@ -0,0 +1,32 @@
package ui
import (
"time"
"github.com/rwejlgaard/org/internal/model"
)
// getAgendaItems returns items with scheduling or deadlines within the next 7 days
func (m uiModel) getAgendaItems() []*model.Item {
var items []*model.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([]*model.Item)
getAllItems = func(list []*model.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
}

86
internal/ui/app.go Normal file
View file

@ -0,0 +1,86 @@
package ui
import (
"time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/rwejlgaard/org/internal/model"
)
type viewMode int
const (
modeList viewMode = iota
modeAgenda
modeEdit
modeConfirmDelete
modeCapture
modeAddSubTask
modeSetDeadline
)
type uiModel struct {
orgFile *model.OrgFile
cursor int
mode viewMode
help help.Model
keys keyMap
width int
height int
statusMsg string
statusExpiry time.Time
editingItem *model.Item
textarea textarea.Model
textinput textinput.Model
itemToDelete *model.Item
reorderMode bool
}
func initialModel(orgFile *model.OrgFile) uiModel {
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 uiModel{
orgFile: orgFile,
cursor: 0,
mode: modeList,
help: h,
keys: keys,
textarea: ta,
textinput: ti,
}
}
func (m uiModel) Init() tea.Cmd {
return nil
}
func (m *uiModel) setStatus(msg string) {
m.statusMsg = msg
m.statusExpiry = time.Now().Add(3 * time.Second)
}
func (m uiModel) getVisibleItems() []*model.Item {
if m.mode == modeAgenda {
return m.getAgendaItems()
}
return m.orgFile.GetAllItems()
}
// RunUI starts the terminal UI
func RunUI(orgFile *model.OrgFile) error {
p := tea.NewProgram(initialModel(orgFile), tea.WithAltScreen())
_, err := p.Run()
return err
}

134
internal/ui/keybindings.go Normal file
View file

@ -0,0 +1,134 @@
package ui
import "github.com/charmbracelet/bubbles/key"
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,
}
}

550
internal/ui/modes.go Normal file
View file

@ -0,0 +1,550 @@
package ui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/rwejlgaard/org/internal/model"
"github.com/rwejlgaard/org/internal/parser"
)
func (m uiModel) 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 == model.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 == model.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 == model.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 := parser.Save(m.orgFile); 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 uiModel) 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 uiModel) 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 uiModel) 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 := &model.Item{
Level: 1,
State: model.StateTODO,
Title: title,
Notes: []string{},
Children: []*model.Item{},
}
// Insert at beginning
m.orgFile.Items = append([]*model.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 uiModel) 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 := &model.Item{
Level: m.editingItem.Level + 1,
State: model.StateTODO,
Title: title,
Notes: []string{},
Children: []*model.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 uiModel) 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>", parser.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 *uiModel) cycleStateBackward(item *model.Item) {
switch item.State {
case model.StateNone:
item.State = model.StateDONE
case model.StateTODO:
item.State = model.StateNone
case model.StatePROG:
item.State = model.StateTODO
case model.StateBLOCK:
item.State = model.StatePROG
case model.StateDONE:
item.State = model.StateBLOCK
}
}
func (m *uiModel) deleteItem(item *model.Item) {
var removeFromList func([]*model.Item, *model.Item) []*model.Item
removeFromList = func(items []*model.Item, target *model.Item) []*model.Item {
result := []*model.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 *uiModel) 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 *uiModel) 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 *uiModel) swapItems(item1, item2 *model.Item) {
// Find parent list containing both items
var swapInList func([]*model.Item) bool
swapInList = func(items []*model.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)
}

18
internal/ui/styles.go Normal file
View file

@ -0,0 +1,18 @@
package ui
import "github.com/charmbracelet/lipgloss"
// Styles for UI rendering
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"))
)

576
internal/ui/views.go Normal file
View file

@ -0,0 +1,576 @@
package ui
import (
"fmt"
"strings"
"time"
"github.com/alecthomas/chroma/v2/quick"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/lipgloss"
"github.com/rwejlgaard/org/internal/model"
"github.com/rwejlgaard/org/internal/parser"
)
// 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 uiModel) 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 uiModel) 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 uiModel) 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 uiModel) 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 uiModel) 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 uiModel) 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 uiModel) 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 uiModel) renderItem(item *model.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 model.StateTODO:
stateStr = todoStyle.Render("[TODO] ")
case model.StatePROG:
stateStr = progStyle.Render("[PROG] ")
case model.StateBLOCK:
stateStr = blockStyle.Render("[BLOCK]")
case model.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)", parser.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)", parser.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
}

443
org.go
View file

@ -1,443 +0,0 @@
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
}

0
todo.org Normal file
View file

1357
ui.go

File diff suppressed because it is too large Load diff