From 16e64e4e8d574503f65f420460e675ca2829cf89 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Fri, 1 May 2026 16:01:28 +0200 Subject: [PATCH] AddUIElement / AddButton infrastructure for inline UI Assisted-by: Claude:claude-opus-4-7:Claude Code Assisted-by: Claude:claude-opus-4-7:Claude Code --- ILSpy/SmartTextOutputExtensions.cs | 65 ++++++++++++++++++ ILSpy/TextView/AvaloniaEditTextOutput.cs | 18 +++++ ILSpy/TextView/DecompilerTabPageModel.cs | 10 +++ ILSpy/TextView/DecompilerTextView.axaml.cs | 5 ++ ILSpy/TextView/ISmartTextOutput.cs | 14 +++- ILSpy/TextView/UIElementGenerator.cs | 79 ++++++++++++++++++++++ 6 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 ILSpy/SmartTextOutputExtensions.cs create mode 100644 ILSpy/TextView/UIElementGenerator.cs diff --git a/ILSpy/SmartTextOutputExtensions.cs b/ILSpy/SmartTextOutputExtensions.cs new file mode 100644 index 000000000..4c3f95769 --- /dev/null +++ b/ILSpy/SmartTextOutputExtensions.cs @@ -0,0 +1,65 @@ +// 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 Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; + +using ILSpy.TextView; + +namespace ILSpy +{ + public static class SmartTextOutputExtensions + { + public static void AddButton(this ISmartTextOutput output, IImage? icon, string text, EventHandler click) + { + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(click); + + output.AddUIElement(() => { + var button = new Button { + Cursor = new Cursor(StandardCursorType.Arrow), + Margin = new Thickness(2), + Padding = new Thickness(9, 1, 9, 1), + MinWidth = 73, + }; + if (icon != null) + { + button.Content = new StackPanel { + Orientation = Orientation.Horizontal, + Children = { + new Image { Width = 16, Height = 16, Source = icon, Margin = new Thickness(0, 0, 4, 0) }, + new TextBlock { Text = text }, + }, + }; + } + else + { + button.Content = text; + } + button.Click += click; + return button; + }); + } + } +} diff --git a/ILSpy/TextView/AvaloniaEditTextOutput.cs b/ILSpy/TextView/AvaloniaEditTextOutput.cs index d6bad0f12..858451ade 100644 --- a/ILSpy/TextView/AvaloniaEditTextOutput.cs +++ b/ILSpy/TextView/AvaloniaEditTextOutput.cs @@ -16,10 +16,13 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +using System; using System.Collections.Generic; using System.Reflection.Metadata; using System.Text; +using Avalonia.Controls; + using AvaloniaEdit.Document; using AvaloniaEdit.Folding; using AvaloniaEdit.Highlighting; @@ -62,6 +65,12 @@ namespace ILSpy.TextView /// Maps reference targets to their definition offsets in the rendered text. public DefinitionLookup DefinitionLookup { get; } = new(); + readonly List>> uiElements = new(); + + /// Inline UI elements collected during writing, in offset order. Fed to + /// by the text view. + public IReadOnlyList>> UIElements => uiElements; + public string Title { get; set; } = string.Empty; public string IndentationString { get; set; } = "\t"; @@ -172,6 +181,15 @@ namespace ILSpy.TextView foldings.Add(folding); } + public void AddUIElement(Func element) + { + if (element == null) + return; + if (uiElements.Count > 0 && uiElements[uiElements.Count - 1].Key == builder.Length) + throw new InvalidOperationException("Only one UIElement is allowed for each position in the document."); + uiElements.Add(new KeyValuePair>(builder.Length, new Lazy(element))); + } + public void BeginSpan(HighlightingColor highlightingColor) { openSpans.Push((builder.Length, highlightingColor)); diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index ba5f99d0b..44388ffca 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -21,6 +21,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Avalonia.Controls; using Avalonia.Threading; using AvaloniaEdit.Document; @@ -90,6 +91,13 @@ namespace ILSpy.TextView [ObservableProperty] private DefinitionLookup? definitionLookup; + /// + /// Inline UI elements (), in offset order. + /// Fed to by the text view. + /// + [ObservableProperty] + private IReadOnlyList>>? uIElements; + /// /// Fired when the user clicks a cross-document reference. The host (DockWorkspace) /// resolves the target on the assembly tree side. @@ -172,6 +180,7 @@ namespace ILSpy.TextView var collectedFoldings = output.Foldings; var collectedReferences = output.References; var collectedLookup = output.DefinitionLookup; + var collectedUIElements = output.UIElements; await Dispatcher.UIThread.InvokeAsync(() => { Title = nodeTitle; SyntaxExtension = newSyntaxExtension; @@ -179,6 +188,7 @@ namespace ILSpy.TextView Foldings = collectedFoldings; References = collectedReferences; DefinitionLookup = collectedLookup; + UIElements = collectedUIElements; Text = rendered; }); } diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 4dfc2b68d..c3efd12ad 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -62,6 +62,7 @@ namespace ILSpy.TextView const double MaxMovementAwayFromPopup = 5; readonly ReferenceElementGenerator referenceElementGenerator; + readonly UIElementGenerator uiElementGenerator; readonly TextMarkerService textMarkerService; readonly List localReferenceMarks = new(); RichTextColorizer? activeColorizer; @@ -81,6 +82,9 @@ namespace ILSpy.TextView referenceElementGenerator.ReferenceClicked += OnReferenceClicked; Editor.TextArea.TextView.ElementGenerators.Add(referenceElementGenerator); + uiElementGenerator = new UIElementGenerator(); + Editor.TextArea.TextView.ElementGenerators.Add(uiElementGenerator); + // Background renderer that paints local-reference highlights. Lives once for the // lifetime of the view; the marks themselves are cleared and rebuilt per click. textMarkerService = new TextMarkerService(Editor.TextArea.TextView); @@ -180,6 +184,7 @@ namespace ILSpy.TextView } referenceElementGenerator.References = model.References; + uiElementGenerator.UIElements = model.UIElements; Editor.TextArea.TextView.Redraw(); Editor.ScrollToHome(); diff --git a/ILSpy/TextView/ISmartTextOutput.cs b/ILSpy/TextView/ISmartTextOutput.cs index 23e6bab0e..08f4ffb03 100644 --- a/ILSpy/TextView/ISmartTextOutput.cs +++ b/ILSpy/TextView/ISmartTextOutput.cs @@ -16,6 +16,10 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +using System; + +using Avalonia.Controls; + using AvaloniaEdit.Highlighting; using ICSharpCode.Decompiler; @@ -23,11 +27,17 @@ using ICSharpCode.Decompiler; namespace ILSpy.TextView { /// - /// Adds Avalonia-specific output features to — currently just - /// span-based highlighting. Inline UI elements are deferred to a later phase. + /// Adds Avalonia-specific output features to : span-based + /// highlighting + inline UI elements rendered by . /// public interface ISmartTextOutput : ITextOutput { + /// + /// Inserts an interactive UI element at the current position. The factory runs lazily + /// on the UI thread when the element generator first realises the row. + /// + void AddUIElement(Func element); + void BeginSpan(HighlightingColor highlightingColor); void EndSpan(); diff --git a/ILSpy/TextView/UIElementGenerator.cs b/ILSpy/TextView/UIElementGenerator.cs new file mode 100644 index 000000000..d2c0ec6d7 --- /dev/null +++ b/ILSpy/TextView/UIElementGenerator.cs @@ -0,0 +1,79 @@ +// 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.Collections.Generic; + +using Avalonia.Controls; + +using AvaloniaEdit.Rendering; + +namespace ILSpy.TextView +{ + using Pair = KeyValuePair>; + + /// + /// Embeds inline UI elements produced by in + /// the rendered text. Mirrors the WPF host's UIElementGenerator: the element factory is + /// stored as a so the actual control is constructed on the + /// UI thread when the row is first realised. + /// + sealed class UIElementGenerator : VisualLineElementGenerator, IComparer + { + public IReadOnlyList? UIElements; + + public override int GetFirstInterestedOffset(int startOffset) + { + if (UIElements == null) + return -1; + int r = BinarySearch(UIElements, new Pair(startOffset, null!)); + if (r < 0) + r = ~r; + return r < UIElements.Count ? UIElements[r].Key : -1; + } + + public override VisualLineElement? ConstructElement(int offset) + { + if (UIElements == null) + return null; + int r = BinarySearch(UIElements, new Pair(offset, null!)); + return r >= 0 ? new InlineObjectElement(0, UIElements[r].Value.Value) : null; + } + + int IComparer.Compare(Pair x, Pair y) => x.Key.CompareTo(y.Key); + + // IReadOnlyList has no BinarySearch. Hand-rolled — list is offset-sorted by + // AvaloniaEditTextOutput.AddUIElement. + int BinarySearch(IReadOnlyList list, Pair value) + { + int lo = 0, hi = list.Count - 1; + while (lo <= hi) + { + int mid = lo + ((hi - lo) >> 1); + int cmp = ((IComparer)this).Compare(list[mid], value); + if (cmp == 0) + return mid; + if (cmp < 0) + lo = mid + 1; + else + hi = mid - 1; + } + return ~lo; + } + } +}