Skip to content

Commit cf6457c

Browse files
authored
Merge pull request #5388 from gui-cs/tig/add-markdown-render-to-ansi-api
Fixes #5385. Add Markdown.RenderToAnsi() API for headless ANSI rendering
2 parents ae69090 + 065380d commit cf6457c

2 files changed

Lines changed: 230 additions & 0 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
using Terminal.Gui.App;
2+
using Terminal.Gui.Drivers;
3+
4+
namespace Terminal.Gui.Views;
5+
6+
public partial class Markdown
7+
{
8+
private const int MAX_RENDER_WIDTH = 4096;
9+
private static readonly Lock _renderToAnsiLock = new ();
10+
11+
/// <summary>
12+
/// Renders the current <see cref="Text"/> (or the supplied <paramref name="markdown"/>)
13+
/// to an ANSI escape-sequence string suitable for writing directly to a terminal.
14+
/// </summary>
15+
/// <param name="markdown">
16+
/// Optional markdown text to render. If <see langword="null"/>, uses the current <see cref="Text"/>.
17+
/// </param>
18+
/// <param name="width">
19+
/// The target column width for word-wrapping. Defaults to 80. Clamped to
20+
/// the range [<see cref="MIN_WRAP_WIDTH"/>, 4096].
21+
/// </param>
22+
/// <returns>A string containing ANSI escape sequences that reproduce the styled markdown output.</returns>
23+
/// <remarks>
24+
/// <para>
25+
/// This method does not require <see cref="Application"/> to be initialized. It creates a
26+
/// temporary headless ANSI driver internally, performs layout and drawing into an off-screen
27+
/// buffer, and returns the ANSI representation.
28+
/// </para>
29+
/// <para>
30+
/// Configuration properties set on this instance — <see cref="SyntaxHighlighter"/>,
31+
/// <see cref="MarkdownPipeline"/>, <see cref="UseThemeBackground"/>, and
32+
/// <see cref="ShowHeadingPrefix"/> — are copied to the temporary view used for rendering.
33+
/// Copy buttons (<see cref="ShowCopyButtons"/>) are always disabled for ANSI output because
34+
/// they are interactive-only controls.
35+
/// </para>
36+
/// </remarks>
37+
public string RenderToAnsi (string? markdown = null, int width = 80)
38+
{
39+
if (width < MIN_WRAP_WIDTH)
40+
{
41+
width = MIN_WRAP_WIDTH;
42+
}
43+
else if (width > MAX_RENDER_WIDTH)
44+
{
45+
width = MAX_RENDER_WIDTH;
46+
}
47+
48+
string text = markdown ?? Text;
49+
50+
if (string.IsNullOrEmpty (text))
51+
{
52+
return string.Empty;
53+
}
54+
55+
// A static lock guards the process-wide DisableRealDriverIO environment variable
56+
// so concurrent calls do not race on set/restore.
57+
lock (_renderToAnsiLock)
58+
{
59+
string? previousValue = Environment.GetEnvironmentVariable ("DisableRealDriverIO");
60+
Environment.SetEnvironmentVariable ("DisableRealDriverIO", "1");
61+
62+
try
63+
{
64+
using IApplication app = Application.Create ().Init (DriverRegistry.Names.ANSI);
65+
66+
// Use a small initial height for the first layout pass (just enough to compute content height)
67+
app.Driver!.SetScreenSize (width, 1);
68+
69+
using Markdown renderView = new ()
70+
{
71+
Text = text,
72+
Width = Dim.Fill (),
73+
Height = Dim.Fill (),
74+
SyntaxHighlighter = SyntaxHighlighter,
75+
MarkdownPipeline = MarkdownPipeline,
76+
UseThemeBackground = UseThemeBackground,
77+
ShowHeadingPrefix = ShowHeadingPrefix,
78+
ShowCopyButtons = false // Copy buttons are interactive-only
79+
};
80+
81+
renderView.App = app;
82+
renderView.SetRelativeLayout (app.Screen.Size);
83+
renderView.Layout ();
84+
85+
int contentHeight = renderView.GetContentHeight ();
86+
87+
if (contentHeight < 1)
88+
{
89+
return string.Empty;
90+
}
91+
92+
// Resize to the full content height so the entire document is drawn
93+
app.Driver.SetScreenSize (width, contentHeight);
94+
renderView.Frame = app.Screen with { X = 0, Y = 0 };
95+
renderView.Layout ();
96+
app.Driver.ClearContents ();
97+
renderView.Draw ();
98+
99+
return app.Driver.ToAnsi ();
100+
}
101+
finally
102+
{
103+
Environment.SetEnvironmentVariable ("DisableRealDriverIO", previousValue);
104+
}
105+
}
106+
}
107+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
using JetBrains.Annotations;
2+
using Terminal.Gui.Drawing;
3+
4+
namespace ViewsTests.Markdown;
5+
6+
// Copilot
7+
8+
[TestSubject (typeof (Terminal.Gui.Views.Markdown))]
9+
public class MarkdownRenderToAnsiTests
10+
{
11+
[Fact]
12+
public void RenderToAnsi_BasicMarkdown_ReturnsNonEmptyAnsi ()
13+
{
14+
Terminal.Gui.Views.Markdown view = new () { Text = "# Hello\n\nWorld" };
15+
16+
string result = view.RenderToAnsi ();
17+
18+
Assert.NotEmpty (result);
19+
// ANSI escape sequences start with ESC [
20+
Assert.Contains ("\x1b[", result);
21+
// The text content should be present
22+
Assert.Contains ("Hello", result);
23+
Assert.Contains ("World", result);
24+
}
25+
26+
[Fact]
27+
public void RenderToAnsi_WithMarkdownParameter_OverridesText ()
28+
{
29+
Terminal.Gui.Views.Markdown view = new () { Text = "Original" };
30+
31+
string result = view.RenderToAnsi ("**Override**");
32+
33+
Assert.Contains ("Override", result);
34+
Assert.DoesNotContain ("Original", result);
35+
}
36+
37+
[Fact]
38+
public void RenderToAnsi_EmptyText_ReturnsEmpty ()
39+
{
40+
Terminal.Gui.Views.Markdown view = new ();
41+
42+
string result = view.RenderToAnsi ("");
43+
44+
Assert.Equal (string.Empty, result);
45+
}
46+
47+
[Fact]
48+
public void RenderToAnsi_NullMarkdownUsesInstanceText ()
49+
{
50+
Terminal.Gui.Views.Markdown view = new () { Text = "# Title" };
51+
52+
string result = view.RenderToAnsi (null);
53+
54+
Assert.Contains ("Title", result);
55+
}
56+
57+
[Fact]
58+
public void RenderToAnsi_WidthAffectsWrapping ()
59+
{
60+
string longLine = "This is a very long line that should be wrapped when the width is narrow enough to force wrapping behavior.";
61+
Terminal.Gui.Views.Markdown view = new () { Text = longLine };
62+
63+
string narrow = view.RenderToAnsi (width: 20);
64+
string wide = view.RenderToAnsi (width: 200);
65+
66+
// Narrow output should have more newlines (more lines due to wrapping)
67+
int narrowNewlines = narrow.Split ('\n').Length;
68+
int wideNewlines = wide.Split ('\n').Length;
69+
Assert.True (narrowNewlines > wideNewlines, $"Narrow ({narrowNewlines} lines) should have more lines than wide ({wideNewlines} lines)");
70+
}
71+
72+
[Fact]
73+
public void RenderToAnsi_WithSyntaxHighlighter_ProducesOutput ()
74+
{
75+
Terminal.Gui.Views.Markdown view = new ()
76+
{
77+
Text = "```csharp\nint x = 42;\n```",
78+
SyntaxHighlighter = new TextMateSyntaxHighlighter ()
79+
};
80+
81+
string result = view.RenderToAnsi ();
82+
83+
Assert.NotEmpty (result);
84+
Assert.Contains ("42", result);
85+
}
86+
87+
[Fact]
88+
public void RenderToAnsi_SmallWidth_ClampsToMinimum ()
89+
{
90+
Terminal.Gui.Views.Markdown view = new () { Text = "Hello" };
91+
92+
// Width below MIN_WRAP_WIDTH (4) should not throw
93+
string result = view.RenderToAnsi (width: 1);
94+
95+
Assert.NotEmpty (result);
96+
}
97+
98+
[Fact]
99+
public void RenderToAnsi_DoesNotMutateInstance ()
100+
{
101+
Terminal.Gui.Views.Markdown view = new () { Text = "# Original" };
102+
103+
_ = view.RenderToAnsi ("# Different");
104+
105+
// Original text should be unchanged
106+
Assert.Equal ("# Original", view.Text);
107+
}
108+
109+
[Fact]
110+
public void RenderToAnsi_UseThemeBackground_False_ProducesOutput ()
111+
{
112+
Terminal.Gui.Views.Markdown view = new ()
113+
{
114+
Text = "# Hello",
115+
UseThemeBackground = false
116+
};
117+
118+
string result = view.RenderToAnsi ();
119+
120+
Assert.NotEmpty (result);
121+
Assert.Contains ("Hello", result);
122+
}
123+
}

0 commit comments

Comments
 (0)