From 19f12326d809229791088db1fb070ac598b37e42 Mon Sep 17 00:00:00 2001 From: andrewtheart Date: Sat, 11 Jul 2026 04:16:10 -0500 Subject: [PATCH] Add IScrollOffsetSource: a pixel-based scroll-position seam Introduce a small IScrollOffsetSource abstraction so the editor scroll position is expressed in pixels rather than legacy scrollbar units (SingleLineHeight / DefaultVerticalScrollSensitivity). ScrollManager routes every offset read/write through it; the ScrollBarOffsetSource adapter backs it with the existing two ScrollBar primitives and does the pixel<->scrollbar-unit conversion internally (ScrollOffsetMath). Behavior is unchanged. Foundation for word-wrap scrolling, long-line horizontal virtualization and diagonal 2-axis touchpad scrolling (#33). Adds ScrollOffsetMathTests. --- TextControlBox.Tests/ScrollOffsetMathTests.cs | 96 +++++++++++++ TextControlBox/Core/IScrollOffsetSource.cs | 128 ++++++++++++++++++ TextControlBox/Core/ScrollManager.cs | 40 +++--- TextControlBox/Core/ScrollOffsetMath.cs | 52 +++++++ 4 files changed, 299 insertions(+), 17 deletions(-) create mode 100644 TextControlBox.Tests/ScrollOffsetMathTests.cs create mode 100644 TextControlBox/Core/IScrollOffsetSource.cs create mode 100644 TextControlBox/Core/ScrollOffsetMath.cs 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/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); +}