diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 554d4fc..ce2356b 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -70,6 +70,7 @@ jobs:
dotnet example panels
dotnet example colors
dotnet example emojis
+ dotnet example exceptions
- name: Build
shell: bash
diff --git a/docs/input/assets/images/compact_exception.png b/docs/input/assets/images/compact_exception.png
new file mode 100644
index 0000000..a0fd051
Binary files /dev/null and b/docs/input/assets/images/compact_exception.png differ
diff --git a/docs/input/assets/images/exception.png b/docs/input/assets/images/exception.png
new file mode 100644
index 0000000..8f5dcab
Binary files /dev/null and b/docs/input/assets/images/exception.png differ
diff --git a/docs/input/exceptions.md b/docs/input/exceptions.md
new file mode 100644
index 0000000..3ac7231
--- /dev/null
+++ b/docs/input/exceptions.md
@@ -0,0 +1,26 @@
+Title: Exceptions
+Order: 3
+---
+
+Exceptions isn't always readable when viewed in the terminal.
+You can make exception a bit more readable by using the `WriteException` method.
+
+```csharp
+AnsiConsole.WriteException(ex);
+```
+
+
+
+
+
+You can also shorten specific parts of the exception to make it even
+more readable, and make paths clickable hyperlinks. Whether or not
+the hyperlinks are clickable is up to the terminal.
+
+```csharp
+AnsiConsole.WriteException(ex,
+ ExceptionFormat.ShortenPaths | ExceptionFormat.ShortenTypes |
+ ExceptionFormat.ShortenMethods | ExceptionFormat.ShowLinks);
+```
+
+
diff --git a/docs/input/markup.md b/docs/input/markup.md
index acc8c1f..87f3d22 100644
--- a/docs/input/markup.md
+++ b/docs/input/markup.md
@@ -65,6 +65,15 @@ For a list of emoji, see the [Emojis](xref:styles) appendix section.
# Colors
+In the examples above, all colors was referenced by their name,
+but you can also use the hex or rgb representation for colors in markdown.
+
+```csharp
+AnsiConsole.Markup("[red]Foo[/] ");
+AnsiConsole.Markup("[#ff0000]Bar[/] ");
+AnsiConsole.Markup("[rgb(255,0,0)]Baz[/] ");
+```
+
For a list of colors, see the [Colors](xref:colors) appendix section.
# Styles
diff --git a/examples/Exceptions/Exceptions.csproj b/examples/Exceptions/Exceptions.csproj
new file mode 100644
index 0000000..19d600a
--- /dev/null
+++ b/examples/Exceptions/Exceptions.csproj
@@ -0,0 +1,15 @@
+
+
+
+ Exe
+ netcoreapp3.1
+ false
+ Exceptions
+ Demonstrates how to render formatted exceptions.
+
+
+
+
+
+
+
diff --git a/examples/Exceptions/Program.cs b/examples/Exceptions/Program.cs
new file mode 100644
index 0000000..7e10289
--- /dev/null
+++ b/examples/Exceptions/Program.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Security.Authentication;
+using Spectre.Console;
+
+namespace Exceptions
+{
+ public static class Program
+ {
+ public static void Main(string[] args)
+ {
+ try
+ {
+ DoMagic(42, null);
+ }
+ catch (Exception ex)
+ {
+ AnsiConsole.WriteLine();
+ AnsiConsole.WriteException(ex);
+
+ AnsiConsole.WriteLine();
+ AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything | ExceptionFormats.ShowLinks);
+ }
+ }
+
+ private static void DoMagic(int foo, string[,] bar)
+ {
+ try
+ {
+ CheckCredentials(foo, bar);
+ }
+ catch(Exception ex)
+ {
+ throw new InvalidOperationException("Whaaat?", ex);
+ }
+ }
+
+ private static void CheckCredentials(int qux, string[,] corgi)
+ {
+ throw new InvalidCredentialException("The credentials are invalid.");
+ }
+ }
+}
diff --git a/src/Spectre.Console.Tests/.editorconfig b/src/Spectre.Console.Tests/.editorconfig
index 90a63b8..5b36435 100644
--- a/src/Spectre.Console.Tests/.editorconfig
+++ b/src/Spectre.Console.Tests/.editorconfig
@@ -24,3 +24,6 @@ dotnet_diagnostic.CA2000.severity = none
# SA1118: Parameter should not span multiple lines
dotnet_diagnostic.SA1118.severity = none
+
+# CA1031: Do not catch general exception types
+dotnet_diagnostic.CA1031.severity = none
diff --git a/src/Spectre.Console.Tests/Data/Exceptions.cs b/src/Spectre.Console.Tests/Data/Exceptions.cs
new file mode 100644
index 0000000..e79c84b
--- /dev/null
+++ b/src/Spectre.Console.Tests/Data/Exceptions.cs
@@ -0,0 +1,23 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Spectre.Console.Tests.Data
+{
+ public static class TestExceptions
+ {
+ [SuppressMessage("Usage", "CA1801:Review unused parameters", Justification = "")]
+ public static bool MethodThatThrows(int? number) => throw new InvalidOperationException("Throwing!");
+
+ public static void ThrowWithInnerException()
+ {
+ try
+ {
+ MethodThatThrows(null);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException("Something threw!", ex);
+ }
+ }
+ }
+}
diff --git a/src/Spectre.Console.Tests/Extensions/StringExtensions.cs b/src/Spectre.Console.Tests/Extensions/StringExtensions.cs
index 7319815..66446c8 100644
--- a/src/Spectre.Console.Tests/Extensions/StringExtensions.cs
+++ b/src/Spectre.Console.Tests/Extensions/StringExtensions.cs
@@ -1,13 +1,34 @@
using System;
+using System.Text.RegularExpressions;
namespace Spectre.Console.Tests
{
public static class StringExtensions
{
+ private static readonly Regex _lineNumberRegex = new Regex(":\\d+", RegexOptions.Singleline);
+ private static readonly Regex _filenameRegex = new Regex("\\sin\\s.*cs:nn", RegexOptions.Multiline);
+
public static string NormalizeLineEndings(this string text)
{
return text?.Replace("\r\n", "\n", StringComparison.OrdinalIgnoreCase)
?.Replace("\r", string.Empty, StringComparison.OrdinalIgnoreCase);
}
+
+ public static string NormalizeStackTrace(this string text)
+ {
+ text = _lineNumberRegex.Replace(text, match =>
+ {
+ return ":nn";
+ });
+
+ return _filenameRegex.Replace(text, match =>
+ {
+ var value = match.Value;
+ var index = value.LastIndexOfAny(new[] { '\\', '/' });
+ var filename = value.Substring(index + 1, value.Length - index - 1);
+
+ return $" in /xyz/{filename}";
+ });
+ }
}
}
diff --git a/src/Spectre.Console.Tests/Tools/MarkupConsoleFixture.cs b/src/Spectre.Console.Tests/Tools/MarkupConsoleFixture.cs
new file mode 100644
index 0000000..c9b9be5
--- /dev/null
+++ b/src/Spectre.Console.Tests/Tools/MarkupConsoleFixture.cs
@@ -0,0 +1,44 @@
+using System;
+using System.IO;
+using System.Text;
+using Spectre.Console.Rendering;
+
+namespace Spectre.Console.Tests.Tools
+{
+ public sealed class MarkupConsoleFixture : IDisposable, IAnsiConsole
+ {
+ private readonly StringWriter _writer;
+ private readonly IAnsiConsole _console;
+
+ public string Output => _writer.ToString().TrimEnd('\n');
+
+ public Capabilities Capabilities => _console.Capabilities;
+ public Encoding Encoding => _console.Encoding;
+ public int Width { get; }
+ public int Height => _console.Height;
+
+ public MarkupConsoleFixture(ColorSystem system, AnsiSupport ansi = AnsiSupport.Yes, int width = 80)
+ {
+ _writer = new StringWriter();
+ _console = AnsiConsole.Create(new AnsiConsoleSettings
+ {
+ Ansi = ansi,
+ ColorSystem = (ColorSystemSupport)system,
+ Out = _writer,
+ LinkIdentityGenerator = new TestLinkIdentityGenerator(),
+ });
+
+ Width = width;
+ }
+
+ public void Dispose()
+ {
+ _writer?.Dispose();
+ }
+
+ public void Write(Segment segment)
+ {
+ _console.Write(segment);
+ }
+ }
+}
diff --git a/src/Spectre.Console.Tests/Tools/PlainConsole.cs b/src/Spectre.Console.Tests/Tools/PlainConsole.cs
index e793b28..b0928ae 100644
--- a/src/Spectre.Console.Tests/Tools/PlainConsole.cs
+++ b/src/Spectre.Console.Tests/Tools/PlainConsole.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
+using System.Linq;
using System.Text;
using Spectre.Console.Rendering;
@@ -50,5 +51,16 @@ namespace Spectre.Console.Tests
Writer.Write(segment.Text);
}
+
+ public string[] WriteExceptionAndGetLines(Exception ex, ExceptionFormats formats = ExceptionFormats.None)
+ {
+ this.WriteException(ex, formats);
+
+ return Output.NormalizeStackTrace()
+ .NormalizeLineEndings()
+ .Split(new char[] { '\n' })
+ .Select(line => line.TrimEnd())
+ .ToArray();
+ }
}
}
diff --git a/src/Spectre.Console.Tests/Unit/ExceptionTests.cs b/src/Spectre.Console.Tests/Unit/ExceptionTests.cs
new file mode 100644
index 0000000..43d9fac
--- /dev/null
+++ b/src/Spectre.Console.Tests/Unit/ExceptionTests.cs
@@ -0,0 +1,99 @@
+using System;
+using Shouldly;
+using Spectre.Console.Tests.Data;
+using Xunit;
+
+namespace Spectre.Console.Tests.Unit
+{
+ public sealed class ExceptionTests
+ {
+ [Fact]
+ public void Should_Write_Exception()
+ {
+ // Given
+ var console = new PlainConsole(width: 1024);
+ var dex = GetException(() => TestExceptions.MethodThatThrows(null));
+
+ // When
+ var result = console.WriteExceptionAndGetLines(dex);
+
+ // Then
+ result.Length.ShouldBe(4);
+ result[0].ShouldBe("System.InvalidOperationException: Throwing!");
+ result[1].ShouldBe(" at Spectre.Console.Tests.Data.TestExceptions.MethodThatThrows(Nullable`1 number) in /xyz/Exceptions.cs:nn");
+ result[2].ShouldBe(" at Spectre.Console.Tests.Unit.ExceptionTests.<>c.b__0_0() in /xyz/ExceptionTests.cs:nn");
+ result[3].ShouldBe(" at Spectre.Console.Tests.Unit.ExceptionTests.GetException(Action action) in /xyz/ExceptionTests.cs:nn");
+ }
+
+ [Fact]
+ public void Should_Write_Exception_With_Shortened_Types()
+ {
+ // Given
+ var console = new PlainConsole(width: 1024);
+ var dex = GetException(() => TestExceptions.MethodThatThrows(null));
+
+ // When
+ var result = console.WriteExceptionAndGetLines(dex, ExceptionFormats.ShortenTypes);
+
+ // Then
+ result.Length.ShouldBe(4);
+ result[0].ShouldBe("InvalidOperationException: Throwing!");
+ result[1].ShouldBe(" at Spectre.Console.Tests.Data.TestExceptions.MethodThatThrows(Nullable`1 number) in /xyz/Exceptions.cs:nn");
+ result[2].ShouldBe(" at Spectre.Console.Tests.Unit.ExceptionTests.<>c.b__1_0() in /xyz/ExceptionTests.cs:nn");
+ result[3].ShouldBe(" at Spectre.Console.Tests.Unit.ExceptionTests.GetException(Action action) in /xyz/ExceptionTests.cs:nn");
+ }
+
+ [Fact]
+ public void Should_Write_Exception_With_Shortened_Methods()
+ {
+ // Given
+ var console = new PlainConsole(width: 1024);
+ var dex = GetException(() => TestExceptions.MethodThatThrows(null));
+
+ // When
+ var result = console.WriteExceptionAndGetLines(dex, ExceptionFormats.ShortenMethods);
+
+ // Then
+ result.Length.ShouldBe(4);
+ result[0].ShouldBe("System.InvalidOperationException: Throwing!");
+ result[1].ShouldBe(" at MethodThatThrows(Nullable`1 number) in /xyz/Exceptions.cs:nn");
+ result[2].ShouldBe(" at b__2_0() in /xyz/ExceptionTests.cs:nn");
+ result[3].ShouldBe(" at GetException(Action action) in /xyz/ExceptionTests.cs:nn");
+ }
+
+ [Fact]
+ public void Should_Write_Exception_With_Inner_Exception()
+ {
+ // Given
+ var console = new PlainConsole(width: 1024);
+ var dex = GetException(() => TestExceptions.ThrowWithInnerException());
+
+ // When
+ var result = console.WriteExceptionAndGetLines(dex);
+
+ // Then
+ result.Length.ShouldBe(7);
+ result[0].ShouldBe("System.InvalidOperationException: Something threw!");
+ result[1].ShouldBe(" System.InvalidOperationException: Throwing!");
+ result[2].ShouldBe(" at Spectre.Console.Tests.Data.TestExceptions.MethodThatThrows(Nullable`1 number) in /xyz/Exceptions.cs:nn");
+ result[3].ShouldBe(" at Spectre.Console.Tests.Data.TestExceptions.ThrowWithInnerException() in /xyz/Exceptions.cs:nn");
+ result[4].ShouldBe(" at Spectre.Console.Tests.Data.TestExceptions.ThrowWithInnerException() in /xyz/Exceptions.cs:nn");
+ result[5].ShouldBe(" at Spectre.Console.Tests.Unit.ExceptionTests.<>c.b__3_0() in /xyz/ExceptionTests.cs:nn");
+ result[6].ShouldBe(" at Spectre.Console.Tests.Unit.ExceptionTests.GetException(Action action) in /xyz/ExceptionTests.cs:nn");
+ }
+
+ public static Exception GetException(Action action)
+ {
+ try
+ {
+ action?.Invoke();
+ }
+ catch (Exception e)
+ {
+ return e;
+ }
+
+ throw new InvalidOperationException("Exception harness failed");
+ }
+ }
+}
diff --git a/src/Spectre.Console.Tests/Unit/PanelTests.cs b/src/Spectre.Console.Tests/Unit/PanelTests.cs
index 909d7ae..6a36c43 100644
--- a/src/Spectre.Console.Tests/Unit/PanelTests.cs
+++ b/src/Spectre.Console.Tests/Unit/PanelTests.cs
@@ -1,4 +1,6 @@
+using System.Collections.Generic;
using Shouldly;
+using Spectre.Console.Rendering;
using Xunit;
namespace Spectre.Console.Tests.Unit
@@ -298,5 +300,33 @@ namespace Spectre.Console.Tests.Unit
console.Lines[3].ShouldBe("│ └─────────────┘ │");
console.Lines[4].ShouldBe("└─────────────────┘");
}
+
+ [Fact]
+ public void Should_Wrap_Content_Correctly()
+ {
+ // Given
+ var console = new PlainConsole(width: 84);
+ var rows = new List();
+ var grid = new Grid();
+ grid.AddColumn(new GridColumn().PadLeft(2).PadRight(0));
+ grid.AddColumn(new GridColumn().PadLeft(1).PadRight(0));
+ grid.AddRow("at", "[grey]System.Runtime.CompilerServices.TaskAwaiter.[/][yellow]HandleNonSuccessAndDebuggerNotification[/]([blue]Task[/] task)");
+ rows.Add(grid);
+
+ var panel = new Panel(grid)
+ .Expand().RoundedBorder()
+ .SetBorderStyle(Style.WithForeground(Color.Grey))
+ .SetHeader("Short paths ", Style.WithForeground(Color.Grey));
+
+ // When
+ console.Render(panel);
+
+ // Then
+ console.Lines.Count.ShouldBe(4);
+ console.Lines[0].ShouldBe("╭─Short paths ─────────────────────────────────────────────────────────────────────╮");
+ console.Lines[1].ShouldBe("│ at System.Runtime.CompilerServices.TaskAwaiter. │");
+ console.Lines[2].ShouldBe("│ HandleNonSuccessAndDebuggerNotification(Task task) │");
+ console.Lines[3].ShouldBe("╰──────────────────────────────────────────────────────────────────────────────────╯");
+ }
}
}
diff --git a/src/Spectre.Console.sln b/src/Spectre.Console.sln
index 25fe573..9f465ac 100644
--- a/src/Spectre.Console.sln
+++ b/src/Spectre.Console.sln
@@ -35,6 +35,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Links", "..\examples\Links\
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Emojis", "..\examples\Emojis\Emojis.csproj", "{1EABB956-957F-4C1A-8AC0-FD19C8F3C2F2}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Exceptions", "..\examples\Exceptions\Exceptions.csproj", "{90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GitHub", "GitHub", "{C3E2CB5C-1517-4C75-B59A-93D4E22BEC8D}"
+ ProjectSection(SolutionItems) = preProject
+ ..\.github\workflows\ci.yaml = ..\.github\workflows\ci.yaml
+ ..\.github\workflows\docs.yaml = ..\.github\workflows\docs.yaml
+ ..\.github\workflows\publish.yaml = ..\.github\workflows\publish.yaml
+ EndProjectSection
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -177,6 +186,18 @@ Global
{1EABB956-957F-4C1A-8AC0-FD19C8F3C2F2}.Release|x64.Build.0 = Release|Any CPU
{1EABB956-957F-4C1A-8AC0-FD19C8F3C2F2}.Release|x86.ActiveCfg = Release|Any CPU
{1EABB956-957F-4C1A-8AC0-FD19C8F3C2F2}.Release|x86.Build.0 = Release|Any CPU
+ {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Debug|x64.Build.0 = Debug|Any CPU
+ {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Debug|x86.Build.0 = Debug|Any CPU
+ {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Release|Any CPU.Build.0 = Release|Any CPU
+ {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Release|x64.ActiveCfg = Release|Any CPU
+ {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Release|x64.Build.0 = Release|Any CPU
+ {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Release|x86.ActiveCfg = Release|Any CPU
+ {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -191,6 +212,8 @@ Global
{094245E6-4C94-485D-B5AC-3153E878B112} = {F0575243-121F-4DEE-9F6B-246E26DC0844}
{6AF8C93B-AA41-4F44-8B1B-B8D166576174} = {F0575243-121F-4DEE-9F6B-246E26DC0844}
{1EABB956-957F-4C1A-8AC0-FD19C8F3C2F2} = {F0575243-121F-4DEE-9F6B-246E26DC0844}
+ {90C081A7-7C1D-4A4A-82B6-8FF473C3EA32} = {F0575243-121F-4DEE-9F6B-246E26DC0844}
+ {C3E2CB5C-1517-4C75-B59A-93D4E22BEC8D} = {20595AD4-8D75-4AF8-B6BC-9C38C160423F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {5729B071-67A0-48FB-8B1B-275E6822086C}
diff --git a/src/Spectre.Console/AnsiConsole.Exceptions.cs b/src/Spectre.Console/AnsiConsole.Exceptions.cs
new file mode 100644
index 0000000..a6ec8be
--- /dev/null
+++ b/src/Spectre.Console/AnsiConsole.Exceptions.cs
@@ -0,0 +1,20 @@
+using System;
+
+namespace Spectre.Console
+{
+ ///
+ /// A console capable of writing ANSI escape sequences.
+ ///
+ public static partial class AnsiConsole
+ {
+ ///
+ /// Writes an exception to the console.
+ ///
+ /// The exception to write to the console.
+ /// The exception format options.
+ public static void WriteException(Exception exception, ExceptionFormats format = ExceptionFormats.None)
+ {
+ Console.WriteException(exception, format);
+ }
+ }
+}
diff --git a/src/Spectre.Console/AnsiConsole.State.cs b/src/Spectre.Console/AnsiConsole.State.cs
index 31e0503..f3bb03b 100644
--- a/src/Spectre.Console/AnsiConsole.State.cs
+++ b/src/Spectre.Console/AnsiConsole.State.cs
@@ -1,7 +1,3 @@
-using System;
-using System.IO;
-using Spectre.Console.Internal;
-
namespace Spectre.Console
{
///
@@ -9,9 +5,6 @@ namespace Spectre.Console
///
public static partial class AnsiConsole
{
- private static ConsoleColor _defaultForeground;
- private static ConsoleColor _defaultBackground;
-
internal static Style CurrentStyle { get; private set; } = Style.Plain;
internal static bool Created { get; private set; }
@@ -42,20 +35,6 @@ namespace Spectre.Console
set => CurrentStyle = CurrentStyle.WithDecoration(value);
}
- internal static void Initialize(TextWriter? @out)
- {
- if (@out?.IsStandardOut() ?? false)
- {
- Foreground = _defaultForeground = System.Console.ForegroundColor;
- Background = _defaultBackground = System.Console.BackgroundColor;
- }
- else
- {
- Foreground = _defaultForeground = Color.Silver;
- Background = _defaultBackground = Color.Black;
- }
- }
-
///
/// Resets colors and text decorations.
///
@@ -78,8 +57,7 @@ namespace Spectre.Console
///
public static void ResetColors()
{
- Foreground = _defaultForeground;
- Background = _defaultBackground;
+ CurrentStyle = Style.Plain;
}
}
}
diff --git a/src/Spectre.Console/AnsiConsole.cs b/src/Spectre.Console/AnsiConsole.cs
index 40ae22d..f1d09e1 100644
--- a/src/Spectre.Console/AnsiConsole.cs
+++ b/src/Spectre.Console/AnsiConsole.cs
@@ -16,7 +16,6 @@ namespace Spectre.Console
ColorSystem = ColorSystemSupport.Detect,
Out = System.Console.Out,
});
- Initialize(System.Console.Out);
Created = true;
return console;
});
diff --git a/src/Spectre.Console/Color.cs b/src/Spectre.Console/Color.cs
index b58da6c..f79d452 100644
--- a/src/Spectre.Console/Color.cs
+++ b/src/Spectre.Console/Color.cs
@@ -245,9 +245,32 @@ namespace Spectre.Console
};
}
+ ///
+ /// Converts the color to a markup string.
+ ///
+ /// A representing the color as markup.
+ public string ToMarkupString()
+ {
+ if (Number != null)
+ {
+ var name = ColorTable.GetName(Number.Value);
+ if (!string.IsNullOrWhiteSpace(name))
+ {
+ return name;
+ }
+ }
+
+ return string.Format(CultureInfo.InvariantCulture, "#{0:X2}{1:X2}{2:X2}", R, G, B);
+ }
+
///
public override string ToString()
{
+ if (IsDefault)
+ {
+ return "default";
+ }
+
if (Number != null)
{
var name = ColorTable.GetName(Number.Value);
diff --git a/src/Spectre.Console/Emoji.cs b/src/Spectre.Console/Emoji.cs
index 0d9e604..f9795c1 100644
--- a/src/Spectre.Console/Emoji.cs
+++ b/src/Spectre.Console/Emoji.cs
@@ -16,7 +16,17 @@ namespace Spectre.Console
/// A string with emoji codes replaced with actual emoji.
public static string Replace(string value)
{
- static string ReplaceEmoji(Match match) => _emojis[match.Groups[2].Value];
+ static string ReplaceEmoji(Match match)
+ {
+ var key = match.Groups[2].Value;
+ if (_emojis.TryGetValue(key, out var emoji))
+ {
+ return emoji;
+ }
+
+ return match.Value;
+ }
+
return _emojiCode.Replace(value, ReplaceEmoji);
}
}
diff --git a/src/Spectre.Console/ExceptionFormat.cs b/src/Spectre.Console/ExceptionFormat.cs
new file mode 100644
index 0000000..54e361a
--- /dev/null
+++ b/src/Spectre.Console/ExceptionFormat.cs
@@ -0,0 +1,41 @@
+using System;
+
+namespace Spectre.Console
+{
+ ///
+ /// Represents how an exception is formatted.
+ ///
+ [Flags]
+ public enum ExceptionFormats
+ {
+ ///
+ /// The default formatting.
+ ///
+ None = 0,
+
+ ///
+ /// Whether or not paths should be shortened.
+ ///
+ ShortenPaths = 1,
+
+ ///
+ /// Whether or not types should be shortened.
+ ///
+ ShortenTypes = 2,
+
+ ///
+ /// Whether or not methods should be shortened.
+ ///
+ ShortenMethods = 4,
+
+ ///
+ /// Whether or not to show paths as links in the terminal.
+ ///
+ ShowLinks = 8,
+
+ ///
+ /// Shortens everything that can be shortened.
+ ///
+ ShortenEverything = ShortenMethods | ShortenTypes | ShortenPaths,
+ }
+}
diff --git a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Exceptions.cs b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Exceptions.cs
new file mode 100644
index 0000000..a50005e
--- /dev/null
+++ b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Exceptions.cs
@@ -0,0 +1,21 @@
+using System;
+
+namespace Spectre.Console
+{
+ ///
+ /// Contains extension methods for .
+ ///
+ public static partial class AnsiConsoleExtensions
+ {
+ ///
+ /// Writes an exception to the console.
+ ///
+ /// The console.
+ /// The exception to write to the console.
+ /// The exception format options.
+ public static void WriteException(this IAnsiConsole console, Exception exception, ExceptionFormats format = ExceptionFormats.None)
+ {
+ Render(console, exception.GetRenderable(format));
+ }
+ }
+}
diff --git a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Rendering.cs b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Rendering.cs
index dec7b3c..72d92b4 100644
--- a/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Rendering.cs
+++ b/src/Spectre.Console/Extensions/AnsiConsoleExtensions.Rendering.cs
@@ -27,10 +27,9 @@ namespace Spectre.Console
}
var options = new RenderContext(console.Encoding, console.Capabilities.LegacyConsole);
- var segments = renderable.Render(options, console.Width).Where(x => !(x.Text.Length == 0 && !x.IsLineBreak)).ToArray();
+ var segments = renderable.Render(options, console.Width).ToArray();
segments = Segment.Merge(segments).ToArray();
- var current = Style.Plain;
foreach (var segment in segments)
{
if (string.IsNullOrEmpty(segment.Text))
diff --git a/src/Spectre.Console/Extensions/ExceptionExtensions.cs b/src/Spectre.Console/Extensions/ExceptionExtensions.cs
new file mode 100644
index 0000000..1c4d73a
--- /dev/null
+++ b/src/Spectre.Console/Extensions/ExceptionExtensions.cs
@@ -0,0 +1,22 @@
+using System;
+using Spectre.Console.Rendering;
+
+namespace Spectre.Console
+{
+ ///
+ /// Contains extension methods for .
+ ///
+ public static class ExceptionExtensions
+ {
+ ///
+ /// Gets a representation of the exception.
+ ///
+ /// The exception to format.
+ /// The exception format options.
+ /// A representing the exception.
+ public static IRenderable GetRenderable(this Exception exception, ExceptionFormats format = ExceptionFormats.None)
+ {
+ return ExceptionFormatter.Format(exception, format);
+ }
+ }
+}
diff --git a/src/Spectre.Console/Extensions/StringExtensions.cs b/src/Spectre.Console/Extensions/StringExtensions.cs
new file mode 100644
index 0000000..50e6efa
--- /dev/null
+++ b/src/Spectre.Console/Extensions/StringExtensions.cs
@@ -0,0 +1,26 @@
+namespace Spectre.Console
+{
+ ///
+ /// Contains extension methods for .
+ ///
+ public static class StringExtensions
+ {
+ ///
+ /// Converts the string to something that is safe to
+ /// use in a markup string.
+ ///
+ /// The text to convert.
+ /// A string that is safe to use in a markup string.
+ public static string SafeMarkup(this string text)
+ {
+ if (text == null)
+ {
+ return string.Empty;
+ }
+
+ return text
+ .Replace("[", "[[")
+ .Replace("]", "]]");
+ }
+ }
+}
diff --git a/src/Spectre.Console/Internal/ExceptionFormatter.cs b/src/Spectre.Console/Internal/ExceptionFormatter.cs
new file mode 100644
index 0000000..2a305b2
--- /dev/null
+++ b/src/Spectre.Console/Internal/ExceptionFormatter.cs
@@ -0,0 +1,159 @@
+using System;
+using System.Linq;
+using System.Text;
+using Spectre.Console.Internal;
+using Spectre.Console.Rendering;
+
+namespace Spectre.Console
+{
+ internal static class ExceptionFormatter
+ {
+ private static readonly Color _typeColor = Color.White;
+ private static readonly Color _methodColor = Color.Yellow;
+ private static readonly Color _parameterColor = Color.Blue;
+ private static readonly Color _pathColor = Color.Yellow;
+ private static readonly Color _dimmedColor = Color.Grey;
+
+ public static IRenderable Format(Exception exception, ExceptionFormats format)
+ {
+ if (exception is null)
+ {
+ throw new ArgumentNullException(nameof(exception));
+ }
+
+ var info = ExceptionParser.Parse(exception.ToString());
+ if (info == null)
+ {
+ return new Text(exception.ToString());
+ }
+
+ return GetException(info, format);
+ }
+
+ private static IRenderable GetException(ExceptionInfo info, ExceptionFormats format)
+ {
+ if (info is null)
+ {
+ throw new ArgumentNullException(nameof(info));
+ }
+
+ return new Rows(new IRenderable[]
+ {
+ GetMessage(info, format),
+ GetStackFrames(info, format),
+ }).Expand();
+ }
+
+ private static Markup GetMessage(ExceptionInfo ex, ExceptionFormats format)
+ {
+ var shortenTypes = (format & ExceptionFormats.ShortenTypes) != 0;
+ var type = Emphasize(ex.Type, new[] { '.' }, _typeColor.ToMarkupString(), shortenTypes);
+ var message = $"[b red]{ex.Message.SafeMarkup()}[/]";
+ return new Markup(string.Concat(type, ": ", message));
+ }
+
+ private static Grid GetStackFrames(ExceptionInfo ex, ExceptionFormats format)
+ {
+ var grid = new Grid();
+ grid.AddColumn(new GridColumn().PadLeft(2).PadRight(0).NoWrap());
+ grid.AddColumn(new GridColumn().PadLeft(1).PadRight(0));
+
+ // Inner
+ if (ex.Inner != null)
+ {
+ grid.AddRow(
+ Text.Empty,
+ GetException(ex.Inner, format));
+ }
+
+ // Stack frames
+ foreach (var frame in ex.Frames)
+ {
+ var builder = new StringBuilder();
+
+ // Method
+ var shortenMethods = (format & ExceptionFormats.ShortenMethods) != 0;
+ builder.Append(Emphasize(frame.Method, new[] { '.' }, _methodColor.ToMarkupString(), shortenMethods));
+ builder.Append('(');
+ builder.Append(string.Join(", ", frame.Parameters.Select(x => $"[{_parameterColor.ToMarkupString()}]{x.Type.SafeMarkup()}[/] {x.Name}")));
+ builder.Append(')');
+
+ if (frame.Path != null)
+ {
+ builder.Append(" [").Append(_dimmedColor.ToMarkupString()).Append("]in[/] ");
+
+ // Path
+ AppendPath(builder, frame, format);
+
+ // Line number
+ if (frame.LineNumber != null)
+ {
+ builder.Append(':');
+ builder.Append('[').Append(_parameterColor.ToMarkupString()).Append(']').Append(frame.LineNumber).Append("[/]");
+ }
+ }
+
+ grid.AddRow($"[{_dimmedColor.ToMarkupString()}]at[/]", builder.ToString());
+ }
+
+ return grid;
+ }
+
+ private static void AppendPath(StringBuilder builder, StackFrameInfo frame, ExceptionFormats format)
+ {
+ if (frame?.Path is null)
+ {
+ return;
+ }
+
+ void RenderLink()
+ {
+ var shortenPaths = (format & ExceptionFormats.ShortenPaths) != 0;
+ builder.Append(Emphasize(frame.Path, new[] { '/', '\\' }, $"b {_pathColor.ToMarkupString()}", shortenPaths));
+ }
+
+ if ((format & ExceptionFormats.ShowLinks) != 0)
+ {
+ var hasLink = frame.TryGetUri(out var uri);
+ if (hasLink && uri != null)
+ {
+ builder.Append("[link=").Append(uri.AbsoluteUri).Append(']');
+ }
+
+ RenderLink();
+
+ if (hasLink && uri != null)
+ {
+ builder.Append("[/]");
+ }
+ }
+ else
+ {
+ RenderLink();
+ }
+ }
+
+ private static string Emphasize(string input, char[] separators, string color, bool compact)
+ {
+ var builder = new StringBuilder();
+
+ var type = input;
+ var index = type.LastIndexOfAny(separators);
+ if (index != -1)
+ {
+ if (!compact)
+ {
+ builder.Append("[silver]").Append(type, 0, index + 1).Append("[/]");
+ }
+
+ builder.Append('[').Append(color).Append(']').Append(type, index + 1, type.Length - index - 1).Append("[/]");
+ }
+ else
+ {
+ builder.Append(type);
+ }
+
+ return builder.ToString();
+ }
+ }
+}
diff --git a/src/Spectre.Console/Internal/ExceptionInfo.cs b/src/Spectre.Console/Internal/ExceptionInfo.cs
new file mode 100644
index 0000000..b8b6fc9
--- /dev/null
+++ b/src/Spectre.Console/Internal/ExceptionInfo.cs
@@ -0,0 +1,23 @@
+using System.Collections.Generic;
+
+namespace Spectre.Console.Internal
+{
+ internal sealed class ExceptionInfo
+ {
+ public string Type { get; }
+ public string Message { get; }
+ public List Frames { get; }
+ public ExceptionInfo? Inner { get; }
+
+ public ExceptionInfo(
+ string type, string message,
+ List frames,
+ ExceptionInfo? inner)
+ {
+ Type = type ?? string.Empty;
+ Message = message ?? string.Empty;
+ Frames = frames ?? new List();
+ Inner = inner;
+ }
+ }
+}
diff --git a/src/Spectre.Console/Internal/ExceptionParser.cs b/src/Spectre.Console/Internal/ExceptionParser.cs
new file mode 100644
index 0000000..bf2e0fd
--- /dev/null
+++ b/src/Spectre.Console/Internal/ExceptionParser.cs
@@ -0,0 +1,142 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace Spectre.Console.Internal
+{
+ internal static class ExceptionParser
+ {
+ private static readonly Regex _messageRegex = new Regex(@"^(?'type'.*):\s(?'message'.*)$");
+ private static readonly Regex _stackFrameRegex = new Regex(@"^\s*\w*\s(?'method'.*)\((?'params'.*)\)");
+ private static readonly Regex _fullStackFrameRegex = new Regex(@"^\s*(?'at'\w*)\s(?'method'.*)\((?'params'.*)\)\s(?'in'\w*)\s(?'path'.*)\:(?'line'\w*)\s(?'linenumber'\d*)$");
+
+ public static ExceptionInfo? Parse(string exception)
+ {
+ if (exception is null)
+ {
+ throw new ArgumentNullException(nameof(exception));
+ }
+
+ var lines = exception.SplitLines();
+ return Parse(new Queue(lines));
+ }
+
+ private static ExceptionInfo? Parse(Queue lines)
+ {
+ if (lines.Count == 0)
+ {
+ // Error: No lines to parse
+ return null;
+ }
+
+ var line = lines.Dequeue();
+ line = line.Replace(" ---> ", string.Empty);
+
+ var match = _messageRegex.Match(line);
+ if (!match.Success)
+ {
+ return null;
+ }
+
+ var inner = (ExceptionInfo?)null;
+
+ // Stack frames
+ var frames = new List();
+ while (lines.Count > 0)
+ {
+ if (lines.Peek().TrimStart().StartsWith("---> ", StringComparison.OrdinalIgnoreCase))
+ {
+ inner = Parse(lines);
+ if (inner == null)
+ {
+ // Error: Could not parse inner exception
+ return null;
+ }
+
+ continue;
+ }
+
+ line = lines.Dequeue();
+
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ // Empty line
+ continue;
+ }
+
+ if (line.TrimStart().StartsWith("--- ", StringComparison.OrdinalIgnoreCase))
+ {
+ // End of inner exception
+ break;
+ }
+
+ var stackFrame = ParseStackFrame(line);
+ if (stackFrame == null)
+ {
+ // Error: Could not parse stack frame
+ return null;
+ }
+
+ frames.Add(stackFrame);
+ }
+
+ return new ExceptionInfo(
+ match.Groups["type"].Value,
+ match.Groups["message"].Value,
+ frames, inner);
+ }
+
+ private static StackFrameInfo? ParseStackFrame(string frame)
+ {
+ var match = _fullStackFrameRegex.Match(frame);
+ if (match?.Success != true)
+ {
+ match = _stackFrameRegex.Match(frame);
+ if (match?.Success != true)
+ {
+ return null;
+ }
+ }
+
+ var parameters = ParseMethodParameters(match.Groups["params"].Value);
+ if (parameters == null)
+ {
+ // Error: Could not parse parameters
+ return null;
+ }
+
+ var method = match.Groups["method"].Value;
+ var path = match.Groups["path"].Success ? match.Groups["path"].Value : null;
+
+ var lineNumber = (int?)null;
+ if (!string.IsNullOrWhiteSpace(match.Groups["linenumber"].Value))
+ {
+ lineNumber = int.Parse(match.Groups["linenumber"].Value, CultureInfo.InvariantCulture);
+ }
+
+ return new StackFrameInfo(method, parameters, path, lineNumber);
+ }
+
+ private static List<(string Type, string Name)>? ParseMethodParameters(string parameters)
+ {
+ var result = new List<(string Type, string Name)>();
+ foreach (var parameterPart in parameters.Split(new[] { ", " }, StringSplitOptions.RemoveEmptyEntries))
+ {
+ var parameterNameIndex = parameterPart.LastIndexOf(' ');
+ if (parameterNameIndex == -1)
+ {
+ // Error: Could not parse parameter
+ return null;
+ }
+
+ var type = parameterPart.Substring(0, parameterNameIndex);
+ var name = parameterPart.Substring(parameterNameIndex + 1, parameterPart.Length - parameterNameIndex - 1);
+
+ result.Add((type, name));
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/src/Spectre.Console/Internal/StackFrameInfo.cs b/src/Spectre.Console/Internal/StackFrameInfo.cs
new file mode 100644
index 0000000..34deacf
--- /dev/null
+++ b/src/Spectre.Console/Internal/StackFrameInfo.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Net;
+
+namespace Spectre.Console.Internal
+{
+ internal sealed class StackFrameInfo
+ {
+ public string Method { get; }
+ public List<(string Type, string Name)> Parameters { get; }
+ public string? Path { get; }
+ public int? LineNumber { get; }
+
+ public StackFrameInfo(
+ string method, List<(string Type, string Name)> parameters,
+ string? path, int? lineNumber)
+ {
+ Method = method ?? throw new System.ArgumentNullException(nameof(method));
+ Parameters = parameters ?? throw new System.ArgumentNullException(nameof(parameters));
+ Path = path;
+ LineNumber = lineNumber;
+ }
+
+ [SuppressMessage("Design", "CA1031:Do not catch general exception types")]
+ public bool TryGetUri([NotNullWhen(true)] out Uri? result)
+ {
+ try
+ {
+ if (Path == null)
+ {
+ result = null;
+ return false;
+ }
+
+ if (!Uri.TryCreate(Path, UriKind.Absolute, out var uri))
+ {
+ result = null;
+ return false;
+ }
+
+ if (uri.Scheme == "file")
+ {
+ // For local files, we need to append
+ // the host name. Otherwise the terminal
+ // will most probably not allow it.
+ var builder = new UriBuilder(uri)
+ {
+ Host = Dns.GetHostName(),
+ };
+
+ uri = builder.Uri;
+ }
+
+ result = uri;
+ return true;
+ }
+ catch
+ {
+ result = null;
+ return false;
+ }
+ }
+ }
+}
diff --git a/src/Spectre.Console/Rendering/Segment.cs b/src/Spectre.Console/Rendering/Segment.cs
index 35c823d..58667ec 100644
--- a/src/Spectre.Console/Rendering/Segment.cs
+++ b/src/Spectre.Console/Rendering/Segment.cs
@@ -239,7 +239,7 @@ namespace Spectre.Console.Rendering
}
// Same style?
- if (previous.Style.Equals(segment.Style))
+ if (previous.Style.Equals(segment.Style) && !previous.IsLineBreak)
{
previous = new Segment(previous.Text + segment.Text, previous.Style);
}
@@ -299,7 +299,15 @@ namespace Spectre.Console.Rendering
while (lengthLeft > 0)
{
var index = totalLength - lengthLeft;
+
+ // How many characters should we take?
var take = Math.Min(width, totalLength - index);
+ if (take == 0)
+ {
+ // This shouldn't really occur, but I don't like
+ // never ending loops if it does...
+ throw new InvalidOperationException("Text folding failed since 'take' was zero.");
+ }
result.Add(new Segment(segment.Text.Substring(index, take), segment.Style));
lengthLeft -= take;
diff --git a/src/Spectre.Console/TableBorder.cs b/src/Spectre.Console/TableBorder.cs
index 29f9202..7dd937f 100644
--- a/src/Spectre.Console/TableBorder.cs
+++ b/src/Spectre.Console/TableBorder.cs
@@ -1,7 +1,5 @@
using System;
using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
using System.Text;
using Spectre.Console.Internal;
using Spectre.Console.Rendering;
diff --git a/src/Spectre.Console/Widgets/Panel.cs b/src/Spectre.Console/Widgets/Panel.cs
index 7eb39c8..e1bc285 100644
--- a/src/Spectre.Console/Widgets/Panel.cs
+++ b/src/Spectre.Console/Widgets/Panel.cs
@@ -94,8 +94,15 @@ namespace Spectre.Console
// Split the child segments into lines.
var childSegments = ((IRenderable)child).Render(context, childWidth);
- foreach (var line in Segment.SplitLines(childSegments, panelWidth))
+ foreach (var line in Segment.SplitLines(childSegments, childWidth))
{
+ if (line.Count == 1 && line[0].IsWhiteSpace)
+ {
+ // NOTE: This check might impact other things.
+ // Hopefully not, but there is a chance.
+ continue;
+ }
+
result.Add(new Segment(border.GetPart(BoxBorderPart.Left), borderStyle));
var content = new List();
diff --git a/src/Spectre.Console/Widgets/Paragraph.cs b/src/Spectre.Console/Widgets/Paragraph.cs
index 4fdc1f5..de4bbf9 100644
--- a/src/Spectre.Console/Widgets/Paragraph.cs
+++ b/src/Spectre.Console/Widgets/Paragraph.cs
@@ -227,17 +227,10 @@ namespace Spectre.Console
throw new InvalidOperationException("Iterator returned empty segment.");
}
- if (newLine && current.IsWhiteSpace && !current.IsLineBreak)
- {
- newLine = false;
- continue;
- }
-
newLine = false;
if (current.IsLineBreak)
{
- line.Add(current);
lines.Add(line);
line = new SegmentLine();
newLine = true;
diff --git a/src/Spectre.Console/Widgets/Rows.cs b/src/Spectre.Console/Widgets/Rows.cs
index 2a8455e..6de5c6d 100644
--- a/src/Spectre.Console/Widgets/Rows.cs
+++ b/src/Spectre.Console/Widgets/Rows.cs
@@ -44,22 +44,26 @@ namespace Spectre.Console
///
protected override IEnumerable Render(RenderContext context, int maxWidth)
{
+ var result = new List();
+
foreach (var child in _children)
{
var segments = child.Render(context, maxWidth);
foreach (var (_, _, last, segment) in segments.Enumerate())
{
- yield return segment;
+ result.Add(segment);
if (last)
{
if (!segment.IsLineBreak)
{
- yield return Segment.LineBreak;
+ result.Add(Segment.LineBreak);
}
}
}
}
+
+ return result;
}
}
}
diff --git a/src/Spectre.Console/Widgets/Table.cs b/src/Spectre.Console/Widgets/Table.cs
index f6d723d..673e4fe 100644
--- a/src/Spectre.Console/Widgets/Table.cs
+++ b/src/Spectre.Console/Widgets/Table.cs
@@ -298,7 +298,6 @@ namespace Spectre.Console
var widths = width_ranges.Select(range => range.Max).ToList();
var tableWidth = widths.Sum();
-
if (tableWidth > maxWidth)
{
var wrappable = _columns.Select(c => !c.NoWrap).ToList();