org/internal/ui/modes.go

902 lines
22 KiB
Go

package ui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/rwejlgaard/org/internal/model"
"github.com/rwejlgaard/org/internal/parser"
)
func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Handle special modes
switch m.mode {
case modeEdit:
return m.updateEditMode(msg)
case modeConfirmDelete:
return m.updateConfirmDelete(msg)
case modeCapture:
return m.updateCapture(msg)
case modeAddSubTask:
return m.updateAddSubTask(msg)
case modeSetDeadline:
return m.updateSetDeadline(msg)
case modeSetPriority:
return m.updateSetPriority(msg)
case modeSetEffort:
return m.updateSetEffort(msg)
case modeHelp:
return m.updateHelp(msg)
case modeSettings:
return m.updateSettings(msg)
case modeSettingsAddTag:
return m.updateSettingsAddTag(msg)
case modeSettingsAddState:
return m.updateSettingsAddState(msg)
case modeTagEdit:
return m.updateTagEdit(msg)
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.help.Width = msg.Width
m.textarea.SetWidth(msg.Width - 4)
m.textarea.SetHeight(msg.Height - 10)
m.textinput.Width = 50 // Set a reasonable width for the text input
return m, nil
case tea.KeyMsg:
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
m.mode = modeHelp
m.helpScroll = 0 // Reset scroll when entering help
return m, nil
case key.Matches(msg, m.keys.Up):
if m.reorderMode {
m.moveItemUp()
} 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)
}
}
case key.Matches(msg, m.keys.Down):
if m.reorderMode {
m.moveItemDown()
} else {
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)
}
}
case key.Matches(msg, m.keys.Left):
items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) {
m.cycleStateBackward(items[m.cursor])
// Auto clock out when changing to DONE
if items[m.cursor].State == model.StateDONE && items[m.cursor].IsClockedIn() {
items[m.cursor].ClockOut()
}
m.setStatus("State changed")
}
case key.Matches(msg, m.keys.Right):
items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) {
m.cycleStateForward(items[m.cursor])
// Auto clock out when changing to last state (typically DONE)
stateNames := m.config.GetStateNames()
if len(stateNames) > 0 && string(items[m.cursor].State) == stateNames[len(stateNames)-1] && items[m.cursor].IsClockedIn() {
items[m.cursor].ClockOut()
}
m.setStatus("State changed")
}
case key.Matches(msg, m.keys.ShiftUp):
m.moveItemUp()
case key.Matches(msg, m.keys.ShiftDown):
m.moveItemDown()
case key.Matches(msg, m.keys.CycleState):
items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) {
m.cycleStateForward(items[m.cursor])
// Auto clock out when changing to last state (typically DONE)
stateNames := m.config.GetStateNames()
if len(stateNames) > 0 && string(items[m.cursor].State) == stateNames[len(stateNames)-1] && items[m.cursor].IsClockedIn() {
items[m.cursor].ClockOut()
}
m.setStatus("State changed")
}
case key.Matches(msg, m.keys.ToggleFold):
items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) {
items[m.cursor].ToggleFold()
if items[m.cursor].Folded {
m.setStatus("Folded")
} else {
m.setStatus("Unfolded")
}
}
case key.Matches(msg, m.keys.EditNotes):
items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) {
m.editingItem = items[m.cursor]
m.mode = modeEdit
m.textarea.SetValue(strings.Join(m.editingItem.Notes, "\n"))
m.textarea.Focus()
return m, textarea.Blink
}
case key.Matches(msg, m.keys.Settings):
m.mode = modeSettings
m.initSettings()
return m, nil
case key.Matches(msg, m.keys.TagItem):
items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) {
m.editingItem = items[m.cursor]
m.mode = modeTagEdit
m.textinput.SetValue(strings.Join(items[m.cursor].Tags, ":"))
m.textinput.Placeholder = "tag1:tag2:tag3"
m.textinput.Focus()
return m, textinput.Blink
}
case key.Matches(msg, m.keys.Capture):
m.mode = modeCapture
m.textinput.SetValue("")
m.textinput.Placeholder = "What needs doing?"
m.textinput.Focus()
return m, textinput.Blink
case key.Matches(msg, m.keys.AddSubTask):
items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) {
m.editingItem = items[m.cursor]
m.mode = modeAddSubTask
m.textinput.SetValue("")
m.textinput.Placeholder = "Sub-task title"
m.textinput.Focus()
return m, textinput.Blink
}
case key.Matches(msg, m.keys.Delete):
items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) {
m.itemToDelete = items[m.cursor]
m.mode = modeConfirmDelete
}
case key.Matches(msg, m.keys.ToggleView):
if m.mode == modeList {
m.mode = modeAgenda
} else {
m.mode = modeList
}
m.cursor = 0
case key.Matches(msg, m.keys.Save):
if err := parser.Save(m.orgFile); err != nil {
m.setStatus(fmt.Sprintf("Error saving: %v", err))
} else {
m.setStatus("Saved!")
}
case key.Matches(msg, m.keys.ToggleReorder):
m.reorderMode = !m.reorderMode
if m.reorderMode {
m.setStatus("Reorder mode ON - Use ↑/↓ to move items, 'r' to exit")
} else {
m.setStatus("Reorder mode OFF")
}
case key.Matches(msg, m.keys.ClockIn):
items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) {
if items[m.cursor].ClockIn() {
m.setStatus("Clocked in!")
} else {
m.setStatus("Already clocked in")
}
}
case key.Matches(msg, m.keys.ClockOut):
items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) {
if items[m.cursor].ClockOut() {
m.setStatus("Clocked out!")
} else {
m.setStatus("Not clocked in")
}
}
case key.Matches(msg, m.keys.SetDeadline):
items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) {
m.editingItem = items[m.cursor]
m.mode = modeSetDeadline
m.textinput.SetValue("")
m.textinput.Placeholder = "YYYY-MM-DD or +N (days from today)"
m.textinput.Focus()
return m, textinput.Blink
}
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
}
}
}
return m, nil
}
func (m uiModel) updateEditMode(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.textarea.SetWidth(msg.Width - 4)
m.textarea.SetHeight(msg.Height - 10)
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEsc:
// Save notes and exit edit mode
if m.editingItem != nil {
noteText := m.textarea.Value()
if noteText == "" {
m.editingItem.Notes = []string{}
} else {
m.editingItem.Notes = strings.Split(noteText, "\n")
}
}
m.mode = modeList
m.textarea.Blur()
m.setStatus("Notes saved")
return m, nil
}
}
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
func (m uiModel) updateConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "y", "Y":
// Delete the item
m.deleteItem(m.itemToDelete)
m.mode = modeList
m.itemToDelete = nil
m.setStatus("Item deleted")
// Adjust cursor if needed
items := m.getVisibleItems()
if m.cursor >= len(items) && len(items) > 0 {
m.cursor = len(items) - 1
}
case "n", "N", "esc":
m.mode = modeList
m.itemToDelete = nil
m.setStatus("Cancelled")
}
}
return m, nil
}
func (m uiModel) updateCapture(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.textinput.Width = 50
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEnter:
title := strings.TrimSpace(m.textinput.Value())
if title != "" {
// Get default state from config
defaultState := model.TodoState(m.config.GetDefaultNewTaskState())
// Create new TODO at top level
newItem := &model.Item{
Level: 1,
State: defaultState,
Title: title,
Notes: []string{},
Children: []*model.Item{},
}
// Insert at beginning
m.orgFile.Items = append([]*model.Item{newItem}, m.orgFile.Items...)
m.setStatus("TODO captured!")
}
m.mode = modeList
m.textinput.Blur()
m.cursor = 0
return m, nil
case tea.KeyEsc:
m.mode = modeList
m.textinput.Blur()
m.setStatus("Cancelled")
return m, nil
}
}
m.textinput, cmd = m.textinput.Update(msg)
return m, cmd
}
func (m uiModel) updateAddSubTask(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.textinput.Width = 50
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEnter:
title := strings.TrimSpace(m.textinput.Value())
if title != "" && m.editingItem != nil {
// Get default state from config
defaultState := model.TodoState(m.config.GetDefaultNewTaskState())
// Create new sub-task
newItem := &model.Item{
Level: m.editingItem.Level + 1,
State: defaultState,
Title: title,
Notes: []string{},
Children: []*model.Item{},
}
m.editingItem.Children = append(m.editingItem.Children, newItem)
m.editingItem.Folded = false // Unfold to show new sub-task
m.setStatus("Sub-task added!")
}
m.mode = modeList
m.textinput.Blur()
m.editingItem = nil
return m, nil
case tea.KeyEsc:
m.mode = modeList
m.textinput.Blur()
m.editingItem = nil
m.setStatus("Cancelled")
return m, nil
}
}
m.textinput, cmd = m.textinput.Update(msg)
return m, cmd
}
func (m uiModel) updateSetDeadline(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
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)
return m, cmd
}
// parseDeadlineInput parses deadline input like "2024-01-15" or "+3" (3 days from now)
func parseDeadlineInput(input string) (time.Time, error) {
// Check if it's a relative date (+N days)
if strings.HasPrefix(input, "+") {
daysStr := strings.TrimPrefix(input, "+")
days := 0
_, err := fmt.Sscanf(daysStr, "%d", &days)
if err != nil {
return time.Time{}, fmt.Errorf("invalid relative date format: %s", input)
}
return time.Now().AddDate(0, 0, days), nil
}
// Try parsing as absolute date
formats := []string{
"2006-01-02",
"2006/01/02",
"01/02/2006",
}
for _, format := range formats {
if t, err := time.Parse(format, input); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("unable to parse date: %s (use YYYY-MM-DD or +N)", input)
}
func (m uiModel) 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) cycleStateForward(item *model.Item) {
stateNames := m.config.GetStateNames()
if len(stateNames) == 0 {
return
}
// Find current state index
currentIndex := -1
currentState := string(item.State)
// Handle empty state
if currentState == "" {
currentIndex = -1
} else {
for i, name := range stateNames {
if name == currentState {
currentIndex = i
break
}
}
}
// Cycle forward
if currentIndex < 0 || currentIndex >= len(stateNames)-1 {
if currentIndex == len(stateNames)-1 {
item.State = model.TodoState("") // Back to empty
} else {
item.State = model.TodoState(stateNames[0]) // First state
}
} else {
item.State = model.TodoState(stateNames[currentIndex+1])
}
}
func (m *uiModel) cycleStateBackward(item *model.Item) {
stateNames := m.config.GetStateNames()
if len(stateNames) == 0 {
return
}
// Find current state index
currentIndex := -1
currentState := string(item.State)
// Handle empty state
if currentState == "" {
currentIndex = len(stateNames) // One past the last state
} else {
for i, name := range stateNames {
if name == currentState {
currentIndex = i
break
}
}
}
// Cycle backward
if currentIndex <= 0 {
item.State = model.TodoState("") // Empty state
} else if currentIndex > len(stateNames) {
item.State = model.TodoState(stateNames[len(stateNames)-1])
} else {
item.State = model.TodoState(stateNames[currentIndex-1])
}
}
func (m *uiModel) deleteItem(item *model.Item) {
var removeFromList func([]*model.Item, *model.Item) []*model.Item
removeFromList = func(items []*model.Item, target *model.Item) []*model.Item {
result := []*model.Item{}
for _, it := range items {
if it == target {
continue
}
it.Children = removeFromList(it.Children, target)
result = append(result, it)
}
return result
}
m.orgFile.Items = removeFromList(m.orgFile.Items, item)
}
func (m *uiModel) moveItemUp() {
items := m.getVisibleItems()
if len(items) == 0 || m.cursor == 0 {
return
}
currentItem := items[m.cursor]
// 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, 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() {
items := m.getVisibleItems()
if len(items) == 0 || m.cursor >= len(items)-1 {
return
}
currentItem := items[m.cursor]
// 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, 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) {
// Find parent list containing both items
var swapInList func([]*model.Item) bool
swapInList = func(items []*model.Item) bool {
for i := 0; i < len(items)-1; i++ {
if items[i] == item1 && items[i+1] == item2 {
items[i], items[i+1] = items[i+1], items[i]
return true
}
if items[i] == item2 && items[i+1] == item1 {
items[i], items[i+1] = items[i+1], items[i]
return true
}
if swapInList(items[i].Children) {
return true
}
}
if len(items) > 0 && swapInList(items[len(items)-1].Children) {
return true
}
return false
}
swapInList(m.orgFile.Items)
}
func (m uiModel) updateHelp(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 "?", "esc", "q":
m.mode = modeList
m.helpScroll = 0 // Reset scroll when exiting
return m, nil
case "up", "k":
if m.helpScroll > 0 {
m.helpScroll--
}
return m, nil
case "down", "j":
m.helpScroll++
// The view will handle clamping to max scroll
return m, nil
case "pageup":
m.helpScroll -= 10
if m.helpScroll < 0 {
m.helpScroll = 0
}
return m, nil
case "pagedown":
m.helpScroll += 10
return m, nil
case "home", "g":
m.helpScroll = 0
return m, nil
}
}
return m, nil
}
// updateTagEdit handles tag editing mode
func (m *uiModel) updateTagEdit(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.keys.Quit):
m.mode = modeList
m.textinput.Blur()
return m, nil
case msg.Type == tea.KeyEnter:
if m.editingItem != nil {
// Parse tags from input (colon-separated)
tagsStr := m.textinput.Value()
var tags []string
if tagsStr != "" {
tags = strings.Split(tagsStr, ":")
// Remove empty strings
var filteredTags []string
for _, tag := range tags {
tag = strings.TrimSpace(tag)
if tag != "" {
filteredTags = append(filteredTags, tag)
}
}
tags = filteredTags
}
m.editingItem.Tags = tags
m.setStatus("Tags updated")
}
m.mode = modeList
m.textinput.Blur()
return m, nil
default:
var cmd tea.Cmd
m.textinput, cmd = m.textinput.Update(msg)
return m, cmd
}
}
return m, nil
}