summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-28 09:48:27 +0200
committerPaul Buetow <paul@buetow.org>2026-04-07 09:24:18 +0300
commita37e561acddaaa63ca574788a0826755a2a7a446 (patch)
tree19a2bb54c77c249be9d670fa9a445f13b527c07c
parente2ef4d55f2da0959c832a530e68ea0f519bffdd1 (diff)
ui: add ultra mode navigation (vp)
-rw-r--r--internal/ui/keyhandlers.go1
-rw-r--r--internal/ui/table.go3
-rw-r--r--internal/ui/table_test.go120
-rw-r--r--internal/ui/ultra.go143
4 files changed, 267 insertions, 0 deletions
diff --git a/internal/ui/keyhandlers.go b/internal/ui/keyhandlers.go
index b645a9c..613efd5 100644
--- a/internal/ui/keyhandlers.go
+++ b/internal/ui/keyhandlers.go
@@ -114,6 +114,7 @@ func (m *Model) handleNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.showUltra = true
m.ultraCursor = m.tbl.Cursor()
m.ultraOffset = 0
+ m.ultraEnsureVisible()
return m, nil
case "1":
return m.handleJumpToRandomTask()
diff --git a/internal/ui/table.go b/internal/ui/table.go
index ad2795a..48ed9d5 100644
--- a/internal/ui/table.go
+++ b/internal/ui/table.go
@@ -514,6 +514,9 @@ func (m *Model) handleWindowResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
m.windowHeight = msg.Height
m.computeColumnWidths()
m.updateTableHeight()
+ if m.showUltra {
+ m.ultraEnsureVisible()
+ }
// Update help viewport if active
if m.showHelp && m.helpViewport.Width() > 0 {
diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go
index ec2b9bc..b3e40dd 100644
--- a/internal/ui/table_test.go
+++ b/internal/ui/table_test.go
@@ -608,6 +608,21 @@ func TestNavigationHotkeys(t *testing.T) {
}
}
+func setupUltraTaskSet(t *testing.T, tmp string) string {
+ taskPath := filepath.Join(tmp, "task")
+ script := "#!/bin/sh\n" +
+ "if echo \"$@\" | grep -q export; then\n" +
+ " echo '{\"id\":1,\"uuid\":\"1\",\"description\":\"alpha\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n" +
+ " echo '{\"id\":2,\"uuid\":\"2\",\"description\":\"beta bravo\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n" +
+ " echo '{\"id\":3,\"uuid\":\"3\",\"description\":\"charlie delta\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n" +
+ " exit 0\n" +
+ "fi\n"
+ if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ return taskPath
+}
+
func setupBasicTask(t *testing.T, tmp string) string {
taskPath := filepath.Join(tmp, "task")
script := "#!/bin/sh\n" +
@@ -729,6 +744,111 @@ func TestUltraExitHotkeysClearUltraState(t *testing.T) {
}
}
+func TestUltraEntryResizeAndNavigationBindings(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)
+ }
+
+ resize := func(width, height int) {
+ t.Helper()
+ mv, cmd := (&m).Update(tea.WindowSizeMsg{Width: width, Height: height})
+ if cmd != nil {
+ t.Fatalf("resize %dx%d unexpectedly returned a command", width, height)
+ }
+ m = *mv.(*Model)
+ }
+
+ resize(60, 16)
+
+ step(tea.KeyPressMsg{Code: 'j', Text: "j"})
+ step(tea.KeyPressMsg{Code: 'j', Text: "j"})
+ if m.tbl.Cursor() != 2 {
+ t.Fatalf("table cursor = %d, want 2 before ultra entry", m.tbl.Cursor())
+ }
+
+ step(tea.KeyPressMsg{Code: 'u', Text: "u"})
+ if !m.showUltra {
+ t.Fatalf("u did not enter ultra mode")
+ }
+ if m.ultraCursor != 2 {
+ t.Fatalf("u: cursor = %d, want 2", m.ultraCursor)
+ }
+ if m.ultraOffset != 1 {
+ t.Fatalf("u: offset = %d, want 1", m.ultraOffset)
+ }
+ if got := m.ultraVisibleCount(); got != 2 {
+ t.Fatalf("u: visible count = %d, want 2", got)
+ }
+ if start := m.ultraVisibleStart(len(m.ultraTaskList())); m.ultraCursor < start || m.ultraCursor >= start+m.ultraVisibleCount() {
+ t.Fatalf("u: cursor %d not visible at offset %d", m.ultraCursor, m.ultraOffset)
+ }
+
+ resize(60, 7)
+ if m.ultraOffset != 2 {
+ t.Fatalf("resize: offset = %d, want 2", m.ultraOffset)
+ }
+ if got := m.ultraVisibleCount(); got != 1 {
+ t.Fatalf("resize: visible count = %d, want 1", got)
+ }
+ if start := m.ultraVisibleStart(len(m.ultraTaskList())); m.ultraCursor < start || m.ultraCursor >= start+m.ultraVisibleCount() {
+ t.Fatalf("resize: cursor %d not visible at offset %d", m.ultraCursor, m.ultraOffset)
+ }
+
+ step(tea.KeyPressMsg{Code: 'k', Text: "k"})
+ if m.ultraCursor != 1 {
+ t.Fatalf("k: cursor = %d, want 1", m.ultraCursor)
+ }
+ if m.ultraOffset != 1 {
+ t.Fatalf("k: offset = %d, want 1", m.ultraOffset)
+ }
+
+ step(tea.KeyPressMsg{Code: tea.KeyPgDown, Text: "pgdn"})
+ if m.ultraCursor != 2 {
+ t.Fatalf("pgdn: cursor = %d, want 2", m.ultraCursor)
+ }
+ if m.ultraOffset != 2 {
+ t.Fatalf("pgdn: offset = %d, want 2", m.ultraOffset)
+ }
+
+ step(tea.KeyPressMsg{Code: tea.KeyPgUp, Text: "pgup"})
+ if m.ultraCursor != 1 {
+ t.Fatalf("pgup: cursor = %d, want 1", m.ultraCursor)
+ }
+ if m.ultraOffset != 1 {
+ t.Fatalf("pgup: offset = %d, want 1", m.ultraOffset)
+ }
+
+ step(tea.KeyPressMsg{Code: 'g', Text: "g"})
+ if m.ultraCursor != 0 {
+ t.Fatalf("g: cursor = %d, want 0", m.ultraCursor)
+ }
+ if m.ultraOffset != 0 {
+ t.Fatalf("g: offset = %d, want 0", m.ultraOffset)
+ }
+
+ step(tea.KeyPressMsg{Code: 'G', Text: "G"})
+ if m.ultraCursor != 2 {
+ t.Fatalf("G: cursor = %d, want 2", m.ultraCursor)
+ }
+ if m.ultraOffset != 2 {
+ t.Fatalf("G: offset = %d, want 2", m.ultraOffset)
+ }
+}
+
// 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 ab94bfb..8e7ad2a 100644
--- a/internal/ui/ultra.go
+++ b/internal/ui/ultra.go
@@ -78,6 +78,53 @@ func (m *Model) ultraVisibleStart(total int) int {
return start
}
+func (m *Model) ultraVisibleCount() int {
+ tasks := m.ultraTaskList()
+ if len(tasks) == 0 {
+ return 0
+ }
+
+ width := m.ultraRenderWidth()
+ top := m.ultraStatusLine(m.ultraModeStatus(tasks), width)
+ bottom := m.ultraStatusLine(m.ultraCursorStatus(tasks), width)
+ _, overlayHeight := m.ultraSearchOverlay()
+
+ budget := m.ultraCardBudget(top, bottom, overlayHeight)
+ if budget <= 0 {
+ return 0
+ }
+
+ start := m.ultraVisibleStart(len(tasks))
+ selected := m.ultraVisibleCursor(tasks)
+ used := 0
+ count := 0
+ for i := start; i < len(tasks); i++ {
+ card := m.renderUltraCard(tasks[i], width, i == selected, m.ultraSearchRegex)
+ if card == "" {
+ continue
+ }
+
+ cardHeight := lipgloss.Height(card)
+ if count > 0 {
+ if used+1+cardHeight > budget {
+ break
+ }
+ used++
+ } else if cardHeight > budget {
+ break
+ }
+
+ if used+cardHeight > budget {
+ break
+ }
+
+ used += cardHeight
+ count++
+ }
+
+ return count
+}
+
func (m *Model) ultraTaskList() []task.Task {
if m.ultraFiltered == nil {
return m.tasks
@@ -353,11 +400,107 @@ func ultraDueValue(m *Model, due string) string {
return ultraOrDash(val)
}
+func (m *Model) ultraEnsureVisible() {
+ tasks := m.ultraTaskList()
+ if len(tasks) == 0 {
+ m.ultraCursor = 0
+ m.ultraOffset = 0
+ return
+ }
+
+ if m.ultraCursor < 0 {
+ m.ultraCursor = 0
+ }
+ if m.ultraCursor >= len(tasks) {
+ m.ultraCursor = len(tasks) - 1
+ }
+ if m.ultraOffset < 0 {
+ m.ultraOffset = 0
+ }
+ if m.ultraOffset >= len(tasks) {
+ m.ultraOffset = len(tasks) - 1
+ }
+
+ for range tasks {
+ visible := m.ultraVisibleCount()
+ if visible <= 0 {
+ if m.ultraOffset == m.ultraCursor {
+ return
+ }
+ m.ultraOffset = m.ultraCursor
+ continue
+ }
+
+ start := m.ultraVisibleStart(len(tasks))
+ end := start + visible - 1
+ if m.ultraCursor < start {
+ if m.ultraOffset == m.ultraCursor {
+ return
+ }
+ m.ultraOffset = m.ultraCursor
+ continue
+ }
+ if m.ultraCursor > end {
+ next := m.ultraCursor - visible + 1
+ if next < 0 {
+ next = 0
+ }
+ if next == m.ultraOffset {
+ return
+ }
+ m.ultraOffset = next
+ continue
+ }
+ return
+ }
+}
+
// 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()
+ case "k", "up":
+ if last >= 0 {
+ m.ultraCursor--
+ if m.ultraCursor < 0 {
+ m.ultraCursor = 0
+ }
+ }
+ m.ultraEnsureVisible()
+ case "pgdn", "pgdown", "space":
+ m.ultraCursor += m.ultraVisibleCount()
+ if last >= 0 && m.ultraCursor > last {
+ m.ultraCursor = last
+ }
+ m.ultraEnsureVisible()
+ case "pgup", "b":
+ m.ultraCursor -= m.ultraVisibleCount()
+ if m.ultraCursor < 0 {
+ m.ultraCursor = 0
+ }
+ m.ultraEnsureVisible()
+ case "g", "home":
+ m.ultraCursor = 0
+ m.ultraOffset = 0
+ case "G", "end":
+ if last >= 0 {
+ m.ultraCursor = last
+ } else {
+ m.ultraCursor = 0
+ }
+ m.ultraEnsureVisible()
}
return m, nil
}