internal/termtext/termtext.go

dd06e80a071d451ebb7b083363e0580550481114
gitbay/internal/termtext/termtext.go history · blame · raw

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}