diff --git a/docs/input/assets/images/barchart.png b/docs/input/assets/images/barchart.png new file mode 100644 index 0000000..73e9d55 Binary files /dev/null and b/docs/input/assets/images/barchart.png differ diff --git a/docs/input/widgets/barchart.md b/docs/input/widgets/barchart.md new file mode 100644 index 0000000..6e1a993 --- /dev/null +++ b/docs/input/widgets/barchart.md @@ -0,0 +1,75 @@ +Title: Bar Chart +Order: 1 +--- + +Use `BarChart` to render bar charts to the console. + + + +# Usage + +## Basic usage + +```csharp +AnsiConsole.Render(new BarChart() + .Width(60) + .Label("[green bold underline]Number of fruits[/]") + .CenterLabel() + .AddItem("Apple", 12, Color.Yellow) + .AddItem("Orange", 54, Color.Green) + .AddItem("Banana", 33, Color.Red)); +``` + +## Add items with converter + +```csharp +// Create a list of fruits +var items = new List<(string Label, double Value)> +{ + ("Apple", 12), + ("Orange", 54), + ("Banana", 33), +}; + +// Render bar chart +AnsiConsole.Render(new BarChart() + .Width(60) + .Label("[green bold underline]Number of fruits[/]") + .CenterLabel() + .AddItems(items, (item) => new BarChartItem( + item.Label, item.Value, Color.Yellow))); +``` + +## Add items implementing IBarChartItem + +```csharp +public sealed class Fruit : IBarChartItem +{ + public string Label { get; set; } + public double Value { get; set; } + public Color? Color { get; set; } + + public Fruit(string label, double value, Color? color = null) + { + Label = label; + Value = value; + Color = color; + } +} + +// Create a list of fruits +var items = new List +{ + new Fruit("Apple", 12, Color.Yellow), + new Fruit("Orange", 54, Color.Red), + new Fruit("Banana", 33, Color.Green), +}; + +// Render bar chart +AnsiConsole.Render(new BarChart() + .Width(60) + .Label("[green bold underline]Number of fruits[/]") + .CenterLabel() + .AddItem(new Fruit("Mango", 3)) + .AddItems(items)); +``` \ No newline at end of file diff --git a/docs/input/widgets/calendar.md b/docs/input/widgets/calendar.md index 3bf382c..3f3ad77 100644 --- a/docs/input/widgets/calendar.md +++ b/docs/input/widgets/calendar.md @@ -1,5 +1,5 @@ Title: Calendar -Order: 2 +Order: 3 RedirectFrom: calendar --- diff --git a/docs/input/widgets/canvas-image.md b/docs/input/widgets/canvas-image.md index ae97ce5..24fd8d4 100644 --- a/docs/input/widgets/canvas-image.md +++ b/docs/input/widgets/canvas-image.md @@ -1,5 +1,5 @@ Title: Canvas Image -Order: 5 +Order: 6 --- To add [ImageSharp](https://github.com/SixLabors/ImageSharp) superpowers to diff --git a/docs/input/widgets/canvas.md b/docs/input/widgets/canvas.md index fc8739f..847f50e 100644 --- a/docs/input/widgets/canvas.md +++ b/docs/input/widgets/canvas.md @@ -1,5 +1,5 @@ Title: Canvas -Order: 4 +Order: 5 --- `Canvas` is a widget that allows you to render arbitrary "pixels" diff --git a/docs/input/widgets/figlet.md b/docs/input/widgets/figlet.md index d6ecdeb..762ea0d 100644 --- a/docs/input/widgets/figlet.md +++ b/docs/input/widgets/figlet.md @@ -1,5 +1,5 @@ Title: Figlet -Order: 3 +Order: 4 RedirectFrom: figlet --- diff --git a/docs/input/widgets/rule.md b/docs/input/widgets/rule.md index f2020cc..ff6ac55 100644 --- a/docs/input/widgets/rule.md +++ b/docs/input/widgets/rule.md @@ -1,5 +1,5 @@ Title: Rule -Order: 1 +Order: 2 RedirectFrom: rule --- diff --git a/examples/Charts/Charts.csproj b/examples/Charts/Charts.csproj new file mode 100644 index 0000000..5b16a50 --- /dev/null +++ b/examples/Charts/Charts.csproj @@ -0,0 +1,15 @@ + + + + Exe + net5.0 + false + Charts + Demonstrates how to render charts in a console. + + + + + + + diff --git a/examples/Charts/Program.cs b/examples/Charts/Program.cs new file mode 100644 index 0000000..08e194b --- /dev/null +++ b/examples/Charts/Program.cs @@ -0,0 +1,21 @@ +using Spectre.Console; + +namespace InfoExample +{ + public static class Program + { + public static void Main() + { + var chart = new BarChart() + .Width(60) + .Label("[green bold underline]Number of fruits[/]") + .CenterLabel() + .AddItem("Apple", 12, Color.Yellow) + .AddItem("Orange", 54, Color.Green) + .AddItem("Banana", 33, Color.Red); + + AnsiConsole.WriteLine(); + AnsiConsole.Render(chart); + } + } +} diff --git a/src/Spectre.Console.Tests/Expectations/BarChartTests.Should_Render_Correctly.verified.txt b/src/Spectre.Console.Tests/Expectations/BarChartTests.Should_Render_Correctly.verified.txt new file mode 100644 index 0000000..2ee8695 --- /dev/null +++ b/src/Spectre.Console.Tests/Expectations/BarChartTests.Should_Render_Correctly.verified.txt @@ -0,0 +1,4 @@ + Number of fruits + Apple ████████ 12 +Orange █████████████████████████████████████████████████ 54 +Banana ████████████████████████████ 33 diff --git a/src/Spectre.Console.Tests/Unit/BarChartTests.cs b/src/Spectre.Console.Tests/Unit/BarChartTests.cs new file mode 100644 index 0000000..558cf52 --- /dev/null +++ b/src/Spectre.Console.Tests/Unit/BarChartTests.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; +using VerifyXunit; +using Xunit; + +namespace Spectre.Console.Tests.Unit +{ + [UsesVerify] + public sealed class BarChartTests + { + [Fact] + public async Task Should_Render_Correctly() + { + // Given + var console = new PlainConsole(width: 80); + + // When + console.Render(new BarChart() + .Width(60) + .Label("Number of fruits") + .AddItem("Apple", 12) + .AddItem("Orange", 54) + .AddItem("Banana", 33)); + + // Then + await Verifier.Verify(console.Output); + } + } +} diff --git a/src/Spectre.Console.sln b/src/Spectre.Console.sln index 43fdbfa..fe3d4c8 100644 --- a/src/Spectre.Console.sln +++ b/src/Spectre.Console.sln @@ -62,6 +62,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Progress", "..\examples\Pro EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Status", "..\examples\Status\Status.csproj", "{3716AFDF-0904-4635-8422-86E6B9356840}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Charts", "..\examples\Charts\Charts.csproj", "{0A1AFD26-86A0-4060-B277-D380172C7070}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -324,6 +326,18 @@ Global {3716AFDF-0904-4635-8422-86E6B9356840}.Release|x64.Build.0 = Release|Any CPU {3716AFDF-0904-4635-8422-86E6B9356840}.Release|x86.ActiveCfg = Release|Any CPU {3716AFDF-0904-4635-8422-86E6B9356840}.Release|x86.Build.0 = Release|Any CPU + {0A1AFD26-86A0-4060-B277-D380172C7070}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0A1AFD26-86A0-4060-B277-D380172C7070}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0A1AFD26-86A0-4060-B277-D380172C7070}.Debug|x64.ActiveCfg = Debug|Any CPU + {0A1AFD26-86A0-4060-B277-D380172C7070}.Debug|x64.Build.0 = Debug|Any CPU + {0A1AFD26-86A0-4060-B277-D380172C7070}.Debug|x86.ActiveCfg = Debug|Any CPU + {0A1AFD26-86A0-4060-B277-D380172C7070}.Debug|x86.Build.0 = Debug|Any CPU + {0A1AFD26-86A0-4060-B277-D380172C7070}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0A1AFD26-86A0-4060-B277-D380172C7070}.Release|Any CPU.Build.0 = Release|Any CPU + {0A1AFD26-86A0-4060-B277-D380172C7070}.Release|x64.ActiveCfg = Release|Any CPU + {0A1AFD26-86A0-4060-B277-D380172C7070}.Release|x64.Build.0 = Release|Any CPU + {0A1AFD26-86A0-4060-B277-D380172C7070}.Release|x86.ActiveCfg = Release|Any CPU + {0A1AFD26-86A0-4060-B277-D380172C7070}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -348,6 +362,7 @@ Global {5693761A-754A-40A8-9144-36510D6A4D69} = {F0575243-121F-4DEE-9F6B-246E26DC0844} {2B712A52-40F1-4C1C-833E-7C869ACA91F3} = {F0575243-121F-4DEE-9F6B-246E26DC0844} {3716AFDF-0904-4635-8422-86E6B9356840} = {F0575243-121F-4DEE-9F6B-246E26DC0844} + {0A1AFD26-86A0-4060-B277-D380172C7070} = {F0575243-121F-4DEE-9F6B-246E26DC0844} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5729B071-67A0-48FB-8B1B-275E6822086C} diff --git a/src/Spectre.Console/Extensions/BarGraphExtensions.cs b/src/Spectre.Console/Extensions/BarGraphExtensions.cs new file mode 100644 index 0000000..76cbff2 --- /dev/null +++ b/src/Spectre.Console/Extensions/BarGraphExtensions.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; + +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static class BarGraphExtensions + { + /// + /// Adds an item to the bar chart. + /// + /// The bar chart. + /// The item label. + /// The item value. + /// The item color. + /// The same instance so that multiple calls can be chained. + public static BarChart AddItem(this BarChart chart, string label, double value, Color? color = null) + { + if (chart is null) + { + throw new ArgumentNullException(nameof(chart)); + } + + chart.Data.Add(new BarChartItem(label, value, color)); + return chart; + } + + /// + /// Adds an item to the bar chart. + /// + /// A type that implements . + /// The bar chart. + /// The item. + /// The same instance so that multiple calls can be chained. + public static BarChart AddItem(this BarChart chart, T item) + where T : IBarChartItem + { + if (chart is null) + { + throw new ArgumentNullException(nameof(chart)); + } + + chart.Data.Add(new BarChartItem( + item.Label, + item.Value, + item.Color)); + + return chart; + } + + /// + /// Adds multiple items to the bar chart. + /// + /// A type that implements . + /// The bar chart. + /// The items. + /// The same instance so that multiple calls can be chained. + public static BarChart AddItems(this BarChart chart, IEnumerable items) + where T : IBarChartItem + { + if (chart is null) + { + throw new ArgumentNullException(nameof(chart)); + } + + if (items is null) + { + throw new ArgumentNullException(nameof(items)); + } + + foreach (var item in items) + { + AddItem(chart, item); + } + + return chart; + } + + /// + /// Adds multiple items to the bar chart. + /// + /// A type that implements . + /// The bar chart. + /// The items. + /// The converter that converts instances of T to . + /// The same instance so that multiple calls can be chained. + public static BarChart AddItems(this BarChart chart, IEnumerable items, Func converter) + { + if (chart is null) + { + throw new ArgumentNullException(nameof(chart)); + } + + if (items is null) + { + throw new ArgumentNullException(nameof(items)); + } + + if (converter is null) + { + throw new ArgumentNullException(nameof(converter)); + } + + foreach (var item in items) + { + chart.Data.Add(converter(item)); + } + + return chart; + } + + /// + /// Sets the width of the bar chart. + /// + /// The bar chart. + /// The bar chart width. + /// The same instance so that multiple calls can be chained. + public static BarChart Width(this BarChart chart, int? width) + { + if (chart is null) + { + throw new ArgumentNullException(nameof(chart)); + } + + chart.Width = width; + return chart; + } + + /// + /// Sets the label of the bar chart. + /// + /// The bar chart. + /// The bar chart label. + /// The same instance so that multiple calls can be chained. + public static BarChart Label(this BarChart chart, string? label) + { + if (chart is null) + { + throw new ArgumentNullException(nameof(chart)); + } + + chart.Label = label; + return chart; + } + + /// + /// Shows values next to each bar in the bar chart. + /// + /// The bar chart. + /// The same instance so that multiple calls can be chained. + public static BarChart ShowValues(this BarChart chart) + { + return ShowValues(chart, true); + } + + /// + /// Hides values next to each bar in the bar chart. + /// + /// The bar chart. + /// The same instance so that multiple calls can be chained. + public static BarChart HideValues(this BarChart chart) + { + return ShowValues(chart, false); + } + + /// + /// Sets whether or not values should be shown + /// next to each bar. + /// + /// The bar chart. + /// Whether or not values should be shown next to each bar. + /// The same instance so that multiple calls can be chained. + public static BarChart ShowValues(this BarChart chart, bool show) + { + if (chart is null) + { + throw new ArgumentNullException(nameof(chart)); + } + + chart.ShowValues = show; + return chart; + } + + /// + /// Aligns the label to the left. + /// + /// The bar chart. + /// The same instance so that multiple calls can be chained. + public static BarChart LeftAlignLabel(this BarChart chart) + { + if (chart is null) + { + throw new ArgumentNullException(nameof(chart)); + } + + chart.LabelAlignment = Justify.Left; + return chart; + } + + /// + /// Centers the label. + /// + /// The bar chart. + /// The same instance so that multiple calls can be chained. + public static BarChart CenterLabel(this BarChart chart) + { + if (chart is null) + { + throw new ArgumentNullException(nameof(chart)); + } + + chart.LabelAlignment = Justify.Center; + return chart; + } + + /// + /// Aligns the label to the right. + /// + /// The bar chart. + /// The same instance so that multiple calls can be chained. + public static BarChart RightAlignLabel(this BarChart chart) + { + if (chart is null) + { + throw new ArgumentNullException(nameof(chart)); + } + + chart.LabelAlignment = Justify.Right; + return chart; + } + } +} diff --git a/src/Spectre.Console/Extensions/GridExtensions.cs b/src/Spectre.Console/Extensions/GridExtensions.cs index 078edd1..be3dbe2 100644 --- a/src/Spectre.Console/Extensions/GridExtensions.cs +++ b/src/Spectre.Console/Extensions/GridExtensions.cs @@ -97,5 +97,22 @@ namespace Spectre.Console grid.AddRow(columns.Select(column => new Markup(column)).ToArray()); return grid; } + + /// + /// Sets the grid width. + /// + /// The grid. + /// The width. + /// The same instance so that multiple calls can be chained. + public static Grid Width(this Grid grid, int? width) + { + if (grid is null) + { + throw new ArgumentNullException(nameof(grid)); + } + + grid.Width = width; + return grid; + } } } diff --git a/src/Spectre.Console/Extensions/TableExtensions.cs b/src/Spectre.Console/Extensions/TableExtensions.cs index 9535b06..0ba5216 100644 --- a/src/Spectre.Console/Extensions/TableExtensions.cs +++ b/src/Spectre.Console/Extensions/TableExtensions.cs @@ -150,7 +150,7 @@ namespace Spectre.Console /// The table. /// The width. /// The same instance so that multiple calls can be chained. - public static Table Width(this Table table, int width) + public static Table Width(this Table table, int? width) { if (table is null) { diff --git a/src/Spectre.Console/Rendering/Renderable.cs b/src/Spectre.Console/Rendering/Renderable.cs index 296af6f..35f97bb 100644 --- a/src/Spectre.Console/Rendering/Renderable.cs +++ b/src/Spectre.Console/Rendering/Renderable.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics; namespace Spectre.Console.Rendering { @@ -8,12 +9,14 @@ namespace Spectre.Console.Rendering public abstract class Renderable : IRenderable { /// + [DebuggerStepThrough] Measurement IRenderable.Measure(RenderContext context, int maxWidth) { return Measure(context, maxWidth); } /// + [DebuggerStepThrough] IEnumerable IRenderable.Render(RenderContext context, int maxWidth) { return Render(context, maxWidth); diff --git a/src/Spectre.Console/Widgets/BarChart.cs b/src/Spectre.Console/Widgets/BarChart.cs new file mode 100644 index 0000000..3541311 --- /dev/null +++ b/src/Spectre.Console/Widgets/BarChart.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using System.Linq; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// A renderable (horizontal) bar chart. + /// + public sealed class BarChart : Renderable + { + /// + /// Gets the bar chart data. + /// + public List Data { get; } + + /// + /// Gets or sets the width of the bar chart. + /// + public int? Width { get; set; } + + /// + /// Gets or sets the bar chart label. + /// + public string? Label { get; set; } + + /// + /// Gets or sets the bar chart label alignment. + /// + public Justify? LabelAlignment { get; set; } = Justify.Center; + + /// + /// Gets or sets a value indicating whether or not + /// values should be shown next to each bar. + /// + public bool ShowValues { get; set; } = true; + + /// + /// Initializes a new instance of the class. + /// + public BarChart() + { + Data = new List(); + } + + /// + protected override IEnumerable Render(RenderContext context, int maxWidth) + { + var maxValue = Data.Max(item => item.Value); + + var table = new Grid(); + table.Collapse(); + table.AddColumn(new GridColumn().PadRight(2).RightAligned()); + table.AddColumn(new GridColumn().PadLeft(0)); + table.Width = Width; + + if (!string.IsNullOrWhiteSpace(Label)) + { + table.AddRow(Text.Empty, new Markup(Label).Alignment(LabelAlignment)); + } + + foreach (var item in Data) + { + table.AddRow( + new Markup(item.Label), + new ProgressBar() + { + Value = item.Value, + MaxValue = maxValue, + ShowRemaining = false, + CompletedStyle = new Style().Foreground(item.Color ?? Color.Default), + FinishedStyle = new Style().Foreground(item.Color ?? Color.Default), + UnicodeBar = '█', + AsciiBar = '█', + ShowValue = ShowValues, + }); + } + + return ((IRenderable)table).Render(context, maxWidth); + } + } +} diff --git a/src/Spectre.Console/Widgets/BarChartItem.cs b/src/Spectre.Console/Widgets/BarChartItem.cs new file mode 100644 index 0000000..094f036 --- /dev/null +++ b/src/Spectre.Console/Widgets/BarChartItem.cs @@ -0,0 +1,38 @@ +using System; + +namespace Spectre.Console +{ + /// + /// An item that's shown in a bar chart. + /// + public sealed class BarChartItem : IBarChartItem + { + /// + /// Gets the item label. + /// + public string Label { get; } + + /// + /// Gets the item value. + /// + public double Value { get; } + + /// + /// Gets the item color. + /// + public Color? Color { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The item label. + /// The item value. + /// The item color. + public BarChartItem(string label, double value, Color? color = null) + { + Label = label ?? throw new ArgumentNullException(nameof(label)); + Value = value; + Color = color; + } + } +} diff --git a/src/Spectre.Console/Widgets/Grid.cs b/src/Spectre.Console/Widgets/Grid.cs index 9fccaf9..7fedde1 100644 --- a/src/Spectre.Console/Widgets/Grid.cs +++ b/src/Spectre.Console/Widgets/Grid.cs @@ -42,6 +42,11 @@ namespace Spectre.Console set => MarkAsDirty(() => _alignment = value); } + /// + /// Gets or sets the width of the grid. + /// + public int? Width { get; set; } + /// /// Initializes a new instance of the class. /// @@ -124,6 +129,7 @@ namespace Spectre.Console ShowHeaders = false, IsGrid = true, PadRightCell = _padRightCell, + Width = Width, }; foreach (var column in _columns) diff --git a/src/Spectre.Console/Widgets/IBarGraphItem.cs b/src/Spectre.Console/Widgets/IBarGraphItem.cs new file mode 100644 index 0000000..3f8187c --- /dev/null +++ b/src/Spectre.Console/Widgets/IBarGraphItem.cs @@ -0,0 +1,23 @@ +namespace Spectre.Console +{ + /// + /// Represents a bar chart item. + /// + public interface IBarChartItem + { + /// + /// Gets the item label. + /// + string Label { get; } + + /// + /// Gets the item value. + /// + double Value { get; } + + /// + /// Gets the item color. + /// + Color? Color { get; } + } +} diff --git a/src/Spectre.Console/Widgets/ProgressBar.cs b/src/Spectre.Console/Widgets/ProgressBar.cs index 3c4e742..9fe16f4 100644 --- a/src/Spectre.Console/Widgets/ProgressBar.cs +++ b/src/Spectre.Console/Widgets/ProgressBar.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using Spectre.Console.Rendering; namespace Spectre.Console @@ -10,6 +11,10 @@ namespace Spectre.Console public double MaxValue { get; set; } = 100; public int? Width { get; set; } + public bool ShowRemaining { get; set; } = true; + public char UnicodeBar { get; set; } = '━'; + public char AsciiBar { get; set; } = '-'; + public bool ShowValue { get; set; } public Style CompletedStyle { get; set; } = new Style(foreground: Color.Yellow); public Style FinishedStyle { get; set; } = new Style(foreground: Color.Green); @@ -26,15 +31,38 @@ namespace Spectre.Console var width = Math.Min(Width ?? maxWidth, maxWidth); var completed = Math.Min(MaxValue, Math.Max(0, Value)); - var token = !context.Unicode || context.LegacyConsole ? '-' : '━'; + var token = !context.Unicode || context.LegacyConsole ? AsciiBar : UnicodeBar; var style = completed >= MaxValue ? FinishedStyle : CompletedStyle; var bars = Math.Max(0, (int)(width * (completed / MaxValue))); + + var value = completed.ToString(CultureInfo.InvariantCulture); + if (ShowValue) + { + bars = bars - value.Length - 1; + } + yield return new Segment(new string(token, bars), style); + if (ShowValue) + { + yield return new Segment(" " + value, style); + } + if (bars < width) { - yield return new Segment(new string(token, width - bars), RemainingStyle); + var diff = width - bars; + if (ShowValue) + { + diff = diff - value.Length - 1; + if (diff <= 0) + { + yield break; + } + } + + var remainingToken = ShowRemaining ? token : ' '; + yield return new Segment(new string(remainingToken, diff), RemainingStyle); } } }