diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-28 09:48:27 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-07 09:24:18 +0300 |
| commit | a37e561acddaaa63ca574788a0826755a2a7a446 (patch) | |
| tree | 19a2bb54c77c249be9d670fa9a445f13b527c07c | |
| parent | e2ef4d55f2da0959c832a530e68ea0f519bffdd1 (diff) | |
ui: add ultra mode navigation (vp)
| -rw-r--r-- | internal/ui/keyhandlers.go | 1 | ||||
| -rw-r--r-- | internal/ui/table.go | 3 | ||||
| -rw-r--r-- | internal/ui/table_test.go | 120 | ||||
| -rw-r--r-- | internal/ui/ultra.go | 143 |
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 } |
