summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-06 22:46:39 +0300
committerPaul Buetow <paul@buetow.org>2026-04-07 09:24:18 +0300
commit896cfea3c06e7aa3247c80f175f96249b9bf5a88 (patch)
tree506965c69002cd1ab34e96fd4e3bc4d2f89cc7fd
parent4ddd1f139d4e7934c127f86c2dd2a142d2a44118 (diff)
ui: add ultra mode help screen (yv)
Add buildUltraHelpContent() with hotkey sections for ultra mode. Wire H key in handleUltraMode() to show mode-aware help. Route help viewport to ultra-specific content when showUltra is active. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--internal/ui/keyhandlers.go25
-rw-r--r--internal/ui/table.go300
-rw-r--r--internal/ui/table_test.go434
-rw-r--r--internal/ui/ultra.go358
4 files changed, 930 insertions, 187 deletions
diff --git a/internal/ui/keyhandlers.go b/internal/ui/keyhandlers.go
index 13333aa..e9235cd 100644
--- a/internal/ui/keyhandlers.go
+++ b/internal/ui/keyhandlers.go
@@ -140,12 +140,22 @@ func (m *Model) handleToggleHelp() (tea.Model, tea.Cmd) {
}
m.helpViewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(height))
// Set the content immediately
- content := m.buildHelpContent()
- m.helpViewport.SetContent(content)
+ m.helpViewport.SetContent(m.activeHelpContent())
return m, nil
}
func (m *Model) handleQuitOrEscape() (tea.Model, tea.Cmd) {
+ if m.showHelp {
+ m.showHelp = false
+ // Clear help search state
+ m.helpSearchRegex = nil
+ m.helpSearchMatches = nil
+ m.helpSearchIndex = 0
+ m.helpSearchInput.SetValue("")
+ // Reset help viewport
+ m.helpViewport = viewport.Model{}
+ return m, nil
+ }
if m.showUltra {
m.ultraClearFocusedID()
m.showUltra = false
@@ -167,17 +177,6 @@ func (m *Model) handleQuitOrEscape() (tea.Model, tea.Cmd) {
m.updateTableHeight()
return m, nil
}
- if m.showHelp {
- m.showHelp = false
- // Clear help search state
- m.helpSearchRegex = nil
- m.helpSearchMatches = nil
- m.helpSearchIndex = 0
- m.helpSearchInput.SetValue("")
- // Reset help viewport
- m.helpViewport = viewport.Model{}
- return m, nil
- }
if m.searchRegex != nil {
m.searchRegex = nil
m.searchMatches = nil
diff --git a/internal/ui/table.go b/internal/ui/table.go
index 42487c1..b829501 100644
--- a/internal/ui/table.go
+++ b/internal/ui/table.go
@@ -33,6 +33,16 @@ type cellMatch struct {
col int
}
+type helpItem struct {
+ key string
+ desc string
+}
+
+type helpSection struct {
+ title string
+ items []helpItem
+}
+
// blinkState holds row-level blink animation state for the task table.
// A blink cycles the selected row's highlight on/off after a modification.
type blinkState struct {
@@ -434,7 +444,11 @@ func (m *Model) reload() error {
if len(m.searchMatches) > 0 {
m.searchIndex = 0
}
- m.rebuildUltraFiltered(ultraFilterIDs)
+ if m.ultraSearchRegex != nil {
+ m.ultraFiltered = m.ultraFilteredIndexes(m.ultraSearchRegex)
+ } else {
+ m.rebuildUltraFiltered(ultraFilterIDs)
+ }
if m.tbl.Columns() == nil {
m.tbl, m.tblStyles = m.newTable(rows)
@@ -483,7 +497,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleTaskDetailMode(msg)
}
+ if m.showHelp {
+ if handled, model, cmd := m.handleEditingModes(msg); handled {
+ return model, cmd
+ }
+ return m.handleNormalMode(msg)
+ }
+
if m.showUltra {
+ if m.ultraSearching {
+ return m.handleUltraSearchMode(msg)
+ }
if handled, model, cmd := m.handleEditingModes(msg); handled {
return model, cmd
}
@@ -519,7 +543,11 @@ func (m *Model) handleWindowResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
m.computeColumnWidths()
m.updateTableHeight()
if m.showUltra {
+ if m.ultraSearchRegex != nil {
+ m.ultraFiltered = m.ultraFilteredIndexes(m.ultraSearchRegex)
+ }
m.ultraEnsureVisible()
+ m.syncUltraTableSelection()
}
// Update help viewport if active
@@ -709,96 +737,12 @@ func (m Model) appendInlineInputOverlay(view string) string {
// updateHelpContent updates the help viewport content
func (m *Model) updateHelpContent() {
- content := m.buildHelpContent()
- m.helpViewport.SetContent(content)
+ m.helpViewport.SetContent(m.activeHelpContent())
}
// buildHelpContent builds the help content
func (m Model) buildHelpContent() string {
- // Create styles using theme colors
- headerStyle := lipgloss.NewStyle().
- Bold(true).
- Foreground(lipgloss.Color(m.theme.HeaderFG)).
- Background(lipgloss.Color(m.theme.SelectedBG)).
- Padding(0, 1)
-
- keyStyle := lipgloss.NewStyle().
- Bold(true).
- Foreground(lipgloss.Color(m.theme.SelectedFG))
-
- descStyle := lipgloss.NewStyle().
- Foreground(lipgloss.Color("250")) // Light gray for readability
-
- // Build help content with styled headers
- var sections []string
-
- // Navigation section
- sections = append(sections, headerStyle.Render("Navigation"),
- m.formatHelpLine("↑/k, ↓/j", "move up/down", keyStyle, descStyle),
- m.formatHelpLine("←/h, →/l", "move left/right", keyStyle, descStyle),
- m.formatHelpLine("0, g, Home", "go to start", keyStyle, descStyle),
- m.formatHelpLine("G, End", "go to end", keyStyle, descStyle),
- m.formatHelpLine("pgup/pgdn, b", "page up/down", keyStyle, descStyle),
- m.formatHelpLine("1", "jump to random task", keyStyle, descStyle),
- m.formatHelpLine("2", "jump to random task (no due date)", keyStyle, descStyle),
- "")
-
- // Task Management section
- sections = append(sections, headerStyle.Render("Task Management"),
- m.formatHelpLine("Enter", "view task details", keyStyle, descStyle),
- m.formatHelpLine("+", "add new task", keyStyle, descStyle),
- m.formatHelpLine("e, E", "edit entire task", keyStyle, descStyle),
- m.formatHelpLine("d", "mark task done", keyStyle, descStyle),
- m.formatHelpLine("U", "undo last done", keyStyle, descStyle),
- m.formatHelpLine("s", "start/stop task", keyStyle, descStyle),
- "")
-
- // Task Fields section
- sections = append(sections, headerStyle.Render("Task Fields"),
- m.formatHelpLine("i", "edit current field", keyStyle, descStyle),
- m.formatHelpLine("p", "set priority", keyStyle, descStyle),
- m.formatHelpLine("w, W", "set/remove due date", keyStyle, descStyle),
- m.formatHelpLine("r", "set random due date", keyStyle, descStyle),
- m.formatHelpLine("R", "edit recurrence", keyStyle, descStyle),
- m.formatHelpLine("t", "edit tags", keyStyle, descStyle),
- m.formatHelpLine("J", "edit project", keyStyle, descStyle),
- m.formatHelpLine("T", "convert first tag to project", keyStyle, descStyle),
- m.formatHelpLine("a, A", "add/replace annotations", keyStyle, descStyle),
- m.formatHelpLine("o", "open URL from description", keyStyle, descStyle),
- "")
-
- // View & Search section
- sections = append(sections, headerStyle.Render("View & Search"),
- m.formatHelpLine("f", "change filter", keyStyle, descStyle),
- m.formatHelpLine("/, ?", "search", keyStyle, descStyle),
- m.formatHelpLine("n, N", "next/previous match", keyStyle, descStyle),
- m.formatHelpLine("space", "refresh tasks", keyStyle, descStyle),
- "")
-
- // Appearance section
- sections = append(sections, headerStyle.Render("Appearance"),
- m.formatHelpLine("c, C", "random/reset theme", keyStyle, descStyle),
- m.formatHelpLine("x", "toggle disco mode", keyStyle, descStyle),
- m.formatHelpLine("B", "toggle blinking", keyStyle, descStyle),
- "")
-
- // General section
- sections = append(sections, headerStyle.Render("General"),
- m.formatHelpLine("H", "toggle help", keyStyle, descStyle),
- m.formatHelpLine("ESC", "close dialogs/cancel", keyStyle, descStyle),
- m.formatHelpLine("q", "quit", keyStyle, descStyle))
-
- // Apply search highlighting if active
- if m.helpSearchRegex != nil {
- for i, line := range sections {
- if m.helpSearchRegex.MatchString(line) {
- sections[i] = m.highlightHelpLine(line)
- }
- }
- }
-
- // Join all sections
- return strings.Join(sections, "\n")
+ return m.buildRenderedHelpContent(m.helpSections())
}
// renderHelpScreen renders the help screen with optional search highlighting
@@ -860,52 +804,144 @@ func (m Model) highlightHelpLine(line string) string {
// getHelpLines returns searchable help content as plain text lines
func (m Model) getHelpLines() []string {
- return []string{
- "Navigation",
- "↑/k, ↓/j: move up/down",
- "←/h, →/l: move left/right",
- "0, g, Home: go to start",
- "G, End: go to end",
- "pgup/pgdn, b: page up/down",
- "1: jump to random task",
- "2: jump to random task (no due date)",
- "",
- "Task Management",
- "Enter: view task details",
- "+: add new task",
- "e, E: edit entire task",
- "d: mark task done",
- "U: undo last done",
- "s: start/stop task",
- "",
- "Task Fields",
- "i: edit current field",
- "p: set priority",
- "w, W: set/remove due date",
- "r: set random due date",
- "R: edit recurrence",
- "t: edit tags",
- "J: edit project",
- "T: convert first tag to project",
- "a, A: add/replace annotations",
- "o: open URL from description",
- "",
- "View & Search",
- "f: change filter",
- "/, ?: search",
- "n, N: next/previous match",
- "space: refresh tasks",
- "",
- "Appearance",
- "c, C: random/reset theme",
- "x: toggle disco mode",
- "B: toggle blinking",
- "",
- "General",
- "H: toggle help",
- "ESC: close dialogs/cancel",
- "q: quit",
+ return flattenHelpSections(m.activeHelpSections())
+}
+
+func (m Model) activeHelpContent() string {
+ if m.showUltra {
+ return m.buildUltraHelpContent()
+ }
+ return m.buildHelpContent()
+}
+
+func (m Model) activeHelpSections() []helpSection {
+ if m.showUltra {
+ return m.ultraHelpSections()
+ }
+ return m.helpSections()
+}
+
+func (m Model) buildRenderedHelpContent(sections []helpSection) string {
+ headerStyle, keyStyle, descStyle := m.helpStyles()
+ lines := make([]string, 0, len(sections)*4)
+ for i, section := range sections {
+ lines = append(lines, headerStyle.Render(section.title))
+ for _, item := range section.items {
+ lines = append(lines, m.formatHelpLine(item.key, item.desc, keyStyle, descStyle))
+ }
+ if i < len(sections)-1 {
+ lines = append(lines, "")
+ }
+ }
+
+ if m.helpSearchRegex != nil {
+ for i, line := range lines {
+ if m.helpSearchRegex.MatchString(line) {
+ lines[i] = m.highlightHelpLine(line)
+ }
+ }
+ }
+
+ return strings.Join(lines, "\n")
+}
+
+func (m Model) helpStyles() (lipgloss.Style, lipgloss.Style, lipgloss.Style) {
+ headerStyle := lipgloss.NewStyle().
+ Bold(true).
+ Foreground(lipgloss.Color(m.theme.HeaderFG)).
+ Background(lipgloss.Color(m.theme.SelectedBG)).
+ Padding(0, 1)
+
+ keyStyle := lipgloss.NewStyle().
+ Bold(true).
+ Foreground(lipgloss.Color(m.theme.SelectedFG))
+
+ descStyle := lipgloss.NewStyle().
+ Foreground(lipgloss.Color("250"))
+
+ return headerStyle, keyStyle, descStyle
+}
+
+func (m Model) helpSections() []helpSection {
+ return []helpSection{
+ {
+ title: "Navigation",
+ items: []helpItem{
+ {key: "↑/k, ↓/j", desc: "move up/down"},
+ {key: "←/h, →/l", desc: "move left/right"},
+ {key: "0, g, Home", desc: "go to start"},
+ {key: "G, End", desc: "go to end"},
+ {key: "pgup/pgdn, b", desc: "page up/down"},
+ {key: "1", desc: "jump to random task"},
+ {key: "2", desc: "jump to random task (no due date)"},
+ },
+ },
+ {
+ title: "Task Management",
+ items: []helpItem{
+ {key: "Enter", desc: "view task details"},
+ {key: "+", desc: "add new task"},
+ {key: "e, E", desc: "edit entire task"},
+ {key: "d", desc: "mark task done"},
+ {key: "U", desc: "undo last done"},
+ {key: "s", desc: "start/stop task"},
+ },
+ },
+ {
+ title: "Task Fields",
+ items: []helpItem{
+ {key: "i", desc: "edit current field"},
+ {key: "p", desc: "set priority"},
+ {key: "w, W", desc: "set/remove due date"},
+ {key: "r", desc: "set random due date"},
+ {key: "R", desc: "edit recurrence"},
+ {key: "t", desc: "edit tags"},
+ {key: "J", desc: "edit project"},
+ {key: "T", desc: "convert first tag to project"},
+ {key: "a, A", desc: "add/replace annotations"},
+ {key: "o", desc: "open URL from description"},
+ },
+ },
+ {
+ title: "View & Search",
+ items: []helpItem{
+ {key: "f", desc: "change filter"},
+ {key: "/, ?", desc: "search"},
+ {key: "n, N", desc: "next/previous match"},
+ {key: "space", desc: "refresh tasks"},
+ },
+ },
+ {
+ title: "Appearance",
+ items: []helpItem{
+ {key: "c, C", desc: "random/reset theme"},
+ {key: "x", desc: "toggle disco mode"},
+ {key: "B", desc: "toggle blinking"},
+ },
+ },
+ {
+ title: "General",
+ items: []helpItem{
+ {key: "H", desc: "toggle help"},
+ {key: "ESC", desc: "close dialogs/cancel"},
+ {key: "q", desc: "quit"},
+ },
+ },
+ }
+}
+
+func flattenHelpSections(sections []helpSection) []string {
+ lines := make([]string, 0, len(sections)*4)
+ for i, section := range sections {
+ lines = append(lines, section.title)
+ for _, item := range section.items {
+ lines = append(lines, fmt.Sprintf("%s: %s", item.key, item.desc))
+ }
+ if i < len(sections)-1 {
+ lines = append(lines, "")
+ }
}
+ return lines
}
func (m Model) statusLine() string {
diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go
index bb9d0e1..2b86659 100644
--- a/internal/ui/table_test.go
+++ b/internal/ui/table_test.go
@@ -11,6 +11,7 @@ import (
"time"
tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/x/ansi"
)
func TestAnnotateHotkey(t *testing.T) {
@@ -624,6 +625,21 @@ func setupUltraTaskSet(t *testing.T, tmp string) string {
return taskPath
}
+func setupUltraSearchTaskSet(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\",\"project\":\"home\",\"tags\":[\"blue\"],\"status\":\"pending\",\"entry\":\"\",\"priority\":\"H\",\"urgency\":0,\"annotations\":[{\"entry\":\"\",\"description\":\"project note\"}]}'\n" +
+ " echo '{\"id\":2,\"uuid\":\"2\",\"description\":\"beta bravo\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n" +
+ " echo '{\"id\":3,\"uuid\":\"3\",\"description\":\"charlie delta\",\"tags\":[\"home\"],\"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 setupUltraReloadTaskSet(t *testing.T, tmp string) (string, string) {
taskPath := filepath.Join(tmp, "task")
phaseFile := filepath.Join(tmp, "phase")
@@ -653,6 +669,33 @@ func setupUltraReloadTaskSet(t *testing.T, tmp string) (string, string) {
return taskPath, phaseFile
}
+func setupUltraSearchReloadTaskSet(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\":\"alpha current\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"H\",\"urgency\":2}'\n"+
+ " echo '{\"id\":2,\"uuid\":\"2\",\"description\":\"beta current\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"L\",\"urgency\":1}'\n"+
+ " else\n"+
+ " echo '{\"id\":1,\"uuid\":\"1\",\"description\":\"omega current\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"H\",\"urgency\":2}'\n"+
+ " echo '{\"id\":2,\"uuid\":\"2\",\"description\":\"alpha moved\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"L\",\"urgency\":1}'\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" +
@@ -667,6 +710,20 @@ func setupBasicTask(t *testing.T, tmp string) string {
return taskPath
}
+func setupSharedSearchTaskSet(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\":\"x\",\"description\":\"shared alpha\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n" +
+ " echo '{\"id\":2,\"uuid\":\"y\",\"description\":\"shared beta\",\"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 setupEnv(t *testing.T, taskPath string) {
origPath := os.Getenv("PATH")
os.Setenv("PATH", filepath.Dir(taskPath)+":"+origPath)
@@ -705,6 +762,114 @@ func TestEscClosesHelp(t *testing.T) {
}
}
+func TestUltraHelpUsesUltraBindingsAndClosesBeforeLeavingUltra(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, _ := (&m).Update(tea.WindowSizeMsg{Width: 120, Height: 24})
+ 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)
+ if !m.showUltra {
+ t.Fatalf("u did not enter ultra mode")
+ }
+
+ mv, cmd = (&m).Update(tea.KeyPressMsg{Code: 'H', Text: "H"})
+ if cmd != nil {
+ t.Fatalf("H unexpectedly returned a command")
+ }
+ m = *mv.(*Model)
+ if !m.showHelp {
+ t.Fatalf("H did not open help in ultra mode")
+ }
+ if !m.showUltra {
+ t.Fatalf("opening help unexpectedly exited ultra mode")
+ }
+
+ view := ansi.Strip(m.activeHelpContent())
+ if !strings.Contains(view, "search ultra cards") {
+ t.Fatalf("ultra help content missing ultra search binding: %q", view)
+ }
+ if !strings.Contains(view, "exit ultra mode") {
+ t.Fatalf("ultra help content missing ultra exit binding: %q", view)
+ }
+ if strings.Contains(view, "open URL from description") {
+ t.Fatalf("ultra help rendered normal-mode binding: %q", view)
+ }
+ if strings.Contains(view, "edit current field") {
+ t.Fatalf("ultra help rendered normal-only inline edit binding: %q", view)
+ }
+
+ mv, cmd = (&m).Update(tea.KeyPressMsg{Code: tea.KeyEsc})
+ if cmd != nil {
+ t.Fatalf("esc while ultra help is open unexpectedly returned a command")
+ }
+ m = *mv.(*Model)
+ if m.showHelp {
+ t.Fatalf("esc did not close ultra help")
+ }
+ if !m.showUltra {
+ t.Fatalf("esc while ultra help is open unexpectedly exited ultra mode")
+ }
+
+ mv, cmd = (&m).Update(tea.KeyPressMsg{Code: tea.KeyEsc})
+ if cmd != nil {
+ t.Fatalf("second esc in ultra mode unexpectedly returned a command")
+ }
+ m = *mv.(*Model)
+ if m.showUltra {
+ t.Fatalf("second esc did not exit ultra mode")
+ }
+}
+
+func TestUltraHelpSearchUsesUltraHelpLines(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)
+ }
+
+ step := func(msg tea.Msg) {
+ t.Helper()
+ mv, _ := (&m).Update(msg)
+ m = *mv.(*Model)
+ }
+
+ step(tea.WindowSizeMsg{Width: 120, Height: 24})
+ step(tea.KeyPressMsg{Code: 'u', Text: "u"})
+ step(tea.KeyPressMsg{Code: 'H', Text: "H"})
+ step(tea.KeyPressMsg{Code: '/', Text: "/"})
+ for _, r := range "URL" {
+ step(tea.KeyPressMsg{Code: r, Text: string(r)})
+ }
+ step(tea.KeyPressMsg{Code: tea.KeyEnter})
+ if got := len(m.helpSearchMatches); got != 0 {
+ t.Fatalf("ultra help search matched normal help content, got %d matches", got)
+ }
+
+ step(tea.KeyPressMsg{Code: '/', Text: "/"})
+ for _, r := range "ultra" {
+ step(tea.KeyPressMsg{Code: r, Text: string(r)})
+ }
+ step(tea.KeyPressMsg{Code: tea.KeyEnter})
+ if got := len(m.helpSearchMatches); got == 0 {
+ t.Fatalf("ultra help search did not match ultra-specific help content")
+ }
+}
+
func TestUltraExitHotkeysClearUltraState(t *testing.T) {
tmp := t.TempDir()
taskPath := setupBasicTask(t, tmp)
@@ -786,6 +951,163 @@ func TestUltraExitHotkeysClearUltraState(t *testing.T) {
}
}
+func TestUltraSearchFiltersNavigatesAndHighlights(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath := setupUltraSearchTaskSet(t, tmp)
+ setupEnv(t, taskPath)
+
+ m, err := New(nil, "firefox")
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ var expected []int
+ for i, tsk := range m.tasks {
+ if tsk.Project == "home" || strings.Contains(strings.Join(tsk.Tags, " "), "home") {
+ expected = append(expected, i)
+ }
+ }
+ if len(expected) != 2 {
+ t.Fatalf("test setup failed: expected 2 matching tasks, got %d", len(expected))
+ }
+
+ step := func(msg tea.KeyPressMsg) {
+ t.Helper()
+ mv, _ := (&m).Update(msg)
+ m = *mv.(*Model)
+ }
+
+ step(tea.KeyPressMsg{Code: 'u', Text: "u"})
+ if !m.showUltra {
+ t.Fatalf("u did not enter ultra mode")
+ }
+
+ step(tea.KeyPressMsg{Code: '/', Text: "/"})
+ if !m.ultraSearching {
+ t.Fatalf("/ did not start ultra search")
+ }
+ for _, r := range "home" {
+ step(tea.KeyPressMsg{Code: r, Text: string(r)})
+ }
+ step(tea.KeyPressMsg{Code: tea.KeyEnter})
+
+ if m.ultraSearching {
+ t.Fatalf("enter did not close ultra search")
+ }
+ if m.ultraSearchRegex == nil {
+ t.Fatalf("enter did not compile ultra search regex")
+ }
+ if !reflect.DeepEqual(m.ultraFiltered, expected) {
+ t.Fatalf("unexpected ultraFiltered: got %#v want %#v", m.ultraFiltered, expected)
+ }
+ 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) {
+ t.Fatalf("ultra search text lost card line structure: %q", structured)
+ }
+
+ filtered := m.ultraTaskList()
+ if len(filtered) != 2 {
+ t.Fatalf("unexpected filtered task count: %d", len(filtered))
+ }
+ plain := m.renderUltraCard(filtered[0], 80, true, nil)
+ highlighted := m.renderUltraCard(filtered[0], 80, true, m.ultraSearchRegex)
+ if plain == highlighted {
+ t.Fatalf("search highlighting did not change rendered card")
+ }
+ if ansi.Strip(plain) != ansi.Strip(highlighted) {
+ t.Fatalf("search highlighting changed visible text")
+ }
+ labelHighlighted := m.renderUltraCard(filtered[0], 80, true, regexp.MustCompile(`project:`))
+ 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`))
+ if combinedHighlighted == plain {
+ t.Fatalf("combined meta search did not change rendered card")
+ }
+ if ansi.Strip(combinedHighlighted) != ansi.Strip(plain) {
+ t.Fatalf("combined meta search highlighting changed visible text")
+ }
+ priorityHighlighted := m.renderUltraCard(filtered[0], 80, true, regexp.MustCompile(`H`))
+ if priorityHighlighted == plain {
+ t.Fatalf("priority search did not change rendered card")
+ }
+ if ansi.Strip(priorityHighlighted) != ansi.Strip(plain) {
+ t.Fatalf("priority search highlighting changed visible text")
+ }
+
+ step(tea.KeyPressMsg{Code: 'n', Text: "n"})
+ if got := m.ultraTaskList()[m.ultraCursor].ID; got != filtered[1].ID {
+ t.Fatalf("n did not move to next filtered task: got %d want %d", got, filtered[1].ID)
+ }
+ step(tea.KeyPressMsg{Code: 'N', Text: "N"})
+ if got := m.ultraTaskList()[m.ultraCursor].ID; got != filtered[0].ID {
+ t.Fatalf("N did not move to previous filtered task: got %d want %d", got, filtered[0].ID)
+ }
+
+ prevFiltered := append([]int(nil), m.ultraFiltered...)
+ prevRegex := m.ultraSearchRegex
+ step(tea.KeyPressMsg{Code: '/', Text: "/"})
+ step(tea.KeyPressMsg{Code: '[', Text: "["})
+ if !m.ultraSearching {
+ t.Fatalf("invalid regex should keep ultra search open")
+ }
+ step(tea.KeyPressMsg{Code: tea.KeyEnter})
+ if !reflect.DeepEqual(m.ultraFiltered, prevFiltered) {
+ t.Fatalf("invalid regex changed filtered tasks: got %#v want %#v", m.ultraFiltered, prevFiltered)
+ }
+ if m.ultraSearchRegex != prevRegex {
+ t.Fatalf("invalid regex changed active regex: got %#v want %#v", m.ultraSearchRegex, prevRegex)
+ }
+ step(tea.KeyPressMsg{Code: tea.KeyEsc})
+
+ step(tea.KeyPressMsg{Code: '/', Text: "/"})
+ for _, r := range "annotation" {
+ step(tea.KeyPressMsg{Code: r, Text: string(r)})
+ }
+ step(tea.KeyPressMsg{Code: tea.KeyEnter})
+ if got := len(m.ultraTaskList()); got != 0 {
+ t.Fatalf("search matched invisible annotation label, got %d tasks", got)
+ }
+
+ step(tea.KeyPressMsg{Code: '/', Text: "/"})
+ for _, r := range "beta bravo" {
+ step(tea.KeyPressMsg{Code: r, Text: string(r)})
+ }
+ step(tea.KeyPressMsg{Code: tea.KeyEnter})
+ if got := len(m.ultraTaskList()); got != 1 {
+ t.Fatalf("multi-word ultra search match count = %d, want 1", got)
+ }
+ if got := m.ultraTaskList()[0].ID; got != 2 {
+ t.Fatalf("multi-word ultra search matched task %d, want 2", got)
+ }
+ mv, _ := (&m).Update(tea.WindowSizeMsg{Width: 24, Height: 24})
+ m = *mv.(*Model)
+ if got := len(m.ultraTaskList()); got != 1 {
+ t.Fatalf("resize changed multi-word ultra search match count = %d, want 1", got)
+ }
+ if got := m.ultraTaskList()[0].ID; got != 2 {
+ t.Fatalf("resize changed multi-word ultra search match to task %d, want 2", got)
+ }
+
+ step(tea.KeyPressMsg{Code: '/', Text: "/"})
+ step(tea.KeyPressMsg{Code: tea.KeyEnter})
+ if m.ultraSearchRegex != nil {
+ t.Fatalf("empty ultra search did not clear regex")
+ }
+ if m.ultraFiltered != nil {
+ t.Fatalf("empty ultra search did not clear filtered tasks")
+ }
+ if got := len(m.ultraTaskList()); got != len(m.tasks) {
+ t.Fatalf("empty ultra search did not restore all tasks, got %d want %d", got, len(m.tasks))
+ }
+}
+
func TestUltraFocusedIDLifecycleAcrossNormalEditEntryAndReload(t *testing.T) {
tmp := t.TempDir()
taskPath := setupBasicTask(t, tmp)
@@ -855,6 +1177,61 @@ func TestUltraFocusedIDLifecycleAcrossNormalEditEntryAndReload(t *testing.T) {
}
}
+func TestUltraSearchReloadRebuildsMatches(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath, phaseFile := setupUltraSearchReloadTaskSet(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, _ := (&m).Update(msg)
+ m = *mv.(*Model)
+ }
+
+ step(tea.KeyPressMsg{Code: 'u', Text: "u"})
+ step(tea.KeyPressMsg{Code: '/', Text: "/"})
+ for _, r := range "alpha" {
+ step(tea.KeyPressMsg{Code: r, Text: string(r)})
+ }
+ step(tea.KeyPressMsg{Code: tea.KeyEnter})
+
+ if got := len(m.ultraTaskList()); got != 1 {
+ t.Fatalf("initial search match count = %d, want 1", got)
+ }
+ if got := m.ultraTaskList()[0].ID; got != 1 {
+ t.Fatalf("initial search matched task %d, want 1", got)
+ }
+
+ if err := os.WriteFile(phaseFile, []byte("2"), 0o644); err != nil {
+ t.Fatalf("WriteFile phase: %v", err)
+ }
+ if err := m.reload(); err != nil {
+ t.Fatalf("reload: %v", err)
+ }
+
+ expectedRow := m.taskIndexByID(2)
+ if expectedRow < 0 {
+ t.Fatalf("reloaded tasks missing task 2")
+ }
+ if !reflect.DeepEqual(m.ultraFiltered, []int{expectedRow}) {
+ t.Fatalf("reload did not rebuild search matches: got %#v want %#v", m.ultraFiltered, []int{expectedRow})
+ }
+ if got := len(m.ultraTaskList()); got != 1 {
+ t.Fatalf("reloaded search match count = %d, want 1", got)
+ }
+ if got := m.ultraTaskList()[m.ultraCursor].ID; got != 2 {
+ t.Fatalf("reload kept stale search match %d, want 2", got)
+ }
+ if got := m.tbl.Cursor(); got != expectedRow {
+ t.Fatalf("table cursor after search reload = %d, want %d", got, expectedRow)
+ }
+}
+
func TestUltraEntryResizeAndNavigationBindings(t *testing.T) {
tmp := t.TempDir()
taskPath := setupUltraTaskSet(t, tmp)
@@ -1232,3 +1609,60 @@ func TestSearchExitHotkeys(t *testing.T) {
t.Fatalf("q did not clear search")
}
}
+
+func TestUltraResizeSyncRefreshesNormalSearchSelection(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath := setupSharedSearchTaskSet(t, tmp)
+ setupEnv(t, taskPath)
+
+ m, err := New(nil, "firefox")
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ step := func(msg tea.Msg) {
+ t.Helper()
+ mv, _ := (&m).Update(msg)
+ m = *mv.(*Model)
+ }
+
+ step(tea.WindowSizeMsg{Width: 120, Height: 24})
+ step(tea.KeyPressMsg{Code: '/', Text: "/"})
+ for _, r := range "shared" {
+ step(tea.KeyPressMsg{Code: r, Text: string(r)})
+ }
+ step(tea.KeyPressMsg{Code: tea.KeyEnter})
+ if m.searchRegex == nil {
+ t.Fatalf("normal search regex not set")
+ }
+ if got := m.tbl.Cursor(); got != 0 {
+ t.Fatalf("initial search cursor = %d, want 0", got)
+ }
+ if got := m.tbl.ColumnCursor(); got != 8 {
+ t.Fatalf("initial search column = %d, want 8", got)
+ }
+
+ step(tea.KeyPressMsg{Code: 'u', Text: "u"})
+ if !m.showUltra {
+ t.Fatalf("u did not enter ultra mode")
+ }
+ step(tea.KeyPressMsg{Code: 'j', Text: "j"})
+ if got := m.ultraCursor; got != 1 {
+ t.Fatalf("ultra cursor = %d, want 1", got)
+ }
+
+ step(tea.WindowSizeMsg{Width: 100, Height: 24})
+ if got := m.tbl.Cursor(); got != 1 {
+ t.Fatalf("hidden table cursor after ultra resize = %d, want 1", got)
+ }
+
+ rows := m.tbl.Rows()
+ wantPrev := m.taskToRowSearch(m.tasks[0], m.searchRegex, m.tblStyles, -1)
+ wantNew := m.taskToRowSearch(m.tasks[1], m.searchRegex, m.tblStyles, m.tbl.ColumnCursor())
+ if !reflect.DeepEqual(rows[0], wantPrev) {
+ t.Fatalf("previous row retained stale search selection after ultra resize")
+ }
+ if !reflect.DeepEqual(rows[1], wantNew) {
+ t.Fatalf("new row did not receive refreshed search selection after ultra resize")
+ }
+}
diff --git a/internal/ui/ultra.go b/internal/ui/ultra.go
index 74d6c8c..e7641e1 100644
--- a/internal/ui/ultra.go
+++ b/internal/ui/ultra.go
@@ -39,6 +39,69 @@ func (m *Model) renderUltraModus() string {
return strings.Join(lines, "\n")
}
+func (m Model) buildUltraHelpContent() string {
+ return m.buildRenderedHelpContent(m.ultraHelpSections())
+}
+
+func (m Model) ultraHelpSections() []helpSection {
+ return []helpSection{
+ {
+ title: "Navigation",
+ items: []helpItem{
+ {key: "j, k", desc: "move down/up"},
+ {key: "pgup, pgdn", desc: "page up/down"},
+ {key: "g, G", desc: "go to start/end"},
+ },
+ },
+ {
+ title: "Task Management",
+ items: []helpItem{
+ {key: "Enter, e, E", desc: "edit selected task"},
+ {key: "s", desc: "start/stop task"},
+ {key: "d", desc: "mark task done"},
+ {key: "U", desc: "undo last done"},
+ {key: "+", desc: "add new task"},
+ },
+ },
+ {
+ title: "Task Fields",
+ items: []helpItem{
+ {key: "p", desc: "set priority"},
+ {key: "w", desc: "set due date"},
+ {key: "W", desc: "remove due date"},
+ {key: "t", desc: "edit tags"},
+ {key: "a, A", desc: "add/replace annotations"},
+ {key: "J", desc: "edit project"},
+ {key: "R", desc: "edit recurrence"},
+ {key: "f", desc: "change filter"},
+ },
+ },
+ {
+ title: "Search",
+ items: []helpItem{
+ {key: "/", desc: "search ultra cards"},
+ {key: "n, N", desc: "next/previous match"},
+ },
+ },
+ {
+ title: "Appearance",
+ items: []helpItem{
+ {key: "c, C", desc: "random/reset theme"},
+ {key: "x", desc: "toggle disco mode"},
+ {key: "B", desc: "toggle blinking"},
+ },
+ },
+ {
+ title: "General",
+ items: []helpItem{
+ {key: "H", desc: "toggle help"},
+ {key: "esc", desc: "close help/input or exit ultra mode"},
+ {key: "q", desc: "exit ultra mode"},
+ },
+ },
+ }
+}
+
func (m *Model) ultraRenderWidth() int {
width := m.tbl.Width()
if width <= 0 {
@@ -227,13 +290,35 @@ func (m *Model) reconcileUltraSelection() {
}
if m.ultraFocusedID > 0 {
- _ = m.selectTaskByID(m.ultraFocusedID)
+ if m.ultraTaskIndexByID(m.ultraFocusedID) >= 0 {
+ _ = m.selectTaskByID(m.ultraFocusedID)
+ m.ultraFocusedID = 0
+ m.ultraEnsureVisible()
+ return
+ }
m.ultraFocusedID = 0
- m.ultraEnsureVisible()
- return
}
m.ultraEnsureVisible()
+ m.syncUltraTableSelection()
+}
+
+func (m *Model) syncUltraTableSelection() {
+ tasks := m.ultraTaskList()
+ cursor := m.ultraVisibleCursor(tasks)
+ if cursor < 0 {
+ return
+ }
+
+ row := m.taskIndexByID(tasks[cursor].ID)
+ if row < 0 {
+ return
+ }
+
+ prevRow := m.tbl.Cursor()
+ prevCol := m.tbl.ColumnCursor()
+ m.tbl.SetCursor(row)
+ m.updateSelectionHighlight(prevRow, row, prevCol, m.tbl.ColumnCursor())
}
func (m *Model) ultraOverlay() (string, int) {
@@ -304,8 +389,14 @@ func (m Model) ultraStatusLine(text string, width int) string {
func (m *Model) ultraModeStatus(tasks []task.Task) string {
filter := "all"
- if query := strings.TrimSpace(m.ultraSearchInput.Value()); query != "" {
- filter = query
+ if m.ultraSearching {
+ if query := strings.TrimSpace(m.ultraSearchInput.Value()); query != "" {
+ filter = query
+ } else if m.ultraSearchRegex != nil {
+ filter = m.ultraSearchRegex.String()
+ } else if m.ultraFiltered != nil {
+ filter = "filtered"
+ }
} else if m.ultraSearchRegex != nil {
filter = m.ultraSearchRegex.String()
} else if m.ultraFiltered != nil {
@@ -314,6 +405,67 @@ func (m *Model) ultraModeStatus(tasks []task.Task) string {
return fmt.Sprintf("ULTRA MODE | filter: %s | %d tasks", filter, len(tasks))
}
+func (m *Model) ultraSearchText(t task.Task) string {
+ return ultraJoinSections(
+ m.ultraHeaderText(t),
+ m.ultraMetaText(t),
+ ultraOrDash(strings.TrimSpace(t.Description)),
+ m.ultraAnnotationsSearchText(t),
+ )
+}
+
+func (m *Model) ultraFilteredIndexes(re *regexp.Regexp) []int {
+ if re == nil {
+ return nil
+ }
+
+ indexes := make([]int, 0, len(m.tasks))
+ for i, t := range m.tasks {
+ if re.MatchString(m.ultraSearchText(t)) {
+ indexes = append(indexes, i)
+ }
+ }
+ return indexes
+}
+
+func (m *Model) handleUltraSearchMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+ onEnter := func(value string) error {
+ if value == "" {
+ m.ultraSearchRegex = nil
+ m.ultraFiltered = nil
+ m.ultraCursor = 0
+ m.ultraOffset = 0
+ return nil
+ }
+
+ re, err := compileAndCacheRegex(value)
+ if err != nil {
+ return err
+ }
+
+ m.ultraSearchRegex = re
+ m.ultraFiltered = m.ultraFilteredIndexes(re)
+ m.ultraCursor = 0
+ m.ultraOffset = 0
+ return nil
+ }
+
+ onExit := func() {
+ m.ultraSearching = false
+ m.ultraSearchInput.SetValue("")
+ }
+
+ return m.handleTextInput(msg, &m.ultraSearchInput, onEnter, onExit)
+}
+
+func (m *Model) ultraMoveSearchMatch(delta int) {
+ if len(m.ultraFiltered) == 0 {
+ return
+ }
+
+ m.ultraMoveCursor(delta)
+}
+
func (m *Model) ultraCursorStatus(tasks []task.Task) string {
cursor := m.ultraVisibleCursor(tasks)
if cursor < 0 {
@@ -322,6 +474,85 @@ 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 {
+ 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)),
+ },
+ " | ",
+ )
+}
+
+func (m *Model) ultraDescriptionLines(t task.Task, width int) []string {
+ text := t.Description
+ if text == "" {
+ text = "-"
+ }
+
+ lines := wordWrap(text, ultraBodyWidth(width))
+ for i, line := range lines {
+ lines[i] = " " + line
+ }
+ return lines
+}
+
+func (m *Model) ultraDescriptionText(t task.Task, width int) string {
+ return strings.Join(m.ultraDescriptionLines(t, width), "\n")
+}
+
+func (m *Model) ultraAnnotationsLines(t task.Task, width int) []string {
+ if len(t.Annotations) == 0 {
+ return nil
+ }
+
+ bodyWidth := ultraBodyWidth(width)
+ var lines []string
+ for _, ann := range t.Annotations {
+ text := fmt.Sprintf("[%s] %s", m.formatTaskDate(ann.Entry), ultraOrDash(strings.TrimSpace(ann.Description)))
+ for i, line := range wordWrap(text, bodyWidth) {
+ if i > 0 {
+ line = " " + line
+ }
+ lines = append(lines, line)
+ }
+ }
+ return lines
+}
+
+func (m *Model) ultraAnnotationsText(t task.Task, width int) string {
+ return strings.Join(m.ultraAnnotationsLines(t, width), "\n")
+}
+
+func (m *Model) ultraAnnotationsSearchText(t task.Task) string {
+ if len(t.Annotations) == 0 {
+ return ""
+ }
+
+ lines := make([]string, 0, len(t.Annotations))
+ for _, ann := range t.Annotations {
+ line := fmt.Sprintf("[%s] %s", m.formatTaskDate(ann.Entry), ultraOrDash(strings.TrimSpace(ann.Description)))
+ lines = append(lines, line)
+ }
+ return strings.Join(lines, "\n")
+}
+
// renderUltraCard assembles the card sections and applies the outer selection style.
func (m *Model) renderUltraCard(t task.Task, width int, selected bool, re *regexp.Regexp) string {
card := ultraJoinSections(
@@ -364,63 +595,79 @@ func (m *Model) renderUltraAnnotations(t task.Task, width int) string {
func (m *Model) renderUltraHeaderWithRegex(t task.Task, width int, re *regexp.Regexp) string {
_ = width
- id := m.ultraStyledText(re, lipgloss.NewStyle().Bold(true), fmt.Sprintf("#%d", t.ID))
- priority := ultraPriorityToken(m.theme, t.Priority)
- status := m.ultraStyledText(re, lipgloss.NewStyle().Foreground(lipgloss.Color("252")), ultraOrDash(t.Status))
- urgency := m.ultraStyledText(re, lipgloss.NewStyle().Foreground(lipgloss.Color("252")), fmt.Sprintf("%.1f", t.Urgency))
- age := m.ultraStyledText(re, lipgloss.NewStyle().Foreground(lipgloss.Color("252")), ultraTaskAge(t.Entry))
+ 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) {
+ return m.renderUltraSearchLine(line, lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")), re)
+ }
+
+ id := m.ultraStyledText(re, lipgloss.NewStyle().Bold(true), idText)
+ priority := m.ultraStyledText(re, ultraPriorityStyle(m.theme, t.Priority), priorityText)
+ status := m.ultraStyledText(re, lipgloss.NewStyle().Foreground(lipgloss.Color("252")), statusText)
+ urgency := m.ultraStyledText(re, lipgloss.NewStyle().Foreground(lipgloss.Color("252")), urgencyText)
+ age := m.ultraStyledText(re, lipgloss.NewStyle().Foreground(lipgloss.Color("252")), ageText)
return strings.Join([]string{id, priority, status, urgency, age}, " | ")
}
func (m *Model) renderUltraMetaWithRegex(t task.Task, width int, re *regexp.Regexp) 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("252")), re)
+ }
+
parts := []string{
- m.ultraKeyValue(re, "project", ultraOrDash(t.Project)),
- m.ultraKeyValue(re, "tags", ultraOrDash(strings.Join(t.Tags, " "))),
- m.ultraKeyValue(re, "due", ultraDueValue(m, t.Due)),
- m.ultraKeyValue(re, "recur", ultraOrDash(t.Recur)),
- m.ultraKeyValue(re, "start", ultraOrDash(m.formatTaskDate(t.Start))),
+ m.ultraKeyValue(re, "project", project),
+ m.ultraKeyValue(re, "tags", tags),
+ m.ultraKeyValue(re, "due", due),
+ m.ultraKeyValue(re, "recur", recur),
+ m.ultraKeyValue(re, "start", start),
}
return strings.Join(parts, " | ")
}
func (m *Model) renderUltraDescriptionWithRegex(t task.Task, width int, re *regexp.Regexp) string {
- bodyWidth := ultraBodyWidth(width)
style := lipgloss.NewStyle().Foreground(lipgloss.Color("250"))
- text := t.Description
- if text == "" {
- text = "-"
- }
-
var lines []string
- for _, line := range wordWrap(text, bodyWidth) {
+ for _, line := range m.ultraDescriptionLines(t, width) {
if re != nil && re.MatchString(line) {
line = m.highlightMatches(line, re)
}
- lines = append(lines, style.Render(" "+line))
+ lines = append(lines, style.Render(line))
}
return strings.Join(lines, "\n")
}
func (m *Model) renderUltraAnnotationsWithRegex(t task.Task, width int, re *regexp.Regexp) string {
- if len(t.Annotations) == 0 {
+ lines := m.ultraAnnotationsLines(t, width)
+ if len(lines) == 0 {
return ""
}
- bodyWidth := ultraBodyWidth(width)
style := lipgloss.NewStyle().Foreground(lipgloss.Color("248"))
- var lines []string
- for _, ann := range t.Annotations {
- text := fmt.Sprintf("[%s] %s", m.formatTaskDate(ann.Entry), ultraOrDash(strings.TrimSpace(ann.Description)))
- for i, line := range wordWrap(text, bodyWidth) {
- if re != nil && re.MatchString(line) {
- line = m.highlightMatches(line, re)
- }
- if i > 0 {
- line = " " + line
- }
- lines = append(lines, style.Render(line))
+ for i, line := range lines {
+ if re != nil && re.MatchString(line) {
+ line = m.highlightMatches(line, re)
}
+ lines[i] = style.Render(line)
}
return strings.Join(lines, "\n")
}
@@ -468,10 +715,17 @@ func (m *Model) ultraStyledText(re *regexp.Regexp, style lipgloss.Style, text st
return style.Render(ultraOrDash(text))
}
+func (m *Model) renderUltraSearchLine(text string, style lipgloss.Style, re *regexp.Regexp) string {
+ if re != nil && re.MatchString(text) {
+ text = m.highlightMatches(text, re)
+ }
+ return style.Render(text)
+}
+
func (m *Model) ultraKeyValue(re *regexp.Regexp, label, value string) string {
labelStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(m.theme.HeaderFG))
valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
- return labelStyle.Render(label+":") + " " + m.ultraStyledText(re, valueStyle, value)
+ return m.ultraStyledText(re, labelStyle, label+":") + " " + m.ultraStyledText(re, valueStyle, value)
}
func ultraCardStyle(theme Theme, width int, selected, blink bool) lipgloss.Style {
@@ -485,10 +739,7 @@ func ultraCardStyle(theme Theme, width int, selected, blink bool) lipgloss.Style
return style
}
-func ultraPriorityToken(theme Theme, priority string) string {
- if priority == "" {
- return "-"
- }
+func ultraPriorityStyle(theme Theme, priority string) lipgloss.Style {
style := lipgloss.NewStyle().Width(1)
switch priority {
case "H":
@@ -498,7 +749,7 @@ func ultraPriorityToken(theme Theme, priority string) string {
case "L":
style = style.Background(lipgloss.Color(theme.PrioLowBG))
}
- return style.Render(priority)
+ return style
}
func ultraJoinSections(sections ...string) string {
@@ -515,6 +766,18 @@ func ultraJoinSections(sections ...string) string {
return strings.Join(parts, "\n")
}
+func ultraRegexMatchesAny(re *regexp.Regexp, parts ...string) bool {
+ if re == nil {
+ return false
+ }
+ for _, part := range parts {
+ if re.MatchString(part) {
+ return true
+ }
+ }
+ return false
+}
+
func ultraBodyWidth(width int) int {
if width <= 2 {
return 20
@@ -607,12 +870,23 @@ func (m *Model) ultraEnsureVisible() {
// handleUltraMode handles keyboard input in ultra mode.
func (m *Model) handleUltraMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
+ case "H":
+ return m.handleToggleHelp()
case "q", "esc":
return m.handleQuitOrEscape()
+ case "/":
+ m.ultraSearching = true
+ m.ultraSearchInput.SetValue("")
+ m.ultraSearchInput.Focus()
+ return m, nil
case "j", "down":
m.ultraMoveCursor(1)
case "k", "up":
m.ultraMoveCursor(-1)
+ case "n":
+ m.ultraMoveSearchMatch(1)
+ case "N":
+ m.ultraMoveSearchMatch(-1)
case "pgdn", "pgdown", "space":
m.ultraMoveCursor(m.ultraVisibleCount())
case "pgup", "b":