summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-28 10:47:28 +0200
committerPaul Buetow <paul@buetow.org>2026-04-07 09:24:18 +0300
commit4ddd1f139d4e7934c127f86c2dd2a142d2a44118 (patch)
treeb83a76adeb465f182941193d2de2395bbd186b1e /internal
parenta37e561acddaaa63ca574788a0826755a2a7a446 (diff)
ui: add ultra mode task actions (vq)
Diffstat (limited to 'internal')
-rw-r--r--internal/ui/handlers.go29
-rw-r--r--internal/ui/keyhandlers.go2
-rw-r--r--internal/ui/table.go7
-rw-r--r--internal/ui/table_test.go283
-rw-r--r--internal/ui/ultra.go370
5 files changed, 652 insertions, 39 deletions
diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go
index d61e166..2d3208a 100644
--- a/internal/ui/handlers.go
+++ b/internal/ui/handlers.go
@@ -346,6 +346,11 @@ func (m *Model) handleAddTaskMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.tbl.SetCursor(row)
m.tbl.SetColumnCursor(7) // Description column
m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor())
+ if m.showUltra {
+ m.ultraFocusedID = newID
+ m.selectTaskByID(newID)
+ m.ultraFocusedID = 0
+ }
return m, m.startBlink(newID, false)
}
return m, nil
@@ -466,7 +471,11 @@ func (m *Model) handleHelpSearchMode(msg tea.KeyPressMsg) (tea.Model, tea.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 {
- // Only allow navigation while blinking
+ 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
@@ -479,6 +488,24 @@ func (m *Model) handleBlinkingState(msg tea.Msg) (tea.Model, tea.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 {
diff --git a/internal/ui/keyhandlers.go b/internal/ui/keyhandlers.go
index 613efd5..13333aa 100644
--- a/internal/ui/keyhandlers.go
+++ b/internal/ui/keyhandlers.go
@@ -111,6 +111,7 @@ func (m *Model) handleNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
case "i":
return m.handleEnterOrEdit()
case "u":
+ m.ultraClearFocusedID()
m.showUltra = true
m.ultraCursor = m.tbl.Cursor()
m.ultraOffset = 0
@@ -146,6 +147,7 @@ func (m *Model) handleToggleHelp() (tea.Model, tea.Cmd) {
func (m *Model) handleQuitOrEscape() (tea.Model, tea.Cmd) {
if m.showUltra {
+ m.ultraClearFocusedID()
m.showUltra = false
m.ultraSearchRegex = nil
m.ultraFiltered = nil
diff --git a/internal/ui/table.go b/internal/ui/table.go
index 48ed9d5..42487c1 100644
--- a/internal/ui/table.go
+++ b/internal/ui/table.go
@@ -87,6 +87,7 @@ type ultraState struct {
ultraSearchInput textinput.Model
ultraSearchRegex *regexp.Regexp
ultraFiltered []int
+ ultraFocusedID int
}
// editState holds inline field-editing state for the task table.
@@ -387,6 +388,7 @@ func (m *Model) reload() error {
// Always show only pending tasks by default.
filters := append([]string(nil), m.filters...)
filters = append(filters, "status:pending")
+ ultraFilterIDs := m.ultraFilteredTaskIDs()
tasks, err := task.Export(filters...)
if err != nil {
return err
@@ -432,6 +434,7 @@ func (m *Model) reload() error {
if len(m.searchMatches) > 0 {
m.searchIndex = 0
}
+ m.rebuildUltraFiltered(ultraFilterIDs)
if m.tbl.Columns() == nil {
m.tbl, m.tblStyles = m.newTable(rows)
@@ -439,6 +442,7 @@ func (m *Model) reload() error {
m.tbl.SetRows(rows)
m.applyColumns()
}
+ m.reconcileUltraSelection()
m.updateSelectionHighlight(-1, m.tbl.Cursor(), 0, m.tbl.ColumnCursor())
return nil
}
@@ -536,6 +540,9 @@ 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
+ }
m.reload()
cmd := m.startBlink(m.editID, false)
m.editID = 0
diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go
index b3e40dd..bb9d0e1 100644
--- a/internal/ui/table_test.go
+++ b/internal/ui/table_test.go
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "reflect"
"regexp"
"strings"
"testing"
@@ -623,6 +624,35 @@ func setupUltraTaskSet(t *testing.T, tmp string) string {
return taskPath
}
+func setupUltraReloadTaskSet(t *testing.T, tmp string) (string, string) {
+ taskPath := filepath.Join(tmp, "task")
+ phaseFile := filepath.Join(tmp, "phase")
+ if err := os.WriteFile(phaseFile, []byte("1"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ script := fmt.Sprintf("#!/bin/sh\n"+
+ "phase=$(cat %q)\n"+
+ "if echo \"$@\" | grep -q export; then\n"+
+ " if [ \"$phase\" = \"1\" ]; then\n"+
+ " echo '{\"id\":1,\"uuid\":\"1\",\"description\":\"one\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"L\",\"urgency\":0}'\n"+
+ " echo '{\"id\":2,\"uuid\":\"2\",\"description\":\"two\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"M\",\"urgency\":0}'\n"+
+ " echo '{\"id\":3,\"uuid\":\"3\",\"description\":\"three\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"H\",\"urgency\":0}'\n"+
+ " else\n"+
+ " echo '{\"id\":1,\"uuid\":\"1\",\"description\":\"one\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"H\",\"urgency\":0}'\n"+
+ " echo '{\"id\":2,\"uuid\":\"2\",\"description\":\"two\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"L\",\"urgency\":0}'\n"+
+ " echo '{\"id\":3,\"uuid\":\"3\",\"description\":\"three\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"M\",\"urgency\":0}'\n"+
+ " fi\n"+
+ " exit 0\n"+
+ "fi\n", phaseFile)
+
+ if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ return taskPath, phaseFile
+}
+
func setupBasicTask(t *testing.T, tmp string) string {
taskPath := filepath.Join(tmp, "task")
script := "#!/bin/sh\n" +
@@ -685,6 +715,7 @@ func TestUltraExitHotkeysClearUltraState(t *testing.T) {
t.Fatalf("New: %v", err)
}
+ m.ultraFocusedID = 42
mv, cmd := (&m).Update(tea.KeyPressMsg{Code: 'u', Text: "u"})
if cmd != nil {
t.Fatalf("u unexpectedly returned a command")
@@ -693,10 +724,14 @@ func TestUltraExitHotkeysClearUltraState(t *testing.T) {
if !m.showUltra {
t.Fatalf("u did not enter ultra mode")
}
+ if m.ultraFocusedID != 0 {
+ t.Fatalf("u did not clear ultraFocusedID, got %d", m.ultraFocusedID)
+ }
m.ultraSearchRegex = regexp.MustCompile("alpha")
m.ultraFiltered = []int{0, 1}
m.ultraSearchInput.SetValue("ultra needle")
+ m.ultraFocusedID = 17
mv, cmd = (&m).Update(tea.KeyPressMsg{Code: 'q', Text: "q"})
if cmd != nil {
@@ -715,12 +750,16 @@ func TestUltraExitHotkeysClearUltraState(t *testing.T) {
if got := m.ultraSearchInput.Value(); got != "" {
t.Fatalf("q did not clear ultraSearchInput, got %q", got)
}
+ if m.ultraFocusedID != 0 {
+ t.Fatalf("q did not clear ultraFocusedID, got %d", m.ultraFocusedID)
+ }
mv, cmd = (&m).Update(tea.KeyPressMsg{Code: 'u', Text: "u"})
if cmd != nil {
t.Fatalf("u unexpectedly returned a command on re-entry")
}
m = *mv.(*Model)
+ m.ultraFocusedID = 23
m.ultraSearchRegex = regexp.MustCompile("beta")
m.ultraFiltered = []int{2}
m.ultraSearchInput.SetValue("second needle")
@@ -742,6 +781,78 @@ func TestUltraExitHotkeysClearUltraState(t *testing.T) {
if got := m.ultraSearchInput.Value(); got != "" {
t.Fatalf("esc did not clear ultraSearchInput, got %q", got)
}
+ if m.ultraFocusedID != 0 {
+ t.Fatalf("esc did not clear ultraFocusedID, got %d", m.ultraFocusedID)
+ }
+}
+
+func TestUltraFocusedIDLifecycleAcrossNormalEditEntryAndReload(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath := setupBasicTask(t, tmp)
+ setupEnv(t, taskPath)
+
+ m, err := New(nil, "firefox")
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ mv, cmd := (&m).Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ if cmd != nil {
+ t.Fatalf("resize unexpectedly returned a command")
+ }
+ m = *mv.(*Model)
+
+ m.blinkEnabled = false
+ m.editID = 1
+
+ mv, cmd = (&m).Update(editDoneMsg{})
+ if cmd != nil {
+ t.Fatalf("editDone unexpectedly returned a command")
+ }
+ m = *mv.(*Model)
+ if m.ultraFocusedID != 0 {
+ t.Fatalf("normal edit completion left ultraFocusedID=%d, want 0", m.ultraFocusedID)
+ }
+
+ mv, cmd = (&m).Update(tea.KeyPressMsg{Code: 'j', Text: "j"})
+ if cmd != nil {
+ t.Fatalf("j unexpectedly returned a command")
+ }
+ m = *mv.(*Model)
+ if got := m.tbl.Cursor(); got != 1 {
+ t.Fatalf("cursor after j = %d, want 1", got)
+ }
+
+ mv, cmd = (&m).Update(tea.KeyPressMsg{Code: 'u', Text: "u"})
+ if cmd != nil {
+ t.Fatalf("u unexpectedly returned a command")
+ }
+ m = *mv.(*Model)
+ if !m.showUltra {
+ t.Fatalf("u did not enter ultra mode")
+ }
+ if m.ultraFocusedID != 0 {
+ t.Fatalf("u left ultraFocusedID=%d, want 0", m.ultraFocusedID)
+ }
+ if got := m.ultraCursor; got != 1 {
+ t.Fatalf("ultra cursor after entry = %d, want 1", got)
+ }
+
+ if err := m.reload(); err != nil {
+ t.Fatalf("reload: %v", err)
+ }
+ if m.ultraFocusedID != 0 {
+ t.Fatalf("reload left ultraFocusedID=%d, want 0", m.ultraFocusedID)
+ }
+ if got := m.ultraCursor; got != 1 {
+ t.Fatalf("ultra cursor after reload = %d, want 1", got)
+ }
+ if got := m.ultraTaskList()[m.ultraCursor].ID; got != 2 {
+ t.Fatalf("reload snapped to task %d, want 2", got)
+ }
+ if got := m.tbl.Cursor(); got != 1 {
+ t.Fatalf("table cursor after reload = %d, want 1", got)
+ }
}
func TestUltraEntryResizeAndNavigationBindings(t *testing.T) {
@@ -849,6 +960,178 @@ func TestUltraEntryResizeAndNavigationBindings(t *testing.T) {
}
}
+func TestUltraBlinkUsesVisibleSelectionAndRendersBlink(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)
+ }
+
+ step := func(msg tea.KeyPressMsg) {
+ t.Helper()
+ mv, cmd := (&m).Update(msg)
+ if cmd != nil {
+ t.Fatalf("%q unexpectedly returned a command", msg.String())
+ }
+ m = *mv.(*Model)
+ }
+
+ mv, cmd := (&m).Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ if cmd != nil {
+ t.Fatalf("resize unexpectedly returned a command")
+ }
+ m = *mv.(*Model)
+
+ step(tea.KeyPressMsg{Code: 'j', Text: "j"})
+ step(tea.KeyPressMsg{Code: 'u', Text: "u"})
+ if !m.showUltra {
+ t.Fatalf("u did not enter ultra mode")
+ }
+
+ m.blinkID = m.tasks[m.ultraCursor].ID
+ m.blinkOn = false
+ baseView := m.renderUltraModus()
+ m.blinkOn = true
+ blinkView := m.renderUltraModus()
+ if baseView == blinkView {
+ t.Fatalf("ultra view did not change when blink state toggled")
+ }
+
+ beforeTable := m.tbl.Cursor()
+ beforeUltra := m.ultraCursor
+ mv, cmd = (&m).Update(tea.KeyPressMsg{Code: 'j', Text: "j"})
+ if cmd != nil {
+ t.Fatalf("blink navigation unexpectedly returned a command")
+ }
+ m = *mv.(*Model)
+ if m.tbl.Cursor() != beforeTable {
+ t.Fatalf("blink navigation moved hidden table cursor: got %d want %d", m.tbl.Cursor(), beforeTable)
+ }
+ if m.ultraCursor != beforeUltra+1 {
+ t.Fatalf("blink navigation did not move visible ultra cursor: got %d want %d", m.ultraCursor, beforeUltra+1)
+ }
+}
+
+func TestUltraPriorityOpUsesUltraSelection(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)
+ }
+
+ m.showUltra = true
+ m.tbl.SetCursor(0)
+ m.ultraCursor = 1
+
+ hiddenID := m.tasks[m.tbl.Cursor()].ID
+ selectedID := m.ultraTaskList()[m.ultraCursor].ID
+ if hiddenID == selectedID {
+ t.Fatalf("test setup failed: hidden and ultra selections match")
+ }
+
+ mv, cmd := (&m).Update(tea.KeyPressMsg{Code: 'p', Text: "p"})
+ if cmd != nil {
+ t.Fatalf("ultra priority hotkey unexpectedly returned a command")
+ }
+ m = *mv.(*Model)
+
+ if m.priorityID != selectedID {
+ t.Fatalf("priority editor targeted task %d, want ultra-selected task %d", m.priorityID, selectedID)
+ }
+ if m.priorityID == hiddenID {
+ t.Fatalf("priority editor followed hidden table cursor %d instead of ultra selection %d", hiddenID, selectedID)
+ }
+ if !m.prioritySelecting {
+ t.Fatalf("priority editor was not activated")
+ }
+}
+
+func TestUltraReloadPreservesFilteredSelection(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath, phaseFile := setupUltraReloadTaskSet(t, tmp)
+ setupEnv(t, taskPath)
+
+ m, err := New(nil, "firefox")
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ if got := m.tasks; len(got) != 3 || got[0].ID != 3 || got[1].ID != 2 || got[2].ID != 1 {
+ t.Fatalf("unexpected initial sort order: %+v", got)
+ }
+
+ m.showUltra = true
+ m.ultraFiltered = []int{0, 2}
+ m.ultraCursor = 1
+ m.ultraOffset = 0
+ if got := m.ultraTaskList(); len(got) != 2 || got[0].ID != 3 || got[1].ID != 1 {
+ t.Fatalf("unexpected initial ultra filter list: %+v", got)
+ }
+
+ if err := os.WriteFile(phaseFile, []byte("2"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := m.reload(); err != nil {
+ t.Fatalf("reload: %v", err)
+ }
+
+ if got := m.tasks; len(got) != 3 || got[0].ID != 1 || got[1].ID != 3 || got[2].ID != 2 {
+ t.Fatalf("unexpected reloaded sort order: %+v", got)
+ }
+ if !reflect.DeepEqual(m.ultraFiltered, []int{1, 0}) {
+ t.Fatalf("ultraFiltered was not rebuilt from task IDs: %#v", m.ultraFiltered)
+ }
+ if got := m.ultraTaskList(); len(got) != 2 || got[0].ID != 3 || got[1].ID != 1 {
+ t.Fatalf("ultra task list changed selection order: %+v", got)
+ }
+ if got := m.ultraTaskList()[m.ultraCursor].ID; got != 1 {
+ t.Fatalf("ultra cursor drifted after reload: got task %d want 1", got)
+ }
+}
+
+func TestUltraInlineOverlayRenders(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath := setupBasicTask(t, tmp)
+ setupEnv(t, taskPath)
+
+ m, err := New(nil, "firefox")
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ mv, cmd := (&m).Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ if cmd != nil {
+ t.Fatalf("resize unexpectedly returned a command")
+ }
+ m = *mv.(*Model)
+
+ mv, cmd = (&m).Update(tea.KeyPressMsg{Code: 'u', Text: "u"})
+ if cmd != nil {
+ t.Fatalf("u unexpectedly returned a command")
+ }
+ m = *mv.(*Model)
+
+ mv, cmd = (&m).Update(tea.KeyPressMsg{Code: 'f', Text: "f"})
+ if cmd != nil {
+ t.Fatalf("f unexpectedly returned a command")
+ }
+ m = *mv.(*Model)
+ if !m.filterEditing {
+ t.Fatalf("f did not activate filter editing in ultra mode")
+ }
+
+ view := m.View().Content
+ if !strings.Contains(view, "filter:") {
+ t.Fatalf("ultra view did not render filter overlay: %q", view)
+ }
+}
+
// TestExpandedCellViewNoDoubleRender is a regression test for a bug where
// expandedCellView() was appended to the layout unconditionally AND again
// inside the cellExpanded guard, producing a duplicate line when expanded.
diff --git a/internal/ui/ultra.go b/internal/ui/ultra.go
index 8e7ad2a..74d6c8c 100644
--- a/internal/ui/ultra.go
+++ b/internal/ui/ultra.go
@@ -18,7 +18,7 @@ func (m *Model) renderUltraModus() string {
top := m.ultraStatusLine(m.ultraModeStatus(tasks), width)
bottom := m.ultraStatusLine(m.ultraCursorStatus(tasks), width)
- overlay, overlayHeight := m.ultraSearchOverlay()
+ overlay, overlayHeight := m.ultraOverlay()
var lines []string
lines = append(lines, top)
@@ -87,7 +87,7 @@ func (m *Model) ultraVisibleCount() int {
width := m.ultraRenderWidth()
top := m.ultraStatusLine(m.ultraModeStatus(tasks), width)
bottom := m.ultraStatusLine(m.ultraCursorStatus(tasks), width)
- _, overlayHeight := m.ultraSearchOverlay()
+ _, overlayHeight := m.ultraOverlay()
budget := m.ultraCardBudget(top, bottom, overlayHeight)
if budget <= 0 {
@@ -140,6 +140,146 @@ func (m *Model) ultraTaskList() []task.Task {
return tasks
}
+func (m *Model) ultraFilteredTaskIDs() []int {
+ if m.ultraFiltered == nil {
+ return nil
+ }
+
+ ids := make([]int, 0, len(m.ultraFiltered))
+ for _, idx := range m.ultraFiltered {
+ if idx < 0 || idx >= len(m.tasks) {
+ continue
+ }
+ ids = append(ids, m.tasks[idx].ID)
+ }
+ return ids
+}
+
+func (m *Model) rebuildUltraFiltered(ids []int) {
+ if m.ultraFiltered == nil {
+ return
+ }
+
+ indexes := make([]int, 0, len(ids))
+ for _, id := range ids {
+ if idx := m.taskIndexByID(id); idx >= 0 {
+ indexes = append(indexes, idx)
+ }
+ }
+ m.ultraFiltered = indexes
+}
+
+func (m *Model) getUltraSelectedTaskID() (int, error) {
+ tasks := m.ultraTaskList()
+ if len(tasks) == 0 {
+ return 0, fmt.Errorf("no ultra tasks available")
+ }
+ if m.ultraCursor < 0 || m.ultraCursor >= len(tasks) {
+ return 0, fmt.Errorf("ultra cursor %d out of range", m.ultraCursor)
+ }
+ return tasks[m.ultraCursor].ID, nil
+}
+
+func (m *Model) ultraTaskIndexByID(id int) int {
+ for i, t := range m.ultraTaskList() {
+ if t.ID == id {
+ return i
+ }
+ }
+ return -1
+}
+
+func (m *Model) taskIndexByID(id int) int {
+ for i, t := range m.tasks {
+ if t.ID == id {
+ return i
+ }
+ }
+ return -1
+}
+
+func (m *Model) selectTaskByID(id int) bool {
+ row := m.taskIndexByID(id)
+ if row < 0 {
+ return false
+ }
+
+ prevRow := m.tbl.Cursor()
+ prevCol := m.tbl.ColumnCursor()
+ m.tbl.SetCursor(row)
+
+ if m.showUltra {
+ if ultraRow := m.ultraTaskIndexByID(id); ultraRow >= 0 {
+ m.ultraCursor = ultraRow
+ }
+ m.ultraEnsureVisible()
+ }
+
+ if prevRow != m.tbl.Cursor() || prevCol != m.tbl.ColumnCursor() {
+ m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor())
+ }
+ return true
+}
+
+func (m *Model) reconcileUltraSelection() {
+ if !m.showUltra {
+ return
+ }
+
+ if m.ultraFocusedID > 0 {
+ _ = m.selectTaskByID(m.ultraFocusedID)
+ m.ultraFocusedID = 0
+ m.ultraEnsureVisible()
+ return
+ }
+
+ m.ultraEnsureVisible()
+}
+
+func (m *Model) ultraOverlay() (string, int) {
+ overlay, overlayHeight := m.ultraSearchOverlay()
+
+ inputOverlay := m.ultraInputOverlay()
+ if inputOverlay != "" {
+ if overlay != "" {
+ overlay += "\n" + inputOverlay
+ overlayHeight += lipgloss.Height(inputOverlay)
+ } else {
+ overlay = inputOverlay
+ overlayHeight = lipgloss.Height(inputOverlay)
+ }
+ }
+
+ return overlay, overlayHeight
+}
+
+func (m *Model) ultraInputOverlay() string {
+ switch {
+ case m.annotating:
+ return m.annotateInput.View()
+ case m.dueEditing:
+ return m.dueView(true)
+ case m.prioritySelecting:
+ return m.priorityView(true)
+ case m.descEditing:
+ return m.descInput.View()
+ case m.tagsEditing:
+ return m.tagsInput.View()
+ case m.recurEditing:
+ return m.recurInput.View()
+ case m.projEditing:
+ return m.projInput.View()
+ case m.filterEditing:
+ return m.filterInput.View()
+ case m.addingTask:
+ return m.addInput.View()
+ case m.searching:
+ return m.searchInput.View()
+ default:
+ return ""
+ }
+}
+
// ultraVisibleCursor treats ultraCursor as a cursor within the visible ultra task list.
func (m *Model) ultraVisibleCursor(tasks []task.Task) int {
if len(tasks) == 0 {
@@ -193,7 +333,13 @@ func (m *Model) renderUltraCard(t task.Task, width int, selected bool, re *regex
if card == "" {
return ""
}
- return ultraCardStyle(m.theme, width, selected).Render(card)
+ blink := m.blinkID != 0 && m.blinkOn && t.ID == m.blinkID
+ if blink {
+ lines := strings.SplitN(card, "\n", 2)
+ lines[0] = "! " + lines[0]
+ card = strings.Join(lines, "\n")
+ }
+ return ultraCardStyle(m.theme, width, selected, blink).Render(card)
}
// renderUltraHeader renders the task's primary state line.
@@ -328,10 +474,13 @@ func (m *Model) ultraKeyValue(re *regexp.Regexp, label, value string) string {
return labelStyle.Render(label+":") + " " + m.ultraStyledText(re, valueStyle, value)
}
-func ultraCardStyle(theme Theme, width int, selected bool) lipgloss.Style {
+func ultraCardStyle(theme Theme, width int, selected, blink bool) lipgloss.Style {
style := lipgloss.NewStyle().Width(width)
if selected {
- return style.Foreground(lipgloss.Color(theme.SelectedFG)).Background(lipgloss.Color(theme.SelectedBG))
+ style = style.Foreground(lipgloss.Color(theme.SelectedFG)).Background(lipgloss.Color(theme.SelectedBG))
+ }
+ if blink {
+ style = style.Bold(true).Reverse(true)
}
return style
}
@@ -457,50 +606,195 @@ func (m *Model) ultraEnsureVisible() {
// handleUltraMode handles keyboard input in ultra mode.
func (m *Model) handleUltraMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
- tasks := m.ultraTaskList()
- last := len(tasks) - 1
-
switch msg.String() {
case "q", "esc":
return m.handleQuitOrEscape()
case "j", "down":
- if last >= 0 {
- m.ultraCursor++
- if m.ultraCursor > last {
- m.ultraCursor = last
- }
- }
- m.ultraEnsureVisible()
+ m.ultraMoveCursor(1)
case "k", "up":
- if last >= 0 {
- m.ultraCursor--
- if m.ultraCursor < 0 {
- m.ultraCursor = 0
- }
- }
- m.ultraEnsureVisible()
+ m.ultraMoveCursor(-1)
case "pgdn", "pgdown", "space":
- m.ultraCursor += m.ultraVisibleCount()
- if last >= 0 && m.ultraCursor > last {
- m.ultraCursor = last
- }
- m.ultraEnsureVisible()
+ m.ultraMoveCursor(m.ultraVisibleCount())
case "pgup", "b":
- m.ultraCursor -= m.ultraVisibleCount()
+ m.ultraMoveCursor(-m.ultraVisibleCount())
+ case "g", "home":
+ m.ultraGoHome()
+ case "G", "end":
+ m.ultraGoEnd()
+ case "enter", "e", "E":
+ return m.handleUltraEditTask()
+ case "s":
+ return m.handleUltraToggleStart()
+ case "d":
+ return m.handleUltraMarkDone()
+ case "p":
+ return m.handleUltraSetPriority()
+ case "w":
+ return m.handleUltraSetDueDate()
+ case "W":
+ return m.handleUltraRemoveDueDate()
+ 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 "+":
+ 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
+}
+
+func (m *Model) ultraMoveCursor(delta int) {
+ m.ultraFocusedID = 0
+
+ tasks := m.ultraTaskList()
+ last := len(tasks) - 1
+ if last >= 0 {
+ m.ultraCursor += delta
if m.ultraCursor < 0 {
m.ultraCursor = 0
}
- m.ultraEnsureVisible()
- case "g", "home":
- m.ultraCursor = 0
- m.ultraOffset = 0
- case "G", "end":
- if last >= 0 {
+ if m.ultraCursor > last {
m.ultraCursor = last
- } else {
- m.ultraCursor = 0
}
- m.ultraEnsureVisible()
}
- return m, nil
+
+ m.ultraEnsureVisible()
+}
+
+func (m *Model) ultraGoHome() {
+ m.ultraFocusedID = 0
+ m.ultraCursor = 0
+ m.ultraOffset = 0
+}
+
+func (m *Model) ultraGoEnd() {
+ m.ultraFocusedID = 0
+
+ tasks := m.ultraTaskList()
+ if last := len(tasks) - 1; last >= 0 {
+ m.ultraCursor = last
+ } else {
+ m.ultraCursor = 0
+ }
+
+ m.ultraEnsureVisible()
+}
+
+func (m *Model) ultraClearFocusedID() {
+ m.ultraFocusedID = 0
+}
+
+func (m *Model) ultraPrepareSelectedTask() (int, bool) {
+ id, err := m.getUltraSelectedTaskID()
+ if err != nil {
+ return 0, false
+ }
+ if !m.selectTaskByID(id) {
+ return 0, false
+ }
+
+ m.ultraFocusedID = id
+ return id, true
+}
+
+func (m *Model) handleUltraEditTask() (tea.Model, tea.Cmd) {
+ id, ok := m.ultraPrepareSelectedTask()
+ if !ok {
+ return m, nil
+ }
+
+ m.editID = id
+ return m, editCmd(id)
+}
+
+func (m *Model) handleUltraToggleStart() (tea.Model, tea.Cmd) {
+ if _, ok := m.ultraPrepareSelectedTask(); !ok {
+ return m, nil
+ }
+
+ return m.handleToggleStart()
+}
+
+func (m *Model) handleUltraMarkDone() (tea.Model, tea.Cmd) {
+ id, ok := m.ultraPrepareSelectedTask()
+ if !ok {
+ return m, nil
+ }
+
+ return m, m.startBlink(id, true)
+}
+
+func (m *Model) handleUltraSetPriority() (tea.Model, tea.Cmd) {
+ if _, ok := m.ultraPrepareSelectedTask(); !ok {
+ return m, nil
+ }
+
+ return m.handleSetPriority()
+}
+
+func (m *Model) handleUltraSetDueDate() (tea.Model, tea.Cmd) {
+ if _, ok := m.ultraPrepareSelectedTask(); !ok {
+ return m, nil
+ }
+
+ return m.handleSetDueDate()
+}
+
+func (m *Model) handleUltraRemoveDueDate() (tea.Model, tea.Cmd) {
+ if _, ok := m.ultraPrepareSelectedTask(); !ok {
+ return m, nil
+ }
+
+ return m.handleRemoveDueDate()
+}
+
+func (m *Model) handleUltraEditTags() (tea.Model, tea.Cmd) {
+ if _, ok := m.ultraPrepareSelectedTask(); !ok {
+ return m, nil
+ }
+
+ return m.handleEditTags()
+}
+
+func (m *Model) handleUltraAnnotate(replace bool) (tea.Model, tea.Cmd) {
+ if _, ok := m.ultraPrepareSelectedTask(); !ok {
+ return m, nil
+ }
+
+ return m.handleAnnotate(replace)
+}
+
+func (m *Model) handleUltraEditProject() (tea.Model, tea.Cmd) {
+ if _, ok := m.ultraPrepareSelectedTask(); !ok {
+ return m, nil
+ }
+
+ return m.handleEditProject()
+}
+
+func (m *Model) handleUltraSetRecurrence() (tea.Model, tea.Cmd) {
+ if _, ok := m.ultraPrepareSelectedTask(); !ok {
+ return m, nil
+ }
+
+ return m.handleSetRecurrence()
}