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:
@@ -32,6 +32,12 @@ type UI struct {
|
||||
// Posters of titles that are not in the library yet are downloaded from
|
||||
// the public image hosts Radarr/Sonarr point to (TMDb, TheTVDB).
|
||||
Covers *bool `json:"covers,omitempty"`
|
||||
// CoverStyle selects the block characters posters are drawn with:
|
||||
// "quadrant" (2x2 pixels per cell, every font has the glyphs), "sextant"
|
||||
// (2x3, sharper, needs a font with the Symbols for Legacy Computing
|
||||
// block, e.g. kitty, WezTerm, foot, Ghostty) or "auto" (default: sextant
|
||||
// on terminals known to draw them, quadrant elsewhere).
|
||||
CoverStyle string `json:"cover_style,omitempty"`
|
||||
}
|
||||
|
||||
// ShowCovers reports whether poster thumbnails are enabled.
|
||||
@@ -146,5 +152,10 @@ func (c *Config) Validate() error {
|
||||
errs = append(errs, fmt.Errorf("%s: url %q must be an http(s) URL", s.name, s.svc.URL))
|
||||
}
|
||||
}
|
||||
switch c.UI.CoverStyle {
|
||||
case "", "auto", "quadrant", "sextant":
|
||||
default:
|
||||
errs = append(errs, fmt.Errorf(`ui.cover_style: %q must be "auto", "quadrant" or "sextant"`, c.UI.CoverStyle))
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
@@ -97,3 +97,15 @@ func TestShowCovers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverStyleValidation(t *testing.T) {
|
||||
base := `{"radarr":{"url":"http://r","apikey":"k"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"},"ui":{"cover_style":`
|
||||
for _, ok := range []string{`"auto"`, `"quadrant"`, `"sextant"`, `""`} {
|
||||
if _, err := Parse([]byte(base + ok + `}}`)); err != nil {
|
||||
t.Errorf("%s rejected: %v", ok, err)
|
||||
}
|
||||
}
|
||||
if _, err := Parse([]byte(base + `"braille"}}`)); err == nil {
|
||||
t.Error("unknown style accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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".
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user