// 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: every // element background becomes the theme paper (no glamour panels behind code or // tables), prose/headings/code/links use the theme colors, and H1 pops as a // filled bar whose text is chosen to stay legible on the heading color. 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 force every background in the // whole config (document, code, tables, cells, …) to the theme paper. cfg.CodeBlock.Chroma = nil setAllBackgrounds(reflect.ValueOf(cfg).Elem(), &bg) // Colors. cfg.Document.Color = &text cfg.Text.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, with legible text and bold. yes := true ht := legibleText(heading) cfg.H1.Color = &ht cfg.H1.BackgroundColor = &heading cfg.H1.Bold = &yes } // setAllBackgrounds sets every *string field named "BackgroundColor" in the // (recursively walked) value to bg. func setAllBackgrounds(v reflect.Value, bg *string) { switch v.Kind() { case reflect.Pointer: if !v.IsNil() { setAllBackgrounds(v.Elem(), bg) } 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.ValueOf(bg)) } continue } setAllBackgrounds(f, bg) } } } // 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. 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 }