1package common
2
3import (
4 "image/color"
5 "sync"
6
7 "charm.land/glamour/v2"
8 "git.secluded.site/crush/internal/ui/styles"
9 "git.secluded.site/crush/internal/ui/xchroma"
10 "github.com/alecthomas/chroma/v2/formatters"
11)
12
13const formatterName = "crush"
14
15func init() {
16 // NOTE: Glamour does not offer us an option to pass the formatter
17 // implementation directly. We need to register and use by name.
18 var zero color.Color
19 formatters.Register(formatterName, xchroma.Formatter(zero, nil))
20}
21
22// mdCacheMu guards mdCache and quietMDCache.
23//
24// Lock ordering: when both mdCacheMu and rendererLocksMu are
25// needed (only in InvalidateMarkdownRendererCache), acquire
26// mdCacheMu FIRST, then rendererLocksMu. No other call site may
27// hold rendererLocksMu while acquiring mdCacheMu.
28var (
29 mdCacheMu sync.Mutex
30 mdCache = map[int]*glamour.TermRenderer{}
31 quietMDCache = map[int]*glamour.TermRenderer{}
32)
33
34// MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with
35// the given styles and width. Renderers are memoized per width and shared
36// across callers; call InvalidateMarkdownRendererCache when the active
37// styles change.
38//
39// The returned renderer is NOT safe for concurrent Render calls
40// (goldmark's BlockStack carries state across the public Render
41// API). Crush's TUI is single-threaded so production never
42// contends, but parallel callers (most notably parallel tests)
43// must serialize via [LockMarkdownRenderer]. Treat the renderer
44// as effectively pinned to one goroutine at a time.
45func MarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
46 mdCacheMu.Lock()
47 defer mdCacheMu.Unlock()
48 if r, ok := mdCache[width]; ok {
49 return r
50 }
51 r, _ := glamour.NewTermRenderer(
52 glamour.WithStyles(sty.Markdown),
53 glamour.WithWordWrap(width),
54 glamour.WithChromaFormatter(formatterName),
55 )
56 mdCache[width] = r
57 return r
58}
59
60// QuietMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors
61// (plain text with structure) and the given width. Renderers are memoized per
62// width and shared across callers. Same concurrency contract as
63// [MarkdownRenderer]: serialize via [LockMarkdownRenderer].
64func QuietMarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
65 mdCacheMu.Lock()
66 defer mdCacheMu.Unlock()
67 if r, ok := quietMDCache[width]; ok {
68 return r
69 }
70 r, _ := glamour.NewTermRenderer(
71 glamour.WithStyles(sty.QuietMarkdown),
72 glamour.WithWordWrap(width),
73 glamour.WithChromaFormatter(formatterName),
74 )
75 quietMDCache[width] = r
76 return r
77}
78
79// InvalidateMarkdownRendererCache drops every cached renderer
80// AND every per-renderer mutex in a single atomic critical
81// section so the two maps cannot disagree mid-toggle. Call this
82// whenever the active styles change so subsequent renderers
83// pick up the new ansi.StyleConfig.
84//
85// Existing holders of an old mutex (mid-Render goroutines) keep
86// their reference safely; new renderers minted after the
87// invalidation get freshly minted mutexes.
88//
89// Lock ordering: mdCacheMu is acquired first, then
90// rendererLocksMu — see the comments on each mutex.
91func InvalidateMarkdownRendererCache() {
92 mdCacheMu.Lock()
93 defer mdCacheMu.Unlock()
94 rendererLocksMu.Lock()
95 defer rendererLocksMu.Unlock()
96
97 mdCache = map[int]*glamour.TermRenderer{}
98 quietMDCache = map[int]*glamour.TermRenderer{}
99 rendererLocks = map[*glamour.TermRenderer]*sync.Mutex{}
100}
101
102// rendererLocksMu guards rendererLocks. We key per-renderer
103// mutexes by pointer so the lock granularity matches the
104// renderer cache granularity (one mutex per (width, palette)
105// renderer instance, not one mutex for the entire cache).
106//
107// Lock ordering: when both mdCacheMu and rendererLocksMu are
108// needed (only in InvalidateMarkdownRendererCache), acquire
109// mdCacheMu FIRST, then rendererLocksMu.
110var (
111 rendererLocksMu sync.Mutex
112 rendererLocks = map[*glamour.TermRenderer]*sync.Mutex{}
113)
114
115// LockMarkdownRenderer returns the per-renderer mutex used to
116// serialize concurrent Render calls on a shared
117// [glamour.TermRenderer] instance. The returned [*sync.Mutex] is
118// stable for the lifetime of the renderer (i.e. until
119// [InvalidateMarkdownRendererCache] is called).
120//
121// Callers that issue more than one Render call in the same
122// logical operation should hold the mutex for the entire
123// sequence so other goroutines do not interleave their own
124// Render calls and corrupt the renderer state. F8's
125// streamingMarkdown is the immediate consumer; other call
126// sites that today issue exactly one Render call per item
127// render are safe without locking under the single-threaded
128// TUI Update loop, but should adopt this lock if they ever run
129// in parallel (e.g. background prerender workers).
130func LockMarkdownRenderer(r *glamour.TermRenderer) *sync.Mutex {
131 rendererLocksMu.Lock()
132 defer rendererLocksMu.Unlock()
133 if mu, ok := rendererLocks[r]; ok {
134 return mu
135 }
136 mu := &sync.Mutex{}
137 rendererLocks[r] = mu
138 return mu
139}