From 79b440f6f7a9ed97ef45980513783f668f851cf0 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 20 Oct 2026 23:40:25 +0200 Subject: [PATCH] Help screen: wrap descriptions under themselves, size to the terminal Co-Authored-By: Claude Sonnet 5.5 --- internal/ui/app.go | 7 +- internal/ui/help.go | 196 +++++++++++++++++++++++++-------------- internal/ui/help_test.go | 62 +++++++++++++ 3 files changed, 192 insertions(+), 73 deletions(-) create mode 100644 internal/ui/help_test.go diff --git a/internal/ui/app.go b/internal/ui/app.go index 4781a84..48a25cc 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -391,6 +391,11 @@ func (a *App) pushOverlay(p, focus tview.Primitive, hints string, full bool, onC // openModal shows p centered above the current view. func (a *App) openModal(p tview.Primitive, width, height int) { + a.openModalHints(p, width, height, "↑↓ move · ←→ change · Enter select · Esc cancel") +} + +// openModalHints is openModal with its own status bar hints. +func (a *App) openModalHints(p tview.Primitive, width, height int, hints string) { box := tview.NewFlex(). AddItem(nil, 0, 1, false). AddItem(tview.NewFlex().SetDirection(tview.FlexRow). @@ -398,7 +403,7 @@ func (a *App) openModal(p tview.Primitive, width, height int) { AddItem(p, height, 0, true). AddItem(nil, 0, 1, false), width, 0, true). AddItem(nil, 0, 1, false) - a.pushOverlay(box, p, "↑↓ move · ←→ change · Enter select · Esc cancel", false, nil) + a.pushOverlay(box, p, hints, false, nil) } // openSidePanel shows p at the top right so most of the view behind it stays diff --git a/internal/ui/help.go b/internal/ui/help.go index 92d61ff..4669659 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -1,89 +1,140 @@ package ui import ( + "strings" + "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) -const helpText = `[yellow]Global[-] - F1-F7 switch view (also from the release and episode lists) - Tab, Shift+Tab switch panel within a view - F9, ? this help. F9 works everywhere; ? only when no text - field has the focus (press Esc first in a search box) - q, Ctrl+C quit - Esc close dialog / go back +// helpItem is one line of the key reference: the keys and what they do. +type helpItem struct{ keys, desc string } -[yellow]Movies and Series[-] - / focus the search box - Enter, a add the selected title (dialog: ↑↓ move, ←→ change a - dropdown, Space toggles, Enter/Space opens a list) - i interactive release search (library titles) +// helpSection is a titled group of items, with an optional note above them. +type helpSection struct { + title string + note string + items []helpItem +} -[yellow]Library[-] - Tab switch between the movies and series lists - f cycle filter (all, missing, downloaded, monitored, unmonitored); - every list has its own filters - F filter by file properties: quality, codec (x265, x264, AV1), - HDR / DV / SDR, audio (Atmos, TrueHD, DTS, DD+ ...), language. - Changes apply live behind the dialog, also while you move through an - open list; Esc undoes them. Options that would leave no - results are not offered. - Series are read on demand. - / text filter for the focused list only; each list keeps its - own text (Enter or Esc returns to the list) - t toggle monitored - d remove from the library (files are kept unless you tick - "Delete files") - s automatic search (series: all missing, asks first) - i, Enter movie: release search; series: seasons and episodes - r reload +var helpSections = []helpSection{ + {title: "Global", items: []helpItem{ + {"F1-F7", "switch view (also from the release and episode lists)"}, + {"Tab, Shift+Tab", "switch panel within a view"}, + {"F9, ?", "this help. F9 works everywhere; ? only when no text field has the focus (press Esc first in a search box)"}, + {"q, Ctrl+C", "quit"}, + {"Esc", "close a dialog / go back"}, + }}, + {title: "Movies and Series", items: []helpItem{ + {"/", "focus the search box"}, + {"Enter, a", "add the selected title (dialog: ↑↓ move, ←→ change a dropdown, Space toggles, Enter/Space opens a list)"}, + {"i", "interactive release search (library titles)"}, + }}, + {title: "Library", items: []helpItem{ + {"Tab", "switch between the movies and series lists"}, + {"f", "cycle the status filter (all, missing, downloaded, monitored, unmonitored); every list has its own filters"}, + {"F", "filter by file properties: quality, codec (x265, x264, AV1), HDR / DV / SDR, audio (Atmos, TrueHD, DTS, DD+ ...), language. Changes apply live behind the dialog, also while you move through an open list; Esc undoes them. Options that would leave no results are not offered. Series files are read on demand."}, + {"/", "text filter for the focused list only; each list keeps its own text (Enter or Esc returns to the list)"}, + {"t", "toggle monitored"}, + {"d", "remove from the library (files are kept unless you tick \"Delete files\")"}, + {"s", "automatic search (series: all missing, asks first)"}, + {"i, Enter", "movie: release search; series: seasons and episodes"}, + {"r", "reload"}, + }}, + {title: "Calendar (F5)", items: []helpItem{ + {"[ ]", "move the two-week window back / forward by a week"}, + {"t", "back to today"}, + {"u", "include unmonitored items"}, + {"Enter, i", "release search for the selected movie / episode"}, + {"s", "automatic search"}, + {"r", "reload"}, + }}, + {title: "Seasons and episodes", items: []helpItem{ + {"Enter", "season: expand / collapse; episode: release search"}, + {"i, s, t", "release search, automatic search, toggle monitored (on a season they apply to the whole season)"}, + }}, + {title: "Release list", items: []helpItem{ + {"Enter, g", "grab the selected release"}, + {"s", "cycle the sort (best, size, age)"}, + {"f", "hide rejected releases"}, + {"F", "filter by quality, codec, HDR / DV, audio, language (read from the release title, quality and custom formats)"}, + }}, + {title: "Downloads", items: []helpItem{ + {"p", "pause / resume the SABnzbd queue"}, + {"d", "delete the selected job or entry"}, + {"b", "remove from the Radarr/Sonarr queue and blocklist"}, + {"t", "retry a failed download (history)"}, + {"r", "refresh now"}, + }}, + {title: "Config (F6)", note: "Edit the settings of the config file: service addresses and API keys, add-form defaults, refresh interval, posters, notifications.", items: []helpItem{ + {"↑↓, Tab", "move between fields / to the next section"}, + {"Space", "toggle a checkbox"}, + {"Enter", "press a button: Test connections checks the three services with the values typed in; Save writes the file and applies everything at once; Revert discards edits"}, + {"q, ?", "are typed here as text; F9 opens this help"}, + }}, + {title: "Log (F7)", note: "Popups fade away by themselves; everything they said is kept here.", items: []helpItem{ + {"↑↓, PgUp, PgDn", "scroll"}, + {"G", "follow new entries"}, + {"c", "clear the log"}, + }}, +} -[yellow]Calendar (F5)[-] - [ ] move the two-week window back / forward by a week - t back to today - u include unmonitored items - Enter, i release search for the selected movie / episode - s automatic search r reload +const helpKeyWidth = 16 // columns for the keys, before the descriptions -[yellow]Seasons and episodes[-] - Enter season: expand/collapse; episode: release search - i, s, t release search, automatic search, toggle monitored - (on a season they apply to the whole season) - -[yellow]Release list[-] - Enter, g grab the selected release - s cycle sort (best, size, age) - f hide rejected releases - F filter by quality, codec, HDR / DV, audio, language (read from - the release title, quality and custom formats) - -[yellow]Downloads[-] - p pause / resume the SABnzbd queue - d delete the selected job or entry - b remove from arr queue + blocklist - t retry a failed download (history) - r refresh now - -[yellow]Config (F6)[-] - Edit the settings of the config file: service addresses and API keys, add-form - defaults, refresh interval, posters, notifications. - ↑↓ move, Tab next section, Space toggles, Enter presses a button - Test connections checks the three services with the values typed in - Save writes the file and applies everything at once; Revert discards edits - Letters such as q and ? are typed here; F9 opens this help - -[yellow]Log (F7)[-] - Popups fade away by themselves; everything they said is kept here. - ↑↓, PgUp/PgDn scroll G follow new entries c clear - -[yellow]Connections[-] -` +// renderHelp lays the key reference out for a text area of the given width: +// descriptions that do not fit wrap under themselves, not under the keys. +func renderHelp(width int) string { + var b strings.Builder + descWidth := max(width-2-helpKeyWidth, 20) + for i, sec := range helpSections { + if i > 0 { + b.WriteByte('\n') + } + b.WriteString("[yellow]" + sec.title + "[-]\n") + if sec.note != "" { + for _, l := range flowWrap(tview.Escape(sec.note), width-2, width-2, 0) { + b.WriteString(" " + l + "\n") + } + } + for _, it := range sec.items { + keys := tview.Escape(it.keys) + pad := strings.Repeat(" ", max(helpKeyWidth-tview.TaggedStringWidth(keys), 1)) + lines := flowWrap(tview.Escape(it.desc), descWidth, descWidth, 0) + for j, l := range lines { + if j == 0 { + b.WriteString(" " + keys + pad + l + "\n") + } else { + b.WriteString(strings.Repeat(" ", 2+helpKeyWidth) + l + "\n") + } + } + } + } + return b.String() +} // showHelp opens the key reference together with the connection state. func (a *App) showHelp() { - tv := tview.NewTextView().SetDynamicColors(true).SetScrollable(true) + screenW, screenH := 100, 40 + if a.screen != nil { + screenW, screenH = a.screen.Size() + } + width := min(100, screenW-4) + textWidth := width - 3 // border and a column of air + text := renderHelp(textWidth) + "\n[yellow]Connections[-]\n" + for _, l := range strings.Split(strings.TrimRight(a.serviceSummary(), "\n"), "\n") { + for j, w := range flowWrap(l, textWidth-2, textWidth-2, 0) { + if j == 0 { + text += " " + w + "\n" + } else { + text += " " + w + "\n" + } + } + } + + tv := tview.NewTextView().SetDynamicColors(true).SetScrollable(true).SetWrap(false) tv.SetBorder(true).SetTitle(" Help — Esc to close ") - tv.SetText(helpText + a.serviceSummary()) + tv.SetBorderPadding(0, 0, 1, 0) + tv.SetText(text) tv.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey { if ev.Key() == tcell.KeyEsc || (ev.Key() == tcell.KeyRune && (ev.Rune() == 'q' || ev.Rune() == '?')) { a.closeModal() @@ -91,5 +142,6 @@ func (a *App) showHelp() { } return ev }) - a.openModal(tv, 80, 40) + height := min(screenH-2, strings.Count(text, "\n")+2) + a.openModalHints(tv, width, height, "↑↓ PgUp PgDn scroll · Esc, q or ? close") } diff --git a/internal/ui/help_test.go b/internal/ui/help_test.go new file mode 100644 index 0000000..93c6c83 --- /dev/null +++ b/internal/ui/help_test.go @@ -0,0 +1,62 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/rivo/tview" +) + +func TestRenderHelpFitsAndAligns(t *testing.T) { + for _, width := range []int{40, 60, 76, 96} { + text := renderHelp(width) + var inItem bool + for i, line := range strings.Split(text, "\n") { + if w := tview.TaggedStringWidth(line); w > width { + t.Errorf("width %d, line %d is %d columns wide: %q", width, i, w, line) + } + plain := plainText(line) + // Descriptions wrap under themselves: a continuation line starts with + // spaces up to the description column, never at the left edge. + switch { + case plain == "": + inItem = false + case !strings.HasPrefix(plain, " "): + inItem = false // a section title + case strings.HasPrefix(plain, strings.Repeat(" ", 2+helpKeyWidth)) && strings.TrimSpace(plain) != "": + if !inItem { + t.Errorf("width %d: continuation without an item before it: %q", width, plain) + } + default: + inItem = true + } + } + // Nothing is lost. + for _, sec := range helpSections { + if !strings.Contains(text, sec.title) { + t.Errorf("width %d: section %q missing", width, sec.title) + } + for _, it := range sec.items { + first := strings.Fields(it.desc)[0] + if !strings.Contains(plainText(text), first) { + t.Errorf("width %d: description of %q lost", width, it.keys) + } + } + } + } +} + +func TestRenderHelpKeepsEveryWord(t *testing.T) { + desc := "Changes apply live behind the dialog, also while you move through an open list" + words := strings.Fields(desc) + text := plainText(renderHelp(50)) + // Join the wrapped lines of the long Library "F" description and look for every word in order. + pos := 0 + for _, w := range words { + i := strings.Index(text[pos:], w) + if i < 0 { + t.Fatalf("word %q missing or out of order", w) + } + pos += i + len(w) + } +}