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