484 lines
11 KiB
Go
484 lines
11 KiB
Go
package app
|
|
|
|
import (
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// renderMarkdownLines turns a scratchpad's text into a slice of
|
|
// terminal rows, each at most `cols` visible columns wide and ready to
|
|
// paint (style codes included, trailing reset where needed, no
|
|
// newline). The renderer covers the markdown subset most likely to
|
|
// appear in scratchpad notes: headings (#, ##, ###), bold (**x**),
|
|
// inline code (`x`), fenced code blocks (```), bullet/numbered lists,
|
|
// blockquotes (> ), horizontal rules, and links rendered as their
|
|
// text. Plain text passes through unchanged.
|
|
func renderMarkdownLines(content string, cols int) []string {
|
|
if cols < 1 {
|
|
cols = 1
|
|
}
|
|
var out []string
|
|
inFence := false
|
|
for _, raw := range strings.Split(content, "\n") {
|
|
line := strings.TrimRight(raw, "\r")
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
if strings.HasPrefix(trimmed, "```") {
|
|
inFence = !inFence
|
|
out = append(out, mdFenceRule(cols))
|
|
continue
|
|
}
|
|
if inFence {
|
|
out = append(out, mdCodeBlockLines(line, cols)...)
|
|
continue
|
|
}
|
|
if trimmed == "" {
|
|
out = append(out, "")
|
|
continue
|
|
}
|
|
if isMDHRule(trimmed) {
|
|
out = append(out, styleBorder+strings.Repeat("─", cols)+styleReset)
|
|
continue
|
|
}
|
|
if body, level := parseMDHeading(line); level > 0 {
|
|
style := mdHeadingStyle(level)
|
|
out = append(out, wrapInline(parseInline(body), style, cols)...)
|
|
continue
|
|
}
|
|
if body, ok := parseBlockquote(line); ok {
|
|
prefix := styleAccent + "│ " + styleReset
|
|
lines := wrapInline(parseInline(body), styleHint, cols-2)
|
|
if len(lines) == 0 {
|
|
out = append(out, prefix)
|
|
continue
|
|
}
|
|
for _, l := range lines {
|
|
out = append(out, prefix+l)
|
|
}
|
|
continue
|
|
}
|
|
if marker, body, ok := parseListItem(line); ok {
|
|
prefix := mdBulletPrefix(marker)
|
|
indent := strings.Repeat(" ", mdVisibleLen(prefix))
|
|
lines := wrapInline(parseInline(body), "", cols-mdVisibleLen(prefix))
|
|
if len(lines) == 0 {
|
|
out = append(out, prefix)
|
|
continue
|
|
}
|
|
for i, l := range lines {
|
|
if i == 0 {
|
|
out = append(out, prefix+l)
|
|
} else {
|
|
out = append(out, indent+l)
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
out = append(out, wrapInline(parseInline(line), "", cols)...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func mdHeadingStyle(level int) string {
|
|
switch level {
|
|
case 1:
|
|
return styleActive + styleBold
|
|
case 2:
|
|
return styleBold + styleAccent
|
|
default:
|
|
return styleBold
|
|
}
|
|
}
|
|
|
|
func mdBulletPrefix(marker string) string {
|
|
if isOrderedMarker(marker) {
|
|
return styleAccent + marker + " " + styleReset
|
|
}
|
|
return styleAccent + "• " + styleReset
|
|
}
|
|
|
|
func mdFenceRule(cols int) string {
|
|
if cols < 2 {
|
|
return styleBorder + strings.Repeat("─", cols) + styleReset
|
|
}
|
|
return styleBorder + strings.Repeat("─", cols) + styleReset
|
|
}
|
|
|
|
// mdCodeBlockLines emits one rendered row per (wrapped) source line
|
|
// inside a fenced code block, prefixed with a thin accent gutter so the
|
|
// block reads as one visual unit.
|
|
func mdCodeBlockLines(line string, cols int) []string {
|
|
gutter := styleAccent + "│" + styleReset + " "
|
|
body := line
|
|
avail := cols - 2
|
|
if avail < 1 {
|
|
avail = 1
|
|
}
|
|
chunks := wrapPlain(body, avail)
|
|
if len(chunks) == 0 {
|
|
return []string{gutter}
|
|
}
|
|
out := make([]string, 0, len(chunks))
|
|
for _, c := range chunks {
|
|
out = append(out, gutter+"\x1b[38;5;180m"+c+styleReset)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isMDHRule(s string) bool {
|
|
if len(s) < 3 {
|
|
return false
|
|
}
|
|
c := s[0]
|
|
if c != '-' && c != '_' && c != '*' {
|
|
return false
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] != c && s[i] != ' ' {
|
|
return false
|
|
}
|
|
}
|
|
count := 0
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == c {
|
|
count++
|
|
}
|
|
}
|
|
return count >= 3
|
|
}
|
|
|
|
func parseMDHeading(line string) (string, int) {
|
|
i := 0
|
|
for i < len(line) && line[i] == ' ' && i < 3 {
|
|
i++
|
|
}
|
|
level := 0
|
|
for i+level < len(line) && line[i+level] == '#' && level < 6 {
|
|
level++
|
|
}
|
|
if level == 0 {
|
|
return "", 0
|
|
}
|
|
rest := line[i+level:]
|
|
if rest != "" && rest[0] != ' ' {
|
|
return "", 0
|
|
}
|
|
return strings.TrimSpace(rest), level
|
|
}
|
|
|
|
func parseBlockquote(line string) (string, bool) {
|
|
t := strings.TrimLeft(line, " ")
|
|
if !strings.HasPrefix(t, ">") {
|
|
return "", false
|
|
}
|
|
rest := strings.TrimPrefix(t, ">")
|
|
rest = strings.TrimPrefix(rest, " ")
|
|
return rest, true
|
|
}
|
|
|
|
func parseListItem(line string) (marker, body string, ok bool) {
|
|
t := strings.TrimLeft(line, " ")
|
|
if len(t) >= 2 && (t[0] == '-' || t[0] == '*' || t[0] == '+') && t[1] == ' ' {
|
|
return string(t[0]), t[2:], true
|
|
}
|
|
// Ordered: digits then "." then space.
|
|
j := 0
|
|
for j < len(t) && t[j] >= '0' && t[j] <= '9' {
|
|
j++
|
|
}
|
|
if j > 0 && j+1 < len(t) && t[j] == '.' && t[j+1] == ' ' {
|
|
return t[:j+1], t[j+2:], true
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
func isOrderedMarker(m string) bool {
|
|
if len(m) < 2 {
|
|
return false
|
|
}
|
|
if m[len(m)-1] != '.' {
|
|
return false
|
|
}
|
|
for i := 0; i < len(m)-1; i++ {
|
|
if m[i] < '0' || m[i] > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// mdSpan is one styled run of plain text. style is an SGR prefix
|
|
// applied at the start; the renderer emits styleReset between adjacent
|
|
// spans of differing style and at end-of-line.
|
|
type mdSpan struct {
|
|
text string
|
|
style string
|
|
}
|
|
|
|
// parseInline turns one source line into styled spans. Recognises:
|
|
// - **bold** / __bold__ → bold span
|
|
// - `code` → inline code span
|
|
// - [text](url) → text rendered as accent+underline
|
|
//
|
|
// Unmatched delimiters are passed through as literal characters so a
|
|
// stray `*` or backtick doesn't swallow the rest of the line.
|
|
func parseInline(line string) []mdSpan {
|
|
var spans []mdSpan
|
|
var buf strings.Builder
|
|
flush := func(style string) {
|
|
if buf.Len() == 0 {
|
|
return
|
|
}
|
|
spans = append(spans, mdSpan{text: buf.String(), style: style})
|
|
buf.Reset()
|
|
}
|
|
i := 0
|
|
for i < len(line) {
|
|
c := line[i]
|
|
switch {
|
|
case c == '`':
|
|
if end := strings.IndexByte(line[i+1:], '`'); end >= 0 {
|
|
flush("")
|
|
spans = append(spans, mdSpan{text: line[i+1 : i+1+end], style: "\x1b[38;5;180m"})
|
|
i += end + 2
|
|
continue
|
|
}
|
|
case c == '*' && i+1 < len(line) && line[i+1] == '*':
|
|
if end := strings.Index(line[i+2:], "**"); end >= 0 {
|
|
flush("")
|
|
inner := parseInline(line[i+2 : i+2+end])
|
|
for _, s := range inner {
|
|
st := s.style
|
|
if st == "" {
|
|
st = styleBold
|
|
}
|
|
spans = append(spans, mdSpan{text: s.text, style: st})
|
|
}
|
|
i += end + 4
|
|
continue
|
|
}
|
|
case c == '_' && i+1 < len(line) && line[i+1] == '_':
|
|
if end := strings.Index(line[i+2:], "__"); end >= 0 {
|
|
flush("")
|
|
inner := parseInline(line[i+2 : i+2+end])
|
|
for _, s := range inner {
|
|
st := s.style
|
|
if st == "" {
|
|
st = styleBold
|
|
}
|
|
spans = append(spans, mdSpan{text: s.text, style: st})
|
|
}
|
|
i += end + 4
|
|
continue
|
|
}
|
|
case c == '[':
|
|
if close := strings.IndexByte(line[i+1:], ']'); close >= 0 {
|
|
rest := line[i+1+close+1:]
|
|
if strings.HasPrefix(rest, "(") {
|
|
if pclose := strings.IndexByte(rest[1:], ')'); pclose >= 0 {
|
|
flush("")
|
|
label := line[i+1 : i+1+close]
|
|
spans = append(spans, mdSpan{text: label, style: styleAccent + "\x1b[4m"})
|
|
i += 1 + close + 1 + 1 + pclose + 1
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
buf.WriteByte(c)
|
|
i++
|
|
}
|
|
flush("")
|
|
return spans
|
|
}
|
|
|
|
// wrapInline lays out styled spans across one or more terminal rows of
|
|
// `cols` visible columns each. Each output row is prefixed with
|
|
// `lineStyle` so the caller can theme an entire wrapped paragraph
|
|
// (headings, blockquotes) with one SGR. Wrapping prefers word
|
|
// boundaries; oversized tokens hard-cut at the column boundary.
|
|
func wrapInline(spans []mdSpan, lineStyle string, cols int) []string {
|
|
if cols < 1 {
|
|
cols = 1
|
|
}
|
|
var out []string
|
|
var b strings.Builder
|
|
written := 0
|
|
curStyle := ""
|
|
|
|
startLine := func() {
|
|
b.Reset()
|
|
written = 0
|
|
curStyle = ""
|
|
if lineStyle != "" {
|
|
b.WriteString(lineStyle)
|
|
curStyle = lineStyle
|
|
}
|
|
}
|
|
finishLine := func() {
|
|
if b.Len() == 0 && lineStyle == "" {
|
|
out = append(out, "")
|
|
return
|
|
}
|
|
b.WriteString(styleReset)
|
|
out = append(out, b.String())
|
|
}
|
|
|
|
startLine()
|
|
writeChar := func(r rune, st string) {
|
|
if curStyle != st {
|
|
b.WriteString(styleReset)
|
|
if lineStyle != "" {
|
|
b.WriteString(lineStyle)
|
|
}
|
|
if st != "" {
|
|
b.WriteString(st)
|
|
}
|
|
curStyle = st
|
|
}
|
|
b.WriteRune(r)
|
|
written += runeCellWidth(r)
|
|
}
|
|
|
|
for _, sp := range spans {
|
|
st := sp.style
|
|
// Tokenize span into words+spaces for word-boundary wrapping.
|
|
text := sp.text
|
|
for len(text) > 0 {
|
|
r, size := utf8.DecodeRuneInString(text)
|
|
// Take a run of either spaces or non-spaces.
|
|
isSpace := unicode.IsSpace(r)
|
|
j := 0
|
|
w := 0
|
|
for j < len(text) {
|
|
rr, sz := utf8.DecodeRuneInString(text[j:])
|
|
if unicode.IsSpace(rr) != isSpace {
|
|
break
|
|
}
|
|
j += sz
|
|
w += runeCellWidth(rr)
|
|
}
|
|
tok := text[:j]
|
|
text = text[j:]
|
|
_ = r
|
|
_ = size
|
|
|
|
if isSpace {
|
|
if written == 0 {
|
|
// Drop leading whitespace at line start.
|
|
continue
|
|
}
|
|
if written+w > cols {
|
|
finishLine()
|
|
startLine()
|
|
continue
|
|
}
|
|
for _, rr := range tok {
|
|
writeChar(rr, st)
|
|
}
|
|
continue
|
|
}
|
|
// Non-space token. If it fits, append; else wrap.
|
|
if w <= cols {
|
|
if written+w > cols {
|
|
// Trim trailing spaces written so far before wrap.
|
|
finishLine()
|
|
startLine()
|
|
}
|
|
for _, rr := range tok {
|
|
writeChar(rr, st)
|
|
}
|
|
continue
|
|
}
|
|
// Token longer than a full row: hard-cut.
|
|
for _, rr := range tok {
|
|
cw := runeCellWidth(rr)
|
|
if written+cw > cols {
|
|
finishLine()
|
|
startLine()
|
|
}
|
|
writeChar(rr, st)
|
|
}
|
|
}
|
|
}
|
|
finishLine()
|
|
if len(out) == 0 {
|
|
out = append(out, "")
|
|
}
|
|
return out
|
|
}
|
|
|
|
// wrapPlain wraps a literal string (no styling) at a `cols` visible
|
|
// column budget. Used by code-block rendering, which preserves the raw
|
|
// line verbatim.
|
|
func wrapPlain(line string, cols int) []string {
|
|
if cols < 1 {
|
|
cols = 1
|
|
}
|
|
if line == "" {
|
|
return []string{""}
|
|
}
|
|
var out []string
|
|
var b strings.Builder
|
|
written := 0
|
|
for _, r := range line {
|
|
w := runeCellWidth(r)
|
|
if written+w > cols {
|
|
out = append(out, b.String())
|
|
b.Reset()
|
|
written = 0
|
|
}
|
|
b.WriteRune(r)
|
|
written += w
|
|
}
|
|
if b.Len() > 0 {
|
|
out = append(out, b.String())
|
|
}
|
|
return out
|
|
}
|
|
|
|
// runeCellWidth is a tiny approximation of terminal cell width: 0 for
|
|
// non-printables, 1 for the common case. Wide East-Asian and emoji
|
|
// runes would ideally be 2, but pads in practice are Latin/symbol text;
|
|
// landing a precise width walk is left for when we see a real case.
|
|
func runeCellWidth(r rune) int {
|
|
if r == 0 || r == '\r' || r == '\n' {
|
|
return 0
|
|
}
|
|
if r < 0x20 || r == 0x7f {
|
|
return 0
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// mdVisibleLen counts visible columns in a string with embedded SGR
|
|
// escapes — the inverse of the writer that produces them.
|
|
func mdVisibleLen(s string) int {
|
|
n := 0
|
|
i := 0
|
|
for i < len(s) {
|
|
if s[i] == 0x1b {
|
|
j := i + 1
|
|
if j < len(s) && s[j] == '[' {
|
|
j++
|
|
for j < len(s) && !isCSIFinal(s[j]) {
|
|
j++
|
|
}
|
|
if j < len(s) {
|
|
j++
|
|
}
|
|
i = j
|
|
continue
|
|
}
|
|
i = j
|
|
continue
|
|
}
|
|
r, size := utf8.DecodeRuneInString(s[i:])
|
|
n += runeCellWidth(r)
|
|
i += size
|
|
}
|
|
return n
|
|
}
|
|
|