mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
tui: render markdown tables
This commit is contained in:
parent
266b1a52a4
commit
a40d1cf56c
2 changed files with 380 additions and 6 deletions
|
|
@ -14,8 +14,9 @@ const FlushLeftSentinel = '\x1c'
|
|||
|
||||
// RenderMarkdown renders a small subset of Markdown to styled terminal
|
||||
// text using theme colors. Supported: headings, bold, italic, inline
|
||||
// code, fenced code blocks, bullet lists, numbered lists, blockquotes.
|
||||
// Not supported: tables, links with complex formatting, HTML.
|
||||
// code, fenced code blocks, bullet lists, numbered lists, blockquotes,
|
||||
// simple GitHub-style tables.
|
||||
// Not supported: links with complex formatting, HTML.
|
||||
//
|
||||
// width is used to draw horizontal rules (e.g. around code fences).
|
||||
// Pass 0 to use a reasonable fallback.
|
||||
|
|
@ -58,7 +59,8 @@ func RenderMarkdown(src string, th Theme, width int) string {
|
|||
fenceBuf.Reset()
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
for idx := 0; idx < len(lines); idx++ {
|
||||
line := lines[idx]
|
||||
trim := strings.TrimLeft(line, " ")
|
||||
if strings.HasPrefix(trim, "```") {
|
||||
if inFence {
|
||||
|
|
@ -83,6 +85,24 @@ func RenderMarkdown(src string, th Theme, width int) string {
|
|||
fenceBuf.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
// GitHub-style table blocks. Detect a header row followed by
|
||||
// a separator row like "| --- | ---: |" and consume all
|
||||
// following pipe rows as table body. Done before inline
|
||||
// rendering so cell widths can be measured and padded.
|
||||
if idx+1 < len(lines) && looksLikeTableHeader(line, lines[idx+1]) {
|
||||
block := []string{line, lines[idx+1]}
|
||||
j := idx + 2
|
||||
for j < len(lines) && looksLikeTableRow(lines[j]) {
|
||||
block = append(block, lines[j])
|
||||
j++
|
||||
}
|
||||
for _, rendered := range renderTable(block, th, width) {
|
||||
out.WriteString(rendered)
|
||||
out.WriteString("\n")
|
||||
}
|
||||
idx = j - 1
|
||||
continue
|
||||
}
|
||||
// Headings.
|
||||
if m := headingRE.FindStringSubmatch(line); m != nil {
|
||||
level := len(m[1])
|
||||
|
|
@ -121,15 +141,284 @@ func RenderMarkdown(src string, th Theme, width int) string {
|
|||
}
|
||||
|
||||
var (
|
||||
headingRE = regexp.MustCompile(`^(#{1,6})\s+(.*)$`)
|
||||
bulletRE = regexp.MustCompile(`^(\s*)[-*+]\s+(.*)$`)
|
||||
numberRE = regexp.MustCompile(`^(\s*)(\d+)\.\s+(.*)$`)
|
||||
headingRE = regexp.MustCompile(`^(#{1,6})\s+(.*)$`)
|
||||
bulletRE = regexp.MustCompile(`^(\s*)[-*+]\s+(.*)$`)
|
||||
numberRE = regexp.MustCompile(`^(\s*)(\d+)\.\s+(.*)$`)
|
||||
tableSepCell = regexp.MustCompile(`^:?-{3,}:?$`)
|
||||
|
||||
boldRE = regexp.MustCompile(`\*\*([^*]+)\*\*`)
|
||||
italRE = regexp.MustCompile(`\*([^*]+)\*`)
|
||||
codeRE = regexp.MustCompile("`([^`]+)`")
|
||||
)
|
||||
|
||||
type tableAlign int
|
||||
|
||||
const (
|
||||
tableAlignLeft tableAlign = iota
|
||||
tableAlignCenter
|
||||
tableAlignRight
|
||||
)
|
||||
|
||||
func looksLikeTableHeader(header, sep string) bool {
|
||||
h := splitTableRow(header)
|
||||
s, ok := parseTableSeparator(sep)
|
||||
return ok && len(h) >= 2 && len(s) == len(h)
|
||||
}
|
||||
|
||||
func looksLikeTableRow(line string) bool {
|
||||
cells := splitTableRow(line)
|
||||
return len(cells) >= 2
|
||||
}
|
||||
|
||||
func splitTableRow(line string) []string {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.Contains(line, "|") {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(line, "|") {
|
||||
line = strings.TrimPrefix(line, "|")
|
||||
}
|
||||
if strings.HasSuffix(line, "|") {
|
||||
line = strings.TrimSuffix(line, "|")
|
||||
}
|
||||
parts := splitUnescapedPipes(line)
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(strings.ReplaceAll(parts[i], `\|`, "|"))
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func splitUnescapedPipes(s string) []string {
|
||||
var parts []string
|
||||
var b strings.Builder
|
||||
escaped := false
|
||||
for _, r := range s {
|
||||
if escaped {
|
||||
if r != '|' {
|
||||
b.WriteRune('\\')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if r == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if r == '|' {
|
||||
parts = append(parts, b.String())
|
||||
b.Reset()
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
if escaped {
|
||||
b.WriteRune('\\')
|
||||
}
|
||||
parts = append(parts, b.String())
|
||||
return parts
|
||||
}
|
||||
|
||||
func parseTableSeparator(line string) ([]tableAlign, bool) {
|
||||
cells := splitTableRow(line)
|
||||
if len(cells) < 2 {
|
||||
return nil, false
|
||||
}
|
||||
aligns := make([]tableAlign, len(cells))
|
||||
for i, cell := range cells {
|
||||
compact := strings.ReplaceAll(strings.TrimSpace(cell), " ", "")
|
||||
if !tableSepCell.MatchString(compact) {
|
||||
return nil, false
|
||||
}
|
||||
left := strings.HasPrefix(compact, ":")
|
||||
right := strings.HasSuffix(compact, ":")
|
||||
switch {
|
||||
case left && right:
|
||||
aligns[i] = tableAlignCenter
|
||||
case right:
|
||||
aligns[i] = tableAlignRight
|
||||
default:
|
||||
aligns[i] = tableAlignLeft
|
||||
}
|
||||
}
|
||||
return aligns, true
|
||||
}
|
||||
|
||||
func renderTable(block []string, th Theme, maxWidth int) []string {
|
||||
if len(block) < 2 {
|
||||
return block
|
||||
}
|
||||
aligns, ok := parseTableSeparator(block[1])
|
||||
if !ok {
|
||||
return block
|
||||
}
|
||||
rows := make([][]string, 0, len(block)-1)
|
||||
rows = append(rows, splitTableRow(block[0]))
|
||||
for _, line := range block[2:] {
|
||||
cells := splitTableRow(line)
|
||||
if len(cells) == 0 {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, cells)
|
||||
}
|
||||
cols := len(aligns)
|
||||
widths := make([]int, cols)
|
||||
rendered := make([][]string, len(rows))
|
||||
for r, row := range rows {
|
||||
rendered[r] = make([]string, cols)
|
||||
for c := 0; c < cols; c++ {
|
||||
cell := ""
|
||||
if c < len(row) {
|
||||
cell = row[c]
|
||||
}
|
||||
styled := renderInline(cell, th)
|
||||
rendered[r][c] = styled
|
||||
if w := visibleWidth(styled); w > widths[c] {
|
||||
widths[c] = w
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range widths {
|
||||
if widths[i] < 3 {
|
||||
widths[i] = 3
|
||||
}
|
||||
}
|
||||
fitTableWidths(widths, maxWidth)
|
||||
|
||||
out := make([]string, 0, len(rows)+1)
|
||||
out = append(out, renderTableRow(rendered[0], widths, aligns, th, true)...)
|
||||
out = append(out, renderTableSeparator(widths, aligns, th))
|
||||
for _, row := range rendered[1:] {
|
||||
out = append(out, renderTableRow(row, widths, aligns, th, false)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fitTableWidths(widths []int, maxWidth int) {
|
||||
cols := len(widths)
|
||||
if cols == 0 || maxWidth <= 0 {
|
||||
return
|
||||
}
|
||||
// Each column contributes one leading and one trailing space;
|
||||
// there are cols+1 pipe separators. The rest is cell content.
|
||||
avail := maxWidth - (cols + 1) - 2*cols
|
||||
if avail < cols*3 {
|
||||
avail = cols * 3
|
||||
}
|
||||
for sumInts(widths) > avail {
|
||||
idx := widestColumn(widths)
|
||||
if idx < 0 || widths[idx] <= 3 {
|
||||
return
|
||||
}
|
||||
widths[idx]--
|
||||
}
|
||||
}
|
||||
|
||||
func sumInts(xs []int) int {
|
||||
total := 0
|
||||
for _, x := range xs {
|
||||
total += x
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func widestColumn(widths []int) int {
|
||||
idx := -1
|
||||
best := 0
|
||||
for i, w := range widths {
|
||||
if w > best {
|
||||
idx = i
|
||||
best = w
|
||||
}
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
func renderTableRow(row []string, widths []int, aligns []tableAlign, th Theme, header bool) []string {
|
||||
wrapped := make([][]string, len(widths))
|
||||
height := 1
|
||||
for c := range widths {
|
||||
cell := ""
|
||||
if c < len(row) {
|
||||
cell = row[c]
|
||||
}
|
||||
parts := wrapANSILine(cell, widths[c])
|
||||
if len(parts) == 0 {
|
||||
parts = []string{""}
|
||||
}
|
||||
if header {
|
||||
for i := range parts {
|
||||
parts[i] = Bold(parts[i])
|
||||
}
|
||||
}
|
||||
wrapped[c] = parts
|
||||
if len(parts) > height {
|
||||
height = len(parts)
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]string, 0, height)
|
||||
for r := 0; r < height; r++ {
|
||||
var b strings.Builder
|
||||
b.WriteString(th.FG256(th.Muted, "|"))
|
||||
for c := range widths {
|
||||
cell := ""
|
||||
if r < len(wrapped[c]) {
|
||||
cell = wrapped[c][r]
|
||||
}
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(alignCell(cell, widths[c], aligns[c]))
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(th.FG256(th.Muted, "|"))
|
||||
}
|
||||
out = append(out, b.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func renderTableSeparator(widths []int, aligns []tableAlign, th Theme) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(th.FG256(th.Muted, "|"))
|
||||
for c, w := range widths {
|
||||
dashes := strings.Repeat("-", w)
|
||||
switch aligns[c] {
|
||||
case tableAlignCenter:
|
||||
dashes = ":" + strings.Repeat("-", maxInt(1, w-2)) + ":"
|
||||
case tableAlignRight:
|
||||
dashes = strings.Repeat("-", maxInt(1, w-1)) + ":"
|
||||
}
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(th.FG256(th.Muted, dashes))
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(th.FG256(th.Muted, "|"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func alignCell(s string, width int, align tableAlign) string {
|
||||
pad := width - visibleWidth(s)
|
||||
if pad <= 0 {
|
||||
return s
|
||||
}
|
||||
switch align {
|
||||
case tableAlignRight:
|
||||
return strings.Repeat(" ", pad) + s
|
||||
case tableAlignCenter:
|
||||
left := pad / 2
|
||||
right := pad - left
|
||||
return strings.Repeat(" ", left) + s + strings.Repeat(" ", right)
|
||||
default:
|
||||
return s + strings.Repeat(" ", pad)
|
||||
}
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func renderInline(s string, th Theme) string {
|
||||
s = codeRE.ReplaceAllStringFunc(s, func(m string) string {
|
||||
inner := m[1 : len(m)-1]
|
||||
|
|
|
|||
85
internal/tui/markdown_test.go
Normal file
85
internal/tui/markdown_test.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderMarkdownTableWrapsToWidth(t *testing.T) {
|
||||
in := strings.Join([]string{
|
||||
"| Area | What’s visible | Summary |",
|
||||
"| --- | --- | --- |",
|
||||
"| Overall UI | Dark terminal/TUI-style interface | A screenshot of an AI/coding-agent session, likely inside zot or a similar terminal app. |",
|
||||
"| Main content | Markdown-formatted response | The assistant is describing what was seen in a previous screenshot. |",
|
||||
}, "\n")
|
||||
out := RenderMarkdown(in, Dark, 80)
|
||||
plain := stripANSI(out)
|
||||
lines := strings.Split(plain, "\n")
|
||||
if len(lines) <= 4 {
|
||||
t.Fatalf("expected wrapped table rows, got:\n%s", plain)
|
||||
}
|
||||
for i, line := range lines {
|
||||
if visibleWidth(line) > 80 {
|
||||
t.Fatalf("line %d width %d > 80: %q\n%s", i, visibleWidth(line), line, plain)
|
||||
}
|
||||
}
|
||||
want := pipePositions(lines[0])
|
||||
for i, line := range lines[1:] {
|
||||
if got := pipePositions(line); !equalInts(got, want) {
|
||||
t.Fatalf("line %d pipe columns %v, want %v: %q\n%s", i+1, got, want, line, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownTableAlignsColumns(t *testing.T) {
|
||||
in := strings.Join([]string{
|
||||
"| Tool | Core-Sprache | Runtime/Distribution |",
|
||||
"| --- | --- | --- |",
|
||||
"| Claude Code | TypeScript | Node.js |",
|
||||
"| Codex CLI | Rust | Natives Binary (npm-Wrapper) |",
|
||||
"| OpenCode | TypeScript (+ Go für TUI) | Bun, kompiliertes Binary |",
|
||||
"| zot | Go | Natives Binary |",
|
||||
}, "\n")
|
||||
out := RenderMarkdown(in, Dark, 80)
|
||||
plain := stripANSI(out)
|
||||
lines := strings.Split(plain, "\n")
|
||||
if len(lines) != 6 {
|
||||
t.Fatalf("got %d lines:\n%s", len(lines), plain)
|
||||
}
|
||||
counts := make([]int, len(lines))
|
||||
for i, line := range lines {
|
||||
counts[i] = strings.Count(line, "|")
|
||||
if counts[i] != 4 {
|
||||
t.Fatalf("line %d has %d pipes, want 4: %q\n%s", i, counts[i], line, plain)
|
||||
}
|
||||
}
|
||||
// Every pipe column should line up across all rows.
|
||||
want := pipePositions(lines[0])
|
||||
for i, line := range lines[1:] {
|
||||
if got := pipePositions(line); !equalInts(got, want) {
|
||||
t.Fatalf("line %d pipe columns %v, want %v: %q\n%s", i+1, got, want, line, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pipePositions(s string) []int {
|
||||
var out []int
|
||||
for i, r := range []rune(s) {
|
||||
if r == '|' {
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func equalInts(a, b []int) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue