diff --git a/Directory.Packages.props b/Directory.Packages.props
index 3302fce182..0fece6814a 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -19,6 +19,7 @@
+
diff --git a/Examples/UICatalog/Scenarios/SpectreViewScenario.cs b/Examples/UICatalog/Scenarios/SpectreViewScenario.cs
new file mode 100644
index 0000000000..2cc6526d1d
--- /dev/null
+++ b/Examples/UICatalog/Scenarios/SpectreViewScenario.cs
@@ -0,0 +1,192 @@
+#nullable enable
+
+using global::Spectre.Console;
+using global::Spectre.Console.Rendering;
+using Terminal.Gui.Interop.Spectre;
+using SpectreColor = global::Spectre.Console.Color;
+
+namespace UICatalog.Scenarios;
+
+[ScenarioMetadata ("Spectre", "Demonstrates Spectre.Console integration.")]
+[ScenarioCategory ("Controls")]
+[ScenarioCategory ("Text and Formatting")]
+public sealed class SpectreViewScenario : Scenario
+{
+ public override void Main ()
+ {
+ ConfigurationManager.Enable (ConfigLocations.All);
+
+ using IApplication app = Application.Create ();
+ app.Init ();
+
+ using Window appWindow = new ();
+ appWindow.Title = GetQuitKeyAndName ();
+ appWindow.BorderStyle = LineStyle.None;
+
+ string [] labels = ["_Table", "_Panel", "_Rule", "_Tree", "_BarChart", "_Calendar", "_Figlet", "_Markup"];
+ OptionSelector selector = new ()
+ {
+ X = 0,
+ Y = 0,
+ Width = 36,
+ Height = Dim.Auto (),
+ BorderStyle = LineStyle.Rounded,
+ Labels = labels,
+ Value = 0,
+ Title = "_Spectre Widget"
+ };
+
+ TextField userInput = new ()
+ {
+ X = Pos.Right (selector) + 1,
+ Y = 0,
+ Width = 22,
+ Height = 1,
+ Text = "Alice"
+ };
+
+ Label userLabel = new ()
+ {
+ X = Pos.Left (userInput),
+ Y = Pos.Bottom (userInput),
+ Text = "_Name for sample data:"
+ };
+
+ CheckBox autoSizeCheckBox = new ()
+ {
+ X = Pos.Left (userInput),
+ Y = Pos.Bottom (userLabel),
+ Text = "_AutoSize content",
+ Value = CheckState.Checked
+ };
+
+ Button refreshButton = new ()
+ {
+ X = Pos.Left (userInput),
+ Y = Pos.Bottom (autoSizeCheckBox) + 1,
+ Text = "_Refresh"
+ };
+
+ FrameView previewFrame = new ()
+ {
+ X = 0,
+ Y = Pos.Bottom (selector) + 1,
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ Title = "Preview",
+ BorderStyle = LineStyle.Rounded
+ };
+
+ SpectreView spectreView = new ()
+ {
+ X = 0,
+ Y = 0,
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ AutoSize = true,
+ Renderable = CreateRenderable (labels [0], userInput.Text),
+ ViewportSettings = ViewportSettingsFlags.HasVerticalScrollBar | ViewportSettingsFlags.HasHorizontalScrollBar
+ };
+
+ previewFrame.Add (spectreView);
+ appWindow.Add (selector, userInput, userLabel, autoSizeCheckBox, refreshButton, previewFrame);
+
+ selector.ValueChanged += (_, args) =>
+ {
+ if (args.NewValue is null)
+ {
+ return;
+ }
+
+ string selectedLabel = labels [(int)args.NewValue];
+ spectreView.Renderable = CreateRenderable (selectedLabel, userInput.Text);
+ };
+
+ refreshButton.Accepted += (_, _) =>
+ {
+ string selectedLabel = labels [(int)(selector.Value ?? 0)];
+ spectreView.Renderable = CreateRenderable (selectedLabel, userInput.Text);
+ };
+
+ autoSizeCheckBox.ValueChanged += (_, args) =>
+ {
+ spectreView.AutoSize = args.NewValue == CheckState.Checked;
+ };
+
+ app.Run (appWindow);
+ }
+
+ private static IRenderable CreateRenderable (string selectedLabel, string userName)
+ {
+ string safeName = string.IsNullOrWhiteSpace (userName) ? "User" : userName;
+
+ switch (selectedLabel)
+ {
+ case "_Panel":
+ {
+ return new Panel ($"Welcome, {safeName}!\nThis is Spectre rendered in a Terminal.Gui view.")
+ {
+ Header = new PanelHeader ("Spectre Panel")
+ };
+ }
+
+ case "_Rule":
+ {
+ return new Rule ($"Spectre Rule for {safeName}");
+ }
+
+ case "_Tree":
+ {
+ Tree tree = new ("Project");
+ global::Spectre.Console.TreeNode src = tree.AddNode ("src");
+ src.AddNode ("App.cs");
+ src.AddNode ("SpectreView.cs");
+ tree.AddNode ("README.md");
+
+ return tree;
+ }
+
+ case "_BarChart":
+ {
+ BarChart chart = new ();
+ chart.Width (60);
+ chart.AddItem (safeName, 81, new SpectreColor (100, 149, 237));
+ chart.AddItem ("Average", 73, new SpectreColor (0, 250, 154));
+ chart.AddItem ("Target", 90, new SpectreColor (255, 165, 0));
+
+ return chart;
+ }
+
+ case "_Calendar":
+ {
+ DateTime now = DateTime.Now;
+ Calendar calendar = new (now.Year, now.Month);
+ calendar.HighlightStyle (new Style (SpectreColor.Black, SpectreColor.Yellow));
+
+ return calendar;
+ }
+
+ case "_Figlet":
+ {
+ return new FigletText (safeName).Centered ();
+ }
+
+ case "_Markup":
+ {
+ return new Markup ($"[bold aqua]{safeName}[/] uses [yellow]SpectreView[/] inside [green]Terminal.Gui[/].");
+ }
+
+ default:
+ {
+ Table table = new ();
+ table.Border = TableBorder.Rounded;
+ table.AddColumn ("Name");
+ table.AddColumn ("Score");
+ table.AddRow (safeName, "81");
+ table.AddRow ("Average", "73");
+
+ return table;
+ }
+ }
+ }
+}
diff --git a/Examples/UICatalog/Scenarios/SpectreViewScenario.gif b/Examples/UICatalog/Scenarios/SpectreViewScenario.gif
new file mode 100644
index 0000000000..4b8344f969
Binary files /dev/null and b/Examples/UICatalog/Scenarios/SpectreViewScenario.gif differ
diff --git a/Examples/UICatalog/UICatalog.csproj b/Examples/UICatalog/UICatalog.csproj
index 814fd08ab2..d144b2007f 100644
--- a/Examples/UICatalog/UICatalog.csproj
+++ b/Examples/UICatalog/UICatalog.csproj
@@ -35,6 +35,7 @@
+
diff --git a/Terminal.Gui.Interop.Spectre/SpectreMarkupBridge.cs b/Terminal.Gui.Interop.Spectre/SpectreMarkupBridge.cs
new file mode 100644
index 0000000000..5d90ae80f5
--- /dev/null
+++ b/Terminal.Gui.Interop.Spectre/SpectreMarkupBridge.cs
@@ -0,0 +1,158 @@
+using global::Spectre.Console;
+using Terminal.Gui.Drawing;
+using TgAttribute = Terminal.Gui.Drawing.Attribute;
+using TgColor = Terminal.Gui.Drawing.Color;
+using SpectreColor = global::Spectre.Console.Color;
+
+namespace Terminal.Gui.Interop.Spectre;
+
+///
+/// Converts between Spectre.Console styling and Terminal.Gui drawing attributes.
+///
+public static class SpectreMarkupBridge
+{
+ ///
+ /// Converts a Spectre to a Terminal.Gui .
+ ///
+ /// The Spectre style to convert.
+ /// The converted Terminal.Gui attribute.
+ public static TgAttribute ToAttribute (this Style style)
+ {
+ TgColor foreground = SpectreColorToTg (style.Foreground);
+ TgColor background = SpectreColorToTg (style.Background);
+ TextStyle textStyle = DecorationToTextStyle (style.Decoration);
+
+ return new (foreground, background, textStyle);
+ }
+
+ ///
+ /// Converts a Terminal.Gui to a Spectre .
+ ///
+ /// The Terminal.Gui attribute to convert.
+ /// The converted Spectre style.
+ public static Style ToSpectreStyle (this TgAttribute attribute)
+ {
+ SpectreColor foreground = TgColorToSpectre (attribute.Foreground);
+ SpectreColor background = TgColorToSpectre (attribute.Background);
+ Decoration decoration = TextStyleToDecoration (attribute.Style);
+
+ return new (foreground, background, decoration);
+ }
+
+ private static TgColor SpectreColorToTg (SpectreColor? color)
+ {
+ if (color is null)
+ {
+ return TgColor.None;
+ }
+
+ SpectreColor value = color.Value;
+
+ if (value == SpectreColor.Default)
+ {
+ return TgColor.None;
+ }
+
+ return new TgColor (value.R, value.G, value.B);
+ }
+
+ private static SpectreColor TgColorToSpectre (TgColor color)
+ {
+ if (color == TgColor.None)
+ {
+ return SpectreColor.Default;
+ }
+
+ return new SpectreColor ((byte)color.R, (byte)color.G, (byte)color.B);
+ }
+
+ private static TextStyle DecorationToTextStyle (Decoration? decoration)
+ {
+ if (decoration is null)
+ {
+ return TextStyle.None;
+ }
+
+ Decoration value = decoration.Value;
+ TextStyle style = TextStyle.None;
+
+ if ((value & Decoration.Bold) != 0)
+ {
+ style |= TextStyle.Bold;
+ }
+
+ if ((value & Decoration.Dim) != 0)
+ {
+ style |= TextStyle.Faint;
+ }
+
+ if ((value & Decoration.Italic) != 0)
+ {
+ style |= TextStyle.Italic;
+ }
+
+ if ((value & Decoration.Underline) != 0)
+ {
+ style |= TextStyle.Underline;
+ }
+
+ if ((value & Decoration.Invert) != 0)
+ {
+ style |= TextStyle.Reverse;
+ }
+
+ if ((value & (Decoration.SlowBlink | Decoration.RapidBlink)) != 0)
+ {
+ style |= TextStyle.Blink;
+ }
+
+ if ((value & Decoration.Strikethrough) != 0)
+ {
+ style |= TextStyle.Strikethrough;
+ }
+
+ return style;
+ }
+
+ private static Decoration TextStyleToDecoration (TextStyle style)
+ {
+ Decoration decoration = Decoration.None;
+
+ if ((style & TextStyle.Bold) != 0)
+ {
+ decoration |= Decoration.Bold;
+ }
+
+ if ((style & TextStyle.Faint) != 0)
+ {
+ decoration |= Decoration.Dim;
+ }
+
+ if ((style & TextStyle.Italic) != 0)
+ {
+ decoration |= Decoration.Italic;
+ }
+
+ if ((style & TextStyle.Underline) != 0)
+ {
+ decoration |= Decoration.Underline;
+ }
+
+ if ((style & TextStyle.Reverse) != 0)
+ {
+ decoration |= Decoration.Invert;
+ }
+
+ if ((style & TextStyle.Blink) != 0)
+ {
+ decoration |= Decoration.SlowBlink;
+ }
+
+ if ((style & TextStyle.Strikethrough) != 0)
+ {
+ decoration |= Decoration.Strikethrough;
+ }
+
+ return decoration;
+ }
+}
diff --git a/Terminal.Gui.Interop.Spectre/SpectreView.cs b/Terminal.Gui.Interop.Spectre/SpectreView.cs
new file mode 100644
index 0000000000..fca278ebfe
--- /dev/null
+++ b/Terminal.Gui.Interop.Spectre/SpectreView.cs
@@ -0,0 +1,194 @@
+using global::Spectre.Console;
+using global::Spectre.Console.Rendering;
+using Terminal.Gui.Drawing;
+using Terminal.Gui.Text;
+using Terminal.Gui.ViewBase;
+using TgAttribute = Terminal.Gui.Drawing.Attribute;
+using DrawingSize = System.Drawing.Size;
+
+namespace Terminal.Gui.Interop.Spectre;
+
+///
+/// A read-only that renders a Spectre .
+///
+public class SpectreView : View
+{
+ private static readonly IAnsiConsole _nullConsole = AnsiConsole.Create (new AnsiConsoleSettings
+ {
+ Out = new AnsiConsoleOutput (TextWriter.Null)
+ });
+
+ private IRenderable? _renderable;
+ private bool _autoSize = true;
+
+ ///
+ /// Gets or sets the Spectre renderable to display.
+ ///
+ public IRenderable? Renderable
+ {
+ get => _renderable;
+ set
+ {
+ if (ReferenceEquals (_renderable, value))
+ {
+ return;
+ }
+
+ _renderable = value;
+ UpdateContentSizeFromRenderable ();
+ SetNeedsDraw ();
+ }
+ }
+
+ ///
+ /// Gets or sets whether this view updates content size from the rendered Spectre content.
+ ///
+ public bool AutoSize
+ {
+ get => _autoSize;
+ set
+ {
+ if (_autoSize == value)
+ {
+ return;
+ }
+
+ _autoSize = value;
+ UpdateContentSizeFromRenderable ();
+ SetNeedsDraw ();
+ }
+ }
+
+ ///
+ protected override void OnViewportChanged (DrawEventArgs e)
+ {
+ base.OnViewportChanged (e);
+ UpdateContentSizeFromRenderable ();
+ }
+
+ ///
+ protected override bool OnDrawingContent (DrawContext? context)
+ {
+ if (Renderable is null)
+ {
+ return true;
+ }
+
+ int maxWidth = Math.Max (Viewport.Width, 1);
+ (IReadOnlyList segments, _, _) = RenderSegments (Renderable, maxWidth);
+
+ int row = 0;
+ int col = 0;
+
+ foreach (Segment segment in segments)
+ {
+ if (segment.IsLineBreak)
+ {
+ row++;
+ col = 0;
+
+ continue;
+ }
+
+ if (segment.IsControlCode || string.IsNullOrEmpty (segment.Text))
+ {
+ continue;
+ }
+
+ DrawSegment (segment, row, ref col);
+ }
+
+ return true;
+ }
+
+ private void DrawSegment (Segment segment, int row, ref int col)
+ {
+ if (row < Viewport.Y || row >= Viewport.Bottom)
+ {
+ col += segment.Text.GetColumns ();
+
+ return;
+ }
+
+ TgAttribute attribute = segment.Style.ToAttribute ();
+
+ foreach (string grapheme in GraphemeHelper.GetGraphemes (segment.Text))
+ {
+ int graphemeWidth = grapheme.GetColumns ();
+
+ if (graphemeWidth > 0)
+ {
+ bool visible = col + graphemeWidth > Viewport.X && col < Viewport.Right;
+
+ if (visible)
+ {
+ SetAttribute (attribute);
+ AddStr (col - Viewport.X, row - Viewport.Y, grapheme);
+ }
+ }
+
+ col += graphemeWidth;
+ }
+ }
+
+ private void UpdateContentSizeFromRenderable ()
+ {
+ if (!AutoSize)
+ {
+ SetContentSize (null);
+
+ return;
+ }
+
+ if (Renderable is null)
+ {
+ SetContentSize (new DrawingSize (0, 0));
+
+ return;
+ }
+
+ int maxWidth = Math.Max (Viewport.Width, 1);
+ (_, int contentWidth, int contentHeight) = RenderSegments (Renderable, maxWidth);
+ SetContentSize (new DrawingSize (contentWidth, contentHeight));
+ }
+
+ private static (IReadOnlyList Segments, int ContentWidth, int ContentHeight) RenderSegments (IRenderable renderable, int maxWidth)
+ {
+ RenderOptions renderOptions = RenderOptions.Create (_nullConsole, null);
+ Measurement measurement = renderable.Measure (renderOptions, maxWidth);
+ List segments = [.. renderable.Render (renderOptions, maxWidth)];
+
+ if (segments.Count == 0)
+ {
+ return (segments, 0, 0);
+ }
+
+ int maxLineWidth = 0;
+ int lineWidth = 0;
+ int lineCount = 1;
+
+ foreach (Segment segment in segments)
+ {
+ if (segment.IsLineBreak)
+ {
+ maxLineWidth = Math.Max (maxLineWidth, lineWidth);
+ lineWidth = 0;
+ lineCount++;
+
+ continue;
+ }
+
+ if (segment.IsControlCode || string.IsNullOrEmpty (segment.Text))
+ {
+ continue;
+ }
+
+ lineWidth += segment.Text.GetColumns ();
+ }
+
+ maxLineWidth = Math.Max (maxLineWidth, lineWidth);
+ int contentWidth = Math.Max (measurement.Max, maxLineWidth);
+
+ return (segments, contentWidth, lineCount);
+ }
+}
diff --git a/Terminal.Gui.Interop.Spectre/Terminal.Gui.Interop.Spectre.csproj b/Terminal.Gui.Interop.Spectre/Terminal.Gui.Interop.Spectre.csproj
new file mode 100644
index 0000000000..7831d4a2f7
--- /dev/null
+++ b/Terminal.Gui.Interop.Spectre/Terminal.Gui.Interop.Spectre.csproj
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+ Terminal.Gui.Interop.Spectre
+ Terminal.Gui.Interop.Spectre
+ net10.0
+ enable
+ enable
+ true
+ true
+ Terminal.Gui.Interop.Spectre
+ Bridge package for rendering Spectre.Console IRenderable widgets inside Terminal.Gui applications.
+ gui-cs contributors
+ MIT
+ https://github.com/gui-cs/Terminal.Gui
+ https://github.com/gui-cs/Terminal.Gui
+ terminal;gui;console;spectre;interop
+
+
+
+
+
+
+
diff --git a/Terminal.sln b/Terminal.sln
index de4c68a853..a763af6eec 100644
--- a/Terminal.sln
+++ b/Terminal.sln
@@ -158,6 +158,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Terminal.Gui.Analyzers.Inte
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceTests", "Tests\PerformanceTests\PerformanceTests.csproj", "{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Terminal.Gui.Interop.Spectre", "Terminal.Gui.Interop.Spectre\Terminal.Gui.Interop.Spectre.csproj", "{9F70B0A0-8CC2-4384-93FE-359C0D6A0BD6}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -468,6 +470,18 @@ Global
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x64.Build.0 = Release|Any CPU
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x86.ActiveCfg = Release|Any CPU
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x86.Build.0 = Release|Any CPU
+ {9F70B0A0-8CC2-4384-93FE-359C0D6A0BD6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9F70B0A0-8CC2-4384-93FE-359C0D6A0BD6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9F70B0A0-8CC2-4384-93FE-359C0D6A0BD6}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9F70B0A0-8CC2-4384-93FE-359C0D6A0BD6}.Debug|x64.Build.0 = Debug|Any CPU
+ {9F70B0A0-8CC2-4384-93FE-359C0D6A0BD6}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9F70B0A0-8CC2-4384-93FE-359C0D6A0BD6}.Debug|x86.Build.0 = Debug|Any CPU
+ {9F70B0A0-8CC2-4384-93FE-359C0D6A0BD6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9F70B0A0-8CC2-4384-93FE-359C0D6A0BD6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9F70B0A0-8CC2-4384-93FE-359C0D6A0BD6}.Release|x64.ActiveCfg = Release|Any CPU
+ {9F70B0A0-8CC2-4384-93FE-359C0D6A0BD6}.Release|x64.Build.0 = Release|Any CPU
+ {9F70B0A0-8CC2-4384-93FE-359C0D6A0BD6}.Release|x86.ActiveCfg = Release|Any CPU
+ {9F70B0A0-8CC2-4384-93FE-359C0D6A0BD6}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Tests/UnitTestsParallelizable/Interop/Spectre/SpectreViewTests.cs b/Tests/UnitTestsParallelizable/Interop/Spectre/SpectreViewTests.cs
new file mode 100644
index 0000000000..4743d0703e
--- /dev/null
+++ b/Tests/UnitTestsParallelizable/Interop/Spectre/SpectreViewTests.cs
@@ -0,0 +1,392 @@
+// Copilot
+
+using System.Text;
+using JetBrains.Annotations;
+using global::Spectre.Console;
+using global::Spectre.Console.Rendering;
+using Terminal.Gui.Interop.Spectre;
+using Terminal.Gui.Drawing;
+using Terminal.Gui.Views;
+using SpectreColor = Spectre.Console.Color;
+using TgAttribute = Terminal.Gui.Drawing.Attribute;
+
+namespace InteropTests.Spectre;
+
+[TestSubject (typeof (SpectreView))]
+public class SpectreViewTests
+{
+ [Fact]
+ public void SpectreMarkupBridge_ToAttribute_Converts_Color_And_Decoration ()
+ {
+ Style style = new (SpectreColor.Red, SpectreColor.Blue, Decoration.Bold | Decoration.Underline | Decoration.Strikethrough);
+
+ TgAttribute attribute = style.ToAttribute ();
+
+ Assert.Equal (255, attribute.Foreground.R);
+ Assert.Equal (0, attribute.Foreground.G);
+ Assert.Equal (0, attribute.Foreground.B);
+ Assert.Equal (0, attribute.Background.R);
+ Assert.Equal (0, attribute.Background.G);
+ Assert.Equal (255, attribute.Background.B);
+ Assert.True ((attribute.Style & TextStyle.Bold) != 0);
+ Assert.True ((attribute.Style & TextStyle.Underline) != 0);
+ Assert.True ((attribute.Style & TextStyle.Strikethrough) != 0);
+ }
+
+ [Fact]
+ public void SpectreMarkupBridge_ToSpectreStyle_Converts_Color_And_Decoration ()
+ {
+ TgAttribute attribute = new (
+ new Terminal.Gui.Drawing.Color (0, 255, 0),
+ new Terminal.Gui.Drawing.Color (128, 0, 128),
+ TextStyle.Italic | TextStyle.Underline);
+
+ Style spectreStyle = attribute.ToSpectreStyle ();
+
+ Assert.Equal (0, spectreStyle.Foreground.R);
+ Assert.Equal (255, spectreStyle.Foreground.G);
+ Assert.Equal (0, spectreStyle.Foreground.B);
+ Assert.Equal (128, spectreStyle.Background.R);
+ Assert.Equal (0, spectreStyle.Background.G);
+ Assert.Equal (128, spectreStyle.Background.B);
+ Assert.True ((spectreStyle.Decoration & Decoration.Italic) != 0);
+ Assert.True ((spectreStyle.Decoration & Decoration.Underline) != 0);
+ }
+
+ [Fact]
+ public void SpectreMarkupBridge_RoundTrip_Preserves_Style ()
+ {
+ TgAttribute original = new (
+ new Terminal.Gui.Drawing.Color (100, 150, 200),
+ new Terminal.Gui.Drawing.Color (50, 60, 70),
+ TextStyle.Bold | TextStyle.Faint | TextStyle.Strikethrough);
+
+ Style spectreStyle = original.ToSpectreStyle ();
+ TgAttribute roundTripped = spectreStyle.ToAttribute ();
+
+ Assert.Equal (original.Foreground.R, roundTripped.Foreground.R);
+ Assert.Equal (original.Foreground.G, roundTripped.Foreground.G);
+ Assert.Equal (original.Foreground.B, roundTripped.Foreground.B);
+ Assert.Equal (original.Background.R, roundTripped.Background.R);
+ Assert.Equal (original.Background.G, roundTripped.Background.G);
+ Assert.Equal (original.Background.B, roundTripped.Background.B);
+ Assert.Equal (original.Style, roundTripped.Style);
+ }
+
+ [Fact]
+ public void SpectreMarkupBridge_Default_Colors_Map_To_None ()
+ {
+ Style style = new (SpectreColor.Default, SpectreColor.Default);
+
+ TgAttribute attribute = style.ToAttribute ();
+
+ Assert.Equal (Terminal.Gui.Drawing.Color.None, attribute.Foreground);
+ Assert.Equal (Terminal.Gui.Drawing.Color.None, attribute.Background);
+ }
+
+ [Fact]
+ public void SpectreMarkupBridge_None_Colors_Map_To_Default ()
+ {
+ TgAttribute attribute = new (Terminal.Gui.Drawing.Color.None, Terminal.Gui.Drawing.Color.None);
+
+ Style spectreStyle = attribute.ToSpectreStyle ();
+
+ Assert.Equal (SpectreColor.Default, spectreStyle.Foreground);
+ Assert.Equal (SpectreColor.Default, spectreStyle.Background);
+ }
+
+ [Fact]
+ public void SpectreView_Renders_Markup_IRenderable ()
+ {
+ Markup markup = new ("[bold red]Hello[/] [green]World[/]");
+
+ string output = RenderToText (markup, 40, 3);
+
+ Assert.Contains ("Hello", output);
+ Assert.Contains ("World", output);
+ }
+
+ [Fact]
+ public void SpectreView_Renders_Table_Panel_Rule_Tree_BarChart_And_FigletText ()
+ {
+ Table table = new ();
+ table.AddColumn ("Name");
+ table.AddColumn ("Score");
+ table.AddRow ("Alice", "100");
+
+ string tableOutput = RenderToText (table, 80, 8);
+ Assert.Contains ("Alice", tableOutput);
+
+ Panel panel = new ("Panel Body");
+ string panelOutput = RenderToText (panel, 80, 6);
+ Assert.Contains ("Panel Body", panelOutput);
+
+ Rule rule = new ("Rule Title");
+ string ruleOutput = RenderToText (rule, 80, 3);
+ Assert.Contains ("Rule Title", ruleOutput);
+
+ Tree tree = new ("Root");
+ tree.AddNode ("Branch");
+ string treeOutput = RenderToText (tree, 80, 6);
+ Assert.Contains ("Root", treeOutput);
+ Assert.Contains ("Branch", treeOutput);
+
+ BarChart barChart = new ();
+ barChart.AddItem ("A", 10, SpectreColor.Green);
+ string chartOutput = RenderToText (barChart, 80, 6);
+ Assert.Contains ("A", chartOutput);
+
+ FigletText figlet = new ("Hi");
+ string figletOutput = RenderToText (figlet, 80, 8);
+ Assert.Contains ("_", figletOutput);
+ }
+
+ [Fact]
+ public void SpectreView_Renderable_Change_Triggers_Rerender ()
+ {
+ using IApplication app = Application.Create ();
+ app.Init (DriverRegistry.Names.ANSI);
+ app.Driver!.SetScreenSize (60, 6);
+
+ SpectreView view = new ()
+ {
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ Renderable = new Rule ("Before")
+ };
+
+ using Runnable root = new ()
+ {
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ BorderStyle = LineStyle.None
+ };
+ root.Add (view);
+ app.Begin (root);
+ app.LayoutAndDraw ();
+
+ string before = GetDriverText (app.Driver.Contents!);
+ Assert.Contains ("Before", before);
+
+ view.Renderable = new Rule ("After");
+ app.LayoutAndDraw ();
+
+ string after = GetDriverText (app.Driver.Contents!);
+ Assert.Contains ("After", after);
+ }
+
+ [Fact]
+ public void SpectreView_AutoSize_Reports_ContentSize_For_DimAuto ()
+ {
+ using IApplication app = Application.Create ();
+ app.Init (DriverRegistry.Names.ANSI);
+ app.Driver!.SetScreenSize (80, 25);
+
+ Table table = new ();
+ table.AddColumn ("Name");
+ table.AddColumn ("Value");
+ table.AddRow ("Alice", "100");
+ table.AddRow ("Bob", "95");
+
+ SpectreView view = new ()
+ {
+ Width = 30,
+ Height = Dim.Auto (DimAutoStyle.Content),
+ Renderable = table
+ };
+
+ using Runnable root = new ()
+ {
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ BorderStyle = LineStyle.None
+ };
+ root.Add (view);
+
+ app.Begin (root);
+ app.LayoutAndDraw ();
+
+ Assert.True (view.GetContentSize ().Height > 0);
+ Assert.True (view.Frame.Height > 0);
+ }
+
+ [Fact]
+ public void SpectreView_Integrates_With_Window_And_Interactive_Views ()
+ {
+ using IApplication app = Application.Create ();
+ app.Init (DriverRegistry.Names.ANSI);
+ app.Driver!.SetScreenSize (80, 10);
+
+ Button button = new ()
+ {
+ Text = "Go",
+ X = 0,
+ Y = 0
+ };
+ SpectreView spectre = new ()
+ {
+ Renderable = new Rule ("Decor"),
+ X = 0,
+ Y = 1,
+ Width = Dim.Fill (),
+ Height = 3
+ };
+
+ Window window = new ()
+ {
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ BorderStyle = LineStyle.None
+ };
+ window.Add (button, spectre);
+
+ app.Begin (window);
+ app.LayoutAndDraw ();
+
+ string output = GetDriverText (app.Driver.Contents!);
+ Assert.Contains ("Go", output);
+ Assert.Contains ("Decor", output);
+ }
+
+ [Fact]
+ public void SpectreView_Skips_ControlCode_Segments ()
+ {
+ // A custom IRenderable that emits a control-code segment between text segments.
+ // If control codes are not skipped, the escape text will appear between Hello and World.
+ ControlCodeRenderable renderable = new ();
+
+ string output = RenderToText (renderable, 40, 3);
+
+ // The control code text "[?25l" should NOT appear as visible text between Hello and World
+ Assert.DoesNotContain ("[?25", output);
+ // The two text segments should be adjacent (no garbage between them)
+ Assert.Contains ("HelloWorld", output);
+ }
+
+ [Fact]
+ public void SpectreView_ZeroWidth_Characters_Do_Not_Misalign ()
+ {
+ // A renderable that emits a combining character (zero-width) followed by normal text.
+ // The combining mark merges with the preceding character into a single grapheme.
+ // "After" should follow immediately without spurious gaps.
+ ZeroWidthRenderable renderable = new ();
+
+ using IApplication app = Application.Create ();
+ app.Init (DriverRegistry.Names.ANSI);
+ app.Driver!.SetScreenSize (40, 3);
+
+ SpectreView view = new ()
+ {
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ Renderable = renderable
+ };
+
+ using Runnable root = new ()
+ {
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ BorderStyle = LineStyle.None
+ };
+ root.Add (view);
+ app.Begin (root);
+ app.LayoutAndDraw ();
+
+ Cell [,] contents = app.Driver.Contents!;
+ StringBuilder row0 = new ();
+
+ for (int col = 0; col < contents.GetLength (1); col++)
+ {
+ row0.Append (contents [0, col].Grapheme);
+ }
+
+ string line = row0.ToString ().TrimEnd ();
+
+ // "Base\u0301" renders as "Bas" + "é" (4 display columns), then "After" immediately follows
+ Assert.Contains ("After", line);
+
+ // "After" should start at column 4 (width of "Base\u0301" = 4 graphemes, each 1 col)
+ // No spurious gap between the combined character and "After"
+ Assert.DoesNotContain (" After", line);
+ }
+
+ /// A test renderable that emits control-code segments between text.
+ private sealed class ControlCodeRenderable : IRenderable
+ {
+ public Measurement Measure (RenderOptions options, int maxWidth)
+ {
+ return new Measurement (10, 10);
+ }
+
+ public IEnumerable Render (RenderOptions options, int maxWidth)
+ {
+ yield return new Segment ("Hello", Style.Plain);
+ yield return Segment.Control ("\x1b[?25l");
+ yield return new Segment ("World", Style.Plain);
+ }
+ }
+
+ /// A test renderable that emits a combining mark (zero-width) character.
+ private sealed class ZeroWidthRenderable : IRenderable
+ {
+ public Measurement Measure (RenderOptions options, int maxWidth)
+ {
+ return new Measurement (10, 10);
+ }
+
+ public IEnumerable Render (RenderOptions options, int maxWidth)
+ {
+ // "Base" + combining diacritical acute (U+0301, zero-width) + "After"
+ yield return new Segment ("Base\u0301", Style.Plain);
+ yield return new Segment ("After", Style.Plain);
+ }
+ }
+
+ private static string RenderToText (IRenderable renderable, int width, int height)
+ {
+ using IApplication app = Application.Create ();
+ app.Init (DriverRegistry.Names.ANSI);
+ app.Driver!.SetScreenSize (width, height);
+
+ SpectreView view = new ()
+ {
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ Renderable = renderable
+ };
+
+ using Runnable root = new ()
+ {
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ BorderStyle = LineStyle.None
+ };
+ root.Add (view);
+
+ app.Begin (root);
+ app.LayoutAndDraw ();
+
+ return GetDriverText (app.Driver.Contents!);
+ }
+
+ private static string GetDriverText (Cell [,] contents)
+ {
+ int rowCount = contents.GetLength (0);
+ int colCount = contents.GetLength (1);
+ List lines = [];
+
+ for (int row = 0; row < rowCount; row++)
+ {
+ StringBuilder lineBuilder = new ();
+
+ for (int col = 0; col < colCount; col++)
+ {
+ lineBuilder.Append (contents [row, col].Grapheme);
+ }
+
+ lines.Add (lineBuilder.ToString ());
+ }
+
+ return string.Join ("\n", lines);
+ }
+}
diff --git a/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj b/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj
index 7da1c0c2dd..0b8f506f9a 100644
--- a/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj
+++ b/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj
@@ -45,6 +45,7 @@
+