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 @@ +