mirror of
https://github.com/mum4k/termdash.git
synced 2025-05-08 19:29:25 +08:00
Finishing column width calculation logic.
This commit is contained in:
parent
a7405c55bf
commit
2bec9b18da
@ -143,9 +143,9 @@ type Cell struct {
|
||||
data []*Data
|
||||
// width is the width of the data when draws on canvas.
|
||||
width int
|
||||
// trimmed indicates if the content of this cell is trimmed if it doesn't
|
||||
// fit the columns width.
|
||||
trimmed bool
|
||||
// trimmable indicates if the content of this cell would be trimmed if it
|
||||
// doesn't fit the columns width.
|
||||
trimmable bool
|
||||
|
||||
// colSpan specified how many columns does this cell span.
|
||||
colSpan int
|
||||
@ -189,7 +189,7 @@ func NewCellWithOpts(data []*Data, opts ...CellOption) *Cell {
|
||||
}
|
||||
|
||||
// We trim when we don't wrap.
|
||||
c.trimmed = c.hierarchical.getWrapMode() == wrap.Never
|
||||
c.trimmable = c.hierarchical.getWrapMode() == wrap.Never
|
||||
return c
|
||||
}
|
||||
|
||||
|
@ -18,6 +18,7 @@ package table
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"math"
|
||||
)
|
||||
@ -49,20 +50,41 @@ func newContentLayout(content *Content, cvsAr image.Rectangle) (*contentLayout,
|
||||
// widths of individual columns.
|
||||
// The argument cvsWidth is assumed to exclude space required for any border,
|
||||
// padding or spacing.
|
||||
func columnWidths(content *Content, cvsWidth int) []columnWidth {
|
||||
func columnWidths(content *Content, cvsWidth int) ([]columnWidth, error) {
|
||||
if wp := content.opts.columnWidthsPercent; len(wp) > 0 {
|
||||
// If the user provided widths for the columns, use those.
|
||||
widths, err := splitToPercent(cvsWidth, wp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return widths, nil
|
||||
}
|
||||
|
||||
columns := int(content.cols)
|
||||
// Attempt equal column widths and see if there are any trimmed rows.
|
||||
if columns > cvsWidth {
|
||||
return nil, fmt.Errorf("the canvas width %d must be at least equal to the number of columns %d, so that each column gets at least one cell", cvsWidth, columns)
|
||||
}
|
||||
widths := splitEqually(cvsWidth, columns)
|
||||
if !widthsTrimCell(content, widths) {
|
||||
return widths, nil
|
||||
}
|
||||
|
||||
// No widths specified and some rows need to be trimmed.
|
||||
// Choose columns widths that optimize for the smallest number of trimmed rows.
|
||||
// This is similar to the rod-cutting problem, except instead of maximizing
|
||||
// the price, we're minimizing the number of rows that would have their
|
||||
// content trimmed.
|
||||
|
||||
inputs := &cutCanvasInputs{
|
||||
content: content,
|
||||
cvsWidth: cvsWidth,
|
||||
cvsWidth: columnWidth(cvsWidth),
|
||||
columns: int(content.cols),
|
||||
best: map[cutState]int{},
|
||||
}
|
||||
state := &cutState{
|
||||
colIdx: 0,
|
||||
remWidth: cvsWidth,
|
||||
remWidth: inputs.cvsWidth,
|
||||
}
|
||||
|
||||
best := cutCanvas(inputs, state, nil)
|
||||
@ -74,7 +96,7 @@ func columnWidths(content *Content, cvsWidth int) []columnWidth {
|
||||
last = cut
|
||||
}
|
||||
res = append(res, columnWidth(cvsWidth-last))
|
||||
return res
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// cutState uniquely identifies a state in the cutting process.
|
||||
@ -85,7 +107,7 @@ type cutState struct {
|
||||
|
||||
// remWidth is the total remaining width of the canvas for the current
|
||||
// column and all the following columns.
|
||||
remWidth int
|
||||
remWidth columnWidth
|
||||
}
|
||||
|
||||
// bestCuts is the best result for a particular cutState.
|
||||
@ -105,7 +127,7 @@ type cutCanvasInputs struct {
|
||||
content *Content
|
||||
|
||||
// cvsWidth is the width of the canvas that is available for the data.
|
||||
cvsWidth int
|
||||
cvsWidth columnWidth
|
||||
|
||||
// columns indicates the total number of columns in the table.
|
||||
columns int
|
||||
@ -115,6 +137,8 @@ type cutCanvasInputs struct {
|
||||
best map[cutState]int
|
||||
}
|
||||
|
||||
// cutCanvas cuts the canvas width to a number of rows optimizing for the
|
||||
// smallest possible number of trimmed rows.
|
||||
func cutCanvas(inputs *cutCanvasInputs, state *cutState, cuts []int) *bestCuts {
|
||||
minCost := math.MaxInt32
|
||||
var minCuts []int
|
||||
@ -127,7 +151,7 @@ func cutCanvas(inputs *cutCanvasInputs, state *cutState, cuts []int) *bestCuts {
|
||||
}
|
||||
}
|
||||
|
||||
for colWidth := 1; colWidth < state.remWidth; colWidth++ {
|
||||
for colWidth := columnWidth(1); colWidth < state.remWidth; colWidth++ {
|
||||
diff := inputs.cvsWidth - state.remWidth
|
||||
idxThisCut := diff + colWidth
|
||||
costThisCut := trimmedRows(inputs.content, state.colIdx, colWidth)
|
||||
@ -135,7 +159,7 @@ func cutCanvas(inputs *cutCanvasInputs, state *cutState, cuts []int) *bestCuts {
|
||||
colIdx: nextColIdx,
|
||||
remWidth: state.remWidth - colWidth,
|
||||
}
|
||||
nextCuts := append(cuts, idxThisCut)
|
||||
nextCuts := append(cuts, int(idxThisCut))
|
||||
|
||||
// Use the memoized result if available.
|
||||
var nextBest *bestCuts
|
||||
@ -160,21 +184,90 @@ func cutCanvas(inputs *cutCanvasInputs, state *cutState, cuts []int) *bestCuts {
|
||||
}
|
||||
}
|
||||
|
||||
// widthsTrimCell asserts whether there is at least one cell in the content
|
||||
// whose data gets trimmed when using the specified column widths.
|
||||
func widthsTrimCell(content *Content, widths []columnWidth) bool {
|
||||
for colIdx := 0; colIdx < int(content.cols); colIdx++ {
|
||||
if trimmed := trimmedRows(content, colIdx, widths[colIdx]); trimmed > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// trimmedRows returns the number of rows that will have data cells with
|
||||
// trimmed content in column of the specified index if the assigned width of
|
||||
// the column is colWidth.
|
||||
func trimmedRows(content *Content, colIdx int, colWidth int) int {
|
||||
func trimmedRows(content *Content, colIdx int, colWidth columnWidth) int {
|
||||
trimmed := 0
|
||||
for _, row := range content.rows {
|
||||
tgtCell := row.cells[colIdx]
|
||||
if !tgtCell.trimmed {
|
||||
if !tgtCell.trimmable {
|
||||
// Cells that have wrapping enabled are never trimmed and so have
|
||||
// no influence on the calculated column widths.
|
||||
continue
|
||||
}
|
||||
if tgtCell.width > colWidth {
|
||||
if tgtCell.width > int(colWidth) {
|
||||
trimmed++
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// splitToPercent splits the canvas widths to columns each having the assigned
|
||||
// percentage of the width.
|
||||
func splitToPercent(cvsWidth int, widthsPercent []int) ([]columnWidth, error) {
|
||||
if cvsWidth == 0 {
|
||||
return nil, errors.New("unable to split canvas of zero width to columns")
|
||||
}
|
||||
if len(widthsPercent) == 0 {
|
||||
return nil, errors.New("at least one width percentage must be provided")
|
||||
}
|
||||
if got := len(widthsPercent); got > cvsWidth {
|
||||
return nil, fmt.Errorf("the canvas width %d must be at least equal to the number of columns %d, so that each column gets at least one cell", cvsWidth, got)
|
||||
}
|
||||
|
||||
var res []columnWidth
|
||||
remaining := cvsWidth
|
||||
for i := 0; i < len(widthsPercent)-1; i++ {
|
||||
perc := widthsPercent[i]
|
||||
var adjPerc float64
|
||||
if remaining < cvsWidth {
|
||||
ofOrig := float64(cvsWidth) / 100 * float64(perc)
|
||||
adjPerc = ofOrig / float64(remaining) * 100
|
||||
} else {
|
||||
adjPerc = float64(perc)
|
||||
}
|
||||
cur := int(math.Floor(float64(remaining) / 100 * adjPerc))
|
||||
|
||||
colsAfter := len(widthsPercent) - i - 1
|
||||
if remAfter := remaining - cur; remAfter < colsAfter {
|
||||
diff := colsAfter - remAfter
|
||||
// Leave at least one cell for each remaining column.
|
||||
cur -= diff
|
||||
}
|
||||
|
||||
if cur < 1 {
|
||||
// At least one cell per column.
|
||||
cur = 1
|
||||
}
|
||||
res = append(res, columnWidth(cur))
|
||||
remaining -= cur
|
||||
}
|
||||
|
||||
res = append(res, columnWidth(remaining))
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// splitEqually splits the canvas to equal columns.
|
||||
func splitEqually(cvsWidth, columns int) []columnWidth {
|
||||
each := cvsWidth / columns
|
||||
var res []columnWidth
|
||||
sum := 0
|
||||
for i := 0; i < columns-1; i++ {
|
||||
res = append(res, columnWidth(each))
|
||||
sum += each
|
||||
}
|
||||
res = append(res, columnWidth(cvsWidth-sum))
|
||||
return res
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ func TestColumnWidths(t *testing.T) {
|
||||
opts []ContentOption
|
||||
cvsWidth int
|
||||
want []columnWidth
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "single column, wide enough",
|
||||
@ -57,6 +58,26 @@ func TestColumnWidths(t *testing.T) {
|
||||
cvsWidth: 1,
|
||||
want: []columnWidth{1},
|
||||
},
|
||||
{
|
||||
desc: "plenty of width, prefers equal split",
|
||||
columns: Columns(2),
|
||||
rows: []*Row{
|
||||
NewRow(
|
||||
NewCell("ab"),
|
||||
NewCell("cde"),
|
||||
),
|
||||
NewRow(
|
||||
NewCell("a"),
|
||||
NewCell("cde"),
|
||||
),
|
||||
NewRow(
|
||||
NewCell(""),
|
||||
NewCell("cde"),
|
||||
),
|
||||
},
|
||||
cvsWidth: 50,
|
||||
want: []columnWidth{25, 25},
|
||||
},
|
||||
{
|
||||
desc: "two columns, canvas wide enough, no trimming",
|
||||
columns: Columns(2),
|
||||
@ -77,6 +98,56 @@ func TestColumnWidths(t *testing.T) {
|
||||
cvsWidth: 5,
|
||||
want: []columnWidth{2, 3},
|
||||
},
|
||||
{
|
||||
desc: "two columns, canvas not wide enough, user specified widths",
|
||||
columns: Columns(2),
|
||||
rows: []*Row{
|
||||
NewRow(
|
||||
NewCell("ab"),
|
||||
NewCell("cde"),
|
||||
),
|
||||
NewRow(
|
||||
NewCell("a"),
|
||||
NewCell("cde"),
|
||||
),
|
||||
NewRow(
|
||||
NewCell(""),
|
||||
NewCell("cde"),
|
||||
),
|
||||
},
|
||||
opts: []ContentOption{
|
||||
ColumnWidthsPercent(99, 1),
|
||||
},
|
||||
cvsWidth: 4,
|
||||
want: []columnWidth{3, 1},
|
||||
},
|
||||
{
|
||||
desc: "fails when canvas width not enough for user specified widths",
|
||||
columns: Columns(2),
|
||||
rows: []*Row{
|
||||
NewRow(
|
||||
NewCell("ab"),
|
||||
NewCell("cde"),
|
||||
),
|
||||
},
|
||||
opts: []ContentOption{
|
||||
ColumnWidthsPercent(99, 1),
|
||||
},
|
||||
cvsWidth: 1,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "fails when canvas width not enough for optimized widths",
|
||||
columns: Columns(2),
|
||||
rows: []*Row{
|
||||
NewRow(
|
||||
NewCell("ab"),
|
||||
NewCell("cde"),
|
||||
),
|
||||
},
|
||||
cvsWidth: 1,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "two columns, canvas not wide enough, optimal to trim first",
|
||||
columns: Columns(2),
|
||||
@ -97,6 +168,41 @@ func TestColumnWidths(t *testing.T) {
|
||||
cvsWidth: 4,
|
||||
want: []columnWidth{1, 3},
|
||||
},
|
||||
{
|
||||
desc: "cells that wrap aren't accounted for",
|
||||
columns: Columns(2),
|
||||
rows: []*Row{
|
||||
NewRow(
|
||||
NewCell("ab"),
|
||||
NewCellWithOpts(
|
||||
[]*Data{
|
||||
NewData("cde"),
|
||||
},
|
||||
CellWrapAtWords(),
|
||||
),
|
||||
),
|
||||
NewRow(
|
||||
NewCell("a"),
|
||||
NewCellWithOpts(
|
||||
[]*Data{
|
||||
NewData("cde"),
|
||||
},
|
||||
CellWrapAtWords(),
|
||||
),
|
||||
),
|
||||
NewRow(
|
||||
NewCell(""),
|
||||
NewCellWithOpts(
|
||||
[]*Data{
|
||||
NewData("cde"),
|
||||
},
|
||||
CellWrapAtWords(),
|
||||
),
|
||||
),
|
||||
},
|
||||
cvsWidth: 4,
|
||||
want: []columnWidth{2, 2},
|
||||
},
|
||||
{
|
||||
desc: "two columns, canvas not wide enough, optimal to trim second",
|
||||
columns: Columns(2),
|
||||
@ -172,7 +278,13 @@ func TestColumnWidths(t *testing.T) {
|
||||
t.Fatalf("NewContent => unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := columnWidths(content, tc.cvsWidth)
|
||||
got, err := columnWidths(content, tc.cvsWidth)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("columnWidths => unexpected error: %v, wantErr: %v", err, tc.wantErr)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if diff := pretty.Compare(tc.want, got); diff != "" {
|
||||
t.Errorf("columnWidths => unexpected diff (-want, +got):\n%s", diff)
|
||||
}
|
||||
@ -225,3 +337,118 @@ func BenchmarkColumnWidths(b *testing.B) {
|
||||
columnWidths(content, 30)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitToPercent(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
cvsWidth int
|
||||
widthsPercent []int
|
||||
want []columnWidth
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "fails for zero canvas width",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "fails when no widths provided",
|
||||
cvsWidth: 10,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "fails when we don't have at least one cell per column",
|
||||
cvsWidth: 2,
|
||||
widthsPercent: []int{10, 50, 20},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "single column",
|
||||
cvsWidth: 15,
|
||||
widthsPercent: []int{100},
|
||||
want: []columnWidth{15},
|
||||
},
|
||||
{
|
||||
desc: "divides evenly into the percentages",
|
||||
cvsWidth: 10,
|
||||
widthsPercent: []int{10, 50, 20, 10, 10},
|
||||
want: []columnWidth{1, 5, 2, 1, 1},
|
||||
},
|
||||
{
|
||||
desc: "divides unevenly into the percentages, last is the largest",
|
||||
cvsWidth: 3,
|
||||
widthsPercent: []int{10, 90},
|
||||
want: []columnWidth{1, 2},
|
||||
},
|
||||
{
|
||||
desc: "divides unevenly into the percentages, first is the largest",
|
||||
cvsWidth: 3,
|
||||
widthsPercent: []int{90, 10},
|
||||
want: []columnWidth{2, 1},
|
||||
},
|
||||
{
|
||||
desc: "each column is given at least one cell",
|
||||
cvsWidth: 3,
|
||||
widthsPercent: []int{10, 80, 10},
|
||||
want: []columnWidth{1, 1, 1},
|
||||
},
|
||||
{
|
||||
desc: "leaves at least one cell for each remaining column",
|
||||
cvsWidth: 6,
|
||||
widthsPercent: []int{99, 1, 1, 1, 1},
|
||||
want: []columnWidth{2, 1, 1, 1, 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
got, err := splitToPercent(tc.cvsWidth, tc.widthsPercent)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("splitToPercent => unexpected error: %v, wantErr: %v", err, tc.wantErr)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if diff := pretty.Compare(tc.want, got); diff != "" {
|
||||
t.Errorf("splitToPercent => unexpected diff (-want, +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitEqually(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
cvsWidth int
|
||||
columns int
|
||||
want []columnWidth
|
||||
}{
|
||||
{
|
||||
desc: "single column",
|
||||
cvsWidth: 9,
|
||||
columns: 1,
|
||||
want: []columnWidth{9},
|
||||
},
|
||||
{
|
||||
desc: "splits evenly",
|
||||
cvsWidth: 9,
|
||||
columns: 3,
|
||||
want: []columnWidth{3, 3, 3},
|
||||
},
|
||||
{
|
||||
desc: "splits unevenly",
|
||||
cvsWidth: 10,
|
||||
columns: 3,
|
||||
want: []columnWidth{3, 3, 4},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
got := splitEqually(tc.cvsWidth, tc.columns)
|
||||
if diff := pretty.Compare(tc.want, got); diff != "" {
|
||||
t.Errorf("splitEqually => unexpected diff (-want, +got):\n%s", diff)
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user