diff --git a/JitHub.slnx b/JitHub.slnx index 923aee7..d39636a 100644 --- a/JitHub.slnx +++ b/JitHub.slnx @@ -24,6 +24,14 @@ + + + + + + + + diff --git a/MarkdownRenderer/MarkdownRenderer.Gfm/GfmChildBuilder.cs b/MarkdownRenderer/MarkdownRenderer.Gfm/GfmChildBuilder.cs index a73f4c0..7b81b30 100644 --- a/MarkdownRenderer/MarkdownRenderer.Gfm/GfmChildBuilder.cs +++ b/MarkdownRenderer/MarkdownRenderer.Gfm/GfmChildBuilder.cs @@ -85,7 +85,11 @@ private static StackBox BuildContainer(ContainerBlock cb, MarkdownLayoutContext return stack; } - internal static void AddInlines(InlineContainerBox box, ContainerInline inlines, System.Func? skipFirstIf = null) + internal static void AddInlines( + InlineContainerBox box, + ContainerInline inlines, + System.Func? skipFirstIf = null, + int inheritedAliasStart = -1) { bool skippedFirst = skipFirstIf is null; foreach (var i in inlines) @@ -101,10 +105,17 @@ internal static void AddInlines(InlineContainerBox box, ContainerInline inlines, var run = BuildInline(i, box.Context); if (run is not null) { - run.SetStyleAliases(box.Context.CreateStyleAliasSnapshotFrom(aliasStart)); + int effectiveAliasStart = inheritedAliasStart >= 0 ? inheritedAliasStart : aliasStart; + run.SetStyleAliases(box.Context.CreateStyleAliasSnapshotFrom(effectiveAliasStart)); box.Context.RegisterMarkdownAttributes(i, box.BlockIndex); box.Add(run); } + else if (i is ContainerInline nested) + { + box.Context.RegisterMarkdownAttributes(i, box.BlockIndex); + int effectiveAliasStart = inheritedAliasStart >= 0 ? inheritedAliasStart : aliasStart; + AddInlines(box, nested, inheritedAliasStart: effectiveAliasStart); + } } } @@ -135,7 +146,6 @@ internal static void AddInlines(InlineContainerBox box, ContainerInline inlines, { SourceSpan = new MarkdownRenderer.SourceSpan(inline.Span.Start, inline.Span.Length) }, - ContainerInline ci2 => FlattenAsTextRun(ci2), _ => null }; diff --git a/MarkdownRenderer/MarkdownRenderer.Sample/MainWindow.xaml.cs b/MarkdownRenderer/MarkdownRenderer.Sample/MainWindow.xaml.cs index 91933b5..c327405 100644 --- a/MarkdownRenderer/MarkdownRenderer.Sample/MainWindow.xaml.cs +++ b/MarkdownRenderer/MarkdownRenderer.Sample/MainWindow.xaml.cs @@ -8,6 +8,7 @@ using MarkdownRenderer.Controls; using MarkdownRenderer.Gfm; using MarkdownRenderer.Parsing; +using MarkdownRenderer.SyntaxHighlighting.TextMate; using MarkdownRenderer.Theming; namespace MarkdownRenderer.Sample; @@ -170,6 +171,7 @@ public MainWindow() _renderer = new MarkdownRendererControlBuilder() .UseGitHubFlavoredMarkdown() .UseMarkdownExtra() + .UseTextMateSyntaxHighlighting() .WithMarkdown(FullDemoSample) .WithTheme(new MarkdownTheme()) .WithEmbedFactory(new SampleEmbedFactory()) @@ -394,9 +396,9 @@ 2. And back to normal. private const string CodeSample = """ ## Fenced Code Blocks - C# example: + C# with a filename, line numbers, and highlighted lines: - ```csharp + ```csharp filename="MarkdownRendererControl.cs" {3,8-10} startLine=120 public sealed class MarkdownRendererControl : UserControl { private volatile LayoutSnapshot? _snapshot; @@ -417,9 +419,20 @@ private async Task RebuildAsync(CancellationToken ct) } ``` - Python example: + TypeScript with a title: + + ```ts title="Async preview model" + type RenderState = "idle" | "loading" | "ready"; + + export async function renderMarkdown(source: string): Promise { + const response = await fetch("/api/markdown", { method: "POST", body: source }); + return response.ok ? "ready" : "idle"; + } + ``` + + Python example without line numbers: - ```python + ```python noLineNumbers import asyncio async def render_markdown(text: str) -> LayoutSnapshot: @@ -428,18 +441,44 @@ async def render_markdown(text: str) -> LayoutSnapshot: return await asyncio.to_thread(layout_builder.build, document) ``` + PowerShell example: + + ```powershell filename="build.ps1" + dotnet test .\MarkdownRenderer\MarkdownRenderer.Tests\MarkdownRenderer.Tests.csproj -p:Platform=x64 + dotnet build .\MarkdownRenderer\MarkdownRenderer.Sample\MarkdownRenderer.Sample.csproj -p:Platform=x64 + ``` + + JSON example: + + ```json + { + "renderer": "MarkdownRenderer", + "codeBlockVersion": 2, + "syntaxHighlighting": true + } + ``` + + Diff example: + + ```diff + - plain shaded text box + + native code surface + + syntax highlighting + + copy actions + ``` + + Long line that wraps: + + ```js filename="long-line.js" + export const message = "This deliberately long line demonstrates that code blocks always wrap instead of requiring horizontal scrolling."; + ``` + Indented code block (4 spaces): var x = 42; Console.WriteLine($"The answer is {x}"); Inline `code` uses a background highlight. - - ## Language tags - - The renderer stores the `FencedCodeBlock.Info` property (e.g. `"csharp"`) - on the source map entry — a future syntax-highlighting pass can use this - to apply per-token colors via `CanvasTextLayout.SetColor`. """; private const string AlertsSample = """ diff --git a/MarkdownRenderer/MarkdownRenderer.Sample/MarkdownRenderer.Sample.csproj b/MarkdownRenderer/MarkdownRenderer.Sample/MarkdownRenderer.Sample.csproj index a64513f..d1fc9de 100644 --- a/MarkdownRenderer/MarkdownRenderer.Sample/MarkdownRenderer.Sample.csproj +++ b/MarkdownRenderer/MarkdownRenderer.Sample/MarkdownRenderer.Sample.csproj @@ -33,6 +33,7 @@ + diff --git a/MarkdownRenderer/MarkdownRenderer.SyntaxHighlighting.TextMate/MarkdownRenderer.SyntaxHighlighting.TextMate.csproj b/MarkdownRenderer/MarkdownRenderer.SyntaxHighlighting.TextMate/MarkdownRenderer.SyntaxHighlighting.TextMate.csproj new file mode 100644 index 0000000..bee4240 --- /dev/null +++ b/MarkdownRenderer/MarkdownRenderer.SyntaxHighlighting.TextMate/MarkdownRenderer.SyntaxHighlighting.TextMate.csproj @@ -0,0 +1,35 @@ + + + net10.0-windows10.0.26100.0 + 10.0.19041.0 + MarkdownRenderer.SyntaxHighlighting.TextMate + MarkdownRenderer.SyntaxHighlighting.TextMate + true + false + enable + latest + x86;x64;ARM64 + x64 + $(Platform) + true + MarkdownRenderer.SyntaxHighlighting.TextMate + 0.1.0 + nerocui + TextMate grammar based syntax highlighting for MarkdownRenderer code blocks. + markdown;winui;syntax-highlighting;textmate;code + MIT + https://github.com/JitHubApp/JitHubV2 + git + https://github.com/JitHubApp/JitHubV2/tree/main/docs/markdown-renderer + README.md + icon-192.png + + + + + + + + + + diff --git a/MarkdownRenderer/MarkdownRenderer.SyntaxHighlighting.TextMate/TextMateCodeBlockSyntaxHighlighter.cs b/MarkdownRenderer/MarkdownRenderer.SyntaxHighlighting.TextMate/TextMateCodeBlockSyntaxHighlighter.cs new file mode 100644 index 0000000..80152bf --- /dev/null +++ b/MarkdownRenderer/MarkdownRenderer.SyntaxHighlighting.TextMate/TextMateCodeBlockSyntaxHighlighter.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using MarkdownRenderer.CodeBlocks; +using TextMateSharp.Grammars; +using TextMateSharp.Registry; +using Windows.UI; + +namespace MarkdownRenderer.SyntaxHighlighting.TextMate; + +/// +/// Syntax highlighter backed by the grammars and VS Code-style themes bundled +/// by TextMateSharp.Grammars. +/// +public sealed class TextMateCodeBlockSyntaxHighlighter : ICodeBlockSyntaxHighlighter +{ + private readonly object _gate = new(); + private readonly Dictionary _states = new(); + + /// + public int Revision { get; init; } + + /// + public ValueTask HighlightAsync(CodeBlockHighlightRequest request) + { + if (request is null) throw new ArgumentNullException(nameof(request)); + request.CancellationToken.ThrowIfCancellationRequested(); + + if (request.ThemeVariant == CodeBlockThemeVariant.HighContrast) + return ValueTask.FromResult(CodeBlockHighlightResult.Empty); + + var languageId = NormalizeLanguageId(request.Language); + if (languageId is null) + return ValueTask.FromResult(CodeBlockHighlightResult.Empty); + + lock (_gate) + { + request.CancellationToken.ThrowIfCancellationRequested(); + var state = GetState(request.ThemeVariant); + var scope = ResolveScope(state.Options, languageId); + if (string.IsNullOrWhiteSpace(scope)) + return ValueTask.FromResult(CodeBlockHighlightResult.Empty); + + var grammar = state.Registry.LoadGrammar(scope); + if (grammar is null) + return ValueTask.FromResult(CodeBlockHighlightResult.Empty); + + var spans = Tokenize(request.Code, grammar, state.Registry.GetTheme(), request.CancellationToken); + return ValueTask.FromResult(new CodeBlockHighlightResult(spans)); + } + } + + private ThemeState GetState(CodeBlockThemeVariant variant) + { + if (_states.TryGetValue(variant, out var state)) + return state; + + var themeName = variant == CodeBlockThemeVariant.Light ? ThemeName.LightPlus : ThemeName.DarkPlus; + var options = new RegistryOptions(themeName); + var registry = new Registry(options); + state = new ThemeState(options, registry); + _states[variant] = state; + return state; + } + + private static IReadOnlyList Tokenize( + string code, + IGrammar grammar, + TextMateSharp.Themes.Theme theme, + System.Threading.CancellationToken cancellationToken) + { + var spans = new List(); + IStateStack? state = null; + + foreach (var line in EnumerateLines(code)) + { + cancellationToken.ThrowIfCancellationRequested(); + var result = state is null + ? grammar.TokenizeLine(line.Text) + : grammar.TokenizeLine(line.Text, state, TimeSpan.FromMilliseconds(200)); + state = result.RuleStack; + + foreach (var token in result.Tokens) + { + int length = token.Length; + if (length <= 0) + continue; + + var color = ResolveColor(theme, token.Scopes); + if (color is null) + continue; + + spans.Add(new CodeBlockHighlightSpan(line.Offset + token.StartIndex, length, color.Value)); + } + } + + return spans; + } + + private static Color? ResolveColor(TextMateSharp.Themes.Theme theme, IList scopes) + { + var rule = theme.Match(scopes).LastOrDefault(r => r.foreground != 0); + if (rule is null) + return null; + + var hex = theme.GetColor(rule.foreground); + return TryParseHexColor(hex, out var color) ? color : null; + } + + private static bool TryParseHexColor(string? hex, out Color color) + { + color = default; + if (string.IsNullOrWhiteSpace(hex)) + return false; + + var value = hex.Trim(); + if (value.StartsWith("#", StringComparison.Ordinal)) + value = value.Substring(1); + + if (value.Length == 6 && + byte.TryParse(value.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) && + byte.TryParse(value.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) && + byte.TryParse(value.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b)) + { + color = Color.FromArgb(0xFF, r, g, b); + return true; + } + + if (value.Length == 8 && + byte.TryParse(value.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var a) && + byte.TryParse(value.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r8) && + byte.TryParse(value.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g8) && + byte.TryParse(value.Substring(6, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b8)) + { + color = Color.FromArgb(a, r8, g8, b8); + return true; + } + + return false; + } + + private static string? ResolveScope(RegistryOptions options, string languageId) + { + try + { + var scope = options.GetScopeByLanguageId(languageId); + if (!string.IsNullOrWhiteSpace(scope)) + return scope; + } + catch + { + } + + try + { + var scope = options.GetScopeByExtension("." + languageId); + return string.IsNullOrWhiteSpace(scope) ? null : scope; + } + catch + { + return null; + } + } + + private static string? NormalizeLanguageId(string? language) + { + var value = language?.Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(value)) + return null; + + return value switch + { + "cs" or "csharp" => "csharp", + "js" or "javascript" => "javascript", + "jsx" => "javascriptreact", + "ts" or "typescript" => "typescript", + "tsx" => "typescriptreact", + "py" or "python" => "python", + "ps" or "ps1" or "pwsh" or "powershell" => "powershell", + "bash" or "sh" or "shell" or "shellscript" => "shellscript", + "md" or "markdown" => "markdown", + "yml" or "yaml" => "yaml", + _ => value, + }; + } + + private static IEnumerable<(string Text, int Offset)> EnumerateLines(string code) + { + if (code.Length == 0) + { + yield return (string.Empty, 0); + yield break; + } + + int offset = 0; + while (offset < code.Length) + { + int newline = code.IndexOfAny(['\r', '\n'], offset); + if (newline < 0) + { + yield return (code.Substring(offset), offset); + yield break; + } + + int end = newline; + yield return (code.Substring(offset, end - offset), offset); + offset = newline + 1; + if (code[newline] == '\r' && offset < code.Length && code[offset] == '\n') + offset++; + } + } + + private sealed record ThemeState(RegistryOptions Options, Registry Registry); +} diff --git a/MarkdownRenderer/MarkdownRenderer.SyntaxHighlighting.TextMate/TextMateSyntaxHighlightingExtensions.cs b/MarkdownRenderer/MarkdownRenderer.SyntaxHighlighting.TextMate/TextMateSyntaxHighlightingExtensions.cs new file mode 100644 index 0000000..24d1b62 --- /dev/null +++ b/MarkdownRenderer/MarkdownRenderer.SyntaxHighlighting.TextMate/TextMateSyntaxHighlightingExtensions.cs @@ -0,0 +1,32 @@ +using MarkdownRenderer.CodeBlocks; +using MarkdownRenderer.Controls; + +namespace MarkdownRenderer.SyntaxHighlighting.TextMate; + +/// +/// Fluent helpers for enabling TextMate grammar based code-block highlighting. +/// +public static class TextMateSyntaxHighlightingExtensions +{ + /// Configures a builder to use the default TextMate code-block highlighter. + public static MarkdownRendererControlBuilder UseTextMateSyntaxHighlighting( + this MarkdownRendererControlBuilder builder, + TextMateCodeBlockSyntaxHighlighter? highlighter = null) + { + if (builder is null) throw new System.ArgumentNullException(nameof(builder)); + return builder + .WithCodeBlockSyntaxHighlightingEnabled(true) + .WithCodeBlockSyntaxHighlighter(highlighter ?? new TextMateCodeBlockSyntaxHighlighter()); + } + + /// Configures a control to use the default TextMate code-block highlighter. + public static MarkdownRendererControl UseTextMateSyntaxHighlighting( + this MarkdownRendererControl control, + TextMateCodeBlockSyntaxHighlighter? highlighter = null) + { + if (control is null) throw new System.ArgumentNullException(nameof(control)); + control.IsCodeBlockSyntaxHighlightingEnabled = true; + control.CodeBlockSyntaxHighlighter = highlighter ?? new TextMateCodeBlockSyntaxHighlighter(); + return control; + } +} diff --git a/MarkdownRenderer/MarkdownRenderer.Tests/CodeBlockHighlightCacheTests.cs b/MarkdownRenderer/MarkdownRenderer.Tests/CodeBlockHighlightCacheTests.cs new file mode 100644 index 0000000..1068fdd --- /dev/null +++ b/MarkdownRenderer/MarkdownRenderer.Tests/CodeBlockHighlightCacheTests.cs @@ -0,0 +1,54 @@ +using MarkdownRenderer.CodeBlocks; +using Xunit; + +namespace MarkdownRenderer.Tests; + +public sealed class CodeBlockHighlightCacheTests +{ + [Fact] + public void Set_EvictsOldestEntry_WhenCapacityIsExceeded() + { + var cache = new BoundedCodeBlockHighlightCache(2); + var first = new CodeBlockHighlightResult([new CodeBlockHighlightSpan(0, 1, default)]); + var second = new CodeBlockHighlightResult([new CodeBlockHighlightSpan(1, 1, default)]); + var third = new CodeBlockHighlightResult([new CodeBlockHighlightSpan(2, 1, default)]); + + cache.Set("first", first); + cache.Set("second", second); + cache.Set("third", third); + + Assert.Equal(2, cache.Count); + Assert.False(cache.TryGetValue("first", out _)); + Assert.True(cache.TryGetValue("second", out var cachedSecond)); + Assert.Same(second, cachedSecond); + Assert.True(cache.TryGetValue("third", out var cachedThird)); + Assert.Same(third, cachedThird); + } + + [Fact] + public void Set_UpdatesExistingEntry_WithoutGrowing() + { + var cache = new BoundedCodeBlockHighlightCache(2); + var original = new CodeBlockHighlightResult([]); + var updated = new CodeBlockHighlightResult([new CodeBlockHighlightSpan(0, 4, default)]); + + cache.Set("code", original); + cache.Set("code", updated); + + Assert.Equal(1, cache.Count); + Assert.True(cache.TryGetValue("code", out var cached)); + Assert.Same(updated, cached); + } + + [Fact] + public void Clear_RemovesCachedEntries() + { + var cache = new BoundedCodeBlockHighlightCache(2); + + cache.Set("code", new CodeBlockHighlightResult([])); + cache.Clear(); + + Assert.Equal(0, cache.Count); + Assert.False(cache.TryGetValue("code", out _)); + } +} diff --git a/MarkdownRenderer/MarkdownRenderer.Tests/CodeBlockMetadataTests.cs b/MarkdownRenderer/MarkdownRenderer.Tests/CodeBlockMetadataTests.cs new file mode 100644 index 0000000..b290e0b --- /dev/null +++ b/MarkdownRenderer/MarkdownRenderer.Tests/CodeBlockMetadataTests.cs @@ -0,0 +1,83 @@ +using Markdig; +using Markdig.Syntax; +using MarkdownRenderer.Layout; +using Xunit; + +namespace MarkdownRenderer.Tests; + +public sealed class CodeBlockMetadataTests +{ + [Fact] + public void CopyPayload_UsesDisplayedCodeTextWithoutFences() + { + var document = Markdown.Parse("```js\nconsole.log(1);\nconsole.log(2);\n```\n"); + var block = Assert.IsType(document[0]); + + string payload = CodeBlockMetadata.CopyPayload(block.Lines.ToString()); + + Assert.Equal("console.log(1);\nconsole.log(2);", payload); + Assert.DoesNotContain("```", payload); + Assert.DoesNotContain("js", payload); + } + + [Fact] + public void CopyPayload_NormalizesCarriageReturnLineEndings() + { + string payload = CodeBlockMetadata.CopyPayload("alpha\rbeta\r\ngamma"); + + Assert.Equal("alpha\nbeta\ngamma", payload); + } + + [Theory] + [InlineData(null, "Code")] + [InlineData("", "Code")] + [InlineData("csharp", "C#")] + [InlineData("cs", "C#")] + [InlineData("js", "JavaScript")] + [InlineData("javascript", "JavaScript")] + [InlineData("ts", "TypeScript")] + [InlineData("typescript", "TypeScript")] + [InlineData("python", "Python")] + [InlineData("py", "Python")] + [InlineData("powershell", "PowerShell")] + [InlineData("pwsh", "PowerShell")] + [InlineData("custom-lang", "custom-lang")] + public void DisplayLanguage_NormalizesAliases_AndPreservesUnknown(string? input, string expected) + { + Assert.Equal(expected, CodeBlockMetadata.DisplayLanguage(input)); + } + + [Fact] + public void FromBlock_ParsesV2FenceMetadata() + { + var document = Markdown.Parse("```csharp filename=\"src/App.cs\" title=\"Main app\" {1,3-5} showLineNumbers startLine=10 diff ignored\n+Console.WriteLine(1);\n```\n"); + var block = Assert.IsType(document[0]); + + var metadata = CodeBlockMetadata.FromBlock(block, block.Lines.ToString()); + + Assert.Equal("csharp", metadata.Language); + Assert.Equal("C#", metadata.LanguageDisplay); + Assert.Equal("src/App.cs", metadata.FileName); + Assert.Equal("Main app", metadata.Title); + Assert.True(metadata.ShowLineNumbers); + Assert.Equal(10, metadata.StartLine); + Assert.True(metadata.IsDiff); + Assert.True(metadata.HighlightedLines.Contains(1)); + Assert.False(metadata.HighlightedLines.Contains(2)); + Assert.True(metadata.HighlightedLines.Contains(3)); + Assert.True(metadata.HighlightedLines.Contains(5)); + Assert.False(metadata.HighlightedLines.Contains(6)); + } + + [Fact] + public void FromBlock_NoLineNumbers_OverridesShowLineNumbers() + { + var document = Markdown.Parse("```ts showLineNumbers noLineNumbers\nconst x = 1;\n```\n"); + var block = Assert.IsType(document[0]); + + var metadata = CodeBlockMetadata.FromBlock(block, block.Lines.ToString()); + + Assert.Equal("typescript", metadata.Language); + Assert.False(metadata.ShowLineNumbers); + } +} diff --git a/MarkdownRenderer/MarkdownRenderer.Tests/HighContrastDefaultsTests.cs b/MarkdownRenderer/MarkdownRenderer.Tests/HighContrastDefaultsTests.cs index 285e3ea..d6c25f2 100644 --- a/MarkdownRenderer/MarkdownRenderer.Tests/HighContrastDefaultsTests.cs +++ b/MarkdownRenderer/MarkdownRenderer.Tests/HighContrastDefaultsTests.cs @@ -20,6 +20,10 @@ public void BodyAndCodeUseWindowForegroundAndBackground() { var body = MarkdownHighContrastDefaults.Resolve("Body"); var code = MarkdownHighContrastDefaults.Resolve("CodeBlock"); + var codeHeader = MarkdownHighContrastDefaults.Resolve("CodeBlockHeader"); + var codeLanguage = MarkdownHighContrastDefaults.Resolve("CodeBlockLanguage"); + var codeGutter = MarkdownHighContrastDefaults.Resolve("CodeBlockGutter"); + var codeLineNumber = MarkdownHighContrastDefaults.Resolve("CodeBlockLineNumber"); var inlineCode = MarkdownHighContrastDefaults.Resolve("CodeInline"); Assert.Equal(MarkdownHighContrastColorRole.WindowText, body.Foreground); @@ -27,6 +31,16 @@ public void BodyAndCodeUseWindowForegroundAndBackground() Assert.Equal(MarkdownHighContrastColorRole.WindowText, code.Foreground); Assert.Equal(MarkdownHighContrastColorRole.Window, code.Background); Assert.Equal(MarkdownHighContrastColorRole.WindowText, code.AccentBar); + Assert.Equal(MarkdownHighContrastColorRole.WindowText, codeHeader.Foreground); + Assert.Equal(MarkdownHighContrastColorRole.Window, codeHeader.Background); + Assert.Equal(MarkdownHighContrastColorRole.WindowText, codeHeader.AccentBar); + Assert.Equal(MarkdownHighContrastColorRole.WindowText, codeLanguage.Foreground); + Assert.Null(codeLanguage.Background); + Assert.Equal(MarkdownHighContrastColorRole.WindowText, codeGutter.Foreground); + Assert.Equal(MarkdownHighContrastColorRole.Window, codeGutter.Background); + Assert.Equal(MarkdownHighContrastColorRole.WindowText, codeGutter.AccentBar); + Assert.Equal(MarkdownHighContrastColorRole.WindowText, codeLineNumber.Foreground); + Assert.Null(codeLineNumber.Background); Assert.Equal(MarkdownHighContrastColorRole.WindowText, inlineCode.Foreground); Assert.Equal(MarkdownHighContrastColorRole.Window, inlineCode.Background); } @@ -34,8 +48,12 @@ public void BodyAndCodeUseWindowForegroundAndBackground() [Fact] public void TableHeaderUsesHighlightPair() { + var table = MarkdownHighContrastDefaults.Resolve("Table"); var roles = MarkdownHighContrastDefaults.Resolve("TableHeader"); + Assert.Equal(MarkdownHighContrastColorRole.WindowText, table.Foreground); + Assert.Equal(MarkdownHighContrastColorRole.Window, table.Background); + Assert.Equal(MarkdownHighContrastColorRole.WindowText, table.AccentBar); Assert.Equal(MarkdownHighContrastColorRole.HighlightText, roles.Foreground); Assert.Equal(MarkdownHighContrastColorRole.Highlight, roles.Background); } @@ -48,4 +66,14 @@ public void AlertsUseHotlightAccentBar() Assert.Equal(MarkdownHighContrastColorRole.WindowText, roles.Foreground); Assert.Equal(MarkdownHighContrastColorRole.Hotlight, roles.AccentBar); } + + [Fact] + public void AbbreviationUsesVisibleSystemUnderline() + { + var roles = MarkdownHighContrastDefaults.Resolve("Abbreviation"); + + Assert.Equal(MarkdownHighContrastColorRole.WindowText, roles.Foreground); + Assert.Equal(MarkdownHighContrastColorRole.WindowText, roles.AccentBar); + Assert.True(roles.Underline); + } } diff --git a/MarkdownRenderer/MarkdownRenderer.Tests/KeyboardNavTests.cs b/MarkdownRenderer/MarkdownRenderer.Tests/KeyboardNavTests.cs index 56205ca..289f5bd 100644 --- a/MarkdownRenderer/MarkdownRenderer.Tests/KeyboardNavTests.cs +++ b/MarkdownRenderer/MarkdownRenderer.Tests/KeyboardNavTests.cs @@ -52,6 +52,19 @@ public void FocusableItem_ZeroValues_Valid() Assert.Equal(0, item.InlineIndex); } + [Fact] + public void FocusableItem_CodeBlockCopy_RoundTripsKind() + { + var item = new FocusableItem(12, 0, FocusableItemKind.CodeBlockCopy); + + Assert.Equal(12, item.BlockIndex); + Assert.False(item.IsLink); + Assert.False(item.IsInlineEmbed); + Assert.False(item.IsBlockEmbed); + Assert.True(item.IsCodeBlockCopy); + Assert.True(item.IsCodeBlockAction); + } + [Fact] public void FocusableItem_LargeValues_Preserved() { diff --git a/MarkdownRenderer/MarkdownRenderer.Tests/MarkdigParserTests.cs b/MarkdownRenderer/MarkdownRenderer.Tests/MarkdigParserTests.cs index 07acd4b..c562693 100644 --- a/MarkdownRenderer/MarkdownRenderer.Tests/MarkdigParserTests.cs +++ b/MarkdownRenderer/MarkdownRenderer.Tests/MarkdigParserTests.cs @@ -1,5 +1,6 @@ using Xunit; using Markdig; +using Markdig.Extensions.Abbreviations; using Markdig.Syntax; using Markdig.Syntax.Inlines; using Markdig.Extensions.Tables; @@ -184,6 +185,42 @@ public void Parse_Link_ProducesLinkInline() Assert.Equal("https://github.com", link.Url); } + [Fact] + public void Parse_AbbreviationOccurrences_CanBeNestedInsideGenericContainerInline() + { + const string md = """ + HTML and SVG expansions should expose abbreviation runs. + + *[HTML]: Hyper Text Markup Language + *[SVG]: Scalable Vector Graphics + """; + + var pipeline = new MarkdownPipelineBuilder() + .UseAbbreviations() + .Build(); + var doc = new MarkdigParser(pipeline).Parse(md).Document; + var para = Assert.Single(doc.OfType()); + + Assert.DoesNotContain(para.Inline!, inline => inline is AbbreviationInline); + + var abbreviations = DescendantInlines(para.Inline!) + .OfType() + .ToArray(); + + Assert.Collection( + abbreviations, + abbreviation => + { + Assert.Equal("HTML", abbreviation.Abbreviation?.Label); + Assert.Equal("Hyper Text Markup Language", abbreviation.Abbreviation?.Text.ToString()); + }, + abbreviation => + { + Assert.Equal("SVG", abbreviation.Abbreviation?.Label); + Assert.Equal("Scalable Vector Graphics", abbreviation.Abbreviation?.Text.ToString()); + }); + } + // ── GFM extensions ─────────────────────────────────────────────────────── [Fact] @@ -242,4 +279,17 @@ public void MultipleBlocks_CountMatchesExpected() Assert.Equal(1, doc.OfType().Count()); Assert.Equal(1, doc.OfType().Count()); } + + private static IEnumerable DescendantInlines(ContainerInline container) + { + foreach (var inline in container) + { + yield return inline; + if (inline is not ContainerInline nested) + continue; + + foreach (var descendant in DescendantInlines(nested)) + yield return descendant; + } + } } diff --git a/MarkdownRenderer/MarkdownRenderer.Tests/MarkdownRenderer.Tests.csproj b/MarkdownRenderer/MarkdownRenderer.Tests/MarkdownRenderer.Tests.csproj index e88499e..36ba9cc 100644 --- a/MarkdownRenderer/MarkdownRenderer.Tests/MarkdownRenderer.Tests.csproj +++ b/MarkdownRenderer/MarkdownRenderer.Tests/MarkdownRenderer.Tests.csproj @@ -21,6 +21,7 @@ + @@ -45,10 +46,19 @@ + + + + + + + + + + { + Assert.InRange(span.Start, 0, code.Length); + Assert.InRange(span.Length, 1, code.Length); + }); + } + + [Fact] + public async Task HighlightAsync_UnknownLanguage_ReturnsEmptyResult() + { + var highlighter = new TextMateCodeBlockSyntaxHighlighter(); + var request = new CodeBlockHighlightRequest("definitely-not-real", "hello", CodeBlockThemeVariant.Dark, CancellationToken.None); + + var result = await highlighter.HighlightAsync(request); + + Assert.NotNull(result); + Assert.Empty(result!.Spans); + } + + [Fact] + public async Task HighlightAsync_HighContrastSuppressesTokenColors() + { + var highlighter = new TextMateCodeBlockSyntaxHighlighter(); + var request = new CodeBlockHighlightRequest("csharp", "public class Demo { }", CodeBlockThemeVariant.HighContrast, CancellationToken.None); + + var result = await highlighter.HighlightAsync(request); + + Assert.NotNull(result); + Assert.Empty(result!.Spans); + } + + [Fact] + public async Task HighlightAsync_ReusesProviderAcrossLanguages() + { + var highlighter = new TextMateCodeBlockSyntaxHighlighter(); + const string csharp = "// comment\npublic sealed class Demo { public string Name => \"ok\"; }"; + var first = await highlighter.HighlightAsync(new CodeBlockHighlightRequest("csharp", csharp, CodeBlockThemeVariant.Dark, CancellationToken.None)); + + foreach (var (language, code) in new[] + { + ("typescript", "const value: string = \"ok\";"), + ("python", "def hello():\n return \"ok\""), + ("powershell", "Write-Host \"ok\""), + ("json", "{ \"ok\": true }"), + ("diff", "+added\n-removed"), + }) + { + var mixed = await highlighter.HighlightAsync(new CodeBlockHighlightRequest(language, code, CodeBlockThemeVariant.Dark, CancellationToken.None)); + Assert.NotNull(mixed); + Assert.NotEmpty(mixed!.Spans); + } + + var second = await highlighter.HighlightAsync(new CodeBlockHighlightRequest("csharp", csharp, CodeBlockThemeVariant.Dark, CancellationToken.None)); + + Assert.NotNull(first); + Assert.NotNull(second); + Assert.NotEmpty(first!.Spans); + Assert.NotEmpty(second!.Spans); + Assert.True(second.Spans.Select(span => span.Foreground).Distinct().Count() >= 2); + } + + [Fact] + public async Task HighlightAsync_HandlesCarriageReturnLineEndings() + { + var highlighter = new TextMateCodeBlockSyntaxHighlighter(); + const string code = "// comment\rpublic sealed class Demo\r{\r public string Name => \"ok\";\r}"; + + var result = await highlighter.HighlightAsync(new CodeBlockHighlightRequest("csharp", code, CodeBlockThemeVariant.Dark, CancellationToken.None)); + + Assert.NotNull(result); + Assert.NotEmpty(result!.Spans); + Assert.True(result.Spans.Select(span => span.Foreground).Distinct().Count() >= 2); + } + + [Fact] + public async Task HighlightAsync_AlreadyCanceled_Throws() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + var highlighter = new TextMateCodeBlockSyntaxHighlighter(); + var request = new CodeBlockHighlightRequest("csharp", "public class Demo { }", CodeBlockThemeVariant.Dark, cts.Token); + + await Assert.ThrowsAsync(async () => await highlighter.HighlightAsync(request)); + } +} diff --git a/MarkdownRenderer/MarkdownRenderer/Accessibility/MarkdownLocalizedStrings.cs b/MarkdownRenderer/MarkdownRenderer/Accessibility/MarkdownLocalizedStrings.cs index 219f219..bc56b92 100644 --- a/MarkdownRenderer/MarkdownRenderer/Accessibility/MarkdownLocalizedStrings.cs +++ b/MarkdownRenderer/MarkdownRenderer/Accessibility/MarkdownLocalizedStrings.cs @@ -16,6 +16,9 @@ internal static class MarkdownLocalizedStrings public static string EmbeddedContentName => Get("EmbeddedContentName", "Embedded content"); public static string ContextMenuCopy => Get("ContextMenuCopy", "Copy"); public static string ContextMenuSelectAll => Get("ContextMenuSelectAll", "Select All"); + public static string CodeBlockCopy => Get("CodeBlockCopy", "Copy"); + public static string CodeBlockCopied => Get("CodeBlockCopied", "Copied"); + public static string CodeBlockCopyAutomationName => Get("CodeBlockCopyAutomationName", "Copy code"); public static string CodeLanguageHelp(string language) => string.Format(CultureInfo.CurrentUICulture, Get("CodeLanguageHelpFormat", "Language: {0}"), language); @@ -47,6 +50,7 @@ public static string CodeLanguageHelp(string language) => Theming.MarkdownElementKeys.FigureCaption => Get("StyleFigureCaption", "Figure caption"), Theming.MarkdownElementKeys.Diagram => Get("StyleDiagram", "Diagram"), Theming.MarkdownElementKeys.ListMarker => Get("StyleListMarker", "List marker"), + Theming.MarkdownElementKeys.Table => Get("StyleTable", "Table"), Theming.MarkdownElementKeys.TableHeader => Get("StyleTableHeader", "Table header"), Theming.MarkdownElementKeys.TableCell => Get("StyleTableCell", "Table cell"), _ => elementKey, diff --git a/MarkdownRenderer/MarkdownRenderer/Accessibility/MarkdownNodePeer.cs b/MarkdownRenderer/MarkdownRenderer/Accessibility/MarkdownNodePeer.cs index 5848374..d6fd7d7 100644 --- a/MarkdownRenderer/MarkdownRenderer/Accessibility/MarkdownNodePeer.cs +++ b/MarkdownRenderer/MarkdownRenderer/Accessibility/MarkdownNodePeer.cs @@ -27,6 +27,7 @@ public MarkdownNodePeer(MarkdownRendererControl owner, MarkdownAutomationPeer ro MarkdownSemanticRole.TableCell => "MarkdownTableCell", MarkdownSemanticRole.Image => "MarkdownImage", MarkdownSemanticRole.Embed => "MarkdownEmbed", + MarkdownSemanticRole.Abbreviation => "MarkdownAbbreviation", _ => "MarkdownGroup", }; @@ -42,6 +43,7 @@ public MarkdownNodePeer(MarkdownRendererControl owner, MarkdownAutomationPeer ro MarkdownSemanticRole.TableCell => AutomationControlType.DataItem, MarkdownSemanticRole.Image => AutomationControlType.Image, MarkdownSemanticRole.Embed => AutomationControlType.Custom, + MarkdownSemanticRole.Abbreviation => AutomationControlType.Text, _ => AutomationControlType.Group, }; diff --git a/MarkdownRenderer/MarkdownRenderer/Accessibility/MarkdownSemanticDocument.cs b/MarkdownRenderer/MarkdownRenderer/Accessibility/MarkdownSemanticDocument.cs index 06f154c..530597a 100644 --- a/MarkdownRenderer/MarkdownRenderer/Accessibility/MarkdownSemanticDocument.cs +++ b/MarkdownRenderer/MarkdownRenderer/Accessibility/MarkdownSemanticDocument.cs @@ -23,6 +23,7 @@ internal enum MarkdownSemanticRole TableCell, Image, Embed, + Abbreviation, } internal sealed class MarkdownSemanticNode @@ -272,7 +273,7 @@ public IEnumerable GetNodesIntersectingTextRange(int textS { if (node == Root) continue; if (node.TextEnd < textStart || node.TextStart > textEnd) continue; - if (node.Role is MarkdownSemanticRole.Link or MarkdownSemanticRole.Image or MarkdownSemanticRole.Embed or + if (node.Role is MarkdownSemanticRole.Link or MarkdownSemanticRole.Image or MarkdownSemanticRole.Embed or MarkdownSemanticRole.Abbreviation or MarkdownSemanticRole.Table or MarkdownSemanticRole.TableCell or MarkdownSemanticRole.List or MarkdownSemanticRole.ListItem) { yield return node; @@ -366,6 +367,7 @@ public MarkdownSemanticDocument Build(LayoutSnapshot snapshot) return box switch { InlineContainerBox inline => BuildInline(inline), + CodeBlockBox codeBlock => BuildCodeBlock(codeBlock), ImageBox image => BuildImage(image), EmbedBox embed => BuildEmbed(embed), ListItemBox listItem => BuildListItem(listItem), @@ -438,12 +440,51 @@ private MarkdownSemanticNode BuildInline(InlineContainerBox inline) HelpText = !string.IsNullOrWhiteSpace(imageRun.Title) ? imageRun.Title : imageRun.Url, }); } + else if (run is AbbreviationRun abbreviationRun) + { + node.Add(new MarkdownSemanticNode(MarkdownSemanticRole.Abbreviation, inline) + { + InlineBox = inline, + InlineRun = abbreviationRun, + TextStart = runSpan.Start, + TextEnd = runSpan.End, + HelpText = abbreviationRun.Expansion, + }); + } } AppendBlockSeparator(); return node; } + private MarkdownSemanticNode BuildCodeBlock(CodeBlockBox codeBlock) + { + var hasLanguage = !string.IsNullOrWhiteSpace(codeBlock.CodeLanguage); + var node = new MarkdownSemanticNode(MarkdownSemanticRole.CodeBlock, codeBlock) + { + TextStart = _text.Length, + CodeLanguage = hasLanguage ? codeBlock.LanguageDisplay : null, + HelpText = hasLanguage + ? MarkdownLocalizedStrings.CodeLanguageHelp(codeBlock.LanguageDisplay) + : null, + }; + + foreach (var chunk in codeBlock.Chunks) + { + foreach (var run in chunk.Runs) + { + int runStart = _text.Length; + _text.Append(run.AccessibleText); + int runEnd = _text.Length; + _spans.Add(new MarkdownTextSpan(runStart, runEnd, chunk, run, null, null)); + } + } + + node.TextEnd = _text.Length; + AppendBlockSeparator(); + return node; + } + private (int Start, int End) FindRunTextSpan(InlineContainerBox inline, InlineRun run) { foreach (var span in _spans) diff --git a/MarkdownRenderer/MarkdownRenderer/CodeBlocks/BoundedCodeBlockHighlightCache.cs b/MarkdownRenderer/MarkdownRenderer/CodeBlocks/BoundedCodeBlockHighlightCache.cs new file mode 100644 index 0000000..6ac6411 --- /dev/null +++ b/MarkdownRenderer/MarkdownRenderer/CodeBlocks/BoundedCodeBlockHighlightCache.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; + +namespace MarkdownRenderer.CodeBlocks; + +internal sealed class BoundedCodeBlockHighlightCache + where TKey : notnull +{ + private readonly int _capacity; + private readonly Dictionary _entries = new(); + private readonly Queue _insertionOrder = new(); + + public BoundedCodeBlockHighlightCache(int capacity) + { + if (capacity <= 0) + throw new ArgumentOutOfRangeException(nameof(capacity)); + + _capacity = capacity; + } + + public int Count => _entries.Count; + + public bool TryGetValue(TKey key, out CodeBlockHighlightResult result) + { + if (_entries.TryGetValue(key, out var value)) + { + result = value; + return true; + } + + result = CodeBlockHighlightResult.Empty; + return false; + } + + public void Set(TKey key, CodeBlockHighlightResult result) + { + if (_entries.ContainsKey(key)) + { + _entries[key] = result; + return; + } + + _entries[key] = result; + _insertionOrder.Enqueue(key); + Trim(); + } + + public void Clear() + { + _entries.Clear(); + _insertionOrder.Clear(); + } + + private void Trim() + { + while (_entries.Count > _capacity && _insertionOrder.Count > 0) + { + var oldest = _insertionOrder.Dequeue(); + _entries.Remove(oldest); + } + } +} diff --git a/MarkdownRenderer/MarkdownRenderer/CodeBlocks/CodeBlockHighlightRequest.cs b/MarkdownRenderer/MarkdownRenderer/CodeBlocks/CodeBlockHighlightRequest.cs new file mode 100644 index 0000000..1b68fab --- /dev/null +++ b/MarkdownRenderer/MarkdownRenderer/CodeBlocks/CodeBlockHighlightRequest.cs @@ -0,0 +1,34 @@ +using System.Threading; + +namespace MarkdownRenderer.CodeBlocks; + +/// +/// Input passed to an optional syntax-highlighting provider. +/// +public sealed class CodeBlockHighlightRequest +{ + /// Initializes a new highlighting request. + public CodeBlockHighlightRequest( + string? language, + string code, + CodeBlockThemeVariant themeVariant, + CancellationToken cancellationToken) + { + Language = language; + Code = code ?? string.Empty; + ThemeVariant = themeVariant; + CancellationToken = cancellationToken; + } + + /// Normalized language identifier from the fence info string, if any. + public string? Language { get; } + + /// Raw displayed code text, excluding markdown fences. + public string Code { get; } + + /// Resolved renderer theme variant for choosing token colors. + public CodeBlockThemeVariant ThemeVariant { get; } + + /// Cancellation token for abandoning stale highlight work. + public CancellationToken CancellationToken { get; } +} diff --git a/MarkdownRenderer/MarkdownRenderer/CodeBlocks/CodeBlockHighlightResult.cs b/MarkdownRenderer/MarkdownRenderer/CodeBlocks/CodeBlockHighlightResult.cs new file mode 100644 index 0000000..2428941 --- /dev/null +++ b/MarkdownRenderer/MarkdownRenderer/CodeBlocks/CodeBlockHighlightResult.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; + +namespace MarkdownRenderer.CodeBlocks; + +/// +/// Syntax-highlighting spans returned by a code-block highlighter. +/// +public sealed class CodeBlockHighlightResult +{ + /// An empty highlighting result. + public static CodeBlockHighlightResult Empty { get; } = new(Array.Empty()); + + /// Initializes a new result. + public CodeBlockHighlightResult(IReadOnlyList? spans) + { + Spans = spans ?? Array.Empty(); + } + + /// Foreground-color spans in absolute code-text coordinates. + public IReadOnlyList Spans { get; } +} diff --git a/MarkdownRenderer/MarkdownRenderer/CodeBlocks/CodeBlockHighlightSpan.cs b/MarkdownRenderer/MarkdownRenderer/CodeBlocks/CodeBlockHighlightSpan.cs new file mode 100644 index 0000000..a35508e --- /dev/null +++ b/MarkdownRenderer/MarkdownRenderer/CodeBlocks/CodeBlockHighlightSpan.cs @@ -0,0 +1,8 @@ +using Windows.UI; + +namespace MarkdownRenderer.CodeBlocks; + +/// +/// A foreground-color span inside a code block's raw displayed code text. +/// +public readonly record struct CodeBlockHighlightSpan(int Start, int Length, Color Foreground); diff --git a/MarkdownRenderer/MarkdownRenderer/CodeBlocks/CodeBlockLineNumberMode.cs b/MarkdownRenderer/MarkdownRenderer/CodeBlocks/CodeBlockLineNumberMode.cs new file mode 100644 index 0000000..8aed37a --- /dev/null +++ b/MarkdownRenderer/MarkdownRenderer/CodeBlocks/CodeBlockLineNumberMode.cs @@ -0,0 +1,16 @@ +namespace MarkdownRenderer.CodeBlocks; + +/// +/// Controls when native code blocks show a line-number gutter. +/// +public enum CodeBlockLineNumberMode +{ + /// Show line numbers for multiline code blocks. + AutoMultiline, + + /// Always show line numbers unless a block explicitly disables them. + Always, + + /// Never show line numbers unless a block explicitly enables them. + Never, +} diff --git a/MarkdownRenderer/MarkdownRenderer/CodeBlocks/CodeBlockThemeVariant.cs b/MarkdownRenderer/MarkdownRenderer/CodeBlocks/CodeBlockThemeVariant.cs new file mode 100644 index 0000000..a48a7ab --- /dev/null +++ b/MarkdownRenderer/MarkdownRenderer/CodeBlocks/CodeBlockThemeVariant.cs @@ -0,0 +1,16 @@ +namespace MarkdownRenderer.CodeBlocks; + +/// +/// Coarse renderer theme variant used by syntax highlighters. +/// +public enum CodeBlockThemeVariant +{ + /// Light color theme. + Light, + + /// Dark color theme. + Dark, + + /// Forced high-contrast theme. + HighContrast, +} diff --git a/MarkdownRenderer/MarkdownRenderer/CodeBlocks/ICodeBlockSyntaxHighlighter.cs b/MarkdownRenderer/MarkdownRenderer/CodeBlocks/ICodeBlockSyntaxHighlighter.cs new file mode 100644 index 0000000..192917c --- /dev/null +++ b/MarkdownRenderer/MarkdownRenderer/CodeBlocks/ICodeBlockSyntaxHighlighter.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; + +namespace MarkdownRenderer.CodeBlocks; + +/// +/// Optional provider used to apply syntax highlighting to code blocks. +/// +public interface ICodeBlockSyntaxHighlighter +{ + /// + /// Revision for cache invalidation when the highlighter's grammar or theme changes. + /// + int Revision => 0; + + /// + /// Returns foreground-color spans for the supplied code, or an empty result when unsupported. + /// + ValueTask HighlightAsync(CodeBlockHighlightRequest request); +} diff --git a/MarkdownRenderer/MarkdownRenderer/Controls/MarkdownRendererControl.cs b/MarkdownRenderer/MarkdownRenderer/Controls/MarkdownRendererControl.cs index 24c2b75..76f67b6 100644 --- a/MarkdownRenderer/MarkdownRenderer/Controls/MarkdownRendererControl.cs +++ b/MarkdownRenderer/MarkdownRenderer/Controls/MarkdownRendererControl.cs @@ -1,20 +1,25 @@ using System; using System.Collections.Generic; using System.Numerics; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Graphics.Canvas; using Microsoft.Graphics.Canvas.UI.Xaml; using Microsoft.UI.System; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Automation.Peers; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Media; using Windows.Foundation; using Windows.System; +using Windows.ApplicationModel.DataTransfer; using Windows.UI; using MarkdownRenderer.Accessibility; +using MarkdownRenderer.CodeBlocks; using MarkdownRenderer.Diagnostics; using MarkdownRenderer.Document; using MarkdownRenderer.Hosting; @@ -146,6 +151,16 @@ private readonly record struct PointerSession(uint PointerId, bool IsPrimary) // suppresses link-hover and IBeam-cursor work, just like inline embeds. private readonly List<(Layout.Boxes.EmbedBox Box, Rect Rect)> _blockEmbedRects = new(); + // Code block copy buttons are overlay-hosted native controls, but they are + // renderer chrome rather than user-authored embeds. Keep them on a separate + // realization track so RealizedEmbedCount remains embed-only. + private enum CodeBlockHostedElementKind + { + Copy, + } + + private readonly List<(Layout.Boxes.CodeBlockBox Box, Rect Rect, CodeBlockHostedElementKind Kind)> _codeBlockActionRects = new(); + // Embed virtualisation. Plans capture each embed's position + factory // delegate up front; realisation happens lazily as scrolling brings them // into the viewport (plus an overscan band). Off-screen embeds beyond a @@ -266,6 +281,123 @@ public override void UpdatePlacement() } } private readonly List _embedPlans = new(); + private sealed class CodeBlockActionPlan + { + public Layout.Boxes.CodeBlockBox Box = null!; + public Rect Rect; + public CodeBlockHostedElementKind Kind; + public FrameworkElement? Realized; + private RoutedEventHandler? _clickHandler; + private int _feedbackVersion; + + public void Realize(MarkdownRendererControl owner) + { + if (Realized is not null) return; + + var button = owner.CreateCodeBlockCopyButton(); + AttachHandlers(button, owner); + button.KeyDown += owner.OnHostedEmbedKeyDown; + Realized = button; + Box.RealizedCopyButton = button; + UpdatePlacement(); + owner._overlay!.Children.Add(button); + } + + public void Derealize(MarkdownRendererControl owner) + { + if (Realized is null) return; + var fe = Realized; + if (fe is Button button) + { + if (_clickHandler is not null) + button.Click -= _clickHandler; + button.KeyDown -= owner.OnHostedEmbedKeyDown; + } + + try { owner._overlay!.Children.Remove(fe); } catch { } + Box.RealizedCopyButton = null; + Realized = null; + _clickHandler = null; + _feedbackVersion++; + } + + public void AdoptRealizedFrom(CodeBlockActionPlan oldPlan, MarkdownRendererControl owner) + { + if (oldPlan.Realized is null) return; + + var fe = oldPlan.Realized; + if (fe is Button button) + { + if (oldPlan._clickHandler is not null) + button.Click -= oldPlan._clickHandler; + button.KeyDown -= owner.OnHostedEmbedKeyDown; + AttachHandlers(button, owner); + button.KeyDown += owner.OnHostedEmbedKeyDown; + owner.SetCodeBlockCopyButtonState(button, copied: false); + } + + oldPlan._clickHandler = null; + oldPlan._feedbackVersion++; + oldPlan.Box.RealizedCopyButton = null; + oldPlan.Realized = null; + + Realized = fe; + Box.RealizedCopyButton = fe; + UpdatePlacement(); + } + + private void AttachHandlers(Button button, MarkdownRendererControl owner) + { + _clickHandler = (_, _) => owner.CopyCodeBlockToClipboard(this); + button.Click += _clickHandler; + } + + public bool IsSameLogicalAction(CodeBlockActionPlan other) + => ReferenceEquals(Box, other.Box) && Kind == other.Kind; + + public void UpdatePlacement() + { + if (Realized is null) return; + double left = Math.Round(Rect.X); + double top = Math.Round(Rect.Y); + double width = Math.Round(Rect.X + Rect.Width) - left; + double height = Math.Round(Rect.Y + Rect.Height) - top; + Realized.Width = width; + Realized.Height = height; + Canvas.SetLeft(Realized, left); + Canvas.SetTop(Realized, top); + Canvas.SetZIndex(Realized, 2); + } + + public void ShowCopiedFeedback(MarkdownRendererControl owner) + { + if (Realized is not Button button) + return; + + int version = ++_feedbackVersion; + owner.SetCodeBlockCopyButtonState(button, copied: true); + _ = Task.Run(async () => + { + try { await Task.Delay(1500).ConfigureAwait(false); } + catch { return; } + + owner.DispatcherQueue.TryEnqueue(() => + { + if (owner._isUnloaded || _feedbackVersion != version) + return; + if (Realized is Button realizedButton) + owner.SetCodeBlockCopyButtonState(realizedButton, copied: false); + }); + }); + } + } + + private readonly List _codeBlockActionPlans = new(); + private readonly BoundedCodeBlockHighlightCache _codeBlockHighlightCache = new(CodeBlockHighlightCacheMaxEntries); + private readonly HashSet _codeBlockHighlightInFlight = new(); + private readonly SemaphoreSlim _codeBlockHighlightSemaphore = new(2, 2); + private CancellationTokenSource? _codeBlockHighlightCts; + private int _codeBlockHighlightGeneration; private bool _promotingKeyboardFocusEntry; private bool _lastFocusEntryWasKeyboardTraversal; private bool _lastFocusEntryWasKeyboardInput; @@ -282,6 +414,17 @@ public override void UpdatePlacement() // in-memory cache start loading (no-op) immediately after build. private readonly List _imagePlans = new(); + private readonly record struct CodeBlockHighlightCacheKey( + string? Language, + ulong CodeHash, + int CodeLength, + CodeBlockThemeVariant ThemeVariant, + int ProviderIdentity, + int ProviderRevision); + + private const int CodeBlockHighlightCacheMaxEntries = 128; + private const double CodeBlockHighlightOverscanPx = 1600; + /// /// Overscan band (pixels, each direction) within which off-screen images /// are preemptively loaded. Wider than the embed virtualisation overscan @@ -331,7 +474,7 @@ public override void UpdatePlacement() /// Dependency property backing . public static readonly DependencyProperty MarkdownProperty = DependencyProperty.Register(nameof(Markdown), typeof(string), typeof(MarkdownRendererControl), - new PropertyMetadata(string.Empty, (d, _) => ((MarkdownRendererControl)d).RequestRebuild())); + new PropertyMetadata(string.Empty, (d, _) => ((MarkdownRendererControl)d).OnMarkdownChanged())); /// Gets or sets the markdown source text to render. public string Markdown @@ -390,6 +533,84 @@ public bool IsSelectionEnabled set => SetValue(IsSelectionEnabledProperty, value); } + /// Dependency property backing . + public static readonly DependencyProperty IsCodeBlockCopyEnabledProperty = + DependencyProperty.Register(nameof(IsCodeBlockCopyEnabled), typeof(bool), + typeof(MarkdownRendererControl), new PropertyMetadata(true, (d, _) => ((MarkdownRendererControl)d).RequestRebuild())); + + /// Gets or sets whether code blocks include an always-visible copy button. + public bool IsCodeBlockCopyEnabled + { + get => (bool)GetValue(IsCodeBlockCopyEnabledProperty); + set => SetValue(IsCodeBlockCopyEnabledProperty, value); + } + + /// Dependency property backing . + public static readonly DependencyProperty CodeBlockCopyButtonLabelProperty = + DependencyProperty.Register(nameof(CodeBlockCopyButtonLabel), typeof(string), + typeof(MarkdownRendererControl), new PropertyMetadata(null, (d, _) => ((MarkdownRendererControl)d).UpdateCodeBlockCopyButtonLabels())); + + /// + /// Gets or sets the accessible label and tooltip text used for code-block copy buttons. + /// A null or whitespace value uses the renderer's localized default. + /// + public string? CodeBlockCopyButtonLabel + { + get => (string?)GetValue(CodeBlockCopyButtonLabelProperty); + set => SetValue(CodeBlockCopyButtonLabelProperty, value); + } + + /// Dependency property backing . + public static readonly DependencyProperty CodeBlockCopiedButtonLabelProperty = + DependencyProperty.Register(nameof(CodeBlockCopiedButtonLabel), typeof(string), + typeof(MarkdownRendererControl), new PropertyMetadata(null, (d, _) => ((MarkdownRendererControl)d).UpdateCodeBlockCopyButtonLabels())); + + /// + /// Gets or sets the accessible label and tooltip text announced briefly after a code block is copied. + /// A null or whitespace value uses the renderer's localized default. + /// + public string? CodeBlockCopiedButtonLabel + { + get => (string?)GetValue(CodeBlockCopiedButtonLabelProperty); + set => SetValue(CodeBlockCopiedButtonLabelProperty, value); + } + + /// Dependency property backing . + public static readonly DependencyProperty IsCodeBlockSyntaxHighlightingEnabledProperty = + DependencyProperty.Register(nameof(IsCodeBlockSyntaxHighlightingEnabled), typeof(bool), + typeof(MarkdownRendererControl), new PropertyMetadata(true, (d, _) => ((MarkdownRendererControl)d).RequestRebuild())); + + /// Gets or sets whether code blocks may request syntax highlighting from a configured provider. + public bool IsCodeBlockSyntaxHighlightingEnabled + { + get => (bool)GetValue(IsCodeBlockSyntaxHighlightingEnabledProperty); + set => SetValue(IsCodeBlockSyntaxHighlightingEnabledProperty, value); + } + + /// Dependency property backing . + public static readonly DependencyProperty CodeBlockSyntaxHighlighterProperty = + DependencyProperty.Register(nameof(CodeBlockSyntaxHighlighter), typeof(ICodeBlockSyntaxHighlighter), + typeof(MarkdownRendererControl), new PropertyMetadata(null, (d, _) => ((MarkdownRendererControl)d).OnCodeBlockSyntaxHighlighterChanged())); + + /// Gets or sets the optional code-block syntax-highlighting provider. + public ICodeBlockSyntaxHighlighter? CodeBlockSyntaxHighlighter + { + get => (ICodeBlockSyntaxHighlighter?)GetValue(CodeBlockSyntaxHighlighterProperty); + set => SetValue(CodeBlockSyntaxHighlighterProperty, value); + } + + /// Dependency property backing . + public static readonly DependencyProperty CodeBlockLineNumberModeProperty = + DependencyProperty.Register(nameof(CodeBlockLineNumberMode), typeof(CodeBlockLineNumberMode), + typeof(MarkdownRendererControl), new PropertyMetadata(CodeBlockLineNumberMode.AutoMultiline, (d, _) => ((MarkdownRendererControl)d).RequestRebuild())); + + /// Gets or sets when code blocks show line numbers. + public CodeBlockLineNumberMode CodeBlockLineNumberMode + { + get => (CodeBlockLineNumberMode)GetValue(CodeBlockLineNumberModeProperty); + set => SetValue(CodeBlockLineNumberModeProperty, value); + } + /// /// Gets the latest parsed document facade committed by the renderer. /// @@ -403,19 +624,31 @@ public bool IsSelectionEnabled /// Extension registry to assign, or null to use the renderer default. /// Embed factory to assign, or null to disable hosted block embeds. /// True to enable text selection. + /// True to show copy buttons on code blocks. + /// Optional code-block syntax-highlighting provider. + /// Accessible label and tooltip for code-block copy buttons, or null for the localized default. + /// Accessible label and tooltip after copy succeeds, or null for the localized default. /// A new configured renderer control. public static MarkdownRendererControl CreateDefault( string? markdown = null, MarkdownTheme? theme = null, MarkdownExtensionRegistry? extensionRegistry = null, IMarkdownEmbedFactory? embedFactory = null, - bool isSelectionEnabled = true) + bool isSelectionEnabled = true, + bool isCodeBlockCopyEnabled = true, + ICodeBlockSyntaxHighlighter? codeBlockSyntaxHighlighter = null, + string? codeBlockCopyButtonLabel = null, + string? codeBlockCopiedButtonLabel = null) => new MarkdownRendererControlBuilder() .WithMarkdown(markdown) .WithTheme(theme) .WithExtensionRegistry(extensionRegistry) .WithEmbedFactory(embedFactory) .WithSelectionEnabled(isSelectionEnabled) + .WithCodeBlockCopyEnabled(isCodeBlockCopyEnabled) + .WithCodeBlockCopyButtonLabel(codeBlockCopyButtonLabel) + .WithCodeBlockCopiedButtonLabel(codeBlockCopiedButtonLabel) + .WithCodeBlockSyntaxHighlighter(codeBlockSyntaxHighlighter) .Build(); internal MarkdownLinkPeer GetOrCreateLinkPeer(MarkdownBlockPeer parent, LinkRun run) @@ -544,6 +777,69 @@ public int RealizedEmbedCount } } + private Button CreateCodeBlockCopyButton() + { + var button = new Button + { + Content = new SymbolIcon(Symbol.Copy), + Padding = new Thickness(0), + MinWidth = 0, + MinHeight = 0, + IsTabStop = true, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch, + }; + AutomationProperties.SetAutomationId(button, "MarkdownCodeBlockCopyButton"); + SetCodeBlockCopyButtonState(button, copied: false); + return button; + } + + private string ResolvedCodeBlockCopyButtonLabel => + string.IsNullOrWhiteSpace(CodeBlockCopyButtonLabel) + ? MarkdownLocalizedStrings.CodeBlockCopyAutomationName + : CodeBlockCopyButtonLabel!; + + private string ResolvedCodeBlockCopiedButtonLabel => + string.IsNullOrWhiteSpace(CodeBlockCopiedButtonLabel) + ? MarkdownLocalizedStrings.CodeBlockCopied + : CodeBlockCopiedButtonLabel!; + + private void SetCodeBlockCopyButtonState(Button button, bool copied) + { + string label = copied ? ResolvedCodeBlockCopiedButtonLabel : ResolvedCodeBlockCopyButtonLabel; + if (button.Content is SymbolIcon icon) + icon.Symbol = copied ? Symbol.Accept : Symbol.Copy; + else + button.Content = new SymbolIcon(copied ? Symbol.Accept : Symbol.Copy); + + AutomationProperties.SetName(button, label); + ToolTipService.SetToolTip(button, label); + } + + private void UpdateCodeBlockCopyButtonLabels() + { + foreach (var plan in _codeBlockActionPlans) + { + if (plan.Realized is Button button) + SetCodeBlockCopyButtonState(button, copied: false); + } + } + + private void CopyCodeBlockToClipboard(CodeBlockActionPlan plan) + { + try + { + var package = new DataPackage(); + package.SetText(plan.Box.CodeText); + Clipboard.SetContent(package); + plan.ShowCopiedFeedback(this); + } + catch (Exception ex) + { + MarkdownDiagnostics.WriteLine($"[MarkdownRendererControl] Code block copy failed: {ex.Message}"); + } + } + private void OnHostedEmbedKeyDown(object sender, KeyRoutedEventArgs e) { if (sender is not FrameworkElement element) @@ -754,11 +1050,37 @@ private void OnLosingFocus(UIElement sender, LosingFocusEventArgs e) } } + private void OnMarkdownChanged() + { + ClearCodeBlockHighlightCache(); + RequestRebuild(); + } + + private void OnCodeBlockSyntaxHighlighterChanged() + { + ClearCodeBlockHighlightCache(); + RequestRebuild(); + } + private bool IsElementWithinRenderer(DependencyObject element) + => IsElementWithin(element, this); + + private bool IsElementWithinCodeBlockAction(DependencyObject element) + { + foreach (var plan in _codeBlockActionPlans) + { + if (plan.Realized is DependencyObject realized && IsElementWithin(element, realized)) + return true; + } + + return false; + } + + private static bool IsElementWithin(DependencyObject element, DependencyObject ancestor) { for (DependencyObject? current = element; current is not null;) { - if (ReferenceEquals(current, this)) + if (ReferenceEquals(current, ancestor)) return true; try { current = VisualTreeHelper.GetParent(current); } @@ -816,6 +1138,9 @@ private void OnAppRootPointerPressed(object sender, PointerRoutedEventArgs e) if (e.OriginalSource is DependencyObject source && IsElementWithinRenderer(source)) { MarkdownSelectionCoordinator.ClearSelectionsExcept(this); + if (IsElementWithinCodeBlockAction(source)) + return; + if (!IsPointerOverCanvasTextSurface(e, out var canvasPoint) || IsPointOverEmbed(canvasPoint)) ClearSelectionForExternalInteraction(); return; @@ -958,6 +1283,8 @@ private void OnUnloaded() // (e.g. from OnImageLoadCompleted) that are already in-flight know // not to call RequestRebuild after we've torn down. _isUnloaded = true; + HideAbbreviationTooltip(); + ReleaseAbbreviationTooltipTimers(); if (_sizeChangedHandler is not null) { SizeChanged -= _sizeChangedHandler; @@ -981,6 +1308,12 @@ private void OnUnloaded() _pipelineCts = null; oldCts?.Cancel(); oldCts?.Dispose(); + var oldHighlightCts = _codeBlockHighlightCts; + _codeBlockHighlightCts = null; + oldHighlightCts?.Cancel(); + oldHighlightCts?.Dispose(); + _codeBlockHighlightGeneration++; + _codeBlockHighlightInFlight.Clear(); // Unsubscribe scroll handler so scroll-inertia events after visual-tree // removal don't fire OnScrollViewChanged on a partially-torn-down control. if (_scroll is not null) _scroll.ViewChanged -= OnScrollViewChanged; @@ -995,6 +1328,7 @@ private void OnUnloaded() // and their event handlers would leak past detach. DerealizeAllEmbeds(); _embedPlans.Clear(); + _codeBlockActionPlans.Clear(); _imagePlans.Clear(); _embedRects.Clear(); _blockEmbedRects.Clear(); @@ -1095,6 +1429,8 @@ public void RequestRebuild() private void RequestRebuild(RebuildReason reason) { + ResetTransientInteractionState(clearSelection: true); + // Cancel the in-flight build. Dispose is deferred to ContinueWith so the // in-flight task (which may still be executing ct.Register() callbacks // inside Task.Run/TaskScheduler internals) doesn't encounter a disposed @@ -1113,6 +1449,39 @@ private void RequestRebuild(RebuildReason reason) }, TaskScheduler.Default); } + private void ResetTransientInteractionState(bool clearSelection) + { + _pointerSession = default; + _selectionAnchor = null; + _clickMode = ClickMode.Single; + _dragAnchorStart = default; + _dragAnchorEnd = default; + _consecutiveClickCount = 0; + _lastPressTickMs = 0; + _lastPressPoint = default; + SetSelectionDragShieldActive(false); + HideAbbreviationTooltip(); + + if (_snapshot is { } snapshot) + { + foreach (var block in snapshot.Blocks) + ClearHover(block); + } + + _lastHoveredRun = null; + _lastHoveredBox = null; + SetCursorShape(null); + + try { _canvas?.ReleasePointerCaptures(); } catch { } + + if (!clearSelection) + return; + + _selection.Clear(); + _selectionAdornerRects.Clear(); + try { _selectionAdorner?.Invalidate(); } catch { } + } + private async Task RebuildAsync(CancellationToken ct, RebuildReason reason) { try @@ -1192,6 +1561,8 @@ private async Task RebuildInternalAsync(CancellationToken ct, RebuildReason reas { RasterizationScale = rasterScale, CancellationToken = ct, + IsCodeBlockCopyEnabled = IsCodeBlockCopyEnabled, + CodeBlockLineNumberMode = CodeBlockLineNumberMode, }; var builder = new LayoutBuilder(ctx, EmbedFactory); @@ -1313,7 +1684,9 @@ private async Task RebuildInternalAsync(CancellationToken ct, RebuildReason reas _overlay.Children.Clear(); _embedRects.Clear(); _blockEmbedRects.Clear(); + _codeBlockActionRects.Clear(); _embedPlans.Clear(); + _codeBlockActionPlans.Clear(); foreach (var img in _imagePlans) img.LoadCompleted -= OnImageLoadCompleted; _imagePlans.Clear(); // Identities change across rebuild even when the count happens to @@ -1333,6 +1706,8 @@ private async Task RebuildInternalAsync(CancellationToken ct, RebuildReason reas _scroll.ViewChanged += OnScrollViewChanged; } RealizeVisibleEmbeds(); + RestartCodeBlockHighlighting(); + ScheduleVisibleCodeBlockHighlighting(); UpdateSelectionAdornerViewport(); _canvas.Invalidate(); @@ -1380,6 +1755,205 @@ private void OnScrollViewChanged(object? sender, ScrollViewerViewChangedEventArg // IsIntermediate=false fires at the end of inertia; we also realise on // intermediate ticks to keep the visual current. RealizeVisibleEmbeds(); + ScheduleVisibleCodeBlockHighlighting(); + } + + private void RestartCodeBlockHighlighting() + { + var old = _codeBlockHighlightCts; + old?.Cancel(); + old?.Dispose(); + _codeBlockHighlightCts = new CancellationTokenSource(); + _codeBlockHighlightGeneration++; + _codeBlockHighlightInFlight.Clear(); + } + + private void ScheduleVisibleCodeBlockHighlighting() + { + if (!IsCodeBlockSyntaxHighlightingEnabled || + CodeBlockSyntaxHighlighter is not { } highlighter || + _snapshot is not { } snapshot || + _scroll is null || + _themeSnapshot is not { } theme || + theme.IsHighContrast) + { + return; + } + + var cts = _codeBlockHighlightCts; + if (cts is null || cts.IsCancellationRequested) + return; + + var variant = ResolveCodeBlockThemeVariant(theme); + int generation = _codeBlockHighlightGeneration; + int providerIdentity = RuntimeHelpers.GetHashCode(highlighter); + int providerRevision = highlighter.Revision; + bool appliedCached = false; + double highlightTop = _scroll.VerticalOffset - CodeBlockHighlightOverscanPx; + double highlightBottom = _scroll.VerticalOffset + _scroll.ViewportHeight + CodeBlockHighlightOverscanPx; + foreach (var block in EnumerateCodeBlocks(snapshot)) + { + if (!IsCodeBlockInHighlightBand(block, highlightTop, highlightBottom)) + continue; + + if (block.CodeText.Length > 200_000 || block.LineCount > 5_000) + continue; + + var key = CreateHighlightCacheKey(block, variant, providerIdentity, providerRevision); + if (_codeBlockHighlightCache.TryGetValue(key, out var cached)) + { + block.ApplySyntaxHighlighting(cached.Spans); + appliedCached = true; + continue; + } + + if (!_codeBlockHighlightInFlight.Add(key)) + continue; + + _ = HighlightCodeBlockAsync(snapshot, block, key, variant, highlighter, providerIdentity, providerRevision, generation, cts.Token); + } + + if (appliedCached) + _canvas?.Invalidate(); + } + + private async Task HighlightCodeBlockAsync( + LayoutSnapshot snapshot, + Layout.Boxes.CodeBlockBox block, + CodeBlockHighlightCacheKey key, + CodeBlockThemeVariant variant, + ICodeBlockSyntaxHighlighter highlighter, + int providerIdentity, + int providerRevision, + int generation, + CancellationToken token) + { + try + { + await _codeBlockHighlightSemaphore.WaitAsync(token).ConfigureAwait(false); + try + { + token.ThrowIfCancellationRequested(); + var request = new CodeBlockHighlightRequest(block.CodeLanguage, block.CodeText, variant, token); + var result = await highlighter.HighlightAsync(request).ConfigureAwait(false) + ?? CodeBlockHighlightResult.Empty; + token.ThrowIfCancellationRequested(); + + DispatcherQueue.TryEnqueue(() => + { + if (_isUnloaded || token.IsCancellationRequested || !ReferenceEquals(_snapshot, snapshot)) + return; + _codeBlockHighlightCache.Set(key, result); + RemoveCodeBlockHighlightInFlight(key, generation); + ApplyCodeBlockHighlightResult(snapshot, key, variant, providerIdentity, providerRevision, result); + _canvas?.Invalidate(); + ScheduleVisibleCodeBlockHighlighting(); + }); + } + finally + { + _codeBlockHighlightSemaphore.Release(); + } + } + catch (OperationCanceledException) + { + DispatcherQueue.TryEnqueue(() => RemoveCodeBlockHighlightInFlight(key, generation)); + } + catch (Exception ex) + { + MarkdownDiagnostics.WriteLine($"[MarkdownRendererControl] Code block highlighting failed: {ex.Message}"); + DispatcherQueue.TryEnqueue(() => RemoveCodeBlockHighlightInFlight(key, generation)); + } + } + + private void RemoveCodeBlockHighlightInFlight(CodeBlockHighlightCacheKey key, int generation) + { + if (generation == _codeBlockHighlightGeneration) + _codeBlockHighlightInFlight.Remove(key); + } + + private void ApplyCodeBlockHighlightResult( + LayoutSnapshot snapshot, + CodeBlockHighlightCacheKey key, + CodeBlockThemeVariant variant, + int providerIdentity, + int providerRevision, + CodeBlockHighlightResult result) + { + foreach (var candidate in EnumerateCodeBlocks(snapshot)) + { + if (CreateHighlightCacheKey(candidate, variant, providerIdentity, providerRevision).Equals(key)) + candidate.ApplySyntaxHighlighting(result.Spans); + } + } + + private static bool IsCodeBlockInHighlightBand(Layout.Boxes.CodeBlockBox block, double top, double bottom) + => block.Bounds.Bottom >= top && block.Bounds.Top <= bottom; + + private static IEnumerable EnumerateCodeBlocks(LayoutSnapshot snapshot) + { + foreach (var block in snapshot.GetMeasuredTopLevelBlocks()) + { + foreach (var codeBlock in EnumerateCodeBlocks(block)) + yield return codeBlock; + } + } + + private static IEnumerable EnumerateCodeBlocks(BlockBox block) + { + switch (block) + { + case Layout.Boxes.CodeBlockBox codeBlock: + yield return codeBlock; + break; + case Layout.Boxes.ListItemBox listItem: + foreach (var item in EnumerateCodeBlocks(listItem.Marker)) + yield return item; + foreach (var item in EnumerateCodeBlocks(listItem.Content)) + yield return item; + break; + case Layout.Boxes.StackBox stack: + foreach (var child in stack.Children) + { + foreach (var item in EnumerateCodeBlocks(child)) + yield return item; + } + break; + } + } + + private CodeBlockHighlightCacheKey CreateHighlightCacheKey( + Layout.Boxes.CodeBlockBox block, + CodeBlockThemeVariant variant, + int providerIdentity, + int providerRevision) + => new(block.CodeLanguage, Fnv1A64(block.CodeText), block.CodeText.Length, variant, providerIdentity, providerRevision); + + private void ClearCodeBlockHighlightCache() + => _codeBlockHighlightCache.Clear(); + + private static CodeBlockThemeVariant ResolveCodeBlockThemeVariant(Theming.ThemeSnapshot theme) + { + if (theme.IsHighContrast) + return CodeBlockThemeVariant.HighContrast; + + var bg = theme.SurfaceColor; + double luminance = (0.2126 * bg.R + 0.7152 * bg.G + 0.0722 * bg.B) / 255.0; + return luminance < 0.5 ? CodeBlockThemeVariant.Dark : CodeBlockThemeVariant.Light; + } + + private static ulong Fnv1A64(string value) + { + const ulong offset = 14695981039346656037UL; + const ulong prime = 1099511628211UL; + ulong hash = offset; + foreach (var ch in value) + { + hash ^= ch; + hash *= prime; + } + + return hash; } private void ApplySnapshotSize(LayoutSnapshot snapshot) @@ -1457,18 +2031,25 @@ private void DerealizeAllEmbeds() { if (plan.Realized is not null) plan.Derealize(this); } + foreach (var plan in _codeBlockActionPlans) + { + if (plan.Realized is not null) plan.Derealize(this); + } } private void RebuildRealizationPlans(LayoutSnapshot snapshot, bool preserveRealized) { var oldPlans = preserveRealized ? _embedPlans.ToArray() : Array.Empty(); + var oldActionPlans = preserveRealized ? _codeBlockActionPlans.ToArray() : Array.Empty(); foreach (var img in _imagePlans) img.LoadCompleted -= OnImageLoadCompleted; _imagePlans.Clear(); _embedRects.Clear(); _blockEmbedRects.Clear(); + _codeBlockActionRects.Clear(); _embedPlans.Clear(); + _codeBlockActionPlans.Clear(); foreach (var b in snapshot.GetMeasuredTopLevelBlocks()) CollectEmbedPlans(b); @@ -1501,6 +2082,31 @@ private void RebuildRealizationPlans(LayoutSnapshot snapshot, bool preserveReali } } + if (oldActionPlans.Length > 0) + { + var adopted = new HashSet(); + foreach (var newPlan in _codeBlockActionPlans) + { + foreach (var oldPlan in oldActionPlans) + { + if (adopted.Contains(oldPlan) || oldPlan.Realized is null) + continue; + if (!newPlan.IsSameLogicalAction(oldPlan)) + continue; + + newPlan.AdoptRealizedFrom(oldPlan, this); + adopted.Add(oldPlan); + break; + } + } + + foreach (var oldPlan in oldActionPlans) + { + if (oldPlan.Realized is not null) + oldPlan.Derealize(this); + } + } + _lastFiredRealizedCount = -1; } @@ -1539,6 +2145,32 @@ internal void RealizeVisibleEmbeds() img.EnsureLoading(); } + // Drop old realisation-side caches; they'll be repopulated from realised plans. + _embedRects.Clear(); + _blockEmbedRects.Clear(); + _codeBlockActionRects.Clear(); + + foreach (var plan in _codeBlockActionPlans) + { + double pTop = plan.Rect.Top; + double pBottom = plan.Rect.Bottom; + bool inRealize = EmbedVisibility.IsInRealizeBand(pTop, pBottom, top, bottom, EmbedVirtualizationOverscanPx); + bool inDerealize = EmbedVisibility.IsInDerealizeBand(pTop, pBottom, top, bottom, EmbedVirtualizationDerealizeOverscanPx); + if (inRealize) + plan.Realize(this); + else if (!inDerealize) + plan.Derealize(this); + + if (plan.Realized is not null) + { + double left = Math.Round(plan.Rect.X); + double t = Math.Round(plan.Rect.Y); + double w = Math.Round(plan.Rect.X + plan.Rect.Width) - left; + double h = Math.Round(plan.Rect.Y + plan.Rect.Height) - t; + _codeBlockActionRects.Add((plan.Box, new Rect(left, t, w, h), plan.Kind)); + } + } + if (_embedPlans.Count == 0) { // No embeds: still emit a transition-to-zero event so subscribers @@ -1553,13 +2185,6 @@ internal void RealizeVisibleEmbeds() return; } - // Drop old realisation-side caches; they'll be repopulated as embeds realise. - // We do NOT clear them when only some embeds change state mid-scroll — - // because removing a single fe from _overlay.Children doesn't shift - // others' indices. Tracking realised plans gives us authoritative cache rebuilds. - _embedRects.Clear(); - _blockEmbedRects.Clear(); - foreach (var plan in _embedPlans) { double pTop = plan.Rect.Top; @@ -1643,6 +2268,12 @@ private void CollectEmbedPlans(Layout.BlockBox box) } break; } + case Layout.Boxes.CodeBlockBox codeBlock: + { + if (codeBlock.IsCopyButtonEnabled && codeBlock.CopyButtonBounds.Width > 0 && codeBlock.CopyButtonBounds.Height > 0) + _codeBlockActionPlans.Add(new CodeBlockActionPlan { Box = codeBlock, Rect = codeBlock.CopyButtonBounds, Kind = CodeBlockHostedElementKind.Copy }); + break; + } case Layout.Boxes.ListItemBox lib: CollectEmbedPlans(lib.Marker); CollectEmbedPlans(lib.Content); @@ -1795,6 +2426,18 @@ private void OnPointerPressed(object sender, PointerRoutedEventArgs e) var pt = e.GetCurrentPoint(_canvas).Position; RememberFocusResumePoint(pt); + // Pressing renderer chrome (code-copy action) must not create or clear + // selection; let the native button handle the click. + if (IsPointOverCodeBlockAction(pt)) + { + _consecutiveClickCount = 0; + _lastPressTickMs = 0; + _lastPressPoint = default; + return; + } + + HideAbbreviationTooltip(); + // Pressing *on* a hosted inline embed must NOT start a selection. // The embed is a real WinUI element layered above the canvas — its // own pointer-pressed handler must run (Button click, TextBox focus, @@ -1926,6 +2569,14 @@ private void FocusRendererForPointerInteraction() private InlineRun? _lastHoveredRun; private Layout.Boxes.InlineContainerBox? _lastHoveredBox; // box that contains _lastHoveredRun; used for targeted canvas invalidation + private AbbreviationRun? _lastHoveredAbbreviation; + private ToolTip? _abbreviationToolTip; + private Microsoft.UI.Dispatching.DispatcherQueueTimer? _abbreviationTooltipShowTimer; + private Microsoft.UI.Dispatching.DispatcherQueueTimer? _abbreviationTooltipHideTimer; + private AbbreviationRun? _pendingAbbreviationTooltipRun; + private Rect _pendingAbbreviationTooltipPlacementRect; + private static readonly TimeSpan AbbreviationTooltipShowDelay = TimeSpan.FromMilliseconds(250); + private static readonly TimeSpan AbbreviationTooltipHideDelay = TimeSpan.FromMilliseconds(200); // Tracks the ProtectedCursor shape we last set, or null when we have // reset to the system default. Three states: // null → ProtectedCursor was reset; system default (Arrow) shows. @@ -1946,6 +2597,7 @@ private void OnPointerMoved(object sender, PointerRoutedEventArgs e) // Drag-select. if (_selectionAnchor is not null) { + HideAbbreviationTooltip(); var dragPoint = PrepareSelectionDragPoint(pt); // Atomic embed inclusion: when the pointer is inside an inline // embed rect during a drag, snap the position to either the @@ -2030,6 +2682,7 @@ private void OnPointerMoved(object sender, PointerRoutedEventArgs e) // embed to set its own cursor if desired. if (IsPointOverEmbed(pt)) { + HideAbbreviationTooltip(); // Clear our own link-hover so when the pointer leaves the embed // the previous hovered link doesn't appear stuck-on. if (_lastHoveredRun is not null) @@ -2105,9 +2758,189 @@ private void OnPointerMoved(object sender, PointerRoutedEventArgs e) _lastHoveredRun = hovered; _lastHoveredBox = hoveredBox; + UpdateAbbreviationTooltip(hovered, hoveredBox, pt); SetCursorShape(wantedShape); } + private void UpdateAbbreviationTooltip( + InlineRun? hoveredRun, + Layout.Boxes.InlineContainerBox? hoveredBox, + Point pointerPoint) + { + var abbreviation = hoveredRun as AbbreviationRun; + if (abbreviation is null || + string.IsNullOrWhiteSpace(abbreviation.Expansion) || + _canvas is null || + hoveredBox is null || + !hoveredBox.TryGetRunBounds(abbreviation, pointerPoint, out var placementRect)) + { + ScheduleAbbreviationTooltipHide(); + return; + } + + CancelAbbreviationTooltipHide(); + if (ReferenceEquals(abbreviation, _lastHoveredAbbreviation)) + { + CancelAbbreviationTooltipShow(); + return; + } + + if (ReferenceEquals(abbreviation, _pendingAbbreviationTooltipRun)) + { + _pendingAbbreviationTooltipPlacementRect = placementRect; + return; + } + + if (_lastHoveredAbbreviation is not null) + HideAbbreviationTooltip(); + + ScheduleAbbreviationTooltipShow(abbreviation, placementRect); + } + + private void ScheduleAbbreviationTooltipShow(AbbreviationRun abbreviation, Rect placementRect) + { + CancelAbbreviationTooltipHide(); + _pendingAbbreviationTooltipRun = abbreviation; + _pendingAbbreviationTooltipPlacementRect = placementRect; + + var timer = EnsureAbbreviationTooltipShowTimer(); + if (timer is null) + { + ShowAbbreviationTooltip(abbreviation, placementRect); + return; + } + + timer.Stop(); + timer.Interval = AbbreviationTooltipShowDelay; + timer.Start(); + } + + private void ShowPendingAbbreviationTooltip() + { + _abbreviationTooltipShowTimer?.Stop(); + var abbreviation = _pendingAbbreviationTooltipRun; + var placementRect = _pendingAbbreviationTooltipPlacementRect; + _pendingAbbreviationTooltipRun = null; + + if (_isUnloaded || + abbreviation is null || + !ReferenceEquals(abbreviation, _lastHoveredRun as AbbreviationRun)) + { + return; + } + + ShowAbbreviationTooltip(abbreviation, placementRect); + } + + private void ShowAbbreviationTooltip(AbbreviationRun abbreviation, Rect placementRect) + { + if (_isUnloaded || _canvas is null || string.IsNullOrWhiteSpace(abbreviation.Expansion)) + return; + + CloseAbbreviationTooltip(); + _lastHoveredAbbreviation = abbreviation; + _abbreviationToolTip = new ToolTip + { + Content = abbreviation.Expansion, + IsHitTestVisible = false, + Placement = PlacementMode.Top, + PlacementTarget = _canvas, + PlacementRect = placementRect, + VerticalOffset = -4, + }; + ToolTipService.SetToolTip(_canvas, _abbreviationToolTip); + _abbreviationToolTip.IsOpen = true; + } + + private void ScheduleAbbreviationTooltipHide() + { + CancelAbbreviationTooltipShow(); + _pendingAbbreviationTooltipRun = null; + + if (_abbreviationToolTip is null) + { + _lastHoveredAbbreviation = null; + return; + } + + var timer = EnsureAbbreviationTooltipHideTimer(); + if (timer is null) + { + HideAbbreviationTooltip(); + return; + } + + timer.Stop(); + timer.Interval = AbbreviationTooltipHideDelay; + timer.Start(); + } + + private void HideAbbreviationTooltip() + { + CancelAbbreviationTooltipShow(); + CancelAbbreviationTooltipHide(); + _pendingAbbreviationTooltipRun = null; + CloseAbbreviationTooltip(); + _lastHoveredAbbreviation = null; + } + + private void CloseAbbreviationTooltip() + { + if (_abbreviationToolTip is not null) + { + try { _abbreviationToolTip.IsOpen = false; } catch { } + if (_canvas is not null) + ToolTipService.SetToolTip(_canvas, null); + _abbreviationToolTip = null; + } + } + + private void CancelAbbreviationTooltipShow() + { + _abbreviationTooltipShowTimer?.Stop(); + } + + private void CancelAbbreviationTooltipHide() + { + _abbreviationTooltipHideTimer?.Stop(); + } + + private void ReleaseAbbreviationTooltipTimers() + { + _abbreviationTooltipShowTimer?.Stop(); + _abbreviationTooltipHideTimer?.Stop(); + _abbreviationTooltipShowTimer = null; + _abbreviationTooltipHideTimer = null; + } + + private Microsoft.UI.Dispatching.DispatcherQueueTimer? EnsureAbbreviationTooltipShowTimer() + { + if (_abbreviationTooltipShowTimer is not null) + return _abbreviationTooltipShowTimer; + + var dispatcher = DispatcherQueue ?? _canvas?.DispatcherQueue; + if (dispatcher is null) + return null; + + _abbreviationTooltipShowTimer = dispatcher.CreateTimer(); + _abbreviationTooltipShowTimer.Tick += (_, _) => ShowPendingAbbreviationTooltip(); + return _abbreviationTooltipShowTimer; + } + + private Microsoft.UI.Dispatching.DispatcherQueueTimer? EnsureAbbreviationTooltipHideTimer() + { + if (_abbreviationTooltipHideTimer is not null) + return _abbreviationTooltipHideTimer; + + var dispatcher = DispatcherQueue ?? _canvas?.DispatcherQueue; + if (dispatcher is null) + return null; + + _abbreviationTooltipHideTimer = dispatcher.CreateTimer(); + _abbreviationTooltipHideTimer.Tick += (_, _) => HideAbbreviationTooltip(); + return _abbreviationTooltipHideTimer; + } + private void InvalidateInteractiveTextAdorner() { if (_overlay is null) @@ -2190,6 +3023,13 @@ private static (Layout.Boxes.InlineContainerBox? Box, InlineRun? Run) FindInline case Layout.Boxes.InlineContainerBox icb: var r = icb.RunAt(pt); return r is not null ? (icb, r) : (null, null); + case Layout.Boxes.CodeBlockBox codeBlock: + foreach (var chunk in codeBlock.Chunks) + { + var c = FindInlineHover(chunk, pt); + if (c.Run is not null) return c; + } + return (null, null); case Layout.Boxes.ListItemBox lib: var m = FindInlineHover(lib.Marker, pt); if (m.Run is not null) return m; @@ -2219,6 +3059,9 @@ private static void ClearHover(Layout.BlockBox box) case Layout.Boxes.InlineContainerBox icb: icb.HoveredRun = null; break; + case Layout.Boxes.CodeBlockBox codeBlock: + foreach (var chunk in codeBlock.Chunks) ClearHover(chunk); + break; case Layout.Boxes.ListItemBox lib: ClearHover(lib.Marker); ClearHover(lib.Content); @@ -2238,6 +3081,9 @@ private static void ClearHover(Layout.BlockBox box) /// private bool IsPointOverEmbed(Point pt) { + if (IsPointOverCodeBlockAction(pt)) + return true; + for (int i = 0; i < _embedRects.Count; i++) { var r = _embedRects[i].Rect; @@ -2255,6 +3101,19 @@ private bool IsPointOverEmbed(Point pt) return false; } + private bool IsPointOverCodeBlockAction(Point pt) + { + for (int i = 0; i < _codeBlockActionRects.Count; i++) + { + var r = _codeBlockActionRects[i].Rect; + if (pt.X >= r.X && pt.X < r.X + r.Width && + pt.Y >= r.Y && pt.Y < r.Y + r.Height) + return true; + } + + return false; + } + /// /// If the point is inside an inline embed's rectangle, returns a /// DocumentPosition that snaps to the start (left half) or end (right @@ -2540,6 +3399,8 @@ private void OnPointerCanceledOrCaptureLost(object sender, PointerRoutedEventArg private void OnPointerExited(object sender, PointerRoutedEventArgs e) { + ScheduleAbbreviationTooltipHide(); + // PointerExited fires when the pointer leaves canvas bounds. During an // active captured drag this is expected (drag through hosted embeds / // adjacent areas) so we MUST NOT clear _selectionAnchor here — that @@ -2836,6 +3697,11 @@ private void SuppressHostedTabStopsForNativeTab() if (plan.Realized is { } realized) SuppressHostedTabStops(realized, suppressed, seen); } + foreach (var plan in _codeBlockActionPlans) + { + if (plan.Realized is { } realized) + SuppressHostedTabStops(realized, suppressed, seen); + } if (suppressed.Count == 0) return; @@ -3113,6 +3979,11 @@ private void RememberFocusResumePoint(Point documentPoint) eb.Bounds.Width - eb.Margin.Left - eb.Margin.Right, eb.Bounds.Height - eb.Margin.Top - eb.Margin.Bottom); } + if (box is Layout.Boxes.CodeBlockBox codeBlock && codeBlock.BlockIndex == item.BlockIndex) + { + if (item.IsCodeBlockCopy) + return codeBlock.CopyButtonBounds; + } if (box is Layout.Boxes.InlineContainerBox icb && icb.BlockIndex == item.BlockIndex) return icb.GetRunRect(item.InlineIndex); if (box is Layout.Boxes.ListItemBox lib) @@ -3318,6 +4189,10 @@ private bool TrySetFocusedItemForHostedElement(FrameworkElement element) { case Layout.Boxes.EmbedBox eb when item.IsBlockEmbed && eb.BlockIndex == item.BlockIndex: return eb.RealizedElement; + case Layout.Boxes.CodeBlockBox codeBlock when codeBlock.BlockIndex == item.BlockIndex: + if (item.IsCodeBlockCopy) + return codeBlock.RealizedCopyButton; + return null; case Layout.Boxes.InlineContainerBox icb when item.IsInlineEmbed && icb.BlockIndex == item.BlockIndex: foreach (var run in icb.Runs) { @@ -3516,6 +4391,13 @@ private static bool TryGetLinkForFocusable( private static Layout.Boxes.InlineContainerBox? FindIcbInBlock(BlockBox box, int blockIndex) { if (box is Layout.Boxes.InlineContainerBox icb && icb.BlockIndex == blockIndex) return icb; + if (box is Layout.Boxes.CodeBlockBox codeBlock) + { + foreach (var chunk in codeBlock.Chunks) + { + if (chunk.BlockIndex == blockIndex) return chunk; + } + } if (box is Layout.Boxes.ListItemBox lib) return FindIcbInBlock(lib.Marker, blockIndex) ?? FindIcbInBlock(lib.Content, blockIndex); if (box is Layout.Boxes.StackBox sb) diff --git a/MarkdownRenderer/MarkdownRenderer/Controls/MarkdownRendererControlBuilder.cs b/MarkdownRenderer/MarkdownRenderer/Controls/MarkdownRendererControlBuilder.cs index 2b99b63..db04cbb 100644 --- a/MarkdownRenderer/MarkdownRenderer/Controls/MarkdownRendererControlBuilder.cs +++ b/MarkdownRenderer/MarkdownRenderer/Controls/MarkdownRendererControlBuilder.cs @@ -1,4 +1,5 @@ using System; +using MarkdownRenderer.CodeBlocks; using MarkdownRenderer.Hosting; using MarkdownRenderer.Parsing; using MarkdownRenderer.Theming; @@ -15,6 +16,12 @@ public sealed class MarkdownRendererControlBuilder private MarkdownExtensionRegistry? _registry; private IMarkdownEmbedFactory? _embedFactory; private bool _isSelectionEnabled = true; + private bool _isCodeBlockCopyEnabled = true; + private string? _codeBlockCopyButtonLabel; + private string? _codeBlockCopiedButtonLabel; + private bool _isCodeBlockSyntaxHighlightingEnabled = true; + private ICodeBlockSyntaxHighlighter? _codeBlockSyntaxHighlighter; + private CodeBlockLineNumberMode _codeBlockLineNumberMode = CodeBlockLineNumberMode.AutoMultiline; /// Sets the markdown source shown by the control. /// Markdown source text. A null value is treated as an empty string. @@ -72,6 +79,54 @@ public MarkdownRendererControlBuilder WithSelectionEnabled(bool isEnabled) return this; } + /// Sets whether code block copy buttons are shown. + /// True to show copy buttons on code blocks; false to hide them. + /// The current builder. + public MarkdownRendererControlBuilder WithCodeBlockCopyEnabled(bool enabled = true) + { + _isCodeBlockCopyEnabled = enabled; + return this; + } + + /// Sets the accessible label and tooltip used for code-block copy buttons. + /// Accessible label and tooltip text, or null to use the renderer default. + /// The current builder. + public MarkdownRendererControlBuilder WithCodeBlockCopyButtonLabel(string? label) + { + _codeBlockCopyButtonLabel = label; + return this; + } + + /// Sets the accessible label and tooltip shown after a code-block copy succeeds. + /// Accessible label and tooltip text, or null to use the renderer default. + /// The current builder. + public MarkdownRendererControlBuilder WithCodeBlockCopiedButtonLabel(string? label) + { + _codeBlockCopiedButtonLabel = label; + return this; + } + + /// Sets whether configured syntax highlighters may color code blocks. + public MarkdownRendererControlBuilder WithCodeBlockSyntaxHighlightingEnabled(bool enabled = true) + { + _isCodeBlockSyntaxHighlightingEnabled = enabled; + return this; + } + + /// Sets the optional syntax highlighter used for code blocks. + public MarkdownRendererControlBuilder WithCodeBlockSyntaxHighlighter(ICodeBlockSyntaxHighlighter? highlighter) + { + _codeBlockSyntaxHighlighter = highlighter; + return this; + } + + /// Sets when code blocks show line numbers. + public MarkdownRendererControlBuilder WithCodeBlockLineNumberMode(CodeBlockLineNumberMode mode) + { + _codeBlockLineNumberMode = mode; + return this; + } + /// Creates the configured markdown renderer control. /// A new instance. public MarkdownRendererControl Build() @@ -83,6 +138,12 @@ public MarkdownRendererControl Build() ExtensionRegistry = _registry, EmbedFactory = _embedFactory, IsSelectionEnabled = _isSelectionEnabled, + IsCodeBlockCopyEnabled = _isCodeBlockCopyEnabled, + CodeBlockCopyButtonLabel = _codeBlockCopyButtonLabel, + CodeBlockCopiedButtonLabel = _codeBlockCopiedButtonLabel, + IsCodeBlockSyntaxHighlightingEnabled = _isCodeBlockSyntaxHighlightingEnabled, + CodeBlockSyntaxHighlighter = _codeBlockSyntaxHighlighter, + CodeBlockLineNumberMode = _codeBlockLineNumberMode, }; } } diff --git a/MarkdownRenderer/MarkdownRenderer/Document/MarkdownSourceMap.cs b/MarkdownRenderer/MarkdownRenderer/Document/MarkdownSourceMap.cs index 92bc572..38e7214 100644 --- a/MarkdownRenderer/MarkdownRenderer/Document/MarkdownSourceMap.cs +++ b/MarkdownRenderer/MarkdownRenderer/Document/MarkdownSourceMap.cs @@ -23,7 +23,56 @@ public MarkdownSourceMap(string sourceText) /// Adds a mapping from a rendered inline run to a source span. public void Add(int blockIndex, int inlineIndex, int renderedLength, SourceSpan span) { - _entries.Add(new Entry(blockIndex, inlineIndex, renderedLength, span)); + _entries.Add(new Entry(blockIndex, inlineIndex, renderedLength, span, SourcePrefixLength: 0, SourceSuffixLength: 0)); + } + + /// + /// Expands the first/last source-map entries for a block to include source-only + /// prefix/suffix characters such as heading markers. + /// + internal void AddSourceAffixesToBlock(int blockIndex, int sourceStart, int sourceEnd) + { + int first = -1; + int last = -1; + for (int i = 0; i < _entries.Count; i++) + { + if (_entries[i].BlockIndex != blockIndex || _entries[i].Span.IsEmpty) + continue; + + if (first < 0) + first = i; + last = i; + } + + if (first < 0 || last < 0) + return; + + sourceStart = Math.Clamp(sourceStart, 0, _sourceText.Length); + sourceEnd = Math.Clamp(sourceEnd, sourceStart, _sourceText.Length); + + var firstEntry = _entries[first]; + int prefixLength = Math.Max(0, firstEntry.Span.Start - sourceStart); + if (prefixLength > 0) + { + firstEntry = firstEntry with + { + Span = new SourceSpan(sourceStart, firstEntry.Span.Length + prefixLength), + SourcePrefixLength = firstEntry.SourcePrefixLength + prefixLength, + }; + _entries[first] = firstEntry; + } + + var lastEntry = _entries[last]; + int suffixLength = Math.Max(0, sourceEnd - lastEntry.Span.End); + if (suffixLength > 0) + { + lastEntry = lastEntry with + { + Span = new SourceSpan(lastEntry.Span.Start, lastEntry.Span.Length + suffixLength), + SourceSuffixLength = lastEntry.SourceSuffixLength + suffixLength, + }; + _entries[last] = lastEntry; + } } /// Returns the exact markdown source slice covered by a rendered document range. @@ -63,10 +112,10 @@ public string Slice(DocumentRange range) if (firstHit is null) { firstHit = e; - firstFromOffset = ProjectOffset(e, from); + firstFromOffset = ProjectStartOffset(e, from); } lastHit = e; - lastToOffset = ProjectOffset(e, to); + lastToOffset = ProjectEndOffset(e, to); } if (firstHit is null || lastHit is null) return string.Empty; @@ -81,6 +130,42 @@ public string Slice(DocumentRange range) /// entry's source span. Exact when render-length matches span-length; /// otherwise proportional. /// + private static int ProjectStartOffset(Entry e, int renderedOffset) + { + if (e.SourcePrefixLength == 0 && e.SourceSuffixLength == 0) + return ProjectOffset(e, renderedOffset); + + if (renderedOffset <= 0) + return 0; + + return ProjectAffixedContentOffset(e, renderedOffset); + } + + private static int ProjectEndOffset(Entry e, int renderedOffset) + { + if (e.SourcePrefixLength == 0 && e.SourceSuffixLength == 0) + return ProjectOffset(e, renderedOffset); + + if (renderedOffset >= e.RenderedLength) + return e.Span.Length; + + return ProjectAffixedContentOffset(e, renderedOffset); + } + + private static int ProjectAffixedContentOffset(Entry e, int renderedOffset) + { + int prefixLength = Math.Clamp(e.SourcePrefixLength, 0, e.Span.Length); + int suffixLength = Math.Clamp(e.SourceSuffixLength, 0, e.Span.Length - prefixLength); + int contentLength = Math.Max(0, e.Span.Length - prefixLength - suffixLength); + if (e.RenderedLength <= 0) + return prefixLength; + + int contentOffset = e.RenderedLength == contentLength + ? renderedOffset + : (int)Math.Round(renderedOffset * (double)contentLength / Math.Max(1, e.RenderedLength)); + return prefixLength + Math.Clamp(contentOffset, 0, contentLength); + } + private static int ProjectOffset(Entry e, int renderedOffset) { if (e.RenderedLength <= 0) return 0; @@ -91,5 +176,11 @@ private static int ProjectOffset(Entry e, int renderedOffset) internal IReadOnlyList Entries => _entries; - internal readonly record struct Entry(int BlockIndex, int InlineIndex, int RenderedLength, SourceSpan Span); + internal readonly record struct Entry( + int BlockIndex, + int InlineIndex, + int RenderedLength, + SourceSpan Span, + int SourcePrefixLength, + int SourceSuffixLength); } diff --git a/MarkdownRenderer/MarkdownRenderer/Layout/Boxes/CodeBlockBox.cs b/MarkdownRenderer/MarkdownRenderer/Layout/Boxes/CodeBlockBox.cs new file mode 100644 index 0000000..ba56959 --- /dev/null +++ b/MarkdownRenderer/MarkdownRenderer/Layout/Boxes/CodeBlockBox.cs @@ -0,0 +1,641 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Microsoft.Graphics.Canvas; +using Microsoft.Graphics.Canvas.Text; +using Microsoft.UI.Xaml; +using Windows.Foundation; +using Windows.UI; +using MarkdownRenderer.CodeBlocks; +using MarkdownRenderer.Document; +using MarkdownRenderer.Theming; + +namespace MarkdownRenderer.Layout.Boxes; + +internal sealed class CodeBlockBox : BlockBox +{ + private const float HeaderHeight = 38f; + private const float HeaderPaddingX = 12f; + private const float HeaderGap = 8f; + private const float CopyButtonWidth = 34f; + private const float ActionHeight = 28f; + private const float DiffMarkerWidth = 16f; + private const float LineNumberPadding = 10f; + private const float CodeTextPaddingLeft = 12f; + + private readonly MarkdownLayoutContext _context; + private readonly List _chunks = new(); + private readonly List _lines; + private readonly IReadOnlyList _styleContextKeys; + private readonly IReadOnlyList _styleAliasKeys; + + public CodeBlockBox( + MarkdownLayoutContext context, + CodeBlockMetadata metadata, + string displayedCodeText, + bool isCopyButtonEnabled, + bool showLineNumbers) + { + _context = context; + _styleContextKeys = context.CreateStyleContextSnapshot(); + _styleAliasKeys = context.CreateStyleAliasSnapshot(); + Metadata = metadata; + CodeLanguage = metadata.Language; + CodeText = CodeBlockMetadata.CopyPayload(displayedCodeText); + LanguageDisplay = metadata.LanguageDisplay; + HeaderText = metadata.HeaderText; + IsCopyButtonEnabled = isCopyButtonEnabled; + ShowLineNumbers = showLineNumbers; + _lines = BuildLines(CodeText, metadata.IsDiff); + Margin = GetCodeStyle().Margin; + } + + public IReadOnlyList Chunks => _chunks; + public CodeBlockMetadata Metadata { get; } + public string StableKey => Metadata.StableKey; + public string? CodeLanguage { get; } + public string LanguageDisplay { get; } + public string HeaderText { get; } + public string CodeText { get; } + public int LineCount => _lines.Count; + public bool IsCopyButtonEnabled { get; } + public bool ShowLineNumbers { get; } + public Rect CopyButtonBounds { get; private set; } + internal FrameworkElement? RealizedCopyButton { get; set; } + + public void AddChunk(InlineContainerBox chunk) + { + chunk.DrawContainerChrome = false; + chunk.UseContainerPadding = false; + chunk.UseContainerMargin = false; + chunk.Margin = default; + _chunks.Add(chunk); + } + + internal void ApplySyntaxHighlighting(IReadOnlyList? spans) + { + var allSpans = spans ?? Array.Empty(); + foreach (var chunk in _chunks) + { + var local = new List(); + int chunkStart = chunk.CodeBlockTextOffset; + int chunkEnd = chunkStart + chunk.CodeBlockTextLength; + foreach (var span in allSpans) + { + int spanStart = Math.Max(span.Start, chunkStart); + int spanEnd = Math.Min(span.Start + span.Length, chunkEnd); + if (spanEnd > spanStart) + local.Add(new CodeBlockHighlightSpan(spanStart - chunkStart, spanEnd - spanStart, span.Foreground)); + } + + chunk.SetForegroundSpans(local); + } + } + + public override float Measure(float availableWidth) + { + ThrowIfCancellationRequested(); + var style = GetCodeStyle(); + Margin = style.Margin; + + double innerWidth = Math.Max(1, availableWidth - Margin.Left - Margin.Right); + double gutterWidth = ComputeGutterWidth(style); + double codeTextPaddingLeft = gutterWidth > 0 ? CodeTextPaddingLeft : 0; + double bodyViewportWidth = Math.Max(1, innerWidth - style.Padding.Left - style.Padding.Right - gutterWidth - codeTextPaddingLeft); + double y = Margin.Top + HeaderHeight + style.Padding.Top; + + foreach (var chunk in _chunks) + { + chunk.ThrowIfCancellationRequested(); + float h = chunk.Measure((float)bodyViewportWidth); + chunk.Arrange( + (float)(Margin.Left + style.Padding.Left + gutterWidth + codeTextPaddingLeft), + (float)y, + (float)bodyViewportWidth); + y += h; + } + + y += style.Padding.Bottom; + y += Margin.Bottom; + + Bounds = new Rect(0, 0, availableWidth, Math.Max(0, y)); + UpdateActionBounds(); + return (float)Bounds.Height; + } + + public override void Arrange(float x, float y, float width) + { + float dx = x - (float)Bounds.X; + float dy = y - (float)Bounds.Y; + foreach (var chunk in _chunks) + chunk.Arrange((float)chunk.Bounds.X + dx, (float)chunk.Bounds.Y + dy, (float)chunk.Bounds.Width); + + Bounds = new Rect(x, y, width, Bounds.Height); + UpdateActionBounds(); + IsDirty = false; + } + + internal override void ThrowIfCancellationRequested() + { + _context.CancellationToken.ThrowIfCancellationRequested(); + foreach (var chunk in _chunks) + chunk.ThrowIfCancellationRequested(); + } + + public override void Paint(CanvasDrawingSession ds, Rect viewport) + { + var codeStyle = GetCodeStyle(); + var headerStyle = GetHeaderStyle(); + var languageStyle = GetLanguageStyle(); + var outer = OuterRect(); + if (outer.Width <= 0 || outer.Height <= 0) + return; + + float radius = Math.Max(0, codeStyle.CornerRadius); + if (codeStyle.Background is { } bg) + ds.FillRoundedRectangle(outer, radius, radius, bg); + + if (headerStyle.Background is { } headerBg) + { + ds.FillRoundedRectangle(outer, radius, radius, headerBg); + if (codeStyle.Background is { } bodyBg) + { + ds.FillRectangle( + new Rect(outer.X, outer.Y + HeaderHeight, outer.Width, Math.Max(0, outer.Height - HeaderHeight)), + bodyBg); + } + } + + var separator = codeStyle.BorderBrush ?? headerStyle.BorderBrush ?? languageStyle.Foreground; + ds.DrawLine( + (float)outer.Left, + (float)(outer.Top + HeaderHeight), + (float)outer.Right, + (float)(outer.Top + HeaderHeight), + _context.ThemeSnapshot.IsHighContrast + ? separator + : WithAlpha(separator, Math.Min(separator.A, (byte)0x60)), + 1f); + + DrawHeaderText(ds, outer, languageStyle); + DrawBodySurfaces(ds, outer, codeStyle, headerStyle, languageStyle, viewport); + + var clip = CodeViewportRect(outer, codeStyle); + using (ds.CreateLayer(1.0f, clip)) + { + foreach (var chunk in _chunks) + { + if (chunk.Bounds.Bottom < viewport.Top || chunk.Bounds.Top > viewport.Bottom) + continue; + chunk.Paint(ds, viewport); + } + } + + if (codeStyle.BorderBrush is { } border && codeStyle.BorderThickness > 0) + { + float inset = codeStyle.BorderThickness / 2f; + ds.DrawRoundedRectangle( + new Rect( + outer.X + inset, + outer.Y + inset, + Math.Max(0, outer.Width - codeStyle.BorderThickness), + Math.Max(0, outer.Height - codeStyle.BorderThickness)), + radius, + radius, + border, + codeStyle.BorderThickness); + } + } + + public override bool HitTest(Point point, out DocumentPosition position) + { + var outer = OuterRect(); + if (!outer.Contains(point)) + { + position = CodeStartPosition(); + return false; + } + + if (TryHitTestChunks(point, out position)) + return true; + + var codeViewport = CodeViewportRect(outer, GetCodeStyle()); + if (point.Y < codeViewport.Top) + { + position = CodeStartPosition(); + return true; + } + + if (point.Y > codeViewport.Bottom) + { + position = CodeEndPosition(); + return true; + } + + if (_chunks.Count > 0) + { + var first = _chunks[0]; + var last = _chunks[_chunks.Count - 1]; + if (point.Y < first.Bounds.Top) + { + position = CodeStartPosition(); + return true; + } + + if (point.Y > last.Bounds.Bottom) + { + position = CodeEndPosition(); + return true; + } + + double clampedX = Clamp(point.X, codeViewport.Left, codeViewport.Right); + foreach (var chunk in _chunks) + { + if (point.Y < chunk.Bounds.Top || point.Y > chunk.Bounds.Bottom) + continue; + + if (chunk.HitTest(new Point(clampedX, point.Y), out position)) + return true; + } + } + + position = CodeStartPosition(); + return true; + } + + public override IEnumerable GetSelectionRects(DocumentRange range) + { + var clip = CodeViewportRect(OuterRect(), GetCodeStyle()); + foreach (var chunk in _chunks) + { + foreach (var rect in chunk.GetSelectionRects(range)) + { + var clipped = Intersect(rect, clip); + if (clipped.Width > 0 && clipped.Height > 0) + yield return clipped; + } + } + } + + public override void PaintSelectionForeground(CanvasDrawingSession ds, DocumentRange range, Windows.UI.Color color, Rect viewport) + { + var clip = CodeViewportRect(OuterRect(), GetCodeStyle()); + using (ds.CreateLayer(1.0f, clip)) + { + foreach (var chunk in _chunks) + { + if (chunk.Bounds.Bottom < viewport.Top || chunk.Bounds.Top > viewport.Bottom) + continue; + chunk.PaintSelectionForeground(ds, range, color, viewport); + } + } + } + + public override void Dispose() + { + foreach (var chunk in _chunks) + chunk.Dispose(); + } + + private ElementStyle GetCodeStyle() + => _context.ThemeSnapshot.GetStyle(MarkdownElementKeys.CodeBlock, _styleContextKeys, _styleAliasKeys); + + private ElementStyle GetHeaderStyle() + => _context.ThemeSnapshot.GetStyle(MarkdownElementKeys.CodeBlockHeader, _styleContextKeys, _styleAliasKeys); + + private ElementStyle GetLanguageStyle() + => _context.ThemeSnapshot.GetStyle(MarkdownElementKeys.CodeBlockLanguage, _styleContextKeys, _styleAliasKeys); + + private ElementStyle GetGutterStyle() + => _context.ThemeSnapshot.GetStyle(MarkdownElementKeys.CodeBlockGutter, _styleContextKeys, _styleAliasKeys); + + private ElementStyle GetLineNumberStyle() + => _context.ThemeSnapshot.GetStyle(MarkdownElementKeys.CodeBlockLineNumber, _styleContextKeys, _styleAliasKeys); + + private Rect OuterRect() + => new( + Bounds.X + Margin.Left, + Bounds.Y + Margin.Top, + Math.Max(0, Bounds.Width - Margin.Left - Margin.Right), + Math.Max(0, Bounds.Height - Margin.Top - Margin.Bottom)); + + private Rect CodeViewportRect(Rect outer, ElementStyle style) + { + double gutterWidth = ComputeGutterWidth(style); + double codeTextPaddingLeft = gutterWidth > 0 ? CodeTextPaddingLeft : 0; + double left = outer.Left + style.Padding.Left + gutterWidth + codeTextPaddingLeft; + double top = outer.Top + HeaderHeight + style.Padding.Top; + double height = Math.Max(0, outer.Height - HeaderHeight - style.Padding.Top - style.Padding.Bottom); + double width = Math.Max(0, outer.Width - style.Padding.Left - style.Padding.Right - gutterWidth - codeTextPaddingLeft); + return new Rect(left, top, width, height); + } + + private double ComputeGutterWidth(ElementStyle style) + { + double diffWidth = Metadata.IsDiff ? DiffMarkerWidth : 0; + if (!ShowLineNumbers) + return diffWidth; + + int lastLine = Math.Max(1, Metadata.StartLine + Math.Max(0, LineCount - 1)); + int digits = lastLine.ToString(CultureInfo.InvariantCulture).Length; + return diffWidth + LineNumberPadding + Math.Max(2, digits) * Math.Max(6, style.FontSize * 0.56f) + LineNumberPadding; + } + + private bool TryHitTestChunks(Point point, out DocumentPosition position) + { + foreach (var chunk in _chunks) + { + if (chunk.HitTest(point, out position)) + return true; + } + + position = CodeStartPosition(); + return false; + } + + private DocumentPosition CodeStartPosition() + { + if (_chunks.Count == 0) + return new DocumentPosition(BlockIndex, 0, 0); + + var first = _chunks[0]; + if (first.Runs.Count == 0) + return new DocumentPosition(first.BlockIndex, 0, 0); + + return new DocumentPosition(first.BlockIndex, first.Runs[0].InlineIndex, 0); + } + + private DocumentPosition CodeEndPosition() + { + if (_chunks.Count == 0) + return new DocumentPosition(BlockIndex, 0, 0); + + var last = _chunks[_chunks.Count - 1]; + if (last.Runs.Count == 0) + return new DocumentPosition(last.BlockIndex, 0, 0); + + var run = last.Runs[last.Runs.Count - 1]; + return new DocumentPosition(last.BlockIndex, run.InlineIndex, run.Text.Length); + } + + private static double Clamp(double value, double min, double max) + { + if (max <= min) + return min; + + return Math.Min(Math.Max(value, min), max - 0.5); + } + + private void UpdateActionBounds() + { + var outer = OuterRect(); + double top = outer.Top + Math.Max(0, (HeaderHeight - ActionHeight) / 2.0); + bool rtl = _context.FlowDirection == FlowDirection.RightToLeft; + + CopyButtonBounds = Rect.Empty; + if (rtl) + { + double x = outer.Left + HeaderPaddingX; + if (IsCopyButtonEnabled) + { + CopyButtonBounds = new Rect(x, top, CopyButtonWidth, ActionHeight); + } + } + else + { + double x = outer.Right - HeaderPaddingX; + if (IsCopyButtonEnabled) + { + x -= CopyButtonWidth; + CopyButtonBounds = new Rect(x, top, CopyButtonWidth, ActionHeight); + } + } + } + + private void DrawHeaderText(CanvasDrawingSession ds, Rect outer, ElementStyle style) + { + bool rtl = _context.FlowDirection == FlowDirection.RightToLeft; + double actionLeft = outer.Right - HeaderPaddingX; + double actionRight = outer.Left + HeaderPaddingX; + if (!CopyButtonBounds.IsEmpty) + { + actionLeft = Math.Min(actionLeft, CopyButtonBounds.Left); + actionRight = Math.Max(actionRight, CopyButtonBounds.Right); + } + double left = rtl ? actionRight + HeaderGap : outer.Left + HeaderPaddingX; + double right = rtl ? outer.Right - HeaderPaddingX : actionLeft - HeaderGap; + if (right <= left) + return; + + using var format = new CanvasTextFormat + { + FontFamily = style.FontFamily, + FontSize = style.FontSize, + FontWeight = style.FontWeight, + FontStyle = style.FontStyle, + WordWrapping = CanvasWordWrapping.NoWrap, + Direction = rtl + ? CanvasTextDirection.RightToLeftThenTopToBottom + : CanvasTextDirection.LeftToRightThenTopToBottom, + HorizontalAlignment = rtl ? CanvasHorizontalAlignment.Right : CanvasHorizontalAlignment.Left, + VerticalAlignment = CanvasVerticalAlignment.Center, + }; + + ds.DrawText(HeaderText, new Rect(left, outer.Top, right - left, HeaderHeight), style.Foreground, format); + } + + private void DrawBodySurfaces( + CanvasDrawingSession ds, + Rect outer, + ElementStyle codeStyle, + ElementStyle headerStyle, + ElementStyle languageStyle, + Rect viewport) + { + var gutterStyle = GetGutterStyle(); + var lineNumberStyle = GetLineNumberStyle(); + double gutterWidth = ComputeGutterWidth(codeStyle); + var bodyTop = outer.Top + HeaderHeight; + var bodyBottom = outer.Bottom; + + if (gutterWidth > 0) + { + var gutterRect = new Rect( + outer.Left, + bodyTop, + Math.Min(gutterWidth + codeStyle.Padding.Left, outer.Width), + Math.Max(0, bodyBottom - bodyTop)); + if (gutterStyle.Background is { } gutterBg) + ds.FillRectangle(gutterRect, gutterBg); + + var sep = codeStyle.BorderBrush ?? headerStyle.BorderBrush ?? languageStyle.Foreground; + ds.DrawLine( + (float)gutterRect.Right, + (float)bodyTop, + (float)gutterRect.Right, + (float)bodyBottom, + _context.ThemeSnapshot.IsHighContrast ? sep : WithAlpha(sep, 0x40), + 1f); + } + + DrawLineDecorations(ds, outer, codeStyle, lineNumberStyle, viewport); + } + + private void DrawLineDecorations(CanvasDrawingSession ds, Rect outer, ElementStyle codeStyle, ElementStyle lineNumberStyle, Rect viewport) + { + double gutterWidth = ComputeGutterWidth(codeStyle); + if (_lines.Count == 0) + return; + + using var lineNumberFormat = new CanvasTextFormat + { + FontFamily = lineNumberStyle.FontFamily, + FontSize = lineNumberStyle.FontSize, + FontWeight = lineNumberStyle.FontWeight, + FontStyle = lineNumberStyle.FontStyle, + WordWrapping = CanvasWordWrapping.NoWrap, + HorizontalAlignment = CanvasHorizontalAlignment.Right, + VerticalAlignment = CanvasVerticalAlignment.Top, + }; + + var textViewport = CodeViewportRect(outer, codeStyle); + double fullLeft = outer.Left; + double fullRight = outer.Right; + double numberLeft = outer.Left + (Metadata.IsDiff ? DiffMarkerWidth : 0); + double numberWidth = Math.Max(0, gutterWidth - (Metadata.IsDiff ? DiffMarkerWidth : 0) - LineNumberPadding); + var additionBg = _context.ThemeSnapshot.IsHighContrast + ? Color.FromArgb(0x00, 0, 0, 0) + : Color.FromArgb(0x24, 0x2E, 0xC2, 0x7E); + var removalBg = _context.ThemeSnapshot.IsHighContrast + ? Color.FromArgb(0x00, 0, 0, 0) + : Color.FromArgb(0x24, 0xF8, 0x51, 0x49); + var highlightBg = _context.ThemeSnapshot.IsHighContrast + ? Color.FromArgb(0x00, 0, 0, 0) + : Color.FromArgb(0x26, 0xFF, 0xD8, 0x66); + + foreach (var line in _lines) + { + var rects = GetLineRects(line); + Rect first = Rect.Empty; + foreach (var rect in rects) + { + if (first.IsEmpty) + first = rect; + double top = rect.Top; + double bottom = rect.Bottom; + if (bottom < viewport.Top || top > viewport.Bottom) + continue; + + Color? bg = line.DiffKind switch + { + CodeLineDiffKind.Added => additionBg, + CodeLineDiffKind.Removed => removalBg, + _ => Metadata.HighlightedLines.Contains(line.Number) ? highlightBg : null, + }; + if (bg is { A: > 0 } lineBg) + ds.FillRectangle(new Rect(fullLeft, top, fullRight - fullLeft, Math.Max(1, bottom - top)), lineBg); + } + + if (!first.IsEmpty && ShowLineNumbers) + { + var label = (Metadata.StartLine + line.Number - 1).ToString(CultureInfo.InvariantCulture); + ds.DrawText( + label, + new Rect(numberLeft, first.Top, numberWidth, Math.Max(1, first.Height)), + lineNumberStyle.Foreground, + lineNumberFormat); + } + + if (!first.IsEmpty && Metadata.IsDiff && line.DiffKind is not CodeLineDiffKind.None) + { + var marker = line.DiffKind == CodeLineDiffKind.Added ? "+" : "-"; + var markerColor = line.DiffKind == CodeLineDiffKind.Added + ? Color.FromArgb(0xFF, 0x2E, 0xC2, 0x7E) + : Color.FromArgb(0xFF, 0xF8, 0x51, 0x49); + ds.DrawText( + marker, + new Rect(outer.Left + 4, first.Top, DiffMarkerWidth - 4, Math.Max(1, first.Height)), + _context.ThemeSnapshot.IsHighContrast ? lineNumberStyle.Foreground : markerColor, + lineNumberFormat); + } + } + } + + private IEnumerable GetLineRects(CodeLineInfo line) + { + int lineStart = line.Start; + int lineEnd = line.Start + Math.Max(1, line.Length); + foreach (var chunk in _chunks) + { + int chunkStart = chunk.CodeBlockTextOffset; + int chunkEnd = chunkStart + chunk.CodeBlockTextLength; + int overlapStart = Math.Max(lineStart, chunkStart); + int overlapEnd = Math.Min(lineEnd, chunkEnd); + if (overlapEnd <= overlapStart) + continue; + + foreach (var rect in chunk.GetBufferRangeLineRects(overlapStart - chunkStart, overlapEnd - overlapStart)) + yield return rect; + } + } + + private static List BuildLines(string code, bool isDiff) + { + var lines = new List(); + if (code.Length == 0) + { + lines.Add(new CodeLineInfo(1, 0, 0, CodeLineDiffKind.None)); + return lines; + } + + int start = 0; + int number = 1; + for (int i = 0; i <= code.Length; i++) + { + bool atEnd = i == code.Length; + if (!atEnd && code[i] != '\n') + continue; + + int end = atEnd ? i : i + 1; + int contentLength = end - start; + var diffKind = CodeLineDiffKind.None; + if (isDiff && contentLength > 0) + { + char first = code[start]; + if (first == '+') + diffKind = CodeLineDiffKind.Added; + else if (first == '-') + diffKind = CodeLineDiffKind.Removed; + } + + lines.Add(new CodeLineInfo(number, start, contentLength, diffKind)); + number++; + start = end; + } + + return lines; + } + + private static Rect Intersect(Rect a, Rect b) + { + double left = Math.Max(a.Left, b.Left); + double top = Math.Max(a.Top, b.Top); + double right = Math.Min(a.Right, b.Right); + double bottom = Math.Min(a.Bottom, b.Bottom); + return right > left && bottom > top + ? new Rect(left, top, right - left, bottom - top) + : Rect.Empty; + } + + private static Windows.UI.Color WithAlpha(Windows.UI.Color color, byte alpha) + => Windows.UI.Color.FromArgb(alpha, color.R, color.G, color.B); + + private readonly record struct CodeLineInfo(int Number, int Start, int Length, CodeLineDiffKind DiffKind); + + private enum CodeLineDiffKind + { + None, + Added, + Removed, + } +} diff --git a/MarkdownRenderer/MarkdownRenderer/Layout/Boxes/InlineContainerBox.cs b/MarkdownRenderer/MarkdownRenderer/Layout/Boxes/InlineContainerBox.cs index d886540..1b6944d 100644 --- a/MarkdownRenderer/MarkdownRenderer/Layout/Boxes/InlineContainerBox.cs +++ b/MarkdownRenderer/MarkdownRenderer/Layout/Boxes/InlineContainerBox.cs @@ -5,6 +5,7 @@ using Microsoft.UI.Xaml; using Windows.Foundation; using Windows.UI; +using MarkdownRenderer.CodeBlocks; using MarkdownRenderer.Diagnostics; using MarkdownRenderer.Document; using MarkdownRenderer.Theming; @@ -31,6 +32,7 @@ internal sealed class InlineContainerBox : BlockBox private readonly MarkdownLayoutContext _context; private readonly IReadOnlyList _styleContextKeys; private readonly IReadOnlyList _styleAliasKeys; + private IReadOnlyList _foregroundSpans = Array.Empty(); /// /// Run currently being hovered by the pointer. Retained for hit-test / @@ -50,8 +52,14 @@ internal sealed class InlineContainerBox : BlockBox public string ElementKey => _elementKey; public MarkdownLayoutContext Context => _context; public string? CodeLanguage { get; init; } + internal int CodeBlockTextOffset { get; init; } + internal int CodeBlockTextLength { get; init; } public CanvasHorizontalAlignment TextAlignment { get; set; } = CanvasHorizontalAlignment.Left; internal bool HasMeasuredLayout => _layout is not null; + internal float ContentWidth => _layout is null ? (float)Bounds.Width : (float)Math.Max(_layout.LayoutBounds.Width, _layout.DrawBounds.Width); + internal bool DrawContainerChrome { get; set; } = true; + internal bool UseContainerPadding { get; set; } = true; + internal bool UseContainerMargin { get; set; } = true; public InlineContainerBox(MarkdownLayoutContext context, string elementKey) { @@ -72,6 +80,13 @@ public void Add(InlineRun run) _bufferDirty = true; // buffer is stale untnl next BuildBuffer() } + internal void SetForegroundSpans(IReadOnlyList? spans) + { + _foregroundSpans = spans ?? Array.Empty(); + if (_layout is not null) + ApplyForegroundSpans(_layout); + } + /// /// Converts a (which must target this box) to an /// absolute character index withnn the concatenated inline buffer. @@ -155,7 +170,10 @@ public override float Measure(float availableWidth) { ThrowIfCancellationRequested(); var style = GetContainerStyle(); - float horizontalPadding = (float)(style.Padding.Left + style.Padding.Right); + var padding = GetEffectivePadding(style); + var margin = GetEffectiveMargin(style); + Margin = margin; + float horizontalPadding = (float)(padding.Left + padding.Right); float layoutWidth = Math.Max(1f, availableWidth - horizontalPadding); if (MeasureAtomncInlineRuns(layoutWidth, style.FontSize)) { @@ -198,6 +216,7 @@ public override float Measure(float availableWidth) // Enable DirectWrite color font path (Segoe UI Emoji / COLR-CPAL glyphs). _layout.Options = CanvasDrawTextOptions.EnableColorFont; ApplyRunStyles(_layout, applyColors: true); + ApplyForegroundSpans(_layout); ApplyEmbedSpacing(_layout); } catch @@ -210,8 +229,8 @@ public override float Measure(float availableWidth) } var bounds = _layout!.LayoutBounds; - float top = (float)(style.Margin.Top + style.Padding.Top); - float bottom = (float)(style.Margin.Bottom + style.Padding.Bottom); + float top = (float)(margin.Top + padding.Top); + float bottom = (float)(margin.Bottom + padding.Bottom); float height = (float)bounds.Height + top + bottom; Bounds = new Rect(0, 0, availableWidth, height); return height; @@ -227,6 +246,12 @@ private ElementStyle GetRunStyle(InlineRun run) return _context.ThemeSnapshot.GetStyle(key, _styleContextKeys, aliases); } + private Thickness GetEffectivePadding(ElementStyle style) + => UseContainerPadding ? style.Padding : default; + + private Thickness GetEffectiveMargin(ElementStyle style) + => UseContainerMargin ? style.Margin : Margin; + internal override void ThrowIfCancellationRequested() => _context.CancellationToken.ThrowIfCancellationRequested(); @@ -268,8 +293,10 @@ private IReadOnlyList GetRunAliases(InlineRun run) // visually amplified on large heading glyphs. float scale = (float)_context.RasterizationScale; if (scale <= 0f) scale = 1f; - float xDnu = (float)(Bounds.X + style.Margin.Left + style.Padding.Left); - float yDnu = (float)(Bounds.Y + style.Margin.Top + style.Padding.Top); + var margin = GetEffectiveMargin(style); + var padding = GetEffectivePadding(style); + float xDnu = (float)(Bounds.X + margin.Left + padding.Left); + float yDnu = (float)(Bounds.Y + margin.Top + padding.Top); float x = MathF.Round(xDnu * scale) / scale; float y = MathF.Round(yDnu * scale) / scale; return (x, y); @@ -280,23 +307,25 @@ public override void Paint(CanvasDrawingSession ds, Rect viewport) if (_layout is null) return; var style = GetContainerStyle(); - if (style.Background is { } bg) + if (DrawContainerChrome && style.Background is { } bg) { + var margin = GetEffectiveMargin(style); var rect = new Rect( - Bounds.X + style.Margin.Left, - Bounds.Y + style.Margin.Top, - Bounds.Width - style.Margin.Left - style.Margin.Right, - Bounds.Height - style.Margin.Top - style.Margin.Bottom); + Bounds.X + margin.Left, + Bounds.Y + margin.Top, + Bounds.Width - margin.Left - margin.Right, + Bounds.Height - margin.Top - margin.Bottom); ds.FillRoundedRectangle(rect, style.CornerRadius, style.CornerRadius, bg); } - if (style.BorderBrush is { } border && style.BorderThickness > 0) + if (DrawContainerChrome && style.BorderBrush is { } border && style.BorderThickness > 0) { + var margin = GetEffectiveMargin(style); var rect = new Rect( - Bounds.X + style.Margin.Left, - Bounds.Y + style.Margin.Top, - Bounds.Width - style.Margin.Left - style.Margin.Right, - Bounds.Height - style.Margin.Top - style.Margin.Bottom); + Bounds.X + margin.Left, + Bounds.Y + margin.Top, + Bounds.Width - margin.Left - margin.Right, + Bounds.Height - margin.Top - margin.Bottom); float nnset = style.BorderThickness / 2f; ds.DrawRoundedRectangle( new Rect( @@ -404,6 +433,75 @@ public IEnumerable GetRangeRects(DocumentRange range) } } + internal IEnumerable GetBufferRangeRects(int from, int length) + { + if (_layout is null) + yield break; + + EnsureBuffer(); + if (_buffer.Length == 0) + yield break; + + from = Math.Clamp(from, 0, _buffer.Length); + length = Math.Clamp(length, 0, _buffer.Length - from); + if (length <= 0) + length = from < _buffer.Length ? 1 : 0; + if (length <= 0) + yield break; + + var style = GetContainerStyle(); + var (baseX, baseY) = GetSnappedOrigin(style); + var regions = _layout.GetCharacterRegions(from, length); + if (regions is null) + yield break; + + foreach (var r in regions) + { + yield return new Rect( + baseX + r.LayoutBounds.X, + baseY + r.LayoutBounds.Y, + r.LayoutBounds.Width, + r.LayoutBounds.Height); + } + } + + internal IEnumerable GetBufferRangeLineRects(int from, int length) + { + if (_layout is null) + yield break; + + EnsureBuffer(); + from = Math.Clamp(from, 0, _buffer.Length); + length = Math.Clamp(length, 0, _buffer.Length - from); + if (length <= 0) + length = from < _buffer.Length ? 1 : 0; + if (length <= 0) + yield break; + + var style = GetContainerStyle(); + var (baseX, baseY) = GetSnappedOrigin(style); + int rangeEnd = from + length; + int lineStart = 0; + double y = 0; + foreach (var metric in _layout.LineMetrics) + { + int charCount = Math.Max(0, metric.CharacterCount); + int lineEnd = lineStart + charCount; + bool intersects = lineEnd > from && lineStart < rangeEnd; + if (intersects) + { + yield return new Rect( + baseX, + baseY + y, + Math.Max(1, _layout.LayoutBounds.Width), + Math.Max(1, metric.Height)); + } + + y += metric.Height; + lineStart = lineEnd; + } + } + public override IEnumerable GetSelectionRects(DocumentRange range) => GetRangeRects(range); @@ -621,6 +719,72 @@ public Rect GetRunRect(int inlineIndex) return null; } + internal bool TryGetRunBounds(InlineRun run, Point preferredPoint, out Rect bounds) + { + bounds = default; + if (_layout is null) + return false; + + EnsureBuffer(); + + int cumulative = 0; + foreach (var candidate in _runs) + { + int len = candidate.Text.Length; + if (!ReferenceEquals(candidate, run)) + { + cumulative += len; + continue; + } + + if (len <= 0) + return false; + + var regions = _layout.GetCharacterRegions(cumulative, len); + if (regions is null || regions.Length == 0) + return false; + + var style = GetContainerStyle(); + var (baseX, baseY) = GetSnappedOrigin(style); + Rect? first = null; + Rect? closest = null; + double closestDistance = double.MaxValue; + + foreach (var region in regions) + { + var lb = region.LayoutBounds; + var rect = new Rect( + baseX + lb.X, + baseY + lb.Y, + lb.Width, + lb.Height); + + first ??= rect; + if (rect.Contains(preferredPoint)) + { + bounds = rect; + return true; + } + + double centerX = rect.X + rect.Width / 2.0; + double centerY = rect.Y + rect.Height / 2.0; + double dx = centerX - preferredPoint.X; + double dy = centerY - preferredPoint.Y; + double distance = dx * dx + dy * dy; + if (distance < closestDistance) + { + closestDistance = distance; + closest = rect; + } + } + + bounds = closest ?? first.GetValueOrDefault(); + return bounds.Width > 0 && bounds.Height > 0; + } + + return false; + } + private int ToBufferIndex(DocumentPosition pos) { if (pos.BlockIndex < BlockIndex) return 0; @@ -684,6 +848,21 @@ private void ApplyRunStyles(CanvasTextLayout layout, bool applyColors) } } + private void ApplyForegroundSpans(CanvasTextLayout layout) + { + if (_foregroundSpans.Count == 0) + return; + + EnsureBuffer(); + foreach (var span in _foregroundSpans) + { + int start = Math.Clamp(span.Start, 0, _buffer.Length); + int length = Math.Clamp(span.Length, 0, _buffer.Length - start); + if (length > 0) + layout.SetColor(start, length, span.Foreground); + } + } + private static void ApplyBaselineStyle(CanvasTextLayout layout, int start, int length, InlineRun run) { if (length <= 0) @@ -742,7 +921,8 @@ private CanvasTextLayout EnsureSelectionLayout(Color color, ElementStyle style) : CanvasTextDirection.LeftToRightThenTopToBottom, HorizontalAlignment = TextAlignment, }; - float horizontalPadding = (float)(style.Padding.Left + style.Padding.Right); + var padding = GetEffectivePadding(style); + float horizontalPadding = (float)(padding.Left + padding.Right); _selectionLayout = new CanvasTextLayout( _context.ResourceCreator, _buffer, @@ -956,8 +1136,11 @@ private static void DrawRunDecorations( if (style.Underline) { - ds.DrawLine((float)(baseX + lb.X), underlineY, (float)(baseX + lb.X + lb.Width), - underlineY, color, 1.0f); + if (run is AbbreviationRun) + DrawDottedUnderline(ds, (float)(baseX + lb.X), (float)(baseX + lb.X + lb.Width), underlineY, style.AccentBar ?? color); + else + ds.DrawLine((float)(baseX + lb.X), underlineY, (float)(baseX + lb.X + lb.Width), + underlineY, color, 1.0f); } if (style.Strikethrough) @@ -967,6 +1150,17 @@ private static void DrawRunDecorations( } } + private static void DrawDottedUnderline(CanvasDrawingSession ds, float startX, float endX, float y, Color color) + { + const float dot = 1.25f; + const float gap = 2.25f; + for (float x = startX; x < endX; x += dot + gap) + { + float x2 = MathF.Min(x + dot, endX); + ds.DrawLine(x, y, x2, y, color, 1.0f); + } + } + private void DrawRunBackgrounds(CanvasDrawingSession ds, float baseX, float baseY) { if (_layout is null) return; diff --git a/MarkdownRenderer/MarkdownRenderer/Layout/Boxes/TableBox.cs b/MarkdownRenderer/MarkdownRenderer/Layout/Boxes/TableBox.cs index 87ec3b7..32afd5f 100644 --- a/MarkdownRenderer/MarkdownRenderer/Layout/Boxes/TableBox.cs +++ b/MarkdownRenderer/MarkdownRenderer/Layout/Boxes/TableBox.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Microsoft.Graphics.Canvas; +using Microsoft.Graphics.Canvas.Geometry; using Microsoft.UI.Xaml; using Windows.Foundation; using Windows.UI; @@ -34,9 +35,6 @@ internal enum CellAlignment private float[]? _colWidths; private float[]? _rowHeights; // header rows first, then body rows - private const float CellPadH = 8f; - private const float CellPadV = 6f; - public TableBox( MarkdownLayoutContext context, InlineContainerBox[][] headerCells, @@ -47,7 +45,15 @@ public TableBox( _headerCells = headerCells ?? Array.Empty(); _bodyCells = bodyCells ?? Array.Empty(); _columnAlignments = columnAlignments is null ? Array.Empty() : [.. columnAlignments]; - Margin = new Thickness(0, 6, 0, 6); + Margin = GetTableStyle().Margin; + + foreach (var cell in GetCellBoxes()) + { + cell.DrawContainerChrome = false; + cell.UseContainerPadding = false; + cell.UseContainerMargin = false; + cell.Margin = default; + } if (_headerCells.Length > 0 && _headerCells[0].Length > 0) _colCount = _headerCells[0].Length; @@ -89,13 +95,21 @@ public override float Measure(float availableWidth) ThrowIfCancellationRequested(); if (_colCount == 0) { Bounds = new Rect(0, 0, availableWidth, 0); return 0; } + var tableStyle = GetTableStyle(); + var headerStyle = GetHeaderStyle(); + var bodyStyle = GetBodyStyle(); + var headerPadding = EffectiveCellPadding(headerStyle); + var bodyPadding = EffectiveCellPadding(bodyStyle); + Margin = tableStyle.Margin; + float innerWidth = availableWidth - (float)(Margin.Left + Margin.Right); float colWidth = Math.Max(1f, innerWidth / _colCount); _colWidths = new float[_colCount]; for (int i = 0; i < _colCount; i++) _colWidths[i] = colWidth; - float cellMeasureWidth = Math.Max(1f, colWidth - CellPadH * 2); + float headerCellMeasureWidth = Math.Max(1f, colWidth - (float)(headerPadding.Left + headerPadding.Right)); + float bodyCellMeasureWidth = Math.Max(1f, colWidth - (float)(bodyPadding.Left + bodyPadding.Right)); int totalRows = _headerCells.Length + _bodyCells.Length; _rowHeights = new float[totalRows]; @@ -107,9 +121,9 @@ public override float Measure(float availableWidth) { _context.CancellationToken.ThrowIfCancellationRequested(); _headerCells[r][c].TextAlignment = ToCanvasAlignment(GetColumnAlignment(c), _context.FlowDirection == FlowDirection.RightToLeft); - maxH = Math.Max(maxH, _headerCells[r][c].Measure(cellMeasureWidth)); + maxH = Math.Max(maxH, _headerCells[r][c].Measure(headerCellMeasureWidth)); } - _rowHeights[r] = maxH + CellPadV * 2; + _rowHeights[r] = maxH + (float)(headerPadding.Top + headerPadding.Bottom); } for (int r = 0; r < _bodyCells.Length; r++) { @@ -119,9 +133,9 @@ public override float Measure(float availableWidth) { _context.CancellationToken.ThrowIfCancellationRequested(); _bodyCells[r][c].TextAlignment = ToCanvasAlignment(GetColumnAlignment(c), _context.FlowDirection == FlowDirection.RightToLeft); - maxH = Math.Max(maxH, _bodyCells[r][c].Measure(cellMeasureWidth)); + maxH = Math.Max(maxH, _bodyCells[r][c].Measure(bodyCellMeasureWidth)); } - _rowHeights[_headerCells.Length + r] = maxH + CellPadV * 2; + _rowHeights[_headerCells.Length + r] = maxH + (float)(bodyPadding.Top + bodyPadding.Bottom); } float totalHeight = (float)(Margin.Top + Margin.Bottom); @@ -136,6 +150,8 @@ public override void Arrange(float x, float y, float width) base.Arrange(x, y, width); if (_colWidths is null || _rowHeights is null) return; + var headerPadding = EffectiveCellPadding(GetHeaderStyle()); + var bodyPadding = EffectiveCellPadding(GetBodyStyle()); float colWidth = _colWidths[0]; float rowY = y + (float)Margin.Top; bool rtl = _context.FlowDirection == FlowDirection.RightToLeft; @@ -156,7 +172,10 @@ public override void Arrange(float x, float y, float width) ? x + (float)Margin.Left + innerW - (nCols - visCol) * colWidth : x + (float)Margin.Left + visCol * colWidth; _headerCells[r][c].TextAlignment = ToCanvasAlignment(GetColumnAlignment(c), rtl); - _headerCells[r][c].Arrange(colX + CellPadH, rowY + CellPadV, colWidth - CellPadH * 2); + _headerCells[r][c].Arrange( + colX + (float)headerPadding.Left, + rowY + (float)headerPadding.Top, + Math.Max(1f, colWidth - (float)(headerPadding.Left + headerPadding.Right))); } rowY += rh; } @@ -171,7 +190,10 @@ public override void Arrange(float x, float y, float width) ? x + (float)Margin.Left + innerW - (nCols - visCol) * colWidth : x + (float)Margin.Left + visCol * colWidth; _bodyCells[r][c].TextAlignment = ToCanvasAlignment(GetColumnAlignment(c), rtl); - _bodyCells[r][c].Arrange(colX + CellPadH, rowY + CellPadV, colWidth - CellPadH * 2); + _bodyCells[r][c].Arrange( + colX + (float)bodyPadding.Left, + rowY + (float)bodyPadding.Top, + Math.Max(1f, colWidth - (float)(bodyPadding.Left + bodyPadding.Right))); } rowY += rh; } @@ -181,21 +203,38 @@ public override void Paint(CanvasDrawingSession ds, Rect viewport) { if (_colWidths is null || _rowHeights is null) return; - var headerStyle = _context.ThemeSnapshot.GetStyle(MarkdownElementKeys.TableHeader); - var bodyStyle = _context.ThemeSnapshot.GetStyle(MarkdownElementKeys.TableCell); - var headerBgColor = headerStyle.Background - ?? _context.ThemeSnapshot.GetStyle(MarkdownElementKeys.CodeBlock).Background; + var tableStyle = GetTableStyle(); + var headerStyle = GetHeaderStyle(); + var bodyStyle = GetBodyStyle(); + var bodyBgColor = bodyStyle.Background ?? tableStyle.Background ?? _context.ThemeSnapshot.SurfaceColor; + var headerBgColor = headerStyle.Background ?? bodyBgColor; + var borderColor = tableStyle.BorderBrush + ?? bodyStyle.BorderBrush + ?? headerStyle.BorderBrush + ?? WithAlpha(bodyStyle.Foreground, _context.ThemeSnapshot.IsHighContrast ? (byte)0xFF : (byte)0x30); + float borderThickness = tableStyle.BorderThickness > 0 ? tableStyle.BorderThickness : 1f; + float radius = Math.Max(0, tableStyle.CornerRadius); float startX = (float)(Bounds.X + Margin.Left); float innerWidth = (float)(Bounds.Width - Margin.Left - Margin.Right); float colWidth = _colWidths[0]; float headerStartY = (float)(Bounds.Y + Margin.Top); + float tableH = (float)(Bounds.Height - Margin.Top - Margin.Bottom); + var tableRect = new Rect(startX, headerStartY, innerWidth, tableH); - // Full-width header row background (uses code-block bg as a subtle tint). float headerTotalH = 0; for (int i = 0; i < _headerCells.Length; i++) headerTotalH += _rowHeights[i]; - if (_headerCells.Length > 0 && headerBgColor is { } hBg) - ds.FillRectangle(new Rect(startX, headerStartY, innerWidth, headerTotalH), hBg); + + if (radius > 0) + { + using var clip = CanvasGeometry.CreateRoundedRectangle(_context.ResourceCreator, tableRect, radius, radius); + using (ds.CreateLayer(1.0f, clip)) + PaintTableSurfaces(ds, tableRect, bodyBgColor, headerBgColor, headerTotalH); + } + else + { + PaintTableSurfaces(ds, tableRect, bodyBgColor, headerBgColor, headerTotalH); + } // Paint all cell text layouts. foreach (var cell in GetCellBoxes()) @@ -205,7 +244,7 @@ public override void Paint(CanvasDrawingSession ds, Rect viewport) float sepY = headerStartY + headerTotalH; if (_headerCells.Length > 0) { - var sep = Color.FromArgb(100, bodyStyle.Foreground.R, bodyStyle.Foreground.G, bodyStyle.Foreground.B); + var sep = _context.ThemeSnapshot.IsHighContrast ? borderColor : WithAlpha(borderColor, 0xC0); ds.DrawLine(startX, sepY, startX + innerWidth, sepY, sep, 1f); } @@ -214,19 +253,59 @@ public override void Paint(CanvasDrawingSession ds, Rect viewport) for (int r = 0; r < _bodyCells.Length; r++) { rowY += _rowHeights[_headerCells.Length + r]; - var rowSep = Color.FromArgb(30, bodyStyle.Foreground.R, bodyStyle.Foreground.G, bodyStyle.Foreground.B); + var rowSep = _context.ThemeSnapshot.IsHighContrast ? borderColor : WithAlpha(borderColor, 0x70); ds.DrawLine(startX, rowY, startX + innerWidth, rowY, rowSep, 0.5f); } // Column separators. - float tableH = (float)(Bounds.Height - Margin.Top - Margin.Bottom); - var colSep = Color.FromArgb(30, bodyStyle.Foreground.R, bodyStyle.Foreground.G, bodyStyle.Foreground.B); + var colSep = _context.ThemeSnapshot.IsHighContrast ? borderColor : WithAlpha(borderColor, 0x40); float colSepX = startX; for (int c = 1; c < _colCount; c++) { colSepX += colWidth; ds.DrawLine(colSepX, headerStartY, colSepX, headerStartY + tableH, colSep, 0.5f); } + + if (borderThickness > 0) + { + float inset = borderThickness / 2f; + ds.DrawRoundedRectangle( + new Rect( + tableRect.X + inset, + tableRect.Y + inset, + Math.Max(0, tableRect.Width - borderThickness), + Math.Max(0, tableRect.Height - borderThickness)), + radius, + radius, + borderColor, + borderThickness); + } + } + + private void PaintTableSurfaces( + CanvasDrawingSession ds, + Rect tableRect, + Color bodyBgColor, + Color headerBgColor, + float headerTotalH) + { + ds.FillRectangle(tableRect, bodyBgColor); + + if (_headerCells.Length > 0 && headerTotalH > 0) + ds.FillRectangle(new Rect(tableRect.X, tableRect.Y, tableRect.Width, headerTotalH), headerBgColor); + + if (_context.ThemeSnapshot.IsHighContrast) + return; + + float rowY = (float)(tableRect.Y + headerTotalH); + var stripe = WithAlpha(GetBodyStyle().Foreground, 0x08); + for (int r = 0; r < _bodyCells.Length; r++) + { + float rowH = _rowHeights![_headerCells.Length + r]; + if (r % 2 == 1) + ds.FillRectangle(new Rect(tableRect.X, rowY, tableRect.Width, rowH), stripe); + rowY += rowH; + } } public override void PaintSelectionForeground( @@ -325,6 +404,27 @@ private static double Clamp(double value, double min, double max) internal override void ThrowIfCancellationRequested() => _context.CancellationToken.ThrowIfCancellationRequested(); + private ElementStyle GetTableStyle() + => _context.ThemeSnapshot.GetStyle(MarkdownElementKeys.Table); + + private ElementStyle GetHeaderStyle() + => _context.ThemeSnapshot.GetStyle(MarkdownElementKeys.TableHeader); + + private ElementStyle GetBodyStyle() + => _context.ThemeSnapshot.GetStyle(MarkdownElementKeys.TableCell); + + private static Thickness EffectiveCellPadding(ElementStyle style) + => IsZero(style.Padding) ? new Thickness(12, 9, 12, 9) : style.Padding; + + private static bool IsZero(Thickness thickness) + => thickness.Left == 0 && + thickness.Top == 0 && + thickness.Right == 0 && + thickness.Bottom == 0; + + private static Color WithAlpha(Color color, byte alpha) + => Color.FromArgb(alpha, color.R, color.G, color.B); + private CellAlignment GetColumnAlignment(int column) => column >= 0 && column < _columnAlignments.Length ? _columnAlignments[column] diff --git a/MarkdownRenderer/MarkdownRenderer/Layout/CodeBlockMetadata.cs b/MarkdownRenderer/MarkdownRenderer/Layout/CodeBlockMetadata.cs new file mode 100644 index 0000000..255c800 --- /dev/null +++ b/MarkdownRenderer/MarkdownRenderer/Layout/CodeBlockMetadata.cs @@ -0,0 +1,374 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Markdig.Syntax; + +namespace MarkdownRenderer.Layout; + +internal sealed class CodeBlockMetadata +{ + public const string PlainCodeLabel = "Code"; + + private static readonly char[] Whitespace = [' ', '\t', '\r', '\n']; + + private CodeBlockMetadata( + string? language, + string displayLanguage, + string? title, + string? fileName, + CodeLineRangeSet highlightedLines, + bool? showLineNumbers, + int startLine, + bool isDiff, + string stableKey) + { + Language = language; + LanguageDisplay = displayLanguage; + Title = title; + FileName = fileName; + HighlightedLines = highlightedLines; + ShowLineNumbers = showLineNumbers; + StartLine = startLine; + IsDiff = isDiff; + StableKey = stableKey; + } + + public string? Language { get; } + public string LanguageDisplay { get; } + public string? Title { get; } + public string? FileName { get; } + public string HeaderText => !string.IsNullOrWhiteSpace(Title) + ? Title! + : !string.IsNullOrWhiteSpace(FileName) + ? FileName! + : LanguageDisplay; + public CodeLineRangeSet HighlightedLines { get; } + public bool? ShowLineNumbers { get; } + public int StartLine { get; } + public bool IsDiff { get; } + public string StableKey { get; } + + public static CodeBlockMetadata FromBlock(LeafBlock block, string displayedCodeText) + { + string info = string.Empty; + if (block is FencedCodeBlock fenced) + { + var languageInfo = fenced.Info?.ToString() ?? string.Empty; + var arguments = fenced.Arguments?.ToString() ?? string.Empty; + info = string.IsNullOrWhiteSpace(arguments) + ? languageInfo + : string.IsNullOrWhiteSpace(languageInfo) + ? arguments + : languageInfo + " " + arguments; + } + + string? language = null; + string? title = null; + string? fileName = null; + var highlightedLines = CodeLineRangeSet.Empty; + bool? showLineNumbers = null; + int startLine = 1; + bool isDiff = false; + bool languageConsumed = false; + + foreach (var token in TokenizeInfo(info)) + { + if (token.Length == 0) + continue; + + if (token.StartsWith("{", StringComparison.Ordinal) && + token.EndsWith("}", StringComparison.Ordinal) && + token.Length > 2) + { + highlightedLines = CodeLineRangeSet.Parse(token.Substring(1, token.Length - 2)); + continue; + } + + if (token.Equals("showLineNumbers", StringComparison.OrdinalIgnoreCase)) + { + showLineNumbers = true; + continue; + } + + if (token.Equals("noLineNumbers", StringComparison.OrdinalIgnoreCase)) + { + showLineNumbers = false; + continue; + } + + if (token.Equals("diff", StringComparison.OrdinalIgnoreCase)) + { + isDiff = true; + continue; + } + + var eq = token.IndexOf('='); + if (eq > 0) + { + var key = token.Substring(0, eq).Trim(); + var value = Unquote(token.Substring(eq + 1).Trim()); + switch (key.ToLowerInvariant()) + { + case "filename": + case "file": + fileName = value; + break; + case "title": + title = value; + break; + case "startline": + case "start": + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedStart)) + startLine = Math.Max(1, parsedStart); + break; + case "diff": + if (TryParseBoolean(value, out var parsedDiff)) + isDiff = parsedDiff; + break; + } + + continue; + } + + if (!languageConsumed) + { + language = NormalizeLanguage(token); + languageConsumed = true; + if (string.Equals(language, "diff", StringComparison.OrdinalIgnoreCase)) + isDiff = true; + } + } + + string stableKey = CreateStableKey(block, CopyPayload(displayedCodeText)); + return new CodeBlockMetadata( + language, + DisplayLanguage(language), + title, + fileName, + highlightedLines, + showLineNumbers, + startLine, + isDiff, + stableKey); + } + + public static string CopyPayload(string? displayedCodeText) => NormalizeCodeLineEndings(displayedCodeText); + + public static string NormalizeCodeLineEndings(string? displayedCodeText) + { + if (string.IsNullOrEmpty(displayedCodeText)) + return string.Empty; + + var value = displayedCodeText!; + if (value.IndexOf('\r') < 0) + return value; + + return value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'); + } + + public static string? NormalizeLanguage(string? language) + { + var value = language?.Trim() ?? string.Empty; + if (value.Length == 0) + return null; + + return value.ToLowerInvariant() switch + { + "csharp" or "cs" => "csharp", + "javascript" or "js" => "javascript", + "typescript" or "ts" => "typescript", + "python" or "py" => "python", + "powershell" or "pwsh" or "ps1" => "powershell", + "yaml" or "yml" => "yaml", + "markdown" or "md" => "markdown", + "bash" or "sh" or "shell" => "bash", + _ => value, + }; + } + + public static string DisplayLanguage(string? language) + { + var value = NormalizeLanguage(language) ?? string.Empty; + if (value.Length == 0) + return PlainCodeLabel; + + return value switch + { + "csharp" => "C#", + "javascript" => "JavaScript", + "typescript" => "TypeScript", + "jsx" => "JSX", + "tsx" => "TSX", + "python" => "Python", + "powershell" => "PowerShell", + "json" => "JSON", + "yaml" => "YAML", + "xml" => "XML", + "html" => "HTML", + "css" => "CSS", + "markdown" => "Markdown", + "bash" => "Shell", + "diff" => "Diff", + _ => value, + }; + } + + private static string CreateStableKey(LeafBlock block, string code) + => string.Create(CultureInfo.InvariantCulture, $"{block.Span.Start}:{block.Span.Length}:{Fnv1A(code):X16}"); + + private static ulong Fnv1A(string value) + { + const ulong offset = 14695981039346656037UL; + const ulong prime = 1099511628211UL; + ulong hash = offset; + foreach (var ch in value) + { + hash ^= ch; + hash *= prime; + } + + return hash; + } + + private static bool TryParseBoolean(string value, out bool result) + { + if (bool.TryParse(value, out result)) + return true; + if (value.Equals("1", StringComparison.OrdinalIgnoreCase) || + value.Equals("yes", StringComparison.OrdinalIgnoreCase) || + value.Equals("on", StringComparison.OrdinalIgnoreCase)) + { + result = true; + return true; + } + + if (value.Equals("0", StringComparison.OrdinalIgnoreCase) || + value.Equals("no", StringComparison.OrdinalIgnoreCase) || + value.Equals("off", StringComparison.OrdinalIgnoreCase)) + { + result = false; + return true; + } + + result = false; + return false; + } + + private static string Unquote(string value) + { + if (value.Length >= 2) + { + char first = value[0]; + char last = value[value.Length - 1]; + if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) + return value.Substring(1, value.Length - 2); + } + + return value; + } + + private static IEnumerable TokenizeInfo(string info) + { + if (string.IsNullOrWhiteSpace(info)) + yield break; + + int i = 0; + while (i < info.Length) + { + while (i < info.Length && Array.IndexOf(Whitespace, info[i]) >= 0) + i++; + if (i >= info.Length) + yield break; + + int start = i; + char quote = '\0'; + int braceDepth = 0; + while (i < info.Length) + { + char ch = info[i]; + if (quote != '\0') + { + if (ch == quote) + quote = '\0'; + i++; + continue; + } + + if (ch == '"' || ch == '\'') + { + quote = ch; + i++; + continue; + } + + if (ch == '{') + braceDepth++; + else if (ch == '}' && braceDepth > 0) + braceDepth--; + else if (braceDepth == 0 && Array.IndexOf(Whitespace, ch) >= 0) + break; + + i++; + } + + yield return info.Substring(start, i - start); + } + } +} + +internal readonly struct CodeLineRangeSet +{ + private readonly IReadOnlyList<(int Start, int End)> _ranges; + + private CodeLineRangeSet(IReadOnlyList<(int Start, int End)> ranges) + { + _ranges = ranges; + } + + public static CodeLineRangeSet Empty { get; } = new(Array.Empty<(int Start, int End)>()); + public bool IsEmpty => _ranges.Count == 0; + + public bool Contains(int lineNumber) + { + foreach (var (start, end) in _ranges) + { + if (lineNumber >= start && lineNumber <= end) + return true; + } + + return false; + } + + public static CodeLineRangeSet Parse(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return Empty; + + var ranges = new List<(int Start, int End)>(); + foreach (var rawPart in value.Split(',')) + { + var part = rawPart.Trim(); + if (part.Length == 0) + continue; + + int dash = part.IndexOf('-'); + if (dash > 0) + { + if (int.TryParse(part.Substring(0, dash), NumberStyles.Integer, CultureInfo.InvariantCulture, out var start) && + int.TryParse(part.Substring(dash + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out var end)) + { + start = Math.Max(1, start); + end = Math.Max(start, end); + ranges.Add((start, end)); + } + } + else if (int.TryParse(part, NumberStyles.Integer, CultureInfo.InvariantCulture, out var line)) + { + line = Math.Max(1, line); + ranges.Add((line, line)); + } + } + + return ranges.Count == 0 ? Empty : new CodeLineRangeSet(ranges); + } +} diff --git a/MarkdownRenderer/MarkdownRenderer/Layout/FocusableItem.cs b/MarkdownRenderer/MarkdownRenderer/Layout/FocusableItem.cs index 52f1b14..c4044f4 100644 --- a/MarkdownRenderer/MarkdownRenderer/Layout/FocusableItem.cs +++ b/MarkdownRenderer/MarkdownRenderer/Layout/FocusableItem.cs @@ -6,6 +6,7 @@ internal enum FocusableItemKind Link, InlineEmbed, BlockEmbed, + CodeBlockCopy, } /// Represents a keyboard-focusable element in the document. @@ -23,4 +24,6 @@ public FocusableItem(int blockIndex, int inlineIndex, FocusableItemKind kind) => public bool IsLink => Kind == FocusableItemKind.Link; public bool IsInlineEmbed => Kind == FocusableItemKind.InlineEmbed; public bool IsBlockEmbed => Kind == FocusableItemKind.BlockEmbed; + public bool IsCodeBlockCopy => Kind == FocusableItemKind.CodeBlockCopy; + public bool IsCodeBlockAction => IsCodeBlockCopy; } diff --git a/MarkdownRenderer/MarkdownRenderer/Layout/LayoutBuilder.cs b/MarkdownRenderer/MarkdownRenderer/Layout/LayoutBuilder.cs index c54c10b..d043948 100644 --- a/MarkdownRenderer/MarkdownRenderer/Layout/LayoutBuilder.cs +++ b/MarkdownRenderer/MarkdownRenderer/Layout/LayoutBuilder.cs @@ -5,6 +5,7 @@ using Markdig.Syntax.Inlines; using Markdig.Extensions.Abbreviations; using Markdig.Extensions.Footnotes; +using MarkdownRenderer.CodeBlocks; using MarkdownRenderer.Hosting; using MarkdownRenderer.Layout.Boxes; using MarkdownRenderer.Parsing; @@ -187,6 +188,8 @@ private InlineContainerBox BuildHeading(HeadingBlock h) var box = new InlineContainerBox(_context, key); box.BlockIndex = _context.NextBlockIndex(); AddInlines(box, h.Inline); + if (h.Span.Start >= 0 && h.Span.Length > 0) + _context.SourceMap.AddSourceAffixesToBlock(box.BlockIndex, h.Span.Start, h.Span.Start + h.Span.Length); return box; } @@ -200,14 +203,30 @@ private InlineContainerBox BuildParagraph(ParagraphBlock u) private BlockBox BuildCodeBlock(LeafBlock block, string text) { - if (text.Length <= MaxMonolithicTextLayoutLength) - return BuildCodeBlockChunk(block, text, 0, text.Length); - - var stack = new StackBox + text = CodeBlockMetadata.NormalizeCodeLineEndings(text); + var metadata = CodeBlockMetadata.FromBlock(block, text); + int lineCount = CountLogicalLines(text); + bool showLineNumbers = metadata.ShowLineNumbers ?? _context.CodeBlockLineNumberMode switch { - FlowDirection = _context.FlowDirection, + CodeBlockLineNumberMode.Always => true, + CodeBlockLineNumberMode.Never => false, + _ => lineCount > 1, + }; + var codeBox = new CodeBlockBox( + _context, + metadata, + text, + _context.IsCodeBlockCopyEnabled, + showLineNumbers) + { + BlockIndex = _context.NextBlockIndex(), }; - stack.BlockIndex = _context.NextBlockIndex(); + + if (text.Length <= MaxMonolithicTextLayoutLength) + { + codeBox.AddChunk(BuildCodeBlockChunk(block, metadata, text, 0, text.Length)); + return codeBox; + } int offset = 0; while (offset < text.Length) @@ -221,18 +240,25 @@ private BlockBox BuildCodeBlock(LeafBlock block, string text) length = newline - offset + 1; } - stack.Add(BuildCodeBlockChunk(block, text.Substring(offset, length), offset, text.Length)); + codeBox.AddChunk(BuildCodeBlockChunk(block, metadata, text.Substring(offset, length), offset, text.Length)); offset += length; } - return stack; + return codeBox; } - private InlineContainerBox BuildCodeBlockChunk(LeafBlock block, string text, int textOffset, int totalTextLength) + private InlineContainerBox BuildCodeBlockChunk( + LeafBlock block, + CodeBlockMetadata metadata, + string text, + int textOffset, + int totalTextLength) { var box = new InlineContainerBox(_context, MarkdownElementKeys.CodeBlock) { - CodeLanguage = NormalizeCodeLanguage(block) + CodeLanguage = metadata.Language, + CodeBlockTextOffset = textOffset, + CodeBlockTextLength = text.Length, }; box.BlockIndex = _context.NextBlockIndex(); // No ElementKey on the run: it inherits the container's CodeBlock style. @@ -246,6 +272,21 @@ private InlineContainerBox BuildCodeBlockChunk(LeafBlock block, string text, int return box; } + private static int CountLogicalLines(string text) + { + if (string.IsNullOrEmpty(text)) + return 1; + + int lines = 1; + foreach (char ch in text) + { + if (ch == '\n') + lines++; + } + + return text.EndsWith("\n", StringComparison.Ordinal) && lines > 1 ? lines - 1 : lines; + } + private static SourceSpan SliceSourceSpan(LeafBlock block, int textOffset, int textLength, int totalTextLength) { if (block.Span.Start < 0 || block.Span.Length <= 0 || totalTextLength <= 0) @@ -257,15 +298,6 @@ private static SourceSpan SliceSourceSpan(LeafBlock block, int textOffset, int t return new SourceSpan(start, Math.Max(0, end - start)); } - private static string? NormalizeCodeLanguage(LeafBlock block) - { - if (block is not FencedCodeBlock fenced) return null; - var text = fenced.Info?.ToString().Trim() ?? string.Empty; - if (text.Length == 0) return null; - var firstSpace = text.IndexOfAny(new[] { ' ', '\t', '\r', '\n' }); - return firstSpace > 0 ? text.Substring(0, firstSpace) : text; - } - private StackBox BuildQuote(QuoteBlock qb) { var style = _context.ThemeSnapshot.GetStyle( @@ -386,7 +418,7 @@ private StackBox BuildGenericContainer(ContainerBlock cb) return stack; } - private void AddInlines(InlineContainerBox box, ContainerInline? inline) + private void AddInlines(InlineContainerBox box, ContainerInline? inline, int inheritedAliasStart = -1) { if (inline is null) return; foreach (var n in inline) @@ -397,10 +429,17 @@ private void AddInlines(InlineContainerBox box, ContainerInline? inline) var run = BuildInline(n, box.BlockIndex); if (run is not null) { - run.StyleAliases = _context.CreateStyleAliasSnapshotFrom(aliasStart); + int effectiveAliasStart = inheritedAliasStart >= 0 ? inheritedAliasStart : aliasStart; + run.StyleAliases = _context.CreateStyleAliasSnapshotFrom(effectiveAliasStart); _context.RegisterMarkdownAttributes(n, box.BlockIndex); box.Add(run); } + else if (n is ContainerInline nested) + { + _context.RegisterMarkdownAttributes(n, box.BlockIndex); + int effectiveAliasStart = inheritedAliasStart >= 0 ? inheritedAliasStart : aliasStart; + AddInlines(box, nested, effectiveAliasStart); + } } } @@ -457,12 +496,6 @@ private void AddInlines(InlineContainerBox box, ContainerInline? inline) if (parentBlockIndex >= 0) _context.RegisterFootnoteRef(order, parentBlockIndex); return run; } - case ContainerInline cn2: - { - var sb = new System.Text.StringBuilder(); - FlattenContainer(cn2, sb); - return new TextRun(sb.ToString()) { SourceSpan = new SourceSpan(cn2.Span.Start, cn2.Span.Length) }; - } } return null; } diff --git a/MarkdownRenderer/MarkdownRenderer/Layout/LayoutSnapshot.cs b/MarkdownRenderer/MarkdownRenderer/Layout/LayoutSnapshot.cs index 28ad3a4..b0186dd 100644 --- a/MarkdownRenderer/MarkdownRenderer/Layout/LayoutSnapshot.cs +++ b/MarkdownRenderer/MarkdownRenderer/Layout/LayoutSnapshot.cs @@ -226,6 +226,7 @@ private static float EstimateHeight(BlockBox block) { ImageBox => Math.Max(160f, margin + 120f), EmbedBox => Math.Max(64f, margin + 48f), + CodeBlockBox => Math.Max(72f, margin + 64f), TableBox table => Math.Clamp(36f + table.RowCount * 34f + margin, 72f, 360f), ListItemBox => Math.Max(36f, margin + 32f), StackBox stack => Math.Clamp(32f + stack.Children.Count * 28f + margin, 48f, 420f), @@ -329,6 +330,10 @@ private static void WalkForFocusable(BlockBox box, List list) case EmbedBox eb: list.Add(new FocusableItem(eb.BlockIndex, 0, FocusableItemKind.BlockEmbed)); break; + case CodeBlockBox cb: + if (cb.IsCopyButtonEnabled) + list.Add(new FocusableItem(cb.BlockIndex, 0, FocusableItemKind.CodeBlockCopy)); + break; case ListItemBox lnb: WalkForFocusable(lnb.Marker, list); WalkForFocusable(lnb.Content, list); diff --git a/MarkdownRenderer/MarkdownRenderer/Layout/MarkdownLayoutContext.cs b/MarkdownRenderer/MarkdownRenderer/Layout/MarkdownLayoutContext.cs index 53fd4d7..03d67de 100644 --- a/MarkdownRenderer/MarkdownRenderer/Layout/MarkdownLayoutContext.cs +++ b/MarkdownRenderer/MarkdownRenderer/Layout/MarkdownLayoutContext.cs @@ -10,6 +10,7 @@ using Markdig.Renderers.Html; using Markdig.Syntax; using MarkdownRenderer.Document; +using MarkdownRenderer.CodeBlocks; using MarkdownRenderer.Parsing; using MarkdownRenderer.Theming; @@ -73,6 +74,12 @@ public MarkdownLayoutContext( /// Cancellation token for the current background layout pass. public CancellationToken CancellationToken { get; init; } + /// True when code block copy buttons should be included in layout metadata. + public bool IsCodeBlockCopyEnabled { get; init; } = true; + + /// Line-number policy for code blocks. + public CodeBlockLineNumberMode CodeBlockLineNumberMode { get; init; } = CodeBlockLineNumberMode.AutoMultiline; + /// Returns the next one-based block index for a custom block. public int NextBlockIndex() => ++_blockIndex; private int _blockIndex; diff --git a/MarkdownRenderer/MarkdownRenderer/Properties/Resources.resx b/MarkdownRenderer/MarkdownRenderer/Properties/Resources.resx index cdd4558..204b74f 100644 --- a/MarkdownRenderer/MarkdownRenderer/Properties/Resources.resx +++ b/MarkdownRenderer/MarkdownRenderer/Properties/Resources.resx @@ -75,6 +75,9 @@ List marker + + Table + Table header @@ -87,4 +90,13 @@ Select All + + Copy + + + Copied + + + Copy code + diff --git a/MarkdownRenderer/MarkdownRenderer/Theming/ElementStyle.cs b/MarkdownRenderer/MarkdownRenderer/Theming/ElementStyle.cs index 4945de5..966db5b 100644 --- a/MarkdownRenderer/MarkdownRenderer/Theming/ElementStyle.cs +++ b/MarkdownRenderer/MarkdownRenderer/Theming/ElementStyle.cs @@ -30,6 +30,14 @@ public static class MarkdownElementKeys public const string CodeInline = "CodeInline"; /// Fenced or indented code block text. public const string CodeBlock = "CodeBlock"; + /// Fenced or indented code block header surface. + public const string CodeBlockHeader = "CodeBlockHeader"; + /// Fenced or indented code block language label. + public const string CodeBlockLanguage = "CodeBlockLanguage"; + /// Fenced or indented code block gutter surface. + public const string CodeBlockGutter = "CodeBlockGutter"; + /// Fenced or indented code block line-number text. + public const string CodeBlockLineNumber = "CodeBlockLineNumber"; /// Block quote container. public const string Quote = "Quote"; /// Inline link text. @@ -68,6 +76,8 @@ public static class MarkdownElementKeys public const string ImageCaption = "ImageCaption"; // GFM extension keys + /// GitHub-flavored markdown table container. + public const string Table = "Table"; /// GitHub-flavored markdown table header cell. public const string TableHeader = "TableHeader"; /// GitHub-flavored markdown table body cell. diff --git a/MarkdownRenderer/MarkdownRenderer/Theming/MarkdownHighContrastDefaults.cs b/MarkdownRenderer/MarkdownRenderer/Theming/MarkdownHighContrastDefaults.cs index 23a7056..9a08d49 100644 --- a/MarkdownRenderer/MarkdownRenderer/Theming/MarkdownHighContrastDefaults.cs +++ b/MarkdownRenderer/MarkdownRenderer/Theming/MarkdownHighContrastDefaults.cs @@ -29,6 +29,19 @@ internal static class MarkdownHighContrastDefaults MarkdownHighContrastColorRole.Window, MarkdownHighContrastColorRole.WindowText), + "CodeBlockHeader" => new( + MarkdownHighContrastColorRole.WindowText, + MarkdownHighContrastColorRole.Window, + MarkdownHighContrastColorRole.WindowText), + + "CodeBlockLanguage" or "CodeBlockLineNumber" => new( + MarkdownHighContrastColorRole.WindowText), + + "CodeBlockGutter" => new( + MarkdownHighContrastColorRole.WindowText, + MarkdownHighContrastColorRole.Window, + MarkdownHighContrastColorRole.WindowText), + "CodeInline" => new( MarkdownHighContrastColorRole.WindowText, MarkdownHighContrastColorRole.Window), @@ -41,14 +54,24 @@ internal static class MarkdownHighContrastDefaults MarkdownHighContrastColorRole.WindowText, Strikethrough: true), - "Inserted" or "Abbreviation" => new( + "Inserted" => new( + MarkdownHighContrastColorRole.WindowText, + Underline: true), + + "Abbreviation" => new( MarkdownHighContrastColorRole.WindowText, + AccentBar: MarkdownHighContrastColorRole.WindowText, Underline: true), "Marked" or "DefinitionDescription" or "Figure" or "FigureCaption" or "Diagram" => new( MarkdownHighContrastColorRole.WindowText, MarkdownHighContrastColorRole.Window), + "Table" => new( + MarkdownHighContrastColorRole.WindowText, + MarkdownHighContrastColorRole.Window, + MarkdownHighContrastColorRole.WindowText), + "TableHeader" => new( MarkdownHighContrastColorRole.HighlightText, MarkdownHighContrastColorRole.Highlight), diff --git a/MarkdownRenderer/MarkdownRenderer/Theming/ThemeResolver.cs b/MarkdownRenderer/MarkdownRenderer/Theming/ThemeResolver.cs index 836445d..c466a7e 100644 --- a/MarkdownRenderer/MarkdownRenderer/Theming/ThemeResolver.cs +++ b/MarkdownRenderer/MarkdownRenderer/Theming/ThemeResolver.cs @@ -52,7 +52,9 @@ public ThemeSnapshot CreateSnapshot() MarkdownElementKeys.Heading3, MarkdownElementKeys.Heading4, MarkdownElementKeys.Heading5, MarkdownElementKeys.Heading6, MarkdownElementKeys.Body, MarkdownElementKeys.CodeInline, - MarkdownElementKeys.CodeBlock,MarkdownElementKeys.Quote, + MarkdownElementKeys.CodeBlock, MarkdownElementKeys.CodeBlockHeader, + MarkdownElementKeys.CodeBlockLanguage, MarkdownElementKeys.CodeBlockGutter, + MarkdownElementKeys.CodeBlockLineNumber, MarkdownElementKeys.Quote, MarkdownElementKeys.Link, MarkdownElementKeys.Strong, MarkdownElementKeys.Emphasis, MarkdownElementKeys.Strikethrough, MarkdownElementKeys.Subscript, MarkdownElementKeys.Superscript, @@ -62,7 +64,7 @@ public ThemeSnapshot CreateSnapshot() MarkdownElementKeys.ImageCaption, MarkdownElementKeys.Figure, MarkdownElementKeys.FigureCaption, MarkdownElementKeys.Diagram, MarkdownElementKeys.DefinitionTerm, MarkdownElementKeys.DefinitionDescription, - MarkdownElementKeys.TableHeader, MarkdownElementKeys.TableCell, + MarkdownElementKeys.Table, MarkdownElementKeys.TableHeader, MarkdownElementKeys.TableCell, MarkdownElementKeys.AlertNote, MarkdownElementKeys.AlertTip, MarkdownElementKeys.AlertImportant, MarkdownElementKeys.AlertWarning, MarkdownElementKeys.AlertCaution, @@ -94,7 +96,13 @@ private ElementStyle GetDefault(string key) // Accent: try to get the user's accent color, fall back to Win11 blue. var accent = _theme.AccentColor ?? ResolveAccentTextColor(isDark); var linkHover = AdjustColor(accent, isDark ? 0.18f : -0.12f); - var codeBg = isDark ? Color.FromArgb(0x1A, 0xFF, 0xFF, 0xFF) : Color.FromArgb(0x0F, 0x00, 0x00, 0x00); + var codeBg = isDark ? Color.FromArgb(0xFF, 0x1E, 0x1E, 0x1E) : Color.FromArgb(0xFF, 0xF6, 0xF8, 0xFA); + var codeHeaderBg = isDark ? Color.FromArgb(0xFF, 0x25, 0x25, 0x26) : Color.FromArgb(0xFF, 0xF0, 0xF2, 0xF5); + var codeBorder = isDark ? Color.FromArgb(0xFF, 0x3A, 0x3A, 0x3C) : Color.FromArgb(0xFF, 0xD0, 0xD7, 0xDE); + var codeMuted = isDark ? Color.FromArgb(0xFF, 0x85, 0x85, 0x85) : Color.FromArgb(0xFF, 0x6E, 0x77, 0x81); + var tableBg = isDark ? Color.FromArgb(0xFF, 0x25, 0x25, 0x25) : Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF); + var tableHeaderBg = isDark ? Color.FromArgb(0xFF, 0x2D, 0x2D, 0x30) : Color.FromArgb(0xFF, 0xF7, 0xF8, 0xFA); + var tableBorder = isDark ? Color.FromArgb(0xFF, 0x3D, 0x3D, 0x40) : Color.FromArgb(0xFF, 0xD8, 0xDC, 0xE0); var quoteBar = isDark ? Color.FromArgb(0x50, 0xFF, 0xFF, 0xFF) : Color.FromArgb(0x50, 0x00, 0x00, 0x00); // Single font family names for Win2D/DirectWrite. DirectWrite does NOT support @@ -112,7 +120,11 @@ private ElementStyle GetDefault(string key) MarkdownElementKeys.Heading5 => new ElementStyle { FontFamily = font, FontSize = 15, FontWeight = FontWeights.SemiBold, Foreground = fg, Margin = new Thickness(0, 8, 0, 2) }, MarkdownElementKeys.Heading6 => new ElementStyle { FontFamily = font, FontSize = 14, FontWeight = FontWeights.SemiBold, Foreground = fgSecondary, Margin = new Thickness(0, 6, 0, 2) }, MarkdownElementKeys.Body => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, Margin = new Thickness(0, 0, 0, 8), ListIndent = 22f, NestedListIndent = 0f }, - MarkdownElementKeys.CodeBlock => new ElementStyle { FontFamily = mono, FontSize = 13, Foreground = fg, Background = codeBg, CornerRadius = 4, Margin = new Thickness(0, 4, 0, 8), Padding = new Thickness(12, 8, 12, 8) }, + MarkdownElementKeys.CodeBlock => new ElementStyle { FontFamily = mono, FontSize = 13, Foreground = fg, Background = codeBg, BorderBrush = codeBorder, BorderThickness = 1, CornerRadius = 6, Margin = new Thickness(0, 4, 0, 8), Padding = new Thickness(12, 10, 12, 10) }, + MarkdownElementKeys.CodeBlockHeader => new ElementStyle { FontFamily = font, FontSize = 12, Foreground = codeMuted, Background = codeHeaderBg, BorderBrush = codeBorder }, + MarkdownElementKeys.CodeBlockLanguage => new ElementStyle { FontFamily = font, FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = codeMuted }, + MarkdownElementKeys.CodeBlockGutter => new ElementStyle { FontFamily = mono, FontSize = 12, Foreground = codeMuted, Background = codeHeaderBg }, + MarkdownElementKeys.CodeBlockLineNumber => new ElementStyle { FontFamily = mono, FontSize = 12, Foreground = codeMuted }, MarkdownElementKeys.CodeInline => new ElementStyle { FontFamily = mono, FontSize = 12, Foreground = fg, Background = codeBg, CornerRadius = 3, Padding = new Thickness(2, 0, 2, 0) }, MarkdownElementKeys.Quote => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fgSecondary, AccentBar = quoteBar, Margin = new Thickness(0, 4, 0, 4), Padding = new Thickness(12, 2, 8, 2) }, MarkdownElementKeys.Link => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = accent, HoverForeground = linkHover, FocusForeground = linkHover, Underline = true }, @@ -123,7 +135,7 @@ private ElementStyle GetDefault(string key) MarkdownElementKeys.Superscript => new ElementStyle { FontFamily = font, FontSize = 11, Foreground = fg }, MarkdownElementKeys.Inserted => new ElementStyle { FontFamily = font, FontSize = 14, Underline = true, Foreground = fg }, MarkdownElementKeys.Marked => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, Background = isDark ? Color.FromArgb(0x45, 0xFF, 0xD8, 0x66) : Color.FromArgb(0x66, 0xFF, 0xE5, 0x8A), CornerRadius = 3 }, - MarkdownElementKeys.Abbreviation => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, Underline = true }, + MarkdownElementKeys.Abbreviation => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, AccentBar = accent, Underline = true }, MarkdownElementKeys.DefinitionTerm => new ElementStyle { FontFamily = font, FontSize = 14, FontWeight = FontWeights.SemiBold, Foreground = fg, Margin = new Thickness(0, 4, 0, 0) }, MarkdownElementKeys.DefinitionDescription => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fgSecondary, Margin = new Thickness(18, 0, 0, 6) }, MarkdownElementKeys.Figure => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, Margin = new Thickness(0, 8, 0, 10) }, @@ -132,9 +144,9 @@ private ElementStyle GetDefault(string key) MarkdownElementKeys.ListMarker => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fgSecondary, ListIndent = 22f, NestedListIndent = 0f }, MarkdownElementKeys.ThematicBreak => new ElementStyle { FontFamily = font, Foreground = quoteBar, Margin = new Thickness(0, 12, 0, 12) }, MarkdownElementKeys.ImageCaption => new ElementStyle { FontFamily = font, FontSize = 12, FontStyle = FontStyle.Italic, Foreground = fgSecondary, Margin = new Thickness(0, 2, 0, 8) }, - // Table styles: no Background — TableBox draws header row bg directly. - MarkdownElementKeys.TableHeader => new ElementStyle { FontFamily = font, FontSize = 14, FontWeight = FontWeights.SemiBold, Foreground = fg }, - MarkdownElementKeys.TableCell => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg }, + MarkdownElementKeys.Table => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, Background = tableBg, BorderBrush = tableBorder, BorderThickness = 1, CornerRadius = 6, Margin = new Thickness(0, 8, 0, 12) }, + MarkdownElementKeys.TableHeader => new ElementStyle { FontFamily = font, FontSize = 14, FontWeight = FontWeights.SemiBold, Foreground = fg, Background = tableHeaderBg, BorderBrush = tableBorder, Padding = new Thickness(12, 9, 12, 9) }, + MarkdownElementKeys.TableCell => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, Background = tableBg, BorderBrush = tableBorder, Padding = new Thickness(12, 9, 12, 9) }, MarkdownElementKeys.AlertNote => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, AccentBar = Color.FromArgb(0xFF, 0x0E, 0xA5, 0xE9), Padding = new Thickness(12, 2, 8, 2), Margin = new Thickness(0, 4, 0, 8) }, MarkdownElementKeys.AlertTip => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, AccentBar = Color.FromArgb(0xFF, 0x22, 0xC5, 0x5E), Padding = new Thickness(12, 2, 8, 2), Margin = new Thickness(0, 4, 0, 8) }, MarkdownElementKeys.AlertImportant => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, AccentBar = Color.FromArgb(0xFF, 0xA8, 0x55, 0xF7), Padding = new Thickness(12, 2, 8, 2), Margin = new Thickness(0, 4, 0, 8) }, @@ -163,7 +175,11 @@ private ElementStyle GetHighContrastDefault(string key) MarkdownElementKeys.Heading5 => new ElementStyle { FontFamily = font, FontSize = 15, FontWeight = FontWeights.SemiBold, Foreground = fg, Margin = new Thickness(0, 8, 0, 2) }, MarkdownElementKeys.Heading6 => new ElementStyle { FontFamily = font, FontSize = 14, FontWeight = FontWeights.SemiBold, Foreground = fg, Margin = new Thickness(0, 6, 0, 2) }, MarkdownElementKeys.Body => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, Background = bg, Margin = new Thickness(0, 0, 0, 8) }, - MarkdownElementKeys.CodeBlock => new ElementStyle { FontFamily = mono, FontSize = 13, Foreground = fg, Background = bg, AccentBar = accentBar, Margin = new Thickness(0, 4, 0, 8), Padding = new Thickness(12, 8, 12, 8) }, + MarkdownElementKeys.CodeBlock => new ElementStyle { FontFamily = mono, FontSize = 13, Foreground = fg, Background = bg, AccentBar = accentBar, BorderBrush = accentBar, BorderThickness = accentBar is null ? 0 : 1, CornerRadius = 0, Margin = new Thickness(0, 4, 0, 8), Padding = new Thickness(12, 10, 12, 10) }, + MarkdownElementKeys.CodeBlockHeader => new ElementStyle { FontFamily = font, FontSize = 12, Foreground = fg, Background = bg, BorderBrush = accentBar }, + MarkdownElementKeys.CodeBlockLanguage => new ElementStyle { FontFamily = font, FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = fg }, + MarkdownElementKeys.CodeBlockGutter => new ElementStyle { FontFamily = mono, FontSize = 12, Foreground = fg, Background = bg, BorderBrush = accentBar }, + MarkdownElementKeys.CodeBlockLineNumber => new ElementStyle { FontFamily = mono, FontSize = 12, Foreground = fg }, MarkdownElementKeys.CodeInline => new ElementStyle { FontFamily = mono, FontSize = 12, Foreground = fg, Background = bg, Padding = new Thickness(2, 0, 2, 0) }, MarkdownElementKeys.Quote => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, AccentBar = accentBar, Margin = new Thickness(0, 4, 0, 4), Padding = new Thickness(12, 2, 8, 2) }, MarkdownElementKeys.Link => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, HoverForeground = fg, FocusForeground = fg, Underline = roles.Underline }, @@ -174,7 +190,7 @@ private ElementStyle GetHighContrastDefault(string key) MarkdownElementKeys.Superscript => new ElementStyle { FontFamily = font, FontSize = 11, Foreground = fg }, MarkdownElementKeys.Inserted => new ElementStyle { FontFamily = font, FontSize = 14, Underline = roles.Underline, Foreground = fg }, MarkdownElementKeys.Marked => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, Background = bg }, - MarkdownElementKeys.Abbreviation => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, Underline = roles.Underline }, + MarkdownElementKeys.Abbreviation => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, AccentBar = accentBar, Underline = roles.Underline }, MarkdownElementKeys.DefinitionTerm => new ElementStyle { FontFamily = font, FontSize = 14, FontWeight = FontWeights.SemiBold, Foreground = fg, Margin = new Thickness(0, 4, 0, 0) }, MarkdownElementKeys.DefinitionDescription => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, Background = bg, Margin = new Thickness(18, 0, 0, 6) }, MarkdownElementKeys.Figure => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, Background = bg, Margin = new Thickness(0, 8, 0, 10) }, @@ -183,8 +199,9 @@ private ElementStyle GetHighContrastDefault(string key) MarkdownElementKeys.ListMarker => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, ListIndent = 22f, NestedListIndent = 0f }, MarkdownElementKeys.ThematicBreak => new ElementStyle { FontFamily = font, Foreground = fg, Margin = new Thickness(0, 12, 0, 12) }, MarkdownElementKeys.ImageCaption => new ElementStyle { FontFamily = font, FontSize = 12, FontStyle = FontStyle.Italic, Foreground = fg, Margin = new Thickness(0, 2, 0, 8) }, - MarkdownElementKeys.TableHeader => new ElementStyle { FontFamily = font, FontSize = 14, FontWeight = FontWeights.SemiBold, Foreground = fg, Background = bg }, - MarkdownElementKeys.TableCell => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, Background = bg }, + MarkdownElementKeys.Table => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, Background = bg, BorderBrush = accentBar, BorderThickness = accentBar is null ? 0 : 1, Margin = new Thickness(0, 8, 0, 12) }, + MarkdownElementKeys.TableHeader => new ElementStyle { FontFamily = font, FontSize = 14, FontWeight = FontWeights.SemiBold, Foreground = fg, Background = bg, BorderBrush = accentBar, Padding = new Thickness(12, 9, 12, 9) }, + MarkdownElementKeys.TableCell => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, Background = bg, BorderBrush = accentBar, Padding = new Thickness(12, 9, 12, 9) }, MarkdownElementKeys.AlertNote or MarkdownElementKeys.AlertTip or MarkdownElementKeys.AlertImportant or MarkdownElementKeys.AlertWarning or MarkdownElementKeys.AlertCaution => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, AccentBar = accentBar, Padding = new Thickness(12, 2, 8, 2), Margin = new Thickness(0, 4, 0, 8) }, _ => new ElementStyle { FontFamily = font, FontSize = 14, Foreground = fg, Background = bg, Margin = new Thickness(0, 0, 0, 4) } @@ -350,11 +367,9 @@ private Color ResolveSurfaceColor() if (_systemTheme.IsHighContrast) return _systemTheme.WindowColor; - if (TryResolveResourceColor("TextControlBackgroundFocused", out var color) || - TryResolveResourceColor("TextControlBackground", out color) || - TryResolveResourceColor("LayerFillColorDefaultBrush", out color) || + if (TryResolveResourceColor("ApplicationPageBackgroundThemeBrush", out var color) || TryResolveResourceColor("SolidBackgroundFillColorBaseBrush", out color) || - TryResolveResourceColor("ApplicationPageBackgroundThemeBrush", out color)) + TryResolveResourceColor("LayerFillColorDefaultBrush", out color)) { return CompositeOver(color, _host.ActualTheme == ElementTheme.Dark ? Color.FromArgb(0xFF, 0x20, 0x20, 0x20) diff --git a/docs/markdown-renderer/current-gaps-and-roadmap.md b/docs/markdown-renderer/current-gaps-and-roadmap.md index c03aa67..6ce463a 100644 --- a/docs/markdown-renderer/current-gaps-and-roadmap.md +++ b/docs/markdown-renderer/current-gaps-and-roadmap.md @@ -31,6 +31,8 @@ This document summarizes release-readiness work and deferred feature tracks. - Math/LaTeX support. - Safe raw HTML policy and renderer. - Optional richer diagram package built on the embed API. +- Extend the internal rich block chrome pattern to diagrams, figures, tables, + alerts/quotes, and future code refinements. - Targeted recycling for additional proven pure managed helper allocations. ## Deferred feature tracks diff --git a/docs/markdown-renderer/public-api.md b/docs/markdown-renderer/public-api.md index 2525f45..261ea3f 100644 --- a/docs/markdown-renderer/public-api.md +++ b/docs/markdown-renderer/public-api.md @@ -9,6 +9,7 @@ painting, and source-map internals are intentionally not the consumer path. | --- | --- | | `MarkdownRenderer` | Core WinUI control, CommonMark rendering, theming, selection, accessibility, images/SVG, hosted-control support, and document queries. | | `MarkdownRenderer.Gfm` | GitHub-flavored markdown helpers and renderers, plus opt-in Markdown Extra helpers. | +| `MarkdownRenderer.SyntaxHighlighting.TextMate` | Optional TextMate grammar provider for broad code-block syntax highlighting. | ## Core namespaces @@ -21,6 +22,8 @@ painting, and source-map internals are intentionally not the consumer path. | `MarkdownRenderer.Selection` | `MarkdownCopyOptions` and plain-text copy mode. | | `MarkdownRenderer.Theming` | `MarkdownTheme`, `ElementStyle`, `ElementStyleOverride`, and `MarkdownElementKeys`. | | `MarkdownRenderer.Gfm` | GFM factory and builder extension methods. | +| `MarkdownRenderer.CodeBlocks` | Code-block syntax-highlighting provider contracts and line-number options. | +| `MarkdownRenderer.SyntaxHighlighting.TextMate` | TextMate highlighter and builder/control extension methods. | ## Creating controls @@ -44,15 +47,22 @@ Fluent setup: ```csharp using MarkdownRenderer.Controls; +using MarkdownRenderer.CodeBlocks; using MarkdownRenderer.Gfm; +using MarkdownRenderer.SyntaxHighlighting.TextMate; var control = new MarkdownRendererControlBuilder() .UseGitHubFlavoredMarkdown() .UseMarkdownExtra() + .UseTextMateSyntaxHighlighting() .WithMarkdown(markdownSource) .WithTheme(theme) .WithEmbedFactory(embedFactory) .WithSelectionEnabled(true) + .WithCodeBlockCopyEnabled(true) + .WithCodeBlockCopyButtonLabel("Copy code") + .WithCodeBlockCopiedButtonLabel("Copied") + .WithCodeBlockLineNumberMode(CodeBlockLineNumberMode.AutoMultiline) .Build(); ``` @@ -70,6 +80,12 @@ Common consumer properties and methods: | `ExtensionRegistry` | Optional parser/renderer registry. Null uses core CommonMark behavior. | | `EmbedFactory` | Optional block-level hosted WinUI control factory. | | `IsSelectionEnabled` | Enables pointer/keyboard text selection. | +| `IsCodeBlockCopyEnabled` | Shows always-visible native copy buttons on fenced and indented code blocks. Defaults to true. | +| `CodeBlockCopyButtonLabel` | Accessible name and tooltip for icon-only code-block copy buttons. Null uses the localized default. | +| `CodeBlockCopiedButtonLabel` | Accessible name and tooltip used briefly after a successful code-block copy. Null uses the localized default. | +| `IsCodeBlockSyntaxHighlightingEnabled` | Allows a configured highlighter provider to color code blocks. Defaults to true. | +| `CodeBlockSyntaxHighlighter` | Optional syntax-highlighting provider. Null keeps code plain. | +| `CodeBlockLineNumberMode` | Controls line-number defaults. Defaults to `AutoMultiline`. | | `Document` | Immutable public parsed-document snapshot for queries. | | `RequestRebuild()` | Explicitly schedules a rebuild when an advanced integration changes external state. | | `CopySelectionToClipboard(MarkdownCopyOptions? options = null)` | Copies the current selection with source-markdown defaults and optional rendered text. | diff --git a/docs/markdown-renderer/supported-markdown.md b/docs/markdown-renderer/supported-markdown.md index f14a388..267f11b 100644 --- a/docs/markdown-renderer/supported-markdown.md +++ b/docs/markdown-renderer/supported-markdown.md @@ -22,13 +22,30 @@ use Markdig's HTML renderer. | ATX headings H1-H6 | Core | Exposes heading levels through UI Automation and document queries. | | Emphasis and strong emphasis | Core | Participates in selection, clipboard HTML, and text attributes. | | Inline code | Core | Uses code style keys and source-accurate copy. | -| Fenced and indented code blocks | Core | Fenced language info is exposed through the document facade and UIA help text. Huge code blocks are segmented. | +| Fenced and indented code blocks | Core | Rendered as native code surfaces with header metadata, copy action, always-wrapped text, multiline line numbers, highlighted-line metadata, diff markers, and huge-code segmentation. Syntax highlighting is provider-based and optional. | | Block quotes | Core | Styled through theme background, padding, border, radius, and accent fields. | | Ordered and unordered lists | Core | Preserves ordered-list start numbers and depth-aware indent styling. | | Thematic breaks | Core | Native painted rule. | | Links and autolinks | Core | Pointer, keyboard, UIA Invoke, hover/focus overlay styling, and fragment navigation. | | Images | Core | Standalone and inline images share bitmap/SVG loading, lazy load, source maps, selection, and UIA alt text. | +## Optional syntax highlighting + +Broad code-block syntax highlighting is available from +`MarkdownRenderer.SyntaxHighlighting.TextMate`: + +```csharp +var control = new MarkdownRendererControlBuilder() + .UseTextMateSyntaxHighlighting() + .WithMarkdown(markdown) + .Build(); +``` + +Fence metadata supports Shiki/Nextra-style options such as +`filename="app.ts"`, `title="Example"`, `{1,3-5}`, `showLineNumbers`, +`noLineNumbers`, `startLine=10`, and `diff`. +Unsupported languages fall back to plain code. + ## GitHub-flavored markdown Enable with: @@ -66,7 +83,7 @@ var control = new MarkdownRendererControlBuilder() | Syntax | Status | Notes | | --- | --- | --- | | Definition lists | Extra | Native term/description rendering, style keys, selection, UIA, and document queries. | -| Abbreviations | Extra | Renders abbreviation text with accessible expansion metadata and document queries. | +| Abbreviations | Extra | Renders abbreviation text with hover expansion tooltips, accessible expansion metadata, and document queries. | | Figures and captions | Extra | Rendered only where Markdig produces figure nodes; ordinary images keep normal behavior. | | Subscript and superscript | Extra/GFM helper | Uses baseline-aware inline runs and UIA text attributes. | | Inserted and marked text | Extra/GFM helper | Uses dedicated style keys, source maps, and clipboard HTML. | diff --git a/docs/markdown-renderer/theming-and-customization.md b/docs/markdown-renderer/theming-and-customization.md index 6b19cad..3bcbb44 100644 --- a/docs/markdown-renderer/theming-and-customization.md +++ b/docs/markdown-renderer/theming-and-customization.md @@ -23,6 +23,10 @@ Core keys: - `Heading1` through `Heading6` - `CodeInline` - `CodeBlock` +- `CodeBlockHeader` +- `CodeBlockLanguage` +- `CodeBlockGutter` +- `CodeBlockLineNumber` - `Quote` - `Link` - `Strong` @@ -44,6 +48,7 @@ Core keys: GFM keys: +- `Table` - `TableHeader` - `TableCell` - `AlertNote` @@ -95,6 +100,21 @@ theme.Overrides[MarkdownElementKeys.CodeBlock] = new ElementStyleOverride Padding = new Thickness(12), }; +theme.Overrides[MarkdownElementKeys.CodeBlockHeader] = new ElementStyleOverride +{ + Background = Color.FromArgb(0x10, 0x80, 0x80, 0x80), +}; + +theme.Overrides[MarkdownElementKeys.CodeBlockLanguage] = new ElementStyleOverride +{ + Foreground = Colors.Gray, +}; + +theme.Overrides[MarkdownElementKeys.CodeBlockLineNumber] = new ElementStyleOverride +{ + Foreground = Colors.DimGray, +}; + theme.Overrides[MarkdownElementKeys.Link] = new ElementStyleOverride { Foreground = Colors.MediumPurple,