mirror of
https://github.com/RWejlgaard/org.git
synced 2026-05-06 04:34:45 +00:00
images and getting closer to feature complete
This commit is contained in:
parent
4e41ad678f
commit
13e52e2880
12 changed files with 531 additions and 81 deletions
BIN
.imgs/capture_prompt.png
Normal file
BIN
.imgs/capture_prompt.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
.imgs/delete_prompt.png
Normal file
BIN
.imgs/delete_prompt.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
.imgs/editing.png
Normal file
BIN
.imgs/editing.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
.imgs/list_view.png
Normal file
BIN
.imgs/list_view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
16
README.md
Normal file
16
README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Org
|
||||
|
||||
Org is a simple orgmode application inspired from the simplicity of `nano`.
|
||||
|
||||
|
||||
## Screenshots
|
||||
|
||||
### List view
|
||||

|
||||
|
||||
## Editing notes
|
||||

|
||||
|
||||
### Prompts
|
||||

|
||||

|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue