cat / 012
clone

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

main
012 / e2e / stilldraw_test.go
stilldraw_test.go 7.5K
1package e2e23import (4 "fmt"5 "image"6 "image/color"7 "image/draw"8 "math"9 "os"10 "path/filepath"1112 "golang.org/x/image/font"13 "golang.org/x/image/font/gofont/gomono"14 "golang.org/x/image/font/gofont/gomonobold"15 "golang.org/x/image/font/gofont/gomonobolditalic"16 "golang.org/x/image/font/gofont/gomonoitalic"17 "golang.org/x/image/font/opentype"18 "golang.org/x/image/math/fixed"19 "golang.org/x/image/vector"20)2122// Drawing a screen's cells as a picture of a terminal: text in23// JetBrains Mono, the font of the recordings and the site (testdata/fonts,24// so every machine draws the same letters), with Go Mono for the few25// characters it has none of, and box drawing, blocks, braille and the26// symbols 012 draws its chrome with drawn as shapes, as terminals draw27// them, so lines join across cells.2829const (30 stillFontSize = 26 // pixels; stills are drawn at twice the size they show31 stillPad = 28 // around the screen, inside the window32 stillRadius = 18 // the window's corners33)3435// stillFaces are the font's four styles at the still's size, regular,36// bold, italic and bold italic, and Go Mono's for what the font lacks.37type stillFaces struct {38 faces, fallback [4]font.Face39 cw, ch, ascent int40}4142// fontFiles are the styles' files in testdata/fonts.43var fontFiles = [4]string{"JetBrainsMono-Regular.ttf", "JetBrainsMono-Bold.ttf", "JetBrainsMono-Italic.ttf", "JetBrainsMono-BoldItalic.ttf"}4445func loadFaces() (*stillFaces, error) {46 var fs stillFaces47 goMono := [4][]byte{gomono.TTF, gomonobold.TTF, gomonoitalic.TTF, gomonobolditalic.TTF}48 for i, name := range fontFiles {49 ttf, err := os.ReadFile(filepath.Join("testdata", "fonts", name))50 if err != nil {51 return nil, err52 }53 if fs.faces[i], err = newFace(ttf); err != nil {54 return nil, fmt.Errorf("%s: %w", name, err)55 }56 if fs.fallback[i], err = newFace(goMono[i]); err != nil {57 return nil, err58 }59 }60 adv, _ := fs.faces[0].GlyphAdvance('0')61 m := fs.faces[0].Metrics()62 fs.cw = adv.Round()63 fs.ch = int(math.Round(stillFontSize * 1.3))64 fs.ascent = (fs.ch-(m.Ascent+m.Descent).Round())/2 + m.Ascent.Round()65 return &fs, nil66}6768func newFace(ttf []byte) (font.Face, error) {69 parsed, err := opentype.Parse(ttf)70 if err != nil {71 return nil, err72 }73 return opentype.NewFace(parsed, &opentype.FaceOptions{Size: stillFontSize, DPI: 72, Hinting: font.HintingNone})74}7576// face is the face c's rune is drawn in: its style in the font, or in Go77// Mono when the font has no glyph for it; nil when neither has.78func (fs *stillFaces) face(c stillCell) font.Face {79 i := 080 if c.bold {81 i++82 }83 if c.italic {84 i += 285 }86 for _, f := range []font.Face{fs.faces[i], fs.fallback[i]} {87 if _, ok := f.GlyphAdvance(c.r); ok {88 return f89 }90 }91 return nil92}9394// drawStill draws grid as a terminal window in palette p. It returns the95// runes it had no way to draw.96func drawStill(grid [][]stillCell, p stillPalette, fs *stillFaces) (*image.NRGBA, []rune) {97 cols, rows := len(grid[0]), len(grid)98 w, h := cols*fs.cw+2*stillPad, rows*fs.ch+2*stillPad99 img := image.NewNRGBA(image.Rect(0, 0, w, h))100 window(img, p)101 var missing []rune102 for y, row := range grid {103 for x := range row {104 origin := image.Pt(stillPad+x*fs.cw, stillPad+y*fs.ch)105 if !drawCell(img, row, x, origin, p, fs) {106 missing = append(missing, row[x].r)107 }108 }109 }110 return img, missing111}112113// window fills a rounded rectangle with the terminal's background, with114// a hairline border so the window reads on a page of the same color.115func window(img *image.NRGBA, p stillPalette) {116 b := img.Bounds()117 w, h := float32(b.Dx()), float32(b.Dy())118 border := blend(rgba(p.bg), rgba(p.fg), 0.18)119 fillShape(img, b, border, func(s *shape) { s.roundRect(0, 0, w, h, stillRadius) })120 fillShape(img, b, rgba(p.bg), func(s *shape) { s.roundRect(2, 2, w-2, h-2, stillRadius-2) })121}122123// drawCell draws cell x of row at origin; false when its rune has no124// glyph.125func drawCell(img *image.NRGBA, row []stillCell, x int, origin image.Point, p stillPalette, fs *stillFaces) bool {126 c := row[x]127 span := 1128 if c.wide {129 span = 2130 }131 r := image.Rect(origin.X, origin.Y, origin.X+span*fs.cw, origin.Y+fs.ch)132 if c.r == 0 && c.filled {133 return true // the second half of a wide character, drawn with the first134 }135 fg, bg := p.inks(c)136 if c.bg.kind != inkNone {137 draw.Draw(img, image.Rect(origin.X, origin.Y, origin.X+fs.cw, origin.Y+fs.ch), image.NewUniform(bg), image.Point{}, draw.Src)138 }139 ok := true140 if c.r != 0 && c.r != ' ' && c.r != ' ' {141 ok = drawGlyph(img, c, r, fg, fs)142 }143 lines(img, c, r, fg, bg, p, fs)144 return ok145}146147// drawGlyph draws c's rune: as a shape when it's one terminals draw148// themselves, else from the font.149func drawGlyph(img *image.NRGBA, c stillCell, r image.Rectangle, fg color.RGBA, fs *stillFaces) bool {150 if drawn := drawSpecial(img, c.r, r, fg, fs); drawn {151 return true152 }153 face := fs.face(c)154 if face == nil {155 return false156 }157 adv, _ := face.GlyphAdvance(c.r)158 d := font.Drawer{Dst: img, Src: image.NewUniform(fg), Face: face}159 d.Dot = fixed.P(r.Min.X, r.Min.Y+fs.ascent)160 d.Dot.X += (fixed.I(r.Dx()) - adv) / 2161 d.DrawString(string(c.r))162 return true163}164165// lines draws c's underline and strikethrough.166func lines(img *image.NRGBA, c stillCell, r image.Rectangle, fg, bg color.RGBA, p stillPalette, fs *stillFaces) {167 t := lineWidth(fs.cw)168 if c.strike {169 y := r.Min.Y + fs.ascent - stillFontSize*3/10170 fill(img, image.Rect(r.Min.X, y, r.Max.X, y+t), fg)171 }172 if c.underline == ulNone {173 return174 }175 col := fg176 if c.ulInk.kind != inkNone {177 col = p.color(c.ulInk, inkFg)178 if c.ulInk.kind == inkPalette {179 col = readable(col, bg, rgba(p.fg), 3)180 }181 }182 y := r.Min.Y + fs.ascent + t*2183 switch c.underline {184 case ulDouble:185 fill(img, image.Rect(r.Min.X, y-t, r.Max.X, y), col)186 fill(img, image.Rect(r.Min.X, y+t, r.Max.X, y+2*t), col)187 case ulWavy:188 wavy(img, r, y, t, col)189 case ulDotted:190 for x := r.Min.X; x < r.Max.X; x += 2 * t {191 fill(img, image.Rect(x, y, x+t, y+t), col)192 }193 case ulDashed:194 for x := r.Min.X; x < r.Max.X; x += r.Dx() / 2 {195 fill(img, image.Rect(x, y, x+r.Dx()*3/10, y+t), col)196 }197 default:198 fill(img, image.Rect(r.Min.X, y, r.Max.X, y+t), col)199 }200}201202// wavy draws a curly underline: one wave a cell, in phase across cells.203func wavy(img *image.NRGBA, r image.Rectangle, y, t int, col color.RGBA) {204 amp := float32(t) * 1.2205 fillShape(img, image.Rect(r.Min.X, y-2*t-2, r.Max.X, y+2*t+2), col, func(s *shape) {206 var pts [][2]float32207 w := float32(r.Dx())208 for i := 0; i <= 12; i++ {209 fx := w * float32(i) / 12210 pts = append(pts, [2]float32{fx, float32(2*t+2) + amp*float32(math.Sin(2*math.Pi*float64(i)/12))})211 }212 s.stroke(pts, float32(t))213 })214}215216// lineWidth is a light line's width for cells cw wide.217func lineWidth(cw int) int { return max(1, (cw+4)/8) }218219func fill(img *image.NRGBA, r image.Rectangle, c color.Color) {220 draw.Draw(img, r, image.NewUniform(c), image.Point{}, draw.Over)221}222223// blend mixes a toward b by t.224func blend(a, b color.RGBA, t float64) color.RGBA {225 mix := func(x, y uint8) uint8 { return uint8(math.Round(float64(x)*(1-t) + float64(y)*t)) }226 return color.RGBA{mix(a.R, b.R), mix(a.G, b.G), mix(a.B, b.B), 0xff}227}228229// fillShape rasterizes the shape build makes, in r's coordinates, and230// fills it with c.231func fillShape(img *image.NRGBA, r image.Rectangle, c color.Color, build func(*shape)) {232 s := &shape{z: vector.NewRasterizer(r.Dx(), r.Dy())}233 s.z.DrawOp = draw.Over234 build(s)235 s.z.Draw(img, r, image.NewUniform(c), image.Point{})236}237238func runeList(rs []rune) string {239 seen := map[rune]bool{}240 var out string241 for _, r := range rs {242 if !seen[r] {243 seen[r] = true244 out += fmt.Sprintf(" %q", r)245 }246 }247 return out248}