1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-04-28 13:48:51 +08:00

POC code that determines column widths.

This commit is contained in:
Jakub Sobon 2019-03-17 01:52:45 -04:00
parent 2fc4ffc9e3
commit e36e1d7ba7
No known key found for this signature in database
GPG Key ID: F2451A77FB05D3B7
8 changed files with 412 additions and 2 deletions

View File

@ -33,6 +33,16 @@ func NewCells(text string, opts ...cell.Option) []*Cell {
return res
}
// CellsWidth returns the width the cells will use on a terminal if printed out.
// This takes into account if some of the runes are full-width runes.
func CellsWidth(cells []*Cell) int {
width := 0
for _, c := range cells {
width += runewidth.RuneWidth(c.Rune)
}
return width
}
// Cell represents a single cell on the terminal.
type Cell struct {
// Rune is the rune stored in the cell.

View File

@ -625,3 +625,35 @@ func TestRemWidth(t *testing.T) {
})
}
}
func TestCellsWidth(t *testing.T) {
tests := []struct {
desc string
cells []*Cell
want int
}{
{
desc: "ascii characters",
cells: NewCells("hello"),
want: 5,
},
{
desc: "string from mattn/runewidth/runewidth_test",
cells: NewCells("■㈱の世界①"),
want: 10,
},
{
desc: "string using termdash characters",
cells: NewCells("⇄…⇧⇩"),
want: 4,
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
if got := CellsWidth(tc.cells); got != tc.want {
t.Errorf("CellsWidth => %v, want %v", got, tc.want)
}
})
}
}

View File

