diff --git a/ILSpy.Tests/Options/DisplaySettingsReactionTests.cs b/ILSpy.Tests/Options/DisplaySettingsReactionTests.cs new file mode 100644 index 000000000..5b09ca435 --- /dev/null +++ b/ILSpy.Tests/Options/DisplaySettingsReactionTests.cs @@ -0,0 +1,110 @@ +// 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.ComponentModel; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; +using Avalonia.Threading; + +using AwesomeAssertions; + +using ILSpy; +using ILSpy.AppEnv; +using ILSpy.Options; +using ILSpy.TextView; +using ILSpy.TreeNodes; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +/// +/// The Options page is non-modal and live-apply (no "OK" step), so unlike WPF -- which did a full +/// assembly-list Refresh() when the modal dialog closed -- every display setting has to drive its own +/// live reaction. These tests pin that a decompiler-output setting actually re-decompiles the active +/// tab, and that every setting is classified so a newly-added one can't silently fall through. +/// +[TestFixture] +public class DisplaySettingsReactionTests +{ + static async Task Pump(int ticks = 12) + { + for (int i = 0; i < ticks; i++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(20); + } + } + + [AvaloniaTest] + public async Task Toggling_A_Decompiler_Output_Setting_Re_Decompiles_The_Active_Tab() + { + var (_, vm) = await TestHarness.BootAsync(); + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + vm.AssemblyTreeModel.SelectedItem = typeNode; + var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + int decompileStarts = 0; + void OnTab(object? _, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(DecompilerTabPageModel.IsDecompiling) && tab.IsDecompiling) + decompileStarts++; + } + tab.PropertyChanged += OnTab; + try + { + var display = AppComposition.Current.GetExport().DisplaySettings; + display.DecodeCustomAttributeBlobs = !display.DecodeCustomAttributeBlobs; + await Pump(); + } + finally + { + tab.PropertyChanged -= OnTab; + } + + decompileStarts.Should().BeGreaterThan(0, + "toggling a decompiler-output display setting must re-decompile the active tab " + + "(the live Options page has no full-refresh-on-close to fall back on)"); + } + + [Test] + public void Every_Display_Setting_Is_Classified() + { + // The safety net: each settable DisplaySettings property must appear in the reaction table, + // so adding a new setting forces a deliberate classification (editor / tree / re-decompile / + // none) instead of silently doing nothing. Set equality also catches stale entries left + // behind when a setting is removed. + var settable = typeof(DisplaySettings) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p is { CanRead: true, CanWrite: true }) + .Select(p => p.Name) + .ToHashSet(); + settable.Should().NotBeEmpty(); + + var classified = DisplaySettingReactions.ClassifiedProperties.ToHashSet(); + settable.Should().BeSubsetOf(classified, + "every settable DisplaySettings property must be classified in DisplaySettingReactions"); + classified.Should().BeSubsetOf(settable, + "DisplaySettingReactions must not reference settings that no longer exist"); + } +} diff --git a/ILSpy/AssemblyTree/AssemblyTreeModel.cs b/ILSpy/AssemblyTree/AssemblyTreeModel.cs index 23b6412e1..5018781f9 100644 --- a/ILSpy/AssemblyTree/AssemblyTreeModel.cs +++ b/ILSpy/AssemblyTree/AssemblyTreeModel.cs @@ -254,33 +254,45 @@ namespace ILSpy.AssemblyTree return; if (Root == null) return; - switch (e.Inner.PropertyName) + // One classification table (DisplaySettingReactions) drives every reaction, so a + // newly-added setting can't silently fall through -- the coverage test fails until it's + // listed. The Options page is non-modal/live-apply, so there is no full-refresh-on-close + // fallback the way the WPF host had; each bucket must do its own update. + var name = e.Inner.PropertyName; + switch (Options.DisplaySettingReactions.For(name)) { - case nameof(Options.DisplaySettings.ShowMetadataTokens): - case nameof(Options.DisplaySettings.ShowMetadataTokensInBase10): + case Options.DisplaySettingReaction.TreeText: // Text suffix on member nodes is computed at read time — just fire the // notification so bound cell templates re-pull. NotifyTextChanged(Root); break; - case nameof(Options.DisplaySettings.UseNestedNamespaceNodes): - // Tree shape changes — every loaded AssemblyTreeNode's namespace subtree - // needs rebuilding. - foreach (var asm in Root.Children.OfType()) - asm.ReloadChildren(); - // AssemblyListPane caches a snapshot of each expanded node's children via - // the HierarchicalOptions.ChildrenSelector — mid-expand mutations of - // node.Children aren't observed. Re-raising Root forces BindTree to fire, - // which creates a fresh HierarchicalModel that re-reads children on demand. + case Options.DisplaySettingReaction.TreeShape: + if (name == nameof(Options.DisplaySettings.UseNestedNamespaceNodes)) + { + // Every loaded AssemblyTreeNode's namespace subtree needs rebuilding. + foreach (var asm in Root.Children.OfType()) + asm.ReloadChildren(); + } + else + { + // Visible MetadataTablesTreeNode instances get their children regenerated; + // untouched (lazy) ones already pick up the new value on first expand. + RebuildMetadataTablesIn(Root); + } + // AssemblyListPane caches a snapshot of each expanded node's children via the + // HierarchicalOptions.ChildrenSelector — mid-expand mutations of node.Children + // aren't observed. Re-raising Root forces BindTree to fire, which creates a fresh + // HierarchicalModel that re-reads children on demand. OnPropertyChanged(nameof(Root)); break; - case nameof(Options.DisplaySettings.HideEmptyMetadataTables): - // Visible MetadataTablesTreeNode instances get their children regenerated; - // untouched (lazy) ones already pick up the new value on first expand. - RebuildMetadataTablesIn(Root); - // Same DataGrid-snapshot problem as the UseNestedNamespaceNodes branch — - // force a re-bind so the rebuilt children make it into the visible grid. - OnPropertyChanged(nameof(Root)); + case Options.DisplaySettingReaction.Redecompile: + // Baked into the decompiler/disassembler output (folding, member/using + // expansion, debug info, IL detail, indentation), so it only shows after a + // re-decompile of the active tab. + RefreshDecompiledView(); break; + // EditorLive / None: the text view applies editor settings to AvaloniaEdit itself, + // and the rest have no model-side reaction. } } diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index 9ff77513c..f903b4968 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -707,6 +707,11 @@ namespace ILSpy.Docking { lastShownNodes = null; ShowSelectedNode(); + // ShowSelectedNode re-projects the selection, but DecompilerTabPageModel.CurrentNodes + // dedups an unchanged node -- so a same-node refresh (a decompiler-output display setting, + // or freshly resolved dependencies) wouldn't actually re-run. Force the active decompiler + // tab to re-decompile so its output reflects the new state. + ActiveDecompilerTab?.Redecompile(); } void ShowSelectedNode() diff --git a/ILSpy/Options/DisplaySettingReactions.cs b/ILSpy/Options/DisplaySettingReactions.cs new file mode 100644 index 000000000..2b9263b3d --- /dev/null +++ b/ILSpy/Options/DisplaySettingReactions.cs @@ -0,0 +1,102 @@ +// 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.Collections.Generic; + +namespace ILSpy.Options +{ + /// + /// How a live change has to be surfaced. The Options page is + /// non-modal and applies immediately, so -- unlike the WPF host, which ran a full assembly-list + /// Refresh() when its modal Options dialog closed -- every setting has to drive its own + /// reaction or it silently does nothing. + /// + public enum DisplaySettingReaction + { + /// The text view applies it to the editor itself (font, line numbers, word wrap, + /// brace highlighting, ...); the assembly tree and the decompile output are unaffected. + EditorLive, + + /// Re-pull of member-node text only (the metadata-token suffix). + TreeText, + + /// In-place rebuild of a tree subtree whose shape the setting changes + /// (nested namespaces, hidden empty metadata tables). + TreeShape, + + /// Baked into the decompiler / disassembler output, so the active tab must be + /// re-decompiled for the change to show. + Redecompile, + + /// Deliberately no model-side reaction (window chrome, search ordering). + None, + } + + /// + /// Single source of truth classifying every property by its live + /// reaction. AssemblyTreeModel dispatches on it; DisplaySettingsReactionTests + /// asserts the table covers every setting, so a newly-added one can't fall through unhandled. + /// + public static class DisplaySettingReactions + { + static readonly Dictionary reactions = new() { + // Tree. + [nameof(DisplaySettings.ShowMetadataTokens)] = DisplaySettingReaction.TreeText, + [nameof(DisplaySettings.ShowMetadataTokensInBase10)] = DisplaySettingReaction.TreeText, + [nameof(DisplaySettings.UseNestedNamespaceNodes)] = DisplaySettingReaction.TreeShape, + [nameof(DisplaySettings.HideEmptyMetadataTables)] = DisplaySettingReaction.TreeShape, + + // Decompiler / disassembler output (see DecompilerTabPageModel.ApplyDisplaySettings + + // GetIndentationString, and CSharpILMixedLanguage / ILLanguage for the IL-detail ones). + [nameof(DisplaySettings.FoldBraces)] = DisplaySettingReaction.Redecompile, + [nameof(DisplaySettings.ExpandMemberDefinitions)] = DisplaySettingReaction.Redecompile, + [nameof(DisplaySettings.ExpandUsingDeclarations)] = DisplaySettingReaction.Redecompile, + [nameof(DisplaySettings.ShowDebugInfo)] = DisplaySettingReaction.Redecompile, + [nameof(DisplaySettings.ShowRawOffsetsAndBytesBeforeInstruction)] = DisplaySettingReaction.Redecompile, + [nameof(DisplaySettings.DecodeCustomAttributeBlobs)] = DisplaySettingReaction.Redecompile, + [nameof(DisplaySettings.IndentationUseTabs)] = DisplaySettingReaction.Redecompile, + [nameof(DisplaySettings.IndentationSize)] = DisplaySettingReaction.Redecompile, + [nameof(DisplaySettings.IndentationTabSize)] = DisplaySettingReaction.Redecompile, + + // Editor-only (DecompilerTextView applies these directly to the AvaloniaEdit control). + [nameof(DisplaySettings.SelectedFont)] = DisplaySettingReaction.EditorLive, + [nameof(DisplaySettings.SelectedFontSize)] = DisplaySettingReaction.EditorLive, + [nameof(DisplaySettings.ShowLineNumbers)] = DisplaySettingReaction.EditorLive, + [nameof(DisplaySettings.EnableWordWrap)] = DisplaySettingReaction.EditorLive, + [nameof(DisplaySettings.HighlightCurrentLine)] = DisplaySettingReaction.EditorLive, + [nameof(DisplaySettings.HighlightMatchingBraces)] = DisplaySettingReaction.EditorLive, + + // No model-side reaction. + [nameof(DisplaySettings.StyleWindowTitleBar)] = DisplaySettingReaction.None, + [nameof(DisplaySettings.SortResults)] = DisplaySettingReaction.None, + }; + + /// Every classified property name. The coverage test asserts this equals the set of + /// settable properties. + public static IReadOnlyCollection ClassifiedProperties => reactions.Keys; + + /// + /// The reaction for , or + /// for an unclassified one (the coverage test prevents that case from reaching production). + /// + public static DisplaySettingReaction For(string? propertyName) + => propertyName != null && reactions.TryGetValue(propertyName, out var reaction) + ? reaction + : DisplaySettingReaction.None; + } +} diff --git a/ILSpy/Options/DisplaySettings.cs b/ILSpy/Options/DisplaySettings.cs index 356431efa..a27c3f4f7 100644 --- a/ILSpy/Options/DisplaySettings.cs +++ b/ILSpy/Options/DisplaySettings.cs @@ -93,9 +93,6 @@ namespace ILSpy.Options [ObservableProperty] bool showRawOffsetsAndBytesBeforeInstruction; - [ObservableProperty] - bool enableSmoothScrolling = true; - [ObservableProperty] bool decodeCustomAttributeBlobs; @@ -123,7 +120,6 @@ namespace ILSpy.Options UseNestedNamespaceNodes = (bool?)section.Attribute(nameof(UseNestedNamespaceNodes)) ?? false; ShowRawOffsetsAndBytesBeforeInstruction = (bool?)section.Attribute(nameof(ShowRawOffsetsAndBytesBeforeInstruction)) ?? false; StyleWindowTitleBar = (bool?)section.Attribute(nameof(StyleWindowTitleBar)) ?? false; - EnableSmoothScrolling = (bool?)section.Attribute(nameof(EnableSmoothScrolling)) ?? true; DecodeCustomAttributeBlobs = (bool?)section.Attribute(nameof(DecodeCustomAttributeBlobs)) ?? false; } @@ -150,7 +146,6 @@ namespace ILSpy.Options section.SetAttributeValue(nameof(UseNestedNamespaceNodes), UseNestedNamespaceNodes); section.SetAttributeValue(nameof(ShowRawOffsetsAndBytesBeforeInstruction), ShowRawOffsetsAndBytesBeforeInstruction); section.SetAttributeValue(nameof(StyleWindowTitleBar), StyleWindowTitleBar); - section.SetAttributeValue(nameof(EnableSmoothScrolling), EnableSmoothScrolling); section.SetAttributeValue(nameof(DecodeCustomAttributeBlobs), DecodeCustomAttributeBlobs); return section; } diff --git a/ILSpy/Options/DisplaySettingsPanel.axaml b/ILSpy/Options/DisplaySettingsPanel.axaml index a33aff383..4e13dd4fe 100644 --- a/ILSpy/Options/DisplaySettingsPanel.axaml +++ b/ILSpy/Options/DisplaySettingsPanel.axaml @@ -81,8 +81,6 @@ Content="{x:Static res:Resources.ShowRawOffsetsAndBytesBeforeInstruction}" /> - diff --git a/ILSpy/Properties/Resources.Designer.cs b/ILSpy/Properties/Resources.Designer.cs index 4d907d73b..b314262c2 100644 --- a/ILSpy/Properties/Resources.Designer.cs +++ b/ILSpy/Properties/Resources.Designer.cs @@ -1848,15 +1848,6 @@ namespace ICSharpCode.ILSpy.Properties { } } - /// - /// Looks up a localized string similar to Enable smooth scrolling. - /// - public static string EnableSmoothScrolling { - get { - return ResourceManager.GetString("EnableSmoothScrolling", resourceCulture); - } - } - /// /// Looks up a localized string similar to Enable word wrap. /// diff --git a/ILSpy/Properties/Resources.resx b/ILSpy/Properties/Resources.resx index 34cf2a046..0511a6f71 100644 --- a/ILSpy/Properties/Resources.resx +++ b/ILSpy/Properties/Resources.resx @@ -642,9 +642,6 @@ Are you sure you want to continue? Enable folding on all blocks in braces - - Enable smooth scrolling - Enable word wrap diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index 3c76e745e..2c80ee247 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -324,6 +324,17 @@ namespace ILSpy.TextView StartDecompile(); } + /// + /// Forces a fresh decompilation of the current nodes, bypassing the + /// dedup. Used by ForceRefreshActiveTab when a display setting or freshly resolved + /// dependencies changed but the selected node did not. No-op when nothing is being decompiled. + /// + public void Redecompile() + { + if (CurrentNodes.Count > 0) + StartDecompile(); + } + // Built (off the UI thread) when a decompile blows past its output limit: a short message plus // a "Display code anyway" button (retry at the extended limit, only offered for the normal // limit) and a "Save code" button. The button click handlers run back on the UI thread.