diff --git a/Directory.Packages.props b/Directory.Packages.props
index 1da5e82e0c..5d4cd4464d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -37,7 +37,7 @@
-
+
diff --git a/Terminal.Gui/Drawing/Markdown/MarkdownAttributeHelper.cs b/Terminal.Gui/Drawing/Markdown/MarkdownAttributeHelper.cs
index 134bc7b28e..95084b2f48 100644
--- a/Terminal.Gui/Drawing/Markdown/MarkdownAttributeHelper.cs
+++ b/Terminal.Gui/Drawing/Markdown/MarkdownAttributeHelper.cs
@@ -27,6 +27,13 @@ internal static class MarkdownAttributeHelper
///
/// A fully resolved ready for drawing.
public static Attribute GetAttributeForSegment (View view, StyledSegment segment, ISyntaxHighlighter? highlighter = null, Color? themeBackground = null)
+ {
+ Attribute attribute = ResolveSegmentAttribute (view, segment, highlighter, themeBackground);
+
+ return attribute with { Style = attribute.Style | segment.AdditionalStyle };
+ }
+
+ private static Attribute ResolveSegmentAttribute (View view, StyledSegment segment, ISyntaxHighlighter? highlighter, Color? themeBackground)
{
if (segment.Role is { } role)
{
@@ -116,7 +123,7 @@ public static List ToStyledSegments (IReadOnlyList run
foreach (InlineRun run in runs)
{
- segments.Add (new StyledSegment (run.Text, run.StyleRole, run.Url, run.ImageSource, run.Attribute, run.Role));
+ segments.Add (run.ToStyledSegment ());
}
return segments;
diff --git a/Terminal.Gui/Drawing/Markdown/StyledSegment.cs b/Terminal.Gui/Drawing/Markdown/StyledSegment.cs
index f24b40fb59..9fecb708bc 100644
--- a/Terminal.Gui/Drawing/Markdown/StyledSegment.cs
+++ b/Terminal.Gui/Drawing/Markdown/StyledSegment.cs
@@ -61,4 +61,13 @@ public StyledSegment (
/// if the role should be resolved from .
///
public VisualRole? Role { get; }
+
+ // Preserve nested Markdown styles without fixing colors before the view's scheme is resolved.
+ internal TextStyle AdditionalStyle { get; init; }
+
+ internal StyledSegment WithText (string text, VisualRole? role = null) =>
+ new (text, StyleRole, Url, ImageSource, Attribute, role ?? Role)
+ {
+ AdditionalStyle = AdditionalStyle
+ };
}
diff --git a/Terminal.Gui/Views/CodeRoleLegendHighlighter.cs b/Terminal.Gui/Views/CodeRoleLegendHighlighter.cs
index e463c19a7c..a16fa4a359 100644
--- a/Terminal.Gui/Views/CodeRoleLegendHighlighter.cs
+++ b/Terminal.Gui/Views/CodeRoleLegendHighlighter.cs
@@ -41,7 +41,7 @@ public IReadOnlyList Highlight (string code, string? language)
public Attribute? GetAttributeForScope (MarkdownStyleRole role) => _textMate.GetAttributeForScope (role);
- private static void AddRoleSegments (List segments, StyledSegment segment)
+ internal static void AddRoleSegments (List segments, StyledSegment segment)
{
int index = 0;
@@ -77,6 +77,6 @@ private static (string Text, VisualRole Role) FindRoleMarker (string text, int i
private static void AddSegment (List segments, StyledSegment source, string text, VisualRole? role)
{
- segments.Add (new StyledSegment (text, source.StyleRole, source.Url, source.ImageSource, source.Attribute, role));
+ segments.Add (source.WithText (text, role));
}
}
diff --git a/Terminal.Gui/Views/Markdown/InlineRun.cs b/Terminal.Gui/Views/Markdown/InlineRun.cs
index ff3dca22ac..c33518f9bb 100644
--- a/Terminal.Gui/Views/Markdown/InlineRun.cs
+++ b/Terminal.Gui/Views/Markdown/InlineRun.cs
@@ -1,17 +1,24 @@
namespace Terminal.Gui.Views;
-internal sealed class InlineRun (
- string text,
- MarkdownStyleRole styleRole,
- string? url = null,
- string? imageSource = null,
- Attribute? attribute = null,
- VisualRole? role = null)
+internal sealed record InlineRun (
+ string Text,
+ MarkdownStyleRole StyleRole,
+ string? Url = null,
+ string? ImageSource = null,
+ Attribute? Attribute = null,
+ VisualRole? Role = null)
{
- public string Text { get; } = text;
- public MarkdownStyleRole StyleRole { get; } = styleRole;
- public string? Url { get; } = url;
- public string? ImageSource { get; } = imageSource;
- public Attribute? Attribute { get; } = attribute;
- public VisualRole? Role { get; } = role;
+ public TextStyle AdditionalStyle { get; init; }
+
+ public StyledSegment ToStyledSegment (string? text = null) =>
+ new (text ?? Text, StyleRole, Url, ImageSource, Attribute, Role)
+ {
+ AdditionalStyle = AdditionalStyle
+ };
+
+ public static InlineRun FromStyledSegment (StyledSegment segment) =>
+ new (segment.Text, segment.StyleRole, segment.Url, segment.ImageSource, segment.Attribute, segment.Role)
+ {
+ AdditionalStyle = segment.AdditionalStyle
+ };
}
diff --git a/Terminal.Gui/Views/Markdown/IntermediateBlock.cs b/Terminal.Gui/Views/Markdown/IntermediateBlock.cs
index 90eb42c686..c7c7eac176 100644
--- a/Terminal.Gui/Views/Markdown/IntermediateBlock.cs
+++ b/Terminal.Gui/Views/Markdown/IntermediateBlock.cs
@@ -8,11 +8,14 @@ internal sealed class IntermediateBlock (IReadOnlyList runs,
string? anchor = null,
bool isThematicBreak = false,
TableData? tableData = null,
- string? language = null)
+ string? language = null,
+ MarkdownTableContent? tableContent = null,
+ string? sourceText = null)
{
public IReadOnlyList Runs { get; } = runs;
public bool Wrap { get; } = wrap;
public string Prefix { get; } = prefix;
+ public string? SourceText { get; } = sourceText;
public string ContinuationPrefix { get; } = continuationPrefix;
public bool IsCodeBlock { get; } = isCodeBlock;
public bool IsThematicBreak { get; } = isThematicBreak;
@@ -20,6 +23,8 @@ internal sealed class IntermediateBlock (IReadOnlyList runs,
/// Gets the parsed table data if this block represents a table; otherwise .
public TableData? TableData { get; } = tableData;
+ public MarkdownTableContent? TableContent { get; } = tableContent;
+
/// Gets whether this block represents a Markdown table.
public bool IsTable => TableData is { };
diff --git a/Terminal.Gui/Views/Markdown/Markdown.cs b/Terminal.Gui/Views/Markdown/Markdown.cs
index 1f2ed440dc..9193d04bd9 100644
--- a/Terminal.Gui/Views/Markdown/Markdown.cs
+++ b/Terminal.Gui/Views/Markdown/Markdown.cs
@@ -532,6 +532,8 @@ bool IDesignable.EnableForDesign ()
* [Basic Formatting](#basic-formatting)
* [Links](#links)
* [Checklist](#checklist)
+ * [Definition Lists](#definition-lists)
+ * [Hard Line Breaks](#hard-line-breaks)
* [Code Blocks](#code-blocks)
* [Tables](#tables)
* [Separators](#separators)
@@ -588,6 +590,9 @@ Plain text. *Formatted text* with **bold** and `inline code`.
| [Links](https://tui-cs.github.io/Terminal.Gui/api/Terminal.Gui.Views.MarkdownTable.html) | โ
Totally! |
| Inline `code` | โ
*Awesome!* |
| Emojis ๐ | โ
**Whoa!** |
+ | Code with a backtick | ``a`b`` |
+ | Nested formatting | **bold *and italic*** with ~~strikethrough~~ |
+ | Nested link label | [Testing [Header]](https://example.com/path_(part)) |
**table** with different alignments:
@@ -608,6 +613,9 @@ And this text is after. Thematic breaks are rendered as full-width horizontal li
> **Tip:** This is a block quote with *inline formatting*.
+ > [!WARNING]
+ > Alert kinds remain visible alongside their content.
+
Here's a multi-line block quote with a link, code, and more:
> **Tip:** Block quotes can contain *inline formatting*, **bold text**,
@@ -615,6 +623,16 @@ And this text is after. Thematic breaks are rendered as full-width horizontal li
>
> They can also span multiple lines with blank quote lines between paragraphs.
+ ## Definition Lists
+
+ Terminal.Gui
+ : A toolkit for terminal user interfaces.
+
+ ## Hard Line Breaks
+
+ This line ends with a backslash.\
+ This starts on a new line without a new paragraph.
+
That's all folks! ๐
""";
}
diff --git a/Terminal.Gui/Views/Markdown/MarkdownInlineParser.cs b/Terminal.Gui/Views/Markdown/MarkdownInlineParser.cs
index bfb1254e66..298a7cb21b 100644
--- a/Terminal.Gui/Views/Markdown/MarkdownInlineParser.cs
+++ b/Terminal.Gui/Views/Markdown/MarkdownInlineParser.cs
@@ -1,205 +1,50 @@
+using Markdig;
+using Markdig.Helpers;
+using Markdig.Parsers;
+using Markdig.Syntax;
+
namespace Terminal.Gui.Views;
-///
-/// Stateless parser for inline Markdown formatting (bold, italic, code, links, images).
-/// Used by both and to parse
-/// inline content from raw Markdown text.
-///
+/// Parse standalone table-cell Markdown with the same inline syntax and lowering as AST-backed tables.
internal static class MarkdownInlineParser
{
- ///
- /// Parses inline Markdown tokens from and returns a list of
- /// segments with their associated .
- ///
- /// The raw Markdown text to parse.
- ///
- /// The to assign to plain text segments that are not
- /// wrapped in any Markdown formatting.
- ///
- /// An ordered list of inline runs covering the entire input text.
- public static List ParseInlines (string text, MarkdownStyleRole defaultRole)
- {
- List runs = [];
- var idx = 0;
-
- while (idx < text.Length)
- {
- if (TryParseImage (text, idx, out InlineRun? imageRun, out int imageLen))
- {
- runs.Add (imageRun!);
- idx += imageLen;
-
- continue;
- }
-
- if (TryParseLink (text, idx, out InlineRun? linkRun, out int linkLen))
- {
- runs.Add (linkRun!);
- idx += linkLen;
-
- continue;
- }
-
- if (TryParseDelimited (text, idx, "`", MarkdownStyleRole.InlineCode, out InlineRun? codeRun, out int codeLen))
- {
- runs.Add (codeRun!);
- idx += codeLen;
-
- continue;
- }
-
- if (TryParseDelimited (text, idx, "**", MarkdownStyleRole.Strong, out InlineRun? strongRun, out int strongLen))
- {
- runs.Add (strongRun!);
- idx += strongLen;
-
- continue;
- }
-
- if (TryParseDelimited (text, idx, "*", MarkdownStyleRole.Emphasis, out InlineRun? emRun, out int emLen))
- {
- runs.Add (emRun!);
- idx += emLen;
-
- continue;
- }
-
- int nextSpecial = FindNextSpecialToken (text, idx);
-
- // If the next special token is at the current position, no TryParse could consume it.
- // Emit the character as plain text and advance past it to avoid an infinite loop.
- if (nextSpecial == idx)
- {
- runs.Add (new InlineRun (text [idx].ToString (), defaultRole));
- idx++;
-
- continue;
- }
+ private static readonly InlineParserList _parsers = CreateInlineParsers ();
- string plainText = nextSpecial == -1 ? text [idx..] : text.Substring (idx, nextSpecial - idx);
-
- runs.Add (new InlineRun (plainText, defaultRole));
-
- if (nextSpecial == -1)
- {
- break;
- }
-
- idx = nextSpecial;
- }
-
- return runs;
- }
-
- private static bool TryParseDelimited (string text, int start, string delimiter, MarkdownStyleRole role, out InlineRun? run, out int tokenLength)
+ private static InlineParserList CreateInlineParsers ()
{
- run = null;
- tokenLength = 0;
-
- if (!text.AsSpan (start).StartsWith (delimiter.AsSpan (), StringComparison.Ordinal))
- {
- return false;
- }
-
- int end = text.IndexOf (delimiter, start + delimiter.Length, StringComparison.Ordinal);
-
- if (end <= start + delimiter.Length)
- {
- return false;
- }
+ MarkdownPipelineBuilder builder = new ();
+ builder.UseAdvancedExtensions ();
+ builder.Build ();
- string content = text.Substring (start + delimiter.Length, end - start - delimiter.Length);
- run = new InlineRun (content, role);
- tokenLength = end - start + delimiter.Length;
-
- return true;
+ return new InlineParserList (builder.InlineParsers);
}
- private static bool TryParseLink (string text, int start, out InlineRun? run, out int tokenLength)
- {
- run = null;
- tokenLength = 0;
-
- if (start >= text.Length || text [start] != '[')
- {
- return false;
- }
-
- int closeText = text.IndexOf (']', start + 1);
-
- if (closeText < 0 || closeText + 1 >= text.Length || text [closeText + 1] != '(')
- {
- return false;
- }
-
- int closeUrl = text.IndexOf (')', closeText + 2);
-
- if (closeUrl < 0)
- {
- return false;
- }
-
- string linkText = text.Substring (start + 1, closeText - start - 1);
- string linkUrl = text.Substring (closeText + 2, closeUrl - closeText - 2);
-
- run = new InlineRun (linkText, MarkdownStyleRole.Link, linkUrl);
- tokenLength = closeUrl - start + 1;
-
- return true;
- }
-
- private static bool TryParseImage (string text, int start, out InlineRun? run, out int tokenLength)
+ /// Parse inline Markdown source into display runs with semantic styles and link metadata.
+ /// The cell's Markdown inline source, including any backslash escapes.
+ /// The semantic style for unformatted text.
+ /// The display runs for the cell.
+ public static List ParseInlines (string text, MarkdownStyleRole defaultRole)
{
- run = null;
- tokenLength = 0;
-
- if (start + 1 >= text.Length || text [start] != '!' || text [start + 1] != '[')
- {
- return false;
- }
-
- int closeAlt = text.IndexOf (']', start + 2);
-
- if (closeAlt < 0 || closeAlt + 1 >= text.Length || text [closeAlt + 1] != '(')
+ MarkdownDocument document = new ();
+ ParagraphBlock paragraph = new ()
{
- return false;
- }
+ Lines = new StringLineGroup (text)
+ };
+ document.Add (paragraph);
- int closeSrc = text.IndexOf (')', closeAlt + 2);
+ // Retain spans so unsupported inline leaves can fall back to their original source.
+ InlineProcessor processor = new (document, _parsers, true, null);
- if (closeSrc < 0)
+ try
{
- return false;
+ // Process only inlines: leading '#' or '>' in a cell must remain ordinary text.
+ processor.ProcessInlineLeaf (paragraph);
}
-
- string alt = text.Substring (start + 2, closeAlt - start - 2);
- string source = text.Substring (closeAlt + 2, closeSrc - closeAlt - 2);
-
- run = new InlineRun (MarkdownImageResolver.GetFallbackText (alt), MarkdownStyleRole.ImageAlt, imageSource: source);
- tokenLength = closeSrc - start + 1;
-
- return true;
- }
-
- private static int FindNextSpecialToken (string text, int start)
- {
- int [] indexes = [text.IndexOf ('!', start), text.IndexOf ('[', start), text.IndexOf ('`', start), text.IndexOf ('*', start)];
-
- int next = -1;
-
- foreach (int idx in indexes)
+ catch (ArgumentException)
{
- if (idx < 0)
- {
- continue;
- }
-
- if (next == -1 || idx < next)
- {
- next = idx;
- }
+ return [new InlineRun (text, defaultRole)];
}
- return next;
+ return Markdown.LowerCellInlines (paragraph.Inline?.FirstChild, defaultRole, text);
}
}
diff --git a/Terminal.Gui/Views/Markdown/MarkdownTable.cs b/Terminal.Gui/Views/Markdown/MarkdownTable.cs
index 0f22da723e..c96e4d2ea3 100644
--- a/Terminal.Gui/Views/Markdown/MarkdownTable.cs
+++ b/Terminal.Gui/Views/Markdown/MarkdownTable.cs
@@ -15,7 +15,8 @@ namespace Terminal.Gui.Views;
///
///
/// Header cells are bold; body cells support inline Markdown formatting (bold, italic, code,
-/// links) via . Column alignment (left, center, right)
+/// links). Tables created by retain parsed inline segments; standalone
+/// text uses . Column alignment (left, center, right)
/// parsed from the Markdown separator row is respected. Cells that exceed their column width
/// are word-wrapped, and row heights expand to accommodate wrapped content.
///
@@ -26,6 +27,8 @@ namespace Terminal.Gui.Views;
///
public sealed class MarkdownTable : View, IDesignable
{
+ internal MarkdownTableContent Content { get; private set; } = new ([], []);
+
private TableData _data = _emptyData;
private int [] _columnWidths = [];
@@ -150,27 +153,23 @@ private string BuildTableText ()
public TableData TableData
{
get => _data;
- set
- {
- _data = value;
- _headerSegments = ParseCellSegments (value.Headers, MarkdownStyleRole.Heading);
- _rowSegments = new List [value.Rows.Length] [];
-
- for (var r = 0; r < value.Rows.Length; r++)
- {
- _rowSegments [r] = ParseCellSegments (value.Rows [r], MarkdownStyleRole.Normal);
- }
-
- // Keep Text in sync with TableData
- SetTextDirect (BuildTableText ());
+ set => SetContent (value, null, Frame.Width > 0 ? Frame.Width : 80);
+ }
- // Compute initial layout using current Frame width (or a default for standalone use)
- int initialWidth = Frame.Width > 0 ? Frame.Width : 80;
- _lastComputedWidth = -1;
- Recalculate (initialWidth);
- SetNeedsLayout ();
- SetNeedsDraw ();
- }
+ internal void SetContent (TableData data, MarkdownTableContent? content, int width)
+ {
+ _data = data;
+ Content = content ?? new (ParseCellSegments (data.Headers, MarkdownStyleRole.Heading),
+ [.. data.Rows.Select (row => ParseCellSegments (row, MarkdownStyleRole.Normal))]);
+ _headerSegments = Content.Headers;
+ _rowSegments = Content.Rows;
+
+ // Keep Text in sync without parsing or laying out discarded intermediate content.
+ SetTextDirect (BuildTableText ());
+ _lastComputedWidth = -1;
+ Recalculate (width);
+ SetNeedsLayout ();
+ SetNeedsDraw ();
}
/// Recalculates column widths and row heights for the given available width.
@@ -182,7 +181,7 @@ internal void Recalculate (int maxWidth)
}
_lastComputedWidth = maxWidth;
- _columnWidths = ComputeColumnWidths (_data, maxWidth);
+ _columnWidths = ComputeColumnWidths (Content, maxWidth);
_headerRowHeight = ComputeRowHeight (_headerSegments, _columnWidths);
@@ -710,74 +709,55 @@ internal static List> WrapSegments (List segm
List> lines = [];
List currentLine = [];
+ List word = [];
var currentWidth = 0;
foreach (StyledSegment segment in segments)
{
- string remaining = segment.Text;
+ var start = 0;
- while (remaining.Length > 0)
+ for (var index = 0; index < segment.Text.Length; index++)
{
- int spaceIdx = remaining.IndexOf (' ');
- string word;
- string trailing;
+ char character = segment.Text [index];
- if (spaceIdx >= 0)
+ if (character is not (' ' or '\t' or '\n'))
{
- word = remaining [..spaceIdx];
- trailing = " ";
- remaining = remaining [(spaceIdx + 1)..];
+ continue;
}
- else
+
+ if (index > start)
{
- word = remaining;
- trailing = "";
- remaining = "";
+ word.Add (segment.WithText (segment.Text [start..index]));
}
- string chunk = word + trailing;
- int chunkWidth = chunk.GetColumns ();
+ FlushWord ();
+ start = index + 1;
- // If adding this word would exceed the line, wrap
- if (currentWidth > 0 && currentWidth + chunkWidth > maxWidth)
+ if (character == '\n')
{
- lines.Add (currentLine);
- currentLine = [];
- currentWidth = 0;
+ FlushLine ();
+
+ continue;
}
- // If a single word is wider than maxWidth, hard-break it
- if (chunkWidth > maxWidth && currentWidth == 0)
+ if (currentWidth < maxWidth)
{
- string hardChunk = TruncateToWidth (chunk, maxWidth);
-
- // If maxWidth is too narrow for even one grapheme, take the first grapheme
- // to guarantee forward progress and avoid an infinite loop.
- if (hardChunk.Length == 0)
- {
- string firstGrapheme = GraphemeHelper.GetGraphemes (chunk).FirstOrDefault () ?? chunk [..1];
- hardChunk = firstGrapheme;
- }
-
- currentLine.Add (new StyledSegment (hardChunk, segment.StyleRole, segment.Url, segment.ImageSource, segment.Attribute, segment.Role));
- lines.Add (currentLine);
- currentLine = [];
- currentWidth = 0;
-
- int usedChars = hardChunk.TrimEnd ().Length;
- remaining = chunk [usedChars..].TrimStart () + remaining;
-
- continue;
+ currentLine.Add (segment.WithText (" "));
+ currentWidth++;
}
+ }
- currentLine.Add (new StyledSegment (chunk, segment.StyleRole, segment.Url, segment.ImageSource, segment.Attribute, segment.Role));
- currentWidth += chunkWidth;
+ if (start < segment.Text.Length)
+ {
+ word.Add (segment.WithText (segment.Text [start..]));
}
}
+ FlushWord ();
+
if (currentLine.Count > 0)
{
- lines.Add (currentLine);
+ FlushLine ();
}
if (lines.Count == 0)
@@ -786,6 +766,60 @@ internal static List> WrapSegments (List segm
}
return lines;
+
+ void FlushLine ()
+ {
+ lines.Add (currentLine);
+ currentLine = [];
+ currentWidth = 0;
+ }
+
+ void FlushWord ()
+ {
+ if (word.Count == 0)
+ {
+ return;
+ }
+
+ int wordWidth = string.Concat (word.Select (part => part.Text)).GetColumns ();
+
+ if (currentWidth > 0 && currentWidth + wordWidth > maxWidth)
+ {
+ FlushLine ();
+ }
+
+ foreach (StyledSegment part in word)
+ {
+ string remaining = part.Text;
+
+ while (remaining.Length > 0)
+ {
+ int remainingWidth = remaining.GetColumns ();
+ string chunk = remainingWidth == 0 || remainingWidth <= maxWidth - currentWidth
+ ? remaining
+ : TruncateToWidth (remaining, maxWidth - currentWidth);
+
+ if (chunk.Length == 0 && currentWidth > 0)
+ {
+ FlushLine ();
+
+ continue;
+ }
+
+ // Keep a wide grapheme intact even when the column is narrower than it.
+ if (chunk.Length == 0)
+ {
+ chunk = GraphemeHelper.GetGraphemes (remaining).First ();
+ }
+
+ currentLine.Add (part.WithText (chunk));
+ currentWidth += chunk.GetColumns ();
+ remaining = remaining [chunk.Length..];
+ }
+ }
+
+ word.Clear ();
+ }
}
private static List [] ParseCellSegments (string [] cells, MarkdownStyleRole defaultRole)
@@ -833,7 +867,7 @@ private static int CalculateTableHeightWrapped (int headerHeight, int [] bodyRow
///
/// Computes column widths using a Rich-style collapse algorithm:
///
- /// - Measure each column's min (longest word + padding) and max (full content + padding) widths.
+ /// - Use each column's cached min (longest word + padding) and max (full content + padding) widths.
/// - If total max fits within , use max widths.
/// -
/// Otherwise, iteratively collapse the widest column toward the second-widest.
@@ -843,31 +877,11 @@ private static int CalculateTableHeightWrapped (int headerHeight, int [] bodyRow
///
- Last resort: reduce all columns evenly if still over budget.
///
///
- internal static int [] ComputeColumnWidths (TableData data, int maxWidth)
+ internal static int [] ComputeColumnWidths (MarkdownTableContent content, int maxWidth)
{
- int cols = data.ColumnCount;
- var maxWidths = new int [cols];
- var minWidths = new int [cols];
-
- // Measure max (full content) and min (longest word) for each column
- for (var c = 0; c < cols; c++)
- {
- int headerMax = MeasureRenderedWidth (data.Headers [c]);
- int headerMin = MeasureLongestWord (data.Headers [c]);
- maxWidths [c] = headerMax;
- minWidths [c] = headerMin;
- }
-
- foreach (string [] row in data.Rows)
- {
- for (var c = 0; c < cols && c < row.Length; c++)
- {
- int cellMax = MeasureRenderedWidth (row [c]);
- int cellMin = MeasureLongestWord (row [c]);
- maxWidths [c] = Math.Max (maxWidths [c], cellMax);
- minWidths [c] = Math.Max (minWidths [c], cellMin);
- }
- }
+ int cols = content.Headers.Length;
+ int [] maxWidths = [.. content.MaximumWidths];
+ int [] minWidths = [.. content.MinimumWidths];
// Add padding (1 space each side)
for (var c = 0; c < cols; c++)
@@ -1042,41 +1056,17 @@ private static void ReduceEvenly (int [] widths, int [] minWidths, int excess)
}
}
- /// Measures the display width of a cell's rendered text (stripping markdown formatting).
- internal static int MeasureRenderedWidth (string cellText)
- {
- List runs = MarkdownInlineParser.ParseInlines (cellText, MarkdownStyleRole.Normal);
- var width = 0;
-
- foreach (InlineRun run in runs)
- {
- width += run.Text.GetColumns ();
- }
+ /// Measure the display width of a cell's styled content.
+ internal static int MeasureRenderedWidth (IReadOnlyList segments) =>
+ Math.Max (string.Concat (segments.Select (segment => segment.Text)).Split ('\n').Max (line => line.GetColumns ()), 1);
- return Math.Max (width, 1);
- }
-
- ///
- /// Measures the display width of the longest single word in a cell's rendered text.
- /// This determines the minimum column width (below which words would be hard-broken).
- ///
- internal static int MeasureLongestWord (string cellText)
+ /// Measure the longest displayed word across inline style boundaries.
+ internal static int MeasureLongestWord (IReadOnlyList segments)
{
- List runs = MarkdownInlineParser.ParseInlines (cellText, MarkdownStyleRole.Normal);
- var longest = 1;
-
- foreach (InlineRun run in runs)
- {
- string [] words = run.Text.Split (' ', StringSplitOptions.RemoveEmptyEntries);
-
- foreach (string word in words)
- {
- int w = word.GetColumns ();
- longest = Math.Max (longest, w);
- }
- }
+ string text = string.Concat (segments.Select (segment => segment.Text));
+ string [] words = text.Split ([' ', '\n', '\t'], StringSplitOptions.RemoveEmptyEntries);
- return longest;
+ return words.Length == 0 ? 1 : Math.Max (words.Max (word => word.GetColumns ()), 1);
}
private static int CalculateLeftPadding (int cellWidth, int textWidth, Alignment alignment)
diff --git a/Terminal.Gui/Views/Markdown/MarkdownTableContent.cs b/Terminal.Gui/Views/Markdown/MarkdownTableContent.cs
new file mode 100644
index 0000000000..2d1154fbdc
--- /dev/null
+++ b/Terminal.Gui/Views/Markdown/MarkdownTableContent.cs
@@ -0,0 +1,33 @@
+namespace Terminal.Gui.Views;
+
+/// Retains parsed table segments and their width bounds independently of the current viewport.
+internal sealed class MarkdownTableContent
+{
+ public MarkdownTableContent (List [] headers, List [] [] rows)
+ {
+ Headers = headers;
+ Rows = rows;
+ MaximumWidths = new int [headers.Length];
+ MinimumWidths = new int [headers.Length];
+
+ for (int column = 0; column < headers.Length; column++)
+ {
+ MaximumWidths [column] = MarkdownTable.MeasureRenderedWidth (headers [column]);
+ MinimumWidths [column] = MarkdownTable.MeasureLongestWord (headers [column]);
+ }
+
+ foreach (List [] row in rows)
+ {
+ for (int column = 0; column < headers.Length && column < row.Length; column++)
+ {
+ MaximumWidths [column] = Math.Max (MaximumWidths [column], MarkdownTable.MeasureRenderedWidth (row [column]));
+ MinimumWidths [column] = Math.Max (MinimumWidths [column], MarkdownTable.MeasureLongestWord (row [column]));
+ }
+ }
+ }
+
+ public List [] Headers { get; }
+ public List [] [] Rows { get; }
+ public int [] MaximumWidths { get; }
+ public int [] MinimumWidths { get; }
+}
diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs
index d275f6ef5f..d9afe952d9 100644
--- a/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs
+++ b/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs
@@ -20,7 +20,7 @@ private void BuildRenderedLines ()
continue;
}
- int width = CalculateWidth (block.Runs.Select (r => new StyledSegment (r.Text, r.StyleRole, r.Url, r.ImageSource, r.Attribute, r.Role)).ToList ());
+ int width = CalculateWidth (MarkdownAttributeHelper.ToStyledSegments (block.Runs));
if (!string.IsNullOrEmpty (block.Prefix))
{
@@ -81,12 +81,11 @@ private void BuildRenderedLines ()
{
SyntaxHighlighter = SyntaxHighlighter,
UseThemeBackground = UseThemeBackground,
- TableData = tableData,
X = 0,
Y = startLine,
Width = Dim.Fill ()
};
- tableView.Recalculate (tableLayoutWidth);
+ tableView.SetContent (tableData, block.TableContent, tableLayoutWidth);
// Capture the rendered height BEFORE Add() โ Add triggers EndInit โ Layout
// which may recalculate the table at a stale content width, corrupting RenderedHeight.
@@ -136,9 +135,12 @@ private void BuildRenderedLines ()
}
List wrapped = WrapBlock (block, viewportWidth);
+ string? sourceText = block.SourceText;
foreach (RenderedLine line in wrapped)
{
+ line.SourceText = sourceText;
+ sourceText = sourceText is null ? null : string.Empty;
_renderedLines.Add (line);
_maxLineWidth = Math.Max (_maxLineWidth, line.Width);
}
@@ -255,14 +257,14 @@ private static RenderedLine CreateUnwrappedLine (IntermediateBlock block)
segments.Add (new StyledSegment (block.Prefix, MarkdownStyleRole.ListMarker));
}
- segments.AddRange (block.Runs.Select (run => new StyledSegment (run.Text, run.StyleRole, run.Url, run.ImageSource, run.Attribute, run.Role)));
+ segments.AddRange (MarkdownAttributeHelper.ToStyledSegments (block.Runs));
int width = CalculateWidth (segments);
return new RenderedLine (segments, false, width, block.IsCodeBlock, block.IsThematicBreak, codeLanguage: block.Language);
}
- private static List WrapBlock (IntermediateBlock block, int viewportWidth)
+ internal static List WrapBlock (IntermediateBlock block, int viewportWidth)
{
List lines = [];
@@ -272,6 +274,7 @@ private static List WrapBlock (IntermediateBlock block, int viewpo
List currentSegments = [];
var currentWidth = 0;
var firstLine = true;
+ var endedWithHardBreak = false;
if (!string.IsNullOrEmpty (firstPrefix))
{
@@ -283,6 +286,29 @@ private static List WrapBlock (IntermediateBlock block, int viewpo
{
foreach (string grapheme in GraphemeHelper.GetGraphemes (run.Text))
{
+ if (grapheme == "\n")
+ {
+ if (currentSegments.Count == 0)
+ {
+ currentSegments.Add (new StyledSegment ("", MarkdownStyleRole.Normal));
+ }
+
+ lines.Add (new RenderedLine ([.. currentSegments], true, currentWidth));
+ currentSegments.Clear ();
+ currentWidth = 0;
+ firstLine = false;
+ endedWithHardBreak = true;
+
+ if (!string.IsNullOrEmpty (continuationPrefix))
+ {
+ currentSegments.Add (new StyledSegment (continuationPrefix, MarkdownStyleRole.ListMarker));
+ currentWidth += continuationPrefix.GetColumns ();
+ }
+
+ continue;
+ }
+
+ endedWithHardBreak = false;
int graphemeWidth = Math.Max (grapheme.GetColumns (), 1);
if (currentWidth + graphemeWidth > viewportWidth && currentSegments.Count > 0)
@@ -357,11 +383,17 @@ private static List WrapBlock (IntermediateBlock block, int viewpo
}
}
- currentSegments.Add (new StyledSegment (grapheme, run.StyleRole, run.Url, run.ImageSource, run.Attribute, run.Role));
+ currentSegments.Add (run.ToStyledSegment (grapheme));
currentWidth += graphemeWidth;
}
}
+ // A terminal hard break ends the current line; it does not start a phantom prefix-only line.
+ if (endedWithHardBreak)
+ {
+ return lines;
+ }
+
if (currentSegments.Count == 0)
{
string prefix = firstLine ? firstPrefix : continuationPrefix;
diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Parsing.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Parsing.cs
index 812271589b..962541cdd2 100644
--- a/Terminal.Gui/Views/Markdown/MarkdownView.Parsing.cs
+++ b/Terminal.Gui/Views/Markdown/MarkdownView.Parsing.cs
@@ -1,10 +1,14 @@
using System.Text.RegularExpressions;
using Markdig;
+using Markdig.Extensions.Abbreviations;
+using Markdig.Extensions.Alerts;
+using Markdig.Extensions.DefinitionLists;
using Markdig.Extensions.Tables;
using Markdig.Extensions.TaskLists;
using Markdig.Helpers;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
+using Microsoft.Extensions.Logging;
namespace Terminal.Gui.Views;
@@ -25,7 +29,31 @@ private void EnsureParsed ()
_blocks.Clear ();
MarkdownPipeline pipeline = MarkdownPipeline ?? _defaultPipeline;
- MarkdownDocument doc = Markdig.Markdown.Parse (_markdown, pipeline);
+ MarkdownDocument doc;
+
+ try
+ {
+ doc = Markdig.Markdown.Parse (_markdown, pipeline);
+ }
+ catch (ArgumentException exception)
+ {
+ // Markdig uses ArgumentException for nesting limits; avoid depending on its message.
+ Logging.Logger.LogWarning (exception, "Markdown parsing failed; rendering the source as literal text.");
+ doc = new MarkdownDocument ();
+ int lineNumber = 0;
+
+ // Lower literal paragraphs through the same state-reset and layout path as parsed input.
+ foreach (string line in _markdown.Replace ("\r\n", "\n", StringComparison.Ordinal).Replace ('\r', '\n').Split ('\n'))
+ {
+ ContainerInline inline = new ();
+ inline.AppendChild (new LiteralInline (line));
+ doc.Add (new ParagraphBlock
+ {
+ Line = lineNumber++,
+ Inline = inline
+ });
+ }
+ }
LowerFromAst (doc);
@@ -68,7 +96,7 @@ private void WalkBlock (Block block, string prefix, string contPrefix, MarkdownS
switch (block)
{
case HeadingBlock heading:
- HandleHeadingBlock (heading, prefix);
+ HandleHeadingBlock (heading, prefix, contPrefix);
break;
@@ -77,6 +105,44 @@ private void WalkBlock (Block block, string prefix, string contPrefix, MarkdownS
break;
+ case DefinitionTerm term:
+ _blocks.Add (new IntermediateBlock (WalkInlines (term.Inline?.FirstChild, defaultRole, _markdown), true, prefix, contPrefix));
+
+ break;
+
+ case DefinitionList definitions:
+ foreach (Block item in definitions)
+ {
+ int previousCount = _blocks.Count;
+ WalkBlock (item, prefix, contPrefix, defaultRole);
+
+ if (_blocks.Count == previousCount)
+ {
+ continue;
+ }
+
+ prefix = contPrefix;
+ }
+
+ break;
+
+ case DefinitionItem definition:
+ foreach (Block content in definition)
+ {
+ int previousCount = _blocks.Count;
+ string indent = content is DefinitionTerm ? string.Empty : " ";
+ WalkBlock (content, prefix + indent, contPrefix + indent, defaultRole);
+
+ if (_blocks.Count == previousCount)
+ {
+ continue;
+ }
+
+ prefix = contPrefix;
+ }
+
+ break;
+
case FencedCodeBlock:
case CodeBlock:
HandleCodeBlockNode ((LeafBlock)block);
@@ -88,6 +154,19 @@ private void WalkBlock (Block block, string prefix, string contPrefix, MarkdownS
break;
+ case AlertBlock alert:
+ // Markdig accepts custom alphabetic kinds; retain their names and markers too.
+ string kind = alert.Kind.ToString ();
+ string marker = TranslateListMarkerText (prefix + "> ") + $"[!{kind}]";
+ _blocks.Add (new IntermediateBlock ([new InlineRun (kind.ToUpperInvariant (), MarkdownStyleRole.Strong)], true, prefix + "> ", contPrefix + "> ", sourceText: marker));
+
+ foreach (Block content in alert)
+ {
+ WalkBlock (content, contPrefix + "> ", contPrefix + "> ", MarkdownStyleRole.Quote);
+ }
+
+ break;
+
case QuoteBlock quote:
foreach (Block child in quote)
{
@@ -122,12 +201,12 @@ private void WalkBlock (Block block, string prefix, string contPrefix, MarkdownS
}
}
- private void HandleHeadingBlock (HeadingBlock heading, string prefix)
+ private void HandleHeadingBlock (HeadingBlock heading, string prefix, string contPrefix)
{
- List runs = WalkInlines (heading.Inline?.FirstChild, MarkdownStyleRole.Heading);
+ List runs = WalkInlines (heading.Inline?.FirstChild, MarkdownStyleRole.Heading, _markdown);
// Compute anchor slug from heading text before inserting the marker
- string headingText = string.Concat (runs.Select (r => r.Text));
+ string headingText = string.Concat (runs.Select (r => r.Text)).Replace ('\n', ' ');
string anchor = DeduplicateSlug (GenerateAnchorSlug (headingText), _slugCounts);
if (ShowHeadingPrefix)
@@ -140,12 +219,12 @@ private void HandleHeadingBlock (HeadingBlock heading, string prefix)
runs.Insert (0, new InlineRun (prefix, MarkdownStyleRole.HeadingMarker));
}
- _blocks.Add (new IntermediateBlock (runs, true, anchor: anchor));
+ _blocks.Add (new IntermediateBlock (runs, true, continuationPrefix: contPrefix, anchor: anchor));
}
private void HandleParagraphBlock (ParagraphBlock para, string prefix, string contPrefix, MarkdownStyleRole defaultRole)
{
- List runs = WalkInlines (para.Inline?.FirstChild, defaultRole);
+ List runs = WalkInlines (para.Inline?.FirstChild, defaultRole, _markdown);
_blocks.Add (new IntermediateBlock (runs, true, prefix, contPrefix));
}
@@ -182,6 +261,13 @@ private void HandleListBlock (ListBlock list, string prefix, string contPrefix,
string itemPrefix = prefix + marker;
string itemCont = contPrefix + new string (' ', marker.GetColumns ());
+ if (listItem.Count == 0)
+ {
+ _blocks.Add (new IntermediateBlock ([], true, itemPrefix, itemCont));
+
+ continue;
+ }
+
var isFirst = true;
foreach (Block child in listItem)
@@ -199,13 +285,13 @@ private void HandleListBlock (ListBlock list, string prefix, string contPrefix,
// To align wrapped task text after variable-width glyph markers, include the trailing space in the continuation indent.
string taskCont = contPrefix + new string (' ', marker.GetColumns () + checkbox.GetColumns ());
- List runs = WalkInlines (firstInline.NextSibling, role);
+ List runs = WalkInlines (firstInline.NextSibling, role, _markdown);
TrimLeadingSpace (runs);
_blocks.Add (new IntermediateBlock (runs, true, taskPrefix, taskCont));
}
else
{
- List runs = WalkInlines (firstInline, defaultRole);
+ List runs = WalkInlines (firstInline, defaultRole, _markdown);
_blocks.Add (new IntermediateBlock (runs, true, itemPrefix, itemCont));
}
@@ -225,6 +311,8 @@ private void HandleTableBlock (Table table)
List headers = [];
Alignment [] alignments = [];
List rows = [];
+ List [] headerSegments = [];
+ List []> rowSegments = [];
var headerParsed = false;
foreach (Block tableRow in table)
@@ -239,6 +327,7 @@ private void HandleTableBlock (Table table)
if (!headerParsed)
{
headers.AddRange (cells);
+ headerSegments = [.. row.Select (cell => ExtractCellSegments ((TableCell)cell, MarkdownStyleRole.Heading))];
alignments = table.ColumnDefinitions.Take (headers.Count)
.Select (col => col.Alignment switch
@@ -253,6 +342,7 @@ private void HandleTableBlock (Table table)
else if (!row.IsHeader)
{
rows.Add (cells);
+ rowSegments.Add ([.. row.Select (cell => ExtractCellSegments ((TableCell)cell, MarkdownStyleRole.Normal))]);
}
}
@@ -270,82 +360,194 @@ private void HandleTableBlock (Table table)
}
TableData tableData = new (headers.ToArray (), alignments, rows.ToArray ());
- _blocks.Add (new IntermediateBlock ([new InlineRun ("", MarkdownStyleRole.Table)], false, tableData: tableData));
+ MarkdownTableContent content = new (headerSegments, [.. rowSegments]);
+ _blocks.Add (new IntermediateBlock ([new InlineRun ("", MarkdownStyleRole.Table)], false, tableData: tableData, tableContent: content));
}
- private static string ExtractCellText (TableCell cell)
+ private List ExtractCellSegments (TableCell cell, MarkdownStyleRole defaultRole)
{
- // Option A: reconstruct raw markdown-like text from the AST so MarkdownTable
- // can re-parse inline formatting with its existing MarkdownInlineParser path.
- StringBuilder sb = new ();
+ List segments = [];
- foreach (Block child in cell)
+ foreach (Block content in cell)
{
- if (child is ParagraphBlock para)
+ if (content is not ParagraphBlock paragraph)
+ {
+ continue;
+ }
+
+ if (segments.Count > 0)
{
- AppendInlineText (para.Inline?.FirstChild, sb);
+ segments.Add (new StyledSegment (" ", defaultRole));
}
+
+ segments.AddRange (MarkdownAttributeHelper.ToStyledSegments (LowerCellInlines (paragraph.Inline?.FirstChild, defaultRole, _markdown)));
}
- return sb.ToString ().Trim ();
+ return segments;
}
- private static void AppendInlineText (Inline? inline, StringBuilder sb)
+ internal static List LowerCellInlines (Inline? inline, MarkdownStyleRole defaultRole, string source) =>
+ WalkInlines (inline, defaultRole, source, isTableCell: true);
+
+ private string ExtractCellText (TableCell cell)
+ {
+ // Reuse literal source only when reparsing the entire cell preserves its display semantics.
+ if (MarkdownPipeline is { }
+ && cell.Count == 1
+ && cell [0] is ParagraphBlock { Inline.FirstChild: LiteralInline { NextSibling: null } literal })
+ {
+ string original = EscapeCellPipes (GetInlineSource (literal, _markdown).Trim ());
+ List runs = MarkdownInlineParser.ParseInlines (original, MarkdownStyleRole.Normal);
+
+ if (string.Concat (runs.Select (run => run.Text)) == literal.Content.ToString ()
+ && runs.All (run => run.StyleRole == MarkdownStyleRole.Normal
+ && run.AdditionalStyle == TextStyle.None
+ && run.Url is null
+ && run.ImageSource is null))
+ {
+ return original;
+ }
+ }
+
+ StringBuilder source = new ();
+
+ foreach (Block block in cell)
+ {
+ if (block is not ParagraphBlock paragraph)
+ {
+ continue;
+ }
+
+ if (source.Length > 0)
+ {
+ source.Append (' ');
+ }
+
+ AppendInlineSource (paragraph.Inline?.FirstChild, source);
+ }
+
+ return EscapeCellPipes (source.ToString ().Trim ());
+ }
+
+ private static string EscapeCellPipes (string source)
+ {
+ if (!source.Contains ('|'))
+ {
+ return source;
+ }
+
+ // Grid cells can contain raw pipes; clipboard rows always use pipe-table syntax.
+ StringBuilder escaped = new (source.Length);
+ int backslashes = 0;
+
+ foreach (char character in source)
+ {
+ if (character == '|' && backslashes % 2 == 0)
+ {
+ escaped.Append ('\\');
+ }
+
+ escaped.Append (character);
+ backslashes = character == '\\' ? backslashes + 1 : 0;
+ }
+
+ return escaped.ToString ();
+ }
+
+ private void AppendInlineSource (Inline? inline, StringBuilder source)
{
while (inline is { })
{
+ string original = GetInlineSource (inline, _markdown);
+
switch (inline)
{
- case LiteralInline lit:
- sb.Append (lit.Content.ToString ());
+ case LiteralInline literal:
+ string text = literal.Content.ToString ();
+
+ // Encode custom-pipeline literals so reparsing cannot enable other extensions.
+ if (MarkdownPipeline is { })
+ {
+ source.Append (EncodeCellLiteral (text));
+
+ break;
+ }
+
+ source.Append (literal.IsFirstCharacterEscaped && original.Length > 0 ? original : text);
break;
- case EmphasisInline em:
- var delim = new string (em.DelimiterChar, em.DelimiterCount);
- sb.Append (delim);
- AppendInlineText (em.FirstChild, sb);
- sb.Append (delim);
+ case EmphasisInline emphasis:
+ string marker = new (emphasis.DelimiterChar, emphasis.DelimiterCount);
+ source.Append (marker);
+ AppendInlineSource (emphasis.FirstChild, source);
+ source.Append (marker);
break;
case CodeInline code:
- sb.Append ('`');
- sb.Append (code.Content);
- sb.Append ('`');
+ if (original.Length > 0 && !original.Contains ('\n') && !original.Contains ('\r'))
+ {
+ source.Append (original);
- break;
+ break;
+ }
- case LinkInline { IsImage: true } link:
- sb.Append (";
- sb.Append (link.Url);
- sb.Append (')');
+ AppendCellCode (source, code.Content);
break;
case LinkInline link:
- sb.Append ('[');
- AppendInlineText (link.FirstChild, sb);
- sb.Append ("](");
- sb.Append (link.Url);
- sb.Append (')');
+ source.Append (link.IsImage ? ";
+ source.Append (EscapeCellDestination (link.Url));
+ source.Append ('>');
+
+ if (link.Title is { })
+ {
+ source.Append (" \"");
+ source.Append (EncodeCellLiteral (link.Title));
+ source.Append ('"');
+ }
+
+ source.Append (')');
break;
case HtmlEntityInline entity:
- sb.Append (entity.Transcoded.ToString ());
+ source.Append (original.Length > 0 ? original : EncodeCellLiteral (entity.Transcoded.ToString ()));
+
+ break;
+
+ case AbbreviationInline abbreviation:
+ source.Append (EncodeCellLiteral (abbreviation.Abbreviation.Label ?? string.Empty));
break;
case AutolinkInline auto:
- sb.Append (auto.Url);
+ source.Append (original.Length > 0 ? original : $"<{EscapeCellDestination (auto.Url)}>");
+
+ break;
+
+ case LineBreakInline lineBreak:
+ // Keep a pipe-table row on one physical line, including explicit hard breaks.
+ source.Append (lineBreak.IsHard ? "
" : " ");
+
+ break;
+
+ case HtmlInline:
+ // Match the display path, which omits inline HTML tags.
break;
- case ContainerInline container:
- AppendInlineText (container.FirstChild, sb);
+ case ContainerInline group:
+ AppendInlineSource (group.FirstChild, source);
+
+ break;
+
+ case LeafInline:
+ source.Append (EncodeCellLiteral (original));
break;
}
@@ -354,6 +556,70 @@ private static void AppendInlineText (Inline? inline, StringBuilder sb)
}
}
+ private static void AppendCellCode (StringBuilder source, string content)
+ {
+ int longestRun = 0;
+ int currentRun = 0;
+
+ foreach (char character in content)
+ {
+ currentRun = character == '`' ? currentRun + 1 : 0;
+ longestRun = Math.Max (longestRun, currentRun);
+ }
+
+ string delimiter = new ('`', longestRun + 1);
+ bool pad = content.StartsWith ('`') || content.EndsWith ('`')
+ || (content.StartsWith (' ') && content.EndsWith (' ') && !string.IsNullOrWhiteSpace (content));
+ source.Append (delimiter);
+
+ if (pad)
+ {
+ source.Append (' ');
+ }
+
+ source.Append (content.Replace ("\\|", "|", StringComparison.Ordinal).Replace ("|", "\\|", StringComparison.Ordinal));
+
+ if (pad)
+ {
+ source.Append (' ');
+ }
+
+ source.Append (delimiter);
+ }
+
+ private static string GetInlineSource (Inline inline, string source) =>
+ inline.Span.Start >= 0 && inline.Span.End >= inline.Span.Start && inline.Span.End < source.Length
+ ? source.Substring (inline.Span.Start, inline.Span.Length)
+ : string.Empty;
+
+ private static string EscapeCellDestination (string? destination) =>
+ (destination ?? string.Empty).Replace ("\\", "\\\\", StringComparison.Ordinal)
+ .Replace ("|", "\\|", StringComparison.Ordinal)
+ .Replace ("(", "\\(", StringComparison.Ordinal)
+ .Replace (")", "\\)", StringComparison.Ordinal)
+ .Replace ("<", "\\<", StringComparison.Ordinal)
+ .Replace (">", "\\>", StringComparison.Ordinal);
+
+ private static string EncodeCellLiteral (string text)
+ {
+ StringBuilder source = new ();
+
+ foreach (char character in text)
+ {
+ if (!character.IsAsciiPunctuation ())
+ {
+ source.Append (character);
+
+ continue;
+ }
+
+ // Encode punctuation systematically so literal text cannot enable inline extensions.
+ source.Append ("").Append ((int)character).Append (';');
+ }
+
+ return source.ToString ();
+ }
+
private void HandleHtmlBlock (HtmlBlock html, string prefix, string contPrefix, MarkdownStyleRole defaultRole)
{
foreach (StringLine line in html.Lines)
@@ -410,7 +676,7 @@ private void HandleUnknownBlock (Block block, string prefix, string contPrefix,
}
}
- private static List WalkInlines (Inline? inline, MarkdownStyleRole defaultRole)
+ private static List WalkInlines (Inline? inline, MarkdownStyleRole defaultRole, string source, bool isTableCell = false)
{
List runs = [];
@@ -431,27 +697,43 @@ private static List WalkInlines (Inline? inline, MarkdownStyleRole de
case EmphasisInline em:
MarkdownStyleRole emRole = em is { DelimiterChar: '~', DelimiterCount: >= 2 } ? MarkdownStyleRole.Strikethrough :
em.DelimiterCount >= 2 ? MarkdownStyleRole.Strong : MarkdownStyleRole.Emphasis;
- runs.AddRange (WalkInlines (em.FirstChild, emRole));
+ TextStyle emphasisStyle = emRole switch
+ {
+ MarkdownStyleRole.Strong => TextStyle.Bold,
+ MarkdownStyleRole.Strikethrough => TextStyle.Strikethrough,
+ _ => TextStyle.Italic
+ };
+ runs.AddRange (WalkInlines (em.FirstChild, emRole, source, isTableCell)
+ .Select (run => run with
+ {
+ AdditionalStyle = run.AdditionalStyle | emphasisStyle
+ }));
break;
case CodeInline code:
- runs.Add (new InlineRun (code.Content, MarkdownStyleRole.InlineCode));
+ // Normalize table separators before a surrounding link replaces the code role.
+ string codeText = isTableCell ? code.Content.Replace ("\\|", "|", StringComparison.Ordinal) : code.Content;
+ runs.Add (new InlineRun (codeText, MarkdownStyleRole.InlineCode));
break;
case LinkInline { IsImage: true } link:
- List altRuns = WalkInlines (link.FirstChild, MarkdownStyleRole.ImageAlt);
+ List altRuns = WalkInlines (link.FirstChild, MarkdownStyleRole.ImageAlt, source, isTableCell);
string altText = string.Concat (altRuns.Select (r => r.Text));
string fallback = MarkdownImageResolver.GetFallbackText (altText);
- runs.Add (new InlineRun (fallback, MarkdownStyleRole.ImageAlt, imageSource: link.Url));
+ runs.Add (new InlineRun (fallback, MarkdownStyleRole.ImageAlt, ImageSource: link.Url));
break;
case LinkInline link:
string? url = link.Url;
- List linkRuns = WalkInlines (link.FirstChild, MarkdownStyleRole.Link);
- runs.AddRange (linkRuns.Select (r => new InlineRun (r.Text, MarkdownStyleRole.Link, url, r.ImageSource, r.Attribute)));
+ List linkRuns = WalkInlines (link.FirstChild, MarkdownStyleRole.Link, source, isTableCell);
+ runs.AddRange (linkRuns.Select (r => r with
+ {
+ StyleRole = MarkdownStyleRole.Link,
+ Url = url
+ }));
break;
@@ -460,8 +742,8 @@ private static List WalkInlines (Inline? inline, MarkdownStyleRole de
break;
- case LineBreakInline:
- runs.Add (new InlineRun (" ", defaultRole));
+ case LineBreakInline lineBreak:
+ runs.Add (new InlineRun (lineBreak.IsHard ? "\n" : " ", defaultRole));
break;
@@ -479,12 +761,23 @@ private static List WalkInlines (Inline? inline, MarkdownStyleRole de
// Inline HTML โ skip in terminal context.
break;
+ case AbbreviationInline abbreviation:
+ runs.Add (new InlineRun (abbreviation.Abbreviation.Label ?? string.Empty, defaultRole));
+
+ break;
+
+ case LeafInline unknown:
+ // Preserve unsupported leaf syntax instead of silently dropping its text.
+ runs.Add (new InlineRun (GetInlineSource (unknown, source), defaultRole));
+
+ break;
+
default:
// For unrecognized container inline types (e.g. PipeTableDelimiterInline),
// recurse into children to extract any text content.
if (inline is ContainerInline unknownContainer)
{
- runs.AddRange (WalkInlines (unknownContainer.FirstChild, defaultRole));
+ runs.AddRange (WalkInlines (unknownContainer.FirstChild, defaultRole, source, isTableCell));
}
break;
@@ -511,7 +804,10 @@ private static void TrimLeadingSpace (List runs)
}
InlineRun first = runs [0];
- runs [0] = new InlineRun (trimmed, first.StyleRole, first.Url, first.ImageSource, first.Attribute);
+ runs [0] = first with
+ {
+ Text = trimmed
+ };
if (runs [0].Text.Length == 0)
{
@@ -519,7 +815,7 @@ private static void TrimLeadingSpace (List runs)
}
}
- private static int GetBlockEndLine (Block block) =>
+ internal static int GetBlockEndLine (Block block) =>
block switch
{
FencedCodeBlock fcb => fcb.Line + fcb.Lines.Count + 1, // opening fence + content + closing fence
@@ -527,6 +823,8 @@ private static int GetBlockEndLine (Block block) =>
QuoteBlock qb => qb.Count > 0 ? GetBlockEndLine (qb [^1]) : qb.Line,
ListBlock lb => lb.Count > 0 ? GetBlockEndLine (lb [^1]) : lb.Line,
ListItemBlock lib => lib.Count > 0 ? GetBlockEndLine (lib [^1]) : lib.Line,
+ DefinitionList definitions => definitions.Count > 0 ? GetBlockEndLine (definitions [^1]) : definitions.Line,
+ DefinitionItem definition => definition.Count > 0 ? GetBlockEndLine (definition [^1]) : definition.Line,
Table t => t.Count > 0 ? GetBlockEndLine (t [^1]) : t.Line,
_ => block.Line
};
@@ -535,7 +833,7 @@ private void AddCodeBlockLines (IReadOnlyList codeLines, string? languag
{
if (codeLines.Count == 0)
{
- _blocks.Add (new IntermediateBlock ([new InlineRun ("", MarkdownStyleRole.CodeBlock, role: VisualRole.Code)], false, isCodeBlock: true, language: language));
+ _blocks.Add (new IntermediateBlock ([new InlineRun ("", MarkdownStyleRole.CodeBlock, Role: VisualRole.Code)], false, isCodeBlock: true, language: language));
return;
}
@@ -548,19 +846,14 @@ private void AddCodeBlockLines (IReadOnlyList codeLines, string? languag
if (SyntaxHighlighter is null)
{
- runs = [new InlineRun (line, MarkdownStyleRole.CodeBlock, role: VisualRole.Code)];
+ runs = [new InlineRun (line, MarkdownStyleRole.CodeBlock, Role: VisualRole.Code)];
}
else
{
IReadOnlyList highlighted = SyntaxHighlighter.Highlight (line, language);
List converted = [];
- converted.AddRange (highlighted.Select (segment => new InlineRun (segment.Text,
- segment.StyleRole,
- segment.Url,
- segment.ImageSource,
- segment.Attribute,
- segment.Role)));
+ converted.AddRange (highlighted.Select (InlineRun.FromStyledSegment));
runs = converted;
}
diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs
index d2320447bf..37203dbeff 100644
--- a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs
+++ b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs
@@ -229,6 +229,25 @@ private string GetSelectedText ()
int lineStartX = lineIdx == start.Y ? start.X : 0;
int lineEndX = lineIdx == end.Y ? end.X : int.MaxValue;
+
+ if (line.SourceText is { Length: > 0 } sourceText && lineStartX == 0 && lineEndX >= line.Width)
+ {
+ int lastSourceLine = lineIdx;
+
+ while (lastSourceLine + 1 < _renderedLines.Count && _renderedLines [lastSourceLine + 1].SourceText == string.Empty)
+ {
+ lastSourceLine++;
+ }
+
+ if (lastSourceLine < end.Y || (lastSourceLine == end.Y && end.X >= _renderedLines [lastSourceLine].Width))
+ {
+ outputLines.Add (sourceText);
+ lineIdx = lastSourceLine;
+
+ continue;
+ }
+ }
+
StringBuilder lineSb = new ();
AppendLineText (lineSb, line, lineStartX, lineEndX);
outputLines.Add (lineSb.ToString ());
diff --git a/Terminal.Gui/Views/Markdown/RenderedLine.cs b/Terminal.Gui/Views/Markdown/RenderedLine.cs
index c2e0668352..096cf73e79 100644
--- a/Terminal.Gui/Views/Markdown/RenderedLine.cs
+++ b/Terminal.Gui/Views/Markdown/RenderedLine.cs
@@ -4,6 +4,7 @@ internal sealed class RenderedLine (IReadOnlyList segments, bool
{
public IReadOnlyList Segments { get; } = segments;
public bool WrapEligible { get; } = wrapEligible;
+ public string? SourceText { get; set; }
public int Width { get; } = width;
public bool IsCodeBlock { get; } = isCodeBlock;
public bool IsThematicBreak { get; } = isThematicBreak;
diff --git a/Terminal.Gui/Views/Markdown/TableData.cs b/Terminal.Gui/Views/Markdown/TableData.cs
index 07dde92537..c3437b0e9c 100644
--- a/Terminal.Gui/Views/Markdown/TableData.cs
+++ b/Terminal.Gui/Views/Markdown/TableData.cs
@@ -1,12 +1,19 @@
namespace Terminal.Gui.Views;
-/// Represents the parsed structure of a Markdown table.
+/// Represents a Markdown table's cell sources and column alignments.
+///
+/// Header and body cells contain Markdown inline source, including escapes needed to preserve literal text.
+/// These strings are not display text. Tables lowered from an AST retain inline source where possible,
+/// expand resolved links with their titles, and encode transformed literals to preserve display semantics.
+/// Cell paragraphs and soft breaks are separated by spaces; hard breaks use newline character entities.
+/// The resulting source can differ from the input while retaining its rendered content and styles.
+///
public sealed class TableData
{
/// Initializes a new from parsed table rows.
- /// The header cell texts.
+ /// The header cells as Markdown inline source.
/// The column alignments parsed from the separator row.
- /// The body row cell texts.
+ /// The body cells as Markdown inline source.
public TableData (string [] headers, Alignment [] alignments, string [] [] rows)
{
Headers = headers;
@@ -15,13 +22,13 @@ public TableData (string [] headers, Alignment [] alignments, string [] [] rows)
ColumnCount = headers.Length;
}
- /// Gets the header cell texts.
+ /// Gets the header cells as Markdown inline source.
public string [] Headers { get; }
/// Gets the alignment for each column, parsed from the separator row.
public Alignment [] ColumnAlignments { get; }
- /// Gets the body rows. Each row is an array of cell texts.
+ /// Gets the body rows. Each cell contains Markdown inline source.
public string [] [] Rows { get; }
/// Gets the number of columns in the table.
@@ -79,21 +86,53 @@ public TableData (string [] headers, Alignment [] alignments, string [] [] rows)
private static string [] SplitRow (string line)
{
- string trimmed = line.Trim ().Trim ('|');
+ string trimmed = line.Trim ();
if (string.IsNullOrEmpty (trimmed))
{
return [];
}
- string [] cells = trimmed.Split ('|');
+ if (trimmed.StartsWith ('|'))
+ {
+ trimmed = trimmed [1..];
+ }
- for (var i = 0; i < cells.Length; i++)
+ if (trimmed.EndsWith ('|'))
{
- cells [i] = cells [i].Trim ();
+ int precedingBackslashes = 0;
+
+ for (int index = trimmed.Length - 2; index >= 0 && trimmed [index] == '\\'; index--)
+ {
+ precedingBackslashes++;
+ }
+
+ if (precedingBackslashes % 2 == 0)
+ {
+ trimmed = trimmed [..^1];
+ }
}
- return cells;
+ List cells = [];
+ int start = 0;
+ int backslashes = 0;
+
+ for (int index = 0; index < trimmed.Length; index++)
+ {
+ char character = trimmed [index];
+
+ if (character == '|' && backslashes % 2 == 0)
+ {
+ cells.Add (trimmed [start..index].Trim ());
+ start = index + 1;
+ }
+
+ backslashes = character == '\\' ? backslashes + 1 : 0;
+ }
+
+ cells.Add (trimmed [start..].Trim ());
+
+ return [.. cells];
}
private static bool IsSeparatorRow (string [] cells)
diff --git a/Tests/NativeAotSmoke/MarkdownSmokeTests.cs b/Tests/NativeAotSmoke/MarkdownSmokeTests.cs
new file mode 100644
index 0000000000..04798cefd4
--- /dev/null
+++ b/Tests/NativeAotSmoke/MarkdownSmokeTests.cs
@@ -0,0 +1,109 @@
+using System.Drawing;
+using System.Text.RegularExpressions;
+using Markdig;
+using Markdown = Terminal.Gui.Views.Markdown;
+
+namespace NativeAotSmoke;
+
+// CoPilot - GPT-6
+internal static class MarkdownSmokeTests
+{
+ public static void Run ()
+ {
+ using Markdown view = new ()
+ {
+ Text = "first \nsecond\n---",
+ Width = 60,
+ Height = 20,
+ ShowCopyButtons = false
+ };
+ view.Layout ();
+
+ if (view.LineCount != 2 || !view.ScrollToAnchor ("first-second"))
+ {
+ throw new InvalidOperationException ("AOT Markdown heading layout or anchor generation failed.");
+ }
+
+ AssertRendered (view, "Term **bold**\n: definition", "Term bold\n definition");
+ AssertRendered (view, "paragraph\n\n-", "paragraph\n\nโข");
+ AssertRendered (view, "- first \n second", "โข first\n second");
+ AssertRendered (view, "- first \n second\n ---", "โข ## first\n second");
+ AssertRendered (view, "*[HTML]: HyperText Markup Language\n\nHTML supports **bold**", "HTML supports bold");
+ AssertRendered (view, "[reference][target]\n\n[target]: https://example.com", "reference");
+ AssertRendered (view, "```csharp\nint answer = 42;\n```", "int answer = 42;");
+ AssertRendered (view, "> [!WARNING]\n> Critical action", "> WARNING\n> Critical action");
+
+ string codeTable = Normalize (view.RenderToAnsi ("| Code |\n| --- |\n| ``a`b`` |"));
+
+ if (!codeTable.Contains (" a`b ", StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException ("AOT Markdown table code span was corrupted.");
+ }
+
+ ExerciseTableLinks ();
+
+ view.MarkdownPipeline = new MarkdownPipelineBuilder ().UseAlertBlocks (allowNestedAlerts: true).Build ();
+ AssertRendered (view, "> outer\n>\n> > [!WARNING]\n> > nested", "> outer\n> > WARNING\n> > nested");
+
+ view.MarkdownPipeline = new MarkdownPipelineBuilder ().UseAdvancedExtensions ().UseEmojiAndSmiley ().UseCjkFriendlyEmphasis ().Build ();
+ string table = view.RenderToAnsi ("| Name | Value |\n| --- | --- |\n| cell | :x: |");
+
+ if (!table.Contains ("Name", StringComparison.Ordinal) || !table.Contains ("โ", StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException ("AOT Markdown table rendering failed.");
+ }
+ }
+
+ private static void AssertRendered (Markdown view, string markdown, string expected)
+ {
+ string normalized = Normalize (view.RenderToAnsi (markdown));
+
+ if (normalized != expected)
+ {
+ throw new InvalidOperationException ($"AOT Markdown rendering failed: expected '{expected}', got '{normalized}'.");
+ }
+ }
+
+ private static string Normalize (string ansi)
+ {
+ string plain = Regex.Replace (ansi, @"\x1b\[[0-?]*[ -/]*[@-~]", string.Empty);
+ plain = Regex.Replace (plain, @"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)", string.Empty);
+
+ return string.Join ("\n", plain.Replace ("\r\n", "\n").TrimEnd ().Split ('\n').Select (line => line.TrimEnd ()));
+ }
+
+ private static void ExerciseTableLinks ()
+ {
+ const string url = "https://example.com/path_(part)";
+ using IApplication app = Application.Create ().Init (Terminal.Gui.Drivers.DriverRegistry.Names.ANSI);
+ app.Driver!.SetScreenSize (60, 12);
+ using Runnable window = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.None };
+ Markdown markdown = new () { Text = $"| Link |\n| --- |\n| [**Testing [Header]**]({url}) |", Width = Dim.Fill (), Height = Dim.Fill () };
+ window.Add (markdown);
+ List activated = [];
+ markdown.LinkClicked += (_, e) =>
+ {
+ activated.Add (e.Url);
+ e.Handled = true;
+ };
+ app.Begin (window);
+ app.LayoutAndDraw ();
+ MarkdownTable table = markdown.SubViews.OfType ().Single ();
+ Point position = table.ViewportToScreen (new Point (2, 3));
+ TextStyle style = app.Driver.Contents! [position.Y, position.X].Attribute!.Value.Style;
+
+ if ((style & (TextStyle.Bold | TextStyle.Underline)) != (TextStyle.Bold | TextStyle.Underline))
+ {
+ throw new InvalidOperationException ("AOT Markdown table link styling was lost.");
+ }
+
+ table.NewMouseEvent (new Mouse { Position = new Point (2, 3), Flags = MouseFlags.LeftButtonClicked });
+ table.SetFocus ();
+ table.InvokeCommand (Command.Accept);
+
+ if (activated.Count != 2 || activated.Any (target => target != url))
+ {
+ throw new InvalidOperationException ("AOT Markdown table link activation changed its target.");
+ }
+ }
+}
diff --git a/Tests/NativeAotSmoke/Program.cs b/Tests/NativeAotSmoke/Program.cs
index 3a6ac08cef..9dc5728282 100644
--- a/Tests/NativeAotSmoke/Program.cs
+++ b/Tests/NativeAotSmoke/Program.cs
@@ -26,6 +26,11 @@ private static async Task RunAsync (string [] args)
{
bool smokeTest = args.Length > 0 && args [0] == "--smoke-test";
+ if (smokeTest)
+ {
+ MarkdownSmokeTests.Run ();
+ }
+
using IApplication app = Application.Create ().Init ();
#region Localization sanity check
@@ -156,6 +161,7 @@ public AotAllViewsWindow ()
yield return Design (new HexView ());
yield return Design (new Link { Text = "Terminal.Gui on GitHub", Url = "https://github.com/tui-cs/Terminal.Gui" });
yield return Design (new ListView ());
+ yield return (nameof (Markdown), new Markdown { Text = "# Native AOT\n\nTerm\n: definition\n\n- first \n second" });
yield return Design (new OptionSelector ());
yield return Design (new ProgressBar ());
yield return Design (new ScrollBar { Orientation = Orientation.Horizontal });
diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/InlineRunTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/InlineRunTests.cs
new file mode 100644
index 0000000000..981d0df4a8
--- /dev/null
+++ b/Tests/UnitTestsParallelizable/Views/Markdown/InlineRunTests.cs
@@ -0,0 +1,43 @@
+namespace ViewsTests.Markdown;
+
+// CoPilot - GPT-6
+public class InlineRunTests
+{
+ [Fact]
+ public void Text_Copy_Has_Consistent_Values_And_Equality ()
+ {
+ InlineRun original = new ("before", MarkdownStyleRole.Strong, "url", "image", Role: VisualRole.Code)
+ {
+ AdditionalStyle = TextStyle.Bold | TextStyle.Italic
+ };
+ InlineRun copy = original with
+ {
+ Text = "after"
+ };
+ InlineRun expected = new ("after", MarkdownStyleRole.Strong, "url", "image", Role: VisualRole.Code)
+ {
+ AdditionalStyle = original.AdditionalStyle
+ };
+
+ Assert.Equal (expected, copy);
+ Assert.Equal (expected.GetHashCode (), copy.GetHashCode ());
+ Assert.DoesNotContain (typeof (InlineRun).GetProperties (), property => char.IsLower (property.Name [0]));
+ }
+
+ [Fact]
+ public void Styled_Segment_Round_Trip_Preserves_Copied_Run_Metadata ()
+ {
+ InlineRun original = new ("before", MarkdownStyleRole.Link, "url", "image", new Terminal.Gui.Drawing.Attribute (Color.Red, Color.Blue), VisualRole.CodeKeyword)
+ {
+ AdditionalStyle = TextStyle.Bold | TextStyle.Strikethrough
+ };
+ InlineRun copy = original with
+ {
+ Text = "after"
+ };
+ InlineRun restored = InlineRun.FromStyledSegment (copy.ToStyledSegment ());
+
+ Assert.Equal (copy, restored);
+ Assert.Equal (copy.AdditionalStyle, restored.AdditionalStyle);
+ }
+}
diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownAlertCopyTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownAlertCopyTests.cs
new file mode 100644
index 0000000000..69f77ecac8
--- /dev/null
+++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownAlertCopyTests.cs
@@ -0,0 +1,71 @@
+using TgMarkdown = Terminal.Gui.Views.Markdown;
+
+namespace ViewsTests.Markdown;
+
+// CoPilot - GPT-6
+public class MarkdownAlertCopyTests
+{
+ [Theory]
+ [InlineData ("WARNING")]
+ [InlineData ("CUSTOM")]
+ [InlineData ("Note")]
+ [InlineData ("custom")]
+ public void Partial_Selection_Preserves_Alert_Marker (string kind)
+ {
+ using TgMarkdown view = new ()
+ {
+ Text = $"before\n\n> [!{kind}]\n> content\n\nafter",
+ Width = 60,
+ Height = 20
+ };
+ view.Layout ();
+
+ Assert.Equal ($"> [!{kind}]\n> content", SelectRows (view, 2, 3));
+ }
+
+ [Fact]
+ public void Wrapped_Alert_Header_Copies_Marker_Once ()
+ {
+ using TgMarkdown view = new ()
+ {
+ Text = "before\n\n> [!IMPORTANT]\n> x",
+ Width = 8,
+ Height = 20
+ };
+ view.Layout ();
+
+ Assert.Equal ("> [!IMPORTANT]\n> x", SelectRows (view, 2, view.LineCount - 1));
+ }
+
+ [Theory]
+ [InlineData (0, 2, "> IMPORT")]
+ [InlineData (1, 3, " IMPORT\n> ANT")]
+ public void Partially_Selected_Wrapped_Header_Keeps_Display_Text (int firstColumn, int lastRow, string expected)
+ {
+ using TgMarkdown view = new ()
+ {
+ Text = "before\n\n> [!IMPORTANT]\n> x",
+ Width = 8,
+ Height = 20
+ };
+ view.Layout ();
+
+ Assert.Equal (expected, SelectRows (view, 2, lastRow, firstColumn));
+ }
+
+ private static string SelectRows (TgMarkdown view, int first, int last, int firstColumn = 0)
+ {
+ view.NewMouseEvent (new Mouse
+ {
+ Position = new Point (firstColumn, first),
+ Flags = MouseFlags.LeftButtonPressed
+ });
+ view.NewMouseEvent (new Mouse
+ {
+ Position = new Point (59, last),
+ Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport
+ });
+
+ return Assert.IsType (view.SelectedText);
+ }
+}
diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownAnchorLifecycleTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownAnchorLifecycleTests.cs
new file mode 100644
index 0000000000..758d2cc150
--- /dev/null
+++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownAnchorLifecycleTests.cs
@@ -0,0 +1,46 @@
+using TgMarkdown = Terminal.Gui.Views.Markdown;
+
+namespace ViewsTests.Markdown;
+
+// CoPilot - GPT-6
+public class MarkdownAnchorLifecycleTests
+{
+ [Fact]
+ public void Anchors_Survive_Resize_And_Are_Replaced_With_Content ()
+ {
+ using TgMarkdown view = new ()
+ {
+ Text = "# Same\n\nbody\n\n# Same",
+ Width = 40,
+ Height = 2
+ };
+ view.Layout ();
+ Assert.True (view.ScrollToAnchor ("same-1"));
+ view.Width = 20;
+ view.Layout ();
+ Assert.True (view.ScrollToAnchor ("same-1"));
+ view.Text = "# New";
+ view.Layout ();
+
+ Assert.False (view.ScrollToAnchor ("same"));
+ Assert.False (view.ScrollToAnchor ("same-1"));
+ Assert.True (view.ScrollToAnchor ("new"));
+ }
+
+ [Fact]
+ public void Narrow_Layout_Defers_Anchors_Until_Content_Can_Be_Rendered ()
+ {
+ using TgMarkdown view = new ()
+ {
+ Text = "# Heading",
+ Width = 1,
+ Height = 2
+ };
+ view.Layout ();
+ Assert.False (view.ScrollToAnchor ("heading"));
+ view.Width = 40;
+ view.Layout ();
+
+ Assert.True (view.ScrollToAnchor ("heading"));
+ }
+}
diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownContainerRegressionTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownContainerRegressionTests.cs
new file mode 100644
index 0000000000..cf812b6f07
--- /dev/null
+++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownContainerRegressionTests.cs
@@ -0,0 +1,23 @@
+using System.Text.RegularExpressions;
+using Markdig;
+using TgMarkdown = Terminal.Gui.Views.Markdown;
+
+namespace ViewsTests.Markdown;
+
+// CoPilot - GPT-6
+public class MarkdownContainerRegressionTests
+{
+ [Fact]
+ public void Custom_Container_In_List_Retains_Develop_Prefix_Behavior ()
+ {
+ using TgMarkdown view = new ()
+ {
+ MarkdownPipeline = new MarkdownPipelineBuilder ().UseCustomContainers ().Build ()
+ };
+ string ansi = view.RenderToAnsi ("- ::: box\n first\n\n second\n :::");
+ string plain = Regex.Replace (ansi, @"\x1b\[[0-?]*[ -/]*[@-~]", string.Empty);
+ string [] lines = [.. plain.Split ('\n').Select (line => line.TrimEnd ()).Where (line => line.Length > 0)];
+
+ Assert.Equal (["โข first", "โข second"], lines);
+ }
+}
diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownDependencyRegressionTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownDependencyRegressionTests.cs
new file mode 100644
index 0000000000..eb2d953261
--- /dev/null
+++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownDependencyRegressionTests.cs
@@ -0,0 +1,264 @@
+using System.Text.RegularExpressions;
+using Markdig;
+using Markdig.Extensions.Tables;
+
+namespace ViewsTests.Markdown;
+
+// CoPilot - GPT-6
+public class MarkdownDependencyRegressionTests
+{
+ [Theory]
+ [InlineData (": ")]
+ [InlineData (": ")]
+ [InlineData (": ")]
+ [InlineData ("~ ")]
+ public void DefinitionList_Preserves_Term_And_Indents_Definition (string marker)
+ {
+ Assert.Equal (["Term bold", " definition"], RenderLines ($"Term **bold**\n{marker}definition"));
+ }
+
+ [Fact]
+ public void DefinitionList_Preserves_Multiple_Terms_And_Definitions ()
+ {
+ string [] lines = RenderLines ("First\n: one\n~ two\n\nSecond\n: three");
+
+ Assert.Equal (["First", " one", " two", "Second", " three"], lines);
+ }
+
+ [Theory]
+ [InlineData ("\n", "")]
+ [InlineData ("\r\n", "")]
+ [InlineData ("\n", "\n")]
+ public void DefinitionList_Spacing_Before_Heading_Matches_Source (string newline, string blankLine)
+ {
+ string source = $"Term{newline}: def{newline}{blankLine}# Heading";
+ string [] expected = blankLine.Length == 0 ? ["Term", " def", " # Heading"] : ["Term", " def", "", "# Heading"];
+
+ Assert.Equal (expected, RenderLines (source));
+ }
+
+ [Fact]
+ public void DefinitionList_End_Line_Includes_The_Definition ()
+ {
+ Markdig.Syntax.MarkdownDocument document = Markdig.Markdown.Parse ("Term\n: def", new MarkdownPipelineBuilder ().UseAdvancedExtensions ().Build ());
+ Assert.Equal (1, Terminal.Gui.Views.Markdown.GetBlockEndLine (Assert.Single (document)));
+ }
+
+ [Fact]
+ public void DefinitionList_Continuation_Preserves_Leading_Colon ()
+ {
+ string [] lines = RenderLines ("Term\n: definition\n:not-a-marker");
+
+ Assert.Contains (":not-a-marker", string.Join ("\n", lines));
+ }
+
+ [Theory]
+ [InlineData ("-", "โข")]
+ [InlineData ("- \t", "โข")]
+ [InlineData ("2.", "2.")]
+ public void EmptyListItem_After_Paragraph_Preserves_Marker (string marker, string expected)
+ {
+ string [] lines = RenderLines ($"paragraph\n\n{marker}");
+
+ Assert.Equal ("paragraph", lines [0]);
+ Assert.Equal (expected, lines [^1]);
+ }
+
+ [Fact]
+ public void EmptyListItems_Preserve_Each_Marker ()
+ {
+ Assert.Equal (["โข", "โข"], RenderLines ("-\n-"));
+ }
+
+ [Theory]
+ [InlineData ("first \nsecond", "first", "second")]
+ [InlineData ("first\\\nsecond", "first", "second")]
+ [InlineData ("**first \nsecond**", "first", "second")]
+ [InlineData ("[first \nsecond](https://example.com)", "first", "second")]
+ [InlineData ("first \nsecond\n---", "## first", "second")]
+ [InlineData ("> first \n> second", "> first", "> second")]
+ [InlineData ("> first \n> second\n> ---", "> ## first", "> second")]
+ [InlineData ("- first \n second", "โข first", " second")]
+ [InlineData ("- first \n second\n ---", "โข ## first", " second")]
+ public void HardBreak_Starts_New_Rendered_Line (string markdown, string first, string second)
+ {
+ Assert.Equal ([first, second], RenderLines (markdown));
+ }
+
+ [Fact]
+ public void SoftBreak_Remains_A_Space ()
+ {
+ Assert.Equal (["first second"], RenderLines ("first\nsecond"));
+ }
+
+ [Fact]
+ public void HardBreak_After_Wrapping_Preserves_Continuation_Indent ()
+ {
+ Assert.Equal (["โข abcdef", " ghij", " next"], RenderLines ("- abcdefghij \n next", 8));
+ }
+
+ [Fact]
+ public void Setext_HardBreak_Generates_Usable_Anchor ()
+ {
+ using Terminal.Gui.Views.Markdown view = new () { Text = "first \nsecond\n---", Width = 40, Height = 10 };
+ view.Layout ();
+
+ Assert.True (view.ScrollToAnchor ("first-second"));
+ Assert.Equal (2, view.LineCount);
+ }
+
+ [Theory]
+ [InlineData ("> bar\n> -", "> ## bar")]
+ [InlineData ("-\n bar\n -", "โข ## bar")]
+ public void SetextHeading_In_Indented_Structure_Renders_As_Heading (string markdown, string expected)
+ {
+ Assert.Equal ([expected], RenderLines (markdown));
+ }
+
+ [Fact]
+ public void HtmlBlock_Ends_At_WhitespaceOnly_Line ()
+ {
+ string [] lines = RenderLines ("html
\n \t\n# Heading");
+
+ Assert.Equal (["html", "# Heading"], lines.Where (line => line.Length > 0));
+ }
+
+ [Fact]
+ public void GridTable_Open_Last_Cell_Does_Not_Consume_Next_Line ()
+ {
+ using Terminal.Gui.Views.Markdown view = new ()
+ {
+ Text = "+-----+----------+\n| 6 | 1\n| |\n+-----+----------+", Width = 60, Height = 20
+ };
+ view.Layout ();
+ MarkdownTable table = Assert.Single (view.SubViews.OfType ());
+
+ Assert.Equal (["6", "1"], table.TableData.Headers);
+ }
+
+ [Fact]
+ public void PipeTable_After_Paragraph_Preserves_Code_And_Unmatched_Subscript ()
+ {
+ using Terminal.Gui.Views.Markdown view = new ()
+ {
+ Text = "paragraph\n| Code | Cost |\n| --- | --- |\n| `value` | **~$1.50** |", Width = 60, Height = 20
+ };
+ view.Layout ();
+ MarkdownTable table = Assert.Single (view.SubViews.OfType ());
+
+ Assert.Equal (["Code", "Cost"], table.TableData.Headers);
+ Assert.Equal (["`value`", "**~$1.50**"], Assert.Single (table.TableData.Rows));
+ }
+
+ [Fact]
+ public void Custom_Pipeline_Strict_Gfm_Tables_Normalize_Body_Columns ()
+ {
+ MarkdownPipeline pipeline = new MarkdownPipelineBuilder ().UsePipeTables (new PipeTableOptions { UseGfmRules = true }).Build ();
+ using Terminal.Gui.Views.Markdown view = new ()
+ {
+ Text = "| A | B |\n| --- | --- |\n| one | two | extra |\n| short |", MarkdownPipeline = pipeline, Width = 60, Height = 20
+ };
+ view.Layout ();
+ MarkdownTable table = Assert.Single (view.SubViews.OfType ());
+
+ Assert.Equal (["one", "two"], table.TableData.Rows [0]);
+ Assert.Equal (["short", ""], table.TableData.Rows [1]);
+ }
+
+ [Fact]
+ public void Custom_Pipeline_Emoji_Preserves_Table_Alignment_And_Content ()
+ {
+ MarkdownPipeline pipeline = new MarkdownPipelineBuilder ().UseEmojiAndSmiley ().UsePipeTables ().Build ();
+ using Terminal.Gui.Views.Markdown view = new ()
+ {
+ Text = "| Left | Center | Right |\n| --- | :---: | ---: |\n| :x: | b | c |", MarkdownPipeline = pipeline, Width = 60, Height = 20
+ };
+ view.Layout ();
+ MarkdownTable table = Assert.Single (view.SubViews.OfType ());
+
+ Assert.Equal ([Alignment.Start, Alignment.Center, Alignment.End], table.TableData.ColumnAlignments);
+ Assert.Equal (["โ", "b", "c"], Assert.Single (table.TableData.Rows));
+ }
+
+ [Fact]
+ public void Custom_Pipeline_Cjk_Emphasis_Preserves_Supplementary_Characters ()
+ {
+ MarkdownPipeline pipeline = new MarkdownPipelineBuilder ().UseCjkFriendlyEmphasis ().Build ();
+
+ Assert.Equal (["๐ ฎทSurrogatePair"], RenderLines ("๐ ฎท**SurrogatePair**", pipeline: pipeline));
+ }
+
+ [Fact]
+ public void Custom_Pipeline_Nesting_Limit_Is_Respected ()
+ {
+ MarkdownPipeline pipeline = new MarkdownPipelineBuilder { MaximumNestingDepth = 16 }.Build ();
+ using Terminal.Gui.Views.Markdown view = new () { Width = 60, Height = 20, MarkdownPipeline = pipeline, Text = new string ('>', 20) };
+
+ Assert.Throws (() => Markdig.Markdown.Parse (view.Text, pipeline));
+ view.Layout ();
+
+ Assert.Equal ([new string ('>', 20)], RenderLines (view.Text, pipeline: pipeline));
+ }
+
+ [Fact]
+ public void Default_Pipeline_Deep_Nesting_Renders_Literal_Source ()
+ {
+ string source = new string ('>', 140) + " **literal**";
+ using Terminal.Gui.Views.Markdown view = new ()
+ {
+ Text = source,
+ Width = 200,
+ Height = 10
+ };
+ view.Layout ();
+
+ Assert.Equal ([source], RenderLines (source, 200));
+ Assert.False (view.ScrollToAnchor ("literal"));
+ }
+
+ [Fact]
+ public void Heading_AutoIdentifier_Does_Not_Consume_Nested_Link_Label ()
+ {
+ string [] lines = RenderLines ("### Header\n\n[Testing [Header]](https://example.com)");
+
+ Assert.Equal ("Testing [Header]", lines [^1]);
+ }
+
+ [Fact]
+ public void Unmatched_Emphasis_Closer_Preserves_Trailing_Text ()
+ {
+ Assert.Equal (["=a=== tail"], RenderLines ("===a===== tail"));
+ }
+
+ [Theory]
+ [InlineData ("HTML supports **bold** and *italic*", "HTML supports bold and italic")]
+ [InlineData ("**HTML is great**", "HTML is great")]
+ [InlineData ("HTML supports **bold** and **HTML too**", "HTML supports bold and HTML too")]
+ public void Abbreviations_Preserve_Label_And_Adjacent_Emphasis (string markdown, string expected)
+ {
+ Assert.Equal ([expected], RenderLines ($"*[HTML]: HyperText Markup Language\n\n{markdown}"));
+ }
+
+ [Fact]
+ public void Abbreviations_In_Table_Cells_Preserve_Label ()
+ {
+ using Terminal.Gui.Views.Markdown view = new ()
+ {
+ Text = "*[HTML]: HyperText Markup Language\n\n| Name |\n| --- |\n| **HTML** |", Width = 60, Height = 20
+ };
+ view.Layout ();
+ MarkdownTable table = Assert.Single (view.SubViews.OfType ());
+
+ Assert.Equal (["**HTML**"], Assert.Single (table.TableData.Rows));
+ }
+
+ private static string [] RenderLines (string markdown, int width = 80, MarkdownPipeline? pipeline = null)
+ {
+ using Terminal.Gui.Views.Markdown view = new () { MarkdownPipeline = pipeline };
+ string ansi = view.RenderToAnsi (markdown, width);
+ string plain = Regex.Replace (ansi, @"\x1b\[[0-?]*[ -/]*[@-~]", string.Empty);
+ plain = Regex.Replace (plain, @"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)", string.Empty);
+
+ return [.. plain.Replace ("\r\n", "\n").TrimEnd ().Split ('\n').Select (line => line.TrimEnd ())];
+ }
+}
diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownInlineRoundTripTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownInlineRoundTripTests.cs
new file mode 100644
index 0000000000..efbfa39dd0
--- /dev/null
+++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownInlineRoundTripTests.cs
@@ -0,0 +1,190 @@
+using UnitTests;
+using TgMarkdown = Terminal.Gui.Views.Markdown;
+
+namespace ViewsTests.Markdown;
+
+// CoPilot - GPT-6
+public class MarkdownInlineRoundTripTests : TestDriverBase
+{
+ [Theory]
+ [InlineData ("**a\\|b**", "a|b", MarkdownStyleRole.Strong)]
+ [InlineData ("*x\\*y*", "x*y", MarkdownStyleRole.Emphasis)]
+ [InlineData ("[Testing \\[Header\\]](https://example.com/path_(part))", "Testing [Header]", MarkdownStyleRole.Link)]
+ [InlineData ("![alt\\]text](image_(part).png)", "[alt]text]", MarkdownStyleRole.ImageAlt)]
+ public void String_Inlines_Respect_Escaped_Delimiters (string source, string expected, MarkdownStyleRole role)
+ {
+ List runs = MarkdownInlineParser.ParseInlines (source, MarkdownStyleRole.Normal);
+
+ Assert.Equal (expected, string.Concat (runs.Select (run => run.Text)));
+ Assert.All (runs, run => Assert.Equal (role, run.StyleRole));
+
+ if (role == MarkdownStyleRole.Link)
+ {
+ Assert.All (runs, run => Assert.Equal ("https://example.com/path_(part)", run.Url));
+ }
+
+ if (role == MarkdownStyleRole.ImageAlt)
+ {
+ Assert.Equal ("image_(part).png", Assert.Single (runs).ImageSource);
+ }
+ }
+
+ [Theory]
+ [InlineData ("| a \\|", "a \\|")]
+ [InlineData ("| a \\||", "a \\|")]
+ [InlineData ("| a \\\\|", "a \\\\")]
+ public void Row_Boundaries_Preserve_Escaped_Pipes (string row, string expected)
+ {
+ TableData data = Assert.IsType (TableData.TryParse (["| H |", "| --- |", row]));
+
+ Assert.Equal (expected, data.Rows [0] [0]);
+ }
+
+ [Theory]
+ [InlineData ("|| H |", 2)]
+ [InlineData ("| H ||", 2)]
+ [InlineData ("||", 1)]
+ public void Row_Boundaries_Remove_Only_One_Outer_Pipe (string header, int columns)
+ {
+ TableData data = Assert.IsType (TableData.TryParse ([header, "| --- | --- |"]));
+
+ Assert.Equal (columns, data.ColumnCount);
+ }
+
+ [Theory]
+ [InlineData ("snake_case")]
+ [InlineData ("[1]")]
+ public void Harmless_Literals_Do_Not_Gain_Unnecessary_Escapes (string source)
+ {
+ using TgMarkdown view = CreateTable (source);
+ MarkdownTable table = Assert.Single (view.SubViews.OfType ());
+
+ Assert.Equal (source, table.TableData.Rows [0] [0]);
+ }
+
+ [Theory]
+ [InlineData ("$x^2$")]
+ [InlineData ("Wow\\")]
+ [InlineData ("Wow")]
+ [InlineData ("[`a\\|b`](u)")]
+ [InlineData ("a\\|b")]
+ [InlineData ("**a\\|b**")]
+ [InlineData ("\\*literal\\*")]
+ [InlineData ("*x\\*y*")]
+ [InlineData ("[Testing \\[Header\\]](https://example.com/path_(part))")]
+ [InlineData ("[Testing [Header]](https://example.com/path_(part))")]
+ [InlineData ("[label](https://example.com/a\\|b)")]
+ [InlineData ("[label](https://example.com/a\\))")]
+ [InlineData ("**bold *inner***")]
+ [InlineData ("``a\\|b`c``")]
+ [InlineData ("![alt\\]text](image_(part).png)")]
+ [InlineData ("\\~literal\\~")]
+ [InlineData ("\\^literal\\^")]
+ [InlineData ("\\=\\=literal\\=\\=")]
+ public void Table_String_And_Ast_Paths_Produce_Identical_Styled_Content (string source)
+ {
+ using TgMarkdown view = CreateTable (source);
+ MarkdownTable parsed = Assert.Single (view.SubViews.OfType ());
+ using MarkdownTable standalone = new ()
+ {
+ TableData = parsed.TableData
+ };
+ TableData copied = Assert.IsType (TableData.TryParse (standalone.Text.Split ('\n')));
+ using MarkdownTable reparsed = new ()
+ {
+ TableData = copied
+ };
+
+ Assert.Equal (Expand (GetBody (parsed)), Expand (GetBody (standalone)));
+ Assert.Equal (Expand (GetBody (parsed)), Expand (GetBody (reparsed)));
+ }
+
+ [Theory]
+ [InlineData ("``a`", false)]
+ [InlineData ("```x`", false)]
+ [InlineData ("``a``", true)]
+ [InlineData ("`", false)]
+ public void Backtick_Runs_Are_Matched_Whole_Or_Kept_Literal (string source, bool code)
+ {
+ List runs = MarkdownInlineParser.ParseInlines (source, MarkdownStyleRole.Normal);
+
+ Assert.Equal (code ? "a" : source, string.Concat (runs.Select (run => run.Text)));
+ Assert.All (runs, run => Assert.Equal (code ? MarkdownStyleRole.InlineCode : MarkdownStyleRole.Normal, run.StyleRole));
+ }
+
+ [Theory]
+ [InlineData ("$x^2$", "$x^2$")]
+ [InlineData ("before $x^2$ after", "before $x^2$ after")]
+ [InlineData ("\\{note\\}", "{note}")]
+ public void Table_Cell_Text_Survives_Advanced_Inline_Parsing (string source, string expected)
+ {
+ using TgMarkdown view = CreateTable (source);
+ MarkdownTable parsed = Assert.Single (view.SubViews.OfType ());
+ using MarkdownTable standalone = new ()
+ {
+ TableData = new (["H"], [Alignment.Start], [[source]])
+ };
+
+ Assert.Equal (expected, string.Concat (GetBody (parsed).Select (segment => segment.Text)));
+ Assert.Equal (expected, string.Concat (GetBody (standalone).Select (segment => segment.Text)));
+ }
+
+ [Fact]
+ public void Unescaped_Table_Attributes_Are_Metadata_In_Both_Paths ()
+ {
+ using TgMarkdown view = CreateTable ("**bold**{note}");
+ MarkdownTable parsed = Assert.Single (view.SubViews.OfType ());
+ List standalone = MarkdownInlineParser.ParseInlines ("**bold**{note}", MarkdownStyleRole.Normal);
+
+ Assert.Equal ("bold", string.Concat (GetBody (parsed).Select (segment => segment.Text)));
+ Assert.Equal ("bold", string.Concat (standalone.Select (run => run.Text)));
+ }
+
+ [Fact]
+ public void Linked_Table_Code_Unescapes_Pipe_Before_Applying_Link_Role ()
+ {
+ using TgMarkdown view = CreateTable ("[`a\\|b`](u)");
+ MarkdownTable table = Assert.Single (view.SubViews.OfType ());
+ StyledSegment segment = Assert.Single (GetBody (table));
+
+ Assert.Equal ("a|b", segment.Text);
+ Assert.Equal (MarkdownStyleRole.Link, segment.StyleRole);
+ Assert.Equal ("u", segment.Url);
+ }
+
+ [Fact]
+ public void Linked_Code_Outside_Tables_Preserves_Literal_Backslash ()
+ {
+ using IDriver driver = CreateTestDriver (60, 10);
+ using TgMarkdown view = new ()
+ {
+ Driver = driver,
+ Text = "[`a\\|b`](u)",
+ Width = 60,
+ Height = 10
+ };
+ view.Layout ();
+ view.Draw ();
+
+ Assert.Equal ("a\\|b", string.Concat (Enumerable.Range (0, 4).Select (column => driver.Contents! [0, column].Grapheme)));
+ }
+
+ private static TgMarkdown CreateTable (string cell)
+ {
+ TgMarkdown view = new ()
+ {
+ Text = $"| H |\n| --- |\n| {cell} |",
+ Width = 60,
+ Height = 20
+ };
+ view.Layout ();
+
+ return view;
+ }
+
+ private static List GetBody (MarkdownTable table) => table.Content.Rows [0] [0];
+
+ private static IEnumerable<(string, MarkdownStyleRole, string?, string?, TextStyle, VisualRole?)> Expand (IEnumerable segments) =>
+ segments.SelectMany (segment => GraphemeHelper.GetGraphemes (segment.Text)
+ .Select (grapheme => (grapheme, segment.StyleRole, segment.Url, segment.ImageSource, segment.AdditionalStyle, segment.Role)));
+}
diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownParseRecoveryTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownParseRecoveryTests.cs
new file mode 100644
index 0000000000..3dc1903807
--- /dev/null
+++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownParseRecoveryTests.cs
@@ -0,0 +1,135 @@
+using Markdig;
+using Microsoft.Extensions.Logging;
+using Moq;
+using UnitTests;
+using TgMarkdown = Terminal.Gui.Views.Markdown;
+
+namespace ViewsTests.Markdown;
+
+// CoPilot - GPT-6
+public class MarkdownParseRecoveryTests : TestDriverBase
+{
+ [Fact]
+ public void Parse_ArgumentException_Does_Not_Depend_On_Message ()
+ {
+ using IDriver driver = CreateTestDriver (60, 10);
+ int parses = 0;
+ MarkdownPipelineBuilder builder = new ();
+ builder.DocumentProcessed += _ =>
+ {
+ parses++;
+
+ throw new ArgumentException ("A different parser failure message");
+ };
+ using TgMarkdown view = new ()
+ {
+ Driver = driver,
+ Text = "**literal**",
+ MarkdownPipeline = builder.Build (),
+ Width = 60,
+ Height = 10
+ };
+ view.Layout ();
+ view.Width = 40;
+ view.Layout ();
+
+ view.Draw ();
+
+ Assert.Equal (1, parses);
+ Assert.Equal ("**literal**", ReadRow (driver, 0));
+ }
+
+ [Fact]
+ public void Parse_Fallback_Logs_The_Original_Exception ()
+ {
+ ArgumentException failure = new ("custom pipeline failure");
+ MarkdownPipelineBuilder builder = new ();
+ builder.DocumentProcessed += _ => throw failure;
+ Mock logger = new ();
+ using IDisposable scope = Logging.PushLogger (logger.Object);
+ using TgMarkdown view = new ()
+ {
+ MarkdownPipeline = builder.Build (),
+ Text = "literal",
+ Width = 60,
+ Height = 10
+ };
+ view.Layout ();
+
+ Assert.Contains (logger.Invocations, invocation => invocation.Method.Name == nameof (ILogger.Log)
+ && Equals (invocation.Arguments [0], LogLevel.Warning)
+ && ReferenceEquals (invocation.Arguments [3], failure));
+ }
+
+ [Fact]
+ public void Parse_Fallback_Preserves_Blank_Source_Lines ()
+ {
+ using IDriver driver = CreateTestDriver (60, 10);
+ MarkdownPipelineBuilder builder = new ();
+ builder.DocumentProcessed += _ => throw new ArgumentException ("parse failure");
+ using TgMarkdown view = new ()
+ {
+ MarkdownPipeline = builder.Build (),
+ Driver = driver,
+ Text = "first\n\nthird",
+ Width = 60,
+ Height = 10
+ };
+ view.Layout ();
+
+ view.Draw ();
+
+ Assert.Equal (3, view.LineCount);
+ Assert.Equal (["first", "", "third"], Enumerable.Range (0, 3).Select (row => ReadRow (driver, row)));
+ }
+
+ [Fact]
+ public void Depth_Fallback_Resets_Anchors_And_Slugs_And_Allows_Recovery ()
+ {
+ using TgMarkdown view = new ()
+ {
+ MarkdownPipeline = new MarkdownPipelineBuilder { MaximumNestingDepth = 16 }.Build (),
+ Text = "# Old",
+ Width = 60,
+ Height = 10
+ };
+ view.Layout ();
+ Assert.True (view.ScrollToAnchor ("old"));
+ view.Text = new string ('>', 20);
+ view.Layout ();
+
+ Assert.Equal (1, view.LineCount);
+ Assert.False (view.ScrollToAnchor ("old"));
+
+ view.Text = "# Old\n\n# Old";
+ view.Layout ();
+
+ Assert.True (view.ScrollToAnchor ("old"));
+ Assert.True (view.ScrollToAnchor ("old-1"));
+ Assert.False (view.ScrollToAnchor ("old-2"));
+ }
+
+ [Fact]
+ public void ArgumentException_From_Lowering_Is_Not_Swallowed ()
+ {
+ Mock highlighter = new ();
+ highlighter.Setup (value => value.Highlight (It.IsAny (), It.IsAny ())).Throws (new ArgumentException ("lowering"));
+ using TgMarkdown view = new ()
+ {
+ SyntaxHighlighter = highlighter.Object,
+ Width = 60,
+ Height = 10
+ };
+
+ ArgumentException exception = Assert.Throws (() =>
+ {
+ view.Text = "```cs\ncode\n```";
+ view.Layout ();
+ });
+
+ Assert.Equal ("lowering", exception.Message);
+ }
+
+ private static string ReadRow (IDriver driver, int row) =>
+ string.Concat (Enumerable.Range (0, driver.Screen.Width).Select (column => driver.Contents! [row, column].Grapheme)).TrimEnd ();
+}
diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownParsedContentTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownParsedContentTests.cs
new file mode 100644
index 0000000000..f9e2af220a
--- /dev/null
+++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownParsedContentTests.cs
@@ -0,0 +1,229 @@
+using System.Text;
+using Markdig;
+using UnitTests;
+using TgMarkdown = Terminal.Gui.Views.Markdown;
+
+namespace ViewsTests.Markdown;
+
+// CoPilot - GPT-6
+public class MarkdownParsedContentTests : TestDriverBase
+{
+ [Theory]
+ [InlineData ("$x^2$", "$x^2$")]
+ [InlineData ("Wow\\", "Wow!docs")]
+ [InlineData ("[`a\\|b`](u)", "a|b")]
+ [InlineData ("``a`b``", "a`b")]
+ [InlineData ("``a\\|b`c``", "a|b`c")]
+ [InlineData ("`` `x` ``", "`x`")]
+ [InlineData ("\\*literal\\*", "*literal*")]
+ [InlineData ("[Testing [Header]](https://example.com)", "Testing [Header]")]
+ [InlineData ("~~deleted~~", "deleted")]
+ [InlineData ("**bold *inner***", "bold inner")]
+ [InlineData ("a\\|b", "a|b")]
+ public void Table_Cell_Renders_Parsed_Text (string source, string expected)
+ {
+ using IDriver driver = CreateTestDriver (60, 20);
+ using TgMarkdown markdown = CreateTable (source, driver);
+ MarkdownTable table = Assert.Single (markdown.SubViews.OfType ());
+
+ Assert.Equal ($"โ {expected}", ReadRow (driver, table, 3).TrimEnd (' ', 'โ'));
+ Assert.Equal (5, table.RenderedHeight);
+ }
+
+ [Theory]
+ [InlineData ("~~deleted~~", TextStyle.Strikethrough)]
+ [InlineData ("**bold**", TextStyle.Bold)]
+ [InlineData ("*italic*", TextStyle.Italic)]
+ [InlineData ("***both***", TextStyle.Bold | TextStyle.Italic)]
+ [InlineData ("[**bold**](https://example.com)", TextStyle.Bold | TextStyle.Underline)]
+ public void Table_Cell_Preserves_Text_Style (string source, TextStyle expected)
+ {
+ using IDriver driver = CreateTestDriver (60, 20);
+ using TgMarkdown markdown = CreateTable (source, driver);
+ MarkdownTable table = Assert.Single (markdown.SubViews.OfType ());
+ Point position = table.ViewportToScreen (new Point (2, 3));
+ TextStyle actual = driver.Contents! [position.Y, position.X].Attribute!.Value.Style;
+
+ Assert.Equal (expected, actual & expected);
+ }
+
+ [Fact]
+ public void Table_Nested_Emphasis_Combines_Styles_At_Inner_Text ()
+ {
+ using IDriver driver = CreateTestDriver (60, 20);
+ using TgMarkdown markdown = CreateTable ("**bold *inner***", driver);
+ MarkdownTable table = Assert.Single (markdown.SubViews.OfType ());
+ Point position = table.ViewportToScreen (new Point (7, 3));
+ TextStyle actual = driver.Contents! [position.Y, position.X].Attribute!.Value.Style;
+
+ Assert.Equal (TextStyle.Bold | TextStyle.Italic, actual & (TextStyle.Bold | TextStyle.Italic));
+ }
+
+ [Fact]
+ public void Table_Text_Model_Uses_Safe_Code_Delimiters ()
+ {
+ using TgMarkdown markdown = CreateTable ("``a`b``");
+ MarkdownTable table = Assert.Single (markdown.SubViews.OfType ());
+
+ Assert.Equal ("``a`b``", table.TableData.Rows [0] [0]);
+ }
+
+ [Fact]
+ public void Table_Nested_Label_Keyboard_Activation_Uses_Original_Url ()
+ {
+ const string url = "https://example.com/path_(part)";
+
+ // Exercise application keyboard focus here; rendering and mouse tests use plain drivers.
+ using IApplication app = Application.Create ().Init (DriverRegistry.Names.ANSI);
+ using Runnable window = BeginTable (app, $"[Testing [Header]]({url})", out TgMarkdown markdown);
+ MarkdownTable table = Assert.Single (markdown.SubViews.OfType ());
+ List activated = [];
+ markdown.LinkClicked += (_, e) =>
+ {
+ activated.Add (e.Url);
+ e.Handled = true;
+ };
+
+ table.SetFocus ();
+ table.InvokeCommand (Command.Accept);
+
+ Assert.Equal ([url], activated);
+ }
+
+ [Fact]
+ public void Table_Nested_Label_Mouse_Activation_Uses_Original_Url ()
+ {
+ const string url = "https://example.com/path_(part)";
+ using IDriver driver = CreateTestDriver (60, 20);
+ using TgMarkdown markdown = CreateTable ($"[Testing [Header]]({url})", driver);
+ MarkdownTable table = Assert.Single (markdown.SubViews.OfType ());
+ string? activated = null;
+ markdown.LinkClicked += (_, e) =>
+ {
+ activated = e.Url;
+ e.Handled = true;
+ };
+ table.NewMouseEvent (new Mouse
+ {
+ Position = new Point (2, 3),
+ Flags = MouseFlags.LeftButtonClicked
+ });
+
+ Assert.Equal (url, activated);
+ }
+
+ [Fact]
+ public void Table_Wrapping_Preserves_Styles_And_Original_Link ()
+ {
+ const string url = "https://example.com/path_(part)";
+ using IDriver driver = CreateTestDriver (9, 20);
+ using TgMarkdown markdown = CreateTable ($"[**abcdefghij**]({url})", driver, 9);
+ MarkdownTable table = Assert.Single (markdown.SubViews.OfType ());
+ Assert.True (table.RenderedHeight > 5);
+ Point position = table.ViewportToScreen (new Point (2, 4));
+ TextStyle actual = driver.Contents! [position.Y, position.X].Attribute!.Value.Style;
+ Assert.Equal (TextStyle.Bold | TextStyle.Underline, actual & (TextStyle.Bold | TextStyle.Underline));
+ string? activated = null;
+ markdown.LinkClicked += (_, e) =>
+ {
+ activated = e.Url;
+ e.Handled = true;
+ };
+
+ table.NewMouseEvent (new Mouse { Position = new Point (2, 4), Flags = MouseFlags.LeftButtonClicked });
+
+ Assert.Equal (url, activated);
+ }
+
+ [Fact]
+ public void TableData_Reassignment_Replaces_Parsed_Content ()
+ {
+ using IDriver driver = CreateTestDriver (60, 20);
+ using TgMarkdown markdown = CreateTable ("~~old~~", driver);
+ MarkdownTable table = Assert.Single (markdown.SubViews.OfType ());
+ table.TableData = new (["Cell"], [Alignment.Start], [ ["**new**"]]);
+ table.Layout ();
+ table.SetNeedsDraw ();
+ table.SetClipToScreen ();
+ table.Draw ();
+
+ Assert.Equal ("โ new", ReadRow (driver, table, 3).TrimEnd (' ', 'โ'));
+ }
+
+ [Theory]
+ [InlineData ("NOTE")]
+ [InlineData ("TIP")]
+ [InlineData ("IMPORTANT")]
+ [InlineData ("WARNING")]
+ [InlineData ("CAUTION")]
+ public void Alert_Kind_Is_Visible_And_Bold (string kind)
+ {
+ using IDriver driver = CreateTestDriver (50, 10);
+ using TgMarkdown markdown = new ()
+ {
+ Driver = driver,
+ Text = $"> [!{kind}]\n> Critical **action**",
+ Width = 50,
+ Height = 10
+ };
+ markdown.Layout ();
+ markdown.Draw ();
+
+ Assert.StartsWith ($"> {kind}", ReadRow (driver, markdown, 0));
+ Assert.StartsWith ("> Critical action", ReadRow (driver, markdown, 1));
+ Point position = markdown.ViewportToScreen (new Point (2, 0));
+ Assert.True (driver.Contents! [position.Y, position.X].Attribute!.Value.Style.HasFlag (TextStyle.Bold));
+ }
+
+ [Fact]
+ public void Custom_Pipeline_Nested_Alert_Preserves_Kind_And_Quote_Prefix ()
+ {
+ MarkdownPipeline pipeline = new MarkdownPipelineBuilder ().UseAlertBlocks (allowNestedAlerts: true).Build ();
+ using TgMarkdown markdown = new () { MarkdownPipeline = pipeline };
+ string ansi = markdown.RenderToAnsi ("> outer\n>\n> > [!WARNING]\n> > nested");
+ string plain = System.Text.RegularExpressions.Regex.Replace (ansi, @"\x1b\[[0-?]*[ -/]*[@-~]", string.Empty);
+
+ Assert.Contains ("> > WARNING", plain);
+ Assert.Contains ("> > nested", plain);
+ }
+
+ private static TgMarkdown CreateTable (string source, IDriver? driver = null, int width = 60)
+ {
+ TgMarkdown markdown = new ()
+ {
+ Driver = driver,
+ Text = $"| Cell |\n| --- |\n| {source} |",
+ Width = width,
+ Height = 20
+ };
+ markdown.Layout ();
+ markdown.Draw ();
+
+ return markdown;
+ }
+
+ private static Runnable BeginTable (IApplication app, string source, out TgMarkdown markdown, int width = 60)
+ {
+ app.Driver!.SetScreenSize (width, 20);
+ Runnable window = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.None };
+ markdown = new () { Text = $"| Cell |\n| --- |\n| {source} |", Width = Dim.Fill (), Height = Dim.Fill () };
+ window.Add (markdown);
+ app.Begin (window);
+ app.LayoutAndDraw ();
+
+ return window;
+ }
+
+ private static string ReadRow (IDriver driver, View view, int row)
+ {
+ Point position = view.ViewportToScreen (new Point (0, row));
+ StringBuilder text = new ();
+
+ for (int x = position.X; x < position.X + view.Viewport.Width; x++)
+ {
+ text.Append (driver.Contents! [position.Y, x].Grapheme);
+ }
+
+ return text.ToString ();
+ }
+}
diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownReviewRegressionTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownReviewRegressionTests.cs
new file mode 100644
index 0000000000..ed0b878042
--- /dev/null
+++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownReviewRegressionTests.cs
@@ -0,0 +1,76 @@
+using System.Text.RegularExpressions;
+using Markdig;
+using TgMarkdown = Terminal.Gui.Views.Markdown;
+
+namespace ViewsTests.Markdown;
+
+// CoPilot - GPT-6
+public class MarkdownReviewRegressionTests
+{
+ [Theory]
+ [InlineData ("- Term\n : definition", new string [] { "โข Term", " definition" })]
+ [InlineData ("- First\n : one\n : two\n\n Second\n : three", new string [] { "โข First", " one", " two", " Second", " three" })]
+ public void Definition_List_In_List_Uses_One_Bullet (string source, string [] expected)
+ {
+ Assert.Equal (expected, RenderLines (source).Where (line => line.Length > 0));
+ }
+
+ [Fact]
+ public void Alert_In_List_Uses_One_Bullet ()
+ {
+ Assert.Equal (["โข > NOTE", " > first", " > second"], RenderLines ("- > [!NOTE]\n > first\n >\n > second", new MarkdownPipelineBuilder ().UseAlertBlocks (allowNestedAlerts: true).Build ()).Where (line => line.Length > 0));
+ }
+
+ [Theory]
+ [InlineData ("\n\ntext", "", 3)]
+ [InlineData ("\ntext", "", 2)]
+ [InlineData ("text\n", "โข ", 1)]
+ public void Hard_Breaks_Keep_Nonempty_Segments_Without_Trailing_Phantom_Line (string text, string prefix, int count)
+ {
+ IntermediateBlock block = new ([new InlineRun (text, MarkdownStyleRole.Emphasis, "https://example.com")], true, prefix, " ");
+ List lines = TgMarkdown.WrapBlock (block, 80);
+
+ Assert.Equal (count, lines.Count);
+ Assert.All (lines, line => Assert.NotEmpty (line.Segments));
+ }
+
+ [Theory]
+ [InlineData ("*\\\ntext*")]
+ [InlineData ("*\\\n\\\ntext*")]
+ [InlineData ("first \n\nlast")]
+ [InlineData ("[first \nsecond](https://example.com)")]
+ public void Hard_Break_Lines_Allow_Selection_And_Hit_Testing (string source)
+ {
+ using TgMarkdown view = new ()
+ {
+ Text = source,
+ Width = 40,
+ Height = 10
+ };
+ view.Layout ();
+ view.NewMouseEvent (new Mouse
+ {
+ Position = new Point (0, 0),
+ Flags = MouseFlags.LeftButtonPressed
+ });
+ view.NewMouseEvent (new Mouse
+ {
+ Position = new Point (1, 1),
+ Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport
+ });
+
+ Assert.NotNull (view.SelectedText);
+ }
+
+ private static string [] RenderLines (string source, MarkdownPipeline? pipeline = null)
+ {
+ using TgMarkdown view = new ()
+ {
+ MarkdownPipeline = pipeline
+ };
+ string ansi = view.RenderToAnsi (source);
+ string plain = Regex.Replace (ansi, @"\x1b\[[0-?]*[ -/]*[@-~]", string.Empty);
+
+ return [.. plain.Replace ("\r\n", "\n").TrimEnd ().Split ('\n').Select (line => line.TrimEnd ())];
+ }
+}
diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownSegmentCopyTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownSegmentCopyTests.cs
new file mode 100644
index 0000000000..6a67791b50
--- /dev/null
+++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownSegmentCopyTests.cs
@@ -0,0 +1,40 @@
+using TgMarkdown = Terminal.Gui.Views.Markdown;
+
+namespace ViewsTests.Markdown;
+
+// CoPilot - GPT-6
+public class MarkdownSegmentCopyTests
+{
+ [Fact]
+ public void Segment_WithText_Preserves_All_Metadata ()
+ {
+ StyledSegment source = new ("before", MarkdownStyleRole.Link, "https://example.com", "image.png", new Terminal.Gui.Drawing.Attribute (Color.Red, Color.Blue), VisualRole.CodeKeyword)
+ {
+ AdditionalStyle = TextStyle.Bold | TextStyle.Italic | TextStyle.Strikethrough
+ };
+ StyledSegment copy = source.WithText ("after");
+
+ Assert.Equal ("after", copy.Text);
+ Assert.Equal (source.StyleRole, copy.StyleRole);
+ Assert.Equal (source.Url, copy.Url);
+ Assert.Equal (source.ImageSource, copy.ImageSource);
+ Assert.Equal (source.Attribute, copy.Attribute);
+ Assert.Equal (source.Role, copy.Role);
+ Assert.Equal (source.AdditionalStyle, copy.AdditionalStyle);
+ }
+
+ [Fact]
+ public void Code_Legend_Preserves_Additional_Style ()
+ {
+ StyledSegment source = new ("CodeKeyword", MarkdownStyleRole.CodeBlock)
+ {
+ AdditionalStyle = TextStyle.Bold | TextStyle.Italic
+ };
+ List output = [];
+ CodeRoleLegendHighlighter.AddRoleSegments (output, source);
+ StyledSegment segment = Assert.Single (output);
+
+ Assert.Equal (source.AdditionalStyle, segment.AdditionalStyle);
+ Assert.Equal (VisualRole.CodeKeyword, segment.Role);
+ }
+}
diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownTableLayoutRegressionTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownTableLayoutRegressionTests.cs
new file mode 100644
index 0000000000..257d6def17
--- /dev/null
+++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownTableLayoutRegressionTests.cs
@@ -0,0 +1,120 @@
+using UnitTests;
+
+namespace ViewsTests.Markdown;
+
+// CoPilot - GPT-6
+public class MarkdownTableLayoutRegressionTests : TestDriverBase
+{
+ [Theory]
+ [InlineData (MarkdownStyleRole.Strong)]
+ [InlineData (MarkdownStyleRole.Link)]
+ public void Wrapping_Keeps_A_Word_Together_Across_Styles (MarkdownStyleRole role)
+ {
+ StyledSegment styled = new ("bo", role, "https://example.com")
+ {
+ AdditionalStyle = TextStyle.Italic
+ };
+ List segments = [new ("see ", MarkdownStyleRole.Normal), styled, new ("ld", MarkdownStyleRole.Normal)];
+ List> lines = MarkdownTable.WrapSegments (segments, 6);
+
+ Assert.Equal (["see", "bold"], lines.Select (line => string.Concat (line.Select (segment => segment.Text)).TrimEnd ()));
+ StyledSegment wrapped = lines [1] [0];
+
+ Assert.Equal (role, wrapped.StyleRole);
+ Assert.Equal (styled.Url, wrapped.Url);
+ Assert.Equal (styled.AdditionalStyle, wrapped.AdditionalStyle);
+ }
+
+ [Fact]
+ public void Wrapping_Keeps_A_Styled_Combining_Mark_With_Its_Base ()
+ {
+ List> lines = MarkdownTable.WrapSegments ([new ("a", MarkdownStyleRole.Normal), new ("\u0301", MarkdownStyleRole.Strong)], 1);
+ List line = Assert.Single (lines);
+
+ Assert.Equal ("a\u0301", string.Concat (line.Select (segment => segment.Text)));
+ Assert.Equal (MarkdownStyleRole.Strong, line [1].StyleRole);
+ }
+
+ [Theory]
+ [InlineData ("็\nx", "็|x")]
+ [InlineData ("็\n\nx", "็||x")]
+ [InlineData ("abcdef\nx", "ab|cd|ef|x")]
+ public void Hard_Wrapping_Does_Not_Add_A_Line_Before_A_Hard_Break (string text, string expected)
+ {
+ List> lines = MarkdownTable.WrapSegments ([new (text, MarkdownStyleRole.Normal)], text.StartsWith ("็", StringComparison.Ordinal) ? 1 : 2);
+
+ Assert.Equal (expected, string.Join ('|', lines.Select (line => string.Concat (line.Select (segment => segment.Text)))));
+ }
+
+ [Theory]
+ [InlineData ("see **bo**ld")]
+ [InlineData ("see [bo](u)ld")]
+ [InlineData ("see **b*o***ld")]
+ public void Drawing_Keeps_A_Styled_Word_On_One_Row (string source)
+ {
+ using IDriver driver = CreateTestDriver (10, 10);
+ using MarkdownTable table = new ()
+ {
+ Driver = driver,
+ TableData = new (["H"], [Alignment.Start], [[source]]),
+ Width = 10
+ };
+ table.Layout ();
+ table.Draw ();
+
+ Assert.Equal ("see", ReadText (3));
+ Assert.Equal ("bold", ReadText (4));
+
+ return;
+
+ string ReadText (int row) => string.Concat (Enumerable.Range (2, 6).Select (column => driver.Contents! [row, column].Grapheme)).TrimEnd ();
+ }
+
+ [Theory]
+ [InlineData ("\t")]
+ [InlineData ("\n")]
+ public void Wrapping_Uses_The_Same_Word_Boundaries_As_Measurement (string separator)
+ {
+ List segments = [new ($"a{separator}bbbbbbbb", MarkdownStyleRole.Normal)];
+ int wordWidth = MarkdownTable.MeasureLongestWord (segments);
+ List> lines = MarkdownTable.WrapSegments (segments, wordWidth);
+
+ Assert.Equal (8, wordWidth);
+ Assert.Equal (["a", "bbbbbbbb"], lines.Select (line => string.Concat (line.Select (segment => segment.Text)).TrimEnd ()));
+ }
+
+ [Fact]
+ public void Resizing_Preserves_Cached_Width_Bounds_Across_Style_Boundaries ()
+ {
+ MarkdownTableContent content = new ([[new ("H", MarkdownStyleRole.Heading)]],
+ [[[new ("long word", MarkdownStyleRole.Normal)]],
+ [[new ("abc", MarkdownStyleRole.Normal), new ("def", MarkdownStyleRole.Strong)]]]);
+
+ Assert.Equal ([11], MarkdownTable.ComputeColumnWidths (content, 80));
+ Assert.Equal ([8], MarkdownTable.ComputeColumnWidths (content, 10));
+ Assert.Equal ([11], MarkdownTable.ComputeColumnWidths (content, 80));
+ Assert.Equal ([9], content.MaximumWidths);
+ Assert.Equal ([6], content.MinimumWidths);
+ }
+
+ [Fact]
+ public void Tab_Separated_Cell_Draws_The_Long_Word_Without_Splitting_It ()
+ {
+ using IDriver driver = CreateTestDriver (12, 10);
+ using MarkdownTable table = new ()
+ {
+ Driver = driver,
+ TableData = new (["H"], [Alignment.Start], [["a\tbbbbbbbb"]]),
+ Width = 12
+ };
+ table.Layout ();
+ table.Draw ();
+
+ Assert.Equal ("a", ReadText (3).TrimEnd ());
+ Assert.Equal ("bbbbbbbb", ReadText (4));
+
+ return;
+
+ string ReadText (int row) => string.Concat (Enumerable.Range (2, 8).Select (column => driver.Contents! [row, column].Grapheme));
+ }
+}
diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownTableRoundTripTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownTableRoundTripTests.cs
new file mode 100644
index 0000000000..3f129a1390
--- /dev/null
+++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownTableRoundTripTests.cs
@@ -0,0 +1,83 @@
+using UnitTests;
+using TgMarkdown = Terminal.Gui.Views.Markdown;
+
+namespace ViewsTests.Markdown;
+
+// CoPilot - GPT-6
+public class MarkdownTableRoundTripTests : TestDriverBase
+{
+ [Theory]
+ [InlineData ("a\\|b", "a|b", MarkdownStyleRole.Normal)]
+ [InlineData ("\\*literal\\*", "*literal*", MarkdownStyleRole.Normal)]
+ [InlineData ("``a`b``", "a`b", MarkdownStyleRole.InlineCode)]
+ [InlineData ("``a\\|b`c``", "a|b`c", MarkdownStyleRole.InlineCode)]
+ public void Table_Copy_Round_Trips_Literal_Text_And_Code (string cell, string expected, MarkdownStyleRole role)
+ {
+ using TgMarkdown view = new ()
+ {
+ Text = $"before\n\n| H |\n| --- |\n| {cell} |\n\nafter",
+ Width = 60,
+ Height = 20
+ };
+ view.Layout ();
+ MarkdownTable table = Assert.Single (view.SubViews.OfType ());
+ string copy = SelectRows (view, table.Frame.Y, table.Frame.Y + table.RenderedHeight - 1);
+ TableData data = Assert.IsType (TableData.TryParse (copy.Split ('\n')));
+ List runs = MarkdownInlineParser.ParseInlines (data.Rows [0] [0], MarkdownStyleRole.Normal);
+
+ Assert.Equal (1, data.ColumnCount);
+ Assert.Equal (expected, string.Concat (runs.Select (run => run.Text)));
+ Assert.All (runs, run => Assert.Equal (role, run.StyleRole));
+
+ using TgMarkdown roundTrip = new ()
+ {
+ Text = copy,
+ Width = 60,
+ Height = 20
+ };
+ roundTrip.Layout ();
+ MarkdownTable reparsed = Assert.Single (roundTrip.SubViews.OfType ());
+ Assert.Equal (table.TableData.Rows [0], reparsed.TableData.Rows [0]);
+
+ using IDriver driver = CreateTestDriver (60, 10);
+ using MarkdownTable standalone = new ()
+ {
+ Driver = driver,
+ TableData = data,
+ Width = 60
+ };
+ standalone.Layout ();
+ Assert.Equal (MarkdownTable.ComputeColumnWidths (table.Content, 60), MarkdownTable.ComputeColumnWidths (standalone.Content, 60));
+ standalone.Draw ();
+
+ Assert.Equal (expected, ReadCellText ());
+
+ standalone.TableData = table.TableData;
+ standalone.Layout ();
+ standalone.SetClipToScreen ();
+ standalone.SetNeedsDraw ();
+ standalone.Draw ();
+
+ Assert.Equal (expected, ReadCellText ());
+
+ return;
+
+ string ReadCellText () => string.Concat (Enumerable.Range (2, expected.Length).Select (column => driver.Contents! [3, column].Grapheme));
+ }
+
+ private static string SelectRows (TgMarkdown view, int first, int last)
+ {
+ view.NewMouseEvent (new Mouse
+ {
+ Position = new Point (0, first),
+ Flags = MouseFlags.LeftButtonPressed
+ });
+ view.NewMouseEvent (new Mouse
+ {
+ Position = new Point (59, last),
+ Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport
+ });
+
+ return Assert.IsType (view.SelectedText);
+ }
+}
diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownTableSerializationTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownTableSerializationTests.cs
new file mode 100644
index 0000000000..e31b6bd17f
--- /dev/null
+++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownTableSerializationTests.cs
@@ -0,0 +1,152 @@
+using Markdig;
+using Markdig.Syntax;
+using Markdig.Syntax.Inlines;
+using TgMarkdown = Terminal.Gui.Views.Markdown;
+
+namespace ViewsTests.Markdown;
+
+// CoPilot - GPT-6
+public class MarkdownTableSerializationTests
+{
+ [Fact]
+ public void Custom_Pipeline_Table_Copy_Keeps_Ordinary_Punctuation_Readable ()
+ {
+ using TgMarkdown view = new ()
+ {
+ MarkdownPipeline = new MarkdownPipelineBuilder ().UsePipeTables ().Build (),
+ Text = "before\n\n| H |\n| --- |\n| Hello, world. |\n\nafter",
+ Width = 60,
+ Height = 20
+ };
+ view.Layout ();
+ MarkdownTable table = Assert.Single (view.SubViews.OfType ());
+ view.NewMouseEvent (new Mouse
+ {
+ Position = new Point (0, table.Frame.Y),
+ Flags = MouseFlags.LeftButtonPressed
+ });
+ view.NewMouseEvent (new Mouse
+ {
+ Position = new Point (59, table.Frame.Y + table.RenderedHeight - 1),
+ Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport
+ });
+ string copied = Assert.IsType (view.SelectedText);
+ TableData data = Assert.IsType (TableData.TryParse (copied.Split ('\n')));
+
+ Assert.Contains ("Hello, world.", copied);
+ Assert.Equal ("Hello, world.", string.Concat (MarkdownInlineParser.ParseInlines (data.Rows [0] [0], MarkdownStyleRole.Normal).Select (run => run.Text)));
+ }
+
+ [Theory]
+ [InlineData (false, false)]
+ [InlineData (true, false)]
+ [InlineData (false, true)]
+ public void Multiline_Grid_Cell_Copy_Preserves_Separators (bool separateParagraphs, bool hardBreak)
+ {
+ string blank = separateParagraphs ? "| |\n" : string.Empty;
+ string first = hardBreak ? "| foo\\|" : "| foo |";
+ using TgMarkdown view = new ()
+ {
+ Text = $"before\n\n+-----+\n| H |\n+=====+\n{first}\n{blank}| bar |\n+-----+\n\nafter",
+ Width = 60,
+ Height = 20
+ };
+ view.Layout ();
+ MarkdownTable table = Assert.Single (view.SubViews.OfType ());
+ view.NewMouseEvent (new Mouse
+ {
+ Position = new Point (0, table.Frame.Y),
+ Flags = MouseFlags.LeftButtonPressed
+ });
+ view.NewMouseEvent (new Mouse
+ {
+ Position = new Point (59, table.Frame.Y + table.RenderedHeight - 1),
+ Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport
+ });
+ TableData copied = Assert.IsType (TableData.TryParse (Assert.IsType (view.SelectedText).Split ('\n')));
+ List runs = MarkdownInlineParser.ParseInlines (copied.Rows [0] [0], MarkdownStyleRole.Normal);
+
+ Assert.Equal (hardBreak ? "foo\nbar" : "foo bar", string.Concat (runs.Select (run => run.Text)));
+ }
+
+ [Theory]
+ [InlineData (" a|b ")]
+ [InlineData ("`a|b`")]
+ public void Grid_Cell_Pipes_Do_Not_Create_Extra_Clipboard_Columns (string cell)
+ {
+ using TgMarkdown view = new ()
+ {
+ Text = $"+-----+\n| H |\n+=====+\n|{cell}|\n+-----+",
+ Width = 60,
+ Height = 20
+ };
+ view.Layout ();
+ MarkdownTable table = Assert.Single (view.SubViews.OfType ());
+ TableData copied = Assert.IsType (TableData.TryParse (table.Text.Split ('\n')));
+ List runs = MarkdownInlineParser.ParseInlines (Assert.Single (copied.Rows [0]), MarkdownStyleRole.Normal);
+
+ Assert.Equal (1, copied.ColumnCount);
+ Assert.Equal ("a|b", string.Concat (runs.Select (run => run.Text)));
+ }
+
+ [Theory]
+ [InlineData ("[label]( \"a title\")", "path with space", "a title")]
+ [InlineData ("[label](u \"a title\")", "u", "a title")]
+ [InlineData ("", "image with space", "a title")]
+ [InlineData ("[label][ref]", "path with space", "reference title")]
+ [InlineData ("[label](u \"\")", "u", "")]
+ [InlineData ("[label](u \"a \\\"quote\\\" \\| title\")", "u", "a \"quote\" | title")]
+ public void Serialized_Link_Preserves_Destination_And_Title (string cell, string url, string title)
+ {
+ using TgMarkdown view = new ()
+ {
+ Text = $"| H |\n| --- |\n| {cell} |\n\n[ref]: \"reference title\"",
+ Width = 60,
+ Height = 20
+ };
+ view.Layout ();
+ MarkdownTable table = Assert.Single (view.SubViews.OfType ());
+ MarkdownDocument reparsed = Markdig.Markdown.Parse (table.TableData.Rows [0] [0]);
+ LinkInline link = Assert.Single (reparsed.Descendants ());
+
+ Assert.Equal (url, link.Url);
+ Assert.Equal (title, link.Title);
+ }
+
+ [Fact]
+ public void Custom_Pipeline_Literal_Domain_Does_Not_Gain_A_Link ()
+ {
+ using TgMarkdown view = new ()
+ {
+ MarkdownPipeline = new MarkdownPipelineBuilder ().UsePipeTables ().Build (),
+ Text = "| H |\n| --- |\n| www.example.com |",
+ Width = 60,
+ Height = 20
+ };
+ view.Layout ();
+ MarkdownTable table = Assert.Single (view.SubViews.OfType ());
+ List runs = MarkdownInlineParser.ParseInlines (table.TableData.Rows [0] [0], MarkdownStyleRole.Normal);
+
+ Assert.Equal ("www.example.com", string.Concat (runs.Select (run => run.Text)));
+ Assert.All (runs, run => Assert.Null (run.Url));
+ }
+
+ [Theory]
+ [InlineData ("www\\.example.com")]
+ [InlineData ("www.example.com")]
+ public void Literal_Domain_Remains_Literal_After_Reparsing (string source)
+ {
+ using TgMarkdown view = new ()
+ {
+ Text = $"| H |\n| --- |\n| {source} |",
+ Width = 60,
+ Height = 20
+ };
+ view.Layout ();
+ MarkdownTable table = Assert.Single (view.SubViews.OfType ());
+ List runs = MarkdownInlineParser.ParseInlines (table.TableData.Rows [0] [0], MarkdownStyleRole.Normal);
+
+ Assert.Equal ("www.example.com", string.Concat (runs.Select (run => run.Text)));
+ Assert.All (runs, run => Assert.Null (run.Url));
+ }
+}
diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownTableTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownTableTests.cs
index 2bd04ceebf..06e8367baf 100644
--- a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownTableTests.cs
+++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownTableTests.cs
@@ -380,6 +380,24 @@ public void Column_Widths_Strip_Markdown_Formatting ()
$"Table width {table.Frame.Width} suggests markdown delimiters were measured");
}
+ // CoPilot - GPT-6
+ private static int [] ComputeColumnWidths (TableData data, int maxWidth)
+ {
+ List [] headers = [.. data.Headers.Select (Cell)];
+ List []> rows = [];
+
+ foreach (string [] row in data.Rows)
+ {
+ rows.Add ([.. row.Select (Cell)]);
+ }
+
+ MarkdownTableContent content = new (headers, [.. rows]);
+
+ return MarkdownTable.ComputeColumnWidths (content, maxWidth);
+
+ static List Cell (string text) => [new StyledSegment (text, MarkdownStyleRole.Normal)];
+ }
+
#region Column Width Algorithm Tests
[Fact]
@@ -390,7 +408,7 @@ public void ComputeColumnWidths_Natural_Fit_Uses_Max_Widths ()
TableData data = TableData.TryParse (lines)!;
// maxWidth = 100 is plenty of room
- int [] widths = MarkdownTable.ComputeColumnWidths (data, 100);
+ int [] widths = ComputeColumnWidths (data, 100);
// "A"/"x" = 1 col + 2 padding = 3; "B"/"y" = 1 col + 2 padding = 3
Assert.Equal (3, widths [0]);
@@ -406,7 +424,7 @@ public void ComputeColumnWidths_Collapse_Shrinks_Widest_First ()
// Natural: col0 = max("Cat","X") = 3+2=5, col1 = ~28+2=30
// Force into 25 cols โ col1 (wider) should shrink, col0 should be preserved
- int [] widths = MarkdownTable.ComputeColumnWidths (data, 25);
+ int [] widths = ComputeColumnWidths (data, 25);
// col0 should be at or near its natural width (5)
Assert.True (widths [0] >= 4, $"Left column {widths [0]} was shrunk too much");
@@ -428,7 +446,7 @@ public void ComputeColumnWidths_Left_Wins_Tiebreak ()
// Natural: both cols = 5+2=7, total = 7+7+3 = 17
// Force into 15 โ need to lose 2 from column content
- int [] widths = MarkdownTable.ComputeColumnWidths (data, 15);
+ int [] widths = ComputeColumnWidths (data, 15);
// Left column should be >= right column (left wins)
Assert.True (widths [0] >= widths [1], $"Left ({widths [0]}) should be >= right ({widths [1]}) when tied");
@@ -447,7 +465,7 @@ public void ComputeColumnWidths_Min_Width_Is_Longest_Word ()
TableData data = TableData.TryParse (lines)!;
// "Navigation" is 10 chars โ min should be 10+2=12
- int [] widths = MarkdownTable.ComputeColumnWidths (data, 30);
+ int [] widths = ComputeColumnWidths (data, 30);
// col0 min = "Navigation" (10) + 2 = 12
Assert.True (widths [0] >= 12, $"Column 0 width {widths [0]} is below longest word 'Navigation' (need >=12)");
@@ -462,7 +480,7 @@ public void ComputeColumnWidths_Last_Resort_Even_Reduction ()
// Both cols: longest word = 9 chars, min = 9+2=11
// Total min = 11+11+3 = 25. Force into 15 โ must go below min.
- int [] widths = MarkdownTable.ComputeColumnWidths (data, 15);
+ int [] widths = ComputeColumnWidths (data, 15);
// Should still fit within maxWidth
int total = widths.Sum () + widths.Length + 1;
@@ -479,7 +497,7 @@ public void ComputeColumnWidths_Single_Column ()
List lines = ["| Header |", "|--------|", "| Cell |"];
TableData data = TableData.TryParse (lines)!;
- int [] widths = MarkdownTable.ComputeColumnWidths (data, 50);
+ int [] widths = ComputeColumnWidths (data, 50);
// "Header" = 6+2=8
Assert.Equal (8, widths [0]);
@@ -492,28 +510,28 @@ public void ComputeColumnWidths_Empty_Cells_Get_Minimum ()
List lines = ["| H | Long Header |", "|---|-------------|", "| | content |"];
TableData data = TableData.TryParse (lines)!;
- int [] widths = MarkdownTable.ComputeColumnWidths (data, 80);
+ int [] widths = ComputeColumnWidths (data, 80);
Assert.True (widths [0] >= 3, $"Empty column width {widths [0]} should be at least 3");
}
[Fact]
- public void MeasureRenderedWidth_Strips_Markdown ()
+ public void MeasureRenderedWidth_Uses_Styled_Content ()
{
- // Copilot โ Rendered width should not include markdown delimiters
- Assert.Equal (4, MarkdownTable.MeasureRenderedWidth ("**bold**"));
- Assert.Equal (6, MarkdownTable.MeasureRenderedWidth ("*italic*"));
- Assert.Equal (4, MarkdownTable.MeasureRenderedWidth ("`code`"));
- Assert.Equal (5, MarkdownTable.MeasureRenderedWidth ("plain"));
+ // CoPilot - GPT-6: Measure display segments rather than reparsing source.
+ Assert.Equal (4, MarkdownTable.MeasureRenderedWidth ([new StyledSegment ("bold", MarkdownStyleRole.Strong)]));
+ Assert.Equal (6, MarkdownTable.MeasureRenderedWidth ([new StyledSegment ("italic", MarkdownStyleRole.Emphasis)]));
+ Assert.Equal (4, MarkdownTable.MeasureRenderedWidth ([new StyledSegment ("code", MarkdownStyleRole.InlineCode)]));
+ Assert.Equal (5, MarkdownTable.MeasureRenderedWidth ([new StyledSegment ("plain", MarkdownStyleRole.Normal)]));
}
[Fact]
public void MeasureLongestWord_Finds_Longest ()
{
- // Copilot โ Should find the longest individual word
- Assert.Equal (5, MarkdownTable.MeasureLongestWord ("hi there world"));
- Assert.Equal (10, MarkdownTable.MeasureLongestWord ("Navigation"));
- Assert.Equal (4, MarkdownTable.MeasureLongestWord ("**bold** text"));
+ // CoPilot - GPT-6: Measure displayed words across segment boundaries.
+ Assert.Equal (5, MarkdownTable.MeasureLongestWord ([new StyledSegment ("hi there world", MarkdownStyleRole.Normal)]));
+ Assert.Equal (10, MarkdownTable.MeasureLongestWord ([new StyledSegment ("Navigation", MarkdownStyleRole.Normal)]));
+ Assert.Equal (4, MarkdownTable.MeasureLongestWord ([new StyledSegment ("bold", MarkdownStyleRole.Strong), new StyledSegment (" text", MarkdownStyleRole.Normal)]));
}
[Fact]