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:
+6
-1
@@ -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
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user