@ -19,6 +19,7 @@ package table
import (
"fmt"
"sync"
"github.com/mum4k/termdash/align"
"github.com/mum4k/termdash/cell"
@ -143,6 +144,8 @@ func ContentCellOpts(cellOpts ...cell.Option) ContentOption {
// ContentRowHeight sets the height of rows to the provided number of cells.
// The number must be a non-zero positive integer.
// Rows still use larger than provided height if wrapping is enabled and the
// content doesn't fit.
// Defaults to row height automatically adjusted to the content.
// This is a hierarchical option and can be overridden when provided at Row
// level.
@ -229,8 +232,14 @@ type Content struct {
// rows are the rows in the table.
rows []*Row
// layout describes the layout of this table on a canvas.
layout *contentLayout
// opts are the options provided to NewContent.
opts *contentOptions
// mu protects the Content
mu sync.Mutex
}
// NewContent returns a new Content instance.
@ -240,8 +249,9 @@ type Content struct {
// allowing for the CellColSpan option.
func NewContent(cols Columns, rows []*Row, opts ...ContentOption) (*Content, error) {
c := &Content{
cols: cols,
opts: newContentOptions(opts...),
cols: cols,
layout: &contentLayout{},
opts: newContentOptions(opts...),
}
for _, r := range rows {
if err := c.addRow(r); err != nil {
@ -296,6 +306,9 @@ func (c *Content) addRow(row *Row) error {
// AddRowWithOpts adds a row to the content and applies the options.
func (c *Content) AddRowWithOpts(cells []*Cell, opts ...RowOption) error {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.addRow(NewRowWithOpts(cells, opts...)); err != nil {
return err
}

View File

@ -22,6 +22,7 @@ import (
"github.com/mum4k/termdash/align"
"github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/internal/canvas/buffer"
"github.com/mum4k/termdash/internal/wrap"
)
@ -69,6 +70,8 @@ func CellOpts(cellOpts ...cell.Option) CellOption {
// CellHeight sets the height of cells to the provided number of cells.
// The number must be a non-zero positive integer.
// Rows still use larger than provided height if wrapping is enabled and the
// content doesn't fit.
// Defaults to cell height automatically adjusted to the content.
// This is a hierarchical option, it overrides the one provided at Content or
// Row level.
@ -156,6 +159,16 @@ func (c *Cell) String() string {
return fmt.Sprintf("| %v ", b.String())
}
// width returns the width of all the runes in this cell when they are printed
// on the terminal.
func (c *Cell) width() int {
res := 0
for _, d := range c.data {
res += buffer.CellsWidth(d.cells)
}
return res
}
// NewCell returns a new Cell with the provided text.
// If you need to apply options at the Cell or Data level use NewCellWithOpts.
// The text contain cannot control characters (unicode.IsControl) or space

View File

@ -0,0 +1,156 @@
// Copyright 2019 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package table
// content_layout.go stores layout calculated for a canvas size.
import (
"errors"
"image"
"log"
"math"
"github.com/mum4k/termdash/internal/wrap"
)
// columnWidth is the width of a column in cells.
// This excludes any border, padding or spacing, i.e. this is the data portion
// only.
type columnWidth int
// contentLayout determines how the content gets placed onto the canvas.
type contentLayout struct {
// lastCvsAr is the are of the last canvas the content was drawn on.
// This is image.ZR if the content hasn't been drawn yet.
lastCvsAr image.Rectangle
// columnWidths are the widths of individual columns in the table.
columnWidths []columnWidth
// Details about HV lines that are the borders.
}
// newContentLayout calculates new layout for the content when drawn on a
// canvas represented with the provided area.
func newContentLayout(content *Content, cvsAr image.Rectangle) (*contentLayout, error) {
return nil, errors.New("unimplemented")
}
// columnWidths given the content and the available canvas width returns the
// 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 {
// 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.
idxColumnCosts := columnCosts(content, cvsWidth)
log.Printf("idxColumnCosts: %v", idxColumnCosts)
minCost, minCuts := cutCanvas(idxColumnCosts, cvsWidth, cvsWidth, int(content.cols), 0, nil)
log.Printf("minCost: %v", minCost)
log.Printf("minCuts: %v", minCuts)
var res []columnWidth
last := 0
for _, cut := range minCuts {
res = append(res, columnWidth(cut-last))
last = cut
}
res = append(res, columnWidth(cvsWidth-last))
return res
}
func cutCanvas(idxColumnCosts map[int]widthCost, cvsWidth, remWidth, columns, colIdx int, cuts []int) (int, []int) {
log.Printf("cutCanvas remWidth:%d, columns:%d, colIdx:%d, cuts:%v", remWidth, columns, colIdx, cuts)
if remWidth <= 0 {
log.Printf(" -> 0")
return 0, cuts
}
minCost := math.MaxInt32
var minCuts []int
widthCosts := idxColumnCosts[colIdx]
nextColIdx := colIdx + 1
if nextColIdx > columns-1 {
log.Printf(" -> no more cuts remWidth:%d cost:%d", remWidth, widthCosts[remWidth])
return widthCosts[remWidth], cuts
}
for colWidth := 1; colWidth < remWidth; colWidth++ {
diff := cvsWidth - remWidth
idxThisCut := diff + colWidth
costThisCut := widthCosts[colWidth]
nextCost, nextCuts := cutCanvas(
idxColumnCosts,
cvsWidth,
remWidth-colWidth,
columns,
nextColIdx,
//append(cuts, colWidth+colIdx),
append(cuts, idxThisCut),
)
if newMinCost := costThisCut + nextCost; newMinCost < minCost {
log.Printf("at cuts %v, costThisCut from widthCosts:%v, at width %d:%d, nextCost:%d, minCost:%d", cuts, widthCosts, colWidth, costThisCut, nextCost, minCost)
minCost = newMinCost
minCuts = nextCuts
log.Printf("new minCost:%d minCuts:%v", minCost, minCuts)
}
}
log.Printf("cutCanvas remWidth:%d, columns:%d, colIdx:%d, cuts:%v -> minCost:%d, minCuts:%d", remWidth, columns, colIdx, cuts, minCost, minCuts)
return minCost, minCuts
}
// widthCost maps column widths to the number of rows that would be trimmed on
// that width because data in the column are longer than the width.
type widthCost map[int]int
// columnCosts calculates the costs of cutting the columns to various widths.
// Returns a map of column indexes to their cut costs.
func columnCosts(content *Content, cvsWidth int) map[int]widthCost {
idxColumnCosts := map[int]widthCost{}
maxColWidth := cvsWidth - (int(content.cols) - 1)
for colIdx := 0; colIdx < int(content.cols); colIdx++ {
for colWidth := 1; colWidth <= maxColWidth; colWidth++ {
wc, ok := idxColumnCosts[colIdx]
if !ok {
wc = widthCost{}
idxColumnCosts[colIdx] = wc
}
wc[colWidth] = trimmedRows(content, colIdx, colWidth)
}
}
return idxColumnCosts
}
// 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 {
trimmed := 0
for _, row := range content.rows {
tgtCell := row.cells[colIdx]
if tgtCell.hierarchical.getWrapMode() != wrap.Never {
// Cells that have wrapping enabled are never trimmed.
continue
}
if tgtCell.width() > colWidth {
trimmed++
}
}
return trimmed
}

View File

@ -0,0 +1,181 @@
// Copyright 2019 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package table
import (
"testing"
"github.com/kylelemons/godebug/pretty"
)
func TestColumnWidths(t *testing.T) {
tests := []struct {
desc string
columns Columns
rows []*Row
opts []ContentOption
cvsWidth int
want []columnWidth
}{
{
desc: "single column, wide enough",
columns: Columns(1),
rows: []*Row{
NewRow(
NewCell("ab"),
),
NewRow(
NewCell(""),
),
},
cvsWidth: 2,
want: []columnWidth{2},
},
{
desc: "single column, not wide enough",
columns: Columns(1),
rows: []*Row{
NewRow(
NewCell("ab"),
),
NewRow(
NewCell(""),
),
},
cvsWidth: 1,
want: []columnWidth{1},
},
{
desc: "two columns, canvas wide enough, no trimming",
columns: Columns(2),
rows: []*Row{
NewRow(
NewCell("ab"),
NewCell("cde"),
),
NewRow(
NewCell("a"),
NewCell("cde"),
),
NewRow(
NewCell(""),
NewCell("cde"),
),
},
cvsWidth: 5,
want: []columnWidth{2, 3},
},
{
desc: "two columns, canvas not wide enough, optimal to trim first",
columns: Columns(2),
rows: []*Row{
NewRow(
NewCell("ab"),
NewCell("cde"),
),
NewRow(
NewCell("a"),
NewCell("cde"),
),
NewRow(
NewCell(""),
NewCell("cde"),
),
},
cvsWidth: 4,
want: []columnWidth{1, 3},
},
{
desc: "two columns, canvas not wide enough, optimal to trim second",
columns: Columns(2),
rows: []*Row{
NewRow(
NewCell("ab"),
NewCell("c"),
),
NewRow(
NewCell("ab"),
NewCell("c"),
),
NewRow(
NewCell(""),
NewCell("cde"),
),
},
cvsWidth: 4,
want: []columnWidth{2, 2},
},
{
desc: "three columns, canvas wide enough, no trimming",
columns: Columns(3),
rows: []*Row{
NewRow(
NewCell("ab"),
NewCell("cde"),
NewCell("fg"),
),
NewRow(
NewCell("a"),
NewCell("c"),
NewCell("f"),
),
NewRow(
NewCell("ab"),
NewCell("cde"),
NewCell("fg"),
),
},
cvsWidth: 7,
want: []columnWidth{2, 3, 2},
},
{
desc: "three columns, canvas not wide enough, one very long cell",
columns: Columns(3),
rows: []*Row{
NewRow(
NewCell("00"),
NewCell("11111111"),
NewCell("22"),
),
NewRow(
NewCell("00"),
NewCell("11"),
NewCell("22"),
),
NewRow(
NewCell("00"),
NewCell("111"),
NewCell("22"),
),
},
cvsWidth: 6,
want: []columnWidth{2, 2, 2},
},
}
for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
content, err := NewContent(tc.columns, tc.rows, tc.opts...)
if err != nil {
t.Fatalf("NewContent => unexpected error: %v", err)
}
got := columnWidths(content, tc.cvsWidth)
if diff := pretty.Compare(tc.want, got); diff != "" {
t.Errorf("columnWidths => unexpected diff (-want, +got):\n%s", diff)
}
})
}
}

View File

@ -81,6 +81,8 @@ func RowCellOpts(cellOpts ...cell.Option) RowOption {
// RowHeight sets the height of rows to the provided number of cells.
// The number must be a non-zero positive integer.
// Rows still use larger than provided height if wrapping is enabled and the
// content doesn't fit.
// Defaults to row height automatically adjusted to the content.
// This is a hierarchical option, it overrides the one provided at Content
// level and can be overridden when provided at the Cell level.

View File

@ -61,6 +61,9 @@ func validateContent(content *Content) error {
return err
}
for _, d := range c.data {
if len(d.cells) == 0 {
continue
}
if err := wrap.ValidCells(d.cells); err != nil {
return fmt.Errorf("invalid data: %v", err)
}