Real-image posters: a new size is a new image id, never a changed placement

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 00:29:16 +02:00
co-authored by Claude Sonnet 5.5
parent 2ba47cb4ee
commit cf07aaf8fb
5 changed files with 82 additions and 112 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ Notes:
- Global speed / pause state in the status bar. Pause/resume and delete a SABnzbd job.
- **Status bar / help:** the bottom bar lists the keys that work in the current view or panel; the top right shows the connection state of each service; `F9` opens the full key reference.
- **Posters:** the detail panes show the poster at the top right; the head (title, facts, path) and the plot are one text that flows along the poster's left side and continues at full width below it (text is wrapped by arr-tui itself, with a narrower width for the first lines); on panes too small for the poster, the text simply uses the full width. Two ways to draw the poster, chosen by `ui.cover_style`:
- **Real images** (`kitty`, and `auto` in kitty/Ghostty): the Kitty graphics protocol with Unicode placeholders. The poster (kept up to 640 px tall) is uploaded once as PNG (`a=t,f=100,q=2`, 4096-byte base64 chunks), placed virtually with the cell footprint (`a=p,U=1,c=,r=`; a new size deletes and recreates the placement), and shown by writing U+10EEEE placeholder characters whose combining marks give row and column (the 297-entry table from the protocol) and whose foreground color is the image id. Since it is ordinary text, popups, dialogs and view changes cover it correctly and tcell's diffing keeps it in place. The footprint uses the real cell size in pixels (the window's pixel size divided by its cell count, rounded down to whole pixels so padding around the grid does not skew it; the placements are made again whenever the window or cell size changes) and a box whose shape is within 3% of the poster's; the remaining sliver is cropped from the poster's edges before upload (one upload per size, the previous one is deleted), because the terminal letterboxes an image that does not fit its box exactly. As a second safeguard the placeholder cells use the panes' background color, so anything the picture does not cover blends in (without it the terminal's own, slightly different, default background showed as grey bars beside the poster). Writes to the terminal go through the screen's tty on the UI goroutine, so they cannot interleave with drawing. Requires 24-bit color; otherwise, and inside tmux/screen, the block fallback is used. Terminals are recognised from `TERM`/`TERM_PROGRAM`/`KITTY_WINDOW_ID`/`GHOSTTY_RESOURCES_DIR`. The go-termimg library was looked at and not used: its placeholder support is marked experimental, its diacritic table does not match the protocol's, and it writes images straight to the terminal instead of through the screen.
- **Real images** (`kitty`, and `auto` in kitty/Ghostty): the Kitty graphics protocol with Unicode placeholders. The poster (kept up to 640 px tall) is uploaded once as PNG (`a=t,f=100,q=2`, 4096-byte base64 chunks), placed virtually with the cell footprint (`a=p,U=1,c=,r=`; a new size deletes and recreates the placement), and shown by writing U+10EEEE placeholder characters whose combining marks give row and column (the 297-entry table from the protocol) and whose foreground color is the image id. Since it is ordinary text, popups, dialogs and view changes cover it correctly and tcell's diffing keeps it in place. The footprint uses the real cell size in pixels (the window's pixel size divided by its cell count, rounded down to whole pixels so padding around the grid does not skew it; all images are uploaded afresh whenever the window or cell size changes) and a box whose shape is within 3% of the poster's; the remaining sliver is cropped from the poster's edges before upload (one upload per size, the previous one is deleted), because the terminal letterboxes an image that does not fit its box exactly. As a second safeguard the placeholder cells use the panes' background color, so anything the picture does not cover blends in (without it the terminal's own, slightly different, default background showed as grey bars beside the poster). Writes to the terminal go through the screen's tty on the UI goroutine, so they cannot interleave with drawing. Requires 24-bit color; otherwise, and inside tmux/screen, the block fallback is used. Terminals are recognised from `TERM`/`TERM_PROGRAM`/`KITTY_WINDOW_ID`/`GHOSTTY_RESOURCES_DIR`. The go-termimg library was looked at and not used: its placeholder support is marked experimental, its diacritic table does not match the protocol's, and it writes images straight to the terminal instead of through the screen.
- **Block characters** (`quadrant`, `sextant`, and the fallback): every cell shows several pixels in the best two-color split (`quadrant`: 2×2 pixels, glyphs from the Block Elements range every font has; `sextant`: 2×3 pixels, sharper, glyphs from Symbols for Legacy Computing). `auto` picks sextants on kitty, WezTerm, foot and Ghostty, and on other terminals when fontconfig lists a font with the sextant characters (`fc-list :charset=1fb00`, about 20 ms at startup; terminals draw glyphs their font lacks from fallback fonts; skipped over SSH because the installed fonts are the remote machine's), and quadrants otherwise.
Posters are JPEG, PNG, GIF or WebP (Radarr and Sonarr keep whatever bytes the provider sent under a `.jpg` name, which can be WebP), decoded and downscaled in the process; for any other format the Log says what the data looked like (e.g. AVIF, an HTML error page, or its first bytes). Library items fetch `/MediaCover/<id>/poster-250.jpg` from their Radarr/Sonarr instance with the API key (then the original `poster.jpg`; an instance that answers 200 with an HTML page, i.e. wants a login for `/MediaCover`, is also tried with the key as the `apikey` query parameter; and if the instance has no usable copy, the provider's `remoteUrl`, fetched without credentials); search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory (the terminal's copies are deleted when the cache is dropped and on exit), failed downloads are not retried for two minutes and are written to the Log (F7) with the reason, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it.
- **Calendar view (F5):** a two-week window (starting today, moved a week at a time with `[`/`]`, `t` returns to today) with the library's movie releases and airing episodes, grouped by day. Radarr `GET /calendar` supplies the cinema, digital and physical release dates (taken as written: midnight UTC means that calendar day) and Sonarr `GET /calendar?includeSeries=true` the episodes with their air time (shown in local time). Unmonitored items are included with `u`. A movie appears once per release date in the window. Each entry shows type, title, episode or release kind and status (downloaded / missing / upcoming); the detail pane shows poster and plot; `Enter`/`i` starts the release search and `s` an automatic search.
+3 -3
View File
@@ -193,8 +193,8 @@ func New(cfg *config.Config) *App {
}
// 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.
// real-image posters are then uploaded and placed again from scratch instead of
// relying on the terminal to rescale what is there.
func (a *App) noteWindow(s tcell.Screen) {
w, h := s.Size()
win := [3]int{w, h, int(a.cellAspect * 1000)}
@@ -204,7 +204,7 @@ func (a *App) noteWindow(s tcell.Screen) {
first := a.lastWindow == [3]int{}
a.lastWindow = win
if !first && a.gfx != nil {
a.gfx.invalidate()
a.gfx.deleteAll()
}
}
+1 -4
View File
@@ -401,10 +401,7 @@ func (c *coverView) drawGraphics(screen tcell.Screen, x, y, cols, rows int) bool
if c.gfx == nil || screen.Colors() < 1<<24 || cols > maxPlaceholders || rows > maxPlaceholders {
return false
}
id, err := c.gfx.imageID(c.img, cropToBox(c.img.Bounds(), cols, rows, c.aspectOfCells()))
if err == nil {
err = c.gfx.place(id, cols, rows)
}
id, err := c.gfx.show(c.img, cropToBox(c.img.Bounds(), cols, rows, c.aspectOfCells()), cols, rows)
if err != nil {
return false
}
+29 -47
View File
@@ -28,22 +28,29 @@ const placeholderRune = '\U0010EEEE'
const kittyChunk = 4096 // bytes of base64 per escape sequence, the protocol maximum
// kittyGraphics uploads images to the terminal and keeps track of them.
//
// Every footprint of a poster (the part of it that is shown and the number of
// cells it covers) is uploaded as its own image with its own id and placed once.
// An existing placement is never changed: terminals do not reliably replace the
// size of a placement that is already there (Ghostty kept showing the old, small
// one after the window was enlarged), whereas a new image id always starts clean.
type kittyGraphics struct {
write func([]byte) error // writes to the terminal, in step with the screen
nextID uint32
ids map[cropKey]uint32
latest map[*image.RGBA]cropKey // the crop of each image that is currently uploaded
placed map[uint32][2]int // columns and rows each image is currently placed with
ids map[footprint]uint32
latest map[*image.RGBA]footprint // the footprint of each poster that is currently uploaded
}
// cropKey identifies an uploaded image: a poster and the part of it that was sent.
type cropKey struct {
img *image.RGBA
rect image.Rectangle
// footprint identifies an uploaded image: a poster, the part of it that was
// sent, and the cells it covers.
type footprint struct {
img *image.RGBA
rect image.Rectangle
cols, rows int
}
func newKittyGraphics(write func([]byte) error) *kittyGraphics {
return &kittyGraphics{write: write, nextID: 1, ids: map[cropKey]uint32{}, latest: map[*image.RGBA]cropKey{}, placed: map[uint32][2]int{}}
return &kittyGraphics{write: write, nextID: 1, ids: map[footprint]uint32{}, latest: map[*image.RGBA]footprint{}}
}
// kittyAPC builds one graphics command: ESC _ G <keys> ; <payload> ESC \.
@@ -59,11 +66,12 @@ func kittyAPC(keys string, payload []byte) []byte {
return b.Bytes()
}
// imageID returns the terminal's id for the part rect of the image,
// uploading it on first use. When the same image is needed with another crop
// (after a resize) the earlier upload is removed.
func (k *kittyGraphics) imageID(img *image.RGBA, rect image.Rectangle) (uint32, error) {
key := cropKey{img, rect}
// show makes sure the part rect of the image is uploaded and placed over
// cols×rows cells, and returns the id the placeholder cells must carry. The same
// footprint is reused; a different one for the same poster replaces the old
// upload (which is removed from the terminal).
func (k *kittyGraphics) show(img *image.RGBA, rect image.Rectangle, cols, rows int) (uint32, error) {
key := footprint{img, rect, cols, rows}
if id, ok := k.ids[key]; ok {
return id, nil
}
@@ -91,11 +99,14 @@ func (k *kittyGraphics) imageID(img *image.RGBA, rect image.Rectangle) (uint32,
}
rest = rest[n:]
}
// U=1: a virtual placement, shown wherever placeholder cells refer to the id.
if err := k.write(kittyAPC(fmt.Sprintf("a=p,U=1,i=%d,c=%d,r=%d,q=2", id, cols, rows), nil)); err != nil {
return 0, err
}
k.nextID++
if old, ok := k.latest[img]; ok {
if oldID, ok := k.ids[old]; ok {
_ = k.write(kittyAPC(fmt.Sprintf("a=d,d=I,i=%d,q=2", oldID), nil))
delete(k.placed, oldID)
delete(k.ids, old)
}
}
@@ -104,43 +115,14 @@ func (k *kittyGraphics) imageID(img *image.RGBA, rect image.Rectangle) (uint32,
return id, nil
}
// place makes the image cover cols×rows cells for the placeholders. A new size
// replaces the previous placement.
func (k *kittyGraphics) place(id uint32, cols, rows int) error {
if p, ok := k.placed[id]; ok {
if p == [2]int{cols, rows} {
return nil
}
// Remove the old placement (the uploaded data stays) before making a new one.
if err := k.write(kittyAPC(fmt.Sprintf("a=d,d=i,i=%d,q=2", id), nil)); err != nil {
return err
}
}
// U=1: a virtual placement, shown wherever placeholder cells refer to it.
if err := k.write(kittyAPC(fmt.Sprintf("a=p,U=1,i=%d,c=%d,r=%d,q=2", id, cols, rows), nil)); err != nil {
return err
}
k.placed[id] = [2]int{cols, rows}
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.
// deleteAll removes every image uploaded so far from the terminal. The next
// draw uploads what it needs again, which is also how a resize is handled.
func (k *kittyGraphics) deleteAll() {
for _, id := range k.ids {
_ = k.write(kittyAPC(fmt.Sprintf("a=d,d=I,i=%d,q=2", id), nil))
}
k.ids = map[cropKey]uint32{}
k.latest = map[*image.RGBA]cropKey{}
k.placed = map[uint32][2]int{}
k.ids = map[footprint]uint32{}
k.latest = map[*image.RGBA]footprint{}
}
// placeholderCell returns what to draw in the cell at (row, col) of the image
+48 -57
View File
@@ -51,16 +51,21 @@ func TestKittyUploadChunksAndEncodes(t *testing.T) {
k := newKittyGraphics(rec.write)
img := noisy(200, 300) // incompressible: far more than one 4096-byte chunk of base64
id, err := k.imageID(img, img.Bounds())
id, err := k.show(img, img.Bounds(), 20, 15)
if err != nil || id != 1 {
t.Fatalf("id=%d err=%v", id, err)
}
if len(rec.cmds) < 3 {
if len(rec.cmds) < 4 {
t.Fatalf("only %d commands for a large image", len(rec.cmds))
}
// The upload is followed by the placement, once.
if last := rec.cmds[len(rec.cmds)-1]; last != "\x1b_Ga=p,U=1,i=1,c=20,r=15,q=2\x1b\\" {
t.Errorf("last command = %q", last)
}
uploads := rec.cmds[:len(rec.cmds)-1]
re := regexp.MustCompile(`^\x1b_G([^;]*);(.*)\x1b\\$`)
var payload strings.Builder
for i, c := range rec.cmds {
for i, c := range uploads {
m := re.FindStringSubmatch(c)
if m == nil {
t.Fatalf("command %d is not a graphics command: %q", i, c[:min(40, len(c))])
@@ -69,7 +74,7 @@ func TestKittyUploadChunksAndEncodes(t *testing.T) {
if len(data) > kittyChunk {
t.Errorf("chunk %d has %d bytes", i, len(data))
}
last := i == len(rec.cmds)-1
last := i == len(uploads)-1
switch {
case i == 0 && !strings.HasPrefix(keys, "a=t,f=100,t=d,i=1,q=2,m=1"):
t.Errorf("first command keys = %q", keys)
@@ -94,43 +99,51 @@ func TestKittyUploadChunksAndEncodes(t *testing.T) {
t.Error("pixel data changed")
}
// The same image is not uploaded twice; another one gets the next id.
// The same footprint is not uploaded twice; another poster gets the next id.
n := len(rec.cmds)
if again, _ := k.imageID(img, img.Bounds()); again != id || len(rec.cmds) != n {
t.Error("an uploaded image must be reused")
if again, _ := k.show(img, img.Bounds(), 20, 15); again != id || len(rec.cmds) != n {
t.Error("an uploaded footprint must be reused")
}
if other, _ := k.imageID(noisy(4, 4), image.Rect(0, 0, 4, 4)); other != 2 {
small := noisy(4, 4)
if other, _ := k.show(small, small.Bounds(), 3, 2); other != 2 {
t.Errorf("second image id = %d", other)
}
}
func TestKittyPlacement(t *testing.T) {
func TestKittyNewSizeIsANewImage(t *testing.T) {
rec := &recorder{}
k := newKittyGraphics(rec.write)
if err := k.place(7, 20, 15); err != nil {
t.Fatal(err)
}
if len(rec.cmds) != 1 || rec.cmds[0] != "\x1b_Ga=p,U=1,i=7,c=20,r=15,q=2\x1b\\" {
t.Fatalf("first placement: %q", rec.cmds)
}
k.place(7, 20, 15) // unchanged: nothing to send
if len(rec.cmds) != 1 {
t.Errorf("unchanged placement sent %d commands", len(rec.cmds))
}
k.place(7, 10, 8) // a new size replaces the placement
if len(rec.cmds) != 3 || rec.cmds[1] != "\x1b_Ga=d,d=i,i=7,q=2\x1b\\" || rec.cmds[2] != "\x1b_Ga=p,U=1,i=7,c=10,r=8,q=2\x1b\\" {
t.Errorf("resize: %q", rec.cmds[1:])
img := noisy(10, 15)
id1, _ := k.show(img, img.Bounds(), 20, 15)
rec.cmds = nil
if again, _ := k.show(img, img.Bounds(), 20, 15); again != id1 || len(rec.cmds) != 0 {
t.Fatalf("same footprint: id %d, %d commands", again, len(rec.cmds))
}
rec.cmds = nil
small := noisy(2, 2)
id, _ := k.imageID(small, small.Bounds())
k.deleteAll()
if last := rec.cmds[len(rec.cmds)-1]; last != "\x1b_Ga=d,d=I,i="+itoa(int(id))+",q=2\x1b\\" {
t.Errorf("deleteAll sent %q", last)
// A different size is never done by changing the placement (a terminal can keep the
// old one): the poster is uploaded under a new id, placed, and the old id removed.
id2, _ := k.show(img, img.Bounds(), 10, 8)
if id2 == id1 {
t.Fatal("a new footprint must get a new id")
}
if len(k.ids) != 0 || len(k.placed) != 0 {
t.Error("deleteAll must forget everything")
all := strings.Join(rec.cmds, "")
if !strings.Contains(all, "a=t,f=100,t=d,i=2") || !strings.Contains(all, "a=p,U=1,i=2,c=10,r=8") || !strings.Contains(all, "a=d,d=I,i=1") {
t.Errorf("resize sent %q", rec.cmds)
}
if strings.Contains(all, "a=p,U=1,i=1") {
t.Error("the old placement must not be touched")
}
// After deleteAll (what a window resize triggers) everything is uploaded afresh.
rec.cmds = nil
k.deleteAll()
id3, _ := k.show(img, img.Bounds(), 10, 8)
if id3 == id2 || !strings.Contains(strings.Join(rec.cmds, ""), "a=d,d=I,i=2") || !strings.Contains(strings.Join(rec.cmds, ""), "a=t,f=100,t=d,i=3") {
t.Errorf("after deleteAll: id %d, %q", id3, rec.cmds)
}
if len(k.ids) != 1 || len(k.latest) != 1 {
t.Errorf("bookkeeping: %d ids, %d latest", len(k.ids), len(k.latest))
}
}
@@ -243,15 +256,11 @@ func TestCoverViewGraphicsDrawsPlaceholders(t *testing.T) {
// A cell aspect of 2.5 (taller cells) changes the footprint of the same box.
c.cellAspect = func() float64 { return 2.5 }
c.Draw(screen)
// The box is now 17×10 cells, a different shape: a new crop is uploaded, the
// old upload is removed, and the image is placed afresh.
var placements []string
for _, cmd := range rec.cmds[2:] {
placements = append(placements, cmd[:min(len(cmd), 30)])
}
last := rec.cmds[len(rec.cmds)-1]
if !strings.Contains(last, "c=17,r=10") || !strings.Contains(strings.Join(rec.cmds[2:], ""), "a=d,d=I,i=1") {
t.Errorf("after the cell aspect changed: %q", placements)
// The box is now 17×10 cells, a different shape: a new image id is uploaded and
// placed, the old upload is removed.
all := strings.Join(rec.cmds[2:], "")
if !strings.Contains(all, "a=p,U=1,i=2,c=17,r=10") || !strings.Contains(all, "a=d,d=I,i=1") {
t.Errorf("after the cell aspect changed: %q", rec.cmds[2:])
}
}
@@ -280,24 +289,6 @@ func TestCropToBox(t *testing.T) {
}
}
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.