Files
patterm/internal/app/markdown.go
2026-05-15 00:28:06 +01:00

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
}