Browse Source

ImageListResourceEntryNode + factory wiring

Assisted-by: Claude:claude-opus-4-7:Claude Code

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
184fe9b650
  1. 8
      ICSharpCode.ILSpyX/Abstractions/IResourceNodeFactory.cs
  2. 4
      ILSpy.Tests.Windows/ILSpy.Tests.Windows.csproj
  3. 158
      ILSpy.Tests.Windows/ImageList/ImageListResourceEntryNodeTests.cs
  4. 228
      ILSpy/TreeNodes/ImageListResourceEntryNode.cs
  5. 55
      ILSpy/TreeNodes/ImageListResourceNodeFactory.cs
  6. 21
      ILSpy/TreeNodes/ResourcesFileTreeNode.cs

8
ICSharpCode.ILSpyX/Abstractions/IResourceNodeFactory.cs

@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
// DEALINGS IN THE SOFTWARE.
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.Decompiler.Util;
namespace ICSharpCode.ILSpyX.Abstractions
{
@ -28,5 +29,12 @@ namespace ICSharpCode.ILSpyX.Abstractions @@ -28,5 +29,12 @@ namespace ICSharpCode.ILSpyX.Abstractions
// Null means "this factory doesn't claim the resource"; callers iterate factories until
// one returns non-null and otherwise fall back to a generic node.
ITreeNode? CreateNode(Resource resource);
/// <summary>
/// Builds a node for a serialized-object entry inside an embedded <c>.resources</c>
/// file (e.g. an <c>ImageListStreamer</c> blob). Default no-op — only factories that
/// understand a specific serialized payload type need to override this.
/// </summary>
ITreeNode? CreateNode(string key, ResourceSerializedObject serializedObject) => null;
}
}

4
ILSpy.Tests.Windows/ILSpy.Tests.Windows.csproj

@ -45,6 +45,10 @@ @@ -45,6 +45,10 @@
<PackageReference Include="Microsoft.Testing.Extensions.VSTestBridge" />
<PackageReference Include="AwesomeAssertions" />
<PackageReference Include="NSubstitute" />
<!-- PreserializedResourceWriter — required by the ImageList integration tests to
build .resources fixtures with typed serialized-object entries (NRBF-wrapped
ImageListStreamer blobs) without depending on the obsolete BinaryFormatter. -->
<PackageReference Include="System.Resources.Extensions" />
<!-- Windows-only native packages that the matching ILSpy commands gate
behind `Debug + IsOSPlatform(Windows)`. Available unconditionally here because

158
ILSpy.Tests.Windows/ImageList/ImageListResourceEntryNodeTests.cs

