With a Trakt client ID, the details of a movie or series show the director(s) or creators and up to 8 cast members with their roles below the plot, in every view. The title is found on Trakt by its TMDb/TVDB (else IMDb) id, then its people are read. Casts are cached and, like posters, only loaded once the selection rests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
200 lines
5.8 KiB
Go
200 lines
5.8 KiB
Go
package ui
|
|
|
|
import (
|
|
"image"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/rivo/tview"
|
|
)
|
|
|
|
const (
|
|
coverGapCols = 2 // empty columns between the poster and the text left of it
|
|
coverGapRows = 1 // empty rows between the poster and the text below it
|
|
minCoverRows = 6 // a smaller poster is not worth showing
|
|
minCoverCols = 8
|
|
minTextRows = 6 // the poster never takes these rows from the text
|
|
|
|
// posterSettle: when the selection moves again within this time (a held
|
|
// arrow key), the poster waits until the selection rests that long. Every
|
|
// poster rewrites all of its cells, which on a huge terminal (zoomed far
|
|
// out) is more output than the terminal can draw at key-repeat speed.
|
|
posterSettle = 120 * time.Millisecond
|
|
)
|
|
|
|
// detailPane shows the details of one title as one flowing text with the
|
|
// poster floating at the top right: the head (title and facts) and the plot
|
|
// run along the poster on the left and continue at full width below it. On
|
|
// panes too small for the poster the text simply uses the full width.
|
|
type detailPane struct {
|
|
*tview.Flex // holds the text; the poster is drawn on top of its corner
|
|
app *App
|
|
text *tview.TextView
|
|
cover *coverView
|
|
|
|
detail detailText
|
|
lastRef coverRef // the poster of the current detail, to fetch it again after a style change
|
|
want string // key of the poster that should be shown
|
|
hasCover bool // a poster (or its loading note) is part of the content
|
|
lastShow time.Time // when the shown title last changed
|
|
settle *time.Timer // shows the poster once the selection rests
|
|
|
|
// The cast from Trakt, below the plot (see people.go).
|
|
castKey string // the title it belongs to
|
|
cast string // rendered; "" while loading or when there is none
|
|
castShown time.Time
|
|
castSettle *time.Timer
|
|
|
|
// What the text was last wrapped for.
|
|
laidOut struct {
|
|
width, narrow, rows int
|
|
valid bool
|
|
}
|
|
coverW, coverH int // size of the poster on screen, 0 = hidden
|
|
}
|
|
|
|
func newDetailPane(app *App) *detailPane {
|
|
d := &detailPane{
|
|
Flex: tview.NewFlex(),
|
|
app: app,
|
|
text: tview.NewTextView().SetDynamicColors(true).SetWrap(false),
|
|
cover: newCoverView(app.coverStyle, app.gfx, func() float64 { return app.cellAspect }),
|
|
}
|
|
d.AddItem(d.text, 0, 1, false)
|
|
d.SetBorder(true).SetTitle(" Details ")
|
|
app.panes = append(app.panes, d)
|
|
return d
|
|
}
|
|
|
|
// Show displays the text and, if there is one, the poster.
|
|
func (d *detailPane) Show(text detailText, ref coverRef) {
|
|
d.detail, d.lastRef = text, ref
|
|
d.laidOut.valid = false
|
|
d.showPeople(text.people)
|
|
if !d.app.covers || ref.fetch == nil {
|
|
d.want, d.hasCover = "", false
|
|
d.cover.setImage(nil)
|
|
return
|
|
}
|
|
now := time.Now()
|
|
moving := ref.key != d.want && now.Sub(d.lastShow) < posterSettle
|
|
if ref.key != d.want {
|
|
d.lastShow = now
|
|
}
|
|
d.want, d.hasCover = ref.key, true
|
|
if d.settle != nil {
|
|
d.settle.Stop()
|
|
}
|
|
if moving {
|
|
// Keep the poster's place (so the text does not reflow) but leave it
|
|
// empty until the selection stops.
|
|
d.cover.setNote("")
|
|
want := ref.key
|
|
d.settle = time.AfterFunc(posterSettle, func() {
|
|
d.app.post(func() {
|
|
if d.want == want {
|
|
d.loadPoster(ref)
|
|
}
|
|
})
|
|
})
|
|
return
|
|
}
|
|
d.loadPoster(ref)
|
|
}
|
|
|
|
// loadPoster shows the poster of ref, fetching it first if needed.
|
|
func (d *detailPane) loadPoster(ref coverRef) {
|
|
d.cover.setNote("loading cover…")
|
|
want := ref.key
|
|
d.app.loadCover(ref, func(img *image.RGBA, err error) {
|
|
if d.want != want {
|
|
return // the selection moved on
|
|
}
|
|
if err != nil {
|
|
d.hasCover = false // nothing to show: give the room back to the text
|
|
d.cover.setImage(nil)
|
|
return
|
|
}
|
|
d.cover.setImage(img)
|
|
})
|
|
}
|
|
|
|
// Clear empties the pane.
|
|
func (d *detailPane) Clear() { d.Show(detailText{}, coverRef{}) }
|
|
|
|
func (d *detailPane) Draw(screen tcell.Screen) {
|
|
x, y, w, h := d.GetInnerRect()
|
|
|
|
d.coverW, d.coverH = 0, 0
|
|
if d.hasCover {
|
|
aw, ah := d.cover.aspect()
|
|
// At most 40% of the width, and the text keeps some rows below.
|
|
d.coverW, d.coverH = coverCells(aw, ah, w*2/5, h-minTextRows, d.cover.aspectOfCells())
|
|
if d.coverH < minCoverRows || d.coverW < minCoverCols {
|
|
d.coverW, d.coverH = 0, 0
|
|
}
|
|
}
|
|
|
|
narrow, rows := w, 0
|
|
if d.coverW > 0 {
|
|
// The text keeps clear of the poster: columns to its left, and one more
|
|
// line is wrapped narrow so nothing sits right under it.
|
|
narrow, rows = w-d.coverW-coverGapCols, d.coverH+coverGapRows
|
|
}
|
|
if !d.laidOut.valid || d.laidOut.width != w || d.laidOut.narrow != narrow || d.laidOut.rows != rows {
|
|
d.laidOut.width, d.laidOut.narrow, d.laidOut.rows, d.laidOut.valid = w, narrow, rows, true
|
|
text := d.detail.String()
|
|
if d.cast != "" {
|
|
text += "\n\n" + d.cast
|
|
}
|
|
d.text.SetText(strings.Join(flowWrap(text, narrow, w, rows), "\n"))
|
|
}
|
|
|
|
d.Flex.Draw(screen)
|
|
if d.coverW > 0 {
|
|
d.cover.SetRect(x+w-d.coverW, y, d.coverW, d.coverH)
|
|
d.cover.Draw(screen)
|
|
}
|
|
}
|
|
|
|
// flowWrap word-wraps text for a column that is narrow for its first
|
|
// narrowRows lines and wide afterwards, like text flowing around a picture
|
|
// floating at the top right. Newlines in the text are kept. Color tags have
|
|
// no width and carry over from line to line.
|
|
func flowWrap(text string, narrow, wide, narrowRows int) []string {
|
|
var lines []string
|
|
var cur strings.Builder
|
|
curW := 0
|
|
limit := func() int {
|
|
if len(lines) < narrowRows {
|
|
return max(narrow, 1)
|
|
}
|
|
return max(wide, 1)
|
|
}
|
|
flush := func() {
|
|
lines = append(lines, cur.String())
|
|
cur.Reset()
|
|
curW = 0
|
|
}
|
|
for _, para := range strings.Split(text, "\n") {
|
|
for _, word := range strings.Fields(para) {
|
|
ww := tview.TaggedStringWidth(word)
|
|
if curW > 0 && curW+1+ww > limit() {
|
|
flush()
|
|
}
|
|
if curW > 0 {
|
|
cur.WriteByte(' ')
|
|
curW++
|
|
}
|
|
cur.WriteString(word)
|
|
curW += ww
|
|
}
|
|
flush() // end of paragraph
|
|
}
|
|
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
|
|
lines = lines[:len(lines)-1]
|
|
}
|
|
return lines
|
|
}
|