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; + } + } +}