From 365ba9dee8c04749df3702617a99449e41e37e67 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 20 Oct 2026 21:51:50 +0200 Subject: [PATCH] Render covers with quadrant blocks for twice the horizontal detail Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- README.md | 4 +- SPECS.md | 2 +- internal/ui/cover.go | 117 +++++++++++++++++++++++++++++++------- internal/ui/cover_test.go | 79 ++++++++++++++++++------- internal/ui/detail.go | 2 +- 6 files changed, 161 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91920ac..5c4ceac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ First release. - **Series drill-down:** seasons and episodes with monitoring, automatic search and release search. - **Downloads:** SABnzbd queue and history plus the Radarr and Sonarr queues; pause/resume, delete, retry, remove and blocklist. -- **Posters:** thumbnails in the detail panes (half-block rendering, any color terminal). +- **Posters:** thumbnails in the detail panes (quadrant block rendering, any color terminal). - **Log (F5) and popups:** feedback appears as fading popups; the log keeps the history. - Connection indicators for all three services, context-sensitive key hints, `?` help. - JSON config with environment overrides (`ARR_TUI_*`), `--version`, cross-platform builds diff --git a/README.md b/README.md index b013210..681d922 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video optionally start the automatic search. - **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons), sort and filter them (by approval, quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), and grab one. Series: pick a season or a single episode first. -- **Posters**: a small poster thumbnail in the detail panes, drawn with colored half-block - characters (needs a terminal with colors; works over SSH). Library items use the copy stored in +- **Posters**: a small poster thumbnail in the detail panes, drawn with colored quadrant + block characters (needs a terminal with colors; works over SSH). Library items use the copy stored in Radarr/Sonarr; search results are downloaded from the metadata provider's image host (TMDb, TheTVDB). Turn it off with `"ui": {"covers": false}`. - **Library**: browse movies (top) and series (below) with a details pane on the right, filter by status (missing / downloaded / monitored) and by file properties (quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), toggle diff --git a/SPECS.md b/SPECS.md index c03acf1..cf7c9e6 100644 --- a/SPECS.md +++ b/SPECS.md @@ -63,7 +63,7 @@ Notes: - Radarr and Sonarr queues (status, warnings, import errors), so stuck imports are visible. - 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 a poster thumbnail above the text, rendered as half-block characters with 24-bit colors (each cell is two pixels; JPEG/PNG only, decoded and downscaled in the process, so no terminal graphics protocol is needed). 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, 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. +- **Posters:** the detail panes show a poster thumbnail above the text, rendered with quadrant block characters in 24-bit colors (each cell shows 2×2 pixels in the best two-color split, from the Block Elements range every font has; JPEG/PNG only, decoded and downscaled in the process, so no terminal graphics protocol is needed). 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, 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. - **Notifications and log (F5):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll. ### 4.2 v0.2 (done, M8) diff --git a/internal/ui/cover.go b/internal/ui/cover.go index 87ab185..9bbe628 100644 --- a/internal/ui/cover.go +++ b/internal/ui/cover.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "image" + "image/color" _ "image/jpeg" // poster formats _ "image/png" "time" @@ -21,6 +22,7 @@ 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. @@ -68,9 +70,11 @@ func boxScale(src image.Image, w, h int) *image.RGBA { return dst } -// coverView draws a poster with half-block characters: every cell shows two -// vertically stacked pixels (foreground = upper, background = lower), so it -// works in any terminal with colors, also over SSH. +// 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 @@ -84,6 +88,18 @@ func newCoverView() *coverView { return &coverView{Box: tview.NewBox()} } 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 } +// 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. +func coverCells(imgW, imgH, w, h int) (cols, rows int) { + cols = w + rows = cols * imgH / imgW / 2 + if rows > h { + rows = h + cols = rows * 2 * imgW / imgH + } + return max(cols, 0), max(rows, 0) +} + func (c *coverView) Draw(screen tcell.Screen) { c.Box.DrawForSubclass(screen, c) x, y, w, h := c.GetInnerRect() @@ -97,33 +113,92 @@ func (c *coverView) Draw(screen tcell.Screen) { return } - // Fit the poster into w columns by 2h pixel rows, keeping its aspect. b := c.img.Bounds() - tw := w - th := tw * b.Dy() / b.Dx() - if th > 2*h { - th = 2 * h - tw = th * b.Dx() / b.Dy() - } - th &^= 1 - if tw < 1 || th < 2 { + cols, rows := coverCells(b.Dx(), b.Dy(), w, h) + if cols < 1 || rows < 1 { return } - if c.scaled == nil || c.scaled.Bounds().Dx() != tw || c.scaled.Bounds().Dy() != th { - c.scaled = boxScale(c.img, tw, th) + 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(px, py int) tcell.Color { - i := c.scaled.PixOffset(px, py) - return tcell.NewRGBColor(int32(c.scaled.Pix[i]), int32(c.scaled.Pix[i+1]), int32(c.scaled.Pix[i+2])) + 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} } - for row := 0; row < th/2; row++ { - for col := 0; col < tw; col++ { - style := tcell.StyleDefault.Foreground(px(col, 2*row)).Background(px(col, 2*row+1)) - screen.SetContent(x+col, y+row, '▀', nil, style) + 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) } } } +// 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 + } + } + } + if best == 0 { + c := toRGBA(flat) + return ' ', c, c + } + fm, _ := mean(best, true) + bm, _ := mean(best, false) + return quadrants[best], toRGBA(fm), toRGBA(bm) +} + // coverRef says where a poster comes from. The zero value means "no poster". type coverRef struct { key string // identifies the poster in the cache diff --git a/internal/ui/cover_test.go b/internal/ui/cover_test.go index a2a7736..4e467ce 100644 --- a/internal/ui/cover_test.go +++ b/internal/ui/cover_test.go @@ -77,39 +77,80 @@ func TestDecodeCover(t *testing.T) { } } -func TestCoverViewDrawsHalfBlocks(t *testing.T) { +func TestQuadCell(t *testing.T) { + red, blue, black := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}, color.RGBA{0, 0, 0, 255} + for _, tc := range []struct { + name string + px [4]color.RGBA + wantCh rune + wantFg color.RGBA + wantBg color.RGBA + sameBoth bool + }{ + {"flat", [4]color.RGBA{red, red, red, red}, ' ', red, red, true}, + {"top half", [4]color.RGBA{red, red, blue, blue}, '▀', red, blue, false}, + {"bottom half", [4]color.RGBA{blue, blue, red, red}, '▀', blue, red, false}, // same picture, canonical form + {"left half", [4]color.RGBA{red, blue, red, blue}, '▌', red, blue, false}, + {"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) + 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) + } + } + // 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 != ' ' { + t.Errorf("noise rendered as %q", ch) + } +} + +func TestCoverCells(t *testing.T) { + // A 2:3 poster in a roomy box is limited by the height: 10 rows are 20 + // width units tall, so 13 columns. + if c, r := coverCells(40, 60, 30, 10); c != 13 || r != 10 { + t.Errorf("tall limit: %dx%d", c, r) + } + // In a narrow box the width limits it. + if c, r := coverCells(40, 60, 10, 20); c != 10 || r != 7 { + t.Errorf("narrow limit: %dx%d", c, r) + } +} + +func TestCoverViewDraws(t *testing.T) { screen := tcell.NewSimulationScreen("") if err := screen.Init(); err != nil { t.Fatal(err) } defer screen.Fini() - screen.SetSize(20, 10) + screen.SetSize(30, 10) c := newCoverView() - c.setImage(stripes(40, 60)) // 2:3 poster - c.SetRect(0, 0, 20, 10) // 20 columns × 20 pixel rows: a 13×20 pixel poster - c.Draw(screen) + c.setImage(stripes(40, 60)) // red above blue + c.SetRect(0, 0, 30, 10) // fits 13 columns × 10 rows - // Row 0 holds pixel rows 0 (top) and 1: both red. The middle cell row - // (index 4/5) crosses into blue; the last row is blue on both halves. + c.Draw(screen) rgba := func(c tcell.Color) color.RGBA { r, g, b := c.RGB() return color.RGBA{uint8(r), uint8(g), uint8(b), 255} } - check := func(row int, wantFg, wantBg color.RGBA) { - t.Helper() - ch, _, style, _ := screen.GetContent(0, row) - fg, bg, _ := style.Decompose() - if ch != '▀' || rgba(fg) != wantFg || rgba(bg) != wantBg { - t.Errorf("row %d: %q fg=%v bg=%v", row, ch, rgba(fg), rgba(bg)) - } + cell := func(x, y int) (rune, color.RGBA, color.RGBA) { + ch, _, st, _ := screen.GetContent(x, y) + fg, bg, _ := st.Decompose() + return ch, rgba(fg), rgba(bg) } red, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255} - check(0, red, red) - check(9, blue, blue) - // Beyond the poster's width nothing is drawn. - if ch, _, _, _ := screen.GetContent(19, 0); ch == '▀' { - t.Error("poster stretched beyond its aspect ratio") + if _, fg, bg := cell(0, 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 { + 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 != ' ' { + t.Errorf("poster stretched beyond its aspect ratio: %q", ch) } } diff --git a/internal/ui/detail.go b/internal/ui/detail.go index dfe3443..56b055a 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -8,7 +8,7 @@ import ( ) const ( - coverRows = 18 // poster height in terminal rows (36 pixels) + coverRows = 20 // poster height in terminal rows minCoverRows = 6 // a smaller poster is not worth showing minTextRows = 10 // the poster never takes these rows from the text )