diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-07 19:32:46 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-07 19:32:46 +0300 |
| commit | efb531343046a5e32c177136c70afda97169c8fb (patch) | |
| tree | 29301a47189ded20dfa2aaa6045e012286d90cd7 | |
| parent | bc04f89ace5247e897af98c523142dcfc574ea91 (diff) | |
ui: consolidate ultra card to one status line per task
Replace the separate header (ID/priority/status/urgency/age) and meta
(project/tags/due/recur/start) lines with a single status line:
#ID | priority | status | urgency | due: X | proj: X | tags: X
- Age, recur, and start are dropped for compactness; started tasks are
already highlighted in yellow, detail view has full field set
- ultraModeStatus and ultraStatusText replace ultraHeaderText/ultraMetaText
- renderUltraStatusWithRegex replaces renderUltraHeaderWithRegex and
renderUltraMetaWithRegex; priority badge text padded to 3 chars in the
plain line so whole-line and styled search paths produce identical text
- Update tests to reflect 2-line cards (status + description) and new
field labels (proj: instead of project:)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | internal/ui/table_test.go | 37 | ||||
| -rw-r--r-- | internal/ui/ultra.go | 109 |
2 files changed, 66 insertions, 80 deletions
diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 855f7d6..f3f2da3 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -1019,7 +1019,7 @@ func TestUltraSearchFiltersNavigatesAndHighlights(t *testing.T) { if m.ultraCursor != 0 || m.ultraOffset != 0 { t.Fatalf("search did not reset cursor/offset, got cursor=%d offset=%d", m.ultraCursor, m.ultraOffset) } - if structured := m.ultraSearchText(m.tasks[0]); !regexp.MustCompile(`(?m)^project:`).MatchString(structured) { + if structured := m.ultraSearchText(m.tasks[0]); !regexp.MustCompile(`proj:`).MatchString(structured) { t.Fatalf("ultra search text lost card line structure: %q", structured) } @@ -1035,14 +1035,14 @@ func TestUltraSearchFiltersNavigatesAndHighlights(t *testing.T) { if ansi.Strip(plain) != ansi.Strip(highlighted) { t.Fatalf("search highlighting changed visible text") } - labelHighlighted := m.renderUltraCard(filtered[0], 80, true, regexp.MustCompile(`project:`)) + labelHighlighted := m.renderUltraCard(filtered[0], 80, true, regexp.MustCompile(`proj:`)) if labelHighlighted == plain { t.Fatalf("label search did not change rendered card") } if ansi.Strip(labelHighlighted) != ansi.Strip(plain) { t.Fatalf("label search highlighting changed visible text") } - combinedHighlighted := m.renderUltraCard(filtered[0], 80, true, regexp.MustCompile(`project: home`)) + combinedHighlighted := m.renderUltraCard(filtered[0], 80, true, regexp.MustCompile(`proj: home`)) if combinedHighlighted == plain { t.Fatalf("combined meta search did not change rendered card") } @@ -1291,7 +1291,7 @@ func TestUltraEntryResizeAndNavigationBindings(t *testing.T) { if m.ultraCursor != 2 { t.Fatalf("u: cursor = %d, want 2", m.ultraCursor) } - // Compact cards (3 lines each) all fit at offset 0 in a 60×16 window. + // 2-line cards (status + description) all fit at offset 0 in a 60×16 window. if m.ultraOffset != 0 { t.Fatalf("u: offset = %d, want 0", m.ultraOffset) } @@ -1302,12 +1302,14 @@ func TestUltraEntryResizeAndNavigationBindings(t *testing.T) { t.Fatalf("u: cursor %d not visible at offset %d", m.ultraCursor, m.ultraOffset) } + // At 60×7 budget=5: 2-line cards with 1-line separator → 2 cards fit (2+1+2=5). + // cursor=2 → ultraEnsureVisible scrolls offset to 1 so cards 1 and 2 are shown. resize(60, 7) - if m.ultraOffset != 2 { - t.Fatalf("resize: offset = %d, want 2", m.ultraOffset) + if m.ultraOffset != 1 { + t.Fatalf("resize: offset = %d, want 1", m.ultraOffset) } - if got := m.ultraVisibleCount(); got != 1 { - t.Fatalf("resize: visible count = %d, want 1", got) + if got := m.ultraVisibleCount(); got != 2 { + t.Fatalf("resize: visible count = %d, want 2", 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) @@ -1321,20 +1323,22 @@ func TestUltraEntryResizeAndNavigationBindings(t *testing.T) { t.Fatalf("k: offset = %d, want 1", m.ultraOffset) } + // pgdn += visibleCount(2): cursor 1→3 clamped to 2; still visible at offset 1. 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) + if m.ultraOffset != 1 { + t.Fatalf("pgdn: offset = %d, want 1", m.ultraOffset) } + // pgup -= visibleCount(2): cursor 2→0; ultraEnsureVisible scrolls offset to 0. step(tea.KeyPressMsg{Code: tea.KeyPgUp, Text: "pgup"}) - if m.ultraCursor != 1 { - t.Fatalf("pgup: cursor = %d, want 1", m.ultraCursor) + if m.ultraCursor != 0 { + t.Fatalf("pgup: cursor = %d, want 0", m.ultraCursor) } - if m.ultraOffset != 1 { - t.Fatalf("pgup: offset = %d, want 1", m.ultraOffset) + if m.ultraOffset != 0 { + t.Fatalf("pgup: offset = %d, want 0", m.ultraOffset) } step(tea.KeyPressMsg{Code: 'g', Text: "g"}) @@ -1345,12 +1349,13 @@ func TestUltraEntryResizeAndNavigationBindings(t *testing.T) { t.Fatalf("g: offset = %d, want 0", m.ultraOffset) } + // G goes to last task (2); ultraEnsureVisible scrolls offset to 1. 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) + if m.ultraOffset != 1 { + t.Fatalf("G: offset = %d, want 1", m.ultraOffset) } } diff --git a/internal/ui/ultra.go b/internal/ui/ultra.go index 8419e78..53bc677 100644 --- a/internal/ui/ultra.go +++ b/internal/ui/ultra.go @@ -442,8 +442,7 @@ func (m *Model) ultraModeStatus(tasks []task.Task) string { func (m *Model) ultraSearchText(t task.Task) string { return ultraJoinSections( - m.ultraHeaderText(t), - m.ultraMetaText(t), + m.ultraStatusText(t), ultraOrDash(strings.TrimSpace(t.Description)), m.ultraAnnotationsSearchText(t), ) @@ -549,27 +548,21 @@ func (m *Model) ultraCursorStatus(tasks []task.Task) string { return fmt.Sprintf("%d/%d", cursor+1, len(tasks)) } -func (m *Model) ultraHeaderText(t task.Task) string { +// ultraStatusText returns the plain-text representation of the single +// consolidated status line shown per card: ID, priority, status, urgency, +// due date, project, and tags. Age, recur, and start are omitted — started +// tasks are highlighted in yellow, and the remaining fields are available in +// the detail view. +func (m *Model) ultraStatusText(t task.Task) string { return strings.Join( []string{ fmt.Sprintf("#%d", t.ID), ultraOrDash(t.Priority), ultraOrDash(t.Status), fmt.Sprintf("%.1f", t.Urgency), - ultraTaskAge(t.Entry), - }, - " | ", - ) -} - -func (m *Model) ultraMetaText(t task.Task) string { - return strings.Join( - []string{ - "project: " + ultraOrDash(t.Project), - "tags: " + ultraOrDash(strings.Join(t.Tags, " ")), "due: " + ultraDueValue(m, t.Due), - "recur: " + ultraOrDash(t.Recur), - "start: " + ultraOrDash(m.formatTaskDate(t.Start)), + "proj: " + ultraOrDash(t.Project), + "tags: " + ultraOrDash(strings.Join(t.Tags, " ")), }, " | ", ) @@ -631,13 +624,10 @@ func (m *Model) renderUltraCard(t task.Task, width int, selected bool, re *regex bg = m.theme.SelectedBG } - // No blank lines between sections — ultraJoinSections passes "" so - // ultraJoinSectionsWithBlank skips the separator entirely. The outer - // ultraCardStyle already fills the selected background across all lines, - // so per-section bg-coloured blank rows are not needed. + // Single status line (ID, priority, status, urgency, due, proj, tags) + // followed by description and annotations — no blank lines between sections. card := ultraJoinSections( - m.renderUltraHeaderWithRegex(t, width, re, bg), - m.renderUltraMetaWithRegex(t, width, re, bg), + m.renderUltraStatusWithRegex(t, width, re, bg), m.renderUltraDescriptionWithRegex(t, width, re, bg), m.renderUltraAnnotationsWithRegex(t, width, re, bg), ) @@ -654,14 +644,9 @@ func (m *Model) renderUltraCard(t task.Task, width int, selected bool, re *regex return ultraCardStyle(m.theme, width, selected, started, blink).Render(card) } -// renderUltraHeader renders the task's primary state line (no selection bg). -func (m *Model) renderUltraHeader(t task.Task, width int) string { - return m.renderUltraHeaderWithRegex(t, width, m.ultraSearchRegex, "") -} - -// renderUltraMeta renders the task's secondary metadata line (no selection bg). -func (m *Model) renderUltraMeta(t task.Task, width int) string { - return m.renderUltraMetaWithRegex(t, width, m.ultraSearchRegex, "") +// renderUltraStatus renders the consolidated single-line card status (no selection bg). +func (m *Model) renderUltraStatus(t task.Task, width int) string { + return m.renderUltraStatusWithRegex(t, width, m.ultraSearchRegex, "") } // renderUltraDescription renders the wrapped task description body (no selection bg). @@ -674,14 +659,37 @@ func (m *Model) renderUltraAnnotations(t task.Task, width int) string { return m.renderUltraAnnotationsWithRegex(t, width, m.ultraSearchRegex, "") } -func (m *Model) renderUltraHeaderWithRegex(t task.Task, width int, re *regexp.Regexp, bg string) string { +// renderUltraStatusWithRegex renders a single consolidated status line combining +// the former header (ID, priority, status, urgency) and meta (due, project, tags) +// fields. Age, recur, and start are omitted for compactness. +func (m *Model) renderUltraStatusWithRegex(t task.Task, width int, re *regexp.Regexp, bg string) string { + _ = width idText := fmt.Sprintf("#%d", t.ID) priorityText := ultraOrDash(t.Priority) statusText := ultraOrDash(t.Status) urgencyText := fmt.Sprintf("%.1f", t.Urgency) - ageText := ultraTaskAge(t.Entry) - line := strings.Join([]string{idText, priorityText, statusText, urgencyText, ageText}, " | ") - if re != nil && re.MatchString(line) && !ultraRegexMatchesAny(re, idText, priorityText, statusText, urgencyText, ageText) { + due := ultraDueValue(m, t.Due) + project := ultraOrDash(t.Project) + tags := ultraOrDash(strings.Join(t.Tags, " ")) + + // Priority badges render as 3-char wide pills in the styled path (Width(3) + // + Center). Pad the plain text to match so whole-line and styled paths + // produce the same visible text after ANSI stripping. + priorityPadded := priorityText + if t.Priority == "H" || t.Priority == "M" || t.Priority == "L" { + priorityPadded = " " + t.Priority + " " + } + + line := strings.Join([]string{ + idText, priorityPadded, statusText, urgencyText, + "due: " + due, "proj: " + project, "tags: " + tags, + }, " | ") + // Fall back to whole-line rendering when the regex spans a field separator + // or a full "key: value" pair (e.g. "proj: home") that can't be matched by + // checking label and value individually. + if re != nil && re.MatchString(line) && !ultraRegexMatchesAny(re, + idText, priorityText, statusText, urgencyText, due, project, tags, + ) { return m.renderUltraSearchLine(line, lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("253")), re, bg) } @@ -695,38 +703,11 @@ func (m *Model) renderUltraHeaderWithRegex(t task.Task, width int, re *regexp.Re priority := m.ultraStyledText(re, ultraPriorityStyle(m.theme, t.Priority), priorityText, priorityBG) status := m.ultraStyledText(re, lipgloss.NewStyle().Foreground(lipgloss.Color("246")), statusText, bg) urgency := m.ultraStyledText(re, lipgloss.NewStyle().Foreground(lipgloss.Color("214")), urgencyText, bg) - age := m.ultraStyledText(re, lipgloss.NewStyle().Foreground(lipgloss.Color("240")), ageText, bg) - return strings.Join([]string{id, priority, status, urgency, age}, sep) -} - -func (m *Model) renderUltraMetaWithRegex(t task.Task, width int, re *regexp.Regexp, bg string) string { - _ = width - project := ultraOrDash(t.Project) - tags := ultraOrDash(strings.Join(t.Tags, " ")) - due := ultraDueValue(m, t.Due) - recur := ultraOrDash(t.Recur) - start := ultraOrDash(m.formatTaskDate(t.Start)) - line := strings.Join( - []string{ - "project: " + project, - "tags: " + tags, - "due: " + due, - "recur: " + recur, - "start: " + start, - }, - " | ", - ) - if re != nil && re.MatchString(line) && !ultraRegexMatchesAny(re, "project:", project, "tags:", tags, "due:", due, "recur:", recur, "start:", start) { - return m.renderUltraSearchLine(line, lipgloss.NewStyle().Foreground(lipgloss.Color("253")), re, bg) - } - - sep := ultraFieldSep(bg) parts := []string{ - m.ultraKeyValue(re, "project", project, bg), - m.ultraKeyValue(re, "tags", tags, bg), + id, priority, status, urgency, m.ultraKeyValue(re, "due", due, bg), - m.ultraKeyValue(re, "recur", recur, bg), - m.ultraKeyValue(re, "start", start, bg), + m.ultraKeyValue(re, "proj", project, bg), + m.ultraKeyValue(re, "tags", tags, bg), } return strings.Join(parts, sep) } |
