summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-22 14:07:54 +0300
committerPaul Buetow <paul@buetow.org>2026-06-22 14:07:54 +0300
commit6417a632d65486f44eb51b48855eeafb2b03da31 (patch)
tree26754d27265e1bad869836e9befe2664150dadfc
parentb83ee79cc9372f2fa51daf71757a1213328e1723 (diff)
tq0 refactor UI help rendering
-rw-r--r--internal/ui/help/help.go116
-rw-r--r--internal/ui/help/help_test.go75
-rw-r--r--internal/ui/table.go223
-rw-r--r--internal/ui/ultra.go93
4 files changed, 308 insertions, 199 deletions
diff --git a/internal/ui/help/help.go b/internal/ui/help/help.go
new file mode 100644
index 0000000..0d33279
--- /dev/null
+++ b/internal/ui/help/help.go
@@ -0,0 +1,116 @@
+package help
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "charm.land/lipgloss/v2"
+)
+
+// Item is a single key binding and description in a help section.
+type Item struct {
+ Key string
+ Desc string
+}
+
+// Section groups related help items under a title.
+type Section struct {
+ Title string
+ Items []Item
+}
+
+// Palette contains the colors needed to render help content.
+type Palette struct {
+ HeaderFG string
+ HeaderBG string
+ KeyFG string
+ DescFG string
+ SearchFG string
+ SearchBG string
+}
+
+// Render converts help sections into styled terminal content.
+func Render(sections []Section, palette Palette, search *regexp.Regexp) string {
+ headerStyle, keyStyle, descStyle := styles(palette)
+ 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, formatLine(item.Key, item.Desc, keyStyle, descStyle))
+ }
+ if i < len(sections)-1 {
+ lines = append(lines, "")
+ }
+ }
+
+ if search != nil {
+ for i, line := range lines {
+ if search.MatchString(line) {
+ lines[i] = highlightLine(line, palette, search)
+ }
+ }
+ }
+
+ return strings.Join(lines, "\n")
+}
+
+// Lines returns plain text lines for searchable help content.
+func Lines(sections []Section) []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 styles(palette Palette) (lipgloss.Style, lipgloss.Style, lipgloss.Style) {
+ headerStyle := lipgloss.NewStyle().
+ Bold(true).
+ Foreground(lipgloss.Color(palette.HeaderFG)).
+ Background(lipgloss.Color(palette.HeaderBG)).
+ Padding(0, 1)
+
+ keyStyle := lipgloss.NewStyle().
+ Bold(true).
+ Foreground(lipgloss.Color(palette.KeyFG))
+
+ descStyle := lipgloss.NewStyle().
+ Foreground(lipgloss.Color(palette.DescFG))
+
+ return headerStyle, keyStyle, descStyle
+}
+
+func formatLine(key, desc string, keyStyle, descStyle lipgloss.Style) string {
+ paddedKey := fmt.Sprintf("%-12s", key)
+ return keyStyle.Render(paddedKey) + " " + descStyle.Render(desc)
+}
+
+func highlightLine(line string, palette Palette, search *regexp.Regexp) string {
+ matches := search.FindAllStringIndex(line, -1)
+ if len(matches) == 0 {
+ return line
+ }
+
+ highlightStyle := lipgloss.NewStyle().
+ Background(lipgloss.Color(palette.SearchBG)).
+ Foreground(lipgloss.Color(palette.SearchFG))
+
+ highlighted := line
+ offset := 0
+ for _, match := range matches {
+ start := match[0] + offset
+ end := match[1] + offset
+ rendered := highlightStyle.Render(highlighted[start:end])
+ highlighted = highlighted[:start] + rendered + highlighted[end:]
+ offset += len(rendered) - (end - start)
+ }
+
+ return highlighted
+}
diff --git a/internal/ui/help/help_test.go b/internal/ui/help/help_test.go
new file mode 100644
index 0000000..79c3ec0
--- /dev/null
+++ b/internal/ui/help/help_test.go
@@ -0,0 +1,75 @@
+package help
+
+import (
+ "regexp"
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi"
+)
+
+func TestLinesFlattensSectionsForSearch(t *testing.T) {
+ sections := []Section{
+ {
+ Title: "Navigation",
+ Items: []Item{
+ {Key: "j", Desc: "move down"},
+ {Key: "k", Desc: "move up"},
+ },
+ },
+ {
+ Title: "General",
+ Items: []Item{
+ {Key: "q", Desc: "quit"},
+ },
+ },
+ }
+
+ got := Lines(sections)
+ want := []string{
+ "Navigation",
+ "j: move down",
+ "k: move up",
+ "",
+ "General",
+ "q: quit",
+ }
+
+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
+ t.Fatalf("Lines() mismatch\nwant:\n%s\ngot:\n%s", strings.Join(want, "\n"), strings.Join(got, "\n"))
+ }
+}
+
+func TestRenderHighlightsSearchMatches(t *testing.T) {
+ sections := []Section{
+ {
+ Title: "Search",
+ Items: []Item{
+ {Key: "/", Desc: "search tasks"},
+ {Key: "n", Desc: "next match"},
+ },
+ },
+ }
+ palette := Palette{
+ HeaderFG: "15",
+ HeaderBG: "8",
+ KeyFG: "14",
+ DescFG: "250",
+ SearchFG: "0",
+ SearchBG: "11",
+ }
+
+ base := Render(sections, palette, nil)
+ got := Render(sections, palette, regexp.MustCompile("search"))
+ if !strings.Contains(ansi.Strip(got), "search tasks") {
+ t.Fatalf("rendered help omitted matching item: %q", got)
+ }
+ if got == base {
+ t.Fatalf("search highlight did not change rendered help")
+ }
+
+ noMatch := Render(sections, palette, regexp.MustCompile("not-present"))
+ if noMatch != base {
+ t.Fatalf("non-matching search changed rendered help\nbase: %q\nnoMatch: %q", base, noMatch)
+ }
+}
diff --git a/internal/ui/table.go b/internal/ui/table.go
index f37d2ab..69d1481 100644
--- a/internal/ui/table.go
+++ b/internal/ui/table.go
@@ -21,6 +21,7 @@ import (
"codeberg.org/snonux/tasksamurai/internal"
atable "codeberg.org/snonux/tasksamurai/internal/atable"
"codeberg.org/snonux/tasksamurai/internal/task"
+ uihelp "codeberg.org/snonux/tasksamurai/internal/ui/help"
)
var priorityOptions = []string{"H", "M", "L", ""}
@@ -38,16 +39,6 @@ type cellMatch struct {
col int
}
-type helpItem struct {
- key string
- desc string
-}
-
-type helpSection struct {
- title string
- items []helpItem
-}
-
type undoRestore struct {
uuid string
status string
@@ -124,6 +115,12 @@ type ultraModeState struct {
ultraStartup bool
}
+// helpState holds help-screen visibility and viewport state.
+type helpState struct {
+ showHelp bool
+ helpViewport viewport.Model
+}
+
// shellState holds the Taskwarrior command prompt and captured output panel.
type shellState struct {
shellActive bool
@@ -185,7 +182,6 @@ type editState struct {
type Model struct {
tbl atable.Model
tblStyles atable.Styles
- showHelp bool
blinkState // row blink animation (see blinkState)
searchState // task-table and help-screen search (see searchState)
@@ -193,6 +189,7 @@ type Model struct {
ultraState // ultra mode task list and search state (see ultraState)
detailEditState // detail-overlay external description editor state
ultraModeState // ultra-mode lifecycle flags
+ helpState // help-screen viewport state
shellState // Taskwarrior command prompt and output panel
editState // inline field editing (see editState)
@@ -227,8 +224,6 @@ type Model struct {
statusMsg string // temporary status message shown in status bar
- helpViewport viewport.Model
-
taskContext context.Context
cancelTaskContext context.CancelFunc
}
@@ -857,7 +852,7 @@ func (m *Model) updateHelpContent() {
// buildHelpContent builds the help content
func (m Model) buildHelpContent() string {
- return m.buildRenderedHelpContent(m.helpSections())
+ return uihelp.Render(m.helpSections(), m.helpPalette(), m.helpSearchRegex)
}
// renderHelpScreen renders the help screen with optional search highlighting
@@ -883,43 +878,9 @@ func (m Model) renderHelpScreen() string {
return result
}
-// formatHelpLine formats a help line with key and description styling
-func (m Model) formatHelpLine(key, desc string, keyStyle, descStyle lipgloss.Style) string {
- // Pad key to consistent width for alignment
- paddedKey := fmt.Sprintf("%-12s", key)
- return keyStyle.Render(paddedKey) + " " + descStyle.Render(desc)
-}
-
-// highlightHelpLine applies search highlighting to a help line
-func (m Model) highlightHelpLine(line string) string {
- if m.helpSearchRegex == nil {
- return line
- }
-
- matches := m.helpSearchRegex.FindAllStringIndex(line, -1)
- if len(matches) == 0 {
- return line
- }
-
- highlighted := line
- offset := 0
- highlightStyle := lipgloss.NewStyle().
- Background(lipgloss.Color(m.theme.SearchBG)).
- Foreground(lipgloss.Color(m.theme.SearchFG))
-
- for _, match := range matches {
- start := match[0] + offset
- end := match[1] + offset
- highlighted = highlighted[:start] + highlightStyle.Render(highlighted[start:end]) + highlighted[end:]
- offset += len(highlightStyle.Render(highlighted[start:end])) - (end - start)
- }
-
- return highlighted
-}
-
// getHelpLines returns searchable help content as plain text lines
func (m Model) getHelpLines() []string {
- return flattenHelpSections(m.activeHelpSections())
+ return uihelp.Lines(m.activeHelpSections())
}
func (m Model) activeHelpContent() string {
@@ -929,140 +890,96 @@ func (m Model) activeHelpContent() string {
return m.buildHelpContent()
}
-func (m Model) activeHelpSections() []helpSection {
+func (m Model) activeHelpSections() []uihelp.Section {
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, "")
- }
+func (m Model) helpPalette() uihelp.Palette {
+ return uihelp.Palette{
+ HeaderFG: m.theme.HeaderFG,
+ HeaderBG: m.theme.SelectedBG,
+ KeyFG: m.theme.SelectedFG,
+ DescFG: "250",
+ SearchFG: m.theme.SearchFG,
+ SearchBG: m.theme.SearchBG,
}
-
- 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{
+func (m Model) helpSections() []uihelp.Section {
+ return []uihelp.Section{
{
- 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: "Navigation",
+ Items: []uihelp.Item{
+ {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: "D", desc: "delete task/recurring series"},
- {key: "U", desc: "undo last done/delete"},
- {key: "s", desc: "start/stop task"},
+ Title: "Task Management",
+ Items: []uihelp.Item{
+ {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: "D", Desc: "delete task/recurring series"},
+ {Key: "U", Desc: "undo last done/delete"},
+ {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: "Task Fields",
+ Items: []uihelp.Item{
+ {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: m.agentFilterHotkeyLabel(), desc: "toggle +agent/-agent filter"},
- {key: "f", desc: "change filter"},
- {key: ":", desc: "run task command prompt"},
- {key: ";", desc: "run task command prompt for selected task"},
- {key: "/, ?", desc: "search"},
- {key: "n, N", desc: "next/previous match"},
- {key: "space", desc: "refresh tasks"},
+ Title: "View & Search",
+ Items: []uihelp.Item{
+ {Key: m.agentFilterHotkeyLabel(), Desc: "toggle +agent/-agent filter"},
+ {Key: "f", Desc: "change filter"},
+ {Key: ":", Desc: "run task command prompt"},
+ {Key: ";", Desc: "run task command prompt for selected task"},
+ {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: "Appearance",
+ Items: []uihelp.Item{
+ {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"},
+ Title: "General",
+ Items: []uihelp.Item{
+ {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 {
status := fmt.Sprintf("Total:%d InProgress:%d Due:%d | press H for help", m.total, m.inProgress, m.due)
if m.statusMsg != "" {
diff --git a/internal/ui/ultra.go b/internal/ui/ultra.go
index 9545c6f..8234af2 100644
--- a/internal/ui/ultra.go
+++ b/internal/ui/ultra.go
@@ -11,6 +11,7 @@ import (
"codeberg.org/snonux/tasksamurai/internal"
"codeberg.org/snonux/tasksamurai/internal/task"
+ uihelp "codeberg.org/snonux/tasksamurai/internal/ui/help"
)
func (m *Model) renderUltraModus() string {
@@ -73,70 +74,70 @@ func (m *Model) ultraNoTasksMessage(width, budget int) string {
}
func (m Model) buildUltraHelpContent() string {
- return m.buildRenderedHelpContent(m.ultraHelpSections())
+ return uihelp.Render(m.ultraHelpSections(), m.helpPalette(), m.helpSearchRegex)
}
-func (m Model) ultraHelpSections() []helpSection {
- return []helpSection{
+func (m Model) ultraHelpSections() []uihelp.Section {
+ return []uihelp.Section{
{
- title: "Navigation",
- items: []helpItem{
- {key: "j, k", desc: "move down/up"},
- {key: "pgup, pgdn", desc: "page up/down"},
- {key: "g, G, 0", desc: "go to start/end"},
- {key: "space", desc: "refresh tasks"},
+ Title: "Navigation",
+ Items: []uihelp.Item{
+ {Key: "j, k", Desc: "move down/up"},
+ {Key: "pgup, pgdn", Desc: "page up/down"},
+ {Key: "g, G, 0", Desc: "go to start/end"},
+ {Key: "space", Desc: "refresh tasks"},
},
},
{
- title: "Task Management",
- items: []helpItem{
- {key: "Enter, e, E", desc: "edit selected task"},
- {key: "o", desc: "open URL from description"},
- {key: "s", desc: "start/stop task"},
- {key: "d", desc: "mark task done"},
- {key: "D", desc: "delete task/recurring series"},
- {key: "U", desc: "undo last done/delete"},
- {key: "+", desc: "add new task"},
+ Title: "Task Management",
+ Items: []uihelp.Item{
+ {Key: "Enter, e, E", Desc: "edit selected task"},
+ {Key: "o", Desc: "open URL from description"},
+ {Key: "s", Desc: "start/stop task"},
+ {Key: "d", Desc: "mark task done"},
+ {Key: "D", Desc: "delete task/recurring series"},
+ {Key: "U", Desc: "undo last done/delete"},
+ {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: "r", desc: "set random 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: m.agentFilterHotkeyLabel(), desc: "toggle +agent/-agent filter"},
- {key: "f", desc: "change filter"},
+ Title: "Task Fields",
+ Items: []uihelp.Item{
+ {Key: "p", Desc: "set priority"},
+ {Key: "w", Desc: "set due date"},
+ {Key: "W", Desc: "remove due date"},
+ {Key: "r", Desc: "set random 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: m.agentFilterHotkeyLabel(), Desc: "toggle +agent/-agent filter"},
+ {Key: "f", Desc: "change filter"},
},
},
{
- title: "Search",
- items: []helpItem{
- {key: "/", desc: "search ultra cards"},
- {key: "n, N", desc: "next/previous match"},
- {key: ":", desc: "run task command prompt"},
- {key: ";", desc: "run task command prompt for selected task"},
+ Title: "Search",
+ Items: []uihelp.Item{
+ {Key: "/", Desc: "search ultra cards"},
+ {Key: "n, N", Desc: "next/previous match"},
+ {Key: ":", Desc: "run task command prompt"},
+ {Key: ";", Desc: "run task command prompt for selected task"},
},
},
{
- title: "Appearance",
- items: []helpItem{
- {key: "c, C", desc: "random/reset theme"},
- {key: "x", desc: "toggle disco mode"},
- {key: "B", desc: "toggle blinking"},
+ Title: "Appearance",
+ Items: []uihelp.Item{
+ {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"},
+ Title: "General",
+ Items: []uihelp.Item{
+ {Key: "H", Desc: "toggle help"},
+ {Key: "esc", Desc: "close help/input or exit ultra mode"},
+ {Key: "q", Desc: "exit ultra mode"},
},
},
}