From e4140bce67211be2d428c11cd9405d30363c8b79 Mon Sep 17 00:00:00 2001 From: Luxferre Date: Sat, 15 Aug 2026 09:00:48 +0300 Subject: [PATCH] improved markdown table support for go port --- main.go | 176 ++++++++++++++++++++++++++++++++++++++++++++++++--- main_test.go | 61 ++++++++++++++++++ 2 files changed, 228 insertions(+), 9 deletions(-) diff --git a/main.go b/main.go index 39a87f7..c2fca13 100644 --- a/main.go +++ b/main.go @@ -282,6 +282,117 @@ func isTableLine(line string) bool { return strings.HasPrefix(trimmed, "|") || strings.HasSuffix(trimmed, "|") } +func wrapCell(s string, width int) []string { + if width <= 0 { return []string{""} } + s = strings.ReplaceAll(s, "
", "\n") + s = strings.ReplaceAll(s, "
", "\n") + s = strings.ReplaceAll(s, "
", "\n") + var lines []string + for _, p := range strings.Split(s, "\n") { + p = strings.TrimSpace(p) + if p == "" { + lines = append(lines, "") + continue + } + if visibleLen(p) <= width { + lines = append(lines, p) + continue + } + words := strings.Fields(p) + if len(words) == 0 { + lines = append(lines, "") + continue + } + var curLine string + var activeEsc string + updateActiveEsc := func(token string) { + for i := 0; i < len(token); { + if token[i] == 0x1b { + j := i + 1 + if j < len(token) && token[j] == '[' { + j++ + for j < len(token) && !(token[j] >= 0x40 && token[j] <= 0x7e) { j++ } + if j < len(token) { j++ } + } + esc := token[i:j] + if esc == "\033[0m" || esc == "\033[m" { + activeEsc = "" + } else { + activeEsc = esc + } + i = j + } else { + i++ + } + } + } + flushLine := func() { + if curLine != "" { + out := curLine + if COL && activeEsc != "" && !strings.HasSuffix(out, "\033[0m") { + out += "\033[0m" + } + lines = append(lines, out) + curLine = "" + } + } + for _, word := range words { + wLen := visibleLen(word) + if wLen > width { + flushLine() + var chunk strings.Builder + cLen := 0 + for i := 0; i < len(word); { + if word[i] == 0x1b { + j := i + 1 + if j < len(word) && word[j] == '[' { + j++ + for j < len(word) && !(word[j] >= 0x40 && word[j] <= 0x7e) { j++ } + if j < len(word) { j++ } + } + chunk.WriteString(word[i:j]) + updateActiveEsc(word[i:j]) + i = j + continue + } + r, size := utf8.DecodeRuneInString(word[i:]) + if cLen >= width { + if COL && activeEsc != "" { chunk.WriteString("\033[0m") } + lines = append(lines, chunk.String()) + chunk.Reset() + if COL && activeEsc != "" { chunk.WriteString(activeEsc) } + cLen = 0 + } + chunk.WriteRune(r) + cLen++ + i += size + } + if chunk.Len() > 0 { curLine = chunk.String() } + continue + } + cLen := visibleLen(curLine) + if curLine == "" { + curLine = word + updateActiveEsc(word) + } else if cLen+1+wLen <= width { + curLine += " " + word + updateActiveEsc(word) + } else { + flushLine() + if COL && activeEsc != "" { + curLine = activeEsc + word + } else { + curLine = word + } + updateActiveEsc(word) + } + } + flushLine() + } + if len(lines) == 0 { lines = []string{""} } + return lines +} + func renderTable(lines []string) []string { if len(lines) == 0 { return nil } var rows [][]string @@ -334,6 +445,29 @@ func renderTable(lines []string) []string { if colWidths[i] < 3 { colWidths[i] = 3 } } + maxTableWidth := termWidth() + if maxTableWidth < 20 { maxTableWidth = 80 } + overhead := 3*numCols + 1 + availContent := maxTableWidth - overhead + if availContent < numCols*3 { availContent = numCols * 3 } + + tot := 0 + for _, w := range colWidths { tot += w } + + for tot > availContent { + maxIdx := 0 + maxVal := colWidths[0] + for i := 1; i < numCols; i++ { + if colWidths[i] > maxVal { + maxVal = colWidths[i] + maxIdx = i + } + } + if maxVal <= 3 { break } + colWidths[maxIdx]-- + tot-- + } + var res []string var topParts []string @@ -341,13 +475,25 @@ func renderTable(lines []string) []string { res = append(res, c("┌"+strings.Join(topParts, "┬")+"┐", 2)) if hasHeader { - var hCells []string + headerCols := make([][]string, numCols) + maxHeaderLines := 1 for i, h := range headerRow { - rh := c(h, 1, 36) - pad := strings.Repeat(" ", colWidths[i]-visibleLen(h)) - hCells = append(hCells, " "+rh+pad+" ") + wrapped := wrapCell(h, colWidths[i]) + if len(wrapped) > maxHeaderLines { maxHeaderLines = len(wrapped) } + headerCols[i] = wrapped + } + + for lineIdx := 0; lineIdx < maxHeaderLines; lineIdx++ { + var hCells []string + for i := 0; i < numCols; i++ { + txt := "" + if lineIdx < len(headerCols[i]) { txt = headerCols[i][lineIdx] } + rh := c(txt, 1, 36) + pad := strings.Repeat(" ", colWidths[i]-visibleLen(txt)) + hCells = append(hCells, " "+rh+pad+" ") + } + res = append(res, c("│", 2)+strings.Join(hCells, c("│", 2))+c("│", 2)) } - res = append(res, c("│", 2)+strings.Join(hCells, c("│", 2))+c("│", 2)) var midParts []string for _, w := range colWidths { midParts = append(midParts, strings.Repeat("─", w+2)) } @@ -355,13 +501,25 @@ func renderTable(lines []string) []string { } for _, r := range rows { - var rCells []string + rowCols := make([][]string, numCols) + maxRowLines := 1 for i, cell := range r { rc := renderInline(cell) - pad := strings.Repeat(" ", colWidths[i]-visibleLen(rc)) - rCells = append(rCells, " "+rc+pad+" ") + wrapped := wrapCell(rc, colWidths[i]) + if len(wrapped) > maxRowLines { maxRowLines = len(wrapped) } + rowCols[i] = wrapped + } + + for lineIdx := 0; lineIdx < maxRowLines; lineIdx++ { + var rCells []string + for i := 0; i < numCols; i++ { + txt := "" + if lineIdx < len(rowCols[i]) { txt = rowCols[i][lineIdx] } + pad := strings.Repeat(" ", colWidths[i]-visibleLen(txt)) + rCells = append(rCells, " "+txt+pad+" ") + } + res = append(res, c("│", 2)+strings.Join(rCells, c("│", 2))+c("│", 2)) } - res = append(res, c("│", 2)+strings.Join(rCells, c("│", 2))+c("│", 2)) } var botParts []string diff --git a/main_test.go b/main_test.go index 572da42..aa54813 100644 --- a/main_test.go +++ b/main_test.go @@ -1519,6 +1519,67 @@ func TestRenderTableFormatting(t *testing.T) { } } +func TestWrapCell(t *testing.T) { + COL = true + defer func() { COL = false }() + + lines := wrapCell("Short text", 20) + if len(lines) != 1 || lines[0] != "Short text" { + t.Errorf("wrapCell short = %v", lines) + } + + lines = wrapCell("The quick brown fox jumps over the lazy dog", 15) + if len(lines) < 3 { + t.Errorf("wrapCell long = %v", lines) + } + for _, l := range lines { + if visibleLen(l) > 15 { + t.Errorf("line %q visible length = %d > 15", l, visibleLen(l)) + } + } + + // With ANSI escape codes + styled := "\033[1;36mAlice In Wonderland\033[0m" + lines = wrapCell(styled, 10) + if len(lines) != 2 { + t.Errorf("wrapCell styled count = %d, lines = %v", len(lines), lines) + } + for _, l := range lines { + if visibleLen(l) > 10 { + t.Errorf("styled line %q length = %d > 10", l, visibleLen(l)) + } + } +} + +func TestRenderTableWidthConstraint(t *testing.T) { + COL = true + defer func() { COL = false }() + + // Create an extra-wide table with long text + raw := strings.Join([]string{ + "| Long Column Header One | Extremely Long Column Header Two That Would Definitely Overflow | Another Very Wide Column Header Three |", + "|---|---|---|", + "| Some detailed explanation that is very long and has lots of words in it | Another paragraph of text that continues on and on without stopping | Final column with even more long descriptions |", + }, "\n") + + rendered := renderMD(raw) + lines := strings.Split(rendered, "\n") + maxW := termWidth() + if maxW < 20 { maxW = 80 } + + for i, ln := range lines { + vl := visibleLen(ln) + if vl > maxW { + t.Errorf("table line %d visible length = %d, exceeds max width %d:\n%s", i, vl, maxW, ln) + } + } + + // Ensure content was wrapped rather than dropped + if !strings.Contains(rendered, "detailed") || !strings.Contains(rendered, "explanation") || !strings.Contains(rendered, "paragraph") { + t.Errorf("rendered table should contain all words wrapped across lines:\n%s", rendered) + } +} + func TestRenderMDNoColorFallback(t *testing.T) { COL = false raw := "# Heading\n**bold** and `code`\n- list item"