Revert the scrolling calendar: back to moving the two-week window with [ and ]
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+2
-3
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
- **Calendar (F5):** a scrolling list of movie releases and airing episodes grouped by day, starting
|
- **Calendar (F5):** two weeks of movie releases and airing episodes grouped by day, with poster,
|
||||||
at today and loading more days as you scroll, with poster, plot, release search and automatic
|
plot, release search and automatic search. The Log moves to F6.
|
||||||
search. The Log moves to F6.
|
|
||||||
- **Download notifications:** popup and log entry when a SABnzbd download finishes or fails, in the
|
- **Download notifications:** popup and log entry when a SABnzbd download finishes or fails, in the
|
||||||
background (`ui.notify_downloads`, optional `ui.bell`).
|
background (`ui.notify_downloads`, optional `ui.bell`).
|
||||||
- **Remove from the library** (`d`), with separate checkboxes for deleting the files and for the
|
- **Remove from the library** (`d`), with separate checkboxes for deleting the files and for the
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
|
|||||||
- **Library**: browse movies (top) and series (below) with a details pane on the right, filter by status (missing / downloaded / monitored) and by file properties (quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), toggle
|
- **Library**: browse movies (top) and series (below) with a details pane on the right, filter by status (missing / downloaded / monitored) and by file properties (quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), toggle
|
||||||
monitoring, trigger searches, remove titles (the files stay on disk unless you tick "Delete files"). Series open into seasons and episodes with per-season and
|
monitoring, trigger searches, remove titles (the files stay on disk unless you tick "Delete files"). Series open into seasons and episodes with per-season and
|
||||||
per-episode monitoring, automatic search and release search.
|
per-episode monitoring, automatic search and release search.
|
||||||
- **Calendar**: movie releases (cinemas, digital, physical) and airing episodes in one scrolling list
|
- **Calendar**: the next two weeks of movie releases (cinemas, digital, physical) and airing episodes
|
||||||
grouped by day, starting at today, with poster and plot. More days load as you scroll; include
|
in one list grouped by day, with poster and plot. Move by weeks, include unmonitored items, search
|
||||||
unmonitored items, search for releases or start an automatic search from there.
|
for releases or start an automatic search from there.
|
||||||
- **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete,
|
- **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete,
|
||||||
retry failed downloads, remove from the arr queue and blocklist.
|
retry failed downloads, remove from the arr queue and blocklist.
|
||||||
- **Log (F6)**: actions report through popups that fade away; the log keeps the history.
|
- **Log (F6)**: actions report through popups that fade away; the log keeps the history.
|
||||||
@@ -73,7 +73,7 @@ Keep the file private (`chmod 600`); it holds your API keys.
|
|||||||
| `Enter`, `a` | add selected title |
|
| `Enter`, `a` | add selected title |
|
||||||
| `i` | interactive release search |
|
| `i` | interactive release search |
|
||||||
| `Tab`, `f`, `F`, `/`, `t`, `s`, `d`, `i`/`Enter`, `r` (library) | movies/series list, status filter, file-property filter, text filter, monitor, search, remove, releases/episodes, reload |
|
| `Tab`, `f`, `F`, `/`, `t`, `s`, `d`, `i`/`Enter`, `r` (library) | movies/series list, status filter, file-property filter, text filter, monitor, search, remove, releases/episodes, reload |
|
||||||
| `t`, `u`, `Enter`, `s` (calendar) | today, unmonitored, releases, search; scroll for more days |
|
| `[`, `]`, `t`, `u`, `Enter`, `s` (calendar) | week back / forward, today, unmonitored, releases, search |
|
||||||
| `Enter`, `i`, `s`, `t` (episodes) | expand season / releases, release search, search, monitor |
|
| `Enter`, `i`, `s`, `t` (episodes) | expand season / releases, release search, search, monitor |
|
||||||
| `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 |
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ Notes:
|
|||||||
- Global speed / pause state in the status bar. Pause/resume and delete a SABnzbd job.
|
- Global speed / pause state in the status bar. Pause/resume and delete a SABnzbd job.
|
||||||
- **Status bar / help:** the bottom bar lists the keys that work in the current view or panel; the top right shows the connection state of each service; `?` opens the full key reference.
|
- **Status bar / help:** the bottom bar lists the keys that work in the current view or panel; the top right shows the connection state of each service; `?` opens the full key reference.
|
||||||
- **Posters:** the detail panes show the poster at the top right; the head (title, facts, path) and the plot are one text that flows along the poster's left side and continues at full width below it (text is wrapped by arr-tui itself, with a narrower width for the first lines); on panes too small for the poster, the text simply uses the full width. The poster is drawn with block characters in 24-bit colors: every cell shows several pixels in the best two-color split (`quadrant`: 2×2 pixels, glyphs from the Block Elements range every font has; `sextant`: 2×3 pixels, sharper, glyphs from Symbols for Legacy Computing). `ui.cover_style` is `auto` (default: sextant on kitty, WezTerm, foot, Ghostty, quadrant elsewhere), `quadrant` or `sextant`. JPEG/PNG only, decoded and downscaled in the process, so no terminal graphics protocol is needed. Library items fetch `/MediaCover/<id>/poster-250.jpg` from their Radarr/Sonarr instance with the API key; search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory, failed downloads are not retried for two minutes, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it.
|
- **Posters:** the detail panes show the poster at the top right; the head (title, facts, path) and the plot are one text that flows along the poster's left side and continues at full width below it (text is wrapped by arr-tui itself, with a narrower width for the first lines); on panes too small for the poster, the text simply uses the full width. The poster is drawn with block characters in 24-bit colors: every cell shows several pixels in the best two-color split (`quadrant`: 2×2 pixels, glyphs from the Block Elements range every font has; `sextant`: 2×3 pixels, sharper, glyphs from Symbols for Legacy Computing). `ui.cover_style` is `auto` (default: sextant on kitty, WezTerm, foot, Ghostty, quadrant elsewhere), `quadrant` or `sextant`. JPEG/PNG only, decoded and downscaled in the process, so no terminal graphics protocol is needed. Library items fetch `/MediaCover/<id>/poster-250.jpg` from their Radarr/Sonarr instance with the API key; search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory, failed downloads are not retried for two minutes, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it.
|
||||||
- **Calendar view (F5):** one scrolling list of the library's movie releases and airing episodes, grouped by day. It starts with 7 days back and 28 ahead, today's header at the top, and loads another 28 days whenever the selection comes within 6 rows of either end (or ↓/↑ is pressed at an end), up to about 180 days back and 400 ahead; the selection and scroll position stay put when days are added above. `t` returns to today (resetting the list if it was left), `u` includes unmonitored items, `r` reloads. 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). A movie appears once per release date. 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.
|
||||||
- **Notifications and log (F5):** 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 (F5):** 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.
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ Keybindings (the status bar shows the ones that apply to the current view):
|
|||||||
| `i` | Movies, Series, Library: interactive release search (series: seasons and episodes) |
|
| `i` | Movies, Series, Library: interactive release search (series: seasons and episodes) |
|
||||||
| `F` | Library and release list: filter by properties (quality, codec, HDR/DV, audio, language) |
|
| `F` | Library and release list: filter by properties (quality, codec, HDR/DV, audio, language) |
|
||||||
| `f` | Library: cycle the status filter; release list: hide rejected releases |
|
| `f` | Library: cycle the status filter; release list: hide rejected releases |
|
||||||
| `t` `u` | Calendar: back to today, include unmonitored (scroll for more days) |
|
| `[` `]` `t` `u` | Calendar: week back / forward, today, include unmonitored |
|
||||||
| `d` | Library: remove the item from the library (asks first) |
|
| `d` | Library: remove the item from the library (asks first) |
|
||||||
| `t` | Library, episodes: toggle monitored; Downloads history: retry a failed download |
|
| `t` | Library, episodes: toggle monitored; Downloads history: retry a failed download |
|
||||||
| `s` | Library, episodes: automatic search; release list: cycle sort (best / size / age) |
|
| `s` | Library, episodes: automatic search; release list: cycle sort (best / size / age) |
|
||||||
|
|||||||
+1
-1
@@ -453,7 +453,7 @@ func libraryHints() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func calendarHints() string {
|
func calendarHints() string {
|
||||||
return "scroll for more · t today · u unmonitored · Enter releases · s search · r reload"
|
return "[ ] week · t today · u unmonitored · Enter releases · s search · r reload"
|
||||||
}
|
}
|
||||||
|
|
||||||
func logHints() string { return "↑↓ scroll · G follow · c clear" }
|
func logHints() string { return "↑↓ scroll · G follow · c clear" }
|
||||||
|
|||||||
+45
-173
@@ -18,12 +18,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
calendarPast = 7 // days before today loaded at the start
|
calendarDays = 14 // length of the window
|
||||||
calendarAhead = 28 // days after today loaded at the start
|
calendarStep = 7 // `[` and `]` move by this many days
|
||||||
calendarChunk = 28 // days added when scrolling reaches either end
|
|
||||||
calendarMaxBack = 180 // how far back, and
|
|
||||||
calendarMaxFwd = 400 // how far ahead the list can grow
|
|
||||||
calendarEdge = 6 // rows from an end at which more is loaded
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// calEntry is one line of the calendar: a movie release or an episode airing.
|
// calEntry is one line of the calendar: a movie release or an episode airing.
|
||||||
@@ -170,9 +166,7 @@ func dayLabel(day, today time.Time) (date, relative string) {
|
|||||||
return date, ""
|
return date, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// calendarView lists the movies and episodes coming up as one scrolling list.
|
// calendarView lists the movies and episodes coming up in a two-week window.
|
||||||
// It starts with a few days back and a month ahead and loads more days
|
|
||||||
// whenever the selection gets close to either end.
|
|
||||||
type calendarView struct {
|
type calendarView struct {
|
||||||
app *App
|
app *App
|
||||||
radarr *radarr.Client
|
radarr *radarr.Client
|
||||||
@@ -184,14 +178,12 @@ type calendarView struct {
|
|||||||
details *detailPane
|
details *detailPane
|
||||||
|
|
||||||
// UI-goroutine state.
|
// UI-goroutine state.
|
||||||
|
offset int // days from today to the first day shown; moved with [ and ]
|
||||||
unmonitored bool
|
unmonitored bool
|
||||||
from, to time.Time // the days loaded so far
|
|
||||||
entries []calEntry
|
entries []calEntry
|
||||||
rows []calRow
|
rows []calRow
|
||||||
loaded bool
|
loaded bool
|
||||||
loading [2]bool // loading more at the [top, bottom]
|
gen int
|
||||||
gen int // invalidates loads from before a reset
|
|
||||||
reposition bool // put today at the top after the next render
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// calRow is a table row: a day header (entry == nil) or an entry.
|
// calRow is a table row: a day header (entry == nil) or an entry.
|
||||||
@@ -207,17 +199,14 @@ func newCalendarView(app *App, r *radarr.Client, s *sonarr.Client) *calendarView
|
|||||||
table: tview.NewTable().SetSelectable(true, false).SetFixed(0, 0),
|
table: tview.NewTable().SetSelectable(true, false).SetFixed(0, 0),
|
||||||
details: newDetailPane(app),
|
details: newDetailPane(app),
|
||||||
}
|
}
|
||||||
v.table.SetBorder(true)
|
v.table.SetBorder(true).SetTitle(" Upcoming ")
|
||||||
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
|
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
AddItem(v.header, 1, 0, false).
|
AddItem(v.header, 1, 0, false).
|
||||||
AddItem(tview.NewFlex().
|
AddItem(tview.NewFlex().
|
||||||
AddItem(v.table, 0, 3, true).
|
AddItem(v.table, 0, 3, true).
|
||||||
AddItem(v.details, 0, 2, false), 0, 1, true)
|
AddItem(v.details, 0, 2, false), 0, 1, true)
|
||||||
|
|
||||||
v.table.SetSelectionChangedFunc(func(int, int) {
|
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetails() })
|
||||||
v.renderDetails()
|
|
||||||
v.loadNearEnd()
|
|
||||||
})
|
|
||||||
v.table.SetSelectedFunc(func(int, int) { v.open() })
|
v.table.SetSelectedFunc(func(int, int) { v.open() })
|
||||||
v.table.SetInputCapture(v.handleKey)
|
v.table.SetInputCapture(v.handleKey)
|
||||||
v.render()
|
v.render()
|
||||||
@@ -225,15 +214,19 @@ func newCalendarView(app *App, r *radarr.Client, s *sonarr.Client) *calendarView
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *calendarView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
func (v *calendarView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
||||||
switch ev.Key() {
|
if ev.Key() != tcell.KeyRune {
|
||||||
case tcell.KeyDown, tcell.KeyPgDn, tcell.KeyEnd:
|
return ev
|
||||||
v.loadMore(true) // pressing down at the last entry cannot change the selection, so ask here
|
}
|
||||||
case tcell.KeyUp, tcell.KeyPgUp, tcell.KeyHome:
|
|
||||||
v.loadMore(false)
|
|
||||||
case tcell.KeyRune:
|
|
||||||
switch ev.Rune() {
|
switch ev.Rune() {
|
||||||
|
case '[':
|
||||||
|
v.offset -= calendarStep
|
||||||
|
v.reload()
|
||||||
|
case ']':
|
||||||
|
v.offset += calendarStep
|
||||||
|
v.reload()
|
||||||
case 't':
|
case 't':
|
||||||
v.today()
|
v.offset = 0
|
||||||
|
v.reload()
|
||||||
case 'u':
|
case 'u':
|
||||||
v.unmonitored = !v.unmonitored
|
v.unmonitored = !v.unmonitored
|
||||||
v.reload()
|
v.reload()
|
||||||
@@ -247,108 +240,24 @@ func (v *calendarView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
|||||||
return ev
|
return ev
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
|
||||||
return ev
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *calendarView) activate() {
|
// window returns the first and last day shown.
|
||||||
if !v.loaded {
|
func (v *calendarView) window(now time.Time) (from, to time.Time) {
|
||||||
v.reload()
|
from = localDay(now).AddDate(0, 0, v.offset)
|
||||||
} else {
|
return from, from.AddDate(0, 0, calendarDays-1)
|
||||||
v.refresh()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// reload forgets everything and loads the initial range, with today at the top.
|
func (v *calendarView) activate() { v.reload() }
|
||||||
|
|
||||||
|
// reload fetches both calendars in parallel.
|
||||||
func (v *calendarView) reload() {
|
func (v *calendarView) reload() {
|
||||||
v.gen++
|
v.gen++
|
||||||
v.loading = [2]bool{}
|
gen, unmonitored := v.gen, v.unmonitored
|
||||||
today := localDay(time.Now())
|
|
||||||
v.from, v.to = today.AddDate(0, 0, -calendarPast), today.AddDate(0, 0, calendarAhead-1)
|
|
||||||
v.entries, v.loaded, v.reposition = nil, false, true
|
|
||||||
v.render()
|
|
||||||
v.fetch(v.from, v.to, v.gen, func(entries []calEntry) {
|
|
||||||
v.entries, v.loaded = entries, true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// refresh reloads the range loaded so far, keeping the position.
|
|
||||||
func (v *calendarView) refresh() {
|
|
||||||
gen := v.gen
|
|
||||||
v.fetch(v.from, v.to, gen, func(entries []calEntry) { v.entries = entries })
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadNearEnd loads more days when the selection is close to either end.
|
|
||||||
func (v *calendarView) loadNearEnd() {
|
|
||||||
if !v.loaded {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r, _ := v.table.GetSelection()
|
|
||||||
if r >= len(v.rows)-calendarEdge {
|
|
||||||
v.loadMore(true)
|
|
||||||
}
|
|
||||||
if r <= calendarEdge {
|
|
||||||
v.loadMore(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadMore extends the loaded range by a chunk of days downwards (later) or
|
|
||||||
// upwards (earlier), up to the limits.
|
|
||||||
func (v *calendarView) loadMore(later bool) {
|
|
||||||
idx := 0
|
|
||||||
if later {
|
|
||||||
idx = 1
|
|
||||||
}
|
|
||||||
if !v.loaded || v.loading[idx] {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
today := localDay(time.Now())
|
|
||||||
var from, to time.Time
|
|
||||||
if later {
|
|
||||||
if v.to.Sub(today) >= calendarMaxFwd*24*time.Hour {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
from, to = v.to.AddDate(0, 0, 1), v.to.AddDate(0, 0, calendarChunk)
|
|
||||||
} else {
|
|
||||||
if today.Sub(v.from) >= calendarMaxBack*24*time.Hour {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
from, to = v.from.AddDate(0, 0, -calendarChunk), v.from.AddDate(0, 0, -1)
|
|
||||||
}
|
|
||||||
v.loading[idx] = true
|
|
||||||
v.render() // shows "loading" in the title
|
|
||||||
gen := v.gen
|
|
||||||
v.fetch(from, to, gen, func(entries []calEntry) {
|
|
||||||
v.entries = mergeEntries(v.entries, entries)
|
|
||||||
if later {
|
|
||||||
v.to = to
|
|
||||||
} else {
|
|
||||||
v.from = from
|
|
||||||
}
|
|
||||||
}, func() { v.loading[idx] = false })
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeEntries adds new entries to the sorted list, skipping ones already there.
|
|
||||||
func mergeEntries(have, add []calEntry) []calEntry {
|
|
||||||
seen := make(map[string]bool, len(have))
|
|
||||||
for _, e := range have {
|
|
||||||
seen[e.key()] = true
|
|
||||||
}
|
|
||||||
for _, e := range add {
|
|
||||||
if !seen[e.key()] {
|
|
||||||
have = append(have, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sortEntries(have)
|
|
||||||
return have
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetch loads the entries between from and to in the background. apply runs
|
|
||||||
// on the UI goroutine with the entries when at least one service answered;
|
|
||||||
// done (optional) always runs afterwards. Results from before a reset are dropped.
|
|
||||||
func (v *calendarView) fetch(from, to time.Time, gen int, apply func([]calEntry), done ...func()) {
|
|
||||||
unmonitored := v.unmonitored
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
from, to := v.window(now)
|
||||||
|
v.render()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -384,35 +293,19 @@ func (v *calendarView) fetch(from, to time.Time, gen int, apply func([]calEntry)
|
|||||||
if gen != v.gen {
|
if gen != v.gen {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, d := range done {
|
|
||||||
d() // before rendering, so the title no longer says "loading"
|
|
||||||
}
|
|
||||||
for _, err := range []error{movieErr, tvErr} {
|
for _, err := range []error{movieErr, tvErr} {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
v.app.SetMessage("[red]Calendar: " + tview.Escape(err.Error()))
|
v.app.SetMessage("[red]Calendar: " + tview.Escape(err.Error()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if movieErr == nil || tvErr == nil { // show what we got
|
if movieErr == nil || tvErr == nil { // show what we got
|
||||||
apply(entries)
|
v.entries, v.loaded = entries, true
|
||||||
}
|
}
|
||||||
v.render()
|
v.render()
|
||||||
})
|
})
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// today selects the first entry of today (or the next day with one), with
|
|
||||||
// today's header at the top. If today is out of the loaded range the list is
|
|
||||||
// reset around it.
|
|
||||||
func (v *calendarView) today() {
|
|
||||||
today := localDay(time.Now())
|
|
||||||
if today.Before(v.from) || today.After(v.to) {
|
|
||||||
v.reload()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
v.reposition = true
|
|
||||||
v.render()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *calendarView) selected() (*calEntry, bool) {
|
func (v *calendarView) selected() (*calEntry, bool) {
|
||||||
r, _ := v.table.GetSelection()
|
r, _ := v.table.GetSelection()
|
||||||
if r < 0 || r >= len(v.rows) || v.rows[r].entry == nil {
|
if r < 0 || r >= len(v.rows) || v.rows[r].entry == nil {
|
||||||
@@ -423,13 +316,12 @@ func (v *calendarView) selected() (*calEntry, bool) {
|
|||||||
|
|
||||||
func (v *calendarView) render() {
|
func (v *calendarView) render() {
|
||||||
selKey := ""
|
selKey := ""
|
||||||
oldSel, _ := v.table.GetSelection()
|
|
||||||
oldOffset, _ := v.table.GetOffset()
|
|
||||||
if e, ok := v.selected(); ok {
|
if e, ok := v.selected(); ok {
|
||||||
selKey = e.key()
|
selKey = e.key()
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
today := localDay(now)
|
today := localDay(now)
|
||||||
|
from, to := v.window(now)
|
||||||
|
|
||||||
v.rows = v.rows[:0]
|
v.rows = v.rows[:0]
|
||||||
var lastDay time.Time
|
var lastDay time.Time
|
||||||
@@ -443,11 +335,8 @@ func (v *calendarView) render() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
v.table.Clear()
|
v.table.Clear()
|
||||||
selRow, firstEntry, todayRow := -1, -1, -1
|
selRow, firstEntry := 0, -1
|
||||||
for i, r := range v.rows {
|
for i, r := range v.rows {
|
||||||
if todayRow < 0 && !r.day.Before(today) {
|
|
||||||
todayRow = i // the header of today, or of the next day with entries
|
|
||||||
}
|
|
||||||
if r.entry == nil {
|
if r.entry == nil {
|
||||||
date, relative := dayLabel(r.day, today)
|
date, relative := dayLabel(r.day, today)
|
||||||
v.table.SetCell(i, 0, tview.NewTableCell("[::b][yellow]"+date).SetSelectable(false))
|
v.table.SetCell(i, 0, tview.NewTableCell("[::b][yellow]"+date).SetSelectable(false))
|
||||||
@@ -477,16 +366,13 @@ func (v *calendarView) render() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
title := " Upcoming · " + v.from.Format("2 Jan") + " – " + v.to.Format("2 Jan")
|
title := " Upcoming · " + from.Format("2 Jan") + " – " + to.Format("2 Jan") + " "
|
||||||
if v.unmonitored {
|
if v.unmonitored {
|
||||||
title += " · incl. unmonitored"
|
title += "· incl. unmonitored "
|
||||||
}
|
}
|
||||||
if v.loading[0] || v.loading[1] || !v.loaded {
|
v.table.SetTitle(title)
|
||||||
title += " · loading…"
|
switch {
|
||||||
}
|
case len(v.entries) == 0:
|
||||||
v.table.SetTitle(title + " ")
|
|
||||||
|
|
||||||
if firstEntry < 0 {
|
|
||||||
msg := "Loading…"
|
msg := "Loading…"
|
||||||
if v.loaded {
|
if v.loaded {
|
||||||
msg = "Nothing in this period"
|
msg = "Nothing in this period"
|
||||||
@@ -494,31 +380,17 @@ func (v *calendarView) render() {
|
|||||||
v.table.SetCell(0, 0, tview.NewTableCell("[gray]"+msg).SetSelectable(false))
|
v.table.SetCell(0, 0, tview.NewTableCell("[gray]"+msg).SetSelectable(false))
|
||||||
v.table.SetSelectable(false, false)
|
v.table.SetSelectable(false, false)
|
||||||
v.table.Select(0, 0)
|
v.table.Select(0, 0)
|
||||||
} else {
|
|
||||||
v.table.SetSelectable(true, false)
|
|
||||||
switch {
|
|
||||||
case v.reposition && v.loaded:
|
|
||||||
// Today's header at the top, the first entry from today on selected.
|
|
||||||
v.reposition = false
|
|
||||||
target := firstEntry
|
|
||||||
for i := max(todayRow, 0); i < len(v.rows); i++ {
|
|
||||||
if v.rows[i].entry != nil {
|
|
||||||
target = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
v.table.Select(target, 0)
|
|
||||||
v.table.SetOffset(max(todayRow, 0), 0)
|
|
||||||
case selRow >= 0:
|
|
||||||
// Keep the selection where it was on screen, also when rows were
|
|
||||||
// added above it.
|
|
||||||
v.table.Select(selRow, 0)
|
|
||||||
v.table.SetOffset(max(oldOffset+selRow-oldSel, 0), 0)
|
|
||||||
default:
|
default:
|
||||||
v.table.Select(firstEntry, 0)
|
v.table.SetSelectable(true, false)
|
||||||
|
if selRow == 0 {
|
||||||
|
selRow = firstEntry
|
||||||
|
}
|
||||||
|
v.table.Select(selRow, 0)
|
||||||
|
if selRow == firstEntry {
|
||||||
|
v.table.ScrollToBeginning()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
v.header.SetText("[::b]Calendar[::-] · scroll for more · t today · u unmonitored · Enter releases · s search")
|
v.header.SetText("[::b]Calendar[::-] · [ ] move by a week · t today · u unmonitored · Enter releases · s search")
|
||||||
v.renderDetails()
|
v.renderDetails()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,21 +124,3 @@ func TestSortEntriesAndDayLabel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMergeEntries(t *testing.T) {
|
|
||||||
d1, d2, d3 := day(2026, 10, 21), day(2026, 10, 22), day(2026, 10, 23)
|
|
||||||
have := []calEntry{{day: d2, title: "b", episodeID: 2}, {day: d3, title: "c", episodeID: 3}}
|
|
||||||
add := []calEntry{
|
|
||||||
{day: d1, title: "a", episodeID: 1},
|
|
||||||
{day: d2, title: "b", episodeID: 2}, // already there: not duplicated
|
|
||||||
{day: d3, title: "film", movie: true, movieID: 7, sub: "In cinemas"},
|
|
||||||
}
|
|
||||||
got := mergeEntries(have, add)
|
|
||||||
var titles []string
|
|
||||||
for _, e := range got {
|
|
||||||
titles = append(titles, e.title)
|
|
||||||
}
|
|
||||||
if strings.Join(titles, ",") != "a,b,c,film" {
|
|
||||||
t.Errorf("merged = %v", titles)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-2
@@ -38,8 +38,7 @@ const helpText = `[yellow]Global[-]
|
|||||||
r reload
|
r reload
|
||||||
|
|
||||||
[yellow]Calendar (F5)[-]
|
[yellow]Calendar (F5)[-]
|
||||||
↑↓, PgUp/PgDn scroll; earlier and later days are loaded as you get near
|
[ ] move the two-week window back / forward by a week
|
||||||
either end (up to about 6 months back and 13 months ahead)
|
|
||||||
t back to today
|
t back to today
|
||||||
u include unmonitored items
|
u include unmonitored items
|
||||||
Enter, i release search for the selected movie / episode
|
Enter, i release search for the selected movie / episode
|
||||||
|
|||||||
Reference in New Issue
Block a user