1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
|
// Package preview renders the current buffer through Glamour into a scrollable,
// read-only viewport — the full glow read experience, markup concealed.
package preview
import (
"math"
"os"
"reflect"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/ansi"
"github.com/charmbracelet/glamour/styles"
)
// applyTheme rewrites a glamour style so it matches the glint theme: prose,
// headings, code and links use the theme colors on the theme paper. Every
// element background is first cleared so the only explicit backgrounds are the
// document paper, code (also paper — no glamour panels), and the H1 heading bar.
// Crucially the Text background is left unset: that lets heading text inherit the
// H1 block's heading background (so the bar fills behind the words, not just the
// margins). Regions glamour leaves unstyled (table borders, cell padding) are
// repainted with paper by fillBackground at render time.
func applyTheme(cfg *ansi.StyleConfig, c Colors) {
var zero uint
cfg.Document.Margin = &zero
if c.Background == "" {
return
}
bg, text, heading, code, link := c.Background, c.Text, c.Heading, c.Code, c.Link
// Disable chroma so code renders plainly, then clear every background so we
// can set only the few we want explicitly.
cfg.CodeBlock.Chroma = nil
clearAllBackgrounds(reflect.ValueOf(cfg).Elem())
// Backgrounds: paper for the document and code; everything else inherits.
cfg.Document.BackgroundColor = &bg
cfg.Code.BackgroundColor = &bg
cfg.CodeBlock.BackgroundColor = &bg
cfg.Table.BackgroundColor = &bg
// Colors. Text.Color is left unset so prose inherits Document.Color while
// heading text inherits the H1 block's legible color (set below) — if Text
// carried its own color it would override the heading text, leaving it
// illegible on the heading bar.
cfg.Document.Color = &text
cfg.Code.Color = &code
cfg.CodeBlock.Color = &code
for _, h := range []*ansi.StyleBlock{&cfg.Heading, &cfg.H2, &cfg.H3, &cfg.H4, &cfg.H5, &cfg.H6} {
h.Color = &heading
}
cfg.Link.Color = &link
cfg.LinkText.Color = &link
// H1: filled bar in the heading color (text inherits this bg), bold.
yes := true
ht := legibleText(heading)
cfg.H1.Color = &ht
cfg.H1.BackgroundColor = &heading
cfg.H1.Bold = &yes
// Drop the blank line glamour emits after every heading (its BlockSuffix
// "\n") for H2-H6, so a subhead sits directly above its body. H1 keeps the
// gap: empty the shared Heading suffix (which H2-H6 inherit) and re-assert it
// on H1 only. Cascade overrides parent with child only when child is non-empty.
cfg.Heading.BlockSuffix = ""
cfg.H1.BlockSuffix = "\n"
}
// clearAllBackgrounds nils every *string field named "BackgroundColor" in the
// (recursively walked) value.
func clearAllBackgrounds(v reflect.Value) {
switch v.Kind() {
case reflect.Pointer:
if !v.IsNil() {
clearAllBackgrounds(v.Elem())
}
case reflect.Struct:
t := v.Type()
for i := 0; i < v.NumField(); i++ {
f := v.Field(i)
if t.Field(i).Name == "BackgroundColor" && f.Type() == reflect.TypeOf((*string)(nil)) {
if f.CanSet() {
f.Set(reflect.Zero(f.Type()))
}
continue
}
clearAllBackgrounds(f)
}
}
}
// fillBackground re-asserts the theme paper background and text foreground after
// every ANSI reset and at the start of every line, so glyphs glamour emits with
// no styling (table borders, cell padding, list bullets) render in the theme
// colors instead of the terminal default — otherwise borders are invisible when
// the terminal foreground matches the paper (light terminal + dark theme, and
// the reverse). Spans that set their own colors still override immediately, so
// styled text is unaffected.
func fillBackground(s, bgHex, fgHex string) string {
bg := hexToRGB(bgHex)
if bg == "" {
return s
}
base := "\x1b[48;2;" + bg + "m"
if fg := hexToRGB(fgHex); fg != "" {
base = "\x1b[38;2;" + fg + ";48;2;" + bg + "m"
}
paper := base
// Re-assert after every reset. Glamour/termenv emit both "\x1b[0m" and the
// bare "\x1b[m"; regions after an unhandled reset fall back to the terminal's
// own colors — so both forms must be covered.
s = strings.ReplaceAll(s, "\x1b[0m", "\x1b[0m"+paper)
s = strings.ReplaceAll(s, "\x1b[m", "\x1b[m"+paper)
lines := strings.Split(s, "\n")
for i, ln := range lines {
lines[i] = paper + ln
}
return strings.Join(lines, "\n")
}
// hexToRGB converts "#RRGGBB" to the "R;G;B" decimal form used in SGR codes.
func hexToRGB(hex string) string {
h := strings.TrimPrefix(hex, "#")
if len(h) != 6 {
return ""
}
r, err1 := strconv.ParseInt(h[0:2], 16, 0)
g, err2 := strconv.ParseInt(h[2:4], 16, 0)
b, err3 := strconv.ParseInt(h[4:6], 16, 0)
if err1 != nil || err2 != nil || err3 != nil {
return ""
}
return strconv.FormatInt(r, 10) + ";" + strconv.FormatInt(g, 10) + ";" + strconv.FormatInt(b, 10)
}
// legibleText returns a near-black or near-paper text color, whichever contrasts
// better with the background hex (so H1 text stays readable on any heading color).
func legibleText(bgHex string) string {
if relLuminance(bgHex) > 0.5 {
return "#100F0F"
}
return "#FFFCF0"
}
// relLuminance is the WCAG relative luminance of an "#RRGGBB" color.
func relLuminance(hex string) float64 {
h := strings.TrimPrefix(hex, "#")
if len(h) != 6 {
return 0
}
chan8 := func(s string) float64 {
n, _ := strconv.ParseInt(s, 16, 0)
c := float64(n) / 255
if c <= 0.03928 {
return c / 12.92
}
return math.Pow((c+0.055)/1.055, 2.4)
}
return 0.2126*chan8(h[0:2]) + 0.7152*chan8(h[2:4]) + 0.0722*chan8(h[4:6])
}
// Model wraps a Glamour renderer and a viewport.
// Colors are the theme hexes the preview paints glamour with, so the read view
// matches the editor exactly (no glamour panel colors, no system reliance).
type Colors struct {
Background string
Text string
Heading string
Code string
Link string
}
type Model struct {
vp viewport.Model
style string
colors Colors
width int
height int
}
// New returns a preview using the given Glamour style (builtin name or a path
// to a style JSON file).
func New(style string) *Model {
return &Model{
vp: viewport.New(0, 0),
style: style,
width: 80,
height: 24,
}
}
// SetColors sets the theme colors glamour renders with so the preview matches
// the editor canvas (background, prose, headings, code, links).
func (m *Model) SetColors(c Colors) { m.colors = c }
// SetSize resizes the viewport.
func (m *Model) SetSize(w, h int) {
m.width = w
if h < 1 {
h = 1
}
m.height = h
m.vp.Width = w
m.vp.Height = h
}
// SetStyle changes the glamour style used by the next Render.
func (m *Model) SetStyle(s string) { m.style = s }
// Render runs markdown through Glamour and loads it into the viewport.
func (m *Model) Render(markdown string) error {
r, err := m.renderer()
if err != nil {
return err
}
out, err := r.Render(markdown)
if err != nil {
return err
}
m.vp.SetContent(out)
m.vp.GotoTop()
return nil
}
// fileStyle reports whether s names an existing style file on disk.
func fileStyle(s string) bool {
if s == "" {
return false
}
_, err := os.Stat(s)
return err == nil
}
// knownStyles maps glamour's known builtin style names.
var knownStyles = map[string]bool{
"ascii": true,
"dark": true,
"light": true,
"dracula": true,
"tokyo-night": true,
"notty": true,
"pink": true,
}
// renderer builds a Glamour renderer, treating m.style as a file path when it
// exists on disk and as a builtin style name otherwise. Falls back to "dark" if
// the style is neither an existing file nor a known builtin name.
func (m *Model) renderer() (*glamour.TermRenderer, error) {
width := m.width
if width < 1 {
width = 80
}
opts := []glamour.TermRendererOption{glamour.WithWordWrap(width)}
switch {
case fileStyle(m.style):
// An explicit style file wins.
opts = append(opts, glamour.WithStylePath(m.style))
case m.style == "light" || m.style == "dark" || m.style == "":
// The theme-driven styles: take glamour's base config but paint the
// document with the theme background (and drop glamour's own margin) so
// the preview blends seamlessly into the canvas.
cfg := styles.DarkStyleConfig
if m.style == "light" {
cfg = styles.LightStyleConfig
}
applyTheme(&cfg, m.colors)
opts = append(opts, glamour.WithStyles(cfg))
case knownStyles[m.style]:
// A user-chosen named style keeps its own look.
opts = append(opts, glamour.WithStandardStyle(m.style))
default:
opts = append(opts, glamour.WithStandardStyle("dark"))
}
return glamour.NewTermRenderer(opts...)
}
// Update forwards scroll keys to the viewport.
func (m *Model) Update(msg tea.Msg) tea.Cmd {
var cmd tea.Cmd
m.vp, cmd = m.vp.Update(msg)
return cmd
}
// View renders the viewport. For the theme-driven styles it repaints every
// unstyled gap (table borders/padding, viewport fill) with the theme paper so
// nothing falls back to the terminal's own background. Applied here (not in
// Render) so the viewport's own padding/resets are covered too.
func (m *Model) View() string {
out := m.vp.View()
if m.colors.Background != "" && (m.style == "" || m.style == "light" || m.style == "dark") {
out = fillBackground(out, m.colors.Background, m.colors.Text)
}
return out
}
// Style returns the current glamour style (used in tests).
func (m *Model) Style() string { return m.style }
|