From 6417a632d65486f44eb51b48855eeafb2b03da31 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 22 Jun 2026 14:07:54 +0300 Subject: tq0 refactor UI help rendering --- internal/ui/help/help.go | 116 ++++++++++++++++++++++ internal/ui/help/help_test.go | 75 ++++++++++++++ internal/ui/table.go | 223 +++++++++++++----------------------------- internal/ui/ultra.go | 93 +++++++++--------- 4 files changed, 308 insertions(+), 199 deletions(-) create mode 100644 internal/ui/help/help.go create mode 100644 internal/ui/help/help_test.go 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"}, }, }, } -- cgit v1.2.3