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,