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. - **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, - **Downloads:** SABnzbd queue and history plus the Radarr and Sonarr queues; pause/resume, delete,
retry, remove and blocklist. 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. - **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. - Connection indicators for all three services, context-sensitive key hints, `?` help.
- JSON config with environment overrides (`ARR_TUI_*`), `--version`, cross-platform builds - 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. optionally start the automatic search.
- **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons), - **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. 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 - **Posters**: the detail panes show the poster at the top right, with title and facts to its left
block characters (needs a terminal with colors; works over SSH). Library items use the copy stored in and the plot below. It is drawn with colored block characters (works over SSH, needs a terminal
Radarr/Sonarr; search results are downloaded from the metadata provider's image host with colors). Library items use the copy stored in Radarr/Sonarr; search results are downloaded
(TMDb, TheTVDB). Turn it off with `"ui": {"covers": false}`. 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 - **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 monitoring, trigger searches. Series open into seasons and episodes with per-season and
per-episode monitoring, automatic search and release search. 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" }, "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" }, "sonarr": { "url": "http://localhost:8989", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/tv" },
"sabnzbd": { "url": "http://localhost:8080", "apikey": "..." }, "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. - 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. - 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. - **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. - **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) ### 4.2 v0.2 (done, M8)
+2 -1
View File
@@ -17,6 +17,7 @@
}, },
"ui": { "ui": {
"refresh_seconds": 3, "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 // Posters of titles that are not in the library yet are downloaded from
// the public image hosts Radarr/Sonarr point to (TMDb, TheTVDB). // the public image hosts Radarr/Sonarr point to (TMDb, TheTVDB).
Covers *bool `json:"covers,omitempty"` 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. // 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)) 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...) 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 ( import (
"context" "context"
"fmt" "fmt"
"os"
"strings" "strings"
"time" "time"
@@ -44,6 +45,7 @@ type App struct {
probes []probe probes []probe
covers bool // poster thumbnails enabled covers bool // poster thumbnails enabled
coverStyle coverStyle
coverCache *coverCache coverCache *coverCache
} }
@@ -89,6 +91,7 @@ func New(cfg *config.Config) *App {
state: map[string]serviceState{}, state: map[string]serviceState{},
covers: cfg.UI.ShowCovers(), covers: cfg.UI.ShowCovers(),
coverStyle: coverStyleFor(cfg.UI.CoverStyle, os.Getenv),
coverCache: newCoverCache(), coverCache: newCoverCache(),
} }
+3 -3
View File
@@ -16,7 +16,7 @@ type result interface {
key() string // stable identity, e.g. "tmdb:603" key() string // stable identity, e.g. "tmdb:603"
title() string title() string
label() string label() string
details() string details() detailText
inLibrary() bool inLibrary() bool
withID(id int) result // copy flagged as added to the library withID(id int) result // copy flagged as added to the library
cover() coverRef cover() coverRef
@@ -59,7 +59,7 @@ type movieResult struct {
func (m movieResult) key() string { return radarr.TMDBTerm(m.TMDBID) } func (m movieResult) key() string { return radarr.TMDBTerm(m.TMDBID) }
func (m movieResult) title() string { return m.Title } func (m movieResult) title() string { return m.Title }
func (m movieResult) label() string { return movieLabel(m.Movie) } func (m movieResult) label() string { return movieLabel(m.Movie) }
func (m movieResult) details() string { return movieDetails(m.Movie) } func (m movieResult) details() detailText { return movieDetails(m.Movie) }
func (m movieResult) inLibrary() bool { return m.InLibrary() } func (m movieResult) inLibrary() bool { return m.InLibrary() }
func (m movieResult) cover() coverRef { return m.poster } func (m movieResult) cover() coverRef { return m.poster }
func (m movieResult) withID(id int) result { func (m movieResult) withID(id int) result {
@@ -110,7 +110,7 @@ type seriesResult struct {
func (s seriesResult) key() string { return "tvdb:" + itoa(s.TVDBID) } func (s seriesResult) key() string { return "tvdb:" + itoa(s.TVDBID) }
func (s seriesResult) title() string { return s.Title } func (s seriesResult) title() string { return s.Title }
func (s seriesResult) label() string { return seriesLabel(s.Series) } func (s seriesResult) label() string { return seriesLabel(s.Series) }
func (s seriesResult) details() string { return seriesDetails(s.Series) } func (s seriesResult) details() detailText { return seriesDetails(s.Series) }
func (s seriesResult) inLibrary() bool { return s.InLibrary() } func (s seriesResult) inLibrary() bool { return s.InLibrary() }
func (s seriesResult) cover() coverRef { return s.poster } func (s seriesResult) cover() coverRef { return s.poster }
func (s seriesResult) withID(id int) result { func (s seriesResult) withID(id int) result {
+187 -78
View File
@@ -8,6 +8,7 @@ import (
"image/color" "image/color"
_ "image/jpeg" // poster formats _ "image/jpeg" // poster formats
_ "image/png" _ "image/png"
"strings"
"time" "time"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
@@ -22,7 +23,6 @@ const (
coverFailureTTL = 2 * time.Minute coverFailureTTL = 2 * time.Minute
maxCoverCache = 300 maxCoverCache = 300
coverFetchLimit = 4 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. // 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 return dst
} }
// coverView draws a poster with quadrant block characters: every cell shows // coverStyle is a way to draw a poster with block characters: every cell
// 2×2 pixels in two colors (the best split of the four pixels into a // shows sw×sh pixels in two colors, the best split of its pixels into a
// foreground and a background group), which doubles the horizontal detail of // foreground and a background group.
// plain half blocks. The glyphs are all from the Block Elements range that type coverStyle struct {
// every terminal font has, and it works over SSH. name string
sw, sh int
glyph func(mask int) rune // mask: bit i = pixel i (row-major) is foreground
}
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}
)
// 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 { type coverView struct {
*tview.Box *tview.Box
style coverStyle
img *image.RGBA img *image.RGBA
note string // shown instead of the image: "loading…", "no cover" note string // shown instead of the image: "loading…", "no cover"
scaled *image.RGBA // cache of img at the last drawn size // The cells for the last drawn size.
cols, rows int
cells []styledCell
} }
func newCoverView() *coverView { return &coverView{Box: tview.NewBox()} } type styledCell struct {
ch rune
fg, bg tcell.Color
}
func (c *coverView) setImage(img *image.RGBA) { c.img, c.note, c.scaled = img, "", nil } func newCoverView(style coverStyle) *coverView { return &coverView{Box: tview.NewBox(), style: style} }
func (c *coverView) setNote(note string) { c.img, c.note, c.scaled = nil, note, nil }
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 // 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. // 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 { if cols < 1 || rows < 1 {
return return
} }
if c.scaled == nil || c.scaled.Bounds().Dx() != 2*cols || c.scaled.Bounds().Dy() != 2*rows { if c.cells == nil || c.cols != cols || c.rows != rows {
c.scaled = boxScale(c.img, 2*cols, 2*rows) c.cols, c.rows, c.cells = cols, rows, c.build(cols, 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}
} }
// 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 row := 0; row < rows; row++ {
for col := 0; col < cols; col++ { for col := 0; col < cols; col++ {
ch, fg, bg := quadCell([4]color.RGBA{ cell := c.cells[row*cols+col]
px(2*col, 2*row), px(2*col+1, 2*row), // top left, top right screen.SetContent(x+col, y+row, cell.ch, nil, tcell.StyleDefault.Foreground(cell.fg).Background(cell.bg))
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 // build renders the poster into cols×rows cells.
// (bit 0 top left, 1 top right, 2 bottom left, 3 bottom right). func (c *coverView) build(cols, rows int) []styledCell {
var quadrants = [16]rune{' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█'} st := c.style
scaled := boxScale(c.img, cols*st.sw, rows*st.sh)
// quadCell picks the glyph and colors that best reproduce four pixels (top cells := make([]styledCell, 0, cols*rows)
// left, top right, bottom left, bottom right) with one foreground and one px := make([]color.RGBA, st.sw*st.sh)
// background color. for row := 0; row < rows; row++ {
func quadCell(p [4]color.RGBA) (ch rune, fg, bg color.RGBA) { for col := 0; col < cols; col++ {
mean := func(mask int, want bool) (c [3]float64, n int) { for sy := 0; sy < st.sh; sy++ {
for i := 0; i < 4; i++ { for sx := 0; sx < st.sw; sx++ {
if (mask>>i&1 == 1) == want { i := scaled.PixOffset(col*st.sw+sx, row*st.sh+sy)
c[0], c[1], c[2] = c[0]+float64(p[i].R), c[1]+float64(p[i].G), c[2]+float64(p[i].B) px[sy*st.sw+sx] = color.RGBA{scaled.Pix[i], scaled.Pix[i+1], scaled.Pix[i+2], 255}
n++
} }
} }
for k := range c { ch, fg, bg := st.bestCell(px)
if n > 0 { cells = append(cells, styledCell{ch,
c[k] /= float64(n) tcell.NewRGBColor(int32(fg.R), int32(fg.G), int32(fg.B)),
tcell.NewRGBColor(int32(bg.R), int32(bg.G), int32(bg.B))})
} }
} }
return c, n return cells
}
// 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". // 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}, {"one corner", [4]color.RGBA{red, blue, blue, blue}, '▘', red, blue, false},
{"diagonal", [4]color.RGBA{red, black, black, red}, '▞', black, red, 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 { 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) 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. // Nearly flat noise stays a flat cell instead of a distracting pattern.
n := color.RGBA{100, 100, 100, 255} n := color.RGBA{100, 100, 100, 255}
m := color.RGBA{102, 100, 101, 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) t.Errorf("noise rendered as %q", ch)
} }
} }
@@ -127,7 +127,7 @@ func TestCoverViewDraws(t *testing.T) {
defer screen.Fini() defer screen.Fini()
screen.SetSize(30, 10) screen.SetSize(30, 10)
c := newCoverView() c := newCoverView(quadrantStyle)
c.setImage(stripes(40, 60)) // red above blue c.setImage(stripes(40, 60)) // red above blue
c.SetRect(0, 0, 30, 10) // fits 13 columns × 10 rows 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) return ch, rgba(fg), rgba(bg)
} }
red, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255} 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) 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) t.Errorf("bottom right = fg %v bg %v, want blue", fg, bg)
} }
// Outside the poster's footprint nothing is drawn. // Left of the poster's footprint nothing is drawn.
if ch, _, _, _ := screen.GetContent(13, 0); ch != ' ' { if ch, _, _, _ := screen.GetContent(16, 0); ch != ' ' {
t.Errorf("poster stretched beyond its aspect ratio: %q", 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") 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)
}
}
}
+45 -20
View File
@@ -8,39 +8,49 @@ import (
) )
const ( const (
coverRows = 20 // poster height in terminal rows
minCoverRows = 6 // a smaller poster is not worth showing minCoverRows = 6 // a smaller poster is not worth showing
minCoverCols = 8 //
minTextRows = 10 // the poster never takes these rows from the text minTextRows = 10 // the poster never takes these rows from the text
) )
// detailPane shows a poster above a text. The poster shrinks on short panes // detailPane shows the details of one title: the head (title and facts) with
// and is dropped when the text would get too little room. // 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 { type detailPane struct {
*tview.Flex *tview.Flex // column: top row, plot
app *App app *App
top *tview.Flex // row: head, cover
head *tview.TextView
cover *coverView cover *coverView
text *tview.TextView plot *tview.TextView
text detailText
want string // key of the poster that should be shown want string // key of the poster that should be shown
hasCover bool // a poster (or its loading note) is part of the content hasCover bool // a poster (or its loading note) is part of the content
coverSize int // rows currently given to the poster combined bool // head and plot are shown together in the plot view
} }
func newDetailPane(app *App) *detailPane { func newDetailPane(app *App) *detailPane {
d := &detailPane{ d := &detailPane{
Flex: tview.NewFlex().SetDirection(tview.FlexRow), Flex: tview.NewFlex().SetDirection(tview.FlexRow),
app: app, app: app,
cover: newCoverView(), top: tview.NewFlex(),
text: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true), 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.SetBorder(true).SetTitle(" Details ")
d.combined = true
return d return d
} }
// Show displays the text and, if there is one, the poster. // Show displays the text and, if there is one, the poster.
func (d *detailPane) Show(text string, ref coverRef) { func (d *detailPane) Show(text detailText, ref coverRef) {
d.text.SetText(text).ScrollToBeginning() d.text = text
d.applyText()
if !d.app.covers || ref.fetch == nil { if !d.app.covers || ref.fetch == nil {
d.want, d.hasCover = "", false d.want, d.hasCover = "", false
d.cover.setImage(nil) d.cover.setImage(nil)
@@ -63,20 +73,35 @@ func (d *detailPane) Show(text string, ref coverRef) {
} }
// Clear empties the pane. // 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) { func (d *detailPane) Draw(screen tcell.Screen) {
_, _, _, h := d.GetInnerRect() _, _, w, h := d.GetInnerRect()
rows := 0 coverW, coverH := 0, 0
if d.hasCover { if d.hasCover {
rows = min(coverRows, h-minTextRows) aw, ah := d.cover.aspect()
if rows < minCoverRows { // At most 40% of the width, and the plot keeps its rows.
rows = 0 coverW, coverH = coverCells(aw, ah, w*2/5, h-minTextRows)
if coverH < minCoverRows || coverW < minCoverCols {
coverW, coverH = 0, 0
} }
} }
if rows != d.coverSize { if combined := coverW == 0; combined != d.combined {
d.coverSize = rows d.combined = combined
d.ResizeItem(d.cover, rows, 0) 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) 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 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. // 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 var b strings.Builder
title := tview.Escape(m.Title) title := tview.Escape(m.Title)
if m.Year > 0 { if m.Year > 0 {
@@ -59,10 +73,7 @@ func movieDetails(m radarr.Movie) string {
case m.InLibrary(): case m.InLibrary():
b.WriteString("[yellow]In library, not downloaded[-]\n") b.WriteString("[yellow]In library, not downloaded[-]\n")
} }
if m.Overview != "" { return detailText{head: b.String(), plot: tview.Escape(m.Overview)}
b.WriteString("\n" + tview.Escape(m.Overview) + "\n")
}
return b.String()
} }
// profileIndex returns the index of the profile named name, or 0. // 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. // 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 var b strings.Builder
title := tview.Escape(s.Title) title := tview.Escape(s.Title)
if s.Year > 0 { 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) fmt.Fprintf(&b, "[yellow]In library, %d/%d episodes downloaded[-]\n", st.EpisodeFileCount, st.EpisodeCount)
} }
} }
if s.Overview != "" { return detailText{head: b.String(), plot: tview.Escape(s.Overview)}
b.WriteString("\n" + tview.Escape(s.Overview) + "\n")
}
return b.String()
} }
func plural(n int) string { 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"}, 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}}, 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..."} { 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) 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)" { if got := seriesLabel(s); got != "Breaking Bad (2008)" {
t.Errorf("label = %q", got) 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"} { for _, want := range []string{"Breaking Bad (2008)", "★ 9.3", "1 season ·", "47 min", "Network: AMC", "Status: ended"} {
if !strings.Contains(got, want) { if !strings.Contains(got, want) {
t.Errorf("details missing %q:\n%s", want, got) t.Errorf("details missing %q:\n%s", want, got)
@@ -65,11 +68,11 @@ func TestSeriesLabelAndDetails(t *testing.T) {
s.ID = 1 s.ID = 1
s.Statistics = sonarr.Statistics{EpisodeCount: 10, EpisodeFileCount: 4} 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) t.Errorf("progress missing:\n%s", got)
} }
s.Statistics.EpisodeFileCount = 10 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) 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 missing bool // monitored and not complete
status string status string
size string size string
details string details detailText
cover coverRef cover coverRef
movie *radarr.Movie movie *radarr.Movie
series *sonarr.Series series *sonarr.Series
@@ -105,7 +105,11 @@ func newMovieItem(m radarr.Movie) libItem {
default: default:
it.status = "[yellow]missing" 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 return it
} }
@@ -130,7 +134,8 @@ func newSeriesItem(s sonarr.Series) libItem {
default: default:
it.status = "[yellow]" + counts 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 return it
} }