Detail panes: poster top right, title and facts left, plot below; sextant rendering

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 21:57:22 +02:00
co-authored by Claude Sonnet 5.5
parent 365ba9dee8
commit b4fe931fa9
15 changed files with 468 additions and 153 deletions
+2 -1
View File
@@ -16,7 +16,8 @@ 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 (quadrant block rendering, any color terminal).
- **Posters:** a thumbnail at the top right of the detail panes, title and facts to its left, plot
below (quadrant or, on capable terminals, sextant block rendering; `ui.cover_style`).
- **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
+10 -5
View File
@@ -8,10 +8,15 @@ 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 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}`.
- **Posters**: the detail panes show the poster at the top right, with title and facts to its left
and the plot below. It is drawn with colored block characters (works over SSH, needs a terminal
with colors). 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}`. `"ui": {"cover_style": ...}` picks the characters: `quadrant` (2x2
pixels per cell, every font has them), `sextant` (2x3, sharper, needs a font with the Symbols for
Legacy Computing block, e.g. kitty, WezTerm, foot, Ghostty) or `auto` (default: sextant on
terminals known to support it, quadrant elsewhere). If you see boxes or question marks in the
poster, set `quadrant`.
- **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
monitoring, trigger searches. Series open into seasons and episodes with per-season and
per-episode monitoring, automatic search and release search.
@@ -42,7 +47,7 @@ Settings → General.
"radarr": { "url": "http://localhost:7878", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/movies" },
"sonarr": { "url": "http://localhost:8989", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/tv" },
"sabnzbd": { "url": "http://localhost:8080", "apikey": "..." },
"ui": { "refresh_seconds": 3, "covers": true }
"ui": { "refresh_seconds": 3, "covers": true, "cover_style": "auto" }
}
```
+1 -1
View File
@@ -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 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/<id>/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 the poster at the top right, the title and facts to its left and the plot below both; on panes too small for that, everything is one text. The poster is drawn with block characters in 24-bit colors: 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). `ui.cover_style` is `auto` (default: sextant on kitty, WezTerm, foot, Ghostty, quadrant elsewhere), `quadrant` or `sextant`. JPEG/PNG only, decoded and downscaled in the process, so no terminal graphics protocol is needed. Library items fetch `/MediaCover/<id>/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)
+2 -1
View File
@@ -17,6 +17,7 @@
},
"ui": {
"refresh_seconds": 3,
"covers": true
"covers": true,
"cover_style": "auto"
}
}
+11
View File
@@ -32,6 +32,12 @@ type UI struct {
// Posters of titles that are not in the library yet are downloaded from
// the public image hosts Radarr/Sonarr point to (TMDb, TheTVDB).
Covers *bool `json:"covers,omitempty"`
// CoverStyle selects the block characters posters are drawn with:
// "quadrant" (2x2 pixels per cell, every font has the glyphs), "sextant"
// (2x3, sharper, needs a font with the Symbols for Legacy Computing
// block, e.g. kitty, WezTerm, foot, Ghostty) or "auto" (default: sextant
// on terminals known to draw them, quadrant elsewhere).
CoverStyle string `json:"cover_style,omitempty"`
}
// ShowCovers reports whether poster thumbnails are enabled.
@@ -146,5 +152,10 @@ func (c *Config) Validate() error {
errs = append(errs, fmt.Errorf("%s: url %q must be an http(s) URL", s.name, s.svc.URL))
}
}
switch c.UI.CoverStyle {
case "", "auto", "quadrant", "sextant":
default:
errs = append(errs, fmt.Errorf(`ui.cover_style: %q must be "auto", "quadrant" or "sextant"`, c.UI.CoverStyle))
}
return errors.Join(errs...)
}
+12
View File
@@ -97,3 +97,15 @@ func TestShowCovers(t *testing.T) {
}
}
}
func TestCoverStyleValidation(t *testing.T) {
base := `{"radarr":{"url":"http://r","apikey":"k"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"},"ui":{"cover_style":`
for _, ok := range []string{`"auto"`, `"quadrant"`, `"sextant"`, `""`} {
if _, err := Parse([]byte(base + ok + `}}`)); err != nil {
t.Errorf("%s rejected: %v", ok, err)
}
}
if _, err := Parse([]byte(base + `"braille"}}`)); err == nil {
t.Error("unknown style accepted")
}
}
+3
View File
@@ -4,6 +4,7 @@ package ui
import (
"context"
"fmt"
"os"
"strings"
"time"
@@ -44,6 +45,7 @@ type App struct {
probes []probe
covers bool // poster thumbnails enabled
coverStyle coverStyle
coverCache *coverCache
}
@@ -89,6 +91,7 @@ func New(cfg *config.Config) *App {
state: map[string]serviceState{},
covers: cfg.UI.ShowCovers(),
coverStyle: coverStyleFor(cfg.UI.CoverStyle, os.Getenv),
coverCache: newCoverCache(),
}
+13 -13
View File
@@ -16,7 +16,7 @@ type result interface {
key() string // stable identity, e.g. "tmdb:603"
title() string
label() string
details() string
details() detailText
inLibrary() bool
withID(id int) result // copy flagged as added to the library
cover() coverRef
@@ -56,12 +56,12 @@ type movieResult struct {
poster coverRef
}
func (m movieResult) key() string { return radarr.TMDBTerm(m.TMDBID) }
func (m movieResult) title() string { return m.Title }
func (m movieResult) label() string { return movieLabel(m.Movie) }
func (m movieResult) details() string { return movieDetails(m.Movie) }
func (m movieResult) inLibrary() bool { return m.InLibrary() }
func (m movieResult) cover() coverRef { return m.poster }
func (m movieResult) key() string { return radarr.TMDBTerm(m.TMDBID) }
func (m movieResult) title() string { return m.Title }
func (m movieResult) label() string { return movieLabel(m.Movie) }
func (m movieResult) details() detailText { return movieDetails(m.Movie) }
func (m movieResult) inLibrary() bool { return m.InLibrary() }
func (m movieResult) cover() coverRef { return m.poster }
func (m movieResult) withID(id int) result {
m.ID = id
return m
@@ -107,12 +107,12 @@ type seriesResult struct {
poster coverRef
}
func (s seriesResult) key() string { return "tvdb:" + itoa(s.TVDBID) }
func (s seriesResult) title() string { return s.Title }
func (s seriesResult) label() string { return seriesLabel(s.Series) }
func (s seriesResult) details() string { return seriesDetails(s.Series) }
func (s seriesResult) inLibrary() bool { return s.InLibrary() }
func (s seriesResult) cover() coverRef { return s.poster }
func (s seriesResult) key() string { return "tvdb:" + itoa(s.TVDBID) }
func (s seriesResult) title() string { return s.Title }
func (s seriesResult) label() string { return seriesLabel(s.Series) }
func (s seriesResult) details() detailText { return seriesDetails(s.Series) }
func (s seriesResult) inLibrary() bool { return s.InLibrary() }
func (s seriesResult) cover() coverRef { return s.poster }
func (s seriesResult) withID(id int) result {
s.ID = id
return s
+193 -84
View File
@@ -8,6 +8,7 @@ import (
"image/color"
_ "image/jpeg" // poster formats
_ "image/png"
"strings"
"time"
"github.com/gdamore/tcell/v2"
@@ -22,7 +23,6 @@ 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.
@@ -70,23 +70,177 @@ func boxScale(src image.Image, w, h int) *image.RGBA {
return dst
}
// 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
note string // shown instead of the image: "loading…", "no cover"
scaled *image.RGBA // cache of img at the last drawn size
// coverStyle is a way to draw a poster with block characters: every cell
// shows sw×sh pixels in two colors, the best split of its pixels into a
// foreground and a background group.
type coverStyle struct {
name string
sw, sh int
glyph func(mask int) rune // mask: bit i = pixel i (row-major) is foreground
}
func newCoverView() *coverView { return &coverView{Box: tview.NewBox()} }
var (
// quadrantStyle (2×2 pixels): the glyphs are in the Block Elements range
// that every terminal font has.
quadrantStyle = coverStyle{"quadrant", 2, 2, func(mask int) rune { return quadrants[mask] }}
// sextantStyle (2×3 pixels): sharper, but the glyphs live in the Symbols
// for Legacy Computing block, which many fonts lack.
sextantStyle = coverStyle{"sextant", 2, 3, sextantGlyph}
)
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 }
// 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{' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█'}
// sextantGlyph maps a 2×3 pixel mask (bit 0 top left, 1 top right, 2 middle
// left, ... 5 bottom right) to its character. Unicode lists the 60 sextants
// in mask order but leaves out the empty and full cell and the two halves,
// which already exist as space, full block and the half blocks.
func sextantGlyph(mask int) rune {
switch mask {
case 0:
return ' '
case 63:
return '█'
case 21:
return '▌'
case 42:
return '▐'
}
idx := mask - 1
if mask > 21 {
idx--
}
if mask > 42 {
idx--
}
return rune(0x1FB00 + idx)
}
// coverStyleFor resolves the configured style. "auto" picks sextants on
// terminals known to have the glyphs and quadrants everywhere else.
func coverStyleFor(setting string, env func(string) string) coverStyle {
switch setting {
case "sextant":
return sextantStyle
case "quadrant":
return quadrantStyle
}
term, program := strings.ToLower(env("TERM")), strings.ToLower(env("TERM_PROGRAM"))
for _, known := range []string{"kitty", "foot", "ghostty", "wezterm"} {
if strings.Contains(term, known) || strings.Contains(program, known) {
return sextantStyle
}
}
if env("KITTY_WINDOW_ID") != "" || env("WEZTERM_PANE") != "" {
return sextantStyle
}
return quadrantStyle
}
// flatPixelError is the squared RGB distance per pixel below which a cell is
// drawn flat: smooth areas and noise should not turn into patterns.
const flatPixelError = 150
// bestCell picks the glyph and the two colors that best reproduce the pixels
// of one cell. Only masks without the last pixel are tried, because a mask
// and its complement draw the same picture with the colors swapped.
func (st coverStyle) bestCell(p []color.RGBA) (ch rune, fg, bg color.RGBA) {
n := len(p)
type sum struct{ r, g, b, n float64 }
var all sum
for _, c := range p {
all.r, all.g, all.b, all.n = all.r+float64(c.R), all.g+float64(c.G), all.b+float64(c.B), all.n+1
}
avg := func(s sum) [3]float64 {
if s.n == 0 {
return [3]float64{}
}
return [3]float64{s.r / s.n, s.g / s.n, s.b / s.n}
}
dist := func(c color.RGBA, m [3]float64) float64 {
dr, dg, db := float64(c.R)-m[0], float64(c.G)-m[1], float64(c.B)-m[2]
return dr*dr + dg*dg + db*db
}
toRGBA := func(m [3]float64) color.RGBA {
return color.RGBA{uint8(m[0] + .5), uint8(m[1] + .5), uint8(m[2] + .5), 255}
}
flat := avg(all)
var flatErr float64
for _, c := range p {
flatErr += dist(c, flat)
}
if flatErr < flatPixelError*float64(n) {
c := toRGBA(flat)
return ' ', c, c
}
best, bestErr := 0, flatErr*0.6 // a split must clearly beat the flat cell
var bestFg, bestBg [3]float64
for mask := 1; mask < 1<<(n-1); mask++ {
var f, b sum
for i, c := range p {
t := &b
if mask>>i&1 == 1 {
t = &f
}
t.r, t.g, t.b, t.n = t.r+float64(c.R), t.g+float64(c.G), t.b+float64(c.B), t.n+1
}
fm, bm := avg(f), avg(b)
var e float64
for i, c := range p {
if mask>>i&1 == 1 {
e += dist(c, fm)
} else {
e += dist(c, bm)
}
if e >= bestErr {
break
}
}
if e < bestErr {
best, bestErr, bestFg, bestBg = mask, e, fm, bm
}
}
if best == 0 {
c := toRGBA(flat)
return ' ', c, c
}
return st.glyph(best), toRGBA(bestFg), toRGBA(bestBg)
}
// coverView draws a poster, see coverStyle. It works in any terminal with
// colors, also over SSH.
type coverView struct {
*tview.Box
style coverStyle
img *image.RGBA
note string // shown instead of the image: "loading…", "no cover"
// The cells for the last drawn size.
cols, rows int
cells []styledCell
}
type styledCell struct {
ch rune
fg, bg tcell.Color
}
func newCoverView(style coverStyle) *coverView { return &coverView{Box: tview.NewBox(), style: style} }
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
}
b := c.img.Bounds()
return b.Dx(), b.Dy()
}
// 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.
@@ -118,85 +272,40 @@ func (c *coverView) Draw(screen tcell.Screen) {
if cols < 1 || rows < 1 {
return
}
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(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}
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++ {
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)
cell := c.cells[row*cols+col]
screen.SetContent(x+col, y+row, cell.ch, nil, tcell.StyleDefault.Foreground(cell.fg).Background(cell.bg))
}
}
}
// 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
// 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)
cells := make([]styledCell, 0, cols*rows)
px := make([]color.RGBA, st.sw*st.sh)
for row := 0; row < rows; row++ {
for col := 0; col < cols; col++ {
for sy := 0; sy < st.sh; sy++ {
for sx := 0; sx < st.sw; sx++ {
i := scaled.PixOffset(col*st.sw+sx, row*st.sh+sy)
px[sy*st.sw+sx] = color.RGBA{scaled.Pix[i], scaled.Pix[i+1], scaled.Pix[i+2], 255}
}
}
ch, fg, bg := st.bestCell(px)
cells = append(cells, styledCell{ch,
tcell.NewRGBColor(int32(fg.R), int32(fg.G), int32(fg.B)),
tcell.NewRGBColor(int32(bg.R), int32(bg.G), int32(bg.B))})
}
}
if best == 0 {
c := toRGBA(flat)
return ' ', c, c
}
fm, _ := mean(best, true)
bm, _ := mean(best, false)
return quadrants[best], toRGBA(fm), toRGBA(bm)
return cells
}
// coverRef says where a poster comes from. The zero value means "no poster".
+63 -7
View File
@@ -94,7 +94,7 @@ func TestQuadCell(t *testing.T) {
{"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)
ch, fg, bg := quadrantStyle.bestCell(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)
}
@@ -102,7 +102,7 @@ func TestQuadCell(t *testing.T) {
// 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 != ' ' {
if ch, _, _ := quadrantStyle.bestCell([]color.RGBA{n, m, n, n}); ch != ' ' {
t.Errorf("noise rendered as %q", ch)
}
}
@@ -127,7 +127,7 @@ func TestCoverViewDraws(t *testing.T) {
defer screen.Fini()
screen.SetSize(30, 10)
c := newCoverView()
c := newCoverView(quadrantStyle)
c.setImage(stripes(40, 60)) // red above blue
c.SetRect(0, 0, 30, 10) // fits 13 columns × 10 rows
@@ -142,14 +142,15 @@ func TestCoverViewDraws(t *testing.T) {
return ch, rgba(fg), rgba(bg)
}
red, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}
if _, fg, bg := cell(0, 0); fg != red || bg != red {
// The poster is 13 columns wide and sits against the right edge (x 17..29).
if _, fg, bg := cell(17, 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 {
if _, fg, bg := cell(29, 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 != ' ' {
// Left of the poster's footprint nothing is drawn.
if ch, _, _, _ := screen.GetContent(16, 0); ch != ' ' {
t.Errorf("poster stretched beyond its aspect ratio: %q", ch)
}
}
@@ -188,3 +189,58 @@ func TestLoadCoverRemembersFailures(t *testing.T) {
t.Error("expected an error for a recently failed cover")
}
}
func TestSextantGlyphs(t *testing.T) {
seen := map[rune]int{}
for mask := 0; mask < 64; mask++ {
r := sextantGlyph(mask)
if prev, dup := seen[r]; dup {
t.Fatalf("masks %d and %d share the glyph %q", prev, mask, r)
}
seen[r] = mask
if mask > 0 && mask < 63 && mask != 21 && mask != 42 && (r < 0x1FB00 || r > 0x1FB3B) {
t.Errorf("mask %d: %U outside the sextant block", mask, r)
}
}
for mask, want := range map[int]rune{1: 0x1FB00, 2: 0x1FB01, 20: 0x1FB13, 22: 0x1FB14, 43: 0x1FB28, 62: 0x1FB3B, 21: '▌', 42: '▐', 63: '█', 0: ' '} {
if got := sextantGlyph(mask); got != want {
t.Errorf("sextantGlyph(%d) = %U, want %U", mask, got, want)
}
}
}
func TestSextantCell(t *testing.T) {
red, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}
// Two rows of red above one row of blue.
ch, fg, bg := sextantStyle.bestCell([]color.RGBA{red, red, red, red, blue, blue})
if ch != sextantGlyph(15) || fg != red || bg != blue {
t.Errorf("got %U fg=%v bg=%v", ch, fg, bg)
}
// One row each: red, blue, red.
ch, fg, bg = sextantStyle.bestCell([]color.RGBA{red, red, blue, blue, red, red})
if ch != sextantGlyph(0b001100) || fg != blue || bg != red {
t.Errorf("three bands: %U fg=%v bg=%v", ch, fg, bg)
}
}
func TestCoverStyleFor(t *testing.T) {
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
for _, tc := range []struct {
setting string
env map[string]string
want string
}{
{"auto", map[string]string{"TERM": "xterm-kitty"}, "sextant"},
{"", map[string]string{"TERM_PROGRAM": "WezTerm"}, "sextant"},
{"auto", map[string]string{"TERM": "foot"}, "sextant"},
{"auto", map[string]string{"KITTY_WINDOW_ID": "3", "TERM": "screen"}, "sextant"},
{"auto", map[string]string{"TERM": "xterm-256color"}, "quadrant"},
{"auto", nil, "quadrant"},
{"quadrant", map[string]string{"TERM": "xterm-kitty"}, "quadrant"},
{"sextant", nil, "sextant"},
} {
if got := coverStyleFor(tc.setting, env(tc.env)).name; got != tc.want {
t.Errorf("%q %v = %s, want %s", tc.setting, tc.env, got, tc.want)
}
}
}
+49 -24
View File
@@ -8,39 +8,49 @@ import (
)
const (
coverRows = 20 // poster height in terminal rows
minCoverRows = 6 // a smaller poster is not worth showing
minCoverCols = 8 //
minTextRows = 10 // the poster never takes these rows from the text
)
// detailPane shows a poster above a text. The poster shrinks on short panes
// and is dropped when the text would get too little room.
// detailPane shows the details of one title: the head (title and facts) with
// the poster to its right on top, and the plot below both. On panes too small
// for the poster everything is one text.
type detailPane struct {
*tview.Flex
app *App
cover *coverView
text *tview.TextView
*tview.Flex // column: top row, plot
app *App
top *tview.Flex // row: head, cover
head *tview.TextView
cover *coverView
plot *tview.TextView
want string // key of the poster that should be shown
hasCover bool // a poster (or its loading note) is part of the content
coverSize int // rows currently given to the poster
text detailText
want string // key of the poster that should be shown
hasCover bool // a poster (or its loading note) is part of the content
combined bool // head and plot are shown together in the plot view
}
func newDetailPane(app *App) *detailPane {
d := &detailPane{
Flex: tview.NewFlex().SetDirection(tview.FlexRow),
app: app,
cover: newCoverView(),
text: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
top: tview.NewFlex(),
head: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
cover: newCoverView(app.coverStyle),
plot: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
}
d.AddItem(d.cover, 0, 0, false).AddItem(d.text, 0, 1, false)
d.cover.SetBorderPadding(0, 0, 1, 0)
d.top.AddItem(d.head, 0, 1, false).AddItem(d.cover, 0, 0, false)
d.AddItem(d.top, 0, 0, false).AddItem(d.plot, 0, 1, false)
d.SetBorder(true).SetTitle(" Details ")
d.combined = true
return d
}
// Show displays the text and, if there is one, the poster.
func (d *detailPane) Show(text string, ref coverRef) {
d.text.SetText(text).ScrollToBeginning()
func (d *detailPane) Show(text detailText, ref coverRef) {
d.text = text
d.applyText()
if !d.app.covers || ref.fetch == nil {
d.want, d.hasCover = "", false
d.cover.setImage(nil)
@@ -63,20 +73,35 @@ func (d *detailPane) Show(text string, ref coverRef) {
}
// Clear empties the pane.
func (d *detailPane) Clear() { d.Show("", coverRef{}) }
func (d *detailPane) Clear() { d.Show(detailText{}, coverRef{}) }
// applyText puts the texts into their views for the current layout.
func (d *detailPane) applyText() {
if d.combined {
d.head.SetText("")
d.plot.SetText(d.text.String()).ScrollToBeginning()
return
}
d.head.SetText(d.text.head).ScrollToBeginning()
d.plot.SetText(d.text.plot).ScrollToBeginning()
}
func (d *detailPane) Draw(screen tcell.Screen) {
_, _, _, h := d.GetInnerRect()
rows := 0
_, _, w, h := d.GetInnerRect()
coverW, coverH := 0, 0
if d.hasCover {
rows = min(coverRows, h-minTextRows)
if rows < minCoverRows {
rows = 0
aw, ah := d.cover.aspect()
// At most 40% of the width, and the plot keeps its rows.
coverW, coverH = coverCells(aw, ah, w*2/5, h-minTextRows)
if coverH < minCoverRows || coverW < minCoverCols {
coverW, coverH = 0, 0
}
}
if rows != d.coverSize {
d.coverSize = rows
d.ResizeItem(d.cover, rows, 0)
if combined := coverW == 0; combined != d.combined {
d.combined = combined
d.applyText()
}
d.ResizeItem(d.top, coverH, 0)
d.top.ResizeItem(d.cover, coverW+1, 0) // +1: the padding left of the poster
d.Flex.Draw(screen)
}
+76
View File
@@ -0,0 +1,76 @@
package ui
import (
"strings"
"testing"
"github.com/gdamore/tcell/v2"
)
func rowText(screen tcell.SimulationScreen, y, x0, x1 int) string {
var b strings.Builder
for x := x0; x < x1; x++ {
ch, _, _, _ := screen.GetContent(x, y)
b.WriteRune(ch)
}
return b.String()
}
func TestDetailPaneLayout(t *testing.T) {
a := newTestApp(t)
screen := tcell.NewSimulationScreen("")
if err := screen.Init(); err != nil {
t.Fatal(err)
}
defer screen.Fini()
screen.SetSize(60, 40)
d := newDetailPane(a)
d.SetRect(0, 0, 60, 40)
d.Show(detailText{head: "[::b]The Matrix (1999)[::-]\nStatus: released", plot: "Plot text goes here"}, coverRef{})
// Without a poster everything is one text.
d.Draw(screen)
if got := rowText(screen, 1, 1, 30); !strings.HasPrefix(got, "The Matrix (1999)") {
t.Errorf("first line = %q", got)
}
if got := rowText(screen, 3, 1, 30); !strings.HasPrefix(got, "Plot text goes here") {
t.Errorf("without a poster the plot follows the head: %q", got)
}
// With a poster: head on the left, poster on the right, plot below both.
d.hasCover = true
d.cover.setImage(stripes(40, 60))
d.Draw(screen)
if got := rowText(screen, 1, 1, 30); !strings.HasPrefix(got, "The Matrix (1999)") {
t.Errorf("head moved: %q", got)
}
coverX := -1
for x := 59; x > 30; x-- {
if ch, _, st, _ := screen.GetContent(x, 1); ch != ' ' || st != tcell.StyleDefault {
coverX = x
break
}
}
if coverX < 55 {
t.Errorf("no poster against the right edge (last drawn column %d)", coverX)
}
// The plot starts below the poster: 40*2/5 = 24 columns -> 24*3/2/2 = 18 rows.
plotRow := -1
for y := 1; y < 39; y++ {
if strings.HasPrefix(rowText(screen, y, 1, 30), "Plot text goes here") {
plotRow = y
break
}
}
if plotRow < 18 {
t.Errorf("plot at row %d, want below the poster", plotRow)
}
// A pane too short for the poster falls back to plain text.
d.SetRect(0, 0, 60, 14)
d.Draw(screen)
if !d.combined {
t.Error("a short pane must drop the poster")
}
}
+18 -10
View File
@@ -25,8 +25,22 @@ func movieLabel(m radarr.Movie) string {
return label
}
// detailText is the text of a detail pane: a short head (title, facts,
// state) and the plot below it.
type detailText struct {
head, plot string
}
// String joins both parts, for places that show them as one text.
func (d detailText) String() string {
if d.plot == "" {
return d.head
}
return d.head + "\n" + d.plot
}
// movieDetails renders the detail pane text for a movie.
func movieDetails(m radarr.Movie) string {
func movieDetails(m radarr.Movie) detailText {
var b strings.Builder
title := tview.Escape(m.Title)
if m.Year > 0 {
@@ -59,10 +73,7 @@ func movieDetails(m radarr.Movie) string {
case m.InLibrary():
b.WriteString("[yellow]In library, not downloaded[-]\n")
}
if m.Overview != "" {
b.WriteString("\n" + tview.Escape(m.Overview) + "\n")
}
return b.String()
return detailText{head: b.String(), plot: tview.Escape(m.Overview)}
}
// profileIndex returns the index of the profile named name, or 0.
@@ -100,7 +111,7 @@ func seriesLabel(s sonarr.Series) string {
}
// seriesDetails renders the detail pane text for a series.
func seriesDetails(s sonarr.Series) string {
func seriesDetails(s sonarr.Series) detailText {
var b strings.Builder
title := tview.Escape(s.Title)
if s.Year > 0 {
@@ -138,10 +149,7 @@ func seriesDetails(s sonarr.Series) string {
fmt.Fprintf(&b, "[yellow]In library, %d/%d episodes downloaded[-]\n", st.EpisodeFileCount, st.EpisodeCount)
}
}
if s.Overview != "" {
b.WriteString("\n" + tview.Escape(s.Overview) + "\n")
}
return b.String()
return detailText{head: b.String(), plot: tview.Escape(s.Overview)}
}
func plural(n int) string {
+7 -4
View File
@@ -28,8 +28,11 @@ func TestMovieDetails(t *testing.T) {
ID: 1, Title: "Alien", Year: 1979, Runtime: 117, Genres: []string{"Horror", "Sci-Fi"},
Overview: "In space...", Ratings: radarr.Ratings{IMDB: &radarr.Rating{Value: 8.5}},
})
if got.plot != "In space..." || strings.Contains(got.head, "In space") {
t.Errorf("plot belongs below the head: head=%q plot=%q", got.head, got.plot)
}
for _, want := range []string{"Alien (1979)", "★ 8.5", "1h57m", "Horror, Sci-Fi", "In library, not downloaded", "In space..."} {
if !strings.Contains(got, want) {
if !strings.Contains(got.String(), want) {
t.Errorf("details missing %q:\n%s", want, got)
}
}
@@ -56,7 +59,7 @@ func TestSeriesLabelAndDetails(t *testing.T) {
if got := seriesLabel(s); got != "Breaking Bad (2008)" {
t.Errorf("label = %q", got)
}
got := seriesDetails(s)
got := seriesDetails(s).String()
for _, want := range []string{"Breaking Bad (2008)", "★ 9.3", "1 season ·", "47 min", "Network: AMC", "Status: ended"} {
if !strings.Contains(got, want) {
t.Errorf("details missing %q:\n%s", want, got)
@@ -65,11 +68,11 @@ func TestSeriesLabelAndDetails(t *testing.T) {
s.ID = 1
s.Statistics = sonarr.Statistics{EpisodeCount: 10, EpisodeFileCount: 4}
if got := seriesDetails(s); !strings.Contains(got, "4/10 episodes") {
if got := seriesDetails(s).String(); !strings.Contains(got, "4/10 episodes") {
t.Errorf("progress missing:\n%s", got)
}
s.Statistics.EpisodeFileCount = 10
if got := seriesDetails(s); !strings.Contains(got, "all episodes downloaded") {
if got := seriesDetails(s).String(); !strings.Contains(got, "all episodes downloaded") {
t.Errorf("complete marker missing:\n%s", got)
}
}
+8 -3
View File
@@ -46,7 +46,7 @@ type libItem struct {
missing bool // monitored and not complete
status string
size string
details string
details detailText
cover coverRef
movie *radarr.Movie
series *sonarr.Series
@@ -105,7 +105,11 @@ func newMovieItem(m radarr.Movie) libItem {
default:
it.status = "[yellow]missing"
}
it.details = movieDetails(m) + monitoredLine(m.Monitored) + sizeLine(m.SizeOnDisk) + pathLine(m.Path)
it.details = movieDetails(m)
it.details.head += monitoredLine(m.Monitored) + sizeLine(m.SizeOnDisk)
if path := pathLine(m.Path); path != "" {
it.details.plot += "\n\n" + path
}
return it
}
@@ -130,7 +134,8 @@ func newSeriesItem(s sonarr.Series) libItem {
default:
it.status = "[yellow]" + counts
}
it.details = seriesDetails(s) + monitoredLine(s.Monitored) + sizeLine(st.SizeOnDisk)
it.details = seriesDetails(s)
it.details.head += monitoredLine(s.Monitored) + sizeLine(st.SizeOnDisk)
return it
}