Browse Source

Options tab — live bindings, left tabs, font preview

Three changes:

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
80f72ba48e
  1. 26
      ILSpy.Tests/Options/OptionsTabTests.cs
  2. 4
      ILSpy/Options/DecompilerSettingsViewModel.cs
  3. 7
      ILSpy/Options/DisplaySettingsPanel.axaml
  4. 27
      ILSpy/Options/DisplaySettingsViewModel.cs
  5. 9
      ILSpy/Options/IOptionPage.cs
  6. 4
      ILSpy/Options/MiscSettingsViewModel.cs
  7. 23
      ILSpy/Options/OptionsPageModel.cs
  8. 12
      ILSpy/Options/OptionsPageView.axaml
  9. 55
      ILSpy/Options/SettingsSnapshot.cs
  10. 30
      ILSpy/SettingsService.cs

26
ILSpy.Tests/Options/OptionsTabTests.cs

@ -128,12 +128,13 @@ public class OptionsTabTests @@ -128,12 +128,13 @@ public class OptionsTabTests
}
[AvaloniaTest]
public async Task Apply_Writes_Snapshot_Through_To_Live_DecompilerSettings()
public void Panel_Edits_Take_Effect_Immediately_On_Live_DecompilerSettings()
{
// Snapshot pattern: changes in the panel's settings instance don't reach the live
// service until Apply. After Apply, SettingsService.DecompilerSettings reflects the
// new value. Toggling a property on the snapshot copy alone shouldn't affect the
// live instance until Apply is called.
// Live-binding architecture (no snapshot): toggling a checkbox in the Decompiler
// panel mutates the live SettingsService.DecompilerSettings instance directly. No
// Apply step. Subscribers to DecompilerSettings.PropertyChanged see the change
// immediately; the next decompile pulls the new value via Clone() in
// DecompilerTabPageModel.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var settings = AppComposition.Current.GetExport<SettingsService>();
@ -149,26 +150,17 @@ public class OptionsTabTests @@ -149,26 +150,17 @@ public class OptionsTabTests
.OfType<ContentTabPage>().First(t => t.Content is OptionsPageModel).Content!;
var decompilerPage = (DecompilerSettingsViewModel)model.Pages[0];
// Find the UsingDeclarations item in the reflection-built tree and flip it.
// Flip UsingDeclarations through the panel viewmodel.
var usingDecl = decompilerPage.Settings
.SelectMany(g => g.Settings)
.First(s => s.Property.Name == nameof(global::ICSharpCode.Decompiler.DecompilerSettings.UsingDeclarations));
usingDecl.IsEnabled = !liveBefore;
// Live service unaffected so far — only the snapshot copy changed.
// Snapshot edits must not leak into the live service before Apply.
settings.DecompilerSettings.UsingDeclarations.Should().Be(liveBefore);
// Apply — flushes snapshot to XML and reloads sections so live values match.
model.ApplyCommand.Execute(null);
await Task.Yield();
// After Apply, the live service must see the panel's toggled value.
// Live service must see the change immediately — no Apply needed.
settings.DecompilerSettings.UsingDeclarations.Should().Be(!liveBefore);
// Clean-up: restore the original so the persisted XML doesn't pollute later tests.
// Clean-up: restore the original so the next test sees a clean slate.
usingDecl.IsEnabled = liveBefore;
model.ApplyCommand.Execute(null);
}
[AvaloniaTest]

4
ILSpy/Options/DecompilerSettingsViewModel.cs

@ -58,9 +58,9 @@ namespace ILSpy.Options @@ -58,9 +58,9 @@ namespace ILSpy.Options
DecompilerSettings decompilerSettings = null!;
public void Load(SettingsSnapshot snapshot)
public void Load(SettingsService settings)
{
decompilerSettings = snapshot.GetSettings<DecompilerSettings>();
decompilerSettings = settings.DecompilerSettings;
LoadSettings();
}

7
ILSpy/Options/DisplaySettingsPanel.axaml

@ -20,8 +20,13 @@ @@ -20,8 +20,13 @@
Value="{Binding Settings.SelectedFontSize, Mode=TwoWay}" />
<Border Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2"
BorderBrush="#FFCCCEDB" BorderThickness="1" Padding="6" Margin="0,2,0,0">
<!-- FontFamily binds to a derived FontFamily property because Avalonia's
runtime binding pipeline doesn't coerce string → FontFamily (the
implicit conversion only fires at XAML parse time). The viewmodel
projects Settings.SelectedFont through the right type and re-fires
PropertyChanged when the picker selection changes. -->
<TextBlock Text="The quick brown fox jumps over the lazy dog"
FontFamily="{Binding Settings.SelectedFont}"
FontFamily="{Binding CurrentFontFamily}"
FontSize="{Binding Settings.SelectedFontSize}" />
</Border>
</Grid>

27
ILSpy/Options/DisplaySettingsViewModel.cs

