summaryrefslogtreecommitdiff
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
parent8ce0a453f355dd3f9239ee33bf4a426f6951ac9f (diff)
Refactor UI input handlers for task 1
-rw-r--r--internal/ui/detail_handlers.go244
-rw-r--r--internal/ui/editor_handlers.go76
-rw-r--r--internal/ui/handlers.go350
-rw-r--r--internal/ui/helpers_test.go55
-rw-r--r--internal/ui/input_helpers.go126
-rw-r--r--internal/ui/keyactions.go594
-rw-r--r--internal/ui/keyhandlers.go589
-rw-r--r--internal/ui/table.go64
8 files changed, 1093 insertions, 1005 deletions
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