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 // 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 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 d.text.SetText(strings.Join(flowWrap(d.detail.String(), 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 }