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:
@@ -13,6 +13,7 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
|
|||||||
per-episode monitoring, automatic search and release search.
|
per-episode monitoring, automatic search and release search.
|
||||||
- **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete,
|
- **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete,
|
||||||
retry failed downloads, remove from the arr queue and blocklist.
|
retry failed downloads, remove from the arr queue and blocklist.
|
||||||
|
- **Log (F5)**: actions report through popups that fade away; the log keeps the history.
|
||||||
- Connection indicators for all three services in the top right; `?` shows every key.
|
- Connection indicators for all three services in the top right; `?` shows every key.
|
||||||
|
|
||||||
Requires Radarr/Sonarr v3+ API (Radarr 5/6, Sonarr 4) and SABnzbd 4+. Only Usenet releases are
|
Requires Radarr/Sonarr v3+ API (Radarr 5/6, Sonarr 4) and SABnzbd 4+. Only Usenet releases are
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ A terminal UI to search for movies and series, add them to Radarr / Sonarr, and
|
|||||||
- **TUI library:** [tview](https://github.com/rivo/tview) (on top of tcell)
|
- **TUI library:** [tview](https://github.com/rivo/tview) (on top of tcell)
|
||||||
- **License:** MIT
|
- **License:** MIT
|
||||||
- **Module:** `git.stinnesbeck.com/nils/arr-tui`
|
- **Module:** `git.stinnesbeck.com/nils/arr-tui`
|
||||||
- **Status:** M8 done
|
- **Status:** M9 done
|
||||||
|
|
||||||
## 1. Goals
|
## 1. Goals
|
||||||
|
|
||||||
@@ -62,7 +62,8 @@ Notes:
|
|||||||
- SABnzbd queue (name, size, progress, ETA, speed) and history (completed / failed, with retry).
|
- SABnzbd queue (name, size, progress, ETA, speed) and history (completed / failed, with retry).
|
||||||
- Radarr and Sonarr queues (status, warnings, import errors), so stuck imports are visible.
|
- Radarr and Sonarr queues (status, warnings, import errors), so stuck imports are visible.
|
||||||
- Global speed / pause state in the status bar. Pause/resume and delete a SABnzbd job.
|
- Global speed / pause state in the status bar. Pause/resume and delete a SABnzbd job.
|
||||||
- **Status bar / help:** key hints, connection state of each service.
|
- **Status bar / help:** the bottom bar lists the keys that work in the current view or panel; the top right shows the connection state of each service; `?` opens the full key reference.
|
||||||
|
- **Notifications and log (F5):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll.
|
||||||
|
|
||||||
### 4.2 v0.2
|
### 4.2 v0.2
|
||||||
|
|
||||||
@@ -195,7 +196,8 @@ The file should be created with mode `0600`; warn if it is more permissive. API
|
|||||||
- [x] **M6** — Interactive release search (movies, then series seasons/episodes) and grab.
|
- [x] **M6** — Interactive release search (movies, then series seasons/episodes) and grab.
|
||||||
- [x] **M7** — Polish: help screen (`?`/F9), per-service connection indicators, README, Makefile release builds, `--version`.
|
- [x] **M7** — Polish: help screen (`?`/F9), per-service connection indicators, README, Makefile release builds, `--version`.
|
||||||
- [x] **M8** — Library view (movies/series, filters, text filter, monitor toggle, automatic search) and series drill-down (seasons/episodes, monitoring, search, release search).
|
- [x] **M8** — Library view (movies/series, filters, text filter, monitor toggle, automatic search) and series drill-down (seasons/episodes, monitoring, search, release search).
|
||||||
- [ ] **M9+** — ideas: calendar, notifications, delete from library, multiple instances.
|
- [x] **M9** — Popup notifications, Log view (F5), context-sensitive key hints in the status bar.
|
||||||
|
- [ ] **M10+** — ideas: calendar, notifications, delete from library, multiple instances.
|
||||||
|
|
||||||
## 10. Open Questions
|
## 10. Open Questions
|
||||||
|
|
||||||
|
|||||||
+51
-32
@@ -21,6 +21,8 @@ type view struct {
|
|||||||
body tview.Primitive
|
body tview.Primitive
|
||||||
// Optional hooks, called when the view becomes visible / hidden.
|
// Optional hooks, called when the view becomes visible / hidden.
|
||||||
onShow, onHide func()
|
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.
|
// App is the top-level arr-tui application.
|
||||||
@@ -32,7 +34,8 @@ type App struct {
|
|||||||
views []view
|
views []view
|
||||||
active int
|
active int
|
||||||
|
|
||||||
message string
|
toasts []toast
|
||||||
|
logs *logView
|
||||||
overlays []overlay // dialogs and full-screen views stacked above the pages
|
overlays []overlay // dialogs and full-screen views stacked above the pages
|
||||||
|
|
||||||
services *tview.TextView
|
services *tview.TextView
|
||||||
@@ -50,9 +53,11 @@ type serviceState struct {
|
|||||||
var serviceNames = []string{"Radarr", "Sonarr", "SABnzbd"}
|
var serviceNames = []string{"Radarr", "Sonarr", "SABnzbd"}
|
||||||
|
|
||||||
// overlay is a page stacked above the main views; focus is restored on close.
|
// 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 {
|
type overlay struct {
|
||||||
page string
|
page string
|
||||||
focus tview.Primitive
|
focus tview.Primitive
|
||||||
|
hints string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds the application shell: a tab bar, one page per view and a status bar.
|
// 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)
|
radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)
|
||||||
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
|
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
|
||||||
|
a.logs = newLogView(a)
|
||||||
library := newLibraryView(a, radarrClient, sonarrClient)
|
library := newLibraryView(a, radarrClient, sonarrClient)
|
||||||
downloads := newDownloadsView(a, sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey),
|
downloads := newDownloadsView(a, sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey),
|
||||||
[]arrSource{
|
[]arrSource{
|
||||||
@@ -77,10 +83,11 @@ func New(cfg *config.Config) *App {
|
|||||||
},
|
},
|
||||||
time.Duration(cfg.UI.RefreshSeconds)*time.Second)
|
time.Duration(cfg.UI.RefreshSeconds)*time.Second)
|
||||||
a.views = []view{
|
a.views = []view{
|
||||||
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}, cfg.Radarr).root},
|
{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},
|
{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},
|
{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},
|
{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 {
|
for i, v := range a.views {
|
||||||
a.pages.AddPage(v.name, v.body, true, i == 0)
|
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).
|
layout := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
AddItem(tview.NewFlex().
|
AddItem(tview.NewFlex().
|
||||||
AddItem(a.tabs, 0, 1, false).
|
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.pages, 0, 1, true).
|
||||||
AddItem(a.status, 1, 0, false)
|
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.app.SetInputCapture(a.handleKey)
|
||||||
a.switchTo(0)
|
a.switchTo(0)
|
||||||
a.renderStatus()
|
|
||||||
a.renderServices()
|
a.renderServices()
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
@@ -109,20 +115,28 @@ func (a *App) SetService(name, version string, err error) {
|
|||||||
version = prev.version
|
version = prev.version
|
||||||
}
|
}
|
||||||
a.state[name] = serviceState{known: true, version: version, err: err}
|
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()
|
a.renderServices()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) renderServices() {
|
func (a *App) renderServices() {
|
||||||
var s string
|
var s string
|
||||||
for _, n := range serviceNames {
|
for _, n := range serviceNames {
|
||||||
|
short := strings.TrimSuffix(n, "nzbd") // SABnzbd -> SAB, keeps the bar narrow
|
||||||
st := a.state[n]
|
st := a.state[n]
|
||||||
switch {
|
switch {
|
||||||
case !st.known:
|
case !st.known:
|
||||||
s += "[gray]○ " + n + "[-] "
|
s += "[gray]○ " + short + "[-] "
|
||||||
case st.err != nil:
|
case st.err != nil:
|
||||||
s += "[red]● " + n + "[-] "
|
s += "[red]● " + short + "[-] "
|
||||||
default:
|
default:
|
||||||
s += "[green]●[-] " + n + " "
|
s += "[green]●[-] " + short + " "
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.services.SetText(s)
|
a.services.SetText(s)
|
||||||
@@ -147,13 +161,6 @@ func (a *App) serviceSummary() string {
|
|||||||
return b.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.
|
// post runs f on the UI goroutine and redraws. Safe to call from any goroutine.
|
||||||
func (a *App) post(f func()) {
|
func (a *App) post(f func()) {
|
||||||
a.app.QueueUpdateDraw(f)
|
a.app.QueueUpdateDraw(f)
|
||||||
@@ -165,21 +172,22 @@ func (a *App) Run() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) renderStatus() {
|
func (a *App) renderStatus() {
|
||||||
hints := "[gray]?/F9 help · q quit · F1-F4 views · Tab panels · / search"
|
var hints string
|
||||||
if len(a.overlays) > 0 {
|
if n := len(a.overlays); n > 0 {
|
||||||
hints = "[gray]Esc back · Ctrl+C quit"
|
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("[gray]" + hints)
|
||||||
a.status.SetText(hints)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
a.status.SetText(a.message + " [gray]│ " + hints)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// pushOverlay stacks p above everything and focuses focus.
|
// 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))
|
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.pages.AddPage(name, p, true, true)
|
||||||
a.app.SetFocus(focus)
|
a.app.SetFocus(focus)
|
||||||
a.renderStatus()
|
a.renderStatus()
|
||||||
@@ -194,12 +202,12 @@ 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)
|
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).
|
// openFull shows p above the whole view area (the tab bar and status bar stay visible).
|
||||||
func (a *App) openFull(p tview.Primitive) {
|
func (a *App) openFull(p tview.Primitive, hints string) {
|
||||||
a.pushOverlay(p, p)
|
a.pushOverlay(p, p, hints)
|
||||||
}
|
}
|
||||||
|
|
||||||
// closeModal removes the topmost overlay and restores the previous focus.
|
// closeModal removes the topmost overlay and restores the previous focus.
|
||||||
@@ -294,6 +302,7 @@ func (a *App) switchTo(i int) {
|
|||||||
a.active = i
|
a.active = i
|
||||||
a.pages.SwitchToPage(a.views[i].name)
|
a.pages.SwitchToPage(a.views[i].name)
|
||||||
a.renderTabs()
|
a.renderTabs()
|
||||||
|
a.renderStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) renderTabs() {
|
func (a *App) renderTabs() {
|
||||||
@@ -302,8 +311,18 @@ func (a *App) renderTabs() {
|
|||||||
if i == a.active {
|
if i == a.active {
|
||||||
s += fmt.Sprintf("[black:white] %c %s [-:-] ", v.key, v.name)
|
s += fmt.Sprintf("[black:white] %c %s [-:-] ", v.key, v.name)
|
||||||
} else {
|
} 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)
|
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" }
|
||||||
|
|||||||
+16
-12
@@ -3,7 +3,6 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -124,6 +123,7 @@ func newDownloadsView(app *App, sab *sabnzbd.Client, sources []arrSource, interv
|
|||||||
// nextPanel moves focus to the next (dir 1) or previous (dir -1) panel:
|
// nextPanel moves focus to the next (dir 1) or previous (dir -1) panel:
|
||||||
// queue, Radarr/Sonarr queue, history.
|
// queue, Radarr/Sonarr queue, history.
|
||||||
func (v *downloadsView) nextPanel(dir int) {
|
func (v *downloadsView) nextPanel(dir int) {
|
||||||
|
defer v.app.renderStatus()
|
||||||
cur := v.app.app.GetFocus()
|
cur := v.app.app.GetFocus()
|
||||||
for i, p := range v.panels {
|
for i, p := range v.panels {
|
||||||
if p == cur {
|
if p == cur {
|
||||||
@@ -201,11 +201,8 @@ func (v *downloadsView) refresh(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
v.app.post(func() {
|
v.app.post(func() {
|
||||||
var errs []string
|
|
||||||
v.app.SetService("SABnzbd", "", qerr)
|
v.app.SetService("SABnzbd", "", qerr)
|
||||||
if qerr != nil {
|
if qerr == nil {
|
||||||
errs = append(errs, "SABnzbd: "+qerr.Error())
|
|
||||||
} else {
|
|
||||||
v.sabQueue, v.slots, v.paused = queue, queue.Slots, queue.Paused
|
v.sabQueue, v.slots, v.paused = queue, queue.Slots, queue.Paused
|
||||||
v.renderQueue()
|
v.renderQueue()
|
||||||
}
|
}
|
||||||
@@ -215,18 +212,13 @@ func (v *downloadsView) refresh(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
for i, s := range v.sources {
|
for i, s := range v.sources {
|
||||||
v.app.SetService(s.name, "", arrs[i].err)
|
v.app.SetService(s.name, "", arrs[i].err)
|
||||||
if arrs[i].err != nil {
|
if arrs[i].err == nil {
|
||||||
errs = append(errs, s.name+": "+arrs[i].err.Error())
|
v.arrItems[s.name] = arrs[i].items
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
v.arrItems[s.name] = arrs[i].items
|
|
||||||
}
|
}
|
||||||
v.rebuildArrRows()
|
v.rebuildArrRows()
|
||||||
v.renderArr()
|
v.renderArr()
|
||||||
v.renderSummary()
|
v.renderSummary()
|
||||||
if len(errs) > 0 {
|
|
||||||
v.app.SetMessage("[red]" + tview.Escape(strings.Join(errs, " · ")))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,3 +516,15 @@ func (v *downloadsView) removeArr(blocklist bool) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hints lists the keys of the focused panel.
|
||||||
|
func (v *downloadsView) hints() string {
|
||||||
|
common := "p pause/resume · r refresh · Tab next panel"
|
||||||
|
switch v.app.app.GetFocus() {
|
||||||
|
case v.arrQueue:
|
||||||
|
return "d remove · b remove + blocklist · " + common
|
||||||
|
case v.history:
|
||||||
|
return "d delete · t retry failed · " + common
|
||||||
|
}
|
||||||
|
return "d delete job · " + common
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,14 +54,11 @@ func (a *App) openSeries(c *sonarr.Client, s sonarr.Series, onChange func()) {
|
|||||||
detail: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
|
detail: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
|
||||||
expanded: map[int]bool{},
|
expanded: map[int]bool{},
|
||||||
}
|
}
|
||||||
foot := tview.NewTextView().SetDynamicColors(true).
|
|
||||||
SetText("[gray]Enter expand / releases · i releases · s search · t monitor · r reload · Esc back")
|
|
||||||
v.table.SetBorder(true)
|
v.table.SetBorder(true)
|
||||||
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
|
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
AddItem(v.header, 1, 0, false).
|
AddItem(v.header, 1, 0, false).
|
||||||
AddItem(v.table, 0, 1, true).
|
AddItem(v.table, 0, 1, true).
|
||||||
AddItem(v.detail, 2, 0, false).
|
AddItem(v.detail, 2, 0, false)
|
||||||
AddItem(foot, 1, 0, false)
|
|
||||||
|
|
||||||
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetail() })
|
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetail() })
|
||||||
v.table.SetSelectedFunc(func(int, int) { v.enter() })
|
v.table.SetSelectedFunc(func(int, int) { v.enter() })
|
||||||
@@ -89,7 +86,7 @@ func (a *App) openSeries(c *sonarr.Client, s sonarr.Series, onChange func()) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
v.render()
|
v.render()
|
||||||
a.openFull(v.root)
|
a.openFull(v.root, "Enter expand/releases · i releases · s search · t monitor · r reload · Esc back")
|
||||||
v.reload()
|
v.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -6,7 +6,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const helpText = `[yellow]Global[-]
|
const helpText = `[yellow]Global[-]
|
||||||
F1-F4 switch view
|
F1-F5 switch view
|
||||||
Tab, Shift+Tab switch panel within a view
|
Tab, Shift+Tab switch panel within a view
|
||||||
?, F9 this help (F9 works while typing)
|
?, F9 this help (F9 works while typing)
|
||||||
q, Ctrl+C quit
|
q, Ctrl+C quit
|
||||||
@@ -43,6 +43,10 @@ const helpText = `[yellow]Global[-]
|
|||||||
t retry a failed download (history)
|
t retry a failed download (history)
|
||||||
r refresh now
|
r refresh now
|
||||||
|
|
||||||
|
[yellow]Log (F5)[-]
|
||||||
|
Popups fade away by themselves; everything they said is kept here.
|
||||||
|
↑↓, PgUp/PgDn scroll G follow new entries c clear
|
||||||
|
|
||||||
[yellow]Connections[-]
|
[yellow]Connections[-]
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxLogLines = 1000
|
||||||
|
|
||||||
|
// logView keeps the history of everything that was reported as a popup.
|
||||||
|
type logView struct {
|
||||||
|
root *tview.TextView
|
||||||
|
app *App
|
||||||
|
follow bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLogView(app *App) *logView {
|
||||||
|
v := &logView{app: app, follow: true}
|
||||||
|
v.root = tview.NewTextView().SetDynamicColors(true).SetScrollable(true).SetMaxLines(maxLogLines)
|
||||||
|
v.root.SetBorder(true).SetTitle(" Log ")
|
||||||
|
v.root.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
|
||||||
|
switch ev.Key() {
|
||||||
|
case tcell.KeyUp, tcell.KeyPgUp, tcell.KeyHome:
|
||||||
|
v.follow = false
|
||||||
|
case tcell.KeyEnd:
|
||||||
|
v.follow = true
|
||||||
|
case tcell.KeyRune:
|
||||||
|
switch ev.Rune() {
|
||||||
|
case 'c':
|
||||||
|
v.root.Clear()
|
||||||
|
return nil
|
||||||
|
case 'k':
|
||||||
|
v.follow = false
|
||||||
|
case 'G':
|
||||||
|
v.follow = true
|
||||||
|
v.root.ScrollToEnd()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ev
|
||||||
|
})
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// add appends an entry.
|
||||||
|
func (v *logView) add(l level, text string) {
|
||||||
|
color := map[level]string{levelInfo: "white", levelSuccess: "green", levelWarn: "yellow", levelError: "red"}[l]
|
||||||
|
fmt.Fprintf(v.root, "[gray]%s[-] [%s]%-5s[-] %s\n", time.Now().Format("15:04:05"), color, l, tview.Escape(text))
|
||||||
|
if v.follow {
|
||||||
|
v.root.ScrollToEnd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *logView) onShow() {
|
||||||
|
if v.follow {
|
||||||
|
v.root.ScrollToEnd()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type level int
|
||||||
|
|
||||||
|
const (
|
||||||
|
levelInfo level = iota
|
||||||
|
levelSuccess
|
||||||
|
levelWarn
|
||||||
|
levelError
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l level) String() string {
|
||||||
|
return [...]string{"INFO", "OK", "WARN", "ERROR"}[l]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l level) color() tcell.Color {
|
||||||
|
return [...]tcell.Color{tcell.ColorWhite, tcell.ColorGreen, tcell.ColorYellow, tcell.ColorRed}[l]
|
||||||
|
}
|
||||||
|
|
||||||
|
// how long a toast stays on screen, and how long the end of that time fades.
|
||||||
|
func (l level) lifetime() time.Duration {
|
||||||
|
return [...]time.Duration{3 * time.Second, 3 * time.Second, 5 * time.Second, 8 * time.Second}[l]
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
toastFade = time.Second
|
||||||
|
maxToasts = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
type toast struct {
|
||||||
|
text string
|
||||||
|
level level
|
||||||
|
expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// progress toasts ("Searching…") are replaced by the next message.
|
||||||
|
func (t toast) progress() bool { return strings.HasSuffix(t.text, "…") }
|
||||||
|
|
||||||
|
var (
|
||||||
|
tagRe = regexp.MustCompile(`\[([^\[\]]*)\[\]|\[[^\[\]]*\]`)
|
||||||
|
levelPrefix = []struct {
|
||||||
|
tag string
|
||||||
|
level level
|
||||||
|
}{{"[red]", levelError}, {"[green]", levelSuccess}, {"[yellow]", levelWarn}}
|
||||||
|
)
|
||||||
|
|
||||||
|
// plainText removes tview color tags and undoes tview.Escape.
|
||||||
|
func plainText(s string) string {
|
||||||
|
return tagRe.ReplaceAllStringFunc(s, func(m string) string {
|
||||||
|
if strings.HasSuffix(m, "[]") {
|
||||||
|
return m[:len(m)-2] + "]"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMessage reports something to the user: as a short-lived popup and, unless
|
||||||
|
// it is only progress ("Adding…"), as an entry in the log. A leading [red],
|
||||||
|
// [yellow] or [green] tag selects the level; an empty text dismisses progress
|
||||||
|
// popups. UI goroutine only; use post from other goroutines.
|
||||||
|
func (a *App) SetMessage(text string) {
|
||||||
|
if text == "" {
|
||||||
|
a.dropProgress()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lvl := levelInfo
|
||||||
|
for _, p := range levelPrefix {
|
||||||
|
if strings.HasPrefix(text, p.tag) {
|
||||||
|
lvl, text = p.level, strings.TrimPrefix(text, p.tag)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.Notify(lvl, plainText(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify shows a popup and logs the message. UI goroutine only.
|
||||||
|
func (a *App) Notify(l level, text string) {
|
||||||
|
t := toast{text: text, level: l, expires: time.Now().Add(l.lifetime())}
|
||||||
|
a.dropProgress()
|
||||||
|
if !t.progress() {
|
||||||
|
a.logs.add(l, text)
|
||||||
|
}
|
||||||
|
a.toasts = append(a.toasts, t)
|
||||||
|
if len(a.toasts) > maxToasts {
|
||||||
|
a.toasts = a.toasts[len(a.toasts)-maxToasts:]
|
||||||
|
}
|
||||||
|
// Redraw when the fade starts and when the toast is gone.
|
||||||
|
for _, d := range []time.Duration{l.lifetime() - toastFade, l.lifetime() + 50*time.Millisecond} {
|
||||||
|
time.AfterFunc(d, func() { a.post(func() {}) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) dropProgress() {
|
||||||
|
kept := a.toasts[:0]
|
||||||
|
for _, t := range a.toasts {
|
||||||
|
if !t.progress() {
|
||||||
|
kept = append(kept, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.toasts = kept
|
||||||
|
}
|
||||||
|
|
||||||
|
// toastRoot draws the popups above the whole interface.
|
||||||
|
type toastRoot struct {
|
||||||
|
*tview.Flex
|
||||||
|
app *App
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *toastRoot) Draw(screen tcell.Screen) {
|
||||||
|
r.Flex.Draw(screen)
|
||||||
|
a := r.app
|
||||||
|
now := time.Now()
|
||||||
|
kept := a.toasts[:0]
|
||||||
|
for _, t := range a.toasts {
|
||||||
|
if now.Before(t.expires) {
|
||||||
|
kept = append(kept, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.toasts = kept
|
||||||
|
|
||||||
|
w, h := screen.Size()
|
||||||
|
y := h - 3 // above the status bar
|
||||||
|
for i := len(a.toasts) - 1; i >= 0 && y >= 1; i-- {
|
||||||
|
t := a.toasts[i]
|
||||||
|
text := " " + t.text + " "
|
||||||
|
width := min(tview.TaggedStringWidth(text), w-2)
|
||||||
|
x := w - width - 1
|
||||||
|
fg := t.level.color()
|
||||||
|
if t.expires.Sub(now) < toastFade {
|
||||||
|
fg = tcell.ColorGray // fade out
|
||||||
|
}
|
||||||
|
style := tcell.StyleDefault.Background(tcell.NewRGBColor(30, 30, 40)).Foreground(fg)
|
||||||
|
for cx := x; cx < x+width; cx++ {
|
||||||
|
screen.SetContent(cx, y, ' ', nil, style)
|
||||||
|
}
|
||||||
|
col := x
|
||||||
|
for _, ch := range text {
|
||||||
|
if col >= x+width {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
screen.SetContent(col, y, ch, nil, style)
|
||||||
|
col += tview.TaggedStringWidth(string(ch))
|
||||||
|
}
|
||||||
|
y--
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlainText(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ in, want string }{
|
||||||
|
{"[green]Done[-]", "Done"},
|
||||||
|
{"[red]" + tview.Escape("Movie [1080p] failed"), "Movie [1080p] failed"},
|
||||||
|
{"plain", "plain"},
|
||||||
|
{"[yellow]a [::b]b[::-]", "a b"},
|
||||||
|
} {
|
||||||
|
if got := plainText(tc.in); got != tc.want {
|
||||||
|
t.Errorf("plainText(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToastProgress(t *testing.T) {
|
||||||
|
if !(toast{text: "Searching…"}).progress() || (toast{text: "Done"}).progress() {
|
||||||
|
t.Error("progress detection wrong")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,14 +54,11 @@ func (a *App) openReleases(title string, fetch func(context.Context) ([]arr.Rele
|
|||||||
table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
|
table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
|
||||||
detail: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
|
detail: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
|
||||||
}
|
}
|
||||||
foot := tview.NewTextView().SetDynamicColors(true).
|
|
||||||
SetText("[gray]Enter/g grab · s sort · f hide rejected · Esc back")
|
|
||||||
v.table.SetBorder(true)
|
v.table.SetBorder(true)
|
||||||
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
|
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
AddItem(v.header, 1, 0, false).
|
AddItem(v.header, 1, 0, false).
|
||||||
AddItem(v.table, 0, 1, true).
|
AddItem(v.table, 0, 1, true).
|
||||||
AddItem(v.detail, 3, 0, false).
|
AddItem(v.detail, 3, 0, false)
|
||||||
AddItem(foot, 1, 0, false)
|
|
||||||
|
|
||||||
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetail() })
|
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetail() })
|
||||||
v.table.SetSelectedFunc(func(int, int) { v.grabSelected() })
|
v.table.SetSelectedFunc(func(int, int) { v.grabSelected() })
|
||||||
@@ -86,7 +83,7 @@ func (a *App) openReleases(title string, fetch func(context.Context) ([]arr.Rele
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
v.cancel = cancel
|
v.cancel = cancel
|
||||||
v.render() // shows the "searching" placeholder
|
v.render() // shows the "searching" placeholder
|
||||||
a.openFull(v.root)
|
a.openFull(v.root, "Enter/g grab · s sort · f hide rejected · Esc back")
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
releases, err := fetch(ctx)
|
releases, err := fetch(ctx)
|
||||||
|
|||||||
Reference in New Issue
Block a user