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