internal/termtext/termtext.go
210 lines · 4931 bytes
1// Package termtext renders markdown and org to text for a terminal:
2// wrapped to a width, links reduced to their text, code highlighted
3// with 16 colours. Width 0 is plain: no wrapping and no SGR, for
4// piped output.
5package termtext
6
7import (
8 "bytes"
9 "strings"
10 "unicode"
11 "unicode/utf8"
12
13 "github.com/alecthomas/chroma/v2/quick"
14 "golang.org/x/text/width"
15)
16
17type Options struct {
18 Width int
19 Color bool
20 Base string
21}
22
23func Render(src, format string, o Options) string {
24 if format == "org" {
25 return Org(src, o)
26 }
27 return Markdown(src, o)
28}
29
30const (
31 sgrReset = "\x1b[0m"
32 sgrBold = "\x1b[1m"
33 sgrDim = "\x1b[2m"
34 sgrUnderline = "\x1b[4m"
35)
36
37// out collects rendered lines. Every block goes through it so the
38// prefixes (indent, list marker, quote bar) and the wrap live in one
39// place.
40type out struct {
41 o Options
42 b strings.Builder
43 noURLs bool // links as their text only (Inline)
44}
45
46func (w *out) paint(sgr, s string) string {
47 if !w.o.Color || s == "" {
48 return s
49 }
50 return sgr + s + sgrReset
51}
52
53// para writes s wrapped to the width, the first line after first and
54// the rest after rest. Hard breaks in s ("\n") start a new line. An
55// SGR run open at a wrapped line's end is closed there and reopened
56// after the next line's prefix, so the prefix itself is never painted
57// and a style never bleeds past a line break.
58func (w *out) para(s, first, rest string) {
59 prefix := first
60 var open []string
61 for _, hard := range strings.Split(s, "\n") {
62 for _, line := range Wrap(hard, w.o.Width-cells(rest)) {
63 w.b.WriteString(prefix)
64 for _, sgr := range open {
65 w.b.WriteString(sgr)
66 }
67 w.b.WriteString(line)
68 open = sgrOpen(line, open)
69 if len(open) > 0 {
70 w.b.WriteString(sgrReset)
71 }
72 w.b.WriteString("\n")
73 prefix = rest
74 }
75 }
76}
77
78// sgrOpen scans s for SGR sequences, starting from the stack of runs
79// already open, and returns the stack still open at s's end. sgrReset
80// clears the whole stack; any other sequence pushes onto it.
81func sgrOpen(s string, open []string) []string {
82 for i := 0; i < len(s); i++ {
83 if s[i] != 0x1b {
84 continue
85 }
86 j := strings.IndexByte(s[i:], 'm')
87 if j < 0 {
88 break
89 }
90 sgr := s[i : i+j+1]
91 if sgr == sgrReset {
92 open = nil
93 } else {
94 open = append(open, sgr)
95 }
96 i += j
97 }
98 return open
99}
100
101// code writes lines verbatim under prefix plus four spaces,
102// highlighted when colour is on.
103func (w *out) code(src, lang, prefix string) {
104 src = strings.TrimRight(src, "\n")
105 if w.o.Color && w.o.Width > 0 {
106 var hb bytes.Buffer
107 if lang == "" {
108 lang = "plaintext"
109 }
110 if quick.Highlight(&hb, src, lang, "terminal16", "monokai") == nil {
111 src = strings.TrimRight(hb.String(), "\n")
112 }
113 }
114 for _, line := range strings.Split(src, "\n") {
115 w.b.WriteString(prefix + " " + line + "\n")
116 }
117}
118
119func (w *out) rule(prefix string) {
120 w.b.WriteString(prefix + w.paint(sgrDim, "───") + "\n")
121}
122
123func (w *out) blank() { w.b.WriteString("\n") }
124
125func (w *out) String() string {
126 return strings.TrimRight(w.b.String(), "\n") + "\n"
127}
128
129// link is a link as terminal text: its text, then the target when the
130// target says something the text does not. Relative targets are made
131// absolute against Base.
132func (w *out) link(text, target string) string {
133 if w.noURLs && text != "" {
134 return text
135 }
136 if strings.HasPrefix(target, "/") && w.o.Base != "" {
137 target = strings.TrimRight(w.o.Base, "/") + target
138 }
139 if text == "" {
140 return target
141 }
142 if target == "" || target == text || "mailto:"+text == target ||
143 strings.TrimPrefix(strings.TrimPrefix(target, "https://"), "http://") == text {
144 return text
145 }
146 return text + " (" + target + ")"
147}
148
149// Wrap breaks s at spaces into lines of at most width cells. A word
150// wider than width is a line of its own. width <= 0 is no wrapping.
151func Wrap(s string, width int) []string {
152 if width <= 0 {
153 return []string{s}
154 }
155 var lines []string
156 var cur string
157 for _, word := range strings.Fields(s) {
158 switch {
159 case cur == "":
160 cur = word
161 case cells(cur)+1+cells(word) <= width:
162 cur += " " + word
163 default:
164 lines = append(lines, cur)
165 cur = word
166 }
167 }
168 if cur != "" || len(lines) == 0 {
169 lines = append(lines, cur)
170 }
171 return lines
172}
173
174func cells(s string) int {
175 n := 0
176 for i := 0; i < len(s); {
177 if s[i] == 0x1b {
178 j := strings.IndexByte(s[i:], 'm')
179 if j < 0 {
180 break
181 }
182 i += j + 1
183 continue
184 }
185 r, size := utf8.DecodeRuneInString(s[i:])
186 i += size
187 switch {
188 case unicode.In(r, unicode.Mn, unicode.Me) || r == '':
189 case width.LookupRune(r).Kind() == width.EastAsianWide || width.LookupRune(r).Kind() == width.EastAsianFullwidth:
190 n += 2
191 default:
192 n++
193 }
194 }
195 return n
196}
197
198func stripSGR(s string) string {
199 var b strings.Builder
200 for i := 0; i < len(s); i++ {
201 if s[i] == 0x1b {
202 if j := strings.IndexByte(s[i:], 'm'); j >= 0 {
203 i += j
204 continue
205 }
206 }
207 b.WriteByte(s[i])
208 }
209 return b.String()
210}