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/keyactions.go | 594 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 594 insertions(+) create mode 100644 internal/ui/keyactions.go (limited to 'internal/ui/keyactions.go') 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 +} -- cgit v1.2.3