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