Make HelpProvider colors configurable (#1408)

This commit is contained in:
Frank Ray
2024-01-18 14:31:28 +00:00
committed by GitHub
parent d5b4621233
commit 9cc888e5ad
21 changed files with 633 additions and 121 deletions

View File

@ -1,5 +1,3 @@
using Spectre.Console.Cli.Resources;
namespace Spectre.Console.Cli.Help;
/// <summary>
@ -10,7 +8,8 @@ namespace Spectre.Console.Cli.Help;
/// </remarks>
public class HelpProvider : IHelpProvider
{
private HelpProviderResources resources;
private readonly HelpProviderResources resources;
private readonly HelpProviderStyle? helpStyles;
/// <summary>
/// Gets a value indicating how many examples from direct children to show in the help text.
@ -27,6 +26,14 @@ public class HelpProvider : IHelpProvider
/// </summary>
protected virtual bool TrimTrailingPeriod { get; }
/// <summary>
/// Gets a value indicating whether to emit the markup styles, inline, when rendering the help text.
/// </summary>
/// <remarks>
/// Useful for unit testing different styling of the same help text.
/// </remarks>
protected virtual bool RenderMarkupInline { get; } = false;
private sealed class HelpArgument
{
public string Name { get; }
@ -94,6 +101,11 @@ public class HelpProvider : IHelpProvider
}
}
internal Composer NewComposer()
{
return new Composer(RenderMarkupInline);
}
/// <summary>
/// Initializes a new instance of the <see cref="HelpProvider"/> class.
/// </summary>
@ -104,6 +116,10 @@ public class HelpProvider : IHelpProvider
this.MaximumIndirectExamples = settings.MaximumIndirectExamples;
this.TrimTrailingPeriod = settings.TrimTrailingPeriod;
// Don't provide a default style if HelpProviderStyles is null,
// as the user will have explicitly done this to output unstyled help text
this.helpStyles = settings.HelpProviderStyles;
resources = new HelpProviderResources(settings.Culture);
}
@ -148,8 +164,8 @@ public class HelpProvider : IHelpProvider
yield break;
}
var composer = new Composer();
composer.Style("yellow", $"{resources.Description}:").LineBreak();
var composer = NewComposer();
composer.Style(helpStyles?.Description?.Header ?? Style.Plain, $"{resources.Description}:").LineBreak();
composer.Text(command.Description).LineBreak();
yield return composer.LineBreak();
}
@ -162,16 +178,16 @@ public class HelpProvider : IHelpProvider
/// <returns>An enumerable collection of <see cref="IRenderable"/> objects.</returns>
public virtual IEnumerable<IRenderable> GetUsage(ICommandModel model, ICommandInfo? command)
{
var composer = new Composer();
composer.Style("yellow", $"{resources.Usage}:").LineBreak();
var composer = NewComposer();
composer.Style(helpStyles?.Usage?.Header ?? Style.Plain, $"{resources.Usage}:").LineBreak();
composer.Tab().Text(model.ApplicationName);
var parameters = new List<string>();
var parameters = new List<Composer>();
if (command == null)
{
parameters.Add($"[grey][[{resources.Options}]][/]");
parameters.Add($"[aqua]<{resources.Command}>[/]");
parameters.Add(NewComposer().Style(helpStyles?.Usage?.Options ?? Style.Plain, $"[{resources.Options}]"));
parameters.Add(NewComposer().Style(helpStyles?.Usage?.Command ?? Style.Plain, $"<{resources.Command}>"));
}
else
{
@ -183,11 +199,11 @@ public class HelpProvider : IHelpProvider
{
if (isCurrent)
{
parameters.Add($"[underline]{current.Name.EscapeMarkup()}[/]");
parameters.Add(NewComposer().Style(helpStyles?.Usage?.CurrentCommand ?? Style.Plain, $"{current.Name}"));
}
else
{
parameters.Add($"{current.Name.EscapeMarkup()}");
parameters.Add(NewComposer().Text(current.Name));
}
}
@ -198,7 +214,7 @@ public class HelpProvider : IHelpProvider
foreach (var argument in current.Parameters.OfType<ICommandArgument>()
.Where(a => a.Required).OrderBy(a => a.Position).ToArray())
{
parameters.Add($"[aqua]<{argument.Value.EscapeMarkup()}>[/]");
parameters.Add(NewComposer().Style(helpStyles?.Usage?.RequiredArgument ?? Style.Plain, $"<{argument.Value}>"));
}
}
@ -207,27 +223,27 @@ public class HelpProvider : IHelpProvider
{
foreach (var optionalArgument in optionalArguments)
{
parameters.Add($"[silver][[{optionalArgument.Value.EscapeMarkup()}]][/]");
parameters.Add(NewComposer().Style(helpStyles?.Usage?.OptionalArgument ?? Style.Plain, $"[{optionalArgument.Value}]"));
}
}
}
if (isCurrent)
{
parameters.Add($"[grey][[{resources.Options}]][/]");
parameters.Add(NewComposer().Style(helpStyles?.Usage?.Options ?? Style.Plain, $"[{resources.Options}]"));
}
}
if (command.IsBranch && command.DefaultCommand == null)
{
// The user must specify the command
parameters.Add($"[aqua]<{resources.Command}>[/]");
parameters.Add(NewComposer().Style(helpStyles?.Usage?.Command ?? Style.Plain, $"<{resources.Command}>"));
}
else if (command.IsBranch && command.DefaultCommand != null && command.Commands.Count > 0)
{
// We are on a branch with a default command
// The user can optionally specify the command
parameters.Add($"[aqua][[{resources.Command}]][/]");
parameters.Add(NewComposer().Style(helpStyles?.Usage?.Command ?? Style.Plain, $"[{resources.Command}]"));
}
else if (command.IsDefaultCommand)
{
@ -237,7 +253,7 @@ public class HelpProvider : IHelpProvider
{
// Commands other than the default are present
// So make these optional in the usage statement
parameters.Add($"[aqua][[{resources.Command}]][/]");
parameters.Add(NewComposer().Style(helpStyles?.Usage?.Command ?? Style.Plain, $"[{resources.Command}]"));
}
}
}
@ -245,10 +261,7 @@ public class HelpProvider : IHelpProvider
composer.Join(" ", parameters);
composer.LineBreak();
return new[]
{
composer,
};
return new[] { composer };
}
/// <summary>
@ -302,14 +315,14 @@ public class HelpProvider : IHelpProvider
if (Math.Min(maxExamples, examples.Count) > 0)
{
var composer = new Composer();
var composer = NewComposer();
composer.LineBreak();
composer.Style("yellow", $"{resources.Examples}:").LineBreak();
composer.Style(helpStyles?.Examples?.Header ?? Style.Plain, $"{resources.Examples}:").LineBreak();
for (var index = 0; index < Math.Min(maxExamples, examples.Count); index++)
{
var args = string.Join(" ", examples[index]);
composer.Tab().Text(model.ApplicationName).Space().Style("grey", args);
composer.Tab().Text(model.ApplicationName).Space().Style(helpStyles?.Examples?.Arguments ?? Style.Plain, args);
composer.LineBreak();
}
@ -334,11 +347,9 @@ public class HelpProvider : IHelpProvider
}
var result = new List<IRenderable>
{
new Markup(Environment.NewLine),
new Markup($"[yellow]{resources.Arguments}:[/]"),
new Markup(Environment.NewLine),
};
{
NewComposer().LineBreak().Style(helpStyles?.Arguments?.Header ?? Style.Plain, $"{resources.Arguments}:").LineBreak(),
};
var grid = new Grid();
grid.AddColumn(new GridColumn { Padding = new Padding(4, 4), NoWrap = true });
@ -347,15 +358,15 @@ public class HelpProvider : IHelpProvider
foreach (var argument in arguments.Where(x => x.Required).OrderBy(x => x.Position))
{
grid.AddRow(
$"[silver]<{argument.Name.EscapeMarkup()}>[/]",
argument.Description?.TrimEnd('.') ?? " ");
NewComposer().Style(helpStyles?.Arguments?.RequiredArgument ?? Style.Plain, $"<{argument.Name}>"),
NewComposer().Text(argument.Description?.TrimEnd('.') ?? " "));
}
foreach (var argument in arguments.Where(x => !x.Required).OrderBy(x => x.Position))
{
grid.AddRow(
$"[grey][[{argument.Name.EscapeMarkup()}]][/]",
argument.Description?.TrimEnd('.') ?? " ");
NewComposer().Style(helpStyles?.Arguments?.OptionalArgument ?? Style.Plain, $"[{argument.Name}]"),
NewComposer().Text(argument.Description?.TrimEnd('.') ?? " "));
}
result.Add(grid);
@ -379,11 +390,9 @@ public class HelpProvider : IHelpProvider
}
var result = new List<IRenderable>
{
new Markup(Environment.NewLine),
new Markup($"[yellow]{resources.Options}:[/]"),
new Markup(Environment.NewLine),
};
{
NewComposer().LineBreak().Style(helpStyles?.Options?.Header ?? Style.Plain, $"{resources.Options}:").LineBreak(),
};
var helpOptions = parameters.ToArray();
var defaultValueColumn = ShowOptionDefaultValues && helpOptions.Any(e => e.DefaultValue != null);
@ -397,71 +406,24 @@ public class HelpProvider : IHelpProvider
grid.AddColumn(new GridColumn { Padding = new Padding(0, 0) });
static string GetOptionParts(HelpOption option)
{
var builder = new StringBuilder();
if (option.Short != null)
{
builder.Append('-').Append(option.Short.EscapeMarkup());
if (option.Long != null)
{
builder.Append(", ");
}
}
else
{
builder.Append(" ");
if (option.Long != null)
{
builder.Append(" ");
}
}
if (option.Long != null)
{
builder.Append("--").Append(option.Long.EscapeMarkup());
}
if (option.Value != null)
{
builder.Append(' ');
if (option.ValueIsOptional ?? false)
{
builder.Append("[grey][[").Append(option.Value.EscapeMarkup()).Append("]][/]");
}
else
{
builder.Append("[silver]<").Append(option.Value.EscapeMarkup()).Append(">[/]");
}
}
return builder.ToString();
}
if (defaultValueColumn)
{
grid.AddRow(" ", $"[lime]{resources.Default}[/]", " ");
grid.AddRow(
NewComposer().Space(),
NewComposer().Style(helpStyles?.Options?.DefaultValueHeader ?? Style.Plain, resources.Default),
NewComposer().Space());
}
foreach (var option in helpOptions)
{
var columns = new List<string> { GetOptionParts(option) };
var columns = new List<IRenderable>() { GetOptionParts(option) };
if (defaultValueColumn)
{
static string Bold(object obj) => $"[bold]{obj.ToString().EscapeMarkup()}[/]";
var defaultValue = option.DefaultValue switch
{
null => " ",
"" => " ",
Array { Length: 0 } => " ",
Array array => string.Join(", ", array.Cast<object>().Select(Bold)),
_ => Bold(option.DefaultValue),
};
columns.Add(defaultValue);
columns.Add(GetOptionDefaultValue(option.DefaultValue));
}
columns.Add(option.Description?.TrimEnd('.') ?? " ");
columns.Add(NewComposer().Text(option.Description?.TrimEnd('.') ?? " "));
grid.AddRow(columns.ToArray());
}
@ -471,6 +433,60 @@ public class HelpProvider : IHelpProvider
return result;
}
private IRenderable GetOptionParts(HelpOption option)
{
var composer = NewComposer();
if (option.Short != null)
{
composer.Text("-").Text(option.Short);
if (option.Long != null)
{
composer.Text(", ");
}
}
else
{
composer.Text(" ");
if (option.Long != null)
{
composer.Text(" ");
}
}
if (option.Long != null)
{
composer.Text("--").Text(option.Long);
}
if (option.Value != null)
{
composer.Text(" ");
if (option.ValueIsOptional ?? false)
{
composer.Style(helpStyles?.Options?.OptionalOption ?? Style.Plain, $"[{option.Value}]");
}
else
{
composer.Style(helpStyles?.Options?.RequiredOption ?? Style.Plain, $"<{option.Value}>");
}
}
return composer;
}
private IRenderable GetOptionDefaultValue(object? defaultValue)
{
return defaultValue switch
{
null => NewComposer().Text(" "),
"" => NewComposer().Text(" "),
Array { Length: 0 } => NewComposer().Text(" "),
Array array => NewComposer().Join(", ", array.Cast<object>().Select(o => NewComposer().Style(helpStyles?.Options?.DefaultValue ?? Style.Plain, o.ToString() ?? string.Empty))),
_ => NewComposer().Style(helpStyles?.Options?.DefaultValue ?? Style.Plain, defaultValue?.ToString() ?? string.Empty),
};
}
/// <summary>
/// Gets the commands section of the help information.
/// </summary>
@ -491,11 +507,9 @@ public class HelpProvider : IHelpProvider
}
var result = new List<IRenderable>
{
new Markup(Environment.NewLine),
new Markup($"[yellow]{resources.Commands}:[/]"),
new Markup(Environment.NewLine),
};
{
NewComposer().LineBreak().Style(helpStyles?.Commands?.Header ?? Style.Plain, $"{resources.Commands}:").LineBreak(),
};
var grid = new Grid();
grid.AddColumn(new GridColumn { Padding = new Padding(4, 4), NoWrap = true });
@ -503,27 +517,27 @@ public class HelpProvider : IHelpProvider
foreach (var child in commands)
{
var arguments = new Composer();
arguments.Style("silver", child.Name.EscapeMarkup());
var arguments = NewComposer();
arguments.Style(helpStyles?.Commands?.ChildCommand ?? Style.Plain, child.Name);
arguments.Space();
foreach (var argument in HelpArgument.Get(child).Where(a => a.Required))
{
arguments.Style("silver", $"<{argument.Name.EscapeMarkup()}>");
arguments.Style(helpStyles?.Commands?.RequiredArgument ?? Style.Plain, $"<{argument.Name}>");
arguments.Space();
}
if (TrimTrailingPeriod)
{
grid.AddRow(
arguments.ToString().TrimEnd(),
child.Description?.TrimEnd('.') ?? " ");
NewComposer().Text(arguments.ToString().TrimEnd()),
NewComposer().Text(child.Description?.TrimEnd('.') ?? " "));
}
else
{
grid.AddRow(
arguments.ToString().TrimEnd(),
child.Description ?? " ");
NewComposer().Text(arguments.ToString().TrimEnd()),
NewComposer().Text(child.Description ?? " "));
}
}

