Add popup notifications, log view (F5) and context-sensitive key hints (M9)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+51
-32
@@ -21,6 +21,8 @@ type view struct {
|
||||
body tview.Primitive
|
||||
// Optional hooks, called when the view becomes visible / hidden.
|
||||
onShow, onHide func()
|
||||
// hints lists the keys that work in this view (shown in the status bar).
|
||||
hints func() string
|
||||
}
|
||||
|
||||
// App is the top-level arr-tui application.
|
||||
@@ -32,7 +34,8 @@ type App struct {
|
||||
views []view
|
||||
active int
|
||||
|
||||
message string
|
||||
toasts []toast
|
||||
logs *logView
|
||||
overlays []overlay // dialogs and full-screen views stacked above the pages
|
||||
|
||||
services *tview.TextView
|
||||
@@ -50,9 +53,11 @@ type serviceState struct {
|
||||
var serviceNames = []string{"Radarr", "Sonarr", "SABnzbd"}
|
||||
|
||||
// overlay is a page stacked above the main views; focus is restored on close.
|
||||
// hints replaces the view's key hints in the status bar while it is open.
|
||||
type overlay struct {
|
||||
page string
|
||||
focus tview.Primitive
|
||||
hints string
|
||||
}
|
||||
|
||||
// New builds the application shell: a tab bar, one page per view and a status bar.
|
||||
@@ -69,6 +74,7 @@ func New(cfg *config.Config) *App {
|
||||
|
||||
radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)
|
||||
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
|
||||
a.logs = newLogView(a)
|
||||
library := newLibraryView(a, radarrClient, sonarrClient)
|
||||
downloads := newDownloadsView(a, sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey),
|
||||
[]arrSource{
|
||||
@@ -77,10 +83,11 @@ func New(cfg *config.Config) *App {
|
||||
},
|
||||
time.Duration(cfg.UI.RefreshSeconds)*time.Second)
|
||||
a.views = []view{
|
||||
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}, cfg.Radarr).root},
|
||||
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).root},
|
||||
{key: '3', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate},
|
||||
{key: '4', name: "Library", body: library.root, onShow: library.activate},
|
||||
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}, cfg.Radarr).root, hints: searchHints},
|
||||
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).root, hints: searchHints},
|
||||
{key: '3', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate, hints: downloads.hints},
|
||||
{key: '4', name: "Library", body: library.root, onShow: library.activate, hints: libraryHints},
|
||||
{key: '5', name: "Log", body: a.logs.root, onShow: a.logs.onShow, hints: logHints},
|
||||
}
|
||||
for i, v := range a.views {
|
||||
a.pages.AddPage(v.name, v.body, true, i == 0)
|
||||
@@ -89,14 +96,13 @@ func New(cfg *config.Config) *App {
|
||||
layout := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(tview.NewFlex().
|
||||
AddItem(a.tabs, 0, 1, false).
|
||||
AddItem(a.services, 34, 0, false), 1, 0, false).
|
||||
AddItem(a.services, 25, 0, false), 1, 0, false).
|
||||
AddItem(a.pages, 0, 1, true).
|
||||
AddItem(a.status, 1, 0, false)
|
||||
|
||||
a.app.SetRoot(layout, true).EnableMouse(false)
|
||||
a.app.SetRoot(&toastRoot{Flex: layout, app: a}, true).EnableMouse(false)
|
||||
a.app.SetInputCapture(a.handleKey)
|
||||
a.switchTo(0)
|
||||
a.renderStatus()
|
||||
a.renderServices()
|
||||
return a
|
||||
}
|
||||
@@ -109,20 +115,28 @@ func (a *App) SetService(name, version string, err error) {
|
||||
version = prev.version
|
||||
}
|
||||
a.state[name] = serviceState{known: true, version: version, err: err}
|
||||
// Log connection changes only, so background polling does not flood the log.
|
||||
switch {
|
||||
case err != nil && (!prev.known || prev.err == nil):
|
||||
a.logs.add(levelError, name+" unreachable: "+err.Error())
|
||||
case err == nil && (!prev.known || prev.err != nil):
|
||||
a.logs.add(levelSuccess, strings.TrimSpace(name+" "+version)+" connected")
|
||||
}
|
||||
a.renderServices()
|
||||
}
|
||||
|
||||
func (a *App) renderServices() {
|
||||
var s string
|
||||
for _, n := range serviceNames {
|
||||
short := strings.TrimSuffix(n, "nzbd") // SABnzbd -> SAB, keeps the bar narrow
|
||||
st := a.state[n]
|
||||
switch {
|
||||
case !st.known:
|
||||
s += "[gray]○ " + n + "[-] "
|
||||
s += "[gray]○ " + short + "[-] "
|
||||
case st.err != nil:
|
||||
s += "[red]● " + n + "[-] "
|
||||
s += "[red]● " + short + "[-] "
|
||||
default:
|
||||
s += "[green]●[-] " + n + " "
|
||||
s += "[green]●[-] " + short + " "
|
||||
}
|
||||
}
|
||||
a.services.SetText(s)
|
||||
@@ -147,13 +161,6 @@ func (a *App) serviceSummary() string {
|
||||
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) {
|
||||
a.message = text
|
||||
a.renderStatus()
|
||||
}
|
||||
|
||||
// post runs f on the UI goroutine and redraws. Safe to call from any goroutine.
|
||||
func (a *App) post(f func()) {
|
||||
a.app.QueueUpdateDraw(f)
|
||||
@@ -165,21 +172,22 @@ func (a *App) Run() error {
|
||||
}
|
||||
|
||||
func (a *App) renderStatus() {
|
||||
hints := "[gray]?/F9 help · q quit · F1-F4 views · Tab panels · / search"
|
||||
if len(a.overlays) > 0 {
|
||||
hints = "[gray]Esc back · Ctrl+C quit"
|
||||
var hints string
|
||||
if n := len(a.overlays); n > 0 {
|
||||
hints = a.overlays[n-1].hints + " · Ctrl+C quit"
|
||||
} else {
|
||||
if h := a.views[a.active].hints; h != nil {
|
||||
hints = h() + " · "
|
||||
}
|
||||
hints += "? help · F1-F5 · q quit"
|
||||
}
|
||||
if a.message == "" {
|
||||
a.status.SetText(hints)
|
||||
return
|
||||
}
|
||||
a.status.SetText(a.message + " [gray]│ " + hints)
|
||||
a.status.SetText("[gray]" + hints)
|
||||
}
|
||||
|
||||
// pushOverlay stacks p above everything and focuses focus.
|
||||
func (a *App) pushOverlay(p, focus tview.Primitive) {
|
||||
func (a *App) pushOverlay(p, focus tview.Primitive, hints string) {
|
||||
name := fmt.Sprintf("overlay-%d", len(a.overlays))
|
||||
a.overlays = append(a.overlays, overlay{page: name, focus: a.app.GetFocus()})
|
||||
a.overlays = append(a.overlays, overlay{page: name, focus: a.app.GetFocus(), hints: hints})
|
||||
a.pages.AddPage(name, p, true, true)
|
||||
a.app.SetFocus(focus)
|
||||
a.renderStatus()
|
||||
@@ -194,12 +202,12 @@ 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)
|
||||
a.pushOverlay(box, p, "Tab/arrows move · Enter select · Esc cancel")
|
||||
}
|
||||
|
||||
// openFull shows p above the whole view area (the tab bar and status bar stay visible).
|
||||
func (a *App) openFull(p tview.Primitive) {
|
||||
a.pushOverlay(p, p)
|
||||
func (a *App) openFull(p tview.Primitive, hints string) {
|
||||
a.pushOverlay(p, p, hints)
|
||||
}
|
||||
|
||||
// closeModal removes the topmost overlay and restores the previous focus.
|
||||
@@ -294,6 +302,7 @@ func (a *App) switchTo(i int) {
|
||||
a.active = i
|
||||
a.pages.SwitchToPage(a.views[i].name)
|
||||
a.renderTabs()
|
||||
a.renderStatus()
|
||||
}
|
||||
|
||||
func (a *App) renderTabs() {
|
||||
@@ -302,8 +311,18 @@ func (a *App) renderTabs() {
|
||||
if i == a.active {
|
||||
s += fmt.Sprintf("[black:white] %c %s [-:-] ", v.key, v.name)
|
||||
} else {
|
||||
s += fmt.Sprintf("[gray] %c %s [-] ", v.key, v.name)
|
||||
s += fmt.Sprintf("[gray] %c %s [-]", v.key, v.name)
|
||||
}
|
||||
}
|
||||
a.tabs.SetText(s)
|
||||
}
|
||||
|
||||
func searchHints() string {
|
||||
return "/ search · Tab list/search · Enter add · i releases"
|
||||
}
|
||||
|
||||
func libraryHints() string {
|
||||
return "m kind · f filter · / text · t monitor · s search · Enter open"
|
||||
}
|
||||
|
||||
func logHints() string { return "↑↓ scroll · G follow · c clear" }
|
||||
|
||||
Reference in New Issue
Block a user