Add interactive release search (M6)
Release listing and grab in the shared arr client, a full-screen release list (sort, hide rejected, rejection reasons, confirmation before grabbing rejected releases; Usenet only), and a season/episode picker for Sonarr. Overlays are now a stack so dialogs can open above the release list. Add a fake Newznab indexer to the dev setup. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+42
-18
@@ -3,6 +3,7 @@ package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
@@ -14,8 +15,6 @@ import (
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
|
||||
)
|
||||
|
||||
const modalPage = "modal"
|
||||
|
||||
type view struct {
|
||||
key rune
|
||||
name string
|
||||
@@ -33,9 +32,14 @@ type App struct {
|
||||
views []view
|
||||
active int
|
||||
|
||||
message string
|
||||
modalOpen bool
|
||||
modalFocus tview.Primitive
|
||||
message string
|
||||
overlays []overlay // dialogs and full-screen views stacked above the pages
|
||||
}
|
||||
|
||||
// overlay is a page stacked above the main views; focus is restored on close.
|
||||
type overlay struct {
|
||||
page string
|
||||
focus tview.Primitive
|
||||
}
|
||||
|
||||
// New builds the application shell: a tab bar, one page per view and a status bar.
|
||||
@@ -96,6 +100,9 @@ func (a *App) Run() error {
|
||||
|
||||
func (a *App) renderStatus() {
|
||||
hints := "[gray]q quit · Tab/1-4/F1-F4 views · / search"
|
||||
if len(a.overlays) > 0 {
|
||||
hints = "[gray]Esc back · Ctrl+C quit"
|
||||
}
|
||||
if a.message == "" {
|
||||
a.status.SetText(hints)
|
||||
return
|
||||
@@ -103,10 +110,17 @@ func (a *App) renderStatus() {
|
||||
a.status.SetText(a.message + " [gray]│ " + hints)
|
||||
}
|
||||
|
||||
// openModal shows p centered over the current view and remembers the focus.
|
||||
// pushOverlay stacks p above everything and focuses focus.
|
||||
func (a *App) pushOverlay(p, focus tview.Primitive) {
|
||||
name := fmt.Sprintf("overlay-%d", len(a.overlays))
|
||||
a.overlays = append(a.overlays, overlay{page: name, focus: a.app.GetFocus()})
|
||||
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.modalFocus = a.app.GetFocus()
|
||||
a.modalOpen = true
|
||||
box := tview.NewFlex().
|
||||
AddItem(nil, 0, 1, false).
|
||||
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
@@ -114,21 +128,31 @@ func (a *App) openModal(p tview.Primitive, width, height int) {
|
||||
AddItem(p, height, 0, true).
|
||||
AddItem(nil, 0, 1, false), width, 0, true).
|
||||
AddItem(nil, 0, 1, false)
|
||||
a.pages.AddPage(modalPage, box, true, true)
|
||||
a.app.SetFocus(p)
|
||||
a.pushOverlay(box, p)
|
||||
}
|
||||
|
||||
// openFull shows p above the whole view area (the tab bar and status bar stay visible).
|
||||
func (a *App) openFull(p tview.Primitive) {
|
||||
a.pushOverlay(p, p)
|
||||
}
|
||||
|
||||
// closeModal removes the topmost overlay and restores the previous focus.
|
||||
func (a *App) closeModal() {
|
||||
a.pages.RemovePage(modalPage)
|
||||
a.modalOpen = false
|
||||
if a.modalFocus != nil {
|
||||
a.app.SetFocus(a.modalFocus)
|
||||
a.modalFocus = nil
|
||||
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.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
|
||||
@@ -138,13 +162,13 @@ func (a *App) confirm(text string, yes func()) {
|
||||
yes()
|
||||
}
|
||||
})
|
||||
a.openModal(m, 60, 9)
|
||||
a.openModal(m, 60, 7+min(lines, 10))
|
||||
}
|
||||
|
||||
// typing reports whether keystrokes belong to a text field or dialog and must
|
||||
// not trigger global shortcuts.
|
||||
func (a *App) typing() bool {
|
||||
if a.modalOpen {
|
||||
if len(a.overlays) > 0 {
|
||||
return true
|
||||
}
|
||||
_, isInput := a.app.GetFocus().(*tview.InputField)
|
||||
@@ -157,7 +181,7 @@ func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
||||
return nil
|
||||
}
|
||||
// View switching that also works while typing in a search box. Not while a dialog is open.
|
||||
if !a.modalOpen {
|
||||
if len(a.overlays) == 0 {
|
||||
if i := a.viewShortcut(ev); i >= 0 && i < len(a.views) {
|
||||
a.switchTo(i)
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user