Colors no longer come from the terminal's palette, whose color 0 many color themes set to a grey that is not their background. A theme sets tview's default styles and the color names used in texts, all chosen from entries 16-255 of the 256-color table so terminals without 24-bit color show them exactly. "auto" picks light or dark from the background color the terminal reports. Settable in the Config view, applied at the next start (widgets copy their colors when created). Replaces using the terminal's own background and text colors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
617 lines
19 KiB
Go
617 lines
19 KiB
Go
// Package ui contains the tview user interface.
|
||
package ui
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gdamore/tcell/v2"
|
||
"github.com/rivo/tview"
|
||
|
||
"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"
|
||
)
|
||
|
||
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
|
||
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())
|
||
th.apply()
|
||
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.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.views = []view{
|
||
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}).root, hints: searchHints},
|
||
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}).root, 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)
|
||
a.app.SetInputCapture(a.handleKey)
|
||
a.switchTo(0)
|
||
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 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()
|
||
}
|
||
|
||
// 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:
|
||
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 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()
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
}()
|
||
go a.watchServices(stop)
|
||
a.setNotifyDownloads(a.notifyDone)
|
||
defer a.setNotifyDownloads(false)
|
||
return a.app.Run()
|
||
}
|
||
|
||
// 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 · 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" }
|