Posters: always a 2:3 box

The poster box followed the image's shape, so images Radarr/Sonarr hand
out in another shape came out smaller than the others in a list. The box
is now always 2:3: an image within 12% of that fills it, trimmed at the
long sides (block characters too, which were stretched before); one
further off is shown whole, centred in the box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 11:15:26 +02:00
co-authored by Claude Opus 5.5
parent 1056846a48
commit 786e783951
4 changed files with 84 additions and 12 deletions
+34 -11
View File
@@ -311,13 +311,26 @@ func (c *coverView) aspectOfCells() float64 {
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
// Every poster gets a box of the usual poster shape, 2:3, so all posters of a
// list have the same size whatever image Radarr/Sonarr hand out.
const (
posterW, posterH = 2, 3
// posterShapeTolerance is how far an image's shape may differ from 2:3 and
// still fill the box (trimmed at the long sides); one further off is shown
// whole inside it.
posterShapeTolerance = 0.12
)
// aspect returns the shape of the box the poster is shown in.
func (c *coverView) aspect() (w, h int) { return posterW, posterH }
// nearPosterShape reports whether an image is close enough to 2:3 to fill the box.
func nearPosterShape(b image.Rectangle) bool {
if b.Dx() <= 0 || b.Dy() <= 0 {
return false
}
b := c.img.Bounds()
return b.Dx(), b.Dy()
d := float64(b.Dx())*posterH/(float64(b.Dy())*posterW) - 1
return d >= -posterShapeTolerance && d <= posterShapeTolerance
}
// coverFitTolerance is how far the shape of the cell box may differ from the
@@ -376,19 +389,26 @@ func (c *coverView) Draw(screen tcell.Screen) {
return
}
// The 2:3 box, right-aligned in the area. An image close to that shape
// fills it (trimmed); one far from it (square, landscape) is shown whole,
// centred in the box.
b := c.img.Bounds()
cols, rows := coverCells(b.Dx(), b.Dy(), w, h, c.aspectOfCells())
boxW, boxH := coverCells(posterW, posterH, w, h, c.aspectOfCells())
cols, rows := boxW, boxH
if !nearPosterShape(b) {
cols, rows = coverCells(b.Dx(), b.Dy(), boxW, boxH, c.aspectOfCells())
}
if cols < 1 || rows < 1 {
return
}
if c.drawGraphics(screen, x+w-cols, y, cols, rows) {
x += w - boxW + (boxW-cols)/2
y += (boxH - rows) / 2
if c.drawGraphics(screen, x, y, cols, rows) {
return
}
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++ {
cell := c.cells[row*cols+col]
@@ -445,7 +465,10 @@ func (c *coverView) drawGraphics(screen tcell.Screen, x, y, cols, rows int) bool
// 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)
// Trim the image to the shape of the cells first, as for real images, so
// it is not stretched.
crop := cropToBox(c.img.Bounds(), cols, rows, c.aspectOfCells())
scaled := boxScale(c.img.SubImage(crop), 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++ {
+45
View File
@@ -358,3 +358,48 @@ func TestCellsStyleIsFlat(t *testing.T) {
t.Errorf("got %q %v %v", ch, fg, bg)
}
}
func TestPostersShareTheTwoByThreeBox(t *testing.T) {
sim := tcell.NewSimulationScreen("")
if err := sim.Init(); err != nil {
t.Fatal(err)
}
defer sim.Fini()
sim.SetSize(40, 30)
// extent returns the columns and rows covered by poster cells (white).
extent := func(w, h int) (x0, y0, x1, y1 int) {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for i := range img.Pix {
img.Pix[i] = 255
}
sim.Clear()
c := newCoverView(quadrantStyle, nil, func() float64 { return 2 })
c.setImage(img)
c.SetRect(0, 0, 40, 30)
c.Draw(sim)
x0, y0, x1, y1 = 99, 99, -1, -1
for y := 0; y < 30; y++ {
for x := 0; x < 40; x++ {
_, _, st, _ := sim.GetContent(x, y)
if _, bg, _ := st.Decompose(); bg == tcell.NewRGBColor(255, 255, 255) {
x0, y0, x1, y1 = min(x0, x), min(y0, y), max(x1, x), max(y1, y)
}
}
}
return
}
ax0, ay0, ax1, ay1 := extent(2000, 3000) // the usual shape
bx0, by0, bx1, by1 := extent(2100, 3000) // 5% wider: trimmed, same box
if ax0 != bx0 || ay0 != by0 || ax1 != bx1 || ay1 != by1 {
t.Errorf("2:3 at %d,%d-%d,%d but 0.7 at %d,%d-%d,%d", ax0, ay0, ax1, ay1, bx0, by0, bx1, by1)
}
if ay0 != 0 || ax1 != 39 {
t.Errorf("the box sits at the top right: %d,%d-%d,%d", ax0, ay0, ax1, ay1)
}
// A square image is shown whole: as wide as the box, shorter, centred.
sx0, sy0, sx1, sy1 := extent(1000, 1000)
if sx0 != ax0 || sx1 != ax1 || sy1-sy0 >= ay1-ay0 || sy0 <= ay0 || (sy0-ay0)-(ay1-sy1) > 1 || (ay1-sy1)-(sy0-ay0) > 1 {
t.Errorf("square at %d,%d-%d,%d in the box %d,%d-%d,%d", sx0, sy0, sx1, sy1, ax0, ay0, ax1, ay1)
}
}