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:
+2
-1
@@ -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
|
||||||
|
|||||||
@@ -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" }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -17,6 +17,7 @@
|
|||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"refresh_seconds": 3,
|
"refresh_seconds": 3,
|
||||||
"covers": true
|
"covers": true,
|
||||||
|
"cover_style": "auto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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".
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user