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
10.6 KiB349 linesraw
1package tui
2
3import (
4	"cmp"
5	"crypto/sha256"
6	"fmt"
7	"regexp"
8	"strings"
9	"sync"
10
11	"charm.land/glamour/v2"
12	"charm.land/glamour/v2/ansi"
13	"charm.land/glamour/v2/styles"
14	"github.com/alecthomas/chroma/v2"
15	"github.com/alecthomas/chroma/v2/formatters"
16	chromastyles "github.com/alecthomas/chroma/v2/styles"
17)
18
19// HighlightCode colors one line of code the way a reply's code blocks are
20// colored: the lexer chroma matched to the file, in the theme built from the
21// terminal's palette. A line no lexer claims stays as it is.
22func HighlightCode(line string, lexer chroma.Lexer) string {
23	if lexer == nil {
24		return line
25	}
26	tokens, err := chroma.Coalesce(lexer).Tokenise(nil, line)
27	if err != nil {
28		return line
29	}
30	var b strings.Builder
31	if formatters.TTY256.Format(&b, chromastyles.Get(codeTheme(transcriptInk)), tokens) != nil {
32		return line
33	}
34	// some lexers end every input with a newline, which would wrap a row
35	return strings.ReplaceAll(b.String(), "\n", "")
36}
37
38var markdownThemes sync.Mutex
39
40// fenceOpen and fenceClose mark the rows that frame a code block.
41const (
42	fenceOpen  = "┌─"
43	fenceClose = "└"
44)
45
46// codeFence is a code block's frame row in Decor, and quoteBar leads each
47// quoted row. Both are decoration, which a copy does not carry.
48func codeFence(mark string) string { return decorInk() + mark + ansiReset }
49func quoteBar() string             { return decorInk() + "│ " + ansiReset }
50
51// Chroma themes are registered by name; key them by the detected terminal
52// palette so a theme change cannot reuse the previous palette's colors.
53func codeTheme(colors ink) string {
54	if colors.code == "" {
55		return "monokai"
56	}
57	palette := colors.code + colors.secondary + colors.brandFrom + colors.brandTo
58	hash := sha256.Sum256([]byte(palette))
59	name := fmt.Sprintf("albedo-%x", hash[:6])
60	markdownThemes.Lock()
61	defer markdownThemes.Unlock()
62	if chromastyles.Registry[name] == nil {
63		chromastyles.Register(chroma.MustNewStyle(name, chroma.StyleEntries{
64			chroma.Text:          colors.code,
65			chroma.Comment:       colors.secondary + " italic",
66			chroma.Keyword:       colors.brandFrom,
67			chroma.NameFunction:  colors.brandTo,
68			chroma.LiteralString: colors.brandTo,
69			chroma.LiteralNumber: colors.brandFrom,
70		}))
71	}
72	return name
73}
74
75// accent is a markdown accent color, or the palette slot when the terminal
76// did not report its colors.
77func accent(hex, slot string) *string { return new(cmp.Or(hex, slot)) }
78
79func markdownStyle() ansi.StyleConfig {
80	style := styles.NoTTYStyleConfig
81	zero, bold := uint(0), true
82	style.Document.Margin = &zero
83	style.Document.BlockPrefix, style.Document.BlockSuffix = "", ""
84	style.Heading.BlockSuffix = ""
85	for _, heading := range []*ansi.StyleBlock{&style.H1, &style.H2, &style.H3, &style.H4, &style.H5, &style.H6} {
86		heading.Prefix = ""
87		heading.Bold = &bold
88		heading.Color = accent(transcriptInk.heading, "5")
89	}
90	style.Strong = ansi.StylePrimitive{Bold: &bold, Color: accent(transcriptInk.strong, "3")}
91	style.Emph = ansi.StylePrimitive{Italic: &bold}
92	style.Code = ansi.StyleBlock{StylePrimitive: ansi.StylePrimitive{Color: accent(transcriptInk.inlineCode, "2")}}
93	style.LinkText = ansi.StylePrimitive{Color: accent(transcriptInk.link, "4")}
94	style.List.LevelIndent = 0
95	style.Table.Margin = &zero
96	style.BlockQuote.IndentToken = new(quoteBar())
97	style.CodeBlock.Margin = &zero
98	style.CodeBlock.BlockPrefix = codeFence(fenceOpen) + "\n"
99	style.CodeBlock.BlockSuffix = codeFence(fenceClose)
100	style.HorizontalRule.Format = "\n────────────\n"
101	if transcriptInk.decor != "" {
102		style.HorizontalRule.Color = &transcriptInk.decor
103	}
104	style.CodeBlock.Theme = codeTheme(transcriptInk)
105	return style
106}
107
108// RenderMarkdownAnsi renders text block by block. Glamour spaces a block by
109// the one before it and no further, so each block renders once.
110func RenderMarkdownAnsi(text string, width int) string {
111	return renderBlocks(text, cmp.Or(max(0, width), 80), true)
112}
113
114// renderGrowing is RenderMarkdownAnsi for a reply streaming in: its last
115// block is still growing, so it is rendered again each time and not kept.
116func renderGrowing(text string, width int) string {
117	return renderBlocks(text, cmp.Or(max(0, width), 80), false)
118}
119
120// renderCopyable renders text to width with its wrapped rows marked, so a
121// copy can join them back into the lines they were.
122func renderCopyable(text string, width int) string {
123	return markWraps(RenderMarkdownAnsi(text, width), renderBlocks(text, 0, true))
124}
125
126// renderBlocks is text rendered at width, or unwrapped at 0. Unless whole,
127// its last block may still grow and is not cached.
128func renderBlocks(text string, width int, whole bool) string {
129	blocks := markdownBlocks(text)
130	var b strings.Builder
131	for i, block := range blocks {
132		prev := ""
133		if i > 0 {
134			prev = blocks[i-1]
135		}
136		piece, ok := markdownPiece(prev, block, width, whole || i < len(blocks)-1)
137		if !ok {
138			return strings.Trim(renderMarkdown(text, width), " \n")
139		}
140		b.WriteString(piece)
141	}
142	return strings.Trim(b.String(), " \n")
143}
144
145// markdownPiece is what block adds to the rendering of prev, or false when
146// rendering prev on its own does not begin the rendering of both. Only a
147// finished block is kept: the last one may still be growing.
148func markdownPiece(prev, block string, width int, finished bool) (string, bool) {
149	key := pieceKey{width: width, prev: prev, block: block}
150	if piece, ok := pieces.get(key); ok {
151		return piece, true
152	}
153	whole, head := renderMarkdown(prev+block, width), ""
154	if prev != "" {
155		head, _ = markdownPiece("", prev, width, true)
156	}
157	if !strings.HasPrefix(whole, head) {
158		return "", false
159	}
160	piece := whole[len(head):]
161	if finished {
162		pieces.put(key, piece)
163	}
164	return piece, true
165}
166
167// rendererKey is what a glamour renderer is built for.
168type rendererKey struct {
169	ink   ink
170	width int
171}
172
173// renderers keeps the glamour renderer for the widths in use: building one
174// costs more than most renders it does. A resize adds a width, so the map is
175// emptied once it holds a handful.
176var renderers = struct {
177	sync.Mutex
178	byKey map[rendererKey]*glamour.TermRenderer
179}{byKey: map[rendererKey]*glamour.TermRenderer{}}
180
181func markdownRenderer(width int) (*glamour.TermRenderer, error) {
182	key := rendererKey{ink: transcriptInk, width: width}
183	if renderer, ok := renderers.byKey[key]; ok {
184		return renderer, nil
185	}
186	renderer, err := glamour.NewTermRenderer(
187		glamour.WithStyles(markdownStyle()), glamour.WithWordWrap(width), glamour.WithPreservedNewLines(),
188		mathOptions(accent(transcriptInk.math, "6")),
189	)
190	if err != nil {
191		return nil, err
192	}
193	if len(renderers.byKey) >= 4 {
194		clear(renderers.byKey)
195	}
196	renderers.byKey[key] = renderer
197	return renderer, nil
198}
199
200func renderMarkdown(text string, width int) string {
201	renderers.Lock()
202	defer renderers.Unlock()
203	renderer, err := markdownRenderer(width)
204	if err != nil {
205		return text
206	}
207	markdownThemes.Lock()
208	defer markdownThemes.Unlock()
209	rendered, err := renderer.Render(text)
210	if err != nil {
211		return text
212	}
213	return rendered
214}
215
216var (
217	orderedItem = regexp.MustCompile(`^\d{1,9}[.)](\s|$)`)
218	// Link definitions and raw HTML blocks can reach across blank lines.
219	spansBlocks = regexp.MustCompile(`(?i)^ {0,3}(\[[^\]]+\]:|<(pre|script|style|textarea)\b|<!--)`)
220)
221
222// markdownBlocks splits text before every line that has to open a new
223// top-level block: one at the margin after a blank line, outside a fence,
224// that cannot continue a list or quote above it. Text that can reach across
225// blank lines outside a fence keeps it whole.
226func markdownBlocks(text string) []string {
227	var blocks []string
228	start, fence, blank := 0, "", false
229	for pos := 0; pos < len(text); {
230		i := strings.IndexByte(text[pos:], '\n')
231		end := len(text)
232		if i >= 0 {
233			end = pos + i + 1
234		}
235		line := strings.TrimRight(text[pos:end], "\r\n")
236		if fence == "" && spansBlocks.MatchString(line) {
237			return []string{text}
238		}
239		if fence == "" && blank && pos > start && opensBlock(line) {
240			blocks = append(blocks, text[start:pos])
241			start = pos
242		}
243		fence = fenceAfter(fence, line)
244		blank = strings.TrimSpace(line) == ""
245		pos = end
246	}
247	return append(blocks, text[start:])
248}
249
250func opensBlock(line string) bool {
251	return line != "" && strings.IndexByte(" \t-*+>|", line[0]) < 0 && !orderedItem.MatchString(line)
252}
253
254// fenceAfter is the code fence or display math open after line, given the
255// one open before. Display math is kept by its closer, $$ or \].
256func fenceAfter(open, line string) string {
257	if open == "$$" || open == `\]` {
258		if strings.Contains(line, open) {
259			return ""
260		}
261		return open
262	}
263	trimmed := strings.TrimLeft(line, " ")
264	if len(line)-len(trimmed) > 3 || trimmed == "" {
265		return open
266	}
267	if d, _, closed, ok := openDisplayMath([]byte(trimmed)); open == "" && ok && !closed {
268		return d.close
269	}
270	if trimmed[0] != '`' && trimmed[0] != '~' {
271		return open
272	}
273	run := trimmed[:len(trimmed)-len(strings.TrimLeft(trimmed, trimmed[:1]))]
274	switch {
275	case len(run) < 3:
276		return open
277	case open == "":
278		return run
279	case run[0] == open[0] && len(run) >= len(open) && strings.TrimSpace(trimmed[len(run):]) == "":
280		return ""
281	}
282	return open
283}
284
285type pieceKey struct {
286	prev, block string
287	width       int
288}
289
290// pieceCache keeps rendered pieces in two generations: a hit moves a piece
291// into the fresh one, and the stale one is dropped once the fresh one fills,
292// so the pieces the transcript still shows stay cached. Each generation holds
293// at most maxPieceBytes of key and rendered text, excluding map overhead.
294type pieceCache struct {
295	fresh, stale map[pieceKey]string
296	ink          ink
297	bytes        int
298	mu           sync.Mutex
299}
300
301const maxPieceBytes = 1 << 20
302
303var pieces pieceCache
304
305func (c *pieceCache) get(key pieceKey) (string, bool) {
306	c.mu.Lock()
307	defer c.mu.Unlock()
308	if c.ink != transcriptInk {
309		c.ink, c.fresh, c.stale, c.bytes = transcriptInk, nil, nil, 0
310	}
311	if piece, ok := c.fresh[key]; ok {
312		return piece, true
313	}
314	piece, ok := c.stale[key]
315	if ok {
316		c.store(key, piece)
317	}
318	return piece, ok
319}
320
321// put keeps copies: the key and piece are slices of one moment's stream and
322// its rendering, and would hold all of it.
323func (c *pieceCache) put(key pieceKey, piece string) {
324	if len(key.prev)+len(key.block)+len(piece) > maxPieceBytes {
325		return
326	}
327	c.mu.Lock()
328	defer c.mu.Unlock()
329	if _, exists := c.fresh[key]; exists {
330		return
331	}
332	key.prev, key.block = strings.Clone(key.prev), strings.Clone(key.block)
333	c.store(key, strings.Clone(piece))
334}
335
336func (c *pieceCache) store(key pieceKey, piece string) {
337	if _, exists := c.fresh[key]; exists {
338		return
339	}
340	size := len(key.prev) + len(key.block) + len(piece)
341	if size > maxPieceBytes {
342		return
343	}
344	if c.fresh == nil || size > maxPieceBytes-c.bytes {
345		c.stale, c.fresh, c.bytes = c.fresh, map[pieceKey]string{}, 0
346	}
347	c.fresh[key] = piece
348	c.bytes += size
349}