1package markdown
2
3import (
4 "io"
5 "strings"
6
7 "github.com/MichaelMure/go-term-text"
8 "github.com/gomarkdown/markdown/ast"
9)
10
11const minColumnCompactedWidth = 5
12
13type tableCell struct {
14 content string
15 alignment ast.CellAlignFlags
16}
17
18type tableRenderer struct {
19 header []tableCell
20 body [][]tableCell
21}
22
23func newTableRenderer() *tableRenderer {
24 return &tableRenderer{}
25}
26
27func (tr *tableRenderer) AddHeaderCell(content string, alignment ast.CellAlignFlags) {
28 tr.header = append(tr.header, tableCell{
29 content: content,
30 alignment: alignment,
31 })
32}
33
34func (tr *tableRenderer) NextBodyRow() {
35 tr.body = append(tr.body, nil)
36}
37
38func (tr *tableRenderer) AddBodyCell(content string) {
39 row := tr.body[len(tr.body)-1]
40 column := len(row)
41 row = append(row, tableCell{
42 content: content,
43 alignment: tr.header[column].alignment,
44 })
45 tr.body[len(tr.body)-1] = row
46}
47
48func (tr *tableRenderer) Render(w io.Writer, leftPad int, lineWidth int) {
49 columnWidths, truncated := tr.columnWidths(lineWidth - leftPad)
50 pad := strings.Repeat(" ", leftPad)
51
52 drawTopLine(w, pad, columnWidths, truncated)
53
54 drawRow(w, pad, tr.header, columnWidths, truncated)
55
56 drawHeaderUnderline(w, pad, columnWidths, truncated)
57
58 for i, row := range tr.body {
59 drawRow(w, pad, row, columnWidths, truncated)
60 if i != len(tr.body)-1 {
61 drawRowLine(w, pad, columnWidths, truncated)
62 }
63 }
64
65 drawBottomLine(w, pad, columnWidths, truncated)
66}
67
68func (tr *tableRenderer) columnWidths(lineWidth int) (widths []int, truncated bool) {
69 maxWidth := make([]int, len(tr.header))
70
71 for i, cell := range tr.header {
72 maxWidth[i] = max(maxWidth[i], text.MaxLineLen(cell.content))
73 }
74
75 for _, row := range tr.body {
76 for i, cell := range row {
77 maxWidth[i] = max(maxWidth[i], text.MaxLineLen(cell.content))
78 }
79 }
80
81 sumWidth := 1
82 minWidth := 1
83 for _, width := range maxWidth {
84 sumWidth += width + 1
85 minWidth += min(width, minColumnCompactedWidth) + 1
86 }
87
88 // Strategy 1: the easy case, content is not large enough to overflow
89 if sumWidth <= lineWidth {
90 return maxWidth, false
91 }
92
93 // Strategy 2: overflow, but still enough room
94 if minWidth < lineWidth {
95 return tr.overflowColumnWidths(lineWidth, maxWidth), false
96 }
97
98 // Strategy 3: too much columns, we need to truncate
99 return tr.truncateColumnWidths(lineWidth, maxWidth), true
100}
101
102func (tr *tableRenderer) overflowColumnWidths(lineWidth int, maxWidth []int) []int {
103 // We have an overflow. First, we take as is the columns that are thinner
104 // than the space equally divided.
105 // Integer division, rounded lower.
106 available := lineWidth - len(tr.header) - 1
107 fairSpace := available / len(tr.header)
108
109 result := make([]int, len(tr.header))
110 remainingColumn := len(tr.header)
111
112 for i, width := range maxWidth {
113 if width <= fairSpace {
114 result[i] = width
115 available -= width
116 remainingColumn--
117 } else {
118 // Mark the column as non-allocated yet
119 result[i] = -1
120 }
121 }
122
123 // Now we allocate evenly the remaining space to the remaining columns
124 for i, width := range result {
125 if width == -1 {
126 width = available / remainingColumn
127 result[i] = width
128 available -= width
129 remainingColumn--
130 }
131 }
132
133 return result
134}
135
136func (tr *tableRenderer) truncateColumnWidths(lineWidth int, maxWidth []int) []int {
137 var result []int
138 used := 1
139
140 // Pack as much column as possible without compacting them too much
141 for _, width := range maxWidth {
142 w := min(width, minColumnCompactedWidth)
143
144 if used+w+1 > lineWidth {
145 return result
146 }
147
148 result = append(result, w)
149 used += w + 1
150 }
151
152 return result
153}
154
155func drawTopLine(w io.Writer, pad string, columnWidths []int, truncated bool) {
156 _, _ = w.Write([]byte(pad))
157 _, _ = w.Write([]byte("┌"))
158 for i, width := range columnWidths {
159 _, _ = w.Write([]byte(strings.Repeat("─", width)))
160 if i != len(columnWidths)-1 {
161 _, _ = w.Write([]byte("┬"))
162 }
163 }
164 _, _ = w.Write([]byte("┐"))
165 if truncated {
166 _, _ = w.Write([]byte("…"))
167 }
168 _, _ = w.Write([]byte("\n"))
169}
170
171func drawHeaderUnderline(w io.Writer, pad string, columnWidths []int, truncated bool) {
172 _, _ = w.Write([]byte(pad))
173 _, _ = w.Write([]byte("╞"))
174 for i, width := range columnWidths {
175 _, _ = w.Write([]byte(strings.Repeat("═", width)))
176 if i != len(columnWidths)-1 {
177 _, _ = w.Write([]byte("╪"))
178 }
179 }
180 _, _ = w.Write([]byte("╡"))
181 if truncated {
182 _, _ = w.Write([]byte("…"))
183 }
184 _, _ = w.Write([]byte("\n"))
185}
186
187func drawBottomLine(w io.Writer, pad string, columnWidths []int, truncated bool) {
188 _, _ = w.Write([]byte(pad))
189 _, _ = w.Write([]byte("└"))
190 for i, width := range columnWidths {
191 _, _ = w.Write([]byte(strings.Repeat("─", width)))
192 if i != len(columnWidths)-1 {
193 _, _ = w.Write([]byte("┴"))
194 }
195 }
196 _, _ = w.Write([]byte("┘"))
197 if truncated {
198 _, _ = w.Write([]byte("…"))
199 }
200 _, _ = w.Write([]byte("\n"))
201}
202
203func drawRowLine(w io.Writer, pad string, columnWidths []int, truncated bool) {
204 _, _ = w.Write([]byte(pad))
205 _, _ = w.Write([]byte("├"))
206 for i, width := range columnWidths {
207 _, _ = w.Write([]byte(strings.Repeat("─", width)))
208 if i != len(columnWidths)-1 {
209 _, _ = w.Write([]byte("┼"))
210 }
211 }
212 _, _ = w.Write([]byte("┤"))
213 if truncated {
214 _, _ = w.Write([]byte("…"))
215 }
216 _, _ = w.Write([]byte("\n"))
217}
218
219func drawRow(w io.Writer, pad string, cells []tableCell, columnWidths []int, truncated bool) {
220 contents := make([][]string, len(cells))
221
222 // As we draw the row line by line, we need a way to reset and recover
223 // the formatting when we alternate between cells. To do that, we accumulate
224 // the ongoing series of ANSI escape sequence for each cell and output them
225 // again each time we switch to the next cell so we end up in the exact same
226 // state. Inefficient but works.
227 formatting := make([]strings.Builder, len(cells))
228
229 accFormatting := func(cellIndex int, items []text.EscapeItem) {
230 for _, item := range items {
231 formatting[cellIndex].WriteString(item.Item)
232 }
233 }
234
235 maxHeight := 0
236
237 // Wrap each cell content into multiple lines, depending on
238 // how wide each cell is.
239 for i, cell := range cells {
240 wrapped, lines := text.Wrap(cell.content, columnWidths[i])
241 contents[i] = strings.Split(wrapped, "\n")
242 maxHeight = max(maxHeight, lines)
243 }
244
245 // Draw the row line by line
246 for i := 0; i < maxHeight; i++ {
247 _, _ = w.Write([]byte(pad))
248 _, _ = w.Write([]byte("│"))
249 for j, width := range columnWidths {
250 content := ""
251 if len(contents[j]) > i {
252 content = contents[j][i]
253 trimmed := text.TrimSpace(content)
254
255 switch cells[j].alignment {
256 case ast.TableAlignmentLeft, 0:
257 _, _ = w.Write([]byte(formatting[j].String()))
258 _, _ = w.Write([]byte(trimmed))
259 _, _ = w.Write([]byte(resetAll))
260 _, _ = w.Write([]byte(strings.Repeat(" ", width-text.Len(trimmed))))
261
262 case ast.TableAlignmentCenter:
263 spaces := width - text.Len(trimmed)
264 _, _ = w.Write([]byte(strings.Repeat(" ", spaces/2)))
265 _, _ = w.Write([]byte(formatting[j].String()))
266 _, _ = w.Write([]byte(trimmed))
267 _, _ = w.Write([]byte(resetAll))
268 _, _ = w.Write([]byte(strings.Repeat(" ", spaces-(spaces/2))))
269
270 case ast.TableAlignmentRight:
271 _, _ = w.Write([]byte(strings.Repeat(" ", width-text.Len(trimmed))))
272 _, _ = w.Write([]byte(formatting[j].String()))
273 _, _ = w.Write([]byte(trimmed))
274 _, _ = w.Write([]byte(resetAll))
275 }
276
277 // extract and accumulate the formatting
278 _, seqs := text.ExtractTermEscapes(content)
279 accFormatting(j, seqs)
280 } else {
281 padding := strings.Repeat(" ", width-text.Len(content))
282 _, _ = w.Write([]byte(padding))
283 }
284 _, _ = w.Write([]byte("│"))
285 }
286 if truncated {
287 _, _ = w.Write([]byte("…"))
288 }
289 _, _ = w.Write([]byte("\n"))
290 }
291}
292
293func min(a, b int) int {
294 if a < b {
295 return a
296 }
297 return b
298}
299
300func max(a, b int) int {
301 if a > b {
302 return a
303 }
304 return b
305}