View File

@ -0,0 +1,219 @@
namespace Spectre.Console.Cli.Help;
/// <summary>
/// Styles for the HelpProvider to use when rendering help text.
/// </summary>
public sealed class HelpProviderStyle
{
/// <summary>
/// Gets or sets the style for describing the purpose or details of a command.
/// </summary>
public DescriptionStyle? Description { get; set; }
/// <summary>
/// Gets or sets the style for specifying the usage format of a command.
/// </summary>
public UsageStyle? Usage { get; set; }
/// <summary>
/// Gets or sets the style for providing examples of command usage.
/// </summary>
public ExampleStyle? Examples { get; set; }
/// <summary>
/// Gets or sets the style for specifying arguments in a command.
/// </summary>
public ArgumentStyle? Arguments { get; set; }
/// <summary>
/// Gets or sets the style for specifying options or flags in a command.
/// </summary>
public OptionStyle? Options { get; set; }
/// <summary>
/// Gets or sets the style for specifying subcommands or nested commands.
/// </summary>
public CommandStyle? Commands { get; set; }
/// <summary>
/// Gets the default HelpProvider styles.
/// </summary>
public static HelpProviderStyle Default { get; } =
new HelpProviderStyle()
{
Description = new DescriptionStyle()
{
Header = "yellow",
},
Usage = new UsageStyle()
{
Header = "yellow",
CurrentCommand = "underline",
Command = "aqua",
Options = "grey",
RequiredArgument = "aqua",
OptionalArgument = "silver",
},
Examples = new ExampleStyle()
{
Header = "yellow",
Arguments = "grey",
},
Arguments = new ArgumentStyle()
{
Header = "yellow",
RequiredArgument = "silver",
OptionalArgument = "silver",
},
Commands = new CommandStyle()
{
Header = "yellow",
ChildCommand = "silver",
RequiredArgument = "silver",
},
Options = new OptionStyle()
{
Header = "yellow",
DefaultValueHeader = "lime",
DefaultValue = "bold",
RequiredOption = "silver",
OptionalOption = "grey",
},
};
}
/// <summary>
/// Defines styles for describing the purpose or details of a command.
/// </summary>
public sealed class DescriptionStyle
{
/// <summary>
/// Gets or sets the style for the header in the description.
/// </summary>
public Style? Header { get; set; }
}
/// <summary>
/// Defines styles for specifying the usage format of a command.
/// </summary>
public sealed class UsageStyle
{
/// <summary>
/// Gets or sets the style for the header in the usage.
/// </summary>
public Style? Header { get; set; }
/// <summary>
/// Gets or sets the style for the current command in the usage.
/// </summary>
public Style? CurrentCommand { get; set; }
/// <summary>
/// Gets or sets the style for general commands in the usage.
/// </summary>
public Style? Command { get; set; }
/// <summary>
/// Gets or sets the style for options in the usage.
/// </summary>
public Style? Options { get; set; }
/// <summary>
/// Gets or sets the style for required arguments in the usage.
/// </summary>
public Style? RequiredArgument { get; set; }
/// <summary>
/// Gets or sets the style for optional arguments in the usage.
/// </summary>
public Style? OptionalArgument { get; set; }
}
/// <summary>
/// Defines styles for providing examples of command usage.
/// </summary>
public sealed class ExampleStyle
{
/// <summary>
/// Gets or sets the style for the header in the examples.
/// </summary>
public Style? Header { get; set; }
/// <summary>
/// Gets or sets the style for arguments in the examples.
/// </summary>
public Style? Arguments { get; set; }
}
/// <summary>
/// Defines styles for specifying arguments in a command.
/// </summary>
public sealed class ArgumentStyle
{
/// <summary>
/// Gets or sets the style for the header in the arguments.
/// </summary>
public Style? Header { get; set; }
/// <summary>
/// Gets or sets the style for required arguments.
/// </summary>
public Style? RequiredArgument { get; set; }
/// <summary>
/// Gets or sets the style for optional arguments.
/// </summary>
public Style? OptionalArgument { get; set; }
}
/// <summary>
/// Defines styles for specifying subcommands or nested commands.
/// </summary>
public sealed class CommandStyle
{
/// <summary>
/// Gets or sets the style for the header in the command section.
/// </summary>
public Style? Header { get; set; }
/// <summary>
/// Gets or sets the style for child commands in the command section.
/// </summary>
public Style? ChildCommand { get; set; }
/// <summary>
/// Gets or sets the style for required arguments in the command section.
/// </summary>
public Style? RequiredArgument { get; set; }
}
/// <summary>
/// Defines styles for specifying options or flags in a command.
/// </summary>
public sealed class OptionStyle
{
/// <summary>
/// Gets or sets the style for the header in the options.
/// </summary>
public Style? Header { get; set; }
/// <summary>
/// Gets or sets the style for the header of default values in the options.
/// </summary>
public Style? DefaultValueHeader { get; set; }
/// <summary>
/// Gets or sets the style for default values in the options.
/// </summary>
public Style? DefaultValue { get; set; }
/// <summary>
/// Gets or sets the style for required options.
/// </summary>
public Style? RequiredOption { get; set; }
/// <summary>
/// Gets or sets the style for optional options.
/// </summary>
public Style? OptionalOption { get; set; }
}

