]> git.giacomolli.uk - onechat/blob - terminal_markdown.go
Render Markdown with plain terminal styles
[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]) && len(terminalMarkdownCells(line)) > 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 len(cells) < 2 {
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 := range cells {
223                         for _, cell := range row {
224                                 out = append(out, terminalMarkdownLines(cell, columns, "", false)...)
225                         }
226                 }
227
228                 return out
229         }
230
231         budget := columns - (count-1)*3
232         for {
233                 total, largest := 0, 0
234                 for column, size := range sizes {
235                         total += size
236                         if size > minimum[column] && (sizes[largest] == minimum[largest] || size > sizes[largest]) {
237                                 largest = column
238                         }
239                 }
240
241                 if total <= budget {
242                         break
243                 }
244
245                 sizes[largest]--
246         }
247
248         var out []string
249
250         for row, values := range cells {
251                 wrapped := make([][][]TerminalMarkdownGlyph, count)
252                 height := 1
253                 for column, value := range values {
254                         wrapped[column] = terminalMarkdownWrap(value, sizes[column], false)
255                         height = max(height, len(wrapped[column]))
256                 }
257
258                 for line := range height {
259                         parts := make([]string, count)
260                         for column := range count {
261                                 var glyphs []TerminalMarkdownGlyph
262
263                                 if line < len(wrapped[column]) {
264                                         glyphs = wrapped[column][line]
265                                 }
266
267                                 parts[column] = terminalMarkdownSGR(glyphs) + strings.Repeat(" ", sizes[column]-terminalMarkdownColumns(glyphs))
268                         }
269
270                         out = append(out, strings.Join(parts, " | "))
271                 }
272
273                 if row == 0 {
274                         rules := make([]string, count)
275                         for column, size := range sizes {
276                                 rules[column] = strings.Repeat("-", size)
277                         }
278
279                         out = append(out, "\x1b[2m"+strings.Join(rules, "-+-")+"\x1b[0m")
280                 }
281         }
282
283         return out
284 }
285
286 // terminalMarkdownInline uses the shared marks, so unclosed marks stay as text.
287 // Strikethrough stays as text because the terminal styles exclude it.
288 func terminalMarkdownInline(line string, style int) []TerminalMarkdownGlyph {
289         var out []TerminalMarkdownGlyph
290
291         for index := 0; index < len(line); {
292                 if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(markdownPunctuation, line[index+1]) >= 0 {
293                         out = append(out, terminalMarkdownPlain(line[index+1:index+2], style)...)
294                         index += 2
295
296                         continue
297                 }
298
299                 span, found := markdownMarkSpan(line, index)
300                 if !found || span.kind == "strikethrough" {
301                         end := index + 1
302                         for end < len(line) && !strings.ContainsRune("\\`[*_~", rune(line[end])) {
303                                 end++
304                         }
305
306                         out = append(out, terminalMarkdownPlain(line[index:end], style)...)
307                         index = end
308
309                         continue
310                 }
311
312                 mark := 0
313                 switch span.kind {
314                 case "bold":
315                         mark = terminalMarkdownBold
316                 case "italic":
317                         mark = terminalMarkdownItalic
318                 case "code":
319                         mark = terminalMarkdownReverse
320                 }
321
322                 var content []TerminalMarkdownGlyph
323
324                 if span.kind == "code" {
325                         content = terminalMarkdownPlain(span.content, style|mark)
326                 } else {
327                         content = terminalMarkdownInline(span.content, style|mark)
328                 }
329
330                 out = append(out, content...)
331                 if span.kind == "text_link" && terminalMarkdownGlyphText(content) != span.url {
332                         out = append(out, terminalMarkdownPlain(" ("+span.url+")", style)...)
333                 }
334
335                 index = span.end
336         }
337
338         return out
339 }
340
341 // terminalMarkdownPrefix returns the hanging mark of a quote or a list item.
342 func terminalMarkdownPrefix(line string) (string, string) {
343         trimmed := strings.TrimLeft(line, " ")
344         indent := line[:len(line)-len(trimmed)]
345         if strings.HasPrefix(trimmed, ">") {
346                 return indent + "| ", strings.TrimPrefix(strings.TrimPrefix(trimmed, ">"), " ")
347         }
348
349         if len(trimmed) > 1 && strings.ContainsRune("-*+", rune(trimmed[0])) && trimmed[1] == ' ' {
350                 return indent + "- ", trimmed[2:]
351         }
352
353         digits := 0
354         for digits < len(trimmed) && trimmed[digits] >= '0' && trimmed[digits] <= '9' {
355                 digits++
356         }
357
358         if digits > 0 && digits+1 < len(trimmed) && strings.ContainsRune(".)", rune(trimmed[digits])) && trimmed[digits+1] == ' ' {
359                 return indent + trimmed[:digits+2], trimmed[digits+2:]
360         }
361
362         return "", line
363 }
364
365 // terminalMarkdownWrap splits at spaces when possible and otherwise at glyph
366 // boundaries. It always makes progress, even for a single wide glyph.
367 func terminalMarkdownWrap(glyphs []TerminalMarkdownGlyph, columns int, literal bool) [][]TerminalMarkdownGlyph {
368         var out [][]TerminalMarkdownGlyph
369
370         for len(glyphs) > 0 {
371                 end, used, space := 0, 0, -1
372                 for end < len(glyphs) && (used+glyphs[end].columns <= columns || end == 0) {
373                         if glyphs[end].char == ' ' {
374                                 space = end
375                         }
376
377                         used += glyphs[end].columns
378                         end++
379                 }
380
381                 next := end
382                 if !literal && end < len(glyphs) && glyphs[end].char != ' ' && space > 0 {
383                         end = space
384                         next = space + 1
385                 }
386
387                 out = append(out, glyphs[:end])
388                 glyphs = glyphs[next:]
389                 if !literal {
390                         for len(glyphs) > 0 && glyphs[0].char == ' ' {
391                                 glyphs = glyphs[1:]
392                         }
393                 }
394         }
395
396         if len(out) == 0 {
397                 out = append(out, nil)
398         }
399
400         return out
401 }
402
403 // terminalMarkdownSGR writes only the selected SGR styles and a final reset.
404 func terminalMarkdownSGR(glyphs []TerminalMarkdownGlyph) string {
405         var out strings.Builder
406
407         style := 0
408         for _, glyph := range glyphs {
409                 if glyph.style != style {
410                         if style != 0 {
411                                 out.WriteString("\x1b[0m")
412                         }
413
414                         for bit, code := range []string{"1", "2", "3", "7"} {
415                                 if glyph.style&(1<<bit) != 0 {
416                                         out.WriteString("\x1b[" + code + "m")
417                                 }
418                         }
419
420                         style = glyph.style
421                 }
422
423                 out.WriteRune(glyph.char)
424         }
425
426         if style != 0 {
427                 out.WriteString("\x1b[0m")
428         }
429
430         return out.String()
431 }
432
433 // terminalMarkdownColumns returns the display width without SGR commands.
434 func terminalMarkdownColumns(glyphs []TerminalMarkdownGlyph) int {
435         columns := 0
436         for _, glyph := range glyphs {
437                 columns += glyph.columns
438         }
439
440         return columns
441 }
442
443 // terminalMarkdownGlyphText returns the text without its styles.
444 func terminalMarkdownGlyphText(glyphs []TerminalMarkdownGlyph) string {
445         var out strings.Builder
446
447         for _, glyph := range glyphs {
448                 out.WriteRune(glyph.char)
449         }
450
451         return out.String()
452 }