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<TreeNode> - { - 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<TreeNode> { 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<TreeNode>()); - 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<TreeNode> + { + 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<TreeNode> { 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<TreeNode>()); + 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 +{ + /// <summary> + /// Contains extension methods for <see cref="IHasCulture"/>. + /// </summary> + public static class HasTreeNodeExtensions + { + /// <summary> + /// Adds a child to the end of the node's list of children. + /// </summary> + /// <typeparam name="T">An object type with tree nodes.</typeparam> + /// <param name="obj">The object that has tree nodes.</param> + /// <param name="child">Child to be added.</param> + /// <returns>The same instance so that multiple calls can be chained.</returns> + public static T AddChild<T>(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 +{ + /// <summary> + /// Represents something that has tree nodes. + /// </summary> + public interface IHasTreeNodes + { + /// <summary> + /// Gets the children of this node. + /// </summary> + public List<TreeNode> 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 /// <summary> /// An ASCII rendering of a tree. /// </summary> - public sealed class AsciiTreeRendering : ITreeRendering + public sealed class AsciiTreeAppearance : TreeAppearance { /// <inheritdoc/> - public string GetPart(TreePart part) + public override int PartSize => 4; + + /// <inheritdoc/> + 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."), }; } - - /// <inheritdoc/> - 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 -{ - /// <summary> - /// Selection of different renderings which can be used by <see cref="Tree"/>. - /// </summary> - public static class TreeRendering - { - /// <summary> - /// Gets ASCII rendering of a tree. - /// </summary> - 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 +{ + /// <summary> + /// Represents a tree appearance. + /// </summary> + public abstract partial class TreeAppearance + { + /// <summary> + /// Gets ASCII rendering of a tree. + /// </summary> + 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 { /// <summary> - /// Represents the characters used to render a tree. + /// Represents a tree appearance. /// </summary> - public interface ITreeRendering + public abstract partial class TreeAppearance { + /// <summary> + /// Gets the length of all tree part strings. + /// </summary> + public abstract int PartSize { get; } + /// <summary> /// Get the set of characters used to render the corresponding <see cref="TreePart"/>. /// </summary> /// <param name="part">The part of the tree to get rendering string for.</param> /// <returns>Rendering string for the tree part.</returns> - string GetPart(TreePart part); - - /// <summary> - /// Gets the length of all tree part strings. - /// </summary> - 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; /// <summary> - /// Gets or sets the rendering type used for the tree. + /// Gets or sets the appearance of the tree. /// </summary> - public ITreeRendering Rendering { get; set; } = TreeRendering.Ascii; + public TreeAppearance Appearance { get; set; } = TreeAppearance.Ascii; /// <summary> /// Initializes a new instance of the <see cref="Tree"/> class. @@ -29,37 +29,37 @@ namespace Spectre.Console /// <param name="rootNode">Root node of the tree to be rendered.</param> public Tree(TreeNode rootNode) { - _rootNode = rootNode; + _rootNode = rootNode ?? throw new ArgumentNullException(nameof(rootNode)); } /// <inheritdoc /> 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); } /// <inheritdoc /> @@ -68,27 +68,25 @@ namespace Spectre.Console return _rootNode .Render(context, maxWidth) .Concat(new List<Segment> { Segment.LineBreak }) - .Concat(RenderChildren(context, maxWidth - Rendering.PartSize, _rootNode, depth: 0)); + .Concat(RenderChildren(context, maxWidth - Appearance.PartSize, _rootNode, depth: 0)); } private IEnumerable<Segment> RenderChildren(RenderContext context, int maxWidth, TreeNode node, int depth, int? trailingStarted = null) { var result = new List<Segment>(); - - 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 /// <summary> /// Node of a tree. /// </summary> - public sealed class TreeNode : IRenderable + public sealed class TreeNode : IHasTreeNodes, IRenderable { private readonly IRenderable _renderable; - private List<TreeNode> _children; + + /// <inheritdoc/> + public List<TreeNode> Children { get; } /// <summary> /// Initializes a new instance of the <see cref="TreeNode"/> class. @@ -19,25 +22,8 @@ namespace Spectre.Console /// <param name="children">Any children that the node is declared with.</param> public TreeNode(IRenderable renderable, IEnumerable<TreeNode>? children = null) { - _renderable = renderable; - _children = new List<TreeNode>(children ?? Enumerable.Empty<TreeNode>()); - } - - /// <summary> - /// Gets the children of this node. - /// </summary> - public List<TreeNode> Children - { - get => _children; - } - - /// <summary> - /// Adds a child to the end of the node's list of children. - /// </summary> - /// <param name="child">Child to be added.</param> - public void AddChild(TreeNode child) - { - _children.Add(child); + _renderable = renderable ?? throw new ArgumentNullException(nameof(renderable)); + Children = new List<TreeNode>(children ?? Enumerable.Empty<TreeNode>()); } /// <inheritdoc/>