View File

@ -41,6 +41,11 @@ public interface ICommandAppSettings
/// </summary>
bool TrimTrailingPeriod { get; set; }
/// <summary>
/// Gets or sets the styles to used when rendering the help text.
/// </summary>
HelpProviderStyle? HelpProviderStyles { get; set; }
/// <summary>
/// Gets or sets the <see cref="IAnsiConsole"/>.
/// </summary>

View File

@ -4,22 +4,43 @@ internal sealed class Composer : IRenderable
{
private readonly StringBuilder _content;
/// <summary>
/// Whether to emit the markup styles, inline, when rendering the content.
/// </summary>
private readonly bool _renderMarkup = false;
public Composer()
{
_content = new StringBuilder();
}
public Composer(bool renderMarkup)
: this()
{
_renderMarkup = renderMarkup;
}
public Composer Text(string text)
{
_content.Append(text);
return this;
}
public Composer Style(Style style, string text)
{
_content.Append('[').Append(style.ToMarkup()).Append(']');
_content.Append(text.EscapeMarkup());
_content.Append("[/]");
return this;
}
public Composer Style(string style, string text)
{
_content.Append('[').Append(style).Append(']');
_content.Append(text.EscapeMarkup());
_content.Append("[/]");
return this;
}
@ -28,6 +49,7 @@ internal sealed class Composer : IRenderable
_content.Append('[').Append(style).Append(']');
action(this);
_content.Append("[/]");
return this;
}
@ -72,12 +94,19 @@ internal sealed class Composer : IRenderable
return this;
}
public Composer Join(string separator, IEnumerable<string> composers)
public Composer Join(string separator, IEnumerable<Composer> composers)
{
if (composers != null)
{
Space();
Text(string.Join(separator, composers));
foreach (var composer in composers)
{
if (_content.ToString().Length > 0)
{
Text(separator);
}
Text(composer.ToString());
}
}
return this;
@ -85,12 +114,26 @@ internal sealed class Composer : IRenderable
public Measurement Measure(RenderOptions options, int maxWidth)
{
return ((IRenderable)new Markup(_content.ToString())).Measure(options, maxWidth);
if (_renderMarkup)
{
return ((IRenderable)new Paragraph(_content.ToString())).Measure(options, maxWidth);
}
else
{
return ((IRenderable)new Markup(_content.ToString())).Measure(options, maxWidth);
}
}
public IEnumerable<Segment> Render(RenderOptions options, int maxWidth)
{
return ((IRenderable)new Markup(_content.ToString())).Render(options, maxWidth);
if (_renderMarkup)
{
return ((IRenderable)new Paragraph(_content.ToString())).Render(options, maxWidth);
}
else
{
return ((IRenderable)new Markup(_content.ToString())).Render(options, maxWidth);
}
}
public override string ToString()

View File

@ -14,9 +14,10 @@ internal sealed class CommandAppSettings : ICommandAppSettings
public CaseSensitivity CaseSensitivity { get; set; }
public bool PropagateExceptions { get; set; }
public bool ValidateExamples { get; set; }
public bool TrimTrailingPeriod { get; set; } = true;
public bool TrimTrailingPeriod { get; set; }
public HelpProviderStyle? HelpProviderStyles { get; set; }
public bool StrictParsing { get; set; }
public bool ConvertFlagsToRemainingArguments { get; set; } = false;
public bool ConvertFlagsToRemainingArguments { get; set; }
public ParsingMode ParsingMode =>
StrictParsing ? ParsingMode.Strict : ParsingMode.Relaxed;
@ -29,6 +30,9 @@ internal sealed class CommandAppSettings : ICommandAppSettings
CaseSensitivity = CaseSensitivity.All;
ShowOptionDefaultValues = true;
MaximumIndirectExamples = 5;
TrimTrailingPeriod = true;
HelpProviderStyles = HelpProviderStyle.Default;
ConvertFlagsToRemainingArguments = false;
}
public bool IsTrue(Func<CommandAppSettings, bool> func, string environmentVariableName)

View File

@ -26,7 +26,7 @@ internal static class MarkupParser
if (token.Kind == MarkupTokenKind.Open)
{
var parsedStyle = StyleParser.Parse(token.Value);
var parsedStyle = string.IsNullOrEmpty(token.Value) ? Style.Plain : StyleParser.Parse(token.Value);
stack.Push(parsedStyle);
}
else if (token.Kind == MarkupTokenKind.Close)