| 1 | package tui |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | |
| 6 | "charm.land/glamour/v2" |
| 7 | "charm.land/glamour/v2/ansi" |
| 8 | ) |
| 9 | |
| 10 | const mdCacheMax = 64 |
| 11 | |
| 12 | type mdRenderer struct { |
| 13 | r *glamour.TermRenderer |
| 14 | width int |
| 15 | cache map[string]string |
| 16 | order []string |
| 17 | } |
| 18 | |
| 19 | func sp(s string) *string { return &s } |
| 20 | func bp(b bool) *bool { return &b } |
| 21 | func up(u uint) *uint { return &u } |
| 22 | |
| 23 | // ansiStyle uses only base ANSI palette indexes (0-7 for normal, 8-15 bright) |
| 24 | // so colors follow the terminal theme instead of hardcoded hex/256 values. |
| 25 | func ansiStyle() ansi.StyleConfig { |
| 26 | return ansi.StyleConfig{ |
| 27 | Document: ansi.StyleBlock{ |
| 28 | StylePrimitive: ansi.StylePrimitive{BlockPrefix: "\n", BlockSuffix: "\n"}, |
| 29 | Margin: up(0), |
| 30 | }, |
| 31 | BlockQuote: ansi.StyleBlock{ |
| 32 | Indent: up(1), |
| 33 | IndentToken: sp("│ "), |
| 34 | }, |
| 35 | Paragraph: ansi.StyleBlock{}, |
| 36 | List: ansi.StyleList{ |
| 37 | LevelIndent: 2, |
| 38 | }, |
| 39 | Heading: ansi.StyleBlock{ |
| 40 | StylePrimitive: ansi.StylePrimitive{BlockSuffix: "\n", Color: sp("4"), Bold: bp(true)}, |
| 41 | }, |
| 42 | H1: ansi.StyleBlock{StylePrimitive: ansi.StylePrimitive{Prefix: "# ", Color: sp("4"), Bold: bp(true)}}, |
| 43 | H2: ansi.StyleBlock{StylePrimitive: ansi.StylePrimitive{Prefix: "## ", Color: sp("4"), Bold: bp(true)}}, |
| 44 | H3: ansi.StyleBlock{StylePrimitive: ansi.StylePrimitive{Prefix: "### ", Color: sp("6"), Bold: bp(true)}}, |
| 45 | H4: ansi.StyleBlock{StylePrimitive: ansi.StylePrimitive{Prefix: "#### ", Color: sp("6")}}, |
| 46 | H5: ansi.StyleBlock{StylePrimitive: ansi.StylePrimitive{Prefix: "##### ", Color: sp("6")}}, |
| 47 | H6: ansi.StyleBlock{StylePrimitive: ansi.StylePrimitive{Prefix: "###### ", Color: sp("6")}}, |
| 48 | Strikethrough: ansi.StylePrimitive{CrossedOut: bp(true)}, |
| 49 | Emph: ansi.StylePrimitive{Italic: bp(true)}, |
| 50 | Strong: ansi.StylePrimitive{Bold: bp(true)}, |
| 51 | HorizontalRule: ansi.StylePrimitive{ |
| 52 | Color: sp("8"), |
| 53 | Format: "\n--------\n", |
| 54 | }, |
| 55 | Item: ansi.StylePrimitive{BlockPrefix: "• "}, |
| 56 | Enumeration: ansi.StylePrimitive{BlockPrefix: ". "}, |
| 57 | Task: ansi.StyleTask{ |
| 58 | Ticked: "[✓] ", |
| 59 | Unticked: "[ ] ", |
| 60 | }, |
| 61 | Link: ansi.StylePrimitive{Color: sp("6"), Underline: bp(true)}, |
| 62 | LinkText: ansi.StylePrimitive{Color: sp("2"), Bold: bp(true)}, |
| 63 | Image: ansi.StylePrimitive{Color: sp("5"), Underline: bp(true)}, |
| 64 | ImageText: ansi.StylePrimitive{ |
| 65 | Color: sp("8"), |
| 66 | Format: "Image: {{.text}} →", |
| 67 | }, |
| 68 | Code: ansi.StyleBlock{ |
| 69 | StylePrimitive: ansi.StylePrimitive{Color: sp("1"), Prefix: "`", Suffix: "`"}, |
| 70 | }, |
| 71 | CodeBlock: ansi.StyleCodeBlock{ |
| 72 | StyleBlock: ansi.StyleBlock{ |
| 73 | StylePrimitive: ansi.StylePrimitive{Color: sp("7")}, |
| 74 | Margin: up(2), |
| 75 | }, |
| 76 | }, |
| 77 | Table: ansi.StyleTable{ |
| 78 | CenterSeparator: sp("┼"), |
| 79 | ColumnSeparator: sp("│"), |
| 80 | RowSeparator: sp("─"), |
| 81 | }, |
| 82 | DefinitionDescription: ansi.StylePrimitive{BlockPrefix: "\n* "}, |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func newRenderer(width int) *mdRenderer { |
| 87 | if width < 20 { |
| 88 | width = 80 |
| 89 | } |
| 90 | style := ansiStyle() |
| 91 | r, _ := glamour.NewTermRenderer( |
| 92 | glamour.WithStyles(style), |
| 93 | glamour.WithWordWrap(width-2), |
| 94 | ) |
| 95 | return &mdRenderer{r: r, width: width, cache: map[string]string{}} |
| 96 | } |
| 97 | |
| 98 | func (m *mdRenderer) store(key, value string) { |
| 99 | if _, ok := m.cache[key]; !ok { |
| 100 | m.order = append(m.order, key) |
| 101 | if len(m.order) > mdCacheMax { |
| 102 | drop := m.order[0] |
| 103 | m.order = m.order[1:] |
| 104 | delete(m.cache, drop) |
| 105 | } |
| 106 | } |
| 107 | m.cache[key] = value |
| 108 | } |
| 109 | |
| 110 | func (m *mdRenderer) resize(width int) { |
| 111 | if width == m.width || width < 20 { |
| 112 | return |
| 113 | } |
| 114 | style := ansiStyle() |
| 115 | r, _ := glamour.NewTermRenderer( |
| 116 | glamour.WithStyles(style), |
| 117 | glamour.WithWordWrap(width-2), |
| 118 | ) |
| 119 | m.r = r |
| 120 | m.width = width |
| 121 | m.cache = map[string]string{} |
| 122 | m.order = nil |
| 123 | } |
| 124 | |
| 125 | func (m *mdRenderer) render(key, body string) string { |
| 126 | if m.r == nil { |
| 127 | return body |
| 128 | } |
| 129 | if v, ok := m.cache[key]; ok { |
| 130 | return v |
| 131 | } |
| 132 | out, err := m.r.Render(body) |
| 133 | if err != nil { |
| 134 | out = fmt.Sprintf("render error: %v\n\n%s", err, body) |
| 135 | } |
| 136 | m.store(key, out) |
| 137 | return out |
| 138 | } |
| 139 | |
| 140 | func (m *mdRenderer) invalidate(key string) { |
| 141 | if _, ok := m.cache[key]; !ok { |
| 142 | return |
| 143 | } |
| 144 | delete(m.cache, key) |
| 145 | for i, k := range m.order { |
| 146 | if k == key { |
| 147 | m.order = append(m.order[:i], m.order[i+1:]...) |
| 148 | break |
| 149 | } |
| 150 | } |
| 151 | } |