Skip to content

Commit e9340b0

Browse files
authored
Merge pull request #456 from NeilMacMullen/feature_improve_support__for_multiline_helptext
Improve support for multiline help text
2 parents 8579025 + 58ae1f6 commit e9340b0

9 files changed

+610
-94
lines changed

src/CommandLine/Text/HelpText.cs

Lines changed: 30 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ public class HelpText
2121
{
2222
private const int BuilderCapacity = 128;
2323
private const int DefaultMaximumLength = 80; // default console width
24+
/// <summary>
25+
/// The number of spaces between an option and its associated help text
26+
/// </summary>
27+
private const int OptionToHelpTextSeparatorWidth = 4;
28+
/// <summary>
29+
/// The width of the option prefix (either "--" or " "
30+
/// </summary>
31+
private const int OptionPrefixWidth = 2;
32+
/// <summary>
33+
/// The total amount of extra space that needs to accounted for when indenting Option help text
34+
/// </summary>
35+
private const int TotalOptionPadding = OptionToHelpTextSeparatorWidth + OptionPrefixWidth;
2436
private readonly StringBuilder preOptionsHelp;
2537
private readonly StringBuilder postOptionsHelp;
2638
private readonly SentenceBuilder sentenceBuilder;
@@ -608,7 +620,7 @@ public static IEnumerable<string> RenderUsageTextAsLines<T>(ParserResult<T> pars
608620
var styles = example.GetFormatStylesOrDefault();
609621
foreach (var s in styles)
610622
{
611-
var commandLine = new StringBuilder(2.Spaces())
623+
var commandLine = new StringBuilder(OptionPrefixWidth.Spaces())
612624
.Append(appAlias)
613625
.Append(' ')
614626
.Append(Parser.Default.FormatCommandLine(example.Sample,
@@ -665,37 +677,7 @@ internal static void AddLine(StringBuilder builder, string value, int maximumLen
665677
value = value.TrimEnd();
666678

667679
builder.AppendWhen(builder.Length > 0, Environment.NewLine);
668-
do
669-
{
670-
var wordBuffer = 0;
671-
var words = value.Split(' ');
672-
for (var i = 0; i < words.Length; i++)
673-
{
674-
if (words[i].Length < (maximumLength - wordBuffer))
675-
{
676-
builder.Append(words[i]);
677-
wordBuffer += words[i].Length;
678-
if ((maximumLength - wordBuffer) > 1 && i != words.Length - 1)
679-
{
680-
builder.Append(" ");
681-
wordBuffer++;
682-
}
683-
}
684-
else if (words[i].Length >= maximumLength && wordBuffer == 0)
685-
{
686-
builder.Append(words[i].Substring(0, maximumLength));
687-
wordBuffer = maximumLength;
688-
break;
689-
}
690-
else
691-
break;
692-
}
693-
value = value.Substring(Math.Min(wordBuffer, value.Length));
694-
builder.AppendWhen(value.Length > 0, Environment.NewLine);
695-
}
696-
while (value.Length > maximumLength);
697-
698-
builder.Append(value);
680+
builder.Append(TextWrapper.WrapAndIndentText(value, 0, maximumLength));
699681
}
700682

701683
private IEnumerable<Specification> GetSpecificationsFromType(Type type)
@@ -757,7 +739,7 @@ private HelpText AddOptionsImpl(
757739

758740
optionsHelp = new StringBuilder(BuilderCapacity);
759741

760-
var remainingSpace = maximumLength - (maxLength + 6);
742+
var remainingSpace = maximumLength - (maxLength + TotalOptionPadding);
761743

762744
specifications.ForEach(
763745
option =>
@@ -809,7 +791,7 @@ private HelpText AddOption(string requiredWord, int maxLength, Specification spe
809791

810792
optionsHelp
811793
.Append(name.Length < maxLength ? name.ToString().PadRight(maxLength) : name.ToString())
812-
.Append(" ");
794+
.Append(OptionToHelpTextSeparatorWidth.Spaces());
813795

814796
var optionHelpText = specification.HelpText;
815797

@@ -821,44 +803,13 @@ private HelpText AddOption(string requiredWord, int maxLength, Specification spe
821803

822804
if (specification.Required)
823805
optionHelpText = "{0} ".FormatInvariant(requiredWord) + optionHelpText;
824-
825-
if (!string.IsNullOrEmpty(optionHelpText))
826-
{
827-
do
828-
{
829-
var wordBuffer = 0;
830-
var words = optionHelpText.Split(' ');
831-
for (var i = 0; i < words.Length; i++)
832-
{
833-
if (words[i].Length < (widthOfHelpText - wordBuffer))
834-
{
835-
optionsHelp.Append(words[i]);
836-
wordBuffer += words[i].Length;
837-
if ((widthOfHelpText - wordBuffer) > 1 && i != words.Length - 1)
838-
{
839-
optionsHelp.Append(" ");
840-
wordBuffer++;
841-
}
842-
}
843-
else if (words[i].Length >= widthOfHelpText && wordBuffer == 0)
844-
{
845-
optionsHelp.Append(words[i].Substring(0, widthOfHelpText));
846-
wordBuffer = widthOfHelpText;
847-
break;
848-
}
849-
else
850-
break;
851-
}
852-
853-
optionHelpText = optionHelpText.Substring(Math.Min(wordBuffer, optionHelpText.Length)).Trim();
854-
optionsHelp.AppendWhen(optionHelpText.Length > 0, Environment.NewLine,
855-
new string(' ', maxLength + 6));
856-
}
857-
while (optionHelpText.Length > widthOfHelpText);
858-
}
859-
806+
807+
//note that we need to indent trim the start of the string because it's going to be
808+
//appended to an existing line that is as long as the indent-level
809+
var indented = TextWrapper.WrapAndIndentText(optionHelpText, maxLength+TotalOptionPadding, widthOfHelpText).TrimStart();
810+
860811
optionsHelp
861-
.Append(optionHelpText)
812+
.Append(indented)
862813
.Append(Environment.NewLine)
863814
.AppendWhen(additionalNewLineAfterOption, Environment.NewLine);
864815

@@ -944,13 +895,13 @@ private int GetMaxOptionLength(OptionSpecification spec)
944895
{
945896
specLength += spec.LongName.Length;
946897
if (AddDashesToOption)
947-
specLength += 2;
898+
specLength += OptionPrefixWidth;
948899

949900
specLength += metaLength;
950901
}
951902

952903
if (hasShort && hasLong)
953-
specLength += 2; // ", "
904+
specLength += OptionPrefixWidth;
954905

955906
return specLength;
956907
}
@@ -997,5 +948,10 @@ private static string FormatDefaultValue<T>(T value)
997948
? builder.ToString(0, builder.Length - 1)
998949
: string.Empty;
999950
}
951+
952+
953+
1000954
}
1001-
}
955+
}
956+
957+

src/CommandLine/Text/TextWrapper.cs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using CommandLine.Infrastructure;
6+
7+
namespace CommandLine.Text
8+
{
9+
/// <summary>
10+
/// A utility class to word-wrap and indent blocks of text
11+
/// </summary>
12+
public class TextWrapper
13+
{
14+
private string[] lines;
15+
public TextWrapper(string input)
16+
{
17+
//start by splitting at newlines and then reinserting the newline as a separate word
18+
//Note that on the input side, we can't assume the line-break style at run time so we have to
19+
//be able to handle both. We can't use Environment.NewLine because that changes at
20+
//_runtime_ and may not match the line-break style that was compiled in
21+
lines = input
22+
.Replace("\r","")
23+
.Split(new[] {'\n'}, StringSplitOptions.None);
24+
}
25+
26+
/// <summary>
27+
/// Splits a string into a words and performs wrapping while also preserving line-breaks and sub-indentation
28+
/// </summary>
29+
/// <param name="columnWidth">The number of characters we can use for text</param>
30+
/// <remarks>
31+
/// This method attempts to wrap text without breaking words
32+
/// For example, if columnWidth is 10 , the input
33+
/// "a string for wrapping 01234567890123"
34+
/// would return
35+
/// "a string
36+
/// "for
37+
/// "wrapping
38+
/// "0123456789
39+
/// "0123"
40+
/// </remarks>
41+
/// <returns>this</returns>
42+
public TextWrapper WordWrap(int columnWidth)
43+
{
44+
//ensure we always use at least 1 column even if the client has told us there's no space available
45+
columnWidth = Math.Max(1, columnWidth);
46+
lines= lines
47+
.SelectMany(line => WordWrapLine(line, columnWidth))
48+
.ToArray();
49+
return this;
50+
}
51+
52+
/// <summary>
53+
/// Indent all lines in the TextWrapper by the desired number of spaces
54+
/// </summary>
55+
/// <param name="numberOfSpaces">The number of spaces to indent by</param>
56+
/// <returns>this</returns>
57+
public TextWrapper Indent(int numberOfSpaces)
58+
{
59+
lines = lines
60+
.Select(line => numberOfSpaces.Spaces() + line)
61+
.ToArray();
62+
return this;
63+
}
64+
65+
/// <summary>
66+
/// Returns the current state of the TextWrapper as a string
67+
/// </summary>
68+
/// <returns></returns>
69+
public string ToText()
70+
{
71+
//return the whole thing as a single string
72+
return string.Join(Environment.NewLine,lines);
73+
}
74+
75+
/// <summary>
76+
/// Convenience method to wraps and indent a string in a single operation
77+
/// </summary>
78+
/// <param name="input">The string to operate on</param>
79+
/// <param name="indentLevel">The number of spaces to indent by</param>
80+
/// <param name="columnWidth">The width of the column used for wrapping</param>
81+
/// <remarks>
82+
/// The string is wrapped _then_ indented so the columnWidth is the width of the
83+
/// usable text block, and does NOT include the indentLevel.
84+
/// </remarks>
85+
/// <returns>the processed string</returns>
86+
public static string WrapAndIndentText(string input, int indentLevel,int columnWidth)
87+
{
88+
return new TextWrapper(input)
89+
.WordWrap(columnWidth)
90+
.Indent(indentLevel)
91+
.ToText();
92+
}
93+
94+
95+
private string [] WordWrapLine(string line,int columnWidth)
96+
{
97+
//create a list of individual lines generated from the supplied line
98+
99+
//When handling sub-indentation we must always reserve at least one column for text!
100+
var unindentedLine = line.TrimStart();
101+
var currentIndentLevel = Math.Min(line.Length - unindentedLine.Length,columnWidth-1) ;
102+
columnWidth -= currentIndentLevel;
103+
104+
return unindentedLine.Split(' ')
105+
.Aggregate(
106+
new List<StringBuilder>(),
107+
(lineList, word) => AddWordToLastLineOrCreateNewLineIfNecessary(lineList, word, columnWidth)
108+
)
109+
.Select(builder => currentIndentLevel.Spaces()+builder.ToString().TrimEnd())
110+
.ToArray();
111+
}
112+
113+
/// <summary>
114+
/// When presented with a word, either append to the last line in the list or start a new line
115+
/// </summary>
116+
/// <param name="lines">A list of StringBuilders containing results so far</param>
117+
/// <param name="word">The individual word to append</param>
118+
/// <param name="columnWidth">The usable text space</param>
119+
/// <remarks>
120+
/// The 'word' can actually be an empty string. It's important to keep these -
121+
/// empty strings allow us to preserve indentation and extra spaces within a line.
122+
/// </remarks>
123+
/// <returns>The same list as is passed in</returns>
124+
private static List<StringBuilder> AddWordToLastLineOrCreateNewLineIfNecessary(List<StringBuilder> lines, string word,int columnWidth)
125+
{
126+
//The current indentation level is based on the previous line but we need to be careful
127+
var previousLine = lines.LastOrDefault()?.ToString() ??string.Empty;
128+
129+
var wouldWrap = !lines.Any() || (word.Length>0 && previousLine.Length + word.Length > columnWidth);
130+
131+
if (!wouldWrap)
132+
{
133+
//The usual case is we just append the 'word' and a space to the current line
134+
//Note that trailing spaces will get removed later when we turn the line list
135+
//into a single string
136+
lines.Last().Append(word + ' ');
137+
}
138+
else
139+
{
140+
//The 'while' here is to take account of the possibility of someone providing a word
141+
//which just can't fit in the current column. In that case we just split it at the
142+
//column end.
143+
//That's a rare case though - most of the time we'll succeed in a single pass without
144+
//having to split
145+
//Note that we always do at least one pass even if the 'word' is empty in order to
146+
//honour sub-indentation and extra spaces within strings
147+
do
148+
{
149+
var availableCharacters = Math.Min(columnWidth, word.Length);
150+
var segmentToAdd = LeftString(word,availableCharacters) + ' ';
151+
lines.Add(new StringBuilder(segmentToAdd));
152+
word = RightString(word,availableCharacters);
153+
} while (word.Length > 0);
154+
}
155+
return lines;
156+
}
157+
158+
159+
/// <summary>
160+
/// Return the right part of a string in a way that compensates for Substring's deficiencies
161+
/// </summary>
162+
private static string RightString(string str,int n)
163+
{
164+
return (n >= str.Length || str.Length==0)
165+
? string.Empty
166+
: str.Substring(n);
167+
}
168+
/// <summary>
169+
/// Return the left part of a string in a way that compensates for Substring's deficiencies
170+
/// </summary>
171+
private static string LeftString(string str,int n)
172+
{
173+
174+
return (n >= str.Length || str.Length==0)
175+
? str
176+
: str.Substring(0,n);
177+
}
178+
}
179+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace CommandLine.Tests.Fakes
2+
{
3+
public class HelpTextWithLineBreaksAndSubIndentation_Options
4+
{
5+
6+
[Option(HelpText = @"This is a help text description where we want:
7+
* The left pad after a linebreak to be honoured and the indentation to be preserved across to the next line
8+
* The ability to return to no indent.
9+
Like this.")]
10+
public string StringValue { get; set; }
11+
12+
}
13+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
namespace CommandLine.Tests.Fakes
2+
{
3+
public class HelpTextWithLineBreaks_Options
4+
{
5+
[Option(HelpText =
6+
@"This is a help text description.
7+
It has multiple lines.
8+
We also want to ensure that indentation is correct.")]
9+
public string StringValue { get; set; }
10+
11+
12+
[Option(HelpText = @"This is a help text description where we want
13+
The left pad after a linebreak to be honoured so that
14+
we can sub-indent within a description.")]
15+
public string StringValu2 { get; set; }
16+
17+
18+
[Option(HelpText = @"This is a help text description where we want
19+
The left pad after a linebreak to be honoured and the indentation to be preserved across to the next line in a way that looks pleasing")]
20+
public string StringValu3 { get; set; }
21+
22+
}
23+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace CommandLine.Tests.Fakes
2+
{
3+
public class HelpTextWithMixedLineBreaks_Options
4+
{
5+
[Option(HelpText =
6+
"This is a help text description\n It has multiple lines.\r\n Third line")]
7+
public string StringValue { get; set; }
8+
}
9+
}

0 commit comments

Comments
 (0)