From c7273d528d14b642884369b5a11e91f30522689e Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Wed, 12 Aug 2026 15:41:45 +0200 Subject: [PATCH] Follow the Windows system UI font for menus, trees, and dialogs Avalonia's built-in default (Segoe UI at 12 DIP) matches a stock Windows install, but the accessibility "Text size" setting (Settings > Accessibility > Text size) scales the NONCLIENTMETRICS fonts without changing DPI, so only applications that read those metrics follow it. ILSpy ignored the setting entirely. Read the message font via SystemParametersInfoForDpi at 96 DPI (so the value is in DIPs regardless of display scaling) and apply it two ways: a style on every TopLevel, not just Window, so popup surfaces (menu dropdowns, tooltips) receive the font directly instead of relying on inheritance across the popup boundary; and an app-level shadow of the Simple theme's FontSizeNormal resource, because the theme pins FontSize through it on a few control themes (ContextMenu is the one ILSpy hits) and a control-theme setter beats the inherited value. The decompiled-code editor is unaffected; it takes its font from DisplaySettings. Metrics are read once at startup; live WM_SETTINGCHANGE tracking was deliberately left out, matching how the UI culture setting behaves. Headless UI tests stay on the fixed defaults because TestApp does not run the production OnFrameworkInitializationCompleted, keeping them deterministic on machines with text scaling active. Assisted-by: Claude:claude-fable-5:Claude Code --- ILSpy.Tests.Windows/WindowsSystemFontTests.cs | 44 +++++++ ILSpy.Tests/UiFontTests.cs | 83 +++++++++++++ ILSpy/App.axaml.cs | 50 ++++++++ ILSpy/Util/WindowsSystemFont.cs | 116 ++++++++++++++++++ 4 files changed, 293 insertions(+) create mode 100644 ILSpy.Tests.Windows/WindowsSystemFontTests.cs create mode 100644 ILSpy.Tests/UiFontTests.cs create mode 100644 ILSpy/Util/WindowsSystemFont.cs diff --git a/ILSpy.Tests.Windows/WindowsSystemFontTests.cs b/ILSpy.Tests.Windows/WindowsSystemFontTests.cs new file mode 100644 index 0000000000..39641a4322 --- /dev/null +++ b/ILSpy.Tests.Windows/WindowsSystemFontTests.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Christoph Wille +// +// 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.ILSpy.Util; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Windows; + +/// +/// Verifies the NONCLIENTMETRICS message-font reader used to follow the Windows system UI font. +/// The exact values depend on the machine (the accessibility "Text size" setting scales them), +/// so the assertions only pin down what must hold everywhere: a face name is present and the +/// size is a sane DIP value (metrics are requested at 96 DPI, so 12 on a default install, +/// larger when text scaling is active). +/// +[TestFixture] +public class WindowsSystemFontTests +{ + [Test] + public void MessageFontIsReadable() + { + bool available = WindowsSystemFont.TryGetMessageFont(out var faceName, out var fontSize); + + Assert.That(available, Is.True); + Assert.That(faceName, Is.Not.Null.And.Not.Empty); + Assert.That(fontSize, Is.InRange(9.0, 48.0)); + } +} diff --git a/ILSpy.Tests/UiFontTests.cs b/ILSpy.Tests/UiFontTests.cs new file mode 100644 index 0000000000..a8a6b6768d --- /dev/null +++ b/ILSpy.Tests/UiFontTests.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Christoph Wille +// +// 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.Headless.NUnit; +using Avalonia.Styling; + +using AwesomeAssertions; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +/// +/// Exercises App.ApplyUiFont (the system-UI-font override) with a synthetic size, since the +/// production path reads machine-dependent NONCLIENTMETRICS. The interesting case is +/// ContextMenu: the Simple theme pins its FontSize to the theme's FontSizeNormal resource, +/// which outranks the inherited value that covers windows and menu-bar dropdowns, so a +/// context menu silently stays at 12 unless the override also reaches that resource. +/// +[TestFixture] +public class UiFontTests +{ + // Distinct from every size the Simple theme uses (10/12/16) so a match proves the override. + const double TestFontSize = 20; + + [AvaloniaTest] + public void UiFontReachesWindowAndContextMenu() + { + var app = Application.Current!; + int styleCount = app.Styles.Count; + bool hadFontSizeNormal = app.Resources.TryGetValue("FontSizeNormal", out var previousFontSizeNormal); + + try + { + App.ApplyUiFont(app, "Segoe UI", TestFontSize); + + var contextMenu = new ContextMenu { + Items = { new MenuItem { Header = "Item" } }, + }; + var target = new Button { ContextMenu = contextMenu }; + var window = new Window { Content = target }; + window.Show(); + + window.FontSize.Should().Be(TestFontSize, "the TopLevel style must reach windows"); + + contextMenu.Open(target); + contextMenu.FontSize.Should().Be(TestFontSize, + "the override must beat the Simple theme's FontSizeNormal pin on ContextMenu"); + + window.Close(); + } + finally + { + // App styles/resources are per-assembly shared state (ResetAppState rebuilds the MEF + // container, not the Application); undo so later tests keep the default 12. + while (app.Styles.Count > styleCount) + app.Styles.RemoveAt(app.Styles.Count - 1); + if (hadFontSizeNormal) + app.Resources["FontSizeNormal"] = previousFontSizeNormal; + else + app.Resources.Remove("FontSizeNormal"); + } + } +} diff --git a/ILSpy/App.axaml.cs b/ILSpy/App.axaml.cs index ba66e8aacd..57dcdac1c4 100644 --- a/ILSpy/App.axaml.cs +++ b/ILSpy/App.axaml.cs @@ -23,8 +23,12 @@ using System.Threading.Tasks; using Avalonia; +using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Controls.Primitives; using Avalonia.Markup.Xaml; +using Avalonia.Media; +using Avalonia.Styling; using Avalonia.Threading; using ICSharpCode.ILSpyX.Settings; @@ -32,6 +36,7 @@ using ICSharpCode.ILSpy.AppEnv; using ICSharpCode.ILSpy.AssemblyTree; using ICSharpCode.ILSpy.Themes; +using ICSharpCode.ILSpy.Util; using ICSharpCode.ILSpy.Views; namespace ICSharpCode.ILSpy @@ -85,6 +90,8 @@ public override void OnFrameworkInitializationCompleted() StartupExceptions.Items.Add(new ExceptionData(ex)); } + ApplySystemFont(); + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { MainWindow? mainWindow = null; @@ -123,6 +130,49 @@ public override void OnFrameworkInitializationCompleted() base.OnFrameworkInitializationCompleted(); } + // Follows the Windows system UI font instead of Avalonia's built-in Segoe UI 12 default. + // The two coincide on a stock install, but the accessibility "Text size" setting scales + // the system metrics without changing DPI, and only apps that read them honor it. + // No-op on non-Windows platforms. + // + // The following deliberately do NOT follow the system font: + // - the decompiled-code editor: user-configurable via DisplaySettings.SelectedFont/Size; + // - controls with an explicit FontSize in their XAML: search-pane results, the resource + // string/object tables (whose fixed RowHeight is tuned to that size), the zoom-buttons + // overlay, the NuGet feed dialog, the XML-doc renderer, and the startup-error / + // assertion dialogs; + // - Simple-theme controls sized via FontSizeSmall/FontSizeLarge rather than + // FontSizeNormal: TabItem headers (Options dialog, already larger than body text) + // and Calendar buttons (unused in ILSpy). + void ApplySystemFont() + { + if (!WindowsSystemFont.TryGetMessageFont(out var faceName, out var fontSize)) + return; + + ApplyUiFont(this, faceName, fontSize); + AppLog.Mark($"System font applied: {faceName} @ {fontSize}"); + } + + // Styled on every TopLevel (not just Window) so popup surfaces -- menu dropdowns, context + // menus, tooltips -- get the font directly rather than relying on inheritance across the + // popup boundary. Internal so the headless test suite can exercise it with a synthetic + // size instead of the machine-dependent system metrics. + internal static void ApplyUiFont(Application app, string faceName, double fontSize) + { + var fontStyle = new Style(x => x.Is()); + fontStyle.Setters.Add(new Setter(TemplatedControl.FontFamilyProperty, new FontFamily(faceName))); + fontStyle.Setters.Add(new Setter(TemplatedControl.FontSizeProperty, fontSize)); + app.Styles.Add(fontStyle); + + // The Simple theme pins FontSize on a few control themes via its FontSizeNormal + // resource (ContextMenu is the one ILSpy hits; also PopupRoot/Window, where the + // style above already outranks it). A control-theme setter beats the inherited + // value, so shadow the resource at app level -- application resources win the + // DynamicResource lookup over theme resources. FontSizeSmall/FontSizeLarge stay + // untouched; their consumers are listed on ApplySystemFont. + app.Resources["FontSizeNormal"] = fontSize; + } + // Resolves where ILSpy.xml is loaded from. Shared by normal startup and the single-instance // gate in Program.Main, which reads the settings before Avalonia starts. internal static void ConfigureSettingsFilePathProvider(CommandLineArguments commandLineArguments) diff --git a/ILSpy/Util/WindowsSystemFont.cs b/ILSpy/Util/WindowsSystemFont.cs new file mode 100644 index 0000000000..07524bae43 --- /dev/null +++ b/ILSpy/Util/WindowsSystemFont.cs @@ -0,0 +1,116 @@ +// Copyright (c) 2026 Christoph Wille +// +// 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.Runtime.InteropServices; + +#pragma warning disable CA1060 // Move pinvokes to native methods class + +namespace ICSharpCode.ILSpy.Util +{ + /// + /// Reads the Windows system UI font (the NONCLIENTMETRICS message font). Avalonia's built-in + /// default (Segoe UI at 12 DIP) matches a stock Windows install, but the accessibility + /// "Text size" setting (Settings > Accessibility > Text size) scales these metrics + /// without changing DPI, and only apps that read them follow it. + /// + public static partial class WindowsSystemFont + { + const uint SPI_GETNONCLIENTMETRICS = 0x0029; + + [StructLayout(LayoutKind.Sequential)] + unsafe struct LOGFONTW + { + public int lfHeight; + public int lfWidth; + public int lfEscapement; + public int lfOrientation; + public int lfWeight; + public byte lfItalic; + public byte lfUnderline; + public byte lfStrikeOut; + public byte lfCharSet; + public byte lfOutPrecision; + public byte lfClipPrecision; + public byte lfQuality; + public byte lfPitchAndFamily; + public fixed char lfFaceName[32]; + } + + [StructLayout(LayoutKind.Sequential)] + struct NONCLIENTMETRICSW + { + public uint cbSize; + public int iBorderWidth; + public int iScrollWidth; + public int iScrollHeight; + public int iCaptionWidth; + public int iCaptionHeight; + public LOGFONTW lfCaptionFont; + public int iSmCaptionWidth; + public int iSmCaptionHeight; + public LOGFONTW lfSmCaptionFont; + public int iMenuWidth; + public int iMenuHeight; + public LOGFONTW lfMenuFont; + public LOGFONTW lfStatusFont; + public LOGFONTW lfMessageFont; + public int iPaddedBorderWidth; + } + + [LibraryImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SystemParametersInfoForDpi(uint uiAction, uint uiParam, ref NONCLIENTMETRICSW pvParam, uint fWinIni, uint dpi); + + /// + /// Gets the system message font as an Avalonia-ready (family name, size in DIPs) pair. + /// Returns false on non-Windows platforms or if the metrics cannot be read. The metrics + /// are requested at 96 DPI, so the size is in device-independent pixels regardless of + /// display scaling (which Avalonia applies separately). + /// + public static unsafe bool TryGetMessageFont(out string faceName, out double fontSize) + { + faceName = string.Empty; + fontSize = 0; + + // SystemParametersInfoForDpi requires Windows 10 1607. + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 14393)) + return false; + + var metrics = new NONCLIENTMETRICSW { cbSize = (uint)sizeof(NONCLIENTMETRICSW) }; + if (!SystemParametersInfoForDpi(SPI_GETNONCLIENTMETRICS, metrics.cbSize, ref metrics, 0, 96)) + return false; + + // A negative lfHeight is the character height (the usual case for the message font); + // a positive one is the cell height. Either way the magnitude is the pixel size at + // the requested DPI. + int height = Math.Abs(metrics.lfMessageFont.lfHeight); + if (height <= 0) + return false; + + var nameBuffer = new ReadOnlySpan(metrics.lfMessageFont.lfFaceName, 32); + int terminator = nameBuffer.IndexOf('\0'); + faceName = new string(terminator >= 0 ? nameBuffer[..terminator] : nameBuffer); + if (faceName.Length == 0) + return false; + + fontSize = height; + return true; + } + } +}