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,