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 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/ILSpy/TextView/ZoomButtons.axaml.cs b/ILSpy/TextView/ZoomButtons.axaml.cs
new file mode 100644
index 000000000..1571eada0
--- /dev/null
+++ b/ILSpy/TextView/ZoomButtons.axaml.cs
@@ -0,0 +1,135 @@
+// 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 Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+using ILSpy.Options;
+
+namespace ILSpy.TextView
+{
+ ///
+ /// Editor-corner overlay with zoom in / out / reset buttons plus a live "133%" label.
+ /// Bound to ; the overlay auto-hides at
+ /// the default font size unless is set to true.
+ ///
+ public partial class ZoomButtons : UserControl
+ {
+ ///
+ /// When false (the default), the overlay is hidden while the zoom level is at
+ /// 100% and only appears once the user actually zooms. When true, it stays
+ /// visible permanently. Matches WPF's ZoomScrollViewer.AlwaysShowZoomButtons.
+ ///
+ public static readonly StyledProperty AlwaysShowZoomButtonsProperty =
+ AvaloniaProperty.Register(nameof(AlwaysShowZoomButtons));
+
+ public bool AlwaysShowZoomButtons {
+ get => GetValue(AlwaysShowZoomButtonsProperty);
+ set => SetValue(AlwaysShowZoomButtonsProperty, value);
+ }
+
+ DisplaySettings? settings;
+
+ public ZoomButtons()
+ {
+ InitializeComponent();
+ RefreshVisibility();
+ // Defer named-control lookup to AttachedToVisualTree: at construction time the
+ // UserControl's own NameScope isn't yet attached to `this`, so FindControl walks
+ // upward and throws "Could not find parent name scope". After visual-tree attach
+ // the scope is in place and the named children resolve normally.
+ AttachedToVisualTree += (_, _) => AttachButtonHandlers();
+ }
+
+ void InitializeComponent() => AvaloniaXamlLoader.Load(this);
+
+ bool handlersAttached;
+
+ void AttachButtonHandlers()
+ {
+ if (handlersAttached)
+ return;
+ handlersAttached = true;
+ if (this.FindControl