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
7.4 KiB239 linesraw
1package tui
2
3import (
4	"bytes"
5	"io"
6	"strings"
7	"unicode"
8	"unicode/utf8"
9
10	"charm.land/glamour/v2"
11	"charm.land/glamour/v2/ansi"
12	"github.com/doug/termtex"
13	"github.com/yuin/goldmark"
14	"github.com/yuin/goldmark/ast"
15	"github.com/yuin/goldmark/parser"
16	"github.com/yuin/goldmark/text"
17	"github.com/yuin/goldmark/util"
18)
19
20// Math in a reply: $…$ and \(…\) inline, $$…$$ and \[…\] on lines of their
21// own as display math. termtex lays both out in Unicode.
22var (
23	kindInlineMath  = ast.NewNodeKind("InlineMath")
24	kindDisplayMath = ast.NewNodeKind("DisplayMath")
25)
26
27type inlineMath struct {
28	ast.BaseInline
29	tex, source string
30}
31
32func (n *inlineMath) Kind() ast.NodeKind            { return kindInlineMath }
33func (n *inlineMath) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
34
35// displayMath is closed once its closing delimiter arrives; a reply still
36// streaming may not have sent it yet.
37type displayMath struct {
38	ast.BaseBlock
39	delimiter
40	tex    []byte
41	closed bool
42}
43
44func (n *displayMath) Kind() ast.NodeKind            { return kindDisplayMath }
45func (n *displayMath) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
46
47// source is the math as written, with the closer once it arrived.
48func (n *displayMath) source() string {
49	written := n.open + string(n.tex)
50	if n.closed {
51		written += n.close
52	}
53	return strings.TrimRight(written, "\n")
54}
55
56type mathExtension struct{}
57
58func (mathExtension) Extend(m goldmark.Markdown) {
59	m.Parser().AddOptions(
60		parser.WithBlockParsers(util.Prioritized(displayMathParser{}, 650)),
61		parser.WithInlineParsers(util.Prioritized(inlineMathParser{}, 150)),
62	)
63}
64
65// mathOptions add the math parsers and render their nodes in color.
66func mathOptions(color *string) glamour.TermRendererOption {
67	return glamour.WithOptions(
68		glamour.WithExtensions(mathExtension{}),
69		glamour.WithElement(kindInlineMath, func(node ast.Node, _ []byte, _ ansi.RenderContext) ansi.Element {
70			return ansi.Element{Renderer: inlineMathElement{node.(*inlineMath), color}}
71		}),
72		glamour.WithElement(kindDisplayMath, func(node ast.Node, _ []byte, _ ansi.RenderContext) ansi.Element {
73			return ansi.Element{Entering: "\n", Exiting: "\n", Renderer: displayMathElement{node.(*displayMath), color}}
74		}),
75	)
76}
77
78type inlineMathElement struct {
79	node  *inlineMath
80	color *string
81}
82
83func (e inlineMathElement) Render(w io.Writer, ctx ansi.RenderContext) error {
84	return e.StyleOverrideRender(w, ctx, ansi.StylePrimitive{})
85}
86
87// StyleOverrideRender keeps the math color inside bold or a link. Math that
88// needs more than one row, such as a matrix, stays as its source.
89func (e inlineMathElement) StyleOverrideRender(w io.Writer, ctx ansi.RenderContext, outer ansi.StylePrimitive) error {
90	outer.Color = e.color
91	laid, err := termtex.Render(e.node.tex, termtex.Style{Inline: true})
92	if err != nil || strings.Contains(laid, "\n") {
93		laid = e.node.source
94	}
95	return (&ansi.BaseElement{Token: laid, Style: outer, Verbatim: true}).Render(w, ctx)
96}
97
98type displayMathElement struct {
99	node  *displayMath
100	color *string
101}
102
103// Render lays the math out to the block's width. Until it closes, or when
104// termtex cannot read it, the source shows instead.
105func (e displayMathElement) Render(w io.Writer, ctx ansi.RenderContext) error {
106	laid := e.node.source()
107	if e.node.closed {
108		if out, err := termtex.Render(strings.TrimSpace(string(e.node.tex)), termtex.Style{Width: ctx.Width()}); err == nil {
109			laid = out
110		}
111	}
112	return (&ansi.BaseElement{Token: laid, Style: ansi.StylePrimitive{Color: e.color}, Verbatim: true}).Render(w, ctx)
113}
114
115// delimiter is one way to write math. Only display delimiters may open a
116// block of their own.
117type delimiter struct {
118	open, close string
119	display     bool
120}
121
122// delimiters are tried in order, so $$ is found before $.
123var delimiters = []delimiter{
124	{open: "$$", close: "$$", display: true},
125	{open: `\[`, close: `\]`, display: true},
126	{open: `\(`, close: `\)`},
127	{open: "$", close: "$"},
128}
129
130// delimiterAt is the delimiter that opens math at the start of line.
131func delimiterAt(line []byte) (delimiter, bool) {
132	for _, d := range delimiters {
133		if bytes.HasPrefix(line, []byte(d.open)) {
134			return d, true
135		}
136	}
137	return delimiter{}, false
138}
139
140// openDisplayMath reads a line that opens display math: its delimiter, the
141// tex after the opener, and whether the line closes it too, in which case
142// nothing may follow the closer.
143func openDisplayMath(line []byte) (d delimiter, tex []byte, closed, ok bool) {
144	d, ok = delimiterAt(line)
145	if !ok || !d.display {
146		return d, nil, false, false
147	}
148	tex, after, closed := bytes.Cut(line[len(d.open):], []byte(d.close))
149	if closed && !util.IsBlank(after) {
150		return d, nil, false, false
151	}
152	return d, tex, closed, true
153}
154
155type displayMathParser struct{}
156
157func (displayMathParser) Trigger() []byte { return []byte{'$', '\\'} }
158
159func (displayMathParser) Open(_ ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
160	line, _ := reader.PeekLine()
161	pos := pc.BlockOffset()
162	if pos < 0 {
163		return nil, parser.NoChildren
164	}
165	d, tex, closed, ok := openDisplayMath(line[pos:])
166	if !ok {
167		return nil, parser.NoChildren
168	}
169	reader.AdvanceToEOL()
170	return &displayMath{delimiter: d, tex: bytes.Clone(tex), closed: closed}, parser.NoChildren
171}
172
173func (displayMathParser) Continue(node ast.Node, reader text.Reader, _ parser.Context) parser.State {
174	n := node.(*displayMath)
175	if n.closed {
176		return parser.Close
177	}
178	line, _ := reader.PeekLine()
179	tex, _, closed := bytes.Cut(line, []byte(n.close))
180	n.tex, n.closed = append(n.tex, tex...), closed
181	reader.AdvanceToEOL()
182	if closed {
183		return parser.Close
184	}
185	return parser.Continue | parser.NoChildren
186}
187
188func (displayMathParser) Close(ast.Node, text.Reader, parser.Context) {}
189func (displayMathParser) CanInterruptParagraph() bool                 { return true }
190func (displayMathParser) CanAcceptIndentedLine() bool                 { return false }
191
192type inlineMathParser struct{}
193
194func (inlineMathParser) Trigger() []byte { return []byte{'$', '\\'} }
195
196func (inlineMathParser) Parse(_ ast.Node, block text.Reader, _ parser.Context) ast.Node {
197	line, _ := block.PeekLine()
198	tex, length := scanInlineMath(line, block.PrecendingCharacter())
199	if length == 0 {
200		return nil
201	}
202	block.Advance(length)
203	return &inlineMath{tex: tex, source: string(line[:length])}
204}
205
206// scanInlineMath is the math at the start of line and its length in bytes,
207// or 0 when line does not start any. A lone $ follows Pandoc and goes
208// further, so prices and shell variables stay text: it opens before a
209// non-space that follows no word character, and closes after a non-space
210// before anything but a word character.
211func scanInlineMath(line []byte, before rune) (string, int) {
212	d, ok := delimiterAt(line)
213	lone := d.open == "$"
214	if !ok || lone && (wordRune(before) || len(line) < 2 || util.IsSpace(line[1])) {
215		return "", 0
216	}
217	body, _, _ := bytes.Cut(line, []byte{'\n'})
218	for i := len(d.open); i < len(body); i++ {
219		if d.close[0] == '$' && body[i] == '\\' {
220			i++ // an escaped character never closes
221			continue
222		}
223		if !bytes.HasPrefix(body[i:], []byte(d.close)) {
224			continue
225		}
226		end := i + len(d.close)
227		if after, _ := utf8.DecodeRune(body[end:]); lone && (util.IsSpace(body[i-1]) || wordRune(after)) {
228			continue
229		}
230		tex := bytes.TrimSpace(body[len(d.open):i])
231		if len(tex) == 0 {
232			return "", 0
233		}
234		return string(tex), end
235	}
236	return "", 0
237}
238
239func wordRune(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' }