@ -0,0 +1,158 @@ @@ -0,0 +1,158 @@
// 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.IO;
using System.Linq;
using System.Resources.Extensions;
using System.Windows.Forms;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.Metadata;
using ILSpy;
using ILSpy.AppEnv;
using ILSpy.ImageList;
using ILSpy.Languages;
using ILSpy.TextView;
using ILSpy.TreeNodes;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Windows;
/// <summary>
/// End-to-end coverage of the ImageList tree-node integration: factory dispatch,
/// lazy frame materialisation, and inline preview emission. The decoder unit tests
/// in <see cref="ImageListDecoderTests"/> cover the byte-format pipeline; these tests
/// cover the wiring around it.
/// </summary>
[TestFixture]
public class ImageListResourceEntryNodeTests
{
// Same pattern as ResourceFactoryTests in the cross-platform suite: touch the
// composition host once so ILSpyTreeNode's static ResourceNodeFactories list is
// populated. AvaloniaTest boots the headless app, then this forces App.Initialize
// → AppComposition.Initialize.
static void EnsureComposition() => AppComposition.Current.GetExport<MainWindow>();
[AvaloniaTest]
public void ResourcesFileTreeNode_Surfaces_ImageListResourceEntryNode_For_ImageListStreamer()
{
// Build a .resources blob containing one ImageListStreamer entry, feed it through
// the factory pipeline, expect a specialised ImageListResourceEntryNode rather than
// the generic "<serialized>" fall-back.
EnsureComposition();
using var fixture = ImageListFixtures.Build(ColorDepth.Depth32Bit, withMask: false, count: 3);
var resourcesBytes = BuildResourcesWithSerializedEntry("MyImages", fixture.NrbfBlob, fixture.TypeName);
var node = (ResourcesFileTreeNode)ResourceEntryNode.Create(
new ByteArrayResource("strings.resources", resourcesBytes));
node.EnsureLazyChildren();
node.Children.Should().HaveCount(1, "the .resources file held exactly one entry");
node.Children[0].Should().BeOfType<ImageListResourceEntryNode>(
"an ImageListStreamer typed entry should be claimed by ImageListResourceNodeFactory");
}
[AvaloniaTest]
public void ResourcesFileTreeNode_Falls_Through_For_Unhandled_Serialised_Types()
{
// A serialized entry whose type-name isn't ImageListStreamer must NOT be claimed by
// the ImageList factory; it should land in the generic OtherEntries collection so the
// existing serialized-object representation still renders it.
EnsureComposition();
var unrelatedNrbf = ImageListFixtures.BuildNrbfClassWithByteArrayMember(
"My.Custom.SerializedType", "MyAssembly", "Data", new byte[] { 1, 2, 3, 4 });
var resourcesBytes = BuildResourcesWithSerializedEntry(
"MyData", unrelatedNrbf, "My.Custom.SerializedType, MyAssembly");
var node = (ResourcesFileTreeNode)ResourceEntryNode.Create(
new ByteArrayResource("strings.resources", resourcesBytes));
node.EnsureLazyChildren();
node.Children.OfType<ImageListResourceEntryNode>().Should().BeEmpty(
"non-ImageListStreamer serialized entries must not be claimed by the ImageList factory");
node.OtherEntries.Should().HaveCount(1).And.Contain(e => e.Type.StartsWith("My.Custom.SerializedType"));
}
[AvaloniaTest]
public void ImageListResourceEntryNode_LoadChildren_Materialises_One_Child_Per_Frame()
{
// LoadChildren should decode the NRBF blob lazily and produce one frame node per
// frame, regardless of which depth the source ImageList used.
EnsureComposition();
using var fixture = ImageListFixtures.Build(ColorDepth.Depth32Bit, withMask: false, count: 5);
var node = new ImageListResourceEntryNode(
"MyImages",
() => new MemoryStream(fixture.NrbfBlob, writable: false));
node.EnsureLazyChildren();
node.Children.Should().HaveCount(5);
node.Children.Should().AllBeOfType<ImageListFrameNode>(
"every child of an ImageList parent node represents one decoded frame");
}
[AvaloniaTest]
public void ImageListResourceEntryNode_Decompile_Writes_Inline_Preview_For_Parent_Selection()
{
// Selecting the parent itself (not a child frame) must produce an inline preview row.
// We don't realise the Func<Control> here — the assertion is just "Decompile registered
// one UI element for the parent view".
EnsureComposition();
using var fixture = ImageListFixtures.Build(ColorDepth.Depth32Bit, withMask: false, count: 4);
var node = new ImageListResourceEntryNode(
"MyImages",
() => new MemoryStream(fixture.NrbfBlob, writable: false));
var output = new AvaloniaEditTextOutput();
var language = AppComposition.Current.GetExport<LanguageService>().CurrentLanguage;
node.Decompile(language, output, new DecompilationOptions());
output.UIElements.Should().HaveCount(1,
"the parent's Decompile contributes exactly one inline preview panel");
}
/// <summary>
/// Builds a .resources file containing a single serialized-object entry whose payload is
/// the supplied NRBF blob. Uses <see cref="PreserializedResourceWriter"/> so we don't have
/// to drive the obsolete <c>BinaryFormatter</c> just to wrap a payload in the right
/// .resources type-code envelope.
/// </summary>
static byte[] BuildResourcesWithSerializedEntry(string key, byte[] nrbfBlob, string typeName)
{
using var ms = new MemoryStream();
using (var writer = new PreserializedResourceWriter(ms))
{
// SYSLIB0011 — the API takes BinaryFormatter-shaped bytes (which our hand-rolled
// NRBF blob is, by design) and packs them into a .resources type-code entry.
// "Obsolete" just warns that consumers will need a deserializer that can handle
// NRBF — exactly what ResourcesFile does. Suppress the warning at the call site.
#pragma warning disable SYSLIB0011
writer.AddBinaryFormattedResource(key, nrbfBlob, typeName);
#pragma warning restore SYSLIB0011
writer.Generate();
}
return ms.ToArray();
}
}

228
ILSpy/TreeNodes/ImageListResourceEntryNode.cs

