]> git.giacomolli.uk - onechat/blob - terminal_markdown.go
Back up the daemon and restore its data
[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 }