Calendar: one scrolling list that loads more days at either end

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 22:47:51 +02:00
co-authored by Claude Sonnet 5.5
parent cd5786ba78
commit 938980f1e1
7 changed files with 216 additions and 68 deletions
+1 -1
View File
@@ -453,7 +453,7 @@ func libraryHints() string {
}
func calendarHints() string {
return "[ ] week · t today · u unmonitored · Enter releases · s search · r reload"
return "scroll for more · t today · u unmonitored · Enter releases · s search · r reload"
}
func logHints() string { return "↑↓ scroll · G follow · c clear" }
+186 -58
View File
@@ -18,8 +18,12 @@ import (
)
const (
calendarDays = 14 // length of the window
calendarStep = 7 // `[` and `]` move by this many days
calendarPast = 7 // days before today loaded at the start
calendarAhead = 28 // days after today loaded at the start
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.
@@ -166,7 +170,9 @@ func dayLabel(day, today time.Time) (date, relative string) {
return date, ""
}
// calendarView lists the movies and episodes coming up in a two-week window.
// calendarView lists the movies and episodes coming up as one scrolling list.
// 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 {
app *App
radarr *radarr.Client
@@ -178,12 +184,14 @@ type calendarView struct {
details *detailPane
// UI-goroutine state.
offset int // days from today to the first day shown; moved with [ and ]
unmonitored bool
from, to time.Time // the days loaded so far
entries []calEntry
rows []calRow
loaded bool
gen int
loading [2]bool // loading more at the [top, bottom]
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.
@@ -199,14 +207,17 @@ func newCalendarView(app *App, r *radarr.Client, s *sonarr.Client) *calendarView
table: tview.NewTable().SetSelectable(true, false).SetFixed(0, 0),
details: newDetailPane(app),
}
v.table.SetBorder(true).SetTitle(" Upcoming ")
v.table.SetBorder(true)
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(v.header, 1, 0, false).
AddItem(tview.NewFlex().
AddItem(v.table, 0, 3, true).
AddItem(v.details, 0, 2, false), 0, 1, true)
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetails() })
v.table.SetSelectionChangedFunc(func(int, int) {
v.renderDetails()
v.loadNearEnd()
})
v.table.SetSelectedFunc(func(int, int) { v.open() })
v.table.SetInputCapture(v.handleKey)
v.render()
@@ -214,50 +225,130 @@ func newCalendarView(app *App, r *radarr.Client, s *sonarr.Client) *calendarView
}
func (v *calendarView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
if ev.Key() != tcell.KeyRune {
return ev
switch ev.Key() {
case tcell.KeyDown, tcell.KeyPgDn, tcell.KeyEnd:
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() {
case 't':
v.today()
case 'u':
v.unmonitored = !v.unmonitored
v.reload()
case 'r':
v.reload()
case 'i':
v.open()
case 's':
v.search()
default:
return ev
}
return nil
}
switch ev.Rune() {
case '[':
v.offset -= calendarStep
v.reload()
case ']':
v.offset += calendarStep
v.reload()
case 't':
v.offset = 0
v.reload()
case 'u':
v.unmonitored = !v.unmonitored
v.reload()
case 'r':
v.reload()
case 'i':
v.open()
case 's':
v.search()
default:
return ev
}
return nil
return ev
}
// window returns the first and last day shown.
func (v *calendarView) window(now time.Time) (from, to time.Time) {
from = localDay(now).AddDate(0, 0, v.offset)
return from, from.AddDate(0, 0, calendarDays-1)
func (v *calendarView) activate() {
if !v.loaded {
v.reload()
} else {
v.refresh()
}
}
func (v *calendarView) activate() { v.reload() }
// reload fetches both calendars in parallel.
// reload forgets everything and loads the initial range, with today at the top.
func (v *calendarView) reload() {
v.gen++
gen, unmonitored := v.gen, v.unmonitored
now := time.Now()
from, to := v.window(now)
v.loading = [2]bool{}
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()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -293,19 +384,35 @@ func (v *calendarView) reload() {
if gen != v.gen {
return
}
for _, d := range done {
d() // before rendering, so the title no longer says "loading"
}
for _, err := range []error{movieErr, tvErr} {
if err != nil {
v.app.SetMessage("[red]Calendar: " + tview.Escape(err.Error()))
}
}
if movieErr == nil || tvErr == nil { // show what we got
v.entries, v.loaded = entries, true
apply(entries)
}
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) {
r, _ := v.table.GetSelection()
if r < 0 || r >= len(v.rows) || v.rows[r].entry == nil {
@@ -316,12 +423,13 @@ func (v *calendarView) selected() (*calEntry, bool) {
func (v *calendarView) render() {
selKey := ""
oldSel, _ := v.table.GetSelection()
oldOffset, _ := v.table.GetOffset()
if e, ok := v.selected(); ok {
selKey = e.key()
}
now := time.Now()
today := localDay(now)
from, to := v.window(now)
v.rows = v.rows[:0]
var lastDay time.Time
@@ -335,8 +443,11 @@ func (v *calendarView) render() {
}
v.table.Clear()
selRow, firstEntry := 0, -1
selRow, firstEntry, todayRow := -1, -1, -1
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 {
date, relative := dayLabel(r.day, today)
v.table.SetCell(i, 0, tview.NewTableCell("[::b][yellow]"+date).SetSelectable(false))
@@ -366,13 +477,16 @@ func (v *calendarView) render() {
}
}
title := " Upcoming · " + from.Format("2 Jan") + " – " + to.Format("2 Jan") + " "
title := " Upcoming · " + v.from.Format("2 Jan") + " – " + v.to.Format("2 Jan")
if v.unmonitored {
title += "· incl. unmonitored "
title += " · incl. unmonitored"
}
v.table.SetTitle(title)
switch {
case len(v.entries) == 0:
if v.loading[0] || v.loading[1] || !v.loaded {
title += " · loading…"
}
v.table.SetTitle(title + " ")
if firstEntry < 0 {
msg := "Loading…"
if v.loaded {
msg = "Nothing in this period"
@@ -380,17 +494,31 @@ func (v *calendarView) render() {
v.table.SetCell(0, 0, tview.NewTableCell("[gray]"+msg).SetSelectable(false))
v.table.SetSelectable(false, false)
v.table.Select(0, 0)
default:
} else {
v.table.SetSelectable(true, false)
if selRow == 0 {
selRow = firstEntry
}
v.table.Select(selRow, 0)
if selRow == firstEntry {
v.table.ScrollToBeginning()
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:
v.table.Select(firstEntry, 0)
}
}
v.header.SetText("[::b]Calendar[::-] · [ ] move by a week · t today · u unmonitored · Enter releases · s search")
v.header.SetText("[::b]Calendar[::-] · scroll for more · t today · u unmonitored · Enter releases · s search")
v.renderDetails()
}
+18
View File
@@ -124,3 +124,21 @@ 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)
}
}
+2 -1
View File
@@ -38,7 +38,8 @@ const helpText = `[yellow]Global[-]
r reload
[yellow]Calendar (F5)[-]
[ ] move the two-week window back / forward by a week
↑↓, PgUp/PgDn scroll; earlier and later days are loaded as you get near
either end (up to about 6 months back and 13 months ahead)
t back to today
u include unmonitored items
Enter, i release search for the selected movie / episode