Add help screen, connection indicators, README and release builds (M7)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+72
-3
@@ -34,8 +34,21 @@ type App struct {
|
||||
|
||||
message string
|
||||
overlays []overlay // dialogs and full-screen views stacked above the pages
|
||||
|
||||
services *tview.TextView
|
||||
state map[string]serviceState // keyed by service name
|
||||
}
|
||||
|
||||
// serviceState is the last known connection state of a backend service.
|
||||
type serviceState struct {
|
||||
known bool
|
||||
version string
|
||||
err error
|
||||
}
|
||||
|
||||
// serviceNames fixes the order of the connection indicators.
|
||||
var serviceNames = []string{"Radarr", "Sonarr", "SABnzbd"}
|
||||
|
||||
// overlay is a page stacked above the main views; focus is restored on close.
|
||||
type overlay struct {
|
||||
page string
|
||||
@@ -49,6 +62,9 @@ func New(cfg *config.Config) *App {
|
||||
tabs: tview.NewTextView().SetDynamicColors(true),
|
||||
pages: tview.NewPages(),
|
||||
status: tview.NewTextView().SetDynamicColors(true),
|
||||
|
||||
services: tview.NewTextView().SetDynamicColors(true).SetTextAlign(tview.AlignRight),
|
||||
state: map[string]serviceState{},
|
||||
}
|
||||
|
||||
radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)
|
||||
@@ -70,7 +86,9 @@ func New(cfg *config.Config) *App {
|
||||
}
|
||||
|
||||
layout := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(a.tabs, 1, 0, false).
|
||||
AddItem(tview.NewFlex().
|
||||
AddItem(a.tabs, 0, 1, false).
|
||||
AddItem(a.services, 34, 0, false), 1, 0, false).
|
||||
AddItem(a.pages, 0, 1, true).
|
||||
AddItem(a.status, 1, 0, false)
|
||||
|
||||
@@ -78,9 +96,56 @@ func New(cfg *config.Config) *App {
|
||||
a.app.SetInputCapture(a.handleKey)
|
||||
a.switchTo(0)
|
||||
a.renderStatus()
|
||||
a.renderServices()
|
||||
return a
|
||||
}
|
||||
|
||||
// SetService records the outcome of a request to a service and updates the
|
||||
// connection indicators. UI goroutine only. A nil err means reachable.
|
||||
func (a *App) SetService(name, version string, err error) {
|
||||
prev := a.state[name]
|
||||
if err == nil && version == "" {
|
||||
version = prev.version
|
||||
}
|
||||
a.state[name] = serviceState{known: true, version: version, err: err}
|
||||
a.renderServices()
|
||||
}
|
||||
|
||||
func (a *App) renderServices() {
|
||||
var s string
|
||||
for _, n := range serviceNames {
|
||||
st := a.state[n]
|
||||
switch {
|
||||
case !st.known:
|
||||
s += "[gray]○ " + n + "[-] "
|
||||
case st.err != nil:
|
||||
s += "[red]● " + n + "[-] "
|
||||
default:
|
||||
s += "[green]●[-] " + n + " "
|
||||
}
|
||||
}
|
||||
a.services.SetText(s)
|
||||
}
|
||||
|
||||
// serviceSummary describes every service for the help screen.
|
||||
func (a *App) serviceSummary() string {
|
||||
var b strings.Builder
|
||||
for _, n := range serviceNames {
|
||||
st := a.state[n]
|
||||
switch {
|
||||
case !st.known:
|
||||
fmt.Fprintf(&b, "[gray]○ %s: not contacted yet[-]\n", n)
|
||||
case st.err != nil:
|
||||
fmt.Fprintf(&b, "[red]● %s: %s[-]\n", n, tview.Escape(st.err.Error()))
|
||||
case st.version != "":
|
||||
fmt.Fprintf(&b, "[green]●[-] %s %s: connected\n", n, tview.Escape(st.version))
|
||||
default:
|
||||
fmt.Fprintf(&b, "[green]●[-] %s: connected\n", n)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// SetMessage shows a message in the status bar. UI goroutine only; use
|
||||
// post from other goroutines.
|
||||
func (a *App) SetMessage(text string) {
|
||||
@@ -99,7 +164,7 @@ func (a *App) Run() error {
|
||||
}
|
||||
|
||||
func (a *App) renderStatus() {
|
||||
hints := "[gray]q quit · Tab/1-4/F1-F4 views · / search"
|
||||
hints := "[gray]?/F9 help · q quit · Tab/F1-F4 views · / search"
|
||||
if len(a.overlays) > 0 {
|
||||
hints = "[gray]Esc back · Ctrl+C quit"
|
||||
}
|
||||
@@ -180,8 +245,12 @@ func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
||||
a.app.Stop()
|
||||
return nil
|
||||
}
|
||||
// View switching that also works while typing in a search box. Not while a dialog is open.
|
||||
// Shortcuts that also work while typing in a search box. Not while a dialog is open.
|
||||
if len(a.overlays) == 0 {
|
||||
if ev.Key() == tcell.KeyF9 {
|
||||
a.showHelp()
|
||||
return nil
|
||||
}
|
||||
if i := a.viewShortcut(ev); i >= 0 && i < len(a.views) {
|
||||
a.switchTo(i)
|
||||
return nil
|
||||
|
||||
@@ -194,6 +194,7 @@ func (v *downloadsView) refresh(ctx context.Context) {
|
||||
|
||||
v.app.post(func() {
|
||||
var errs []string
|
||||
v.app.SetService("SABnzbd", "", qerr)
|
||||
if qerr != nil {
|
||||
errs = append(errs, "SABnzbd: "+qerr.Error())
|
||||
} else {
|
||||
@@ -205,6 +206,7 @@ func (v *downloadsView) refresh(ctx context.Context) {
|
||||
v.renderHistory()
|
||||
}
|
||||
for i, s := range v.sources {
|
||||
v.app.SetService(s.name, "", arrs[i].err)
|
||||
if arrs[i].err != nil {
|
||||
errs = append(errs, s.name+": "+arrs[i].err.Error())
|
||||
continue
|
||||
@@ -387,7 +389,7 @@ func (v *downloadsView) renderHistory() {
|
||||
case h.Failed():
|
||||
status = "[red]" + status + "[-]"
|
||||
if h.FailMessage != "" {
|
||||
status += " [gray](" + tview.Escape(truncate(h.FailMessage, 28)) + ")[-]"
|
||||
status += " [gray](" + tview.Escape(truncate(h.FailMessage, 60)) + ")[-]"
|
||||
}
|
||||
case h.Status == "Completed":
|
||||
status = "[green]" + status + "[-]"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
const helpText = `[yellow]Global[-]
|
||||
F1-F4, Alt+1-4, Tab switch view
|
||||
?, F9 this help (F9 works while typing)
|
||||
q, Ctrl+C quit
|
||||
Esc close dialog / go back
|
||||
|
||||
[yellow]Movies and Series[-]
|
||||
/ focus the search box
|
||||
Enter, a add the selected title
|
||||
i interactive release search (library titles)
|
||||
|
||||
[yellow]Release list[-]
|
||||
Enter, g grab the selected release
|
||||
s cycle sort (best, size, age)
|
||||
f hide rejected releases
|
||||
|
||||
[yellow]Downloads[-]
|
||||
h next panel
|
||||
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]Connections[-]
|
||||
`
|
||||
|
||||
// showHelp opens the key reference together with the connection state.
|
||||
func (a *App) showHelp() {
|
||||
tv := tview.NewTextView().SetDynamicColors(true).SetScrollable(true)
|
||||
tv.SetBorder(true).SetTitle(" Help — Esc to close ")
|
||||
tv.SetText(helpText + a.serviceSummary())
|
||||
tv.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
|
||||
if ev.Key() == tcell.KeyEsc || (ev.Key() == tcell.KeyRune && (ev.Rune() == 'q' || ev.Rune() == '?')) {
|
||||
a.closeModal()
|
||||
return nil
|
||||
}
|
||||
return ev
|
||||
})
|
||||
a.openModal(tv, 76, 34)
|
||||
}
|
||||
@@ -129,11 +129,10 @@ func (v *searchView) loadOptions() {
|
||||
v.mu.Unlock()
|
||||
|
||||
v.app.post(func() {
|
||||
v.app.SetService(v.be.name(), status.Version, err)
|
||||
if err != nil {
|
||||
v.app.SetMessage(fmt.Sprintf("[red]%s: %s", v.be.name(), tview.Escape(err.Error())))
|
||||
return
|
||||
}
|
||||
v.app.SetMessage(fmt.Sprintf("[green]%s %s connected", v.be.name(), tview.Escape(status.Version)))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -204,6 +203,7 @@ func (v *searchView) run(ctx context.Context, gen int, term string) {
|
||||
if gen != v.gen {
|
||||
return // superseded by a newer search
|
||||
}
|
||||
v.app.SetService(v.be.name(), "", err)
|
||||
if err != nil {
|
||||
v.app.SetMessage("[red]Search failed: " + tview.Escape(err.Error()))
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user