diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-22 23:02:26 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-22 23:02:26 +0300 |
| commit | 8e1eec851b7c4f572a741898f5f887d6b0a49ad6 (patch) | |
| tree | a8751c94a10c3e84ec6daa3c1baac7b70ca7f51a | |
| parent | c63fedb9aca43834dbe4df09d9de868a93f2b317 (diff) | |
yq0 deduplicate normal and ultra keybindings
| -rw-r--r-- | internal/ui/keyhandlers.go | 202 | ||||
| -rw-r--r-- | internal/ui/table_test.go | 124 | ||||
| -rw-r--r-- | internal/ui/ultra.go | 59 |
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 } |
