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; + } + } +}