With a Trakt client ID, the details of a movie or series show the director(s) or creators and up to 8 cast members with their roles below the plot, in every view. The title is found on Trakt by its TMDb/TVDB (else IMDb) id, then its people are read. Casts are cached and, like posters, only loaded once the selection rests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
685 lines
21 KiB
Go
685 lines
21 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
|
||
// 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
|
||
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)
|
||
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.people = newPeopleCache()
|
||
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, 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()
|
||
}
|
||
}()
|
||
// 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, 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)
|
||
}
|
||
|
||
// 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" }
|