Detail panes: poster top right, title and facts left, plot below; sextant rendering

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 21:57:22 +02:00
co-authored by Claude Sonnet 5.5
parent 365ba9dee8
commit b4fe931fa9
15 changed files with 468 additions and 153 deletions
+3
View File
@@ -4,6 +4,7 @@ package ui
import (
"context"
"fmt"
"os"
"strings"
"time"
@@ -44,6 +45,7 @@ type App struct {
probes []probe
covers bool // poster thumbnails enabled
coverStyle coverStyle
coverCache *coverCache
}
@@ -89,6 +91,7 @@ func New(cfg *config.Config) *App {
state: map[string]serviceState{},
covers: cfg.UI.ShowCovers(),
coverStyle: coverStyleFor(cfg.UI.CoverStyle, os.Getenv),
coverCache: newCoverCache(),
}
+13 -13
View File
@@ -16,7 +16,7 @@ type result interface {
key() string // stable identity, e.g. "tmdb:603"
title() string
label() string
details() string
details() detailText
inLibrary() bool
withID(id int) result // copy flagged as added to the library
cover() coverRef
@@ -56,12 +56,12 @@ type movieResult struct {
poster coverRef
}
func (m movieResult) key() string { return radarr.TMDBTerm(m.TMDBID) }
func (m movieResult) title() string { return m.Title }
func (m movieResult) label() string { return movieLabel(m.Movie) }
func (m movieResult) details() string { return movieDetails(m.Movie) }
func (m movieResult) inLibrary() bool { return m.InLibrary() }
func (m movieResult) cover() coverRef { return m.poster }
func (m movieResult) key() string { return radarr.TMDBTerm(m.TMDBID) }
func (m movieResult) title() string { return m.Title }
func (m movieResult) label() string { return movieLabel(m.Movie) }
func (m movieResult) details() detailText { return movieDetails(m.Movie) }
func (m movieResult) inLibrary() bool { return m.InLibrary() }
func (m movieResult) cover() coverRef { return m.poster }
func (m movieResult) withID(id int) result {
m.ID = id
return m
@@ -107,12 +107,12 @@ type seriesResult struct {
poster coverRef
}
func (s seriesResult) key() string { return "tvdb:" + itoa(s.TVDBID) }
func (s seriesResult) title() string { return s.Title }
func (s seriesResult) label() string { return seriesLabel(s.Series) }
func (s seriesResult) details() string { return seriesDetails(s.Series) }
func (s seriesResult) inLibrary() bool { return s.InLibrary() }
func (s seriesResult) cover() coverRef { return s.poster }
func (s seriesResult) key() string { return "tvdb:" + itoa(s.TVDBID) }
func (s seriesResult) title() string { return s.Title }
func (s seriesResult) label() string { return seriesLabel(s.Series) }
func (s seriesResult) details() detailText { return seriesDetails(s.Series) }
func (s seriesResult) inLibrary() bool { return s.InLibrary() }
func (s seriesResult) cover() coverRef { return s.poster }
func (s seriesResult) withID(id int) result {
s.ID = id
return s
+193 -84
View File
@@ -8,6 +8,7 @@ import (
"image/color"
_ "image/jpeg" // poster formats
_ "image/png"
"strings"
"time"
"github.com/gdamore/tcell/v2"
@@ -22,7 +23,6 @@ const (
coverFailureTTL = 2 * time.Minute
maxCoverCache = 300
coverFetchLimit = 4
flatCellError = 600 // squared RGB distance below which a cell is drawn flat
)
// decodeCover decodes a JPEG or PNG poster and keeps a small copy.
@@ -70,23 +70,177 @@ func boxScale(src image.Image, w, h int) *image.RGBA {
return dst
}
// coverView draws a poster with quadrant block characters: every cell shows
// 2×2 pixels in two colors (the best split of the four pixels into a
// foreground and a background group), which doubles the horizontal detail of
// plain half blocks. The glyphs are all from the Block Elements range that
// every terminal font has, and it works over SSH.
type coverView struct {
*tview.Box
img *image.RGBA
note string // shown instead of the image: "loading…", "no cover"
scaled *image.RGBA // cache of img at the last drawn size
// coverStyle is a way to draw a poster with block characters: every cell
// shows sw×sh pixels in two colors, the best split of its pixels into a
// foreground and a background group.
type coverStyle struct {
name string
sw, sh int
glyph func(mask int) rune // mask: bit i = pixel i (row-major) is foreground
}
func newCoverView() *coverView { return &coverView{Box: tview.NewBox()} }
var (
// quadrantStyle (2×2 pixels): the glyphs are in the Block Elements range
// that every terminal font has.
quadrantStyle = coverStyle{"quadrant", 2, 2, func(mask int) rune { return quadrants[mask] }}
// sextantStyle (2×3 pixels): sharper, but the glyphs live in the Symbols
// for Legacy Computing block, which many fonts lack.
sextantStyle = coverStyle{"sextant", 2, 3, sextantGlyph}
)
func (c *coverView) setImage(img *image.RGBA) { c.img, c.note, c.scaled = img, "", nil }
func (c *coverView) setNote(note string) { c.img, c.note, c.scaled = nil, note, nil }
// quadrants[mask] is the glyph that fills the quadrants set in mask
// (bit 0 top left, 1 top right, 2 bottom left, 3 bottom right).
var quadrants = [16]rune{' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█'}
// sextantGlyph maps a 2×3 pixel mask (bit 0 top left, 1 top right, 2 middle
// left, ... 5 bottom right) to its character. Unicode lists the 60 sextants
// in mask order but leaves out the empty and full cell and the two halves,
// which already exist as space, full block and the half blocks.
func sextantGlyph(mask int) rune {
switch mask {
case 0:
return ' '
case 63:
return '█'
case 21:
return '▌'
case 42:
return '▐'
}
idx := mask - 1
if mask > 21 {
idx--
}
if mask > 42 {
idx--
}
return rune(0x1FB00 + idx)
}
// coverStyleFor resolves the configured style. "auto" picks sextants on
// terminals known to have the glyphs and quadrants everywhere else.
func coverStyleFor(setting string, env func(string) string) coverStyle {
switch setting {
case "sextant":
return sextantStyle
case "quadrant":
return quadrantStyle
}
term, program := strings.ToLower(env("TERM")), strings.ToLower(env("TERM_PROGRAM"))
for _, known := range []string{"kitty", "foot", "ghostty", "wezterm"} {
if strings.Contains(term, known) || strings.Contains(program, known) {
return sextantStyle
}
}
if env("KITTY_WINDOW_ID") != "" || env("WEZTERM_PANE") != "" {
return sextantStyle
}
return quadrantStyle
}
// flatPixelError is the squared RGB distance per pixel below which a cell is
// drawn flat: smooth areas and noise should not turn into patterns.
const flatPixelError = 150
// bestCell picks the glyph and the two colors that best reproduce the pixels
// of one cell. Only masks without the last pixel are tried, because a mask
// and its complement draw the same picture with the colors swapped.
func (st coverStyle) bestCell(p []color.RGBA) (ch rune, fg, bg color.RGBA) {
n := len(p)
type sum struct{ r, g, b, n float64 }
var all sum
for _, c := range p {
all.r, all.g, all.b, all.n = all.r+float64(c.R), all.g+float64(c.G), all.b+float64(c.B), all.n+1
}
avg := func(s sum) [3]float64 {
if s.n == 0 {
return [3]float64{}
}
return [3]float64{s.r / s.n, s.g / s.n, s.b / s.n}
}
dist := func(c color.RGBA, m [3]float64) float64 {
dr, dg, db := float64(c.R)-m[0], float64(c.G)-m[1], float64(c.B)-m[2]
return dr*dr + dg*dg + db*db
}
toRGBA := func(m [3]float64) color.RGBA {
return color.RGBA{uint8(m[0] + .5), uint8(m[1] + .5), uint8(m[2] + .5), 255}
}
flat := avg(all)
var flatErr float64
for _, c := range p {
flatErr += dist(c, flat)
}
if flatErr < flatPixelError*float64(n) {
c := toRGBA(flat)
return ' ', c, c
}
best, bestErr := 0, flatErr*0.6 // a split must clearly beat the flat cell
var bestFg, bestBg [3]float64
for mask := 1; mask < 1<<(n-1); mask++ {
var f, b sum
for i, c := range p {
t := &b
if mask>>i&1 == 1 {
t = &f
}
t.r, t.g, t.b, t.n = t.r+float64(c.R), t.g+float64(c.G), t.b+float64(c.B), t.n+1
}
fm, bm := avg(f), avg(b)
var e float64
for i, c := range p {
if mask>>i&1 == 1 {
e += dist(c, fm)
} else {
e += dist(c, bm)
}
if e >= bestErr {
break
}
}
if e < bestErr {
best, bestErr, bestFg, bestBg = mask, e, fm, bm
}
}
if best == 0 {
c := toRGBA(flat)
return ' ', c, c
}
return st.glyph(best), toRGBA(bestFg), toRGBA(bestBg)
}
// coverView draws a poster, see coverStyle. It works in any terminal with
// colors, also over SSH.
type coverView struct {
*tview.Box
style coverStyle
img *image.RGBA
note string // shown instead of the image: "loading…", "no cover"
// The cells for the last drawn size.
cols, rows int
cells []styledCell
}
type styledCell struct {
ch rune
fg, bg tcell.Color
}
func newCoverView(style coverStyle) *coverView { return &coverView{Box: tview.NewBox(), style: style} }
func (c *coverView) setImage(img *image.RGBA) { c.img, c.note, c.cells = img, "", nil }
func (c *coverView) setNote(note string) { c.img, c.note, c.cells = nil, note, nil }
// aspect returns the width and height of the poster (2:3 until one is loaded).
func (c *coverView) aspect() (w, h int) {
if c.img == nil {
return 2, 3
}
b := c.img.Bounds()
return b.Dx(), b.Dy()
}
// coverCells returns how many columns and rows a poster of the given aspect
// needs inside w×h cells. A cell is about twice as tall as it is wide.
@@ -118,85 +272,40 @@ func (c *coverView) Draw(screen tcell.Screen) {
if cols < 1 || rows < 1 {
return
}
if c.scaled == nil || c.scaled.Bounds().Dx() != 2*cols || c.scaled.Bounds().Dy() != 2*rows {
c.scaled = boxScale(c.img, 2*cols, 2*rows)
}
px := func(x, y int) color.RGBA {
i := c.scaled.PixOffset(x, y)
return color.RGBA{c.scaled.Pix[i], c.scaled.Pix[i+1], c.scaled.Pix[i+2], 255}
if c.cells == nil || c.cols != cols || c.rows != rows {
c.cols, c.rows, c.cells = cols, rows, c.build(cols, rows)
}
// Right-align the poster inside its area, so a narrower poster sits against the edge.
x += w - cols
for row := 0; row < rows; row++ {
for col := 0; col < cols; col++ {
ch, fg, bg := quadCell([4]color.RGBA{
px(2*col, 2*row), px(2*col+1, 2*row), // top left, top right
px(2*col, 2*row+1), px(2*col+1, 2*row+1), // bottom left, bottom right
})
style := tcell.StyleDefault.Foreground(tcell.NewRGBColor(int32(fg.R), int32(fg.G), int32(fg.B))).
Background(tcell.NewRGBColor(int32(bg.R), int32(bg.G), int32(bg.B)))
screen.SetContent(x+col, y+row, ch, nil, style)
cell := c.cells[row*cols+col]
screen.SetContent(x+col, y+row, cell.ch, nil, tcell.StyleDefault.Foreground(cell.fg).Background(cell.bg))
}
}
}
// quadrants[mask] is the glyph that fills the quadrants set in mask
// (bit 0 top left, 1 top right, 2 bottom left, 3 bottom right).
var quadrants = [16]rune{' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█'}
// quadCell picks the glyph and colors that best reproduce four pixels (top
// left, top right, bottom left, bottom right) with one foreground and one
// background color.
func quadCell(p [4]color.RGBA) (ch rune, fg, bg color.RGBA) {
mean := func(mask int, want bool) (c [3]float64, n int) {
for i := 0; i < 4; i++ {
if (mask>>i&1 == 1) == want {
c[0], c[1], c[2] = c[0]+float64(p[i].R), c[1]+float64(p[i].G), c[2]+float64(p[i].B)
n++
}
}
for k := range c {
if n > 0 {
c[k] /= float64(n)
}
}
return c, n
}
// err is the squared distance of the pixels in one group to the group's mean.
errOf := func(mask int, want bool, m [3]float64) (e float64) {
for i := 0; i < 4; i++ {
if (mask>>i&1 == 1) == want {
dr, dg, db := float64(p[i].R)-m[0], float64(p[i].G)-m[1], float64(p[i].B)-m[2]
e += dr*dr + dg*dg + db*db
}
}
return e
}
toRGBA := func(m [3]float64) color.RGBA {
return color.RGBA{uint8(m[0] + .5), uint8(m[1] + .5), uint8(m[2] + .5), 255}
}
// Mask 0 is a single flat color. A split must clearly beat it, and nearly
// flat cells (smooth gradients, noise) stay flat. Only masks without the
// bottom right quadrant are tried: a mask and its complement draw the same
// picture with swapped colors.
flat, _ := mean(0, false)
flatErr := errOf(0, false, flat)
best, bestErr := 0, flatErr*0.6
if flatErr >= flatCellError {
for mask := 1; mask < 8; mask++ {
fm, _ := mean(mask, true)
bm, _ := mean(mask, false)
if e := errOf(mask, true, fm) + errOf(mask, false, bm); e < bestErr {
best, bestErr = mask, e
// build renders the poster into cols×rows cells.
func (c *coverView) build(cols, rows int) []styledCell {
st := c.style
scaled := boxScale(c.img, cols*st.sw, rows*st.sh)
cells := make([]styledCell, 0, cols*rows)
px := make([]color.RGBA, st.sw*st.sh)
for row := 0; row < rows; row++ {
for col := 0; col < cols; col++ {
for sy := 0; sy < st.sh; sy++ {
for sx := 0; sx < st.sw; sx++ {
i := scaled.PixOffset(col*st.sw+sx, row*st.sh+sy)
px[sy*st.sw+sx] = color.RGBA{scaled.Pix[i], scaled.Pix[i+1], scaled.Pix[i+2], 255}
}
}
ch, fg, bg := st.bestCell(px)
cells = append(cells, styledCell{ch,
tcell.NewRGBColor(int32(fg.R), int32(fg.G), int32(fg.B)),
tcell.NewRGBColor(int32(bg.R), int32(bg.G), int32(bg.B))})
}
}
if best == 0 {
c := toRGBA(flat)
return ' ', c, c
}
fm, _ := mean(best, true)
bm, _ := mean(best, false)
return quadrants[best], toRGBA(fm), toRGBA(bm)
return cells
}
// coverRef says where a poster comes from. The zero value means "no poster".
+63 -7
View File
@@ -94,7 +94,7 @@ func TestQuadCell(t *testing.T) {
{"one corner", [4]color.RGBA{red, blue, blue, blue}, '▘', red, blue, false},
{"diagonal", [4]color.RGBA{red, black, black, red}, '▞', black, red, false},
} {
ch, fg, bg := quadCell(tc.px)
ch, fg, bg := quadrantStyle.bestCell(tc.px[:])
if ch != tc.wantCh || fg != tc.wantFg || bg != tc.wantBg {
t.Errorf("%s: %q fg=%v bg=%v, want %q fg=%v bg=%v", tc.name, ch, fg, bg, tc.wantCh, tc.wantFg, tc.wantBg)
}
@@ -102,7 +102,7 @@ func TestQuadCell(t *testing.T) {
// Nearly flat noise stays a flat cell instead of a distracting pattern.
n := color.RGBA{100, 100, 100, 255}
m := color.RGBA{102, 100, 101, 255}
if ch, _, _ := quadCell([4]color.RGBA{n, m, n, n}); ch != ' ' {
if ch, _, _ := quadrantStyle.bestCell([]color.RGBA{n, m, n, n}); ch != ' ' {
t.Errorf("noise rendered as %q", ch)
}
}
@@ -127,7 +127,7 @@ func TestCoverViewDraws(t *testing.T) {
defer screen.Fini()
screen.SetSize(30, 10)
c := newCoverView()
c := newCoverView(quadrantStyle)
c.setImage(stripes(40, 60)) // red above blue
c.SetRect(0, 0, 30, 10) // fits 13 columns × 10 rows
@@ -142,14 +142,15 @@ func TestCoverViewDraws(t *testing.T) {
return ch, rgba(fg), rgba(bg)
}
red, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}
if _, fg, bg := cell(0, 0); fg != red || bg != red {
// The poster is 13 columns wide and sits against the right edge (x 17..29).
if _, fg, bg := cell(17, 0); fg != red || bg != red {
t.Errorf("top row = fg %v bg %v, want red", fg, bg)
}
if _, fg, bg := cell(12, 9); fg != blue || bg != blue {
if _, fg, bg := cell(29, 9); fg != blue || bg != blue {
t.Errorf("bottom right = fg %v bg %v, want blue", fg, bg)
}
// Outside the poster's footprint nothing is drawn.
if ch, _, _, _ := screen.GetContent(13, 0); ch != ' ' {
// Left of the poster's footprint nothing is drawn.
if ch, _, _, _ := screen.GetContent(16, 0); ch != ' ' {
t.Errorf("poster stretched beyond its aspect ratio: %q", ch)
}
}
@@ -188,3 +189,58 @@ func TestLoadCoverRemembersFailures(t *testing.T) {
t.Error("expected an error for a recently failed cover")
}
}
func TestSextantGlyphs(t *testing.T) {
seen := map[rune]int{}
for mask := 0; mask < 64; mask++ {
r := sextantGlyph(mask)
if prev, dup := seen[r]; dup {
t.Fatalf("masks %d and %d share the glyph %q", prev, mask, r)
}
seen[r] = mask
if mask > 0 && mask < 63 && mask != 21 && mask != 42 && (r < 0x1FB00 || r > 0x1FB3B) {
t.Errorf("mask %d: %U outside the sextant block", mask, r)
}
}
for mask, want := range map[int]rune{1: 0x1FB00, 2: 0x1FB01, 20: 0x1FB13, 22: 0x1FB14, 43: 0x1FB28, 62: 0x1FB3B, 21: '▌', 42: '▐', 63: '█', 0: ' '} {
if got := sextantGlyph(mask); got != want {
t.Errorf("sextantGlyph(%d) = %U, want %U", mask, got, want)
}
}
}
func TestSextantCell(t *testing.T) {
red, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}
// Two rows of red above one row of blue.
ch, fg, bg := sextantStyle.bestCell([]color.RGBA{red, red, red, red, blue, blue})
if ch != sextantGlyph(15) || fg != red || bg != blue {
t.Errorf("got %U fg=%v bg=%v", ch, fg, bg)
}
// One row each: red, blue, red.
ch, fg, bg = sextantStyle.bestCell([]color.RGBA{red, red, blue, blue, red, red})
if ch != sextantGlyph(0b001100) || fg != blue || bg != red {
t.Errorf("three bands: %U fg=%v bg=%v", ch, fg, bg)
}
}
func TestCoverStyleFor(t *testing.T) {
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
for _, tc := range []struct {
setting string
env map[string]string
want string
}{
{"auto", map[string]string{"TERM": "xterm-kitty"}, "sextant"},
{"", map[string]string{"TERM_PROGRAM": "WezTerm"}, "sextant"},
{"auto", map[string]string{"TERM": "foot"}, "sextant"},
{"auto", map[string]string{"KITTY_WINDOW_ID": "3", "TERM": "screen"}, "sextant"},
{"auto", map[string]string{"TERM": "xterm-256color"}, "quadrant"},
{"auto", nil, "quadrant"},
{"quadrant", map[string]string{"TERM": "xterm-kitty"}, "quadrant"},
{"sextant", nil, "sextant"},
} {
if got := coverStyleFor(tc.setting, env(tc.env)).name; got != tc.want {
t.Errorf("%q %v = %s, want %s", tc.setting, tc.env, got, tc.want)
}
}
}
+49 -24
View File
@@ -8,39 +8,49 @@ import (
)
const (
coverRows = 20 // poster height in terminal rows
minCoverRows = 6 // a smaller poster is not worth showing
minCoverCols = 8 //
minTextRows = 10 // the poster never takes these rows from the text
)
// detailPane shows a poster above a text. The poster shrinks on short panes
// and is dropped when the text would get too little room.
// detailPane shows the details of one title: the head (title and facts) with
// the poster to its right on top, and the plot below both. On panes too small
// for the poster everything is one text.
type detailPane struct {
*tview.Flex
app *App
cover *coverView
text *tview.TextView
*tview.Flex // column: top row, plot
app *App
top *tview.Flex // row: head, cover
head *tview.TextView
cover *coverView
plot *tview.TextView
want string // key of the poster that should be shown
hasCover bool // a poster (or its loading note) is part of the content
coverSize int // rows currently given to the poster
text detailText
want string // key of the poster that should be shown
hasCover bool // a poster (or its loading note) is part of the content
combined bool // head and plot are shown together in the plot view
}
func newDetailPane(app *App) *detailPane {
d := &detailPane{
Flex: tview.NewFlex().SetDirection(tview.FlexRow),
app: app,
cover: newCoverView(),
text: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
top: tview.NewFlex(),
head: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
cover: newCoverView(app.coverStyle),
plot: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
}
d.AddItem(d.cover, 0, 0, false).AddItem(d.text, 0, 1, false)
d.cover.SetBorderPadding(0, 0, 1, 0)
d.top.AddItem(d.head, 0, 1, false).AddItem(d.cover, 0, 0, false)
d.AddItem(d.top, 0, 0, false).AddItem(d.plot, 0, 1, false)
d.SetBorder(true).SetTitle(" Details ")
d.combined = true
return d
}
// Show displays the text and, if there is one, the poster.
func (d *detailPane) Show(text string, ref coverRef) {
d.text.SetText(text).ScrollToBeginning()
func (d *detailPane) Show(text detailText, ref coverRef) {
d.text = text
d.applyText()
if !d.app.covers || ref.fetch == nil {
d.want, d.hasCover = "", false
d.cover.setImage(nil)
@@ -63,20 +73,35 @@ func (d *detailPane) Show(text string, ref coverRef) {
}
// Clear empties the pane.
func (d *detailPane) Clear() { d.Show("", coverRef{}) }
func (d *detailPane) Clear() { d.Show(detailText{}, coverRef{}) }
// applyText puts the texts into their views for the current layout.
func (d *detailPane) applyText() {
if d.combined {
d.head.SetText("")
d.plot.SetText(d.text.String()).ScrollToBeginning()
return
}
d.head.SetText(d.text.head).ScrollToBeginning()
d.plot.SetText(d.text.plot).ScrollToBeginning()
}
func (d *detailPane) Draw(screen tcell.Screen) {
_, _, _, h := d.GetInnerRect()
rows := 0
_, _, w, h := d.GetInnerRect()
coverW, coverH := 0, 0
if d.hasCover {
rows = min(coverRows, h-minTextRows)
if rows < minCoverRows {
rows = 0
aw, ah := d.cover.aspect()
// At most 40% of the width, and the plot keeps its rows.
coverW, coverH = coverCells(aw, ah, w*2/5, h-minTextRows)
if coverH < minCoverRows || coverW < minCoverCols {
coverW, coverH = 0, 0
}
}
if rows != d.coverSize {
d.coverSize = rows
d.ResizeItem(d.cover, rows, 0)
if combined := coverW == 0; combined != d.combined {
d.combined = combined
d.applyText()
}
d.ResizeItem(d.top, coverH, 0)
d.top.ResizeItem(d.cover, coverW+1, 0) // +1: the padding left of the poster
d.Flex.Draw(screen)
}
+76
View File
@@ -0,0 +1,76 @@
package ui
import (
"strings"
"testing"
"github.com/gdamore/tcell/v2"
)
func rowText(screen tcell.SimulationScreen, y, x0, x1 int) string {
var b strings.Builder
for x := x0; x < x1; x++ {
ch, _, _, _ := screen.GetContent(x, y)
b.WriteRune(ch)
}
return b.String()
}
func TestDetailPaneLayout(t *testing.T) {
a := newTestApp(t)
screen := tcell.NewSimulationScreen("")
if err := screen.Init(); err != nil {
t.Fatal(err)
}
defer screen.Fini()
screen.SetSize(60, 40)
d := newDetailPane(a)
d.SetRect(0, 0, 60, 40)
d.Show(detailText{head: "[::b]The Matrix (1999)[::-]\nStatus: released", plot: "Plot text goes here"}, coverRef{})
// Without a poster everything is one text.
d.Draw(screen)
if got := rowText(screen, 1, 1, 30); !strings.HasPrefix(got, "The Matrix (1999)") {
t.Errorf("first line = %q", got)
}
if got := rowText(screen, 3, 1, 30); !strings.HasPrefix(got, "Plot text goes here") {
t.Errorf("without a poster the plot follows the head: %q", got)
}
// With a poster: head on the left, poster on the right, plot below both.
d.hasCover = true
d.cover.setImage(stripes(40, 60))
d.Draw(screen)
if got := rowText(screen, 1, 1, 30); !strings.HasPrefix(got, "The Matrix (1999)") {
t.Errorf("head moved: %q", got)
}
coverX := -1
for x := 59; x > 30; x-- {
if ch, _, st, _ := screen.GetContent(x, 1); ch != ' ' || st != tcell.StyleDefault {
coverX = x
break
}
}
if coverX < 55 {
t.Errorf("no poster against the right edge (last drawn column %d)", coverX)
}
// The plot starts below the poster: 40*2/5 = 24 columns -> 24*3/2/2 = 18 rows.
plotRow := -1
for y := 1; y < 39; y++ {
if strings.HasPrefix(rowText(screen, y, 1, 30), "Plot text goes here") {
plotRow = y
break
}
}
if plotRow < 18 {
t.Errorf("plot at row %d, want below the poster", plotRow)
}
// A pane too short for the poster falls back to plain text.
d.SetRect(0, 0, 60, 14)
d.Draw(screen)
if !d.combined {
t.Error("a short pane must drop the poster")
}
}
+18 -10
View File
@@ -25,8 +25,22 @@ func movieLabel(m radarr.Movie) string {
return label
}
// detailText is the text of a detail pane: a short head (title, facts,
// state) and the plot below it.
type detailText struct {
head, plot string
}
// String joins both parts, for places that show them as one text.
func (d detailText) String() string {
if d.plot == "" {
return d.head
}
return d.head + "\n" + d.plot
}
// movieDetails renders the detail pane text for a movie.
func movieDetails(m radarr.Movie) string {
func movieDetails(m radarr.Movie) detailText {
var b strings.Builder
title := tview.Escape(m.Title)
if m.Year > 0 {
@@ -59,10 +73,7 @@ func movieDetails(m radarr.Movie) string {
case m.InLibrary():
b.WriteString("[yellow]In library, not downloaded[-]\n")
}
if m.Overview != "" {
b.WriteString("\n" + tview.Escape(m.Overview) + "\n")
}
return b.String()
return detailText{head: b.String(), plot: tview.Escape(m.Overview)}
}
// profileIndex returns the index of the profile named name, or 0.
@@ -100,7 +111,7 @@ func seriesLabel(s sonarr.Series) string {
}
// seriesDetails renders the detail pane text for a series.
func seriesDetails(s sonarr.Series) string {
func seriesDetails(s sonarr.Series) detailText {
var b strings.Builder
title := tview.Escape(s.Title)
if s.Year > 0 {
@@ -138,10 +149,7 @@ func seriesDetails(s sonarr.Series) string {
fmt.Fprintf(&b, "[yellow]In library, %d/%d episodes downloaded[-]\n", st.EpisodeFileCount, st.EpisodeCount)
}
}
if s.Overview != "" {
b.WriteString("\n" + tview.Escape(s.Overview) + "\n")
}
return b.String()
return detailText{head: b.String(), plot: tview.Escape(s.Overview)}
}
func plural(n int) string {
+7 -4
View File
@@ -28,8 +28,11 @@ func TestMovieDetails(t *testing.T) {
ID: 1, Title: "Alien", Year: 1979, Runtime: 117, Genres: []string{"Horror", "Sci-Fi"},
Overview: "In space...", Ratings: radarr.Ratings{IMDB: &radarr.Rating{Value: 8.5}},
})
if got.plot != "In space..." || strings.Contains(got.head, "In space") {
t.Errorf("plot belongs below the head: head=%q plot=%q", got.head, got.plot)
}
for _, want := range []string{"Alien (1979)", "★ 8.5", "1h57m", "Horror, Sci-Fi", "In library, not downloaded", "In space..."} {
if !strings.Contains(got, want) {
if !strings.Contains(got.String(), want) {
t.Errorf("details missing %q:\n%s", want, got)
}
}
@@ -56,7 +59,7 @@ func TestSeriesLabelAndDetails(t *testing.T) {
if got := seriesLabel(s); got != "Breaking Bad (2008)" {
t.Errorf("label = %q", got)
}
got := seriesDetails(s)
got := seriesDetails(s).String()
for _, want := range []string{"Breaking Bad (2008)", "★ 9.3", "1 season ·", "47 min", "Network: AMC", "Status: ended"} {
if !strings.Contains(got, want) {
t.Errorf("details missing %q:\n%s", want, got)
@@ -65,11 +68,11 @@ func TestSeriesLabelAndDetails(t *testing.T) {
s.ID = 1
s.Statistics = sonarr.Statistics{EpisodeCount: 10, EpisodeFileCount: 4}
if got := seriesDetails(s); !strings.Contains(got, "4/10 episodes") {
if got := seriesDetails(s).String(); !strings.Contains(got, "4/10 episodes") {
t.Errorf("progress missing:\n%s", got)
}
s.Statistics.EpisodeFileCount = 10
if got := seriesDetails(s); !strings.Contains(got, "all episodes downloaded") {
if got := seriesDetails(s).String(); !strings.Contains(got, "all episodes downloaded") {
t.Errorf("complete marker missing:\n%s", got)
}
}
+8 -3
View File
@@ -46,7 +46,7 @@ type libItem struct {
missing bool // monitored and not complete
status string
size string
details string
details detailText
cover coverRef
movie *radarr.Movie
series *sonarr.Series
@@ -105,7 +105,11 @@ func newMovieItem(m radarr.Movie) libItem {
default:
it.status = "[yellow]missing"
}
it.details = movieDetails(m) + monitoredLine(m.Monitored) + sizeLine(m.SizeOnDisk) + pathLine(m.Path)
it.details = movieDetails(m)
it.details.head += monitoredLine(m.Monitored) + sizeLine(m.SizeOnDisk)
if path := pathLine(m.Path); path != "" {
it.details.plot += "\n\n" + path
}
return it
}
@@ -130,7 +134,8 @@ func newSeriesItem(s sonarr.Series) libItem {
default:
it.status = "[yellow]" + counts
}
it.details = seriesDetails(s) + monitoredLine(s.Monitored) + sizeLine(st.SizeOnDisk)
it.details = seriesDetails(s)
it.details.head += monitoredLine(s.Monitored) + sizeLine(st.SizeOnDisk)
return it
}