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).
200 lines
5.8 KiB
Go
200 lines
5.8 KiB
Go
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()
|
|
}
|