From 66fc949e2ac29a9e611bd333a81166b31a1d1abb Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Thu, 17 Feb 2022 22:39:51 +0100 Subject: [PATCH] Initial work on TextPath widget --- examples/Console/Paths/Paths.csproj | 15 +++ examples/Console/Paths/Program.cs | 20 ++++ examples/Examples.sln | 20 +++- src/Spectre.Console/Widgets/TextPath.cs | 107 ++++++++++++++++++ .../Unit/Widgets/TextPathTests.cs | 45 ++++++++ 5 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 examples/Console/Paths/Paths.csproj create mode 100644 examples/Console/Paths/Program.cs create mode 100644 src/Spectre.Console/Widgets/TextPath.cs create mode 100644 test/Spectre.Console.Tests/Unit/Widgets/TextPathTests.cs diff --git a/examples/Console/Paths/Paths.csproj b/examples/Console/Paths/Paths.csproj new file mode 100644 index 0000000..7729021 --- /dev/null +++ b/examples/Console/Paths/Paths.csproj @@ -0,0 +1,15 @@ + + + + Exe + net6.0 + Paths + Demonstrates how to write paths. + Widgets + + + + + + + diff --git a/examples/Console/Paths/Program.cs b/examples/Console/Paths/Program.cs new file mode 100644 index 0000000..14015fe --- /dev/null +++ b/examples/Console/Paths/Program.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading; +using Spectre.Console; + +namespace Live; + +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 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")); + AnsiConsole.Write(table); + } +} diff --git a/examples/Examples.sln b/examples/Examples.sln index b0bd14d..8bf48eb 100644 --- a/examples/Examples.sln +++ b/examples/Examples.sln @@ -67,11 +67,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LiveTable", "Console\LiveTa EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Minimal", "Console\Minimal\Minimal.csproj", "{1780A30A-397A-4CC3-B2A0-A385D9081FA2}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AlternateScreen", "Console\AlternateScreen\AlternateScreen.csproj", "{8A3B636E-5828-438B-A8F4-83811D2704CD}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AlternateScreen", "Console\AlternateScreen\AlternateScreen.csproj", "{8A3B636E-5828-438B-A8F4-83811D2704CD}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Spectre.Console", "..\src\Spectre.Console\Spectre.Console.csproj", "{0C58FB17-F60A-47AB-84BF-961EC8C06AE6}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Spectre.Console", "..\src\Spectre.Console\Spectre.Console.csproj", "{0C58FB17-F60A-47AB-84BF-961EC8C06AE6}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Spectre.Console.ImageSharp", "..\src\Spectre.Console.ImageSharp\Spectre.Console.ImageSharp.csproj", "{A127CE7D-A5A7-4745-9809-EBD7CB12CEE7}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Spectre.Console.ImageSharp", "..\src\Spectre.Console.ImageSharp\Spectre.Console.ImageSharp.csproj", "{A127CE7D-A5A7-4745-9809-EBD7CB12CEE7}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Paths", "Console\Paths\Paths.csproj", "{65CB00B0-A3AE-4E8F-A990-4C8C1A232FE2}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -479,6 +481,18 @@ Global {A127CE7D-A5A7-4745-9809-EBD7CB12CEE7}.Release|x64.Build.0 = Release|Any CPU {A127CE7D-A5A7-4745-9809-EBD7CB12CEE7}.Release|x86.ActiveCfg = Release|Any CPU {A127CE7D-A5A7-4745-9809-EBD7CB12CEE7}.Release|x86.Build.0 = Release|Any CPU + {65CB00B0-A3AE-4E8F-A990-4C8C1A232FE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {65CB00B0-A3AE-4E8F-A990-4C8C1A232FE2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {65CB00B0-A3AE-4E8F-A990-4C8C1A232FE2}.Debug|x64.ActiveCfg = Debug|Any CPU + {65CB00B0-A3AE-4E8F-A990-4C8C1A232FE2}.Debug|x64.Build.0 = Debug|Any CPU + {65CB00B0-A3AE-4E8F-A990-4C8C1A232FE2}.Debug|x86.ActiveCfg = Debug|Any CPU + {65CB00B0-A3AE-4E8F-A990-4C8C1A232FE2}.Debug|x86.Build.0 = Debug|Any CPU + {65CB00B0-A3AE-4E8F-A990-4C8C1A232FE2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {65CB00B0-A3AE-4E8F-A990-4C8C1A232FE2}.Release|Any CPU.Build.0 = Release|Any CPU + {65CB00B0-A3AE-4E8F-A990-4C8C1A232FE2}.Release|x64.ActiveCfg = Release|Any CPU + {65CB00B0-A3AE-4E8F-A990-4C8C1A232FE2}.Release|x64.Build.0 = Release|Any CPU + {65CB00B0-A3AE-4E8F-A990-4C8C1A232FE2}.Release|x86.ActiveCfg = Release|Any CPU + {65CB00B0-A3AE-4E8F-A990-4C8C1A232FE2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Spectre.Console/Widgets/TextPath.cs b/src/Spectre.Console/Widgets/TextPath.cs new file mode 100644 index 0000000..b2acbca --- /dev/null +++ b/src/Spectre.Console/Widgets/TextPath.cs @@ -0,0 +1,107 @@ +namespace Spectre.Console; + +/// +/// Representation of a file system path. +/// +public sealed class TextPath : IRenderable +{ + private readonly string[] _parts; + + /// + /// Initializes a new instance of the class. + /// + /// The path to render. + public TextPath(string path) + { + // Normalize the path + path ??= string.Empty; + path = path.Replace('\\', '/'); + path = path.TrimEnd('/').Trim(); + + // Get the distinct parts + _parts = path.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + } + + /// + public Measurement Measure(RenderContext context, int maxWidth) + { + var fitted = Fit(context, maxWidth); + var length = fitted.Sum(f => f.Length) + fitted.Length - 1; + + return new Measurement( + Math.Min(length, maxWidth), + Math.Max(length, maxWidth)); + } + + /// + public IEnumerable Render(RenderContext context, int maxWidth) + { + var fitted = Fit(context, maxWidth); + + var parts = new List(); + foreach (var (_, _, last, item) in fitted.Enumerate()) + { + parts.Add(new Segment(item)); + + if (!last) + { + parts.Add(new Segment("/", new Style(Color.Grey))); + } + } + + return parts; + } + + private string[] Fit(RenderContext context, int maxWidth) + { + // No parts? + if (_parts.Length == 0) + { + return _parts; + } + + // Will it fit as is? + if (_parts.Sum(p => Cell.GetCellLength(p)) + (_parts.Length - 1) < maxWidth) + { + return _parts; + } + + var ellipsis = context.Unicode ? "…" : "..."; + var ellipsisLength = Cell.GetCellLength(ellipsis); + + if (_parts.Length >= 2) + { + // Try popping parts until it fits + var queue = new Queue(_parts.Skip(1).Take(_parts.Length - 2)); + while (queue.Count > 0) + { + // Remove the first item + queue.Dequeue(); + + // Get the current queue width in cells + var queueWidth = + Cell.GetCellLength(_parts[0]) // First + + ellipsisLength // Ellipsis + + queue.Sum(p => Cell.GetCellLength(p)) // Middle + + Cell.GetCellLength(_parts.Last()) // Last + + queue.Count + 2; // Separators + + // Will it fit? + if (maxWidth >= queueWidth) + { + var result = new List(); + result.Add(_parts[0]); + result.Add(ellipsis); + result.AddRange(queue); + result.Add(_parts.Last()); + return result.ToArray(); + } + } + } + + // 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)) }; + } +} diff --git a/test/Spectre.Console.Tests/Unit/Widgets/TextPathTests.cs b/test/Spectre.Console.Tests/Unit/Widgets/TextPathTests.cs new file mode 100644 index 0000000..06ac67f --- /dev/null +++ b/test/Spectre.Console.Tests/Unit/Widgets/TextPathTests.cs @@ -0,0 +1,45 @@ +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")] + public void Should_Use_Last_Segments_If_Less_Than_Three(int width, string input, string expected) + { + // Given + var console = new TestConsole().Width(width); + + // When + console.Write(new TextPath(input)); + + // Then + console.Output.ShouldBe(expected); + } +}