From 19f12326d809229791088db1fb070ac598b37e42 Mon Sep 17 00:00:00 2001 From: andrewtheart Date: Sat, 11 Jul 2026 04:16:10 -0500 Subject: [PATCH 1/4] Add IScrollOffsetSource: a pixel-based scroll-position seam Introduce a small IScrollOffsetSource abstraction so the editor scroll position is expressed in pixels rather than legacy scrollbar units (SingleLineHeight / DefaultVerticalScrollSensitivity). ScrollManager routes every offset read/write through it; the ScrollBarOffsetSource adapter backs it with the existing two ScrollBar primitives and does the pixel<->scrollbar-unit conversion internally (ScrollOffsetMath). Behavior is unchanged. Foundation for word-wrap scrolling, long-line horizontal virtualization and diagonal 2-axis touchpad scrolling (#33). Adds ScrollOffsetMathTests. --- TextControlBox.Tests/ScrollOffsetMathTests.cs | 96 +++++++++++++ TextControlBox/Core/IScrollOffsetSource.cs | 128 ++++++++++++++++++ TextControlBox/Core/ScrollManager.cs | 40 +++--- TextControlBox/Core/ScrollOffsetMath.cs | 52 +++++++ 4 files changed, 299 insertions(+), 17 deletions(-) create mode 100644 TextControlBox.Tests/ScrollOffsetMathTests.cs create mode 100644 TextControlBox/Core/IScrollOffsetSource.cs create mode 100644 TextControlBox/Core/ScrollOffsetMath.cs diff --git a/TextControlBox.Tests/ScrollOffsetMathTests.cs b/TextControlBox.Tests/ScrollOffsetMathTests.cs new file mode 100644 index 0000000..08777c5 --- /dev/null +++ b/TextControlBox.Tests/ScrollOffsetMathTests.cs @@ -0,0 +1,96 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TextControlBoxNS.Core; + +namespace TextControlBox.Tests; + +/// +/// Pure conversions between the pixel scroll seam (IScrollOffsetSource) and the legacy ScrollBar +/// backing store. These are the exact place a "scroll is N× too fast/slow" regression would hide, +/// so they are unit-tested in isolation (a ScrollBar cannot be instantiated headless). +/// +[TestClass] +public class ScrollOffsetMathTests +{ + [TestMethod] + public void NormalizeSensitivity_FloorsAtOne() + { + Assert.AreEqual(1, ScrollOffsetMath.NormalizeSensitivity(0)); + Assert.AreEqual(1, ScrollOffsetMath.NormalizeSensitivity(-5)); + Assert.AreEqual(4, ScrollOffsetMath.NormalizeSensitivity(4)); + } + + [TestMethod] + public void VerticalValueToPixels_MultipliesBySensitivity() + { + Assert.AreEqual(400.0, ScrollOffsetMath.VerticalValueToPixels(100, 4)); + Assert.AreEqual(100.0, ScrollOffsetMath.VerticalValueToPixels(100, 1)); + } + + [TestMethod] + public void PixelsToVerticalValue_DividesBySensitivity() + { + Assert.AreEqual(100.0, ScrollOffsetMath.PixelsToVerticalValue(400, 4)); + Assert.AreEqual(100.0, ScrollOffsetMath.PixelsToVerticalValue(100, 1)); + } + + [TestMethod] + public void PixelsToVerticalValue_ClampsNegativeToZero() + { + Assert.AreEqual(0.0, ScrollOffsetMath.PixelsToVerticalValue(-500, 4)); + } + + [TestMethod] + public void VerticalOffset_RoundTripsThroughScrollBarUnits() + { + // A pixel offset stored as a legacy ScrollBar.Value and read back must be unchanged. + const int sensitivity = 4; + foreach (double pixels in new[] { 0.0, 20.0, 399.0, 4096.0, 1_000_000.0 }) + { + double value = ScrollOffsetMath.PixelsToVerticalValue(pixels, sensitivity); + double roundTrip = ScrollOffsetMath.VerticalValueToPixels(value, sensitivity); + Assert.AreEqual(pixels, roundTrip, 1e-9); + } + } + + [TestMethod] + public void VerticalExtent_ConvertsBetweenMaximumAndPixels() + { + const int sensitivity = 4; + const double viewport = 300.0; + + // Content taller than the viewport: Maximum (legacy units) + viewport (px) == extent (px). + double extent = ScrollOffsetMath.VerticalMaximumToExtentPixels(500, sensitivity, viewport); + Assert.AreEqual(500 * sensitivity + viewport, extent); + + double maximum = ScrollOffsetMath.VerticalExtentPixelsToMaximum(extent, sensitivity, viewport); + Assert.AreEqual(500.0, maximum, 1e-9); + } + + [TestMethod] + public void VerticalExtentPixelsToMaximum_FloorsAtZeroWhenContentFitsViewport() + { + // Content shorter than the viewport is not scrollable → Maximum 0. + Assert.AreEqual(0.0, ScrollOffsetMath.VerticalExtentPixelsToMaximum(100, 4, 300)); + } + + [TestMethod] + public void HorizontalOffset_ClampsNegativeToZero_AndIsPixelIdentity() + { + Assert.AreEqual(0.0, ScrollOffsetMath.ClampHorizontalOffset(-1)); + Assert.AreEqual(42.0, ScrollOffsetMath.ClampHorizontalOffset(42)); + } + + [TestMethod] + public void HorizontalExtent_ConvertsBetweenMaximumAndPixels() + { + const double viewport = 250.0; + double extent = ScrollOffsetMath.HorizontalMaximumToExtentPixels(1000, viewport); + Assert.AreEqual(1000 + viewport, extent); + + double maximum = ScrollOffsetMath.HorizontalExtentPixelsToMaximum(extent, viewport); + Assert.AreEqual(1000.0, maximum, 1e-9); + + // Content narrower than the viewport → not scrollable. + Assert.AreEqual(0.0, ScrollOffsetMath.HorizontalExtentPixelsToMaximum(100, viewport)); + } +} diff --git a/TextControlBox/Core/IScrollOffsetSource.cs b/TextControlBox/Core/IScrollOffsetSource.cs new file mode 100644 index 0000000..0ce497d --- /dev/null +++ b/TextControlBox/Core/IScrollOffsetSource.cs @@ -0,0 +1,128 @@ +using System; +using Microsoft.UI.Xaml.Controls.Primitives; + +namespace TextControlBoxNS.Core; + +/// +/// The single funnel for the editor's scroll position. Every consumer that used to read or write +/// verticalScrollBar.Value / horizontalScrollBar.Value now goes through this seam. +/// +/// All offsets and extents are PIXELS. This is deliberate (see +/// PLANS/EDITOR_DIAGONAL_SCROLL_REWRITE_PLAN.md, Phase 1 / finding #2): the legacy vertical scroll was +/// stored in SingleLineHeight / DefaultVerticalScrollSensitivity scrollbar units, so exposing the +/// seam in pixels from the start means the Phase 2 backend swap (two primitives ΓåÆ +/// a real ScrollViewer) does not become a whole-consumer unit rewrite. The initial +/// adapter does the pixelΓåöscrollbar-unit conversion internally. +/// +internal interface IScrollOffsetSource +{ + /// Vertical scroll position, in pixels (0 = top). + double VerticalOffset { get; set; } + + /// Horizontal scroll position, in pixels (0 = left). + double HorizontalOffset { get; set; } + + /// Total content height in pixels (ScrollViewer.ExtentHeight semantics). + double VerticalExtent { get; set; } + + /// Total content width in pixels (ScrollViewer.ExtentWidth semantics). + double HorizontalExtent { get; set; } + + /// Visible viewport width in pixels. + double ViewportWidth { get; set; } + + /// Visible viewport height in pixels. + double ViewportHeight { get; set; } + + /// Sets one or both offsets (pixels). Pass null to leave that axis unchanged. + /// Mirrors ScrollViewer.ChangeView so the Phase 2 backend is a drop-in. + void ChangeView(double? horizontalOffset, double? verticalOffset); + + /// Raised whenever the scroll position changes ΓÇö a user drag of a scrollbar thumb OR a + /// programmatic write to / (caret-follow, + /// page keys, go-to-line, find reveal, match hand-off, wheel), or ΓÇö after the Phase 2 swap ΓÇö native + /// wheel/touchpad panning. The Phase 1 raises it from the two + /// scrollbars' Scroll events and from the offset setters. The diagonal-scroll + /// InteractionTracker subscribes so it snaps to any programmatic scroll immediately. + event EventHandler ViewChanged; +} + +/// +/// Phase 1 backend for : the existing two standalone +/// primitives. Vertical ScrollBar.Value/ScrollBar.Maximum are stored in legacy +/// units of SingleLineHeight / verticalSensitivity; this adapter multiplies/divides by +/// verticalSensitivity so the seam is pixel-based. Horizontal values are already pixels. +/// +internal sealed class ScrollBarOffsetSource : IScrollOffsetSource +{ + private readonly ScrollBar _vertical; + private readonly ScrollBar _horizontal; + private readonly int _verticalSensitivity; + + public ScrollBarOffsetSource(ScrollBar vertical, ScrollBar horizontal, int verticalSensitivity) + { + _vertical = vertical; + _horizontal = horizontal; + _verticalSensitivity = Math.Max(1, verticalSensitivity); + _vertical.Scroll += OnScrollBarScroll; + _horizontal.Scroll += OnScrollBarScroll; + } + + public event EventHandler ViewChanged; + + private void OnScrollBarScroll(object sender, ScrollEventArgs e) => ViewChanged?.Invoke(this, EventArgs.Empty); + + public double VerticalOffset + { + get => ScrollOffsetMath.VerticalValueToPixels(_vertical.Value, _verticalSensitivity); + // ScrollBar.Value is clamped to [Minimum, Maximum] by the control, matching the legacy behavior + // where every scroll mutator relied on the scrollbar to clamp an out-of-range assignment. + set + { + _vertical.Value = ScrollOffsetMath.PixelsToVerticalValue(value, _verticalSensitivity); + ViewChanged?.Invoke(this, EventArgs.Empty); + } + } + + public double HorizontalOffset + { + get => _horizontal.Value; + set + { + _horizontal.Value = ScrollOffsetMath.ClampHorizontalOffset(value); + ViewChanged?.Invoke(this, EventArgs.Empty); + } + } + + public double VerticalExtent + { + get => ScrollOffsetMath.VerticalMaximumToExtentPixels(_vertical.Maximum, _verticalSensitivity, ViewportHeight); + set => _vertical.Maximum = ScrollOffsetMath.VerticalExtentPixelsToMaximum(value, _verticalSensitivity, ViewportHeight); + } + + public double HorizontalExtent + { + get => ScrollOffsetMath.HorizontalMaximumToExtentPixels(_horizontal.Maximum, ViewportWidth); + set => _horizontal.Maximum = ScrollOffsetMath.HorizontalExtentPixelsToMaximum(value, ViewportWidth); + } + + public double ViewportWidth + { + get => _horizontal.ViewportSize; + set => _horizontal.ViewportSize = value; + } + + public double ViewportHeight + { + get => _vertical.ViewportSize; + set => _vertical.ViewportSize = value; + } + + public void ChangeView(double? horizontalOffset, double? verticalOffset) + { + if (horizontalOffset.HasValue) + HorizontalOffset = horizontalOffset.Value; + if (verticalOffset.HasValue) + VerticalOffset = verticalOffset.Value; + } +} diff --git a/TextControlBox/Core/ScrollManager.cs b/TextControlBox/Core/ScrollManager.cs index e9a9d26..ca9665c 100644 --- a/TextControlBox/Core/ScrollManager.cs +++ b/TextControlBox/Core/ScrollManager.cs @@ -17,8 +17,13 @@ internal class ScrollManager public int DefaultVerticalScrollSensitivity = 4; public float OldHorizontalScrollValue = 0; - public double VerticalScroll { get => verticalScrollBar.Value; set { verticalScrollBar.Value = value < 0 ? 0 : value; canvasHelper.UpdateAll(); } } - public double HorizontalScroll { get => horizontalScrollBar.Value; set { horizontalScrollBar.Value = value < 0 ? 0 : value; canvasHelper.UpdateAll(); } } + // The single pixel-based scroll-position seam (see IScrollOffsetSource). Phase 1 backs it with the + // two ScrollBar primitives; VerticalScroll / HorizontalScroll below keep their legacy scrollbar-unit / + // pixel API semantics for the public surface but store through the pixel offset source. + public IScrollOffsetSource OffsetSource { get; private set; } + + public double VerticalScroll { get => OffsetSource.VerticalOffset / DefaultVerticalScrollSensitivity; set { OffsetSource.VerticalOffset = (value < 0 ? 0 : value) * DefaultVerticalScrollSensitivity; canvasHelper.UpdateAll(); } } + public double HorizontalScroll { get => OffsetSource.HorizontalOffset; set { OffsetSource.HorizontalOffset = value < 0 ? 0 : value; canvasHelper.UpdateAll(); } } public ScrollBar verticalScrollBar; public ScrollBar horizontalScrollBar; @@ -40,6 +45,7 @@ public void Init(CoreTextControlBox coreTextbox, CanvasUpdateManager canvasHelpe this.textManager = textManager; this.coreTextbox = coreTextbox; this.zoomManager = zoomManager; + OffsetSource = new ScrollBarOffsetSource(this.verticalScrollBar, this.horizontalScrollBar, DefaultVerticalScrollSensitivity); verticalScrollBar.Loaded += VerticalScrollbar_Loaded; verticalScrollBar.Scroll += VerticalScrollBar_Scroll; horizontalScrollBar.Scroll += HorizontalScrollBar_Scroll; @@ -53,7 +59,7 @@ internal void VerticalScrollbar_Loaded(object sender, RoutedEventArgs e) internal void VerticalScrollBar_Scroll(object sender, ScrollEventArgs e) { //only update when a line was scrolled - if ((int)(verticalScrollBar.Value / textRenderer.SingleLineHeight * DefaultVerticalScrollSensitivity) != textRenderer.NumberOfStartLine) + if ((int)(OffsetSource.VerticalOffset / textRenderer.SingleLineHeight) != textRenderer.NumberOfStartLine) { canvasHelper.UpdateAll(); } @@ -77,20 +83,20 @@ public void ScrollLineIntoViewIfOutside(int line, bool update = true) public void ScrollOneLineUp(bool update = true) { - verticalScrollBar.Value -= textRenderer.SingleLineHeight / DefaultVerticalScrollSensitivity; + OffsetSource.VerticalOffset -= textRenderer.SingleLineHeight; if(update) canvasHelper.UpdateAll(); } public void ScrollOneLineDown(bool update = true) { - verticalScrollBar.Value += textRenderer.SingleLineHeight / DefaultVerticalScrollSensitivity; + OffsetSource.VerticalOffset += textRenderer.SingleLineHeight; if(update) canvasHelper.UpdateAll(); } public void ScrollLineIntoView(int line, bool update = true) { - verticalScrollBar.Value = (line - textRenderer.NumberOfRenderedLines / 2) * textRenderer.SingleLineHeight / DefaultVerticalScrollSensitivity; + OffsetSource.VerticalOffset = (line - textRenderer.NumberOfRenderedLines / 2) * textRenderer.SingleLineHeight; if(update) canvasHelper.UpdateAll(); @@ -98,13 +104,13 @@ public void ScrollLineIntoView(int line, bool update = true) public void ScrollTopIntoView(bool update = true) { - verticalScrollBar.Value = (cursorManager.LineNumber - 1) * textRenderer.SingleLineHeight / DefaultVerticalScrollSensitivity; + OffsetSource.VerticalOffset = (cursorManager.LineNumber - 1) * textRenderer.SingleLineHeight; if(update) canvasHelper.UpdateAll(); } public void ScrollBottomIntoView(bool update = true) { - verticalScrollBar.Value = (cursorManager.LineNumber - textRenderer.NumberOfRenderedLines + 1) * textRenderer.SingleLineHeight / DefaultVerticalScrollSensitivity; + OffsetSource.VerticalOffset = (cursorManager.LineNumber - textRenderer.NumberOfRenderedLines + 1) * textRenderer.SingleLineHeight; if(update) canvasHelper.UpdateAll(); } @@ -115,7 +121,7 @@ public void ScrollPageUp() if (cursorManager.LineNumber < 0) cursorManager.LineNumber = 0; - verticalScrollBar.Value -= textRenderer.NumberOfRenderedLines * textRenderer.SingleLineHeight / DefaultVerticalScrollSensitivity; + OffsetSource.VerticalOffset -= textRenderer.NumberOfRenderedLines * textRenderer.SingleLineHeight; canvasHelper.UpdateAll(); } @@ -125,7 +131,7 @@ public void ScrollPageDown() cursorManager.LineNumber += textRenderer.NumberOfRenderedLines; if (cursorManager.LineNumber > textManager.LinesCount - 1) cursorManager.LineNumber = textManager.LinesCount - 1; - verticalScrollBar.Value += textRenderer.NumberOfRenderedLines * textRenderer.SingleLineHeight / DefaultVerticalScrollSensitivity; + OffsetSource.VerticalOffset += textRenderer.NumberOfRenderedLines * textRenderer.SingleLineHeight; canvasHelper.UpdateAll(); } @@ -133,15 +139,15 @@ public void UpdateScrollToShowCursor(bool update = true) { if (textRenderer.NumberOfStartLine + textRenderer.NumberOfRenderedLines - 1 <= cursorManager.LineNumber) { - verticalScrollBar.Value = + OffsetSource.VerticalOffset = (cursorManager.LineNumber - textRenderer.NumberOfRenderedLines + 2) * - textRenderer.SingleLineHeight / DefaultVerticalScrollSensitivity; + textRenderer.SingleLineHeight; } else if (textRenderer.NumberOfStartLine > cursorManager.LineNumber) { - verticalScrollBar.Value = + OffsetSource.VerticalOffset = cursorManager.LineNumber * - textRenderer.SingleLineHeight / DefaultVerticalScrollSensitivity; + textRenderer.SingleLineHeight; } if (update) @@ -159,19 +165,19 @@ public bool ScrollIntoViewHorizontal(CanvasControl canvasText, bool update = tru if (curPosInLine == OldHorizontalScrollValue) return false; - double visibleStart = horizontalScrollBar.Value; + double visibleStart = OffsetSource.HorizontalOffset; double visibleEnd = visibleStart + canvasText.ActualWidth; bool changed = false; if (curPosInLine < visibleStart + 3) { changed = true; - horizontalScrollBar.Value = Math.Max(curPosInLine - 3, horizontalScrollBar.Minimum); + OffsetSource.HorizontalOffset = Math.Max(curPosInLine - 3, horizontalScrollBar.Minimum); } else if (curPosInLine > visibleEnd) { changed = true; - horizontalScrollBar.Value = Math.Min(curPosInLine - canvasText.ActualWidth + 5, horizontalScrollBar.Maximum + 5); + OffsetSource.HorizontalOffset = Math.Min(curPosInLine - canvasText.ActualWidth + 5, horizontalScrollBar.Maximum + 5); } OldHorizontalScrollValue = curPosInLine; diff --git a/TextControlBox/Core/ScrollOffsetMath.cs b/TextControlBox/Core/ScrollOffsetMath.cs new file mode 100644 index 0000000..29d7eef --- /dev/null +++ b/TextControlBox/Core/ScrollOffsetMath.cs @@ -0,0 +1,52 @@ +using System; + +namespace TextControlBoxNS.Core; + +/// +/// Pure, WinUI-free conversions between the editor's pixel scroll seam () +/// and the legacy ScrollBar backing store. The VERTICAL scrollbar stores Value/Maximum +/// in units of SingleLineHeight / DefaultVerticalScrollSensitivity; the HORIZONTAL scrollbar is +/// already in pixels. +/// +/// Extracted from so the pixelΓåöscrollbar-unit arithmetic ΓÇö the +/// exact place a "scroll is 4├ù too fast" regression would hide ΓÇö is unit-testable in isolation (a +/// ScrollBar cannot be instantiated headless). See PLANS/EDITOR_DIAGONAL_SCROLL_REWRITE_PLAN.md, +/// Phase 1. +/// +internal static class ScrollOffsetMath +{ + /// The vertical sensitivity is used as a divisor, so it must be at least 1. + internal static int NormalizeSensitivity(int sensitivity) => Math.Max(1, sensitivity); + + /// Vertical ScrollBar.Value (legacy units) ΓåÆ pixels. + internal static double VerticalValueToPixels(double scrollBarValue, int sensitivity) + => scrollBarValue * NormalizeSensitivity(sensitivity); + + /// Pixels ΓåÆ vertical ScrollBar.Value (legacy units). Negative pixels clamp to 0; the + /// ScrollBar itself further clamps to [Minimum, Maximum]. + internal static double PixelsToVerticalValue(double pixels, int sensitivity) + => Math.Max(0, pixels) / NormalizeSensitivity(sensitivity); + + /// Vertical ScrollBar.Maximum (legacy units) + viewport pixels ΓåÆ total content height + /// in pixels (ScrollViewer.ExtentHeight semantics). + internal static double VerticalMaximumToExtentPixels(double maximum, int sensitivity, double viewportHeightPixels) + => (maximum * NormalizeSensitivity(sensitivity)) + viewportHeightPixels; + + /// Total content height in pixels ΓåÆ vertical ScrollBar.Maximum (legacy units), floored + /// at 0 (content smaller than the viewport is not scrollable). + internal static double VerticalExtentPixelsToMaximum(double extentPixels, int sensitivity, double viewportHeightPixels) + => Math.Max(0, extentPixels - viewportHeightPixels) / NormalizeSensitivity(sensitivity); + + /// Clamps a horizontal pixel offset to be non-negative (horizontal is already pixels). + internal static double ClampHorizontalOffset(double pixels) => Math.Max(0, pixels); + + /// Horizontal ScrollBar.Maximum (pixels) + viewport pixels ΓåÆ total content width in + /// pixels (ScrollViewer.ExtentWidth semantics). + internal static double HorizontalMaximumToExtentPixels(double maximum, double viewportWidthPixels) + => maximum + viewportWidthPixels; + + /// Total content width in pixels ΓåÆ horizontal ScrollBar.Maximum (pixels), floored at + /// 0 (content narrower than the viewport is not scrollable). + internal static double HorizontalExtentPixelsToMaximum(double extentPixels, double viewportWidthPixels) + => Math.Max(0, extentPixels - viewportWidthPixels); +} From 03364d2af17ce1cebdf12428c47fe2aee973df8d Mon Sep 17 00:00:00 2001 From: andrewtheart Date: Sat, 11 Jul 2026 05:28:34 -0500 Subject: [PATCH 2/4] Add long-line horizontal virtualization On files with pathologically long lines (minified JS/CSS/JSON, JSONL logs), the renderer joined every visible line into one multi-megabyte string and laid it out on every frame, causing horizontal-scroll stutter. TextRenderer.Draw now lays out only a sliced window of each visible line when a visible line exceeds 50k chars; ordinary files keep the untouched render path. The uniform slice window is re-aligned to document coordinates via HorizontalOffset + a per-line prefix sum, and the caret, click hit-testing and selection map through GetRenderedCharacterIndexForDocumentCharacter / GetDocumentCharacterIndexFromRenderedIndex / GetRenderedLayoutIndexForDocument (all no-ops when inactive). The window/index arithmetic is extracted into a WinUI-free HorizontalSliceMath with unit tests. Step 2 of #33. --- .../HorizontalSliceMathTests.cs | 154 ++++++++++++ TextControlBox/Core/HorizontalSliceMath.cs | 74 ++++++ .../Core/Renderer/CursorRenderer.cs | 11 +- .../Core/Renderer/SelectionRenderer.cs | 17 +- TextControlBox/Core/Renderer/TextRenderer.cs | 225 +++++++++++++++++- TextControlBox/Helper/CursorHelper.cs | 7 +- 6 files changed, 471 insertions(+), 17 deletions(-) create mode 100644 TextControlBox.Tests/HorizontalSliceMathTests.cs create mode 100644 TextControlBox/Core/HorizontalSliceMath.cs 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/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/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/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); } } From 60661e2f2fdbb26c5d6d799b0bed063928f2c835 Mon Sep 17 00:00:00 2001 From: andrewtheart Date: Sat, 11 Jul 2026 05:56:49 -0500 Subject: [PATCH 3/4] Add word wrap (visual-row model) Introduce word wrap on the previously no-wrap-only control. A document line can span multiple visual rows, so scrolling, caret, selection, click hit-testing and arrow navigation work in visual-row space via a WrapRowMetrics prefix-sum model (document line <-> visual row, with cheap incremental updates) and WrapGeometry pointer math, both pure and unit-tested. Excludes very-long-line wrapped-line virtualization (row count falls back to an estimate above 100k chars). Behavior is unchanged when WordWrap is false. Step 3 of #33. --- TextControlBox.Tests/WrapGeometryTests.cs | 56 ++++ TextControlBox.Tests/WrapRowMetricsTests.cs | 145 +++++++++ .../Core/CoreTextControlBox.xaml.cs | 39 ++- .../Core/Renderer/CursorRenderer.cs | 20 +- .../Core/Renderer/SelectionRenderer.cs | 14 +- TextControlBox/Core/Renderer/TextRenderer.cs | 278 +++++++++++++++++- TextControlBox/Core/TextLayoutManager.cs | 7 +- TextControlBox/Core/WrapGeometry.cs | 34 +++ TextControlBox/Core/WrapRowMetrics.cs | 156 ++++++++++ TextControlBox/Helper/CursorHelper.cs | 21 +- TextControlBox/TextControlBox.cs | 8 + 11 files changed, 751 insertions(+), 27 deletions(-) create mode 100644 TextControlBox.Tests/WrapGeometryTests.cs create mode 100644 TextControlBox.Tests/WrapRowMetricsTests.cs create mode 100644 TextControlBox/Core/WrapGeometry.cs create mode 100644 TextControlBox/Core/WrapRowMetrics.cs diff --git a/TextControlBox.Tests/WrapGeometryTests.cs b/TextControlBox.Tests/WrapGeometryTests.cs new file mode 100644 index 0000000..d5c5118 --- /dev/null +++ b/TextControlBox.Tests/WrapGeometryTests.cs @@ -0,0 +1,56 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TextControlBoxNS.Core; + +namespace TextControlBox.Tests; + +/// Unit tests for the pure word-wrap pointer geometry (). +[TestClass] +public class WrapGeometryTests +{ + // singleLineHeight = 20, sensitivity = 4 => topInset = 5. + private const float LineHeight = 20f; + private const int Sensitivity = 4; + + [TestMethod] + public void CalculateVisualRowFromPointY_AddsStartVisualRow() + { + // y = 5 (== topInset) -> relative row 0; startVisualRow 10 -> 10. + Assert.AreEqual(10, WrapGeometry.CalculateVisualRowFromPointY(5, 10, LineHeight, Sensitivity)); + } + + [TestMethod] + public void CalculateVisualRowFromPointY_CountsRowsBelowInset() + { + // y = 5 + 2*20 = 45 -> relative row 2; startVisualRow 3 -> 5. + Assert.AreEqual(5, WrapGeometry.CalculateVisualRowFromPointY(45, 3, LineHeight, Sensitivity)); + } + + [TestMethod] + public void CalculateVisualRowFromPointY_AboveInset_ClampsToStartRow() + { + Assert.AreEqual(7, WrapGeometry.CalculateVisualRowFromPointY(0, 7, LineHeight, Sensitivity)); + } + + [TestMethod] + public void CalculateWrappedLineHitTestY_WithinLine_ReturnsRelativeY() + { + // lineTopY = 40, y = 65, topInset = 5 => 65 - 40 - 5 = 20 (row 1 of the line). + float hit = WrapGeometry.CalculateWrappedLineHitTestYFromPointY(65, 40, LineHeight, Sensitivity, wrappedRowCount: 3); + Assert.AreEqual(20f, hit, 0.001f); + } + + [TestMethod] + public void CalculateWrappedLineHitTestY_BelowLastRow_ClampsToExtent() + { + // A 2-row line spans [0, 40); a click well below clamps just under 40. + float hit = WrapGeometry.CalculateWrappedLineHitTestYFromPointY(1000, 0, LineHeight, Sensitivity, wrappedRowCount: 2); + Assert.IsTrue(hit < 40f && hit > 39f, $"expected just under 40, got {hit}"); + } + + [TestMethod] + public void CalculateWrappedLineHitTestY_AboveLine_ClampsToZero() + { + float hit = WrapGeometry.CalculateWrappedLineHitTestYFromPointY(0, 100, LineHeight, Sensitivity, wrappedRowCount: 2); + Assert.AreEqual(0f, hit); + } +} diff --git a/TextControlBox.Tests/WrapRowMetricsTests.cs b/TextControlBox.Tests/WrapRowMetricsTests.cs new file mode 100644 index 0000000..f13f647 --- /dev/null +++ b/TextControlBox.Tests/WrapRowMetricsTests.cs @@ -0,0 +1,145 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TextControlBoxNS.Core; + +namespace TextControlBox.Tests; + +/// +/// Unit tests for the pure word-wrap visual-row bookkeeping (): the prefix-sum +/// mapping between document lines and visual rows, and its incremental update — the arithmetic where an +/// off-by-one silently corrupts wrap-mode scrolling or caret placement. +/// +[TestClass] +public class WrapRowMetricsTests +{ + // Document with per-line wrapped row counts: line0=1, line1=3, line2=1, line3=2. Total visual rows = 7. + // Cumulative start rows: [0, 1, 4, 5, 7]. + private static WrapRowMetrics BuildSample(out int[] rows) + { + rows = new[] { 1, 3, 1, 2 }; + int[] local = rows; + var m = new WrapRowMetrics(); + m.Rebuild(local.Length, i => local[i]); + return m; + } + + [TestMethod] + public void Rebuild_ComputesTotalRowsAndPerLineCounts() + { + var m = BuildSample(out var rows); + Assert.AreEqual(7, m.TotalVisualRows); + for (int i = 0; i < rows.Length; i++) + Assert.AreEqual(rows[i], m.GetRowCount(i)); + } + + [TestMethod] + public void GetLineStartRow_IsCumulativePrefix() + { + var m = BuildSample(out _); + Assert.AreEqual(0, m.GetLineStartRow(0, 4)); + Assert.AreEqual(1, m.GetLineStartRow(1, 4)); + Assert.AreEqual(4, m.GetLineStartRow(2, 4)); + Assert.AreEqual(5, m.GetLineStartRow(3, 4)); + Assert.AreEqual(7, m.GetLineStartRow(4, 4)); // end sentinel == total rows + } + + [TestMethod] + public void IsValidFor_MatchesLineCount() + { + var m = BuildSample(out _); + Assert.IsTrue(m.IsValidFor(4)); + Assert.IsFalse(m.IsValidFor(3)); + Assert.IsFalse(m.IsValidFor(5)); + } + + [TestMethod] + public void GetDocumentLineFromVisualRow_MapsEveryRowToItsOwningLine() + { + var m = BuildSample(out _); + // rows: line0=[0], line1=[1,2,3], line2=[4], line3=[5,6] + var expected = new[] { 0, 1, 1, 1, 2, 3, 3 }; + for (int row = 0; row < expected.Length; row++) + Assert.AreEqual(expected[row], m.GetDocumentLineFromVisualRow(row, 4), $"row {row}"); + } + + [TestMethod] + public void GetDocumentLineFromVisualRow_ClampsOutOfRange() + { + var m = BuildSample(out _); + Assert.AreEqual(0, m.GetDocumentLineFromVisualRow(-5, 4)); + Assert.AreEqual(3, m.GetDocumentLineFromVisualRow(999, 4)); + } + + [TestMethod] + public void GetRenderedVisualRowCount_SpansTheGivenLines() + { + var m = BuildSample(out _); + Assert.AreEqual(4, m.GetRenderedVisualRowCount(0, 2, 4)); // lines 0..1 => 1 + 3 + Assert.AreEqual(3, m.GetRenderedVisualRowCount(1, 2, 4)); // lines 1..2 => 3 + 1 + Assert.AreEqual(7, m.GetRenderedVisualRowCount(0, 4, 4)); // whole doc + } + + [TestMethod] + public void ApplyIncremental_PatchesRowCountAndShiftsPrefix() + { + var m = BuildSample(out var rows); + // line1 now wraps to 5 rows instead of 3 (delta +2). Total 7 -> 9. + rows[1] = 5; + bool patched = m.ApplyIncremental(4, new[] { 1 }, 1, 64, i => rows[i]); + Assert.IsTrue(patched); + Assert.AreEqual(5, m.GetRowCount(1)); + Assert.AreEqual(9, m.TotalVisualRows); + Assert.AreEqual(6, m.GetLineStartRow(2, 4)); // 1 + 5 + Assert.AreEqual(7, m.GetLineStartRow(3, 4)); + Assert.AreEqual(9, m.GetLineStartRow(4, 4)); + } + + [TestMethod] + public void ApplyIncremental_MatchesRebuildAfterEdit() + { + var m = BuildSample(out var rows); + rows[2] = 4; + m.ApplyIncremental(4, new[] { 2 }, 1, 64, i => rows[i]); + + var fresh = new WrapRowMetrics(); + fresh.Rebuild(4, i => rows[i]); + + Assert.AreEqual(fresh.TotalVisualRows, m.TotalVisualRows); + for (int row = 0; row < fresh.TotalVisualRows; row++) + Assert.AreEqual(fresh.GetDocumentLineFromVisualRow(row, 4), m.GetDocumentLineFromVisualRow(row, 4), $"row {row}"); + } + + [TestMethod] + public void ApplyIncremental_ReturnsFalse_WhenTooManyDirtyLines() + { + var m = BuildSample(out var rows); + var dirty = Enumerable.Range(0, 4).ToArray(); + Assert.IsFalse(m.ApplyIncremental(4, dirty, dirty.Length, remeasureLimit: 2, i => rows[i])); + } + + [TestMethod] + public void ApplyIncremental_ReturnsFalse_WhenCacheStale() + { + var m = BuildSample(out var rows); + Assert.IsFalse(m.ApplyIncremental(5, new[] { 0 }, 1, 64, i => i < rows.Length ? rows[i] : 1)); + } + + [TestMethod] + public void Clear_ResetsToSingleRow() + { + var m = BuildSample(out _); + m.Clear(); + Assert.AreEqual(1, m.TotalVisualRows); + Assert.IsFalse(m.IsValidFor(4)); + } + + [TestMethod] + public void MeasureRows_BelowOne_IsClampedToOne() + { + var m = new WrapRowMetrics(); + m.Rebuild(3, _ => 0); // pathological measurer + Assert.AreEqual(3, m.TotalVisualRows); // each line at least 1 row + Assert.AreEqual(1, m.GetRowCount(0)); + } +} diff --git a/TextControlBox/Core/CoreTextControlBox.xaml.cs b/TextControlBox/Core/CoreTextControlBox.xaml.cs index 14bb0a8..1b42bce 100644 --- a/TextControlBox/Core/CoreTextControlBox.xaml.cs +++ b/TextControlBox/Core/CoreTextControlBox.xaml.cs @@ -347,13 +347,19 @@ private void InputHandler_KeyDown(object sender, KeyRoutedEventArgs e) if (shift) { selectionManager.StartSelectionIfNeeded(); - cursorManager.MoveDown(); + if (WordWrap) + textRenderer.MoveCursorByVisualRows(canvasText, cursorManager.currentCursorPosition, 1); + else + cursorManager.MoveDown(); selectionManager.SetSelectionEnd(cursorManager.currentCursorPosition); } else { selectionManager.ClearSelectionIfNeeded(this); - cursorManager.MoveDown(); + if (WordWrap) + textRenderer.MoveCursorByVisualRows(canvasText, cursorManager.currentCursorPosition, 1); + else + cursorManager.MoveDown(); } scrollManager.UpdateScrollToShowCursor(true); @@ -365,13 +371,19 @@ private void InputHandler_KeyDown(object sender, KeyRoutedEventArgs e) if (shift) { selectionManager.StartSelectionIfNeeded(); - cursorManager.MoveUp(); + if (WordWrap) + textRenderer.MoveCursorByVisualRows(canvasText, cursorManager.currentCursorPosition, -1); + else + cursorManager.MoveUp(); selectionManager.SetSelectionEnd(cursorManager.currentCursorPosition); } else { selectionManager.ClearSelectionIfNeeded(this); - cursorManager.MoveUp(); + if (WordWrap) + textRenderer.MoveCursorByVisualRows(canvasText, cursorManager.currentCursorPosition, -1); + else + cursorManager.MoveUp(); } scrollManager.UpdateScrollToShowCursor(true); @@ -1099,6 +1111,25 @@ public CursorPosition CursorPosition public new FontFamily FontFamily { get => textManager._FontFamily; set { textManager._FontFamily = value; textRenderer.NeedsTextFormatUpdate = true; canvasUpdateManager.UpdateAll(); } } + /// When true, long lines wrap at the control width instead of scrolling horizontally. Toggling + /// rebuilds the text format (with the new wrapping mode) and the wrap metrics, and resets horizontal + /// scroll (there is no horizontal scrolling in wrap mode). + public bool WordWrap + { + get => textLayoutManager.WordWrap; + set + { + if (textLayoutManager.WordWrap == value) + return; + textLayoutManager.WordWrap = value; + textRenderer.NeedsTextFormatUpdate = true; + textRenderer.InvalidateWrapMetrics(); + scrollManager.HorizontalScroll = 0; + lineNumberRenderer.NeedsUpdateLineNumbers(); + canvasUpdateManager.UpdateAll(); + } + } + public new int FontSize { get => textManager._FontSize; set { textManager._FontSize = value; zoomManager.UpdateZoom(); } } public float RenderedFontSize => zoomManager.ZoomedFontSize; diff --git a/TextControlBox/Core/Renderer/CursorRenderer.cs b/TextControlBox/Core/Renderer/CursorRenderer.cs index b4951da..95c989a 100644 --- a/TextControlBox/Core/Renderer/CursorRenderer.cs +++ b/TextControlBox/Core/Renderer/CursorRenderer.cs @@ -60,10 +60,12 @@ public void RenderCursor(CanvasTextLayout textLayout, int characterPosition, flo Vector2 vector = textLayout.GetCaretPosition(characterPosition < 0 ? 0 : characterPosition, false); + // vector.Y is the caret's row within the (possibly wrapped) layout — 0 for a single-row line, so this + // is unchanged in non-wrap mode and follows the wrapped row in wrap mode. if (customSize == null) - args.DrawingSession.FillRectangle(vector.X + xOffset, y, 2, fontSize, cursorColorBrush); + args.DrawingSession.FillRectangle(vector.X + xOffset, y + vector.Y, 2, fontSize, cursorColorBrush); else - args.DrawingSession.FillRectangle(vector.X + xOffset + customSize.OffsetX, y + customSize.OffsetY, (float)customSize.Width, (float)customSize.Height, cursorColorBrush); + args.DrawingSession.FillRectangle(vector.X + xOffset + customSize.OffsetX, y + vector.Y + customSize.OffsetY, (float)customSize.Width, (float)customSize.Height, cursorColorBrush); } public void Draw(CanvasControl canvasText, CanvasControl canvasCursor, CanvasDrawEventArgs args) @@ -79,8 +81,16 @@ public void Draw(CanvasControl canvasText, CanvasControl canvasCursor, CanvasDra cursorManager.CharacterPosition = currentLineLength; } - float renderPosY = (float)((cursorManager.LineNumber - textRenderer.NumberOfStartLine) * textRenderer.SingleLineHeight) + textRenderer.SingleLineHeight / scrollManager.DefaultVerticalScrollSensitivity; - if (renderPosY > textRenderer.NumberOfRenderedLines * textRenderer.SingleLineHeight || renderPosY < 0) + // In wrap mode the caret's line sits at its visual-row top (GetLineTopY), matching the wrapped text + // draw offset; in non-wrap mode this reduces to the original document-line position. + float topInset = textRenderer.SingleLineHeight / scrollManager.DefaultVerticalScrollSensitivity; + float renderPosY = textRenderer.IsWordWrapEnabled + ? textRenderer.GetLineTopY(cursorManager.LineNumber) + textRenderer.SingleLineHeight + topInset + : (float)((cursorManager.LineNumber - textRenderer.NumberOfStartLine) * textRenderer.SingleLineHeight) + topInset; + bool offscreen = textRenderer.IsWordWrapEnabled + ? (renderPosY > canvasCursor.ActualHeight || renderPosY + textRenderer.SingleLineHeight < 0) + : (renderPosY > textRenderer.NumberOfRenderedLines * textRenderer.SingleLineHeight || renderPosY < 0); + if (offscreen) return; textRenderer.UpdateCurrentLineTextLayout(canvasText); @@ -106,7 +116,7 @@ public void Draw(CanvasControl canvasText, CanvasControl canvasCursor, CanvasDra RenderCursor( textRenderer.CurrentLineTextLayout, renderedCharacterPos, - textRenderer.HorizontalOffset, + textRenderer.IsWordWrapEnabled ? 0 : textRenderer.HorizontalOffset, renderPosY, zoomManager.ZoomedFontSize, _CursorSize, diff --git a/TextControlBox/Core/Renderer/SelectionRenderer.cs b/TextControlBox/Core/Renderer/SelectionRenderer.cs index 651de20..f284167 100644 --- a/TextControlBox/Core/Renderer/SelectionRenderer.cs +++ b/TextControlBox/Core/Renderer/SelectionRenderer.cs @@ -206,8 +206,8 @@ public void Draw(CanvasControl canvasSelection, CanvasDrawEventArgs args) DrawSelection( textRenderer.DrawnTextLayout, args, - textRenderer.HorizontalOffset, - textRenderer.SingleLineHeight / scrollManager.DefaultVerticalScrollSensitivity, + textRenderer.IsWordWrapEnabled ? 0 : textRenderer.HorizontalOffset, + GetSelectionTopMargin(), textRenderer.NumberOfStartLine, textRenderer.NumberOfRenderedLines, zoomManager.ZoomedFontSize, @@ -223,5 +223,15 @@ public void Draw(CanvasControl canvasSelection, CanvasDrawEventArgs args) eventsManager.CallSelectionChanged(); } } + + // Top margin for the selection regions. In wrap mode the whole layout is nudged up by the rows of the + // first visible line scrolled above the viewport, matching the wrapped text draw offset. + private float GetSelectionTopMargin() + { + float topInset = textRenderer.SingleLineHeight / scrollManager.DefaultVerticalScrollSensitivity; + if (!textRenderer.IsWordWrapEnabled) + return topInset; + return topInset - (textRenderer.WrappedStartRowOffset * textRenderer.SingleLineHeight); + } } } \ No newline at end of file diff --git a/TextControlBox/Core/Renderer/TextRenderer.cs b/TextControlBox/Core/Renderer/TextRenderer.cs index bc0ae34..7c1c316 100644 --- a/TextControlBox/Core/Renderer/TextRenderer.cs +++ b/TextControlBox/Core/Renderer/TextRenderer.cs @@ -58,6 +58,26 @@ internal class TextRenderer /// document coordinates. 0 when not sliced, which keeps unchanged. public float HorizontalSlicePixelOffset => IsHorizontallyVirtualized ? HorizontalSliceStart * CachedCharWidth : 0; + // ── Word wrap (visual-row model) ──────────────────────────────────────────────────── + // In wrap mode a single document line can occupy several visual rows, so the vertical scrollbar, caret + // and selection all work in "visual-row" space. WrapRowMetrics holds the document-line ↔ visual-row + // mapping (a prefix sum of per-line row counts) and this renderer feeds it the per-line row measurements. + /// First visual row currently scrolled into view (wrap mode). + public int StartVisualRow { get; private set; } + /// How many visual rows of the first visible document line are scrolled above the viewport top. + public int WrappedStartRowOffset { get; private set; } + /// True when word wrap is enabled (the layout wraps long lines into multiple visual rows). + public bool IsWordWrapEnabled => textLayoutManager?.WordWrap == true; + private readonly WrapRowMetrics wrapMetrics = new(); + private readonly HashSet dirtyWrapLines = new(); + private float cachedWrapWidth; + private bool wrapMetricsDirty = true; + // Above this many dirty lines a full rebuild is cheaper than many incremental patches. + private const int IncrementalWrapRemeasureLimit = 64; + // Above this line length, estimate the wrapped row count instead of laying the whole line out (a + // multi-megabyte line would otherwise create a giant measurement layout). + private const int LongLineRowEstimateThreshold = 100_000; + private CursorManager cursorManager; private TextManager textManager; private ScrollManager scrollManager; @@ -130,24 +150,33 @@ public void UpdateCurrentLineTextLayout(CanvasControl canvasText) return; } + if (IsWordWrapEnabled) + EnsureWrapMetrics(canvasText); + 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 + // Slice the current line to the SAME horizontal window as the main text (non-wrap only) 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) + if (IsHorizontallyVirtualized && !IsWordWrapEnabled && 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; } + // In wrap mode the current line is laid out at the wrap width across as many rows as it needs, so the + // caret and click hit-testing resolve a wrapped row via the layout's own multi-row geometry. + Size layoutSize = IsWordWrapEnabled + ? new Size(GetWrapWidth(canvasText), Math.Max(canvasText.Size.Height, (GetWrappedRowCount(cursorManager.LineNumber) + 1) * Math.Max(1, SingleLineHeight))) + : canvasText.Size; + CurrentLineTextLayout = textLayoutManager.CreateTextLayout( canvasText, TextFormat, lineText, - canvasText.Size); + layoutSize); } // ── Horizontal virtualization helpers ────────────────────────────────────────────── @@ -270,10 +299,196 @@ private string BuildHorizontallySlicedText(int sliceStart, int sliceLen) } return builder.ToString(); } + + // ── Word-wrap visual-row metrics ───────────────────────────────────────────────────── + + private float GetWrapWidth(CanvasControl canvasText) => Math.Max(1, (float)canvasText.ActualWidth); + + /// Forces a full wrap-metrics rebuild on the next . + public void InvalidateWrapMetrics() + { + wrapMetricsDirty = true; + dirtyWrapLines.Clear(); + } + + /// Ensures the document-line ↔ visual-row mapping is current for the wrap width. Rebuilds on a + /// width change or when the line count changed; otherwise re-measures the current line so live typing + /// reflows immediately (there is no per-line change event to subscribe to). + public void EnsureWrapMetrics(CanvasControl canvasText) + { + if (!IsWordWrapEnabled || canvasText == null || TextFormat == null) + return; + + float wrapWidth = GetWrapWidth(canvasText); + if (Math.Abs(cachedWrapWidth - wrapWidth) >= 0.5f) + { + cachedWrapWidth = wrapWidth; + wrapMetricsDirty = true; + NeedsUpdateTextLayout = true; + lineNumberRenderer.NeedsUpdateLineNumbers(); + } + + if (wrapMetricsDirty || !wrapMetrics.IsValidFor(textManager.LinesCount)) + { + wrapMetrics.Rebuild(textManager.LinesCount, i => MeasureWrappedRowCount(canvasText, i)); + wrapMetricsDirty = false; + dirtyWrapLines.Clear(); + return; + } + + // Keep the current line fresh so typing reflows without a full rebuild. A line add/remove changes the + // line count, which forces a rebuild via IsValidFor above. + if (cursorManager.LineNumber >= 0 && cursorManager.LineNumber < textManager.LinesCount) + dirtyWrapLines.Add(cursorManager.LineNumber); + + if (dirtyWrapLines.Count > 0) + { + bool patched = wrapMetrics.ApplyIncremental( + textManager.LinesCount, dirtyWrapLines, dirtyWrapLines.Count, IncrementalWrapRemeasureLimit, + i => MeasureWrappedRowCount(canvasText, i)); + if (!patched) + { + wrapMetrics.Rebuild(textManager.LinesCount, i => MeasureWrappedRowCount(canvasText, i)); + wrapMetricsDirty = false; + } + dirtyWrapLines.Clear(); + } + } + + private int MeasureWrappedRowCount(CanvasControl canvasText, int lineIndex) + { + if (lineIndex < 0 || lineIndex >= textManager.LinesCount) + return 1; + + string lineText = textManager.GetLineText(lineIndex); + if (lineText.Length == 0) + return 1; + + float singleLineHeight = Math.Max(1, SingleLineHeight); + if (lineText.Length >= LongLineRowEstimateThreshold) + { + float avgCharWidth = Math.Max(1, zoomManager.ZoomedFontSize * 0.58f); + int charsPerRow = Math.Max(1, (int)Math.Floor(cachedWrapWidth / avgCharWidth)); + return Math.Max(1, (int)Math.Ceiling(lineText.Length / (double)charsPerRow)); + } + + float layoutHeight = Math.Max(singleLineHeight, (lineText.Length + 1) * singleLineHeight); + using CanvasTextLayout lineLayout = textLayoutManager.CreateTextLayout(canvasText, TextFormat, lineText, cachedWrapWidth, layoutHeight); + // Avoid CanvasTextLayout.LineMetrics (CanvasLineMetrics is non-blittable and throws + // NotSupportedException on newer .NET). Use layout height / line height instead. + int rowCount = (int)Math.Ceiling(lineLayout.LayoutBounds.Height / singleLineHeight); + return Math.Max(1, rowCount); + } + + private CanvasTextLayout CreateWrappedLineTextLayout(CanvasControl canvasText, int lineIndex, bool includeCaretMarker = false) + { + string lineText = textManager.GetLineText(lineIndex); + if (includeCaretMarker) + lineText += "|"; + + float singleLineHeight = Math.Max(1, SingleLineHeight); + int rowCount = GetWrappedRowCount(lineIndex); + float layoutHeight = (float)Math.Max(canvasText.Size.Height, (rowCount + 1) * singleLineHeight); + float wrapWidth = cachedWrapWidth > 1 ? cachedWrapWidth : GetWrapWidth(canvasText); + + return textLayoutManager.CreateTextLayout(canvasText, TextFormat, lineText, wrapWidth, layoutHeight); + } + + /// Visual rows document line occupies (1 when wrap is off). + public int GetWrappedRowCount(int lineIndex) + { + if (!IsWordWrapEnabled || lineIndex < 0 || lineIndex >= textManager.LinesCount) + return 1; + return wrapMetrics.GetRowCount(lineIndex); + } + + /// First visual row of document line (the line index itself when off). + public int GetLineVisualStartRow(int lineIndex) + { + if (!IsWordWrapEnabled) + return Math.Clamp(lineIndex, 0, Math.Max(0, textManager.LinesCount - 1)); + return wrapMetrics.GetLineStartRow(lineIndex, textManager.LinesCount); + } + + /// Document line that owns . + public int GetDocumentLineFromVisualRow(int visualRow) + { + if (!IsWordWrapEnabled) + return Math.Clamp(visualRow, 0, Math.Max(0, textManager.LinesCount - 1)); + return wrapMetrics.GetDocumentLineFromVisualRow(visualRow, textManager.LinesCount); + } + + public int GetStartVisualRowFromScroll() + { + if (!IsWordWrapEnabled) + return NumberOfStartLine; + int visualRow = (int)Math.Floor((scrollManager.VerticalScroll * scrollManager.DefaultVerticalScrollSensitivity) / Math.Max(1, SingleLineHeight)); + return Math.Clamp(visualRow, 0, Math.Max(0, wrapMetrics.TotalVisualRows - 1)); + } + + public int GetVisibleVisualRowCount(CanvasControl canvasText, int extraRows = 0) + => Math.Max(1, (int)Math.Ceiling(canvasText.ActualHeight / Math.Max(1, SingleLineHeight)) + extraRows); + + public int GetRenderedVisualRowCount(int startLine, int lineCount) + { + if (!IsWordWrapEnabled) + return lineCount; + return wrapMetrics.GetRenderedVisualRowCount(startLine, lineCount, textManager.LinesCount); + } + + public int GetVisualRowFromPointY(double y) + => WrapGeometry.CalculateVisualRowFromPointY(y, StartVisualRow, SingleLineHeight, scrollManager.DefaultVerticalScrollSensitivity); + + public float GetWrappedLineHitTestYFromPointY(int lineIndex, double y) + => WrapGeometry.CalculateWrappedLineHitTestYFromPointY(y, GetLineTopY(lineIndex), SingleLineHeight, scrollManager.DefaultVerticalScrollSensitivity, GetWrappedRowCount(lineIndex)); + + /// Y (relative to the viewport top) of document line 's first row. + public float GetLineTopY(int lineIndex) + { + if (!IsWordWrapEnabled) + return (lineIndex - NumberOfStartLine) * SingleLineHeight; + return (GetLineVisualStartRow(lineIndex) - StartVisualRow) * SingleLineHeight; + } + + /// Moves up/down by VISUAL rows, + /// preserving the target column via hit-testing the wrapped layout. Returns false when wrap is off (the + /// caller should fall back to plain line movement). + public bool MoveCursorByVisualRows(CanvasControl canvasText, CursorPosition cursorPosition, int rowDelta) + { + if (!IsWordWrapEnabled || cursorPosition == null || textManager.LinesCount == 0) + return false; + + EnsureWrapMetrics(canvasText); + int lineIndex = Math.Clamp(cursorPosition.LineNumber, 0, textManager.LinesCount - 1); + int characterPosition = Math.Clamp(cursorPosition.CharacterPosition, 0, textManager.GetLineLength(lineIndex)); + + using CanvasTextLayout currentLayout = CreateWrappedLineTextLayout(canvasText, lineIndex, true); + var currentCaret = currentLayout.GetCaretPosition(characterPosition, false); + int currentVisualRow = GetLineVisualStartRow(lineIndex) + (int)Math.Floor(Math.Max(0, currentCaret.Y) / Math.Max(1, SingleLineHeight)); + int targetVisualRow = Math.Clamp(currentVisualRow + rowDelta, 0, Math.Max(0, wrapMetrics.TotalVisualRows - 1)); + int targetLine = GetDocumentLineFromVisualRow(targetVisualRow); + int targetRowOffset = targetVisualRow - GetLineVisualStartRow(targetLine); + + using CanvasTextLayout targetLayout = CreateWrappedLineTextLayout(canvasText, targetLine, true); + targetLayout.HitTest(currentCaret.X, targetRowOffset * Math.Max(1, SingleLineHeight), out var targetRegion); + + cursorPosition.LineNumber = targetLine; + cursorPosition.CharacterPosition = Math.Clamp(targetRegion.CharacterIndex, 0, textManager.GetLineLength(targetLine)); + return true; + } + + public void UpdateRenderedLineRange(CanvasControl canvasText) + { + (NumberOfStartLine, NumberOfRenderedLines) = CalculateLinesToRender(); + } + public (int startLine, int linesToRender) CalculateLinesToRender() { var singleLineHeight = SingleLineHeight; + if (IsWordWrapEnabled) + return CalculateWrappedLinesToRender(coreTextbox.canvasText, singleLineHeight); + //Measure text position and apply the value to the scrollbar scrollManager.verticalScrollBar.Maximum = ((textManager.LinesCount + 1) * singleLineHeight - scrollGrid.ActualHeight) / scrollManager.DefaultVerticalScrollSensitivity; scrollManager.verticalScrollBar.ViewportSize = coreTextbox.canvasText.ActualHeight; @@ -290,6 +505,32 @@ private string BuildHorizontallySlicedText(int sliceStart, int sliceLen) return (startLine, linesToRender); } + private (int startLine, int linesToRender) CalculateWrappedLinesToRender(CanvasControl canvasText, float singleLineHeight) + { + EnsureWrapMetrics(canvasText); + int totalVisualRows = wrapMetrics.TotalVisualRows; + + scrollManager.verticalScrollBar.Maximum = Math.Max(0, (totalVisualRows * singleLineHeight - scrollGrid.ActualHeight) / scrollManager.DefaultVerticalScrollSensitivity); + scrollManager.verticalScrollBar.ViewportSize = coreTextbox.canvasText.ActualHeight; + + StartVisualRow = GetStartVisualRowFromScroll(); + + int startLine = GetDocumentLineFromVisualRow(StartVisualRow); + int startLineVisualRow = GetLineVisualStartRow(startLine); + WrappedStartRowOffset = Math.Max(0, StartVisualRow - startLineVisualRow); + + int visibleRows = GetVisibleVisualRowCount(canvasText, 2); + int rowsToCover = visibleRows + WrappedStartRowOffset; + int linesToRender = 0; + for (int i = startLine; i < textManager.LinesCount && rowsToCover > 0; i++) + { + rowsToCover -= GetWrappedRowCount(i); + linesToRender++; + } + + return (startLine, Math.Max(0, linesToRender)); + } + public void Draw(CanvasControl canvasText, CanvasDrawEventArgs args) { //Create resources and layouts: @@ -320,7 +561,7 @@ public void Draw(CanvasControl canvasText, CanvasDrawEventArgs args) // 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)) + if (!IsWordWrapEnabled && ShouldHorizontallySlice(canvasText, out int hSliceStart, out int hSliceLen)) { RenderedText = BuildHorizontallySlicedText(hSliceStart, hSliceLen); HorizontalSliceStart = hSliceStart; @@ -341,6 +582,25 @@ public void Draw(CanvasControl canvasText, CanvasDrawEventArgs args) _hSliceVisibleEnd = 0; } + // Draw offsets. In wrap mode there is no horizontal scroll and the layout is nudged up by the rows of + // the first visible line that are scrolled above the viewport top; otherwise the existing pixel + // offsets (incl. any horizontal-slice offset) apply. Both reduce to the non-wrap values when wrap is + // off, so the normal render path is unchanged. + float drawTextOffsetX = IsWordWrapEnabled ? 0 : HorizontalOffset; + float drawTextOffsetY = IsWordWrapEnabled ? SingleLineHeight - (WrappedStartRowOffset * SingleLineHeight) : SingleLineHeight; + float searchHighlightOffsetY = IsWordWrapEnabled + ? drawTextOffsetY - SingleLineHeight + (SingleLineHeight / scrollManager.DefaultVerticalScrollSensitivity) + : SingleLineHeight / scrollManager.DefaultVerticalScrollSensitivity; + + int renderedVisualRows = GetRenderedVisualRowCount(NumberOfStartLine, NumberOfRenderedLines); + Size layoutSize = IsWordWrapEnabled + ? new Size + { + Height = Math.Max(canvasText.Size.Height + (WrappedStartRowOffset + 2) * SingleLineHeight, (renderedVisualRows + 2) * SingleLineHeight), + Width = GetWrapWidth(canvasText) + } + : new Size { Height = canvasText.Size.Height, Width = coreTextbox.ActualWidth }; + //check rendering and calculation updates lineNumberRenderer.CheckGenerateLineNumberText(); @@ -353,7 +613,7 @@ public void Draw(CanvasControl canvasText, CanvasDrawEventArgs args) NeedsUpdateTextLayout = false; OldRenderedText = RenderedText; - DrawnTextLayout = textLayoutManager.CreateTextResource(canvasText, DrawnTextLayout, TextFormat, RenderedText, new Size { Height = canvasText.Size.Height, Width = coreTextbox.ActualWidth }); + DrawnTextLayout = textLayoutManager.CreateTextResource(canvasText, DrawnTextLayout, TextFormat, RenderedText, layoutSize); SyntaxHighlightingRenderer.UpdateSyntaxHighlighting(renderTextData, textManager.NewLineCharacter, DrawnTextLayout, designHelper._AppTheme, textManager._SyntaxHighlighting, coreTextbox.EnableSyntaxHighlighting); } @@ -379,12 +639,12 @@ public void Draw(CanvasControl canvasText, CanvasDrawEventArgs args) RenderedText, searchManager.MatchingSearchLines, searchManager.searchParameter.SearchExpression, - HorizontalOffset, - SingleLineHeight / scrollManager.DefaultVerticalScrollSensitivity, + drawTextOffsetX, + searchHighlightOffsetY, designHelper._Design.SearchHighlightColor ); - ccls.DrawTextLayout(DrawnTextLayout, HorizontalOffset, SingleLineHeight, designHelper.TextColorBrush); + ccls.DrawTextLayout(DrawnTextLayout, drawTextOffsetX, drawTextOffsetY, designHelper.TextColorBrush); invisibleCharactersRenderer.DrawTabsAndSpaces(args, ccls, RenderedText, DrawnTextLayout, SingleLineHeight); } diff --git a/TextControlBox/Core/TextLayoutManager.cs b/TextControlBox/Core/TextLayoutManager.cs index 46a4059..612dbf4 100644 --- a/TextControlBox/Core/TextLayoutManager.cs +++ b/TextControlBox/Core/TextLayoutManager.cs @@ -11,6 +11,11 @@ internal class TextLayoutManager { private TextManager textManager; private ZoomManager zoomManager; + + /// When true, the main text format wraps long lines at the layout width instead of clipping. + /// The line-number format always stays . + public bool WordWrap; + public void Init(TextManager textManager, ZoomManager zoomManager) { this.textManager = textManager; @@ -39,7 +44,7 @@ public CanvasTextFormat CreateCanvasTextFormat(float zoomedFontSize, float lineS FontSize = zoomedFontSize, HorizontalAlignment = CanvasHorizontalAlignment.Left, VerticalAlignment = CanvasVerticalAlignment.Top, - WordWrapping = CanvasWordWrapping.NoWrap, + WordWrapping = WordWrap ? CanvasWordWrapping.Wrap : CanvasWordWrapping.NoWrap, LineSpacing = lineSpacing, }; textFormat.IncrementalTabStop = (float)Math.Round(zoomedFontSize * 3f); //default 137px diff --git a/TextControlBox/Core/WrapGeometry.cs b/TextControlBox/Core/WrapGeometry.cs new file mode 100644 index 0000000..6109bc6 --- /dev/null +++ b/TextControlBox/Core/WrapGeometry.cs @@ -0,0 +1,34 @@ +using System; + +namespace TextControlBoxNS.Core; + +/// +/// Pure (WinUI-free) geometry for mapping a pointer Y coordinate to a visual row and to a within-line +/// hit-test Y, in word-wrap mode. Extracted so the click math is unit-testable without a GPU canvas. +/// +internal static class WrapGeometry +{ + /// + /// Visual row under pointer Y. is the first visual row currently + /// scrolled into view; the small top inset (a fraction of a row) matches the vertical draw offset. + /// + public static int CalculateVisualRowFromPointY(double y, int startVisualRow, float singleLineHeight, int defaultVerticalScrollSensitivity) + { + double rowHeight = Math.Max(1, singleLineHeight); + double topInset = rowHeight / Math.Max(1, defaultVerticalScrollSensitivity); + int relativeRow = (int)Math.Floor(Math.Max(0, y - topInset) / rowHeight); + return startVisualRow + relativeRow; + } + + /// + /// Y coordinate (relative to a wrapped line's own layout) to hit-test against, clamped to that line's + /// wrapped extent so a click below the last row maps to the last row rather than past it. + /// + public static float CalculateWrappedLineHitTestYFromPointY(double y, float lineTopY, float singleLineHeight, int defaultVerticalScrollSensitivity, int wrappedRowCount) + { + double rowHeight = Math.Max(1, singleLineHeight); + double topInset = rowHeight / Math.Max(1, defaultVerticalScrollSensitivity); + double maxHitTestY = Math.Max(0, wrappedRowCount * rowHeight - 0.001); + return (float)Math.Clamp(y - lineTopY - topInset, 0, maxHitTestY); + } +} diff --git a/TextControlBox/Core/WrapRowMetrics.cs b/TextControlBox/Core/WrapRowMetrics.cs new file mode 100644 index 0000000..a755ddb --- /dev/null +++ b/TextControlBox/Core/WrapRowMetrics.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; + +namespace TextControlBoxNS.Core; + +/// +/// Pure (WinUI-free) visual-row bookkeeping for word wrap. In wrap mode a single document line can occupy +/// several visual rows, so the vertical scrollbar, caret and selection all work in "visual-row" space rather +/// than document-line space. This class holds the mapping — a per-line row count and the cumulative +/// start-row prefix sum — and supports cheap incremental updates when only a few lines change. +/// +/// The impure part (measuring how many rows a line wraps into at the current width, which needs a +/// CanvasTextLayout) is injected as a delegate, so all the arithmetic here is unit-testable without a +/// GPU canvas — which is exactly where an off-by-one silently corrupts scrolling or caret placement. +/// +internal sealed class WrapRowMetrics +{ + // Row count per document line index. + private readonly Dictionary _rowCountByLine = new(); + // Cumulative start visual row of each document line: entry k is the first visual row of line k, and the + // final entry is the total visual-row count. Length is lineCount + 1 when valid. + private readonly List _startRow = new(); + private int _totalVisualRows = 1; + + /// Total number of visual rows across the whole document (at least 1). + public int TotalVisualRows => _totalVisualRows; + + /// True when the cached prefix sum matches and can be used directly + /// (otherwise callers should first). + public bool IsValidFor(int lineCount) => _startRow.Count == lineCount + 1; + + public void Clear() + { + _rowCountByLine.Clear(); + _startRow.Clear(); + _totalVisualRows = 1; + } + + /// Rebuilds the whole mapping by measuring every line. returns the + /// number of visual rows a document line wraps into (>= 1). + public void Rebuild(int lineCount, Func measureRows) + { + _rowCountByLine.Clear(); + _startRow.Clear(); + + int visualRow = 0; + _startRow.Add(visualRow); + for (int i = 0; i < lineCount; i++) + { + int rows = Math.Max(1, measureRows(i)); + _rowCountByLine[i] = rows; + visualRow += rows; + _startRow.Add(visualRow); + } + + _totalVisualRows = Math.Max(1, visualRow); + } + + /// Re-measures only and shifts the cumulative start-row prefix by + /// each line's row-count delta — cheap integer arithmetic plus a handful of measurements, versus a full + /// . Returns false (caller should Rebuild) when the cache is stale for + /// or more than lines are dirty. + public bool ApplyIncremental(int lineCount, IEnumerable dirtyLines, int dirtyCount, int remeasureLimit, Func measureRows) + { + if (dirtyCount > remeasureLimit || !IsValidFor(lineCount)) + return false; + + bool changed = false; + foreach (int line in dirtyLines) + { + if (line < 0 || line >= lineCount) + continue; + + int oldRows = _rowCountByLine.TryGetValue(line, out int cached) ? cached : 1; + int newRows = Math.Max(1, measureRows(line)); + if (newRows == oldRows) + continue; + + _rowCountByLine[line] = newRows; + int delta = newRows - oldRows; + for (int i = line + 1; i < _startRow.Count; i++) + _startRow[i] += delta; + changed = true; + } + + if (changed && _startRow.Count > 0) + _totalVisualRows = Math.Max(1, _startRow[^1]); + + return true; + } + + /// Visual rows document line occupies (1 when unknown/out of range). + public int GetRowCount(int lineIndex) + => _rowCountByLine.TryGetValue(lineIndex, out int rows) ? rows : 1; + + /// First visual row of document line . Falls back to summing row + /// counts when the prefix cache is not valid for . + public int GetLineStartRow(int lineIndex, int lineCount) + { + int capped = Math.Clamp(lineIndex, 0, lineCount); + if (IsValidFor(lineCount)) + return _startRow[capped]; + + int visualRow = 0; + for (int i = 0; i < capped; i++) + visualRow += GetRowCount(i); + return visualRow; + } + + /// Document line that owns . Binary-searches the prefix cache when + /// valid; otherwise walks the row counts. + public int GetDocumentLineFromVisualRow(int visualRow, int lineCount) + { + if (lineCount == 0) + return 0; + + visualRow = Math.Clamp(visualRow, 0, Math.Max(0, _totalVisualRows - 1)); + + if (IsValidFor(lineCount)) + { + int low = 0; + int high = lineCount - 1; + while (low <= high) + { + int mid = low + ((high - low) / 2); + int lineStart = _startRow[mid]; + int nextLineStart = _startRow[mid + 1]; + + if (visualRow < lineStart) + high = mid - 1; + else if (visualRow >= nextLineStart) + low = mid + 1; + else + return mid; + } + return Math.Clamp(low, 0, lineCount - 1); + } + + int rowCursor = 0; + for (int i = 0; i < lineCount; i++) + { + int rows = GetRowCount(i); + if (rowCursor + rows > visualRow) + return i; + rowCursor += rows; + } + return Math.Max(0, lineCount - 1); + } + + /// Visual rows spanned by document lines [startLine, startLine + lineCount). + public int GetRenderedVisualRowCount(int startLine, int lineCount, int totalLineCount) + { + int endLine = Math.Clamp(startLine + lineCount, 0, totalLineCount); + return Math.Max(0, GetLineStartRow(endLine, totalLineCount) - GetLineStartRow(startLine, totalLineCount)); + } +} diff --git a/TextControlBox/Helper/CursorHelper.cs b/TextControlBox/Helper/CursorHelper.cs index 25d186b..8d4ba86 100644 --- a/TextControlBox/Helper/CursorHelper.cs +++ b/TextControlBox/Helper/CursorHelper.cs @@ -13,19 +13,23 @@ internal class CursorHelper { public static int GetCursorLineFromPoint(TextRenderer textRenderer, Point point) { + // In wrap mode a pointer Y maps to a visual row, which maps to its owning document line. + if (textRenderer.IsWordWrapEnabled) + return textRenderer.GetDocumentLineFromVisualRow(textRenderer.GetVisualRowFromPointY(point.Y)); + //Calculate the relative linenumber, where the pointer was pressed at double adjustedY = point.Y; int relativeLine = (int)Math.Floor(adjustedY / textRenderer.SingleLineHeight); return Math.Max(0, relativeLine + textRenderer.NumberOfStartLine); } - public static int GetCharacterPositionFromPoint(CurrentLineManager currentLineManager, CanvasTextLayout textLayout, Point cursorPosition, float marginLeft) + public static int GetCharacterPositionFromPoint(CurrentLineManager currentLineManager, CanvasTextLayout textLayout, Point cursorPosition, float marginLeft, float y = 0) { if (currentLineManager.GetCurrentLineText() == null || textLayout == null) return 0; textLayout.HitTest( - (float)cursorPosition.X - marginLeft, 0, + (float)cursorPosition.X - marginLeft, y, out var textLayoutRegion); return textLayoutRegion.CharacterIndex; } @@ -51,10 +55,15 @@ public static void UpdateCursorPosFromPoint(CanvasControl canvasText, CurrentLin //GetCursorLineFromPoint returns absolute line index. textRenderer.UpdateCurrentLineTextLayout(canvasText); - // 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); + // In wrap mode the current-line layout spans multiple rows, so hit-test at the within-line Y and use a + // zero left margin (no horizontal scroll). Otherwise 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. All helpers are no-ops when wrap/virtualization are off. + float hitTestY = textRenderer.IsWordWrapEnabled + ? textRenderer.GetWrappedLineHitTestYFromPointY(cursorPos.LineNumber, point.Y) + : 0; + float marginLeft = textRenderer.IsWordWrapEnabled ? 0 : textRenderer.HorizontalOffset; + int renderedCharacterPosition = GetCharacterPositionFromPoint(currentLineManager, textRenderer.CurrentLineTextLayout, point, marginLeft, hitTestY); cursorPos.CharacterPosition = textRenderer.GetDocumentCharacterIndexFromRenderedIndex(cursorPos.LineNumber, renderedCharacterPosition); } } diff --git a/TextControlBox/TextControlBox.cs b/TextControlBox/TextControlBox.cs index 1332f6e..11e98ba 100644 --- a/TextControlBox/TextControlBox.cs +++ b/TextControlBox/TextControlBox.cs @@ -741,6 +741,14 @@ public CursorPosition CursorPosition set => coreTextBox.FontFamily = value; } /// + /// Gets or sets whether long lines wrap at the control width instead of scrolling horizontally. + /// + public bool WordWrap + { + get => coreTextBox.WordWrap; + set => coreTextBox.WordWrap = value; + } + /// /// Gets or sets the font size used for displaying text in the textbox. /// From cb2d078280ac0c84bfc924987e7da41c16cc2eb2 Mon Sep 17 00:00:00 2001 From: andrewtheart Date: Sat, 11 Jul 2026 06:15:59 -0500 Subject: [PATCH 4/4] Add centered horizontal reveal (ScrollIntoViewHorizontallyCentered) New public ScrollIntoViewHorizontallyCentered() centers the current cursor column in the viewport instead of pinning it to an edge (what jump-to-match wants), and the existing edge-reveal is refactored onto a shared slice-aware GetCurrentCursorPixelPositionInLine() so it is correct on horizontally-virtualized long lines. Follow-up to #33. --- .../Core/CoreTextControlBox.xaml.cs | 7 ++ TextControlBox/Core/Renderer/TextRenderer.cs | 2 +- TextControlBox/Core/ScrollManager.cs | 73 +++++++++++++++++-- 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/TextControlBox/Core/CoreTextControlBox.xaml.cs b/TextControlBox/Core/CoreTextControlBox.xaml.cs index 1b42bce..1ed3af8 100644 --- a/TextControlBox/Core/CoreTextControlBox.xaml.cs +++ b/TextControlBox/Core/CoreTextControlBox.xaml.cs @@ -828,6 +828,13 @@ public void ScrollBottomIntoView() scrollManager.ScrollBottomIntoView(); } + /// Horizontally centers the current cursor column in the viewport (no-op in word-wrap mode). + /// Useful for revealing a search match in the middle of the visible width instead of pinned to an edge. + public void ScrollIntoViewHorizontallyCentered() + { + scrollManager.ScrollCursorIntoViewHorizontallyCentered(canvasText); + } + public void ScrollPageUp() { scrollManager.ScrollPageUp(); diff --git a/TextControlBox/Core/Renderer/TextRenderer.cs b/TextControlBox/Core/Renderer/TextRenderer.cs index 7c1c316..198865f 100644 --- a/TextControlBox/Core/Renderer/TextRenderer.cs +++ b/TextControlBox/Core/Renderer/TextRenderer.cs @@ -48,7 +48,7 @@ internal class TextRenderer /// 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); + internal 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; diff --git a/TextControlBox/Core/ScrollManager.cs b/TextControlBox/Core/ScrollManager.cs index ca9665c..537c2f2 100644 --- a/TextControlBox/Core/ScrollManager.cs +++ b/TextControlBox/Core/ScrollManager.cs @@ -156,11 +156,7 @@ public void UpdateScrollToShowCursor(bool update = true) public bool ScrollIntoViewHorizontal(CanvasControl canvasText, bool update = true) { - float curPosInLine = CursorHelper.GetCursorPositionInLine( - textRenderer.CurrentLineTextLayout, - cursorManager.currentCursorPosition, - 0 - ); + float curPosInLine = GetCurrentCursorPixelPositionInLine(); if (curPosInLine == OldHorizontalScrollValue) return false; @@ -188,6 +184,73 @@ public bool ScrollIntoViewHorizontal(CanvasControl canvasText, bool update = tru return changed; } + /// + /// Horizontally CENTERS the current cursor column in the viewport (NoWrap only). Unlike + /// — which reveals the column at the nearest edge (so a match far + /// to the right lands pinned against the right edge) — this places the column in the middle of the + /// visible width, which is what "jump to a search match" wants. It records the resolved column in + /// so the per-draw → + /// pass sees the column as already handled and keeps the centered + /// offset instead of re-revealing it at the edge. + /// + public bool ScrollCursorIntoViewHorizontallyCentered(CanvasControl canvasText, bool update = true) + { + if (coreTextbox.WordWrap) + { + if (OffsetSource.HorizontalOffset != 0) + OffsetSource.HorizontalOffset = 0; + OldHorizontalScrollValue = 0; + return false; + } + + float curPosInLine = GetCurrentCursorPixelPositionInLine(); + + double maxOffset = Math.Max(horizontalScrollBar.Minimum, horizontalScrollBar.Maximum); + double target = Math.Clamp(curPosInLine - canvasText.ActualWidth / 2, horizontalScrollBar.Minimum, maxOffset); + + bool changed = Math.Abs(target - OffsetSource.HorizontalOffset) > 0.5; + if (changed) + OffsetSource.HorizontalOffset = target; + + OldHorizontalScrollValue = curPosInLine; + + if (update) + canvasHelper.UpdateAll(); + + return changed; + } + + /// + /// Pixel X position of the current cursor column within its line, accounting for horizontal + /// virtualization (long lines rendered as a moving slice). Shared by the edge-reveal + /// and the centering + /// so both agree on where the column is. + /// + private float GetCurrentCursorPixelPositionInLine() + { + int charPosForLayout = cursorManager.currentCursorPosition.CharacterPosition; + if (textRenderer.IsHorizontallyVirtualized) + charPosForLayout -= textRenderer.HorizontalSliceStart; + + if (textRenderer.IsHorizontallyVirtualized && (charPosForLayout < 0 || textRenderer.CurrentLineTextLayout == null)) + { + // Cursor is before the slice or the layout is unavailable; estimate the absolute position. + return cursorManager.currentCursorPosition.CharacterPosition * textRenderer.CachedCharWidth; + } + + if (textRenderer.IsHorizontallyVirtualized && charPosForLayout > textRenderer.RenderedText.Length) + { + // Cursor is beyond the slice; estimate the absolute position. + return cursorManager.currentCursorPosition.CharacterPosition * textRenderer.CachedCharWidth; + } + + return CursorHelper.GetCursorPositionInLine( + textRenderer.CurrentLineTextLayout, + new CursorPosition(charPosForLayout, cursorManager.currentCursorPosition.LineNumber), + textRenderer.HorizontalSlicePixelOffset + ); + } + public void EnsureHorizontalScrollBounds(CanvasControl canvasText, LongestLineManager longestLineManager, bool triggeredByCursor, bool forceRecalculateLongestLine = false) { longestLineManager.CheckRecalculateLongestLine(forceRecalculateLongestLine);