Skip to content

Commit

Permalink
Add word-wrap support, with wrap length provided by the user
Browse files Browse the repository at this point in the history
We could try to automatically detect the terminal width and wrap at that
point, but this would increase the binary footprint for all users even
if not using this feature.

Instead, we can allow users to specify their preferred line length limit
(if any), and those who want to bear the cost of checking the terminal
size can do so if they wish. This also makes the feature more testable.

Original patch by Sascha Grunert <sgrunert@suse.com>
  • Loading branch information
mostynb committed Nov 7, 2020
1 parent 6102689 commit ccda36d
Show file tree
Hide file tree
Showing 3 changed files with 163 additions and 6 deletions.
73 changes: 72 additions & 1 deletion help.go
Expand Up @@ -59,6 +59,11 @@ var HelpPrinter helpPrinter = printHelp
// HelpPrinterCustom is a function that writes the help output. It is used as
// the default implementation of HelpPrinter, and may be called directly if
// the ExtraInfo field is set on an App.
//
// In the default implementation, if the customFuncs argument contains a
// "wrapAt" key, which is a function which takes no arguments and returns
// an int, this int value will be used to produce a "wrap" function used
// by the default template to wrap long lines.
var HelpPrinterCustom helpPrinterCustom = printHelpCustom

// VersionPrinter prints the version for the App
Expand Down Expand Up @@ -263,8 +268,22 @@ func ShowCommandCompletions(ctx *Context, command string) {
// allow using arbitrary functions in template rendering.
func printHelpCustom(out io.Writer, templ string, data interface{}, customFuncs map[string]interface{}) {
funcMap := template.FuncMap{
"join": strings.Join,
"join": strings.Join,
"wrap": func(input string, offset int) string { return input },
"offset": offset,
}

if customFuncs["wrapAt"] != nil {
if wa, ok := customFuncs["wrapAt"]; ok {
if waf, ok := wa.(func() int); ok {
wrapAt := waf()
customFuncs["wrap"] = func(input string, offset int) string {
return wrap(input, offset, wrapAt)
}
}
}
}

for key, value := range customFuncs {
funcMap[key] = value
}
Expand Down Expand Up @@ -366,3 +385,55 @@ func checkCommandCompletions(c *Context, name string) bool {
ShowCommandCompletions(c, name)
return true
}

func wrap(input string, offset int, wrapAt int) string {
var sb strings.Builder

lines := strings.Split(input, "\n")

padding := strings.Repeat(" ", offset)

for i, line := range lines {
if i != 0 {
sb.WriteString(padding)
}

sb.WriteString(wrapLine(line, offset, wrapAt, padding))

if i != len(lines)-1 {
sb.WriteString("\n")
}
}

return sb.String()
}

func wrapLine(input string, offset int, wrapAt int, padding string) string {
if wrapAt <= offset || len(input) <= wrapAt-offset {
return input
}

lineWidth := wrapAt - offset
words := strings.Fields(input)
if len(words) == 0 {
return input
}

wrapped := words[0]
spaceLeft := lineWidth - len(wrapped)
for _, word := range words[1:] {
if len(word)+1 > spaceLeft {
wrapped += "\n" + padding + word
spaceLeft = lineWidth - len(word)
} else {
wrapped += " " + word
spaceLeft -= 1 + len(word)
}
}

return wrapped
}

func offset(input string, fixed int) int {
return len(input) + fixed
}
86 changes: 86 additions & 0 deletions help_test.go
Expand Up @@ -907,3 +907,89 @@ func TestHideHelpCommand_WithSubcommands(t *testing.T) {
t.Errorf("Run returned unexpected error: %v", err)
}
}

func TestWrappedHelp(t *testing.T) {

// Reset HelpPrinter after this test.
defer func(old helpPrinter) {
HelpPrinter = old
}(HelpPrinter)

output := new(bytes.Buffer)
app := &App{
Writer: output,
Flags: []Flag{
&BoolFlag{Name: "foo",
Aliases: []string{"h"},
Usage: "here's a really long help text line, let's see where it wraps. blah blah blah and so on.",
},
},
Usage: "here's a sample App.Usage string long enough that it should be wrapped in this test",
UsageText: "i'm not sure how App.UsageText differs from App.Usage, but this should also be wrapped in this test",
// TODO: figure out how to make ArgsUsage appear in the help text, and test that
Description: "here's a sample App.Description string long enough that it should be wrapped in this test",
Copyright: `Here's a sample copyright text string long enough that it should be wrapped.
Including newlines.
And then another long line. Blah blah blah does anybody ever read these things?`,
}

c := NewContext(app, nil, nil)

HelpPrinter = func(w io.Writer, templ string, data interface{}) {
funcMap := map[string]interface{}{
"wrapAt": func() int {
return 30
},
}

HelpPrinterCustom(w, templ, data, funcMap)
}

_ = ShowAppHelp(c)

expected := `NAME:
- here's a sample
App.Usage string long
enough that it should be
wrapped in this test
USAGE:
i'm not sure how
App.UsageText differs from
App.Usage, but this should
also be wrapped in this
test
DESCRIPTION:
here's a sample
App.Description string long
enough that it should be
wrapped in this test
GLOBAL OPTIONS:
--foo, -h here's a
really long help text
line, let's see where it
wraps. blah blah blah
and so on. (default:
false)
COPYRIGHT:
Here's a sample copyright
text string long enough
that it should be wrapped.
Including newlines.
And then another long line.
Blah blah blah does anybody
ever read these things?
`

if output.String() != expected {
t.Errorf("Unexpected wrapping, got:\n%s\nexpected: %s",
output.String(), expected)
}
}
10 changes: 5 additions & 5 deletions template.go
Expand Up @@ -4,16 +4,16 @@ package cli
// cli.go uses text/template to render templates. You can
// render custom help text by setting this variable.
var AppHelpTemplate = `NAME:
{{.Name}}{{if .Usage}} - {{.Usage}}{{end}}
{{$v := offset .Name 6}}{{wrap .Name 3}}{{if .Usage}} - {{wrap .Usage $v}}{{end}}
USAGE:
{{if .UsageText}}{{.UsageText}}{{else}}{{.HelpName}} {{if .VisibleFlags}}[global options]{{end}}{{if .Commands}} command [command options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}{{end}}{{if .Version}}{{if not .HideVersion}}
{{if .UsageText}}{{wrap .UsageText 3}}{{else}}{{.HelpName}} {{if .VisibleFlags}}[global options]{{end}}{{if .Commands}} command [command options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}{{end}}{{if .Version}}{{if not .HideVersion}}
VERSION:
{{.Version}}{{end}}{{end}}{{if .Description}}
DESCRIPTION:
{{.Description}}{{end}}{{if len .Authors}}
{{wrap .Description 3}}{{end}}{{if len .Authors}}
AUTHOR{{with $length := len .Authors}}{{if ne 1 $length}}S{{end}}{{end}}:
{{range $index, $author := .Authors}}{{if $index}}
Expand All @@ -26,10 +26,10 @@ COMMANDS:{{range .VisibleCategories}}{{if .Name}}
GLOBAL OPTIONS:
{{range $index, $option := .VisibleFlags}}{{if $index}}
{{end}}{{$option}}{{end}}{{end}}{{if .Copyright}}
{{end}}{{wrap $option.String 6}}{{end}}{{end}}{{if .Copyright}}
COPYRIGHT:
{{.Copyright}}{{end}}
{{wrap .Copyright 3}}{{end}}
`

// CommandHelpTemplate is the text template for the command help topic.
Expand Down

0 comments on commit ccda36d

Please sign in to comment.