-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrender.go
More file actions
282 lines (240 loc) · 8.45 KB
/
render.go
File metadata and controls
282 lines (240 loc) · 8.45 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
package tabula
import (
"strings"
"charm.land/lipgloss/v2"
)
// How to handle row rendering
// ===========================
//
// Properties that contribute to the final width of a cell:
// - text content
// - padding times two; defaults to [CellPadding]
// - border characters (optional; shared between adjacent cells)
// - per-column padding (left and right; defaults to [CellPadding])
// - extra padding to fill up the cell (so all cells of that column
// are equally wide)
// - maxGrowth is a special case...
//
// To render the table while also respecting these and the table's constraints,
// the [Table.Render] method follows a three-step algorithm:
//
// 1. Calculate the natural/intrinsic width of each column:
// `naturalWidth = max(contentWidth) + 2*padding + border`
// - where `contentWidth` is the max width across all rows and the header
// - deduplicated borders have a simple cell ownership rule: (a) the
// separator between two cells belong to the *right* cell (its left
// border), (b) the far-right outer border (if any) belongs to the last
// cell
// - `naturalWidth` is calculated for all columns, including maxGrowth
// - the table's minimum width is:
// `fixedWidth = sum(naturalWidths)`
// 2. Distribute the remaining space: `remainingSpace = tableWidth - fixedWidth`
// - if the table has columns with maxGrowth, share the remaining space
// equally between them (give the first columns the extra space if the
// remaining space cannot be divided equally):
// `extraSpacePerColumn = remainingSpace / numMaxGrowthColumns`
// - if there are no maxGrowth columns or remainingSpace <= 0, no extra
// width is distributed
// - if `remainingSpace < 0`, overflow handling is currently TODO
// 3. Actually render the table with the calculated widths for each column
//
// This takes advantage of separating the rendering from the measurement /
// model calculation.
//
// ------------------------------
// naturalWidth calculates the intrinsic width of a column across all rows.
// This means the maximum content width plus padding and borders. The borders
// are deduplicated based on ownership rules. Does NOT include any extra
// padding such as when maxGrowth is enabled.
func (t *Table) naturalWidth(col Column) int {
var maxContentWidth int
// calculate the max content width across all rows for this column
for _, row := range t.rows {
cell := row.cells[col.index]
width := lipgloss.Width(cell)
if width > maxContentWidth {
maxContentWidth = width
}
}
// also consider the header content
if w := lipgloss.Width(col.text); w > maxContentWidth {
maxContentWidth = w
}
// if there's a summary row, also check that one
if t.summary != nil && len(t.summary.cells) >= col.index {
width := lipgloss.Width(t.summary.cells[col.index])
if width > maxContentWidth {
maxContentWidth = width
}
}
var borderWidth int
borderCharWidth := lipgloss.Width(RowBorderCharacter)
if t.ownsLeftBorder(col) {
borderWidth += borderCharWidth
}
if t.ownsRightBorder(col) {
borderWidth += borderCharWidth
}
naturalWidth := maxContentWidth + col.leftPadding + col.rightPadding + borderWidth
return naturalWidth
}
// distributeRemainingSpace calculates how to distribute the remaining space
// `tableWidth - fixedWidth` among the columns where maxGrowth=true. It returns
// a slice that has the same length as the number of columns, where each value
// is the extra width to add to that column. Only the maxGrowth columns will
// have a non-zero value.
func (t *Table) distributeRemainingSpace(r int) []int {
var maxGrowthColumns int
for _, col := range t.columns {
if col.maxGrowth {
maxGrowthColumns++
}
}
// allocate for all columns anyway, easier to map back later
maxGrowthWidths := make([]int, len(t.columns))
if maxGrowthColumns == 0 || r <= 0 {
return maxGrowthWidths
}
remainder := r % maxGrowthColumns
share := r / maxGrowthColumns
for _, col := range t.columns {
if col.maxGrowth {
maxGrowthWidths[col.index] = share
if remainder > 0 {
maxGrowthWidths[col.index]++
remainder--
}
}
}
return maxGrowthWidths
}
// ownsLeftBorder determines whether the given column "owns" the left border
// (i.e. is responsible for rendering it). This is used to deduplicate borders
// between adjacent cells.
func (t *Table) ownsLeftBorder(col Column) bool {
if col.index == 0 {
return col.leftBorder
}
prev := t.columns[col.index-1]
return prev.rightBorder || col.leftBorder
}
// ownsRightBorder determines whether the given column "owns" the right border
// (i.e. is responsible for rendering it). This is used to deduplicate borders
// between adjacent cells. This is only ever true for the last column, and only
// if it has a right border.
func (t *Table) ownsRightBorder(col Column) bool {
return col.index == len(t.columns)-1 && col.rightBorder
}
// model is the numerical representation of the table used for rendering. Once
// calculated, [Table.draw] can actually render the table based on this model.
type model struct {
naturalWidths []int // len(t.columns)
fixedWidth int // minimum width of the table (without extra space)
tableWidth int // actual width of the table (with extar space)
remainingSpace int // total remaining space
maxGrowthWidths []int // len(t.columns); only non-zero for maxGrowth columns
}
// resolve calculates the model / numerical representation of the table used
// for rendering.
func (t *Table) resolve() model {
naturalWidths := make([]int, len(t.columns))
for i, col := range t.columns {
naturalWidths[i] = t.naturalWidth(col)
}
fixedWidth := sum(naturalWidths)
remainingSpace := t.width - fixedWidth
if remainingSpace < 0 {
// TODO: handle truncation
// keep overflowing for now
}
maxGrowthWidths := t.distributeRemainingSpace(remainingSpace)
tableWidth := fixedWidth + sum(maxGrowthWidths)
return model{
naturalWidths: naturalWidths,
fixedWidth: fixedWidth,
tableWidth: tableWidth,
remainingSpace: remainingSpace,
maxGrowthWidths: maxGrowthWidths,
}
}
// ownedBorderWidth calculates the total width of the borders that a column is
// responsible for rendering.
func (t *Table) ownedBorderWidth(col Column) int {
borderCharWidth := lipgloss.Width(RowBorderCharacter)
width := 0
if t.ownsLeftBorder(col) {
width += borderCharWidth
}
if t.ownsRightBorder(col) {
width += borderCharWidth
}
return width
}
// renderCell renders a single cell's content with the appropriate width,
// padding, alignment, and borders based on the resolved model.
func (t *Table) renderCell(row string, col Column, m model) string {
finalWidth := m.naturalWidths[col.index] + m.maxGrowthWidths[col.index]
cellWidth := finalWidth - t.ownedBorderWidth(col)
style := lipgloss.NewStyle().
Width(cellWidth).
PaddingLeft(col.leftPadding).
PaddingRight(col.rightPadding).
Align(col.alignment)
return style.Render(row)
}
// renderRow renders a single row of the table, including borders and padding,
// based on the resolved model.
func (t *Table) renderRow(r Row, m model) string {
var b strings.Builder
for _, col := range t.columns {
if t.ownsLeftBorder(col) {
b.WriteString(RowBorderCharacter)
}
var text string
if col.index < len(r.cells) {
text = r.cells[col.index]
}
b.WriteString(t.renderCell(text, col, m))
if t.ownsRightBorder(col) {
b.WriteString(RowBorderCharacter)
}
}
b.WriteRune('\n')
return b.String()
}
// renderHeader renders the header row with borders above and below it.
func (t *Table) renderHeader(m model) string {
var b strings.Builder
// TODO: make the border length configurable:
// - minimum (`m.tableWidth`)
// - maximum (``), potentially overflows
// - terminal width
headerBorder := strings.Repeat(HeaderCharacter, m.tableWidth) + "\n"
headerRow := Row{}
for _, col := range t.columns {
headerRow.cells = append(headerRow.cells, col.text)
}
b.WriteString(headerBorder)
b.WriteString(t.renderRow(headerRow, m))
b.WriteString(headerBorder)
return b.String()
}
// draw renders the table as a string
func (t *Table) draw(m model) string {
var b strings.Builder
b.WriteString(t.renderHeader(m))
for _, row := range t.rows {
b.WriteString(t.renderRow(row, m))
}
if t.summary != nil {
border := strings.Repeat(SummaryRowCharacter, m.tableWidth) + "\n"
b.WriteString(border)
b.WriteString(t.renderRow(*t.summary, m))
}
return b.String()
}
// Render renders the table as a string.
func (t *Table) Render() string {
m := t.resolve()
return t.draw(m)
}