summaryrefslogtreecommitdiff
path: root/internal/ui/keyactions.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-08 16:01:38 +0300
committerPaul Buetow <paul@buetow.org>2026-04-08 16:15:04 +0300
commitd13795080cb8a177d93e593ce91ba3a30b64c5ad (patch)
treeff2266af07b3186b502c3e69c658030e07213773 /internal/ui/keyactions.go
parent8ce0a453f355dd3f9239ee33bf4a426f6951ac9f (diff)
Refactor UI input handlers for task 1
Diffstat (limited to 'internal/ui/keyactions.go')
-rw-r--r--internal/ui/keyactions.go594
1 files changed, 594 insertions, 0 deletions
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
+}