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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 21:57:22 +02:00
co-authored by Claude Sonnet 5.5
parent 365ba9dee8
commit b4fe931fa9
15 changed files with 468 additions and 153 deletions
+193 -84
View File
@@ -8,6 +8,7 @@ import (
"image/color"
_ "image/jpeg" // poster formats
_ "image/png"
"strings"
"time"
"github.com/gdamore/tcell/v2"
@@ -22,7 +23,6 @@ const (
coverFailureTTL = 2 * time.Minute
maxCoverCache = 300
coverFetchLimit = 4
flatCellError = 600 // squared RGB distance below which a cell is drawn flat
)
// decodeCover decodes a JPEG or PNG poster and keeps a small copy.
@@ -70,23 +70,177 @@ func boxScale(src image.Image, w, h int) *image.RGBA {
return dst
}
// coverView draws a poster with quadrant block characters: every cell shows
// 2×2 pixels in two colors (the best split of the four pixels into a
// foreground and a background group), which doubles the horizontal detail of
// plain half blocks. The glyphs are all from the Block Elements range that
// every terminal font has, and it works over SSH.
type coverView struct {
*tview.Box
img *image.RGBA
note string // shown instead of the image: "loading…", "no cover"
scaled *image.RGBA // cache of img at the last drawn size
// coverStyle is a way to draw a poster with block characters: every cell
// shows sw×sh pixels in two colors, the best split of its pixels into a
// foreground and a background group.
type coverStyle struct {
name string
sw, sh int
glyph func(mask int) rune // mask: bit i = pixel i (row-major) is foreground
}
func newCoverView() *coverView { return &coverView{Box: tview.NewBox()} }
var (
// quadrantStyle (2×2 pixels): the glyphs are in the Block Elements range
// that every terminal font has.
quadrantStyle = coverStyle{"quadrant", 2, 2, func(mask int) rune { return quadrants[mask] }}
// sextantStyle (2×3 pixels): sharper, but the glyphs live in the Symbols
// for Legacy Computing block, which many fonts lack.
sextantStyle = coverStyle{"sextant", 2, 3, sextantGlyph}
)
func (c *coverView) setImage(img *image.RGBA) { c.img, c.note, c.scaled = img, "", nil }
func (c *coverView) setNote(note string) { c.img, c.note, c.scaled = nil, note, nil }
// quadrants[mask] is the glyph that fills the quadrants set in mask
// (bit 0 top left, 1 top right, 2 bottom left, 3 bottom right).
var quadrants = [16]rune{' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█'}
// sextantGlyph maps a 2×3 pixel mask (bit 0 top left, 1 top right, 2 middle
// left, ... 5 bottom right) to its character. Unicode lists the 60 sextants
// in mask order but leaves out the empty and full cell and the two halves,
// which already exist as space, full block and the half blocks.
func sextantGlyph(mask int) rune {
switch mask {
case 0:
return ' '
case 63:
return '█'
case 21:
return '▌'
case 42:
return '▐'
}
idx := mask - 1
if mask > 21 {
idx--
}
if mask > 42 {
idx--
}
return rune(0x1FB00 + idx)
}
// coverStyleFor resolves the configured style. "auto" picks sextants on
// terminals known to have the glyphs and quadrants everywhere else.
func coverStyleFor(setting string, env func(string) string) coverStyle {
switch setting {
case "sextant":
return sextantStyle
case "quadrant":
return quadrantStyle
}
term, program := strings.ToLower(env("TERM")), strings.ToLower(env("TERM_PROGRAM"))
for _, known := range []string{"kitty", "foot", "ghostty", "wezterm"} {
if strings.Contains(term, known) || strings.Contains(program, known) {
return sextantStyle
}
}
if env("KITTY_WINDOW_ID") != "" || env("WEZTERM_PANE") != "" {
return sextantStyle
}
return quadrantStyle
}
// flatPixelError is the squared RGB distance per pixel below which a cell is
// drawn flat: smooth areas and noise should not turn into patterns.
const flatPixelError = 150
// bestCell picks the glyph and the two colors that best reproduce the pixels
// of one cell. Only masks without the last pixel are tried, because a mask
// and its complement draw the same picture with the colors swapped.
func (st coverStyle) bestCell(p []color.RGBA) (ch rune, fg, bg color.RGBA) {
n := len(p)
type sum struct{ r, g, b, n float64 }
var all sum
for _, c := range p {
all.r, all.g, all.b, all.n = all.r+float64(c.R), all.g+float64(c.G), all.b+float64(c.B), all.n+1
}
avg := func(s sum) [3]float64 {
if s.n == 0 {
return [3]float64{}
}
return [3]float64{s.r / s.n, s.g / s.n, s.b / s.n}
}
dist := func(c color.RGBA, m [3]float64) float64 {
dr, dg, db := float64(c.R)-m[0], float64(c.G)-m[1], float64(c.B)-m[2]
return dr*dr + dg*dg + db*db
}
toRGBA := func(m [3]float64) color.RGBA {
return color.RGBA{uint8(m[0] + .5), uint8(m[1] + .5), uint8(m[2] + .5), 255}
}
flat := avg(all)
var flatErr float64
for _, c := range p {
flatErr += dist(c, flat)
}
if flatErr < flatPixelError*float64(n) {
c := toRGBA(flat)
return ' ', c, c
}
best, bestErr := 0, flatErr*0.6 // a split must clearly beat the flat cell
var bestFg, bestBg [3]float64
for mask := 1; mask < 1<<(n-1); mask++ {
var f, b sum
for i, c := range p {
t := &b
if mask>>i&1 == 1 {
t = &f
}
t.r, t.g, t.b, t.n = t.r+float64(c.R), t.g+float64(c.G), t.b+float64(c.B), t.n+1
}
fm, bm := avg(f), avg(b)
var e float64
for i, c := range p {
if mask>>i&1 == 1 {
e += dist(c, fm)
} else {
e += dist(c, bm)
}
if e >= bestErr {
break
}
}
if e < bestErr {
best, bestErr, bestFg, bestBg = mask, e, fm, bm
}
}
if best == 0 {
c := toRGBA(flat)
return ' ', c, c
}
return st.glyph(best), toRGBA(bestFg), toRGBA(bestBg)
}
// coverView draws a poster, see coverStyle. It works in any terminal with
// colors, also over SSH.
type coverView struct {
*tview.Box
style coverStyle
img *image.RGBA
note string // shown instead of the image: "loading…", "no cover"
// The cells for the last drawn size.
cols, rows int
cells []styledCell
}
type styledCell struct {
ch rune
fg, bg tcell.Color
}
func newCoverView(style coverStyle) *coverView { return &coverView{Box: tview.NewBox(), style: style} }
func (c *coverView) setImage(img *image.RGBA) { c.img, c.note, c.cells = img, "", nil }
func (c *coverView) setNote(note string) { c.img, c.note, c.cells = nil, note, nil }
// aspect returns the width and height of the poster (2:3 until one is loaded).
func (c *coverView) aspect() (w, h int) {
if c.img == nil {
return 2, 3
}
b := c.img.Bounds()
return b.Dx(), b.Dy()
}
// coverCells returns how many columns and rows a poster of the given aspect
// needs inside w×h cells. A cell is about twice as tall as it is wide.
@@ -118,85 +272,40 @@ func (c *coverView) Draw(screen tcell.Screen) {
if cols < 1 || rows < 1 {
return
}
if c.scaled == nil || c.scaled.Bounds().Dx() != 2*cols || c.scaled.Bounds().Dy() != 2*rows {
c.scaled = boxScale(c.img, 2*cols, 2*rows)
}
px := func(x, y int) color.RGBA {
i := c.scaled.PixOffset(x, y)
return color.RGBA{c.scaled.Pix[i], c.scaled.Pix[i+1], c.scaled.Pix[i+2], 255}
if c.cells == nil || c.cols != cols || c.rows != rows {
c.cols, c.rows, c.cells = cols, rows, c.build(cols, rows)
}
// Right-align the poster inside its area, so a narrower poster sits against the edge.
x += w - cols
for row := 0; row < rows; row++ {
for col := 0; col < cols; col++ {
ch, fg, bg := quadCell([4]color.RGBA{
px(2*col, 2*row), px(2*col+1, 2*row), // top left, top right
px(2*col, 2*row+1), px(2*col+1, 2*row+1), // bottom left, bottom right
})
style := tcell.StyleDefault.Foreground(tcell.NewRGBColor(int32(fg.R), int32(fg.G), int32(fg.B))).
Background(tcell.NewRGBColor(int32(bg.R), int32(bg.G), int32(bg.B)))
screen.SetContent(x+col, y+row, ch, nil, style)
cell := c.cells[row*cols+col]
screen.SetContent(x+col, y+row, cell.ch, nil, tcell.StyleDefault.Foreground(cell.fg).Background(cell.bg))
}
}
}
// quadrants[mask] is the glyph that fills the quadrants set in mask
// (bit 0 top left, 1 top right, 2 bottom left, 3 bottom right).
var quadrants = [16]rune{' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█'}
// quadCell picks the glyph and colors that best reproduce four pixels (top
// left, top right, bottom left, bottom right) with one foreground and one
// background color.
func quadCell(p [4]color.RGBA) (ch rune, fg, bg color.RGBA) {
mean := func(mask int, want bool) (c [3]float64, n int) {
for i := 0; i < 4; i++ {
if (mask>>i&1 == 1) == want {
c[0], c[1], c[2] = c[0]+float64(p[i].R), c[1]+float64(p[i].G), c[2]+float64(p[i].B)
n++
}
}
for k := range c {
if n > 0 {
c[k] /= float64(n)
}
}
return c, n
}
// err is the squared distance of the pixels in one group to the group's mean.
errOf := func(mask int, want bool, m [3]float64) (e float64) {
for i := 0; i < 4; i++ {
if (mask>>i&1 == 1) == want {
dr, dg, db := float64(p[i].R)-m[0], float64(p[i].G)-m[1], float64(p[i].B)-m[2]
e += dr*dr + dg*dg + db*db
}
}
return e
}
toRGBA := func(m [3]float64) color.RGBA {
return color.RGBA{uint8(m[0] + .5), uint8(m[1] + .5), uint8(m[2] + .5), 255}
}
// Mask 0 is a single flat color. A split must clearly beat it, and nearly
// flat cells (smooth gradients, noise) stay flat. Only masks without the
// bottom right quadrant are tried: a mask and its complement draw the same
// picture with swapped colors.
flat, _ := mean(0, false)
flatErr := errOf(0, false, flat)
best, bestErr := 0, flatErr*0.6
if flatErr >= flatCellError {
for mask := 1; mask < 8; mask++ {
fm, _ := mean(mask, true)
bm, _ := mean(mask, false)
if e := errOf(mask, true, fm) + errOf(mask, false, bm); e < bestErr {
best, bestErr = mask, e
// build renders the poster into cols×rows cells.
func (c *coverView) build(cols, rows int) []styledCell {
st := c.style
scaled := boxScale(c.img, cols*st.sw, rows*st.sh)
cells := make([]styledCell, 0, cols*rows)
px := make([]color.RGBA, st.sw*st.sh)
for row := 0; row < rows; row++ {
for col := 0; col < cols; col++ {
for sy := 0; sy < st.sh; sy++ {
for sx := 0; sx < st.sw; sx++ {
i := scaled.PixOffset(col*st.sw+sx, row*st.sh+sy)
px[sy*st.sw+sx] = color.RGBA{scaled.Pix[i], scaled.Pix[i+1], scaled.Pix[i+2], 255}
}
}
ch, fg, bg := st.bestCell(px)
cells = append(cells, styledCell{ch,
tcell.NewRGBColor(int32(fg.R), int32(fg.G), int32(fg.B)),
tcell.NewRGBColor(int32(bg.R), int32(bg.G), int32(bg.B))})
}
}
if best == 0 {
c := toRGBA(flat)
return ' ', c, c
}
fm, _ := mean(best, true)
bm, _ := mean(best, false)
return quadrants[best], toRGBA(fm), toRGBA(bm)
return cells
}
// coverRef says where a poster comes from. The zero value means "no poster".