diff --git a/.imgs/capture_prompt.png b/.imgs/capture_prompt.png new file mode 100644 index 0000000..5d6f764 Binary files /dev/null and b/.imgs/capture_prompt.png differ diff --git a/.imgs/delete_prompt.png b/.imgs/delete_prompt.png new file mode 100644 index 0000000..e9c2641 Binary files /dev/null and b/.imgs/delete_prompt.png differ diff --git a/.imgs/editing.png b/.imgs/editing.png new file mode 100644 index 0000000..eafc87c Binary files /dev/null and b/.imgs/editing.png differ diff --git a/.imgs/list_view.png b/.imgs/list_view.png new file mode 100644 index 0000000..d952b97 Binary files /dev/null and b/.imgs/list_view.png differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c8683a7 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Org + +Org is a simple orgmode application inspired from the simplicity of `nano`. + + +## Screenshots + +### List view +![list view](./.imgs/list_view.png) + +## Editing notes +![editing](./.imgs/editing.png) + +### Prompts +![capture](./.imgs/capture_prompt.png) +![delete](./.imgs/delete_prompt.png) \ No newline at end of file diff --git a/internal/model/item.go b/internal/model/item.go index 1aaa1db..1b1fa6e 100644 --- a/internal/model/item.go +++ b/internal/model/item.go @@ -2,13 +2,25 @@ package model import "time" +// Priority represents org-mode priority levels +type Priority string + +const ( + PriorityNone Priority = "" + PriorityA Priority = "A" + PriorityB Priority = "B" + PriorityC Priority = "C" +) + // 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 + Priority Priority // Priority: A, B, C, or empty Title string // The main title text Scheduled *time.Time Deadline *time.Time + Effort string // Effort estimate (e.g., "8h", "2d") Notes []string // Notes/content under the heading Children []*Item // Sub-items Folded bool // Whether the item is folded (hides notes and children) diff --git a/internal/parser/parser.go b/internal/parser/parser.go index a11e73d..96b3d0e 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -11,14 +11,16 @@ import ( // 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`) + headingPattern = regexp.MustCompile(`^(\*+)\s+(?:(TODO|PROG|BLOCK|DONE)\s+)?(?:\[#([A-C])\]\s+)?(.+)$`) + scheduledPattern = regexp.MustCompile(`SCHEDULED:\s*<([^>]+)>`) + deadlinePattern = regexp.MustCompile(`DEADLINE:\s*<([^>]+)>`) + clockPattern = regexp.MustCompile(`CLOCK:\s*\[([^\]]+)\](?:--\[([^\]]+)\])?`) + effortPattern = regexp.MustCompile(`^\s*:EFFORT:\s*(.+)$`) + logbookDrawerStart = regexp.MustCompile(`^\s*:LOGBOOK:\s*$`) + propertiesDrawerStart = regexp.MustCompile(`^\s*:PROPERTIES:\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 @@ -40,25 +42,42 @@ func ParseOrgFile(path string) (*model.OrgFile, error) { var itemStack []*model.Item // Stack to track parent items var inCodeBlock bool var inLogbookDrawer bool + var inPropertiesDrawer bool for scanner.Scan() { line := scanner.Text() // Check for drawer boundaries - if drawerStart.MatchString(line) { + if logbookDrawerStart.MatchString(line) { inLogbookDrawer = true if currentItem != nil { currentItem.Notes = append(currentItem.Notes, line) } continue } - if drawerEnd.MatchString(line) && inLogbookDrawer { - inLogbookDrawer = false + if propertiesDrawerStart.MatchString(line) { + inPropertiesDrawer = true if currentItem != nil { currentItem.Notes = append(currentItem.Notes, line) } continue } + if drawerEnd.MatchString(line) { + if inLogbookDrawer { + inLogbookDrawer = false + if currentItem != nil { + currentItem.Notes = append(currentItem.Notes, line) + } + continue + } + if inPropertiesDrawer { + inPropertiesDrawer = false + if currentItem != nil { + currentItem.Notes = append(currentItem.Notes, line) + } + continue + } + } // Check for code block boundaries if codeBlockStart.MatchString(line) { @@ -88,11 +107,13 @@ func ParseOrgFile(path string) (*model.OrgFile, error) { if matches := headingPattern.FindStringSubmatch(line); matches != nil { level := len(matches[1]) state := model.TodoState(matches[2]) - title := matches[3] + priority := model.Priority(matches[3]) + title := matches[4] item := &model.Item{ Level: level, State: state, + Priority: priority, Title: title, Notes: []string{}, Children: []*model.Item{}, @@ -132,6 +153,11 @@ func ParseOrgFile(path string) (*model.OrgFile, error) { } } + // Check for EFFORT (inside PROPERTIES drawer) + if matches := effortPattern.FindStringSubmatch(line); matches != nil { + currentItem.Effort = strings.TrimSpace(matches[1]) + } + // Check for CLOCK (can be inside or outside drawer) if matches := clockPattern.FindStringSubmatch(line); matches != nil { if startTime, err := parseClockTimestamp(matches[1]); err == nil { diff --git a/internal/parser/writer.go b/internal/parser/writer.go index 791f232..06020ab 100644 --- a/internal/parser/writer.go +++ b/internal/parser/writer.go @@ -37,6 +37,9 @@ func writeItem(writer *bufio.Writer, item *model.Item) error { if item.State != model.StateNone { line += " " + string(item.State) } + if item.Priority != model.PriorityNone { + line += " [#" + string(item.Priority) + "]" + } line += " " + item.Title + "\n" if _, err := writer.WriteString(line); err != nil { @@ -47,6 +50,7 @@ func writeItem(writer *bufio.Writer, item *model.Item) error { hasScheduled := false hasDeadline := false hasLogbook := false + hasProperties := false for _, note := range item.Notes { if strings.Contains(note, "SCHEDULED:") { hasScheduled = true @@ -57,6 +61,9 @@ func writeItem(writer *bufio.Writer, item *model.Item) error { if strings.Contains(note, ":LOGBOOK:") { hasLogbook = true } + if strings.Contains(note, ":PROPERTIES:") { + hasProperties = true + } } if item.Scheduled != nil && !hasScheduled { @@ -73,6 +80,20 @@ func writeItem(writer *bufio.Writer, item *model.Item) error { } } + // Write effort in :PROPERTIES: drawer if not already in notes + if item.Effort != "" && !hasProperties { + if _, err := writer.WriteString(":PROPERTIES:\n"); err != nil { + return err + } + effortLine := fmt.Sprintf(":EFFORT: %s\n", item.Effort) + if _, err := writer.WriteString(effortLine); err != nil { + return err + } + if _, err := writer.WriteString(":END:\n"); 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 { diff --git a/internal/ui/app.go b/internal/ui/app.go index 11a13f5..b5968d7 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -20,11 +20,14 @@ const ( modeCapture modeAddSubTask modeSetDeadline + modeSetPriority + modeSetEffort ) type uiModel struct { orgFile *model.OrgFile cursor int + scrollOffset int // Track the scroll position mode viewMode help help.Model keys keyMap @@ -78,6 +81,44 @@ func (m uiModel) getVisibleItems() []*model.Item { return m.orgFile.GetAllItems() } +func (m *uiModel) updateScrollOffset(availableHeight int) { + items := m.getVisibleItems() + if len(items) == 0 { + return + } + + // Build line count for each item + itemLineCount := make([]int, len(items)) + for i, item := range items { + lineCount := 1 // The item itself + if !item.Folded && len(item.Notes) > 0 && m.mode == modeList { + // Count note lines (simplified - just count notes) + lineCount += len(item.Notes) + } + itemLineCount[i] = lineCount + } + + // Calculate total lines up to cursor + totalLinesBeforeCursor := 0 + for i := 0; i < m.cursor && i < len(itemLineCount); i++ { + totalLinesBeforeCursor += itemLineCount[i] + } + + // Adjust scroll offset to keep cursor visible + if totalLinesBeforeCursor < m.scrollOffset { + // Cursor is above visible area, scroll up + m.scrollOffset = totalLinesBeforeCursor + } else if totalLinesBeforeCursor >= m.scrollOffset+availableHeight { + // Cursor is below visible area, scroll down + m.scrollOffset = totalLinesBeforeCursor - availableHeight + 1 + } + + // Ensure scroll offset doesn't go negative + if m.scrollOffset < 0 { + m.scrollOffset = 0 + } +} + // RunUI starts the terminal UI func RunUI(orgFile *model.OrgFile) error { p := tea.NewProgram(initialModel(orgFile), tea.WithAltScreen()) diff --git a/internal/ui/keybindings.go b/internal/ui/keybindings.go index 5e80f90..73f3f1f 100644 --- a/internal/ui/keybindings.go +++ b/internal/ui/keybindings.go @@ -23,6 +23,8 @@ type keyMap struct { ClockIn key.Binding ClockOut key.Binding SetDeadline key.Binding + SetPriority key.Binding + SetEffort key.Binding } var keys = keyMap{ @@ -75,8 +77,8 @@ var keys = keyMap{ key.WithHelp("s", "add sub-task"), ), Delete: key.NewBinding( - key.WithKeys("shift+d"), - key.WithHelp("shift+d", "delete item"), + key.WithKeys("D"), + key.WithHelp("D", "delete item"), ), Save: key.NewBinding( key.WithKeys("ctrl+s"), @@ -98,6 +100,14 @@ var keys = keyMap{ key.WithKeys("d"), key.WithHelp("d", "set deadline"), ), + SetPriority: key.NewBinding( + key.WithKeys("p"), + key.WithHelp("p", "set priority"), + ), + SetEffort: key.NewBinding( + key.WithKeys("e"), + key.WithHelp("e", "set effort"), + ), Help: key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "toggle help"), @@ -128,7 +138,7 @@ func (k keyMap) getAllBindings() []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.ClockIn, k.ClockOut, k.SetDeadline, k.SetPriority, k.SetEffort, k.ToggleView, k.Help, k.Quit, } } diff --git a/internal/ui/modes.go b/internal/ui/modes.go index 0568fa5..97df9b4 100644 --- a/internal/ui/modes.go +++ b/internal/ui/modes.go @@ -26,6 +26,10 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateAddSubTask(msg) case modeSetDeadline: return m.updateSetDeadline(msg) + case modeSetPriority: + return m.updateSetPriority(msg) + case modeSetEffort: + return m.updateSetEffort(msg) } switch msg := msg.(type) { @@ -35,6 +39,7 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.help.Width = msg.Width m.textarea.SetWidth(msg.Width - 4) m.textarea.SetHeight(msg.Height - 10) + m.textinput.Width = 50 // Set a reasonable width for the text input return m, nil case tea.KeyMsg: @@ -52,6 +57,12 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { if m.cursor > 0 { m.cursor-- + // Update scroll to keep cursor visible + availableHeight := m.height - 6 // Approximate + if availableHeight < 5 { + availableHeight = 5 + } + m.updateScrollOffset(availableHeight) } } @@ -62,6 +73,12 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { items := m.getVisibleItems() if m.cursor < len(items)-1 { m.cursor++ + // Update scroll to keep cursor visible + availableHeight := m.height - 6 // Approximate + if availableHeight < 5 { + availableHeight = 5 + } + m.updateScrollOffset(availableHeight) } } @@ -203,6 +220,25 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.textinput.Focus() return m, textinput.Blink } + + case key.Matches(msg, m.keys.SetPriority): + items := m.getVisibleItems() + if len(items) > 0 && m.cursor < len(items) { + m.editingItem = items[m.cursor] + m.mode = modeSetPriority + return m, nil + } + + case key.Matches(msg, m.keys.SetEffort): + items := m.getVisibleItems() + if len(items) > 0 && m.cursor < len(items) { + m.editingItem = items[m.cursor] + m.mode = modeSetEffort + m.textinput.SetValue("") + m.textinput.Placeholder = "e.g., 8h, 2d, 1w" + m.textinput.Focus() + return m, textinput.Blink + } } } @@ -273,6 +309,7 @@ func (m uiModel) updateCapture(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.textinput.Width = 50 case tea.KeyMsg: switch msg.Type { @@ -314,6 +351,7 @@ func (m uiModel) updateAddSubTask(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.textinput.Width = 50 case tea.KeyMsg: switch msg.Type { @@ -356,6 +394,7 @@ func (m uiModel) updateSetDeadline(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.textinput.Width = 50 case tea.KeyMsg: switch msg.Type { @@ -453,6 +492,98 @@ func parseDeadlineInput(input string) (time.Time, error) { return time.Time{}, fmt.Errorf("unable to parse date: %s (use YYYY-MM-DD or +N)", input) } +func (m uiModel) updateSetPriority(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case tea.KeyMsg: + switch msg.String() { + case "A", "a": + if m.editingItem != nil { + m.editingItem.Priority = model.PriorityA + m.setStatus("Priority set to A") + } + m.mode = modeList + m.editingItem = nil + return m, nil + case "B", "b": + if m.editingItem != nil { + m.editingItem.Priority = model.PriorityB + m.setStatus("Priority set to B") + } + m.mode = modeList + m.editingItem = nil + return m, nil + case "C", "c": + if m.editingItem != nil { + m.editingItem.Priority = model.PriorityC + m.setStatus("Priority set to C") + } + m.mode = modeList + m.editingItem = nil + return m, nil + case " ", "enter": + // Clear priority + if m.editingItem != nil { + m.editingItem.Priority = model.PriorityNone + m.setStatus("Priority cleared") + } + m.mode = modeList + m.editingItem = nil + return m, nil + case "esc": + m.mode = modeList + m.editingItem = nil + m.setStatus("Cancelled") + return m, nil + } + } + return m, nil +} + +func (m uiModel) updateSetEffort(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.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 effort + m.editingItem.Effort = "" + m.setStatus("Effort cleared!") + } else { + // Set the effort value + m.editingItem.Effort = input + m.setStatus("Effort 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 +} + func (m *uiModel) cycleStateBackward(item *model.Item) { switch item.State { case model.StateNone: @@ -491,17 +622,25 @@ func (m *uiModel) moveItemUp() { } 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") + // Find the previous sibling (not child, not parent's sibling) + prevSibling := m.findPreviousSibling(currentItem) + if prevSibling == nil { + m.setStatus("Cannot move - already at top of list") return } - m.swapItems(currentItem, prevItem) - m.cursor-- + m.swapItems(currentItem, prevSibling) m.setStatus("Item moved up") + + // Update cursor to follow the item + items = m.getVisibleItems() + for i, item := range items { + if item == currentItem { + m.cursor = i + break + } + } } func (m *uiModel) moveItemDown() { @@ -511,17 +650,63 @@ func (m *uiModel) moveItemDown() { } 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") + // Find the next sibling (not child, not parent's sibling) + nextSibling := m.findNextSibling(currentItem) + if nextSibling == nil { + m.setStatus("Cannot move - already at bottom of list") return } - m.swapItems(currentItem, nextItem) - m.cursor++ + m.swapItems(currentItem, nextSibling) m.setStatus("Item moved down") + + // Update cursor to follow the item + items = m.getVisibleItems() + for i, item := range items { + if item == currentItem { + m.cursor = i + break + } + } +} + +func (m *uiModel) findPreviousSibling(item *model.Item) *model.Item { + // Find the parent list containing this item and return the previous sibling + var findInList func([]*model.Item) *model.Item + findInList = func(items []*model.Item) *model.Item { + for i, it := range items { + if it == item && i > 0 { + // Found the item and there's a previous sibling + return items[i-1] + } + // Recursively check children + if result := findInList(it.Children); result != nil { + return result + } + } + return nil + } + return findInList(m.orgFile.Items) +} + +func (m *uiModel) findNextSibling(item *model.Item) *model.Item { + // Find the parent list containing this item and return the next sibling + var findInList func([]*model.Item) *model.Item + findInList = func(items []*model.Item) *model.Item { + for i, it := range items { + if it == item && i < len(items)-1 { + // Found the item and there's a next sibling + return items[i+1] + } + // Recursively check children + if result := findInList(it.Children); result != nil { + return result + } + } + return nil + } + return findInList(m.orgFile.Items) } func (m *uiModel) swapItems(item1, item2 *model.Item) { diff --git a/internal/ui/views.go b/internal/ui/views.go index 3ae3a83..177b157 100644 --- a/internal/ui/views.go +++ b/internal/ui/views.go @@ -81,6 +81,10 @@ func (m uiModel) View() string { return m.viewAddSubTask() case modeSetDeadline: return m.viewSetDeadline() + case modeSetPriority: + return m.viewSetPriority() + case modeSetEffort: + return m.viewSetEffort() } // Build footer (status + help) @@ -130,12 +134,84 @@ func (m uiModel) View() string { content.WriteString("No items. Press 'c' to capture a new TODO.\n") } + // Build a map of item index to line count (for scrolling) + itemLineCount := make([]int, len(items)) + for i, item := range items { + lineCount := 1 // The item itself + if !item.Folded && len(item.Notes) > 0 && m.mode == modeList { + filteredNotes := filterLogbookDrawer(item.Notes) + highlightedNotes := renderNotesWithHighlighting(filteredNotes) + lineCount += len(highlightedNotes) + } + itemLineCount[i] = lineCount + } + + // Calculate total lines up to cursor + totalLinesBeforeCursor := 0 + for i := 0; i < m.cursor && i < len(itemLineCount); i++ { + totalLinesBeforeCursor += itemLineCount[i] + } + + // Determine the scroll offset (without modifying m - View should be pure) + scrollOffset := m.scrollOffset + if totalLinesBeforeCursor < scrollOffset { + // Cursor is above visible area, scroll up + scrollOffset = totalLinesBeforeCursor + } else if totalLinesBeforeCursor >= scrollOffset+availableHeight { + // Cursor is below visible area, scroll down + scrollOffset = totalLinesBeforeCursor - availableHeight + 1 + } + + // Render items starting from scroll offset itemLines := 0 for i, item := range items { - if itemLines >= availableHeight { - break // Don't render more items than fit + // Calculate which line this item starts at + itemStartLine := 0 + for j := 0; j < i; j++ { + itemStartLine += itemLineCount[j] } + // Skip items before scroll offset + if itemStartLine+itemLineCount[i] <= scrollOffset { + continue + } + + // Stop if we've filled the screen + if itemLines >= availableHeight { + break + } + + // Skip partial items at the top if needed + if itemStartLine < scrollOffset { + // This item is partially scrolled off the top + linesToSkip := scrollOffset - itemStartLine + if linesToSkip < itemLineCount[i] { + // Render the visible parts + if linesToSkip == 0 { + line := m.renderItem(item, i == m.cursor) + content.WriteString(line) + content.WriteString("\n") + itemLines++ + linesToSkip++ + } + + // Render remaining notes + if !item.Folded && len(item.Notes) > 0 && m.mode == modeList { + indent := strings.Repeat(" ", item.Level) + filteredNotes := filterLogbookDrawer(item.Notes) + highlightedNotes := renderNotesWithHighlighting(filteredNotes) + for noteIdx := linesToSkip - 1; noteIdx < len(highlightedNotes) && itemLines < availableHeight; noteIdx++ { + content.WriteString(indent) + content.WriteString(" " + highlightedNotes[noteIdx]) + content.WriteString("\n") + itemLines++ + } + } + } + continue + } + + // Render the full item line := m.renderItem(item, i == m.cursor) content.WriteString(line) content.WriteString("\n") @@ -144,7 +220,6 @@ func (m uiModel) View() string { // 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 { @@ -177,8 +252,6 @@ func (m uiModel) View() string { } func (m uiModel) viewConfirmDelete() string { - var b strings.Builder - dialogStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("196")). @@ -202,21 +275,11 @@ func (m uiModel) viewConfirmDelete() string { 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() + // Center the dialog horizontally and vertically + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog) } func (m uiModel) viewCapture() string { - var b strings.Builder - dialogStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("99")). @@ -232,21 +295,11 @@ func (m uiModel) viewCapture() string { 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() + // Center the dialog horizontally and vertically + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog) } func (m uiModel) viewAddSubTask() string { - var b strings.Builder - dialogStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("99")). @@ -266,21 +319,11 @@ func (m uiModel) viewAddSubTask() string { 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() + // Center the dialog horizontally and vertically + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog) } func (m uiModel) viewSetDeadline() string { - var b strings.Builder - dialogStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("141")). @@ -304,16 +347,78 @@ func (m uiModel) viewSetDeadline() string { 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)) + // Center the dialog horizontally and vertically + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog) +} + +func (m uiModel) viewSetPriority() string { + dialogStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("214")). + Padding(1, 2). + Width(60) + + var content strings.Builder + content.WriteString(titleStyle.Render("Set Priority")) + content.WriteString("\n") + if m.editingItem != nil { + content.WriteString(statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title))) + content.WriteString("\n") + if m.editingItem.Priority != model.PriorityNone { + content.WriteString(statusStyle.Render(fmt.Sprintf("Current: [#%s]", m.editingItem.Priority))) } } - b.WriteString(dialog) + content.WriteString("\n\n") - return b.String() + // Show priority options with styling + priorityAStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) + priorityBStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) + priorityCStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true) + + content.WriteString(priorityAStyle.Render("[A] High Priority") + "\n") + content.WriteString(priorityBStyle.Render("[B] Medium Priority") + "\n") + content.WriteString(priorityCStyle.Render("[C] Low Priority") + "\n") + content.WriteString("\n") + content.WriteString(statusStyle.Render("Press Space/Enter to clear priority")) + content.WriteString("\n") + content.WriteString(statusStyle.Render("Press ESC to cancel")) + + dialog := dialogStyle.Render(content.String()) + + // Center the dialog horizontally and vertically + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog) +} + +func (m uiModel) viewSetEffort() string { + dialogStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("141")). + Padding(1, 2). + Width(60) + + var content strings.Builder + content.WriteString(titleStyle.Render("Set Effort")) + content.WriteString("\n") + if m.editingItem != nil { + content.WriteString(statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title))) + content.WriteString("\n") + if m.editingItem.Effort != "" { + content.WriteString(statusStyle.Render(fmt.Sprintf("Current: %s", m.editingItem.Effort))) + } + } + content.WriteString("\n\n") + content.WriteString(m.textinput.View()) + content.WriteString("\n\n") + content.WriteString(statusStyle.Render("Examples: 8h, 2d, 1w, 4h30m")) + content.WriteString("\n") + content.WriteString(statusStyle.Render("Leave empty to clear effort")) + content.WriteString("\n") + content.WriteString(statusStyle.Render("Press Enter to save • ESC to cancel")) + + dialog := dialogStyle.Render(content.String()) + + // Center the dialog horizontally and vertically + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog) } func (m uiModel) viewEditMode() string { @@ -332,10 +437,11 @@ func (m uiModel) viewEditMode() string { return b.String() } -// filterLogbookDrawer removes LOGBOOK drawer content and scheduling metadata from notes +// filterLogbookDrawer removes LOGBOOK and PROPERTIES drawer content and scheduling metadata from notes func filterLogbookDrawer(notes []string) []string { var filtered []string inLogbook := false + inProperties := false for _, note := range notes { trimmed := strings.TrimSpace(note) @@ -346,14 +452,26 @@ func filterLogbookDrawer(notes []string) []string { continue } - // Check for end of LOGBOOK drawer - if trimmed == ":END:" && inLogbook { - inLogbook = false + // Check for start of PROPERTIES drawer + if trimmed == ":PROPERTIES:" { + inProperties = true continue } - // Skip lines inside LOGBOOK drawer - if inLogbook { + // Check for end of drawer + if trimmed == ":END:" { + if inLogbook { + inLogbook = false + continue + } + if inProperties { + inProperties = false + continue + } + } + + // Skip lines inside LOGBOOK or PROPERTIES drawer + if inLogbook || inProperties { continue } @@ -517,9 +635,30 @@ func (m uiModel) renderItem(item *model.Item, isCursor bool) string { b.WriteString(stateStr) b.WriteString(" ") + // Priority + if item.Priority != model.PriorityNone { + var priorityStyle lipgloss.Style + switch item.Priority { + case model.PriorityA: + priorityStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) + case model.PriorityB: + priorityStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) + case model.PriorityC: + priorityStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true) + } + b.WriteString(priorityStyle.Render(fmt.Sprintf("[#%s] ", item.Priority))) + } + // Title b.WriteString(item.Title) + // Effort + if item.Effort != "" { + effortStr := fmt.Sprintf(" (Effort: %s)", item.Effort) + effortStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("141")) // Purple + b.WriteString(effortStyle.Render(effortStr)) + } + // Clock status if item.IsClockedIn() { duration := item.GetCurrentClockDuration()