images and getting closer to feature complete

This commit is contained in:
Rasmus Wejlgaard 2025-11-08 13:29:28 +00:00
parent 4e41ad678f
commit 13e52e2880
12 changed files with 531 additions and 81 deletions

BIN
.imgs/capture_prompt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
.imgs/delete_prompt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
.imgs/editing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
.imgs/list_view.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

16
README.md Normal file
View file

@ -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)

View file

@ -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)

View file

@ -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 {

View file

@ -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 {

View file

@ -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())

View file

@ -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,
}
}

View file

@ -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) {

View file

@ -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()