diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-06 22:52:17 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-07 09:24:18 +0300 |
| commit | 2f7c0a0cb8c247b7ebda3b1873f2219105693818 (patch) | |
| tree | 88bc0d58091150e76125eebf088d1e33251c460d /internal/ui | |
| parent | 0f8005352d02236c923ac9c593de47f886ca4305 (diff) | |
ui: polish ultra modus edge cases (yx)
Add ultraNoTasksMessage() showing centered "No tasks" when task list is
empty. Add tests for single-task cursor stability, empty-list render,
and cursor clamping after filter yields zero results.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/ui')
| -rw-r--r-- | internal/ui/table_test.go | 156 | ||||
| -rw-r--r-- | internal/ui/ultra.go | 52 |
2 files changed, 198 insertions, 10 deletions
diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 2b86659..0acbfab 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -1666,3 +1666,159 @@ func TestUltraResizeSyncRefreshesNormalSearchSelection(t *testing.T) { t.Fatalf("new row did not receive refreshed search selection after ultra resize") } } + +// setupSingleTask creates a fake task binary that returns exactly one task. +func setupSingleTask(t *testing.T, tmp string) string { + t.Helper() + taskPath := filepath.Join(tmp, "task") + script := "#!/bin/sh\n" + + "if echo \"$@\" | grep -q export; then\n" + + " echo '{\"id\":1,\"uuid\":\"1\",\"description\":\"only task\",\"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 +} + +// setupEmptyTasks creates a fake task binary that returns no tasks. +func setupEmptyTasks(t *testing.T, tmp string) string { + t.Helper() + taskPath := filepath.Join(tmp, "task") + script := "#!/bin/sh\n" + + "if echo \"$@\" | grep -q export; then\n" + + " exit 0\n" + + "fi\n" + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return taskPath +} + +// TestUltraSingleTaskCursorStaysAtZero verifies that with one task the cursor +// never advances beyond index 0 when navigating down or jumping to the end. +func TestUltraSingleTaskCursorStaysAtZero(t *testing.T) { + tmp := t.TempDir() + taskPath := setupSingleTask(t, tmp) + setupEnv(t, taskPath) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + + // Enter ultra mode. + mv, _ := (&m).Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) + m = *mv.(*Model) + if !m.showUltra { + t.Fatalf("u did not enter ultra mode") + } + if len(m.ultraTaskList()) != 1 { + t.Fatalf("expected 1 task, got %d", len(m.ultraTaskList())) + } + + // Moving down on a single-task list must keep cursor at 0. + mv, _ = (&m).Update(tea.KeyPressMsg{Code: 'j', Text: "j"}) + m = *mv.(*Model) + if got := m.ultraCursor; got != 0 { + t.Fatalf("j on single task: cursor = %d, want 0", got) + } + + // Jump to end — still must be 0. + mv, _ = (&m).Update(tea.KeyPressMsg{Code: 'G', Text: "G"}) + m = *mv.(*Model) + if got := m.ultraCursor; got != 0 { + t.Fatalf("G on single task: cursor = %d, want 0", got) + } + + // Verify ultraVisibleCursor agrees. + if got := m.ultraVisibleCursor(m.ultraTaskList()); got != 0 { + t.Fatalf("ultraVisibleCursor on single task = %d, want 0", got) + } +} + +// TestUltraNoTasksRender verifies that renderUltraModus does not panic and +// returns a non-empty string containing "No tasks" when the task list is empty. +func TestUltraNoTasksRender(t *testing.T) { + tmp := t.TempDir() + taskPath := setupEmptyTasks(t, tmp) + setupEnv(t, taskPath) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + + // Give the model a window size so rendering has a budget. + mv, _ := (&m).Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = *mv.(*Model) + + // Enter ultra mode. + mv, _ = (&m).Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) + m = *mv.(*Model) + + // Force ultra mode on in case no tasks means it doesn't activate normally. + m.showUltra = true + + if len(m.ultraTaskList()) != 0 { + t.Fatalf("expected 0 tasks, got %d", len(m.ultraTaskList())) + } + + rendered := m.renderUltraModus() + if rendered == "" { + t.Fatal("renderUltraModus returned empty string for empty task list") + } + stripped := ansi.Strip(rendered) + if !strings.Contains(stripped, "No tasks") { + t.Fatalf("renderUltraModus did not contain 'No tasks', got:\n%s", stripped) + } +} + +// TestUltraCursorClampAfterFilterZeroResults verifies that when a search filter +// leaves zero results the cursor is clamped to 0 and remains in-bounds. +func TestUltraCursorClampAfterFilterZeroResults(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) + } + + // Enter ultra mode. + mv, _ := (&m).Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) + m = *mv.(*Model) + if !m.showUltra { + t.Fatalf("u did not enter ultra mode") + } + + // Move cursor to the last task. + mv, _ = (&m).Update(tea.KeyPressMsg{Code: 'G', Text: "G"}) + m = *mv.(*Model) + if got := m.ultraCursor; got != 2 { + t.Fatalf("G: cursor = %d, want 2", got) + } + + // Apply a search that matches nothing — filter produces zero results. + m.ultraSearchRegex = regexp.MustCompile("zzznomatch") + m.ultraFiltered = m.ultraFilteredIndexes(m.ultraSearchRegex) + if len(m.ultraFiltered) != 0 { + t.Fatalf("expected zero filtered results, got %d", len(m.ultraFiltered)) + } + + // ultraEnsureVisible must clamp cursor to 0 for an empty list. + m.ultraEnsureVisible() + if got := m.ultraCursor; got != 0 { + t.Fatalf("cursor after zero-result filter = %d, want 0", got) + } + if got := m.ultraOffset; got != 0 { + t.Fatalf("offset after zero-result filter = %d, want 0", got) + } + + // ultraVisibleCursor must return -1 for an empty task list. + if got := m.ultraVisibleCursor(m.ultraTaskList()); got != -1 { + t.Fatalf("ultraVisibleCursor for empty list = %d, want -1", got) + } +} diff --git a/internal/ui/ultra.go b/internal/ui/ultra.go index e7641e1..288400f 100644 --- a/internal/ui/ultra.go +++ b/internal/ui/ultra.go @@ -22,16 +22,23 @@ func (m *Model) renderUltraModus() string { var lines []string lines = append(lines, top) - lines = append( - lines, - m.ultraRenderCards( - tasks, - width, - m.ultraVisibleCursor(tasks), - m.ultraVisibleStart(len(tasks)), - m.ultraCardBudget(top, bottom, overlayHeight), - )..., - ) + + if len(tasks) == 0 { + // No tasks available — render a centered placeholder instead of an empty card area. + lines = append(lines, m.ultraNoTasksMessage(width, m.ultraCardBudget(top, bottom, overlayHeight))) + } else { + lines = append( + lines, + m.ultraRenderCards( + tasks, + width, + m.ultraVisibleCursor(tasks), + m.ultraVisibleStart(len(tasks)), + m.ultraCardBudget(top, bottom, overlayHeight), + )..., + ) + } + lines = append(lines, bottom) if overlay != "" { lines = append(lines, overlay) @@ -39,6 +46,31 @@ func (m *Model) renderUltraModus() string { return strings.Join(lines, "\n") } +// ultraNoTasksMessage renders a vertically and horizontally centered "No tasks" message +// sized to fill the available card budget height. +func (m *Model) ultraNoTasksMessage(width, budget int) string { + msg := lipgloss.NewStyle(). + Width(width). + Align(lipgloss.Center). + Foreground(lipgloss.Color("240")). + Render("No tasks") + + // Pad vertically so the message appears centered in the card area. + msgHeight := lipgloss.Height(msg) + paddingTop := (budget - msgHeight) / 2 + if paddingTop < 0 { + paddingTop = 0 + } + + var lines []string + emptyLine := lipgloss.NewStyle().Width(width).Render("") + for range paddingTop { + lines = append(lines, emptyLine) + } + lines = append(lines, msg) + return strings.Join(lines, "\n") +} + func (m Model) buildUltraHelpContent() string { return m.buildRenderedHelpContent(m.ultraHelpSections()) } |
