From 05ae000bad731c4978d1e91b781640d9b6dc96be Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Thu, 14 May 2026 22:31:58 +0200 Subject: [PATCH] =?UTF-8?q?Editor=20zoom=20=E2=80=94=20Ctrl+Wheel=20scale?= =?UTF-8?q?=20+=20corner=20button=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors WPF's ZoomScrollViewer behaviour without templating AvaloniaEdit's TextEditor. The Avalonia path scales the editor's FontSize (which feeds DisplaySettings.SelectedFontSize) on Ctrl+Wheel; AvaloniaEdit lays out at the new size on the next render. WPF's full-content-tree LayoutTransform approach doesn't fit AvaloniaEdit's architecture, but the font-scale result is what users actually expect from "editor zoom". Assisted-by: Claude:claude-opus-4-7:Claude Code --- ILSpy.Tests/Editor/EditorZoomTests.cs | 82 +++++++++++++ ILSpy/TextView/DecompilerTextView.axaml | 4 + ILSpy/TextView/DecompilerTextView.axaml.cs | 54 +++++++++ ILSpy/TextView/EditorZoom.cs | 58 +++++++++ ILSpy/TextView/ZoomButtons.axaml | 19 +++ ILSpy/TextView/ZoomButtons.axaml.cs | 135 +++++++++++++++++++++ 6 files changed, 352 insertions(+) create mode 100644 ILSpy.Tests/Editor/EditorZoomTests.cs create mode 100644 ILSpy/TextView/EditorZoom.cs create mode 100644 ILSpy/TextView/ZoomButtons.axaml create mode 100644 ILSpy/TextView/ZoomButtons.axaml.cs diff --git a/ILSpy.Tests/Editor/EditorZoomTests.cs b/ILSpy.Tests/Editor/EditorZoomTests.cs new file mode 100644 index 000000000..5418a0daf --- /dev/null +++ b/ILSpy.Tests/Editor/EditorZoomTests.cs @@ -0,0 +1,82 @@ +// 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 AwesomeAssertions; + +using ILSpy.TextView; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +[TestFixture] +public class EditorZoomTests +{ + [Test] + public void ZoomIn_Multiplies_By_Factor() + { + EditorZoom.ZoomIn(10.0).Should().BeApproximately(11.0, 0.001); + } + + [Test] + public void ZoomOut_Divides_By_Factor() + { + EditorZoom.ZoomOut(11.0).Should().BeApproximately(10.0, 0.001); + } + + [Test] + public void Reset_Returns_Default_Font_Size() + { + EditorZoom.Reset().Should().Be(EditorZoom.DefaultFontSize); + } + + [Test] + public void Zoom_Round_Trip_Returns_To_Default_Without_Floating_Point_Drift() + { + // 1.1 * (1/1.1) is bit-equal-ish but not exactly equal in float; without the + // snap-to-default heuristic, the rounded-trip value would be 13.3333000001 + // instead of 13.3333333... and the user would see a sticky "non-100% zoom" + // even after they explicitly returned to default. The Reset path bypasses + // this entirely, but Ctrl+Wheel round trips need the snap. + var step1 = EditorZoom.ZoomIn(EditorZoom.DefaultFontSize); + var step2 = EditorZoom.ZoomOut(step1); + step2.Should().Be(EditorZoom.DefaultFontSize); + } + + [Test] + public void ZoomIn_Clamps_At_Upper_Bound() + { + EditorZoom.ZoomIn(EditorZoom.MaxFontSize).Should().Be(EditorZoom.MaxFontSize); + } + + [Test] + public void ZoomOut_Clamps_At_Lower_Bound() + { + EditorZoom.ZoomOut(EditorZoom.MinFontSize).Should().Be(EditorZoom.MinFontSize); + } + + [Test] + public void ZoomOut_Of_Slightly_Above_Min_Saturates_Not_Below() + { + // Slightly above MinFontSize zoomed out by the standard factor should land + // AT min, not below. Guards against an off-by-clamp bug where the post-divide + // value is below min but clamping only runs on the multiplier output. + var justAbove = EditorZoom.MinFontSize * 1.05; + EditorZoom.ZoomOut(justAbove).Should().Be(EditorZoom.MinFontSize); + } +} diff --git a/ILSpy/TextView/DecompilerTextView.axaml b/ILSpy/TextView/DecompilerTextView.axaml index 7c77f28af..1be160a19 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml +++ b/ILSpy/TextView/DecompilerTextView.axaml @@ -42,5 +42,9 @@ + + diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 5bffe017f..3cabd756c 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -72,6 +72,7 @@ namespace ILSpy.TextView readonly List activeCustomGenerators = new(); RichTextColorizer? activeColorizer; FoldingManager? activeFoldingManager; + DisplaySettings? currentDisplaySettings; ReferenceSegment? lastTooltipSegment; ReferenceSegment? lastRightClickedSegment; IReadOnlyList contextMenuEntries = Array.Empty(); @@ -173,6 +174,57 @@ namespace ILSpy.TextView // integer / double assignments. Editor.TextArea.Caret.PositionChanged += OnCaretPositionChanged; Editor.TextArea.TextView.ScrollOffsetChanged += OnScrollOffsetChanged; + + // Ctrl+Wheel zoom (matches WPF's ZoomScrollViewer.OnPreviewMouseWheel). Tunnel + // routing so we see it before AvaloniaEdit's scroll handler; Handled=true on + // hit suppresses the scroll. Ctrl+0/Plus/Minus are Avalonia-side extras — + // keyboard zoom is a standard modern-editor expectation that WPF ILSpy doesn't + // happen to ship. + Editor.AddHandler(InputElement.PointerWheelChangedEvent, + OnEditorPointerWheelChanged, + RoutingStrategies.Tunnel, + handledEventsToo: false); + Editor.KeyDown += OnEditorKeyDownForZoom; + } + + void OnEditorPointerWheelChanged(object? sender, PointerWheelEventArgs e) + { + if ((e.KeyModifiers & KeyModifiers.Control) != KeyModifiers.Control) + return; + if (currentDisplaySettings == null) + return; + var step = e.Delta.Y > 0 ? (System.Func)EditorZoom.ZoomIn : EditorZoom.ZoomOut; + currentDisplaySettings.SelectedFontSize = step(currentDisplaySettings.SelectedFontSize); + e.Handled = true; + } + + void OnEditorKeyDownForZoom(object? sender, KeyEventArgs e) + { + if ((e.KeyModifiers & KeyModifiers.Control) != KeyModifiers.Control) + return; + if (currentDisplaySettings == null) + return; + // OemPlus is the unmodified key on the same physical button as "+"; with Shift it + // produces "+", without it produces "=". Accept both so Ctrl+Plus and Ctrl+= + // (which most users actually type) both zoom in. Add (numeric keypad) covers numpad. + switch (e.Key) + { + case Key.OemPlus: + case Key.Add: + currentDisplaySettings.SelectedFontSize = EditorZoom.ZoomIn(currentDisplaySettings.SelectedFontSize); + e.Handled = true; + break; + case Key.OemMinus: + case Key.Subtract: + currentDisplaySettings.SelectedFontSize = EditorZoom.ZoomOut(currentDisplaySettings.SelectedFontSize); + e.Handled = true; + break; + case Key.D0: + case Key.NumPad0: + currentDisplaySettings.SelectedFontSize = EditorZoom.Reset(); + e.Handled = true; + break; + } } void OnCaretPositionChanged(object? sender, EventArgs e) @@ -200,6 +252,8 @@ namespace ILSpy.TextView var settings = TryGetDisplaySettings(); if (settings == null) return; + currentDisplaySettings = settings; + ZoomButtons.Bind(settings); ApplyAllDisplaySettings(settings); settings.PropertyChanged += (_, e) => ApplyDisplaySetting(settings, e.PropertyName); } diff --git a/ILSpy/TextView/EditorZoom.cs b/ILSpy/TextView/EditorZoom.cs new file mode 100644 index 000000000..04f3fee8c --- /dev/null +++ b/ILSpy/TextView/EditorZoom.cs @@ -0,0 +1,58 @@ +// 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; + +namespace ILSpy.TextView +{ + /// + /// Pure-math helper for editor zoom (font-size scaling). The actual editor wiring + /// reads/writes DisplaySettings.SelectedFontSize; this class encapsulates + /// the step calculation and clamping so it's unit-testable without an editor. + /// + public static class EditorZoom + { + /// Multiplicative step per wheel tick / button press. + public const double Factor = 1.1; + + /// Lower bound in points. Below ~8 pt the gutter glyphs collapse. + public const double MinFontSize = 8.0; + + /// Upper bound in points. Above ~72 pt one glyph fills the viewport. + public const double MaxFontSize = 72.0; + + /// Default font size in points, matching DisplaySettings's initial value. + public const double DefaultFontSize = 10.0 * 4 / 3; + + public static double ZoomIn(double currentFontSize) + => Clamp(RoundToDefaultIfClose(currentFontSize * Factor)); + + public static double ZoomOut(double currentFontSize) + => Clamp(RoundToDefaultIfClose(currentFontSize / Factor)); + + public static double Reset() => DefaultFontSize; + + /// Snap to when within 0.001 pt — avoids + /// floating-point drift after zoom-in followed by zoom-out leaving the size stuck + /// at 13.3333000001 instead of the canonical 13.3333333. + static double RoundToDefaultIfClose(double size) + => Math.Abs(size - DefaultFontSize) < 0.001 ? DefaultFontSize : size; + + static double Clamp(double size) => Math.Max(MinFontSize, Math.Min(MaxFontSize, size)); + } +} diff --git a/ILSpy/TextView/ZoomButtons.axaml b/ILSpy/TextView/ZoomButtons.axaml new file mode 100644 index 000000000..dfd528c3f --- /dev/null +++ b/ILSpy/TextView/ZoomButtons.axaml @@ -0,0 +1,19 @@ + + + +