mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
Assisted-by: Claude:claude-opus-4-7:Claude Code Assisted-by: Claude:claude-opus-4-7:Claude Codepull/3755/head
6 changed files with 475 additions and 1 deletions
@ -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(); |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue