Help screen: wrap descriptions under themselves, size to the terminal

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 23:40:25 +02:00
co-authored by Claude Sonnet 5.5
parent 394e78ec6a
commit 79b440f6f7
3 changed files with 192 additions and 73 deletions
+6 -1
View File
@@ -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. // openModal shows p centered above the current view.
func (a *App) openModal(p tview.Primitive, width, height int) { 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(). box := tview.NewFlex().
AddItem(nil, 0, 1, false). AddItem(nil, 0, 1, false).
AddItem(tview.NewFlex().SetDirection(tview.FlexRow). 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(p, height, 0, true).
AddItem(nil, 0, 1, false), width, 0, true). AddItem(nil, 0, 1, false), width, 0, true).
AddItem(nil, 0, 1, false) 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 // openSidePanel shows p at the top right so most of the view behind it stays
+124 -72
View File
@@ -1,89 +1,140 @@
package ui package ui
import ( import (
"strings"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"github.com/rivo/tview" "github.com/rivo/tview"
) )
const helpText = `[yellow]Global[-] // helpItem is one line of the key reference: the keys and what they do.
F1-F7 switch view (also from the release and episode lists) type helpItem struct{ keys, desc string }
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
[yellow]Movies and Series[-] // helpSection is a titled group of items, with an optional note above them.
/ focus the search box type helpSection struct {
Enter, a add the selected title (dialog: ↑↓ move, ←→ change a title string
dropdown, Space toggles, Enter/Space opens a list) note string
i interactive release search (library titles) items []helpItem
}
[yellow]Library[-] var helpSections = []helpSection{
Tab switch between the movies and series lists {title: "Global", items: []helpItem{
f cycle filter (all, missing, downloaded, monitored, unmonitored); {"F1-F7", "switch view (also from the release and episode lists)"},
every list has its own filters {"Tab, Shift+Tab", "switch panel within a view"},
F filter by file properties: quality, codec (x265, x264, AV1), {"F9, ?", "this help. F9 works everywhere; ? only when no text field has the focus (press Esc first in a search box)"},
HDR / DV / SDR, audio (Atmos, TrueHD, DTS, DD+ ...), language. {"q, Ctrl+C", "quit"},
Changes apply live behind the dialog, also while you move through an {"Esc", "close a dialog / go back"},
open list; Esc undoes them. Options that would leave no }},
results are not offered. {title: "Movies and Series", items: []helpItem{
Series are read on demand. {"/", "focus the search box"},
/ text filter for the focused list only; each list keeps its {"Enter, a", "add the selected title (dialog: ↑↓ move, ←→ change a dropdown, Space toggles, Enter/Space opens a list)"},
own text (Enter or Esc returns to the list) {"i", "interactive release search (library titles)"},
t toggle monitored }},
d remove from the library (files are kept unless you tick {title: "Library", items: []helpItem{
"Delete files") {"Tab", "switch between the movies and series lists"},
s automatic search (series: all missing, asks first) {"f", "cycle the status filter (all, missing, downloaded, monitored, unmonitored); every list has its own filters"},
i, Enter movie: release search; series: seasons and episodes {"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."},
r reload {"/", "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)[-] const helpKeyWidth = 16 // columns for the keys, before the descriptions
[ ] 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
[yellow]Seasons and episodes[-] // renderHelp lays the key reference out for a text area of the given width:
Enter season: expand/collapse; episode: release search // descriptions that do not fit wrap under themselves, not under the keys.
i, s, t release search, automatic search, toggle monitored func renderHelp(width int) string {
(on a season they apply to the whole season) var b strings.Builder
descWidth := max(width-2-helpKeyWidth, 20)
[yellow]Release list[-] for i, sec := range helpSections {
Enter, g grab the selected release if i > 0 {
s cycle sort (best, size, age) b.WriteByte('\n')
f hide rejected releases }
F filter by quality, codec, HDR / DV, audio, language (read from b.WriteString("[yellow]" + sec.title + "[-]\n")
the release title, quality and custom formats) if sec.note != "" {
for _, l := range flowWrap(tview.Escape(sec.note), width-2, width-2, 0) {
[yellow]Downloads[-] b.WriteString(" " + l + "\n")
p pause / resume the SABnzbd queue }
d delete the selected job or entry }
b remove from arr queue + blocklist for _, it := range sec.items {
t retry a failed download (history) keys := tview.Escape(it.keys)
r refresh now pad := strings.Repeat(" ", max(helpKeyWidth-tview.TaggedStringWidth(keys), 1))
lines := flowWrap(tview.Escape(it.desc), descWidth, descWidth, 0)
[yellow]Config (F6)[-] for j, l := range lines {
Edit the settings of the config file: service addresses and API keys, add-form if j == 0 {
defaults, refresh interval, posters, notifications. b.WriteString(" " + keys + pad + l + "\n")
↑↓ move, Tab next section, Space toggles, Enter presses a button } else {
Test connections checks the three services with the values typed in b.WriteString(strings.Repeat(" ", 2+helpKeyWidth) + l + "\n")
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. return b.String()
↑↓, PgUp/PgDn scroll G follow new entries c clear }
[yellow]Connections[-]
`
// showHelp opens the key reference together with the connection state. // showHelp opens the key reference together with the connection state.
func (a *App) showHelp() { 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.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 { tv.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
if ev.Key() == tcell.KeyEsc || (ev.Key() == tcell.KeyRune && (ev.Rune() == 'q' || ev.Rune() == '?')) { if ev.Key() == tcell.KeyEsc || (ev.Key() == tcell.KeyRune && (ev.Rune() == 'q' || ev.Rune() == '?')) {
a.closeModal() a.closeModal()
@@ -91,5 +142,6 @@ func (a *App) showHelp() {
} }
return ev 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")
} }
+62
View File
@@ -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)
}
}