Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions TextControlBox.Tests/ScrollOffsetMathTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using TextControlBoxNS.Core;

namespace TextControlBox.Tests;

/// <summary>
/// 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).
/// </summary>
[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));
}
}
128 changes: 128 additions & 0 deletions TextControlBox/Core/IScrollOffsetSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
using System;
using Microsoft.UI.Xaml.Controls.Primitives;

namespace TextControlBoxNS.Core;

/// <summary>
/// The single funnel for the editor's scroll position. Every consumer that used to read or write
/// <c>verticalScrollBar.Value</c> / <c>horizontalScrollBar.Value</c> now goes through this seam.
///
/// <para><b>All offsets and extents are PIXELS.</b> This is deliberate (see
/// PLANS/EDITOR_DIAGONAL_SCROLL_REWRITE_PLAN.md, Phase 1 / finding #2): the legacy vertical scroll was
/// stored in <c>SingleLineHeight / DefaultVerticalScrollSensitivity</c> scrollbar units, so exposing the
/// seam in pixels from the start means the Phase 2 backend swap (two <see cref="ScrollBar"/> primitives →
/// a real <c>ScrollViewer</c>) does not become a whole-consumer unit rewrite. The initial
/// <see cref="ScrollBarOffsetSource"/> adapter does the pixelΓåöscrollbar-unit conversion internally.</para>
/// </summary>
internal interface IScrollOffsetSource
{
/// <summary>Vertical scroll position, in pixels (0 = top).</summary>
double VerticalOffset { get; set; }

/// <summary>Horizontal scroll position, in pixels (0 = left).</summary>
double HorizontalOffset { get; set; }

/// <summary>Total content height in pixels (ScrollViewer.ExtentHeight semantics).</summary>
double VerticalExtent { get; set; }

/// <summary>Total content width in pixels (ScrollViewer.ExtentWidth semantics).</summary>
double HorizontalExtent { get; set; }

/// <summary>Visible viewport width in pixels.</summary>
double ViewportWidth { get; set; }

/// <summary>Visible viewport height in pixels.</summary>
double ViewportHeight { get; set; }

/// <summary>Sets one or both offsets (pixels). Pass <c>null</c> to leave that axis unchanged.
/// Mirrors <c>ScrollViewer.ChangeView</c> so the Phase 2 backend is a drop-in.</summary>
void ChangeView(double? horizontalOffset, double? verticalOffset);

/// <summary>Raised whenever the scroll position changes ΓÇö a user drag of a scrollbar thumb OR a
/// programmatic write to <see cref="VerticalOffset"/>/<see cref="HorizontalOffset"/> (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 <see cref="ScrollBarOffsetSource"/> raises it from the two
/// scrollbars' <c>Scroll</c> events and from the offset setters. The diagonal-scroll
/// <c>InteractionTracker</c> subscribes so it snaps to any programmatic scroll immediately.</summary>
event EventHandler ViewChanged;
}

/// <summary>
/// Phase 1 backend for <see cref="IScrollOffsetSource"/>: the existing two standalone <see cref="ScrollBar"/>
/// primitives. Vertical <c>ScrollBar.Value</c>/<c>ScrollBar.Maximum</c> are stored in legacy
/// units of <c>SingleLineHeight / verticalSensitivity</c>; this adapter multiplies/divides by
/// <c>verticalSensitivity</c> so the seam is pixel-based. Horizontal values are already pixels.
/// </summary>
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;
}
}
40 changes: 23 additions & 17 deletions TextControlBox/Core/ScrollManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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();
}
Expand All @@ -77,34 +83,34 @@ 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();
}

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();
}
Expand All @@ -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();
}

Expand All @@ -125,23 +131,23 @@ 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();
}

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)
Expand All @@ -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;
Expand Down
Loading