tables.go

  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}