Quit with Ctrl+C only; q is an ordinary key everywhere
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- **Ctrl+C quits**; `q` no longer does (also not in the help window, which closes with Esc).
|
||||||
- **Series drill-down** shows the series poster and details of the selected season or episode,
|
- **Series drill-down** shows the series poster and details of the selected season or episode,
|
||||||
including the episode's plot.
|
including the episode's plot.
|
||||||
- **Posters in WebP and GIF:** Sonarr/Radarr can store WebP data under a `.jpg` name; such posters
|
- **Posters in WebP and GIF:** Sonarr/Radarr can store WebP data under a `.jpg` name; such posters
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ Keep the file private (`chmod 600`); it holds your API keys.
|
|||||||
| `Enter`/`g`, `s`, `f`, `F` (release list) | grab, cycle sort, hide rejected, filter by properties |
|
| `Enter`/`g`, `s`, `f`, `F` (release list) | grab, cycle sort, hide rejected, filter by properties |
|
||||||
| `p`, `d`, `t`, `b`, `r` (downloads) | pause, delete, retry, blocklist, refresh |
|
| `p`, `d`, `t`, `b`, `r` (downloads) | pause, delete, retry, blocklist, refresh |
|
||||||
| `Esc` | back / close dialog |
|
| `Esc` | back / close dialog |
|
||||||
| `q`, `Ctrl+C` | quit |
|
| `Ctrl+C` | quit |
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ Notes:
|
|||||||
Posters are JPEG, PNG, GIF or WebP (Radarr and Sonarr keep whatever bytes the provider sent under a `.jpg` name, which can be WebP), decoded and downscaled in the process; for any other format the Log says what the data looked like (e.g. AVIF, an HTML error page, or its first bytes). Library items fetch `/MediaCover/<id>/poster-250.jpg` from their Radarr/Sonarr instance with the API key (then the original `poster.jpg`; an instance that answers 200 with an HTML page, i.e. wants a login for `/MediaCover`, is also tried with the key as the `apikey` query parameter; and if the instance has no usable copy, the provider's `remoteUrl`, fetched without credentials); search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory (the terminal's copies are deleted when the cache is dropped and on exit), failed downloads are not retried for two minutes and are written to the Log (F7) with the reason, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it.
|
Posters are JPEG, PNG, GIF or WebP (Radarr and Sonarr keep whatever bytes the provider sent under a `.jpg` name, which can be WebP), decoded and downscaled in the process; for any other format the Log says what the data looked like (e.g. AVIF, an HTML error page, or its first bytes). Library items fetch `/MediaCover/<id>/poster-250.jpg` from their Radarr/Sonarr instance with the API key (then the original `poster.jpg`; an instance that answers 200 with an HTML page, i.e. wants a login for `/MediaCover`, is also tried with the key as the `apikey` query parameter; and if the instance has no usable copy, the provider's `remoteUrl`, fetched without credentials); search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory (the terminal's copies are deleted when the cache is dropped and on exit), failed downloads are not retried for two minutes and are written to the Log (F7) with the reason, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it.
|
||||||
- **Calendar view (F5):** a two-week window (starting today, moved a week at a time with `[`/`]`, `t` returns to today) with the library's movie releases and airing episodes, grouped by day. Radarr `GET /calendar` supplies the cinema, digital and physical release dates (taken as written: midnight UTC means that calendar day) and Sonarr `GET /calendar?includeSeries=true` the episodes with their air time (shown in local time). Unmonitored items are included with `u`. A movie appears once per release date in the window. Each entry shows type, title, episode or release kind and status (downloaded / missing / upcoming); the detail pane shows poster and plot; `Enter`/`i` starts the release search and `s` an automatic search.
|
- **Calendar view (F5):** a two-week window (starting today, moved a week at a time with `[`/`]`, `t` returns to today) with the library's movie releases and airing episodes, grouped by day. Radarr `GET /calendar` supplies the cinema, digital and physical release dates (taken as written: midnight UTC means that calendar day) and Sonarr `GET /calendar?includeSeries=true` the episodes with their air time (shown in local time). Unmonitored items are included with `u`. A movie appears once per release date in the window. Each entry shows type, title, episode or release kind and status (downloaded / missing / upcoming); the detail pane shows poster and plot; `Enter`/`i` starts the release search and `s` an automatic search.
|
||||||
- **Download notifications:** a background poller (independent of the visible view) reads SABnzbd's history every 15 s and announces downloads that reach `Completed` or `Failed` as a popup and log entry; the first poll only records the current state. `ui.notify_downloads` (default true) and `ui.bell` (default false, rings the terminal bell) control it. Connection errors are not reported here, the indicators and the log already do that.
|
- **Download notifications:** a background poller (independent of the visible view) reads SABnzbd's history every 15 s and announces downloads that reach `Completed` or `Failed` as a popup and log entry; the first poll only records the current state. `ui.notify_downloads` (default true) and `ui.bell` (default false, rings the terminal bell) control it. Connection errors are not reported here, the indicators and the log already do that.
|
||||||
- **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The `Test` button is part of the list, right below the SABnzbd settings; `Save` and `Revert` are centered in a bar below the list. The fields start from the settings in use; API keys are masked (a checkbox shows them). `Test` builds temporary clients from the typed values and reports each service. `Save` validates like startup does (URLs, non-empty keys, refresh 1–3600 s), then writes the file from the file's own contents plus the edits (`config.LoadFile`), so values that only come from `ARR_TUI_*` environment variables are left as the file has them and are shown greyed out; the write is atomic (temp file + rename) and keeps the file's permissions (`0600` when new); defaults such as `covers: true` stay out of the file unless it already named them. Everything then applies without a restart: the Radarr/Sonarr/SABnzbd clients are re-pointed (`Reconfigure`, guarded by a mutex), the connection indicators, add-form defaults and options are refreshed, the library and calendar reload, the Downloads interval, notifications, bell and poster style are switched live. `q` is typed text in this view (`F9` opens the help).
|
- **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The `Test` button is part of the list, right below the SABnzbd settings; `Save` and `Revert` are centered in a bar below the list. The fields start from the settings in use; API keys are masked (a checkbox shows them). `Test` builds temporary clients from the typed values and reports each service. `Save` validates like startup does (URLs, non-empty keys, refresh 1–3600 s), then writes the file from the file's own contents plus the edits (`config.LoadFile`), so values that only come from `ARR_TUI_*` environment variables are left as the file has them and are shown greyed out; the write is atomic (temp file + rename) and keeps the file's permissions (`0600` when new); defaults such as `covers: true` stay out of the file unless it already named them. Everything then applies without a restart: the Radarr/Sonarr/SABnzbd clients are re-pointed (`Reconfigure`, guarded by a mutex), the connection indicators, add-form defaults and options are refreshed, the library and calendar reload, the Downloads interval, notifications, bell and poster style are switched live.
|
||||||
- **Notifications and log (F7):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll.
|
- **Notifications and log (F7):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll.
|
||||||
|
|
||||||
### 4.2 v0.2 (done, M8)
|
### 4.2 v0.2 (done, M8)
|
||||||
@@ -97,7 +97,7 @@ Layout sketch (Library view; the other views follow the same frame):
|
|||||||
│ ● Show One (2008) 12/62 │ │
|
│ ● Show One (2008) 12/62 │ │
|
||||||
│ ○ Show Two (2015) 10/10 │ │
|
│ ○ Show Two (2015) 10/10 │ │
|
||||||
├────────────────────────────────┴───────────────────────────────────┤
|
├────────────────────────────────┴───────────────────────────────────┤
|
||||||
│ Tab movies/series · f filter · / text · t monitor · F9 help · q quit │ ← key hints of the current view
|
│ Tab movies/series · f filter · / text · t monitor · F9 help · Ctrl+C quit │ ← key hints of the current view
|
||||||
└────────────────────────────────────────────────────────────────────┘
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ Keybindings (the status bar shows the ones that apply to the current view):
|
|||||||
| `F1-F7` | Switch view (Movies, Series, Library, Downloads, Calendar, Config, Log); also while typing, and from full-screen views. Dialogs block them. |
|
| `F1-F7` | Switch view (Movies, Series, Library, Downloads, Calendar, Config, Log); also while typing, and from full-screen views. Dialogs block them. |
|
||||||
| `Tab` / `Shift+Tab` | Switch panel within the current view (never the view): search box / results, Downloads panels, Library lists |
|
| `Tab` / `Shift+Tab` | Switch panel within the current view (never the view): search box / results, Downloads panels, Library lists |
|
||||||
| `F9` | Help (works everywhere, also while typing) |
|
| `F9` | Help (works everywhere, also while typing) |
|
||||||
| `q` / `Ctrl+C` | Quit |
|
| `Ctrl+C` | Quit (there is no single-letter quit key, so typing never quits by accident) |
|
||||||
| `Esc` | Close a dialog or a full-screen view |
|
| `Esc` | Close a dialog or a full-screen view |
|
||||||
| `/` | Movies, Series, Library: focus the search / text filter |
|
| `/` | Movies, Series, Library: focus the search / text filter |
|
||||||
| `Enter` / `a` | Movies, Series: add the selected result (dialog: ↑↓ move, ←→ change dropdowns) |
|
| `Enter` / `a` | Movies, Series: add the selected result (dialog: ↑↓ move, ←→ change dropdowns) |
|
||||||
|
|||||||
+2
-29
@@ -25,9 +25,6 @@ type view struct {
|
|||||||
onShow, onHide func()
|
onShow, onHide func()
|
||||||
// hints lists the keys that work in this view (shown in the status bar).
|
// hints lists the keys that work in this view (shown in the status bar).
|
||||||
hints func() string
|
hints func() string
|
||||||
// captureKeys makes the global single-key shortcut (q) stands back
|
|
||||||
// while this view is shown, because its fields take typed text.
|
|
||||||
captureKeys bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// App is the top-level arr-tui application.
|
// App is the top-level arr-tui application.
|
||||||
@@ -160,7 +157,7 @@ func New(cfg *config.Config) *App {
|
|||||||
{key: '3', name: "Library", body: library.root, onShow: library.activate, hints: libraryHints},
|
{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: '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: '5', name: "Calendar", body: calendar.root, onShow: calendar.activate, hints: calendarHints},
|
||||||
{key: '6', name: "Config", body: settings.root, onShow: settings.activate, hints: settingsHints, captureKeys: true},
|
{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},
|
{key: '7', name: "Log", body: a.logs.root, onShow: a.logs.onShow, hints: logHints},
|
||||||
}
|
}
|
||||||
for i, v := range a.views {
|
for i, v := range a.views {
|
||||||
@@ -439,11 +436,7 @@ func (a *App) renderStatus() {
|
|||||||
if v.hints != nil {
|
if v.hints != nil {
|
||||||
parts = append(parts, v.hints())
|
parts = append(parts, v.hints())
|
||||||
}
|
}
|
||||||
if v.captureKeys { // q is text there
|
parts = append(parts, "F9 help · F1-F7 · Ctrl+C quit")
|
||||||
parts = append(parts, "F9 help · F1-F7 views")
|
|
||||||
} else {
|
|
||||||
parts = append(parts, "F9 help · F1-F7 · q quit")
|
|
||||||
}
|
|
||||||
hints = strings.Join(parts, " · ")
|
hints = strings.Join(parts, " · ")
|
||||||
}
|
}
|
||||||
a.status.SetText("[gray]" + hints)
|
a.status.SetText("[gray]" + hints)
|
||||||
@@ -533,16 +526,6 @@ func (a *App) onlyFullOverlays() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// typing reports whether keystrokes belong to a text field or dialog and must
|
|
||||||
// not trigger global shortcuts.
|
|
||||||
func (a *App) typing() bool {
|
|
||||||
if len(a.overlays) > 0 || a.views[a.active].captureKeys {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
_, isInput := a.app.GetFocus().(*tview.InputField)
|
|
||||||
return isInput
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
||||||
if ev.Key() == tcell.KeyCtrlC {
|
if ev.Key() == tcell.KeyCtrlC {
|
||||||
a.app.Stop()
|
a.app.Stop()
|
||||||
@@ -563,16 +546,6 @@ func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if a.typing() {
|
|
||||||
return ev
|
|
||||||
}
|
|
||||||
switch ev.Key() {
|
|
||||||
case tcell.KeyRune:
|
|
||||||
if ev.Rune() == 'q' {
|
|
||||||
a.app.Stop()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ev
|
return ev
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,3 +38,11 @@ func TestOnlyFunctionKeysSwitchViews(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQDoesNotQuit(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
ev := tcell.NewEventKey(tcell.KeyRune, 'q', 0)
|
||||||
|
if got := a.handleKey(ev); got != ev {
|
||||||
|
t.Error("q must be passed on as an ordinary key, not taken as quit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
-4
@@ -22,7 +22,7 @@ var helpSections = []helpSection{
|
|||||||
{"F1-F7", "switch view (also from the release and episode lists)"},
|
{"F1-F7", "switch view (also from the release and episode lists)"},
|
||||||
{"Tab, Shift+Tab", "switch panel within a view"},
|
{"Tab, Shift+Tab", "switch panel within a view"},
|
||||||
{"F9", "this help (works everywhere, also while typing)"},
|
{"F9", "this help (works everywhere, also while typing)"},
|
||||||
{"q, Ctrl+C", "quit"},
|
{"Ctrl+C", "quit"},
|
||||||
{"Esc", "close a dialog / go back"},
|
{"Esc", "close a dialog / go back"},
|
||||||
}},
|
}},
|
||||||
{title: "Movies and Series", items: []helpItem{
|
{title: "Movies and Series", items: []helpItem{
|
||||||
@@ -72,7 +72,6 @@ var helpSections = []helpSection{
|
|||||||
{"Space", "toggle a checkbox; press a button"},
|
{"Space", "toggle a checkbox; press a button"},
|
||||||
{"Test", "(button below the SABnzbd settings) checks the three services with the values typed in"},
|
{"Test", "(button below the SABnzbd settings) checks the three services with the values typed in"},
|
||||||
{"Save, Revert", "(at the bottom) Save writes the file and applies everything at once; Revert discards edits"},
|
{"Save, Revert", "(at the bottom) Save writes the file and applies everything at once; Revert discards edits"},
|
||||||
{"q", "is typed here as text (it does not quit); F9 opens this help"},
|
|
||||||
}},
|
}},
|
||||||
{title: "Log (F7)", note: "Popups fade away by themselves; everything they said is kept here.", items: []helpItem{
|
{title: "Log (F7)", note: "Popups fade away by themselves; everything they said is kept here.", items: []helpItem{
|
||||||
{"↑↓, PgUp, PgDn", "scroll"},
|
{"↑↓, PgUp, PgDn", "scroll"},
|
||||||
@@ -138,12 +137,12 @@ func (a *App) showHelp() {
|
|||||||
tv.SetBorderPadding(0, 0, 1, 0)
|
tv.SetBorderPadding(0, 0, 1, 0)
|
||||||
tv.SetText(text)
|
tv.SetText(text)
|
||||||
tv.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
|
tv.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
|
||||||
if ev.Key() == tcell.KeyEsc || (ev.Key() == tcell.KeyRune && ev.Rune() == 'q') {
|
if ev.Key() == tcell.KeyEsc {
|
||||||
a.closeModal()
|
a.closeModal()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return ev
|
return ev
|
||||||
})
|
})
|
||||||
height := min(screenH-2, strings.Count(text, "\n")+2)
|
height := min(screenH-2, strings.Count(text, "\n")+2)
|
||||||
a.openModalHints(tv, width, height, "↑↓ PgUp PgDn scroll · Esc or q close")
|
a.openModalHints(tv, width, height, "↑↓ PgUp PgDn scroll · Esc close")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user