From ae6d2c63a320cde59ae5a266ee72aab8617fbfd5 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Fri, 4 Sep 2020 01:34:31 +0200 Subject: [PATCH] Add column support Adds support for rendering arbitrary data into columns. Closes #67 --- examples/Columns/Columns.csproj | 17 +++ examples/Columns/Program.cs | 42 ++++++ examples/Diagnostic/Program.cs | 1 - examples/Table/Program.cs | 1 - src/.editorconfig | 3 + .../Unit/ColumnsTests.cs | 46 ++++++ src/Spectre.Console.Tests/Unit/GridTests.cs | 7 +- src/Spectre.Console.Tests/Unit/TableTests.cs | 7 +- src/Spectre.Console.Tests/Unit/TextTests.cs | 15 ++ src/Spectre.Console.sln | 15 ++ .../ConsoleExtensions.Rendering.cs | 5 +- src/Spectre.Console/Rendering/Columns.cs | 141 ++++++++++++++++++ src/Spectre.Console/Rendering/Grid.cs | 14 +- src/Spectre.Console/Rendering/Panel.cs | 4 +- src/Spectre.Console/Rendering/Segment.cs | 27 +++- .../Rendering/Table.Calculations.cs | 14 +- src/Spectre.Console/Rendering/Table.cs | 12 +- .../Traits/Extensions/BorderExtensions.cs | 2 +- .../Traits/Extensions/ExpandableExtensions.cs | 2 +- 19 files changed, 340 insertions(+), 35 deletions(-) create mode 100644 examples/Columns/Columns.csproj create mode 100644 examples/Columns/Program.cs create mode 100644 src/Spectre.Console.Tests/Unit/ColumnsTests.cs create mode 100644 src/Spectre.Console/Rendering/Columns.cs diff --git a/examples/Columns/Columns.csproj b/examples/Columns/Columns.csproj new file mode 100644 index 0000000..04200cf --- /dev/null +++ b/examples/Columns/Columns.csproj @@ -0,0 +1,17 @@ + + + + Exe + netcoreapp3.1 + false + + + + + + + + + + + diff --git a/examples/Columns/Program.cs b/examples/Columns/Program.cs new file mode 100644 index 0000000..654d93c --- /dev/null +++ b/examples/Columns/Program.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace ColumnsExample +{ + public static class Program + { + public static async Task Main() + { + // Download some random users + using var client = new HttpClient(); + dynamic users = JObject.Parse( + await client.GetStringAsync("https://randomuser.me/api/?results=15")); + + // Create a card for each user + var cards = new List(); + foreach(var user in users.results) + { + cards.Add(new Panel(GetCard(user)) + .SetHeader($"{user.location.country}") + .RoundedBorder().Expand()); + } + + // Render all cards in columns + AnsiConsole.Render(new Columns(cards)); + } + + private static string GetCard(dynamic user) + { + var name = $"{user.name.first} {user.name.last}"; + var country = $"{user.location.city}"; + + return $"[b]{name}[/]\n[yellow]{country}[/]"; + } + } +} diff --git a/examples/Diagnostic/Program.cs b/examples/Diagnostic/Program.cs index c194bde..60cd796 100644 --- a/examples/Diagnostic/Program.cs +++ b/examples/Diagnostic/Program.cs @@ -1,4 +1,3 @@ -using System; using Spectre.Console; namespace Diagnostic diff --git a/examples/Table/Program.cs b/examples/Table/Program.cs index 36704b9..23dd678 100644 --- a/examples/Table/Program.cs +++ b/examples/Table/Program.cs @@ -1,5 +1,4 @@ using Spectre.Console; -using Spectre.Console.Rendering; namespace TableExample { diff --git a/src/.editorconfig b/src/.editorconfig index c7304ba..31bd18c 100644 --- a/src/.editorconfig +++ b/src/.editorconfig @@ -82,5 +82,8 @@ dotnet_diagnostic.RCS1079.severity = warning # RCS1057: Add empty line between declarations. dotnet_diagnostic.RCS1057.severity = none +# RCS1057: Validate arguments correctly +dotnet_diagnostic.RCS1227.severity = none + # IDE0004: Remove Unnecessary Cast dotnet_diagnostic.IDE0004.severity = warning \ No newline at end of file diff --git a/src/Spectre.Console.Tests/Unit/ColumnsTests.cs b/src/Spectre.Console.Tests/Unit/ColumnsTests.cs new file mode 100644 index 0000000..9d6a455 --- /dev/null +++ b/src/Spectre.Console.Tests/Unit/ColumnsTests.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using Shouldly; +using Xunit; + +namespace Spectre.Console.Tests.Unit +{ + public sealed class ColumnsTests + { + private sealed class User + { + public string Name { get; set; } + public string Country { get; set; } + } + + [Fact] + public void Should_Render_Columns_Correctly() + { + // Given + var console = new PlainConsole(width: 61); + var users = new[] + { + new User { Name = "Savannah Thompson", Country = "Australia" }, + new User { Name = "Sophie Ramos", Country = "United States" }, + new User { Name = "Katrin Goldberg", Country = "Germany" }, + }; + + var cards = new List(); + foreach (var user in users) + { + cards.Add( + new Panel($"[b]{user.Name}[/]\n[yellow]{user.Country}[/]") + .RoundedBorder().Expand()); + } + + // When + console.Render(new Columns(cards)); + + // Then + console.Lines.Count.ShouldBe(4); + console.Lines[0].ShouldBe("╭────────────────────╮ ╭────────────────╮ ╭─────────────────╮"); + console.Lines[1].ShouldBe("│ Savannah Thompson │ │ Sophie Ramos │ │ Katrin Goldberg │"); + console.Lines[2].ShouldBe("│ Australia │ │ United States │ │ Germany │"); + console.Lines[3].ShouldBe("╰────────────────────╯ ╰────────────────╯ ╰─────────────────╯"); + } + } +} diff --git a/src/Spectre.Console.Tests/Unit/GridTests.cs b/src/Spectre.Console.Tests/Unit/GridTests.cs index 0003559..e873e5d 100644 --- a/src/Spectre.Console.Tests/Unit/GridTests.cs +++ b/src/Spectre.Console.Tests/Unit/GridTests.cs @@ -42,7 +42,7 @@ namespace Spectre.Console.Tests.Unit } [Fact] - public void Should_Throw_If_Row_Columns_Is_Less_Than_Number_Of_Columns() + public void Should_Add_Empty_Items_If_User_Provides_Less_Row_Items_Than_Columns() { // Given var grid = new Grid(); @@ -50,11 +50,10 @@ namespace Spectre.Console.Tests.Unit grid.AddColumn(); // When - var result = Record.Exception(() => grid.AddRow("Foo")); + grid.AddRow("Foo"); // Then - result.ShouldBeOfType(); - result.Message.ShouldBe("The number of row columns are less than the number of grid columns."); + grid.RowCount.ShouldBe(1); } [Fact] diff --git a/src/Spectre.Console.Tests/Unit/TableTests.cs b/src/Spectre.Console.Tests/Unit/TableTests.cs index c58061a..ca0fde5 100644 --- a/src/Spectre.Console.Tests/Unit/TableTests.cs +++ b/src/Spectre.Console.Tests/Unit/TableTests.cs @@ -87,7 +87,7 @@ namespace Spectre.Console.Tests.Unit } [Fact] - public void Should_Throw_If_Row_Columns_Is_Less_Than_Number_Of_Columns() + public void Should_Add_Empty_Items_If_User_Provides_Less_Row_Items_Than_Columns() { // Given var table = new Table(); @@ -95,11 +95,10 @@ namespace Spectre.Console.Tests.Unit table.AddColumn("World"); // When - var result = Record.Exception(() => table.AddRow("Foo")); + table.AddRow("Foo"); // Then - result.ShouldBeOfType(); - result.Message.ShouldBe("The number of row columns are less than the number of table columns."); + table.RowCount.ShouldBe(1); } [Fact] diff --git a/src/Spectre.Console.Tests/Unit/TextTests.cs b/src/Spectre.Console.Tests/Unit/TextTests.cs index 09f1141..e2121fd 100644 --- a/src/Spectre.Console.Tests/Unit/TextTests.cs +++ b/src/Spectre.Console.Tests/Unit/TextTests.cs @@ -65,6 +65,21 @@ namespace Spectre.Console.Tests.Unit fixture.RawOutput.ShouldBe("Hello\n\nWorld\n\n"); } + [Fact] + public void Should_Render_Panel_2() + { + // Given + var console = new PlainConsole(width: 80); + + // When + console.Render(new Markup("[b]Hello World[/]\n[yellow]Hello World[/]")); + + // Then + console.Lines.Count.ShouldBe(2); + console.Lines[0].ShouldBe("Hello World"); + console.Lines[1].ShouldBe("Hello World"); + } + [Theory] [InlineData(5, "Hello World", "Hello\nWorld")] [InlineData(10, "Hello Sweet Nice World", "Hello \nSweet Nice\nWorld")] diff --git a/src/Spectre.Console.sln b/src/Spectre.Console.sln index 04365e5..f4f4575 100644 --- a/src/Spectre.Console.sln +++ b/src/Spectre.Console.sln @@ -27,6 +27,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Colors", "..\examples\Color EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Diagnostic", "..\examples\Diagnostic\Diagnostic.csproj", "{4337F255-88E9-4408-81A3-DF1AF58AC753}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Columns", "..\examples\Columns\Columns.csproj", "{33357599-C79D-4299-888F-634E2C3EACEF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -121,6 +123,18 @@ Global {4337F255-88E9-4408-81A3-DF1AF58AC753}.Release|x64.Build.0 = Release|Any CPU {4337F255-88E9-4408-81A3-DF1AF58AC753}.Release|x86.ActiveCfg = Release|Any CPU {4337F255-88E9-4408-81A3-DF1AF58AC753}.Release|x86.Build.0 = Release|Any CPU + {33357599-C79D-4299-888F-634E2C3EACEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {33357599-C79D-4299-888F-634E2C3EACEF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {33357599-C79D-4299-888F-634E2C3EACEF}.Debug|x64.ActiveCfg = Debug|Any CPU + {33357599-C79D-4299-888F-634E2C3EACEF}.Debug|x64.Build.0 = Debug|Any CPU + {33357599-C79D-4299-888F-634E2C3EACEF}.Debug|x86.ActiveCfg = Debug|Any CPU + {33357599-C79D-4299-888F-634E2C3EACEF}.Debug|x86.Build.0 = Debug|Any CPU + {33357599-C79D-4299-888F-634E2C3EACEF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {33357599-C79D-4299-888F-634E2C3EACEF}.Release|Any CPU.Build.0 = Release|Any CPU + {33357599-C79D-4299-888F-634E2C3EACEF}.Release|x64.ActiveCfg = Release|Any CPU + {33357599-C79D-4299-888F-634E2C3EACEF}.Release|x64.Build.0 = Release|Any CPU + {33357599-C79D-4299-888F-634E2C3EACEF}.Release|x86.ActiveCfg = Release|Any CPU + {33357599-C79D-4299-888F-634E2C3EACEF}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -131,6 +145,7 @@ Global {C7FF6FDB-FB59-4517-8669-521C96AB7323} = {F0575243-121F-4DEE-9F6B-246E26DC0844} {1F51C55C-BA4C-4856-9001-0F7924FFB179} = {F0575243-121F-4DEE-9F6B-246E26DC0844} {4337F255-88E9-4408-81A3-DF1AF58AC753} = {F0575243-121F-4DEE-9F6B-246E26DC0844} + {33357599-C79D-4299-888F-634E2C3EACEF} = {F0575243-121F-4DEE-9F6B-246E26DC0844} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5729B071-67A0-48FB-8B1B-275E6822086C} diff --git a/src/Spectre.Console/ConsoleExtensions.Rendering.cs b/src/Spectre.Console/ConsoleExtensions.Rendering.cs index 096425d..b4e61dc 100644 --- a/src/Spectre.Console/ConsoleExtensions.Rendering.cs +++ b/src/Spectre.Console/ConsoleExtensions.Rendering.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using Spectre.Console.Internal; using Spectre.Console.Rendering; @@ -30,8 +31,8 @@ namespace Spectre.Console using (console.PushStyle(Style.Plain)) { - var segments = renderable.Render(options, console.Width); - segments = Segment.Merge(segments); + var segments = renderable.Render(options, console.Width).Where(x => !(x.Text.Length == 0 && !x.IsLineBreak)).ToArray(); + segments = Segment.Merge(segments).ToArray(); var current = Style.Plain; foreach (var segment in segments) diff --git a/src/Spectre.Console/Rendering/Columns.cs b/src/Spectre.Console/Rendering/Columns.cs new file mode 100644 index 0000000..ada2c1f --- /dev/null +++ b/src/Spectre.Console/Rendering/Columns.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// Renders things in columns. + /// + public sealed class Columns : Renderable, IPaddable, IExpandable + { + private readonly List _items; + + /// + public Padding Padding { get; set; } = new Padding(0, 1); + + /// + /// Gets or sets a value indicating whether or not the columns should + /// expand to the available space. If false, the column + /// width will be auto calculated. Defaults to true. + /// + public bool Expand { get; set; } = true; + + /// + /// Initializes a new instance of the class. + /// + /// The items to render. + public Columns(IEnumerable items) + { + if (items is null) + { + throw new ArgumentNullException(nameof(items)); + } + + _items = new List(items); + } + + /// + /// Initializes a new instance of the class. + /// + /// The items to render. + public Columns(IEnumerable items) + { + if (items is null) + { + throw new ArgumentNullException(nameof(items)); + } + + _items = new List(items.Select(item => new Markup(item))); + } + + /// + protected override IEnumerable Render(RenderContext context, int maxWidth) + { + var maxPadding = Math.Max(Padding.Left, Padding.Right); + + var itemWidths = _items.Select(item => item.Measure(context, maxWidth).Max).ToArray(); + var columnCount = CalculateColumnCount(maxWidth, itemWidths, _items.Count, maxPadding); + + var table = new Table(); + table.NoBorder(); + table.HideHeaders(); + table.PadRightCell = false; + + if (Expand) + { + table.Expand(); + } + + // Add columns + for (var index = 0; index < columnCount; index++) + { + table.AddColumn(new TableColumn(string.Empty) + { + Padding = Padding, + NoWrap = true, + }); + } + + // Add rows + for (var start = 0; start < _items.Count; start += columnCount) + { + table.AddRow(_items.Skip(start).Take(columnCount).ToArray()); + } + + return ((IRenderable)table).Render(context, maxWidth); + } + + // Algorithm borrowed from https://github.com/willmcgugan/rich/blob/master/rich/columns.py + private int CalculateColumnCount(int maxWidth, int[] itemWidths, int columnCount, int padding) + { + var widths = new Dictionary(); + while (columnCount > 1) + { + var columnIndex = 0; + widths.Clear(); + + var exceededTotalWidth = false; + foreach (var renderableWidth in IterateWidths(itemWidths, columnCount)) + { + widths[columnIndex] = Math.Max(widths.ContainsKey(columnIndex) ? widths[columnIndex] : 0, renderableWidth); + var totalWidth = widths.Values.Sum() + (padding * (widths.Count - 1)); + if (totalWidth > maxWidth) + { + columnCount = widths.Count - 1; + exceededTotalWidth = true; + break; + } + else + { + columnIndex = (columnIndex + 1) % columnCount; + } + } + + if (!exceededTotalWidth) + { + break; + } + } + + return columnCount; + } + + private IEnumerable IterateWidths(int[] itemWidths, int columnCount) + { + foreach (var width in itemWidths) + { + yield return width; + } + + if (_items.Count % columnCount != 0) + { + for (var i = 0; i < columnCount - (_items.Count % columnCount) - 1; i++) + { + yield return 0; + } + } + } + } +} diff --git a/src/Spectre.Console/Rendering/Grid.cs b/src/Spectre.Console/Rendering/Grid.cs index 7a3a433..f261a26 100644 --- a/src/Spectre.Console/Rendering/Grid.cs +++ b/src/Spectre.Console/Rendering/Grid.cs @@ -7,7 +7,7 @@ namespace Spectre.Console /// /// A renderable grid. /// - public sealed class Grid : Renderable + public sealed class Grid : Renderable, IExpandable { private readonly Table _table; @@ -21,6 +21,13 @@ namespace Spectre.Console /// public int RowCount => _table.RowCount; + /// + public bool Expand + { + get => _table.Expand; + set => _table.Expand = value; + } + /// /// Initializes a new instance of the class. /// @@ -94,11 +101,6 @@ namespace Spectre.Console throw new ArgumentNullException(nameof(columns)); } - if (columns.Length < _table.ColumnCount) - { - throw new InvalidOperationException("The number of row columns are less than the number of grid columns."); - } - if (columns.Length > _table.ColumnCount) { throw new InvalidOperationException("The number of row columns are greater than the number of grid columns."); diff --git a/src/Spectre.Console/Rendering/Panel.cs b/src/Spectre.Console/Rendering/Panel.cs index d83b1f9..2682e7d 100644 --- a/src/Spectre.Console/Rendering/Panel.cs +++ b/src/Spectre.Console/Rendering/Panel.cs @@ -64,8 +64,8 @@ namespace Spectre.Console { var childWidth = _child.Measure(context, maxWidth); return new Measurement( - childWidth.Min + 2 + Padding.GetHorizontalPadding(), - childWidth.Max + 2 + Padding.GetHorizontalPadding()); + childWidth.Min + EdgeWidth + Padding.GetHorizontalPadding(), + childWidth.Max + EdgeWidth + Padding.GetHorizontalPadding()); } /// diff --git a/src/Spectre.Console/Rendering/Segment.cs b/src/Spectre.Console/Rendering/Segment.cs index 6afa7e5..f0a3562 100644 --- a/src/Spectre.Console/Rendering/Segment.cs +++ b/src/Spectre.Console/Rendering/Segment.cs @@ -13,10 +13,25 @@ namespace Spectre.Console.Rendering [DebuggerDisplay("{Text,nq}")] public class Segment { + private readonly bool _mutable; + private string _text; + /// /// Gets the segment text. /// - public string Text { get; internal set; } + public string Text + { + get => _text; + private set + { + if (!_mutable) + { + throw new NotSupportedException(); + } + + _text = value; + } + } /// /// Gets a value indicating whether or not this is an expicit line break @@ -39,12 +54,12 @@ namespace Spectre.Console.Rendering /// /// Gets a segment representing a line break. /// - public static Segment LineBreak { get; } = new Segment(Environment.NewLine, Style.Plain, true); + public static Segment LineBreak { get; } = new Segment(Environment.NewLine, Style.Plain, true, false); /// /// Gets an empty segment. /// - public static Segment Empty { get; } = new Segment(string.Empty, Style.Plain); + public static Segment Empty { get; } = new Segment(string.Empty, Style.Plain, false, false); /// /// Initializes a new instance of the class. @@ -65,14 +80,16 @@ namespace Spectre.Console.Rendering { } - private Segment(string text, Style style, bool lineBreak) + private Segment(string text, Style style, bool lineBreak, bool mutable = true) { if (text is null) { throw new ArgumentNullException(nameof(text)); } - Text = text.NormalizeLineEndings(); + _mutable = mutable; + _text = text.NormalizeLineEndings(); + Style = style; IsLineBreak = lineBreak; IsWhiteSpace = string.IsNullOrWhiteSpace(text); diff --git a/src/Spectre.Console/Rendering/Table.Calculations.cs b/src/Spectre.Console/Rendering/Table.Calculations.cs index 87c1da4..d75ac7d 100644 --- a/src/Spectre.Console/Rendering/Table.Calculations.cs +++ b/src/Spectre.Console/Rendering/Table.Calculations.cs @@ -18,7 +18,7 @@ namespace Spectre.Console // https://github.com/willmcgugan/rich/blob/527475837ebbfc427530b3ee0d4d0741d2d0fc6d/rich/table.py#L394 private List CalculateColumnWidths(RenderContext options, int maxWidth) { - var width_ranges = _columns.Select(column => MeasureColumn(column, options, maxWidth)); + var width_ranges = _columns.Select(column => MeasureColumn(column, options, maxWidth)).ToArray(); var widths = width_ranges.Select(range => range.Max).ToList(); var tableWidth = widths.Sum(); @@ -117,9 +117,17 @@ namespace Spectre.Console private int GetExtraWidth(bool includePadding) { - var separators = _columns.Count - 1; + var hideBorder = BorderKind == BorderKind.None; + var separators = hideBorder ? 0 : _columns.Count - 1; + var edges = hideBorder ? 0 : EdgeCount; var padding = includePadding ? _columns.Select(x => x.Padding.GetHorizontalPadding()).Sum() : 0; - return separators + EdgeCount + padding; + + if (!PadRightCell) + { + padding -= _columns.Last().Padding.Right; + } + + return separators + edges + padding; } } } diff --git a/src/Spectre.Console/Rendering/Table.cs b/src/Spectre.Console/Rendering/Table.cs index a1f47c7..5555d28 100644 --- a/src/Spectre.Console/Rendering/Table.cs +++ b/src/Spectre.Console/Rendering/Table.cs @@ -125,17 +125,19 @@ namespace Spectre.Console throw new ArgumentNullException(nameof(columns)); } - if (columns.Length < _columns.Count) - { - throw new InvalidOperationException("The number of row columns are less than the number of table columns."); - } - if (columns.Length > _columns.Count) { throw new InvalidOperationException("The number of row columns are greater than the number of table columns."); } _rows.Add(columns.ToList()); + + // Need to add missing columns? + if (columns.Length < _columns.Count) + { + var diff = _columns.Count - columns.Length; + Enumerable.Range(0, diff).ForEach(_ => _rows.Last().Add(Text.Empty)); + } } /// diff --git a/src/Spectre.Console/Rendering/Traits/Extensions/BorderExtensions.cs b/src/Spectre.Console/Rendering/Traits/Extensions/BorderExtensions.cs index 55a8085..d66e7fa 100644 --- a/src/Spectre.Console/Rendering/Traits/Extensions/BorderExtensions.cs +++ b/src/Spectre.Console/Rendering/Traits/Extensions/BorderExtensions.cs @@ -1,6 +1,6 @@ using System; -namespace Spectre.Console.Rendering +namespace Spectre.Console { /// /// Contains extension methods for . diff --git a/src/Spectre.Console/Rendering/Traits/Extensions/ExpandableExtensions.cs b/src/Spectre.Console/Rendering/Traits/Extensions/ExpandableExtensions.cs index c565840..05f6880 100644 --- a/src/Spectre.Console/Rendering/Traits/Extensions/ExpandableExtensions.cs +++ b/src/Spectre.Console/Rendering/Traits/Extensions/ExpandableExtensions.cs @@ -1,6 +1,6 @@ using System; -namespace Spectre.Console.Rendering +namespace Spectre.Console { /// /// Contains extension methods for .