internal/termtext/termtext.go

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

212 lines · 5072 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
 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}