diff --git a/TextControlBox.Tests/HorizontalSliceMathTests.cs b/TextControlBox.Tests/HorizontalSliceMathTests.cs
new file mode 100644
index 0000000..89da7a2
--- /dev/null
+++ b/TextControlBox.Tests/HorizontalSliceMathTests.cs
@@ -0,0 +1,154 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using TextControlBoxNS.Core;
+
+namespace TextControlBox.Tests;
+
+///
+/// Unit tests for the pure horizontal-virtualization arithmetic (). These
+/// cover the slice-window computation, the reuse "safe zone" check, and the document-column ↔ rendered-index
+/// mapping — the places where an off-by-one silently misplaces the caret or selection on very long lines.
+///
+[TestClass]
+public class HorizontalSliceMathTests
+{
+ [TestMethod]
+ public void VisibleCharRange_AtOrigin_StartsAtZeroWithRightPadding()
+ {
+ // charWidth 10 => 800px viewport spans 80 chars; +100 right padding, left padding clamped at 0.
+ var (start, end) = HorizontalSliceMath.VisibleCharRange(horizontalScrollPixels: 0, viewportWidthPixels: 800, charWidth: 10);
+ Assert.AreEqual(0, start);
+ Assert.AreEqual(0 + 80 + 100, end);
+ }
+
+ [TestMethod]
+ public void VisibleCharRange_Scrolled_AppliesLeftPadding()
+ {
+ // scroll 1000px / 10 = column 100, minus 50 left padding => 50.
+ var (start, end) = HorizontalSliceMath.VisibleCharRange(horizontalScrollPixels: 1000, viewportWidthPixels: 800, charWidth: 10);
+ Assert.AreEqual(50, start);
+ Assert.AreEqual(50 + 80 + 100, end);
+ }
+
+ [TestMethod]
+ public void VisibleCharRange_ZeroCharWidth_DoesNotDivideByZero()
+ {
+ var (start, end) = HorizontalSliceMath.VisibleCharRange(horizontalScrollPixels: 20, viewportWidthPixels: 30, charWidth: 0);
+ // charWidth falls back to 1: 20 - 50 clamped to 0; end = 0 + 30 + 100.
+ Assert.AreEqual(0, start);
+ Assert.AreEqual(130, end);
+ }
+
+ [TestMethod]
+ public void CanReuseWindow_InsideSafeZone_True()
+ {
+ Assert.IsTrue(HorizontalSliceMath.CanReuseWindow(visibleStart: 60, visibleEnd: 140, safeStart: 50, safeEnd: 200));
+ }
+
+ [TestMethod]
+ public void CanReuseWindow_ScrolledBeforeStart_False()
+ {
+ Assert.IsFalse(HorizontalSliceMath.CanReuseWindow(visibleStart: 40, visibleEnd: 140, safeStart: 50, safeEnd: 200));
+ }
+
+ [TestMethod]
+ public void CanReuseWindow_ScrolledPastEnd_False()
+ {
+ Assert.IsFalse(HorizontalSliceMath.CanReuseWindow(visibleStart: 60, visibleEnd: 210, safeStart: 50, safeEnd: 200));
+ }
+
+ [TestMethod]
+ public void ComputeWindow_BuffersEitherSideAndRecordsSafeZone()
+ {
+ // visible [100, 200) => 100 chars, buffer = 300.
+ var (sliceStart, sliceLen, safeStart, safeEnd) = HorizontalSliceMath.ComputeWindow(visibleStart: 100, visibleEnd: 200);
+ Assert.AreEqual(0, sliceStart); // max(0, 100 - 300) = 0
+ Assert.AreEqual(100 + 300 * 2, sliceLen); // 700
+ Assert.AreEqual(100, safeStart); // inner safe zone start = visibleStart
+ Assert.AreEqual(200 + 300, safeEnd); // visibleEnd + buffer
+ }
+
+ [TestMethod]
+ public void ComputeWindow_FarScroll_SliceStartIsPositive()
+ {
+ // visible [1000, 1100) => 100 chars, buffer = 300 => sliceStart = 700.
+ var (sliceStart, sliceLen, safeStart, safeEnd) = HorizontalSliceMath.ComputeWindow(visibleStart: 1000, visibleEnd: 1100);
+ Assert.AreEqual(700, sliceStart);
+ Assert.AreEqual(700, sliceLen);
+ Assert.AreEqual(1000, safeStart);
+ Assert.AreEqual(1400, safeEnd);
+ }
+
+ [TestMethod]
+ public void ComputeWindow_ReuseCheckHoldsForItsOwnSafeZone()
+ {
+ // A freshly computed window's safe zone must contain the viewport that produced it.
+ var (_, _, safeStart, safeEnd) = HorizontalSliceMath.ComputeWindow(visibleStart: 500, visibleEnd: 600);
+ Assert.IsTrue(HorizontalSliceMath.CanReuseWindow(500, 600, safeStart, safeEnd));
+ }
+
+ [TestMethod]
+ public void SlicedLineLength_LineInsideWindow_ReturnsRemainderCappedAtWindow()
+ {
+ // Window [200, 200+500). A 400-char line contributes 400-200 = 200.
+ Assert.AreEqual(200, HorizontalSliceMath.SlicedLineLength(fullLineLength: 400, sliceStart: 200, sliceLen: 500));
+ }
+
+ [TestMethod]
+ public void SlicedLineLength_LongLine_CapsAtWindowWidth()
+ {
+ // Window width 500; a 100000-char line contributes only the window's worth.
+ Assert.AreEqual(500, HorizontalSliceMath.SlicedLineLength(fullLineLength: 100000, sliceStart: 200, sliceLen: 500));
+ }
+
+ [TestMethod]
+ public void SlicedLineLength_LineEndsBeforeWindow_ReturnsZero()
+ {
+ Assert.AreEqual(0, HorizontalSliceMath.SlicedLineLength(fullLineLength: 150, sliceStart: 200, sliceLen: 500));
+ }
+
+ [TestMethod]
+ public void SlicedLineLength_ZeroWindow_ReturnsZero()
+ {
+ Assert.AreEqual(0, HorizontalSliceMath.SlicedLineLength(fullLineLength: 400, sliceStart: 0, sliceLen: 0));
+ }
+
+ [TestMethod]
+ public void RenderedIndexForColumn_SubtractsSliceStart()
+ {
+ int slicedLen = HorizontalSliceMath.SlicedLineLength(fullLineLength: 1000, sliceStart: 200, sliceLen: 500);
+ Assert.AreEqual(100, HorizontalSliceMath.RenderedIndexForColumn(characterPosition: 300, sliceStart: 200, slicedLineLength: slicedLen));
+ }
+
+ [TestMethod]
+ public void RenderedIndexForColumn_ClampsBelowZeroAndAboveSlice()
+ {
+ Assert.AreEqual(0, HorizontalSliceMath.RenderedIndexForColumn(characterPosition: 10, sliceStart: 200, slicedLineLength: 500));
+ Assert.AreEqual(500, HorizontalSliceMath.RenderedIndexForColumn(characterPosition: 999999, sliceStart: 200, slicedLineLength: 500));
+ }
+
+ [TestMethod]
+ public void ColumnForRenderedIndex_AddsSliceStart()
+ {
+ Assert.AreEqual(300, HorizontalSliceMath.ColumnForRenderedIndex(renderedIndex: 100, sliceStart: 200, fullLineLength: 1000));
+ }
+
+ [TestMethod]
+ public void ColumnForRenderedIndex_ClampsToLineLength()
+ {
+ Assert.AreEqual(1000, HorizontalSliceMath.ColumnForRenderedIndex(renderedIndex: 999999, sliceStart: 200, fullLineLength: 1000));
+ }
+
+ [TestMethod]
+ public void ColumnAndRenderedIndex_RoundTripInsideSlice()
+ {
+ const int sliceStart = 200;
+ const int fullLine = 1000;
+ int slicedLen = HorizontalSliceMath.SlicedLineLength(fullLine, sliceStart, sliceLen: 500);
+ for (int column = sliceStart; column <= sliceStart + slicedLen; column += 37)
+ {
+ int rendered = HorizontalSliceMath.RenderedIndexForColumn(column, sliceStart, slicedLen);
+ int roundTrip = HorizontalSliceMath.ColumnForRenderedIndex(rendered, sliceStart, fullLine);
+ Assert.AreEqual(column, roundTrip);
+ }
+ }
+}
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/HorizontalSliceMath.cs b/TextControlBox/Core/HorizontalSliceMath.cs
new file mode 100644
index 0000000..af832d4
--- /dev/null
+++ b/TextControlBox/Core/HorizontalSliceMath.cs
@@ -0,0 +1,74 @@
+using System;
+
+namespace TextControlBoxNS.Core;
+
+///
+/// Pure (WinUI-free) arithmetic for horizontal virtualization of very long lines: computing the slice window
+/// from the viewport, its buffered reuse "safe zone", and mapping document columns in and out of the sliced
+/// layout. Kept separate from so the index math (where an off-by-one
+/// silently misplaces the caret or selection) is unit-testable without a GPU canvas.
+///
+internal static class HorizontalSliceMath
+{
+ // A little padding each side of the raw viewport so the caret sitting at the very edge is still inside
+ // the laid-out slice.
+ private const int VisiblePaddingLeft = 50;
+ private const int VisiblePaddingRight = 100;
+
+ ///
+ /// Visible character range [start, end) covered by the viewport at a given horizontal scroll (both in
+ /// pixels), padded a little each side. Assumes a monospace .
+ ///
+ public static (int visibleStart, int visibleEnd) VisibleCharRange(double horizontalScrollPixels, double viewportWidthPixels, double charWidth)
+ {
+ double cw = charWidth <= 0 ? 1 : charWidth;
+ int visibleStart = Math.Max(0, (int)(horizontalScrollPixels / cw) - VisiblePaddingLeft);
+ int visibleEnd = visibleStart + (int)(viewportWidthPixels / cw) + VisiblePaddingRight;
+ return (visibleStart, visibleEnd);
+ }
+
+ ///
+ /// True when the viewport [, ) is still inside
+ /// the current window's buffered safe zone [, ), so
+ /// the existing slice can be reused without rebuilding the layout.
+ ///
+ public static bool CanReuseWindow(int visibleStart, int visibleEnd, int safeStart, int safeEnd)
+ => visibleStart >= safeStart && visibleEnd <= safeEnd;
+
+ ///
+ /// Computes a fresh slice window for the given viewport range, with a generous buffer either side for
+ /// smooth scrolling. Returns the window [sliceStart, sliceStart + sliceLen) plus the inner safe zone
+ /// [safeStart, safeEnd) to store for the next-frame check.
+ ///
+ public static (int sliceStart, int sliceLen, int safeStart, int safeEnd) ComputeWindow(int visibleStart, int visibleEnd)
+ {
+ int visibleChars = Math.Max(1, visibleEnd - visibleStart);
+ int bufferChars = visibleChars * 3;
+ int sliceStart = Math.Max(0, visibleStart - bufferChars);
+ int sliceLen = visibleChars + (bufferChars * 2);
+ int safeStart = visibleStart;
+ int safeEnd = visibleEnd + bufferChars;
+ return (sliceStart, sliceLen, safeStart, safeEnd);
+ }
+
+ ///
+ /// Number of characters a line contributes to the window: its length past ,
+ /// capped at the window width . 0 when the line ends before the window.
+ ///
+ public static int SlicedLineLength(int fullLineLength, int sliceStart, int sliceLen)
+ {
+ if (sliceLen <= 0)
+ return 0;
+ return Math.Clamp(fullLineLength - sliceStart, 0, sliceLen);
+ }
+
+ /// Rendered index within a line's sliced layout for a document column, clamped to the sliced
+ /// length. Inverse of .
+ public static int RenderedIndexForColumn(int characterPosition, int sliceStart, int slicedLineLength)
+ => Math.Clamp(characterPosition - sliceStart, 0, slicedLineLength);
+
+ /// Document column for a rendered index within a line's sliced layout, clamped to the document
+ /// line length. Inverse of .
+ public static int ColumnForRenderedIndex(int renderedIndex, int sliceStart, int fullLineLength)
+ => Math.Clamp(renderedIndex + sliceStart, 0, fullLineLength);
+}
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/Renderer/CursorRenderer.cs b/TextControlBox/Core/Renderer/CursorRenderer.cs
index 499ecb9..b4951da 100644
--- a/TextControlBox/Core/Renderer/CursorRenderer.cs
+++ b/TextControlBox/Core/Renderer/CursorRenderer.cs
@@ -94,14 +94,19 @@ public void Draw(CanvasControl canvasText, CanvasControl canvasCursor, CanvasDra
if (characterPos > currentLineLength)
characterPos = currentLineLength;
+ // Map the caret's document column into the (possibly sliced) current-line layout and shift its x
+ // by the slice pixel offset via HorizontalOffset. Both are no-ops when horizontal virtualization
+ // is inactive, so this path is unchanged for ordinary files.
+ int renderedCharacterPos = textRenderer.GetRenderedCharacterIndexForDocumentCharacter(cursorManager.LineNumber, characterPos);
+
// Only paint the caret during the "on" phase of the blink. The current-line highlighter
// below stays unconditional so the highlighted line does not flicker while the caret blinks.
- if (caretBlinkManager.IsCaretVisible)
+ if (caretBlinkManager.IsCaretVisible && renderedCharacterPos >= 0)
{
RenderCursor(
textRenderer.CurrentLineTextLayout,
- characterPos,
- (float)-scrollManager.HorizontalScroll,
+ renderedCharacterPos,
+ textRenderer.HorizontalOffset,
renderPosY,
zoomManager.ZoomedFontSize,
_CursorSize,
diff --git a/TextControlBox/Core/Renderer/SelectionRenderer.cs b/TextControlBox/Core/Renderer/SelectionRenderer.cs
index ed37554..651de20 100644
--- a/TextControlBox/Core/Renderer/SelectionRenderer.cs
+++ b/TextControlBox/Core/Renderer/SelectionRenderer.cs
@@ -98,7 +98,22 @@ Color selectionColor
characterPosEnd = textManager.totalLines.Span[endLine].Length;
}
- if (startLine == endLine)
+ if (textRenderer.IsHorizontallyVirtualized)
+ {
+ // Every visible line is sliced to the same horizontal window; map both endpoints into the
+ // multi-line sliced layout via the per-line prefix offsets. This replaces the cumulative
+ // full-line-length math below, which would over-count because the rendered prior lines are
+ // sliced (shorter) than their document length.
+ selStartIndex = textRenderer.GetRenderedLayoutIndexForDocument(startLine, characterPosStart);
+ selEndIndex = textRenderer.GetRenderedLayoutIndexForDocument(endLine, characterPosEnd);
+ if (selStartIndex < 0 || selEndIndex < 0)
+ {
+ selectionManager.currentTextSelection.renderedIndex = 0;
+ selectionManager.currentTextSelection.renderedLength = 0;
+ return;
+ }
+ }
+ else if (startLine == endLine)
{
int lenghtToLine = 0;
for (int i = 0; i < startLine - unrenderedLinesToRenderStart; i++)
diff --git a/TextControlBox/Core/Renderer/TextRenderer.cs b/TextControlBox/Core/Renderer/TextRenderer.cs
index 95b0529..bc0ae34 100644
--- a/TextControlBox/Core/Renderer/TextRenderer.cs
+++ b/TextControlBox/Core/Renderer/TextRenderer.cs
@@ -3,8 +3,11 @@
using Microsoft.Graphics.Canvas.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System;
+using System.Collections.Generic;
+using System.Text;
using TextControlBoxNS.Core.Text;
using TextControlBoxNS.Helper;
+using TextControlBoxNS.Models;
using Windows.Foundation;
namespace TextControlBoxNS.Core.Renderer;
@@ -19,12 +22,42 @@ internal class TextRenderer
public bool NeedsUpdateTextLayout = true;
public bool NeedsTextFormatUpdate = true;
public float SingleLineHeight { get => TextFormat == null ? 0 : TextFormat.LineSpacing; }
- public float HorizontalOffset => (float)-scrollManager.HorizontalScroll;
+ public float HorizontalOffset => (float)-scrollManager.HorizontalScroll + HorizontalSlicePixelOffset;
public int NumberOfStartLine = 0;
public int NumberOfRenderedLines = 0;
public string RenderedText = "";
public string OldRenderedText = null;
+ // ── Horizontal virtualization (non-wrap, very long lines) ──────────────────────────
+ // Files with pathologically long lines (minified JS/CSS/JSON, JSONL logs) turn every frame into a
+ // multi-megabyte string join + text layout, which stutters horizontal scrolling. When any visible line
+ // exceeds the threshold we lay out ONLY a sliced window of each visible line instead of the whole line,
+ // and record enough offsets to map document positions in and out of that sliced layout for the caret,
+ // click hit-testing and selection.
+ private const int HorizontalVirtualizationThreshold = 50_000;
+ /// First document column kept by the current horizontal slice window.
+ public int HorizontalSliceStart { get; private set; }
+ /// Width (in chars) of the horizontal slice window; 0 when not sliced.
+ public int HorizontalSliceLength { get; private set; }
+ /// True while horizontal virtualization is active for the current frame.
+ public bool IsHorizontallyVirtualized { get; private set; }
+ /// Rendered-layout offset of each visible line's text start inside
+ /// when sliced (indexed by ordinal from ). Lets selection map a document
+ /// (line, char) to a multi-line rendered index in O(1). Rebuilt on every re-slice.
+ private readonly List _renderedLineSlicePrefix = new();
+ /// Measured width of one character in the current font (monospace assumption); falls back to an
+ /// estimate until measured.
+ private float _cachedCharWidth;
+ private float CachedCharWidth => _cachedCharWidth > 0 ? _cachedCharWidth : Math.Max(1, zoomManager.ZoomedFontSize * 0.6f);
+ /// Visible char range covered by the current window's buffered safe zone [start, end); while the
+ /// viewport stays inside it the window is reused so small horizontal scrolls don't re-slice.
+ private int _hSliceVisibleStart;
+ private int _hSliceVisibleEnd;
+ /// Pixel offset of the slice start. A sliced layout's char 0 is document column
+ /// , so adding this to the horizontal draw offset re-aligns it to
+ /// document coordinates. 0 when not sliced, which keeps unchanged.
+ public float HorizontalSlicePixelOffset => IsHorizontallyVirtualized ? HorizontalSliceStart * CachedCharWidth : 0;
+
private CursorManager cursorManager;
private TextManager textManager;
private ScrollManager scrollManager;
@@ -91,14 +124,151 @@ public bool OutOfRenderedArea(int line)
public void UpdateCurrentLineTextLayout(CanvasControl canvasText)
{
CurrentLineTextLayout?.Dispose();
- CurrentLineTextLayout =
- cursorManager.LineNumber < textManager.LinesCount ?
- textLayoutManager.CreateTextLayout(
+ if (cursorManager.LineNumber >= textManager.LinesCount)
+ {
+ CurrentLineTextLayout = null;
+ return;
+ }
+
+ string lineText = textManager.GetLineText(cursorManager.LineNumber) + "|";
+
+ // Slice the current line to the SAME horizontal window as the main text (see Draw) so the caret and
+ // click hit-testing line up with the sliced layout: the caret's rendered index is
+ // (documentChar - HorizontalSliceStart) and its x adds HorizontalSlicePixelOffset. A window computed
+ // independently here would misplace the caret.
+ if (IsHorizontallyVirtualized && HorizontalSliceLength > 0)
+ {
+ int sliceStart = Math.Min(HorizontalSliceStart, lineText.Length);
+ int len = Math.Min(HorizontalSliceLength, lineText.Length - sliceStart);
+ lineText = len > 0 ? lineText.Substring(sliceStart, len) : string.Empty;
+ }
+
+ CurrentLineTextLayout = textLayoutManager.CreateTextLayout(
canvasText,
TextFormat,
- textManager.GetLineText(cursorManager.LineNumber) + "|",
- canvasText.Size) :
- null;
+ lineText,
+ canvasText.Size);
+ }
+
+ // ── Horizontal virtualization helpers ──────────────────────────────────────────────
+
+ /// Rendered index (inside the current line's sliced layout) for a document column, clamped to
+ /// this line's sliced length. When not sliced the rendered index equals the document column, so the
+ /// caret/click paths can call this unconditionally.
+ public int GetRenderedCharacterIndexForDocumentCharacter(int lineIndex, int characterPosition)
+ {
+ if (IsHorizontallyVirtualized)
+ return HorizontalSliceMath.RenderedIndexForColumn(characterPosition, HorizontalSliceStart, SlicedLineLength(lineIndex));
+ return characterPosition;
+ }
+
+ /// Document column for a rendered index inside the current line's sliced layout, clamped to the
+ /// document line length. When not sliced the document column equals the rendered index.
+ public int GetDocumentCharacterIndexFromRenderedIndex(int lineIndex, int renderedIndex)
+ {
+ if (IsHorizontallyVirtualized)
+ return HorizontalSliceMath.ColumnForRenderedIndex(renderedIndex, HorizontalSliceStart, textManager.GetLineLength(lineIndex));
+ return renderedIndex;
+ }
+
+ /// Chars line contributes to the current slice: its length past
+ /// , capped at the window width. 0 when the line ends before the
+ /// window (nothing of it is visible at this scroll position).
+ private int SlicedLineLength(int lineIndex)
+ {
+ if (HorizontalSliceLength <= 0 || lineIndex < 0 || lineIndex >= textManager.LinesCount)
+ return 0;
+ return HorizontalSliceMath.SlicedLineLength(textManager.GetLineLength(lineIndex), HorizontalSliceStart, HorizontalSliceLength);
+ }
+
+ /// Maps a document position (line, char) to its character index inside the multi-line sliced
+ /// via the per-line prefix offsets built by
+ /// . Returns -1 when the line is outside the rendered range.
+ /// Used by selection rendering, which can span multiple rendered lines.
+ public int GetRenderedLayoutIndexForDocument(int lineIndex, int characterPosition)
+ {
+ int ordinal = lineIndex - NumberOfStartLine;
+ if (ordinal < 0 || ordinal >= _renderedLineSlicePrefix.Count)
+ return -1;
+ int inLine = HorizontalSliceMath.RenderedIndexForColumn(characterPosition, HorizontalSliceStart, SlicedLineLength(lineIndex));
+ return _renderedLineSlicePrefix[ordinal] + inLine;
+ }
+
+ /// Decides whether to horizontally virtualize this frame and, if so, the slice window
+ /// [, sliceStart + ). Only triggers when at least
+ /// one visible line exceeds , so ordinary files keep the
+ /// normal (untouched) render path. Reuses the previous window while the viewport stays inside its
+ /// buffered safe zone so small horizontal scrolls don't re-slice.
+ private bool ShouldHorizontallySlice(CanvasControl canvasText, out int sliceStart, out int sliceLen)
+ {
+ sliceStart = 0;
+ sliceLen = 0;
+ if (NumberOfRenderedLines <= 0)
+ return false;
+
+ int end = Math.Min(NumberOfStartLine + NumberOfRenderedLines, textManager.LinesCount);
+ int maxLen = 0;
+ for (int i = NumberOfStartLine; i < end; i++)
+ {
+ int len = textManager.GetLineLength(i);
+ if (len > maxLen)
+ maxLen = len;
+ }
+ if (maxLen <= HorizontalVirtualizationThreshold)
+ return false;
+
+ float charWidth = CachedCharWidth;
+ float viewportWidth = (float)canvasText.Size.Width;
+ float hScroll = (float)scrollManager.HorizontalScroll;
+ (int visibleStartChar, int visibleEndChar) = HorizontalSliceMath.VisibleCharRange(hScroll, viewportWidth, charWidth);
+
+ // Reuse the current window while the viewport is still inside its buffered safe zone.
+ if (IsHorizontallyVirtualized && HorizontalSliceLength > 0
+ && HorizontalSliceMath.CanReuseWindow(visibleStartChar, visibleEndChar, _hSliceVisibleStart, _hSliceVisibleEnd))
+ {
+ sliceStart = HorizontalSliceStart;
+ sliceLen = HorizontalSliceLength;
+ return true;
+ }
+
+ (sliceStart, sliceLen, _hSliceVisibleStart, _hSliceVisibleEnd) = HorizontalSliceMath.ComputeWindow(visibleStartChar, visibleEndChar);
+ return true;
+ }
+
+ /// Builds the visible text with every line sliced to the same horizontal window, joined with the
+ /// newline character, and (re)builds so document positions can be
+ /// mapped back into the resulting layout. Never materializes a full (multi-megabyte) line.
+ private string BuildHorizontallySlicedText(int sliceStart, int sliceLen)
+ {
+ _renderedLineSlicePrefix.Clear();
+ string newline = textManager.NewLineCharacter;
+ int newlineLen = newline.Length;
+ int end = Math.Min(NumberOfStartLine + NumberOfRenderedLines, textManager.LinesCount);
+
+ int capacity = Math.Min(Math.Max(1, NumberOfRenderedLines) * (sliceLen + newlineLen), 1 << 21);
+ var builder = new StringBuilder(capacity);
+ int offset = 0;
+ bool first = true;
+ for (int i = NumberOfStartLine; i < end; i++)
+ {
+ if (!first)
+ {
+ builder.Append(newline);
+ offset += newlineLen;
+ }
+ first = false;
+
+ _renderedLineSlicePrefix.Add(offset); // rendered-layout offset of this line's text start
+
+ string lineText = textManager.GetLineText(i);
+ if (lineText.Length > sliceStart)
+ {
+ int len = Math.Min(sliceLen, lineText.Length - sliceStart);
+ builder.Append(lineText, sliceStart, len);
+ offset += len;
+ }
+ }
+ return builder.ToString();
}
public (int startLine, int linesToRender) CalculateLinesToRender()
{
@@ -133,12 +303,43 @@ public void Draw(CanvasControl canvasText, CanvasDrawEventArgs args)
invisibleCharactersRenderer.UpdateTextFormat(canvasText, TextFormat);
designHelper.CreateColorResources(args.DrawingSession);
+
+ // Measure the actual character width (monospace assumption) for the horizontal-virtualization
+ // slice-to-pixel offset. Re-measured whenever the format is rebuilt (font/zoom change).
+ using (var measureLayout = new CanvasTextLayout(args.DrawingSession, "M", TextFormat, 0, 0))
+ {
+ _cachedCharWidth = Math.Max(1, (float)measureLayout.DrawBounds.Width);
+ }
}
(NumberOfStartLine, NumberOfRenderedLines) = CalculateLinesToRender();
- var renderTextData = textManager.GetLinesForRendering(NumberOfStartLine, NumberOfRenderedLines);
- var linesToRenderRef = renderTextData.Lines;
- RenderedText = renderTextData.Text;
+
+ // Decide horizontal virtualization BEFORE materializing the visible text. Joining many very long
+ // lines (megabytes) into one string every frame — then laying it out — is the actual scroll-stutter
+ // cost on files like JSONL logs (hundreds of 50k+ char lines). When any visible line is very long we
+ // build ONLY the sliced text (a few KB) instead of the full join, and record a per-line prefix so the
+ // caret/selection can still map document positions into the sliced multi-line layout.
+ LineSliceResult renderTextData;
+ if (ShouldHorizontallySlice(canvasText, out int hSliceStart, out int hSliceLen))
+ {
+ RenderedText = BuildHorizontallySlicedText(hSliceStart, hSliceLen);
+ HorizontalSliceStart = hSliceStart;
+ HorizontalSliceLength = hSliceLen;
+ IsHorizontallyVirtualized = true;
+ // Syntax highlighting is skipped for a sliced layout (its char offsets would shift), so pass an
+ // empty per-line span.
+ renderTextData = new LineSliceResult(RenderedText, ReadOnlySpan.Empty);
+ }
+ else
+ {
+ renderTextData = textManager.GetLinesForRendering(NumberOfStartLine, NumberOfRenderedLines);
+ RenderedText = renderTextData.Text;
+ IsHorizontallyVirtualized = false;
+ HorizontalSliceStart = 0;
+ HorizontalSliceLength = 0;
+ _hSliceVisibleStart = 0;
+ _hSliceVisibleEnd = 0;
+ }
//check rendering and calculation updates
lineNumberRenderer.CheckGenerateLineNumberText();
@@ -178,12 +379,12 @@ public void Draw(CanvasControl canvasText, CanvasDrawEventArgs args)
RenderedText,
searchManager.MatchingSearchLines,
searchManager.searchParameter.SearchExpression,
- (float)-scrollManager.HorizontalScroll,
+ HorizontalOffset,
SingleLineHeight / scrollManager.DefaultVerticalScrollSensitivity,
designHelper._Design.SearchHighlightColor
);
- ccls.DrawTextLayout(DrawnTextLayout, (float)-scrollManager.HorizontalScroll, SingleLineHeight, designHelper.TextColorBrush);
+ ccls.DrawTextLayout(DrawnTextLayout, HorizontalOffset, SingleLineHeight, designHelper.TextColorBrush);
invisibleCharactersRenderer.DrawTabsAndSpaces(args, ccls, RenderedText, DrawnTextLayout, SingleLineHeight);
}
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/Helper/CursorHelper.cs b/TextControlBox/Helper/CursorHelper.cs
index b1576f3..25d186b 100644
--- a/TextControlBox/Helper/CursorHelper.cs
+++ b/TextControlBox/Helper/CursorHelper.cs
@@ -50,7 +50,12 @@ public static void UpdateCursorPosFromPoint(CanvasControl canvasText, CurrentLin
//GetCursorLineFromPoint returns absolute line index.
textRenderer.UpdateCurrentLineTextLayout(canvasText);
- cursorPos.CharacterPosition = GetCharacterPositionFromPoint(currentLineManager, textRenderer.CurrentLineTextLayout, point, (float)-scrollManager.HorizontalScroll);
+
+ // The current-line layout is sliced to the horizontal window when virtualized, so hit-test against
+ // the slice-shifted margin, then map the rendered index back to a document column. Both helpers are
+ // no-ops when horizontal virtualization is inactive.
+ int renderedCharacterPosition = GetCharacterPositionFromPoint(currentLineManager, textRenderer.CurrentLineTextLayout, point, textRenderer.HorizontalOffset);
+ cursorPos.CharacterPosition = textRenderer.GetDocumentCharacterIndexFromRenderedIndex(cursorPos.LineNumber, renderedCharacterPosition);
}
}