blob: dc2e3edde6cb7dcb5dd422014b1265065af1f076 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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
*
* https://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 utils
import (
"fmt"
"math"
"regexp"
"strings"
"github.com/fatih/color"
"github.com/rs/zerolog"
)
var AsciiBoxLogger zerolog.Logger
// AsciiBox is a string surrounded by an ascii border (and an optional name)
type AsciiBox struct {
data string
asciiBoxWriter *asciiBoxWriter
compressedBoxSet string
}
func WithAsciiBoxName(name string) func(*BoxOptions) {
return func(opts *BoxOptions) {
opts.Name = name
}
}
func WithAsciiBoxHeader(header string) func(*BoxOptions) {
return func(box *BoxOptions) {
box.Header = header
}
}
func WithAsciiBoxFooter(footer string) func(*BoxOptions) {
return func(box *BoxOptions) {
box.Footer = footer
}
}
func WithAsciiBoxCharWidth(charWidth int) func(*BoxOptions) {
return func(opts *BoxOptions) {
opts.CharWidth = charWidth
}
}
func WithAsciiBoxOptions(boxOptions BoxOptions) func(*BoxOptions) {
return func(opts *BoxOptions) {
*opts = boxOptions
}
}
func WithAsciiBoxBoxSet(boxSet BoxSet) func(*BoxOptions) {
return func(opts *BoxOptions) {
opts.BoxSet = boxSet
}
}
type BoxOptions struct {
// The name of the box
Name string
// The additional header of the box appearing on the right upper side
Header string
// The additional footer of the box appearing on the right lower side
Footer string
// The desired CharWidth
CharWidth int
// The BoxSet used to print this box
BoxSet BoxSet
}
type BoxSet struct {
UpperLeftCorner string
UpperRightCorner string
HorizontalLine string
VerticalLine string
LowerLeftCorner string
LowerRightCorner string
}
func DefaultBoxSet() BoxSet {
return BoxSet{
"╔",
"╗",
"═",
"║",
"╚",
"╝",
}
}
func DefaultLightBoxSet() BoxSet {
return BoxSet{
"╭",
"╮",
"┄",
"┆",
"╰",
"╯",
}
}
// DebugAsciiBox set to true to get debug messages
var DebugAsciiBox bool
// ANSI_PATTERN source: https://github.com/chalk/ansi-regex/blob/main/index.js#L3
var ANSI_PATTERN = regexp.MustCompile("[\u001b\u009b][\\[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]")
// AsciiBoxer is used to render something in a box
type AsciiBoxer interface {
// Box with options
Box(...func(*BoxOptions)) AsciiBox
}
var AsciiBoxWriterDefault = NewAsciiBoxWriter()
var AsciiBoxWriterLight = NewAsciiBoxWriter(WithAsciiBoxWriterDefaultBoxSet(DefaultLightBoxSet()))
type AsciiBoxWriter interface {
BoxBox(box AsciiBox, options ...func(*BoxOptions)) AsciiBox
BoxString(data string, options ...func(*BoxOptions)) AsciiBox
AlignBoxes(asciiBoxes []AsciiBox, desiredWith int, options ...func(*BoxOptions)) AsciiBox
BoxSideBySide(box1 AsciiBox, box2 AsciiBox, options ...func(*BoxOptions)) AsciiBox
BoxBelowBox(box1 AsciiBox, box2 AsciiBox, options ...func(*BoxOptions)) AsciiBox
}
func NewAsciiBoxWriter(opts ...func(writer *asciiBoxWriter)) AsciiBoxWriter {
return newAsciiBoxWriter(opts...)
}
func WithAsciiBoxWriterDefaultBoxSet(boxSet BoxSet) func(*asciiBoxWriter) {
return func(a *asciiBoxWriter) {
a.defaultBoxSet = boxSet
}
}
func WithAsciiBoxWriterDefaultColoredBoxes(nameColor, headerColor, footerColor *color.Color) func(*asciiBoxWriter) {
return func(a *asciiBoxWriter) {
if nameColor != nil {
a.namePrinter = nameColor.Sprint
} else {
a.namePrinter = fmt.Sprint
}
if headerColor != nil {
a.headerPrinter = headerColor.Sprint
} else {
a.headerPrinter = fmt.Sprint
}
if footerColor != nil {
a.footerPrinter = footerColor.Sprint
} else {
a.footerPrinter = fmt.Sprint
}
}
}
func WithAsciiBoxWriterDisableColoredBoxes() func(*asciiBoxWriter) {
return func(a *asciiBoxWriter) {
a.namePrinter = fmt.Sprint
a.headerPrinter = fmt.Sprint
a.footerPrinter = fmt.Sprint
}
}
///////////////////////////////////////
///////////////////////////////////////
//
// Internal section
//
func (b BoxSet) compressBoxSet() string {
return b.UpperLeftCorner + b.UpperRightCorner + b.HorizontalLine + b.VerticalLine + b.LowerLeftCorner + b.LowerRightCorner
}
func (b BoxSet) contributeToCompressedBoxSet(box AsciiBox) string {
actualSet := b.compressBoxSet()
if box.compressedBoxSet == "" {
// they have nothing to contribute
return actualSet
}
if actualSet == "" {
// I have nothing to contribute
return box.compressedBoxSet
}
if strings.Contains(box.compressedBoxSet, actualSet) {
// we have nothing to add
return box.compressedBoxSet
}
return box.compressedBoxSet + "," + actualSet
}
func combineCompressedBoxSets(box1, box2 AsciiBox) string {
allSets := make(map[string]any)
for _, s := range strings.Split(box1.compressedBoxSet, ",") {
allSets[s] = true
}
for _, s := range strings.Split(box2.compressedBoxSet, ",") {
allSets[s] = true
}
var foundSets []string
for set := range allSets {
foundSets = append(foundSets, set)
}
return strings.Join(foundSets, ",")
}
type asciiBoxWriter struct {
newLine rune
emptyPadding string
// the name gets prefixed with an extra symbol for indent
extraNameCharIndent int
borderWidth int
newLineCharWidth int
defaultBoxSet BoxSet
boxHeaderRegex *regexp.Regexp
boxFooterRegex *regexp.Regexp
namePrinter func(a ...any) string
headerPrinter func(a ...any) string
footerPrinter func(a ...any) string
}
var _ AsciiBoxWriter = (*asciiBoxWriter)(nil)
func newAsciiBoxWriter(opts ...func(writer *asciiBoxWriter)) *asciiBoxWriter {
a := &asciiBoxWriter{
newLine: '\n',
emptyPadding: " ",
// the name gets prefixed with an extra symbol for indent
extraNameCharIndent: 1,
borderWidth: 1,
newLineCharWidth: 1,
defaultBoxSet: DefaultBoxSet(),
namePrinter: color.New(color.FgGreen, color.Bold).Sprint,
headerPrinter: color.New(color.FgBlue).Sprint,
footerPrinter: color.New(color.FgRed, color.Italic).Sprint,
}
for _, opt := range opts {
opt(a)
}
hl := a.defaultBoxSet.HorizontalLine
a.boxHeaderRegex = regexp.MustCompile(`^` + a.defaultBoxSet.UpperLeftCorner + hl + `(?P<name>[^` + hl + `]+)` + hl + `*` + `(?P<header>[^` + hl + `]+)?` + hl + `*` + a.defaultBoxSet.UpperRightCorner)
a.boxFooterRegex = regexp.MustCompile(`(?m)^` + a.defaultBoxSet.LowerLeftCorner + hl + `*` + `(?P<footer>[^` + hl + `]+)` + hl + `*` + a.defaultBoxSet.LowerRightCorner)
return a
}
func (a *asciiBoxWriter) boxString(data string, options ...func(*BoxOptions)) AsciiBox {
var opts BoxOptions
opts.BoxSet = a.defaultBoxSet
for _, opt := range options {
opt(&opts)
}
name := opts.Name
nameLength := countChars(name)
if name != "" {
name = a.namePrinter(name)
}
header := opts.Header
if name != "" && header != "" {
header = opts.BoxSet.HorizontalLine + opts.BoxSet.HorizontalLine + a.headerPrinter(header) + opts.BoxSet.HorizontalLine // Lazy manipulation to trick calculation below (adds a spacing between name and header)
}
headerLength := countChars(header)
footer := opts.Footer
if footer != "" {
footer = a.footerPrinter(footer) + opts.BoxSet.HorizontalLine
}
footerLength := countChars(footer)
charWidth := opts.CharWidth
data = strings.ReplaceAll(data, "\r\n", "\n") // carriage return just messes with boxes
data = strings.ReplaceAll(data, "\t", " ") // Tabs just don't work well as they distort the boxes so we convert them to a double space
rawBox := AsciiBox{data, a, opts.BoxSet.compressBoxSet()}
longestLine := rawBox.Width()
footerAddOn := 0
if footer != "" {
footerAddOn = footerLength + 2
}
longestLine = max(longestLine, footerAddOn)
if charWidth < longestLine {
if DebugAsciiBox {
AsciiBoxLogger.Debug().Int("nChars", longestLine-charWidth).Msg("Overflow by nChars chars")
}
charWidth = longestLine + a.borderWidth + a.borderWidth
}
var boxedString strings.Builder
boxedString.Grow((a.borderWidth + longestLine + a.borderWidth + a.newLineCharWidth) * rawBox.Height())
namePadding := int(math.Max(float64(charWidth-nameLength-a.borderWidth-a.extraNameCharIndent-a.borderWidth-headerLength), 0))
boxedString.WriteString(opts.BoxSet.UpperLeftCorner + opts.BoxSet.HorizontalLine + name + strings.Repeat(opts.BoxSet.HorizontalLine, namePadding) + header + opts.BoxSet.UpperRightCorner)
boxedString.WriteRune(a.newLine)
// Name of the header stretches the box so we align to that
charWidth = a.borderWidth + a.extraNameCharIndent + nameLength + namePadding + headerLength + a.borderWidth
for _, line := range rawBox.Lines() {
linePadding := float64(charWidth - boxLineOverheat - countChars(line))
if linePadding < 0 {
linePadding = 0
}
// TODO: this distorts boxes...
frontPadding := math.Floor(linePadding / 2.0)
backPadding := math.Ceil(linePadding / 2.0)
boxedString.WriteString(opts.BoxSet.VerticalLine + strings.Repeat(a.emptyPadding, int(frontPadding)) + line + strings.Repeat(a.emptyPadding, int(backPadding)) + opts.BoxSet.VerticalLine)
boxedString.WriteRune(a.newLine)
}
bottomPadding := namePadding + nameLength + a.extraNameCharIndent + headerLength - footerLength
boxedString.WriteString(opts.BoxSet.LowerLeftCorner + strings.Repeat(opts.BoxSet.HorizontalLine, bottomPadding) + footer + opts.BoxSet.LowerRightCorner)
return AsciiBox{boxedString.String(), a, opts.BoxSet.compressBoxSet()}
}
func (a *asciiBoxWriter) getBoxName(box AsciiBox) string {
subMatch := a.boxHeaderRegex.FindStringSubmatch(box.String())
if subMatch == nil {
return ""
}
index := a.boxHeaderRegex.SubexpIndex("name")
if index < 0 {
return ""
}
return cleanString(subMatch[index])
}
func (a *asciiBoxWriter) getBoxHeader(box AsciiBox) string {
subMatch := a.boxHeaderRegex.FindStringSubmatch(box.String())
if subMatch == nil {
return ""
}
index := a.boxHeaderRegex.SubexpIndex("header")
if index < 0 {
return ""
}
return cleanString(subMatch[index])
}
func (a *asciiBoxWriter) getBoxFooter(box AsciiBox) string {
subMatch := a.boxFooterRegex.FindStringSubmatch(box.String())
if subMatch == nil {
return ""
}
index := a.boxFooterRegex.SubexpIndex("footer")
if index < 0 {
return ""
}
return cleanString(subMatch[index])
}
func (a *asciiBoxWriter) changeBoxName(box AsciiBox, newName string) AsciiBox {
return a.changeBoxAttributes(box, &newName, nil, nil)
}
func (a *asciiBoxWriter) changeBoxHeader(box AsciiBox, newHeader string) AsciiBox {
return a.changeBoxAttributes(box, nil, &newHeader, nil)
}
func (a *asciiBoxWriter) changeBoxFooter(box AsciiBox, newFooter string) AsciiBox {
return a.changeBoxAttributes(box, nil, nil, &newFooter)
}
func (a *asciiBoxWriter) changeBoxAttributes(box AsciiBox, newName, newHeader, newFooter *string) AsciiBox {
// Current data
name := box.asciiBoxWriter.getBoxName(box)
header := box.asciiBoxWriter.getBoxHeader(box)
footer := box.asciiBoxWriter.getBoxFooter(box)
// set new metadata
if newName != nil {
name = *newName
}
if newHeader != nil {
header = *newHeader
}
if newFooter != nil {
footer = *newFooter
}
var newOptions = []func(options *BoxOptions){
WithAsciiBoxName(name),
WithAsciiBoxHeader(header),
WithAsciiBoxFooter(footer),
}
if !a.hasBorders(box) { // this means that this is a naked box.
return a.boxString(box.String(), newOptions...)
}
minimumWidth := countChars(a.defaultBoxSet.UpperLeftCorner + a.defaultBoxSet.HorizontalLine + name + a.defaultBoxSet.UpperRightCorner)
if header != "" { // if we have a header we need to extend that minimum width to make space for the header
minimumWidth += countChars(a.defaultBoxSet.HorizontalLine + header)
}
boxContent := a.unwrap(box) // get the content itself ...
rawWidth := boxContent.Width() // ... and look at the width.
minimumWidth = max(minimumWidth, rawWidth+2) // check that we have enough space for the content.
minimumWidth = max(minimumWidth, countChars(footer)+2) // check that we have enough space for the footer.
newBox := a.BoxString(
boxContent.String(),
append(newOptions, WithAsciiBoxCharWidth(minimumWidth))...,
)
newBox.compressedBoxSet = a.defaultBoxSet.contributeToCompressedBoxSet(box)
return newBox
}
func (a *asciiBoxWriter) mergeHorizontal(boxes []AsciiBox) AsciiBox {
switch len(boxes) {
case 0:
return AsciiBox{"", a, a.defaultBoxSet.compressBoxSet()}
case 1:
return boxes[0]
case 2:
return a.BoxSideBySide(boxes[0], boxes[1])
default:
return a.BoxSideBySide(boxes[0], a.mergeHorizontal(boxes[1:]))
}
}
func (a *asciiBoxWriter) expandBox(box AsciiBox, desiredWidth int) AsciiBox {
if box.Width() >= desiredWidth {
return box
}
boxLines := box.Lines()
numberOfLine := len(boxLines)
boxWidth := box.Width()
padding := strings.Repeat(" ", desiredWidth-boxWidth)
var newBox strings.Builder
newBox.Grow((boxWidth + a.newLineCharWidth) * numberOfLine)
for i, line := range boxLines {
newBox.WriteString(line)
newBox.WriteString(padding)
if i < numberOfLine-1 {
newBox.WriteRune(a.newLine)
}
}
return AsciiBox{newBox.String(), a, a.defaultBoxSet.contributeToCompressedBoxSet(box)}
}
func (a *asciiBoxWriter) unwrap(box AsciiBox) AsciiBox {
if !a.hasBorders(box) {
return box
}
originalLines := box.Lines()
newLines := make([]string, len(originalLines)-2)
completeBoxSet := a.defaultBoxSet.contributeToCompressedBoxSet(box)
for i, line := range originalLines {
if i == 0 {
// we ignore the first line
continue
}
if i == len(originalLines)-1 {
// we ignore the last line
break
}
runes := []rune(line)
// Strip the vertical Lines and trim the padding
unwrappedLine := string(runes[1 : len(runes)-1])
if !strings.ContainsAny(unwrappedLine, strings.ReplaceAll(completeBoxSet, ",", "")) {
// only trim boxes witch don't contain other boxes
unwrappedLine = strings.Trim(unwrappedLine, a.emptyPadding)
}
newLines[i-1] = unwrappedLine
}
return AsciiBox{strings.Join(newLines, string(a.newLine)), a, completeBoxSet}
}
func (a *asciiBoxWriter) hasBorders(box AsciiBox) bool {
if len(box.String()) == 0 {
return false
}
// Check if the first char is the upper left corner
return []rune(box.String())[0] == []rune(a.defaultBoxSet.UpperLeftCorner)[0]
}
func countChars(s string) int {
return len([]rune(ANSI_PATTERN.ReplaceAllString(s, "")))
}
// cleanString returns the strings minus the control sequences
func cleanString(s string) string {
regex, _ := regexp.Compile(`\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])`)
return regex.ReplaceAllString(s, "")
}
//
// Internal section
//
///////////////////////////////////////
///////////////////////////////////////
// Width returns the width of the box without the newlines
func (m AsciiBox) Width() int {
maxWidth := 0
for _, line := range m.Lines() {
currentLength := countChars(line)
if maxWidth < currentLength {
maxWidth = currentLength
}
}
return maxWidth
}
// Height returns the height of the box without
func (m AsciiBox) Height() int {
return len(m.Lines())
}
// Lines returns the lines of the box
func (m AsciiBox) Lines() []string {
return strings.Split(m.data, "\n")
}
func (m AsciiBox) GetBoxName() string {
return m.asciiBoxWriter.getBoxName(m)
}
func (m AsciiBox) ChangeBoxName(newName string) AsciiBox {
return m.asciiBoxWriter.changeBoxName(m, newName)
}
func (m AsciiBox) ChangeBoxHeader(newHeader string) AsciiBox {
return m.asciiBoxWriter.changeBoxHeader(m, newHeader)
}
func (m AsciiBox) ChangeBoxFooter(newFooter string) AsciiBox {
return m.asciiBoxWriter.changeBoxFooter(m, newFooter)
}
func (m AsciiBox) IsEmpty() bool {
if m.asciiBoxWriter.hasBorders(m) {
return m.asciiBoxWriter.unwrap(m).String() == ""
}
return m.String() == ""
}
// String returns the string of the box
func (m AsciiBox) String() string {
return m.data
}
// BoxBox boxes a box
func (a *asciiBoxWriter) BoxBox(box AsciiBox, options ...func(*BoxOptions)) AsciiBox {
// TODO: if there is a box bigger then others in that this will get distorted
newBox := a.BoxString(box.data, options...)
newBox.compressedBoxSet = a.defaultBoxSet.contributeToCompressedBoxSet(box)
return newBox
}
// BoxString boxes a newline separated string into a beautiful box
func (a *asciiBoxWriter) BoxString(data string, options ...func(*BoxOptions)) AsciiBox {
return a.boxString(data, options...)
}
// AlignBoxes aligns all boxes to a desiredWidth and orders them from left to right and top to bottom (size will be at min the size of the biggest box)
func (a *asciiBoxWriter) AlignBoxes(boxes []AsciiBox, desiredWidth int, options ...func(*BoxOptions)) AsciiBox {
if len(boxes) == 0 {
return AsciiBox{"", a, a.defaultBoxSet.compressBoxSet()}
}
actualWidth := desiredWidth
for _, box := range boxes {
boxWidth := box.Width()
if boxWidth > actualWidth {
if DebugAsciiBox {
AsciiBoxLogger.Debug().Int("nChars", boxWidth-desiredWidth).Msg("Overflow by nChars chars")
}
actualWidth = boxWidth
}
}
if DebugAsciiBox {
AsciiBoxLogger.Debug().Int("actualWidth", actualWidth).Msg("Working with actualWidth chars")
}
bigBox := AsciiBox{"", a, a.defaultBoxSet.compressBoxSet()}
currentBoxRow := make([]AsciiBox, 0)
currentRowLength := 0
for _, box := range boxes {
currentRowLength += box.Width()
if currentRowLength > actualWidth {
mergedBoxes := a.mergeHorizontal(currentBoxRow)
if bigBox.IsEmpty() {
bigBox = mergedBoxes
} else {
bigBox = a.BoxBelowBox(bigBox, mergedBoxes)
}
currentRowLength = box.Width()
currentBoxRow = make([]AsciiBox, 0)
}
currentBoxRow = append(currentBoxRow, box)
}
if len(currentBoxRow) > 0 {
// Special case where all boxes fit into one row
mergedBoxes := a.mergeHorizontal(currentBoxRow)
if bigBox.IsEmpty() {
bigBox = mergedBoxes
} else {
bigBox = a.BoxBelowBox(bigBox, mergedBoxes)
}
}
return bigBox
}
// BoxSideBySide renders two boxes side by side
func (a *asciiBoxWriter) BoxSideBySide(box1, box2 AsciiBox, options ...func(*BoxOptions)) AsciiBox {
const newLineCharWidth = 1
var aggregateBox strings.Builder
box1Width := box1.Width()
box1Lines := box1.Lines()
box2Width := box2.Width()
box2Lines := box2.Lines()
maxRows := int(math.Max(float64(len(box1Lines)), float64(len(box2Lines))))
aggregateBox.Grow((box1Width + box2Width + newLineCharWidth) * maxRows)
for row := 0; row < maxRows; row++ {
ranOutOfLines := false
if row >= len(box1Lines) {
ranOutOfLines = true
aggregateBox.WriteString(strings.Repeat(" ", box1Width))
} else {
split1Row := box1Lines[row]
padding := box1Width - countChars(split1Row)
aggregateBox.WriteString(split1Row + strings.Repeat(" ", padding))
}
if row >= len(box2Lines) {
if ranOutOfLines {
break
}
aggregateBox.WriteString(strings.Repeat(" ", box2Width))
} else {
split2Row := box2Lines[row]
padding := box2Width - countChars(split2Row)
aggregateBox.WriteString(split2Row + strings.Repeat(" ", padding))
}
if row < maxRows-1 {
// Only write newline if we are not the last line
aggregateBox.WriteRune('\n')
}
}
return AsciiBox{aggregateBox.String(), a, combineCompressedBoxSets(box1, box2)}
}
// BoxBelowBox renders two boxes below
func (a *asciiBoxWriter) BoxBelowBox(box1, box2 AsciiBox, options ...func(*BoxOptions)) AsciiBox {
box1Width := box1.Width()
box2Width := box2.Width()
if box1Width < box2Width {
box1 = a.expandBox(box1, box2Width)
} else if box2Width < box1Width {
box2 = a.expandBox(box2, box1Width)
}
return AsciiBox{box1.String() + "\n" + box2.String(), a, combineCompressedBoxSets(box1, box2)}
}