diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 60dec92..c032e73 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -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) } diff --git a/doc/architecture.md b/doc/architecture.md index 875a866..77223f8 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -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). diff --git a/doc/development_plan.md b/doc/development_plan.md index 7331aa0..fe42a9e 100644 --- a/doc/development_plan.md +++ b/doc/development_plan.md @@ -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 diff --git a/doc/spec.md b/doc/spec.md index dd81c8e..c2d78ec 100644 --- a/doc/spec.md +++ b/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) diff --git a/internal/editor/chunked_buffer.go b/internal/editor/chunked_buffer.go index 8e4831e..5b5e15e 100644 --- a/internal/editor/chunked_buffer.go +++ b/internal/editor/chunked_buffer.go @@ -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. diff --git a/internal/editor/cursor_test.go b/internal/editor/cursor_test.go index be30249..52d292f 100644 --- a/internal/editor/cursor_test.go +++ b/internal/editor/cursor_test.go @@ -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 diff --git a/internal/editor/frame.go b/internal/editor/frame.go index aced968..a4d1145 100644 --- a/internal/editor/frame.go +++ b/internal/editor/frame.go @@ -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, } } diff --git a/internal/editor/logic.go b/internal/editor/logic.go index a532063..e8576fa 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -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()) } } } diff --git a/internal/editor/state.go b/internal/editor/state.go index b714d78..2645ffa 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -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 } diff --git a/internal/editor/wrap_apply_test.go b/internal/editor/wrap_apply_test.go new file mode 100644 index 0000000..4e0398e --- /dev/null +++ b/internal/editor/wrap_apply_test.go @@ -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 +} diff --git a/internal/editor/wrap_bookkeeping_test.go b/internal/editor/wrap_bookkeeping_test.go new file mode 100644 index 0000000..38d9347 --- /dev/null +++ b/internal/editor/wrap_bookkeeping_test.go @@ -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") +} diff --git a/internal/editor/wrap_index.go b/internal/editor/wrap_index.go new file mode 100644 index 0000000..f473445 --- /dev/null +++ b/internal/editor/wrap_index.go @@ -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() +} diff --git a/internal/editor/wrap_index_test.go b/internal/editor/wrap_index_test.go new file mode 100644 index 0000000..21a7b1f --- /dev/null +++ b/internal/editor/wrap_index_test.go @@ -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) + } +} diff --git a/internal/editor/wrap_mapping_test.go b/internal/editor/wrap_mapping_test.go new file mode 100644 index 0000000..c256029 --- /dev/null +++ b/internal/editor/wrap_mapping_test.go @@ -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 +} diff --git a/internal/ui/unit.go b/internal/ui/unit.go index bb2b607..79f51ac 100644 --- a/internal/ui/unit.go +++ b/internal/ui/unit.go @@ -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.