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