]> git.giacomolli.uk - onechat/blob - terminal_markdown.go
Run each child with its own files and loop
[onechat] / terminal_markdown.go
1 package main
2
3 import (
4 "strings"
5 "unicode"
6
7 "golang.org/x/text/width"
8 )
9
10 const (
11 terminalMarkdownBold = 1 << iota
12 terminalMarkdownDim
13 terminalMarkdownItalic
14 terminalMarkdownReverse
15 )
16
17 // TerminalMarkdownGlyph holds a character, its display width, and its style.
18 type TerminalMarkdownGlyph struct {
19 char rune
20 columns int
21 style int
22 }
23
24 // terminalMarkdown renders Markdown as lines with terminal styles. Width is in
25 // display columns, with a default of 80. Styles end at each line boundary.
26 func terminalMarkdown(text string, columns int) []string {
27 if columns <= 0 {
28 columns = 80
29 }
30
31 lines := strings.Split(text, "\n")
32
33 var out []string
34
35 for index := 0; index < len(lines); index++ {
36 line := lines[index]
37 _, fence := markdownCodeFence(line)
38 end := markdownClosingFence(lines, index, fence)
39 heading := markdownHeadingText(line)
40 switch {
41 case end > index:
42 for _, code := range lines[index+1 : end] {
43 out = append(out, terminalMarkdownLines(terminalMarkdownPlain(code, terminalMarkdownDim), columns, " ", true)...)
44 }
45
46 index = end
47 case strings.TrimSpace(line) == "---":
48 out = append(out, "\x1b[2m"+strings.Repeat("-", columns)+"\x1b[0m")
49 case index+1 < len(lines) && terminalMarkdownSeparator(lines[index+1]):
50 rows := [][]string{terminalMarkdownCells(line)}
51 index += 2
52 for index < len(lines) && strings.Contains(lines[index], "|") && strings.TrimSpace(lines[index]) != "" {
53 rows = append(rows, terminalMarkdownCells(lines[index]))
54 index++
55 }
56
57 index--
58 out = append(out, terminalMarkdownTable(rows, columns)...)
59 case heading != "":
60 out = append(out, terminalMarkdownLines(terminalMarkdownInline(heading, terminalMarkdownBold), columns, "", false)...)
61 default:
62 prefix, content := terminalMarkdownPrefix(line)
63 out = append(out, terminalMarkdownLines(terminalMarkdownInline(content, 0), columns, prefix, false)...)
64 }
65 }
66
67 return out
68 }
69
70 // terminalMarkdownLines wraps glyphs with a hanging prefix. Literal lines keep
71 // their spaces. When a prefix leaves no text column, a list mark gets its own
72 // line and a quote uses a bar without a space or indent. A quote bar and one
73 // glyph, or a complete list mark, can exceed a narrower width rather than lose
74 // text. A wide character stays whole.
75 func terminalMarkdownLines(glyphs []TerminalMarkdownGlyph, columns int, prefix string, literal bool) []string {
76 var out []string
77
78 if len(prefix) >= columns {
79 switch {
80 case literal:
81 prefix = ""
82 case strings.Contains(prefix, "|"):
83 prefix = "|"
84 default:
85 out = append(out, strings.TrimSpace(prefix))
86 prefix = ""
87 }
88 }
89
90 available := max(1, columns-len(prefix))
91
92 parts := terminalMarkdownWrap(glyphs, available, literal)
93
94 for index, part := range parts {
95 mark := prefix
96 if index > 0 && !literal && !strings.Contains(prefix, "|") {
97 mark = strings.Repeat(" ", len(prefix))
98 }
99
100 out = append(out, mark+terminalMarkdownSGR(part))
101 }
102
103 return out
104 }
105
106 // terminalMarkdownPlain makes display glyphs without reading inline marks.
107 // Control characters cannot insert terminal commands. Combining marks take no
108 // column and East Asian wide and fullwidth characters take two.
109 func terminalMarkdownPlain(text string, style int) []TerminalMarkdownGlyph {
110 var glyphs []TerminalMarkdownGlyph
111
112 for _, char := range strings.ReplaceAll(text, "\t", " ") {
113 if unicode.IsControl(char) {
114 continue
115 }
116
117 columns := 1
118 switch {
119 case unicode.Is(unicode.Mn, char) || unicode.Is(unicode.Me, char) || char == '\u200d':
120 columns = 0
121 case width.LookupRune(char).Kind() == width.EastAsianWide || width.LookupRune(char).Kind() == width.EastAsianFullwidth:
122 columns = 2
123 }
124
125 glyphs = append(glyphs, TerminalMarkdownGlyph{
126 char: char,
127 columns: columns,
128 style: style,
129 })
130 }
131
132 return glyphs
133 }
134
135 // terminalMarkdownSeparator tells if a line is a Markdown table separator.
136 func terminalMarkdownSeparator(line string) bool {
137 cells := terminalMarkdownCells(line)
138 if !strings.Contains(line, "|") {
139 return false
140 }
141
142 for _, cell := range cells {
143 cell = strings.Trim(cell, ":")
144 if len(cell) < 3 || strings.Trim(cell, "-") != "" {
145 return false
146 }
147 }
148
149 return true
150 }
151
152 // terminalMarkdownCells splits a row outside code spans and escaped pipes.
153 func terminalMarkdownCells(line string) []string {
154 line = strings.TrimSpace(line)
155 line = strings.TrimPrefix(line, "|")
156 if strings.HasSuffix(line, "|") && !strings.HasSuffix(line, "\\|") {
157 line = strings.TrimSuffix(line, "|")
158 }
159
160 var cells []string
161
162 start := 0
163 for index := 0; index < len(line); index++ {
164 switch line[index] {
165 case '\\':
166 index++
167 case '`':
168 span, found := markdownCodeSpan(line, index)
169 if found {
170 index = span.end - 1
171 }
172 case '|':
173 cells = append(cells, strings.TrimSpace(line[start:index]))
174 start = index + 1
175 }
176 }
177
178 return append(cells, strings.TrimSpace(line[start:]))
179 }
180
181 // terminalMarkdownTable fits columns to the available width and wraps cells.
182 // When the separators and one glyph per column cannot fit, cells stack in row
183 // order.
184 func terminalMarkdownTable(rows [][]string, columns int) []string {
185 count := len(rows[0])
186
187 sizes := make([]int, count)
188
189 minimum := make([]int, count)
190 cells := make([][][]TerminalMarkdownGlyph, len(rows))
191 for row, values := range rows {
192 cells[row] = make([][]TerminalMarkdownGlyph, count)
193 for column := range count {
194 value := ""
195 if column < len(values) {
196 value = values[column]
197 }
198
199 style := 0
200 if row == 0 {
201 style = terminalMarkdownBold
202 }
203
204 cells[row][column] = terminalMarkdownInline(value, style)
205
206 sizes[column] = max(sizes[column], terminalMarkdownColumns(cells[row][column]), 1)
207 minimum[column] = max(minimum[column], 1)
208 for _, glyph := range cells[row][column] {
209 minimum[column] = max(minimum[column], glyph.columns)
210 }
211 }
212 }
213
214 required := (count - 1) * 3
215 for _, size := range minimum {
216 required += size
217 }
218
219 if required > columns {
220 var out []string
221
222 for row, values := range cells {
223 for _, cell := range values {
224 out = append(out, terminalMarkdownLines(cell, columns, "", false)...)
225 }
226
227 if row == 0 {
228 out = append(out, "\x1b[2m"+strings.Repeat("-", columns)+"\x1b[0m")
229 }
230 }
231
232 return out
233 }
234
235 budget := columns - (count-1)*3
236 for {
237 total, largest := 0, 0
238 for column, size := range sizes {
239 total += size
240 if size > minimum[column] && (sizes[largest] == minimum[largest] || size > sizes[largest]) {
241 largest = column
242 }
243 }
244
245 if total <= budget {
246 break
247 }
248
249 sizes[largest]--
250 }
251
252 var out []string
253
254 for row, values := range cells {
255 wrapped := make([][][]TerminalMarkdownGlyph, count)
256 height := 1
257 for column, value := range values {
258 wrapped[column] = terminalMarkdownWrap(value, sizes[column], false)
259 height = max(height, len(wrapped[column]))
260 }
261
262 for line := range height {
263 parts := make([]string, count)
264 for column := range count {
265 var glyphs []TerminalMarkdownGlyph
266
267 if line < len(wrapped[column]) {
268 glyphs = wrapped[column][line]
269 }
270
271 parts[column] = terminalMarkdownSGR(glyphs) + strings.Repeat(" ", sizes[column]-terminalMarkdownColumns(glyphs))
272 }
273
274 out = append(out, strings.Join(parts, " | "))
275 }
276
277 if row == 0 {
278 rules := make([]string, count)
279 for column, size := range sizes {
280 rules[column] = strings.Repeat("-", size)
281 }
282
283 out = append(out, "\x1b[2m"+strings.Join(rules, "-+-")+"\x1b[0m")
284 }
285 }
286
287 return out
288 }
289
290 // terminalMarkdownInline uses the shared marks, so unclosed marks stay as text.
291 // Strikethrough stays as text because the terminal styles exclude it.
292 func terminalMarkdownInline(line string, style int) []TerminalMarkdownGlyph {
293 var out []TerminalMarkdownGlyph
294
295 for index := 0; index < len(line); {
296 if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(markdownPunctuation, line[index+1]) >= 0 {
297 out = append(out, terminalMarkdownPlain(line[index+1:index+2], style)...)
298 index += 2
299
300 continue
301 }
302
303 span, found := markdownMarkSpan(line, index)
304 if !found || span.kind == "strikethrough" {
305 end := index + 1
306 for end < len(line) && !strings.ContainsRune("\\`[*_~", rune(line[end])) {
307 end++
308 }
309
310 out = append(out, terminalMarkdownPlain(line[index:end], style)...)
311 index = end
312
313 continue
314 }
315
316 mark := 0
317 switch span.kind {
318 case "bold":
319 mark = terminalMarkdownBold
320 case "italic":
321 mark = terminalMarkdownItalic
322 case "code":
323 mark = terminalMarkdownReverse
324 }
325
326 var content []TerminalMarkdownGlyph
327
328 if span.kind == "code" {
329 content = terminalMarkdownPlain(span.content, style|mark)
330 } else {
331 content = terminalMarkdownInline(span.content, style|mark)
332 }
333
334 out = append(out, content...)
335 if span.kind == "text_link" && terminalMarkdownGlyphText(content) != span.url {
336 out = append(out, terminalMarkdownPlain(" ("+span.url+")", style)...)
337 }
338
339 index = span.end
340 }
341
342 return out
343 }
344
345 // terminalMarkdownPrefix returns the hanging mark of a quote or a list item.
346 func terminalMarkdownPrefix(line string) (string, string) {
347 trimmed := strings.TrimLeft(line, " ")
348 indent := line[:len(line)-len(trimmed)]
349 if strings.HasPrefix(trimmed, ">") {
350 return indent + "| ", strings.TrimPrefix(strings.TrimPrefix(trimmed, ">"), " ")
351 }
352
353 if len(trimmed) > 1 && strings.ContainsRune("-*+", rune(trimmed[0])) && trimmed[1] == ' ' {
354 return indent + "- ", trimmed[2:]
355 }
356
357 digits := 0
358 for digits < len(trimmed) && trimmed[digits] >= '0' && trimmed[digits] <= '9' {
359 digits++
360 }
361
362 if digits > 0 && digits+1 < len(trimmed) && strings.ContainsRune(".)", rune(trimmed[digits])) && trimmed[digits+1] == ' ' {
363 return indent + trimmed[:digits+2], trimmed[digits+2:]
364 }
365
366 return "", line
367 }
368
369 // terminalMarkdownWrap splits at spaces when possible and otherwise at glyph
370 // boundaries. It always makes progress, even for a single wide glyph.
371 func terminalMarkdownWrap(glyphs []TerminalMarkdownGlyph, columns int, literal bool) [][]TerminalMarkdownGlyph {
372 var out [][]TerminalMarkdownGlyph
373
374 for len(glyphs) > 0 {
375 end, used, space := 0, 0, -1
376 for end < len(glyphs) && (used+glyphs[end].columns <= columns || end == 0) {
377 if glyphs[end].char == ' ' {
378 space = end
379 }
380
381 used += glyphs[end].columns
382 end++
383 }
384
385 next := end
386 if !literal && end < len(glyphs) && glyphs[end].char != ' ' && space > 0 {
387 end = space
388 next = space + 1
389 }
390
391 out = append(out, glyphs[:end])
392 glyphs = glyphs[next:]
393 if !literal {
394 for len(glyphs) > 0 && glyphs[0].char == ' ' {
395 glyphs = glyphs[1:]
396 }
397 }
398 }
399
400 if len(out) == 0 {
401 out = append(out, nil)
402 }
403
404 return out
405 }
406
407 // terminalMarkdownSGR writes only the selected SGR styles and a final reset.
408 func terminalMarkdownSGR(glyphs []TerminalMarkdownGlyph) string {
409 var out strings.Builder
410
411 style := 0
412 for _, glyph := range glyphs {
413 if glyph.style != style {
414 if style != 0 {
415 out.WriteString("\x1b[0m")
416 }
417
418 for bit, code := range []string{"1", "2", "3", "7"} {
419 if glyph.style&(1<<bit) != 0 {
420 out.WriteString("\x1b[" + code + "m")
421 }
422 }
423
424 style = glyph.style
425 }
426
427 out.WriteRune(glyph.char)
428 }
429
430 if style != 0 {
431 out.WriteString("\x1b[0m")
432 }
433
434 return out.String()
435 }
436
437 // terminalMarkdownColumns returns the display width without SGR commands.
438 func terminalMarkdownColumns(glyphs []TerminalMarkdownGlyph) int {
439 columns := 0
440 for _, glyph := range glyphs {
441 columns += glyph.columns
442 }
443
444 return columns
445 }
446
447 // terminalMarkdownGlyphText returns the text without its styles.
448 func terminalMarkdownGlyphText(glyphs []TerminalMarkdownGlyph) string {
449 var out strings.Builder
450
451 for _, glyph := range glyphs {
452 out.WriteRune(glyph.char)
453 }
454
455 return out.String()
456 }