Terminal.app draws block elements from the font, with the halves off the cell's middle, so quadrant posters came out as rows of shifted dots. The new cover style "cells" uses one background color per cell and no glyph; auto picks it when TERM_PROGRAM is Apple_Terminal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
361 lines
13 KiB
Go
361 lines
13 KiB
Go
package ui
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"image"
|
||
"image/color"
|
||
"image/gif"
|
||
"image/jpeg"
|
||
"image/png"
|
||
"strings"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/gdamore/tcell/v2"
|
||
)
|
||
|
||
// stripes returns a w×h image whose top half is red and bottom half blue.
|
||
func stripes(w, h int) *image.RGBA {
|
||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||
for y := 0; y < h; y++ {
|
||
for x := 0; x < w; x++ {
|
||
c := color.RGBA{255, 0, 0, 255}
|
||
if y >= h/2 {
|
||
c = color.RGBA{0, 0, 255, 255}
|
||
}
|
||
img.SetRGBA(x, y, c)
|
||
}
|
||
}
|
||
return img
|
||
}
|
||
|
||
func TestBoxScaleAverages(t *testing.T) {
|
||
src := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||
src.SetRGBA(0, 0, color.RGBA{200, 0, 0, 255})
|
||
src.SetRGBA(1, 0, color.RGBA{0, 0, 0, 255})
|
||
src.SetRGBA(0, 1, color.RGBA{0, 0, 0, 255})
|
||
src.SetRGBA(1, 1, color.RGBA{0, 0, 0, 255})
|
||
got := boxScale(src, 1, 1).RGBAAt(0, 0)
|
||
if got.R < 49 || got.R > 51 || got.G != 0 || got.A != 255 {
|
||
t.Errorf("average = %+v, want R≈50", got)
|
||
}
|
||
// Upscaling must not leave holes.
|
||
up := boxScale(stripes(1, 2), 3, 4)
|
||
if up.RGBAAt(2, 0).R != 255 || up.RGBAAt(2, 3).B != 255 {
|
||
t.Errorf("upscaled corners: %+v %+v", up.RGBAAt(2, 0), up.RGBAAt(2, 3))
|
||
}
|
||
}
|
||
|
||
func TestDecodeCover(t *testing.T) {
|
||
var buf bytes.Buffer
|
||
if err := jpeg.Encode(&buf, stripes(300, 450), nil); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
img, err := decodeCover(buf.Bytes(), storedCoverHeight)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if b := img.Bounds(); b.Dy() != storedCoverHeight || b.Dx() != 106 {
|
||
t.Errorf("stored size %v, want 106x%d", b, storedCoverHeight)
|
||
}
|
||
|
||
buf.Reset()
|
||
if err := png.Encode(&buf, stripes(20, 30)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if img, err := decodeCover(buf.Bytes(), storedCoverHeight); err != nil || img.Bounds().Dy() != 30 {
|
||
t.Errorf("small png kept as is: %v %v", img, err)
|
||
}
|
||
|
||
if _, err := decodeCover([]byte("<html>not an image</html>"), storedCoverHeight); err == nil {
|
||
t.Error("garbage must be rejected")
|
||
}
|
||
// A header that promises a huge image is refused before decoding it.
|
||
var huge bytes.Buffer
|
||
huge.Write([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x27\x10\x00\x00\x27\x10\x08\x02\x00\x00\x00"))
|
||
if _, err := decodeCover(huge.Bytes(), storedCoverHeight); err == nil {
|
||
t.Error("oversized image must be rejected")
|
||
}
|
||
}
|
||
|
||
func TestQuadCell(t *testing.T) {
|
||
red, blue, black := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}, color.RGBA{0, 0, 0, 255}
|
||
for _, tc := range []struct {
|
||
name string
|
||
px [4]color.RGBA
|
||
wantCh rune
|
||
wantFg color.RGBA
|
||
wantBg color.RGBA
|
||
sameBoth bool
|
||
}{
|
||
{"flat", [4]color.RGBA{red, red, red, red}, ' ', red, red, true},
|
||
{"top half", [4]color.RGBA{red, red, blue, blue}, '▀', red, blue, false},
|
||
{"bottom half", [4]color.RGBA{blue, blue, red, red}, '▀', blue, red, false}, // same picture, canonical form
|
||
{"left half", [4]color.RGBA{red, blue, red, blue}, '▌', red, blue, false},
|
||
{"one corner", [4]color.RGBA{red, blue, blue, blue}, '▘', red, blue, false},
|
||
{"diagonal", [4]color.RGBA{red, black, black, red}, '▞', black, red, false},
|
||
} {
|
||
ch, fg, bg := 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)
|
||
}
|
||
}
|
||
// 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, _, _ := quadrantStyle.bestCell([]color.RGBA{n, m, n, n}); ch != ' ' {
|
||
t.Errorf("noise rendered as %q", ch)
|
||
}
|
||
}
|
||
|
||
func TestCoverCells(t *testing.T) {
|
||
// A 2:3 poster in a roomy box is limited by the height: 10 rows are 20
|
||
// width units tall, so 13 columns.
|
||
if c, r := coverCells(40, 60, 30, 10, 2); c != 13 || r != 10 {
|
||
t.Errorf("tall limit: %dx%d", c, r)
|
||
}
|
||
// In a narrow box the width limits it, and the box keeps the poster's shape
|
||
// (8×6 cells is exactly 2:3) instead of 10×7 which would leave bars.
|
||
if c, r := coverCells(40, 60, 10, 20, 2); c != 8 || r != 6 {
|
||
t.Errorf("narrow limit: %dx%d", c, r)
|
||
}
|
||
}
|
||
|
||
func TestCoverViewDraws(t *testing.T) {
|
||
screen := tcell.NewSimulationScreen("")
|
||
if err := screen.Init(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer screen.Fini()
|
||
screen.SetSize(30, 10)
|
||
|
||
c := newCoverView(quadrantStyle, nil, nil)
|
||
c.setImage(stripes(40, 60)) // red above blue
|
||
c.SetRect(0, 0, 30, 10) // fits 13 columns × 10 rows
|
||
|
||
c.Draw(screen)
|
||
rgba := func(c tcell.Color) color.RGBA {
|
||
r, g, b := c.RGB()
|
||
return color.RGBA{uint8(r), uint8(g), uint8(b), 255}
|
||
}
|
||
cell := func(x, y int) (rune, color.RGBA, color.RGBA) {
|
||
ch, _, st, _ := screen.GetContent(x, y)
|
||
fg, bg, _ := st.Decompose()
|
||
return ch, rgba(fg), rgba(bg)
|
||
}
|
||
red, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}
|
||
// 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(29, 9); fg != blue || bg != blue {
|
||
t.Errorf("bottom right = fg %v bg %v, want blue", fg, bg)
|
||
}
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
func TestLoadCoverCachesAndDedupes(t *testing.T) {
|
||
a := newTestApp(t)
|
||
var fetches atomic.Int32
|
||
var buf bytes.Buffer
|
||
png.Encode(&buf, stripes(10, 20))
|
||
ref := coverRef{key: "k", fetch: func(context.Context) ([]byte, error) {
|
||
fetches.Add(1)
|
||
return buf.Bytes(), nil
|
||
}}
|
||
|
||
// Run posted callbacks inline instead of through the (not running) event loop.
|
||
delivered := make(chan error, 2)
|
||
a.loadCover(ref, func(_ *image.RGBA, err error) { delivered <- err })
|
||
a.loadCover(ref, func(_ *image.RGBA, err error) { delivered <- err }) // joins the request in flight
|
||
if len(a.coverCache.waiting["k"]) != 2 {
|
||
t.Fatalf("waiters = %d, want 2", len(a.coverCache.waiting["k"]))
|
||
}
|
||
if n := fetches.Load(); n > 1 {
|
||
t.Errorf("fetched %d times", n)
|
||
}
|
||
}
|
||
|
||
func TestLoadCoverRemembersFailures(t *testing.T) {
|
||
a := newTestApp(t)
|
||
a.coverCache.failed["bad"] = time.Now()
|
||
var gotErr error
|
||
a.loadCover(coverRef{key: "bad", fetch: func(context.Context) ([]byte, error) {
|
||
t.Error("a recent failure must not be retried")
|
||
return nil, nil
|
||
}}, func(_ *image.RGBA, err error) { gotErr = err })
|
||
if gotErr == nil {
|
||
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 TestCoverStyleForFontCheck(t *testing.T) {
|
||
defer func(old func() bool) { sextantFontAvailable = old }(sextantFontAvailable)
|
||
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
||
|
||
sextantFontAvailable = func() bool { return true }
|
||
for _, tc := range []struct {
|
||
name string
|
||
env map[string]string
|
||
want string
|
||
}{
|
||
{"unknown terminal with a suitable font", map[string]string{"TERM": "xterm-256color"}, "sextant"},
|
||
{"over ssh the local fonts are unknown", map[string]string{"TERM": "xterm-256color", "SSH_CONNECTION": "1 2 3 4"}, "quadrant"},
|
||
{"over ssh with a tty", map[string]string{"SSH_TTY": "/dev/pts/1"}, "quadrant"},
|
||
} {
|
||
if got := coverStyleFor("auto", env(tc.env)).name; got != tc.want {
|
||
t.Errorf("%s: %s, want %s", tc.name, got, tc.want)
|
||
}
|
||
}
|
||
// An explicit setting never asks fontconfig.
|
||
sextantFontAvailable = func() bool { t.Error("fontconfig consulted for an explicit style"); return true }
|
||
if coverStyleFor("quadrant", env(nil)).name != "quadrant" || coverStyleFor("sextant", env(nil)).name != "sextant" {
|
||
t.Error("explicit styles must win")
|
||
}
|
||
// No suitable font: quadrants.
|
||
sextantFontAvailable = func() bool { return false }
|
||
if got := coverStyleFor("auto", env(map[string]string{"TERM": "xterm-256color"})).name; got != "quadrant" {
|
||
t.Errorf("no font: %s", got)
|
||
}
|
||
}
|
||
|
||
func TestCoverStyleFor(t *testing.T) {
|
||
defer func(old func() bool) { sextantFontAvailable = old }(sextantFontAvailable)
|
||
sextantFontAvailable = func() bool { return false } // the table below is about the terminal checks
|
||
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"},
|
||
{"auto", map[string]string{"TERM": "xterm-256color", "TERM_PROGRAM": "Apple_Terminal"}, "cells"},
|
||
{"cells", map[string]string{"TERM": "xterm-kitty"}, "cells"},
|
||
{"quadrant", map[string]string{"TERM_PROGRAM": "Apple_Terminal"}, "quadrant"},
|
||
} {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestCoverCellsKeepTheShape(t *testing.T) {
|
||
// For many poster shapes, cell shapes and boxes the chosen box must match
|
||
// the poster within the tolerance (or be the closest available).
|
||
for _, img := range [][2]int{{167, 250}, {500, 750}, {2, 3}, {1000, 1500}, {680, 1000}} {
|
||
for _, aspect := range []float64{1.9, 2, 2.1, 2.25} {
|
||
for w := 12; w <= 40; w += 7 {
|
||
for h := 8; h <= 30; h += 11 {
|
||
c, r := coverCells(img[0], img[1], w, h, aspect)
|
||
if c < 1 || r < 1 || c > w || r > h {
|
||
t.Fatalf("%v aspect %v in %dx%d: %dx%d out of bounds", img, aspect, w, h, c, r)
|
||
}
|
||
shape := float64(img[0]) / float64(img[1])
|
||
err := float64(c)/(float64(r)*aspect)/shape - 1
|
||
if err < 0 {
|
||
err = -err
|
||
}
|
||
if err > 0.10 { // the tolerance is 3%; tiny boxes may only get within a cell
|
||
t.Errorf("%v aspect %v in %dx%d: box %dx%d is %.0f%% off", img, aspect, w, h, c, r, err*100)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestDecodeCoverOtherFormats(t *testing.T) {
|
||
// A WebP stored under a .jpg name, as Sonarr keeps what the provider sends.
|
||
webp, _ := base64.StdEncoding.DecodeString("UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==")
|
||
img, err := decodeCover(webp, storedCoverHeight)
|
||
if err != nil || img.Bounds().Dx() != 1 || img.Bounds().Dy() != 1 {
|
||
t.Fatalf("webp: %v %v", img, err)
|
||
}
|
||
|
||
var buf bytes.Buffer
|
||
pal := image.NewPaletted(image.Rect(0, 0, 20, 30), []color.Color{color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}})
|
||
if err := gif.Encode(&buf, pal, nil); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if img, err := decodeCover(buf.Bytes(), storedCoverHeight); err != nil || img.Bounds().Dy() != 30 {
|
||
t.Errorf("gif: %v %v", img, err)
|
||
}
|
||
}
|
||
|
||
func TestDescribeBytesAndTheErrorItGives(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
data []byte
|
||
want string
|
||
}{
|
||
{"empty", nil, "empty"},
|
||
{"avif", []byte("\x00\x00\x00\x1cftypavif\x00\x00"), "AVIF/HEIC, avif"},
|
||
{"html", []byte(" <html><body>Not Found</body></html>"), "HTML or SVG"},
|
||
{"bmp", []byte("BM\x00\x00\x00\x00"), "BMP"},
|
||
{"tiff", []byte("II*\x00\x08\x00"), "TIFF"},
|
||
{"binary", []byte{1, 2, 3, 4, 5}, "5 bytes starting 01 02 03 04 05"},
|
||
} {
|
||
if got := describeBytes(tc.data); !strings.Contains(got, tc.want) {
|
||
t.Errorf("%s: %q, want it to mention %q", tc.name, got, tc.want)
|
||
}
|
||
_, err := decodeCover(tc.data, storedCoverHeight)
|
||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||
t.Errorf("%s: the error %v should say what the data is (%q)", tc.name, err, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestCellsStyleIsFlat(t *testing.T) {
|
||
c := color.RGBA{10, 200, 30, 255}
|
||
ch, fg, bg := cellsStyle.bestCell([]color.RGBA{c})
|
||
if ch != ' ' || fg != c || bg != c {
|
||
t.Errorf("got %q %v %v", ch, fg, bg)
|
||
}
|
||
}
|