// 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 }