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 }