wip
This commit is contained in:
483
internal/app/markdown.go
Normal file
483
internal/app/markdown.go
Normal file
@@ -0,0 +1,483 @@
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user