Posters (the downloaded bytes, per size, 30 days, at most 200 MB) and casts (people.json, 7 days, at most 2000) are stored in the user's cache directory (os.UserCacheDir()/arr-tui) and used on the next start before asking the servers. Expired posters are pruned at startup, casts are written shortly after new ones arrive and on exit. Tests turn the cache off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
704 lines
22 KiB
Go
704 lines
22 KiB
Go
// Package ui contains the tview user interface.
|
||
package ui
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gdamore/tcell/v2"
|
||
"github.com/rivo/tview"
|
||
|
||
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||
"git.stinnesbeck.com/nils/arr-tui/internal/config"
|
||
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
|
||
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
|
||
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
|
||
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
|
||
)
|
||
|
||
type view struct {
|
||
key rune
|
||
name string
|
||
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.
|
||
type App struct {
|
||
app *tview.Application
|
||
theme theme // the colors in use
|
||
trakt *trakt.Client // trending, most watched and anticipated lists, casts
|
||
people *peopleCache // casts loaded from Trakt
|
||
disk *diskCache // posters and casts kept between runs
|
||
// Called (on the UI goroutine) a moment after a release was grabbed.
|
||
afterGrab []func()
|
||
// The Movies and Series views, to refresh their Trakt lists.
|
||
searches []*searchView
|
||
themed *themedScreen // draws with them; nil until Run
|
||
tabs *tview.TextView
|
||
pages *tview.Pages
|
||
status *tview.TextView
|
||
views []view
|
||
active int
|
||
|
||
toasts []toast
|
||
logs *logView
|
||
overlays []overlay // dialogs and full-screen views stacked above the pages
|
||
|
||
services *tview.TextView
|
||
topBar *tview.Flex // tab bar and connection indicators
|
||
narrow bool // terminal too narrow for full service names
|
||
state map[string]serviceState // keyed by service name
|
||
probes []probe
|
||
|
||
screen tcell.Screen // remembered at the first draw, for the bell
|
||
cfg *config.Config // the settings in use (file, environment and defaults combined)
|
||
cfgPath string // the config file, empty if unknown
|
||
cfgFile *config.Config // the file's contents as written, for saving
|
||
radarr *radarr.Client
|
||
sonarr *sonarr.Client
|
||
sab *sabnzbd.Client
|
||
downloads *downloadsView
|
||
settings *settingsView
|
||
panes []*detailPane // every detail pane, to restyle posters when settings change
|
||
reconnect []func() // run after the service addresses or keys changed
|
||
watchStop context.CancelFunc
|
||
notifyDone bool // announce finished downloads
|
||
bell bool
|
||
covers bool // poster thumbnails enabled
|
||
coverStyle coverStyle
|
||
gfx *kittyGraphics // real-image posters, nil when not used
|
||
lastWindow [3]int // columns, rows and cell shape at the last draw
|
||
cellAspect float64 // height of a terminal cell divided by its width
|
||
coverCache *coverCache
|
||
}
|
||
|
||
// probe checks that one service answers; it returns its version.
|
||
type probe struct {
|
||
name string
|
||
check func(ctx context.Context) (string, error)
|
||
}
|
||
|
||
// heartbeat is how often the connection indicators are refreshed in the
|
||
// background, so a service shows up as connected without visiting its view.
|
||
const heartbeat = 30 * time.Second
|
||
|
||
// 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.
|
||
// hints replaces the view's key hints in the status bar while it is open.
|
||
type overlay struct {
|
||
page string
|
||
root tview.Primitive // what was pushed
|
||
focus tview.Primitive
|
||
hints string
|
||
full bool // full-screen view (can be left with F1-F7), as opposed to a dialog
|
||
close func() // optional cleanup when the overlay is removed
|
||
}
|
||
|
||
// New builds the application shell: a tab bar, one page per view and a status bar.
|
||
func New(cfg *config.Config) *App {
|
||
// Ask the terminal before the screen takes it over, and set the colors
|
||
// before any widget copies them.
|
||
th, themeNote := chooseTheme(cfg.UI.Theme, terminalAnswers())
|
||
useTokens()
|
||
a := &App{
|
||
app: tview.NewApplication(),
|
||
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{},
|
||
|
||
notifyDone: cfg.UI.ShouldNotifyDownloads(),
|
||
bell: cfg.UI.Bell,
|
||
covers: cfg.UI.ShowCovers(),
|
||
cellAspect: 2,
|
||
coverCache: newCoverCache(),
|
||
}
|
||
|
||
blocks, graphics := resolveCovers(cfg.UI.CoverStyle, os.Getenv)
|
||
coverNote := "Posters: " + blocks.name + " block characters"
|
||
if graphics {
|
||
coverNote = "Posters: real images (the terminal confirmed the Kitty graphics protocol)"
|
||
} else if cfg.UI.CoverStyle == "kitty" || kittyTerminal(os.Getenv) {
|
||
coverNote += " (the terminal did not confirm the Kitty graphics protocol)"
|
||
}
|
||
a.coverStyle = blocks
|
||
if graphics {
|
||
a.gfx = a.newGraphics()
|
||
}
|
||
|
||
a.cfg = cfg
|
||
radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)
|
||
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
|
||
a.radarr, a.sonarr = radarrClient, sonarrClient
|
||
a.logs = newLogView(a)
|
||
if a.covers {
|
||
a.logs.add(levelInfo, coverNote)
|
||
}
|
||
a.theme = th
|
||
a.logs.add(levelInfo, themeNote)
|
||
sabClient := sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey)
|
||
a.sab = sabClient
|
||
a.probes = []probe{
|
||
{"Radarr", func(ctx context.Context) (string, error) {
|
||
st, err := radarrClient.SystemStatus(ctx)
|
||
return st.Version, err
|
||
}},
|
||
{"Sonarr", func(ctx context.Context) (string, error) {
|
||
st, err := sonarrClient.SystemStatus(ctx)
|
||
return st.Version, err
|
||
}},
|
||
{"SABnzbd", sabClient.Version},
|
||
}
|
||
settings := newSettingsView(a)
|
||
a.settings = settings
|
||
calendar := newCalendarView(a, radarrClient, sonarrClient)
|
||
library := newLibraryView(a, radarrClient, sonarrClient)
|
||
a.afterGrab = append(a.afterGrab, library.loadQueue)
|
||
downloads := newDownloadsView(a, sabClient,
|
||
[]arrSource{
|
||
{name: "Radarr", queue: radarrClient.Queue, remove: radarrClient.RemoveQueueItem},
|
||
{name: "Sonarr", queue: sonarrClient.Queue, remove: sonarrClient.RemoveQueueItem},
|
||
},
|
||
time.Duration(cfg.UI.RefreshSeconds)*time.Second)
|
||
a.downloads = downloads
|
||
a.trakt = trakt.NewClient(cfg.Trakt.ClientID, "arr-tui")
|
||
a.disk = newDiskCache()
|
||
a.people = newPeopleCache()
|
||
a.loadSavedPeople()
|
||
if u := os.Getenv("ARR_TUI_TRAKT_URL"); u != "" {
|
||
a.trakt.SetBaseURL(u) // another Trakt API, for development (dev/screenshots)
|
||
}
|
||
movies := newSearchView(a, radarrBackend{radarrClient})
|
||
series := newSearchView(a, sonarrBackend{sonarrClient})
|
||
a.searches = []*searchView{movies, series}
|
||
a.views = []view{
|
||
{key: '1', name: "Movies", body: movies.root, onShow: movies.activate, hints: searchHints},
|
||
{key: '2', name: "Series", body: series.root, onShow: series.activate, hints: searchHints},
|
||
{key: '3', name: "Library", body: library.root, onShow: library.activate, onHide: library.deactivate, hints: libraryHints},
|
||
{key: '4', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate, hints: downloads.hints},
|
||
{key: '5', name: "Calendar", body: calendar.root, onShow: calendar.activate, hints: calendarHints},
|
||
{key: '6', name: "Config", body: settings.root, onShow: settings.activate, hints: settingsHints},
|
||
{key: '7', 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)
|
||
}
|
||
|
||
a.topBar = tview.NewFlex().
|
||
AddItem(a.tabs, 0, 1, false).
|
||
AddItem(a.services, servicesWidth(false), 0, false)
|
||
layout := tview.NewFlex().SetDirection(tview.FlexRow).
|
||
AddItem(a.topBar, 1, 0, false).
|
||
AddItem(a.pages, 0, 1, true).
|
||
AddItem(a.status, 1, 0, false)
|
||
|
||
a.app.SetBeforeDrawFunc(func(s tcell.Screen) bool {
|
||
// A focused text field shows the cursor again while it is drawn. Hiding it
|
||
// first covers focus moves that bypass tview (arrow keys in forms), which
|
||
// would leave the cursor behind in the field that was left.
|
||
s.HideCursor()
|
||
a.screen = s
|
||
a.cellAspect = cellAspectOf(s)
|
||
a.noteWindow(s)
|
||
return false
|
||
})
|
||
a.app.SetRoot(&toastRoot{Flex: layout, app: a}, true).EnableMouse(false)
|
||
syncFocus = func(p tview.Primitive) { a.app.SetFocus(p) }
|
||
a.app.SetInputCapture(a.handleKey)
|
||
a.switchTo(0)
|
||
movies.activate() // switchTo does not call onShow for the view already active
|
||
if !cfg.AnyConfigured() {
|
||
// First start (or nothing set up): go where that is done.
|
||
a.switchTo(a.viewIndex("Config"))
|
||
a.Notify(levelInfo, "Welcome! Enter the addresses and API keys of your services, then Save")
|
||
}
|
||
a.renderServices()
|
||
return a
|
||
}
|
||
|
||
// newGraphics creates the real-image drawer. With ARR_TUI_DEBUG_GFX set it also
|
||
// writes what it does (window size, cell shape, every upload) to the Log, which
|
||
// helps to find out why a poster is drawn wrongly in some terminal.
|
||
func (a *App) newGraphics() *kittyGraphics {
|
||
k := newKittyGraphics(a.writeTerminal)
|
||
switch v := os.Getenv("ARR_TUI_DEBUG_GFX"); {
|
||
case v == "":
|
||
case strings.Contains(v, "/"):
|
||
// A path: also append to that file, so the lines can be read without
|
||
// leaving the view that shows the problem.
|
||
f, err := os.OpenFile(v, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||
if err != nil {
|
||
a.logs.add(levelWarn, "ARR_TUI_DEBUG_GFX: "+err.Error())
|
||
k.debug = func(msg string) { a.logs.add(levelInfo, msg) }
|
||
break
|
||
}
|
||
k.debug = func(msg string) {
|
||
a.logs.add(levelInfo, msg)
|
||
fmt.Fprintf(f, "%s %s\n", time.Now().Format("15:04:05.000"), msg)
|
||
}
|
||
default:
|
||
k.debug = func(msg string) { a.logs.add(levelInfo, msg) }
|
||
}
|
||
return k
|
||
}
|
||
|
||
// noteWindow notices when the terminal's size or cell size changed. The
|
||
// real-image posters are then uploaded and placed again from scratch instead of
|
||
// relying on the terminal to rescale what is there.
|
||
func (a *App) noteWindow(s tcell.Screen) {
|
||
w, h := s.Size()
|
||
win := [3]int{w, h, int(a.cellAspect * 1000)}
|
||
if win == a.lastWindow {
|
||
return
|
||
}
|
||
first := a.lastWindow == [3]int{}
|
||
a.lastWindow = win
|
||
if !first && a.gfx != nil {
|
||
a.gfx.deleteAll()
|
||
a.gfx.hold() // blocks while the size is changing, real images when it has settled
|
||
time.AfterFunc(settleDelay+30*time.Millisecond, func() { a.post(func() {}) })
|
||
}
|
||
if a.gfx != nil && a.gfx.debug != nil {
|
||
a.gfx.debug(fmt.Sprintf("window: %d×%d cells, cell shape %.3f", w, h, a.cellAspect))
|
||
}
|
||
}
|
||
|
||
// writeTerminal sends raw bytes (graphics commands) to the terminal through the
|
||
// screen's own connection. UI goroutine only, so it stays in step with drawing.
|
||
func (a *App) writeTerminal(b []byte) error {
|
||
if a.screen == nil {
|
||
return fmt.Errorf("no screen yet")
|
||
}
|
||
tty, ok := a.screen.Tty()
|
||
if !ok {
|
||
return fmt.Errorf("the screen has no terminal")
|
||
}
|
||
_, err := tty.Write(b)
|
||
return err
|
||
}
|
||
|
||
// cellAspectOf measures the height of a terminal cell divided by its width in
|
||
// pixels; 2 when the terminal does not report pixel sizes.
|
||
func cellAspectOf(s tcell.Screen) float64 {
|
||
if tty, ok := s.Tty(); ok {
|
||
if ws, err := tty.WindowSize(); err == nil {
|
||
return cellAspectFromWindow(ws.Width, ws.Height, ws.PixelWidth, ws.PixelHeight)
|
||
}
|
||
}
|
||
return 2
|
||
}
|
||
|
||
// cellAspectFromWindow derives the cell shape from the window size in cells
|
||
// and pixels. The pixel size usually includes some padding around the cell
|
||
// grid, which would skew a plain division (more with a small grid, and it
|
||
// changes while the window is resized); cell sizes are whole pixels, so the
|
||
// division is rounded down, which drops that padding.
|
||
func cellAspectFromWindow(cols, rows, pixelW, pixelH int) float64 {
|
||
if cols <= 0 || rows <= 0 || pixelW <= 0 || pixelH <= 0 {
|
||
return 2
|
||
}
|
||
cw, ch := pixelW/cols, pixelH/rows
|
||
if cw <= 0 || ch <= 0 {
|
||
return 2
|
||
}
|
||
return float64(ch) / float64(cw)
|
||
}
|
||
|
||
// coverMaxHeight is how tall the stored poster copies are: real images keep
|
||
// more detail than block characters need.
|
||
func (a *App) coverMaxHeight() int {
|
||
if a.gfx != nil {
|
||
return graphicsCoverHeight
|
||
}
|
||
return storedCoverHeight
|
||
}
|
||
|
||
// 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}
|
||
// Log connection changes only, so background polling does not flood the log.
|
||
switch {
|
||
case notConfigured(err):
|
||
if !prev.known || !notConfigured(prev.err) {
|
||
a.logs.add(levelInfo, name+" is not configured; set it up in the Config view (F6)")
|
||
}
|
||
case err != nil && (!prev.known || prev.err == nil || notConfigured(prev.err)):
|
||
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()
|
||
}
|
||
|
||
// servicesWidth is the width of the connection indicators.
|
||
func servicesWidth(narrow bool) int {
|
||
if narrow {
|
||
return 14
|
||
}
|
||
return 25
|
||
}
|
||
|
||
// setNarrow switches the indicators between full names and initials; called
|
||
// from the draw handler when the terminal width crosses the threshold.
|
||
func (a *App) setNarrow(narrow bool) {
|
||
if narrow == a.narrow {
|
||
return
|
||
}
|
||
a.narrow = narrow
|
||
a.topBar.ResizeItem(a.services, servicesWidth(narrow), 0)
|
||
a.renderServices()
|
||
a.renderTabs()
|
||
}
|
||
|
||
func (a *App) renderServices() {
|
||
var s string
|
||
for _, n := range serviceNames {
|
||
short := strings.TrimSuffix(n, "nzbd") // SABnzbd -> SAB, keeps the bar narrow
|
||
if a.narrow {
|
||
short = n[:1]
|
||
if n == "SABnzbd" {
|
||
short = "SAB"
|
||
}
|
||
}
|
||
st := a.state[n]
|
||
switch {
|
||
case !st.known, notConfigured(st.err):
|
||
s += "[gray]○ " + short + "[-] "
|
||
case st.err != nil:
|
||
s += "[red]● " + short + "[-] "
|
||
default:
|
||
s += "[green]●[-] " + short + " "
|
||
}
|
||
}
|
||
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 notConfigured(st.err):
|
||
fmt.Fprintf(&b, "[gray]○ %s: not configured (Config view, F6)[-]\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()
|
||
}
|
||
|
||
// viewIndex returns the position of the view with the given name.
|
||
func (a *App) viewIndex(name string) int {
|
||
for i, v := range a.views {
|
||
if v.name == name {
|
||
return i
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// notConfigured reports whether err says a service is not set up.
|
||
func notConfigured(err error) bool {
|
||
return errors.Is(err, arr.ErrNotConfigured) || errors.Is(err, sabnzbd.ErrNotConfigured)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// Run starts the event loop and blocks until the application quits.
|
||
func (a *App) Run() error {
|
||
stop := make(chan struct{})
|
||
defer close(stop)
|
||
defer func() {
|
||
if a.gfx != nil {
|
||
a.gfx.deleteAll()
|
||
}
|
||
if a.people.dirty { // casts fetched in the last seconds
|
||
_ = a.disk.savePeople(a.peopleSnapshot())
|
||
}
|
||
}()
|
||
go a.disk.prunePosters()
|
||
// The screen draws through the theme (see themedScreen).
|
||
s, err := tcell.NewScreen()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
a.themed = newThemedScreen(s, a.theme)
|
||
a.app.SetScreen(a.themed) // initializes it
|
||
if a.themed.initErr != nil {
|
||
return a.themed.initErr
|
||
}
|
||
a.themed.DisableMouse()
|
||
a.themed.DisablePaste()
|
||
|
||
go a.watchServices(stop)
|
||
a.setNotifyDownloads(a.notifyDone)
|
||
defer a.setNotifyDownloads(false)
|
||
return a.app.Run()
|
||
}
|
||
|
||
// setTheme switches the colors while the program runs.
|
||
func (a *App) setTheme(t theme, note string) {
|
||
a.theme = t
|
||
if a.themed != nil {
|
||
a.themed.setTheme(t)
|
||
}
|
||
a.logs.add(levelInfo, note)
|
||
}
|
||
|
||
// setNotifyDownloads starts or stops the background watch for finished downloads.
|
||
func (a *App) setNotifyDownloads(on bool) {
|
||
a.notifyDone = on
|
||
if a.watchStop != nil {
|
||
a.watchStop()
|
||
a.watchStop = nil
|
||
}
|
||
if on {
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
a.watchStop = cancel
|
||
go a.watchDownloads(ctx, a.sab)
|
||
}
|
||
}
|
||
|
||
// watchServices checks all services now and then every heartbeat until stop is closed.
|
||
func (a *App) watchServices(stop <-chan struct{}) {
|
||
tick := time.NewTicker(heartbeat)
|
||
defer tick.Stop()
|
||
for {
|
||
a.probeAll()
|
||
select {
|
||
case <-tick.C:
|
||
case <-stop:
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// probeAll checks every service in the background and updates the indicators.
|
||
func (a *App) probeAll() {
|
||
for _, p := range a.probes {
|
||
go func() {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
version, err := p.check(ctx)
|
||
a.post(func() { a.SetService(p.name, version, err) })
|
||
}()
|
||
}
|
||
}
|
||
|
||
func (a *App) renderStatus() {
|
||
var hints string
|
||
if n := len(a.overlays); n > 0 {
|
||
hints = a.overlays[n-1].hints + " · Ctrl+C quit"
|
||
} else {
|
||
v := a.views[a.active]
|
||
var parts []string
|
||
if v.hints != nil {
|
||
parts = append(parts, v.hints())
|
||
}
|
||
parts = append(parts, "F9 help · F1-F7 · Ctrl+C quit")
|
||
hints = strings.Join(parts, " · ")
|
||
}
|
||
a.status.SetText("[gray]" + hints)
|
||
}
|
||
|
||
// pushOverlay stacks p above everything and focuses focus.
|
||
func (a *App) pushOverlay(p, focus tview.Primitive, hints string, full bool, onClose func()) {
|
||
name := fmt.Sprintf("overlay-%d", len(a.overlays))
|
||
a.overlays = append(a.overlays, overlay{page: name, root: p, focus: a.app.GetFocus(), hints: hints, full: full, close: onClose})
|
||
a.pages.AddPage(name, p, true, true)
|
||
a.app.SetFocus(focus)
|
||
a.renderStatus()
|
||
}
|
||
|
||
// openModal shows p centered above the current view.
|
||
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().
|
||
AddItem(nil, 0, 1, false).
|
||
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
||
AddItem(nil, 0, 1, false).
|
||
AddItem(p, height, 0, true).
|
||
AddItem(nil, 0, 1, false), width, 0, true).
|
||
AddItem(nil, 0, 1, false)
|
||
a.pushOverlay(box, p, hints, false, nil)
|
||
}
|
||
|
||
// openSidePanel shows p at the top right so most of the view behind it stays
|
||
// visible, for dialogs whose changes are applied live.
|
||
func (a *App) openSidePanel(p tview.Primitive, width, height int, hints string, onClose func()) {
|
||
box := tview.NewFlex().
|
||
AddItem(nil, 0, 1, false).
|
||
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
||
AddItem(p, height, 0, true).
|
||
AddItem(nil, 0, 1, false), width, 0, true)
|
||
a.pushOverlay(box, p, hints, false, onClose)
|
||
}
|
||
|
||
// openFull shows p above the whole view area (the tab bar and status bar stay visible).
|
||
func (a *App) openFull(p tview.Primitive, hints string, onClose func()) {
|
||
a.pushOverlay(p, p, hints, true, onClose)
|
||
}
|
||
|
||
// closeIfTop closes the topmost overlay if it is p (and not something opened
|
||
// on top of it since).
|
||
func (a *App) closeIfTop(p tview.Primitive) {
|
||
if n := len(a.overlays); n > 0 && a.overlays[n-1].root == p {
|
||
a.closeModal()
|
||
}
|
||
}
|
||
|
||
// closeModal removes the topmost overlay and restores the previous focus.
|
||
func (a *App) closeModal() {
|
||
if len(a.overlays) == 0 {
|
||
return
|
||
}
|
||
top := a.overlays[len(a.overlays)-1]
|
||
a.overlays = a.overlays[:len(a.overlays)-1]
|
||
a.pages.RemovePage(top.page)
|
||
if top.close != nil {
|
||
top.close()
|
||
}
|
||
if top.focus != nil {
|
||
a.app.SetFocus(top.focus)
|
||
}
|
||
a.renderStatus()
|
||
}
|
||
|
||
// confirm asks a yes/no question; yes runs only if the user picks Delete/Yes.
|
||
func (a *App) confirm(text string, yes func()) {
|
||
lines := strings.Count(text, "\n") + 1 + len([]rune(text))/44
|
||
m := tview.NewModal().
|
||
SetText(text).
|
||
AddButtons([]string{"Cancel", "Confirm"}). // Cancel first so a stray Enter is safe
|
||
SetDoneFunc(func(i int, _ string) {
|
||
a.closeModal()
|
||
if i == 1 {
|
||
yes()
|
||
}
|
||
})
|
||
a.openModal(m, 60, 7+min(lines, 10))
|
||
}
|
||
|
||
// onlyFullOverlays is true when no dialog is open (full-screen views may be).
|
||
func (a *App) onlyFullOverlays() bool {
|
||
for _, o := range a.overlays {
|
||
if !o.full {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
||
if ev.Key() == tcell.KeyCtrlC {
|
||
a.app.Stop()
|
||
return nil
|
||
}
|
||
// Shortcuts that also work while typing in a search box. Dialogs block them;
|
||
// full-screen views (release list, episodes) are closed by a view switch.
|
||
if a.onlyFullOverlays() {
|
||
if ev.Key() == tcell.KeyF9 && len(a.overlays) == 0 {
|
||
a.showHelp()
|
||
return nil
|
||
}
|
||
if i := a.viewShortcut(ev); i >= 0 && i < len(a.views) {
|
||
for len(a.overlays) > 0 {
|
||
a.closeModal()
|
||
}
|
||
a.switchTo(i)
|
||
return nil
|
||
}
|
||
}
|
||
return ev
|
||
}
|
||
|
||
// viewShortcut returns the view index for F1-F7, or -1.
|
||
func (a *App) viewShortcut(ev *tcell.EventKey) int {
|
||
if ev.Key() >= tcell.KeyF1 && ev.Key() <= tcell.KeyF12 {
|
||
return int(ev.Key() - tcell.KeyF1)
|
||
}
|
||
return -1
|
||
}
|
||
|
||
func (a *App) switchTo(i int) {
|
||
if hide := a.views[a.active].onHide; hide != nil && i != a.active {
|
||
hide()
|
||
}
|
||
if show := a.views[i].onShow; show != nil && i != a.active {
|
||
show()
|
||
}
|
||
a.active = i
|
||
a.pages.SwitchToPage(a.views[i].name)
|
||
a.renderTabs()
|
||
a.renderStatus()
|
||
}
|
||
|
||
func (a *App) renderTabs() {
|
||
var s string
|
||
for i, v := range a.views {
|
||
switch {
|
||
case a.narrow && i == a.active:
|
||
s += fmt.Sprintf("[black:white]%c %s[-:-] ", v.key, v.name)
|
||
case a.narrow:
|
||
s += fmt.Sprintf("[gray]%c %s[-] ", v.key, v.name)
|
||
case i == a.active:
|
||
s += fmt.Sprintf("[black:white] %c %s [-:-] ", v.key, v.name)
|
||
default:
|
||
s += fmt.Sprintf("[gray] %c %s [-]", v.key, v.name)
|
||
}
|
||
}
|
||
a.tabs.SetText(s)
|
||
}
|
||
|
||
func searchHints() string {
|
||
return "/ search · [ ] lists · Tab list/search · Enter add · i releases"
|
||
}
|
||
|
||
func libraryHints() string {
|
||
return "Tab list · f status · F props · / text · t monitor · s search · d remove"
|
||
}
|
||
|
||
func calendarHints() string {
|
||
return "[ ] week · t today · u unmonitored · Enter releases · s search · r reload"
|
||
}
|
||
|
||
func logHints() string { return "↑↓ scroll · G follow · c clear" }
|