diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Ansi.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Ansi.cs new file mode 100644 index 0000000000..98c3ee8e78 --- /dev/null +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Ansi.cs @@ -0,0 +1,107 @@ +using Terminal.Gui.App; +using Terminal.Gui.Drivers; + +namespace Terminal.Gui.Views; + +public partial class Markdown +{ + private const int MAX_RENDER_WIDTH = 4096; + private static readonly Lock _renderToAnsiLock = new (); + + /// + /// Renders the current (or the supplied ) + /// to an ANSI escape-sequence string suitable for writing directly to a terminal. + /// + /// + /// Optional markdown text to render. If , uses the current . + /// + /// + /// The target column width for word-wrapping. Defaults to 80. Clamped to + /// the range [, 4096]. + /// + /// A string containing ANSI escape sequences that reproduce the styled markdown output. + /// + /// + /// This method does not require to be initialized. It creates a + /// temporary headless ANSI driver internally, performs layout and drawing into an off-screen + /// buffer, and returns the ANSI representation. + /// + /// + /// Configuration properties set on this instance — , + /// , , and + /// — are copied to the temporary view used for rendering. + /// Copy buttons () are always disabled for ANSI output because + /// they are interactive-only controls. + /// + /// + public string RenderToAnsi (string? markdown = null, int width = 80) + { + if (width < MIN_WRAP_WIDTH) + { + width = MIN_WRAP_WIDTH; + } + else if (width > MAX_RENDER_WIDTH) + { + width = MAX_RENDER_WIDTH; + } + + string text = markdown ?? Text; + + if (string.IsNullOrEmpty (text)) + { + return string.Empty; + } + + // A static lock guards the process-wide DisableRealDriverIO environment variable + // so concurrent calls do not race on set/restore. + lock (_renderToAnsiLock) + { + string? previousValue = Environment.GetEnvironmentVariable ("DisableRealDriverIO"); + Environment.SetEnvironmentVariable ("DisableRealDriverIO", "1"); + + try + { + using IApplication app = Application.Create ().Init (DriverRegistry.Names.ANSI); + + // Use a small initial height for the first layout pass (just enough to compute content height) + app.Driver!.SetScreenSize (width, 1); + + using Markdown renderView = new () + { + Text = text, + Width = Dim.Fill (), + Height = Dim.Fill (), + SyntaxHighlighter = SyntaxHighlighter, + MarkdownPipeline = MarkdownPipeline, + UseThemeBackground = UseThemeBackground, + ShowHeadingPrefix = ShowHeadingPrefix, + ShowCopyButtons = false // Copy buttons are interactive-only + }; + + renderView.App = app; + renderView.SetRelativeLayout (app.Screen.Size); + renderView.Layout (); + + int contentHeight = renderView.GetContentHeight (); + + if (contentHeight < 1) + { + return string.Empty; + } + + // Resize to the full content height so the entire document is drawn + app.Driver.SetScreenSize (width, contentHeight); + renderView.Frame = app.Screen with { X = 0, Y = 0 }; + renderView.Layout (); + app.Driver.ClearContents (); + renderView.Draw (); + + return app.Driver.ToAnsi (); + } + finally + { + Environment.SetEnvironmentVariable ("DisableRealDriverIO", previousValue); + } + } + } +} diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownRenderToAnsiTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownRenderToAnsiTests.cs new file mode 100644 index 0000000000..2c4ab4afe6 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownRenderToAnsiTests.cs @@ -0,0 +1,123 @@ +using JetBrains.Annotations; +using Terminal.Gui.Drawing; + +namespace ViewsTests.Markdown; + +// Copilot + +[TestSubject (typeof (Terminal.Gui.Views.Markdown))] +public class MarkdownRenderToAnsiTests +{ + [Fact] + public void RenderToAnsi_BasicMarkdown_ReturnsNonEmptyAnsi () + { + Terminal.Gui.Views.Markdown view = new () { Text = "# Hello\n\nWorld" }; + + string result = view.RenderToAnsi (); + + Assert.NotEmpty (result); + // ANSI escape sequences start with ESC [ + Assert.Contains ("\x1b[", result); + // The text content should be present + Assert.Contains ("Hello", result); + Assert.Contains ("World", result); + } + + [Fact] + public void RenderToAnsi_WithMarkdownParameter_OverridesText () + { + Terminal.Gui.Views.Markdown view = new () { Text = "Original" }; + + string result = view.RenderToAnsi ("**Override**"); + + Assert.Contains ("Override", result); + Assert.DoesNotContain ("Original", result); + } + + [Fact] + public void RenderToAnsi_EmptyText_ReturnsEmpty () + { + Terminal.Gui.Views.Markdown view = new (); + + string result = view.RenderToAnsi (""); + + Assert.Equal (string.Empty, result); + } + + [Fact] + public void RenderToAnsi_NullMarkdownUsesInstanceText () + { + Terminal.Gui.Views.Markdown view = new () { Text = "# Title" }; + + string result = view.RenderToAnsi (null); + + Assert.Contains ("Title", result); + } + + [Fact] + public void RenderToAnsi_WidthAffectsWrapping () + { + string longLine = "This is a very long line that should be wrapped when the width is narrow enough to force wrapping behavior."; + Terminal.Gui.Views.Markdown view = new () { Text = longLine }; + + string narrow = view.RenderToAnsi (width: 20); + string wide = view.RenderToAnsi (width: 200); + + // Narrow output should have more newlines (more lines due to wrapping) + int narrowNewlines = narrow.Split ('\n').Length; + int wideNewlines = wide.Split ('\n').Length; + Assert.True (narrowNewlines > wideNewlines, $"Narrow ({narrowNewlines} lines) should have more lines than wide ({wideNewlines} lines)"); + } + + [Fact] + public void RenderToAnsi_WithSyntaxHighlighter_ProducesOutput () + { + Terminal.Gui.Views.Markdown view = new () + { + Text = "```csharp\nint x = 42;\n```", + SyntaxHighlighter = new TextMateSyntaxHighlighter () + }; + + string result = view.RenderToAnsi (); + + Assert.NotEmpty (result); + Assert.Contains ("42", result); + } + + [Fact] + public void RenderToAnsi_SmallWidth_ClampsToMinimum () + { + Terminal.Gui.Views.Markdown view = new () { Text = "Hello" }; + + // Width below MIN_WRAP_WIDTH (4) should not throw + string result = view.RenderToAnsi (width: 1); + + Assert.NotEmpty (result); + } + + [Fact] + public void RenderToAnsi_DoesNotMutateInstance () + { + Terminal.Gui.Views.Markdown view = new () { Text = "# Original" }; + + _ = view.RenderToAnsi ("# Different"); + + // Original text should be unchanged + Assert.Equal ("# Original", view.Text); + } + + [Fact] + public void RenderToAnsi_UseThemeBackground_False_ProducesOutput () + { + Terminal.Gui.Views.Markdown view = new () + { + Text = "# Hello", + UseThemeBackground = false + }; + + string result = view.RenderToAnsi (); + + Assert.NotEmpty (result); + Assert.Contains ("Hello", result); + } +}