summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-22 23:02:26 +0300
committerPaul Buetow <paul@buetow.org>2026-06-22 23:02:26 +0300
commit8e1eec851b7c4f572a741898f5f887d6b0a49ad6 (patch)
treea8751c94a10c3e84ec6daa3c1baac7b70ca7f51a
parentc63fedb9aca43834dbe4df09d9de868a93f2b317 (diff)
yq0 deduplicate normal and ultra keybindings
-rw-r--r--internal/ui/keyhandlers.go202
-rw-r--r--internal/ui/table_test.go124
-rw-r--r--internal/ui/ultra.go59
3 files changed, 275 insertions, 110 deletions
diff --git a/internal/ui/keyhandlers.go b/internal/ui/keyhandlers.go
index 16263db..d00da3e 100644
--- a/internal/ui/keyhandlers.go
+++ b/internal/ui/keyhandlers.go
@@ -5,6 +5,21 @@ import (
tea "charm.land/bubbletea/v2"
)
+type sharedKeyHandlers struct {
+ editTask func() (tea.Model, tea.Cmd)
+ toggleStart func() (tea.Model, tea.Cmd)
+ markDone func() (tea.Model, tea.Cmd)
+ deleteTask func() (tea.Model, tea.Cmd)
+ setPriority func() (tea.Model, tea.Cmd)
+ setDueDate func() (tea.Model, tea.Cmd)
+ removeDueDate func() (tea.Model, tea.Cmd)
+ editTags func() (tea.Model, tea.Cmd)
+ annotate func(replace bool) (tea.Model, tea.Cmd)
+ editProject func() (tea.Model, tea.Cmd)
+ setRecurrence func() (tea.Model, tea.Cmd)
+ addTask func() (tea.Model, tea.Cmd)
+}
+
// handleNormalMode handles keyboard input in normal mode (not editing)
func (m *Model) handleNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
// If help is shown, handle special cases
@@ -44,92 +59,171 @@ func (m *Model) handleNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
}
- if msg.String() == m.agentFilterHotkeyLabel() {
- return m.handleToggleAgentFilter()
+ if handled, model, cmd := m.handleSharedKey(msg.String(), m.normalSharedKeyHandlers()); handled {
+ return model, cmd
}
switch msg.String() {
+ case "T":
+ return m.handleTagToProject()
+ case "/", "?":
+ return m.handleSearch()
+ case "n":
+ return m.handleNextSearchMatch()
+ case "N":
+ return m.handlePrevSearchMatch()
+ case "enter":
+ return m.handleShowTaskDetail()
+ case "i":
+ return m.handleEnterOrEdit()
+ case "u":
+ m.ultraClearFocusedID()
+ m.showUltra = true
+ m.ultraCursor = m.tbl.Cursor()
+ m.ultraOffset = 0
+ m.ultraEnsureVisible()
+ return m, nil
+ case "1":
+ return m.handleJumpToRandomTask()
+ case "2":
+ return m.handleJumpToRandomTaskNoDue()
+ default:
+ // Pass through to table for navigation
+ return m.handleTableNavigation(msg)
+ }
+}
+
+func (m *Model) normalSharedKeyHandlers() sharedKeyHandlers {
+ return sharedKeyHandlers{
+ editTask: m.handleEditTask,
+ toggleStart: m.handleToggleStart,
+ markDone: m.handleMarkDone,
+ deleteTask: m.handleDeleteTask,
+ setPriority: m.handleSetPriority,
+ setDueDate: m.handleSetDueDate,
+ removeDueDate: m.handleRemoveDueDate,
+ editTags: m.handleEditTags,
+ annotate: m.handleAnnotate,
+ editProject: m.handleEditProject,
+ setRecurrence: m.handleSetRecurrence,
+ addTask: m.handleAddTask,
+ }
+}
+
+func (m *Model) ultraSharedKeyHandlers() sharedKeyHandlers {
+ return sharedKeyHandlers{
+ editTask: m.handleUltraEditTask,
+ toggleStart: m.handleUltraToggleStart,
+ markDone: m.handleUltraMarkDone,
+ deleteTask: m.handleUltraDeleteTask,
+ setPriority: m.handleUltraSetPriority,
+ setDueDate: m.handleUltraSetDueDate,
+ removeDueDate: m.handleUltraRemoveDueDate,
+ editTags: m.handleUltraEditTags,
+ annotate: m.handleUltraAnnotate,
+ editProject: m.handleUltraEditProject,
+ setRecurrence: m.handleUltraSetRecurrence,
+ addTask: func() (tea.Model, tea.Cmd) {
+ m.ultraClearFocusedID()
+ return m.handleAddTask()
+ },
+ }
+}
+
+func (m *Model) handleSharedKey(key string, handlers sharedKeyHandlers) (bool, tea.Model, tea.Cmd) {
+ if key == m.agentFilterHotkeyLabel() {
+ model, cmd := m.handleToggleAgentFilter()
+ return true, model, cmd
+ }
+
+ switch key {
case "H":
- return m.handleToggleHelp()
+ model, cmd := m.handleToggleHelp()
+ return true, model, cmd
case "q":
- return m.handleQuitKey()
+ model, cmd := m.handleQuitKey()
+ return true, model, cmd
case "esc":
- return m.handleEscapeKey()
+ model, cmd := m.handleEscapeKey()
+ return true, model, cmd
case "e", "E":
- return m.handleEditTask()
+ return callSharedKeyHandler(m, handlers.editTask)
case "s":
- return m.handleToggleStart()
+ return callSharedKeyHandler(m, handlers.toggleStart)
case "d":
- return m.handleMarkDone()
+ return callSharedKeyHandler(m, handlers.markDone)
case "D":
- return m.handleDeleteTask()
+ return callSharedKeyHandler(m, handlers.deleteTask)
case "o":
- return m.handleOpenURL()
+ model, cmd := m.handleOpenURL()
+ return true, model, cmd
case "U":
- return m.handleUndo()
+ model, cmd := m.handleUndo()
+ return true, model, cmd
case "w":
- return m.handleSetDueDate()
+ return callSharedKeyHandler(m, handlers.setDueDate)
case "W":
- return m.handleRemoveDueDate()
+ return callSharedKeyHandler(m, handlers.removeDueDate)
case "r":
- return m.handleRandomDueDate()
+ model, cmd := m.handleRandomDueDate()
+ return true, model, cmd
case "R":
- return m.handleSetRecurrence()
+ return callSharedKeyHandler(m, handlers.setRecurrence)
case "p":
- return m.handleSetPriority()
+ return callSharedKeyHandler(m, handlers.setPriority)
case "a":
- return m.handleAnnotate(false)
+ return callSharedAnnotateHandler(m, handlers.annotate, false)
case "A":
- return m.handleAnnotate(true)
+ return callSharedAnnotateHandler(m, handlers.annotate, true)
case "f":
- return m.handleFilter()
+ model, cmd := m.handleFilter()
+ return true, model, cmd
case ":":
- return m.handleShellPrompt()
+ model, cmd := m.handleShellPrompt()
+ return true, model, cmd
case ";":
- return m.handleShellPromptForSelectedTask()
+ model, cmd := m.handleShellPromptForSelectedTask()
+ return true, model, cmd
case "+":
- return m.handleAddTask()
+ return callSharedKeyHandler(m, handlers.addTask)
case "t":
- return m.handleEditTags()
+ return callSharedKeyHandler(m, handlers.editTags)
case "J":
- return m.handleEditProject()
- case "T":
- return m.handleTagToProject()
+ return callSharedKeyHandler(m, handlers.editProject)
case "c":
- return m.handleRandomTheme()
+ model, cmd := m.handleRandomTheme()
+ return true, model, cmd
case "C":
- return m.handleResetTheme()
+ model, cmd := m.handleResetTheme()
+ return true, model, cmd
case "x":
- return m.handleToggleDisco()
+ model, cmd := m.handleToggleDisco()
+ return true, model, cmd
case "B":
- return m.handleToggleBlink()
+ model, cmd := m.handleToggleBlink()
+ return true, model, cmd
case "space":
- return m.handleRefresh()
- case "/", "?":
- return m.handleSearch()
- case "n":
- return m.handleNextSearchMatch()
- case "N":
- return m.handlePrevSearchMatch()
- case "enter":
- return m.handleShowTaskDetail()
- case "i":
- return m.handleEnterOrEdit()
- case "u":
- m.ultraClearFocusedID()
- m.showUltra = true
- m.ultraCursor = m.tbl.Cursor()
- m.ultraOffset = 0
- m.ultraEnsureVisible()
- return m, nil
- case "1":
- return m.handleJumpToRandomTask()
- case "2":
- return m.handleJumpToRandomTaskNoDue()
+ model, cmd := m.handleRefresh()
+ return true, model, cmd
default:
- // Pass through to table for navigation
- return m.handleTableNavigation(msg)
+ return false, m, nil
+ }
+}
+
+func callSharedKeyHandler(m *Model, handler func() (tea.Model, tea.Cmd)) (bool, tea.Model, tea.Cmd) {
+ if handler == nil {
+ return false, m, nil
+ }
+ model, cmd := handler()
+ return true, model, cmd
+}
+
+func callSharedAnnotateHandler(m *Model, handler func(bool) (tea.Model, tea.Cmd), replace bool) (bool, tea.Model, tea.Cmd) {
+ if handler == nil {
+ return false, m, nil
}
+ model, cmd := handler(replace)
+ return true, model, cmd
}
func (m *Model) handleToggleHelp() (tea.Model, tea.Cmd) {
diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go
index 619a71a..f19be5a 100644
--- a/internal/ui/table_test.go
+++ b/internal/ui/table_test.go
@@ -2754,6 +2754,130 @@ func TestUltraPriorityOpUsesUltraSelection(t *testing.T) {
}
}
+func TestSharedKeyBindingsWorkInNormalAndUltraModes(t *testing.T) {
+ tests := []struct {
+ name string
+ ultra bool
+ key tea.KeyPressMsg
+ check func(t *testing.T, m Model)
+ }{
+ {
+ name: "normal priority",
+ key: tea.KeyPressMsg{Code: 'p', Text: "p"},
+ check: func(t *testing.T, m Model) {
+ if !m.prioritySelecting {
+ t.Fatalf("normal p did not activate priority selection")
+ }
+ if got, want := m.priorityID, 1; got != want {
+ t.Fatalf("normal p targeted task %d, want %d", got, want)
+ }
+ },
+ },
+ {
+ name: "ultra priority",
+ ultra: true,
+ key: tea.KeyPressMsg{Code: 'p', Text: "p"},
+ check: func(t *testing.T, m Model) {
+ if !m.prioritySelecting {
+ t.Fatalf("ultra p did not activate priority selection")
+ }
+ if got, want := m.priorityID, 2; got != want {
+ t.Fatalf("ultra p targeted task %d, want %d", got, want)
+ }
+ },
+ },
+ {
+ name: "normal add",
+ key: tea.KeyPressMsg{Code: '+', Text: "+"},
+ check: func(t *testing.T, m Model) {
+ if !m.addingTask {
+ t.Fatalf("normal + did not activate add input")
+ }
+ },
+ },
+ {
+ name: "ultra add",
+ ultra: true,
+ key: tea.KeyPressMsg{Code: '+', Text: "+"},
+ check: func(t *testing.T, m Model) {
+ if !m.addingTask {
+ t.Fatalf("ultra + did not activate add input")
+ }
+ if !m.showUltra {
+ t.Fatalf("ultra + exited ultra mode")
+ }
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath := setupUltraTaskSet(t, tmp)
+ setupEnv(t, taskPath)
+
+ m, err := New(nil, "firefox")
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ if tt.ultra {
+ m.showUltra = true
+ m.tbl.SetCursor(0)
+ m.ultraCursor = 1
+ }
+
+ mv, cmd := (&m).Update(tt.key)
+ if cmd != nil {
+ t.Fatalf("%s unexpectedly returned a command", tt.name)
+ }
+ tt.check(t, *mv.(*Model))
+ })
+ }
+}
+
+func TestEnterRemainsModeSpecificInNormalAndUltraModes(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath := setupUltraTaskSet(t, tmp)
+ setupEnv(t, taskPath)
+
+ normal, err := New(nil, "firefox")
+ if err != nil {
+ t.Fatalf("New normal: %v", err)
+ }
+
+ mv, cmd := (&normal).Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ if cmd != nil {
+ t.Fatalf("normal enter unexpectedly returned a command")
+ }
+ normal = *mv.(*Model)
+ if !normal.showTaskDetail {
+ t.Fatalf("normal enter did not open task detail")
+ }
+ if normal.editID != 0 {
+ t.Fatalf("normal enter started edit for task %d", normal.editID)
+ }
+
+ ultra, err := New(nil, "firefox")
+ if err != nil {
+ t.Fatalf("New ultra: %v", err)
+ }
+ ultra.showUltra = true
+ ultra.tbl.SetCursor(0)
+ ultra.ultraCursor = 1
+
+ mv, cmd = (&ultra).Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ ultra = *mv.(*Model)
+ if cmd == nil {
+ t.Fatalf("ultra enter did not return edit command")
+ }
+ if ultra.showTaskDetail {
+ t.Fatalf("ultra enter opened task detail")
+ }
+ if got, want := ultra.editID, 2; got != want {
+ t.Fatalf("ultra enter targeted task %d, want %d", got, want)
+ }
+}
+
func TestUltraOpenURLHotkeyUsesUltraSelection(t *testing.T) {
tmp := t.TempDir()
taskPath := filepath.Join(tmp, "task")
diff --git a/internal/ui/ultra.go b/internal/ui/ultra.go
index 8234af2..284a7d6 100644
--- a/internal/ui/ultra.go
+++ b/internal/ui/ultra.go
@@ -1009,17 +1009,11 @@ func (m *Model) ultraEnsureVisible() {
// handleUltraMode handles keyboard input in ultra mode.
func (m *Model) handleUltraMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
- if msg.String() == m.agentFilterHotkeyLabel() {
- return m.handleToggleAgentFilter()
+ if handled, model, cmd := m.handleSharedKey(msg.String(), m.ultraSharedKeyHandlers()); handled {
+ return model, cmd
}
switch msg.String() {
- case "H":
- return m.handleToggleHelp()
- case "q":
- return m.handleQuitKey()
- case "esc":
- return m.handleEscapeKey()
case "u":
cursor := m.ultraCursor
m.leaveUltraMode()
@@ -1047,61 +1041,14 @@ func (m *Model) handleUltraMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.ultraMoveCursor(m.ultraVisibleCount())
case "pgup", "b":
m.ultraMoveCursor(-m.ultraVisibleCount())
- case "space":
- return m.handleRefresh()
case "g", "home":
m.ultraGoHome()
case "G", "end":
m.ultraGoEnd()
case "0":
m.ultraGoHome()
- case "enter", "e", "E":
+ case "enter":
return m.handleUltraEditTask()
- case "s":
- return m.handleUltraToggleStart()
- case "d":
- return m.handleUltraMarkDone()
- case "D":
- return m.handleUltraDeleteTask()
- case "o":
- return m.handleOpenURL()
- case "p":
- return m.handleUltraSetPriority()
- case "w":
- return m.handleUltraSetDueDate()
- case "W":
- return m.handleUltraRemoveDueDate()
- case "r":
- return m.handleRandomDueDate()
- case "t":
- return m.handleUltraEditTags()
- case "a":
- return m.handleUltraAnnotate(false)
- case "A":
- return m.handleUltraAnnotate(true)
- case "J":
- return m.handleUltraEditProject()
- case "R":
- return m.handleUltraSetRecurrence()
- case "f":
- return m.handleFilter()
- case ":":
- return m.handleShellPrompt()
- case ";":
- return m.handleShellPromptForSelectedTask()
- case "+":
- m.ultraClearFocusedID()
- return m.handleAddTask()
- case "U":
- return m.handleUndo()
- case "c":
- return m.handleRandomTheme()
- case "C":
- return m.handleResetTheme()
- case "x":
- return m.handleToggleDisco()
- case "B":
- return m.handleToggleBlink()
}
return m, nil
}