diff --git a/TextControlBox.Tests/ScrollOffsetMathTests.cs b/TextControlBox.Tests/ScrollOffsetMathTests.cs
new file mode 100644
index 0000000..08777c5
--- /dev/null
+++ b/TextControlBox.Tests/ScrollOffsetMathTests.cs
@@ -0,0 +1,96 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using TextControlBoxNS.Core;
+
+namespace TextControlBox.Tests;
+
+///
+/// Pure conversions between the pixel scroll seam (IScrollOffsetSource) and the legacy ScrollBar
+/// backing store. These are the exact place a "scroll is N× too fast/slow" regression would hide,
+/// so they are unit-tested in isolation (a ScrollBar cannot be instantiated headless).
+///
+[TestClass]
+public class ScrollOffsetMathTests
+{
+ [TestMethod]
+ public void NormalizeSensitivity_FloorsAtOne()
+ {
+ Assert.AreEqual(1, ScrollOffsetMath.NormalizeSensitivity(0));
+ Assert.AreEqual(1, ScrollOffsetMath.NormalizeSensitivity(-5));
+ Assert.AreEqual(4, ScrollOffsetMath.NormalizeSensitivity(4));
+ }
+
+ [TestMethod]
+ public void VerticalValueToPixels_MultipliesBySensitivity()
+ {
+ Assert.AreEqual(400.0, ScrollOffsetMath.VerticalValueToPixels(100, 4));
+ Assert.AreEqual(100.0, ScrollOffsetMath.VerticalValueToPixels(100, 1));
+ }
+
+ [TestMethod]
+ public void PixelsToVerticalValue_DividesBySensitivity()
+ {
+ Assert.AreEqual(100.0, ScrollOffsetMath.PixelsToVerticalValue(400, 4));
+ Assert.AreEqual(100.0, ScrollOffsetMath.PixelsToVerticalValue(100, 1));
+ }
+
+ [TestMethod]
+ public void PixelsToVerticalValue_ClampsNegativeToZero()
+ {
+ Assert.AreEqual(0.0, ScrollOffsetMath.PixelsToVerticalValue(-500, 4));
+ }
+
+ [TestMethod]
+ public void VerticalOffset_RoundTripsThroughScrollBarUnits()
+ {
+ // A pixel offset stored as a legacy ScrollBar.Value and read back must be unchanged.
+ const int sensitivity = 4;
+ foreach (double pixels in new[] { 0.0, 20.0, 399.0, 4096.0, 1_000_000.0 })
+ {
+ double value = ScrollOffsetMath.PixelsToVerticalValue(pixels, sensitivity);
+ double roundTrip = ScrollOffsetMath.VerticalValueToPixels(value, sensitivity);
+ Assert.AreEqual(pixels, roundTrip, 1e-9);
+ }
+ }
+
+ [TestMethod]
+ public void VerticalExtent_ConvertsBetweenMaximumAndPixels()
+ {
+ const int sensitivity = 4;
+ const double viewport = 300.0;
+
+ // Content taller than the viewport: Maximum (legacy units) + viewport (px) == extent (px).
+ double extent = ScrollOffsetMath.VerticalMaximumToExtentPixels(500, sensitivity, viewport);
+ Assert.AreEqual(500 * sensitivity + viewport, extent);
+
+ double maximum = ScrollOffsetMath.VerticalExtentPixelsToMaximum(extent, sensitivity, viewport);
+ Assert.AreEqual(500.0, maximum, 1e-9);
+ }
+
+ [TestMethod]
+ public void VerticalExtentPixelsToMaximum_FloorsAtZeroWhenContentFitsViewport()
+ {
+ // Content shorter than the viewport is not scrollable → Maximum 0.
+ Assert.AreEqual(0.0, ScrollOffsetMath.VerticalExtentPixelsToMaximum(100, 4, 300));
+ }
+
+ [TestMethod]
+ public void HorizontalOffset_ClampsNegativeToZero_AndIsPixelIdentity()
+ {
+ Assert.AreEqual(0.0, ScrollOffsetMath.ClampHorizontalOffset(-1));
+ Assert.AreEqual(42.0, ScrollOffsetMath.ClampHorizontalOffset(42));
+ }
+
+ [TestMethod]
+ public void HorizontalExtent_ConvertsBetweenMaximumAndPixels()
+ {
+ const double viewport = 250.0;
+ double extent = ScrollOffsetMath.HorizontalMaximumToExtentPixels(1000, viewport);
+ Assert.AreEqual(1000 + viewport, extent);
+
+ double maximum = ScrollOffsetMath.HorizontalExtentPixelsToMaximum(extent, viewport);
+ Assert.AreEqual(1000.0, maximum, 1e-9);
+
+ // Content narrower than the viewport → not scrollable.
+ Assert.AreEqual(0.0, ScrollOffsetMath.HorizontalExtentPixelsToMaximum(100, viewport));
+ }
+}
diff --git a/TextControlBox.Tests/ZoomScrollAnchorTests.cs b/TextControlBox.Tests/ZoomScrollAnchorTests.cs
new file mode 100644
index 0000000..4af0707
--- /dev/null
+++ b/TextControlBox.Tests/ZoomScrollAnchorTests.cs
@@ -0,0 +1,45 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using TextControlBoxNS.Core;
+
+namespace TextControlBox.Tests;
+
+/// Unit tests for the pure zoom-anchor arithmetic ().
+[TestClass]
+public class ZoomScrollAnchorTests
+{
+ [TestMethod]
+ public void AnchorVerticalOffset_KeepsCentreRowStationary()
+ {
+ // Viewport 200, old line height 20 => centre pixel 100+? offset 100 => centre pixel 200 => row 10.
+ // After line height 40, row 10 must remain at the viewport centre.
+ double oldLineHeight = 20, newLineHeight = 40, viewport = 200, offset = 100;
+ double centreRow = (offset + viewport / 2) / oldLineHeight; // (100 + 100)/20 = 10
+ double result = ZoomScrollAnchor.AnchorVerticalOffset(offset, viewport, oldLineHeight, newLineHeight);
+ // Row 10 at new height => centre pixel 10*40 = 400; offset = 400 - 100 = 300.
+ Assert.AreEqual(300, result, 0.001);
+ // The centre row is preserved: (result + viewport/2)/newLineHeight == centreRow.
+ Assert.AreEqual(centreRow, (result + viewport / 2) / newLineHeight, 0.001);
+ }
+
+ [TestMethod]
+ public void AnchorVerticalOffset_ClampsAtZero()
+ {
+ // Zooming out (smaller line height) near the top must not produce a negative offset.
+ double result = ZoomScrollAnchor.AnchorVerticalOffset(verticalOffset: 5, viewportHeight: 200, oldLineHeight: 40, newLineHeight: 10);
+ Assert.IsTrue(result >= 0);
+ Assert.AreEqual(0, result, 0.001); // centre row ~2.6 * 10 - 100 < 0 => clamped
+ }
+
+ [TestMethod]
+ public void AnchorHorizontalOffset_ScalesByFontRatio()
+ {
+ Assert.AreEqual(150, ZoomScrollAnchor.AnchorHorizontalOffset(horizontalOffset: 100, oldFontSize: 10, newFontSize: 15), 0.001);
+ Assert.AreEqual(50, ZoomScrollAnchor.AnchorHorizontalOffset(horizontalOffset: 100, oldFontSize: 20, newFontSize: 10), 0.001);
+ }
+
+ [TestMethod]
+ public void AnchorHorizontalOffset_UnusableOldSize_ReturnsInput()
+ {
+ Assert.AreEqual(42, ZoomScrollAnchor.AnchorHorizontalOffset(horizontalOffset: 42, oldFontSize: 0, newFontSize: 15), 0.001);
+ }
+}
diff --git a/TextControlBox/Core/CoreTextControlBox.xaml.cs b/TextControlBox/Core/CoreTextControlBox.xaml.cs
index 14bb0a8..19047d9 100644
--- a/TextControlBox/Core/CoreTextControlBox.xaml.cs
+++ b/TextControlBox/Core/CoreTextControlBox.xaml.cs
@@ -138,7 +138,7 @@ public CoreTextControlBox()
searchManager.Init(textManager);
eventsManager.Init(searchManager, cursorManager);
lineNumberRenderer.Init(textManager, textLayoutManager, textRenderer, designHelper, lineNumberManager);
- zoomManager.Init(textManager, textRenderer, canvasUpdateManager, eventsManager, lineNumberRenderer);
+ zoomManager.Init(textManager, textRenderer, canvasUpdateManager, eventsManager, lineNumberRenderer, scrollManager);
focusManager.Init(this, canvasUpdateManager, inputHandler, eventsManager);
pointerActionsManager.Init(this, textRenderer, textManager, cursorManager, canvasUpdateManager, scrollManager, selectionRenderer, currentLineManager, selectionManager, linkHighlightManager);
textLayoutManager.Init(textManager, zoomManager);
diff --git a/TextControlBox/Core/IScrollOffsetSource.cs b/TextControlBox/Core/IScrollOffsetSource.cs
new file mode 100644
index 0000000..0ce497d
--- /dev/null
+++ b/TextControlBox/Core/IScrollOffsetSource.cs
@@ -0,0 +1,128 @@
+using System;
+using Microsoft.UI.Xaml.Controls.Primitives;
+
+namespace TextControlBoxNS.Core;
+
+///
+/// The single funnel for the editor's scroll position. Every consumer that used to read or write
+/// verticalScrollBar.Value / horizontalScrollBar.Value now goes through this seam.
+///
+/// All offsets and extents are PIXELS. This is deliberate (see
+/// PLANS/EDITOR_DIAGONAL_SCROLL_REWRITE_PLAN.md, Phase 1 / finding #2): the legacy vertical scroll was
+/// stored in SingleLineHeight / DefaultVerticalScrollSensitivity scrollbar units, so exposing the
+/// seam in pixels from the start means the Phase 2 backend swap (two primitives →
+/// a real ScrollViewer) does not become a whole-consumer unit rewrite. The initial
+/// adapter does the pixelΓåöscrollbar-unit conversion internally.
+///
+internal interface IScrollOffsetSource
+{
+ /// Vertical scroll position, in pixels (0 = top).
+ double VerticalOffset { get; set; }
+
+ /// Horizontal scroll position, in pixels (0 = left).
+ double HorizontalOffset { get; set; }
+
+ /// Total content height in pixels (ScrollViewer.ExtentHeight semantics).
+ double VerticalExtent { get; set; }
+
+ /// Total content width in pixels (ScrollViewer.ExtentWidth semantics).
+ double HorizontalExtent { get; set; }
+
+ /// Visible viewport width in pixels.
+ double ViewportWidth { get; set; }
+
+ /// Visible viewport height in pixels.
+ double ViewportHeight { get; set; }
+
+ /// Sets one or both offsets (pixels). Pass null to leave that axis unchanged.
+ /// Mirrors ScrollViewer.ChangeView so the Phase 2 backend is a drop-in.
+ void ChangeView(double? horizontalOffset, double? verticalOffset);
+
+ /// Raised whenever the scroll position changes ΓÇö a user drag of a scrollbar thumb OR a
+ /// programmatic write to / (caret-follow,
+ /// page keys, go-to-line, find reveal, match hand-off, wheel), or ΓÇö after the Phase 2 swap ΓÇö native
+ /// wheel/touchpad panning. The Phase 1 raises it from the two
+ /// scrollbars' Scroll events and from the offset setters. The diagonal-scroll
+ /// InteractionTracker subscribes so it snaps to any programmatic scroll immediately.
+ event EventHandler ViewChanged;
+}
+
+///
+/// Phase 1 backend for : the existing two standalone
+/// primitives. Vertical ScrollBar.Value/ScrollBar.Maximum are stored in legacy
+/// units of SingleLineHeight / verticalSensitivity; this adapter multiplies/divides by
+/// verticalSensitivity so the seam is pixel-based. Horizontal values are already pixels.
+///
+internal sealed class ScrollBarOffsetSource : IScrollOffsetSource
+{
+ private readonly ScrollBar _vertical;
+ private readonly ScrollBar _horizontal;
+ private readonly int _verticalSensitivity;
+
+ public ScrollBarOffsetSource(ScrollBar vertical, ScrollBar horizontal, int verticalSensitivity)
+ {
+ _vertical = vertical;
+ _horizontal = horizontal;
+ _verticalSensitivity = Math.Max(1, verticalSensitivity);
+ _vertical.Scroll += OnScrollBarScroll;
+ _horizontal.Scroll += OnScrollBarScroll;
+ }
+
+ public event EventHandler ViewChanged;
+
+ private void OnScrollBarScroll(object sender, ScrollEventArgs e) => ViewChanged?.Invoke(this, EventArgs.Empty);
+
+ public double VerticalOffset
+ {
+ get => ScrollOffsetMath.VerticalValueToPixels(_vertical.Value, _verticalSensitivity);
+ // ScrollBar.Value is clamped to [Minimum, Maximum] by the control, matching the legacy behavior
+ // where every scroll mutator relied on the scrollbar to clamp an out-of-range assignment.
+ set
+ {
+ _vertical.Value = ScrollOffsetMath.PixelsToVerticalValue(value, _verticalSensitivity);
+ ViewChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ public double HorizontalOffset
+ {
+ get => _horizontal.Value;
+ set
+ {
+ _horizontal.Value = ScrollOffsetMath.ClampHorizontalOffset(value);
+ ViewChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ public double VerticalExtent
+ {
+ get => ScrollOffsetMath.VerticalMaximumToExtentPixels(_vertical.Maximum, _verticalSensitivity, ViewportHeight);
+ set => _vertical.Maximum = ScrollOffsetMath.VerticalExtentPixelsToMaximum(value, _verticalSensitivity, ViewportHeight);
+ }
+
+ public double HorizontalExtent
+ {
+ get => ScrollOffsetMath.HorizontalMaximumToExtentPixels(_horizontal.Maximum, ViewportWidth);
+ set => _horizontal.Maximum = ScrollOffsetMath.HorizontalExtentPixelsToMaximum(value, ViewportWidth);
+ }
+
+ public double ViewportWidth
+ {
+ get => _horizontal.ViewportSize;
+ set => _horizontal.ViewportSize = value;
+ }
+
+ public double ViewportHeight
+ {
+ get => _vertical.ViewportSize;
+ set => _vertical.ViewportSize = value;
+ }
+
+ public void ChangeView(double? horizontalOffset, double? verticalOffset)
+ {
+ if (horizontalOffset.HasValue)
+ HorizontalOffset = horizontalOffset.Value;
+ if (verticalOffset.HasValue)
+ VerticalOffset = verticalOffset.Value;
+ }
+}
diff --git a/TextControlBox/Core/ScrollManager.cs b/TextControlBox/Core/ScrollManager.cs
index e9a9d26..ca9665c 100644
--- a/TextControlBox/Core/ScrollManager.cs
+++ b/TextControlBox/Core/ScrollManager.cs
@@ -17,8 +17,13 @@ internal class ScrollManager
public int DefaultVerticalScrollSensitivity = 4;
public float OldHorizontalScrollValue = 0;
- public double VerticalScroll { get => verticalScrollBar.Value; set { verticalScrollBar.Value = value < 0 ? 0 : value; canvasHelper.UpdateAll(); } }
- public double HorizontalScroll { get => horizontalScrollBar.Value; set { horizontalScrollBar.Value = value < 0 ? 0 : value; canvasHelper.UpdateAll(); } }
+ // The single pixel-based scroll-position seam (see IScrollOffsetSource). Phase 1 backs it with the
+ // two ScrollBar primitives; VerticalScroll / HorizontalScroll below keep their legacy scrollbar-unit /
+ // pixel API semantics for the public surface but store through the pixel offset source.
+ public IScrollOffsetSource OffsetSource { get; private set; }
+
+ public double VerticalScroll { get => OffsetSource.VerticalOffset / DefaultVerticalScrollSensitivity; set { OffsetSource.VerticalOffset = (value < 0 ? 0 : value) * DefaultVerticalScrollSensitivity; canvasHelper.UpdateAll(); } }
+ public double HorizontalScroll { get => OffsetSource.HorizontalOffset; set { OffsetSource.HorizontalOffset = value < 0 ? 0 : value; canvasHelper.UpdateAll(); } }
public ScrollBar verticalScrollBar;
public ScrollBar horizontalScrollBar;
@@ -40,6 +45,7 @@ public void Init(CoreTextControlBox coreTextbox, CanvasUpdateManager canvasHelpe
this.textManager = textManager;
this.coreTextbox = coreTextbox;
this.zoomManager = zoomManager;
+ OffsetSource = new ScrollBarOffsetSource(this.verticalScrollBar, this.horizontalScrollBar, DefaultVerticalScrollSensitivity);
verticalScrollBar.Loaded += VerticalScrollbar_Loaded;
verticalScrollBar.Scroll += VerticalScrollBar_Scroll;
horizontalScrollBar.Scroll += HorizontalScrollBar_Scroll;
@@ -53,7 +59,7 @@ internal void VerticalScrollbar_Loaded(object sender, RoutedEventArgs e)
internal void VerticalScrollBar_Scroll(object sender, ScrollEventArgs e)
{
//only update when a line was scrolled
- if ((int)(verticalScrollBar.Value / textRenderer.SingleLineHeight * DefaultVerticalScrollSensitivity) != textRenderer.NumberOfStartLine)
+ if ((int)(OffsetSource.VerticalOffset / textRenderer.SingleLineHeight) != textRenderer.NumberOfStartLine)
{
canvasHelper.UpdateAll();
}
@@ -77,20 +83,20 @@ public void ScrollLineIntoViewIfOutside(int line, bool update = true)
public void ScrollOneLineUp(bool update = true)
{
- verticalScrollBar.Value -= textRenderer.SingleLineHeight / DefaultVerticalScrollSensitivity;
+ OffsetSource.VerticalOffset -= textRenderer.SingleLineHeight;
if(update)
canvasHelper.UpdateAll();
}
public void ScrollOneLineDown(bool update = true)
{
- verticalScrollBar.Value += textRenderer.SingleLineHeight / DefaultVerticalScrollSensitivity;
+ OffsetSource.VerticalOffset += textRenderer.SingleLineHeight;
if(update)
canvasHelper.UpdateAll();
}
public void ScrollLineIntoView(int line, bool update = true)
{
- verticalScrollBar.Value = (line - textRenderer.NumberOfRenderedLines / 2) * textRenderer.SingleLineHeight / DefaultVerticalScrollSensitivity;
+ OffsetSource.VerticalOffset = (line - textRenderer.NumberOfRenderedLines / 2) * textRenderer.SingleLineHeight;
if(update)
canvasHelper.UpdateAll();
@@ -98,13 +104,13 @@ public void ScrollLineIntoView(int line, bool update = true)
public void ScrollTopIntoView(bool update = true)
{
- verticalScrollBar.Value = (cursorManager.LineNumber - 1) * textRenderer.SingleLineHeight / DefaultVerticalScrollSensitivity;
+ OffsetSource.VerticalOffset = (cursorManager.LineNumber - 1) * textRenderer.SingleLineHeight;
if(update)
canvasHelper.UpdateAll();
}
public void ScrollBottomIntoView(bool update = true)
{
- verticalScrollBar.Value = (cursorManager.LineNumber - textRenderer.NumberOfRenderedLines + 1) * textRenderer.SingleLineHeight / DefaultVerticalScrollSensitivity;
+ OffsetSource.VerticalOffset = (cursorManager.LineNumber - textRenderer.NumberOfRenderedLines + 1) * textRenderer.SingleLineHeight;
if(update)
canvasHelper.UpdateAll();
}
@@ -115,7 +121,7 @@ public void ScrollPageUp()
if (cursorManager.LineNumber < 0)
cursorManager.LineNumber = 0;
- verticalScrollBar.Value -= textRenderer.NumberOfRenderedLines * textRenderer.SingleLineHeight / DefaultVerticalScrollSensitivity;
+ OffsetSource.VerticalOffset -= textRenderer.NumberOfRenderedLines * textRenderer.SingleLineHeight;
canvasHelper.UpdateAll();
}
@@ -125,7 +131,7 @@ public void ScrollPageDown()
cursorManager.LineNumber += textRenderer.NumberOfRenderedLines;
if (cursorManager.LineNumber > textManager.LinesCount - 1)
cursorManager.LineNumber = textManager.LinesCount - 1;
- verticalScrollBar.Value += textRenderer.NumberOfRenderedLines * textRenderer.SingleLineHeight / DefaultVerticalScrollSensitivity;
+ OffsetSource.VerticalOffset += textRenderer.NumberOfRenderedLines * textRenderer.SingleLineHeight;
canvasHelper.UpdateAll();
}
@@ -133,15 +139,15 @@ public void UpdateScrollToShowCursor(bool update = true)
{
if (textRenderer.NumberOfStartLine + textRenderer.NumberOfRenderedLines - 1 <= cursorManager.LineNumber)
{
- verticalScrollBar.Value =
+ OffsetSource.VerticalOffset =
(cursorManager.LineNumber - textRenderer.NumberOfRenderedLines + 2) *
- textRenderer.SingleLineHeight / DefaultVerticalScrollSensitivity;
+ textRenderer.SingleLineHeight;
}
else if (textRenderer.NumberOfStartLine > cursorManager.LineNumber)
{
- verticalScrollBar.Value =
+ OffsetSource.VerticalOffset =
cursorManager.LineNumber *
- textRenderer.SingleLineHeight / DefaultVerticalScrollSensitivity;
+ textRenderer.SingleLineHeight;
}
if (update)
@@ -159,19 +165,19 @@ public bool ScrollIntoViewHorizontal(CanvasControl canvasText, bool update = tru
if (curPosInLine == OldHorizontalScrollValue)
return false;
- double visibleStart = horizontalScrollBar.Value;
+ double visibleStart = OffsetSource.HorizontalOffset;
double visibleEnd = visibleStart + canvasText.ActualWidth;
bool changed = false;
if (curPosInLine < visibleStart + 3)
{
changed = true;
- horizontalScrollBar.Value = Math.Max(curPosInLine - 3, horizontalScrollBar.Minimum);
+ OffsetSource.HorizontalOffset = Math.Max(curPosInLine - 3, horizontalScrollBar.Minimum);
}
else if (curPosInLine > visibleEnd)
{
changed = true;
- horizontalScrollBar.Value = Math.Min(curPosInLine - canvasText.ActualWidth + 5, horizontalScrollBar.Maximum + 5);
+ OffsetSource.HorizontalOffset = Math.Min(curPosInLine - canvasText.ActualWidth + 5, horizontalScrollBar.Maximum + 5);
}
OldHorizontalScrollValue = curPosInLine;
diff --git a/TextControlBox/Core/ScrollOffsetMath.cs b/TextControlBox/Core/ScrollOffsetMath.cs
new file mode 100644
index 0000000..29d7eef
--- /dev/null
+++ b/TextControlBox/Core/ScrollOffsetMath.cs
@@ -0,0 +1,52 @@
+using System;
+
+namespace TextControlBoxNS.Core;
+
+///
+/// Pure, WinUI-free conversions between the editor's pixel scroll seam ()
+/// and the legacy ScrollBar backing store. The VERTICAL scrollbar stores Value/Maximum
+/// in units of SingleLineHeight / DefaultVerticalScrollSensitivity; the HORIZONTAL scrollbar is
+/// already in pixels.
+///
+/// Extracted from so the pixelΓåöscrollbar-unit arithmetic ΓÇö the
+/// exact place a "scroll is 4× too fast" regression would hide — is unit-testable in isolation (a
+/// ScrollBar cannot be instantiated headless). See PLANS/EDITOR_DIAGONAL_SCROLL_REWRITE_PLAN.md,
+/// Phase 1.
+///
+internal static class ScrollOffsetMath
+{
+ /// The vertical sensitivity is used as a divisor, so it must be at least 1.
+ internal static int NormalizeSensitivity(int sensitivity) => Math.Max(1, sensitivity);
+
+ /// Vertical ScrollBar.Value (legacy units) → pixels.
+ internal static double VerticalValueToPixels(double scrollBarValue, int sensitivity)
+ => scrollBarValue * NormalizeSensitivity(sensitivity);
+
+ /// Pixels → vertical ScrollBar.Value (legacy units). Negative pixels clamp to 0; the
+ /// ScrollBar itself further clamps to [Minimum, Maximum].
+ internal static double PixelsToVerticalValue(double pixels, int sensitivity)
+ => Math.Max(0, pixels) / NormalizeSensitivity(sensitivity);
+
+ /// Vertical ScrollBar.Maximum (legacy units) + viewport pixels → total content height
+ /// in pixels (ScrollViewer.ExtentHeight semantics).
+ internal static double VerticalMaximumToExtentPixels(double maximum, int sensitivity, double viewportHeightPixels)
+ => (maximum * NormalizeSensitivity(sensitivity)) + viewportHeightPixels;
+
+ /// Total content height in pixels → vertical ScrollBar.Maximum (legacy units), floored
+ /// at 0 (content smaller than the viewport is not scrollable).
+ internal static double VerticalExtentPixelsToMaximum(double extentPixels, int sensitivity, double viewportHeightPixels)
+ => Math.Max(0, extentPixels - viewportHeightPixels) / NormalizeSensitivity(sensitivity);
+
+ /// Clamps a horizontal pixel offset to be non-negative (horizontal is already pixels).
+ internal static double ClampHorizontalOffset(double pixels) => Math.Max(0, pixels);
+
+ /// Horizontal ScrollBar.Maximum (pixels) + viewport pixels → total content width in
+ /// pixels (ScrollViewer.ExtentWidth semantics).
+ internal static double HorizontalMaximumToExtentPixels(double maximum, double viewportWidthPixels)
+ => maximum + viewportWidthPixels;
+
+ /// Total content width in pixels → horizontal ScrollBar.Maximum (pixels), floored at
+ /// 0 (content narrower than the viewport is not scrollable).
+ internal static double HorizontalExtentPixelsToMaximum(double extentPixels, double viewportWidthPixels)
+ => Math.Max(0, extentPixels - viewportWidthPixels);
+}
diff --git a/TextControlBox/Core/TextLayoutManager.cs b/TextControlBox/Core/TextLayoutManager.cs
index 46a4059..a1a085f 100644
--- a/TextControlBox/Core/TextLayoutManager.cs
+++ b/TextControlBox/Core/TextLayoutManager.cs
@@ -9,6 +9,11 @@ namespace TextControlBoxNS.Core;
internal class TextLayoutManager
{
+ /// Pixels added to the font size to form the line height (LineSpacing), so
+ /// SingleLineHeight = ZoomedFontSize + LineSpacingPadding. Kept as a named constant so the zoom
+ /// anchor can compute the post-zoom line height before the format is rebuilt.
+ public const float LineSpacingPadding = 2f;
+
private TextManager textManager;
private ZoomManager zoomManager;
public void Init(TextManager textManager, ZoomManager zoomManager)
@@ -29,7 +34,7 @@ public CanvasTextLayout CreateTextResource(ICanvasResourceCreatorWithDpi resourc
}
public CanvasTextFormat CreateCanvasTextFormat()
{
- return CreateCanvasTextFormat(zoomManager.ZoomedFontSize, zoomManager.ZoomedFontSize + 2, textManager._FontFamily);
+ return CreateCanvasTextFormat(zoomManager.ZoomedFontSize, zoomManager.ZoomedFontSize + LineSpacingPadding, textManager._FontFamily);
}
public CanvasTextFormat CreateCanvasTextFormat(float zoomedFontSize, float lineSpacing, FontFamily fontFamily)
diff --git a/TextControlBox/Core/ZoomManager.cs b/TextControlBox/Core/ZoomManager.cs
index 72162ba..89ef66f 100644
--- a/TextControlBox/Core/ZoomManager.cs
+++ b/TextControlBox/Core/ZoomManager.cs
@@ -18,13 +18,15 @@ internal class ZoomManager
private CanvasUpdateManager canvasHelper;
private EventsManager eventsManager;
private LineNumberRenderer lineNumberRenderer;
+ private ScrollManager scrollManager;
public void Init(
TextManager textManager,
TextRenderer textRenderer,
CanvasUpdateManager canvasHelper,
EventsManager eventsManager,
- LineNumberRenderer lineNumberRenderer
+ LineNumberRenderer lineNumberRenderer,
+ ScrollManager scrollManager
)
{
this.textManager = textManager;
@@ -32,15 +34,25 @@ LineNumberRenderer lineNumberRenderer
this.canvasHelper = canvasHelper;
this.eventsManager = eventsManager;
this.lineNumberRenderer = lineNumberRenderer;
+ this.scrollManager = scrollManager;
}
public void UpdateZoom()
{
+ // Capture the pre-zoom line height (TextFormat is only rebuilt on the next draw, so
+ // textRenderer.SingleLineHeight still reflects the OLD font size here) so the scroll position can be
+ // re-anchored once the new font size is known.
+ float oldSingleLineHeight = textRenderer.SingleLineHeight;
+
ZoomedFontSize = Math.Clamp(textManager._FontSize * (float)_ZoomFactor / 100, textManager.MinFontSize, textManager.MaxFontsize);
_ZoomFactor = Math.Clamp(_ZoomFactor, MinZoom, MaxZoom);
if (_ZoomFactor != OldZoomFactor)
{
+ // Keep the document row at the vertical viewport centre stationary across the zoom so zooming
+ // feels anchored instead of drifting.
+ AnchorScrollAcrossZoom(oldSingleLineHeight);
+
textRenderer.NeedsUpdateTextLayout = true;
OldZoomFactor = _ZoomFactor;
eventsManager.CallZoomChanged(_ZoomFactor);
@@ -52,4 +64,24 @@ public void UpdateZoom()
canvasHelper.UpdateAll();
}
}
+
+ // Re-anchor the pixel scroll seam so the row under the vertical viewport centre stays put after the font
+ // size (and therefore SingleLineHeight = ZoomedFontSize + LineSpacingPadding) changes. The horizontal
+ // axis scales by the font-size ratio to keep the left-most visible column stable.
+ private void AnchorScrollAcrossZoom(float oldSingleLineHeight)
+ {
+ IScrollOffsetSource src = scrollManager?.OffsetSource;
+ if (src is null || oldSingleLineHeight <= 0.5f)
+ return;
+
+ float newSingleLineHeight = ZoomedFontSize + TextLayoutManager.LineSpacingPadding;
+ if (newSingleLineHeight <= 0.5f)
+ return;
+
+ src.VerticalOffset = ZoomScrollAnchor.AnchorVerticalOffset(src.VerticalOffset, src.ViewportHeight, oldSingleLineHeight, newSingleLineHeight);
+
+ double oldFontSize = oldSingleLineHeight - TextLayoutManager.LineSpacingPadding;
+ if (src.HorizontalOffset > 0.5 && oldFontSize > 0.5f)
+ src.HorizontalOffset = ZoomScrollAnchor.AnchorHorizontalOffset(src.HorizontalOffset, oldFontSize, ZoomedFontSize);
+ }
}
diff --git a/TextControlBox/Core/ZoomScrollAnchor.cs b/TextControlBox/Core/ZoomScrollAnchor.cs
new file mode 100644
index 0000000..a7ce4be
--- /dev/null
+++ b/TextControlBox/Core/ZoomScrollAnchor.cs
@@ -0,0 +1,35 @@
+using System;
+
+namespace TextControlBoxNS.Core;
+
+///
+/// Pure (WinUI-free) arithmetic for keeping the scroll position anchored across a zoom (font-size) change,
+/// so zooming feels stationary instead of drifting. The same raw pixel offset maps to a different row once
+/// the line height changes, so the offsets have to be rescaled. Kept separate so it is unit-testable.
+///
+internal static class ZoomScrollAnchor
+{
+ ///
+ /// New vertical pixel offset that keeps the document row under the vertical viewport centre stationary
+ /// when the line height changes from to .
+ ///
+ public static double AnchorVerticalOffset(double verticalOffset, double viewportHeight, double oldLineHeight, double newLineHeight)
+ {
+ double halfViewport = viewportHeight / 2.0;
+ double centreRow = (verticalOffset + halfViewport) / oldLineHeight;
+ double newOffset = centreRow * newLineHeight - halfViewport;
+ return newOffset < 0 ? 0 : newOffset;
+ }
+
+ ///
+ /// New horizontal pixel offset scaled by the font-size ratio (left-edge anchor), so the left-most visible
+ /// column stays roughly stable across the zoom. Returns the input unchanged when the old size is unusable.
+ ///
+ public static double AnchorHorizontalOffset(double horizontalOffset, double oldFontSize, double newFontSize)
+ {
+ if (oldFontSize <= 0.5)
+ return horizontalOffset;
+ double scaled = horizontalOffset * (newFontSize / oldFontSize);
+ return scaled < 0 ? 0 : scaled;
+ }
+}