cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

main
012 / e2e / stillthemes_test.go
stillthemes_test.go 6.1K
1package e2e23import (4 "encoding/json"5 "fmt"6 "image/color"7 "math"8 "os"9 "regexp"10 "strconv"11 "strings"12)1314// Stills are drawn in a terminal color scheme: the golden's palette15// indexes in the scheme's colors, so a still stays tied to its golden16// whatever scheme shows it. The schemes are 012's built-in ones17// (internal/ui/theme/themes.json, the file VHS's Set Theme reads too),18// and the families the docs use are the table in19// docs/contributing/site.md, which the tapes follow as well.2021const (22 schemesFile = "../internal/ui/theme/themes.json"23 familiesDoc = "../docs/contributing/site.md"24)2526// stillPalette is a terminal's colors: a scheme's 16 ANSI colors, text27// and background.28type stillPalette struct {29 name string30 ansi [16]uint3231 fg, bg uint3232}3334// family is a row of the families table: a dark scheme and a light one.35type family struct{ dark, light string }3637// familyRow is a row of the table: | Family | `dark` | `light` | pages |.38var familyRow = regexp.MustCompile("(?m)^\\| ([^|`]+?) \\| `([^`]+)` \\| `([^`]+)` \\|")3940// pictureFamilies reads the families table from the docs.41func pictureFamilies() (map[string]family, error) {42 src, err := os.ReadFile(familiesDoc)43 if err != nil {44 return nil, err45 }46 fams := map[string]family{}47 for _, m := range familyRow.FindAllStringSubmatch(string(src), -1) {48 fams[m[1]] = family{dark: m[2], light: m[3]}49 }50 if len(fams) == 0 {51 return nil, fmt.Errorf("%s: no families table", familiesDoc)52 }53 return fams, nil54}5556// loadSchemes reads the built-in schemes by name.57func loadSchemes() (map[string]stillPalette, error) {58 src, err := os.ReadFile(schemesFile)59 if err != nil {60 return nil, err61 }62 var raw []map[string]any63 if err := json.Unmarshal(src, &raw); err != nil {64 return nil, err65 }66 slots := [16]string{"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",67 "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite"}68 out := map[string]stillPalette{}69 for _, s := range raw {70 name, _ := s["name"].(string)71 p := stillPalette{name: name}72 ok := hexInto(s["foreground"], &p.fg) && hexInto(s["background"], &p.bg)73 for i, k := range slots {74 ok = ok && hexInto(s[k], &p.ansi[i])75 }76 if ok {77 out[name] = p78 }79 }80 return out, nil81}8283func hexInto(v any, dst *uint32) bool {84 s, _ := v.(string)85 n, err := strconv.ParseUint(strings.TrimPrefix(s, "#"), 16, 32)86 if err != nil || len(s) != 7 {87 return false88 }89 *dst = uint32(n)90 return true91}9293// color resolves an ink; def is what inkNone means in its slot.94func (p stillPalette) color(k ink, def inkKind) color.RGBA {95 if k.kind == inkNone {96 k.kind = def97 }98 switch k.kind {99 case inkFg:100 return rgba(p.fg)101 case inkPalette:102 return rgba(p.xterm(k.palette))103 case inkRGB:104 return k.rgb105 }106 return rgba(p.bg)107}108109// xterm is palette entry n: the 16 ANSI colors, then xterm's 6x6x6 cube110// and gray ramp.111func (p stillPalette) xterm(n int) uint32 {112 switch {113 case n < 16:114 return p.ansi[n]115 case n < 232:116 n -= 16117 level := func(v int) uint32 {118 if v == 0 {119 return 0120 }121 return uint32(55 + 40*v)122 }123 return level(n/36)<<16 | level(n/6%6)<<8 | level(n%6)124 }125 g := uint32(8 + 10*(n-232))126 return g<<16 | g<<8 | g127}128129// inks are a cell's colors in p, held to the contrast 012's schemes hold130// their roles to (internal/ui/theme's FromPalette), since a scheme's131// colors weren't chosen for 012's: a background from the palette stands132// apart from the screen, and text moves toward the scheme's text color,133// or black or white, until it reaches its minimum. Colors the user chose134// as RGB are drawn as they are.135func (p stillPalette) inks(c stillCell) (fg, bg color.RGBA) {136 text, screen := rgba(p.fg), rgba(p.bg)137 bg = p.color(c.bg, inkBg)138 if c.bg.kind == inkPalette {139 bg = distinct(bg, screen, text)140 }141 fg = p.color(c.fg, inkFg)142 if c.faint {143 fg = blend(bg, fg, 0.6)144 }145 if c.fg.kind != inkRGB && c.bg.kind != inkRGB {146 fg = readable(fg, bg, text, minContrast(c))147 }148 return fg, bg149}150151// minContrast is a cell's minimum, as theme's: 4.5:1 for text, 3:1 for152// lines and 012's muted gray (hints, borders), 2:1 for faint text.153func minContrast(c stillCell) float64 {154 switch {155 case c.faint:156 return 2157 case c.r >= 0x2500 && c.r <= 0x28ff, c.fg.kind == inkPalette && c.fg.palette == 8:158 return 3159 }160 return 4.5161}162163// The contrast arithmetic is internal/ui/theme's (contrast.go), which164// this module can't import: WCAG 2 contrast, and a color that falls165// short moved just far enough.166167func luminance(c color.RGBA) float64 {168 lin := func(v uint8) float64 {169 f := float64(v) / 0xff170 if f <= 0.04045 {171 return f / 12.92172 }173 return math.Pow((f+0.055)/1.055, 2.4)174 }175 return 0.2126*lin(c.R) + 0.7152*lin(c.G) + 0.0722*lin(c.B)176}177178func contrast(a, b color.RGBA) float64 {179 la, lb := luminance(a), luminance(b)180 if la < lb {181 la, lb = lb, la182 }183 return (la + 0.05) / (lb + 0.05)184}185186// readable is fg, or fg moved toward toward, or failing that black or187// white, until it has min contrast with bg.188func readable(fg, bg, toward color.RGBA, min float64) color.RGBA {189 if contrast(fg, bg) >= min {190 return fg191 }192 if contrast(toward, bg) >= min {193 return blendUntil(fg, toward, func(c color.RGBA) bool { return contrast(c, bg) >= min })194 }195 end := color.RGBA{0xff, 0xff, 0xff, 0xff}196 if luminance(fg) <= luminance(bg) {197 end = color.RGBA{0, 0, 0, 0xff}198 }199 if contrast(end, bg) < min {200 end = color.RGBA{0xff - end.R, 0xff - end.G, 0xff - end.B, 0xff}201 }202 return blendUntil(fg, end, func(c color.RGBA) bool { return contrast(c, bg) >= min })203}204205// distinct is bg, or bg moved toward toward until it stands apart from206// the screen (1.2:1).207func distinct(bg, screen, toward color.RGBA) color.RGBA {208 if contrast(bg, screen) >= 1.2 {209 return bg210 }211 return blendUntil(bg, toward, func(c color.RGBA) bool { return contrast(c, screen) >= 1.2 })212}213214// blendUntil is the color nearest from on the way to end for which ok215// holds; ok(end) must.216func blendUntil(from, end color.RGBA, ok func(color.RGBA) bool) color.RGBA {217 lo, hi := 0.0, 1.0218 for range 24 {219 mid := (lo + hi) / 2220 if ok(blend(from, end, mid)) {221 hi = mid222 } else {223 lo = mid224 }225 }226 return blend(from, end, hi)227}