okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.pettui: one delimiter table for math, bytes.Cut over hand scans, and the fork's IndentWriter for list itemse4c3bd6

main
9.2 KiB307 linesraw
1package tui
2
3import (
4	"fmt"
5	"math"
6	"os"
7	"regexp"
8	"strconv"
9
10	"github.com/lucasb-eyer/go-colorful"
11)
12
13// ink is the transcript's brightness ramp. Each step is mixed from the
14// terminal's own text and background colors to a contrast target, so the
15// ramp keeps its spacing on any theme instead of trusting palette slots.
16type ink struct {
17	// code steps down from prose for inline and fenced code.
18	code string
19	// secondary is text read at a glance: tool rows, timings, notices.
20	secondary string
21	// decor is structure that is seen, not read: list markers, gutters.
22	decor string
23	// busy is albedo's rail color while it works in tools.
24	busy string
25	// codeBg tints the cells under inline code, so a span keeps prose
26	// contrast and still reads as code.
27	codeBg string
28	// surface lifts a selected row off the background.
29	surface string
30	// added and removed are the diff row tints.
31	added, removed string
32	// brandFrom and brandTo are the ends of the brand gradient.
33	brandFrom, brandTo string
34	// bg is the terminal's background, which language colors adapt to.
35	bg string
36	// heading, strong, inlineCode, link and math color markdown's marks,
37	// each a palette hue made legible on bg.
38	heading, strong, inlineCode, link, math string
39}
40
41// APCA contrast targets (Lc) for each step: about 60 suits content text,
42// 45 text read at a glance, and 15 to 30 marks that are not read.
43// The code tint is a luminance step from the background, measured as a
44// contrast ratio with the flare term so a black background gets a larger lift
45// than a gray one. Dark themes tint toward the palette's blue, light themes
46// step toward the text, since a blue wash on a light background reads as a
47// marker highlight.
48const (
49	darkCodeBgRatio  = 1.3
50	lightCodeBgRatio = 1.65
51	surfaceRatio     = 1.25
52	diffRatio        = 1.35
53)
54
55const (
56	codeLc      = 64
57	secondaryLc = 45
58	decorLc     = 22
59	busyLc      = 30
60	// headings and strong text stand out from prose; inline code, links
61	// and math step down to code's contrast
62	headingLc = 75
63)
64
65type rgb struct{ r, g, b float64 }
66
67func (c rgb) hex() string {
68	b := func(v float64) int { return int(math.Round(math.Max(0, math.Min(1, v)) * 255)) }
69	return "#" + strconv.FormatInt(int64(1<<24|b(c.r)<<16|b(c.g)<<8|b(c.b)), 16)[1:]
70}
71
72func (c rgb) mix(to rgb, t float64) rgb {
73	return rgb{c.r + (to.r-c.r)*t, c.g + (to.g-c.g)*t, c.b + (to.b-c.b)*t}
74}
75
76// apca is the magnitude of the APCA 0.0.98G lightness contrast of text on bg.
77func apca(text, bg rgb) float64 {
78	y := func(c rgb) float64 {
79		v := 0.2126729*math.Pow(c.r, 2.4) + 0.7151522*math.Pow(c.g, 2.4) + 0.0721750*math.Pow(c.b, 2.4)
80		if v < 0.022 {
81			v += math.Pow(0.022-v, 1.414)
82		}
83		return v
84	}
85	yt, yb := y(text), y(bg)
86	var s float64
87	if yb > yt {
88		s = (math.Pow(yb, 0.56) - math.Pow(yt, 0.57)) * 1.14
89	} else {
90		s = (math.Pow(yb, 0.65) - math.Pow(yt, 0.62)) * 1.14
91	}
92	if math.Abs(s) < 0.1 {
93		return 0
94	}
95	return (math.Abs(s) - 0.027) * 100
96}
97
98// bisect narrows [0, 1] to the boundary where still stops holding, over 24
99// halvings. Both mix searches are monotone in the fraction. It returns the
100// last fraction where still holds and the first where it does not; callers
101// pick the side that meets their target.
102func bisect(still func(t float64) bool) (lo, hi float64) {
103	lo, hi = 0.0, 1.0
104	for range 24 {
105		mid := (lo + hi) / 2
106		if still(mid) {
107			lo = mid
108		} else {
109			hi = mid
110		}
111	}
112	return lo, hi
113}
114
115// toward mixes from into bg until it reaches the target contrast. A color
116// already under the target stays as it is.
117func toward(from, bg rgb, target float64) rgb {
118	if apca(from, bg) <= target {
119		return from
120	}
121	t, _ := bisect(func(t float64) bool { return apca(from.mix(bg, t), bg) > target })
122	return from.mix(bg, t)
123}
124
125// luminance is relative luminance as the WCAG contrast ratio defines it.
126func luminance(c rgb) float64 {
127	lin := func(v float64) float64 {
128		if v <= 0.04045 {
129			return v / 12.92
130		}
131		return math.Pow((v+0.055)/1.055, 2.4)
132	}
133	return 0.2126*lin(c.r) + 0.7152*lin(c.g) + 0.0722*lin(c.b)
134}
135
136// ratio is the WCAG contrast ratio between two colors.
137func ratio(a, b rgb) float64 {
138	la, lb := luminance(a)+0.05, luminance(b)+0.05
139	return max(la, lb) / min(la, lb)
140}
141
142// step mixes bg toward to until the two differ by the given contrast ratio,
143// or reaches to when it cannot.
144func step(bg, to rgb, target float64) rgb {
145	if ratio(bg, to) <= target {
146		return to
147	}
148	// hi is the first fraction that meets the ratio; lo only approaches it.
149	_, t := bisect(func(t float64) bool { return ratio(bg, bg.mix(to, t)) < target })
150	return bg.mix(to, t)
151}
152
153// legible moves c's OKLCH lightness away from bg, up on a dark background
154// and down on a light one, until its APCA contrast reaches target. Hue and
155// chroma stay, so a language keeps its color on every theme.
156func legible(c, bg rgb, target float64) rgb {
157	if apca(c, bg) >= target {
158		return c
159	}
160	l, chroma, hue := colorful.Color{R: c.r, G: c.g, B: c.b}.OkLch()
161	end := 1.0
162	if bgL, _, _ := (colorful.Color{R: bg.r, G: bg.g, B: bg.b}).OkLch(); bgL > 0.6 {
163		end = 0
164	}
165	at := func(t float64) rgb {
166		v := colorful.OkLch(l+(end-l)*t, chroma, hue).Clamped()
167		return rgb{v.R, v.G, v.B}
168	}
169	_, t := bisect(func(t float64) bool { return apca(at(t), bg) < target })
170	return at(t)
171}
172
173// termColors is what the terminal reported. A missing color is nil, and
174// palette holds only the entries that were reported.
175type termColors struct {
176	fg, bg  *rgb
177	palette map[int]rgb
178}
179
180// The palette entries the theme mixes from.
181const (
182	ansiRed     = 1
183	ansiGreen   = 2
184	ansiYellow  = 3
185	ansiBlue    = 4
186	ansiMagenta = 5
187	ansiCyan    = 6
188)
189
190var queriedPalette = []int{ansiRed, ansiGreen, ansiYellow, ansiBlue, ansiMagenta, ansiCyan}
191
192// minTrustLc is the least contrast a reported text color may have with the
193// reported background. Below it the reply describes something other than
194// what is on screen, like a multiplexer that answers with black for colors
195// it does not know, and the palette fallbacks are safer.
196const minTrustLc = 60
197
198func mixInk(c termColors) (ink, bool) {
199	if c.fg == nil || c.bg == nil || apca(*c.fg, *c.bg) < minTrustLc {
200		return ink{}, false
201	}
202	fg, bg := *c.fg, *c.bg
203	out := ink{
204		code:      toward(fg, bg, codeLc).hex(),
205		secondary: toward(fg, bg, secondaryLc).hex(),
206		decor:     toward(fg, bg, decorLc).hex(),
207		surface:   step(bg, fg, surfaceRatio).hex(),
208		bg:        bg.hex(),
209	}
210	blue, hasBlue := c.palette[ansiBlue]
211	codeTarget, codeRatio := fg, darkCodeBgRatio
212	if luminance(bg) > luminance(fg) {
213		codeRatio = lightCodeBgRatio
214	} else if hasBlue {
215		codeTarget = blue
216	}
217	out.codeBg = step(bg, codeTarget, codeRatio).hex()
218	if magenta, ok := c.palette[ansiMagenta]; ok && apca(magenta, bg) >= busyLc {
219		out.busy = toward(magenta, bg, busyLc).hex()
220	}
221	if red, ok := c.palette[ansiRed]; ok {
222		out.removed = step(bg, red, diffRatio).hex()
223	}
224	if green, ok := c.palette[ansiGreen]; ok {
225		out.added = step(bg, green, diffRatio).hex()
226	}
227	// the brand runs from your color toward violet, halfway between the
228	// palette's blue and magenta
229	cyan, hasCyan := c.palette[ansiCyan]
230	magenta, hasMagenta := c.palette[ansiMagenta]
231	if hasCyan && hasBlue && hasMagenta {
232		out.brandFrom, out.brandTo = cyan.hex(), blue.mix(magenta, 0.5).hex()
233	}
234	markdownAccents(&out, c.palette, bg)
235	return out, true
236}
237
238// markdownAccents colors markdown from the palette: headings in the
239// brand's violet, strong text in the yellow pulled toward red, inline code
240// green, links blue and math cyan.
241func markdownAccents(out *ink, palette map[int]rgb, bg rgb) {
242	onBg := func(c rgb, target float64) string { return legible(c, bg, target).hex() }
243	red, hasRed := palette[ansiRed]
244	if yellow, ok := palette[ansiYellow]; ok {
245		if hasRed {
246			yellow = yellow.mix(red, 0.35)
247		}
248		out.strong = onBg(yellow, headingLc)
249	}
250	blue, hasBlue := palette[ansiBlue]
251	if magenta, ok := palette[ansiMagenta]; ok && hasBlue {
252		out.heading = onBg(blue.mix(magenta, 0.5), headingLc)
253	}
254	if hasBlue {
255		out.link = onBg(blue, codeLc)
256	}
257	if green, ok := palette[ansiGreen]; ok {
258		out.inlineCode = onBg(green, codeLc)
259	}
260	if cyan, ok := palette[ansiCyan]; ok {
261		out.math = onBg(cyan, codeLc)
262	}
263}
264
265var oscColor = regexp.MustCompile(`\x1b\](10|11|4;(\d+));rgb:([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})`)
266
267// parseColors reads OSC 10, 11 and 4 replies, whose channels carry one to
268// four hex digits each.
269func parseColors(reply string) termColors {
270	c := termColors{palette: map[int]rgb{}}
271	for _, m := range oscColor.FindAllStringSubmatch(reply, -1) {
272		channel := func(h string) float64 {
273			v, _ := strconv.ParseUint(h, 16, 16)
274			return float64(v) / float64(uint64(1)<<(4*len(h))-1)
275		}
276		col := &rgb{channel(m[3]), channel(m[4]), channel(m[5])}
277		switch m[1] {
278		case "10":
279			c.fg = col
280		case "11":
281			c.bg = col
282		default:
283			index, _ := strconv.Atoi(m[2])
284			c.palette[index] = *col
285		}
286	}
287	return c
288}
289
290var transcriptInk ink
291
292// DetectInk asks the terminal for its colors and mixes the transcript's
293// ramp from them. It must run before the TUI owns the terminal's input.
294func DetectInk() error {
295	reply, err := queryColors()
296	if err != nil {
297		return err
298	}
299	detected, ok := mixInk(parseColors(reply))
300	if path := os.Getenv("ALBEDO_INK_DEBUG"); path != "" {
301		_ = os.WriteFile(path, fmt.Appendf(nil, "reply %q\ntrusted %v\nink %+v\n", reply, ok, detected), 0o644)
302	}
303	if ok {
304		useInk(detected)
305	}
306	return nil
307}