From bfe1b7ccedef3e135fcffb34f9888172d0cd992b Mon Sep 17 00:00:00 2001 From: Naveen Mahalingam Date: Fri, 13 Dec 2024 11:59:38 -0800 Subject: [PATCH] text: Wrap* should account for display width; fixes #344 (#347) --- go.mod | 1 + go.sum | 3 +- progress/indicator.go | 8 +- progress/progress.go | 8 +- progress/render.go | 8 +- table/render.go | 8 +- table/render_init.go | 16 +- table/render_test.go | 432 ++++++++++++++++++++++------------------- table/table.go | 4 +- text/align.go | 2 +- text/ansi.go | 2 +- text/direction.go | 9 +- text/direction_test.go | 4 +- text/string.go | 84 ++++++-- text/string_test.go | 59 +++++- text/wrap.go | 13 +- text/wrap_test.go | 44 +++-- 17 files changed, 427 insertions(+), 278 deletions(-) diff --git a/go.mod b/go.mod index d9dc0f0..9af7193 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/stretchr/testify v1.8.4 golang.org/x/sys v0.17.0 golang.org/x/term v0.17.0 + golang.org/x/text v0.21.0 ) require ( diff --git a/go.sum b/go.sum index 50378e4..0041eb3 100644 --- a/go.sum +++ b/go.sum @@ -19,7 +19,6 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= @@ -29,6 +28,8 @@ golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/progress/indicator.go b/progress/indicator.go index 7046d89..4d98261 100644 --- a/progress/indicator.go +++ b/progress/indicator.go @@ -94,7 +94,7 @@ func indeterminateIndicatorMovingBackAndForth(indicator string) IndeterminateInd if currentPosition == 0 { direction = 1 - } else if currentPosition+text.RuneWidthWithoutEscSequences(indicator) == maxLen { + } else if currentPosition+text.StringWidthWithoutEscSequences(indicator) == maxLen { direction = -1 } nextPosition += direction @@ -113,7 +113,7 @@ func indeterminateIndicatorMovingLeftToRight(indicator string) IndeterminateIndi currentPosition := nextPosition nextPosition++ - if nextPosition+text.RuneWidthWithoutEscSequences(indicator) > maxLen { + if nextPosition+text.StringWidthWithoutEscSequences(indicator) > maxLen { nextPosition = 0 } @@ -129,7 +129,7 @@ func indeterminateIndicatorMovingRightToLeft(indicator string) IndeterminateIndi return func(maxLen int) IndeterminateIndicator { if nextPosition == -1 { - nextPosition = maxLen - text.RuneWidthWithoutEscSequences(indicator) + nextPosition = maxLen - text.StringWidthWithoutEscSequences(indicator) } currentPosition := nextPosition nextPosition-- @@ -165,7 +165,7 @@ func indeterminateIndicatorPacMan() IndeterminateIndicatorGenerator { if currentPosition == 0 { direction = 1 indicator = pacManMovingRight - } else if currentPosition+text.RuneWidthWithoutEscSequences(indicator) == maxLen { + } else if currentPosition+text.StringWidthWithoutEscSequences(indicator) == maxLen { direction = -1 indicator = pacManMovingLeft } diff --git a/progress/progress.go b/progress/progress.go index 1e27121..8d101f7 100644 --- a/progress/progress.go +++ b/progress/progress.go @@ -332,13 +332,13 @@ func (p *Progress) initForRender() { // calculate length of the actual progress bar by discounting the left/right // border/box chars p.lengthProgress = p.lengthTracker - - text.RuneWidthWithoutEscSequences(p.style.Chars.BoxLeft) - - text.RuneWidthWithoutEscSequences(p.style.Chars.BoxRight) + text.StringWidthWithoutEscSequences(p.style.Chars.BoxLeft) - + text.StringWidthWithoutEscSequences(p.style.Chars.BoxRight) p.lengthProgressOverall = p.lengthMessage + - text.RuneWidthWithoutEscSequences(p.style.Options.Separator) + + text.StringWidthWithoutEscSequences(p.style.Options.Separator) + p.lengthProgress + 1 if p.style.Visibility.Percentage { - p.lengthProgressOverall += text.RuneWidthWithoutEscSequences( + p.lengthProgressOverall += text.StringWidthWithoutEscSequences( fmt.Sprintf(p.style.Options.PercentFormat, 0.0), ) } diff --git a/progress/render.go b/progress/render.go index 6e54d3c..1e86619 100644 --- a/progress/render.go +++ b/progress/render.go @@ -129,7 +129,7 @@ func (p *Progress) generateTrackerStrDeterminate(value int64, total int64, maxLe } else if pFinishedDotsFraction == 0 { pInProgress = "" } - pFinishedStrLen := text.RuneWidthWithoutEscSequences(pFinished + pInProgress) + pFinishedStrLen := text.StringWidthWithoutEscSequences(pFinished + pInProgress) if pFinishedStrLen < maxLen { pUnfinished = strings.Repeat(p.style.Chars.Unfinished, maxLen-pFinishedStrLen) } @@ -149,8 +149,8 @@ func (p *Progress) generateTrackerStrIndeterminate(maxLen int) string { pUnfinished += strings.Repeat(p.style.Chars.Unfinished, indicator.Position) } pUnfinished += indicator.Text - if text.RuneWidthWithoutEscSequences(pUnfinished) < maxLen { - pUnfinished += strings.Repeat(p.style.Chars.Unfinished, maxLen-text.RuneWidthWithoutEscSequences(pUnfinished)) + if text.StringWidthWithoutEscSequences(pUnfinished) < maxLen { + pUnfinished += strings.Repeat(p.style.Chars.Unfinished, maxLen-text.StringWidthWithoutEscSequences(pUnfinished)) } return p.style.Colors.Tracker.Sprintf("%s%s%s", @@ -197,7 +197,7 @@ func (p *Progress) renderTracker(out *strings.Builder, t *Tracker, hint renderHi message = strings.ReplaceAll(message, "\t", " ") message = strings.ReplaceAll(message, "\r", "") // replace with text.ProcessCRLF? if p.lengthMessage > 0 { - messageLen := text.RuneWidthWithoutEscSequences(message) + messageLen := text.StringWidthWithoutEscSequences(message) if messageLen < p.lengthMessage { message = text.Pad(message, p.lengthMessage, ' ') } else { diff --git a/table/render.go b/table/render.go index 7641da3..2fd759e 100644 --- a/table/render.go +++ b/table/render.go @@ -227,7 +227,7 @@ func (t *Table) renderLine(out *strings.Builder, row rowStr, hint renderHint) { func (t *Table) renderLineMergeOutputs(out *strings.Builder, outLine *strings.Builder) { outLineStr := outLine.String() - if text.RuneWidthWithoutEscSequences(outLineStr) > t.style.Size.WidthMax { + if text.StringWidthWithoutEscSequences(outLineStr) > t.style.Size.WidthMax { trimLength := t.style.Size.WidthMax - utf8.RuneCountInString(t.style.Box.UnfinishedRow) if trimLength > 0 { out.WriteString(text.Trim(outLineStr, trimLength)) @@ -392,15 +392,15 @@ func (t *Table) renderTitle(out *strings.Builder) { rowLength = wm } if t.style.Options.DrawBorder { - lenBorder := rowLength - text.RuneWidthWithoutEscSequences(t.style.Box.TopLeft+t.style.Box.TopRight) + lenBorder := rowLength - text.StringWidthWithoutEscSequences(t.style.Box.TopLeft+t.style.Box.TopRight) out.WriteString(colorsBorder.Sprint(t.style.Box.TopLeft)) out.WriteString(colorsBorder.Sprint(text.RepeatAndTrim(t.style.Box.MiddleHorizontal, lenBorder))) out.WriteString(colorsBorder.Sprint(t.style.Box.TopRight)) } - lenText := rowLength - text.RuneWidthWithoutEscSequences(t.style.Box.PaddingLeft+t.style.Box.PaddingRight) + lenText := rowLength - text.StringWidthWithoutEscSequences(t.style.Box.PaddingLeft+t.style.Box.PaddingRight) if t.style.Options.DrawBorder { - lenText -= text.RuneWidthWithoutEscSequences(t.style.Box.Left + t.style.Box.Right) + lenText -= text.StringWidthWithoutEscSequences(t.style.Box.Left + t.style.Box.Right) } titleText := text.WrapText(t.title, lenText) for _, titleLine := range strings.Split(titleText, "\n") { diff --git a/table/render_init.go b/table/render_init.go index 7ad2b61..8a7a0f4 100644 --- a/table/render_init.go +++ b/table/render_init.go @@ -78,7 +78,7 @@ func (t *Table) extractMaxColumnLengthsFromRow(row rowStr, mci mergedColumnIndic func (t *Table) extractMaxColumnLengthsFromRowForMergedColumns(colIdx int, mergedColumnLength int, mci mergedColumnIndices) { numMergedColumns := mci.len(colIdx) - mergedColumnLength -= (numMergedColumns - 1) * text.RuneWidthWithoutEscSequences(t.style.Box.MiddleSeparator) + mergedColumnLength -= (numMergedColumns - 1) * text.StringWidthWithoutEscSequences(t.style.Box.MiddleSeparator) maxLengthSplitAcrossColumns := mergedColumnLength / numMergedColumns if maxLengthSplitAcrossColumns > t.maxColumnLengths[colIdx] { t.maxColumnLengths[colIdx] = maxLengthSplitAcrossColumns @@ -177,22 +177,22 @@ func (t *Table) initForRenderHideColumns() { func (t *Table) initForRenderMaxRowLength() { t.maxRowLength = 0 if t.autoIndex { - t.maxRowLength += text.RuneWidthWithoutEscSequences(t.style.Box.PaddingLeft) + t.maxRowLength += text.StringWidthWithoutEscSequences(t.style.Box.PaddingLeft) t.maxRowLength += len(fmt.Sprint(len(t.rows))) - t.maxRowLength += text.RuneWidthWithoutEscSequences(t.style.Box.PaddingRight) + t.maxRowLength += text.StringWidthWithoutEscSequences(t.style.Box.PaddingRight) if t.style.Options.SeparateColumns { - t.maxRowLength += text.RuneWidthWithoutEscSequences(t.style.Box.MiddleSeparator) + t.maxRowLength += text.StringWidthWithoutEscSequences(t.style.Box.MiddleSeparator) } } if t.style.Options.SeparateColumns { - t.maxRowLength += text.RuneWidthWithoutEscSequences(t.style.Box.MiddleSeparator) * (t.numColumns - 1) + t.maxRowLength += text.StringWidthWithoutEscSequences(t.style.Box.MiddleSeparator) * (t.numColumns - 1) } for _, maxColumnLength := range t.maxColumnLengths { - maxColumnLength += text.RuneWidthWithoutEscSequences(t.style.Box.PaddingLeft + t.style.Box.PaddingRight) + maxColumnLength += text.StringWidthWithoutEscSequences(t.style.Box.PaddingLeft + t.style.Box.PaddingRight) t.maxRowLength += maxColumnLength } if t.style.Options.DrawBorder { - t.maxRowLength += text.RuneWidthWithoutEscSequences(t.style.Box.Left + t.style.Box.Right) + t.maxRowLength += text.StringWidthWithoutEscSequences(t.style.Box.Left + t.style.Box.Right) } } @@ -282,7 +282,7 @@ func (t *Table) initForRenderRowPainterColors() { func (t *Table) initForRenderRowSeparator() { t.rowSeparator = make(rowStr, t.numColumns) for colIdx, maxColumnLength := range t.maxColumnLengths { - maxColumnLength += text.RuneWidthWithoutEscSequences(t.style.Box.PaddingLeft + t.style.Box.PaddingRight) + maxColumnLength += text.StringWidthWithoutEscSequences(t.style.Box.PaddingLeft + t.style.Box.PaddingRight) t.rowSeparator[colIdx] = text.RepeatAndTrim(t.style.Box.MiddleHorizontal, maxColumnLength) } } diff --git a/table/render_test.go b/table/render_test.go index b2a50e7..67cba48 100644 --- a/table/render_test.go +++ b/table/render_test.go @@ -213,6 +213,189 @@ func TestTable_Render_AutoIndex(t *testing.T) { └────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘`) } +func TestTable_Render_AutoWidth(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(testHeader) + tw.AppendRows(testRows) + tw.AppendFooter(testFooter) + tw.SetStyle(StyleLight) + compareOutput(t, tw.Render(), ` +┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐ +│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ +├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ +│ 1 │ Arya │ Stark │ 3000 │ │ +│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ +│ 300 │ Tyrion │ Lannister │ 5000 │ │ +├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ +│ │ │ TOTAL │ 10000 │ │ +└─────┴────────────┴───────────┴────────┴─────────────────────────────┘`) + + tw.SetTitle("Game of Thrones") + tw.Style().Size = SizeOptions{ + WidthMax: 0, + WidthMin: 100, + } + compareOutput(t, tw.Render(), ` +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ Game of Thrones │ +├───────────┬──────────────────┬─────────────────┬──────────────┬──────────────────────────────────┤ +│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ +├───────────┼──────────────────┼─────────────────┼──────────────┼──────────────────────────────────┤ +│ 1 │ Arya │ Stark │ 3000 │ │ +│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ +│ 300 │ Tyrion │ Lannister │ 5000 │ │ +├───────────┼──────────────────┼─────────────────┼──────────────┼──────────────────────────────────┤ +│ │ │ TOTAL │ 10000 │ │ +└───────────┴──────────────────┴─────────────────┴──────────────┴──────────────────────────────────┘`) + + tw.SetTitle("") + tw.Style().Size = SizeOptions{ + WidthMax: 0, + WidthMin: 120, + } + compareOutput(t, tw.Render(), ` +┌───────────────┬──────────────────────┬─────────────────────┬──────────────────┬──────────────────────────────────────┐ +│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ +├───────────────┼──────────────────────┼─────────────────────┼──────────────────┼──────────────────────────────────────┤ +│ 1 │ Arya │ Stark │ 3000 │ │ +│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ +│ 300 │ Tyrion │ Lannister │ 5000 │ │ +├───────────────┼──────────────────────┼─────────────────────┼──────────────────┼──────────────────────────────────────┤ +│ │ │ TOTAL │ 10000 │ │ +└───────────────┴──────────────────────┴─────────────────────┴──────────────────┴──────────────────────────────────────┘`) + + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 1, WidthMax: 4}, + }) + compareOutput(t, tw.Render(), ` +┌──────┬────────────────────────┬───────────────────────┬────────────────────┬─────────────────────────────────────────┐ +│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ +├──────┼────────────────────────┼───────────────────────┼────────────────────┼─────────────────────────────────────────┤ +│ 1 │ Arya │ Stark │ 3000 │ │ +│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ +│ 300 │ Tyrion │ Lannister │ 5000 │ │ +├──────┼────────────────────────┼───────────────────────┼────────────────────┼─────────────────────────────────────────┤ +│ │ │ TOTAL │ 10000 │ │ +└──────┴────────────────────────┴───────────────────────┴────────────────────┴─────────────────────────────────────────┘`) + + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 1, WidthMax: 4}, + {Number: 2, WidthMax: 10}, + }) + compareOutput(t, tw.Render(), ` +┌──────┬────────────┬───────────────────────────┬────────────────────────┬─────────────────────────────────────────────┐ +│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ +├──────┼────────────┼───────────────────────────┼────────────────────────┼─────────────────────────────────────────────┤ +│ 1 │ Arya │ Stark │ 3000 │ │ +│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ +│ 300 │ Tyrion │ Lannister │ 5000 │ │ +├──────┼────────────┼───────────────────────────┼────────────────────────┼─────────────────────────────────────────────┤ +│ │ │ TOTAL │ 10000 │ │ +└──────┴────────────┴───────────────────────────┴────────────────────────┴─────────────────────────────────────────────┘`) + + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 1, WidthMax: 4}, + {Number: 2, WidthMax: 10}, + {Number: 3, WidthMax: 10}, + }) + compareOutput(t, tw.Render(), ` +┌──────┬────────────┬────────────┬────────────────────────────────┬────────────────────────────────────────────────────┐ +│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ +├──────┼────────────┼────────────┼────────────────────────────────┼────────────────────────────────────────────────────┤ +│ 1 │ Arya │ Stark │ 3000 │ │ +│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ +│ 300 │ Tyrion │ Lannister │ 5000 │ │ +├──────┼────────────┼────────────┼────────────────────────────────┼────────────────────────────────────────────────────┤ +│ │ │ TOTAL │ 10000 │ │ +└──────┴────────────┴────────────┴────────────────────────────────┴────────────────────────────────────────────────────┘`) + + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 1, WidthMax: 4}, + {Number: 2, WidthMax: 10}, + {Number: 3, WidthMax: 10}, + {Number: 4, WidthMax: 6}, + }) + compareOutput(t, tw.Render(), ` +┌──────┬────────────┬────────────┬────────┬────────────────────────────────────────────────────────────────────────────┐ +│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ +├──────┼────────────┼────────────┼────────┼────────────────────────────────────────────────────────────────────────────┤ +│ 1 │ Arya │ Stark │ 3000 │ │ +│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ +│ 300 │ Tyrion │ Lannister │ 5000 │ │ +├──────┼────────────┼────────────┼────────┼────────────────────────────────────────────────────────────────────────────┤ +│ │ │ TOTAL │ 10000 │ │ +└──────┴────────────┴────────────┴────────┴────────────────────────────────────────────────────────────────────────────┘`) + + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 1, WidthMax: 4}, + {Number: 2, WidthMax: 10}, + {Number: 3, WidthMax: 10}, + {Number: 4, WidthMax: 6}, + {Number: 5, WidthMax: 27}, + }) + compareOutput(t, tw.Render(), ` +┌──────┬────────────┬────────────┬────────┬─────────────────────────────┐ +│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ +├──────┼────────────┼────────────┼────────┼─────────────────────────────┤ +│ 1 │ Arya │ Stark │ 3000 │ │ +│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ +│ 300 │ Tyrion │ Lannister │ 5000 │ │ +├──────┼────────────┼────────────┼────────┼─────────────────────────────┤ +│ │ │ TOTAL │ 10000 │ │ +└──────┴────────────┴────────────┴────────┴─────────────────────────────┘`) + + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 2, WidthMax: 10}, + {Number: 3, WidthMax: 10}, + {Number: 4, WidthMax: 6}, + {Number: 5, WidthMax: 27}, + }) + compareOutput(t, tw.Render(), ` +┌─────────────────────────────────────────────────────┬────────────┬────────────┬────────┬─────────────────────────────┐ +│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ +├─────────────────────────────────────────────────────┼────────────┼────────────┼────────┼─────────────────────────────┤ +│ 1 │ Arya │ Stark │ 3000 │ │ +│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ +│ 300 │ Tyrion │ Lannister │ 5000 │ │ +├─────────────────────────────────────────────────────┼────────────┼────────────┼────────┼─────────────────────────────┤ +│ │ │ TOTAL │ 10000 │ │ +└─────────────────────────────────────────────────────┴────────────┴────────────┴────────┴─────────────────────────────┘`) + + tw.SetColumnConfigs(nil) + tw.Style().Size = SizeOptions{ + WidthMax: 60, + WidthMin: 0, + } + compareOutput(t, tw.Render(), ` +┌─────┬────────────┬───────────┬────────┬───────────────── ≈ +│ # │ FIRST NAME │ LAST NAME │ SALARY │ ≈ +├─────┼────────────┼───────────┼────────┼───────────────── ≈ +│ 1 │ Arya │ Stark │ 3000 │ ≈ +│ 20 │ Jon │ Snow │ 2000 │ You know nothing ≈ +│ 300 │ Tyrion │ Lannister │ 5000 │ ≈ +├─────┼────────────┼───────────┼────────┼───────────────── ≈ +│ │ │ TOTAL │ 10000 │ ≈ +└─────┴────────────┴───────────┴────────┴───────────────── ≈`) + + // expanded columns, but truncated row - not a valid usage scenario; + // no enforcement on min < max at this point + tw.SetColumnConfigs(nil) + tw.Style().Size = SizeOptions{ + WidthMax: 60, + WidthMin: 80, + } + compareOutput(t, tw.Render(), ` +┌───────┬──────────────┬─────────────┬──────────┬───────── ≈ +│ # │ FIRST NAME │ LAST NAME │ SALARY │ ≈ +├───────┼──────────────┼─────────────┼──────────┼───────── ≈ +│ 1 │ Arya │ Stark │ 3000 │ ≈ +│ 20 │ Jon │ Snow │ 2000 │ You know ≈ +│ 300 │ Tyrion │ Lannister │ 5000 │ ≈ +├───────┼──────────────┼─────────────┼──────────┼───────── ≈ +│ │ │ TOTAL │ 10000 │ ≈ +└───────┴──────────────┴─────────────┴──────────┴───────── ≈`) +} + func TestTable_Render_BorderAndSeparators(t *testing.T) { table := Table{} table.AppendHeader(testHeader) @@ -1247,7 +1430,6 @@ func TestTable_Render_SuppressEmptyColumns(t *testing.T) { "\u202a \u202a11\u202aSansa \u202a6000 ", "\u202a \u202a\u202a \u202a10000 ", }, "\n")) - } func TestTable_Render_TableWithinTable(t *testing.T) { @@ -1363,29 +1545,6 @@ func TestTable_Render_SetWidth_Title(t *testing.T) { }) } -func TestTable_Render_WidthEnforcer(t *testing.T) { - tw := NewWriter() - tw.AppendRows([]Row{ - {"U2", "Hey", "2021-04-19 13:37", "Yuh yuh yuh"}, - {"S12", "Uhhhh", "2021-04-19 13:37", "Some dummy data here"}, - {"R123", "Lobsters", "2021-04-19 13:37", "I like lobsters"}, - {"R123", "Some big name here and it's pretty big", "2021-04-19 13:37", "Abcdefghijklmnopqrstuvwxyz"}, - {"R123", "Small name", "2021-04-19 13:37", "Abcdefghijklmnopqrstuvwxyz"}, - }) - tw.SetColumnConfigs([]ColumnConfig{ - {Number: 2, WidthMax: 20, WidthMaxEnforcer: text.Trim}, - }) - - compareOutput(t, tw.Render(), ` -+------+----------------------+------------------+----------------------------+ -| U2 | Hey | 2021-04-19 13:37 | Yuh yuh yuh | -| S12 | Uhhhh | 2021-04-19 13:37 | Some dummy data here | -| R123 | Lobsters | 2021-04-19 13:37 | I like lobsters | -| R123 | Some big name here a | 2021-04-19 13:37 | Abcdefghijklmnopqrstuvwxyz | -| R123 | Small name | 2021-04-19 13:37 | Abcdefghijklmnopqrstuvwxyz | -+------+----------------------+------------------+----------------------------+`) -} - func TestTable_Render_SuppressTrailingSpaces(t *testing.T) { tw := NewWriter() tw.AppendHeader(testHeader2) @@ -1420,185 +1579,58 @@ func TestTable_Render_SuppressTrailingSpaces(t *testing.T) { R123 Small name 2021-04-19 13:37 Abcdefghijklmnopqrstuvwxyz`) } -func TestTable_Render_AutoWidth(t *testing.T) { - tw := NewWriter() - tw.AppendHeader(testHeader) - tw.AppendRows(testRows) - tw.AppendFooter(testFooter) - tw.SetStyle(StyleLight) - compareOutput(t, tw.Render(), ` -┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐ -│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ -├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ -│ 1 │ Arya │ Stark │ 3000 │ │ -│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ -│ 300 │ Tyrion │ Lannister │ 5000 │ │ -├─────┼────────────┼───────────┼────────┼─────────────────────────────┤ -│ │ │ TOTAL │ 10000 │ │ -└─────┴────────────┴───────────┴────────┴─────────────────────────────┘`) - - tw.SetTitle("Game of Thrones") - tw.Style().Size = SizeOptions{ - WidthMax: 0, - WidthMin: 100, - } - compareOutput(t, tw.Render(), ` -┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ Game of Thrones │ -├───────────┬──────────────────┬─────────────────┬──────────────┬──────────────────────────────────┤ -│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ -├───────────┼──────────────────┼─────────────────┼──────────────┼──────────────────────────────────┤ -│ 1 │ Arya │ Stark │ 3000 │ │ -│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ -│ 300 │ Tyrion │ Lannister │ 5000 │ │ -├───────────┼──────────────────┼─────────────────┼──────────────┼──────────────────────────────────┤ -│ │ │ TOTAL │ 10000 │ │ -└───────────┴──────────────────┴─────────────────┴──────────────┴──────────────────────────────────┘`) - - tw.SetTitle("") - tw.Style().Size = SizeOptions{ - WidthMax: 0, - WidthMin: 120, - } - compareOutput(t, tw.Render(), ` -┌───────────────┬──────────────────────┬─────────────────────┬──────────────────┬──────────────────────────────────────┐ -│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ -├───────────────┼──────────────────────┼─────────────────────┼──────────────────┼──────────────────────────────────────┤ -│ 1 │ Arya │ Stark │ 3000 │ │ -│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ -│ 300 │ Tyrion │ Lannister │ 5000 │ │ -├───────────────┼──────────────────────┼─────────────────────┼──────────────────┼──────────────────────────────────────┤ -│ │ │ TOTAL │ 10000 │ │ -└───────────────┴──────────────────────┴─────────────────────┴──────────────────┴──────────────────────────────────────┘`) - - tw.SetColumnConfigs([]ColumnConfig{ - {Number: 1, WidthMax: 4}, - }) - compareOutput(t, tw.Render(), ` -┌──────┬────────────────────────┬───────────────────────┬────────────────────┬─────────────────────────────────────────┐ -│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ -├──────┼────────────────────────┼───────────────────────┼────────────────────┼─────────────────────────────────────────┤ -│ 1 │ Arya │ Stark │ 3000 │ │ -│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ -│ 300 │ Tyrion │ Lannister │ 5000 │ │ -├──────┼────────────────────────┼───────────────────────┼────────────────────┼─────────────────────────────────────────┤ -│ │ │ TOTAL │ 10000 │ │ -└──────┴────────────────────────┴───────────────────────┴────────────────────┴─────────────────────────────────────────┘`) - - tw.SetColumnConfigs([]ColumnConfig{ - {Number: 1, WidthMax: 4}, - {Number: 2, WidthMax: 10}, - }) - compareOutput(t, tw.Render(), ` -┌──────┬────────────┬───────────────────────────┬────────────────────────┬─────────────────────────────────────────────┐ -│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ -├──────┼────────────┼───────────────────────────┼────────────────────────┼─────────────────────────────────────────────┤ -│ 1 │ Arya │ Stark │ 3000 │ │ -│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ -│ 300 │ Tyrion │ Lannister │ 5000 │ │ -├──────┼────────────┼───────────────────────────┼────────────────────────┼─────────────────────────────────────────────┤ -│ │ │ TOTAL │ 10000 │ │ -└──────┴────────────┴───────────────────────────┴────────────────────────┴─────────────────────────────────────────────┘`) - - tw.SetColumnConfigs([]ColumnConfig{ - {Number: 1, WidthMax: 4}, - {Number: 2, WidthMax: 10}, - {Number: 3, WidthMax: 10}, - }) - compareOutput(t, tw.Render(), ` -┌──────┬────────────┬────────────┬────────────────────────────────┬────────────────────────────────────────────────────┐ -│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ -├──────┼────────────┼────────────┼────────────────────────────────┼────────────────────────────────────────────────────┤ -│ 1 │ Arya │ Stark │ 3000 │ │ -│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ -│ 300 │ Tyrion │ Lannister │ 5000 │ │ -├──────┼────────────┼────────────┼────────────────────────────────┼────────────────────────────────────────────────────┤ -│ │ │ TOTAL │ 10000 │ │ -└──────┴────────────┴────────────┴────────────────────────────────┴────────────────────────────────────────────────────┘`) +func TestTable_Render_WidthEnforcer(t *testing.T) { + t.Run("regular characters", func(t *testing.T) { + tw := NewWriter() + tw.AppendRows([]Row{ + {"U2", "Hey", "2021-04-19 13:37", "Yuh yuh yuh"}, + {"S12", "Uhhhh", "2021-04-19 13:37", "Some dummy data here"}, + {"R123", "Lobsters", "2021-04-19 13:37", "I like lobsters"}, + {"R123", "Some big name here and it's pretty big", "2021-04-19 13:37", "Abcdefghijklmnopqrstuvwxyz"}, + {"R123", "Small name", "2021-04-19 13:37", "Abcdefghijklmnopqrstuvwxyz"}, + }) + tw.SetColumnConfigs([]ColumnConfig{ + {Number: 2, WidthMax: 20, WidthMaxEnforcer: text.Trim}, + }) - tw.SetColumnConfigs([]ColumnConfig{ - {Number: 1, WidthMax: 4}, - {Number: 2, WidthMax: 10}, - {Number: 3, WidthMax: 10}, - {Number: 4, WidthMax: 6}, + compareOutput(t, tw.Render(), ` ++------+----------------------+------------------+----------------------------+ +| U2 | Hey | 2021-04-19 13:37 | Yuh yuh yuh | +| S12 | Uhhhh | 2021-04-19 13:37 | Some dummy data here | +| R123 | Lobsters | 2021-04-19 13:37 | I like lobsters | +| R123 | Some big name here a | 2021-04-19 13:37 | Abcdefghijklmnopqrstuvwxyz | +| R123 | Small name | 2021-04-19 13:37 | Abcdefghijklmnopqrstuvwxyz | ++------+----------------------+------------------+----------------------------+`) }) - compareOutput(t, tw.Render(), ` -┌──────┬────────────┬────────────┬────────┬────────────────────────────────────────────────────────────────────────────┐ -│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ -├──────┼────────────┼────────────┼────────┼────────────────────────────────────────────────────────────────────────────┤ -│ 1 │ Arya │ Stark │ 3000 │ │ -│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ -│ 300 │ Tyrion │ Lannister │ 5000 │ │ -├──────┼────────────┼────────────┼────────┼────────────────────────────────────────────────────────────────────────────┤ -│ │ │ TOTAL │ 10000 │ │ -└──────┴────────────┴────────────┴────────┴────────────────────────────────────────────────────────────────────────────┘`) - tw.SetColumnConfigs([]ColumnConfig{ - {Number: 1, WidthMax: 4}, - {Number: 2, WidthMax: 10}, - {Number: 3, WidthMax: 10}, - {Number: 4, WidthMax: 6}, - {Number: 5, WidthMax: 27}, - }) - compareOutput(t, tw.Render(), ` -┌──────┬────────────┬────────────┬────────┬─────────────────────────────┐ -│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ -├──────┼────────────┼────────────┼────────┼─────────────────────────────┤ -│ 1 │ Arya │ Stark │ 3000 │ │ -│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ -│ 300 │ Tyrion │ Lannister │ 5000 │ │ -├──────┼────────────┼────────────┼────────┼─────────────────────────────┤ -│ │ │ TOTAL │ 10000 │ │ -└──────┴────────────┴────────────┴────────┴─────────────────────────────┘`) + t.Run("wide characters", func(t *testing.T) { + tw := NewWriter() + tw.AppendHeader(Row{"#", "WrapSoft", "WrapHard", "WrapText"}) + tw.AppendRows([]Row{ + {1, "abcd甲乙丙丁abcd", "abcd甲乙丙丁abcd", "abcd甲乙丙丁abcd"}, + {2, "abcdabcdabcd abcdabcd abcd", "abcdabcdabcd abcdabcd abcd", "abcdabcdabcd abcdabcd abcd"}, + {3, "甲乙丙丁甲乙丙丁甲乙丙丁", "甲乙丙丁甲乙丙丁甲乙丙丁", "甲乙丙丁甲乙丙丁甲乙丙丁"}, + }) + tw.SetColumnConfigs([]ColumnConfig{ + {Name: "WrapSoft", WidthMax: 10, WidthMaxEnforcer: text.WrapSoft}, + {Name: "WrapHard", WidthMax: 10, WidthMaxEnforcer: text.WrapHard}, + {Name: "WrapText", WidthMax: 10, WidthMaxEnforcer: text.WrapText}, + }) + tw.Style().Format.Header = text.FormatDefault - tw.SetColumnConfigs([]ColumnConfig{ - {Number: 2, WidthMax: 10}, - {Number: 3, WidthMax: 10}, - {Number: 4, WidthMax: 6}, - {Number: 5, WidthMax: 27}, + compareOutput(t, tw.Render(), ` ++---+------------+------------+------------+ +| # | WrapSoft | WrapHard | WrapText | ++---+------------+------------+------------+ +| 1 | abcd甲乙丙 | abcd甲乙丙 | abcd甲乙丙 | +| | 丁abcd | 丁abcd | 丁abcd | +| 2 | abcdabcdab | abcdabcdab | abcdabcdab | +| | cd | cd abcdabc | cd abcdabc | +| | abcdabcd | d abcd | d abcd | +| | abcd | | | +| 3 | 甲乙丙丁甲 | 甲乙丙丁甲 | 甲乙丙丁甲 | +| | 乙丙丁甲乙 | 乙丙丁甲乙 | 乙丙丁甲乙 | +| | 丙丁 | 丙丁 | 丙丁 | ++---+------------+------------+------------+`) }) - compareOutput(t, tw.Render(), ` -┌─────────────────────────────────────────────────────┬────────────┬────────────┬────────┬─────────────────────────────┐ -│ # │ FIRST NAME │ LAST NAME │ SALARY │ │ -├─────────────────────────────────────────────────────┼────────────┼────────────┼────────┼─────────────────────────────┤ -│ 1 │ Arya │ Stark │ 3000 │ │ -│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │ -│ 300 │ Tyrion │ Lannister │ 5000 │ │ -├─────────────────────────────────────────────────────┼────────────┼────────────┼────────┼─────────────────────────────┤ -│ │ │ TOTAL │ 10000 │ │ -└─────────────────────────────────────────────────────┴────────────┴────────────┴────────┴─────────────────────────────┘`) - - tw.SetColumnConfigs(nil) - tw.Style().Size = SizeOptions{ - WidthMax: 60, - WidthMin: 0, - } - compareOutput(t, tw.Render(), ` -┌─────┬────────────┬───────────┬────────┬───────────────── ≈ -│ # │ FIRST NAME │ LAST NAME │ SALARY │ ≈ -├─────┼────────────┼───────────┼────────┼───────────────── ≈ -│ 1 │ Arya │ Stark │ 3000 │ ≈ -│ 20 │ Jon │ Snow │ 2000 │ You know nothing ≈ -│ 300 │ Tyrion │ Lannister │ 5000 │ ≈ -├─────┼────────────┼───────────┼────────┼───────────────── ≈ -│ │ │ TOTAL │ 10000 │ ≈ -└─────┴────────────┴───────────┴────────┴───────────────── ≈`) - - // expanded columns, but truncated row - not a valid usage scenario; - // no enforcement on min < max at this point - tw.SetColumnConfigs(nil) - tw.Style().Size = SizeOptions{ - WidthMax: 60, - WidthMin: 80, - } - compareOutput(t, tw.Render(), ` -┌───────┬──────────────┬─────────────┬──────────┬───────── ≈ -│ # │ FIRST NAME │ LAST NAME │ SALARY │ ≈ -├───────┼──────────────┼─────────────┼──────────┼───────── ≈ -│ 1 │ Arya │ Stark │ 3000 │ ≈ -│ 20 │ Jon │ Snow │ 2000 │ You know ≈ -│ 300 │ Tyrion │ Lannister │ 5000 │ ≈ -├───────┼──────────────┼─────────────┼──────────┼───────── ≈ -│ │ │ TOTAL │ 10000 │ ≈ -└───────┴──────────────┴─────────────┴──────────┴───────── ≈`) } diff --git a/table/table.go b/table/table.go index 479d4b3..ea2fae6 100644 --- a/table/table.go +++ b/table/table.go @@ -603,9 +603,9 @@ func (t *Table) getFormat(hint renderHint) text.Format { func (t *Table) getMaxColumnLengthForMerging(colIdx int) int { maxColumnLength := t.maxColumnLengths[colIdx] - maxColumnLength += text.RuneWidthWithoutEscSequences(t.style.Box.PaddingRight + t.style.Box.PaddingLeft) + maxColumnLength += text.StringWidthWithoutEscSequences(t.style.Box.PaddingRight + t.style.Box.PaddingLeft) if t.style.Options.SeparateColumns { - maxColumnLength += text.RuneWidthWithoutEscSequences(t.style.Box.EmptySeparator) + maxColumnLength += text.StringWidthWithoutEscSequences(t.style.Box.EmptySeparator) } return maxColumnLength } diff --git a/text/align.go b/text/align.go index 2a1068a..189531d 100644 --- a/text/align.go +++ b/text/align.go @@ -40,7 +40,7 @@ func (a Align) Apply(text string, maxLength int) string { text = aComputed.trimString(text) sLen := utf8.RuneCountInString(text) - sLenWoE := RuneWidthWithoutEscSequences(text) + sLenWoE := StringWidthWithoutEscSequences(text) numEscChars := sLen - sLenWoE // now, align the text diff --git a/text/ansi.go b/text/ansi.go index 6a396b1..6f13b65 100644 --- a/text/ansi.go +++ b/text/ansi.go @@ -39,7 +39,7 @@ func Escape(str string, escapeSeq string) string { // StripEscape("\x1b[91mNymeria \x1b[94mGhost\x1b[0m\x1b[91m Lady\x1b[0m") == "Nymeria Ghost Lady" func StripEscape(str string) string { var out strings.Builder - out.Grow(RuneWidthWithoutEscSequences(str)) + out.Grow(StringWidthWithoutEscSequences(str)) isEscSeq := false for _, sChr := range str { diff --git a/text/direction.go b/text/direction.go index 25eccc2..d0a74f1 100644 --- a/text/direction.go +++ b/text/direction.go @@ -11,14 +11,19 @@ const ( RightToLeft ) +const ( + RuneL2R = '\u202a' + RuneR2L = '\u202b' +) + // Modifier returns a character to force the given direction for the text that // follows the modifier. func (d Direction) Modifier() string { switch d { case LeftToRight: - return "\u202a" + return string(RuneL2R) case RightToLeft: - return "\u202b" + return string(RuneR2L) } return "" } diff --git a/text/direction_test.go b/text/direction_test.go index f6d308c..9d4ee85 100644 --- a/text/direction_test.go +++ b/text/direction_test.go @@ -8,6 +8,6 @@ import ( func TestDirection_Modifier(t *testing.T) { assert.Equal(t, "", Default.Modifier()) - assert.Equal(t, "\u202a", LeftToRight.Modifier()) - assert.Equal(t, "\u202b", RightToLeft.Modifier()) + assert.Equal(t, string(RuneL2R), LeftToRight.Modifier()) + assert.Equal(t, string(RuneR2L), RightToLeft.Modifier()) } diff --git a/text/string.go b/text/string.go index 6d3e0ee..fa28a4c 100644 --- a/text/string.go +++ b/text/string.go @@ -5,6 +5,7 @@ import ( "unicode/utf8" "github.com/mattn/go-runewidth" + "golang.org/x/text/width" ) // RuneWidth stuff @@ -24,7 +25,7 @@ func InsertEveryN(str string, runeToInsert rune, n int) string { return str } - sLen := RuneWidthWithoutEscSequences(str) + sLen := StringWidthWithoutEscSequences(str) var out strings.Builder out.Grow(sLen + (sLen / n)) outLen, esp := 0, escSeqParser{} @@ -102,7 +103,7 @@ func OverrideRuneWidthEastAsianWidth(val bool) { // Pad("Ghost", 7, ' ') == "Ghost " // Pad("Ghost", 10, '.') == "Ghost....." func Pad(str string, maxLen int, paddingChar rune) string { - strLen := RuneWidthWithoutEscSequences(str) + strLen := StringWidthWithoutEscSequences(str) if strLen < maxLen { str += strings.Repeat(string(paddingChar), maxLen-strLen) } @@ -180,7 +181,7 @@ func RepeatAndTrim(str string, maxRunes int) string { // // Deprecated: in favor of RuneWidthWithoutEscSequences func RuneCount(str string) int { - return RuneWidthWithoutEscSequences(str) + return StringWidthWithoutEscSequences(str) } // RuneWidth returns the mostly accurate character-width of the rune. This is @@ -203,19 +204,10 @@ func RuneWidth(r rune) int { // RuneWidthWithoutEscSequences("Ghost") == 5 // RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0m") == 5 // RuneWidthWithoutEscSequences("\x1b[33mGhost\x1b[0") == 5 +// +// deprecated: use StringWidthWithoutEscSequences instead func RuneWidthWithoutEscSequences(str string) int { - count, esp := 0, escSeqParser{} - for _, c := range str { - if esp.InSequence() { - esp.Consume(c) - continue - } - esp.Consume(c) - if !esp.InSequence() { - count += RuneWidth(c) - } - } - return count + return StringWidthWithoutEscSequences(str) } // Snip returns the given string with a fixed length. For ex.: @@ -228,15 +220,48 @@ func RuneWidthWithoutEscSequences(str string) int { // Snip("\x1b[33mGhost\x1b[0m", 7, "~") == "\x1b[33mGhost\x1b[0m " func Snip(str string, length int, snipIndicator string) string { if length > 0 { - lenStr := RuneWidthWithoutEscSequences(str) + lenStr := StringWidthWithoutEscSequences(str) if lenStr > length { - lenStrFinal := length - RuneWidthWithoutEscSequences(snipIndicator) + lenStrFinal := length - StringWidthWithoutEscSequences(snipIndicator) return Trim(str, lenStrFinal) + snipIndicator } } return str } +// StringWidth is similar to RuneWidth, except it works on a string. For +// ex.: +// +// StringWidth("Ghost 生命"): 10 +// StringWidth("\x1b[33mGhost 生命\x1b[0m"): 19 +func StringWidth(str string) int { + return rwCondition.StringWidth(str) +} + +// StringWidthWithoutEscSequences is similar to RuneWidth, except for the fact +// that it ignores escape sequences while counting. For ex.: +// +// StringWidthWithoutEscSequences("") == 0 +// StringWidthWithoutEscSequences("Ghost") == 5 +// StringWidthWithoutEscSequences("\x1b[33mGhost\x1b[0m") == 5 +// StringWidthWithoutEscSequences("\x1b[33mGhost\x1b[0") == 5 +// StringWidthWithoutEscSequences("Ghost 生命"): 10 +// StringWidthWithoutEscSequences("\x1b[33mGhost 生命\x1b[0m"): 10 +func StringWidthWithoutEscSequences(str string) int { + count, esp := 0, escSeqParser{} + for _, c := range str { + if esp.InSequence() { + esp.Consume(c) + continue + } + esp.Consume(c) + if !esp.InSequence() { + count += RuneWidth(c) + } + } + return count +} + // Trim trims a string to the given length while ignoring escape sequences. For // ex.: // @@ -272,3 +297,28 @@ func Trim(str string, maxLen int) string { } return out.String() } + +// Widen is like width.Widen.String() but ignores escape sequences. For ex: +// +// Widen("Ghost 生命"): "Ghost\u3000生命" +// Widen("\x1b[33mGhost 生命\x1b[0m"): "\x1b[33mGhost\u3000生命\x1b[0m" +func Widen(str string) string { + sb := strings.Builder{} + sb.Grow(len(str)) + + esp := escSeqParser{} + for _, c := range str { + if esp.InSequence() { + sb.WriteRune(c) + esp.Consume(c) + continue + } + esp.Consume(c) + if !esp.InSequence() { + sb.WriteString(width.Widen.String(string(c))) + } else { + sb.WriteRune(c) + } + } + return sb.String() +} diff --git a/text/string_test.go b/text/string_test.go index 58de193..2c45f4d 100644 --- a/text/string_test.go +++ b/text/string_test.go @@ -95,9 +95,9 @@ func TestOverrideRuneWidthEastAsianWidth(t *testing.T) { }() OverrideRuneWidthEastAsianWidth(true) - assert.Equal(t, 2, RuneWidthWithoutEscSequences("╋")) + assert.Equal(t, 2, StringWidthWithoutEscSequences("╋")) OverrideRuneWidthEastAsianWidth(false) - assert.Equal(t, 1, RuneWidthWithoutEscSequences("╋")) + assert.Equal(t, 1, StringWidthWithoutEscSequences("╋")) // Note for posterity. We want the length of the box drawing character to // be reported as 1. However, with an environment where LANG is set to @@ -282,6 +282,48 @@ func TestSnip(t *testing.T) { assert.Equal(t, "\x1b[33m\x1b]8;;http://example.com\x1b\\Gh\x1b]8;;\x1b\\\x1b[0m~", Snip("\x1b[33m\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\\x1b[0m", 3, "~")) } +func ExampleStringWidth() { + fmt.Printf("StringWidth(\"Ghost 生命\"): %d\n", StringWidth("Ghost 生命")) + fmt.Printf("StringWidth(\"\\x1b[33mGhost 生命\\x1b[0m\"): %d\n", StringWidth("\x1b[33mGhost 生命\x1b[0m")) + + // Output: StringWidth("Ghost 生命"): 10 + // StringWidth("\x1b[33mGhost 生命\x1b[0m"): 17 +} + +func TestStringWidth(t *testing.T) { + assert.Equal(t, 10, StringWidth("Ghost 生命")) + assert.Equal(t, 17, StringWidth("\x1b[33mGhost 生命\x1b[0m")) +} + +func ExampleStringWidthWithoutEscSequences() { + fmt.Printf("StringWidthWithoutEscSequences(\"\"): %d\n", StringWidthWithoutEscSequences("")) + fmt.Printf("StringWidthWithoutEscSequences(\"Ghost\"): %d\n", StringWidthWithoutEscSequences("Ghost")) + fmt.Printf("StringWidthWithoutEscSequences(\"Ghostツ\"): %d\n", StringWidthWithoutEscSequences("Ghostツ")) + fmt.Printf("StringWidthWithoutEscSequences(\"\\x1b[33mGhost\\x1b[0m\"): %d\n", StringWidthWithoutEscSequences("\x1b[33mGhost\x1b[0m")) + fmt.Printf("StringWidthWithoutEscSequences(\"\\x1b[33mGhost\\x1b[0\"): %d\n", StringWidthWithoutEscSequences("\x1b[33mGhost\x1b[0")) + fmt.Printf("StringWidthWithoutEscSequences(\"Ghost 生命\"): %d\n", StringWidthWithoutEscSequences("Ghost 生命")) + fmt.Printf("StringWidthWithoutEscSequences(\"\\x1b[33mGhost 生命\\x1b[0m\"): %d\n", StringWidthWithoutEscSequences("\x1b[33mGhost 生命\x1b[0m")) + + // Output: StringWidthWithoutEscSequences(""): 0 + // StringWidthWithoutEscSequences("Ghost"): 5 + // StringWidthWithoutEscSequences("Ghostツ"): 7 + // StringWidthWithoutEscSequences("\x1b[33mGhost\x1b[0m"): 5 + // StringWidthWithoutEscSequences("\x1b[33mGhost\x1b[0"): 5 + // StringWidthWithoutEscSequences("Ghost 生命"): 10 + // StringWidthWithoutEscSequences("\x1b[33mGhost 生命\x1b[0m"): 10 +} + +func TestStringWidthWithoutEscSequences(t *testing.T) { + assert.Equal(t, 0, StringWidthWithoutEscSequences("")) + assert.Equal(t, 5, StringWidthWithoutEscSequences("Ghost")) + assert.Equal(t, 7, StringWidthWithoutEscSequences("Ghostツ")) + assert.Equal(t, 5, StringWidthWithoutEscSequences("\x1b[33mGhost\x1b[0m")) + assert.Equal(t, 5, StringWidthWithoutEscSequences("\x1b[33mGhost\x1b[0")) + assert.Equal(t, 5, StringWidthWithoutEscSequences("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\")) + assert.Equal(t, 10, StringWidthWithoutEscSequences("Ghost 生命")) + assert.Equal(t, 10, StringWidthWithoutEscSequences("\x1b[33mGhost 生命\x1b[0m")) +} + func ExampleTrim() { fmt.Printf("Trim(\"Ghost\", 0): %#v\n", Trim("Ghost", 0)) fmt.Printf("Trim(\"Ghost\", 3): %#v\n", Trim("Ghost", 3)) @@ -306,3 +348,16 @@ func TestTrim(t *testing.T) { assert.Equal(t, "\x1b[33mGhost\x1b[0m", Trim("\x1b[33mGhost\x1b[0m", 6)) assert.Equal(t, "\x1b]8;;http://example.com\x1b\\Gho\x1b]8;;\x1b\\", Trim("\x1b]8;;http://example.com\x1b\\Ghost\x1b]8;;\x1b\\", 3)) } + +func ExampleWiden() { + fmt.Printf("Widen(\"Ghost 生命\"): %#v\n", Widen("Ghost 生命")) + fmt.Printf("Widen(\"\\x1b[33mGhost 生命\\x1b[0m\"): %#v\n", Widen("\x1b[33mGhost 生命\x1b[0m")) + + // Output: Widen("Ghost 生命"): "Ghost\u3000生命" + // Widen("\x1b[33mGhost 生命\x1b[0m"): "\x1b[33mGhost\u3000生命\x1b[0m" +} + +func TestWiden(t *testing.T) { + assert.Equal(t, "Ghost 生命", Widen("Ghost 生命")) + assert.Equal(t, "\x1b[33mGhost 生命\x1b[0m", Widen("\x1b[33mGhost 生命\x1b[0m")) +} diff --git a/text/wrap.go b/text/wrap.go index 0f6d7e9..8ad8485 100644 --- a/text/wrap.go +++ b/text/wrap.go @@ -2,7 +2,6 @@ package text import ( "strings" - "unicode/utf8" ) // WrapHard wraps a string to the given length using a newline. Handles strings @@ -15,7 +14,7 @@ func WrapHard(str string, wrapLen int) string { return "" } str = strings.Replace(str, "\t", " ", -1) - sLen := utf8.RuneCountInString(str) + sLen := StringWidthWithoutEscSequences(str) if sLen <= wrapLen { return str } @@ -43,7 +42,7 @@ func WrapSoft(str string, wrapLen int) string { return "" } str = strings.Replace(str, "\t", " ", -1) - sLen := utf8.RuneCountInString(str) + sLen := StringWidthWithoutEscSequences(str) if sLen <= wrapLen { return str } @@ -70,7 +69,7 @@ func WrapText(str string, wrapLen int) string { return "" } str = strings.Replace(str, "\t", " ", -1) - sLen := utf8.RuneCountInString(str) + sLen := StringWidthWithoutEscSequences(str) if sLen <= wrapLen { return str } @@ -111,7 +110,7 @@ func appendChar(char rune, wrapLen int, lineLen *int, inEscSeq bool, lastSeenEsc // increment the line index if not in the middle of an escape sequence if !inEscSeq { - *lineLen++ + *lineLen += RuneWidth(char) } } } @@ -170,7 +169,7 @@ func wrapHard(paragraph string, wrapLen int, out *strings.Builder) { lineLen++ } - wordLen := RuneWidthWithoutEscSequences(word) + wordLen := StringWidthWithoutEscSequences(word) if lineLen+wordLen <= wrapLen { // word fits within the line out.WriteString(word) lineLen += wordLen @@ -196,7 +195,7 @@ func wrapSoft(paragraph string, wrapLen int, out *strings.Builder) { } spacing, spacingLen := wrapSoftSpacing(lineLen) - wordLen := RuneWidthWithoutEscSequences(word) + wordLen := StringWidthWithoutEscSequences(word) if lineLen+spacingLen+wordLen <= wrapLen { // word fits within the line out.WriteString(spacing) out.WriteString(word) diff --git a/text/wrap_test.go b/text/wrap_test.go index 94d1c55..3a77679 100644 --- a/text/wrap_test.go +++ b/text/wrap_test.go @@ -8,6 +8,22 @@ import ( "github.com/stretchr/testify/assert" ) +var ( + textTable = "+---+------+-------+------+\n| 1 | Arya | Stark | 3000 |\n+---+------+-------+------+" + + // colored text with nested escape codes: "{red}{bold}...{un-bold}...{reset}" + textUnBold = "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red Text\x1b[0m" + expectedUnBold = "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red \x1b[0m\n\x1b[91mText\x1b[0m" + + // text with wide characters + textWide = "abcd甲乙丙丁abcd" + expectedWide = "abcd甲乙丙\n丁abcd" + + // colored text with wide characters + textWideColored = "\x1b[22mabcd甲乙丙丁abcd\x1b[0m" + expectedWideColored = "\x1b[22mabcd甲乙丙\x1b[0m\n\u001B[22m丁abcd\u001B[0m" +) + func ExampleWrapHard() { str := `The quick brown fox jumped over the lazy dog. @@ -48,13 +64,10 @@ func TestWrapHard(t *testing.T) { assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m", WrapHard("\x1b[33mJon Snow\n", 3)) assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw \x1b[0m", WrapHard("\x1b[33mJon Snow\n\x1b[0m", 3)) - complexIn := "+---+------+-------+------+\n| 1 | Arya | Stark | 3000 |\n+---+------+-------+------+" - assert.Equal(t, complexIn, WrapHard(complexIn, 27)) - - // colored text with nested escape codes: "{red}{bold}...{un-bold}...{reset}" - textUnBold := "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red Text\x1b[0m" - expectedUnBold := "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red \x1b[0m\n\x1b[91mText\x1b[0m" + assert.Equal(t, textTable, WrapHard(textTable, 27)) assert.Equal(t, expectedUnBold, WrapHard(textUnBold, 23)) + assert.Equal(t, expectedWide, WrapHard(textWide, 10)) + assert.Equal(t, expectedWideColored, WrapHard(textWideColored, 10)) } func TestFoo(t *testing.T) { @@ -103,17 +116,13 @@ func TestWrapSoft(t *testing.T) { assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m", WrapSoft("\x1b[33mJon Snow\x1b[0m", 3)) assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m", WrapSoft("\x1b[33mJon Snow\n", 3)) assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw \x1b[0m", WrapSoft("\x1b[33mJon Snow\n\x1b[0m", 3)) - - complexIn := "+---+------+-------+------+\n| 1 | Arya | Stark | 3000 |\n+---+------+-------+------+" - assert.Equal(t, complexIn, WrapSoft(complexIn, 27)) - assert.Equal(t, "\x1b[33mJon \x1b[0m\n\x1b[33mSnow\x1b[0m", WrapSoft("\x1b[33mJon Snow\x1b[0m", 4)) assert.Equal(t, "\x1b[33mJon \x1b[0m\n\x1b[33mSnow\x1b[0m\n\x1b[33m???\x1b[0m", WrapSoft("\x1b[33mJon Snow???\x1b[0m", 4)) - // colored text with nested escape codes: "{red}{bold}...{un-bold}...{reset}" - textUnBold := "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red Text\x1b[0m" - expectedUnBold := "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red \x1b[0m\n\x1b[91mText\x1b[0m" + assert.Equal(t, textTable, WrapSoft(textTable, 27)) assert.Equal(t, expectedUnBold, WrapSoft(textUnBold, 23)) + assert.Equal(t, expectedWide, WrapHard(textWide, 10)) + assert.Equal(t, expectedWideColored, WrapHard(textWideColored, 10)) } func ExampleWrapText() { @@ -156,11 +165,8 @@ func TestWrapText(t *testing.T) { assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m\n", WrapText("\x1b[33mJon Snow\n", 3)) assert.Equal(t, "\x1b[33mJon\x1b[0m\n\x1b[33mSno\x1b[0m\n\x1b[33mw\x1b[0m\n\x1b[0m", WrapText("\x1b[33mJon Snow\n\x1b[0m", 3)) - complexIn := "+---+------+-------+------+\n| 1 | Arya | Stark | 3000 |\n+---+------+-------+------+" - assert.Equal(t, complexIn, WrapText(complexIn, 27)) - - // colored text with nested escape codes: "{red}{bold}...{un-bold}...{reset}" - textUnBold := "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red Text\x1b[0m" - expectedUnBold := "\x1b[91m\x1b[1mBold Title\x1b[22m Regular Red \x1b[0m\n\x1b[91mText\x1b[0m" + assert.Equal(t, textTable, WrapText(textTable, 27)) assert.Equal(t, expectedUnBold, WrapText(textUnBold, 23)) + assert.Equal(t, expectedWide, WrapHard(textWide, 10)) + assert.Equal(t, expectedWideColored, WrapHard(textWideColored, 10)) }