Fix word-wrap scroll jump: visual-line mapping via WrapIndex
Every scroll<->content mapping site (window start, sub-line shift, tap
mapping, max-scroll clamp, selection menu/handle positions) assumed
1 logical line = 1 visual line. When the viewport top crossed the
bottom of a wrapped line, the view jumped past the wrapped remainder
(jump magnitude (count-1)*lh) instead of moving pixel-by-pixel.
- WrapIndex (internal/editor/wrap_index.go): Fenwick tree of
per-logical-line visual-line counts, parallel to the LineIndex;
built at index-build time, bookkept by the same
UpdateLineIndexAfter{Insert,Delete} hooks (never under-stale: every
touched line resets to the estimate, the next shaping pass
re-corrects it).
- scrollVisualDecompose: the scroll offset lives in visual-line space:
k = LineForVisual(floor(s/lh)), r = s - V(k)*lh. All mapping sites
go through it, so the viewport top is always exactly s into the
document's visual space (V(k)*lh + r = s) — the jump invariant.
All-ones index reduces to the legacy 1:1 mapping (pre-shaping and
non-wrapped behavior unchanged by construction).
- Correction pipeline: the renderer's per-frame VisualLineStarts are
grouped per logical line and written back (applyWrapCounts). The
layout feedback now carries the exact window text the layout was
shaped for (carried in the frame) plus the window start line and the
content-edit counter; corrections apply only on edit-counter match,
and grouping over the current window text (wrong after a scroll moved
the window) is no longer possible.
- bytePosToScreenXY now applies the sub-line shift and the scaled line
pitch: the selection menu/handles were off by up to a full line.
- maxScroll uses TotalVisuals() with the effective (font-scaled) line
height; the bottom clamp lands exactly on the file end for wrapped
content.
- VisibleByteRange returns the real start line (was hardcoded 0).
- emitFrame: replace the unread handoff frame with the newer snapshot
instead of dropping it — a dropped final frame was never re-emitted
(emission is event-driven), leaving the consumer one state behind
forever; fixes the pre-existing TestRealFile_ShiftSelectionInsert
failure. Still non-blocking.
Tests (mutation-verified where practical): wrap_index_test.go (Fenwick
vs naive model, 3000 ops), wrap_bookkeeping_test.go (edit hooks vs
shadow-string oracle, 400 ops — caught a real m=0 under-marking),
wrap_mapping_test.go (the jump regression: V(k)*lh + r == s over sweeps
+ random offsets; legacy-identity pin; boundary sweep), wrap_apply_test.go
(VisualLineStarts grouping + guards — the first version exposed the
always-true WindowStartByte guard that blocked all post-scroll
corrections). go test -race ./... green.
On-device (emulator, 60 wrapped lines): dp sweep 0/17/50/67/134/340
lands on LINE000-vl0/1/3, LINE001-vl0, LINE002-vl0, LINE005-vl0 —
pixel-exact 1:1, no jump (dp 134 is where the old code jumped to
LINE008); bottom clamp exact.
Docs: architecture.md §6.2 (visual-line space invariant),
development_plan.md (Phase 13), spec.md (wrap + clamp lines).
This commit is contained in:
parent
95a2143e16
commit
83f7affee9
|
|
@ -287,7 +287,13 @@ func run(w *app.Window) error {
|
|||
if sendQuery {
|
||||
logic.SearchQueryChan() <- newQuery
|
||||
}
|
||||
logic.LayoutChan() <- glyphLayout
|
||||
logic.LayoutChan() <- ui.LayoutFeedback{
|
||||
GlyphLayout: glyphLayout,
|
||||
WindowText: frame.WindowText,
|
||||
WindowStartByte: frame.WindowStartByte,
|
||||
WindowStartLine: frame.WindowStartLine,
|
||||
EditSeq: frame.EditSeq,
|
||||
}
|
||||
default:
|
||||
handleEvent(e)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -234,18 +234,58 @@ Only the visible byte range is shaped and drawn each frame:
|
|||
endLine]` via the `LineIndex`, then to a byte range. The range is always
|
||||
bounded by real lines of the document.
|
||||
- **Scroll decomposition invariant.** The scroll offset s is split into a
|
||||
content line k and sub-line remainder r (k·lh ≤ s < (k+1)·lh) by a single
|
||||
float64 floor decomposition, and the three consumers of that split MUST
|
||||
stay in lockstep: the window start line (VisibleByteRange), the renderer's
|
||||
sub-line shift (the windowed layout is drawn shifted up by r), and the tap
|
||||
mapping (`tapLocalY` adds r). With them consistent, a tap a dp below the
|
||||
region top always maps to content line k + ⌊(a+r)/lh⌋ =
|
||||
⌊(a+s)/lh⌋ — the line actually under the finger — for every s ≥ 0. The
|
||||
content line k and sub-line remainder r by a single float64 floor
|
||||
decomposition, and all consumers of that split MUST stay in lockstep: the
|
||||
window start line (VisibleByteRange), the renderer's sub-line shift (the
|
||||
windowed layout is drawn shifted up by r), the tap mapping (`tapLocalY`
|
||||
adds r), the selection menu/handle positions (`bytePosToScreenXY`), and the
|
||||
max-scroll clamp. With them consistent, a tap a dp below the region top
|
||||
always maps to the line actually under the finger for every s ≥ 0. The
|
||||
decomposition must be computed in float64: a raw `int(s/lh)` in the Dp
|
||||
float32 domain can round the quotient UP across an integer boundary while
|
||||
a float64 mod still reflects the line below, so the window start and the
|
||||
remainder disagree by one line in a sub-pixel-wide band of offsets and the
|
||||
whole rendered window (hence every tapped line) shifts by one.
|
||||
- **Word wrap: the visual-line space (WrapIndex).** A wrapped logical line
|
||||
occupies several visual lines, so the scroll offset lives in VISUAL-line
|
||||
space, not logical-line space. Every scroll↔content mapping site
|
||||
(window start, sub-line shift, tap-to-line, max scroll) MUST go through
|
||||
the same conversion, or the content jumps: when the viewport top crosses
|
||||
the bottom of a wrapped line, a 1:1 logical↔visual mapping skips the
|
||||
wrapped remainder (jump magnitude (count-1)·lh) instead of moving
|
||||
pixel-by-pixel. The conversion is backed by the `WrapIndex`, a Fenwick
|
||||
tree of per-logical-line visual-line counts (built parallel to the
|
||||
LineIndex, same line set, updated by the same edit hooks):
|
||||
- **Invariant:** with k the logical line at the viewport top and r the
|
||||
sub-line shift, the viewport top is ALWAYS exactly s into the document's
|
||||
visual space: `V(k)·lh + r = s`, where V(k) is the prefix sum of counts
|
||||
before line k. Equivalently k = LineForVisual(⌊s/lh⌋) and r = s − V(k)·lh
|
||||
(0 ≤ r < count(k)·lh: a wrapped line's top may sit several visual lines
|
||||
above the viewport). Any mapping that breaks the identity silently skips
|
||||
or re-shows content — the scroll jump. An all-ones WrapIndex (the state
|
||||
before any shaping correction lands) makes V(k)=k and the mapping
|
||||
reduces to the legacy 1:1 behavior, so pre-shaping and non-wrapped files
|
||||
are unchanged by construction.
|
||||
- **Correction pipeline:** the renderer's per-frame `VisualLineStarts`
|
||||
(one entry per visual line, window-relative) are grouped per logical
|
||||
line and written back into the WrapIndex (the layout feedback carries
|
||||
the exact window text it was shaped for, the window's first logical
|
||||
line, and the content-edit counter; the correction is applied only if
|
||||
the edit counter matches, so a layout shaped before an edit never
|
||||
stamps shifted lines). Counts are content- and width-dependent, not
|
||||
window-dependent: once known they are globally valid until an edit or a
|
||||
wrap-width change.
|
||||
- **Edit staleness:** the UpdateLineIndexAfter{Insert,Delete} hooks
|
||||
bookkeep the WrapIndex in the same pass as the LineIndex (line inserts/
|
||||
deletes shift counts; touched lines reset to the estimate 1). The rule
|
||||
is never under-stale: every line whose content changed is reset, and
|
||||
extra resets (over-stale) are always safe — the next frame that shapes
|
||||
the line re-corrects it. A stale estimate is an under-count (count 1),
|
||||
which only shortens maxScroll and compresses the mapping until the
|
||||
correction lands (a self-heating warm-up, never corruption).
|
||||
- Max scroll is `TotalVisuals()·lh − regionH + lh/2` with the
|
||||
font-scale-effective line height; it grows incrementally as shaped
|
||||
counts arrive (pre-shaping it equals the no-wrap estimate).
|
||||
- **Font-scale axis.** The shaper draws baselines in sp, so on Android the
|
||||
rendered line pitch in density-dp is `EditorLineHeight()*fontScale`
|
||||
(`fontScale = Metric.PxPerSp/PxPerDp`, the user font-size setting).
|
||||
|
|
|
|||
|
|
@ -565,6 +565,67 @@ Insert and one entry in UpdateLineIndexAfterInsert are both caught in ms.
|
|||
with a straggling write can only lose freshness (unique temps keep every
|
||||
rename a complete snapshot).
|
||||
|
||||
### Phase 13 — word-wrap scroll jump — DONE (2026-08-17)
|
||||
|
||||
**Bug (user-reported, content-dependent, not a performance problem):**
|
||||
scrolling a wrapped file, the viewport jumped past the wrapped remainder of a
|
||||
logical line the moment its bottom crossed the viewport top, instead of moving
|
||||
pixel-by-pixel with the finger. Jump magnitude = (count−1)·lh, where count is
|
||||
the line's visual-line count. Root cause: every scroll↔content mapping site
|
||||
assumed 1 logical line = 1 visual line (V(k)=k): the window start line
|
||||
(`visibleByteRangePrecise`), the renderer sub-line shift, the tap mapping
|
||||
(`tapLocalY`), the max-scroll clamp, and `bytePosToScreenXY` (which also
|
||||
ignored the sub-line shift entirely — the selection menu/handles were off by
|
||||
up to a full line, a second latent bug fixed in the same change).
|
||||
|
||||
**Fix:** a `WrapIndex` (Fenwick tree of per-logical-line visual-line counts,
|
||||
parallel to the LineIndex) routes every mapping site through one conversion:
|
||||
the scroll offset lives in visual-line space, k = LineForVisual(⌊s/lh⌋),
|
||||
r = s − V(k)·lh. Counts are corrected per frame from the renderer's
|
||||
`VisualLineStarts` (the layout feedback now carries the exact window text the
|
||||
layout was shaped for, its first logical line, and the content-edit counter;
|
||||
corrections apply only when the edit counter matches). The edit hooks
|
||||
(UpdateLineIndexAfter{Insert,Delete}) bookkeep the index in the same pass as
|
||||
the LineIndex under the never-under-stale rule. Invariant (see
|
||||
architecture.md §6.2): the viewport top is always exactly s into the
|
||||
document's visual space (V(k)·lh + r = s); an all-ones index reduces to the
|
||||
legacy 1:1 mapping, so pre-shaping and non-wrapped behavior are unchanged by
|
||||
construction.
|
||||
|
||||
**Tests (all mutation-verified where practical):**
|
||||
- `wrap_index_test.go`: Fenwick ops (Set/SetRange/Insert/Delete/LineForVisual/
|
||||
prefixes) vs a naive model, 3000 random ops; all-ones-is-identity pin.
|
||||
- `wrap_bookkeeping_test.go`: the edit hooks vs a shadow-string oracle (400
|
||||
random insert/delete ops; changed lines must be re-stamped, survivors keep
|
||||
counts). Caught a real under-marking: an insertion with no newline left the
|
||||
containing line's stale count (m=0 skip).
|
||||
- `wrap_mapping_test.go`: the jump regression — V(k)·lh + r == s over a 4000-
|
||||
step sweep + 2000 random offsets on a fabricated wrapped file; the legacy
|
||||
identity pin (all-ones and no-index reduce to the old mapping); a
|
||||
boundary sweep across every wrapped-line boundary (no skip, no repeat,
|
||||
fixed lines move exactly finger-speed).
|
||||
- `wrap_apply_test.go`: VisualLineStarts→logical-line grouping (multi-wrap
|
||||
line, empty line, trailing-newline edge, guard early-outs). The first
|
||||
version of the test exposed a production bug: the correlation guard
|
||||
(`WindowStartByte > len(windowText)`) was always true at non-zero scroll,
|
||||
so corrections could never apply after scrolling; and grouping over the
|
||||
CURRENT window text (instead of the shaped one) mis-attributes counts
|
||||
whenever a scroll moved the window between shaping and delivery. Both
|
||||
fixed by carrying the shaped window text in the frame/feedback.
|
||||
- Also fixed a pre-existing e2e failure (`TestRealFile_ShiftSelectionInsert`):
|
||||
`emitFrame` dropped a frame when the handoff buffer was full and, since
|
||||
emission is event-driven, a dropped FINAL frame was never re-emitted — the
|
||||
consumer could sit one state behind forever. `emitFrame` now replaces the
|
||||
unread frame with the newer snapshot (latest frame wins) instead of
|
||||
dropping; still non-blocking.
|
||||
|
||||
**On-device (emulator, wraptest.txt: 60 logical lines × 4 visual lines):**
|
||||
dp sweep via the debug cmd poller: dp 0/17/50/67/134/340 land on
|
||||
LINE000-vl0 / LINE000-vl1 / LINE000-vl3 / LINE001-vl0 / LINE002-vl0 /
|
||||
LINE005-vl0 — pixel-exact 1:1 finger↔content, no jump at the wrapped
|
||||
boundaries (dp 134 is where the old code jumped to LINE008); bottom clamp
|
||||
lands exactly on the file end via the wrap-aware TotalVisuals().
|
||||
|
||||
## 6. File-size decision (re-framed)
|
||||
|
||||
v1 framed this as "accept a limit vs build a windowed editor." The live repo
|
||||
|
|
|
|||
15
doc/spec.md
15
doc/spec.md
|
|
@ -48,7 +48,11 @@ elsewhere.
|
|||
(Swipe-typing support follows from the same IME path; final sign-off on a
|
||||
physical device is the one open validation item.)
|
||||
- **Word wrap:** on by default; wrapped lines are virtual — the buffer stores
|
||||
only real newlines.
|
||||
only real newlines. Scrolling wrapped content is continuous: the viewport
|
||||
moves pixel-by-pixel with the finger, including across the bottom of a
|
||||
wrapped line (no jumping over wrapped remainders) — the scroll offset maps
|
||||
to content through the per-line visual-line counts corrected by the
|
||||
renderer every frame (architecture.md §6.2).
|
||||
- **Virtualized viewport:** only the visible byte range is shaped and drawn
|
||||
each frame (typically ~4 KB of a large file), keeping frame cost and shaper
|
||||
memory constant regardless of file size.
|
||||
|
|
@ -141,9 +145,12 @@ Full details, channel topology, and ownership rules: [`architecture.md`](./archi
|
|||
6. **Logic work stays < 16 ms.** Anything that can block or scan more than the
|
||||
viewport goes to the worker pool.
|
||||
7. **Scroll offset is always clamped to `[0, maxScroll]`,** where
|
||||
`maxScroll = contentHeight − viewportHeight` floored at 0. A file whose
|
||||
content fits the viewport has `maxScroll = 0` and cannot scroll. Verified
|
||||
on-device across 2→130,955 lines (`development_plan.md` Phase 6).
|
||||
`maxScroll = contentHeight − viewportHeight` floored at 0 and
|
||||
`contentHeight` counts VISUAL lines (a wrapped line is taller than one
|
||||
line pitch). A file whose content fits the viewport has `maxScroll = 0`
|
||||
and cannot scroll. Verified on-device across 2→130,955 lines
|
||||
(`development_plan.md` Phase 6); the wrap-aware clamp lands exactly on the
|
||||
file end for wrapped content (Phase 13).
|
||||
|
||||
## 6. Performance expectations (validated on-device)
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,13 @@ type ChunkedBuffer struct {
|
|||
// line index is built asynchronously
|
||||
LineIndex *types.LineIndex
|
||||
|
||||
// WrapIndex is the per-logical-line visual line count (word wrap),
|
||||
// parallel to LineIndex: same line set, updated by the same
|
||||
// UpdateLineIndexAfterInsert/Delete hooks, and corrected per-frame by the
|
||||
// renderer's actual wrap results (applyWrapCounts in state.go). A nil
|
||||
// WrapIndex means the legacy 1:1 line mapping (no wrap info yet).
|
||||
WrapIndex *WrapIndex
|
||||
|
||||
// workerPool is retained for API compatibility; in-range files load fully
|
||||
// up front, so chunk loading no longer dispatches worker tasks.
|
||||
workerPool *pool.WorkerPool
|
||||
|
|
@ -455,10 +462,12 @@ func (cb *ChunkedBuffer) VisibleByteRange(scrollOffset ui.Dp, byteOffset int, vi
|
|||
}
|
||||
if cb.LineIndex == nil {
|
||||
start, end = cb.visibleByteRangeEstimate(scrollOffset, viewportHeight, lineH)
|
||||
// No index: the estimate is not line-exact; report 0 (the window is
|
||||
// heuristic) so callers that need a real line start fall back.
|
||||
return start, end, 0
|
||||
}
|
||||
start, end = cb.visibleByteRangePrecise(scrollOffset, viewportHeight, lineH)
|
||||
return start, end, 0
|
||||
start, end, startLine = cb.visibleByteRangePrecise(scrollOffset, viewportHeight, lineH)
|
||||
return start, end, startLine
|
||||
}
|
||||
|
||||
// visibleByteRangeEstimate approximates the visible byte range using
|
||||
|
|
@ -535,9 +544,10 @@ func (cb *ChunkedBuffer) visibleByteRangeEstimate(scrollOffset ui.Dp, viewportHe
|
|||
}
|
||||
|
||||
// visibleByteRangePrecise uses the LineIndex to find the exact byte range.
|
||||
func (cb *ChunkedBuffer) visibleByteRangePrecise(scrollOffset ui.Dp, viewportHeight ui.Dp, lineHeight ui.Dp) (start, end int) {
|
||||
func (cb *ChunkedBuffer) visibleByteRangePrecise(scrollOffset ui.Dp, viewportHeight ui.Dp, lineHeight ui.Dp) (start, end, startLine int) {
|
||||
if cb.LineIndex == nil || len(cb.LineIndex.Offsets) == 0 {
|
||||
return cb.visibleByteRangeEstimate(scrollOffset, viewportHeight, lineHeight)
|
||||
es, ee := cb.visibleByteRangeEstimate(scrollOffset, viewportHeight, lineHeight)
|
||||
return es, ee, 0
|
||||
}
|
||||
if lineHeight <= 0 {
|
||||
lineHeight = EffectiveLineHeight()
|
||||
|
|
@ -547,7 +557,16 @@ func (cb *ChunkedBuffer) visibleByteRangePrecise(scrollOffset ui.Dp, viewportHei
|
|||
// sub-line shift and tapLocalY (scrollDecompose); a raw int(s/lh) in the
|
||||
// Dp float32 domain can round the quotient up across an integer boundary
|
||||
// and disagree with the remainder by one line.
|
||||
startLine, _ := scrollDecompose(scrollOffset, lineHeight)
|
||||
//
|
||||
// scrollDecompose lands in VISUAL-line space (see WrapIndex): v0 is the
|
||||
// visual line at the viewport top, and the WrapIndex maps it to the
|
||||
// logical line that contains it. Without a WrapIndex the mapping is 1:1
|
||||
// (the legacy no-wrap behavior).
|
||||
v0, _ := scrollDecompose(scrollOffset, lineHeight)
|
||||
startLine = int(v0)
|
||||
if w := cb.WrapIndex; w != nil {
|
||||
startLine = w.LineForVisual(int32(v0))
|
||||
}
|
||||
endLine := int(math.Ceil(float64(scrollOffset+viewportHeight) / float64(lineHeight)))
|
||||
|
||||
if startLine < 0 {
|
||||
|
|
@ -588,7 +607,7 @@ func (cb *ChunkedBuffer) visibleByteRangePrecise(scrollOffset ui.Dp, viewportHei
|
|||
}
|
||||
}
|
||||
|
||||
return start, end
|
||||
return start, end, startLine
|
||||
}
|
||||
|
||||
// UpdateLineIndexAfterInsert records the insertion of `text` at absolute
|
||||
|
|
@ -629,6 +648,24 @@ func (cb *ChunkedBuffer) UpdateLineIndexAfterInsert(pos int, text string) {
|
|||
}
|
||||
li.Offsets = newOff
|
||||
li.Size += int64(len(text))
|
||||
|
||||
// WrapIndex bookkeeping (see WrapIndex): the text opens m new lines
|
||||
// (one per '\n'), and the m+1 lines covering the insertion point now
|
||||
// hold new content, so their counts reset to the estimate of 1 until the
|
||||
// next shaping pass re-corrects them. Survivors keep their counts.
|
||||
if w := cb.WrapIndex; w != nil {
|
||||
m := strings.Count(text, "\n")
|
||||
j := lower
|
||||
if !atPos {
|
||||
j-- // inserted mid-line: the line containing pos is lower-1
|
||||
}
|
||||
if m > 0 {
|
||||
w.InsertLines(j, m)
|
||||
}
|
||||
for i := j; i <= j+m && i < w.Len(); i++ {
|
||||
w.Set(i, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateLineIndexAfterDelete records the deletion of the absolute byte range
|
||||
|
|
@ -671,6 +708,42 @@ func (cb *ChunkedBuffer) UpdateLineIndexAfterDelete(start, end int) {
|
|||
if li.Size < 0 {
|
||||
li.Size = 0
|
||||
}
|
||||
|
||||
// WrapIndex bookkeeping (see WrapIndex): (upper-lower) line starts
|
||||
// disappear. If the delete starts mid-line, the line containing the start
|
||||
// and the line after the range merge into one (the merge replaces one
|
||||
// start, not two): the net line-start removals are R below, and the merged
|
||||
// line's content is new, so its count resets to the estimate. Whole-line
|
||||
// deletes (line-start to line-start) remove exactly upper-lower starts and
|
||||
// leave no merged line.
|
||||
if w := cb.WrapIndex; w != nil {
|
||||
// atStart (computed above) says whether the delete begins on a line
|
||||
// start (including pos 0).
|
||||
atStartEff := atStart
|
||||
endIsLineStart := upper < len(old) && old[upper] == int32(end)
|
||||
r := upper - lower
|
||||
if atStartEff {
|
||||
r--
|
||||
}
|
||||
if endIsLineStart {
|
||||
r++
|
||||
}
|
||||
if r > 0 {
|
||||
w.DeleteLines(lower, r)
|
||||
}
|
||||
if !atStartEff || !endIsLineStart {
|
||||
// A merged line exists: it sits right after the removed block in
|
||||
// the new index — at `lower` when the delete began on a line
|
||||
// start, at `lower-1` when it began mid-line.
|
||||
j := lower
|
||||
if !atStartEff {
|
||||
j--
|
||||
}
|
||||
if j >= 0 && j < w.Len() {
|
||||
w.Set(j, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// max returns the larger of two ints.
|
||||
|
|
|
|||
|
|
@ -99,8 +99,9 @@ func TestHandleCursorMove_Bounds(t *testing.T) {
|
|||
func TestTapLocalY_WindowRelative(t *testing.T) {
|
||||
lh := float64(EditorLineHeight())
|
||||
// Tap 100 Dp below a region top that starts at 114 Dp, with a deep scroll
|
||||
// (a large file scrolled far down).
|
||||
got := tapLocalY(214, 114, 1539464)
|
||||
// (a large file scrolled far down). tapLocalY reads TheState.ScrollOffset.
|
||||
TheState.ScrollOffset = 1539464
|
||||
got := tapLocalY(214, 114)
|
||||
// Expected: (214-114) + (1539464 mod lineHeight) => in [100, 100+lineHeight).
|
||||
if got < 100 || got >= 100+lh {
|
||||
t.Errorf("tapLocalY = %v, want in [100, %v) (window-relative)", got, 100+lh)
|
||||
|
|
@ -138,7 +139,7 @@ func TestTapToPosition_WindowBaseOffset(t *testing.T) {
|
|||
// Tap ~3 lines below the top of the window.
|
||||
regionTop := ui.Dp(114)
|
||||
ptY := regionTop + ui.Dp(3*lh)
|
||||
localY := tapLocalY(ptY, regionTop, TheState.ScrollOffset)
|
||||
localY := tapLocalY(ptY, regionTop)
|
||||
SetCursorFromPoint(15, localY)
|
||||
|
||||
// Window line 3 is window-relative byte 6, so the absolute cursor must be
|
||||
|
|
@ -210,7 +211,7 @@ func TestTapToPosition_LargeFileScrolled(t *testing.T) {
|
|||
// Tap ~3 lines below the top of the window.
|
||||
regionTop := ui.Dp(114)
|
||||
ptY := regionTop + ui.Dp(3*lh)
|
||||
localY := tapLocalY(ptY, regionTop, TheState.ScrollOffset)
|
||||
localY := tapLocalY(ptY, regionTop)
|
||||
SetCursorFromPoint(15, localY)
|
||||
|
||||
// Should land on window line 3 (byte offset 6), NOT be clamped to the last
|
||||
|
|
|
|||
|
|
@ -28,6 +28,15 @@ type Frame struct {
|
|||
FontScale float32 // user font-size setting the logic bookkeeping used
|
||||
FocusedElementID string
|
||||
Query string
|
||||
// WindowStartByte / WindowStartLine / EditSeq: the editor window this
|
||||
// frame's elements describe. The main goroutine forwards them with the
|
||||
// shaped glyph layout (LayoutFeedback) so the logic goroutine can apply
|
||||
// the layout's wrap counts to exactly the lines it was shaped for, and
|
||||
// drop them if an edit landed in the meantime.
|
||||
WindowStartByte int
|
||||
WindowStartLine int // -1 when the frame has no editor window
|
||||
WindowText string // the editor window this frame's text element holds
|
||||
EditSeq uint64
|
||||
}
|
||||
|
||||
// frameOf wraps a computed element tree with the current view-state
|
||||
|
|
@ -39,6 +48,10 @@ func (l *Logic) frameOf(elems []ui.Element) Frame {
|
|||
FontScale: l.state.fontScale,
|
||||
FocusedElementID: l.state.FocusedElementID,
|
||||
Query: l.state.Browser.Query,
|
||||
WindowStartByte: l.state.Editor.IMEWindowStartByte,
|
||||
WindowStartLine: l.state.WindowStartLine,
|
||||
WindowText: l.state.Editor.IMEWindowText,
|
||||
EditSeq: l.state.Editor.EditSeq,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ type Logic struct {
|
|||
configChan chan ConfigUpdate
|
||||
frameChan chan Frame // frames carry the view-state snapshot
|
||||
inputChan chan []ui.InputEvent
|
||||
layoutChan chan ui.GlyphLayout
|
||||
layoutChan chan ui.LayoutFeedback
|
||||
resultChan chan ResultEvent
|
||||
searchQueryChan chan string
|
||||
openFileChan chan string
|
||||
|
|
@ -124,7 +124,7 @@ func NewLogic(mfs pool.FileSystem, path string, openfunc func(string)) *Logic {
|
|||
configChan: make(chan ConfigUpdate),
|
||||
frameChan: make(chan Frame, 1),
|
||||
inputChan: make(chan []ui.InputEvent),
|
||||
layoutChan: make(chan ui.GlyphLayout),
|
||||
layoutChan: make(chan ui.LayoutFeedback),
|
||||
resultChan: make(chan ResultEvent),
|
||||
searchQueryChan: make(chan string),
|
||||
openFileChan: make(chan string),
|
||||
|
|
@ -158,7 +158,7 @@ func (l *Logic) InputChan() chan<- []ui.InputEvent {
|
|||
}
|
||||
|
||||
// LayoutChan returns the glyph layout feedback channel.
|
||||
func (l *Logic) LayoutChan() chan<- ui.GlyphLayout {
|
||||
func (l *Logic) LayoutChan() chan<- ui.LayoutFeedback {
|
||||
return l.layoutChan
|
||||
}
|
||||
|
||||
|
|
@ -214,14 +214,22 @@ func (l *Logic) Run() {
|
|||
case update := <-l.configChan:
|
||||
update.apply(l.state)
|
||||
l.emitFrame()
|
||||
case layout := <-l.layoutChan:
|
||||
case fb := <-l.layoutChan:
|
||||
// Store the full GlyphLayout on editor state.
|
||||
// Derive LastLineY from it for scroll clamping.
|
||||
layout := fb.GlyphLayout
|
||||
l.state.Editor.GlyphLayout = layout
|
||||
var derivedLastLineY ui.Dp
|
||||
if len(layout.Y) > 0 {
|
||||
derivedLastLineY = layout.Y[len(layout.Y)-1]
|
||||
}
|
||||
// Wrap-count correction (see WrapIndex): the layout describes the
|
||||
// window it was shaped for (fb.WindowStartLine); apply its visual
|
||||
// line counts to those lines, but only if no edit has shifted the
|
||||
// lines since shaping (fb.EditSeq correlates with the content).
|
||||
if fb.EditSeq == l.state.Editor.EditSeq {
|
||||
l.state.applyWrapCounts(fb)
|
||||
}
|
||||
if derivedLastLineY != l.state.LastLineY {
|
||||
l.state.LastLineY = derivedLastLineY
|
||||
l.emitFrame()
|
||||
|
|
@ -305,13 +313,26 @@ func (l *Logic) emitFrame() {
|
|||
}
|
||||
PerfRecord(rec)
|
||||
}
|
||||
f := l.frameOf(elems)
|
||||
select {
|
||||
case l.frameChan <- l.frameOf(elems):
|
||||
case l.frameChan <- f:
|
||||
default:
|
||||
// Main has not consumed the previous frame yet; drop this one. Frames
|
||||
// are snapshots and the next emission carries the latest state. Keeps
|
||||
// the owner from ever blocking on a slow/gone main (e.g. during the
|
||||
// post-done write drain).
|
||||
// Main has not consumed the previous frame yet. Frames are snapshots
|
||||
// of the latest state, so the newest is always the most valuable:
|
||||
// replace the unread one instead of dropping this one. A dropped
|
||||
// final frame would never be re-emitted (emission is event-driven),
|
||||
// leaving the consumer one state behind until the next event. Still
|
||||
// non-blocking: a drain from a buffer whose consumer is gone (e.g.
|
||||
// the post-done write drain) still just removes the unread frame.
|
||||
select {
|
||||
case <-l.frameChan:
|
||||
default:
|
||||
return // consumer gone; nothing to replace into
|
||||
}
|
||||
select {
|
||||
case l.frameChan <- f:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -502,8 +523,12 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
|
|||
} else if res.TaskType == pool.TypeBuildLineIndex {
|
||||
if res.Success {
|
||||
if idx, ok := res.Data.(*types.LineIndex); ok {
|
||||
if l.state.Editor.ChunkedBuffer != nil {
|
||||
l.state.Editor.ChunkedBuffer.LineIndex = idx
|
||||
if cb := l.state.Editor.ChunkedBuffer; cb != nil {
|
||||
cb.LineIndex = idx
|
||||
// The wrap index is created with the line index: same line
|
||||
// set, same lifecycle. Counts start at the all-ones
|
||||
// estimate and are corrected by shaped frames.
|
||||
cb.WrapIndex = NewWrapIndex(idx.LineCount())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
|
@ -79,6 +80,11 @@ type EditorState struct {
|
|||
// IMEWindowText is the visible window text shown to the IME (the snippet).
|
||||
// It is set during layout and is what an EditEvent.Range indexes into.
|
||||
IMEWindowText string
|
||||
// EditSeq counts content edits (incremented by markDirty). The shaped
|
||||
// glyph layout arriving via layoutChan is only applied to the WrapIndex
|
||||
// when its EditSeq matches, so a layout shaped before an edit can never
|
||||
// stamp stale wrap counts onto shifted lines.
|
||||
EditSeq uint64
|
||||
// --- Touch selection (v1) ---
|
||||
// CaretDrag: after a long press on blank space a single draggable caret
|
||||
// handle is shown (no selection). MenuVisible/MenuRect/MenuItems: the
|
||||
|
|
@ -167,8 +173,14 @@ type State struct {
|
|||
// VisibleStart/VisibleEnd are the byte range the last editor layout shaped
|
||||
// for the viewport. Set by EditorLayout each frame; read by the profiler
|
||||
// probe to confirm the shaped range stays viewport-bounded (not the file).
|
||||
VisibleStart int
|
||||
VisibleEnd int
|
||||
VisibleStart int
|
||||
VisibleEnd int
|
||||
// WindowStartLine is the logical line the visible editor window starts
|
||||
// at, for the last computed layout (-1 when no editor window is shown).
|
||||
// It is shipped with the shaped glyph layout (Frame) so the layout
|
||||
// correlation pass applies wrap counts to the lines that layout
|
||||
// describes, not the current window (a scroll may have moved it).
|
||||
WindowStartLine int
|
||||
FocusedElementID string // ID of the currently focused element
|
||||
Elems []ui.Element
|
||||
lastEvictionTime time.Time // Throttles chunk eviction
|
||||
|
|
@ -814,7 +826,12 @@ func bytePosToScreenXY(absByte int) (glyphX, lineTop float64, ok bool) {
|
|||
if pos < 0 || pos > windowLen {
|
||||
return 0, 0, false
|
||||
}
|
||||
// Same pitch the renderer shaped with (font-scale aware), so the visual
|
||||
// line derived from the shaper's baseline matches the drawn geometry.
|
||||
lineHeight := float64(EffectiveLineHeight())
|
||||
if layout.LineHeight > 0 {
|
||||
lineHeight = float64(layout.LineHeight)
|
||||
}
|
||||
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
|
||||
return layout.ByteOffsets[i] >= pos
|
||||
})
|
||||
|
|
@ -829,13 +846,17 @@ func bytePosToScreenXY(absByte int) (glyphX, lineTop float64, ok bool) {
|
|||
idx = len(layout.ByteOffsets) - 1
|
||||
}
|
||||
gx := float64(reg.X) + float64(layout.X[idx])
|
||||
// layout.Y is the baseline; the baseline offset within a line is always
|
||||
// < lineHeight, so int(baseline/lineHeight) is the visual line index.
|
||||
// layout.Y is the window-relative baseline; the baseline offset within a
|
||||
// line (the ascent) is always < lineHeight, so int(baseline/lineHeight)
|
||||
// is the window-relative visual line index. The window is drawn shifted
|
||||
// up by r' (scrollVisualDecompose), so the line top on screen is
|
||||
// reg.Y - r' + visualLine*lineHeight.
|
||||
visualLine := int(float64(layout.Y[idx]) / lineHeight)
|
||||
if visualLine < 0 {
|
||||
visualLine = 0
|
||||
}
|
||||
lt := float64(reg.Y) + float64(visualLine)*lineHeight
|
||||
_, r := scrollVisualDecompose()
|
||||
lt := float64(reg.Y) - r + float64(visualLine)*lineHeight
|
||||
return gx, lt, true
|
||||
}
|
||||
|
||||
|
|
@ -859,7 +880,7 @@ func HandleTapAt(x, y ui.Dp) {
|
|||
e.CaretDrag = false
|
||||
hideSelectionMenu()
|
||||
localX := float64(x - TheState.EditorRegion.X)
|
||||
localY := tapLocalY(y, TheState.EditorRegion.Y, TheState.ScrollOffset)
|
||||
localY := tapLocalY(y, TheState.EditorRegion.Y)
|
||||
SetCursorFromPoint(localX, localY)
|
||||
}
|
||||
|
||||
|
|
@ -878,7 +899,7 @@ func HandleLongPressAt(x, y ui.Dp) {
|
|||
return
|
||||
}
|
||||
localX := float64(x - TheState.EditorRegion.X)
|
||||
localY := tapLocalY(y, TheState.EditorRegion.Y, TheState.ScrollOffset)
|
||||
localY := tapLocalY(y, TheState.EditorRegion.Y)
|
||||
pos, ok := textPosFromLocalPoint(localX, localY)
|
||||
if !ok {
|
||||
return
|
||||
|
|
@ -908,7 +929,7 @@ func HandleDoubleTapAt(x, y ui.Dp) {
|
|||
return
|
||||
}
|
||||
localX := float64(x - TheState.EditorRegion.X)
|
||||
localY := tapLocalY(y, TheState.EditorRegion.Y, TheState.ScrollOffset)
|
||||
localY := tapLocalY(y, TheState.EditorRegion.Y)
|
||||
pos, ok := textPosFromLocalPoint(localX, localY)
|
||||
if !ok {
|
||||
return
|
||||
|
|
@ -942,7 +963,7 @@ func HandleSelDragEvt(data any) {
|
|||
func selDragMove(which int, x, y ui.Dp) {
|
||||
e := &TheState.Editor
|
||||
localX := float64(x - TheState.EditorRegion.X)
|
||||
localY := tapLocalY(y, TheState.EditorRegion.Y, TheState.ScrollOffset)
|
||||
localY := tapLocalY(y, TheState.EditorRegion.Y)
|
||||
pos, ok := textPosFromLocalPoint(localX, localY)
|
||||
if !e.SelDragging {
|
||||
e.SelDragging = true
|
||||
|
|
@ -1510,6 +1531,9 @@ func currentEditorText() string {
|
|||
}
|
||||
|
||||
func markDirty() {
|
||||
// Every content edit funnels through here, so EditSeq is the universal
|
||||
// "content changed" token (the WrapIndex layout-correlation gate uses it).
|
||||
TheState.Editor.EditSeq++
|
||||
// TheLogic is nil in pure unit tests (no logic goroutine). Editing the
|
||||
// buffer is still valid there; only the autosave side-effect is skipped.
|
||||
if TheLogic != nil {
|
||||
|
|
@ -1602,14 +1626,32 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
|||
// Stored for the input handlers (tap / long press / drags), which convert
|
||||
// app-local Dp points to text-local coordinates through it.
|
||||
TheState.EditorRegion = editorRegion
|
||||
// WindowStartLine is the logical line the visible window starts at; it is
|
||||
// shipped with the shaped layout (Frame) so the layout-correlation pass in
|
||||
// the logic goroutine knows which lines the layout describes. -1 outside
|
||||
// the editor window (browser page, too-large notice).
|
||||
TheState.WindowStartLine = -1
|
||||
// Compute max scroll offset from the last line baseline reported by the renderer.
|
||||
// lastLineY is the shaper's Y value for the last line's baseline.
|
||||
// Add bottom padding (half line height) so last line isn't flush with the bottom bar.
|
||||
var maxScroll ui.Dp
|
||||
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
||||
if li := cb.LineIndex; li != nil {
|
||||
totalLines := li.LineCount()
|
||||
maxScroll = ui.Dp(totalLines)*EffectiveLineHeight() - editorRegion.H + EffectiveLineHeight()/2
|
||||
// Document height is measured in VISUAL lines: a wrapped logical
|
||||
// line occupies several of them, so the max scroll must use the
|
||||
// WrapIndex total, not the logical line count. Before shaping,
|
||||
// every line estimates to one visual line, so MaxScroll starts at
|
||||
// the no-wrap value and grows as shaped counts arrive — it only
|
||||
// ever grows during a warm-up, never jumps under the viewport.
|
||||
total := int64(li.LineCount())
|
||||
if w := cb.WrapIndex; w != nil {
|
||||
total = int64(w.TotalVisuals())
|
||||
}
|
||||
lineHeight := EffectiveLineHeight()
|
||||
if lh := TheState.Editor.GlyphLayout.LineHeight; lh > 0 {
|
||||
lineHeight = lh
|
||||
}
|
||||
maxScroll = ui.Dp(float64(total)*float64(lineHeight)) - editorRegion.H + lineHeight/2
|
||||
} else {
|
||||
// If index is not yet built, allow scrolling beyond estimate.
|
||||
// Use a large scroll limit to ensure user can scroll through the file
|
||||
|
|
@ -1657,7 +1699,11 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
|||
if lh := TheState.Editor.GlyphLayout.LineHeight; lh > 0 {
|
||||
lineHeight = lh
|
||||
}
|
||||
start, end, _ = cb.VisibleByteRange(TheState.ScrollOffset, TheState.ByteOffset, viewportHeight, lineHeight, TheState.WordWrap, TheState.Editor.GlyphLayout, nil)
|
||||
start, end, winLine := cb.VisibleByteRange(TheState.ScrollOffset, TheState.ByteOffset, viewportHeight, lineHeight, TheState.WordWrap, TheState.Editor.GlyphLayout, nil)
|
||||
// Ship with the frame: the shaped layout's wrap counts belong to THIS
|
||||
// window's lines (a scroll may move the window before the layout
|
||||
// arrives, but an edit invalidates it — see EditorState.EditSeq).
|
||||
TheState.WindowStartLine = winLine
|
||||
|
||||
// Proactively load chunks needed for the current viewport
|
||||
startChunk := start / cb.ChunkSize()
|
||||
|
|
@ -1680,10 +1726,12 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
|||
}
|
||||
|
||||
// Adjust scroll offset to be relative to visibleContent origin
|
||||
// Sub-line shift for the renderer: the SAME decomposition the window
|
||||
// start used (VisibleByteRange above), so the drawn geometry and the
|
||||
// window's content lines agree for every scroll offset.
|
||||
_, subLine := scrollDecompose(TheState.ScrollOffset, lineHeight)
|
||||
// Sub-line shift for the renderer: the SAME visual-line decomposition
|
||||
// the window start used (VisibleByteRange above), so the drawn
|
||||
// geometry and the window's content lines agree for every scroll
|
||||
// offset and wrap state. scrollVisualDecompose re-derives it from the
|
||||
// same inputs; the two agree by construction (see its doc).
|
||||
_, subLine := scrollVisualDecompose()
|
||||
visibleScrollOffset = ui.Dp(subLine)
|
||||
|
||||
// Map scroll offset to a chunk index
|
||||
|
|
@ -1691,7 +1739,7 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
|||
var scrollByteOffset int
|
||||
if li := cb.LineIndex; li != nil {
|
||||
// Precise: same decomposition as the window start above.
|
||||
prefetchLine, _ := scrollDecompose(TheState.ScrollOffset, lineHeight)
|
||||
prefetchLine := winLine
|
||||
if prefetchLine < li.LineCount() {
|
||||
scrollByteOffset = li.ByteOffset(prefetchLine)
|
||||
} else {
|
||||
|
|
@ -1814,6 +1862,105 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
|||
// sub-pixel-wide band of scroll offsets, shifting the rendered window — and
|
||||
// with it every tapped content line — by one. The float64 decomposition with
|
||||
// the r<0 / r>=lh corrections keeps k and r consistent for every s >= 0.
|
||||
// scrollVisualDecompose maps the current scroll offset to (windowStartLine,
|
||||
// drawOffset) in visual-line space — the exact inverse of what the renderer
|
||||
// does when it draws the window at reg.Y - drawOffset.
|
||||
//
|
||||
// v0 is the visual line at the viewport top; k is the logical line that
|
||||
// contains it (via the WrapIndex); drawOffset = ScrollOffset - V(k)*lh, the
|
||||
// amount the renderer shifts the window up. With word wrap, drawOffset may
|
||||
// exceed one lineHeight: the viewport top then sits inside a wrapped line's
|
||||
// continuation, and the wrapped lines that fall above the viewport are
|
||||
// clipped away. Without a WrapIndex (index not built yet) or with an
|
||||
// all-ones index (nothing shaped/wrapped yet), the result is exactly the
|
||||
// legacy mapping (k = v0, drawOffset = ScrollOffset mod lh).
|
||||
//
|
||||
// Must be called on the logic goroutine (reads ScrollOffset, the last
|
||||
// GlyphLayout and the buffer's WrapIndex; writes nothing).
|
||||
func scrollVisualDecompose() (k int, r float64) {
|
||||
lh := EffectiveLineHeight()
|
||||
if gl := TheState.Editor.GlyphLayout; gl.LineHeight > 0 {
|
||||
lh = gl.LineHeight
|
||||
}
|
||||
v0, r0 := scrollDecompose(TheState.ScrollOffset, lh)
|
||||
w := (*WrapIndex)(nil)
|
||||
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
||||
w = cb.WrapIndex
|
||||
}
|
||||
if w == nil {
|
||||
return v0, r0
|
||||
}
|
||||
k = w.LineForVisual(int32(v0))
|
||||
vk := w.VisualsBefore(k)
|
||||
// r = s - V(k)*lh = (v0 - V(k))*lh + r0: the v0 - V(k) term counts the
|
||||
// wrapped continuation lines above the window start.
|
||||
return k, float64(v0-int(vk))*float64(lh) + r0
|
||||
}
|
||||
|
||||
// applyWrapCounts corrects the WrapIndex counts for the lines described by
|
||||
// a shaped layout. The layout's VisualLineStarts (window-relative byte
|
||||
// offsets, one per visual line start) are grouped by the logical line whose
|
||||
// byte range contains each start; a wrapped logical line then carries its
|
||||
// true visual-line count.
|
||||
//
|
||||
// The layout describes the window it was shaped for (fb.WindowStartLine /
|
||||
// fb.WindowStartByte), which may differ from the CURRENT window (a scroll
|
||||
// can move the window between shaping and delivery) — that is fine: the
|
||||
// counts belong to real lines that are still valid as long as no edit has
|
||||
// shifted them, which the caller checks via EditSeq before calling here.
|
||||
//
|
||||
// A logical line whose range contains no visual line start (an empty line,
|
||||
// or a line the shaper produced no starts for) keeps its current count.
|
||||
//
|
||||
// Must be called on the logic goroutine.
|
||||
func (s *State) applyWrapCounts(fb ui.LayoutFeedback) {
|
||||
cb := s.Editor.ChunkedBuffer
|
||||
if cb == nil || cb.WrapIndex == nil || fb.WindowStartLine < 0 {
|
||||
return
|
||||
}
|
||||
// fb.WindowText is the exact text this layout was shaped for (carried in
|
||||
// the frame). Grouping over the CURRENT window (IMEWindowText) instead
|
||||
// would attribute counts to the wrong lines whenever a scroll moved the
|
||||
// window between shaping and delivery.
|
||||
winText := fb.WindowText
|
||||
if winText == "" {
|
||||
return
|
||||
}
|
||||
starts := fb.GlyphLayout.VisualLineStarts
|
||||
if len(starts) == 0 {
|
||||
return
|
||||
}
|
||||
// Walk the window's logical lines (delimited by '\n'); attribute each
|
||||
// visual line start to the logical line containing it. Both the starts
|
||||
// and the line ranges are ascending, so a single forward pointer works.
|
||||
si := 0
|
||||
lineStart := 0
|
||||
for li := 0; ; li++ {
|
||||
idx := strings.IndexByte(winText[lineStart:], '\n')
|
||||
lineEnd := len(winText)
|
||||
last := false
|
||||
if idx >= 0 {
|
||||
lineEnd = lineStart + idx + 1
|
||||
} else {
|
||||
last = true
|
||||
}
|
||||
count := 0
|
||||
for si < len(starts) && int(starts[si]) < lineEnd {
|
||||
if int(starts[si]) >= lineStart {
|
||||
count++
|
||||
}
|
||||
si++
|
||||
}
|
||||
if count > 0 {
|
||||
cb.WrapIndex.Set(fb.WindowStartLine+li, int32(count))
|
||||
}
|
||||
if last {
|
||||
break
|
||||
}
|
||||
lineStart = lineEnd
|
||||
}
|
||||
}
|
||||
|
||||
func scrollDecompose(s ui.Dp, lh ui.Dp) (k int, r float64) {
|
||||
sf, lf := float64(s), float64(lh)
|
||||
if lf <= 0 {
|
||||
|
|
@ -1835,16 +1982,17 @@ func scrollDecompose(s ui.Dp, lh ui.Dp) (k int, r float64) {
|
|||
return k, r
|
||||
}
|
||||
|
||||
func tapLocalY(ptY, regionTopY ui.Dp, scrollOffset ui.Dp) float64 {
|
||||
// Same line height the renderer used to shape the window (font-scale
|
||||
// aware), and the same floor decomposition as the window start, so the
|
||||
// tap maps to the drawn geometry for every scroll offset and font
|
||||
// setting.
|
||||
lh := EffectiveLineHeight()
|
||||
if gl := TheState.Editor.GlyphLayout; gl.LineHeight > 0 {
|
||||
lh = gl.LineHeight
|
||||
}
|
||||
_, r := scrollDecompose(scrollOffset, lh)
|
||||
// tapLocalY returns the text-local Y (Dp, window-relative: 0 = top of the
|
||||
// visible window, matching GlyphLayout.Y) of an app-local tap at ptY.
|
||||
//
|
||||
// The renderer draws the window's top at reg.Y - r' (r' = the sub-line draw
|
||||
// offset from scrollVisualDecompose), so a point at app-Y ptY sits at
|
||||
// window-Y (ptY - regionTop) + r'. r' is the same value the renderer was
|
||||
// given, so the tap maps to the drawn geometry for every scroll offset,
|
||||
// font setting and wrap state — including the case where the viewport top
|
||||
// sits inside a wrapped line's continuation (r' > one line).
|
||||
func tapLocalY(ptY, regionTopY ui.Dp) float64 {
|
||||
_, r := scrollVisualDecompose()
|
||||
return float64(ptY-regionTopY) + r
|
||||
}
|
||||
|
||||
|
|
|
|||
218
internal/editor/wrap_apply_test.go
Normal file
218
internal/editor/wrap_apply_test.go
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
package editor
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pad/internal/io/pool/types"
|
||||
"pad/internal/ui"
|
||||
)
|
||||
|
||||
// TestApplyWrapCounts_Grouping verifies that a shaped window's
|
||||
// VisualLineStarts (window-relative byte offsets, one per visual line,
|
||||
// including the initial 0) are attributed to the correct logical lines:
|
||||
// a wrapped logical line gets its true visual-line count, and lines the
|
||||
// layout does not cover keep their previous counts (over-stale is safe,
|
||||
// under-attributing is not).
|
||||
func TestApplyWrapCounts_Grouping(t *testing.T) {
|
||||
// Whole-file buffer; the shaped window covers lines 5..8.
|
||||
var lines []string
|
||||
for i := 0; i < 12; i++ {
|
||||
lines = append(lines, "line-content-"+strings.Repeat("x", i%7))
|
||||
}
|
||||
// The windowed lines get distinctive shapes.
|
||||
lines[5] = "A very long line that wraps three times"
|
||||
lines[6] = "short"
|
||||
lines[7] = "" // empty line
|
||||
lines[8] = "another line that wraps twice"
|
||||
content := strings.Join(lines, "\n") + "\n"
|
||||
|
||||
cb := newTestBufferForWrapWholeFile(content)
|
||||
|
||||
// Distinct seed counts so any (un)set line is distinguishable.
|
||||
for i := 0; i < cb.WrapIndex.Len(); i++ {
|
||||
cb.WrapIndex.Set(i, int32(9))
|
||||
}
|
||||
|
||||
// The shaped window covers lines 5..8 (their content plus terminators).
|
||||
winStart := byteOffsetOfLine(content, 5)
|
||||
windowText := strings.Join(lines[5:9], "\n") + "\n"
|
||||
off := map[string]int{}
|
||||
crs := 0
|
||||
for _, l := range lines[5:9] {
|
||||
off[l] = crs
|
||||
crs += len(l) + 1
|
||||
}
|
||||
// Fabricated visual line starts (window-relative), as the shaper would
|
||||
// emit them: 0 first, then one per wrapped continuation, then one per
|
||||
// following logical line start.
|
||||
starts := []int{
|
||||
0, // line 5 visual 1
|
||||
20, // line 5 wraps
|
||||
35, // line 5 wraps again
|
||||
off["short"], // line 6
|
||||
off[""], // line 7 (empty)
|
||||
off["another line that wraps twice"], // line 8
|
||||
off["another line that wraps twice"] + 19, // line 8 wraps
|
||||
}
|
||||
|
||||
TheState = NewState()
|
||||
TheState.Editor.ChunkedBuffer = cb
|
||||
TheState.Editor.IMEWindowText = windowText
|
||||
TheState.Editor.IMEWindowStartByte = winStart
|
||||
TheState.Editor.EditSeq = 42
|
||||
|
||||
fb := ui.LayoutFeedback{
|
||||
GlyphLayout: ui.GlyphLayout{VisualLineStarts: starts, LineHeight: 20},
|
||||
WindowText: windowText,
|
||||
WindowStartByte: winStart,
|
||||
WindowStartLine: 5,
|
||||
EditSeq: 42,
|
||||
}
|
||||
TheState.applyWrapCounts(fb)
|
||||
|
||||
want := map[int]int32{5: 3, 6: 1, 7: 1, 8: 2}
|
||||
for line, wc := range want {
|
||||
if got := cb.WrapIndex.Get(line); got != wc {
|
||||
t.Errorf("line %d count = %d, want %d", line, got, wc)
|
||||
}
|
||||
}
|
||||
// Untouched lines keep their seed count.
|
||||
for _, line := range []int{0, 1, 2, 3, 4, 9, 10, 11} {
|
||||
if got := cb.WrapIndex.Get(line); got != 9 {
|
||||
t.Errorf("line %d count = %d, want untouched 9", line, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyWrapCounts_TrailingNewline covers a window whose last line ends
|
||||
// with '\n' at the window end: the shaper may emit a visual-line start at
|
||||
// exactly len(winText) for the (empty) trailing line; it must not be
|
||||
// attributed to the last real line.
|
||||
func TestApplyWrapCounts_TrailingNewline(t *testing.T) {
|
||||
content := "short\nlong line that wraps once here\nlast line ends with newline\n"
|
||||
cb := newTestBufferForWrapWholeFile(content)
|
||||
for i := 0; i < cb.WrapIndex.Len(); i++ {
|
||||
cb.WrapIndex.Set(i, 7)
|
||||
}
|
||||
TheState = NewState()
|
||||
TheState.Editor.ChunkedBuffer = cb
|
||||
TheState.Editor.IMEWindowText = content
|
||||
TheState.Editor.IMEWindowStartByte = 0
|
||||
TheState.Editor.EditSeq = 1
|
||||
|
||||
// Line 0: "short" (1); line 1: wraps (2); line 2: "last line..." (1);
|
||||
// plus a trailing-empty-line start at len(content).
|
||||
starts := []int{0, 6, 6 + 18, len("short\nlong line that wraps once here\n"), len(content)}
|
||||
fb := ui.LayoutFeedback{
|
||||
GlyphLayout: ui.GlyphLayout{VisualLineStarts: starts},
|
||||
WindowText: content,
|
||||
WindowStartByte: 0,
|
||||
WindowStartLine: 0,
|
||||
EditSeq: 1,
|
||||
}
|
||||
TheState.applyWrapCounts(fb)
|
||||
|
||||
if got := cb.WrapIndex.Get(0); got != 1 {
|
||||
t.Errorf("line 0 = %d, want 1", got)
|
||||
}
|
||||
if got := cb.WrapIndex.Get(1); got != 2 {
|
||||
t.Errorf("line 1 = %d, want 2", got)
|
||||
}
|
||||
// Line 2 keeps the seed: the entry at len(content) belongs to the empty
|
||||
// trailing line, which is already at the estimate.
|
||||
if got := cb.WrapIndex.Get(2); got != 1 && got != 7 {
|
||||
t.Errorf("line 2 = %d, want 1 (or untouched 7)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyWrapCounts_Guards pins the early-outs: no buffer, no wrap index,
|
||||
// negative window start, empty window text, empty starts, and a window start
|
||||
// byte past the window text.
|
||||
func TestApplyWrapCounts_Guards(t *testing.T) {
|
||||
content := "abc\ndef\n"
|
||||
cb := newTestBufferForWrapWholeFile(content)
|
||||
cb.WrapIndex.Set(0, 5)
|
||||
cb.WrapIndex.Set(1, 5)
|
||||
cb.WrapIndex.Set(2, 5)
|
||||
|
||||
base := func() *ui.LayoutFeedback {
|
||||
return &ui.LayoutFeedback{
|
||||
GlyphLayout: ui.GlyphLayout{VisualLineStarts: []int{0, 4}},
|
||||
WindowText: content,
|
||||
WindowStartByte: 0,
|
||||
WindowStartLine: 0,
|
||||
EditSeq: 0,
|
||||
}
|
||||
}
|
||||
run := func(mut func(*State, *ui.LayoutFeedback)) {
|
||||
TheState = NewState()
|
||||
cbc := newTestBufferForWrapWholeFile(content)
|
||||
cbc.WrapIndex.Set(0, 5)
|
||||
cbc.WrapIndex.Set(1, 5)
|
||||
cbc.WrapIndex.Set(2, 5)
|
||||
TheState.Editor.ChunkedBuffer = cbc
|
||||
TheState.Editor.IMEWindowText = content
|
||||
TheState.Editor.IMEWindowStartByte = 0
|
||||
fb := base()
|
||||
wi := cbc.WrapIndex // capture before mut (some cases nil it)
|
||||
mut(TheState, fb)
|
||||
TheState.applyWrapCounts(*fb)
|
||||
for i := 0; i < 3; i++ {
|
||||
if got := wi.Get(i); got != 5 {
|
||||
t.Errorf("guard case: line %d = %d, want untouched 5", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
run(func(s *State, fb *ui.LayoutFeedback) { s.Editor.ChunkedBuffer = nil })
|
||||
run(func(s *State, fb *ui.LayoutFeedback) { s.Editor.ChunkedBuffer.WrapIndex = nil })
|
||||
run(func(s *State, fb *ui.LayoutFeedback) { fb.WindowStartLine = -1 })
|
||||
run(func(s *State, fb *ui.LayoutFeedback) { fb.WindowText = "" })
|
||||
run(func(s *State, fb *ui.LayoutFeedback) { fb.GlyphLayout.VisualLineStarts = nil })
|
||||
_ = cb
|
||||
}
|
||||
|
||||
// newTestBufferForWrapWholeFile builds a chunked buffer + LineIndex +
|
||||
// all-ones WrapIndex over the given content (the LineIndex convention: a
|
||||
// trailing "\n" opens an empty trailing line).
|
||||
func newTestBufferForWrapWholeFile(content string) *ChunkedBuffer {
|
||||
cb := NewChunkedBuffer("/wrap-group.txt", DefaultChunkSize, nil, "")
|
||||
cb.SetContent([]byte(content))
|
||||
offsets := []int32{0}
|
||||
for i := 0; i < len(content); i++ {
|
||||
if content[i] == '\n' {
|
||||
offsets = append(offsets, int32(i+1))
|
||||
}
|
||||
}
|
||||
cb.LineIndex = types.NewLineIndex(offsets, 0, int64(len(content)))
|
||||
cb.WrapIndex = NewWrapIndex(len(offsets))
|
||||
return cb
|
||||
}
|
||||
|
||||
// byteOffsetOfLine returns the byte offset of the start of 0-based line i
|
||||
// under the LineIndex convention.
|
||||
func byteOffsetOfLine(content string, i int) int {
|
||||
o := 0
|
||||
for line := 0; line < i; line++ {
|
||||
nx := strings.IndexByte(content[o:], '\n')
|
||||
if nx < 0 {
|
||||
return len(content)
|
||||
}
|
||||
o += nx + 1
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// lineSpan returns the total byte length of the first n lines (including
|
||||
// their terminators) of content.
|
||||
func lineSpan(content string, n int) int {
|
||||
o := 0
|
||||
for line := 0; line < n; line++ {
|
||||
nx := strings.IndexByte(content[o:], '\n')
|
||||
if nx < 0 {
|
||||
return len(content) - o
|
||||
}
|
||||
o += nx + 1
|
||||
}
|
||||
return o
|
||||
}
|
||||
140
internal/editor/wrap_bookkeeping_test.go
Normal file
140
internal/editor/wrap_bookkeeping_test.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
package editor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pad/internal/io/pool/types"
|
||||
)
|
||||
|
||||
// TestWrapIndex_EditBookkeeping verifies the WrapIndex maintenance inside
|
||||
// UpdateLineIndexAfterInsert/Delete against an independent oracle built from
|
||||
// a shadow copy of the file content.
|
||||
//
|
||||
// The oracle works on the shadow's lines:
|
||||
// - after each edit, the surviving lines are identified by maximal
|
||||
// prefix/suffix content alignment (a single contiguous edit has a unique
|
||||
// changed block);
|
||||
// - every line inside the changed block must have been reset to the
|
||||
// estimate (1) — missing that stamp leaves a stale wrap count, which is
|
||||
// exactly the scroll-jump bug this whole mechanism exists to prevent;
|
||||
// - surviving lines must keep their pre-edit count (or be conservatively
|
||||
// re-stamped to 1, which the next shaping pass corrects).
|
||||
//
|
||||
// Structural invariant checked every op: WrapIndex.Len() ==
|
||||
// LineIndex.LineCount() (the two indexes must track the same line set).
|
||||
func TestWrapIndex_EditBookkeeping(t *testing.T) {
|
||||
// Distinct-ish line contents so content alignment is reliable.
|
||||
words := []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel"}
|
||||
var named []string
|
||||
for i := 0; i < 40; i++ {
|
||||
named = append(named, fmt.Sprintf("%s-%d-%s", words[i%len(words)], i, words[(i*3)%len(words)]))
|
||||
}
|
||||
content := strings.Join(named, "\n") + "\n"
|
||||
// The LineIndex convention: a trailing "\n" opens an empty trailing line
|
||||
// ("a\nb\n" has three lines: "a", "b", ""). strings.Split matches it.
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
cb := NewChunkedBuffer("/bookkeeping.txt", DefaultChunkSize, nil, "")
|
||||
cb.SetContent([]byte(content))
|
||||
offsets := []int32{0}
|
||||
for i := 0; i < len(content); i++ {
|
||||
if content[i] == '\n' {
|
||||
offsets = append(offsets, int32(i+1))
|
||||
}
|
||||
}
|
||||
cb.LineIndex = types.NewLineIndex(offsets, 0, int64(len(content)))
|
||||
w := NewWrapIndex(len(lines))
|
||||
// Seed with a non-trivial pattern so a lost/shifted count is visible.
|
||||
for i := range lines {
|
||||
w.Set(i, int32(1+(i*7)%5))
|
||||
}
|
||||
cb.WrapIndex = w
|
||||
|
||||
rng := rand.New(rand.NewSource(99))
|
||||
texts := []string{"x", "xy", "x\ny", "x\ny\nz", "\n", "a\nb", "no-newline-here"}
|
||||
|
||||
for op := 0; op < 400; op++ {
|
||||
// Snapshot pre-edit state.
|
||||
oldCounts := make([]int32, w.Len())
|
||||
for i := range oldCounts {
|
||||
oldCounts[i] = w.Get(i)
|
||||
}
|
||||
oldLines := splitLines(content)
|
||||
nBefore := w.Len()
|
||||
|
||||
var newContent string
|
||||
if op%2 == 0 {
|
||||
// Insert
|
||||
pos := rng.Intn(len(content) + 1)
|
||||
text := texts[rng.Intn(len(texts))]
|
||||
content = content[:pos] + text + content[pos:]
|
||||
cb.Insert(pos, text)
|
||||
cb.UpdateLineIndexAfterInsert(pos, text)
|
||||
newContent = content
|
||||
} else {
|
||||
// Delete
|
||||
a := rng.Intn(len(content))
|
||||
b := a + 1 + rng.Intn(min(8, len(content)-a))
|
||||
content = content[:a] + content[b:]
|
||||
cb.Delete(a, b-a)
|
||||
cb.UpdateLineIndexAfterDelete(a, b)
|
||||
newContent = content
|
||||
}
|
||||
|
||||
// Structural: the wrap index must track the line index.
|
||||
if w.Len() != cb.LineIndex.LineCount() {
|
||||
t.Fatalf("op %d: WrapIndex.Len() = %d, LineIndex.LineCount() = %d", op, w.Len(), cb.LineIndex.LineCount())
|
||||
}
|
||||
newLines := splitLines(newContent)
|
||||
if w.Len() != len(newLines) {
|
||||
t.Fatalf("op %d: WrapIndex.Len() = %d, shadow lines = %d", op, w.Len(), len(newLines))
|
||||
}
|
||||
|
||||
// Oracle: maximal prefix/suffix alignment of old vs new lines.
|
||||
j := 0
|
||||
for j < len(oldLines) && j < len(newLines) && oldLines[j] == newLines[j] {
|
||||
j++
|
||||
}
|
||||
d := len(newLines) - len(oldLines)
|
||||
tail := 0
|
||||
for tail < len(newLines)-j && j+tail < len(newLines) && newLines[len(newLines)-1-tail] == oldLines[len(oldLines)-1-tail] {
|
||||
tail++
|
||||
}
|
||||
// Survivors: new[0:j] == old[0:j]; new[len-tail:] == old[len_old-tail:].
|
||||
// Changed block: new[j : len(new)-tail].
|
||||
for i := 0; i < len(newLines); i++ {
|
||||
var want int32
|
||||
var survivorOld int // -1 = changed block
|
||||
if i < j {
|
||||
survivorOld = i
|
||||
} else if i >= len(newLines)-tail {
|
||||
survivorOld = i - d
|
||||
} else {
|
||||
survivorOld = -1
|
||||
}
|
||||
got := w.Get(i)
|
||||
if survivorOld < 0 {
|
||||
// Changed line: must have been reset to the estimate.
|
||||
if got != 1 {
|
||||
t.Fatalf("op %d: line %d changed (content %q) but count = %d, want 1 (stale count would cause a scroll jump)", op, i, newLines[i], got)
|
||||
}
|
||||
} else {
|
||||
want = oldCounts[survivorOld]
|
||||
if got != want && got != 1 {
|
||||
t.Fatalf("op %d: line %d is a survivor (content %q) but count = %d, want %d (or 1)", op, i, newLines[i], got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = nBefore
|
||||
}
|
||||
}
|
||||
|
||||
// splitLines splits content into its logical lines using the LineIndex
|
||||
// convention: each '\n' opens a new line, so a trailing '\n' leaves an empty
|
||||
// trailing line ("a\nb\n" -> ["a","b",""] and "" -> [""]).
|
||||
func splitLines(content string) []string {
|
||||
return strings.Split(content, "\n")
|
||||
}
|
||||
199
internal/editor/wrap_index.go
Normal file
199
internal/editor/wrap_index.go
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
package editor
|
||||
|
||||
// WrapIndex tracks, for each logical line, how many visual (wrapped) lines
|
||||
// it occupies when the editor renders with word wrap enabled.
|
||||
//
|
||||
// Why it exists: every scroll-to-content mapping in the editor runs in
|
||||
// visual-line space. A logical line k that wraps into W(k) visual lines has
|
||||
// document height V(k)*lineHeight where V(k) = sum of W over lines [0,k).
|
||||
// The scroll offset, the window start line, the sub-line draw offset, the
|
||||
// max scroll and tap positioning all decompose against V, so scrolling past
|
||||
// a wrapped line advances the viewport by the wrapped line's full height
|
||||
// instead of jumping over its wrapped remainder.
|
||||
//
|
||||
// Counts start as estimates of 1 (one visual line per logical line) until
|
||||
// the renderer shapes the line; the shaped window's glyph layout corrects
|
||||
// the counts for the lines it covered, every frame (applyWrapCounts). An
|
||||
// all-ones index is exactly the legacy no-wrap mapping, so behavior before
|
||||
// the first shaping pass — and with word wrap off — is unchanged.
|
||||
//
|
||||
// A Fenwick tree answers prefix sums (VisualsBefore, TotalVisuals) and
|
||||
// per-line corrections in O(log n). Line insertions and deletions rebuild
|
||||
// the structure in O(n), the same cost class as LineIndex maintenance.
|
||||
//
|
||||
// int32 is safe for the values stored: the max total visual lines is bounded
|
||||
// by the file size (every visual line holds at least one byte; the editable
|
||||
// cap is 50 MB), and per-line counts are bounded by the same amount.
|
||||
//
|
||||
// Memory: two int32 arrays, i.e. 8 bytes per logical line on top of the
|
||||
// existing LineIndex (4 bytes/line). Realistic note files are negligible;
|
||||
// a 50 MB file of one-character lines (~50M lines) would cost ~400 MB here
|
||||
// and ~200 MB in LineIndex already.
|
||||
type WrapIndex struct {
|
||||
counts []int32 // visual lines per logical line (1 = estimate or single line)
|
||||
tree []int32 // Fenwick tree over counts (1-indexed, len = n+1)
|
||||
}
|
||||
|
||||
// NewWrapIndex returns an index for nLines logical lines, all estimated at
|
||||
// one visual line.
|
||||
func NewWrapIndex(nLines int) *WrapIndex {
|
||||
if nLines < 0 {
|
||||
nLines = 0
|
||||
}
|
||||
w := &WrapIndex{
|
||||
counts: make([]int32, nLines),
|
||||
tree: make([]int32, nLines+1),
|
||||
}
|
||||
for i := range w.counts {
|
||||
w.counts[i] = 1
|
||||
}
|
||||
w.rebuild()
|
||||
return w
|
||||
}
|
||||
|
||||
// rebuild recomputes the Fenwick tree from counts (linear build).
|
||||
func (w *WrapIndex) rebuild() {
|
||||
n := len(w.counts)
|
||||
copy(w.tree[1:n+1], w.counts)
|
||||
for i := 1; i <= n; i++ {
|
||||
j := i + (i & -i)
|
||||
if j <= n {
|
||||
w.tree[j] += w.tree[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Len is the number of logical lines tracked.
|
||||
func (w *WrapIndex) Len() int { return len(w.counts) }
|
||||
|
||||
// Get returns the current visual-line count for line i.
|
||||
func (w *WrapIndex) Get(i int) int32 { return w.counts[i] }
|
||||
|
||||
// Set updates the visual-line count for line i. No-op if unchanged, so the
|
||||
// per-frame correction pass only touches lines whose wrap actually changed.
|
||||
func (w *WrapIndex) Set(i int, c int32) {
|
||||
if i < 0 || i >= len(w.counts) || c <= 0 {
|
||||
return
|
||||
}
|
||||
if w.counts[i] == c {
|
||||
return
|
||||
}
|
||||
delta := c - w.counts[i]
|
||||
w.counts[i] = c
|
||||
for j := i + 1; j < len(w.tree); j += j & -j {
|
||||
w.tree[j] += delta
|
||||
}
|
||||
}
|
||||
|
||||
// SetRange updates consecutive lines' counts (the shaped-window correction
|
||||
// pass). Bounds are clamped.
|
||||
func (w *WrapIndex) SetRange(start int, counts []int32) {
|
||||
for i, c := range counts {
|
||||
w.Set(start+i, c)
|
||||
}
|
||||
}
|
||||
|
||||
// VisualsBefore returns V(k): the total number of visual lines occupied by
|
||||
// logical lines [0,k). Out-of-range k is clamped.
|
||||
func (w *WrapIndex) VisualsBefore(k int) int32 {
|
||||
if k < 0 {
|
||||
return 0
|
||||
}
|
||||
if k > len(w.counts) {
|
||||
k = len(w.counts)
|
||||
}
|
||||
var s int32
|
||||
for i := k; i > 0; i -= i & -i {
|
||||
s += w.tree[i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// TotalVisuals returns the total number of visual lines in the document.
|
||||
func (w *WrapIndex) TotalVisuals() int32 {
|
||||
return w.VisualsBefore(len(w.counts))
|
||||
}
|
||||
|
||||
// LineForVisual returns the logical line that contains visual line v
|
||||
// (0-indexed): the smallest k with V(k+1) > v. Clamped to [0, n-1];
|
||||
// for an empty index it returns 0.
|
||||
//
|
||||
// Implemented as a binary search over VisualsBefore (O(log^2 n)); at the
|
||||
// file sizes this app supports (tens of thousands of lines) that is far
|
||||
// below a microsecond and keeps the Fenwick code simple.
|
||||
func (w *WrapIndex) LineForVisual(v int32) int {
|
||||
n := len(w.counts)
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
if v <= 0 {
|
||||
return 0
|
||||
}
|
||||
if v >= w.TotalVisuals() {
|
||||
return n - 1
|
||||
}
|
||||
// Invariant: V(k) is monotone non-decreasing in k, and V(k+1) > V(k)
|
||||
// (counts >= 1), so the search is well-defined.
|
||||
lo, hi := 0, n-1
|
||||
for lo < hi {
|
||||
mid := (lo + hi) / 2
|
||||
if w.VisualsBefore(mid+1) > v {
|
||||
hi = mid
|
||||
} else {
|
||||
lo = mid + 1
|
||||
}
|
||||
}
|
||||
return lo
|
||||
}
|
||||
|
||||
// InsertLines adds n lines (each estimated at one visual line) at position
|
||||
// pos. pos is clamped to [0, Len].
|
||||
func (w *WrapIndex) InsertLines(pos, n int) {
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
if pos < 0 {
|
||||
pos = 0
|
||||
}
|
||||
if pos > len(w.counts) {
|
||||
pos = len(w.counts)
|
||||
}
|
||||
newCounts := make([]int32, 0, len(w.counts)+n)
|
||||
newCounts = append(newCounts, w.counts[:pos]...)
|
||||
for i := 0; i < n; i++ {
|
||||
newCounts = append(newCounts, 1)
|
||||
}
|
||||
newCounts = append(newCounts, w.counts[pos:]...)
|
||||
w.counts = newCounts
|
||||
w.tree = make([]int32, len(w.counts)+1)
|
||||
w.rebuild()
|
||||
}
|
||||
|
||||
// DeleteLines removes n lines at position pos. Clamped to what exists.
|
||||
func (w *WrapIndex) DeleteLines(pos, n int) {
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
if pos < 0 {
|
||||
pos = 0
|
||||
}
|
||||
if pos >= len(w.counts) {
|
||||
return
|
||||
}
|
||||
if pos+n > len(w.counts) {
|
||||
n = len(w.counts) - pos
|
||||
}
|
||||
w.counts = append(w.counts[:pos], w.counts[pos+n:]...)
|
||||
w.tree = make([]int32, len(w.counts)+1)
|
||||
w.rebuild()
|
||||
}
|
||||
|
||||
// ResetAll sets every line back to the estimate of one visual line. Used
|
||||
// when the wrap width changes (window resize), which invalidates every
|
||||
// shaped count; the visible window is re-corrected on the next frame.
|
||||
func (w *WrapIndex) ResetAll() {
|
||||
for i := range w.counts {
|
||||
w.counts[i] = 1
|
||||
}
|
||||
w.rebuild()
|
||||
}
|
||||
161
internal/editor/wrap_index_test.go
Normal file
161
internal/editor/wrap_index_test.go
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
package editor
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWrapIndex_NaiveModel is a property test: a random sequence of
|
||||
// Set / InsertLines / DeleteLines operations on a WrapIndex must keep the
|
||||
// Fenwick structure exactly consistent with a naive []int32 model.
|
||||
func TestWrapIndex_NaiveModel(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(7))
|
||||
|
||||
w := NewWrapIndex(50)
|
||||
model := make([]int32, 50)
|
||||
for i := range model {
|
||||
model[i] = 1
|
||||
}
|
||||
|
||||
naivePrefix := func(m []int32, k int) int32 {
|
||||
var s int32
|
||||
for i := 0; i < k && i < len(m); i++ {
|
||||
s += m[i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
naiveLineForVisual := func(m []int32, v int32) int {
|
||||
if len(m) == 0 {
|
||||
return 0
|
||||
}
|
||||
if v <= 0 {
|
||||
return 0
|
||||
}
|
||||
var s int32
|
||||
for i, c := range m {
|
||||
s += c
|
||||
if s > v {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(m) - 1
|
||||
}
|
||||
|
||||
ops := 0
|
||||
for i := 0; i < 3000; i++ {
|
||||
switch rng.Intn(4) {
|
||||
case 0: // Set
|
||||
if len(model) == 0 {
|
||||
continue
|
||||
}
|
||||
li := rng.Intn(len(model))
|
||||
c := int32(1 + rng.Intn(8))
|
||||
w.Set(li, c)
|
||||
model[li] = c
|
||||
case 1: // InsertLines
|
||||
pos := rng.Intn(len(model) + 1)
|
||||
n := 1 + rng.Intn(5)
|
||||
w.InsertLines(pos, n)
|
||||
inserted := make([]int32, n)
|
||||
for j := range inserted {
|
||||
inserted[j] = 1
|
||||
}
|
||||
model = append(model[:pos], append(inserted, model[pos:]...)...)
|
||||
case 2: // DeleteLines
|
||||
if len(model) == 0 {
|
||||
continue
|
||||
}
|
||||
pos := rng.Intn(len(model))
|
||||
n := 1 + rng.Intn(min(5, len(model)-pos))
|
||||
w.DeleteLines(pos, n)
|
||||
model = append(model[:pos], model[pos+n:]...)
|
||||
case 3: // SetRange (the shaped-window correction shape)
|
||||
if len(model) == 0 {
|
||||
continue
|
||||
}
|
||||
pos := rng.Intn(len(model))
|
||||
n := 1 + rng.Intn(min(10, len(model)-pos))
|
||||
counts := make([]int32, n)
|
||||
for j := range counts {
|
||||
counts[j] = int32(1 + rng.Intn(6))
|
||||
model[pos+j] = counts[j]
|
||||
}
|
||||
w.SetRange(pos, counts)
|
||||
}
|
||||
ops++
|
||||
|
||||
// Full consistency check after every op.
|
||||
if w.Len() != len(model) {
|
||||
t.Fatalf("op %d (%d): Len = %d, model %d", i, ops, w.Len(), len(model))
|
||||
}
|
||||
for i := 0; i < w.Len(); i++ {
|
||||
if w.Get(i) != model[i] {
|
||||
t.Fatalf("op %d: Get(%d) = %d, model %d", i, i, w.Get(i), model[i])
|
||||
}
|
||||
}
|
||||
// Prefix sums (V(k)) for a sample of k values plus the ends.
|
||||
for _, k := range []int{0, 1, len(model) / 2, len(model) - 1, len(model), len(model) + 5} {
|
||||
if got, want := w.VisualsBefore(k), naivePrefix(model, k); got != want {
|
||||
t.Fatalf("op %d: VisualsBefore(%d) = %d, want %d", i, k, got, want)
|
||||
}
|
||||
}
|
||||
if got, want := w.TotalVisuals(), naivePrefix(model, len(model)); got != want {
|
||||
t.Fatalf("op %d: TotalVisuals = %d, want %d", i, got, want)
|
||||
}
|
||||
// LineForVisual for every visual line (bounded: total is small here).
|
||||
if total := w.TotalVisuals(); total < 5000 {
|
||||
for v := int32(0); v < total; v++ {
|
||||
if got, want := w.LineForVisual(v), naiveLineForVisual(model, v); got != want {
|
||||
t.Fatalf("op %d: LineForVisual(%d) = %d, want %d", i, v, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapIndex_AllOnesIsIdentity pins the legacy-mapping property: an
|
||||
// all-ones index (no wrap corrections applied) must map visual lines to
|
||||
// logical lines 1:1, so pre-shaping behavior is exactly the old code path.
|
||||
func TestWrapIndex_AllOnesIsIdentity(t *testing.T) {
|
||||
w := NewWrapIndex(1000)
|
||||
for v := int32(0); v < 1000; v++ {
|
||||
if got := w.LineForVisual(v); got != int(v) {
|
||||
t.Fatalf("LineForVisual(%d) = %d, want %d (all-ones must be identity)", v, got, v)
|
||||
}
|
||||
}
|
||||
for k := 0; k <= 1000; k++ {
|
||||
if got := w.VisualsBefore(k); got != int32(k) {
|
||||
t.Fatalf("VisualsBefore(%d) = %d, want %d", k, got, k)
|
||||
}
|
||||
}
|
||||
if got := w.TotalVisuals(); got != 1000 {
|
||||
t.Fatalf("TotalVisuals = %d, want 1000", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapIndex_LineForVisualWrapped checks the mapping against a hand-built
|
||||
// wrap pattern: lines wrap as W = [1,3,1,2,1,...] so V(0)=0, V(1)=1,
|
||||
// V(2)=4, V(3)=5, V(4)=7, V(5)=8.
|
||||
func TestWrapIndex_LineForVisualWrapped(t *testing.T) {
|
||||
w := NewWrapIndex(5)
|
||||
counts := []int32{1, 3, 1, 2, 1}
|
||||
w.SetRange(0, counts)
|
||||
|
||||
// Visual line v belongs to the smallest k with V(k+1) > v.
|
||||
want := map[int32]int{
|
||||
0: 0, 1: 1, 2: 1, 3: 1, // line 0: v0; line 1: v1..v3
|
||||
4: 2, // line 2: v4
|
||||
5: 3, 6: 3, // line 3: v5..v6
|
||||
7: 4, // line 4: v7
|
||||
8: 4, // clamped: v >= total(8) -> last line
|
||||
99: 4,
|
||||
}
|
||||
for v, k := range want {
|
||||
if got := w.LineForVisual(v); got != k {
|
||||
t.Errorf("LineForVisual(%d) = %d, want %d", v, got, k)
|
||||
}
|
||||
}
|
||||
if got := w.TotalVisuals(); got != 8 {
|
||||
t.Errorf("TotalVisuals = %d, want 8", got)
|
||||
}
|
||||
}
|
||||
212
internal/editor/wrap_mapping_test.go
Normal file
212
internal/editor/wrap_mapping_test.go
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
package editor
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"pad/internal/io/pool/types"
|
||||
"pad/internal/ui"
|
||||
)
|
||||
|
||||
// TestScrollVisualDecompose_ViewportIdentity is the regression test for the
|
||||
// word-wrap scroll jump: when the viewport top crosses the bottom of a
|
||||
// wrapped logical line, the view jumped past the wrapped remainder instead of
|
||||
// moving pixel-by-pixel with the finger.
|
||||
//
|
||||
// The invariant under test: the document visual offset at the viewport top is
|
||||
// ALWAYS exactly the scroll offset s. If k is the logical line at the viewport
|
||||
// top with sub-line shift r, the viewport top sits V(k)*lh + r into the
|
||||
// document (V(k) = visual lines before line k, each lh tall, plus r into the
|
||||
// next). Any mapping that does not satisfy V(k)*lh + r == s silently skips or
|
||||
// re-shows content — the jump.
|
||||
//
|
||||
// Before the fix, all mapping sites assumed V(k) == k (1 logical line = 1
|
||||
// visual line), so for any wrapped file the identity failed by exactly
|
||||
// (V(k)-k)*lh once the first wrapped line had been scrolled past.
|
||||
func TestScrollVisualDecompose_ViewportIdentity(t *testing.T) {
|
||||
const n = 500 // logical lines
|
||||
rng := rand.New(rand.NewSource(11))
|
||||
counts := make([]int32, n)
|
||||
for i := range counts {
|
||||
counts[i] = int32(1 + rng.Intn(4)) // each line wraps into 1..4 visual lines
|
||||
}
|
||||
|
||||
cb := newTestBufferForWrap(n, counts)
|
||||
|
||||
lh := float64(EffectiveLineHeight())
|
||||
w := cb.WrapIndex
|
||||
totalVisuals := float64(w.TotalVisuals())
|
||||
maxS := (totalVisuals - 1) * lh // stay inside the last visual line
|
||||
|
||||
check := func(s ui.Dp, label string) {
|
||||
TheState.ScrollOffset = s
|
||||
k, r := scrollVisualDecompose()
|
||||
|
||||
if k < 0 || k >= n {
|
||||
t.Fatalf("%s: k = %d out of range [0,%d)", label, k, n)
|
||||
}
|
||||
// r = s - V(k)*lh places the viewport top r below the TOP of line k;
|
||||
// for a wrapped line the top may be several visual lines above the
|
||||
// viewport, so r spans [0, count(k)*lh).
|
||||
if r < -1e-9 || r >= float64(w.Get(k))*lh+1e-9 {
|
||||
t.Fatalf("%s: r = %f outside [0, count(k)*lh = %f*lh)", label, r, float64(w.Get(k)))
|
||||
}
|
||||
// The viewport top must lie inside line k's visual span.
|
||||
vk := float64(w.VisualsBefore(k))
|
||||
if vk*lh > float64(s)+1e-9 {
|
||||
t.Fatalf("%s: s = %f is above line k's span start V(k)*lh = %f", label, float64(s), vk*lh)
|
||||
}
|
||||
if k+1 < n && float64(s) >= float64(w.VisualsBefore(k+1))*lh-1e-9 {
|
||||
t.Fatalf("%s: s = %f is at/past line %d's span start (k should be >= %d)", label, float64(s), k+1, k+1)
|
||||
}
|
||||
// The identity: the viewport top is exactly s into the document's
|
||||
// visual space.
|
||||
got := vk*lh + r
|
||||
if d := got - float64(s); d < -1e-9 || d > 1e-9 {
|
||||
t.Fatalf("%s: s = %f: V(k)*lh + r = %f, want s = %f (off by %f — a scroll jump)", label, float64(s), got, float64(s), d)
|
||||
}
|
||||
// k must be the logical line containing the viewport-top visual line.
|
||||
v0 := int32(float64(s) / lh)
|
||||
if want := w.LineForVisual(v0); k != want {
|
||||
t.Fatalf("%s: s = %f: k = %d, want LineForVisual(%d) = %d", label, float64(s), k, v0, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-line sweep in fine steps across the whole document.
|
||||
steps := 4000
|
||||
for i := 0; i <= steps; i++ {
|
||||
s := ui.Dp(maxS * float64(i) / float64(steps))
|
||||
check(s, "sweep")
|
||||
}
|
||||
// Random offsets (arbitrary sub-line positions).
|
||||
for i := 0; i < 2000; i++ {
|
||||
s := ui.Dp(rng.Float64() * maxS)
|
||||
check(s, "random")
|
||||
}
|
||||
}
|
||||
|
||||
// TestScrollVisualDecompose_LegacyIdentity pins the legacy behavior: with an
|
||||
// all-ones WrapIndex (no wrap corrections applied, i.e. pre-shaping) or no
|
||||
// WrapIndex at all, the mapping must be exactly the old 1:1 mapping, so
|
||||
// non-wrapped files and pre-shaping frames are unchanged.
|
||||
func TestScrollVisualDecompose_LegacyIdentity(t *testing.T) {
|
||||
const n = 100
|
||||
cb := newTestBufferForWrap(n, nil) // all ones
|
||||
cb.WrapIndex.ResetAll()
|
||||
|
||||
lh := float64(EffectiveLineHeight())
|
||||
maxS := float64(n)*lh - lh/2
|
||||
rng := rand.New(rand.NewSource(13))
|
||||
check := func(s ui.Dp, label string) {
|
||||
TheState.ScrollOffset = s
|
||||
k, r := scrollVisualDecompose()
|
||||
v0, r0 := scrollDecompose(s, ui.Dp(lh))
|
||||
if float64(k) != float64(v0) || r != r0 {
|
||||
t.Fatalf("%s: s = %f: (k,r) = (%d,%f), want legacy (v0,r0) = (%d,%f)", label, float64(s), k, r, v0, r0)
|
||||
}
|
||||
}
|
||||
steps := 500
|
||||
for i := 0; i <= steps; i++ {
|
||||
check(ui.Dp(maxS*float64(i)/float64(steps)), "sweep")
|
||||
}
|
||||
for i := 0; i < 500; i++ {
|
||||
check(ui.Dp(rng.Float64()*maxS), "random")
|
||||
}
|
||||
|
||||
// No WrapIndex at all (the String-Buffer fallback / pre-build state).
|
||||
cb.WrapIndex = nil
|
||||
for i := 0; i < 100; i++ {
|
||||
check(ui.Dp(rng.Float64()*maxS), "no-wi")
|
||||
}
|
||||
}
|
||||
|
||||
// TestScrollVisualDecompose_WrappedBoundarySweep walks the scroll offset
|
||||
// across every wrapped-line boundary and checks that the viewport top moves
|
||||
// pixel-by-pixel: the logical line at the top advances by exactly the number
|
||||
// of visual lines the previous line occupies, and the fixed-line position
|
||||
// identity T(i) = V(i)*lh - s holds across the boundary (no skip, no repeat).
|
||||
func TestScrollVisualDecompose_WrappedBoundarySweep(t *testing.T) {
|
||||
counts := []int32{1, 3, 1, 2, 4, 1}
|
||||
cb := newTestBufferForWrap(len(counts), counts)
|
||||
lh := float64(EffectiveLineHeight())
|
||||
w := cb.WrapIndex
|
||||
|
||||
eps := lh / 8
|
||||
var prevK int
|
||||
var prevS float64
|
||||
for v := int32(0); v < w.TotalVisuals(); v++ {
|
||||
for _, s := range []ui.Dp{ui.Dp(float64(v)*lh - eps/2), ui.Dp(float64(v) * lh), ui.Dp(float64(v)*lh + eps/2)} {
|
||||
if float64(s) < 0 {
|
||||
continue
|
||||
}
|
||||
TheState.ScrollOffset = s
|
||||
k, r := scrollVisualDecompose()
|
||||
if k < prevK {
|
||||
t.Fatalf("s = %f: k decreased (%d -> %d): content re-shown", float64(s), prevK, k)
|
||||
}
|
||||
// A fixed line moves exactly finger-speed across the boundary:
|
||||
// T(i) = V(i)*lh - s for the line that was at the top before.
|
||||
if i, ok := firstVisualToLogical(w, v-1); ok && k > i {
|
||||
Tbefore := float64(w.VisualsBefore(i))*lh - prevS
|
||||
Tafter := float64(w.VisualsBefore(i))*lh - float64(s)
|
||||
if d := (Tbefore - Tafter) - (float64(s) - prevS); d < -1e-9 || d > 1e-9 {
|
||||
t.Fatalf("s = %f: fixed line moved by %f for scroll delta %f (jump!)", float64(s), Tbefore-Tafter, float64(s)-prevS)
|
||||
}
|
||||
}
|
||||
if k == prevK && float64(s) > prevS {
|
||||
// Same top line: the sub-line shift must advance by the delta.
|
||||
if d := (r - (prevS - float64(w.VisualsBefore(k))*lh)) - (float64(s) - prevS); d < -1e-9 || d > 1e-9 {
|
||||
t.Fatalf("s = %f: r advanced by %f for delta %f", float64(s), d, float64(s)-prevS)
|
||||
}
|
||||
}
|
||||
_ = r
|
||||
prevK, prevS = k, float64(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// firstVisualToLogical maps visual line v to its logical line (mirrors
|
||||
// LineForVisual; the test wants a named helper for readability).
|
||||
func firstVisualToLogical(w *WrapIndex, v int32) (int, bool) {
|
||||
if v < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return w.LineForVisual(v), true
|
||||
}
|
||||
|
||||
// newTestBufferForWrap builds a minimal editor State backed by a chunked
|
||||
// buffer whose WrapIndex is seeded with the given per-line visual counts
|
||||
// (nil = all ones). It points TheState at it for the scroll mapping under
|
||||
// test. The buffer content is one short line per logical line; only the
|
||||
// WrapIndex matters to the mapping.
|
||||
func newTestBufferForWrap(n int, counts []int32) *ChunkedBuffer {
|
||||
var buf []byte
|
||||
for i := 0; i < n; i++ {
|
||||
buf = append(buf, 'x')
|
||||
if i < n-1 {
|
||||
buf = append(buf, '\n')
|
||||
}
|
||||
}
|
||||
cb := NewChunkedBuffer("/wrap-map.txt", DefaultChunkSize, nil, "")
|
||||
cb.SetContent(buf)
|
||||
offsets := []int32{0}
|
||||
for i := 0; i < len(buf); i++ {
|
||||
if buf[i] == '\n' {
|
||||
offsets = append(offsets, int32(i+1))
|
||||
}
|
||||
}
|
||||
cb.LineIndex = types.NewLineIndex(offsets, 0, int64(len(buf)))
|
||||
w := NewWrapIndex(len(offsets))
|
||||
if counts != nil {
|
||||
for i, c := range counts {
|
||||
w.Set(i, c)
|
||||
}
|
||||
}
|
||||
cb.WrapIndex = w
|
||||
|
||||
TheState = NewState()
|
||||
TheState.Editor.ChunkedBuffer = cb
|
||||
TheState.Editor.GlyphLayout = ui.GlyphLayout{} // LineHeight 0 -> EffectiveLineHeight
|
||||
TheState.ScrollOffset = 0
|
||||
return cb
|
||||
}
|
||||
|
|
@ -74,14 +74,33 @@ func FromDp(r Region, scale float32) RegionPx {
|
|||
// shaper baseline), and Advance[i] is the glyph's width in Dp.
|
||||
// LineHeight is the shaper's actual baseline-to-baseline line height in Dp,
|
||||
// derived from consecutive lines' Y values.
|
||||
// LayoutFeedback carries the renderer's per-frame glyph layout back to the
|
||||
// logic goroutine, together with the window the layout was shaped for.
|
||||
// A scroll may move the window between shaping and delivery, so the
|
||||
// correlation pass applies the layout's wrap counts to the lines THIS layout
|
||||
// describes: WindowText is the exact text that was shaped (grouping the
|
||||
// VisualLineStarts over the current window instead would attribute counts to
|
||||
// the wrong lines whenever the window moved), and WindowStartLine is the
|
||||
// logical line that text begins at. EditSeq correlates with the editor's
|
||||
// content-edit counter: a feedback whose EditSeq differs from the current
|
||||
// state was shaped before an edit and its counts must be dropped (an edit
|
||||
// shifts lines).
|
||||
type LayoutFeedback struct {
|
||||
GlyphLayout GlyphLayout
|
||||
WindowText string // the exact text this layout was shaped for
|
||||
WindowStartByte int // absolute byte offset of the window's first byte
|
||||
WindowStartLine int // logical line the window starts at (-1: none)
|
||||
EditSeq uint64 // editor content-edit counter at frame time
|
||||
}
|
||||
|
||||
type GlyphLayout struct {
|
||||
ByteOffsets []int // byte offset of each glyph in the buffer
|
||||
X []Dp // screen X (Dp) of each glyph, relative to text region origin
|
||||
Y []Dp // screen Y (Dp) baseline of each glyph (shaper value)
|
||||
Advance []Dp // advance width (Dp) of each glyph
|
||||
LineHeight Dp // shaper's actual baseline-to-baseline line height in Dp
|
||||
VisualLineStarts []int // byte offsets where each visual line starts (for word wrap)
|
||||
VisualLineIndex *types.VisualLineIndex // Optional: pre-computed visual line index for this layout
|
||||
ByteOffsets []int // byte offset of each glyph in the buffer
|
||||
X []Dp // screen X (Dp) of each glyph, relative to text region origin
|
||||
Y []Dp // screen Y (Dp) baseline of each glyph (shaper value)
|
||||
Advance []Dp // advance width (Dp) of each glyph
|
||||
LineHeight Dp // shaper's actual baseline-to-baseline line height in Dp
|
||||
VisualLineStarts []int // byte offsets where each visual line starts (for word wrap)
|
||||
VisualLineIndex *types.VisualLineIndex // Optional: pre-computed visual line index for this layout
|
||||
}
|
||||
|
||||
// VisualLineOffsets returns the byte offset of each visual line start.
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user