// Package preview renders the current buffer through Glamour into a scrollable, // read-only viewport — the full glow read experience, markup concealed. package preview import ( "os" "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: every block // on the theme background (no glamour panels), headings/code/links/prose in the // theme colors, glamour's H1 purple-bg/yellow-text and dark code chroma removed. 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 // Backgrounds → the theme paper (so nothing shows a darker panel). cfg.Document.BackgroundColor = &bg cfg.Code.BackgroundColor = &bg cfg.CodeBlock.BackgroundColor = &bg cfg.Table.BackgroundColor = &bg cfg.BlockQuote.BackgroundColor = &bg // Disable chroma syntax styling so code renders plainly on the theme bg // instead of glamour's hardcoded dark code panel. cfg.CodeBlock.Chroma = nil cfg.CodeBlock.Color = &code cfg.Code.Color = &code // Prose + headings + links in theme colors. Clear H1's purple background. cfg.Document.Color = &text cfg.Text.Color = &text for _, h := range []*ansi.StyleBlock{&cfg.Heading, &cfg.H1, &cfg.H2, &cfg.H3, &cfg.H4, &cfg.H5, &cfg.H6} { h.Color = &heading h.BackgroundColor = &bg } cfg.Link.Color = &link cfg.LinkText.Color = &link } // 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. func (m *Model) View() string { return m.vp.View() } // Style returns the current glamour style (used in tests). func (m *Model) Style() string { return m.style }