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 }