@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
// DEALINGS IN THE SOFTWARE.
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Xml.Linq;
@ -47,9 +48,31 @@ namespace ILSpy.Options @@ -47,9 +48,31 @@ namespace ILSpy.Options
.OrderBy(n => n, System.StringComparer.OrdinalIgnoreCase)
.ToArray();
public void Load(SettingsSnapshot snapshot)
/// <summary>
/// Derived FontFamily for the preview TextBlock. Avalonia's runtime binding pipeline
/// doesn't auto-coerce a <c>string</c> source to <see cref="FontFamily"/> (the implicit
/// conversion fires at XAML parse time, not on binding evaluation), so the preview
/// goes through this property — recomputed on every <see cref="DisplaySettings.SelectedFont"/>
/// change via the PropertyChanged subscription in <see cref="Load"/>.
/// </summary>
public FontFamily CurrentFontFamily =>
Settings != null && !string.IsNullOrEmpty(Settings.SelectedFont)
? new FontFamily(Settings.SelectedFont)
: FontFamily.Default;
public void Load(SettingsService service)
{
if (Settings != null)
Settings.PropertyChanged -= OnSettingsPropertyChanged;
Settings = service.DisplaySettings;
Settings.PropertyChanged += OnSettingsPropertyChanged;
OnPropertyChanged(nameof(CurrentFontFamily));
}
void OnSettingsPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
Settings = snapshot.GetSettings<DisplaySettings>();
if (e.PropertyName == nameof(DisplaySettings.SelectedFont))
OnPropertyChanged(nameof(CurrentFontFamily));
}
public void LoadDefaults()

9
ILSpy/Options/IOptionPage.cs

@ -21,14 +21,17 @@ namespace ILSpy.Options @@ -21,14 +21,17 @@ namespace ILSpy.Options
/// <summary>
/// Plugin contract for individual panels in the Options tab. Each implementation is
/// MEF-discovered via <see cref="ExportOptionPageAttribute"/>; the host calls
/// <see cref="Load"/> with a snapshot of the current settings when the tab opens, and
/// <see cref="LoadDefaults"/> when the user clicks the per-panel reset button.
/// <see cref="Load"/> with the live settings service when the tab opens, and
/// <see cref="LoadDefaults"/> when the user clicks the per-panel reset button. Panels
/// bind directly to the live section instances — every toggle is immediately visible
/// to other consumers (the decompiler view, the tree, etc.). XML persistence rides on
/// <see cref="ILSpy.SettingsService.Save"/> at app exit.
/// </summary>
public interface IOptionPage
{
string Title { get; }
void Load(SettingsSnapshot settings);
void Load(SettingsService settings);
void LoadDefaults();
}

4
ILSpy/Options/MiscSettingsViewModel.cs

@ -35,9 +35,9 @@ namespace ILSpy.Options @@ -35,9 +35,9 @@ namespace ILSpy.Options
[ObservableProperty]
MiscSettings settings = null!;
public void Load(SettingsSnapshot snapshot)
public void Load(SettingsService service)
{
Settings = snapshot.GetSettings<MiscSettings>();
Settings = service.MiscSettings;
}
public void LoadDefaults()

23
ILSpy/Options/OptionsPageModel.cs

@ -28,30 +28,31 @@ using ICSharpCode.ILSpy.Properties; @@ -28,30 +28,31 @@ using ICSharpCode.ILSpy.Properties;
namespace ILSpy.Options
{
/// <summary>
/// Content viewmodel for the Options document tab. Wraps a <see cref="SettingsSnapshot"/>,
/// MEF-discovered <see cref="IOptionPage"/> panels (ordered by <see cref="IOptionsMetadata.Order"/>),
/// and the page-level Apply / Reset commands the panel views bind to.
/// Content viewmodel for the Options document tab. Wraps the live
/// <see cref="SettingsService"/>, MEF-discovered <see cref="IOptionPage"/> panels
/// (ordered by <see cref="IOptionsMetadata.Order"/>), and a Reset command that
/// rolls the currently-selected panel back to its built-in defaults.
/// <para>
/// Panels bind two-way to the live section instances, so every toggle takes effect
/// immediately — there's no Apply step. XML persistence rides on
/// <see cref="SettingsService.Save"/> at app exit.
/// </para>
/// </summary>
public sealed partial class OptionsPageModel : ObservableObject
{
readonly SettingsSnapshot snapshot;
public OptionsPageModel(SettingsService settingsService, IEnumerable<ExportFactory<IOptionPage, IOptionsMetadata>> pageFactories)
{
snapshot = settingsService.CreateSnapshot();
Pages = pageFactories
.OrderBy(f => f.Metadata.Order)
.Select(f => f.CreateExport().Value)
.ToArray();
foreach (var page in Pages)
page.Load(snapshot);
page.Load(settingsService);
selectedPage = Pages.FirstOrDefault();
Title = Resources._Options;
ApplyCommand = new RelayCommand(Apply);
ResetCurrentPageCommand = new RelayCommand(ResetCurrentPage);
}
@ -67,12 +68,8 @@ namespace ILSpy.Options @@ -67,12 +68,8 @@ namespace ILSpy.Options
[ObservableProperty]
IOptionPage? selectedPage;
public IRelayCommand ApplyCommand { get; }
public IRelayCommand ResetCurrentPageCommand { get; }
void Apply() => snapshot.Save();
void ResetCurrentPage() => SelectedPage?.LoadDefaults();
}
}

