From d13795080cb8a177d93e593ce91ba3a30b64c5ad Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 8 Apr 2026 16:01:38 +0300 Subject: Refactor UI input handlers for task 1 --- internal/ui/detail_handlers.go | 244 +++++++++++++++++ internal/ui/editor_handlers.go | 76 ++++++ internal/ui/handlers.go | 350 ------------------------ internal/ui/helpers_test.go | 55 +++- internal/ui/input_helpers.go | 126 +++++++++ internal/ui/keyactions.go | 594 +++++++++++++++++++++++++++++++++++++++++ internal/ui/keyhandlers.go | 589 ---------------------------------------- internal/ui/table.go | 64 ----- 8 files changed, 1093 insertions(+), 1005 deletions(-) create mode 100644 internal/ui/detail_handlers.go create mode 100644 internal/ui/editor_handlers.go create mode 100644 internal/ui/input_helpers.go create mode 100644 internal/ui/keyactions.go diff --git a/internal/ui/detail_handlers.go b/internal/ui/detail_handlers.go new file mode 100644 index 0000000..0f5a208 --- /dev/null +++ b/internal/ui/detail_handlers.go @@ -0,0 +1,244 @@ +package ui + +import ( + "fmt" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "codeberg.org/snonux/tasksamurai/internal/task" +) + +// handleTaskDetailMode handles keyboard input in task detail view +func (m *Model) handleTaskDetailMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + if m.detailSearching { + var cmd tea.Cmd + switch msg.String() { + case "enter": + pattern := m.detailSearchInput.Value() + if pattern != "" { + re, err := compileAndCacheRegex(pattern) + if err == nil { + m.detailSearchRegex = re + } else { + m.detailSearchRegex = nil + m.statusMsg = fmt.Sprintf("Invalid regex: %v", err) + } + } else { + m.detailSearchRegex = nil + } + m.detailSearching = false + m.detailSearchInput.Blur() + return m, nil + case "esc", "ctrl+c": + m.detailSearching = false + m.detailSearchInput.Blur() + return m, nil + default: + m.detailSearchInput, cmd = m.detailSearchInput.Update(msg) + return m, cmd + } + } + + // Normal task detail view mode + switch msg.String() { + case "q": + return m.handleQuitKey() + case "esc": + return m.handleEscapeKey() + case "/", "?": + m.detailSearching = true + m.detailSearchInput.SetValue("") + m.detailSearchInput.Focus() + return m, nil + case "n": + // Next search match - not implemented yet but could be added + return m, nil + case "N": + // Previous search match - not implemented yet but could be added + return m, nil + case "up", "k": + if m.detailFieldIndex > 0 { + m.detailFieldIndex-- + } + return m, nil + case "down", "j": + maxFields := m.getDetailFieldCount() + if m.detailFieldIndex < maxFields-1 { + m.detailFieldIndex++ + } + return m, nil + case "g", "home": + m.detailFieldIndex = 0 + return m, nil + case "G", "end": + m.detailFieldIndex = m.getDetailFieldCount() - 1 + return m, nil + case "o": + return m.handleOpenURL() + case "i", "enter": + // Check if current field is editable + return m.handleDetailFieldEdit() + } + + return m, nil +} + +// handleDetailFieldEdit starts editing for the currently-selected field in the +// detail view. Fields 0-2 (ID, UUID, Status) and 6, 8 (Start, Entry) are +// read-only; all others delegate to the appropriate activation helper. +func (m *Model) handleDetailFieldEdit() (tea.Model, tea.Cmd) { + if m.currentTaskDetail == nil { + return m, nil + } + t := m.currentTaskDetail + id := t.ID + + // Fixed-position fields (indices always match the fieldXxx constants). + switch m.detailFieldIndex { + case fieldID, fieldUUID, fieldStatus, fieldStart, fieldEntry: + return m, nil // read-only fields + case fieldPriority: + m.activatePriorityEdit(id, t.Priority) + return m, nil + case fieldTags: + m.activateTagsEdit(id) + return m, nil + case fieldDue: + m.activateDueEdit(id, t.Due) + return m, nil + case fieldProject: + m.activateProjectEdit(id, t.Project) + return m, nil + } + + // Recurrence and Description occupy dynamic positions: recur is present + // only when t.Recur != "", shifting description one slot later. + return m.handleDetailDynamicFields(id, t) +} + +// handleDetailDynamicFields handles editing activation for the task fields +// whose index depends on whether the optional Recur field is present. +func (m *Model) handleDetailDynamicFields(id int, t *task.Task) (tea.Model, tea.Cmd) { + // fieldEntry is 8; the next slot is 9, which holds Recur when present. + fieldPos := fieldEntry + 1 + if t.Recur != "" { + if m.detailFieldIndex == fieldPos { + m.activateRecurEdit(id, t.Recur) + return m, nil + } + fieldPos++ + } + if m.detailFieldIndex == fieldPos { + // Launch external editor for description editing. + m.detailDescEditing = true + return m, editDescriptionCmd(t.Description) + } + // Annotations are read-only in the detail view. They can be edited via + // the table view's Annotations column (activateAnnotationsEdit). + return m, nil +} + +// activatePriorityEdit enables the priority-selector for task id, +// pre-selecting the option that matches currentPriority. +func (m *Model) activatePriorityEdit(id int, currentPriority string) { + m.clearEditingModes() + m.priorityID = id + m.prioritySelecting = true + switch currentPriority { + case "H": + m.priorityIndex = 0 + case "M": + m.priorityIndex = 1 + case "L": + m.priorityIndex = 2 + default: + m.priorityIndex = 3 + } + m.updateTableHeight() +} + +// activateDueEdit enables due-date editing for task id, initialising the +// date picker from currentDue (falls back to now if empty or unparseable). +func (m *Model) activateDueEdit(id int, currentDue string) { + m.dueID = id + if currentDue != "" { + if ts, err := parseTaskDate(currentDue); err == nil { + m.dueDate = ts + } else { + m.dueDate = time.Now() + } + } else { + m.dueDate = time.Now() + } + m.clearEditingModes() + m.dueEditing = true + m.updateTableHeight() +} + +// activateTagsEdit enables tags editing for task id with an empty input. +func (m *Model) activateTagsEdit(id int) { + m.clearEditingModes() + m.tagsID = id + m.tagsEditing = true + m.tagsInput.SetValue("") + m.tagsInput.Focus() + m.updateTableHeight() +} + +// activateProjectEdit enables project editing for task id, +// pre-filling the input with currentProject. +func (m *Model) activateProjectEdit(id int, currentProject string) { + m.clearEditingModes() + m.projID = id + m.projEditing = true + m.projInput.SetValue(currentProject) + m.projInput.Focus() + m.updateTableHeight() +} + +// activateRecurEdit enables recurrence editing for task id, +// pre-filling the input with currentRecur. +func (m *Model) activateRecurEdit(id int, currentRecur string) { + m.clearEditingModes() + m.recurID = id + m.recurEditing = true + m.recurInput.SetValue(currentRecur) + m.recurInput.Focus() + m.updateTableHeight() +} + +// activateAnnotationsEdit enables annotation editing for task id. +// The current annotations are joined with "; " and pre-filled in the input +// so the user can revise all annotations in one pass. +func (m *Model) activateAnnotationsEdit(id int, tsk *task.Task) (tea.Model, tea.Cmd) { + m.clearEditingModes() + m.annotateID = id + m.annotating = true + m.replaceAnnotations = true + if tsk != nil { + var anns []string + for _, a := range tsk.Annotations { + anns = append(anns, a.Description) + } + m.annotateInput.SetValue(strings.Join(anns, "; ")) + } + m.annotateInput.Focus() + m.updateTableHeight() + return m, nil +} + +// activateDescriptionEdit enables inline description editing for task id, +// pre-filling the input with the current description. +func (m *Model) activateDescriptionEdit(id int, tsk *task.Task) (tea.Model, tea.Cmd) { + m.clearEditingModes() + m.descID = id + m.descEditing = true + if tsk != nil { + m.descInput.SetValue(tsk.Description) + } + m.descInput.Focus() + m.updateTableHeight() + return m, nil +} diff --git a/internal/ui/editor_handlers.go b/internal/ui/editor_handlers.go new file mode 100644 index 0000000..8f81420 --- /dev/null +++ b/internal/ui/editor_handlers.go @@ -0,0 +1,76 @@ +package ui + +import ( + "fmt" + "os" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "codeberg.org/snonux/tasksamurai/internal/task" +) + +// handleEditDone handles completion of external editor +func (m *Model) handleEditDone(msg editDoneMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.showError(fmt.Errorf("editor: %w", msg.err)) + } + if m.showUltra { + m.ultraFocusedID = m.editID + } + if !m.reloadAndReport() { + m.editID = 0 + return m, nil + } + cmd := m.startBlink(m.editID, false) + m.editID = 0 + return m, cmd +} + +// handleDescEditDone handles the completion of description editing +func (m *Model) handleDescEditDone(msg descEditDoneMsg) (tea.Model, tea.Cmd) { + m.detailDescEditing = false + if msg.tempFile != "" { + defer func() { _ = os.Remove(msg.tempFile) }() + } + + if msg.err != nil { + m.statusMsg = fmt.Sprintf("Edit error: %v", msg.err) + cmd := tea.Tick(2*time.Second, func(time.Time) tea.Msg { + return struct{ clearStatus bool }{true} + }) + return m, cmd + } + + // Read the edited content + content, err := os.ReadFile(msg.tempFile) + if err != nil { + m.statusMsg = fmt.Sprintf("Error reading file: %v", err) + cmd := tea.Tick(2*time.Second, func(time.Time) tea.Msg { + return struct{ clearStatus bool }{true} + }) + return m, cmd + } + + // Update the description + newDesc := strings.TrimSpace(string(content)) + if m.currentTaskDetail != nil { + err = task.SetDescription(m.currentTaskDetail.ID, newDesc) + if err != nil { + m.statusMsg = fmt.Sprintf("Error updating description: %v", err) + cmd := tea.Tick(2*time.Second, func(time.Time) tea.Msg { + return struct{ clearStatus bool }{true} + }) + return m, cmd + } + + // Reload and start blinking + if !m.reloadAndReport() { + return m, nil + } + return m, m.startDetailBlink(m.detailDescriptionFieldIndex()) + } + + return m, nil +} diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index 831687a..0e41272 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -2,13 +2,11 @@ package ui import ( "fmt" - "strconv" "strings" "time" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/x/ansi" "codeberg.org/snonux/tasksamurai/internal/task" ) @@ -500,351 +498,3 @@ func (m *Model) handleHelpSearchMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.helpSearchInput, cmd = m.helpSearchInput.Update(msg) return m, cmd } - -// handleBlinkingState handles input when a task is blinking -func (m *Model) handleBlinkingState(msg tea.Msg) (tea.Model, tea.Cmd) { - if _, ok := msg.(tea.KeyPressMsg); ok { - if m.showUltra { - return m.handleUltraBlinkingState(msg.(tea.KeyPressMsg)) - } - - // Only allow navigation while blinking. - prevRow := m.tbl.Cursor() - prevCol := m.tbl.ColumnCursor() - var cmd tea.Cmd - m.tbl, cmd = m.tbl.Update(msg) - if prevRow != m.tbl.Cursor() || prevCol != m.tbl.ColumnCursor() { - m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor()) - } - return m, cmd - } - return m, nil -} - -func (m *Model) handleUltraBlinkingState(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "j", "down": - m.ultraMoveCursor(1) - case "k", "up": - m.ultraMoveCursor(-1) - case "pgdn", "pgdown", "space": - m.ultraMoveCursor(m.ultraVisibleCount()) - case "pgup", "b": - m.ultraMoveCursor(-m.ultraVisibleCount()) - case "g", "home": - m.ultraGoHome() - case "G", "end": - m.ultraGoEnd() - } - return m, nil -} - -// handleEditingModes checks if we're in any editing mode and handles it -func (m *Model) handleEditingModes(msg tea.KeyPressMsg) (handled bool, model tea.Model, cmd tea.Cmd) { - switch { - case m.annotating: - model, cmd = m.handleAnnotationMode(msg) - return true, model, cmd - case m.descEditing: - model, cmd = m.handleDescriptionMode(msg) - return true, model, cmd - case m.tagsEditing: - model, cmd = m.handleTagsMode(msg) - return true, model, cmd - case m.dueEditing: - model, cmd = m.handleDueEditMode(msg) - return true, model, cmd - case m.recurEditing: - model, cmd = m.handleRecurrenceMode(msg) - return true, model, cmd - case m.projEditing: - model, cmd = m.handleProjectMode(msg) - return true, model, cmd - case m.prioritySelecting: - model, cmd = m.handlePriorityMode(msg) - return true, model, cmd - case m.filterEditing: - model, cmd = m.handleFilterMode(msg) - return true, model, cmd - case m.addingTask: - model, cmd = m.handleAddTaskMode(msg) - return true, model, cmd - case m.searching: - model, cmd = m.handleSearchMode(msg) - return true, model, cmd - case m.helpSearching: - model, cmd = m.handleHelpSearchMode(msg) - return true, model, cmd - } - return false, m, nil -} - -// getSelectedTaskID extracts the task ID from the selected row -func (m *Model) getSelectedTaskID() (int, error) { - row := m.tbl.SelectedRow() - if row == nil { - return 0, fmt.Errorf("no row selected") - } - idStr := ansi.Strip(row[1]) - return strconv.Atoi(idStr) -} - -// getTaskAtCursor returns the task at the current cursor position -func (m *Model) getTaskAtCursor() *task.Task { - cursor := m.tbl.Cursor() - if cursor < 0 || cursor >= len(m.tasks) { - return nil - } - return &m.tasks[cursor] -} - -// getTaskForOpenURL returns the task that should be used by the open-URL -// hotkey, honoring the active view's highlighted task. -func (m *Model) getTaskForOpenURL() *task.Task { - if m.showTaskDetail && m.currentTaskDetail != nil { - return m.currentTaskDetail - } - - if m.showUltra { - tasks := m.ultraTaskList() - if m.ultraCursor < 0 || m.ultraCursor >= len(tasks) { - return nil - } - return &tasks[m.ultraCursor] - } - - return m.getTaskAtCursor() -} - -// handleTaskDetailMode handles keyboard input in task detail view -func (m *Model) handleTaskDetailMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - if m.detailSearching { - var cmd tea.Cmd - switch msg.String() { - case "enter": - pattern := m.detailSearchInput.Value() - if pattern != "" { - re, err := compileAndCacheRegex(pattern) - if err == nil { - m.detailSearchRegex = re - } else { - m.detailSearchRegex = nil - m.statusMsg = fmt.Sprintf("Invalid regex: %v", err) - } - } else { - m.detailSearchRegex = nil - } - m.detailSearching = false - m.detailSearchInput.Blur() - return m, nil - case "esc", "ctrl+c": - m.detailSearching = false - m.detailSearchInput.Blur() - return m, nil - default: - m.detailSearchInput, cmd = m.detailSearchInput.Update(msg) - return m, cmd - } - } - - // Normal task detail view mode - switch msg.String() { - case "q": - return m.handleQuitKey() - case "esc": - return m.handleEscapeKey() - case "/", "?": - m.detailSearching = true - m.detailSearchInput.SetValue("") - m.detailSearchInput.Focus() - return m, nil - case "n": - // Next search match - not implemented yet but could be added - return m, nil - case "N": - // Previous search match - not implemented yet but could be added - return m, nil - case "up", "k": - if m.detailFieldIndex > 0 { - m.detailFieldIndex-- - } - return m, nil - case "down", "j": - maxFields := m.getDetailFieldCount() - if m.detailFieldIndex < maxFields-1 { - m.detailFieldIndex++ - } - return m, nil - case "g", "home": - m.detailFieldIndex = 0 - return m, nil - case "G", "end": - m.detailFieldIndex = m.getDetailFieldCount() - 1 - return m, nil - case "o": - return m.handleOpenURL() - case "i", "enter": - // Check if current field is editable - return m.handleDetailFieldEdit() - } - - return m, nil -} - -// handleDetailFieldEdit starts editing for the currently-selected field in the -// detail view. Fields 0-2 (ID, UUID, Status) and 6, 8 (Start, Entry) are -// read-only; all others delegate to the appropriate activation helper. -func (m *Model) handleDetailFieldEdit() (tea.Model, tea.Cmd) { - if m.currentTaskDetail == nil { - return m, nil - } - t := m.currentTaskDetail - id := t.ID - - // Fixed-position fields (indices always match the fieldXxx constants). - switch m.detailFieldIndex { - case fieldID, fieldUUID, fieldStatus, fieldStart, fieldEntry: - return m, nil // read-only fields - case fieldPriority: - m.activatePriorityEdit(id, t.Priority) - return m, nil - case fieldTags: - m.activateTagsEdit(id) - return m, nil - case fieldDue: - m.activateDueEdit(id, t.Due) - return m, nil - case fieldProject: - m.activateProjectEdit(id, t.Project) - return m, nil - } - - // Recurrence and Description occupy dynamic positions: recur is present - // only when t.Recur != "", shifting description one slot later. - return m.handleDetailDynamicFields(id, t) -} - -// handleDetailDynamicFields handles editing activation for the task fields -// whose index depends on whether the optional Recur field is present. -func (m *Model) handleDetailDynamicFields(id int, t *task.Task) (tea.Model, tea.Cmd) { - // fieldEntry is 8; the next slot is 9, which holds Recur when present. - fieldPos := fieldEntry + 1 - if t.Recur != "" { - if m.detailFieldIndex == fieldPos { - m.activateRecurEdit(id, t.Recur) - return m, nil - } - fieldPos++ - } - if m.detailFieldIndex == fieldPos { - // Launch external editor for description editing. - m.detailDescEditing = true - return m, editDescriptionCmd(t.Description) - } - // Annotations are read-only in the detail view. They can be edited via - // the table view's Annotations column (activateAnnotationsEdit). - return m, nil -} - -// activatePriorityEdit enables the priority-selector for task id, -// pre-selecting the option that matches currentPriority. -func (m *Model) activatePriorityEdit(id int, currentPriority string) { - m.clearEditingModes() - m.priorityID = id - m.prioritySelecting = true - switch currentPriority { - case "H": - m.priorityIndex = 0 - case "M": - m.priorityIndex = 1 - case "L": - m.priorityIndex = 2 - default: - m.priorityIndex = 3 - } - m.updateTableHeight() -} - -// activateDueEdit enables due-date editing for task id, initialising the -// date picker from currentDue (falls back to now if empty or unparseable). -func (m *Model) activateDueEdit(id int, currentDue string) { - m.dueID = id - if currentDue != "" { - if ts, err := parseTaskDate(currentDue); err == nil { - m.dueDate = ts - } else { - m.dueDate = time.Now() - } - } else { - m.dueDate = time.Now() - } - m.clearEditingModes() - m.dueEditing = true - m.updateTableHeight() -} - -// activateTagsEdit enables tags editing for task id with an empty input. -func (m *Model) activateTagsEdit(id int) { - m.clearEditingModes() - m.tagsID = id - m.tagsEditing = true - m.tagsInput.SetValue("") - m.tagsInput.Focus() - m.updateTableHeight() -} - -// activateProjectEdit enables project editing for task id, -// pre-filling the input with currentProject. -func (m *Model) activateProjectEdit(id int, currentProject string) { - m.clearEditingModes() - m.projID = id - m.projEditing = true - m.projInput.SetValue(currentProject) - m.projInput.Focus() - m.updateTableHeight() -} - -// activateRecurEdit enables recurrence editing for task id, -// pre-filling the input with currentRecur. -func (m *Model) activateRecurEdit(id int, currentRecur string) { - m.clearEditingModes() - m.recurID = id - m.recurEditing = true - m.recurInput.SetValue(currentRecur) - m.recurInput.Focus() - m.updateTableHeight() -} - -// activateAnnotationsEdit enables annotation editing for task id. -// The current annotations are joined with "; " and pre-filled in the input -// so the user can revise all annotations in one pass. -func (m *Model) activateAnnotationsEdit(id int, tsk *task.Task) (tea.Model, tea.Cmd) { - m.clearEditingModes() - m.annotateID = id - m.annotating = true - m.replaceAnnotations = true - if tsk != nil { - var anns []string - for _, a := range tsk.Annotations { - anns = append(anns, a.Description) - } - m.annotateInput.SetValue(strings.Join(anns, "; ")) - } - m.annotateInput.Focus() - m.updateTableHeight() - return m, nil -} - -// activateDescriptionEdit enables inline description editing for task id, -// pre-filling the input with the current description. -func (m *Model) activateDescriptionEdit(id int, tsk *task.Task) (tea.Model, tea.Cmd) { - m.clearEditingModes() - m.descID = id - m.descEditing = true - if tsk != nil { - m.descInput.SetValue(tsk.Description) - } - m.descInput.Focus() - m.updateTableHeight() - return m, nil -} diff --git a/internal/ui/helpers_test.go b/internal/ui/helpers_test.go index f0c0e27..9760277 100644 --- a/internal/ui/helpers_test.go +++ b/internal/ui/helpers_test.go @@ -1,9 +1,14 @@ package ui import ( + "fmt" "reflect" + "strings" "testing" "time" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" ) func TestParseTaskDate(t *testing.T) { @@ -227,6 +232,53 @@ func TestValidateDescription(t *testing.T) { } } +func TestHandleTextInputKeepsStateOnEnterError(t *testing.T) { + input := textinput.New() + input.SetValue("value") + input.Focus() + + m := Model{windowHeight: 20} + called := false + + mv, cmd := (&m).handleTextInput(tea.KeyPressMsg{Code: tea.KeyEnter}, &input, func(string) error { + return fmt.Errorf("boom") + }, func() { + called = true + }) + m = *mv.(*Model) + + if cmd == nil { + t.Fatalf("expected clear-status command on enter error") + } + if called { + t.Fatalf("onExit should not run on enter error") + } + if !input.Focused() { + t.Fatalf("input should stay focused on enter error") + } + if !strings.Contains(m.statusMsg, "boom") { + t.Fatalf("unexpected status message: %q", m.statusMsg) + } +} + +func TestActivateDueEditFallsBackToNowOnInvalidDate(t *testing.T) { + m := Model{windowHeight: 20} + before := time.Now().Add(-time.Second) + + m.activateDueEdit(7, "not-a-date") + + if !m.dueEditing { + t.Fatalf("due editing was not enabled") + } + if m.dueID != 7 { + t.Fatalf("due ID = %d, want 7", m.dueID) + } + after := time.Now().Add(time.Second) + if m.dueDate.Before(before) || m.dueDate.After(after) { + t.Fatalf("due date fallback was not based on now: %v", m.dueDate) + } +} + func TestValidateDueDate(t *testing.T) { tests := []struct { name string @@ -362,7 +414,6 @@ func TestValidateRecurrence(t *testing.T) { }) } } - // TestParseFilterInput verifies that parseFilterInput correctly handles // taskwarrior filter expressions, including attribute filters (proj:xxx), // tag filters (+tag), quoted values (description:"some text"), and empty input. @@ -440,4 +491,4 @@ func TestParseFilterInput(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/internal/ui/input_helpers.go b/internal/ui/input_helpers.go new file mode 100644 index 0000000..159eb6b --- /dev/null +++ b/internal/ui/input_helpers.go @@ -0,0 +1,126 @@ +package ui + +import ( + "fmt" + "strconv" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "codeberg.org/snonux/tasksamurai/internal/task" +) + +// handleBlinkingState handles input when a task is blinking +func (m *Model) handleBlinkingState(msg tea.Msg) (tea.Model, tea.Cmd) { + if _, ok := msg.(tea.KeyPressMsg); ok { + if m.showUltra { + return m.handleUltraBlinkingState(msg.(tea.KeyPressMsg)) + } + + // Only allow navigation while blinking. + prevRow := m.tbl.Cursor() + prevCol := m.tbl.ColumnCursor() + var cmd tea.Cmd + m.tbl, cmd = m.tbl.Update(msg) + if prevRow != m.tbl.Cursor() || prevCol != m.tbl.ColumnCursor() { + m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor()) + } + return m, cmd + } + return m, nil +} + +func (m *Model) handleUltraBlinkingState(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "j", "down": + m.ultraMoveCursor(1) + case "k", "up": + m.ultraMoveCursor(-1) + case "pgdn", "pgdown", "space": + m.ultraMoveCursor(m.ultraVisibleCount()) + case "pgup", "b": + m.ultraMoveCursor(-m.ultraVisibleCount()) + case "g", "home": + m.ultraGoHome() + case "G", "end": + m.ultraGoEnd() + } + return m, nil +} + +// handleEditingModes checks if we're in any editing mode and handles it +func (m *Model) handleEditingModes(msg tea.KeyPressMsg) (handled bool, model tea.Model, cmd tea.Cmd) { + switch { + case m.annotating: + model, cmd = m.handleAnnotationMode(msg) + return true, model, cmd + case m.descEditing: + model, cmd = m.handleDescriptionMode(msg) + return true, model, cmd + case m.tagsEditing: + model, cmd = m.handleTagsMode(msg) + return true, model, cmd + case m.dueEditing: + model, cmd = m.handleDueEditMode(msg) + return true, model, cmd + case m.recurEditing: + model, cmd = m.handleRecurrenceMode(msg) + return true, model, cmd + case m.projEditing: + model, cmd = m.handleProjectMode(msg) + return true, model, cmd + case m.prioritySelecting: + model, cmd = m.handlePriorityMode(msg) + return true, model, cmd + case m.filterEditing: + model, cmd = m.handleFilterMode(msg) + return true, model, cmd + case m.addingTask: + model, cmd = m.handleAddTaskMode(msg) + return true, model, cmd + case m.searching: + model, cmd = m.handleSearchMode(msg) + return true, model, cmd + case m.helpSearching: + model, cmd = m.handleHelpSearchMode(msg) + return true, model, cmd + } + return false, m, nil +} + +// getSelectedTaskID extracts the task ID from the selected row +func (m *Model) getSelectedTaskID() (int, error) { + row := m.tbl.SelectedRow() + if row == nil { + return 0, fmt.Errorf("no row selected") + } + idStr := ansi.Strip(row[1]) + return strconv.Atoi(idStr) +} + +// getTaskAtCursor returns the task at the current cursor position +func (m *Model) getTaskAtCursor() *task.Task { + cursor := m.tbl.Cursor() + if cursor < 0 || cursor >= len(m.tasks) { + return nil + } + return &m.tasks[cursor] +} + +// getTaskForOpenURL returns the task that should be used by the open-URL +// hotkey, honoring the active view's highlighted task. +func (m *Model) getTaskForOpenURL() *task.Task { + if m.showTaskDetail && m.currentTaskDetail != nil { + return m.currentTaskDetail + } + + if m.showUltra { + tasks := m.ultraTaskList() + if m.ultraCursor < 0 || m.ultraCursor >= len(tasks) { + return nil + } + return &tasks[m.ultraCursor] + } + + return m.getTaskAtCursor() +} diff --git a/internal/ui/keyactions.go b/internal/ui/keyactions.go new file mode 100644 index 0000000..d0b459f --- /dev/null +++ b/internal/ui/keyactions.go @@ -0,0 +1,594 @@ +package ui + +import ( + "fmt" + "math/rand" + "os/exec" + "strings" + "time" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "codeberg.org/snonux/tasksamurai/internal/task" +) + +func (m *Model) handleEditTask() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + m.editID = id + return m, editCmd(id) +} + +func (m *Model) handleToggleStart() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + + // Check if task is started + started := false + for _, tsk := range m.tasks { + if tsk.ID == id { + started = tsk.Start != "" + break + } + } + + if started { + if err := task.Stop(id); err != nil { + m.showError(err) + return m, nil + } + } else { + if err := task.Start(id); err != nil { + m.showError(err) + return m, nil + } + } + + if !m.reloadAndReport() { + return m, nil + } + return m, m.startBlink(id, false) +} + +func (m *Model) handleMarkDone() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + return m, m.startBlink(id, true) +} + +func (m *Model) handleOpenURL() (tea.Model, tea.Cmd) { + task := m.getTaskForOpenURL() + if task == nil { + return m, nil + } + + url := urlRegex.FindString(task.Description) + if url == "" { + for _, ann := range task.Annotations { + url = urlRegex.FindString(ann.Description) + if url != "" { + break + } + } + } + if url == "" { + return m, nil + } + + if err := exec.Command(m.browserCmd, url).Run(); err != nil { + m.showError(fmt.Errorf("opening browser: %w", err)) + return m, nil + } + + return m, m.startBlink(task.ID, false) +} + +func (m *Model) handleUndo() (tea.Model, tea.Cmd) { + if len(m.undoStack) == 0 { + return m, nil + } + + uuid := m.undoStack[len(m.undoStack)-1] + m.undoStack = m.undoStack[:len(m.undoStack)-1] + + if err := task.SetStatusUUID(uuid, "pending"); err != nil { + m.showError(err) + return m, nil + } + + // Reload the task list to get the updated task with its new ID + if err := m.reload(); err != nil { + m.showError(err) + return m, nil + } + + // Find the task ID for blinking + var id int + var found bool + for _, tsk := range m.tasks { + if tsk.UUID == uuid { + id = tsk.ID + found = true + break + } + } + + // If task not found or has ID 0, try to get it directly from Taskwarrior + if !found || id == 0 { + // Use task export with UUID filter to get the specific task + filters := []string{uuid} + if m.filters != nil { + filters = append(filters, m.filters...) + } + filters = append(filters, "status:pending") + + tasks, err := task.Export(filters...) + if err == nil && len(tasks) > 0 { + id = tasks[0].ID + // Also update our local task list + for i, tsk := range m.tasks { + if tsk.UUID == uuid { + m.tasks[i].ID = id + break + } + } + } + } + + // If we still don't have a valid ID, don't try to blink + if id == 0 { + m.statusMsg = "Task restored" + return m, nil + } + + return m, m.startBlink(id, false) +} + +func (m *Model) handleSetDueDate() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + + m.clearEditingModes() + m.dueID = id + m.dueEditing = true + m.dueDate = time.Now() + m.updateTableHeight() + return m, nil +} + +func (m *Model) handleRemoveDueDate() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + + // In Taskwarrior, passing an empty value to due: removes the due date + if err := task.SetDueDate(id, ""); err != nil { + m.showError(err) + return m, nil + } + + if !m.reloadAndReport() { + return m, nil + } + return m, m.startBlink(id, false) +} + +func (m *Model) handleRandomDueDate() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + + days := rand.Intn(31) + 7 + due := time.Now().AddDate(0, 0, days).Format("2006-01-02") + + if err := task.SetDueDate(id, due); err != nil { + m.showError(err) + return m, nil + } + + if !m.reloadAndReport() { + return m, nil + } + return m, m.startBlink(id, false) +} + +func (m *Model) handleSetRecurrence() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + + task := m.getTaskAtCursor() + if task == nil { + return m, nil + } + + m.clearEditingModes() + m.recurID = id + m.recurEditing = true + m.recurInput.SetValue(task.Recur) + m.recurInput.Focus() + m.updateTableHeight() + return m, nil +} + +func (m *Model) handleSetPriority() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + + m.clearEditingModes() + m.priorityID = id + m.prioritySelecting = true + m.priorityIndex = 0 + m.updateTableHeight() + return m, nil +} + +func (m *Model) handleAnnotate(replace bool) (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + + m.clearEditingModes() + m.annotateID = id + m.annotating = true + m.replaceAnnotations = replace + m.annotateInput.SetValue("") + m.annotateInput.Focus() + m.updateTableHeight() + return m, nil +} + +func (m *Model) handleFilter() (tea.Model, tea.Cmd) { + m.clearEditingModes() + m.filterEditing = true + m.filterInput.SetValue(strings.Join(m.filters, " ")) + m.filterInput.Focus() + m.updateTableHeight() + return m, nil +} + +func (m *Model) handleAddTask() (tea.Model, tea.Cmd) { + m.clearEditingModes() + m.addingTask = true + m.addInput.SetValue("") + m.addInput.Focus() + m.updateTableHeight() + return m, nil +} + +func (m *Model) handleEditTags() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + + m.clearEditingModes() + m.tagsID = id + m.tagsEditing = true + m.tagsInput.SetValue("") + m.tagsInput.Focus() + m.updateTableHeight() + return m, nil +} + +func (m *Model) handleEditProject() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + + m.clearEditingModes() + m.projID = id + m.projEditing = true + + // Get current project value + task := m.getTaskAtCursor() + if task != nil { + m.projInput.SetValue(task.Project) + } else { + m.projInput.SetValue("") + } + m.projInput.Focus() + m.updateTableHeight() + return m, nil +} + +func (m *Model) handleTagToProject() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + + // Get the task at cursor + currentTask := m.getTaskAtCursor() + if currentTask == nil || len(currentTask.Tags) == 0 { + // No tags to convert + return m, nil + } + + // Get the first tag + firstTag := currentTask.Tags[0] + + // Set the tag as project + if err := task.SetProject(id, firstTag); err != nil { + m.showError(err) + return m, nil + } + + // Remove the tag from the task + if err := task.RemoveTags(id, []string{firstTag}); err != nil { + m.showError(err) + return m, nil + } + + if !m.reloadAndReport() { + return m, nil + } + return m, m.startBlink(id, false) +} + +func (m *Model) handleRandomTheme() (tea.Model, tea.Cmd) { + m.theme = RandomTheme() + m.applyTheme() + return m, nil +} + +func (m *Model) handleResetTheme() (tea.Model, tea.Cmd) { + m.theme = m.defaultTheme + m.applyTheme() + return m, nil +} + +func (m *Model) handleToggleDisco() (tea.Model, tea.Cmd) { + m.disco = !m.disco + return m, nil +} + +func (m *Model) handleToggleBlink() (tea.Model, tea.Cmd) { + m.blinkEnabled = !m.blinkEnabled + if m.blinkEnabled { + m.statusMsg = "Blinking enabled" + } else { + m.statusMsg = "Blinking disabled" + } + return m, nil +} + +func (m *Model) handleRefresh() (tea.Model, tea.Cmd) { + m.reloadAndReport() + return m, nil +} + +func (m *Model) handleSearch() (tea.Model, tea.Cmd) { + m.clearEditingModes() + m.searching = true + m.searchIndex = 0 + m.searchMatches = nil + m.searchInput.SetValue("") + m.searchInput.Focus() + m.updateTableHeight() + return m, nil +} + +func (m *Model) handleNextSearchMatch() (tea.Model, tea.Cmd) { + if len(m.searchMatches) == 0 { + return m, nil + } + + m.searchIndex = (m.searchIndex + 1) % len(m.searchMatches) + match := m.searchMatches[m.searchIndex] + prevRow := m.tbl.Cursor() + prevCol := m.tbl.ColumnCursor() + m.tbl.SetCursor(match.row) + m.tbl.SetColumnCursor(match.col) + m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor()) + return m, nil +} + +func (m *Model) handlePrevSearchMatch() (tea.Model, tea.Cmd) { + if len(m.searchMatches) == 0 { + return m, nil + } + + m.searchIndex = (m.searchIndex - 1 + len(m.searchMatches)) % len(m.searchMatches) + match := m.searchMatches[m.searchIndex] + prevRow := m.tbl.Cursor() + prevCol := m.tbl.ColumnCursor() + m.tbl.SetCursor(match.row) + m.tbl.SetColumnCursor(match.col) + m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor()) + return m, nil +} + +func (m *Model) handleHelpSearch() (tea.Model, tea.Cmd) { + m.helpSearching = true + m.helpSearchIndex = 0 + m.helpSearchMatches = nil + m.helpSearchInput.SetValue("") + m.helpSearchInput.Focus() + return m, nil +} + +func (m *Model) handleNextHelpSearchMatch() (tea.Model, tea.Cmd) { + if len(m.helpSearchMatches) == 0 { + return m, nil + } + + m.helpSearchIndex = (m.helpSearchIndex + 1) % len(m.helpSearchMatches) + // In the future, we could add visual indication of current match + return m, nil +} + +func (m *Model) handlePrevHelpSearchMatch() (tea.Model, tea.Cmd) { + if len(m.helpSearchMatches) == 0 { + return m, nil + } + + m.helpSearchIndex = (m.helpSearchIndex - 1 + len(m.helpSearchMatches)) % len(m.helpSearchMatches) + // In the future, we could add visual indication of current match + return m, nil +} + +func (m *Model) handleShowTaskDetail() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + + // Find the task with this ID + for i := range m.tasks { + if m.tasks[i].ID == id { + m.showTaskDetail = true + m.currentTaskDetail = &m.tasks[i] + m.detailSearching = false + m.detailSearchRegex = nil + m.detailFieldIndex = 0 + m.detailBlinkField = -1 + m.detailBlinkOn = false + m.detailBlinkCount = 0 + m.detailSearchInput = textinput.New() + m.detailSearchInput.Placeholder = "Search..." + m.detailSearchInput.SetWidth(30) + break + } + } + + return m, nil +} + +// handleEnterOrEdit dispatches to the appropriate inline editor based on the +// column the cursor is on. Shared activation helpers (activatePriorityEdit, +// activateDueEdit, etc.) are defined in detail_handlers.go to avoid duplication +// with the detail-view editing path. +func (m *Model) handleEnterOrEdit() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + // No task selected — toggle expanded-cell panel instead. + m.cellExpanded = !m.cellExpanded + m.updateTableHeight() + return m, nil + } + + tsk := m.getTaskAtCursor() + // taskStr extracts a string field from the cursor task, returning "" + // when no task is selected so activation helpers get a safe zero value. + taskStr := func(get func(*task.Task) string) string { + if tsk == nil { + return "" + } + return get(tsk) + } + + switch m.tbl.ColumnCursor() { + case 0: // Priority + m.activatePriorityEdit(id, taskStr(func(t *task.Task) string { return t.Priority })) + case 3: // Due date + m.activateDueEdit(id, taskStr(func(t *task.Task) string { return t.Due })) + case 4: // Recurrence + m.activateRecurEdit(id, taskStr(func(t *task.Task) string { return t.Recur })) + case 5: // Project + m.activateProjectEdit(id, taskStr(func(t *task.Task) string { return t.Project })) + case 6: // Tags + m.activateTagsEdit(id) + case 7: // Annotations + return m.activateAnnotationsEdit(id, tsk) + case 8: // Description + return m.activateDescriptionEdit(id, tsk) + default: + // Other columns: toggle expanded-cell panel. + m.cellExpanded = !m.cellExpanded + m.updateTableHeight() + } + return m, nil +} + +func (m *Model) handleTableNavigation(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + prevRow := m.tbl.Cursor() + prevCol := m.tbl.ColumnCursor() + var cmd tea.Cmd + m.tbl, cmd = m.tbl.Update(msg) + if prevRow != m.tbl.Cursor() || prevCol != m.tbl.ColumnCursor() { + m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor()) + } + return m, cmd +} + +// showError displays an error in the status bar +func (m *Model) showError(err error) { + m.statusMsg = fmt.Sprintf("Error: %v", err) + // Note: we can't return a Cmd from here, so the error will stay until next update +} + +// handleJumpToRandomTask jumps to a random pending task +func (m *Model) handleJumpToRandomTask() (tea.Model, tea.Cmd) { + if len(m.tasks) == 0 { + m.statusMsg = "No tasks to jump to" + return m, nil + } + + // Pick a random index + randomIndex := rand.Intn(len(m.tasks)) + + // Update cursor position + prevRow := m.tbl.Cursor() + prevCol := m.tbl.ColumnCursor() + m.tbl.SetCursor(randomIndex) + m.updateSelectionHighlight(prevRow, randomIndex, prevCol, m.tbl.ColumnCursor()) + + // Blink the task to indicate jump + if randomIndex < len(m.tasks) { + taskID := m.tasks[randomIndex].ID + return m, m.startBlink(taskID, false) + } + + return m, nil +} + +// handleJumpToRandomTaskNoDue jumps to a random pending task without a due date +func (m *Model) handleJumpToRandomTaskNoDue() (tea.Model, tea.Cmd) { + // Find all tasks without due dates + var noDueTasks []int + for i, task := range m.tasks { + if task.Due == "" { + noDueTasks = append(noDueTasks, i) + } + } + + if len(noDueTasks) == 0 { + m.statusMsg = "No tasks without due date to jump to" + return m, nil + } + + // Pick a random task from the no-due list + randomChoice := rand.Intn(len(noDueTasks)) + randomIndex := noDueTasks[randomChoice] + + // Update cursor position + prevRow := m.tbl.Cursor() + prevCol := m.tbl.ColumnCursor() + m.tbl.SetCursor(randomIndex) + m.updateSelectionHighlight(prevRow, randomIndex, prevCol, m.tbl.ColumnCursor()) + + // Blink the task to indicate jump + if randomIndex < len(m.tasks) { + taskID := m.tasks[randomIndex].ID + return m, m.startBlink(taskID, false) + } + + return m, nil +} diff --git a/internal/ui/keyhandlers.go b/internal/ui/keyhandlers.go index de16dd0..f688679 100644 --- a/internal/ui/keyhandlers.go +++ b/internal/ui/keyhandlers.go @@ -1,17 +1,8 @@ package ui import ( - "fmt" - "math/rand" - "os/exec" - "strings" - "time" - - "charm.land/bubbles/v2/textinput" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" - - "codeberg.org/snonux/tasksamurai/internal/task" ) // handleNormalMode handles keyboard input in normal mode (not editing) @@ -257,583 +248,3 @@ func (m *Model) handleEscapeKey() (tea.Model, tea.Cmd) { } return m, nil } - -func (m *Model) handleEditTask() (tea.Model, tea.Cmd) { - id, err := m.getSelectedTaskID() - if err != nil { - return m, nil - } - m.editID = id - return m, editCmd(id) -} - -func (m *Model) handleToggleStart() (tea.Model, tea.Cmd) { - id, err := m.getSelectedTaskID() - if err != nil { - return m, nil - } - - // Check if task is started - started := false - for _, tsk := range m.tasks { - if tsk.ID == id { - started = tsk.Start != "" - break - } - } - - if started { - if err := task.Stop(id); err != nil { - m.showError(err) - return m, nil - } - } else { - if err := task.Start(id); err != nil { - m.showError(err) - return m, nil - } - } - - if !m.reloadAndReport() { - return m, nil - } - return m, m.startBlink(id, false) -} - -func (m *Model) handleMarkDone() (tea.Model, tea.Cmd) { - id, err := m.getSelectedTaskID() - if err != nil { - return m, nil - } - return m, m.startBlink(id, true) -} - -func (m *Model) handleOpenURL() (tea.Model, tea.Cmd) { - task := m.getTaskForOpenURL() - if task == nil { - return m, nil - } - - url := urlRegex.FindString(task.Description) - if url == "" { - for _, ann := range task.Annotations { - url = urlRegex.FindString(ann.Description) - if url != "" { - break - } - } - } - if url == "" { - return m, nil - } - - if err := exec.Command(m.browserCmd, url).Run(); err != nil { - m.showError(fmt.Errorf("opening browser: %w", err)) - return m, nil - } - - return m, m.startBlink(task.ID, false) -} - -func (m *Model) handleUndo() (tea.Model, tea.Cmd) { - if len(m.undoStack) == 0 { - return m, nil - } - - uuid := m.undoStack[len(m.undoStack)-1] - m.undoStack = m.undoStack[:len(m.undoStack)-1] - - if err := task.SetStatusUUID(uuid, "pending"); err != nil { - m.showError(err) - return m, nil - } - - // Reload the task list to get the updated task with its new ID - if err := m.reload(); err != nil { - m.showError(err) - return m, nil - } - - // Find the task ID for blinking - var id int - var found bool - for _, tsk := range m.tasks { - if tsk.UUID == uuid { - id = tsk.ID - found = true - break - } - } - - // If task not found or has ID 0, try to get it directly from Taskwarrior - if !found || id == 0 { - // Use task export with UUID filter to get the specific task - filters := []string{uuid} - if m.filters != nil { - filters = append(filters, m.filters...) - } - filters = append(filters, "status:pending") - - tasks, err := task.Export(filters...) - if err == nil && len(tasks) > 0 { - id = tasks[0].ID - // Also update our local task list - for i, tsk := range m.tasks { - if tsk.UUID == uuid { - m.tasks[i].ID = id - break - } - } - } - } - - // If we still don't have a valid ID, don't try to blink - if id == 0 { - m.statusMsg = "Task restored" - return m, nil - } - - return m, m.startBlink(id, false) -} - -func (m *Model) handleSetDueDate() (tea.Model, tea.Cmd) { - id, err := m.getSelectedTaskID() - if err != nil { - return m, nil - } - - m.clearEditingModes() - m.dueID = id - m.dueEditing = true - m.dueDate = time.Now() - m.updateTableHeight() - return m, nil -} - -func (m *Model) handleRemoveDueDate() (tea.Model, tea.Cmd) { - id, err := m.getSelectedTaskID() - if err != nil { - return m, nil - } - - // In Taskwarrior, passing an empty value to due: removes the due date - if err := task.SetDueDate(id, ""); err != nil { - m.showError(err) - return m, nil - } - - if !m.reloadAndReport() { - return m, nil - } - return m, m.startBlink(id, false) -} - -func (m *Model) handleRandomDueDate() (tea.Model, tea.Cmd) { - id, err := m.getSelectedTaskID() - if err != nil { - return m, nil - } - - days := rand.Intn(31) + 7 - due := time.Now().AddDate(0, 0, days).Format("2006-01-02") - - if err := task.SetDueDate(id, due); err != nil { - m.showError(err) - return m, nil - } - - if !m.reloadAndReport() { - return m, nil - } - return m, m.startBlink(id, false) -} - -func (m *Model) handleSetRecurrence() (tea.Model, tea.Cmd) { - id, err := m.getSelectedTaskID() - if err != nil { - return m, nil - } - - task := m.getTaskAtCursor() - if task == nil { - return m, nil - } - - m.clearEditingModes() - m.recurID = id - m.recurEditing = true - m.recurInput.SetValue(task.Recur) - m.recurInput.Focus() - m.updateTableHeight() - return m, nil -} - -func (m *Model) handleSetPriority() (tea.Model, tea.Cmd) { - id, err := m.getSelectedTaskID() - if err != nil { - return m, nil - } - - m.clearEditingModes() - m.priorityID = id - m.prioritySelecting = true - m.priorityIndex = 0 - m.updateTableHeight() - return m, nil -} - -func (m *Model) handleAnnotate(replace bool) (tea.Model, tea.Cmd) { - id, err := m.getSelectedTaskID() - if err != nil { - return m, nil - } - - m.clearEditingModes() - m.annotateID = id - m.annotating = true - m.replaceAnnotations = replace - m.annotateInput.SetValue("") - m.annotateInput.Focus() - m.updateTableHeight() - return m, nil -} - -func (m *Model) handleFilter() (tea.Model, tea.Cmd) { - m.clearEditingModes() - m.filterEditing = true - m.filterInput.SetValue(strings.Join(m.filters, " ")) - m.filterInput.Focus() - m.updateTableHeight() - return m, nil -} - -func (m *Model) handleAddTask() (tea.Model, tea.Cmd) { - m.clearEditingModes() - m.addingTask = true - m.addInput.SetValue("") - m.addInput.Focus() - m.updateTableHeight() - return m, nil -} - -func (m *Model) handleEditTags() (tea.Model, tea.Cmd) { - id, err := m.getSelectedTaskID() - if err != nil { - return m, nil - } - - m.clearEditingModes() - m.tagsID = id - m.tagsEditing = true - m.tagsInput.SetValue("") - m.tagsInput.Focus() - m.updateTableHeight() - return m, nil -} - -func (m *Model) handleEditProject() (tea.Model, tea.Cmd) { - id, err := m.getSelectedTaskID() - if err != nil { - return m, nil - } - - m.clearEditingModes() - m.projID = id - m.projEditing = true - - // Get current project value - task := m.getTaskAtCursor() - if task != nil { - m.projInput.SetValue(task.Project) - } else { - m.projInput.SetValue("") - } - m.projInput.Focus() - m.updateTableHeight() - return m, nil -} - -func (m *Model) handleTagToProject() (tea.Model, tea.Cmd) { - id, err := m.getSelectedTaskID() - if err != nil { - return m, nil - } - - // Get the task at cursor - currentTask := m.getTaskAtCursor() - if currentTask == nil || len(currentTask.Tags) == 0 { - // No tags to convert - return m, nil - } - - // Get the first tag - firstTag := currentTask.Tags[0] - - // Set the tag as project - if err := task.SetProject(id, firstTag); err != nil { - m.showError(err) - return m, nil - } - - // Remove the tag from the task - if err := task.RemoveTags(id, []string{firstTag}); err != nil { - m.showError(err) - return m, nil - } - - if !m.reloadAndReport() { - return m, nil - } - return m, m.startBlink(id, false) -} - -func (m *Model) handleRandomTheme() (tea.Model, tea.Cmd) { - m.theme = RandomTheme() - m.applyTheme() - return m, nil -} - -func (m *Model) handleResetTheme() (tea.Model, tea.Cmd) { - m.theme = m.defaultTheme - m.applyTheme() - return m, nil -} - -func (m *Model) handleToggleDisco() (tea.Model, tea.Cmd) { - m.disco = !m.disco - return m, nil -} - -func (m *Model) handleToggleBlink() (tea.Model, tea.Cmd) { - m.blinkEnabled = !m.blinkEnabled - if m.blinkEnabled { - m.statusMsg = "Blinking enabled" - } else { - m.statusMsg = "Blinking disabled" - } - return m, nil -} - -func (m *Model) handleRefresh() (tea.Model, tea.Cmd) { - m.reloadAndReport() - return m, nil -} - -func (m *Model) handleSearch() (tea.Model, tea.Cmd) { - m.clearEditingModes() - m.searching = true - m.searchIndex = 0 - m.searchMatches = nil - m.searchInput.SetValue("") - m.searchInput.Focus() - m.updateTableHeight() - return m, nil -} - -func (m *Model) handleNextSearchMatch() (tea.Model, tea.Cmd) { - if len(m.searchMatches) == 0 { - return m, nil - } - - m.searchIndex = (m.searchIndex + 1) % len(m.searchMatches) - match := m.searchMatches[m.searchIndex] - prevRow := m.tbl.Cursor() - prevCol := m.tbl.ColumnCursor() - m.tbl.SetCursor(match.row) - m.tbl.SetColumnCursor(match.col) - m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor()) - return m, nil -} - -func (m *Model) handlePrevSearchMatch() (tea.Model, tea.Cmd) { - if len(m.searchMatches) == 0 { - return m, nil - } - - m.searchIndex = (m.searchIndex - 1 + len(m.searchMatches)) % len(m.searchMatches) - match := m.searchMatches[m.searchIndex] - prevRow := m.tbl.Cursor() - prevCol := m.tbl.ColumnCursor() - m.tbl.SetCursor(match.row) - m.tbl.SetColumnCursor(match.col) - m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor()) - return m, nil -} - -func (m *Model) handleHelpSearch() (tea.Model, tea.Cmd) { - m.helpSearching = true - m.helpSearchIndex = 0 - m.helpSearchMatches = nil - m.helpSearchInput.SetValue("") - m.helpSearchInput.Focus() - return m, nil -} - -func (m *Model) handleNextHelpSearchMatch() (tea.Model, tea.Cmd) { - if len(m.helpSearchMatches) == 0 { - return m, nil - } - - m.helpSearchIndex = (m.helpSearchIndex + 1) % len(m.helpSearchMatches) - // In the future, we could add visual indication of current match - return m, nil -} - -func (m *Model) handlePrevHelpSearchMatch() (tea.Model, tea.Cmd) { - if len(m.helpSearchMatches) == 0 { - return m, nil - } - - m.helpSearchIndex = (m.helpSearchIndex - 1 + len(m.helpSearchMatches)) % len(m.helpSearchMatches) - // In the future, we could add visual indication of current match - return m, nil -} - -func (m *Model) handleShowTaskDetail() (tea.Model, tea.Cmd) { - id, err := m.getSelectedTaskID() - if err != nil { - return m, nil - } - - // Find the task with this ID - for i := range m.tasks { - if m.tasks[i].ID == id { - m.showTaskDetail = true - m.currentTaskDetail = &m.tasks[i] - m.detailSearching = false - m.detailSearchRegex = nil - m.detailFieldIndex = 0 - m.detailBlinkField = -1 - m.detailBlinkOn = false - m.detailBlinkCount = 0 - m.detailSearchInput = textinput.New() - m.detailSearchInput.Placeholder = "Search..." - m.detailSearchInput.SetWidth(30) - break - } - } - - return m, nil -} - -// handleEnterOrEdit dispatches to the appropriate inline editor based on the -// column the cursor is on. Shared activation helpers (activatePriorityEdit, -// activateDueEdit, etc.) are defined in handlers.go to avoid duplication with -// the detail-view editing path. -func (m *Model) handleEnterOrEdit() (tea.Model, tea.Cmd) { - id, err := m.getSelectedTaskID() - if err != nil { - // No task selected — toggle expanded-cell panel instead. - m.cellExpanded = !m.cellExpanded - m.updateTableHeight() - return m, nil - } - - tsk := m.getTaskAtCursor() - // taskStr extracts a string field from the cursor task, returning "" - // when no task is selected so activation helpers get a safe zero value. - taskStr := func(get func(*task.Task) string) string { - if tsk == nil { - return "" - } - return get(tsk) - } - - switch m.tbl.ColumnCursor() { - case 0: // Priority - m.activatePriorityEdit(id, taskStr(func(t *task.Task) string { return t.Priority })) - case 3: // Due date - m.activateDueEdit(id, taskStr(func(t *task.Task) string { return t.Due })) - case 4: // Recurrence - m.activateRecurEdit(id, taskStr(func(t *task.Task) string { return t.Recur })) - case 5: // Project - m.activateProjectEdit(id, taskStr(func(t *task.Task) string { return t.Project })) - case 6: // Tags - m.activateTagsEdit(id) - case 7: // Annotations - return m.activateAnnotationsEdit(id, tsk) - case 8: // Description - return m.activateDescriptionEdit(id, tsk) - default: - // Other columns: toggle expanded-cell panel. - m.cellExpanded = !m.cellExpanded - m.updateTableHeight() - } - return m, nil -} - -func (m *Model) handleTableNavigation(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - prevRow := m.tbl.Cursor() - prevCol := m.tbl.ColumnCursor() - var cmd tea.Cmd - m.tbl, cmd = m.tbl.Update(msg) - if prevRow != m.tbl.Cursor() || prevCol != m.tbl.ColumnCursor() { - m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor()) - } - return m, cmd -} - -// showError displays an error in the status bar -func (m *Model) showError(err error) { - m.statusMsg = fmt.Sprintf("Error: %v", err) - // Note: we can't return a Cmd from here, so the error will stay until next update -} - -// handleJumpToRandomTask jumps to a random pending task -func (m *Model) handleJumpToRandomTask() (tea.Model, tea.Cmd) { - if len(m.tasks) == 0 { - m.statusMsg = "No tasks to jump to" - return m, nil - } - - // Pick a random index - randomIndex := rand.Intn(len(m.tasks)) - - // Update cursor position - prevRow := m.tbl.Cursor() - prevCol := m.tbl.ColumnCursor() - m.tbl.SetCursor(randomIndex) - m.updateSelectionHighlight(prevRow, randomIndex, prevCol, m.tbl.ColumnCursor()) - - // Blink the task to indicate jump - if randomIndex < len(m.tasks) { - taskID := m.tasks[randomIndex].ID - return m, m.startBlink(taskID, false) - } - - return m, nil -} - -// handleJumpToRandomTaskNoDue jumps to a random pending task without a due date -func (m *Model) handleJumpToRandomTaskNoDue() (tea.Model, tea.Cmd) { - // Find all tasks without due dates - var noDueTasks []int - for i, task := range m.tasks { - if task.Due == "" { - noDueTasks = append(noDueTasks, i) - } - } - - if len(noDueTasks) == 0 { - m.statusMsg = "No tasks without due date to jump to" - return m, nil - } - - // Pick a random task from the no-due list - randomChoice := rand.Intn(len(noDueTasks)) - randomIndex := noDueTasks[randomChoice] - - // Update cursor position - prevRow := m.tbl.Cursor() - prevCol := m.tbl.ColumnCursor() - m.tbl.SetCursor(randomIndex) - m.updateSelectionHighlight(prevRow, randomIndex, prevCol, m.tbl.ColumnCursor()) - - // Blink the task to indicate jump - if randomIndex < len(m.tasks) { - taskID := m.tasks[randomIndex].ID - return m, m.startBlink(taskID, false) - } - - return m, nil -} diff --git a/internal/ui/table.go b/internal/ui/table.go index 20928fd..464b29f 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -572,70 +572,6 @@ func (m *Model) handleWindowResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { return m, nil } -// handleEditDone handles completion of external editor -func (m *Model) handleEditDone(msg editDoneMsg) (tea.Model, tea.Cmd) { - if msg.err != nil { - m.showError(fmt.Errorf("editor: %w", msg.err)) - } - if m.showUltra { - m.ultraFocusedID = m.editID - } - if !m.reloadAndReport() { - m.editID = 0 - return m, nil - } - cmd := m.startBlink(m.editID, false) - m.editID = 0 - return m, cmd -} - -// handleDescEditDone handles the completion of description editing -func (m *Model) handleDescEditDone(msg descEditDoneMsg) (tea.Model, tea.Cmd) { - m.detailDescEditing = false - if msg.tempFile != "" { - defer func() { _ = os.Remove(msg.tempFile) }() - } - - if msg.err != nil { - m.statusMsg = fmt.Sprintf("Edit error: %v", msg.err) - cmd := tea.Tick(2*time.Second, func(time.Time) tea.Msg { - return struct{ clearStatus bool }{true} - }) - return m, cmd - } - - // Read the edited content - content, err := os.ReadFile(msg.tempFile) - if err != nil { - m.statusMsg = fmt.Sprintf("Error reading file: %v", err) - cmd := tea.Tick(2*time.Second, func(time.Time) tea.Msg { - return struct{ clearStatus bool }{true} - }) - return m, cmd - } - - // Update the description - newDesc := strings.TrimSpace(string(content)) - if m.currentTaskDetail != nil { - err = task.SetDescription(m.currentTaskDetail.ID, newDesc) - if err != nil { - m.statusMsg = fmt.Sprintf("Error updating description: %v", err) - cmd := tea.Tick(2*time.Second, func(time.Time) tea.Msg { - return struct{ clearStatus bool }{true} - }) - return m, cmd - } - - // Reload and start blinking - if !m.reloadAndReport() { - return m, nil - } - return m, m.startDetailBlink(m.detailDescriptionFieldIndex()) - } - - return m, nil -} - // handleBlinkMsg handles the blinking animation timer func (m *Model) handleBlinkMsg() (tea.Model, tea.Cmd) { // Handle detail view blinking -- cgit v1.2.3