apps/jotts/tui/render_md.go 4.0 K raw
1
package tui
2
3
import (
4
	"fmt"
5
6
	"charm.land/glamour/v2"
7
	"charm.land/glamour/v2/ansi"
8
)
9
10
const mdCacheMax = 64
11
12
type mdRenderer struct {
13
	r     *glamour.TermRenderer
14
	width int
15
	cache map[string]string
16
	order []string
17
}
18
19
func sp(s string) *string { return &s }
20
func bp(b bool) *bool     { return &b }
21
func up(u uint) *uint     { return &u }
22
23
// ansiStyle uses only base ANSI palette indexes (0-7 for normal, 8-15 bright)
24
// so colors follow the terminal theme instead of hardcoded hex/256 values.
25
func ansiStyle() ansi.StyleConfig {
26
	return ansi.StyleConfig{
27
		Document: ansi.StyleBlock{
28
			StylePrimitive: ansi.StylePrimitive{BlockPrefix: "\n", BlockSuffix: "\n"},
29
			Margin:         up(0),
30
		},
31
		BlockQuote: ansi.StyleBlock{
32
			Indent:      up(1),
33
			IndentToken: sp("│ "),
34
		},
35
		Paragraph: ansi.StyleBlock{},
36
		List: ansi.StyleList{
37
			LevelIndent: 2,
38
		},
39
		Heading: ansi.StyleBlock{
40
			StylePrimitive: ansi.StylePrimitive{BlockSuffix: "\n", Color: sp("4"), Bold: bp(true)},
41
		},
42
		H1: ansi.StyleBlock{StylePrimitive: ansi.StylePrimitive{Prefix: "# ", Color: sp("4"), Bold: bp(true)}},
43
		H2: ansi.StyleBlock{StylePrimitive: ansi.StylePrimitive{Prefix: "## ", Color: sp("4"), Bold: bp(true)}},
44
		H3: ansi.StyleBlock{StylePrimitive: ansi.StylePrimitive{Prefix: "### ", Color: sp("6"), Bold: bp(true)}},
45
		H4: ansi.StyleBlock{StylePrimitive: ansi.StylePrimitive{Prefix: "#### ", Color: sp("6")}},
46
		H5: ansi.StyleBlock{StylePrimitive: ansi.StylePrimitive{Prefix: "##### ", Color: sp("6")}},
47
		H6: ansi.StyleBlock{StylePrimitive: ansi.StylePrimitive{Prefix: "###### ", Color: sp("6")}},
48
		Strikethrough: ansi.StylePrimitive{CrossedOut: bp(true)},
49
		Emph:          ansi.StylePrimitive{Italic: bp(true)},
50
		Strong:        ansi.StylePrimitive{Bold: bp(true)},
51
		HorizontalRule: ansi.StylePrimitive{
52
			Color:  sp("8"),
53
			Format: "\n--------\n",
54
		},
55
		Item:        ansi.StylePrimitive{BlockPrefix: "• "},
56
		Enumeration: ansi.StylePrimitive{BlockPrefix: ". "},
57
		Task: ansi.StyleTask{
58
			Ticked:   "[✓] ",
59
			Unticked: "[ ] ",
60
		},
61
		Link:     ansi.StylePrimitive{Color: sp("6"), Underline: bp(true)},
62
		LinkText: ansi.StylePrimitive{Color: sp("2"), Bold: bp(true)},
63
		Image:    ansi.StylePrimitive{Color: sp("5"), Underline: bp(true)},
64
		ImageText: ansi.StylePrimitive{
65
			Color:  sp("8"),
66
			Format: "Image: {{.text}} →",
67
		},
68
		Code: ansi.StyleBlock{
69
			StylePrimitive: ansi.StylePrimitive{Color: sp("1"), Prefix: "`", Suffix: "`"},
70
		},
71
		CodeBlock: ansi.StyleCodeBlock{
72
			StyleBlock: ansi.StyleBlock{
73
				StylePrimitive: ansi.StylePrimitive{Color: sp("7")},
74
				Margin:         up(2),
75
			},
76
		},
77
		Table: ansi.StyleTable{
78
			CenterSeparator: sp("┼"),
79
			ColumnSeparator: sp("│"),
80
			RowSeparator:    sp("─"),
81
		},
82
		DefinitionDescription: ansi.StylePrimitive{BlockPrefix: "\n* "},
83
	}
84
}
85
86
func newRenderer(width int) *mdRenderer {
87
	if width < 20 {
88
		width = 80
89
	}
90
	style := ansiStyle()
91
	r, _ := glamour.NewTermRenderer(
92
		glamour.WithStyles(style),
93
		glamour.WithWordWrap(width-2),
94
	)
95
	return &mdRenderer{r: r, width: width, cache: map[string]string{}}
96
}
97
98
func (m *mdRenderer) store(key, value string) {
99
	if _, ok := m.cache[key]; !ok {
100
		m.order = append(m.order, key)
101
		if len(m.order) > mdCacheMax {
102
			drop := m.order[0]
103
			m.order = m.order[1:]
104
			delete(m.cache, drop)
105
		}
106
	}
107
	m.cache[key] = value
108
}
109
110
func (m *mdRenderer) resize(width int) {
111
	if width == m.width || width < 20 {
112
		return
113
	}
114
	style := ansiStyle()
115
	r, _ := glamour.NewTermRenderer(
116
		glamour.WithStyles(style),
117
		glamour.WithWordWrap(width-2),
118
	)
119
	m.r = r
120
	m.width = width
121
	m.cache = map[string]string{}
122
	m.order = nil
123
}
124
125
func (m *mdRenderer) render(key, body string) string {
126
	if m.r == nil {
127
		return body
128
	}
129
	if v, ok := m.cache[key]; ok {
130
		return v
131
	}
132
	out, err := m.r.Render(body)
133
	if err != nil {
134
		out = fmt.Sprintf("render error: %v\n\n%s", err, body)
135
	}
136
	m.store(key, out)
137
	return out
138
}
139
140
func (m *mdRenderer) invalidate(key string) {
141
	if _, ok := m.cache[key]; !ok {
142
		return
143
	}
144
	delete(m.cache, key)
145
	for i, k := range m.order {
146
		if k == key {
147
			m.order = append(m.order[:i], m.order[i+1:]...)
148
			break
149
		}
150
	}
151
}