From b4fe931fa9fde7b82f63cf2590f166570ed8a38c Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 20 Oct 2026 21:57:22 +0200 Subject: [PATCH] Detail panes: poster top right, title and facts left, plot below; sextant rendering Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 +- README.md | 15 +- SPECS.md | 2 +- config.example.json | 3 +- internal/config/config.go | 11 ++ internal/config/config_test.go | 12 ++ internal/ui/app.go | 3 + internal/ui/backend.go | 26 ++-- internal/ui/cover.go | 277 +++++++++++++++++++++++---------- internal/ui/cover_test.go | 70 ++++++++- internal/ui/detail.go | 73 ++++++--- internal/ui/detail_test.go | 76 +++++++++ internal/ui/format.go | 28 ++-- internal/ui/format_test.go | 11 +- internal/ui/library.go | 11 +- 15 files changed, 468 insertions(+), 153 deletions(-) create mode 100644 internal/ui/detail_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c4ceac..4e908f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 681d922..7e80a8b 100644 --- a/README.md +++ b/README.md @@ -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" } } ``` diff --git a/SPECS.md b/SPECS.md index cf7c9e6..5c0b356 100644 --- a/SPECS.md +++ b/SPECS.md @@ -63,7 +63,7 @@ Notes: - Radarr and Sonarr queues (status, warnings, import errors), so stuck imports are visible. - Global speed / pause state in the status bar. Pause/resume and delete a SABnzbd job. - **Status bar / help:** the bottom bar lists the keys that work in the current view or panel; the top right shows the connection state of each service; `?` opens the full key reference. -- **Posters:** the detail panes show a poster thumbnail above the text, rendered with quadrant block characters in 24-bit colors (each cell shows 2×2 pixels in the best two-color split, from the Block Elements range every font has; JPEG/PNG only, decoded and downscaled in the process, so no terminal graphics protocol is needed). Library items fetch `/MediaCover//poster-250.jpg` from their Radarr/Sonarr instance with the API key; search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory, failed downloads are not retried for two minutes, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it. +- **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//poster-250.jpg` from their Radarr/Sonarr instance with the API key; search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory, failed downloads are not retried for two minutes, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it. - **Notifications and log (F5):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll. ### 4.2 v0.2 (done, M8) diff --git a/config.example.json b/config.example.json index 11c2c80..773d370 100644 --- a/config.example.json +++ b/config.example.json @@ -17,6 +17,7 @@ }, "ui": { "refresh_seconds": 3, - "covers": true + "covers": true, + "cover_style": "auto" } } diff --git a/internal/config/config.go b/internal/config/config.go index 13236c1..241adbe 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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...) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 90893c1..5166dbc 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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") + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index b87dd4f..0f83c7c 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -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(), } diff --git a/internal/ui/backend.go b/internal/ui/backend.go index f26d742..81457fa 100644 --- a/internal/ui/backend.go +++ b/internal/ui/backend.go @@ -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 diff --git a/internal/ui/cover.go b/internal/ui/cover.go index 9bbe628..5697978 100644 --- a/internal/ui/cover.go +++ b/internal/ui/cover.go @@ -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". diff --git a/internal/ui/cover_test.go b/internal/ui/cover_test.go index 4e467ce..e7acda7 100644 --- a/internal/ui/cover_test.go +++ b/internal/ui/cover_test.go @@ -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) + } + } +} diff --git a/internal/ui/detail.go b/internal/ui/detail.go index 56b055a..0140ad7 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -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) } diff --git a/internal/ui/detail_test.go b/internal/ui/detail_test.go new file mode 100644 index 0000000..e7cfcf2 --- /dev/null +++ b/internal/ui/detail_test.go @@ -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") + } +} diff --git a/internal/ui/format.go b/internal/ui/format.go index caf2f44..2600d46 100644 --- a/internal/ui/format.go +++ b/internal/ui/format.go @@ -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 { diff --git a/internal/ui/format_test.go b/internal/ui/format_test.go index 5e66360..505dd6c 100644 --- a/internal/ui/format_test.go +++ b/internal/ui/format_test.go @@ -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) } } diff --git a/internal/ui/library.go b/internal/ui/library.go index d26ba66..ee802a1 100644 --- a/internal/ui/library.go +++ b/internal/ui/library.go @@ -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 }