diff --git a/src/Spectre.Console.Tests/Expectations/TreeRenderingTests.Representative_Tree.verified.txt b/src/Spectre.Console.Tests/Expectations/TreeRenderingTests.Representative_Tree.verified.txt new file mode 100644 index 0000000..9d1ef68 --- /dev/null +++ b/src/Spectre.Console.Tests/Expectations/TreeRenderingTests.Representative_Tree.verified.txt @@ -0,0 +1,40 @@ +Root node +├── child1 +│ ├── multiple +│ │ line 0 +│ ├── multiple +│ │ line 1 +│ ├── multiple +│ │ line 2 +│ ├── multiple +│ │ line 3 +│ ├── multiple +│ │ line 4 +│ ├── multiple +│ │ line 5 +│ ├── multiple +│ │ line 6 +│ ├── multiple +│ │ line 7 +│ ├── multiple +│ │ line 8 +│ └── multiple +│ line 9 +├── child2 +│ └── child2Child +│ └── Child 2 child +│ child +└── child3 + └── single leaf + multiline + └── 2020 January + ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┐ + │ Sun │ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ + ├─────┼─────┼─────┼─────┼─────┼─────┼─────┤ + │ │ │ │ 1 │ 2 │ 3 │ 4 │ + │ 5 │ 6 │ 7 │ 8 │ 9 │ 10 │ 11 │ + │ 12 │ 13 │ 14 │ 15 │ 16 │ 17 │ 18 │ + │ 19 │ 20 │ 21 │ 22 │ 23 │ 24 │ 25 │ + │ 26 │ 27 │ 28 │ 29 │ 30 │ 31 │ │ + │ │ │ │ │ │ │ │ + └─────┴─────┴─────┴─────┴─────┴─────┴─────┘ diff --git a/src/Spectre.Console.Tests/Expectations/TreeRenderingTests.Root_Node_Only.verified.txt b/src/Spectre.Console.Tests/Expectations/TreeRenderingTests.Root_Node_Only.verified.txt new file mode 100644 index 0000000..75fa969 --- /dev/null +++ b/src/Spectre.Console.Tests/Expectations/TreeRenderingTests.Root_Node_Only.verified.txt @@ -0,0 +1 @@ +Root node diff --git a/src/Spectre.Console.Tests/Unit/TreeMeasureTests.cs b/src/Spectre.Console.Tests/Unit/TreeMeasureTests.cs new file mode 100644 index 0000000..0bb8491 --- /dev/null +++ b/src/Spectre.Console.Tests/Unit/TreeMeasureTests.cs @@ -0,0 +1,90 @@ +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 new file mode 100644 index 0000000..abd5702 --- /dev/null +++ b/src/Spectre.Console.Tests/Unit/TreeRenderingTests.cs @@ -0,0 +1,55 @@ +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/Internal/Aligner.cs b/src/Spectre.Console/Internal/Aligner.cs index 4ff8432..bda90d6 100644 --- a/src/Spectre.Console/Internal/Aligner.cs +++ b/src/Spectre.Console/Internal/Aligner.cs @@ -68,7 +68,7 @@ namespace Spectre.Console.Internal case Justify.Right: { var diff = maxWidth - width; - segments.Insert(0, new Segment(new string(' ', diff))); + segments.Insert(0, Segment.Padding(diff)); break; } @@ -76,14 +76,14 @@ namespace Spectre.Console.Internal { // Left side. var diff = (maxWidth - width) / 2; - segments.Insert(0, new Segment(new string(' ', diff))); + segments.Insert(0, Segment.Padding(diff)); // Right side - segments.Add(new Segment(new string(' ', diff))); + segments.Add(Segment.Padding(diff)); var remainder = (maxWidth - width) % 2; if (remainder != 0) { - segments.Add(new Segment(new string(' ', remainder))); + segments.Add(Segment.Padding(remainder)); } break; diff --git a/src/Spectre.Console/Rendering/AsciiTreeRendering.cs b/src/Spectre.Console/Rendering/AsciiTreeRendering.cs new file mode 100644 index 0000000..92e2cc4 --- /dev/null +++ b/src/Spectre.Console/Rendering/AsciiTreeRendering.cs @@ -0,0 +1,25 @@ +using System; + +namespace Spectre.Console.Rendering +{ + /// + /// An ASCII rendering of a tree. + /// + public sealed class AsciiTreeRendering : ITreeRendering + { + /// + public string GetPart(TreePart part) + { + return part switch + { + TreePart.SiblingConnector => "│ ", + TreePart.ChildBranch => "├── ", + TreePart.BottomChildBranch => "└── ", + _ => 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/ITreeRendering.cs b/src/Spectre.Console/Rendering/ITreeRendering.cs new file mode 100644 index 0000000..38c2bcc --- /dev/null +++ b/src/Spectre.Console/Rendering/ITreeRendering.cs @@ -0,0 +1,20 @@ +namespace Spectre.Console.Rendering +{ + /// + /// Represents the characters used to render a tree. + /// + public interface ITreeRendering + { + /// + /// 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; } + } +} \ No newline at end of file diff --git a/src/Spectre.Console/Rendering/Segment.cs b/src/Spectre.Console/Rendering/Segment.cs index 59994eb..e7fed43 100644 --- a/src/Spectre.Console/Rendering/Segment.cs +++ b/src/Spectre.Console/Rendering/Segment.cs @@ -52,6 +52,13 @@ namespace Spectre.Console.Rendering /// public static Segment Empty { get; } = new Segment(string.Empty, Style.Plain, false, false); + /// + /// Creates padding segment. + /// + /// Number of whitespace characters. + /// Segment for specified padding size. + public static Segment Padding(int size) => new(new string(' ', size)); + /// /// Initializes a new instance of the class. /// diff --git a/src/Spectre.Console/Rendering/SegmentShape.cs b/src/Spectre.Console/Rendering/SegmentShape.cs index 149d46b..c3bef68 100644 --- a/src/Spectre.Console/Rendering/SegmentShape.cs +++ b/src/Spectre.Console/Rendering/SegmentShape.cs @@ -48,7 +48,7 @@ namespace Spectre.Console.Rendering var missing = Width - length; if (missing > 0) { - line.Add(new Segment(new string(' ', missing))); + line.Add(Segment.Padding(missing)); } } @@ -59,7 +59,7 @@ namespace Spectre.Console.Rendering { lines.Add(new SegmentLine { - new Segment(new string(' ', Width)), + Segment.Padding(Width), }); } } diff --git a/src/Spectre.Console/Rendering/TreePart.cs b/src/Spectre.Console/Rendering/TreePart.cs new file mode 100644 index 0000000..ea74582 --- /dev/null +++ b/src/Spectre.Console/Rendering/TreePart.cs @@ -0,0 +1,23 @@ +namespace Spectre.Console.Rendering +{ + /// + /// Defines the different rendering parts of a . + /// + public enum TreePart + { + /// + /// Connection between siblings. + /// + SiblingConnector, + + /// + /// Branch from parent to child. + /// + ChildBranch, + + /// + /// Branch from parent to child for the last child in a set. + /// + BottomChildBranch, + } +} \ No newline at end of file diff --git a/src/Spectre.Console/Rendering/TreeRendering.cs b/src/Spectre.Console/Rendering/TreeRendering.cs new file mode 100644 index 0000000..2bb00f5 --- /dev/null +++ b/src/Spectre.Console/Rendering/TreeRendering.cs @@ -0,0 +1,13 @@ +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/Widgets/Figlet/FigletText.cs b/src/Spectre.Console/Widgets/Figlet/FigletText.cs index 2e0c493..d34ce3a 100644 --- a/src/Spectre.Console/Widgets/Figlet/FigletText.cs +++ b/src/Spectre.Console/Widgets/Figlet/FigletText.cs @@ -60,7 +60,7 @@ namespace Spectre.Console if (lineWidth < maxWidth) { - yield return new Segment(new string(' ', maxWidth - lineWidth)); + yield return Segment.Padding(maxWidth - lineWidth); } } else if (alignment == Justify.Center) @@ -68,15 +68,15 @@ namespace Spectre.Console var left = (maxWidth - lineWidth) / 2; var right = left + ((maxWidth - lineWidth) % 2); - yield return new Segment(new string(' ', left)); + yield return Segment.Padding(left); yield return line; - yield return new Segment(new string(' ', right)); + yield return Segment.Padding(right); } else if (alignment == Justify.Right) { if (lineWidth < maxWidth) { - yield return new Segment(new string(' ', maxWidth - lineWidth)); + yield return Segment.Padding(maxWidth - lineWidth); } yield return line; diff --git a/src/Spectre.Console/Widgets/Padder.cs b/src/Spectre.Console/Widgets/Padder.cs index bb48bcc..c8c7536 100644 --- a/src/Spectre.Console/Widgets/Padder.cs +++ b/src/Spectre.Console/Widgets/Padder.cs @@ -66,7 +66,7 @@ namespace Spectre.Console // Top padding for (var i = 0; i < Padding.GetTopSafe(); i++) { - result.Add(new Segment(new string(' ', width))); + result.Add(Segment.Padding(width)); result.Add(Segment.LineBreak); } @@ -76,7 +76,7 @@ namespace Spectre.Console // Left padding if (Padding.GetLeftSafe() != 0) { - result.Add(new Segment(new string(' ', Padding.GetLeftSafe()))); + result.Add(Segment.Padding(Padding.GetLeftSafe())); } result.AddRange(line); @@ -84,7 +84,7 @@ namespace Spectre.Console // Right padding if (Padding.GetRightSafe() != 0) { - result.Add(new Segment(new string(' ', Padding.GetRightSafe()))); + result.Add(Segment.Padding(Padding.GetRightSafe())); } // Missing space on right side? @@ -92,7 +92,7 @@ namespace Spectre.Console var diff = width - lineWidth - Padding.GetLeftSafe() - Padding.GetRightSafe(); if (diff > 0) { - result.Add(new Segment(new string(' ', diff))); + result.Add(Segment.Padding(diff)); } result.Add(Segment.LineBreak); @@ -101,7 +101,7 @@ namespace Spectre.Console // Bottom padding for (var i = 0; i < Padding.GetBottomSafe(); i++) { - result.Add(new Segment(new string(' ', width))); + result.Add(Segment.Padding(width)); result.Add(Segment.LineBreak); } diff --git a/src/Spectre.Console/Widgets/Panel.cs b/src/Spectre.Console/Widgets/Panel.cs index da550fa..547aa30 100644 --- a/src/Spectre.Console/Widgets/Panel.cs +++ b/src/Spectre.Console/Widgets/Panel.cs @@ -112,7 +112,7 @@ namespace Spectre.Console if (length < childWidth) { var diff = childWidth - length; - content.Add(new Segment(new string(' ', diff))); + content.Add(Segment.Padding(diff)); } result.AddRange(content); diff --git a/src/Spectre.Console/Widgets/Tree.cs b/src/Spectre.Console/Widgets/Tree.cs new file mode 100644 index 0000000..4cc9d96 --- /dev/null +++ b/src/Spectre.Console/Widgets/Tree.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Spectre.Console.Internal; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// Representation of tree data. + /// + public sealed class Tree : Renderable + { + private readonly TreeNode _rootNode; + + /// + /// Gets or sets the tree style. + /// + public Style Style { get; set; } = Style.Plain; + + /// + /// Gets or sets the rendering type used for the tree. + /// + public ITreeRendering Rendering { get; set; } = TreeRendering.Ascii; + + /// + /// Initializes a new instance of the class. + /// + /// Root node of the tree to be rendered. + public Tree(TreeNode rootNode) + { + _rootNode = 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) + { + var childMeasurement = MeasureAtDepth(context, maxWidth, child, depth + 1); + if (childMeasurement.Min > currentMin) + { + currentMin = childMeasurement.Min; + } + + if (childMeasurement.Max > currentMax) + { + currentMax = childMeasurement.Max; + } + } + + return new Measurement(currentMin, Math.Min(currentMax, maxWidth)); + } + + /// + protected override IEnumerable Render(RenderContext context, int maxWidth) + { + return _rootNode + .Render(context, maxWidth) + .Concat(new List { Segment.LineBreak }) + .Concat(RenderChildren(context, maxWidth - Rendering.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()) + { + var lines = Segment.SplitLines(context, childNode.Render(context, maxWidth)); + + foreach (var (lineIndex, firstLine, lastLine, line) in lines.Enumerate()) + { + var siblingConnectorSegment = + new Segment(Rendering.GetPart(TreePart.SiblingConnector), Style); + if (trailingStarted != null) + { + result.AddRange(Enumerable.Repeat(siblingConnectorSegment, trailingStarted.Value)); + result.AddRange(Enumerable.Repeat( + Segment.Padding(Rendering.PartSize), + depth - trailingStarted.Value)); + } + else + { + result.AddRange(Enumerable.Repeat(siblingConnectorSegment, depth)); + } + + if (firstLine) + { + result.Add(lastChild + ? new Segment(Rendering.GetPart(TreePart.BottomChildBranch), Style) + : new Segment(Rendering.GetPart(TreePart.ChildBranch), Style)); + } + else + { + result.Add(lastChild ? Segment.Padding(Rendering.PartSize) : siblingConnectorSegment); + } + + result.AddRange(line); + result.Add(Segment.LineBreak); + } + + var childTrailingStarted = trailingStarted ?? (lastChild ? depth : null); + result.AddRange(RenderChildren(context, maxWidth - Rendering.PartSize, childNode, depth + 1, + childTrailingStarted)); + } + + return result; + } + } +} \ No newline at end of file diff --git a/src/Spectre.Console/Widgets/TreeNode.cs b/src/Spectre.Console/Widgets/TreeNode.cs new file mode 100644 index 0000000..724732b --- /dev/null +++ b/src/Spectre.Console/Widgets/TreeNode.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Linq; +using Spectre.Console.Rendering; + +namespace Spectre.Console +{ + /// + /// Node of a tree. + /// + public sealed class TreeNode : IRenderable + { + private readonly IRenderable _renderable; + private List _children; + + /// + /// Initializes a new instance of the class. + /// + /// The which this node wraps. + /// 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); + } + + /// + public Measurement Measure(RenderContext context, int maxWidth) + { + return _renderable.Measure(context, maxWidth); + } + + /// + public IEnumerable Render(RenderContext context, int maxWidth) + { + return _renderable.Render(context, maxWidth); + } + } +} \ No newline at end of file