summaryrefslogtreecommitdiff
path: root/internal/ui
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-06-28 00:00:15 +0300
committerPaul Buetow <paul@buetow.org>2025-06-28 00:00:15 +0300
commit0e065b3b0f5e935fc769be2f1e84779fa9897e99 (patch)
treee72775ab2fba73100955ac04b2c66e2d567fe7b6 /internal/ui
parente527f6084f4a3f592d06c25e34e08cc3769706a8 (diff)
fix: resolve test failures and improve code quality
- Fix file handle leak in SetDebugLog by tracking and closing previous files - Add stderr capture to all taskwarrior commands for better error messages - Fix timezone issues in date handling tests by normalizing to UTC - Change Update method to pointer receiver for consistency - Update all test type assertions to handle pointer receivers correctly - Remove unused imports and variables All tests now pass successfully. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'internal/ui')
-rw-r--r--internal/ui/handlers.go437
-rw-r--r--internal/ui/helpers.go191
-rw-r--r--internal/ui/helpers_test.go363
-rw-r--r--internal/ui/keyhandlers.go493
-rw-r--r--internal/ui/table.go723
-rw-r--r--internal/ui/table_test.go149
-rw-r--r--internal/ui/theme.go3
7 files changed, 1638 insertions, 721 deletions
diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go
new file mode 100644
index 0000000..7fc734b
--- /dev/null
+++ b/internal/ui/handlers.go
@@ -0,0 +1,437 @@
+package ui
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/x/ansi"
+
+ "codeberg.org/snonux/tasksamurai/internal/task"
+)
+
+// handleTextInput provides generic text input handling for all input modes
+func (m *Model) handleTextInput(msg tea.KeyMsg, input *textinput.Model, onEnter func(string) error, onExit func()) (tea.Model, tea.Cmd) {
+ switch msg.Type {
+ case tea.KeyEnter:
+ value := input.Value()
+ if err := onEnter(value); err != nil {
+ m.statusMsg = fmt.Sprintf("Error: %v", err)
+ cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg {
+ return struct{ clearStatus bool }{true}
+ })
+ return m, cmd
+ }
+ input.Blur()
+ onExit()
+ m.updateTableHeight()
+ return m, nil
+ case tea.KeyEsc:
+ input.Blur()
+ onExit()
+ m.updateTableHeight()
+ return m, nil
+ }
+ var cmd tea.Cmd
+ *input, cmd = input.Update(msg)
+ return m, cmd
+}
+
+// handleAnnotationMode handles keyboard input when in annotation mode
+func (m *Model) handleAnnotationMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ onEnter := func(value string) error {
+ // Annotation can be empty when replacing (to remove all)
+ if !m.replaceAnnotations && strings.TrimSpace(value) == "" {
+ return fmt.Errorf("annotation cannot be empty")
+ }
+
+ if m.replaceAnnotations {
+ if err := task.ReplaceAnnotations(m.annotateID, value); err != nil {
+ return err
+ }
+ m.replaceAnnotations = false
+ } else {
+ if err := task.Annotate(m.annotateID, value); err != nil {
+ return err
+ }
+ }
+ m.reload()
+ return nil
+ }
+
+ onExit := func() {
+ m.annotating = false
+ m.replaceAnnotations = false
+ }
+
+ model, cmd := m.handleTextInput(msg, &m.annotateInput, onEnter, onExit)
+ if msg.Type == tea.KeyEnter && m.annotateInput.Value() != "" {
+ // Start blink after successful annotation
+ return model, m.startBlink(m.annotateID, false)
+ }
+ return model, cmd
+}
+
+// handleDescriptionMode handles keyboard input when editing description
+func (m *Model) handleDescriptionMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ onEnter := func(value string) error {
+ if err := validateDescription(value); err != nil {
+ return err
+ }
+ if err := task.SetDescription(m.descID, value); err != nil {
+ return err
+ }
+ m.reload()
+ return nil
+ }
+
+ onExit := func() {
+ m.descEditing = false
+ }
+
+ model, cmd := m.handleTextInput(msg, &m.descInput, onEnter, onExit)
+ if msg.Type == tea.KeyEnter {
+ return model, m.startBlink(m.descID, false)
+ }
+ return model, cmd
+}
+
+// handleTagsMode handles keyboard input when editing tags
+func (m *Model) handleTagsMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ onEnter := func(value string) error {
+ words := strings.Fields(value)
+ var adds, removes []string
+ for _, w := range words {
+ if strings.HasPrefix(w, "-") {
+ if len(w) > 1 {
+ tagName := w[1:]
+ if err := validateTagName(tagName); err != nil {
+ return fmt.Errorf("remove tag '%s': %w", tagName, err)
+ }
+ removes = append(removes, tagName)
+ }
+ } else {
+ if strings.HasPrefix(w, "+") {
+ w = w[1:]
+ }
+ if w != "" {
+ if err := validateTagName(w); err != nil {
+ return fmt.Errorf("add tag '%s': %w", w, err)
+ }
+ adds = append(adds, w)
+ }
+ }
+ }
+ if len(adds) > 0 {
+ if err := task.AddTags(m.tagsID, adds); err != nil {
+ return err
+ }
+ }
+ if len(removes) > 0 {
+ if err := task.RemoveTags(m.tagsID, removes); err != nil {
+ return err
+ }
+ }
+ m.reload()
+ return nil
+ }
+
+ onExit := func() {
+ m.tagsEditing = false
+ }
+
+ model, cmd := m.handleTextInput(msg, &m.tagsInput, onEnter, onExit)
+ if msg.Type == tea.KeyEnter {
+ return model, m.startBlink(m.tagsID, false)
+ }
+ return model, cmd
+}
+
+// handleDueEditMode handles due date editing
+func (m *Model) handleDueEditMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.Type {
+ case tea.KeyEnter:
+ if err := task.SetDueDate(m.dueID, m.dueDate.Format("2006-01-02")); err != nil {
+ m.statusMsg = fmt.Sprintf("Error: %v", err)
+ cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg {
+ return struct{ clearStatus bool }{true}
+ })
+ return m, cmd
+ }
+ m.dueEditing = false
+ m.reload()
+ cmd := m.startBlink(m.dueID, false)
+ m.updateTableHeight()
+ return m, cmd
+ case tea.KeyEsc:
+ m.dueEditing = false
+ m.updateTableHeight()
+ return m, nil
+ }
+
+ switch msg.String() {
+ case "h", "left":
+ m.dueDate = m.dueDate.AddDate(0, 0, -1)
+ case "l", "right":
+ m.dueDate = m.dueDate.AddDate(0, 0, 1)
+ case "k", "up":
+ m.dueDate = m.dueDate.AddDate(0, 0, -7)
+ case "j", "down":
+ m.dueDate = m.dueDate.AddDate(0, 0, 7)
+ }
+ return m, nil
+}
+
+// handleRecurrenceMode handles recurrence editing
+func (m *Model) handleRecurrenceMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ onEnter := func(value string) error {
+ if err := validateRecurrence(value); err != nil {
+ return err
+ }
+ if err := task.SetRecurrence(m.recurID, value); err != nil {
+ return err
+ }
+ m.reload()
+ return nil
+ }
+
+ onExit := func() {
+ m.recurEditing = false
+ }
+
+ model, cmd := m.handleTextInput(msg, &m.recurInput, onEnter, onExit)
+ if msg.Type == tea.KeyEnter {
+ return model, m.startBlink(m.recurID, false)
+ }
+ return model, cmd
+}
+
+// handlePriorityMode handles priority selection
+func (m *Model) handlePriorityMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.Type {
+ case tea.KeyEnter:
+ priority := priorityOptions[m.priorityIndex]
+ if err := validatePriority(priority); err != nil {
+ m.statusMsg = fmt.Sprintf("Error: %v", err)
+ cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg {
+ return struct{ clearStatus bool }{true}
+ })
+ return m, cmd
+ }
+ if err := task.SetPriority(m.priorityID, priority); err != nil {
+ m.statusMsg = fmt.Sprintf("Error: %v", err)
+ cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg {
+ return struct{ clearStatus bool }{true}
+ })
+ return m, cmd
+ }
+ m.prioritySelecting = false
+ m.reload()
+ cmd := m.startBlink(m.priorityID, false)
+ m.updateTableHeight()
+ return m, cmd
+ case tea.KeyEsc:
+ m.prioritySelecting = false
+ m.updateTableHeight()
+ return m, nil
+ }
+
+ switch msg.String() {
+ case "h", "left":
+ m.priorityIndex = (m.priorityIndex + len(priorityOptions) - 1) % len(priorityOptions)
+ case "l", "right":
+ m.priorityIndex = (m.priorityIndex + 1) % len(priorityOptions)
+ }
+ return m, nil
+}
+
+// handleFilterMode handles filter editing
+func (m *Model) handleFilterMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ onEnter := func(value string) error {
+ m.filters = strings.Fields(value)
+ m.reload()
+ return nil
+ }
+
+ onExit := func() {
+ m.filterEditing = false
+ }
+
+ return m.handleTextInput(msg, &m.filterInput, onEnter, onExit)
+}
+
+// handleAddTaskMode handles adding a new task
+func (m *Model) handleAddTaskMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.Type {
+ case tea.KeyEnter:
+ oldIDs := make(map[int]struct{})
+ for _, tsk := range m.tasks {
+ oldIDs[tsk.ID] = struct{}{}
+ }
+
+ if err := task.AddLine(m.addInput.Value()); err != nil {
+ m.statusMsg = fmt.Sprintf("Error: %v", err)
+ cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg {
+ return struct{ clearStatus bool }{true}
+ })
+ return m, cmd
+ }
+
+ m.addingTask = false
+ m.addInput.Blur()
+ m.reload()
+
+ // Find the newly added task
+ var newID int
+ row := -1
+ for i, tsk := range m.tasks {
+ if _, ok := oldIDs[tsk.ID]; !ok {
+ newID = tsk.ID
+ row = i
+ break
+ }
+ }
+
+ m.updateTableHeight()
+ if row >= 0 {
+ prevRow := m.tbl.Cursor()
+ prevCol := m.tbl.ColumnCursor()
+ m.tbl.SetCursor(row)
+ m.tbl.SetColumnCursor(7) // Description column
+ m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor())
+ return m, m.startBlink(newID, false)
+ }
+ return m, nil
+
+ case tea.KeyEsc:
+ m.addingTask = false
+ m.addInput.Blur()
+ m.updateTableHeight()
+ return m, nil
+ }
+
+ var cmd tea.Cmd
+ m.addInput, cmd = m.addInput.Update(msg)
+ return m, cmd
+}
+
+// handleSearchMode handles search input
+func (m *Model) handleSearchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.Type {
+ case tea.KeyEnter:
+ pattern := m.searchInput.Value()
+ if pattern != "" {
+ // Check cache first
+ if cached, ok := searchRegexCache[pattern]; ok {
+ m.searchRegex = cached
+ } else {
+ // Compile and cache if not found
+ re, err := compileAndCacheRegex(pattern)
+ if err == nil {
+ m.searchRegex = re
+ } else {
+ m.searchRegex = nil
+ m.statusMsg = fmt.Sprintf("Invalid regex: %v", err)
+ }
+ }
+ } else {
+ m.searchRegex = nil
+ }
+ m.searching = false
+ m.searchInput.Blur()
+ m.reload()
+ m.updateTableHeight()
+
+ if len(m.searchMatches) > 0 {
+ 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
+
+ case tea.KeyEsc:
+ m.searching = false
+ m.searchInput.Blur()
+ m.updateTableHeight()
+ return m, nil
+ }
+
+ var cmd tea.Cmd
+ m.searchInput, cmd = m.searchInput.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.KeyMsg); ok {
+ // 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
+}
+
+// handleEditingModes checks if we're in any editing mode and handles it
+func (m *Model) handleEditingModes(msg tea.KeyMsg) (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.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
+ }
+ 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]
+} \ No newline at end of file
diff --git a/internal/ui/helpers.go b/internal/ui/helpers.go
new file mode 100644
index 0000000..71c21db
--- /dev/null
+++ b/internal/ui/helpers.go
@@ -0,0 +1,191 @@
+package ui
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// Date format used by Taskwarrior
+const taskDateFormat = "20060102T150405Z"
+
+// parseTaskDate parses a date string in Taskwarrior format
+func parseTaskDate(dateStr string) (time.Time, error) {
+ if dateStr == "" {
+ return time.Time{}, fmt.Errorf("empty date string")
+ }
+ return time.Parse(taskDateFormat, dateStr)
+}
+
+// formatTaskDate formats a time as a Taskwarrior date string
+func formatTaskDate(t time.Time) string {
+ return t.UTC().Format(taskDateFormat)
+}
+
+// daysSince returns the number of days since the given time
+func daysSince(t time.Time) int {
+ return int(time.Since(t).Hours() / 24)
+}
+
+// daysUntil returns the number of days until the given time
+func daysUntil(t time.Time) int {
+ now := time.Now()
+ // Normalize both times to midnight UTC to avoid timezone and fractional day issues
+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
+ target := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
+ return int(target.Sub(today).Hours() / 24)
+}
+
+// formatDueText returns a human-readable due date string
+func formatDueText(dueStr string) string {
+ if dueStr == "" {
+ return ""
+ }
+
+ ts, err := parseTaskDate(dueStr)
+ if err != nil {
+ return dueStr
+ }
+
+ days := daysUntil(ts)
+ switch days {
+ case 0:
+ return "today"
+ case 1:
+ return "tomorrow"
+ case -1:
+ return "yesterday"
+ default:
+ return fmt.Sprintf("%dd", days)
+ }
+}
+
+// compileAndCacheRegex compiles a regex and adds it to the cache
+func compileAndCacheRegex(pattern string) (*regexp.Regexp, error) {
+ re, err := regexp.Compile(pattern)
+ if err != nil {
+ return nil, err
+ }
+
+ // Limit cache size to prevent memory leak
+ if len(searchRegexCache) > 100 {
+ // Clear cache when it gets too large
+ searchRegexCache = make(map[string]*regexp.Regexp)
+ }
+ searchRegexCache[pattern] = re
+
+ return re, nil
+}
+
+// Validation functions
+
+// validateTagName validates a tag name
+func validateTagName(tag string) error {
+ if tag == "" {
+ return fmt.Errorf("tag cannot be empty")
+ }
+
+ // Remove leading + or - for validation
+ tag = strings.TrimPrefix(strings.TrimPrefix(tag, "+"), "-")
+
+ // Check for invalid characters
+ if strings.ContainsAny(tag, " \t\n\r") {
+ return fmt.Errorf("tag cannot contain whitespace")
+ }
+
+ return nil
+}
+
+// validateTags validates a list of tags
+func validateTags(tags []string) error {
+ for _, tag := range tags {
+ if err := validateTagName(tag); err != nil {
+ return fmt.Errorf("invalid tag '%s': %w", tag, err)
+ }
+ }
+ return nil
+}
+
+// validateDueDate validates a due date string
+func validateDueDate(due string) error {
+ if due == "" {
+ return nil // Empty due date is valid
+ }
+
+ // Try common formats
+ formats := []string{
+ "2006-01-02",
+ "2006-01-02T15:04:05",
+ "2006-01-02T15:04:05Z",
+ taskDateFormat,
+ }
+
+ for _, format := range formats {
+ if _, err := time.Parse(format, due); err == nil {
+ return nil
+ }
+ }
+
+ // Check for relative dates that taskwarrior understands
+ relatives := []string{"now", "today", "tomorrow", "yesterday", "monday", "tuesday",
+ "wednesday", "thursday", "friday", "saturday", "sunday", "eod", "eow", "eom", "eoy"}
+
+ due = strings.ToLower(due)
+ for _, rel := range relatives {
+ if due == rel || strings.HasPrefix(due, rel+"+") || strings.HasPrefix(due, rel+"-") {
+ return nil
+ }
+ }
+
+ return fmt.Errorf("invalid due date format: %s", due)
+}
+
+// validatePriority validates a priority value
+func validatePriority(priority string) error {
+ switch priority {
+ case "", "H", "M", "L":
+ return nil
+ default:
+ return fmt.Errorf("invalid priority: %s (must be H, M, L, or empty)", priority)
+ }
+}
+
+// validateRecurrence validates a recurrence string
+func validateRecurrence(recur string) error {
+ if recur == "" {
+ return nil // Empty recurrence is valid
+ }
+
+ // Basic validation - taskwarrior will do the full validation
+ if len(recur) < 2 {
+ return fmt.Errorf("recurrence too short")
+ }
+
+ // Check for common patterns
+ validPrefixes := []string{"daily", "weekly", "monthly", "yearly", "biweekly", "bimonthly"}
+ for _, prefix := range validPrefixes {
+ if strings.HasPrefix(strings.ToLower(recur), prefix) {
+ return nil
+ }
+ }
+
+ // Check for duration format (e.g., "3d", "2w", "1m")
+ if len(recur) >= 2 {
+ last := recur[len(recur)-1]
+ if (last == 'd' || last == 'w' || last == 'm' || last == 'y') &&
+ recur[:len(recur)-1] != "" {
+ return nil
+ }
+ }
+
+ return nil // Let taskwarrior handle complex validation
+}
+
+// validateDescription validates a task description
+func validateDescription(desc string) error {
+ if strings.TrimSpace(desc) == "" {
+ return fmt.Errorf("description cannot be empty")
+ }
+ return nil
+} \ No newline at end of file
diff --git a/internal/ui/helpers_test.go b/internal/ui/helpers_test.go
new file mode 100644
index 0000000..9ba620c
--- /dev/null
+++ b/internal/ui/helpers_test.go
@@ -0,0 +1,363 @@
+package ui
+
+import (
+ "testing"
+ "time"
+)
+
+func TestParseTaskDate(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantErr bool
+ }{
+ {
+ name: "valid date",
+ input: "20250627T150405Z",
+ wantErr: false,
+ },
+ {
+ name: "empty string",
+ input: "",
+ wantErr: true,
+ },
+ {
+ name: "invalid format",
+ input: "2025-06-27",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := parseTaskDate(tt.input)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("parseTaskDate() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestFormatDueText(t *testing.T) {
+ now := time.Now()
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "empty",
+ input: "",
+ expected: "",
+ },
+ {
+ name: "today",
+ input: now.UTC().Format("20060102T150405Z"),
+ expected: "today",
+ },
+ {
+ name: "tomorrow",
+ input: now.Add(24 * time.Hour).UTC().Format("20060102T150405Z"),
+ expected: "tomorrow",
+ },
+ {
+ name: "yesterday",
+ input: now.Add(-24 * time.Hour).UTC().Format("20060102T150405Z"),
+ expected: "yesterday",
+ },
+ {
+ name: "future",
+ input: now.Add(5 * 24 * time.Hour).UTC().Format("20060102T150405Z"),
+ expected: "5d",
+ },
+ {
+ name: "past",
+ input: now.Add(-3 * 24 * time.Hour).UTC().Format("20060102T150405Z"),
+ expected: "-3d",
+ },
+ {
+ name: "invalid",
+ input: "invalid",
+ expected: "invalid",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := formatDueText(tt.input)
+ if got != tt.expected {
+ t.Errorf("formatDueText() = %v, want %v", got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestValidateTagName(t *testing.T) {
+ tests := []struct {
+ name string
+ tag string
+ wantErr bool
+ }{
+ {
+ name: "valid tag",
+ tag: "work",
+ wantErr: false,
+ },
+ {
+ name: "valid with plus",
+ tag: "+work",
+ wantErr: false,
+ },
+ {
+ name: "valid with minus",
+ tag: "-work",
+ wantErr: false,
+ },
+ {
+ name: "empty tag",
+ tag: "",
+ wantErr: true,
+ },
+ {
+ name: "tag with space",
+ tag: "my tag",
+ wantErr: true,
+ },
+ {
+ name: "tag with tab",
+ tag: "my\ttag",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateTagName(tt.tag)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("validateTagName() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestValidatePriority(t *testing.T) {
+ tests := []struct {
+ name string
+ priority string
+ wantErr bool
+ }{
+ {
+ name: "high",
+ priority: "H",
+ wantErr: false,
+ },
+ {
+ name: "medium",
+ priority: "M",
+ wantErr: false,
+ },
+ {
+ name: "low",
+ priority: "L",
+ wantErr: false,
+ },
+ {
+ name: "empty",
+ priority: "",
+ wantErr: false,
+ },
+ {
+ name: "invalid",
+ priority: "X",
+ wantErr: true,
+ },
+ {
+ name: "lowercase",
+ priority: "h",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validatePriority(tt.priority)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("validatePriority() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestValidateDescription(t *testing.T) {
+ tests := []struct {
+ name string
+ desc string
+ wantErr bool
+ }{
+ {
+ name: "valid description",
+ desc: "Fix the bug",
+ wantErr: false,
+ },
+ {
+ name: "empty description",
+ desc: "",
+ wantErr: true,
+ },
+ {
+ name: "whitespace only",
+ desc: " ",
+ wantErr: true,
+ },
+ {
+ name: "description with whitespace",
+ desc: " Fix the bug ",
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateDescription(tt.desc)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("validateDescription() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestValidateDueDate(t *testing.T) {
+ tests := []struct {
+ name string
+ due string
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ due: "",
+ wantErr: false,
+ },
+ {
+ name: "ISO date",
+ due: "2025-06-27",
+ wantErr: false,
+ },
+ {
+ name: "ISO datetime",
+ due: "2025-06-27T15:04:05",
+ wantErr: false,
+ },
+ {
+ name: "ISO datetime with Z",
+ due: "2025-06-27T15:04:05Z",
+ wantErr: false,
+ },
+ {
+ name: "taskwarrior format",
+ due: "20250627T150405Z",
+ wantErr: false,
+ },
+ {
+ name: "relative - today",
+ due: "today",
+ wantErr: false,
+ },
+ {
+ name: "relative - tomorrow",
+ due: "tomorrow",
+ wantErr: false,
+ },
+ {
+ name: "relative - monday",
+ due: "monday",
+ wantErr: false,
+ },
+ {
+ name: "relative - eod",
+ due: "eod",
+ wantErr: false,
+ },
+ {
+ name: "relative - tomorrow+2d",
+ due: "tomorrow+2d",
+ wantErr: false,
+ },
+ {
+ name: "invalid format",
+ due: "27/06/2025",
+ wantErr: true,
+ },
+ {
+ name: "invalid relative",
+ due: "someday",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateDueDate(tt.due)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("validateDueDate() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestValidateRecurrence(t *testing.T) {
+ tests := []struct {
+ name string
+ recur string
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ recur: "",
+ wantErr: false,
+ },
+ {
+ name: "daily",
+ recur: "daily",
+ wantErr: false,
+ },
+ {
+ name: "weekly",
+ recur: "weekly",
+ wantErr: false,
+ },
+ {
+ name: "3 days",
+ recur: "3d",
+ wantErr: false,
+ },
+ {
+ name: "2 weeks",
+ recur: "2w",
+ wantErr: false,
+ },
+ {
+ name: "1 month",
+ recur: "1m",
+ wantErr: false,
+ },
+ {
+ name: "too short",
+ recur: "d",
+ wantErr: true,
+ },
+ {
+ name: "single char",
+ recur: "x",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateRecurrence(tt.recur)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("validateRecurrence() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+} \ No newline at end of file
diff --git a/internal/ui/keyhandlers.go b/internal/ui/keyhandlers.go
new file mode 100644
index 0000000..780ee8c
--- /dev/null
+++ b/internal/ui/keyhandlers.go
@@ -0,0 +1,493 @@
+package ui
+
+import (
+ "fmt"
+ "os/exec"
+ "strings"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+
+ "codeberg.org/snonux/tasksamurai/internal/task"
+)
+
+// handleNormalMode handles keyboard input in normal mode (not editing)
+func (m *Model) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.String() {
+ case "H":
+ return m.handleToggleHelp()
+ case "q", "esc":
+ return m.handleQuitOrEscape()
+ case "e", "E":
+ return m.handleEditTask()
+ case "s":
+ return m.handleToggleStart()
+ case "d":
+ return m.handleMarkDone()
+ case "o":
+ return m.handleOpenURL()
+ case "U":
+ return m.handleUndo()
+ case "D":
+ return m.handleSetDueDate()
+ case "r":
+ return m.handleRandomDueDate()
+ case "R":
+ return m.handleSetRecurrence()
+ case "p":
+ return m.handleSetPriority()
+ case "a":
+ return m.handleAnnotate(false)
+ case "A":
+ return m.handleAnnotate(true)
+ case "f":
+ return m.handleFilter()
+ case "+":
+ return m.handleAddTask()
+ case "t":
+ return m.handleEditTags()
+ case "c":
+ return m.handleRandomTheme()
+ case "C":
+ return m.handleResetTheme()
+ case "x":
+ return m.handleToggleDisco()
+ case " ":
+ return m.handleRefresh()
+ case "/", "?":
+ return m.handleSearch()
+ case "n":
+ return m.handleNextSearchMatch()
+ case "N":
+ return m.handlePrevSearchMatch()
+ case "enter", "i":
+ return m.handleEnterOrEdit()
+ default:
+ // Pass through to table for navigation
+ return m.handleTableNavigation(msg)
+ }
+}
+
+func (m *Model) handleToggleHelp() (tea.Model, tea.Cmd) {
+ m.showHelp = true
+ return m, nil
+}
+
+func (m *Model) handleQuitOrEscape() (tea.Model, tea.Cmd) {
+ if m.cellExpanded {
+ m.cellExpanded = false
+ m.updateTableHeight()
+ return m, nil
+ }
+ if m.showHelp {
+ m.showHelp = false
+ return m, nil
+ }
+ if m.searchRegex != nil {
+ m.searchRegex = nil
+ m.searchMatches = nil
+ m.searchIndex = 0
+ m.reload()
+ return m, nil
+ }
+ return m, tea.Quit
+}
+
+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
+ }
+ }
+
+ m.reload()
+ 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.getTaskAtCursor()
+ if task == nil {
+ return m, nil
+ }
+
+ url := urlRegex.FindString(task.Description)
+ 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
+ }
+
+ m.reload()
+
+ // Find the task ID for blinking
+ var id int
+ for _, tsk := range m.tasks {
+ if tsk.UUID == uuid {
+ id = tsk.ID
+ break
+ }
+ }
+
+ 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) handleRandomDueDate() (tea.Model, tea.Cmd) {
+ id, err := m.getSelectedTaskID()
+ if err != nil {
+ return m, nil
+ }
+
+ days := rng.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
+ }
+
+ m.reload()
+ 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 {