github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/internal/table/table.go (about)

     1  package table
     2  
     3  import (
     4  	"strings"
     5  	"unicode/utf8"
     6  
     7  	"github.com/ActiveState/cli/internal/colorize"
     8  	"github.com/ActiveState/cli/internal/logging"
     9  	"github.com/ActiveState/cli/internal/mathutils"
    10  	"github.com/ActiveState/cli/internal/sliceutils"
    11  	"github.com/ActiveState/cli/internal/termutils"
    12  )
    13  
    14  const dash = "\u2500"
    15  const linebreak = "\n"
    16  const padding = 2
    17  
    18  type FormatFunc func(string, ...interface{}) string
    19  
    20  type row struct {
    21  	columns []string
    22  }
    23  
    24  type Table struct {
    25  	headers []string
    26  	rows    []row
    27  
    28  	HideHeaders bool
    29  	HideDash    bool
    30  	Vertical    bool
    31  }
    32  
    33  func New(headers []string) *Table {
    34  	return &Table{headers: headers}
    35  }
    36  
    37  func (t *Table) AddRow(vs ...[]string) *Table {
    38  	for _, v := range vs {
    39  		t.rows = append(t.rows, row{v})
    40  	}
    41  	return t
    42  }
    43  
    44  func (t *Table) Render() string {
    45  	if len(t.rows) == 0 {
    46  		return ""
    47  	}
    48  
    49  	termWidth := termutils.GetWidth()
    50  	colWidths, total := t.calculateWidth(termWidth)
    51  
    52  	var out string
    53  	if !t.HideHeaders {
    54  		out += "[NOTICE]" + renderRow(t.headers, colWidths) + "[/RESET]" + linebreak
    55  		if !t.HideDash {
    56  			out += "[DISABLED]" + strings.Repeat(dash, total) + "[/RESET]" + linebreak
    57  		}
    58  	}
    59  	for _, row := range t.rows {
    60  		out += renderRow(row.columns, colWidths) + linebreak
    61  	}
    62  
    63  	return strings.TrimRight(out, linebreak)
    64  }
    65  
    66  func (t *Table) calculateWidth(maxTableWidth int) ([]int, int) {
    67  	// Calculate total width of each column, not worrying about max width just yet
    68  	minTableWidth := padding * 2
    69  	colWidths := make([]int, len(t.headers))
    70  	colWidthsCombined := 0
    71  	for n, header := range t.headers {
    72  		// Start with the header size
    73  		colWidths[n] = utf8.RuneCountInString(header)
    74  
    75  		// Check column sizes for each row
    76  		for _, row := range t.rows {
    77  			columnValue, ok := sliceutils.GetString(row.columns, n)
    78  			if !ok {
    79  				continue // column doesn't exit because the previous column spans
    80  			}
    81  			// Strip any colour tags so they are not included in the width calculation
    82  			columnValue = colorize.StripColorCodes(columnValue)
    83  			columnSize := utf8.RuneCountInString(columnValue)
    84  
    85  			// Detect spanned column info
    86  			rowHasSpannedColumn := len(row.columns) < len(t.headers)
    87  			spannedColumnIndex := len(row.columns) - 1
    88  
    89  			if rowHasSpannedColumn && n == spannedColumnIndex {
    90  				// Record total row size as minTableWidth
    91  				colWidthBefore := mathutils.Total(sliceutils.IntRangeUncapped(colWidths, 0, n)...)
    92  				minTableWidth = mathutils.MaxInt(minTableWidth, colWidthBefore+columnSize+(padding*2))
    93  			} else {
    94  				// This is a regular non-spanned column
    95  				colWidths[n] = mathutils.MaxInt(colWidths[n], columnSize)
    96  			}
    97  		}
    98  
    99  		// Add padding and update the total width so far
   100  		colWidths[n] += padding * 2
   101  		colWidthsCombined += colWidths[n]
   102  	}
   103  
   104  	// Capture the width of the vertical header before we equalize the column widths.
   105  	// We must respect this width when rescaling the columns.
   106  	var verticalHeaderWidth int
   107  	if len(colWidths) > 0 && t.Vertical {
   108  		verticalHeaderWidth = colWidths[0]
   109  	}
   110  
   111  	if colWidthsCombined >= maxTableWidth {
   112  		// Equalize widths by 20% of average width.
   113  		// This is to prevent columns that are much larger than others
   114  		// from taking up most of the table width.
   115  		equalizeWidths(colWidths, 20)
   116  	}
   117  
   118  	// Constrain table to max and min dimensions
   119  	tableWidth := mathutils.MaxInt(colWidthsCombined, minTableWidth)
   120  	tableWidth = mathutils.MinInt(tableWidth, maxTableWidth)
   121  
   122  	// Now scale back the row sizes according to the max width
   123  	rescaleColumns(colWidths, tableWidth, t.Vertical, verticalHeaderWidth)
   124  	logging.Debug("Table column widths: %v, total: %d", colWidths, tableWidth)
   125  
   126  	return colWidths, tableWidth
   127  }
   128  
   129  // equalizeWidths equalizes the width of given columns by a given percentage of the average columns width
   130  func equalizeWidths(colWidths []int, percentage int) {
   131  	total := float64(mathutils.Total(colWidths...))
   132  	multiplier := float64(percentage) / 100
   133  	averageWidth := total / float64(len(colWidths))
   134  
   135  	for n := range colWidths {
   136  		colWidth := float64(colWidths[n])
   137  		colWidths[n] += int((averageWidth - colWidth) * multiplier)
   138  	}
   139  
   140  	// Account for floats that got rounded
   141  	if len(colWidths) > 0 {
   142  		colWidths[len(colWidths)-1] += int(total) - mathutils.Total(colWidths...)
   143  	}
   144  }
   145  
   146  func rescaleColumns(colWidths []int, targetTotal int, vertical bool, verticalHeaderWidth int) {
   147  	total := float64(mathutils.Total(colWidths...))
   148  	multiplier := float64(targetTotal) / total
   149  
   150  	originalWidths := make([]int, len(colWidths))
   151  	for n := range colWidths {
   152  		originalWidths[n] = colWidths[n]
   153  		colWidths[n] = int(float64(colWidths[n]) * multiplier)
   154  	}
   155  
   156  	// Account for floats that got rounded
   157  	if len(colWidths) > 0 {
   158  		colWidths[len(colWidths)-1] += targetTotal - mathutils.Total(colWidths...)
   159  	}
   160  
   161  	// If vertical, respect the header width
   162  	// verticalHeaderWidth is the width of the header column before we equalized the column widths.
   163  	// We compare the current width of the header column with the original width and adjust the other columns accordingly.
   164  	if vertical && len(colWidths) > 0 && colWidths[0] < verticalHeaderWidth {
   165  		diff := verticalHeaderWidth - colWidths[0]
   166  		colWidths[0] += diff
   167  		for i := 1; i < len(colWidths); i++ {
   168  			colWidths[i] -= diff / (len(colWidths) - 1)
   169  		}
   170  	}
   171  }
   172  
   173  func renderRow(providedColumns []string, colWidths []int) string {
   174  	// Do not modify the original column widths
   175  	widths := make([]int, len(providedColumns))
   176  	copy(widths, colWidths)
   177  
   178  	// Combine column widths if we have a spanned column
   179  	if len(widths) < len(colWidths) {
   180  		widths[len(widths)-1] = mathutils.Total(colWidths[len(widths)-1:]...)
   181  	}
   182  
   183  	croppedColumns := []colorize.CroppedLines{}
   184  	for n, column := range providedColumns {
   185  		croppedColumns = append(croppedColumns, colorize.GetCroppedText(column, widths[n]-(padding*2), false))
   186  	}
   187  
   188  	var rendered = true
   189  	var lines []string
   190  	// Iterate over rows until we reach a row where no column has data
   191  	for lineNo := 0; rendered; lineNo++ {
   192  		rendered = false
   193  		var line string
   194  		for columnNo, column := range croppedColumns {
   195  			if lineNo > len(column)-1 {
   196  				line += strings.Repeat(" ", widths[columnNo]) // empty column
   197  				continue
   198  			}
   199  			columnLine := column[lineNo]
   200  
   201  			// Add padding and fill up missing whitespace
   202  			prefix := strings.Repeat(" ", padding)
   203  			suffix := strings.Repeat(" ", padding+(widths[columnNo]-columnLine.Length-(padding*2)))
   204  
   205  			line += prefix + columnLine.Line + suffix
   206  			rendered = true
   207  		}
   208  		if rendered {
   209  			lines = append(lines, line)
   210  		}
   211  	}
   212  
   213  	return strings.TrimRight(strings.Join(lines, linebreak), linebreak)
   214  }