diff --git a/examples/Console/Paths/Program.cs b/examples/Console/Paths/Program.cs index 14015fe..41ee0f2 100644 --- a/examples/Console/Paths/Program.cs +++ b/examples/Console/Paths/Program.cs @@ -8,13 +8,22 @@ public static class Program { public static void Main() { - AnsiConsole.WriteLine(); - AnsiConsole.Write(new TextPath(@"C:\Users\Patrik\Source\github\patriksvensson-forks\spectre.console\examples\Console\Paths")); - AnsiConsole.WriteLine(); + var windowsPath = @"C:\This is\A\Super Long\Windows\Path\That\Goes\On And On\And\Never\Seems\To\Stop\But\At\Some\Point\It\Must\I\Guess.txt"; + var unixPath = @"//This is/A/Super Long/Unix/Path/That/Goes/On And On/And/Never/Seems/To/Stop/But/At/Some/Point/It/Must/I/Guess.txt"; var table = new Table().BorderColor(Color.Grey); - table.AddColumns("[grey]Index[/]", "[yellow]Path[/]"); - table.AddRow(new Text("1"), new TextPath(@"C:\Users\Patrik\Source\github\patriksvensson-forks\spectre.console\examples\Console\Paths")); + table.AddColumns("[grey]OS[/]", "[grey]Path[/]"); + + table.AddRow(new Text("Windows"), + new TextPath(windowsPath)); + + table.AddRow(new Text("Unix"), + new TextPath(unixPath) + .RootColor(Color.Blue) + .SeparatorColor(Color.Yellow) + .StemStyle(Color.Red) + .LeafStyle(Color.Green)); + AnsiConsole.Write(table); } } diff --git a/src/Spectre.Console/Extensions/TextPathExtensions.cs b/src/Spectre.Console/Extensions/TextPathExtensions.cs new file mode 100644 index 0000000..f118ac1 --- /dev/null +++ b/src/Spectre.Console/Extensions/TextPathExtensions.cs @@ -0,0 +1,119 @@ +namespace Spectre.Console; + +/// +/// Contains extension methods for . +/// +public static class TextPathExtensions +{ + /// + /// Sets the separator style. + /// + /// The path. + /// The separator style to set. + /// The same instance so that multiple calls can be chained. + public static TextPath SeparatorStyle(this TextPath obj, Style style) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.SeparatorStyle = style; + return obj; + } + + /// + /// Sets the separator color. + /// + /// The path. + /// The separator color. + /// The same instance so that multiple calls can be chained. + public static TextPath SeparatorColor(this TextPath obj, Color color) + { + return SeparatorStyle(obj, new Style(foreground: color)); + } + + /// + /// Sets the root style. + /// + /// The path. + /// The root style to set. + /// The same instance so that multiple calls can be chained. + public static TextPath RootStyle(this TextPath obj, Style style) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.RootStyle = style; + return obj; + } + + /// + /// Sets the root color. + /// + /// The path. + /// The root color. + /// The same instance so that multiple calls can be chained. + public static TextPath RootColor(this TextPath obj, Color color) + { + return RootStyle(obj, new Style(foreground: color)); + } + + /// + /// Sets the stem style. + /// + /// The path. + /// The stem style to set. + /// The same instance so that multiple calls can be chained. + public static TextPath StemStyle(this TextPath obj, Style style) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.StemStyle = style; + return obj; + } + + /// + /// Sets the stem color. + /// + /// The path. + /// The stem color. + /// The same instance so that multiple calls can be chained. + public static TextPath StemStyle(this TextPath obj, Color color) + { + return StemStyle(obj, new Style(foreground: color)); + } + + /// + /// Sets the leaf style. + /// + /// The path. + /// The stem leaf to set. + /// The same instance so that multiple calls can be chained. + public static TextPath LeafStyle(this TextPath obj, Style style) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + obj.LeafStyle = style; + return obj; + } + + /// + /// Sets the leaf color. + /// + /// The path. + /// The leaf color. + /// The same instance so that multiple calls can be chained. + public static TextPath LeafStyle(this TextPath obj, Color color) + { + return LeafStyle(obj, new Style(foreground: color)); + } +} diff --git a/src/Spectre.Console/Internal/Aligner.cs b/src/Spectre.Console/Internal/Aligner.cs index 97cda49..92c7ff3 100644 --- a/src/Spectre.Console/Internal/Aligner.cs +++ b/src/Spectre.Console/Internal/Aligner.cs @@ -45,7 +45,7 @@ internal static class Aligner } } - public static void Align(RenderContext context, T segments, Justify? alignment, int maxWidth) + public static void Align(T segments, Justify? alignment, int maxWidth) where T : List { if (alignment == null || alignment == Justify.Left) diff --git a/src/Spectre.Console/Widgets/Paragraph.cs b/src/Spectre.Console/Widgets/Paragraph.cs index bfcc09b..5ac2f8e 100644 --- a/src/Spectre.Console/Widgets/Paragraph.cs +++ b/src/Spectre.Console/Widgets/Paragraph.cs @@ -151,7 +151,7 @@ public sealed class Paragraph : Renderable, IAlignable, IOverflowable { foreach (var line in lines) { - Aligner.Align(context, line, justification, maxWidth); + Aligner.Align(line, justification, maxWidth); } } diff --git a/src/Spectre.Console/Widgets/Table/TableRenderer.cs b/src/Spectre.Console/Widgets/Table/TableRenderer.cs index a0e178a..fadade9 100644 --- a/src/Spectre.Console/Widgets/Table/TableRenderer.cs +++ b/src/Spectre.Console/Widgets/Table/TableRenderer.cs @@ -115,7 +115,7 @@ internal static class TableRenderer } // Align the row result. - Aligner.Align(context.Options, rowResult, context.Alignment, context.MaxWidth); + Aligner.Align(rowResult, context.Alignment, context.MaxWidth); // Is the row larger than the allowed max width? if (Segment.CellCount(rowResult) > context.MaxWidth) @@ -167,7 +167,7 @@ internal static class TableRenderer segments.AddRange(((IRenderable)paragraph).Render(context.Options, context.TableWidth)); // Align over the whole buffer area - Aligner.Align(context.Options, segments, context.Alignment, context.MaxWidth); + Aligner.Align(segments, context.Alignment, context.MaxWidth); segments.Add(Segment.LineBreak); return segments; diff --git a/src/Spectre.Console/Widgets/TextPath.cs b/src/Spectre.Console/Widgets/TextPath.cs index b2acbca..869cb1a 100644 --- a/src/Spectre.Console/Widgets/TextPath.cs +++ b/src/Spectre.Console/Widgets/TextPath.cs @@ -5,7 +5,32 @@ namespace Spectre.Console; /// public sealed class TextPath : IRenderable { + private const string Ellipsis = "..."; + private const string UnicodeEllipsis = "…"; + private readonly string[] _parts; + private readonly bool _rooted; + private readonly bool _windows; + + /// + /// Gets or sets the root style. + /// + public Style? RootStyle { get; set; } + + /// + /// Gets or sets the separator style. + /// + public Style? SeparatorStyle { get; set; } + + /// + /// Gets or sets the stem style. + /// + public Style? StemStyle { get; set; } + + /// + /// Gets or sets the leaf style. + /// + public Style? LeafStyle { get; set; } /// /// Initializes a new instance of the class. @@ -20,13 +45,27 @@ public sealed class TextPath : IRenderable // Get the distinct parts _parts = path.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + + // Rooted Unix path? + if (path.StartsWith("/")) + { + _rooted = true; + _parts = new[] { "/" }.Concat(_parts).ToArray(); + } + else if (_parts.Length > 0 && _parts[0].EndsWith(":")) + { + // Rooted Windows path + _rooted = true; + _windows = true; + } } /// public Measurement Measure(RenderContext context, int maxWidth) { var fitted = Fit(context, maxWidth); - var length = fitted.Sum(f => f.Length) + fitted.Length - 1; + var separatorCount = fitted.Length - 1; + var length = fitted.Sum(f => f.Length) + separatorCount; return new Measurement( Math.Min(length, maxWidth), @@ -36,19 +75,44 @@ public sealed class TextPath : IRenderable /// public IEnumerable Render(RenderContext context, int maxWidth) { + var rootStyle = RootStyle ?? Style.Plain; + var separatorStyle = SeparatorStyle ?? Style.Plain; + var stemStyle = StemStyle ?? Style.Plain; + var leafStyle = LeafStyle ?? Style.Plain; + var fitted = Fit(context, maxWidth); - var parts = new List(); - foreach (var (_, _, last, item) in fitted.Enumerate()) + foreach (var (_, first, last, item) in fitted.Enumerate()) { - parts.Add(new Segment(item)); - - if (!last) + // Leaf? + if (last) { - parts.Add(new Segment("/", new Style(Color.Grey))); + parts.Add(new Segment(item, leafStyle)); + } + else + { + if (first && _rooted) + { + // Root + parts.Add(new Segment(item, rootStyle)); + + if (_windows) + { + // Windows root has a slash + parts.Add(new Segment("/", separatorStyle)); + } + } + else + { + // Normal path segment + parts.Add(new Segment(item, stemStyle)); + parts.Add(new Segment("/", separatorStyle)); + } } } + parts.Add(Segment.LineBreak); + return parts; } @@ -66,13 +130,17 @@ public sealed class TextPath : IRenderable return _parts; } - var ellipsis = context.Unicode ? "…" : "..."; + var ellipsis = context.Unicode ? UnicodeEllipsis : Ellipsis; var ellipsisLength = Cell.GetCellLength(ellipsis); if (_parts.Length >= 2) { + var skip = _rooted ? 1 : 0; + var separatorCount = _rooted ? 2 : 1; + var rootLength = _rooted ? Cell.GetCellLength(_parts[0]) : 0; + // Try popping parts until it fits - var queue = new Queue(_parts.Skip(1).Take(_parts.Length - 2)); + var queue = new Queue(_parts.Skip(skip).Take(_parts.Length - separatorCount)); while (queue.Count > 0) { // Remove the first item @@ -80,20 +148,27 @@ public sealed class TextPath : IRenderable // Get the current queue width in cells var queueWidth = - Cell.GetCellLength(_parts[0]) // First + rootLength // Root (if rooted) + ellipsisLength // Ellipsis + queue.Sum(p => Cell.GetCellLength(p)) // Middle + Cell.GetCellLength(_parts.Last()) // Last - + queue.Count + 2; // Separators + + queue.Count + separatorCount; // Separators // Will it fit? if (maxWidth >= queueWidth) { var result = new List(); - result.Add(_parts[0]); + + if (_rooted) + { + // Add the root + result.Add(_parts[0]); + } + result.Add(ellipsis); result.AddRange(queue); result.Add(_parts.Last()); + return result.ToArray(); } } @@ -101,7 +176,9 @@ public sealed class TextPath : IRenderable // Just trim the last part so it fits var last = _parts.Last(); - var take = Math.Max(0, maxWidth - ellipsisLength); - return new[] { string.Concat(ellipsis, last.Substring(last.Length - take, take)) }; + var take = Math.Min(last.Length, Math.Max(0, maxWidth - ellipsisLength)); + var start = Math.Max(0, last.Length - take); + + return new[] { string.Concat(ellipsis, last.Substring(start, take)) }; } } diff --git a/test/Spectre.Console.Tests/Spectre.Console.Tests.net48.v3.ncrunchproject b/test/Spectre.Console.Tests/Spectre.Console.Tests.net48.v3.ncrunchproject new file mode 100644 index 0000000..eacd190 --- /dev/null +++ b/test/Spectre.Console.Tests/Spectre.Console.Tests.net48.v3.ncrunchproject @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/test/Spectre.Console.Tests/Spectre.Console.Tests.net5.0.v3.ncrunchproject b/test/Spectre.Console.Tests/Spectre.Console.Tests.net5.0.v3.ncrunchproject new file mode 100644 index 0000000..eacd190 --- /dev/null +++ b/test/Spectre.Console.Tests/Spectre.Console.Tests.net5.0.v3.ncrunchproject @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/test/Spectre.Console.Tests/Unit/Widgets/TextPathTests.cs b/test/Spectre.Console.Tests/Unit/Widgets/TextPathTests.cs index 06ac67f..bddd5b1 100644 --- a/test/Spectre.Console.Tests/Unit/Widgets/TextPathTests.cs +++ b/test/Spectre.Console.Tests/Unit/Widgets/TextPathTests.cs @@ -2,32 +2,6 @@ namespace Spectre.Console.Tests.Unit; public sealed class TextPathTests { - [Fact] - public void Should_Render_Full_Path_If_Possible() - { - // Given - var console = new TestConsole().Width(40); - - // When - console.Write(new TextPath("C:/Foo/Bar/Baz.txt")); - - // Then - console.Output.ShouldBe("C:/Foo/Bar/Baz.txt"); - } - - [Fact] - public void Should_Pop_Segments_From_Left() - { - // Given - var console = new TestConsole().Width(17); - - // When - console.Write(new TextPath("C:/My documents/Bar/Baz.txt")); - - // Then - console.Output.ShouldBe("C:/…/Bar/Baz.txt"); - } - [Theory] [InlineData(8, "1234567890", "…4567890")] [InlineData(9, "1234567890", "…34567890")] @@ -40,6 +14,56 @@ public sealed class TextPathTests console.Write(new TextPath(input)); // Then - console.Output.ShouldBe(expected); + console.Output.TrimEnd().ShouldBe(expected); + } + + [Theory] + [InlineData("C:/Foo/Bar/Baz.txt", "C:/Foo/Bar/Baz.txt")] + [InlineData("/Foo/Bar/Baz.txt", "/Foo/Bar/Baz.txt")] + [InlineData("Foo/Bar/Baz.txt", "Foo/Bar/Baz.txt")] + public void Should_Render_Full_Path_If_Possible(string input, string expected) + { + // Given + var console = new TestConsole().Width(40); + + // When + console.Write(new TextPath(input)); + + // Then + console.Output.TrimEnd().ShouldBe(expected); + } + + [Theory] + [InlineData(17, "C:/My documents/Bar/Baz.txt", "C:/…/Bar/Baz.txt")] + [InlineData(15, "/My documents/Bar/Baz.txt", "/…/Bar/Baz.txt")] + [InlineData(14, "My documents/Bar/Baz.txt", "…/Bar/Baz.txt")] + public void Should_Pop_Segments_From_Left(int width, string input, string expected) + { + // Given + var console = new TestConsole().Width(width); + + // When + console.Write(new TextPath(input)); + + // Then + console.Output.TrimEnd().ShouldBe(expected); + } + + [Theory] + [InlineData("C:/My documents/Bar/Baz.txt")] + [InlineData("/My documents/Bar/Baz.txt")] + [InlineData("My documents/Bar/Baz.txt")] + [InlineData("Bar/Baz.txt")] + [InlineData("Baz.txt")] + public void Should_Insert_Line_Break_At_End_Of_Path(string input) + { + // Given + var console = new TestConsole().Width(80); + + // When + console.Write(new TextPath(input)); + + // Then + console.Output.ShouldEndWith("\n"); } }