diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-06 22:46:39 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-07 09:24:18 +0300 |
| commit | 896cfea3c06e7aa3247c80f175f96249b9bf5a88 (patch) | |
| tree | 506965c69002cd1ab34e96fd4e3bc4d2f89cc7fd | |
| parent | 4ddd1f139d4e7934c127f86c2dd2a142d2a44118 (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.go | 25 | ||||
| -rw-r--r-- | internal/ui/table.go | 300 | ||||
| -rw-r--r-- | internal/ui/table_test.go | 434 | ||||
| -rw-r--r-- | internal/ui/ultra.go | 358 |
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": |
