From 0ccd02b3c5424328345add222d39d079fef4fc78 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 20 Oct 2026 23:17:40 +0200 Subject: [PATCH] Real-image posters: draw placeholder cells on the panes' background color Co-Authored-By: Claude Sonnet 5.5 --- SPECS.md | 2 +- internal/ui/kitty.go | 5 ++++- internal/ui/kitty_test.go | 6 +++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/SPECS.md b/SPECS.md index cc1c243..a3f5fdc 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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; `?` 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 (from the terminal's window size) 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 (visible 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 (from the terminal's window size) 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, decoded and downscaled in the process. Library items fetch `/MediaCover//poster-250.jpg` from their Radarr/Sonarr instance with the API key; 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 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. diff --git a/internal/ui/kitty.go b/internal/ui/kitty.go index fdb418d..b79586c 100644 --- a/internal/ui/kitty.go +++ b/internal/ui/kitty.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" ) // The Kitty graphics protocol with Unicode placeholders, as supported by @@ -139,7 +140,9 @@ func (k *kittyGraphics) deleteAll() { func placeholderCell(id uint32, row, col int) (main rune, comb []rune, style tcell.Style) { comb = []rune{kittyDiacritics[row], kittyDiacritics[col]} fg := tcell.NewRGBColor(int32(id>>16&0xFF), int32(id>>8&0xFF), int32(id&0xFF)) - return placeholderRune, comb, tcell.StyleDefault.Foreground(fg) + // The background shows wherever the picture does not cover the cell (a + // sliver at the edge): use the panes' own color so it is not visible. + return placeholderRune, comb, tcell.StyleDefault.Foreground(fg).Background(tview.Styles.PrimitiveBackgroundColor) } // maxPlaceholders is how many rows and columns can be numbered. diff --git a/internal/ui/kitty_test.go b/internal/ui/kitty_test.go index 18fb75d..d545b5f 100644 --- a/internal/ui/kitty_test.go +++ b/internal/ui/kitty_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" ) // recorder collects what is written to the terminal. @@ -138,10 +139,13 @@ func TestPlaceholderCell(t *testing.T) { if ch != '\U0010EEEE' || len(comb) != 2 || comb[0] != kittyDiacritics[2] || comb[1] != kittyDiacritics[5] { t.Errorf("cell = %U %U", ch, comb) } - fg, _, _ := style.Decompose() + fg, bg, _ := style.Decompose() if r, g, b := fg.RGB(); r != 1 || g != 2 || b != 3 { t.Errorf("the id must be in the foreground color, got %d,%d,%d", r, g, b) } + if bg != tview.Styles.PrimitiveBackgroundColor { + t.Errorf("background %v must match the panes (%v), or uncovered slivers show", bg, tview.Styles.PrimitiveBackgroundColor) + } } func TestKittyTerminalDetection(t *testing.T) {