diff --git a/src/Spectre.Console.Ansi.Tests/AnsiMarkupTests.cs b/src/Spectre.Console.Ansi.Tests/AnsiMarkupTests.cs index a503b9786..a18be6063 100644 --- a/src/Spectre.Console.Ansi.Tests/AnsiMarkupTests.cs +++ b/src/Spectre.Console.Ansi.Tests/AnsiMarkupTests.cs @@ -238,8 +238,8 @@ public void Should_Escape_Markup_Blocks_As_Expected() } [Theory] - [InlineData("[yellow]Hello[/]", "Hello")] - [InlineData("[yellow]Hello [italic]World[/]![/]", "Hello World!")] + [InlineData("[yellow]Hello[/]", "\e[93mHello\e[0m")] + [InlineData("[yellow]Hello [italic]World[/]![/]", "\e[93mHello \e[0m\e[3;93mWorld\e[0m\e[93m!\e[0m")] public void Should_Output_Expected_Ansi_For_Markup(string text, string expected) { // Given @@ -265,7 +265,7 @@ public void Should_Output_Expected_Ansi_For_Link_With_Url_And_Text() // Then fixture.Output.ShouldMatch( - "]8;id=[0-9]*;https:\\/\\/patriksvensson\\.se\\\\Click to visit my blog]8;;\\\\"); + "\e]8;id=[0-9]*;https:\\/\\/patriksvensson\\.se\e\\\\Click to visit my blog\e]8;;\e\\\\"); } [Fact] @@ -279,7 +279,7 @@ public void Should_Output_Expected_Ansi_For_Link_With_Only_Url() // Then fixture.Output.ShouldMatch( - "]8;id=[0-9]*;https:\\/\\/patriksvensson\\.se\\\\https:\\/\\/patriksvensson\\.se]8;;\\\\"); + "\e]8;id=[0-9]*;https:\\/\\/patriksvensson\\.se\e\\\\https:\\/\\/patriksvensson\\.se\e]8;;\e\\\\"); } [Fact] @@ -294,7 +294,7 @@ public void Should_Output_Expected_Ansi_For_Link_With_Bracket_In_Url_Only() // Then fixture.Output.ShouldMatch( - "]8;id=[0-9]*;file:\\/\\/c:\\/temp\\/\\[x\\].txt\\\\file:\\/\\/c:\\/temp\\/\\[x\\].txt]8;;\\\\"); + "\e]8;id=[0-9]*;file:\\/\\/c:\\/temp\\/\\[x\\].txt\e\\\\file:\\/\\/c:\\/temp\\/\\[x\\].txt\e]8;;\e\\\\"); } [Fact] @@ -310,7 +310,7 @@ public void Should_Output_Expected_Ansi_For_Link_With_Bracket_In_Url() // Then fixture.Output.ShouldMatch( - "]8;id=[0-9]*;file:\\/\\/c:\\/temp\\/\\[x\\].txt\\\\file:\\/\\/c:\\/temp\\/\\[x\\].txt]8;;\\\\"); + "\e]8;id=[0-9]*;file:\\/\\/c:\\/temp\\/\\[x\\].txt\e\\\\file:\\/\\/c:\\/temp\\/\\[x\\].txt\e]8;;\e\\\\"); } [Theory] diff --git a/src/Spectre.Console.Ansi/Link.cs b/src/Spectre.Console.Ansi/Link.cs index 373de5434..0217d493b 100644 --- a/src/Spectre.Console.Ansi/Link.cs +++ b/src/Spectre.Console.Ansi/Link.cs @@ -4,7 +4,7 @@ namespace Spectre.Console; /// Represents a link. /// /// The link URL. -public sealed class Link(string url) +public sealed class Link(string url) : IEquatable { /// /// Gets the link ID. @@ -15,4 +15,35 @@ public sealed class Link(string url) /// Gets the url. /// public string Url { get; } = url; + + /// + public bool Equals(Link? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Id == other.Id && Url == other.Url; + } + + /// + public override bool Equals(object? obj) + { + return ReferenceEquals(this, obj) || obj is Link other && Equals(other); + } + + /// + public override int GetHashCode() + { + unchecked + { + return (Id.GetHashCode() * 397) ^ Url.GetHashCode(); + } + } } \ No newline at end of file diff --git a/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Ansi.cs b/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Ansi.cs index 206fc6f8a..7983ed6c2 100644 --- a/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Ansi.cs +++ b/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Ansi.cs @@ -14,11 +14,11 @@ public void Should_Write_Ansi_Codes_To_Console_If_Supported() .EmitAnsiSequences(); // When - console.WriteAnsi("Hello"); + console.WriteAnsi("\e[101mHello\e[0m"); // Then console.Output.NormalizeLineEndings() - .ShouldBe("Hello"); + .ShouldBe("\e[101mHello\e[0m"); } [Fact] @@ -31,7 +31,7 @@ public void Should_Not_Write_Ansi_Codes_To_Console_If_Not_Supported() .EmitAnsiSequences(); // When - console.WriteAnsi("Hello"); + console.WriteAnsi("\e[101mHello\e[0m"); // Then console.Output.NormalizeLineEndings() @@ -49,7 +49,7 @@ public void Should_Return_Ansi_For_Renderable() var result = console.ToAnsi(markup); // Then - result.ShouldBe("Hello World!"); + result.ShouldBe("\e[38;5;11mHello \e[0m\e[38;5;12mWorld\e[0m\e[38;5;11m!\e[0m"); } } } \ No newline at end of file diff --git a/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Cursor.cs b/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Cursor.cs index 8d63c28e1..4381e30cf 100644 --- a/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Cursor.cs +++ b/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Cursor.cs @@ -7,10 +7,10 @@ public sealed class Cursor public sealed class TheMoveMethod { [Theory] - [InlineData(CursorDirection.Up, "HelloWorld")] - [InlineData(CursorDirection.Down, "HelloWorld")] - [InlineData(CursorDirection.Right, "HelloWorld")] - [InlineData(CursorDirection.Left, "HelloWorld")] + [InlineData(CursorDirection.Up, "Hello\e[2AWorld")] + [InlineData(CursorDirection.Down, "Hello\e[2BWorld")] + [InlineData(CursorDirection.Right, "Hello\e[2CWorld")] + [InlineData(CursorDirection.Left, "Hello\e[2DWorld")] public void Should_Return_Correct_Ansi_Code(CursorDirection direction, string expected) { // Given @@ -40,7 +40,7 @@ public void Should_Return_Correct_Ansi_Code() console.Write("World"); // Then - console.Output.ShouldBe("HelloWorld"); + console.Output.ShouldBe("Hello\e[3;5HWorld"); } } } diff --git a/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Markup.cs b/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Markup.cs index 4d40666f4..a3d156c80 100644 --- a/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Markup.cs +++ b/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Markup.cs @@ -5,8 +5,8 @@ public partial class AnsiConsoleTests public sealed class Markup { [Theory] - [InlineData("[yellow]Hello[/]", "Hello")] - [InlineData("[yellow]Hello [italic]World[/]![/]", "Hello World!")] + [InlineData("[yellow]Hello[/]", "\e[93mHello\e[0m")] + [InlineData("[yellow]Hello [italic]World[/]![/]", "\e[93mHello \e[0m\e[3;93mWorld\e[0m\e[93m!\e[0m")] public void Should_Output_Expected_Ansi_For_Markup(string markup, string expected) { // Given @@ -32,7 +32,7 @@ public void Should_Output_Expected_Ansi_For_Link_With_Url_And_Text() console.Markup("[link=https://patriksvensson.se]Click to visit my blog[/]"); // Then - console.Output.ShouldMatch("]8;id=[0-9]*;https:\\/\\/patriksvensson\\.se\\\\Click to visit my blog]8;;\\\\"); + console.Output.ShouldMatch("\e]8;id=[0-9]*;https:\\/\\/patriksvensson\\.se\e\\\\Click to visit my blog\e]8;;\e\\\\"); } [Fact] @@ -46,7 +46,7 @@ public void Should_Output_Expected_Ansi_For_Link_With_Only_Url() console.Markup("[link]https://patriksvensson.se[/]"); // Then - console.Output.ShouldMatch("]8;id=[0-9]*;https:\\/\\/patriksvensson\\.se\\\\https:\\/\\/patriksvensson\\.se]8;;\\\\"); + console.Output.ShouldMatch("\e]8;id=[0-9]*;https:\\/\\/patriksvensson\\.se\e\\\\https:\\/\\/patriksvensson\\.se\e]8;;\e\\\\"); } [Fact] @@ -61,7 +61,7 @@ public void Should_Output_Expected_Ansi_For_Link_With_Bracket_In_Url_Only() console.Markup($"[link]{Path.EscapeMarkup()}[/]"); // Then - console.Output.ShouldMatch("]8;id=[0-9]*;file:\\/\\/c:\\/temp\\/\\[x\\].txt\\\\file:\\/\\/c:\\/temp\\/\\[x\\].txt]8;;\\\\"); + console.Output.ShouldMatch("\e]8;id=[0-9]*;file:\\/\\/c:\\/temp\\/\\[x\\].txt\e\\\\file:\\/\\/c:\\/temp\\/\\[x\\].txt\e]8;;\e\\\\"); } [Fact] @@ -76,11 +76,11 @@ public void Should_Output_Expected_Ansi_For_Link_With_Bracket_In_Url() console.Markup($"[link={Path.EscapeMarkup()}]{Path.EscapeMarkup()}[/]"); // Then - console.Output.ShouldMatch("]8;id=[0-9]*;file:\\/\\/c:\\/temp\\/\\[x\\].txt\\\\file:\\/\\/c:\\/temp\\/\\[x\\].txt]8;;\\\\"); + console.Output.ShouldMatch("\e]8;id=[0-9]*;file:\\/\\/c:\\/temp\\/\\[x\\].txt\e\\\\file:\\/\\/c:\\/temp\\/\\[x\\].txt\e]8;;\e\\\\"); } [Theory] - [InlineData("[yellow]Hello [[ World[/]", "Hello [ World")] + [InlineData("[yellow]Hello [[ World[/]", "\e[93mHello [ World\e[0m")] public void Should_Be_Able_To_Escape_Tags(string markup, string expected) { // Given @@ -174,5 +174,50 @@ public void Should_Not_Fail_As_In_GH1024(string markup, string expected) // Then console.Output.ShouldBe(expected); } + + [Fact] + [GitHubIssue("https://github.com/spectreconsole/spectre.console/issues/2083")] + [GitHubIssue("https://github.com/spectreconsole/spectre.console/issues/2078")] + public void Should_Preserve_Links_When_Multiple_Segments_Are_Merged() + { + // Given + var console = new TestConsole() + .Width(8) + .SupportsAnsi(true) + .EmitAnsiSequences(); + + // When + console.Write( + Align.Center( + new Spectre.Console.Markup( + "[link=https://example.com/readme.md]Docs[/]"))); + + // Then + console.Output.ShouldMatch( + " \e]8;id=[0-9]*;https:\\/\\/example\\.com\\/readme.md\e\\\\Docs\e]8;;\e\\\\ "); + } + + [Fact] + [GitHubIssue("https://github.com/spectreconsole/spectre.console/issues/2083")] + [GitHubIssue("https://github.com/spectreconsole/spectre.console/issues/2078")] + public void Should_Preserve_Links_Over_Line_Breaks_When_Multiple_Segments_Are_Merged() + { + // Given + var console = new TestConsole() + .Width(8) + .SupportsAnsi(true) + .EmitAnsiSequences(); + + // When + console.Write( + Align.Center( + new Spectre.Console.Markup( + "[link=https://example.com/readme.md]Foo and Bar[/]"))); + + // Then + console.Output.ShouldMatch( + "\e]8;id=[0-9]*;https:\\/\\/example\\.com\\/readme.md\e\\\\Foo and \e]8;;\e\\\\\n" + + " \e]8;id=[0-9]*;https:\\/\\/example\\.com\\/readme.md\e\\\\Bar\e]8;;\e\\\\ "); + } } } \ No newline at end of file diff --git a/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.MarkupInterpolated.cs b/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.MarkupInterpolated.cs index df6854b21..ed4b0c917 100644 --- a/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.MarkupInterpolated.cs +++ b/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.MarkupInterpolated.cs @@ -17,7 +17,7 @@ public void Should_Print_Simple_Interpolated_Strings() console.MarkupInterpolated($"[Green]{Path}[/]"); // Then - console.Output.ShouldBe($"{Path}"); + console.Output.ShouldBe($"\e[32m{Path}\e[0m"); } [Fact] @@ -34,7 +34,7 @@ public void Should_Not_Throw_Error_On_Links_Brackets() // Then var pathAsRegEx = Regex.Replace(Path, "([/\\[\\]\\\\])", "\\$1", RegexOptions.Compiled | RegexOptions.IgnoreCase); - console.Output.ShouldMatch($"\\]8;id=[0-9]+;{pathAsRegEx}\\\\{pathAsRegEx}\\]8;;\\\\"); + console.Output.ShouldMatch($"\e\\]8;id=[0-9]+;{pathAsRegEx}\e\\\\{pathAsRegEx}\e\\]8;;\e\\\\"); } } } \ No newline at end of file diff --git a/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Style.cs b/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Style.cs index 127a721f0..ab6f719d6 100644 --- a/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Style.cs +++ b/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Style.cs @@ -3,15 +3,15 @@ namespace Spectre.Console.Tests.Unit; public partial class AnsiConsoleTests { [Theory] - [InlineData(Decoration.Bold, "\u001b[1mHello World")] - [InlineData(Decoration.Dim, "\u001b[2mHello World")] - [InlineData(Decoration.Italic, "\u001b[3mHello World")] - [InlineData(Decoration.Underline, "\u001b[4mHello World")] - [InlineData(Decoration.Invert, "\u001b[7mHello World")] - [InlineData(Decoration.Conceal, "\u001b[8mHello World")] - [InlineData(Decoration.SlowBlink, "\u001b[5mHello World")] - [InlineData(Decoration.RapidBlink, "\u001b[6mHello World")] - [InlineData(Decoration.Strikethrough, "\u001b[9mHello World")] + [InlineData(Decoration.Bold, "\u001b[1mHello World\e[0m")] + [InlineData(Decoration.Dim, "\u001b[2mHello World\e[0m")] + [InlineData(Decoration.Italic, "\u001b[3mHello World\e[0m")] + [InlineData(Decoration.Underline, "\u001b[4mHello World\e[0m")] + [InlineData(Decoration.Invert, "\u001b[7mHello World\e[0m")] + [InlineData(Decoration.Conceal, "\u001b[8mHello World\e[0m")] + [InlineData(Decoration.SlowBlink, "\u001b[5mHello World\e[0m")] + [InlineData(Decoration.RapidBlink, "\u001b[6mHello World\e[0m")] + [InlineData(Decoration.Strikethrough, "\u001b[9mHello World\e[0m")] public void Should_Write_Decorated_Text_Correctly(Decoration decoration, string expected) { // Given @@ -26,8 +26,8 @@ public void Should_Write_Decorated_Text_Correctly(Decoration decoration, string } [Theory] - [InlineData(Decoration.Bold | Decoration.Underline, "\u001b[1;4mHello World")] - [InlineData(Decoration.Bold | Decoration.Underline | Decoration.Conceal, "\u001b[1;4;8mHello World")] + [InlineData(Decoration.Bold | Decoration.Underline, "\u001b[1;4mHello World\e[0m")] + [InlineData(Decoration.Bold | Decoration.Underline | Decoration.Conceal, "\u001b[1;4;8mHello World\e[0m")] public void Should_Write_Text_With_Multiple_Decorations_Correctly(Decoration decoration, string expected) { // Given diff --git a/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.cs b/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.cs index a05b59bc2..b985bc3d3 100644 --- a/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.cs +++ b/src/Spectre.Console.Tests/Unit/AnsiConsoleTests.cs @@ -5,8 +5,8 @@ public partial class AnsiConsoleTests public sealed class Clear { [Theory] - [InlineData(false, "HelloWorld")] - [InlineData(true, "HelloWorld")] + [InlineData(false, "Hello\e[2J\e[3JWorld")] + [InlineData(true, "Hello\e[2J\e[3J\e[1;1HWorld")] public void Should_Clear_Screen(bool home, string expected) { // Given @@ -123,7 +123,7 @@ public void Should_Reset_Colors_Correctly_After_Line_Break() // Then console.Output.NormalizeLineEndings() - .ShouldBe("Hello\nWorld\n"); + .ShouldBe("\e[101mHello\e[0m\n\e[102mWorld\e[0m\n"); } [Fact] @@ -139,7 +139,7 @@ public void Should_Reset_Colors_Correctly_After_Line_Break_In_Text() // Then console.Output.NormalizeLineEndings() - .ShouldBe("Hello\nWorld\n"); + .ShouldBe("\e[101mHello\e[0m\n\e[101mWorld\e[0m\n"); } } diff --git a/src/Spectre.Console.Tests/Unit/Live/Progress/ProgressTests.cs b/src/Spectre.Console.Tests/Unit/Live/Progress/ProgressTests.cs index 848f77e12..89fcaf916 100644 --- a/src/Spectre.Console.Tests/Unit/Live/Progress/ProgressTests.cs +++ b/src/Spectre.Console.Tests/Unit/Live/Progress/ProgressTests.cs @@ -24,11 +24,11 @@ public void Should_Render_Task_Correctly() console.Output .NormalizeLineEndings() .ShouldBe( - "[?25l" + // Hide cursor + "\e[?25l" + // Hide cursor " \n" + // Top padding - "━━━━━━━━━━\n" + // Task + "\e[38;5;8m━━━━━━━━━━\e[0m\n" + // Task " " + // Bottom padding - "[?25h"); // Clear + show cursor + "\e[2K\e[1A\e[2K\e[1A\e[2K\e[?25h"); // Clear + show cursor } [Fact] @@ -52,11 +52,11 @@ public void Should_Not_Auto_Clear_If_Specified() console.Output .NormalizeLineEndings() .ShouldBe( - "[?25l" + // Hide cursor + "\e[?25l" + // Hide cursor " \n" + // Top padding - "━━━━━━━━━━\n" + // Task + "\e[38;5;8m━━━━━━━━━━\e[0m\n" + // Task " \n" + // Bottom padding - "[?25h"); // show cursor + "\e[?25h"); // show cursor } [Fact] @@ -249,12 +249,12 @@ public void Should_Hide_Completed_Tasks() console.Output .NormalizeLineEndings() .ShouldBe( - "[?25l" + // Hide cursor + "\e[?25l" + // Hide cursor " \n" + // top padding - "━━━━━━━━━━\n" + // taskInProgress1 - "━━━━━━━━━━\n" + // taskInProgress2 + "\e[38;5;8m━━━━━━━━━━\e[0m\n" + // taskInProgress1 + "\e[38;5;11m━━\e[0m\e[38;5;8m━━━━━━━━\e[0m\n" + // taskInProgress2 " \n" + // bottom padding - "[?25h"); // show cursor + "\e[?25h"); // show cursor } [Fact] @@ -316,8 +316,8 @@ public void Should_Render_Tasks_Added_Before_And_After_Correctly() console.Output.SplitLines().Select(x => x.Trim()).ToArray() .ShouldBeEquivalentTo(new[] { - "[?25l", "foo1", "afterFoo1", "foo2", "beforeFoo3", "foo3", - "[?25h", + "\e[?25l", "foo1", "afterFoo1", "foo2", "beforeFoo3", "foo3", + "\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[?25h", }); } @@ -350,7 +350,7 @@ public void Should_Render_Tasks_At_Specified_Indexes_Correctly() console.Output.SplitLines().Select(x => x.Trim()).ToArray() .ShouldBeEquivalentTo(new[] { - "[?25l", "foo1", "afterFoo1", "foo2", "beforeFoo3", "foo3", + "\e[?25l", "foo1", "afterFoo1", "foo2", "beforeFoo3", "foo3", "[?25h", }); } diff --git a/src/Spectre.Console/Rendering/Segment.cs b/src/Spectre.Console/Rendering/Segment.cs index 6f30e9970..5f82b56b8 100644 --- a/src/Spectre.Console/Rendering/Segment.cs +++ b/src/Spectre.Console/Rendering/Segment.cs @@ -469,9 +469,9 @@ internal static IEnumerable Merge(IEnumerable segments) continue; } - // Same style? + // Same style and link? if (segmentBuilder.StyleEquals(segment.Style) && !segmentBuilder.IsLineBreak() && - !segmentBuilder.IsControlCode()) + !segmentBuilder.IsControlCode() && segmentBuilder.HasSameLink(segment.Link)) { segmentBuilder.Append(segment.Text); continue; @@ -625,5 +625,15 @@ public void Reset(Segment segment) _textBuilder.Append(segment.Text); _originalSegment = segment; } + + public bool HasSameLink(Link? link) + { + if (link == null && _originalSegment.Link == null) + { + return true; + } + + return _originalSegment.Link?.Equals(link) ?? false; + } } } \ No newline at end of file