From 6668f91e7ae7c9db9d99f414e1db83afcb63a565 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 20 Oct 2026 19:57:05 +0200 Subject: [PATCH] F1-F5 leave full-screen views; dialogs still block them Co-Authored-By: Claude Sonnet 5.5 --- internal/ui/app.go | 35 +++++++++++++++++++++++++++-------- internal/ui/episodes.go | 2 +- internal/ui/help.go | 2 +- internal/ui/notify_test.go | 15 +++++++++++++++ internal/ui/releases.go | 5 ++--- 5 files changed, 46 insertions(+), 13 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 1b8e97c..6162f9f 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -58,6 +58,8 @@ type overlay struct { page string focus tview.Primitive hints string + full bool // full-screen view (can be left with F1-F5), 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. @@ -185,9 +187,9 @@ func (a *App) renderStatus() { } // pushOverlay stacks p above everything and focuses focus. -func (a *App) pushOverlay(p, focus tview.Primitive, hints string) { +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}) + 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() @@ -202,12 +204,12 @@ 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.pushOverlay(box, p, "↑↓ move · ←→ change · Enter select · Esc cancel") + a.pushOverlay(box, p, "↑↓ move · ←→ change · Enter select · Esc cancel", false, nil) } // 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) { - a.pushOverlay(p, p, hints) +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. @@ -218,6 +220,9 @@ func (a *App) closeModal() { 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) } @@ -239,6 +244,16 @@ func (a *App) confirm(text string, yes func()) { 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 +} + // typing reports whether keystrokes belong to a text field or dialog and must // not trigger global shortcuts. func (a *App) typing() bool { @@ -254,13 +269,17 @@ func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey { a.app.Stop() return nil } - // Shortcuts that also work while typing in a search box. Not while a dialog is open. - if len(a.overlays) == 0 { - if ev.Key() == tcell.KeyF9 { + // 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 } diff --git a/internal/ui/episodes.go b/internal/ui/episodes.go index 763e28f..0a871ee 100644 --- a/internal/ui/episodes.go +++ b/internal/ui/episodes.go @@ -86,7 +86,7 @@ func (a *App) openSeries(c *sonarr.Client, s sonarr.Series, onChange func()) { }) v.render() - a.openFull(v.root, "Enter expand/releases · i releases · s search · t monitor · r reload · Esc back") + a.openFull(v.root, "Enter expand/releases · i releases · s search · t monitor · r reload · Esc back", nil) v.reload() } diff --git a/internal/ui/help.go b/internal/ui/help.go index 59a3e76..b0121c7 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -6,7 +6,7 @@ import ( ) const helpText = `[yellow]Global[-] - F1-F5 switch view + F1-F5 switch view (also from the release and episode lists) Tab, Shift+Tab switch panel within a view ?, F9 this help (F9 works while typing) q, Ctrl+C quit diff --git a/internal/ui/notify_test.go b/internal/ui/notify_test.go index f6e30ae..87a27a0 100644 --- a/internal/ui/notify_test.go +++ b/internal/ui/notify_test.go @@ -24,3 +24,18 @@ func TestToastProgress(t *testing.T) { t.Error("progress detection wrong") } } + +func TestOnlyFullOverlays(t *testing.T) { + a := &App{} + if !a.onlyFullOverlays() { + t.Error("no overlays: view shortcuts must work") + } + a.overlays = []overlay{{full: true}, {full: true}} + if !a.onlyFullOverlays() { + t.Error("full-screen views must not block view shortcuts") + } + a.overlays = append(a.overlays, overlay{}) + if a.onlyFullOverlays() { + t.Error("a dialog must block view shortcuts") + } +} diff --git a/internal/ui/releases.go b/internal/ui/releases.go index 373efbe..69e5876 100644 --- a/internal/ui/releases.go +++ b/internal/ui/releases.go @@ -83,7 +83,7 @@ func (a *App) openReleases(title string, fetch func(context.Context) ([]arr.Rele ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) v.cancel = cancel v.render() // shows the "searching" placeholder - a.openFull(v.root, "Enter/g grab · s sort · f hide rejected · Esc back") + a.openFull(v.root, "Enter/g grab · s sort · f hide rejected · Esc back", v.cancel) go func() { releases, err := fetch(ctx) @@ -102,8 +102,7 @@ func (a *App) openReleases(title string, fetch func(context.Context) ([]arr.Rele } func (v *releasesView) close() { - v.cancel() - v.app.closeModal() + v.app.closeModal() // cancels the running search via the overlay's cleanup } func (v *releasesView) setReleases(releases []arr.Release) {