12
ILSpy/Options/OptionsPageView.axaml

@ -9,19 +9,17 @@ @@ -9,19 +9,17 @@
x:Class="ILSpy.Options.OptionsPageView"
x:DataType="vm:OptionsPageModel">
<DockPanel LastChildFill="True">
<!-- Apply / Reset live in the page footer rather than per-panel. Functionally
identical to a per-panel Apply (every Apply commits the full snapshot);
keeps the buttons accessible regardless of which inner tab is active and
avoids parent-binding gymnastics from inside each panel's DataContext. -->
<!-- Reset rolls the currently-selected panel back to its built-in defaults.
No Apply button — panel edits are live, applied immediately to the live
SettingsService instances. XML persistence rides on app-exit Save. -->
<Border DockPanel.Dock="Bottom" BorderBrush="#FFC8CDD3" BorderThickness="0,1,0,0" Padding="8">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6">
<Button Content="{x:Static res:Resources.ResetToDefaults}"
Command="{Binding ResetCurrentPageCommand}" />
<Button Content="{x:Static res:Resources.OK}"
Command="{Binding ApplyCommand}" />
</StackPanel>
</Border>
<TabControl ItemsSource="{Binding Pages}"
<TabControl TabStripPlacement="Left"
ItemsSource="{Binding Pages}"
SelectedItem="{Binding SelectedPage, Mode=TwoWay}">
<TabControl.ItemTemplate>
<DataTemplate x:DataType="vm:IOptionPage">

55
ILSpy/Options/SettingsSnapshot.cs

@ -1,55 +0,0 @@ @@ -1,55 +0,0 @@
// 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 ICSharpCode.ILSpyX;
using ICSharpCode.ILSpyX.Settings;
namespace ILSpy.Options
{
/// <summary>
/// In-memory copy of the persisted settings, used by the Options tab so panel edits
/// don't reach the live <see cref="ILSpy.SettingsService"/> until the user
/// clicks Apply. Inherits the standard <see cref="SettingsServiceBase.GetSettings{T}"/>
/// load+subscribe machinery from the shared base — sections materialise on first
/// access reading from the same XML root as the parent service, but are independent
/// object graphs. <see cref="Save"/> writes those graphs back through the parent's
/// <see cref="ISettingsProvider"/> and asks the parent to reload so subscribers see
/// the new values.
/// </summary>
public sealed class SettingsSnapshot : SettingsServiceBase
{
readonly SettingsService parent;
public SettingsSnapshot(SettingsService parent, ISettingsProvider spySettings) : base(spySettings)
{
this.parent = parent;
}
public void Save()
{
SpySettings.Update(root => {
foreach (var section in sections.Values)
{
SaveSection(section, root);
}
});
parent.Reload();
}
}
}

30
ILSpy/SettingsService.cs

@ -16,7 +16,6 @@ @@ -16,7 +16,6 @@
// 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.ILSpyX;
@ -45,34 +44,11 @@ namespace ILSpy @@ -45,34 +44,11 @@ namespace ILSpy
AssemblyListManager? assemblyListManager;
public AssemblyListManager AssemblyListManager => assemblyListManager ??= new(SpySettings);
/// <summary>Fired after <see cref="Reload"/> propagates fresh values from disk into the
/// live section instances. Subscribers (e.g. the assembly tree, the decompiler view)
/// observe this to refresh derived UI state.</summary>
public event EventHandler? SettingsChanged;
/// <summary>
/// Creates a parallel <see cref="SettingsSnapshot"/> bound to the same XML root. The
/// snapshot returns fresh, independent section instances on first access — Options
/// panels mutate those copies so the live service stays untouched until
/// <see cref="SettingsSnapshot.Save"/>.
/// </summary>
public SettingsSnapshot CreateSnapshot() => new(this, SpySettings);
/// <summary>
/// Reloads every materialised section from <see cref="SpySettings"/>. Called after a
/// snapshot commits so the live instances pick up the just-written values without
/// rebuilding the dictionary (subscribers see PropertyChanged for every changed field).
/// Persists every materialised section to disk. Options panels bind two-way to the
/// live instances so no in-memory commit is needed — <see cref="Save"/> just flushes
/// current state to XML. Called at app-exit from <c>App.axaml.cs</c>.
/// </summary>
public void Reload()
{
foreach (var section in sections.Values)
{
var element = SpySettings[section.SectionName];
section.LoadFromXml(element);
}
SettingsChanged?.Invoke(this, EventArgs.Empty);
}
public void Save()
{
SpySettings.Update(root => {

Loading…
Cancel
Save