tui: render markdown tables
- commit
- a40d1cf56c65b487d7ba714e777fb927efa4b719
- author
- patriceckhart · · 2026-05-03 10:31 UTC
- parent
- 266b1a5
- signature
- unsigned
- refs
- tag: v0.1.45
2 files · +380 -6
M internal/tui/markdown.go +295 -6
| @@ -14,8 +14,9 @@ const FlushLeftSentinel = '\x1c' | |||
| 14 | 14 | ||
| 15 | 15 | // RenderMarkdown renders a small subset of Markdown to styled terminal | |
| 16 | 16 | // text using theme colors. Supported: headings, bold, italic, inline | |
| 17 | - | // code, fenced code blocks, bullet lists, numbered lists, blockquotes. | |
| 18 | - | // Not supported: tables, links with complex formatting, HTML. | |
| 17 | + | // code, fenced code blocks, bullet lists, numbered lists, blockquotes, | |
| 18 | + | // simple GitHub-style tables. | |
| 19 | + | // Not supported: links with complex formatting, HTML. | |
| 19 | 20 | // | |
| 20 | 21 | // width is used to draw horizontal rules (e.g. around code fences). | |
| 21 | 22 | // Pass 0 to use a reasonable fallback. | |
| @@ -58,7 +59,8 @@ func RenderMarkdown(src string, th Theme, width int) string { | |||
| 58 | 59 | fenceBuf.Reset() | |
| 59 | 60 | } | |
| 60 | 61 | ||
| 61 | - | for _, line := range lines { | |
| 62 | + | for idx := 0; idx < len(lines); idx++ { | |
| 63 | + | line := lines[idx] | |
| 62 | 64 | trim := strings.TrimLeft(line, " ") | |
| 63 | 65 | if strings.HasPrefix(trim, "```") { | |
| 64 | 66 | if inFence { | |
| @@ -83,6 +85,24 @@ func RenderMarkdown(src string, th Theme, width int) string { | |||
| 83 | 85 | fenceBuf.WriteString("\n") | |
| 84 | 86 | continue | |
| 85 | 87 | } | |
| 88 | + | // GitHub-style table blocks. Detect a header row followed by | |
| 89 | + | // a separator row like "| --- | ---: |" and consume all | |
| 90 | + | // following pipe rows as table body. Done before inline | |
| 91 | + | // rendering so cell widths can be measured and padded. | |
| 92 | + | if idx+1 < len(lines) && looksLikeTableHeader(line, lines[idx+1]) { | |
| 93 | + | block := []string{line, lines[idx+1]} | |
| 94 | + | j := idx + 2 | |
| 95 | + | for j < len(lines) && looksLikeTableRow(lines[j]) { | |
| 96 | + | block = append(block, lines[j]) | |
| 97 | + | j++ | |
| 98 | + | } | |
| 99 | + | for _, rendered := range renderTable(block, th, width) { | |
| 100 | + | out.WriteString(rendered) | |
| 101 | + | out.WriteString("\n") | |
| 102 | + | } | |
| 103 | + | idx = j - 1 | |
| 104 | + | continue | |
| 105 | + | } | |
| 86 | 106 | // Headings. | |
| 87 | 107 | if m := headingRE.FindStringSubmatch(line); m != nil { | |
| 88 | 108 | level := len(m[1]) | |
| @@ -121,15 +141,284 @@ func RenderMarkdown(src string, th Theme, width int) string { | |||
| 121 | 141 | } | |
| 122 | 142 | ||
| 123 | 143 | var ( | |
| 124 | - | headingRE = regexp.MustCompile(`^(#{1,6})\s+(.*)$`) | |
| 125 | - | bulletRE = regexp.MustCompile(`^(\s*)[-*+]\s+(.*)$`) | |
| 126 | - | numberRE = regexp.MustCompile(`^(\s*)(\d+)\.\s+(.*)$`) | |
| 144 | + | headingRE = regexp.MustCompile(`^(#{1,6})\s+(.*)$`) | |
| 145 | + | bulletRE = regexp.MustCompile(`^(\s*)[-*+]\s+(.*)$`) | |
| 146 | + | numberRE = regexp.MustCompile(`^(\s*)(\d+)\.\s+(.*)$`) | |
| 147 | + | tableSepCell = regexp.MustCompile(`^:?-{3,}:?$`) | |
| 127 | 148 | ||
| 128 | 149 | boldRE = regexp.MustCompile(`\*\*([^*]+)\*\*`) | |
| 129 | 150 | italRE = regexp.MustCompile(`\*([^*]+)\*`) | |
| 130 | 151 | codeRE = regexp.MustCompile("`([^`]+)`") | |
| 131 | 152 | ) | |
| 132 | 153 | ||
| 154 | + | type tableAlign int | |
| 155 | + | ||
| 156 | + | const ( | |
| 157 | + | tableAlignLeft tableAlign = iota | |
| 158 | + | tableAlignCenter | |
| 159 | + | tableAlignRight | |
| 160 | + | ) | |
| 161 | + | ||
| 162 | + | func looksLikeTableHeader(header, sep string) bool { | |
| 163 | + | h := splitTableRow(header) | |
| 164 | + | s, ok := parseTableSeparator(sep) | |
| 165 | + | return ok && len(h) >= 2 && len(s) == len(h) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func looksLikeTableRow(line string) bool { | |
| 169 | + | cells := splitTableRow(line) | |
| 170 | + | return len(cells) >= 2 | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func splitTableRow(line string) []string { | |
| 174 | + | line = strings.TrimSpace(line) | |
| 175 | + | if !strings.Contains(line, "|") { | |
| 176 | + | return nil | |
| 177 | + | } | |
| 178 | + | if strings.HasPrefix(line, "|") { | |
| 179 | + | line = strings.TrimPrefix(line, "|") | |
| 180 | + | } | |
| 181 | + | if strings.HasSuffix(line, "|") { | |
| 182 | + | line = strings.TrimSuffix(line, "|") | |
| 183 | + | } | |
| 184 | + | parts := splitUnescapedPipes(line) | |
| 185 | + | for i := range parts { | |
| 186 | + | parts[i] = strings.TrimSpace(strings.ReplaceAll(parts[i], `\|`, "|")) | |
| 187 | + | } | |
| 188 | + | return parts | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func splitUnescapedPipes(s string) []string { | |
| 192 | + | var parts []string | |
| 193 | + | var b strings.Builder | |
| 194 | + | escaped := false | |
| 195 | + | for _, r := range s { | |
| 196 | + | if escaped { | |
| 197 | + | if r != '|' { | |
| 198 | + | b.WriteRune('\\') | |
| 199 | + | } | |
| 200 | + | b.WriteRune(r) | |
| 201 | + | escaped = false | |
| 202 | + | continue | |
| 203 | + | } | |
| 204 | + | if r == '\\' { | |
| 205 | + | escaped = true | |
| 206 | + | continue | |
| 207 | + | } | |
| 208 | + | if r == '|' { | |
| 209 | + | parts = append(parts, b.String()) | |
| 210 | + | b.Reset() | |
| 211 | + | continue | |
| 212 | + | } | |
| 213 | + | b.WriteRune(r) | |
| 214 | + | } | |
| 215 | + | if escaped { | |
| 216 | + | b.WriteRune('\\') | |
| 217 | + | } | |
| 218 | + | parts = append(parts, b.String()) | |
| 219 | + | return parts | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func parseTableSeparator(line string) ([]tableAlign, bool) { | |
| 223 | + | cells := splitTableRow(line) | |
| 224 | + | if len(cells) < 2 { | |
| 225 | + | return nil, false | |
| 226 | + | } | |
| 227 | + | aligns := make([]tableAlign, len(cells)) | |
| 228 | + | for i, cell := range cells { | |
| 229 | + | compact := strings.ReplaceAll(strings.TrimSpace(cell), " ", "") | |
| 230 | + | if !tableSepCell.MatchString(compact) { | |
| 231 | + | return nil, false | |
| 232 | + | } | |
| 233 | + | left := strings.HasPrefix(compact, ":") | |
| 234 | + | right := strings.HasSuffix(compact, ":") | |
| 235 | + | switch { | |
| 236 | + | case left && right: | |
| 237 | + | aligns[i] = tableAlignCenter | |
| 238 | + | case right: | |
| 239 | + | aligns[i] = tableAlignRight | |
| 240 | + | default: | |
| 241 | + | aligns[i] = tableAlignLeft | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | return aligns, true | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | func renderTable(block []string, th Theme, maxWidth int) []string { | |
| 248 | + | if len(block) < 2 { | |
| 249 | + | return block | |
| 250 | + | } | |
| 251 | + | aligns, ok := parseTableSeparator(block[1]) | |
| 252 | + | if !ok { | |
| 253 | + | return block | |
| 254 | + | } | |
| 255 | + | rows := make([][]string, 0, len(block)-1) | |
| 256 | + | rows = append(rows, splitTableRow(block[0])) | |
| 257 | + | for _, line := range block[2:] { | |
| 258 | + | cells := splitTableRow(line) | |
| 259 | + | if len(cells) == 0 { | |
| 260 | + | continue | |
| 261 | + | } | |
| 262 | + | rows = append(rows, cells) | |
| 263 | + | } | |
| 264 | + | cols := len(aligns) | |
| 265 | + | widths := make([]int, cols) | |
| 266 | + | rendered := make([][]string, len(rows)) | |
| 267 | + | for r, row := range rows { | |
| 268 | + | rendered[r] = make([]string, cols) | |
| 269 | + | for c := 0; c < cols; c++ { | |
| 270 | + | cell := "" | |
| 271 | + | if c < len(row) { | |
| 272 | + | cell = row[c] | |
| 273 | + | } | |
| 274 | + | styled := renderInline(cell, th) | |
| 275 | + | rendered[r][c] = styled | |
| 276 | + | if w := visibleWidth(styled); w > widths[c] { | |
| 277 | + | widths[c] = w | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | for i := range widths { | |
| 282 | + | if widths[i] < 3 { | |
| 283 | + | widths[i] = 3 | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | fitTableWidths(widths, maxWidth) | |
| 287 | + | ||
| 288 | + | out := make([]string, 0, len(rows)+1) | |
| 289 | + | out = append(out, renderTableRow(rendered[0], widths, aligns, th, true)...) | |
| 290 | + | out = append(out, renderTableSeparator(widths, aligns, th)) | |
| 291 | + | for _, row := range rendered[1:] { | |
| 292 | + | out = append(out, renderTableRow(row, widths, aligns, th, false)...) | |
| 293 | + | } | |
| 294 | + | return out | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func fitTableWidths(widths []int, maxWidth int) { | |
| 298 | + | cols := len(widths) | |
| 299 | + | if cols == 0 || maxWidth <= 0 { | |
| 300 | + | return | |
| 301 | + | } | |
| 302 | + | // Each column contributes one leading and one trailing space; | |
| 303 | + | // there are cols+1 pipe separators. The rest is cell content. | |
| 304 | + | avail := maxWidth - (cols + 1) - 2*cols | |
| 305 | + | if avail < cols*3 { | |
| 306 | + | avail = cols * 3 | |
| 307 | + | } | |
| 308 | + | for sumInts(widths) > avail { | |
| 309 | + | idx := widestColumn(widths) | |
| 310 | + | if idx < 0 || widths[idx] <= 3 { | |
| 311 | + | return | |
| 312 | + | } | |
| 313 | + | widths[idx]-- | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | func sumInts(xs []int) int { | |
| 318 | + | total := 0 | |
| 319 | + | for _, x := range xs { | |
| 320 | + | total += x | |
| 321 | + | } | |
| 322 | + | return total | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | func widestColumn(widths []int) int { | |
| 326 | + | idx := -1 | |
| 327 | + | best := 0 | |
| 328 | + | for i, w := range widths { | |
| 329 | + | if w > best { | |
| 330 | + | idx = i | |
| 331 | + | best = w | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | return idx | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | func renderTableRow(row []string, widths []int, aligns []tableAlign, th Theme, header bool) []string { | |
| 338 | + | wrapped := make([][]string, len(widths)) | |
| 339 | + | height := 1 | |
| 340 | + | for c := range widths { | |
| 341 | + | cell := "" | |
| 342 | + | if c < len(row) { | |
| 343 | + | cell = row[c] | |
| 344 | + | } | |
| 345 | + | parts := wrapANSILine(cell, widths[c]) | |
| 346 | + | if len(parts) == 0 { | |
| 347 | + | parts = []string{""} | |
| 348 | + | } | |
| 349 | + | if header { | |
| 350 | + | for i := range parts { | |
| 351 | + | parts[i] = Bold(parts[i]) | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | wrapped[c] = parts | |
| 355 | + | if len(parts) > height { | |
| 356 | + | height = len(parts) | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | out := make([]string, 0, height) | |
| 361 | + | for r := 0; r < height; r++ { | |
| 362 | + | var b strings.Builder | |
| 363 | + | b.WriteString(th.FG256(th.Muted, "|")) | |
| 364 | + | for c := range widths { | |
| 365 | + | cell := "" | |
| 366 | + | if r < len(wrapped[c]) { | |
| 367 | + | cell = wrapped[c][r] | |
| 368 | + | } | |
| 369 | + | b.WriteByte(' ') | |
| 370 | + | b.WriteString(alignCell(cell, widths[c], aligns[c])) | |
| 371 | + | b.WriteByte(' ') | |
| 372 | + | b.WriteString(th.FG256(th.Muted, "|")) | |
| 373 | + | } | |
| 374 | + | out = append(out, b.String()) | |
| 375 | + | } | |
| 376 | + | return out | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | func renderTableSeparator(widths []int, aligns []tableAlign, th Theme) string { | |
| 380 | + | var b strings.Builder | |
| 381 | + | b.WriteString(th.FG256(th.Muted, "|")) | |
| 382 | + | for c, w := range widths { | |
| 383 | + | dashes := strings.Repeat("-", w) | |
| 384 | + | switch aligns[c] { | |
| 385 | + | case tableAlignCenter: | |
| 386 | + | dashes = ":" + strings.Repeat("-", maxInt(1, w-2)) + ":" | |
| 387 | + | case tableAlignRight: | |
| 388 | + | dashes = strings.Repeat("-", maxInt(1, w-1)) + ":" | |
| 389 | + | } | |
| 390 | + | b.WriteByte(' ') | |
| 391 | + | b.WriteString(th.FG256(th.Muted, dashes)) | |
| 392 | + | b.WriteByte(' ') | |
| 393 | + | b.WriteString(th.FG256(th.Muted, "|")) | |
| 394 | + | } | |
| 395 | + | return b.String() | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | func alignCell(s string, width int, align tableAlign) string { | |
| 399 | + | pad := width - visibleWidth(s) | |
| 400 | + | if pad <= 0 { | |
| 401 | + | return s | |
| 402 | + | } | |
| 403 | + | switch align { | |
| 404 | + | case tableAlignRight: | |
| 405 | + | return strings.Repeat(" ", pad) + s | |
| 406 | + | case tableAlignCenter: | |
| 407 | + | left := pad / 2 | |
| 408 | + | right := pad - left | |
| 409 | + | return strings.Repeat(" ", left) + s + strings.Repeat(" ", right) | |
| 410 | + | default: | |
| 411 | + | return s + strings.Repeat(" ", pad) | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | func maxInt(a, b int) int { | |
| 416 | + | if a > b { | |
| 417 | + | return a | |
| 418 | + | } | |
| 419 | + | return b | |
| 420 | + | } | |
| 421 | + | ||
| 133 | 422 | func renderInline(s string, th Theme) string { | |
| 134 | 423 | s = codeRE.ReplaceAllStringFunc(s, func(m string) string { | |
| 135 | 424 | inner := m[1 : len(m)-1] | |
| @@ -14,8 +14,9 @@ const FlushLeftSentinel = '\x1c' | |||||
| 14 | 14 | ||||
| 15 | // RenderMarkdown renders a small subset of Markdown to styled terminal | 15 | // RenderMarkdown renders a small subset of Markdown to styled terminal | ||
| 16 | // text using theme colors. Supported: headings, bold, italic, inline | 16 | // text using theme colors. Supported: headings, bold, italic, inline | ||
| 17 | - | // code, fenced code blocks, bullet lists, numbered lists, blockquotes. | 17 | + | // code, fenced code blocks, bullet lists, numbered lists, blockquotes, |
| 18 | - | // Not supported: tables, links with complex formatting, HTML. | 18 | + | // simple GitHub-style tables. |
| 19 | + | // Not supported: links with complex formatting, HTML. | |||
| 19 | // | 20 | // | ||
| 20 | // width is used to draw horizontal rules (e.g. around code fences). | 21 | // width is used to draw horizontal rules (e.g. around code fences). | ||
| 21 | // Pass 0 to use a reasonable fallback. | 22 | // Pass 0 to use a reasonable fallback. | ||
| @@ -58,7 +59,8 @@ func RenderMarkdown(src string, th Theme, width int) string { | |||||
| 58 | fenceBuf.Reset() | 59 | fenceBuf.Reset() | ||
| 59 | } | 60 | } | ||
| 60 | 61 | ||||
| 61 | - | for _, line := range lines { | 62 | + | for idx := 0; idx < len(lines); idx++ { |
| 63 | + | line := lines[idx] | |||
| 62 | trim := strings.TrimLeft(line, " ") | 64 | trim := strings.TrimLeft(line, " ") | ||
| 63 | if strings.HasPrefix(trim, "```") { | 65 | if strings.HasPrefix(trim, "```") { | ||
| 64 | if inFence { | 66 | if inFence { | ||
| @@ -83,6 +85,24 @@ func RenderMarkdown(src string, th Theme, width int) string { | |||||
| 83 | fenceBuf.WriteString("\n") | 85 | fenceBuf.WriteString("\n") | ||
| 84 | continue | 86 | continue | ||
| 85 | } | 87 | } | ||
| 88 | + | // GitHub-style table blocks. Detect a header row followed by | |||
| 89 | + | // a separator row like "| --- | ---: |" and consume all | |||
| 90 | + | // following pipe rows as table body. Done before inline | |||
| 91 | + | // rendering so cell widths can be measured and padded. | |||
| 92 | + | if idx+1 < len(lines) && looksLikeTableHeader(line, lines[idx+1]) { | |||
| 93 | + | block := []string{line, lines[idx+1]} | |||
| 94 | + | j := idx + 2 | |||
| 95 | + | for j < len(lines) && looksLikeTableRow(lines[j]) { | |||
| 96 | + | block = append(block, lines[j]) | |||
| 97 | + | j++ | |||
| 98 | + | } | |||
| 99 | + | for _, rendered := range renderTable(block, th, width) { | |||
| 100 | + | out.WriteString(rendered) | |||
| 101 | + | out.WriteString("\n") | |||
| 102 | + | } | |||
| 103 | + | idx = j - 1 | |||
| 104 | + | continue | |||
| 105 | + | } | |||
| 86 | // Headings. | 106 | // Headings. | ||
| 87 | if m := headingRE.FindStringSubmatch(line); m != nil { | 107 | if m := headingRE.FindStringSubmatch(line); m != nil { | ||
| 88 | level := len(m[1]) | 108 | level := len(m[1]) | ||
| @@ -121,15 +141,284 @@ func RenderMarkdown(src string, th Theme, width int) string { | |||||
| 121 | } | 141 | } | ||
| 122 | 142 | ||||
| 123 | var ( | 143 | var ( | ||
| 124 | - | headingRE = regexp.MustCompile(`^(#{1,6})\s+(.*)$`) | 144 | + | headingRE = regexp.MustCompile(`^(#{1,6})\s+(.*)$`) |
| 125 | - | bulletRE = regexp.MustCompile(`^(\s*)[-*+]\s+(.*)$`) | 145 | + | bulletRE = regexp.MustCompile(`^(\s*)[-*+]\s+(.*)$`) |
| 126 | - | numberRE = regexp.MustCompile(`^(\s*)(\d+)\.\s+(.*)$`) | 146 | + | numberRE = regexp.MustCompile(`^(\s*)(\d+)\.\s+(.*)$`) |
| 147 | + | tableSepCell = regexp.MustCompile(`^:?-{3,}:?$`) | |||
| 127 | 148 | ||||
| 128 | boldRE = regexp.MustCompile(`\*\*([^*]+)\*\*`) | 149 | boldRE = regexp.MustCompile(`\*\*([^*]+)\*\*`) | ||
| 129 | italRE = regexp.MustCompile(`\*([^*]+)\*`) | 150 | italRE = regexp.MustCompile(`\*([^*]+)\*`) | ||
| 130 | codeRE = regexp.MustCompile("`([^`]+)`") | 151 | codeRE = regexp.MustCompile("`([^`]+)`") | ||
| 131 | ) | 152 | ) | ||
| 132 | 153 | ||||
| 154 | + | type tableAlign int | |||
| 155 | + | ||||
| 156 | + | const ( | |||
| 157 | + | tableAlignLeft tableAlign = iota | |||
| 158 | + | tableAlignCenter | |||
| 159 | + | tableAlignRight | |||
| 160 | + | ) | |||
| 161 | + | ||||
| 162 | + | func looksLikeTableHeader(header, sep string) bool { | |||
| 163 | + | h := splitTableRow(header) | |||
| 164 | + | s, ok := parseTableSeparator(sep) | |||
| 165 | + | return ok && len(h) >= 2 && len(s) == len(h) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func looksLikeTableRow(line string) bool { | |||
| 169 | + | cells := splitTableRow(line) | |||
| 170 | + | return len(cells) >= 2 | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func splitTableRow(line string) []string { | |||
| 174 | + | line = strings.TrimSpace(line) | |||
| 175 | + | if !strings.Contains(line, "|") { | |||
| 176 | + | return nil | |||
| 177 | + | } | |||
| 178 | + | if strings.HasPrefix(line, "|") { | |||
| 179 | + | line = strings.TrimPrefix(line, "|") | |||
| 180 | + | } | |||
| 181 | + | if strings.HasSuffix(line, "|") { | |||
| 182 | + | line = strings.TrimSuffix(line, "|") | |||
| 183 | + | } | |||
| 184 | + | parts := splitUnescapedPipes(line) | |||
| 185 | + | for i := range parts { | |||
| 186 | + | parts[i] = strings.TrimSpace(strings.ReplaceAll(parts[i], `\|`, "|")) | |||
| 187 | + | } | |||
| 188 | + | return parts | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func splitUnescapedPipes(s string) []string { | |||
| 192 | + | var parts []string | |||
| 193 | + | var b strings.Builder | |||
| 194 | + | escaped := false | |||
| 195 | + | for _, r := range s { | |||
| 196 | + | if escaped { | |||
| 197 | + | if r != '|' { | |||
| 198 | + | b.WriteRune('\\') | |||
| 199 | + | } | |||
| 200 | + | b.WriteRune(r) | |||
| 201 | + | escaped = false | |||
| 202 | + | continue | |||
| 203 | + | } | |||
| 204 | + | if r == '\\' { | |||
| 205 | + | escaped = true | |||
| 206 | + | continue | |||
| 207 | + | } | |||
| 208 | + | if r == '|' { | |||
| 209 | + | parts = append(parts, b.String()) | |||
| 210 | + | b.Reset() | |||
| 211 | + | continue | |||
| 212 | + | } | |||
| 213 | + | b.WriteRune(r) | |||
| 214 | + | } | |||
| 215 | + | if escaped { | |||
| 216 | + | b.WriteRune('\\') | |||
| 217 | + | } | |||
| 218 | + | parts = append(parts, b.String()) | |||
| 219 | + | return parts | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func parseTableSeparator(line string) ([]tableAlign, bool) { | |||
| 223 | + | cells := splitTableRow(line) | |||
| 224 | + | if len(cells) < 2 { | |||
| 225 | + | return nil, false | |||
| 226 | + | } | |||
| 227 | + | aligns := make([]tableAlign, len(cells)) | |||
| 228 | + | for i, cell := range cells { | |||
| 229 | + | compact := strings.ReplaceAll(strings.TrimSpace(cell), " ", "") | |||
| 230 | + | if !tableSepCell.MatchString(compact) { | |||
| 231 | + | return nil, false | |||
| 232 | + | } | |||
| 233 | + | left := strings.HasPrefix(compact, ":") | |||
| 234 | + | right := strings.HasSuffix(compact, ":") | |||
| 235 | + | switch { | |||
| 236 | + | case left && right: | |||
| 237 | + | aligns[i] = tableAlignCenter | |||
| 238 | + | case right: | |||
| 239 | + | aligns[i] = tableAlignRight | |||
| 240 | + | default: | |||
| 241 | + | aligns[i] = tableAlignLeft | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | return aligns, true | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | func renderTable(block []string, th Theme, maxWidth int) []string { | |||
| 248 | + | if len(block) < 2 { | |||
| 249 | + | return block | |||
| 250 | + | } | |||
| 251 | + | aligns, ok := parseTableSeparator(block[1]) | |||
| 252 | + | if !ok { | |||
| 253 | + | return block | |||
| 254 | + | } | |||
| 255 | + | rows := make([][]string, 0, len(block)-1) | |||
| 256 | + | rows = append(rows, splitTableRow(block[0])) | |||
| 257 | + | for _, line := range block[2:] { | |||
| 258 | + | cells := splitTableRow(line) | |||
| 259 | + | if len(cells) == 0 { | |||
| 260 | + | continue | |||
| 261 | + | } | |||
| 262 | + | rows = append(rows, cells) | |||
| 263 | + | } | |||
| 264 | + | cols := len(aligns) | |||
| 265 | + | widths := make([]int, cols) | |||
| 266 | + | rendered := make([][]string, len(rows)) | |||
| 267 | + | for r, row := range rows { | |||
| 268 | + | rendered[r] = make([]string, cols) | |||
| 269 | + | for c := 0; c < cols; c++ { | |||
| 270 | + | cell := "" | |||
| 271 | + | if c < len(row) { | |||
| 272 | + | cell = row[c] | |||
| 273 | + | } | |||
| 274 | + | styled := renderInline(cell, th) | |||
| 275 | + | rendered[r][c] = styled | |||
| 276 | + | if w := visibleWidth(styled); w > widths[c] { | |||
| 277 | + | widths[c] = w | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | for i := range widths { | |||
| 282 | + | if widths[i] < 3 { | |||
| 283 | + | widths[i] = 3 | |||
| 284 | + | } | |||
| 285 | + | } | |||
| 286 | + | fitTableWidths(widths, maxWidth) | |||
| 287 | + | ||||
| 288 | + | out := make([]string, 0, len(rows)+1) | |||
| 289 | + | out = append(out, renderTableRow(rendered[0], widths, aligns, th, true)...) | |||
| 290 | + | out = append(out, renderTableSeparator(widths, aligns, th)) | |||
| 291 | + | for _, row := range rendered[1:] { | |||
| 292 | + | out = append(out, renderTableRow(row, widths, aligns, th, false)...) | |||
| 293 | + | } | |||
| 294 | + | return out | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func fitTableWidths(widths []int, maxWidth int) { | |||
| 298 | + | cols := len(widths) | |||
| 299 | + | if cols == 0 || maxWidth <= 0 { | |||
| 300 | + | return | |||
| 301 | + | } | |||
| 302 | + | // Each column contributes one leading and one trailing space; | |||
| 303 | + | // there are cols+1 pipe separators. The rest is cell content. | |||
| 304 | + | avail := maxWidth - (cols + 1) - 2*cols | |||
| 305 | + | if avail < cols*3 { | |||
| 306 | + | avail = cols * 3 | |||
| 307 | + | } | |||
| 308 | + | for sumInts(widths) > avail { | |||
| 309 | + | idx := widestColumn(widths) | |||
| 310 | + | if idx < 0 || widths[idx] <= 3 { | |||
| 311 | + | return | |||
| 312 | + | } | |||
| 313 | + | widths[idx]-- | |||
| 314 | + | } | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | func sumInts(xs []int) int { | |||
| 318 | + | total := 0 | |||
| 319 | + | for _, x := range xs { | |||
| 320 | + | total += x | |||
| 321 | + | } | |||
| 322 | + | return total | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | func widestColumn(widths []int) int { | |||
| 326 | + | idx := -1 | |||
| 327 | + | best := 0 | |||
| 328 | + | for i, w := range widths { | |||
| 329 | + | if w > best { | |||
| 330 | + | idx = i | |||
| 331 | + | best = w | |||
| 332 | + | } | |||
| 333 | + | } | |||
| 334 | + | return idx | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | func renderTableRow(row []string, widths []int, aligns []tableAlign, th Theme, header bool) []string { | |||
| 338 | + | wrapped := make([][]string, len(widths)) | |||
| 339 | + | height := 1 | |||
| 340 | + | for c := range widths { | |||
| 341 | + | cell := "" | |||
| 342 | + | if c < len(row) { | |||
| 343 | + | cell = row[c] | |||
| 344 | + | } | |||
| 345 | + | parts := wrapANSILine(cell, widths[c]) | |||
| 346 | + | if len(parts) == 0 { | |||
| 347 | + | parts = []string{""} | |||
| 348 | + | } | |||
| 349 | + | if header { | |||
| 350 | + | for i := range parts { | |||
| 351 | + | parts[i] = Bold(parts[i]) | |||
| 352 | + | } | |||
| 353 | + | } | |||
| 354 | + | wrapped[c] = parts | |||
| 355 | + | if len(parts) > height { | |||
| 356 | + | height = len(parts) | |||
| 357 | + | } | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | out := make([]string, 0, height) | |||
| 361 | + | for r := 0; r < height; r++ { | |||
| 362 | + | var b strings.Builder | |||
| 363 | + | b.WriteString(th.FG256(th.Muted, "|")) | |||
| 364 | + | for c := range widths { | |||
| 365 | + | cell := "" | |||
| 366 | + | if r < len(wrapped[c]) { | |||
| 367 | + | cell = wrapped[c][r] | |||
| 368 | + | } | |||
| 369 | + | b.WriteByte(' ') | |||
| 370 | + | b.WriteString(alignCell(cell, widths[c], aligns[c])) | |||
| 371 | + | b.WriteByte(' ') | |||
| 372 | + | b.WriteString(th.FG256(th.Muted, "|")) | |||
| 373 | + | } | |||
| 374 | + | out = append(out, b.String()) | |||
| 375 | + | } | |||
| 376 | + | return out | |||
| 377 | + | } | |||
| 378 | + | ||||
| 379 | + | func renderTableSeparator(widths []int, aligns []tableAlign, th Theme) string { | |||
| 380 | + | var b strings.Builder | |||
| 381 | + | b.WriteString(th.FG256(th.Muted, "|")) | |||
| 382 | + | for c, w := range widths { | |||
| 383 | + | dashes := strings.Repeat("-", w) | |||
| 384 | + | switch aligns[c] { | |||
| 385 | + | case tableAlignCenter: | |||
| 386 | + | dashes = ":" + strings.Repeat("-", maxInt(1, w-2)) + ":" | |||
| 387 | + | case tableAlignRight: | |||
| 388 | + | dashes = strings.Repeat("-", maxInt(1, w-1)) + ":" | |||
| 389 | + | } | |||
| 390 | + | b.WriteByte(' ') | |||
| 391 | + | b.WriteString(th.FG256(th.Muted, dashes)) | |||
| 392 | + | b.WriteByte(' ') | |||
| 393 | + | b.WriteString(th.FG256(th.Muted, "|")) | |||
| 394 | + | } | |||
| 395 | + | return b.String() | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | func alignCell(s string, width int, align tableAlign) string { | |||
| 399 | + | pad := width - visibleWidth(s) | |||
| 400 | + | if pad <= 0 { | |||
| 401 | + | return s | |||
| 402 | + | } | |||
| 403 | + | switch align { | |||
| 404 | + | case tableAlignRight: | |||
| 405 | + | return strings.Repeat(" ", pad) + s | |||
| 406 | + | case tableAlignCenter: | |||
| 407 | + | left := pad / 2 | |||
| 408 | + | right := pad - left | |||
| 409 | + | return strings.Repeat(" ", left) + s + strings.Repeat(" ", right) | |||
| 410 | + | default: | |||
| 411 | + | return s + strings.Repeat(" ", pad) | |||
| 412 | + | } | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | func maxInt(a, b int) int { | |||
| 416 | + | if a > b { | |||
| 417 | + | return a | |||
| 418 | + | } | |||
| 419 | + | return b | |||
| 420 | + | } | |||
| 421 | + | ||||
| 133 | func renderInline(s string, th Theme) string { | 422 | func renderInline(s string, th Theme) string { | ||
| 134 | s = codeRE.ReplaceAllStringFunc(s, func(m string) string { | 423 | s = codeRE.ReplaceAllStringFunc(s, func(m string) string { | ||
| 135 | inner := m[1 : len(m)-1] | 424 | inner := m[1 : len(m)-1] | ||
A internal/tui/markdown_test.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestRenderMarkdownTableWrapsToWidth(t *testing.T) { | |
| 9 | + | in := strings.Join([]string{ | |
| 10 | + | "| Area | What’s visible | Summary |", | |
| 11 | + | "| --- | --- | --- |", | |
| 12 | + | "| Overall UI | Dark terminal/TUI-style interface | A screenshot of an AI/coding-agent session, likely inside zot or a similar terminal app. |", | |
| 13 | + | "| Main content | Markdown-formatted response | The assistant is describing what was seen in a previous screenshot. |", | |
| 14 | + | }, "\n") | |
| 15 | + | out := RenderMarkdown(in, Dark, 80) | |
| 16 | + | plain := stripANSI(out) | |
| 17 | + | lines := strings.Split(plain, "\n") | |
| 18 | + | if len(lines) <= 4 { | |
| 19 | + | t.Fatalf("expected wrapped table rows, got:\n%s", plain) | |
| 20 | + | } | |
| 21 | + | for i, line := range lines { | |
| 22 | + | if visibleWidth(line) > 80 { | |
| 23 | + | t.Fatalf("line %d width %d > 80: %q\n%s", i, visibleWidth(line), line, plain) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | want := pipePositions(lines[0]) | |
| 27 | + | for i, line := range lines[1:] { | |
| 28 | + | if got := pipePositions(line); !equalInts(got, want) { | |
| 29 | + | t.Fatalf("line %d pipe columns %v, want %v: %q\n%s", i+1, got, want, line, plain) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestRenderMarkdownTableAlignsColumns(t *testing.T) { | |
| 35 | + | in := strings.Join([]string{ | |
| 36 | + | "| Tool | Core-Sprache | Runtime/Distribution |", | |
| 37 | + | "| --- | --- | --- |", | |
| 38 | + | "| Claude Code | TypeScript | Node.js |", | |
| 39 | + | "| Codex CLI | Rust | Natives Binary (npm-Wrapper) |", | |
| 40 | + | "| OpenCode | TypeScript (+ Go für TUI) | Bun, kompiliertes Binary |", | |
| 41 | + | "| zot | Go | Natives Binary |", | |
| 42 | + | }, "\n") | |
| 43 | + | out := RenderMarkdown(in, Dark, 80) | |
| 44 | + | plain := stripANSI(out) | |
| 45 | + | lines := strings.Split(plain, "\n") | |
| 46 | + | if len(lines) != 6 { | |
| 47 | + | t.Fatalf("got %d lines:\n%s", len(lines), plain) | |
| 48 | + | } | |
| 49 | + | counts := make([]int, len(lines)) | |
| 50 | + | for i, line := range lines { | |
| 51 | + | counts[i] = strings.Count(line, "|") | |
| 52 | + | if counts[i] != 4 { | |
| 53 | + | t.Fatalf("line %d has %d pipes, want 4: %q\n%s", i, counts[i], line, plain) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | // Every pipe column should line up across all rows. | |
| 57 | + | want := pipePositions(lines[0]) | |
| 58 | + | for i, line := range lines[1:] { | |
| 59 | + | if got := pipePositions(line); !equalInts(got, want) { | |
| 60 | + | t.Fatalf("line %d pipe columns %v, want %v: %q\n%s", i+1, got, want, line, plain) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func pipePositions(s string) []int { | |
| 66 | + | var out []int | |
| 67 | + | for i, r := range []rune(s) { | |
| 68 | + | if r == '|' { | |
| 69 | + | out = append(out, i) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | return out | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func equalInts(a, b []int) bool { | |
| 76 | + | if len(a) != len(b) { | |
| 77 | + | return false | |
| 78 | + | } | |
| 79 | + | for i := range a { | |
| 80 | + | if a[i] != b[i] { | |
| 81 | + | return false | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | return true | |
| 85 | + | } | |
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestRenderMarkdownTableWrapsToWidth(t *testing.T) { | |||
| 9 | + | in := strings.Join([]string{ | |||
| 10 | + | "| Area | What’s visible | Summary |", | |||
| 11 | + | "| --- | --- | --- |", | |||
| 12 | + | "| Overall UI | Dark terminal/TUI-style interface | A screenshot of an AI/coding-agent session, likely inside zot or a similar terminal app. |", | |||
| 13 | + | "| Main content | Markdown-formatted response | The assistant is describing what was seen in a previous screenshot. |", | |||
| 14 | + | }, "\n") | |||
| 15 | + | out := RenderMarkdown(in, Dark, 80) | |||
| 16 | + | plain := stripANSI(out) | |||
| 17 | + | lines := strings.Split(plain, "\n") | |||
| 18 | + | if len(lines) <= 4 { | |||
| 19 | + | t.Fatalf("expected wrapped table rows, got:\n%s", plain) | |||
| 20 | + | } | |||
| 21 | + | for i, line := range lines { | |||
| 22 | + | if visibleWidth(line) > 80 { | |||
| 23 | + | t.Fatalf("line %d width %d > 80: %q\n%s", i, visibleWidth(line), line, plain) | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | want := pipePositions(lines[0]) | |||
| 27 | + | for i, line := range lines[1:] { | |||
| 28 | + | if got := pipePositions(line); !equalInts(got, want) { | |||
| 29 | + | t.Fatalf("line %d pipe columns %v, want %v: %q\n%s", i+1, got, want, line, plain) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestRenderMarkdownTableAlignsColumns(t *testing.T) { | |||
| 35 | + | in := strings.Join([]string{ | |||
| 36 | + | "| Tool | Core-Sprache | Runtime/Distribution |", | |||
| 37 | + | "| --- | --- | --- |", | |||
| 38 | + | "| Claude Code | TypeScript | Node.js |", | |||
| 39 | + | "| Codex CLI | Rust | Natives Binary (npm-Wrapper) |", | |||
| 40 | + | "| OpenCode | TypeScript (+ Go für TUI) | Bun, kompiliertes Binary |", | |||
| 41 | + | "| zot | Go | Natives Binary |", | |||
| 42 | + | }, "\n") | |||
| 43 | + | out := RenderMarkdown(in, Dark, 80) | |||
| 44 | + | plain := stripANSI(out) | |||
| 45 | + | lines := strings.Split(plain, "\n") | |||
| 46 | + | if len(lines) != 6 { | |||
| 47 | + | t.Fatalf("got %d lines:\n%s", len(lines), plain) | |||
| 48 | + | } | |||
| 49 | + | counts := make([]int, len(lines)) | |||
| 50 | + | for i, line := range lines { | |||
| 51 | + | counts[i] = strings.Count(line, "|") | |||
| 52 | + | if counts[i] != 4 { | |||
| 53 | + | t.Fatalf("line %d has %d pipes, want 4: %q\n%s", i, counts[i], line, plain) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | // Every pipe column should line up across all rows. | |||
| 57 | + | want := pipePositions(lines[0]) | |||
| 58 | + | for i, line := range lines[1:] { | |||
| 59 | + | if got := pipePositions(line); !equalInts(got, want) { | |||
| 60 | + | t.Fatalf("line %d pipe columns %v, want %v: %q\n%s", i+1, got, want, line, plain) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func pipePositions(s string) []int { | |||
| 66 | + | var out []int | |||
| 67 | + | for i, r := range []rune(s) { | |||
| 68 | + | if r == '|' { | |||
| 69 | + | out = append(out, i) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | return out | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func equalInts(a, b []int) bool { | |||
| 76 | + | if len(a) != len(b) { | |||
| 77 | + | return false | |||
| 78 | + | } | |||
| 79 | + | for i := range a { | |||
| 80 | + | if a[i] != b[i] { | |||
| 81 | + | return false | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | return true | |||
| 85 | + | } | |||