package preview import ( "strings" "github.com/charmbracelet/lipgloss" "glint/internal/theme" ) // SGR escapes. The header writes them by hand rather than through lipgloss so // the colors survive a non-TTY renderer (tests, pipes) exactly like the rest of // this package's output, which glamour also emits unconditionally. const ( sgrReset = "\x1b[0m" sgrBold = "\x1b[1m" sgrItalic = "\x1b[3m" ) // propIndent is the left inset of the properties block, and propGap the space // between the key column and the value column. const ( propIndent = " " propGap = " " ) // renderHeader draws the document header that sits above the glamour body: the // doc name as a full-width bar in the heading color (an H1 block that spans the // column), then the frontmatter properties as a tight, aligned, italic list — // dim keys, italic values, one line each, no rules and no blank lines between. // Returns "" when there is neither a name nor a property. func (m *Model) renderHeader(title string, props []prop) string { if title == "" && len(props) == 0 { return "" } width := m.width if width < 1 { width = 80 } var b strings.Builder if title != "" { b.WriteString(m.titleBar(title, width)) b.WriteByte('\n') } if len(props) > 0 { if title != "" { b.WriteByte('\n') // one blank line under the name } b.WriteString(m.propList(props, width)) } return b.String() } // titleBar renders the doc name as a bold, full-width filled block: the heading // color behind, a legible text color on top. Without a themed heading color it // falls back to bold text. func (m *Model) titleBar(title string, width int) string { pad := width - lipgloss.Width(title) - 2 // one space each side if pad < 0 { title = truncate(title, width-2) pad = 0 } line := " " + title + " " + strings.Repeat(" ", pad) open := sgrBold if bg := theme.HexToRGB(m.colors.Heading); bg != "" { open += "\x1b[38;2;" + theme.HexToRGB(theme.LegibleText(m.colors.Heading)) + ";48;2;" + bg + "m" } return open + line + sgrReset } // propList renders the aligned key/value rows. Keys are padded to the widest // key so the values form a column; long values wrap with a hanging indent that // keeps them inside that column. func (m *Model) propList(props []prop, width int) string { keyw := 0 for _, p := range props { if w := lipgloss.Width(p.key); w > keyw { keyw = w } } valLeft := len(propIndent) + keyw + len(propGap) valWidth := max(width-valLeft, 8) keyOpen, valOpen := m.propStyles() var b strings.Builder for _, p := range props { b.WriteString(propIndent) b.WriteString(keyOpen) b.WriteString(p.key) b.WriteString(strings.Repeat(" ", keyw-lipgloss.Width(p.key))) b.WriteString(sgrReset) b.WriteString(propGap) for i, ln := range wrapWords(p.val, valWidth) { if i > 0 { b.WriteString(strings.Repeat(" ", valLeft)) } b.WriteString(valOpen) b.WriteString(ln) b.WriteString(sgrReset) b.WriteByte('\n') } if p.val == "" { b.WriteByte('\n') } } return b.String() } // propStyles returns the SGR prefixes for a property key (dim) and value // (italic), falling back to plain text when the theme colors are unset. func (m *Model) propStyles() (key, val string) { if c := theme.HexToRGB(m.colors.Muted); c != "" { key = "\x1b[38;2;" + c + "m" } val = sgrItalic if c := theme.HexToRGB(m.colors.Text); c != "" { val += "\x1b[38;2;" + c + "m" } return key, val } // wrapWords greedily wraps text to width, breaking on spaces. An empty string // yields no lines. func wrapWords(s string, width int) []string { if s == "" { return nil } var lines []string cur := "" for _, w := range strings.Fields(s) { switch { case cur == "": cur = w case lipgloss.Width(cur)+1+lipgloss.Width(w) <= width: cur += " " + w default: lines = append(lines, cur) cur = w } } if cur != "" { lines = append(lines, cur) } return lines } // truncate cuts s to at most width display cells, ending in an ellipsis. func truncate(s string, width int) string { if width < 1 { return "" } if lipgloss.Width(s) <= width { return s } r := []rune(s) for len(r) > 0 && lipgloss.Width(string(r))+1 > width { r = r[:len(r)-1] } return string(r) + "…" }