@ -0,0 +1,228 @@ @@ -0,0 +1,228 @@
// 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 System.IO;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
using ICSharpCode.Decompiler.IL;
using ILSpy.Commands;
using ILSpy.Languages;
using ILSpy.TextView;
using ILSpy.TreeNodes;
namespace ILSpy.ImageList
{
/// <summary>
/// Tree node for a <c>System.Windows.Forms.ImageListStreamer</c> resource entry. Lazily
/// decodes the underlying NRBF blob into per-frame <see cref="ImageListFrameNode"/>
/// children, and writes an inline horizontal preview of the whole sheet when the parent
/// itself is selected.
/// </summary>
public sealed class ImageListResourceEntryNode : ILSpyTreeNode
{
readonly string key;
readonly Func<Stream> openStream;
IReadOnlyList<DecodedImage>? cachedFrames;
Exception? cachedDecodeError;
public ImageListResourceEntryNode(string key, Func<Stream> openStream)
{
this.key = key ?? throw new ArgumentNullException(nameof(key));
this.openStream = openStream ?? throw new ArgumentNullException(nameof(openStream));
LazyLoading = true;
}
public override object Text => ILAmbience.EscapeName(key);
public override object Icon => Images.Images.Resource;
// Decode-once cache shared between LoadChildren and Decompile so opening the parent
// twice (tree expand + editor render) hits the NRBF parser exactly once.
IReadOnlyList<DecodedImage>? TryDecode(out Exception? error)
{
if (cachedFrames != null)
{ error = null; return cachedFrames; }
if (cachedDecodeError != null)
{ error = cachedDecodeError; return null; }
try
{
using var stream = openStream();
cachedFrames = ImageListDecoder.Decode(stream);
error = null;
return cachedFrames;
}
catch (Exception ex)
{
cachedDecodeError = ex;
error = ex;
return null;
}
}
protected override void LoadChildren()
{
var frames = TryDecode(out var error);
if (frames == null)
{
// Surface decode failures as a single child node so the user sees them in
// the tree rather than getting a silently-empty resource.
Children.Add(new DecodeErrorNode(error!));
return;
}
for (int i = 0; i < frames.Count; i++)
Children.Add(new ImageListFrameNode($"Image{i}", frames[i]));
}
public override void Decompile(Language language, ITextOutput output, DecompilationOptions options)
{
var frames = TryDecode(out var error);
if (frames == null)
{
language.WriteCommentLine(output, $"{key}: failed to decode ImageList — {error!.Message}");
return;
}
language.WriteCommentLine(output,
$"{key} — ImageList with {frames.Count} frame{(frames.Count == 1 ? "" : "s")} " +
$"({(frames.Count > 0 ? frames[0].Width + "x" + frames[0].Height : "0x0")} each)");
if (output is not ISmartTextOutput smart)
return;
smart.WriteLine();
smart.AddUIElement(() => BuildInlinePreview(frames));
smart.WriteLine();
}
static Control BuildInlinePreview(IReadOnlyList<DecodedImage> frames)
{
var panel = new WrapPanel { Margin = new Thickness(4), Orientation = Orientation.Horizontal };
foreach (var f in frames)
{
var image = new Image {
Source = ImageListBitmap.Create(f),
Width = f.Width,
Height = f.Height,
Margin = new Thickness(2),
};
panel.Children.Add(image);
}
return panel;
}
sealed class DecodeErrorNode : ILSpyTreeNode
{
readonly Exception ex;
public DecodeErrorNode(Exception ex) { this.ex = ex; }
public override object Text => $"<decode failed: {ex.Message}>";
public override object Icon => Images.Images.Resource;
}
}
/// <summary>
/// Single ImageList frame surfaced as its own tree node — selecting it shows the image
/// in the editor with a Save-as-PNG button.
/// </summary>
public sealed class ImageListFrameNode : ILSpyTreeNode
{
readonly string key;
readonly DecodedImage frame;
public ImageListFrameNode(string key, DecodedImage frame)
{
this.key = key ?? throw new ArgumentNullException(nameof(key));
this.frame = frame ?? throw new ArgumentNullException(nameof(frame));
}
public override object Text => key;
public override object Icon => Images.Images.Resource;
public override void Decompile(Language language, ITextOutput output, DecompilationOptions options)
{
language.WriteCommentLine(output, $"{key}: {frame.Width}x{frame.Height} BGRA");
if (output is not ISmartTextOutput smart)
return;
smart.WriteLine();
smart.AddUIElement(() => new Image {
Source = ImageListBitmap.Create(frame),
Width = frame.Width,
Height = frame.Height,
});
smart.WriteLine();
smart.AddButton(Images.Images.Save, "Save", async (_, _) => await SaveAsync().ConfigureAwait(false));
}
public override bool Save()
{
_ = SaveAsync();
return true;
}
async Task SaveAsync()
{
var defaultName = Path.GetFileNameWithoutExtension(
WholeProjectDecompiler.SanitizeFileName(key)) + ".png";
var path = await FilePickers.SaveAsync("PNG image (*.png)|*.png", defaultName).ConfigureAwait(false);
if (path == null)
return;
using var bmp = ImageListBitmap.Create(frame);
using var dst = File.Create(path);
bmp.Save(dst);
}
}
/// <summary>
/// Builds an Avalonia <see cref="WriteableBitmap"/> from a <see cref="DecodedImage"/>.
/// Lives in its own static class so the bitmap copy + stride handling can be unit-tested
/// independently of the tree node.
/// </summary>
public static class ImageListBitmap
{
public static WriteableBitmap Create(DecodedImage image)
{
ArgumentNullException.ThrowIfNull(image);
var bitmap = new WriteableBitmap(
new PixelSize(image.Width, image.Height),
new Vector(96, 96),
PixelFormat.Bgra8888,
AlphaFormat.Unpremul);
using var fb = bitmap.Lock();
int srcStride = image.Width * 4;
if (fb.RowBytes == srcStride)
{
Marshal.Copy(image.BgraPixels, 0, fb.Address, image.BgraPixels.Length);
}
else
{
for (int y = 0; y < image.Height; y++)
Marshal.Copy(image.BgraPixels, y * srcStride, fb.Address + y * fb.RowBytes, srcStride);
}
return bitmap;
}
}
}

