Skip to content

Commit

Permalink
Initial support for rendering wrapped, bulleted lists.
Browse files Browse the repository at this point in the history
Also do not put wrapped links on their own line.
  • Loading branch information
mitchell-as committed Oct 31, 2024
1 parent d6acb39 commit 48614a3
Show file tree
Hide file tree
Showing 9 changed files with 150 additions and 64 deletions.
23 changes: 13 additions & 10 deletions internal/colorize/cropped.go → internal/colorize/wrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import (
"strings"
)

type CroppedLines []CroppedLine
type WrappedLines []WrappedLine

type CroppedLine struct {
type WrappedLine struct {
Line string
Length int
}

func (c CroppedLines) String() string {
func (c WrappedLines) String() string {
var result string
for _, crop := range c {
result = result + crop.Line
Expand All @@ -22,8 +22,9 @@ func (c CroppedLines) String() string {
}

var indentRegexp = regexp.MustCompile(`^([ ]+)`)
var isLinkRegexp = regexp.MustCompile(`\s*(\[[^\]]+\])?https?://`)

func GetCroppedText(text string, maxLen int, includeLineEnds bool) CroppedLines {
func Wrap(text string, maxLen int, includeLineEnds bool, continuation string) WrappedLines {
indent := ""
if indentMatch := indentRegexp.FindStringSubmatch(text); indentMatch != nil {
indent = indentMatch[0]
Expand All @@ -32,11 +33,13 @@ func GetCroppedText(text string, maxLen int, includeLineEnds bool) CroppedLines
}
}

entries := make([]CroppedLine, 0)
maxLen -= len(continuation)

entries := make([]WrappedLine, 0)
colorCodes := colorRx.FindAllStringSubmatchIndex(text, -1)

isLineEnd := false
entry := CroppedLine{}
entry := WrappedLine{}
for pos, amend := range text {
inColorTag := inRange(pos, colorCodes)

Expand Down Expand Up @@ -69,8 +72,8 @@ func GetCroppedText(text string, maxLen int, includeLineEnds bool) CroppedLines
}
}
// Extract the word from the current line if it doesn't start the line.
if i > 0 && i < len(entry.Line)-1 {
wrapped = indent + entry.Line[i:]
if i > 0 && i < len(entry.Line)-1 && !isLinkRegexp.MatchString(entry.Line[i:]) {
wrapped = indent + continuation + entry.Line[i:]
entry.Line = entry.Line[:i]
entry.Length -= wrappedLength
isLineEnd = true // emulate for wrapping purposes
Expand All @@ -79,11 +82,11 @@ func GetCroppedText(text string, maxLen int, includeLineEnds bool) CroppedLines
}
}
entries = append(entries, entry)
entry = CroppedLine{Line: wrapped, Length: wrappedLength}
entry = WrappedLine{Line: wrapped, Length: wrappedLength}
}

if isLineEnd && includeLineEnds {
entries = append(entries, CroppedLine{"\n", 1})
entries = append(entries, WrappedLine{"\n", 1})
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,82 +7,82 @@ import (
"testing"
)

func Test_GetCroppedText(t *testing.T) {
func Test_Wrap(t *testing.T) {
type args struct {
text string
maxLen int
}
tests := []struct {
name string
args args
want CroppedLines
want WrappedLines
}{
{
"No split",
args{"[HEADING]Hello[/RESET]", 5},
[]CroppedLine{{"[HEADING]Hello[/RESET]", 5}},
[]WrappedLine{{"[HEADING]Hello[/RESET]", 5}},
},
{
"Split",
args{"[HEADING]Hello[/RESET]", 3},
[]CroppedLine{{"[HEADING]Hel", 3}, {"lo[/RESET]", 2}},
[]WrappedLine{{"[HEADING]Hel", 3}, {"lo[/RESET]", 2}},
},
{
"Split multiple",
args{"[HEADING]Hello World[/RESET]", 3},
[]CroppedLine{{"[HEADING]Hel", 3}, {"lo ", 3}, {"Wor", 3}, {"ld[/RESET]", 2}},
[]WrappedLine{{"[HEADING]Hel", 3}, {"lo ", 3}, {"Wor", 3}, {"ld[/RESET]", 2}},
},
{
"Split multiple no match",
args{"Hello World", 3},
[]CroppedLine{{"Hel", 3}, {"lo ", 3}, {"Wor", 3}, {"ld", 2}},
[]WrappedLine{{"Hel", 3}, {"lo ", 3}, {"Wor", 3}, {"ld", 2}},
},
{
"No split no match",
args{"Hello", 5},
[]CroppedLine{{"Hello", 5}},
[]WrappedLine{{"Hello", 5}},
},
{
"Split multi-byte characters",
args{"✔ol1✔ol2✔ol3", 4},
[]CroppedLine{{"✔ol1", 4}, {"✔ol2", 4}, {"✔ol3", 4}},
[]WrappedLine{{"✔ol1", 4}, {"✔ol2", 4}, {"✔ol3", 4}},
},
{
"No split multi-byte character with tags",
args{"[HEADING]✔ Some Text[/RESET]", 20},
[]CroppedLine{{"[HEADING]✔ Some Text[/RESET]", 11}},
[]WrappedLine{{"[HEADING]✔ Some Text[/RESET]", 11}},
},
{
"Split multi-byte character with tags",
args{"[HEADING]✔ Some Text[/RESET]", 6},
[]CroppedLine{{"[HEADING]✔ Some", 6}, {" Text[/RESET]", 5}},
[]WrappedLine{{"[HEADING]✔ Some", 6}, {" Text[/RESET]", 5}},
},
{
"Split multi-byte character with tags by words",
args{"[HEADING]✔ Some Text[/RESET]", 10},
[]CroppedLine{{"[HEADING]✔ Some ", 7}, {"Text[/RESET]", 4}},
[]WrappedLine{{"[HEADING]✔ Some ", 7}, {"Text[/RESET]", 4}},
},
{
"Split line break",
args{"[HEADING]Hel\nlo[/RESET]", 5},
[]CroppedLine{{"[HEADING]Hel", 3}, {"lo[/RESET]", 2}},
[]WrappedLine{{"[HEADING]Hel", 3}, {"lo[/RESET]", 2}},
},
{
"Split nested",
args{"[HEADING][NOTICE]Hello[/RESET][/RESET]", 3},
[]CroppedLine{{"[HEADING][NOTICE]Hel", 3}, {"lo[/RESET][/RESET]", 2}},
[]WrappedLine{{"[HEADING][NOTICE]Hel", 3}, {"lo[/RESET][/RESET]", 2}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetCroppedText(tt.args.text, tt.args.maxLen, false); !reflect.DeepEqual(got, tt.want) {
t.Errorf("getCroppedText() = %v, want %v", got, tt.want)
if got := Wrap(tt.args.text, tt.args.maxLen, false, ""); !reflect.DeepEqual(got, tt.want) {
t.Errorf("Wrap() = %v, want %v", got, tt.want)
}
})
}
}

func Test_GetCroppedTextAsString(t *testing.T) {
func Test_WrapAsString(t *testing.T) {
tests := []struct {
name string
text string
Expand Down Expand Up @@ -119,11 +119,11 @@ func Test_GetCroppedTextAsString(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetCroppedText(tt.text, 999, true); !reflect.DeepEqual(got.String(), tt.text) {
if got := Wrap(tt.text, 999, true, ""); !reflect.DeepEqual(got.String(), tt.text) {
escape := func(v string) string {
return strings.Replace(v, "\n", "\\n", -1)
}
t.Errorf("getCroppedText() = %v, want %v (crop data: %s)", escape(got.String()), escape(tt.text), escape(fmt.Sprintf("%#v", got)))
t.Errorf("Wrap() = %v, want %v (crop data: %s)", escape(got.String()), escape(tt.text), escape(fmt.Sprintf("%#v", got)))
}
})
}
Expand Down
10 changes: 1 addition & 9 deletions internal/output/plain.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,22 +103,14 @@ func (f *Plain) write(writer io.Writer, value interface{}) {
// writeNow is a little helper that just writes the given value to the requested writer (no marshalling)
func (f *Plain) writeNow(writer io.Writer, value string) {
if f.Config().Interactive {
value = wordWrap(value)
value = colorize.Wrap(value, termutils.GetWidth(), true, "").String()
}
_, err := colorize.Colorize(value, writer, !f.cfg.Colored)
if err != nil {
logging.ErrorNoStacktrace("Writing colored output failed: %v", err)
}
}

func wordWrap(text string) string {
return wordWrapWithWidth(text, termutils.GetWidth())
}

func wordWrapWithWidth(text string, width int) string {
return colorize.GetCroppedText(text, width, true).String()
}

const nilText = "<nil>"

// sprint will marshal and return the given value as a string
Expand Down
89 changes: 89 additions & 0 deletions internal/output/renderers/bulletlist.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package renderers

import (
"fmt"
"strings"

"github.com/ActiveState/cli/internal/colorize"
"github.com/ActiveState/cli/internal/multilog"
"github.com/ActiveState/cli/internal/output"
"github.com/ActiveState/cli/internal/termutils"
)

type bulletList struct {
prefix string
items []string
bullets []string
}

// BulletTree outputs a list like:
//
// ├─ one
// ├─ two
// │ wrapped
// └─ three
var BulletTree = []string{output.TreeMid, output.TreeMid, output.TreeLink, output.TreeEnd}

// HeadedBulletTree outputs a list like:
//
// one
// ├─ two
// │ wrapped
// └─ three
var HeadedBulletTree = []string{"", output.TreeMid, output.TreeLink, output.TreeEnd}

// NewBulletList returns a printable list of items prefixed with the given set of bullets.
// The set of bullets should contain four items: the bullet for the first item (e.g. ""); the
// bullet for each subsequent item (e.g. "├─"); the bullet for an item's wrapped lines, if any
// (e.g. "│"); and the bullet for the last item (e.g. "└─").
// The returned list can be passed to a plain printer. It should not be passed to a structured
// printer.
func NewBulletList(prefix string, bullets, items []string) *bulletList {
if len(bullets) != 4 {
multilog.Error("Invalid bullet list: 4 bullets required")
bullets = BulletTree
}
return &bulletList{prefix, items, bullets}
}

func (b *bulletList) MarshalOutput(format output.Format) interface{} {
out := make([]string, len(b.items))

// Determine the indentation of each item.
// If the prefix is pure indentation, then the indent is that prefix.
// If the prefix is not pure indentation, then the indent is the number of characters between
// the first non-space character and the end of the prefix.
// For example, both "* " and " * " have and indent of 2 because items should be indented to
// match the bullet item's left margin (note that wrapping will auto-indent to match the leading
// space in the second example).
indent := b.prefix
if nonIndent := strings.TrimLeft(b.prefix, " "); nonIndent != "" {
indent = strings.Repeat(" ", len(nonIndent))
}

for i, item := range b.items {
bullet := b.bullets[0]
if len(b.items) == 1 {
bullet = b.bullets[3] // special case list length of one; use last bullet
}

if i == 0 {
if bullet != "" {
bullet += " "
}
item = b.prefix + bullet + item
} else {
bullet = b.bullets[1]
continuation := indent + b.bullets[2] + " "
if i == len(b.items)-1 {
bullet = b.bullets[3] // this is the last item
continuation = " "
}
wrapped := colorize.Wrap(item, termutils.GetWidth()-len(indent), true, continuation).String()
item = fmt.Sprintf("%s%s %s", indent, bullet, wrapped)
}
out[i] = item
}

return strings.Join(out, "\n")
}
10 changes: 4 additions & 6 deletions internal/runbits/dependencies/changesummary.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/ActiveState/cli/internal/locale"
"github.com/ActiveState/cli/internal/logging"
"github.com/ActiveState/cli/internal/output"
"github.com/ActiveState/cli/internal/output/renderers"
"github.com/ActiveState/cli/internal/sliceutils"
"github.com/ActiveState/cli/pkg/buildplan"
)
Expand Down Expand Up @@ -107,12 +108,8 @@ func OutputChangeSummary(out output.Outputer, newBuildPlan *buildplan.BuildPlan,
// └─ name@oldVersion → name@newVersion (Updated)
// depending on whether or not it has subdependencies, and whether or not showUpdatedPackages is
// `true`.
items := make([]string, len(directDependencies))
for i, ingredient := range directDependencies {
prefix := output.TreeMid
if i == len(directDependencies)-1 {
prefix = output.TreeEnd
}

// Retrieve runtime dependencies, and then filter out any dependencies that are common between all added ingredients.
runtimeDeps := ingredient.RuntimeDependencies(true)
runtimeDeps = runtimeDeps.Filter(func(i *buildplan.Ingredient) bool { _, ok := commonDependencies[i.IngredientID]; return !ok })
Expand All @@ -130,8 +127,9 @@ func OutputChangeSummary(out output.Outputer, newBuildPlan *buildplan.BuildPlan,
item = fmt.Sprintf("[ACTIONABLE]%s@%s[/RESET] → %s (%s)", oldVersion.Name, oldVersion.Version, item, locale.Tl("updated", "updated"))
}

out.Notice(fmt.Sprintf(" [DISABLED]%s[/RESET] %s", prefix, item))
items[i] = item
}

out.Notice(renderers.NewBulletList(" ", renderers.BulletTree, items))
out.Notice("") // blank line
}
12 changes: 4 additions & 8 deletions internal/runbits/dependencies/summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/ActiveState/cli/internal/locale"
"github.com/ActiveState/cli/internal/output"
"github.com/ActiveState/cli/internal/output/renderers"
"github.com/ActiveState/cli/pkg/buildplan"
)

Expand All @@ -27,12 +28,8 @@ func OutputSummary(out output.Outputer, directDependencies buildplan.Artifacts)
out.Notice("") // blank line
out.Notice(locale.Tl("setting_up_dependencies", " Setting up the following dependencies:"))

items := make([]string, len(ingredients))
for i, ingredient := range ingredients {
prefix := " " + output.TreeMid
if i == len(ingredients)-1 {
prefix = " " + output.TreeEnd
}

subDependencies := ingredient.RuntimeDependencies(true)
if _, isCommon := commonDependencies[ingredient.IngredientID]; !isCommon {
// If the ingredient is itself not a common sub-dependency; filter out any common sub dependencies so we don't
Expand All @@ -44,10 +41,9 @@ func OutputSummary(out output.Outputer, directDependencies buildplan.Artifacts)
subdepLocale = locale.Tl("summary_subdeps", "([ACTIONABLE]{{.V0}}[/RESET] sub-dependencies)", strconv.Itoa(numSubs))
}

item := fmt.Sprintf("[ACTIONABLE]%s@%s[/RESET] %s", ingredient.Name, ingredient.Version, subdepLocale)

out.Notice(fmt.Sprintf("[DISABLED]%s[/RESET] %s", prefix, item))
items[i] = fmt.Sprintf("[ACTIONABLE]%s@%s[/RESET] %s", ingredient.Name, ingredient.Version, subdepLocale)
}

out.Notice(renderers.NewBulletList(" ", renderers.BulletTree, items))
out.Notice("") // blank line
}
Loading

0 comments on commit 48614a3

Please sign in to comment.