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 ("!["); - AppendInlineText (link.FirstChild, sb); - 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 ? "![" : "["); + AppendInlineSource (link.FirstChild, source); + source.Append ("](<"); + 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\\![docs](https://x)")] + [InlineData ("Wow![docs](https://x)")] + [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\\![docs](https://x)", "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 ("![label]( 'a title')", "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]