Compare commits

...

6 commits

12 changed files with 644 additions and 190 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View file

@ -23,6 +23,9 @@ org tasks.org # Open specific org file
org /path/to/work.org # Open specific org file with path org /path/to/work.org # Open specific org file with path
org -m # Multi-file: Load all .org files in current directory org -m # Multi-file: Load all .org files in current directory
org -m /path/to/dir # Multi-file: Load all .org files in specified directory org -m /path/to/dir # Multi-file: Load all .org files in specified directory
org -c # Quick capture mode
org -c "Task description" # Quick capture with pre-filled text
echo "Task" | org # Pipe text to capture
``` ```
### Single-File Mode (Default) ### Single-File Mode (Default)
@ -35,6 +38,20 @@ org tasks.org # Opens tasks.org
org ~/work/project.org # Opens specific file org ~/work/project.org # Opens specific file
``` ```
### Quick Capture Mode
Use the `-c` or `--capture` flag to quickly add tasks without navigating through the UI:
```bash
org -c # Open directly in capture mode
org -c "Buy groceries" # Capture with pre-filled text
org -c "Write report" tasks.org # Capture to specific file
echo "Meeting notes" | org # Pipe text to capture
echo "Task" | org ~/work.org # Pipe to specific file
```
This is perfect for quickly capturing tasks from scripts, terminal workflows, or shell aliases. The capture mode skips the need to press 'c' once inside the application, making it faster to add quick TODO items.
### Multi-File Mode ### Multi-File Mode
Use the `-m` or `--multi` flag to load all `.org` files in a directory as top-level items. Each file appears as a top-level item in the interface, with its contents nested underneath. Changes made to items are automatically saved back to their respective files. Use the `-m` or `--multi` flag to load all `.org` files in a directory as top-level items. Each file appears as a top-level item in the interface, with its contents nested underneath. Changes made to items are automatically saved back to their respective files.
@ -92,6 +109,7 @@ Feel free to fork and create a pull request if there's any features missing for
- **Syntax Highlighting**: Code blocks are automatically highlighted (supports both ```lang and #+BEGIN_SRC formats) - **Syntax Highlighting**: Code blocks are automatically highlighted (supports both ```lang and #+BEGIN_SRC formats)
- **Markdown Support**: Use markdown-style code blocks in your notes - **Markdown Support**: Use markdown-style code blocks in your notes
- **Drawer Management**: LOGBOOK and PROPERTIES drawers are automatically filtered in list view - **Drawer Management**: LOGBOOK and PROPERTIES drawers are automatically filtered in list view
- **Fold/Unfold All**: Fold/Unfold all items with shift+tab
### Keybindings ### Keybindings
@ -101,6 +119,7 @@ Feel free to fork and create a pull request if there's any features missing for
| `←/h`, `→/l` | Cycle state backward/forward | | `←/h`, `→/l` | Cycle state backward/forward |
| `t` or `space` | Cycle TODO state | | `t` or `space` | Cycle TODO state |
| `tab` | Fold/unfold item | | `tab` | Fold/unfold item |
| `shift+tab` | Fold/Unfold all items |
| `enter` | Edit notes | | `enter` | Edit notes |
| `c` | Capture new TODO | | `c` | Capture new TODO |
| `s` | Add sub-task | | `s` | Add sub-task |
@ -111,6 +130,7 @@ Feel free to fork and create a pull request if there's any features missing for
| `i` | Clock in | | `i` | Clock in |
| `o` | Clock out | | `o` | Clock out |
| `d` | Set deadline | | `d` | Set deadline |
| `S` | Set scheduled date |
| `p` | Set priority | | `p` | Set priority |
| `e` | Set effort | | `e` | Set effort |
| `r` | Toggle reorder mode | | `r` | Toggle reorder mode |

View file

@ -1,8 +1,10 @@
package main package main
import ( import (
"bufio"
"flag" "flag"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
@ -15,14 +17,44 @@ import (
func main() { func main() {
var filePath string var filePath string
var multiMode bool var multiMode bool
var captureMode bool
flag.BoolVar(&multiMode, "multi", false, "Load all org files in current directory as top-level items") flag.BoolVar(&multiMode, "multi", false, "Load all org files in current directory as top-level items")
flag.BoolVar(&multiMode, "m", false, "Load all org files in current directory (shorthand)") flag.BoolVar(&multiMode, "m", false, "Load all org files in current directory (shorthand)")
flag.BoolVar(&captureMode, "capture", false, "Start in capture mode")
flag.BoolVar(&captureMode, "c", false, "Start in capture mode (shorthand)")
flag.Parse() flag.Parse()
// Check for positional argument // Check for positional argument or capture text
var captureText string
if len(flag.Args()) > 0 {
if captureMode {
// First argument is capture text when in capture mode
captureText = flag.Args()[0]
// Second argument (if present) is the file path
if len(flag.Args()) > 1 {
filePath = flag.Args()[1]
}
} else {
// First argument is file path in normal mode
filePath = flag.Args()[0]
}
}
// Check if input is being piped
stat, _ := os.Stdin.Stat()
if (stat.Mode() & os.ModeCharDevice) == 0 {
// Data is being piped to stdin
reader := bufio.NewReader(os.Stdin)
pipedText, err := io.ReadAll(reader)
if err == nil && len(pipedText) > 0 {
captureMode = true
captureText = string(pipedText)
// If no file path was provided via args, check if last arg could be a path
if filePath == "" && len(flag.Args()) > 0 { if filePath == "" && len(flag.Args()) > 0 {
filePath = flag.Args()[0] filePath = flag.Args()[0]
} }
}
}
// Load configuration // Load configuration
cfg, err := config.LoadConfig() cfg, err := config.LoadConfig()
@ -81,7 +113,7 @@ func main() {
} }
// Run the UI // Run the UI
if err := ui.RunUI(orgFile, cfg); err != nil { if err := ui.RunUI(orgFile, cfg, captureMode, captureText); err != nil {
fmt.Fprintf(os.Stderr, "Error running UI: %v\n", err) fmt.Fprintf(os.Stderr, "Error running UI: %v\n", err)
os.Exit(1) os.Exit(1)
} }

View file

@ -31,6 +31,7 @@ type KeybindingsConfig struct {
Rename []string `toml:"rename"` Rename []string `toml:"rename"`
CycleState []string `toml:"cycle_state"` CycleState []string `toml:"cycle_state"`
ToggleFold []string `toml:"toggle_fold"` ToggleFold []string `toml:"toggle_fold"`
ToggleFoldAll []string `toml:"toggle_fold_all"`
EditNotes []string `toml:"edit_notes"` EditNotes []string `toml:"edit_notes"`
ToggleView []string `toml:"toggle_view"` ToggleView []string `toml:"toggle_view"`
Capture []string `toml:"capture"` Capture []string `toml:"capture"`
@ -41,6 +42,7 @@ type KeybindingsConfig struct {
ClockIn []string `toml:"clock_in"` ClockIn []string `toml:"clock_in"`
ClockOut []string `toml:"clock_out"` ClockOut []string `toml:"clock_out"`
SetDeadline []string `toml:"set_deadline"` SetDeadline []string `toml:"set_deadline"`
SetScheduled []string `toml:"set_scheduled"`
SetPriority []string `toml:"set_priority"` SetPriority []string `toml:"set_priority"`
SetEffort []string `toml:"set_effort"` SetEffort []string `toml:"set_effort"`
Help []string `toml:"help"` Help []string `toml:"help"`
@ -94,6 +96,9 @@ type UIConfig struct {
HelpTextWidth int `toml:"help_text_width"` HelpTextWidth int `toml:"help_text_width"`
MinTerminalWidth int `toml:"min_terminal_width"` MinTerminalWidth int `toml:"min_terminal_width"`
AgendaDays int `toml:"agenda_days"` AgendaDays int `toml:"agenda_days"`
OrgSyntaxHighlighting bool `toml:"org_syntax_highlighting"`
ShowIndentationGuides bool `toml:"show_indentation_guides"`
IndentationGuideColor string `toml:"indentation_guide_color"`
} }
// DefaultConfig returns the default configuration // DefaultConfig returns the default configuration
@ -111,6 +116,7 @@ func DefaultConfig() *Config {
Rename: []string{"R"}, Rename: []string{"R"},
CycleState: []string{"t", " "}, CycleState: []string{"t", " "},
ToggleFold: []string{"tab"}, ToggleFold: []string{"tab"},
ToggleFoldAll: []string{"shift+tab", "backtab"},
EditNotes: []string{"enter"}, EditNotes: []string{"enter"},
ToggleView: []string{"a"}, ToggleView: []string{"a"},
Capture: []string{"c"}, Capture: []string{"c"},
@ -121,6 +127,7 @@ func DefaultConfig() *Config {
ClockIn: []string{"i"}, ClockIn: []string{"i"},
ClockOut: []string{"o"}, ClockOut: []string{"o"},
SetDeadline: []string{"d"}, SetDeadline: []string{"d"},
SetScheduled: []string{"S"},
SetPriority: []string{"p"}, SetPriority: []string{"p"},
SetEffort: []string{"e"}, SetEffort: []string{"e"},
Help: []string{"?"}, Help: []string{"?"},
@ -164,6 +171,9 @@ func DefaultConfig() *Config {
HelpTextWidth: 22, HelpTextWidth: 22,
MinTerminalWidth: 40, MinTerminalWidth: 40,
AgendaDays: 7, AgendaDays: 7,
OrgSyntaxHighlighting: true,
ShowIndentationGuides: true,
IndentationGuideColor: "245",
}, },
} }
} }
@ -276,6 +286,9 @@ func (c *Config) fillDefaults() {
if len(c.Keybindings.ToggleFold) == 0 { if len(c.Keybindings.ToggleFold) == 0 {
c.Keybindings.ToggleFold = defaults.Keybindings.ToggleFold c.Keybindings.ToggleFold = defaults.Keybindings.ToggleFold
} }
if len(c.Keybindings.ToggleFoldAll) == 0 {
c.Keybindings.ToggleFoldAll = defaults.Keybindings.ToggleFoldAll
}
if len(c.Keybindings.EditNotes) == 0 { if len(c.Keybindings.EditNotes) == 0 {
c.Keybindings.EditNotes = defaults.Keybindings.EditNotes c.Keybindings.EditNotes = defaults.Keybindings.EditNotes
} }
@ -306,6 +319,9 @@ func (c *Config) fillDefaults() {
if len(c.Keybindings.SetDeadline) == 0 { if len(c.Keybindings.SetDeadline) == 0 {
c.Keybindings.SetDeadline = defaults.Keybindings.SetDeadline c.Keybindings.SetDeadline = defaults.Keybindings.SetDeadline
} }
if len(c.Keybindings.SetScheduled) == 0 {
c.Keybindings.SetScheduled = defaults.Keybindings.SetScheduled
}
if len(c.Keybindings.SetPriority) == 0 { if len(c.Keybindings.SetPriority) == 0 {
c.Keybindings.SetPriority = defaults.Keybindings.SetPriority c.Keybindings.SetPriority = defaults.Keybindings.SetPriority
} }
@ -371,10 +387,11 @@ func (c *Config) fillDefaults() {
// Fill states if empty // Fill states if empty
if len(c.States.States) == 0 { if len(c.States.States) == 0 {
c.States.States = defaults.States.States c.States.States = defaults.States.States
} // Also set the default new task state since the entire states section is missing
if c.States.DefaultNewTaskState == "" {
c.States.DefaultNewTaskState = defaults.States.DefaultNewTaskState c.States.DefaultNewTaskState = defaults.States.DefaultNewTaskState
} }
// Note: We don't fill DefaultNewTaskState if States.States is non-empty because
// an empty string is a valid intentional value meaning "no default state".
// Fill UI if zero values // Fill UI if zero values
if c.UI.HelpTextWidth == 0 { if c.UI.HelpTextWidth == 0 {
@ -386,6 +403,9 @@ func (c *Config) fillDefaults() {
if c.UI.AgendaDays == 0 { if c.UI.AgendaDays == 0 {
c.UI.AgendaDays = defaults.UI.AgendaDays c.UI.AgendaDays = defaults.UI.AgendaDays
} }
if c.UI.IndentationGuideColor == "" {
c.UI.IndentationGuideColor = defaults.UI.IndentationGuideColor
}
} }
// BuildKeyBinding creates a key.Binding from config // BuildKeyBinding creates a key.Binding from config
@ -507,6 +527,8 @@ func (c *Config) UpdateKeybinding(action string, keys []string) error {
c.Keybindings.CycleState = keys c.Keybindings.CycleState = keys
case "toggle_fold": case "toggle_fold":
c.Keybindings.ToggleFold = keys c.Keybindings.ToggleFold = keys
case "toggle_fold_all":
c.Keybindings.ToggleFoldAll = keys
case "edit_notes": case "edit_notes":
c.Keybindings.EditNotes = keys c.Keybindings.EditNotes = keys
case "capture": case "capture":
@ -547,6 +569,7 @@ func (c *Config) GetAllKeybindings() map[string][]string {
"rename": c.Keybindings.Rename, "rename": c.Keybindings.Rename,
"cycle_state": c.Keybindings.CycleState, "cycle_state": c.Keybindings.CycleState,
"toggle_fold": c.Keybindings.ToggleFold, "toggle_fold": c.Keybindings.ToggleFold,
"toggle_fold_all": c.Keybindings.ToggleFoldAll,
"edit_notes": c.Keybindings.EditNotes, "edit_notes": c.Keybindings.EditNotes,
"toggle_view": c.Keybindings.ToggleView, "toggle_view": c.Keybindings.ToggleView,
"capture": c.Keybindings.Capture, "capture": c.Keybindings.Capture,
@ -557,6 +580,7 @@ func (c *Config) GetAllKeybindings() map[string][]string {
"clock_in": c.Keybindings.ClockIn, "clock_in": c.Keybindings.ClockIn,
"clock_out": c.Keybindings.ClockOut, "clock_out": c.Keybindings.ClockOut,
"set_deadline": c.Keybindings.SetDeadline, "set_deadline": c.Keybindings.SetDeadline,
"set_scheduled": c.Keybindings.SetScheduled,
"set_priority": c.Keybindings.SetPriority, "set_priority": c.Keybindings.SetPriority,
"set_effort": c.Keybindings.SetEffort, "set_effort": c.Keybindings.SetEffort,
"help": c.Keybindings.Help, "help": c.Keybindings.Help,

View file

@ -21,6 +21,7 @@ type Item struct {
Tags []string // Tags for this item (e.g., :work:urgent:) Tags []string // Tags for this item (e.g., :work:urgent:)
Scheduled *time.Time Scheduled *time.Time
Deadline *time.Time Deadline *time.Time
Closed *time.Time // Closed timestamp (when task was marked as done)
Effort string // Effort estimate (e.g., "8h", "2d") Effort string // Effort estimate (e.g., "8h", "2d")
Notes []string // Notes/content under the heading Notes []string // Notes/content under the heading
Children []*Item // Sub-items Children []*Item // Sub-items

View file

@ -16,6 +16,7 @@ import (
var ( var (
scheduledPattern = regexp.MustCompile(`SCHEDULED:\s*<([^>]+)>`) scheduledPattern = regexp.MustCompile(`SCHEDULED:\s*<([^>]+)>`)
deadlinePattern = regexp.MustCompile(`DEADLINE:\s*<([^>]+)>`) deadlinePattern = regexp.MustCompile(`DEADLINE:\s*<([^>]+)>`)
closedPattern = regexp.MustCompile(`CLOSED:\s*\[([^\]]+)\]`)
clockPattern = regexp.MustCompile(`CLOCK:\s*\[([^\]]+)\](?:--\[([^\]]+)\])?`) clockPattern = regexp.MustCompile(`CLOCK:\s*\[([^\]]+)\](?:--\[([^\]]+)\])?`)
effortPattern = regexp.MustCompile(`^\s*:EFFORT:\s*(.+)$`) effortPattern = regexp.MustCompile(`^\s*:EFFORT:\s*(.+)$`)
logbookDrawerStart = regexp.MustCompile(`^\s*:LOGBOOK:\s*$`) logbookDrawerStart = regexp.MustCompile(`^\s*:LOGBOOK:\s*$`)
@ -187,6 +188,13 @@ func ParseOrgFile(path string, cfg *config.Config) (*model.OrgFile, error) {
} }
} }
// Check for CLOSED
if matches := closedPattern.FindStringSubmatch(line); matches != nil {
if t, err := parseClockTimestamp(matches[1]); err == nil {
currentItem.Closed = &t
}
}
// Check for EFFORT (inside PROPERTIES drawer) // Check for EFFORT (inside PROPERTIES drawer)
if matches := effortPattern.FindStringSubmatch(line); matches != nil { if matches := effortPattern.FindStringSubmatch(line); matches != nil {
currentItem.Effort = strings.TrimSpace(matches[1]) currentItem.Effort = strings.TrimSpace(matches[1])

View file

@ -128,6 +128,7 @@ func writeItem(writer *bufio.Writer, item *model.Item) error {
// Write scheduling info if not already in notes // Write scheduling info if not already in notes
hasScheduled := false hasScheduled := false
hasDeadline := false hasDeadline := false
hasClosed := false
hasLogbook := false hasLogbook := false
hasProperties := false hasProperties := false
for _, note := range item.Notes { for _, note := range item.Notes {
@ -137,6 +138,9 @@ func writeItem(writer *bufio.Writer, item *model.Item) error {
if strings.Contains(note, "DEADLINE:") { if strings.Contains(note, "DEADLINE:") {
hasDeadline = true hasDeadline = true
} }
if strings.Contains(note, "CLOSED:") {
hasClosed = true
}
if strings.Contains(note, ":LOGBOOK:") { if strings.Contains(note, ":LOGBOOK:") {
hasLogbook = true hasLogbook = true
} }
@ -145,6 +149,13 @@ func writeItem(writer *bufio.Writer, item *model.Item) error {
} }
} }
if item.Closed != nil && !hasClosed {
closedLine := fmt.Sprintf("CLOSED: [%s]\n", formatClockTimestamp(*item.Closed))
if _, err := writer.WriteString(closedLine); err != nil {
return err
}
}
if item.Scheduled != nil && !hasScheduled { if item.Scheduled != nil && !hasScheduled {
scheduledLine := fmt.Sprintf("SCHEDULED: <%s>\n", FormatOrgDate(*item.Scheduled)) scheduledLine := fmt.Sprintf("SCHEDULED: <%s>\n", FormatOrgDate(*item.Scheduled))
if _, err := writer.WriteString(scheduledLine); err != nil { if _, err := writer.WriteString(scheduledLine); err != nil {

View file

@ -22,6 +22,7 @@ const (
modeCapture modeCapture
modeAddSubTask modeAddSubTask
modeSetDeadline modeSetDeadline
modeSetScheduled
modeSetPriority modeSetPriority
modeSetEffort modeSetEffort
modeHelp modeHelp
@ -55,7 +56,7 @@ type uiModel struct {
captureCursor int // Store cursor position when entering capture mode captureCursor int // Store cursor position when entering capture mode
} }
func InitialModel(orgFile *model.OrgFile, cfg *config.Config) uiModel { func InitialModel(orgFile *model.OrgFile, cfg *config.Config, captureMode bool, captureText string) uiModel {
ta := textarea.New() ta := textarea.New()
ta.Placeholder = "Enter notes here (code blocks supported)..." ta.Placeholder = "Enter notes here (code blocks supported)..."
ta.ShowLineNumbers = false ta.ShowLineNumbers = false
@ -67,10 +68,16 @@ func InitialModel(orgFile *model.OrgFile, cfg *config.Config) uiModel {
h := help.New() h := help.New()
h.ShowAll = false h.ShowAll = false
mode := modeList
if captureMode {
mode = modeCapture
ti.SetValue(strings.TrimSpace(captureText))
}
return uiModel{ return uiModel{
orgFile: orgFile, orgFile: orgFile,
cursor: 0, cursor: 0,
mode: modeList, mode: mode,
help: h, help: h,
keys: newKeyMapFromConfig(cfg), keys: newKeyMapFromConfig(cfg),
styles: newStyleMapFromConfig(cfg), styles: newStyleMapFromConfig(cfg),
@ -81,6 +88,9 @@ func InitialModel(orgFile *model.OrgFile, cfg *config.Config) uiModel {
} }
func (m uiModel) Init() tea.Cmd { func (m uiModel) Init() tea.Cmd {
if m.mode == modeCapture {
return textinput.Blink
}
return nil return nil
} }
@ -112,7 +122,7 @@ func (m *uiModel) updateScrollOffset(availableHeight int) {
noteIndent := indent + " " noteIndent := indent + " "
filteredNotes := filterLogbookDrawer(item.Notes) filteredNotes := filterLogbookDrawer(item.Notes)
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent) wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
highlightedNotes := renderNotesWithHighlighting(wrappedNotes) highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
lineCount += len(highlightedNotes) lineCount += len(highlightedNotes)
} }
itemLineCount[i] = lineCount itemLineCount[i] = lineCount
@ -140,8 +150,12 @@ func (m *uiModel) updateScrollOffset(availableHeight int) {
} }
// RunUI starts the terminal UI // RunUI starts the terminal UI
func RunUI(orgFile *model.OrgFile, cfg *config.Config) error { func RunUI(orgFile *model.OrgFile, cfg *config.Config, captureMode bool, captureText string) error {
p := tea.NewProgram(InitialModel(orgFile, cfg), tea.WithAltScreen()) m := InitialModel(orgFile, cfg, captureMode, captureText)
if captureMode {
m.textinput.Focus()
}
p := tea.NewProgram(m, tea.WithAltScreen())
_, err := p.Run() _, err := p.Run()
return err return err
} }

View file

@ -26,11 +26,13 @@ type keyMap struct {
Delete key.Binding Delete key.Binding
Save key.Binding Save key.Binding
ToggleFold key.Binding ToggleFold key.Binding
ToggleFoldAll key.Binding
EditNotes key.Binding EditNotes key.Binding
ToggleReorder key.Binding ToggleReorder key.Binding
ClockIn key.Binding ClockIn key.Binding
ClockOut key.Binding ClockOut key.Binding
SetDeadline key.Binding SetDeadline key.Binding
SetScheduled key.Binding
SetPriority key.Binding SetPriority key.Binding
SetEffort key.Binding SetEffort key.Binding
Settings key.Binding Settings key.Binding
@ -86,6 +88,10 @@ func newKeyMapFromConfig(cfg *config.Config) keyMap {
key.WithKeys(kb.ToggleFold...), key.WithKeys(kb.ToggleFold...),
key.WithHelp(formatKeyHelp(kb.ToggleFold), "fold/unfold"), key.WithHelp(formatKeyHelp(kb.ToggleFold), "fold/unfold"),
), ),
ToggleFoldAll: key.NewBinding(
key.WithKeys(kb.ToggleFoldAll...),
key.WithHelp(formatKeyHelp(kb.ToggleFoldAll), "fold/unfold all"),
),
EditNotes: key.NewBinding( EditNotes: key.NewBinding(
key.WithKeys(kb.EditNotes...), key.WithKeys(kb.EditNotes...),
key.WithHelp(formatKeyHelp(kb.EditNotes), "edit notes"), key.WithHelp(formatKeyHelp(kb.EditNotes), "edit notes"),
@ -126,6 +132,10 @@ func newKeyMapFromConfig(cfg *config.Config) keyMap {
key.WithKeys(kb.SetDeadline...), key.WithKeys(kb.SetDeadline...),
key.WithHelp(formatKeyHelp(kb.SetDeadline), "set deadline"), key.WithHelp(formatKeyHelp(kb.SetDeadline), "set deadline"),
), ),
SetScheduled: key.NewBinding(
key.WithKeys(kb.SetScheduled...),
key.WithHelp(formatKeyHelp(kb.SetScheduled), "set scheduled"),
),
SetPriority: key.NewBinding( SetPriority: key.NewBinding(
key.WithKeys(kb.SetPriority...), key.WithKeys(kb.SetPriority...),
key.WithHelp(formatKeyHelp(kb.SetPriority), "set priority"), key.WithHelp(formatKeyHelp(kb.SetPriority), "set priority"),
@ -183,7 +193,7 @@ func (k keyMap) FullHelp() [][]key.Binding {
// This will be overridden by custom rendering in viewFullHelp // This will be overridden by custom rendering in viewFullHelp
return [][]key.Binding{ return [][]key.Binding{
{k.Up, k.Down, k.Left, k.Right}, {k.Up, k.Down, k.Left, k.Right},
{k.ToggleFold, k.EditNotes, k.ToggleReorder}, {k.ToggleFold, k.ToggleFoldAll, k.EditNotes, k.ToggleReorder},
{k.Capture, k.AddSubTask, k.Delete, k.Save}, {k.Capture, k.AddSubTask, k.Delete, k.Save},
{k.ToggleView, k.Help, k.Quit}, {k.ToggleView, k.Help, k.Quit},
} }
@ -193,9 +203,9 @@ func (k keyMap) FullHelp() [][]key.Binding {
func (k keyMap) getAllBindings() []key.Binding { func (k keyMap) getAllBindings() []key.Binding {
return []key.Binding{ return []key.Binding{
k.Up, k.Down, k.Left, k.Right, k.Up, k.Down, k.Left, k.Right,
k.ToggleFold, k.EditNotes, k.ToggleReorder, k.ToggleFold, k.ToggleFoldAll, k.EditNotes, k.ToggleReorder,
k.Capture, k.AddSubTask, k.Delete, k.Save, k.Capture, k.AddSubTask, k.Delete, k.Save,
k.ClockIn, k.ClockOut, k.SetDeadline, k.SetPriority, k.SetEffort, k.ClockIn, k.ClockOut, k.SetDeadline, k.SetScheduled, k.SetPriority, k.SetEffort,
k.TagItem, k.Settings, k.ToggleView, k.Help, k.Quit, k.TagItem, k.Settings, k.ToggleView, k.Help, k.Quit,
} }
} }

View file

@ -26,6 +26,8 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateAddSubTask(msg) return m.updateAddSubTask(msg)
case modeSetDeadline: case modeSetDeadline:
return m.updateSetDeadline(msg) return m.updateSetDeadline(msg)
case modeSetScheduled:
return m.updateSetScheduled(msg)
case modeSetPriority: case modeSetPriority:
return m.updateSetPriority(msg) return m.updateSetPriority(msg)
case modeSetEffort: case modeSetEffort:
@ -153,6 +155,46 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
} }
case key.Matches(msg, m.keys.ToggleFoldAll):
if len(m.orgFile.Items) > 0 {
// Check if any top-level item is not folded
anyUnfolded := false
for _, item := range m.orgFile.Items {
if !item.Folded {
anyUnfolded = true
break
}
}
if anyUnfolded {
// Fold all items recursively (collapse all)
var foldAll func([]*model.Item)
foldAll = func(items []*model.Item) {
for _, item := range items {
item.Folded = true
if len(item.Children) > 0 {
foldAll(item.Children)
}
}
}
foldAll(m.orgFile.Items)
m.setStatus("All items folded")
} else {
// Unfold everything recursively
var unfoldAll func([]*model.Item)
unfoldAll = func(items []*model.Item) {
for _, item := range items {
item.Folded = false
if len(item.Children) > 0 {
unfoldAll(item.Children)
}
}
}
unfoldAll(m.orgFile.Items)
m.setStatus("All items unfolded")
}
}
case key.Matches(msg, m.keys.EditNotes): case key.Matches(msg, m.keys.EditNotes):
items := m.getVisibleItems() items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) { if len(items) > 0 && m.cursor < len(items) {
@ -281,6 +323,17 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, textinput.Blink return m, textinput.Blink
} }
case key.Matches(msg, m.keys.SetScheduled):
items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) {
m.editingItem = items[m.cursor]
m.mode = modeSetScheduled
m.textinput.SetValue("")
m.textinput.Placeholder = "YYYY-MM-DD or +N (days from today)"
m.textinput.Focus()
return m, textinput.Blink
}
case key.Matches(msg, m.keys.SetPriority): case key.Matches(msg, m.keys.SetPriority):
items := m.getVisibleItems() items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) { if len(items) > 0 && m.cursor < len(items) {
@ -521,83 +574,11 @@ func (m uiModel) updateAddSubTask(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m uiModel) updateSetDeadline(msg tea.Msg) (tea.Model, tea.Cmd) { func (m uiModel) updateSetDeadline(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd return m.updateSetDate(msg, "DEADLINE")
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.textinput.Width = 50
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) // parseDateInput parses date input like "2024-01-15" or "+3" (3 days from now)
return m, cmd func parseDateInput(input string) (time.Time, error) {
}
// 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) // Check if it's a relative date (+N days)
if strings.HasPrefix(input, "+") { if strings.HasPrefix(input, "+") {
daysStr := strings.TrimPrefix(input, "+") daysStr := strings.TrimPrefix(input, "+")
@ -625,6 +606,110 @@ func parseDeadlineInput(input string) (time.Time, error) {
return time.Time{}, fmt.Errorf("unable to parse date: %s (use YYYY-MM-DD or +N)", input) return time.Time{}, fmt.Errorf("unable to parse date: %s (use YYYY-MM-DD or +N)", input)
} }
func (m uiModel) updateSetScheduled(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateSetDate(msg, "SCHEDULED")
}
func (m uiModel) updateSetDate(msg tea.Msg, dateType string) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.textinput.Width = 50
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEnter:
input := strings.TrimSpace(m.textinput.Value())
if m.editingItem != nil {
var prefixDate string
var clearedDateMsg string
var setDateMsg string
if dateType == "DEADLINE" {
prefixDate = "DEADLINE:"
clearedDateMsg = "Deadline cleared!"
setDateMsg = "Deadline set!"
} else {
prefixDate = "SCHEDULED:"
clearedDateMsg = "Scheduled date cleared!"
setDateMsg = "Scheduled date set!"
}
if input == "" {
// Empty input clears the date
if dateType == "DEADLINE" {
m.editingItem.Deadline = nil
} else {
m.editingItem.Scheduled = nil
}
// Remove property line from notes
var filteredNotes []string
for _, note := range m.editingItem.Notes {
trimmedNote := strings.TrimSpace(note)
if !strings.HasPrefix(trimmedNote, prefixDate) {
filteredNotes = append(filteredNotes, note)
}
}
m.editingItem.Notes = filteredNotes
m.setStatus(clearedDateMsg)
} else {
dateVal, err := parseDateInput(input)
if err != nil {
m.setStatus(fmt.Sprintf("Invalid date: %v", err))
} else {
if dateType == "DEADLINE" {
m.editingItem.Deadline = &dateVal
} else {
m.editingItem.Scheduled = &dateVal
}
// Also update or add property line in notes
updatedNotes := false
for i, note := range m.editingItem.Notes {
trimmedNote := strings.TrimSpace(note)
if strings.HasPrefix(trimmedNote, prefixDate) {
m.editingItem.Notes[i] = fmt.Sprintf("%s <%s>", prefixDate, parser.FormatOrgDate(dateVal))
updatedNotes = true
break
}
}
// If property wasn't in notes, it will be added by writeItem
if !updatedNotes {
// Remove old property lines just to be safe
var filteredNotes []string
for _, note := range m.editingItem.Notes {
trimmedNote := strings.TrimSpace(note)
if !strings.HasPrefix(trimmedNote, prefixDate) {
filteredNotes = append(filteredNotes, note)
}
}
m.editingItem.Notes = filteredNotes
}
m.setStatus(setDateMsg)
}
}
}
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) updateSetPriority(msg tea.Msg) (tea.Model, tea.Cmd) { func (m uiModel) updateSetPriority(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
@ -726,6 +811,7 @@ func (m *uiModel) cycleStateForward(item *model.Item) {
// Find current state index // Find current state index
currentIndex := -1 currentIndex := -1
currentState := string(item.State) currentState := string(item.State)
lastStateIndex := len(stateNames) - 1
// Handle empty state // Handle empty state
if currentState == "" { if currentState == "" {
@ -739,15 +825,51 @@ func (m *uiModel) cycleStateForward(item *model.Item) {
} }
} }
// Store the old state to check if we're transitioning to/from DONE
oldState := currentState
var newState string
// Cycle forward // Cycle forward
if currentIndex < 0 || currentIndex >= len(stateNames)-1 { if currentIndex < 0 || currentIndex >= len(stateNames)-1 {
if currentIndex == len(stateNames)-1 { if currentIndex == len(stateNames)-1 {
item.State = model.TodoState("") // Back to empty newState = "" // Back to empty
} else { } else {
item.State = model.TodoState(stateNames[0]) // First state newState = stateNames[0] // First state
} }
} else { } else {
item.State = model.TodoState(stateNames[currentIndex+1]) newState = stateNames[currentIndex+1]
}
// Update the item state
item.State = model.TodoState(newState)
// Manage CLOSED timestamp
wasInDoneState := (oldState == stateNames[lastStateIndex])
isInDoneState := (newState == stateNames[lastStateIndex])
if isInDoneState && !wasInDoneState {
// Moving TO done state - add CLOSED timestamp
now := time.Now()
item.Closed = &now
// Remove any existing CLOSED line from notes
var filteredNotes []string
for _, note := range item.Notes {
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
filteredNotes = append(filteredNotes, note)
}
}
item.Notes = filteredNotes
} else if wasInDoneState && !isInDoneState {
// Moving FROM done state - remove CLOSED timestamp
item.Closed = nil
// Remove any existing CLOSED line from notes
var filteredNotes []string
for _, note := range item.Notes {
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
filteredNotes = append(filteredNotes, note)
}
}
item.Notes = filteredNotes
} }
} }
@ -760,6 +882,7 @@ func (m *uiModel) cycleStateBackward(item *model.Item) {
// Find current state index // Find current state index
currentIndex := -1 currentIndex := -1
currentState := string(item.State) currentState := string(item.State)
lastStateIndex := len(stateNames) - 1
// Handle empty state // Handle empty state
if currentState == "" { if currentState == "" {
@ -773,13 +896,49 @@ func (m *uiModel) cycleStateBackward(item *model.Item) {
} }
} }
// Store the old state to check if we're transitioning to/from DONE
oldState := currentState
var newState string
// Cycle backward // Cycle backward
if currentIndex <= 0 { if currentIndex <= 0 {
item.State = model.TodoState("") // Empty state newState = "" // Empty state
} else if currentIndex > len(stateNames) { } else if currentIndex > len(stateNames) {
item.State = model.TodoState(stateNames[len(stateNames)-1]) newState = stateNames[len(stateNames)-1]
} else { } else {
item.State = model.TodoState(stateNames[currentIndex-1]) newState = stateNames[currentIndex-1]
}
// Update the item state
item.State = model.TodoState(newState)
// Manage CLOSED timestamp
wasInDoneState := (oldState == stateNames[lastStateIndex])
isInDoneState := (newState == stateNames[lastStateIndex])
if isInDoneState && !wasInDoneState {
// Moving TO done state - add CLOSED timestamp
now := time.Now()
item.Closed = &now
// Remove any existing CLOSED line from notes
var filteredNotes []string
for _, note := range item.Notes {
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
filteredNotes = append(filteredNotes, note)
}
}
item.Notes = filteredNotes
} else if wasInDoneState && !isInDoneState {
// Moving FROM done state - remove CLOSED timestamp
item.Closed = nil
// Remove any existing CLOSED line from notes
var filteredNotes []string
for _, note := range item.Notes {
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
filteredNotes = append(filteredNotes, note)
}
}
item.Notes = filteredNotes
} }
} }

View file

@ -13,7 +13,8 @@ import (
type settingsSection int type settingsSection int
const ( const (
settingsSectionTags settingsSection = iota settingsSectionGeneral settingsSection = iota
settingsSectionTags
settingsSectionStates settingsSectionStates
settingsSectionKeybindings settingsSectionKeybindings
) )
@ -76,7 +77,7 @@ func (m *uiModel) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Left): case key.Matches(msg, m.keys.Left):
// Previous section // Previous section
if m.settingsSection > settingsSectionTags { if m.settingsSection > settingsSectionGeneral {
m.settingsSection-- m.settingsSection--
m.settingsCursor = 0 m.settingsCursor = 0
m.settingsScroll = 0 m.settingsScroll = 0
@ -101,6 +102,8 @@ func (m *uiModel) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Capture): case key.Matches(msg, m.keys.Capture):
// Add new tag or state // Add new tag or state
switch m.settingsSection { switch m.settingsSection {
case settingsSectionGeneral:
// No capture action in General
case settingsSectionTags: case settingsSectionTags:
m.addNewTag() m.addNewTag()
case settingsSectionStates: case settingsSectionStates:
@ -128,6 +131,8 @@ func (m *uiModel) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
// getSettingsItemCount returns the number of items in the current settings view // getSettingsItemCount returns the number of items in the current settings view
func (m *uiModel) getSettingsItemCount() int { func (m *uiModel) getSettingsItemCount() int {
switch m.settingsSection { switch m.settingsSection {
case settingsSectionGeneral:
return 3 // Org syntax highlighting toggle, show indentation guides toggle, indentation guide color
case settingsSectionTags: case settingsSectionTags:
return len(m.config.Tags.Tags) + 1 // +1 for "Add new tag" option return len(m.config.Tags.Tags) + 1 // +1 for "Add new tag" option
case settingsSectionStates: case settingsSectionStates:
@ -172,6 +177,40 @@ func (m *uiModel) updateSettingsScrollOffset() {
// startSettingsEdit starts editing a settings item // startSettingsEdit starts editing a settings item
func (m *uiModel) startSettingsEdit() { func (m *uiModel) startSettingsEdit() {
switch m.settingsSection { switch m.settingsSection {
case settingsSectionGeneral:
// Setting 0: Toggle org syntax highlighting
if m.settingsCursor == 0 {
m.config.UI.OrgSyntaxHighlighting = !m.config.UI.OrgSyntaxHighlighting
if m.config.UI.OrgSyntaxHighlighting {
m.setStatus("Org syntax highlighting enabled (saved)")
} else {
m.setStatus("Org syntax highlighting disabled (saved)")
}
// Auto-save
if err := m.config.Save(); err != nil {
m.setStatus(fmt.Sprintf("Error auto-saving config: %v", err))
}
}
// Setting 1: Toggle show indentation guides
if m.settingsCursor == 1 {
m.config.UI.ShowIndentationGuides = !m.config.UI.ShowIndentationGuides
if m.config.UI.ShowIndentationGuides {
m.setStatus("Indentation guides enabled (saved)")
} else {
m.setStatus("Indentation guides disabled (saved)")
}
// Auto-save
if err := m.config.Save(); err != nil {
m.setStatus(fmt.Sprintf("Error auto-saving config: %v", err))
}
}
// Setting 2: Edit indentation guide color
if m.settingsCursor == 2 {
m.textinput.SetValue(m.config.UI.IndentationGuideColor)
m.textinput.Placeholder = "Enter color (e.g., 245, 99)"
m.textinput.Focus()
}
return
case settingsSectionTags: case settingsSectionTags:
if m.settingsCursor >= len(m.config.Tags.Tags) { if m.settingsCursor >= len(m.config.Tags.Tags) {
return return
@ -237,6 +276,23 @@ func (m *uiModel) startSettingsEdit() {
// saveSettingsEdit saves the edited value and auto-saves to disk // saveSettingsEdit saves the edited value and auto-saves to disk
func (m *uiModel) saveSettingsEdit() { func (m *uiModel) saveSettingsEdit() {
switch m.settingsSection { switch m.settingsSection {
case settingsSectionGeneral:
// Setting 2: Indentation guide color
if m.settingsCursor == 2 {
newColor := strings.TrimSpace(m.textinput.Value())
if newColor != "" {
m.config.UI.IndentationGuideColor = newColor
m.setStatus(fmt.Sprintf("Indentation guide color set to '%s' (saved)", newColor))
// Auto-save
if err := m.config.Save(); err != nil {
m.setStatus(fmt.Sprintf("Error auto-saving config: %v", err))
} else {
// Reload styles
m.styles = newStyleMapFromConfig(m.config)
}
}
}
return
case settingsSectionTags: case settingsSectionTags:
if m.settingsCursor >= len(m.config.Tags.Tags) { if m.settingsCursor >= len(m.config.Tags.Tags) {
return return
@ -350,6 +406,9 @@ func (m *uiModel) saveSettingsEdit() {
// deleteSettingsItem deletes the current settings item and auto-saves // deleteSettingsItem deletes the current settings item and auto-saves
func (m *uiModel) deleteSettingsItem() { func (m *uiModel) deleteSettingsItem() {
switch m.settingsSection { switch m.settingsSection {
case settingsSectionGeneral:
// Cannot delete general settings
return
case settingsSectionTags: case settingsSectionTags:
if m.settingsCursor >= len(m.config.Tags.Tags) { if m.settingsCursor >= len(m.config.Tags.Tags) {
return return
@ -437,6 +496,12 @@ func (m *uiModel) viewSettings() string {
activeTabStyle := lipgloss.NewStyle().Padding(0, 2).Bold(true).Foreground(lipgloss.Color(m.config.Colors.Title)) activeTabStyle := lipgloss.NewStyle().Padding(0, 2).Bold(true).Foreground(lipgloss.Color(m.config.Colors.Title))
tabs := "" tabs := ""
if m.settingsSection == settingsSectionGeneral {
tabs += activeTabStyle.Render("[General]")
} else {
tabs += tabStyle.Render("General")
}
tabs += " "
if m.settingsSection == settingsSectionTags { if m.settingsSection == settingsSectionTags {
tabs += activeTabStyle.Render("[Tags]") tabs += activeTabStyle.Render("[Tags]")
} else { } else {
@ -459,6 +524,8 @@ func (m *uiModel) viewSettings() string {
// Instructions // Instructions
var instructions string var instructions string
switch m.settingsSection { switch m.settingsSection {
case settingsSectionGeneral:
instructions = "←/→: Switch tabs • ↑/↓: Navigate • Enter: Toggle setting\nctrl+s: Save • q/,: Exit"
case settingsSectionTags: case settingsSectionTags:
instructions = "←/→: Switch tabs • ↑/↓: Navigate • Enter: Edit • D: Delete\nc: Add new tag • ctrl+s: Save • q/,: Exit" instructions = "←/→: Switch tabs • ↑/↓: Navigate • Enter: Edit • D: Delete\nc: Add new tag • ctrl+s: Save • q/,: Exit"
case settingsSectionStates: case settingsSectionStates:
@ -470,6 +537,8 @@ func (m *uiModel) viewSettings() string {
// Render the appropriate section // Render the appropriate section
switch m.settingsSection { switch m.settingsSection {
case settingsSectionGeneral:
content.WriteString(m.viewSettingsGeneral())
case settingsSectionTags: case settingsSectionTags:
content.WriteString(m.viewSettingsTags()) content.WriteString(m.viewSettingsTags())
case settingsSectionStates: case settingsSectionStates:
@ -488,6 +557,68 @@ func (m *uiModel) viewSettings() string {
return content.String() return content.String()
} }
// viewSettingsGeneral renders the general settings section
func (m *uiModel) viewSettingsGeneral() string {
var content strings.Builder
// Calculate visible window
reservedLines := 10
if m.textinput.Focused() {
reservedLines += 3
}
availableHeight := m.height - reservedLines
if availableHeight < 3 {
availableHeight = 3
}
enabledStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("34")).Bold(true)
disabledStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
colorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
// Setting 0: Org syntax highlighting toggle
line := ""
if m.settingsCursor == 0 && !m.textinput.Focused() {
line += "▶ "
} else {
line += " "
}
line += "Org syntax highlighting: "
if m.config.UI.OrgSyntaxHighlighting {
line += enabledStyle.Render("Enabled")
} else {
line += disabledStyle.Render("Disabled")
}
content.WriteString(line + "\n")
// Setting 1: Show indentation guides toggle
line = ""
if m.settingsCursor == 1 && !m.textinput.Focused() {
line += "▶ "
} else {
line += " "
}
line += "Show indentation guides: "
if m.config.UI.ShowIndentationGuides {
line += enabledStyle.Render("Enabled")
} else {
line += disabledStyle.Render("Disabled")
}
content.WriteString(line + "\n")
// Setting 2: Indentation guide color
line = ""
if m.settingsCursor == 2 && !m.textinput.Focused() {
line += "▶ "
} else {
line += " "
}
line += "Indentation guide color: "
line += colorStyle.Render(m.config.UI.IndentationGuideColor)
content.WriteString(line + "\n")
return content.String()
}
// viewSettingsTags renders the tags section // viewSettingsTags renders the tags section
func (m *uiModel) viewSettingsTags() string { func (m *uiModel) viewSettingsTags() string {
var content strings.Builder var content strings.Builder

View file

@ -78,6 +78,8 @@ func (m uiModel) View() string {
return m.viewAddSubTask() return m.viewAddSubTask()
case modeSetDeadline: case modeSetDeadline:
return m.viewSetDeadline() return m.viewSetDeadline()
case modeSetScheduled:
return m.viewSetScheduled()
case modeSetPriority: case modeSetPriority:
return m.viewSetPriority() return m.viewSetPriority()
case modeSetEffort: case modeSetEffort:
@ -148,17 +150,24 @@ func (m uiModel) View() string {
for i, item := range items { for i, item := range items {
lineCount := 1 // The item itself lineCount := 1 // The item itself
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList { if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
// Build subtle visual guides for notes // Build indentation for notes
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
var notePrefix strings.Builder var notePrefix strings.Builder
if m.config.UI.ShowIndentationGuides {
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
for j := 1; j <= item.Level; j++ { for j := 1; j <= item.Level; j++ {
notePrefix.WriteString(guideStyle.Render("· ")) notePrefix.WriteString(guideStyle.Render("· "))
} }
} else {
// No visual guides, just use spaces
for j := 1; j <= item.Level; j++ {
notePrefix.WriteString(" ")
}
}
indent := notePrefix.String() indent := notePrefix.String()
noteIndent := indent + " " noteIndent := indent + " "
filteredNotes := filterLogbookDrawer(item.Notes) filteredNotes := filterLogbookDrawer(item.Notes)
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent) wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
highlightedNotes := renderNotesWithHighlighting(wrappedNotes) highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
lineCount += len(highlightedNotes) lineCount += len(highlightedNotes)
} }
itemLineCount[i] = lineCount itemLineCount[i] = lineCount
@ -215,17 +224,24 @@ func (m uiModel) View() string {
// Render remaining notes // Render remaining notes
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList { if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
// Build subtle visual guides for notes // Build indentation for notes
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
var notePrefix strings.Builder var notePrefix strings.Builder
if m.config.UI.ShowIndentationGuides {
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
for i := 1; i <= item.Level; i++ { for i := 1; i <= item.Level; i++ {
notePrefix.WriteString(guideStyle.Render("· ")) notePrefix.WriteString(guideStyle.Render("· "))
} }
} else {
// No visual guides, just use spaces
for i := 1; i <= item.Level; i++ {
notePrefix.WriteString(" ")
}
}
indent := notePrefix.String() indent := notePrefix.String()
noteIndent := indent + " " noteIndent := indent + " "
filteredNotes := filterLogbookDrawer(item.Notes) filteredNotes := filterLogbookDrawer(item.Notes)
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent) wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
highlightedNotes := renderNotesWithHighlighting(wrappedNotes) highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
for noteIdx := linesToSkip - 1; noteIdx < len(highlightedNotes) && itemLines < availableHeight; noteIdx++ { for noteIdx := linesToSkip - 1; noteIdx < len(highlightedNotes) && itemLines < availableHeight; noteIdx++ {
content.WriteString(indent) content.WriteString(indent)
content.WriteString(" " + highlightedNotes[noteIdx]) content.WriteString(" " + highlightedNotes[noteIdx])
@ -245,17 +261,24 @@ func (m uiModel) View() string {
// Show notes if not folded // Show notes if not folded
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList { if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
// Build subtle visual guides for notes // Build indentation for notes
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("235"))
var notePrefix strings.Builder var notePrefix strings.Builder
if m.config.UI.ShowIndentationGuides {
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
for i := 1; i <= item.Level; i++ { for i := 1; i <= item.Level; i++ {
notePrefix.WriteString(guideStyle.Render("· ")) notePrefix.WriteString(guideStyle.Render("· "))
} }
} else {
// No visual guides, just use spaces
for i := 1; i <= item.Level; i++ {
notePrefix.WriteString(" ")
}
}
indent := notePrefix.String() indent := notePrefix.String()
noteIndent := indent + " " noteIndent := indent + " "
filteredNotes := filterLogbookDrawer(item.Notes) filteredNotes := filterLogbookDrawer(item.Notes)
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent) wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
highlightedNotes := renderNotesWithHighlighting(wrappedNotes) highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
for _, note := range highlightedNotes { for _, note := range highlightedNotes {
if itemLines >= availableHeight { if itemLines >= availableHeight {
break break
@ -358,6 +381,14 @@ func (m uiModel) viewAddSubTask() string {
} }
func (m uiModel) viewSetDeadline() string { func (m uiModel) viewSetDeadline() string {
return m.viewSetDate("Set Deadline", "Leave empty to clear deadline")
}
func (m uiModel) viewSetScheduled() string {
return m.viewSetDate("Set Scheduled Date", "Leave empty to clear scheduled date")
}
func (m uiModel) viewSetDate(title, helpMsg string) string {
dialogStyle := lipgloss.NewStyle(). dialogStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("141")). BorderForeground(lipgloss.Color("141")).
@ -365,7 +396,7 @@ func (m uiModel) viewSetDeadline() string {
Width(60) Width(60)
var content strings.Builder var content strings.Builder
content.WriteString(m.styles.titleStyle.Render("Set Deadline")) content.WriteString(m.styles.titleStyle.Render(title))
content.WriteString("\n") content.WriteString("\n")
if m.editingItem != nil { if m.editingItem != nil {
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title))) content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title)))
@ -375,7 +406,7 @@ func (m uiModel) viewSetDeadline() string {
content.WriteString("\n\n") content.WriteString("\n\n")
content.WriteString(m.styles.statusStyle.Render("Examples: 2025-12-31, +7 (7 days from now)")) content.WriteString(m.styles.statusStyle.Render("Examples: 2025-12-31, +7 (7 days from now)"))
content.WriteString("\n") content.WriteString("\n")
content.WriteString(m.styles.statusStyle.Render("Leave empty to clear deadline")) content.WriteString(m.styles.statusStyle.Render(helpMsg))
content.WriteString("\n") content.WriteString("\n")
content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel")) content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel"))
@ -467,7 +498,7 @@ func (m uiModel) viewHelp() string {
navigationBindings := []key.Binding{m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right} navigationBindings := []key.Binding{m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right}
itemBindings := []key.Binding{m.keys.ToggleFold, m.keys.EditNotes, m.keys.CycleState} itemBindings := []key.Binding{m.keys.ToggleFold, m.keys.EditNotes, m.keys.CycleState}
taskBindings := []key.Binding{m.keys.Capture, m.keys.AddSubTask, m.keys.Delete} taskBindings := []key.Binding{m.keys.Capture, m.keys.AddSubTask, m.keys.Delete}
timeBindings := []key.Binding{m.keys.ClockIn, m.keys.ClockOut, m.keys.SetDeadline, m.keys.SetEffort} timeBindings := []key.Binding{m.keys.ClockIn, m.keys.ClockOut, m.keys.SetDeadline, m.keys.SetScheduled, m.keys.SetEffort}
organizationBindings := []key.Binding{m.keys.SetPriority, m.keys.TagItem, m.keys.ShiftUp, m.keys.ShiftDown, m.keys.ToggleReorder} organizationBindings := []key.Binding{m.keys.SetPriority, m.keys.TagItem, m.keys.ShiftUp, m.keys.ShiftDown, m.keys.ToggleReorder}
viewBindings := []key.Binding{m.keys.ToggleView, m.keys.Settings, m.keys.Save, m.keys.Help, m.keys.Quit} viewBindings := []key.Binding{m.keys.ToggleView, m.keys.Settings, m.keys.Save, m.keys.Help, m.keys.Quit}
@ -626,8 +657,8 @@ func filterLogbookDrawer(notes []string) []string {
continue continue
} }
// Skip SCHEDULED and DEADLINE lines // Skip SCHEDULED, DEADLINE, and CLOSED lines
if strings.HasPrefix(trimmed, "SCHEDULED:") || strings.HasPrefix(trimmed, "DEADLINE:") { if strings.HasPrefix(trimmed, "SCHEDULED:") || strings.HasPrefix(trimmed, "DEADLINE:") || strings.HasPrefix(trimmed, "CLOSED:") {
continue continue
} }
@ -661,7 +692,7 @@ func wrapNoteLines(notes []string, width int, indent string) []string {
} }
// renderNotesWithHighlighting renders notes with syntax highlighting for code blocks // renderNotesWithHighlighting renders notes with syntax highlighting for code blocks
func renderNotesWithHighlighting(notes []string) []string { func (m uiModel) renderNotesWithHighlighting(notes []string) []string {
if len(notes) == 0 { if len(notes) == 0 {
return notes return notes
} }
@ -761,10 +792,16 @@ func renderNotesWithHighlighting(notes []string) []string {
// If in code block, accumulate lines // If in code block, accumulate lines
if inCodeBlock { if inCodeBlock {
codeLines = append(codeLines, note) codeLines = append(codeLines, note)
} else {
// Apply org-mode syntax highlighting to non-code text if enabled
if m.config.UI.OrgSyntaxHighlighting {
highlighted := highlightCode(note, "org")
result = append(result, highlighted)
} else { } else {
result = append(result, note) result = append(result, note)
} }
} }
}
// Handle case where code block wasn't closed // Handle case where code block wasn't closed
if inCodeBlock && len(codeLines) > 0 { if inCodeBlock && len(codeLines) > 0 {
@ -894,7 +931,8 @@ func (m uiModel) renderItem(item *model.Item, isCursor bool) string {
var b strings.Builder var b strings.Builder
// Indentation with subtle visual nesting guides // Indentation with subtle visual nesting guides
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) // Very subtle gray if m.config.UI.ShowIndentationGuides {
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
for i := 1; i < item.Level; i++ { for i := 1; i < item.Level; i++ {
if i == item.Level-1 { if i == item.Level-1 {
// Last level before the item - use subtle dot connector // Last level before the item - use subtle dot connector
@ -904,6 +942,12 @@ func (m uiModel) renderItem(item *model.Item, isCursor bool) string {
b.WriteString(guideStyle.Render("· ")) b.WriteString(guideStyle.Render("· "))
} }
} }
} else {
// No visual guides, just use spaces for indentation
for i := 1; i < item.Level; i++ {
b.WriteString(" ")
}
}
// Fold indicator // Fold indicator
if len(item.Children) > 0 || len(item.Notes) > 0 { if len(item.Children) > 0 || len(item.Notes) > 0 {