55
ILSpy/TreeNodes/ImageListResourceNodeFactory.cs

@ -0,0 +1,55 @@ @@ -0,0 +1,55 @@
// 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.Composition;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.Decompiler.Util;
using ICSharpCode.ILSpyX.Abstractions;
namespace ILSpy.ImageList
{
/// <summary>
/// MEF-discovered factory that claims serialized <c>ImageListStreamer</c> entries inside
/// a <c>.resources</c> file and wraps them in an <see cref="ImageListResourceEntryNode"/>.
/// Claims by matching the entry's serialized type-name prefix; doesn't try to deserialize
/// at factory time so opening a tree node stays cheap.
/// </summary>
[Export(typeof(IResourceNodeFactory))]
[Shared]
public sealed class ImageListResourceNodeFactory : IResourceNodeFactory
{
// We don't claim plain Resource entries — only serialized-object entries get the
// ImageListStreamer treatment, and those come through the (string, ResourceSerializedObject) overload.
public ITreeNode? CreateNode(Resource resource) => null;
public ITreeNode? CreateNode(string key, ResourceSerializedObject serializedObject)
{
ArgumentNullException.ThrowIfNull(key);
ArgumentNullException.ThrowIfNull(serializedObject);
var typeName = serializedObject.TypeName;
if (typeName == null
|| !typeName.StartsWith(ImageListDecoder.TargetTypeNamePrefix, StringComparison.Ordinal))
{
return null;
}
return new ImageListResourceEntryNode(key, serializedObject.GetStream);
}
}
}

21
ILSpy/TreeNodes/ResourcesFileTreeNode.cs

@ -100,6 +100,7 @@ namespace ILSpy.TreeNodes @@ -100,6 +100,7 @@ namespace ILSpy.TreeNodes
otherEntries.Add(new SerializedObjectRepresentation(entry.Key, "null", string.Empty));
break;
case ResourceSerializedObject so:
if (!TryDispatchSerializedObject(entry.Key, so))
otherEntries.Add(new SerializedObjectRepresentation(entry.Key, so.TypeName ?? string.Empty, "<serialized>"));
break;
default:
@ -111,6 +112,26 @@ namespace ILSpy.TreeNodes @@ -111,6 +112,26 @@ namespace ILSpy.TreeNodes
}
}
/// <summary>
/// Walks <see cref="ILSpyTreeNode.ResourceNodeFactories"/> for a specialised tree
/// node that claims this serialized-object entry (e.g. an <c>ImageListStreamer</c>
/// blob). Returns true and adds the node to <see cref="Children"/> when claimed;
/// returns false to let the caller fall back to the generic "&lt;serialized&gt;"
/// representation.
/// </summary>
bool TryDispatchSerializedObject(string key, ResourceSerializedObject so)
{
foreach (var factory in ResourceNodeFactories)
{
if (factory.CreateNode(key, so) is ILSpyTreeNode node)
{
Children.Add(node);
return true;
}
}
return false;
}
public override bool Save()
{
_ = SaveDialogAsync();

Loading…
Cancel
Save