From 4bfb24bfcbb17edb88fab1ea2ab988fa2c9eaac2 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sat, 2 Jan 2021 10:47:29 +0100 Subject: [PATCH] Streamline tree API a bit --- ...Should_Render_Tree_Correctly.verified.txt} | 0 ...ith_Only_Root_Node_Correctly.verified.txt} | 0 .../Unit/TreeMeasureTests.cs | 90 ------------ .../Unit/TreeRenderingTests.cs | 55 -------- src/Spectre.Console.Tests/Unit/TreeTests.cs | 128 ++++++++++++++++++ .../Extensions/HasTreeNodeExtensions.cs | 27 ++++ src/Spectre.Console/IHasTreeNodes.cs | 15 ++ .../AsciiTreeAppearance.cs} | 10 +- .../Rendering/TreeRendering.cs | 13 -- src/Spectre.Console/TreeAppearance.Known.cs | 15 ++ .../ITreeRendering.cs => TreeAppearance.cs} | 20 +-- src/Spectre.Console/Widgets/Tree.cs | 72 +++++----- src/Spectre.Console/Widgets/TreeNode.cs | 28 +--- 13 files changed, 245 insertions(+), 228 deletions(-) rename src/Spectre.Console.Tests/Expectations/{TreeRenderingTests.Representative_Tree.verified.txt => TreeTests.Should_Render_Tree_Correctly.verified.txt} (100%) rename src/Spectre.Console.Tests/Expectations/{TreeRenderingTests.Root_Node_Only.verified.txt => TreeTests.Should_Render_Tree_With_Only_Root_Node_Correctly.verified.txt} (100%) delete mode 100644 src/Spectre.Console.Tests/Unit/TreeMeasureTests.cs delete mode 100644 src/Spectre.Console.Tests/Unit/TreeRenderingTests.cs create mode 100644 src/Spectre.Console.Tests/Unit/TreeTests.cs create mode 100644 src/Spectre.Console/Extensions/HasTreeNodeExtensions.cs create mode 100644 src/Spectre.Console/IHasTreeNodes.cs rename src/Spectre.Console/Rendering/{AsciiTreeRendering.cs => Tree/AsciiTreeAppearance.cs} (77%) delete mode 100644 src/Spectre.Console/Rendering/TreeRendering.cs create mode 100644 src/Spectre.Console/TreeAppearance.Known.cs rename src/Spectre.Console/{Rendering/ITreeRendering.cs => TreeAppearance.cs} (64%) diff --git a/src/Spectre.Console.Tests/Expectations/TreeRenderingTests.Representative_Tree.verified.txt b/src/Spectre.Console.Tests/Expectations/TreeTests.Should_Render_Tree_Correctly.verified.txt similarity index 100% rename from src/Spectre.Console.Tests/Expectations/TreeRenderingTests.Representative_Tree.verified.txt rename to src/Spectre.Console.Tests/Expectations/TreeTests.Should_Render_Tree_Correctly.verified.txt diff --git a/src/Spectre.Console.Tests/Expectations/TreeRenderingTests.Root_Node_Only.verified.txt b/src/Spectre.Console.Tests/Expectations/TreeTests.Should_Render_Tree_With_Only_Root_Node_Correctly.verified.txt similarity index 100% rename from src/Spectre.Console.Tests/Expectations/TreeRenderingTests.Root_Node_Only.verified.txt rename to src/Spectre.Console.Tests/Expectations/TreeTests.Should_Render_Tree_With_Only_Root_Node_Correctly.verified.txt diff --git a/src/Spectre.Console.Tests/Unit/TreeMeasureTests.cs b/src/Spectre.Console.Tests/Unit/TreeMeasureTests.cs deleted file mode 100644 index 0bb8491..0000000 --- a/src/Spectre.Console.Tests/Unit/TreeMeasureTests.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Text; -using Spectre.Console.Rendering; -using Xunit; - -namespace Spectre.Console.Tests.Unit -{ - public class TreeMeasureTests - { - [Fact] - public void Measure_Tree_Dominated_Width() - { - // Given - var nestedChildren = - Enumerable.Range(0, 10) - .Select(x => new TreeNode(new Text($"multiple \n line {x}"))); - var child3 = new TreeNode(new Text("child3")); - child3.AddChild(new TreeNode(new Text("single leaf\n multiline"))); - var children = new List - { - new(new Text("child1"), nestedChildren), new(new Text("child2")), child3, - }; - var root = new TreeNode(new Text("Root node"), children); - var tree = new Tree(root); - - // When - var measurement = ((IRenderable)tree).Measure(new RenderContext(Encoding.Unicode, false), 80); - - // Then - // Corresponds to "│ └── multiple" - Assert.Equal(17, measurement.Min); - - // Corresponds to " └── single leaf" when untrimmed - Assert.Equal(19, measurement.Max); - } - - [Fact] - public void Measure_Max_Width_Bound() - { - // Given - var root = new TreeNode(new Text("Root node")); - var currentNode = root; - foreach (var i in Enumerable.Range(0, 100)) - { - var newNode = new TreeNode(new Text(string.Empty)); - currentNode.AddChild(newNode); - currentNode = newNode; - } - - var tree = new Tree(root); - - // When - var measurement = ((IRenderable)tree).Measure(new RenderContext(Encoding.Unicode, false), 80); - - // Then - // Each node depth contributes 4 characters, so 100 node depth -> 400 character min width - Assert.Equal(400, measurement.Min); - - // Successfully capped at 80 terminal width - Assert.Equal(80, measurement.Max); - } - - [Fact] - public void Measure_Leaf_Dominated_Width() - { - // Given - var root = new TreeNode(new Text("Root node")); - var currentNode = root; - foreach (var i in Enumerable.Range(0, 10)) - { - var newNode = new TreeNode(new Text(string.Empty)); - currentNode.AddChild(newNode); - currentNode = newNode; - } - - var tree = new Tree(root); - - // When - var measurement = ((IRenderable)tree).Measure(new RenderContext(Encoding.Unicode, false), 80); - - // Then - // Corresponds to "│ │ │ │ │ │ │ │ │ └── " - Assert.Equal(40, measurement.Min); - - // Corresponds to "│ │ │ │ │ │ │ │ │ └── " - Assert.Equal(40, measurement.Max); - } - } -} \ No newline at end of file diff --git a/src/Spectre.Console.Tests/Unit/TreeRenderingTests.cs b/src/Spectre.Console.Tests/Unit/TreeRenderingTests.cs deleted file mode 100644 index abd5702..0000000 --- a/src/Spectre.Console.Tests/Unit/TreeRenderingTests.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Spectre.Console.Testing; -using VerifyXunit; -using Xunit; - -namespace Spectre.Console.Tests.Unit -{ - [UsesVerify] - public sealed class TreeRenderingTests - { - [Fact] - public Task Representative_Tree() - { - // Given - var console = new FakeConsole(width: 80); - var nestedChildren = - Enumerable.Range(0, 10) - .Select(x => new TreeNode(new Text($"multiple \n line {x}"))); - var child2 = new TreeNode(new Text("child2")); - var child2Child = new TreeNode(new Text("child2Child")); - child2.AddChild(child2Child); - child2Child.AddChild(new TreeNode(new Text("Child 2 child\n child"))); - var child3 = new TreeNode(new Text("child3")); - var child3Child = new TreeNode(new Text("single leaf\n multiline")); - child3Child.AddChild(new TreeNode(new Calendar(2020, 01))); - child3.AddChild(child3Child); - var children = new List { new(new Text("child1"), nestedChildren), child2, child3 }; - var root = new TreeNode(new Text("Root node"), children); - var tree = new Tree(root); - - // When - console.Render(tree); - - // Then - return Verifier.Verify(console.Output); - } - - [Fact] - public Task Root_Node_Only() - { - // Given - var console = new FakeConsole(width: 80); - var root = new TreeNode(new Text("Root node"), Enumerable.Empty()); - var tree = new Tree(root); - - // When - console.Render(tree); - - // Then - return Verifier.Verify(console.Output); - } - } -} \ No newline at end of file diff --git a/src/Spectre.Console.Tests/Unit/TreeTests.cs b/src/Spectre.Console.Tests/Unit/TreeTests.cs new file mode 100644 index 0000000..1cb755b --- /dev/null +++ b/src/Spectre.Console.Tests/Unit/TreeTests.cs @@ -0,0 +1,128 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Shouldly; +using Spectre.Console.Rendering; +using Spectre.Console.Testing; +using VerifyXunit; +using Xunit; + +namespace Spectre.Console.Tests.Unit +{ + [UsesVerify] + public class TreeTests + { + [Fact] + public void Should_Measure_Tree_Correctly() + { + // Given + var nestedChildren = + Enumerable.Range(0, 10) + .Select(x => new TreeNode(new Text($"multiple \n line {x}"))); + var child3 = new TreeNode(new Text("child3")); + child3.AddChild(new TreeNode(new Text("single leaf\n multiline"))); + var children = new List + { + new(new Text("child1"), nestedChildren), new(new Text("child2")), child3, + }; + var root = new TreeNode(new Text("Root node"), children); + var tree = new Tree(root); + + // When + var measurement = ((IRenderable)tree).Measure(new RenderContext(Encoding.Unicode, false), 80); + + // Then + measurement.Min.ShouldBe(17); + measurement.Max.ShouldBe(19); + } + + [Fact] + public void Should_Measure_Tree_Correctly_With_Regard_To_Max_Width() + { + // Given + var root = new TreeNode(new Text("Root node")); + var currentNode = root; + foreach (var i in Enumerable.Range(0, 100)) + { + var newNode = new TreeNode(new Text(string.Empty)); + currentNode.AddChild(newNode); + currentNode = newNode; + } + + var tree = new Tree(root); + + // When + var measurement = ((IRenderable)tree).Measure(new RenderContext(Encoding.Unicode, false), 80); + + // Then + measurement.Min.ShouldBe(400); + measurement.Max.ShouldBe(80); + } + + [Fact] + public void Measure_Leaf_Dominated_Width() + { + // Given + var root = new TreeNode(new Text("Root node")); + var currentNode = root; + foreach (var i in Enumerable.Range(0, 10)) + { + var newNode = new TreeNode(new Text(i.ToString())); + currentNode.AddChild(newNode); + currentNode = newNode; + } + + var tree = new Tree(root); + + // When + var measurement = ((IRenderable)tree).Measure(new RenderContext(Encoding.Unicode, false), 80); + + // Then + measurement.Min.ShouldBe(41); + measurement.Max.ShouldBe(41); + } + + [Fact] + public Task Should_Render_Tree_Correctly() + { + // Given + var console = new FakeConsole(width: 80); + var nestedChildren = + Enumerable.Range(0, 10) + .Select(x => new TreeNode(new Text($"multiple \n line {x}"))); + var child2 = new TreeNode(new Text("child2")); + var child2Child = new TreeNode(new Text("child2Child")); + child2.AddChild(child2Child); + child2Child.AddChild(new TreeNode(new Text("Child 2 child\n child"))); + var child3 = new TreeNode(new Text("child3")); + var child3Child = new TreeNode(new Text("single leaf\n multiline")); + child3Child.AddChild(new TreeNode(new Calendar(2020, 01))); + child3.AddChild(child3Child); + var children = new List { new(new Text("child1"), nestedChildren), child2, child3 }; + var root = new TreeNode(new Text("Root node"), children); + var tree = new Tree(root); + + // When + console.Render(tree); + + // Then + return Verifier.Verify(console.Output); + } + + [Fact] + public Task Should_Render_Tree_With_Only_Root_Node_Correctly() + { + // Given + var console = new FakeConsole(width: 80); + var root = new TreeNode(new Text("Root node"), Enumerable.Empty()); + var tree = new Tree(root); + + // When + console.Render(tree); + + // Then + return Verifier.Verify(console.Output); + } + } +} \ No newline at end of file diff --git a/src/Spectre.Console/Extensions/HasTreeNodeExtensions.cs b/src/Spectre.Console/Extensions/HasTreeNodeExtensions.cs new file mode 100644 index 0000000..945a7f8 --- /dev/null +++ b/src/Spectre.Console/Extensions/HasTreeNodeExtensions.cs @@ -0,0 +1,27 @@ +namespace Spectre.Console +{ + /// + /// Contains extension methods for . + /// + public static class HasTreeNodeExtensions + { + /// + /// Adds a child to the end of the node's list of children. + /// + /// An object type with tree nodes. + /// The object that has tree nodes. + /// Child to be added. + /// The same instance so that multiple calls can be chained. + public static T AddChild(this T obj, TreeNode child) + where T : class, IHasTreeNodes + { + if (obj is null) + { + throw new System.ArgumentNullException(nameof(obj)); + } + + obj.Children.Add(child); + return obj; + } + } +} diff --git a/src/Spectre.Console/IHasTreeNodes.cs b/src/Spectre.Console/IHasTreeNodes.cs new file mode 100644 index 0000000..bbed370 --- /dev/null +++ b/src/Spectre.Console/IHasTreeNodes.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace Spectre.Console +{ + /// + /// Represents something that has tree nodes. + /// + public interface IHasTreeNodes + { + /// + /// Gets the children of this node. + /// + public List Children { get; } + } +} diff --git a/src/Spectre.Console/Rendering/AsciiTreeRendering.cs b/src/Spectre.Console/Rendering/Tree/AsciiTreeAppearance.cs similarity index 77% rename from src/Spectre.Console/Rendering/AsciiTreeRendering.cs rename to src/Spectre.Console/Rendering/Tree/AsciiTreeAppearance.cs index 92e2cc4..2e4d236 100644 --- a/src/Spectre.Console/Rendering/AsciiTreeRendering.cs +++ b/src/Spectre.Console/Rendering/Tree/AsciiTreeAppearance.cs @@ -5,10 +5,13 @@ namespace Spectre.Console.Rendering /// /// An ASCII rendering of a tree. /// - public sealed class AsciiTreeRendering : ITreeRendering + public sealed class AsciiTreeAppearance : TreeAppearance { /// - public string GetPart(TreePart part) + public override int PartSize => 4; + + /// + public override string GetPart(TreePart part) { return part switch { @@ -18,8 +21,5 @@ namespace Spectre.Console.Rendering _ => throw new ArgumentOutOfRangeException(nameof(part), part, "Unknown tree part."), }; } - - /// - public int PartSize => 4; } } \ No newline at end of file diff --git a/src/Spectre.Console/Rendering/TreeRendering.cs b/src/Spectre.Console/Rendering/TreeRendering.cs deleted file mode 100644 index 2bb00f5..0000000 --- a/src/Spectre.Console/Rendering/TreeRendering.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Spectre.Console.Rendering -{ - /// - /// Selection of different renderings which can be used by . - /// - public static class TreeRendering - { - /// - /// Gets ASCII rendering of a tree. - /// - public static ITreeRendering Ascii { get; } = new AsciiTreeRendering(); - } -} \ No newline at end of file diff --git a/src/Spectre.Console/TreeAppearance.Known.cs b/src/Spectre.Console/TreeAppearance.Known.cs new file mode 100644 index 0000000..4cbb85c --- /dev/null +++ b/src/Spectre.Console/TreeAppearance.Known.cs @@ -0,0 +1,15 @@ +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// Represents a tree appearance. + /// + public abstract partial class TreeAppearance + { + /// + /// Gets ASCII rendering of a tree. + /// + public static TreeAppearance Ascii { get; } = new AsciiTreeAppearance(); + } +} diff --git a/src/Spectre.Console/Rendering/ITreeRendering.cs b/src/Spectre.Console/TreeAppearance.cs similarity index 64% rename from src/Spectre.Console/Rendering/ITreeRendering.cs rename to src/Spectre.Console/TreeAppearance.cs index 38c2bcc..077c745 100644 --- a/src/Spectre.Console/Rendering/ITreeRendering.cs +++ b/src/Spectre.Console/TreeAppearance.cs @@ -1,20 +1,22 @@ -namespace Spectre.Console.Rendering +using Spectre.Console.Rendering; + +namespace Spectre.Console { /// - /// Represents the characters used to render a tree. + /// Represents a tree appearance. /// - public interface ITreeRendering + public abstract partial class TreeAppearance { + /// + /// Gets the length of all tree part strings. + /// + public abstract int PartSize { get; } + /// /// Get the set of characters used to render the corresponding . /// /// The part of the tree to get rendering string for. /// Rendering string for the tree part. - string GetPart(TreePart part); - - /// - /// Gets the length of all tree part strings. - /// - int PartSize { get; } + public abstract string GetPart(TreePart part); } } \ No newline at end of file diff --git a/src/Spectre.Console/Widgets/Tree.cs b/src/Spectre.Console/Widgets/Tree.cs index 4cc9d96..e613dc6 100644 --- a/src/Spectre.Console/Widgets/Tree.cs +++ b/src/Spectre.Console/Widgets/Tree.cs @@ -19,9 +19,9 @@ namespace Spectre.Console public Style Style { get; set; } = Style.Plain; /// - /// Gets or sets the rendering type used for the tree. + /// Gets or sets the appearance of the tree. /// - public ITreeRendering Rendering { get; set; } = TreeRendering.Ascii; + public TreeAppearance Appearance { get; set; } = TreeAppearance.Ascii; /// /// Initializes a new instance of the class. @@ -29,37 +29,37 @@ namespace Spectre.Console /// Root node of the tree to be rendered. public Tree(TreeNode rootNode) { - _rootNode = rootNode; + _rootNode = rootNode ?? throw new ArgumentNullException(nameof(rootNode)); } /// protected override Measurement Measure(RenderContext context, int maxWidth) { - return MeasureAtDepth(context, maxWidth, _rootNode, depth: 0); - } - - private Measurement MeasureAtDepth(RenderContext context, int maxWidth, TreeNode node, int depth) - { - var rootMeasurement = node.Measure(context, maxWidth); - var treeIndentation = depth * Rendering.PartSize; - var currentMax = rootMeasurement.Max + treeIndentation; - var currentMin = rootMeasurement.Min + treeIndentation; - - foreach (var child in node.Children) + Measurement MeasureAtDepth(RenderContext context, int maxWidth, TreeNode node, int depth) { - var childMeasurement = MeasureAtDepth(context, maxWidth, child, depth + 1); - if (childMeasurement.Min > currentMin) + var rootMeasurement = node.Measure(context, maxWidth); + var treeIndentation = depth * Appearance.PartSize; + var currentMax = rootMeasurement.Max + treeIndentation; + var currentMin = rootMeasurement.Min + treeIndentation; + + foreach (var child in node.Children) { - currentMin = childMeasurement.Min; + var childMeasurement = MeasureAtDepth(context, maxWidth, child, depth + 1); + if (childMeasurement.Min > currentMin) + { + currentMin = childMeasurement.Min; + } + + if (childMeasurement.Max > currentMax) + { + currentMax = childMeasurement.Max; + } } - if (childMeasurement.Max > currentMax) - { - currentMax = childMeasurement.Max; - } + return new Measurement(currentMin, Math.Min(currentMax, maxWidth)); } - return new Measurement(currentMin, Math.Min(currentMax, maxWidth)); + return MeasureAtDepth(context, maxWidth, _rootNode, depth: 0); } /// @@ -68,27 +68,25 @@ namespace Spectre.Console return _rootNode .Render(context, maxWidth) .Concat(new List { Segment.LineBreak }) - .Concat(RenderChildren(context, maxWidth - Rendering.PartSize, _rootNode, depth: 0)); + .Concat(RenderChildren(context, maxWidth - Appearance.PartSize, _rootNode, depth: 0)); } private IEnumerable RenderChildren(RenderContext context, int maxWidth, TreeNode node, int depth, int? trailingStarted = null) { var result = new List(); - - foreach (var (index, firstChild, lastChild, childNode) in node.Children.Enumerate()) + foreach (var (_, _, lastChild, childNode) in node.Children.Enumerate()) { var lines = Segment.SplitLines(context, childNode.Render(context, maxWidth)); - - foreach (var (lineIndex, firstLine, lastLine, line) in lines.Enumerate()) + foreach (var (_, isFirstLine, _, line) in lines.Enumerate()) { var siblingConnectorSegment = - new Segment(Rendering.GetPart(TreePart.SiblingConnector), Style); + new Segment(Appearance.GetPart(TreePart.SiblingConnector), Style); if (trailingStarted != null) { result.AddRange(Enumerable.Repeat(siblingConnectorSegment, trailingStarted.Value)); result.AddRange(Enumerable.Repeat( - Segment.Padding(Rendering.PartSize), + Segment.Padding(Appearance.PartSize), depth - trailingStarted.Value)); } else @@ -96,15 +94,15 @@ namespace Spectre.Console result.AddRange(Enumerable.Repeat(siblingConnectorSegment, depth)); } - if (firstLine) + if (isFirstLine) { result.Add(lastChild - ? new Segment(Rendering.GetPart(TreePart.BottomChildBranch), Style) - : new Segment(Rendering.GetPart(TreePart.ChildBranch), Style)); + ? new Segment(Appearance.GetPart(TreePart.BottomChildBranch), Style) + : new Segment(Appearance.GetPart(TreePart.ChildBranch), Style)); } else { - result.Add(lastChild ? Segment.Padding(Rendering.PartSize) : siblingConnectorSegment); + result.Add(lastChild ? Segment.Padding(Appearance.PartSize) : siblingConnectorSegment); } result.AddRange(line); @@ -112,8 +110,12 @@ namespace Spectre.Console } var childTrailingStarted = trailingStarted ?? (lastChild ? depth : null); - result.AddRange(RenderChildren(context, maxWidth - Rendering.PartSize, childNode, depth + 1, - childTrailingStarted)); + + result.AddRange( + RenderChildren( + context, maxWidth - Appearance.PartSize, + childNode, depth + 1, + childTrailingStarted)); } return result; diff --git a/src/Spectre.Console/Widgets/TreeNode.cs b/src/Spectre.Console/Widgets/TreeNode.cs index 724732b..d7c19d2 100644 --- a/src/Spectre.Console/Widgets/TreeNode.cs +++ b/src/Spectre.Console/Widgets/TreeNode.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using Spectre.Console.Rendering; @@ -7,10 +8,12 @@ namespace Spectre.Console /// /// Node of a tree. /// - public sealed class TreeNode : IRenderable + public sealed class TreeNode : IHasTreeNodes, IRenderable { private readonly IRenderable _renderable; - private List _children; + + /// + public List Children { get; } /// /// Initializes a new instance of the class. @@ -19,25 +22,8 @@ namespace Spectre.Console /// Any children that the node is declared with. public TreeNode(IRenderable renderable, IEnumerable? children = null) { - _renderable = renderable; - _children = new List(children ?? Enumerable.Empty()); - } - - /// - /// Gets the children of this node. - /// - public List Children - { - get => _children; - } - - /// - /// Adds a child to the end of the node's list of children. - /// - /// Child to be added. - public void AddChild(TreeNode child) - { - _children.Add(child); + _renderable = renderable ?? throw new ArgumentNullException(nameof(renderable)); + Children = new List(children ?? Enumerable.Empty()); } ///