Posters: re-place after a resize, measure cell size without padding, log failed posters
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+36
-2
@@ -66,6 +66,7 @@ type App struct {
|
||||
covers bool // poster thumbnails enabled
|
||||
coverStyle coverStyle
|
||||
gfx *kittyGraphics // real-image posters, nil when not used
|
||||
lastWindow [3]int // columns, rows and cell shape at the last draw
|
||||
cellAspect float64 // height of a terminal cell divided by its width
|
||||
coverCache *coverCache
|
||||
}
|
||||
@@ -181,6 +182,7 @@ func New(cfg *config.Config) *App {
|
||||
s.HideCursor()
|
||||
a.screen = s
|
||||
a.cellAspect = cellAspectOf(s)
|
||||
a.noteWindow(s)
|
||||
return false
|
||||
})
|
||||
a.app.SetRoot(&toastRoot{Flex: layout, app: a}, true).EnableMouse(false)
|
||||
@@ -190,6 +192,22 @@ func New(cfg *config.Config) *App {
|
||||
return a
|
||||
}
|
||||
|
||||
// noteWindow notices when the terminal's size or cell size changed. The
|
||||
// placements of real-image posters are then made again: the picture would
|
||||
// otherwise rely on the terminal rescaling an existing placement correctly.
|
||||
func (a *App) noteWindow(s tcell.Screen) {
|
||||
w, h := s.Size()
|
||||
win := [3]int{w, h, int(a.cellAspect * 1000)}
|
||||
if win == a.lastWindow {
|
||||
return
|
||||
}
|
||||
first := a.lastWindow == [3]int{}
|
||||
a.lastWindow = win
|
||||
if !first && a.gfx != nil {
|
||||
a.gfx.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
// writeTerminal sends raw bytes (graphics commands) to the terminal through the
|
||||
// screen's own connection. UI goroutine only, so it stays in step with drawing.
|
||||
func (a *App) writeTerminal(b []byte) error {
|
||||
@@ -208,13 +226,29 @@ func (a *App) writeTerminal(b []byte) error {
|
||||
// pixels; 2 when the terminal does not report pixel sizes.
|
||||
func cellAspectOf(s tcell.Screen) float64 {
|
||||
if tty, ok := s.Tty(); ok {
|
||||
if ws, err := tty.WindowSize(); err == nil && ws.Width > 0 && ws.Height > 0 && ws.PixelWidth > 0 && ws.PixelHeight > 0 {
|
||||
return (float64(ws.PixelHeight) / float64(ws.Height)) / (float64(ws.PixelWidth) / float64(ws.Width))
|
||||
if ws, err := tty.WindowSize(); err == nil {
|
||||
return cellAspectFromWindow(ws.Width, ws.Height, ws.PixelWidth, ws.PixelHeight)
|
||||
}
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// cellAspectFromWindow derives the cell shape from the window size in cells
|
||||
// and pixels. The pixel size usually includes some padding around the cell
|
||||
// grid, which would skew a plain division (more with a small grid, and it
|
||||
// changes while the window is resized); cell sizes are whole pixels, so the
|
||||
// division is rounded down, which drops that padding.
|
||||
func cellAspectFromWindow(cols, rows, pixelW, pixelH int) float64 {
|
||||
if cols <= 0 || rows <= 0 || pixelW <= 0 || pixelH <= 0 {
|
||||
return 2
|
||||
}
|
||||
cw, ch := pixelW/cols, pixelH/rows
|
||||
if cw <= 0 || ch <= 0 {
|
||||
return 2
|
||||
}
|
||||
return float64(ch) / float64(cw)
|
||||
}
|
||||
|
||||
// coverMaxHeight is how tall the stored poster copies are: real images keep
|
||||
// more detail than block characters need.
|
||||
func (a *App) coverMaxHeight() int {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"image/color"
|
||||
_ "image/jpeg" // poster formats
|
||||
_ "image/png"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -503,6 +504,8 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
|
||||
delete(c.waiting, ref.key)
|
||||
if err != nil {
|
||||
c.failed[ref.key] = time.Now()
|
||||
// Say why in the log: a poster that silently never shows up is hard to diagnose.
|
||||
a.logs.add(levelWarn, "Poster not loaded ("+posterSource(ref.key)+"): "+err.Error())
|
||||
} else {
|
||||
if len(c.imgs) >= maxCoverCache {
|
||||
c.imgs = map[string]*image.RGBA{} // simple bound; posters are cheap to fetch again
|
||||
@@ -518,3 +521,16 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// posterSource describes where a poster key points, for the log: the host and
|
||||
// file name of a remote image, or the path on the Radarr/Sonarr instance.
|
||||
func posterSource(key string) string {
|
||||
if _, local, ok := strings.Cut(key, "|"); ok {
|
||||
key = local
|
||||
}
|
||||
if u, err := url.Parse(key); err == nil && u.Host != "" {
|
||||
return u.Host + u.Path
|
||||
}
|
||||
path, _, _ := strings.Cut(key, "?")
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -124,6 +124,15 @@ func (k *kittyGraphics) place(id uint32, cols, rows int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// invalidate drops every placement (the uploaded pictures stay) so the next
|
||||
// draw places them again, e.g. after the terminal was resized.
|
||||
func (k *kittyGraphics) invalidate() {
|
||||
for id := range k.placed {
|
||||
_ = k.write(kittyAPC(fmt.Sprintf("a=d,d=i,i=%d,q=2", id), nil))
|
||||
}
|
||||
k.placed = map[uint32][2]int{}
|
||||
}
|
||||
|
||||
// deleteAll removes every image uploaded so far from the terminal.
|
||||
func (k *kittyGraphics) deleteAll() {
|
||||
for _, id := range k.ids {
|
||||
|
||||
@@ -279,3 +279,51 @@ func TestCropToBox(t *testing.T) {
|
||||
t.Errorf("offset image: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateReplacesPlacements(t *testing.T) {
|
||||
rec := &recorder{}
|
||||
k := newKittyGraphics(rec.write)
|
||||
k.place(3, 17, 13)
|
||||
k.place(4, 10, 8)
|
||||
rec.cmds = nil
|
||||
k.invalidate()
|
||||
if len(rec.cmds) != 2 || !strings.Contains(strings.Join(rec.cmds, ""), "a=d,d=i,i=3") || !strings.Contains(strings.Join(rec.cmds, ""), "a=d,d=i,i=4") {
|
||||
t.Fatalf("invalidate sent %q", rec.cmds)
|
||||
}
|
||||
// The same size is placed again afterwards, since the placement is gone.
|
||||
rec.cmds = nil
|
||||
k.place(3, 17, 13)
|
||||
if len(rec.cmds) != 1 || !strings.Contains(rec.cmds[0], "a=p,U=1,i=3,c=17,r=13") {
|
||||
t.Errorf("after invalidate: %q", rec.cmds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCellAspectFromWindow(t *testing.T) {
|
||||
// 140×50 cells of 9×20 px plus 4 and 11 px of padding: plain division
|
||||
// would say 1264/140=9.03 and 1011/50=20.22 (aspect 2.24), the real shape is 20/9.
|
||||
if got := cellAspectFromWindow(140, 50, 140*9+4, 50*20+11); math.Abs(got-20.0/9) > 1e-9 {
|
||||
t.Errorf("aspect = %v, want %v", got, 20.0/9)
|
||||
}
|
||||
// Padding that grows with the window does not move it.
|
||||
for _, pad := range []int{0, 3, 8} {
|
||||
if got := cellAspectFromWindow(100, 30, 100*10+pad, 30*21+pad); math.Abs(got-2.1) > 1e-9 {
|
||||
t.Errorf("padding %d: aspect = %v", pad, got)
|
||||
}
|
||||
}
|
||||
for _, bad := range [][4]int{{0, 10, 100, 100}, {10, 0, 100, 100}, {10, 10, 0, 100}, {10, 10, 100, 0}, {100, 100, 50, 50}} {
|
||||
if got := cellAspectFromWindow(bad[0], bad[1], bad[2], bad[3]); got != 2 {
|
||||
t.Errorf("%v: aspect = %v, want the default 2", bad, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPosterSource(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"https://artworks.thetvdb.com/banners/posters/1-1.jpg": "artworks.thetvdb.com/banners/posters/1-1.jpg",
|
||||
"http://radarr:7878|/MediaCover/1/poster.jpg?lastWrite=5": "/MediaCover/1/poster.jpg",
|
||||
} {
|
||||
if got := posterSource(in); got != want {
|
||||
t.Errorf("posterSource(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user