Browse Source

Copy decompiled code as syntax- and semantic-highlighted HTML

AvaloniaEdit's copy puts only plain text on the clipboard, so pasting into
an HTML-aware target (a document, mail, chat) lost the colours the previous
version preserved. Build a coloured HTML fragment from the selection that
merges BOTH the xshd syntax highlighter and ILSpy's semantic RichTextModel
(the decompiler's reference / theme colours the xshd alone doesn't carry),
and place it alongside the text under the native HTML format -- text/html on
Linux/macOS, CF_HTML on Windows. Wired to Ctrl+C (tunnel, so it pre-empts
the plain-text copy) and the right-click Copy entry.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
2aeae01eb8
  1. 94
      ILSpy.Tests/Editor/HtmlClipboardCopyTests.cs
  2. 19
      ILSpy/TextView/DecompilerTextView.axaml.cs
  3. 6
      ILSpy/TextView/EditorCommands.cs
  4. 154
      ILSpy/TextView/HtmlClipboardCopy.cs

94
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;
/// <summary>
/// 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.
/// </summary>
[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<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
Assert.That(typeNode, Is.Not.Null);
typeNode!.IsExpanded = true;
var methodNode = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "AsEnumerable");
vm.AssemblyTreeModel.SelectNode(methodNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var view = await window.WaitForComponent<DecompilerTextView>();
await view.WaitForComponent<TextEditor>();
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("<span", "the HTML fragment carries colours");
withSemantic.Should().NotBe(syntaxOnly,
"the semantic RichTextModel contributes the decompiler colours that the xshd highlighter alone doesn't");
}
[AvaloniaTest]
public async Task No_Selection_Yields_No_Fragment()
{
var (window, vm) = await TestHarness.BootAsync(3);
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
typeNode!.IsExpanded = true;
var methodNode = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "AsEnumerable");
vm.AssemblyTreeModel.SelectNode(methodNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var view = await window.WaitForComponent<DecompilerTextView>();
await view.WaitForComponent<TextEditor>();
view.Editor.Select(0, 0);
HtmlClipboardCopy.CreateHtmlFragmentFromSelection(view.Editor).Should().BeNull();
HtmlClipboardCopy.Copy(view.Editor).Should().BeFalse("nothing to copy with an empty selection");
}
}

19
ILSpy/TextView/DecompilerTextView.axaml.cs

@ -202,8 +202,27 @@ namespace ILSpy.TextView
RoutingStrategies.Tunnel, RoutingStrategies.Tunnel,
handledEventsToo: false); handledEventsToo: false);
Editor.KeyDown += OnEditorKeyDownForZoom; 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;
}
} }
/// <summary>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.</summary>
internal RichTextModel? SemanticHighlightingModel => boundModel?.HighlightingModel;
// ThemeManager.Current is a process-lived singleton, so subscribing to its ThemeChanged in // 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 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 // the process -- one leaked view per decompiler tab. Bind the handler to the visual-tree

6
ILSpy/TextView/EditorCommands.cs

@ -38,7 +38,11 @@ namespace ILSpy.TextView
=> context.TextView is { } view && view.Editor.SelectionLength > 0; => context.TextView is { } view && view.Editor.SelectionLength > 0;
public void Execute(TextViewContext context) 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();
}
} }
/// <summary> /// <summary>

154
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
{
/// <summary>
/// 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.
/// </summary>
public static class HtmlClipboardCopy
{
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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 <see cref="RichTextModel"/> -- the latter carries the decompiler's reference / theme
/// colours that the xshd highlighter alone doesn't. Returns null when there is no selection.
/// </summary>
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("<br>");
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<byte[]>? 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 = "<html><body><!--StartFragment-->";
const string htmlEnd = "<!--EndFragment--></body></html>";
// 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;
}
}
}
Loading…
Cancel
Save