diff --git a/ILSpy.Tests/Editor/HtmlClipboardCopyTests.cs b/ILSpy.Tests/Editor/HtmlClipboardCopyTests.cs
new file mode 100644
index 000000000..5e315a584
--- /dev/null
+++ b/ILSpy.Tests/Editor/HtmlClipboardCopyTests.cs
@@ -0,0 +1,94 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+
+using Avalonia.Headless.NUnit;
+
+using AvaloniaEdit;
+
+using AwesomeAssertions;
+
+using ILSpy.TextView;
+using ILSpy.TreeNodes;
+using ILSpy.Views;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+///
+/// Copying the editor selection produces a syntax-coloured HTML fragment (so a paste into an
+/// HTML-aware target keeps the highlighting). AvaloniaEdit's built-in copy is plain text only.
+///
+[TestFixture]
+public class HtmlClipboardCopyTests
+{
+ [AvaloniaTest]
+ public async Task Copy_Includes_ILSpy_Semantic_Highlighting_Not_Just_Syntax()
+ {
+ var (window, vm) = await TestHarness.BootAsync(3);
+
+ var typeNode = vm.AssemblyTreeModel.FindNode(
+ "System.Linq", "System.Linq", "System.Linq.Enumerable");
+ Assert.That(typeNode, Is.Not.Null);
+ typeNode!.IsExpanded = true;
+ var methodNode = typeNode.Children.OfType()
+ .First(m => m.MethodDefinition.Name == "AsEnumerable");
+ vm.AssemblyTreeModel.SelectNode(methodNode);
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+
+ var view = await window.WaitForComponent();
+ await view.WaitForComponent();
+ view.Editor.SelectAll();
+
+ var semanticModel = view.SemanticHighlightingModel;
+ Assert.That(semanticModel, Is.Not.Null, "the decompiled output has a semantic highlighting model");
+
+ var withSemantic = HtmlClipboardCopy.CreateHtmlFragmentFromSelection(view.Editor, semanticModel);
+ var syntaxOnly = HtmlClipboardCopy.CreateHtmlFragmentFromSelection(view.Editor, null);
+
+ withSemantic.Should().NotBeNullOrEmpty();
+ withSemantic.Should().Contain("(
+ "System.Linq", "System.Linq", "System.Linq.Enumerable");
+ typeNode!.IsExpanded = true;
+ var methodNode = typeNode.Children.OfType()
+ .First(m => m.MethodDefinition.Name == "AsEnumerable");
+ vm.AssemblyTreeModel.SelectNode(methodNode);
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+
+ var view = await window.WaitForComponent();
+ await view.WaitForComponent();
+ view.Editor.Select(0, 0);
+
+ HtmlClipboardCopy.CreateHtmlFragmentFromSelection(view.Editor).Should().BeNull();
+ HtmlClipboardCopy.Copy(view.Editor).Should().BeFalse("nothing to copy with an empty selection");
+ }
+}
diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs
index 6cfd448bd..1699e1a55 100644
--- a/ILSpy/TextView/DecompilerTextView.axaml.cs
+++ b/ILSpy/TextView/DecompilerTextView.axaml.cs
@@ -202,8 +202,27 @@ namespace ILSpy.TextView
RoutingStrategies.Tunnel,
handledEventsToo: false);
Editor.KeyDown += OnEditorKeyDownForZoom;
+
+ // Ctrl+C copies as text + syntax-coloured HTML so a paste into an HTML target keeps the
+ // highlighting. Tunnel so we run before AvaloniaEdit's plain-text copy keybinding and can
+ // suppress it; falls through (Handled stays false) when there's no selection.
+ Editor.AddHandler(InputElement.KeyDownEvent, OnEditorKeyDownForCopy,
+ RoutingStrategies.Tunnel, handledEventsToo: false);
+ }
+
+ void OnEditorKeyDownForCopy(object? sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.C && e.KeyModifiers == KeyModifiers.Control
+ && HtmlClipboardCopy.Copy(Editor, SemanticHighlightingModel))
+ {
+ e.Handled = true;
+ }
}
+ /// ILSpy's semantic highlighting (decompiler reference / theme colours) for the current
+ /// document, so an HTML copy can include it on top of the xshd syntax colours.
+ internal RichTextModel? SemanticHighlightingModel => boundModel?.HighlightingModel;
+
// ThemeManager.Current is a process-lived singleton, so subscribing to its ThemeChanged in
// the constructor and never detaching would root every DecompilerTextView for the lifetime of
// the process -- one leaked view per decompiler tab. Bind the handler to the visual-tree
diff --git a/ILSpy/TextView/EditorCommands.cs b/ILSpy/TextView/EditorCommands.cs
index 43f43ac5a..e7f16d579 100644
--- a/ILSpy/TextView/EditorCommands.cs
+++ b/ILSpy/TextView/EditorCommands.cs
@@ -38,7 +38,11 @@ namespace ILSpy.TextView
=> context.TextView is { } view && view.Editor.SelectionLength > 0;
public void Execute(TextViewContext context)
- => context.TextView?.Editor.Copy();
+ {
+ // Copy as text + syntax/semantic-coloured HTML; fall back to plain copy if nothing selected.
+ if (context.TextView is { } view && !HtmlClipboardCopy.Copy(view.Editor, view.SemanticHighlightingModel))
+ view.Editor.Copy();
+ }
}
///
diff --git a/ILSpy/TextView/HtmlClipboardCopy.cs b/ILSpy/TextView/HtmlClipboardCopy.cs
new file mode 100644
index 000000000..62a35fa8e
--- /dev/null
+++ b/ILSpy/TextView/HtmlClipboardCopy.cs
@@ -0,0 +1,154 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Globalization;
+using System.Text;
+
+using Avalonia.Controls;
+using Avalonia.Input;
+
+using AvaloniaEdit;
+using AvaloniaEdit.Document;
+using AvaloniaEdit.Highlighting;
+
+namespace ILSpy.TextView
+{
+ ///
+ /// Copies the editor's selection to the clipboard as both plain text and syntax-coloured HTML, so
+ /// pasting into an HTML-aware target (a document, mail, chat) keeps the highlighting. The previous
+ /// version did this through AvalonEdit's HtmlClipboard; AvaloniaEdit exposes the fragment builder
+ /// but doesn't put HTML on the clipboard, so this wires it up with the per-platform HTML format.
+ ///
+ public static class HtmlClipboardCopy
+ {
+ ///
+ /// Copies the current selection as text + HTML. Returns false (and copies nothing) when the
+ /// selection is empty, so the caller can fall back to the default copy.
+ ///
+ public static bool Copy(TextEditor editor, RichTextModel? semanticHighlighting = null)
+ {
+ ArgumentNullException.ThrowIfNull(editor);
+ var textArea = editor.TextArea;
+ if (textArea.Selection.IsEmpty)
+ return false;
+
+ var text = TextUtilities.NormalizeNewLines(textArea.Selection.GetText(), Environment.NewLine);
+ var html = CreateHtmlFragmentFromSelection(editor, semanticHighlighting);
+
+ var item = new DataTransferItem();
+ item.Set(DataFormat.Text, text);
+ if (html is not null && HtmlPlatformFormat() is { } htmlFormat)
+ item.Set(htmlFormat, Encoding.UTF8.GetBytes(WrapForPlatform(html)));
+
+ var transfer = new DataTransfer();
+ transfer.Add(item);
+ TopLevel.GetTopLevel(editor)?.Clipboard?.SetDataAsync(transfer);
+ return true;
+ }
+
+ ///
+ /// Builds the coloured HTML fragment for the editor's current selection (the plain inner HTML,
+ /// without any platform CF_HTML wrapper). Merges both the syntax highlighter (xshd) AND ILSpy's
+ /// semantic -- the latter carries the decompiler's reference / theme
+ /// colours that the xshd highlighter alone doesn't. Returns null when there is no selection.
+ ///
+ public static string? CreateHtmlFragmentFromSelection(TextEditor editor, RichTextModel? semanticHighlighting = null)
+ {
+ ArgumentNullException.ThrowIfNull(editor);
+ var textArea = editor.TextArea;
+ if (textArea.Selection.IsEmpty)
+ return null;
+
+ var document = textArea.Document;
+ var highlighter = textArea.TextView.GetService(typeof(IHighlighter)) as IHighlighter;
+ var options = new HtmlOptions(textArea.Options);
+ var html = new StringBuilder();
+
+ foreach (var segment in textArea.Selection.Segments)
+ {
+ var line = document.GetLineByOffset(segment.StartOffset);
+ while (line != null && line.Offset < segment.EndOffset)
+ {
+ if (html.Length > 0)
+ html.AppendLine("
");
+
+ var (offset, length) = Overlap(segment, line);
+ var highlightedLine = highlighter?.HighlightLine(line.LineNumber) ?? new HighlightedLine(document, line);
+
+ if (semanticHighlighting is not null)
+ {
+ // Overlay the semantic colours by merging in a line built from the model's
+ // sections (same approach the previous version used).
+ var semanticLine = new HighlightedLine(document, line);
+ foreach (var section in semanticHighlighting.GetHighlightedSections(offset, length))
+ semanticLine.Sections.Add(section);
+ highlightedLine.MergeWith(semanticLine);
+ }
+
+ html.Append(highlightedLine.ToHtml(offset, offset + length, options));
+ line = line.NextLine;
+ }
+ }
+ return html.ToString();
+ }
+
+ static (int Offset, int Length) Overlap(ISegment a, ISegment b)
+ {
+ int start = Math.Max(a.Offset, b.Offset);
+ int end = Math.Min(a.EndOffset, b.EndOffset);
+ return (start, end - start);
+ }
+
+ // The native clipboard format that other apps recognise as HTML. Linux/X11 uses the MIME type,
+ // macOS the UTI; Windows uses CF_HTML (the fragment must be wrapped, see WrapForPlatform).
+ static DataFormat? HtmlPlatformFormat()
+ {
+ if (OperatingSystem.IsWindows())
+ return DataFormat.CreateBytesPlatformFormat("HTML Format");
+ if (OperatingSystem.IsMacOS())
+ return DataFormat.CreateBytesPlatformFormat("public.html");
+ return DataFormat.CreateBytesPlatformFormat("text/html");
+ }
+
+ // Windows CF_HTML requires a descriptive header with byte offsets; other platforms take the raw
+ // fragment. The offsets are byte positions into the UTF-8 payload.
+ static string WrapForPlatform(string fragment)
+ {
+ if (!OperatingSystem.IsWindows())
+ return fragment;
+
+ const string header =
+ "Version:0.9\r\nStartHTML:{0:00000000}\r\nEndHTML:{1:00000000}\r\n" +
+ "StartFragment:{2:00000000}\r\nEndFragment:{3:00000000}\r\n";
+ const string htmlStart = "";
+ const string htmlEnd = "";
+
+ // Two-pass: the header length depends on the offsets it contains, but the placeholder header
+ // is a fixed width (all fields are 8 digits), so a single formatted pass is exact.
+ int headerLength = Encoding.UTF8.GetByteCount(string.Format(CultureInfo.InvariantCulture, header, 0, 0, 0, 0));
+ int startHtml = headerLength;
+ int startFragment = startHtml + Encoding.UTF8.GetByteCount(htmlStart);
+ int endFragment = startFragment + Encoding.UTF8.GetByteCount(fragment);
+ int endHtml = endFragment + Encoding.UTF8.GetByteCount(htmlEnd);
+
+ return string.Format(CultureInfo.InvariantCulture, header, startHtml, endHtml, startFragment, endFragment)
+ + htmlStart + fragment + htmlEnd;
+ }
+ }
+}