]> git.giacomolli.uk - onechat/blob - terminal_markdown.go
a13a4e949a2ea3cb2b25681be298617d411dab5f
[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 }