Pad/internal/editor/chunked_buffer.go
Greg Pomerantz 83f7affee9 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).
2026-08-17 19:46:47 -04:00

764 lines
24 KiB
Go

package editor
import (
"bytes"
"math"
"sort"
"strings"
"pad/internal/io/pool"
"pad/internal/io/pool/types"
"pad/internal/ui"
)
const (
DefaultChunkSize = 64 * 1024 // 64 KB
// MaxEditableFileSize is the largest file the editor will open for editing.
// In-range files are loaded fully into memory (see Phase 3): the chunked
// buffer keeps the raw bytes (~file size) resident and the renderer
// virtualizes the glyph layout to the visible window, so memory scales
// roughly linearly with file size and stays bounded (no leak). Files above
// this are rejected with a "too large to edit" state (the browser can still
// list them).
//
// On-device measurement (Android emulator, SwiftShader): a 10 MB file uses
// ~150 MB PSS / ~230 MB RSS at steady state and stays flat under scroll
// (previously the shaper was handed the whole file each frame, ballooning
// to ~1.1 GB and OOM-killing the process). 50 MB extrapolates to a few
// hundred MB, comfortable on a modern phone.
MaxEditableFileSize = 50 * 1024 * 1024 // 50 MB
)
// ChunkedBuffer provides chunked access to a file's content.
//
// Model (Phase 3): for in-range files the ENTIRE file is loaded into memory on
// open and split into ordered chunks (see SetContent). Each chunk keeps its
// own length; the byte offset of a chunk is the sum of the lengths of the
// chunks before it (a prefix sum), NOT a fixed i*chunkSize slot. This is what
// makes edits correct: an insert/delete changes a chunk's length and the
// prefix sums automatically shift every later chunk, so byte->chunk mapping
// stays exact. Because every chunk is resident, there is no lazy loading and
// therefore no stale-disk re-read (the old fixed-slot model could re-read a
// shifted tail chunk from disk and clobber in-memory edits).
//
// Chunks grow/shrink with edits; Insert re-chunks any result that grows past
// twice the target size into pieces of at most chunkSize (see Insert), so
// after EVERY edit no chunk exceeds 2*chunkSize and the per-edit copy cost
// stays bounded by O(chunkSize) even under sustained typing at one spot or a
// large paste. Shrunken (even empty) chunks are left in place: the chunk
// count never grows with deletes, all readers walk actual lengths, and
// removing chunks would be pure churn.
type ChunkedBuffer struct {
filename string
chunkSize int // target chunk size (e.g. 64 KB); actual chunks may vary
fileLen int64 // total content length
chunks [][]byte // ordered; chunks[i] is the i-th chunk
dirty bool // true if buffer has been modified
FS pool.FileSystem // filesystem for reads
basePath string // base path for file resolution
// 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
// lastPrefetchedChunk tracks the last chunk that was prefetched, so callers
// can avoid redundant work. Kept for compatibility.
lastPrefetchedChunk int
// dirtyChunks tracks which individual chunks have been modified since they
// were last persisted to disk.
dirtyChunks map[int]bool
// loadingChunks is retained for API compatibility; it is always empty for
// in-range files (no lazy loading).
loadingChunks map[int]bool
// fullyLoaded is true once SetContent has populated all chunks; the buffer
// never falls back to disk afterwards.
fullyLoaded bool
}
// NewChunkedBuffer creates a new ChunkedBuffer.
func NewChunkedBuffer(filename string, chunkSize int, fs pool.FileSystem, basePath string) *ChunkedBuffer {
if chunkSize <= 0 {
chunkSize = DefaultChunkSize
}
return &ChunkedBuffer{
filename: filename,
chunkSize: chunkSize,
fileLen: 0,
chunks: nil,
dirtyChunks: make(map[int]bool),
loadingChunks: make(map[int]bool),
FS: fs,
basePath: basePath,
}
}
// SetFileSize sets the total file length (used before SetContent, e.g. from a
// stat). It only applies when the buffer is not dirty.
func (cb *ChunkedBuffer) SetFileSize(length int64) {
if !cb.dirty {
cb.fileLen = length
}
}
// FileLen returns the total content length.
func (cb *ChunkedBuffer) FileLen() int64 {
return cb.fileLen
}
// Filename returns the filename.
func (cb *ChunkedBuffer) Filename() string {
return cb.filename
}
// ChunkSize returns the target chunk size.
func (cb *ChunkedBuffer) ChunkSize() int {
return cb.chunkSize
}
// SetContent replaces the buffer content with data, split into ordered chunks
// of at most chunkSize. This is the on-open load path for in-range files: the
// whole file becomes resident, so there is no lazy loading and no risk of
// re-reading stale disk data after an edit.
func (cb *ChunkedBuffer) SetContent(data []byte) {
cb.chunks = nil
for start := 0; start < len(data); start += cb.chunkSize {
end := start + cb.chunkSize
if end > len(data) {
end = len(data)
}
chunk := make([]byte, end-start)
copy(chunk, data[start:end])
cb.chunks = append(cb.chunks, chunk)
}
if len(data) == 0 {
cb.chunks = [][]byte{}
}
cb.fileLen = int64(len(data))
cb.dirty = false
cb.dirtyChunks = make(map[int]bool)
cb.fullyLoaded = true
}
// chunkForPos returns the index of the chunk that contains byte offset pos and
// the offset of pos within that chunk. If pos is at or past the end, it returns
// the last chunk and an offset at its end (so callers can append). If the
// buffer is empty it returns (-1, 0).
func (cb *ChunkedBuffer) chunkForPos(pos int) (int, int) {
offset := 0
for i, chunk := range cb.chunks {
if pos < offset+len(chunk) {
return i, pos - offset
}
offset += len(chunk)
}
if n := len(cb.chunks); n > 0 {
return n - 1, len(cb.chunks[n-1])
}
return -1, 0
}
// Content returns the bytes in [start, end) from the chunked buffer. Chunks are
// walked by their actual (prefix-sum) offsets, so the mapping stays correct
// after length-changing edits.
func (cb *ChunkedBuffer) Content(start, end int) string {
if cb.fileLen == 0 {
return ""
}
if start < 0 {
start = 0
}
if end > int(cb.fileLen) {
end = int(cb.fileLen)
}
if start >= end {
return ""
}
var buf bytes.Buffer
offset := 0
for _, chunk := range cb.chunks {
chunkStart := offset
chunkEnd := offset + len(chunk)
offset = chunkEnd
if chunkEnd <= start {
continue
}
if chunkStart >= end {
break
}
segStart := max(start, chunkStart) - chunkStart
segEnd := min(end, chunkEnd) - chunkStart
if segStart < segEnd {
buf.Write(chunk[segStart:segEnd])
}
}
return buf.String()
}
// RuneIndexToByte returns the byte offset of the n-th rune (0-indexed) in the
// buffer. The IME addresses text in rune indices while the buffer is
// byte-based, so this bridges the two. It walks chunks by actual length and
// stops as soon as the n-th rune is found. If n is at or past the end it
// returns the content length.
func (cb *ChunkedBuffer) RuneIndexToByte(n int) int {
if n <= 0 {
return 0
}
if cb.fileLen == 0 {
return 0
}
runes := 0
offset := 0
for _, chunk := range cb.chunks {
for i := 0; i < len(chunk); i++ {
b := chunk[i]
// A UTF-8 rune starts at an ASCII byte (<0x80) or a multi-byte
// lead byte (>=0xC0); 0x80-0xBF are continuation bytes.
if b < 0x80 || b >= 0xC0 {
if runes == n {
return offset + i
}
runes++
}
}
offset += len(chunk)
}
return int(cb.fileLen)
}
// FullContent reconstructs the entire content by concatenating the ordered
// chunks. For in-range files every chunk is resident, so this is exact and
// never returns an error.
func (cb *ChunkedBuffer) FullContent() (string, error) {
var buf bytes.Buffer
for _, chunk := range cb.chunks {
buf.Write(chunk)
}
return buf.String(), nil
}
// loadChunk reads a chunk from disk. Retained for compatibility; in-range
// files load fully via SetContent and do not use this.
func (cb *ChunkedBuffer) loadChunk(idx int) ([]byte, error) {
start := idx * cb.chunkSize
chunk, err := cb.FS.ReadFileAt(cb.filename, start, cb.chunkSize)
if err != nil {
return nil, err
}
for len(cb.chunks) <= idx {
cb.chunks = append(cb.chunks, nil)
}
cb.chunks[idx] = chunk
return chunk, nil
}
// LoadChunk loads a chunk (no-op for fully-loaded in-range files).
func (cb *ChunkedBuffer) LoadChunk(idx int) {
if cb.fullyLoaded {
return
}
if idx >= 0 && idx < len(cb.chunks) && cb.chunks[idx] != nil {
return
}
if _, err := cb.loadChunk(idx); err != nil {
// Best effort; in-range files should never reach here.
}
cb.lastPrefetchedChunk = idx
}
// IsChunkLoaded reports whether the chunk is resident.
func (cb *ChunkedBuffer) IsChunkLoaded(idx int) bool {
return idx >= 0 && idx < len(cb.chunks) && cb.chunks[idx] != nil
}
// IsChunkLoading reports whether a chunk load is in flight (never, for
// in-range files).
func (cb *ChunkedBuffer) IsChunkLoading(idx int) bool {
return cb.loadingChunks[idx]
}
// LoadChunkAsync dispatches an async chunk load (no-op for in-range files,
// which are fully resident).
func (cb *ChunkedBuffer) LoadChunkAsync(idx int) {
if cb.fullyLoaded {
return
}
if cb.workerPool != nil && !cb.loadingChunks[idx] && !cb.IsChunkLoaded(idx) {
cb.loadingChunks[idx] = true
cb.workerPool.DispatchNonBlocking(
pool.NewReadChunkTask(cb.filename, idx, cb.FS),
)
}
}
// SetWorkerPool sets the worker pool (retained for compatibility).
func (cb *ChunkedBuffer) SetWorkerPool(wp *pool.WorkerPool) {
cb.workerPool = wp
}
// LastPrefetchedChunk returns the last prefetched chunk index.
func (cb *ChunkedBuffer) LastPrefetchedChunk() int {
return cb.lastPrefetchedChunk
}
// Prefetch is a no-op for in-range files (all chunks resident).
func (cb *ChunkedBuffer) Prefetch(centerChunk int, radius int) {
cb.lastPrefetchedChunk = centerChunk
}
// EvictFarChunks is intentionally a no-op for in-range files: all chunks stay
// resident so byte->chunk mapping and FullContent remain exact. (Evicting
// would reintroduce the stale-disk re-read hazard the fixed-slot model had.)
func (cb *ChunkedBuffer) EvictFarChunks(cursorPos int, radius int) {
// no-op
}
// markDirtyChunk flags the buffer and a chunk as modified.
func (cb *ChunkedBuffer) markDirtyChunk(idx int) {
cb.dirty = true
if idx >= 0 {
cb.dirtyChunks[idx] = true
}
}
// Insert inserts text at byte position pos, splicing only the affected
// chunk. If the spliced result grows past twice the target chunk size
// (sustained typing at one spot, or a large paste), it is re-chunked into
// pieces of at most chunkSize, so the invariant "no chunk exceeds 2*chunkSize
// after any edit" holds universally (a single halving would leave chunks up
// to ~P/2 for a paste of size P). Piece boundaries are arbitrary byte
// offsets, like the chunk boundaries created by SetContent: chunk boundaries
// may fall inside multi-byte sequences, which is fine because every reader
// reassembles whole windows from chunk bytes (windows are always
// line-aligned, i.e. on rune boundaries).
func (cb *ChunkedBuffer) Insert(pos int, text string) {
if len(text) == 0 {
return
}
fileLen := int(cb.fileLen)
if pos > fileLen {
pos = fileLen // clamp to end
}
if pos < 0 {
pos = 0
}
idx, local := cb.chunkForPos(pos)
var newChunk []byte
if idx < 0 {
// Empty buffer: the spliced result is just the new text.
newChunk = []byte(text)
} else {
chunk := cb.chunks[idx]
newChunk = make([]byte, 0, len(chunk)+len(text))
newChunk = append(newChunk, chunk[:local]...)
newChunk = append(newChunk, text...)
newChunk = append(newChunk, chunk[local:]...)
}
if len(newChunk) > 2*cb.chunkSize {
// Re-chunk the oversized result in place of the affected chunk (or as
// the whole buffer when it was empty) so every piece is <= chunkSize;
// this keeps "no chunk exceeds 2*chunkSize" true after ANY edit,
// including large pastes.
out := make([][]byte, 0, len(cb.chunks)+len(newChunk)/cb.chunkSize)
if idx >= 0 {
out = append(out, cb.chunks[:idx]...)
}
for start := 0; start < len(newChunk); start += cb.chunkSize {
end := min(start+cb.chunkSize, len(newChunk))
piece := make([]byte, end-start)
copy(piece, newChunk[start:end])
out = append(out, piece)
}
if idx >= 0 {
out = append(out, cb.chunks[idx+1:]...)
}
cb.chunks = out
} else if idx < 0 {
cb.chunks = [][]byte{newChunk}
} else {
cb.chunks[idx] = newChunk
}
cb.fileLen += int64(len(text))
cb.markDirtyChunk(idx)
}
// Delete deletes n bytes starting at pos, splicing only the affected chunk(s).
func (cb *ChunkedBuffer) Delete(pos, n int) {
if n <= 0 {
return
}
fileLen := int(cb.fileLen)
if pos < 0 {
pos = 0
}
if pos >= fileLen {
return
}
if pos+n > fileLen {
n = fileLen - pos
}
absEnd := pos + n
offset := 0
for i := range cb.chunks {
chunk := cb.chunks[i]
chunkStart := offset
chunkEnd := offset + len(chunk)
offset = chunkEnd
if chunkEnd <= pos || chunkStart >= absEnd {
continue // no overlap with [pos, absEnd)
}
delStart := max(pos, chunkStart) - chunkStart
delEnd := min(absEnd, chunkEnd) - chunkStart
if delStart >= delEnd {
continue
}
cb.chunks[i] = append(chunk[:delStart], chunk[delEnd:]...)
cb.markDirtyChunk(i)
}
cb.fileLen -= int64(n)
if cb.fileLen < 0 {
cb.fileLen = 0
}
cb.dirty = true
}
// VisibleByteRange returns the byte range [start, end) that is visible on the
// current editor viewport, plus the visual line at the top of the viewport.
// This drives the renderer's virtual scrolling: only this range of text is
// laid out into GlyphLayout, keeping memory and layout cost bounded by the
// viewport, not the file. It uses actual (prefix-sum) chunk offsets so the
// range stays correct after length-changing edits.
func (cb *ChunkedBuffer) VisibleByteRange(scrollOffset ui.Dp, byteOffset int, viewportHeight ui.Dp, lineHeight ui.Dp, wordWrap bool, layout ui.GlyphLayout, visualIndex *types.VisualLineIndex) (start, end, startLine int) {
// The visible range is always derived from the real-line LineIndex (or a
// heuristic estimate before the index is ready). The previously-shaped
// GlyphLayout (layout.VisualLineStarts) only covers the visible window, NOT
// the whole document. Using it to bound the range made the end fall back to
// the entire file whenever the viewport's line count exceeded the window's
// visual-line count, which forced the text shaper to lay out the whole
// document and ballooned memory to the file size (the shaper's internal
// line/glyph buffers grow to the largest layout ever shaped and are never
// released). That is the root cause of the ~1GB "Unknown" memory on large
// files.
//
// Word wrap does not require a separate path: each real line produces at
// least one visual line, so shaping viewportHeight/lineHeight real lines
// always yields at least as many visual lines as fit in the viewport. The
// extra wrapped lines are simply clipped by the renderer.
lineH := lineHeight
if lineH <= 0 {
lineH = EffectiveLineHeight()
}
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, startLine = cb.visibleByteRangePrecise(scrollOffset, viewportHeight, lineH)
return start, end, startLine
}
// visibleByteRangeEstimate approximates the visible byte range using
// heuristic estimates. Used when the line index is not yet available.
func (cb *ChunkedBuffer) visibleByteRangeEstimate(scrollOffset ui.Dp, viewportHeight ui.Dp, lineHeight ui.Dp) (start, end int) {
if lineHeight <= 0 {
lineHeight = EffectiveLineHeight()
}
startLine, _ := scrollDecompose(scrollOffset, lineHeight)
endLine := int(math.Ceil(float64(scrollOffset+viewportHeight) / float64(lineHeight)))
// Clamp line numbers to reasonable bounds
totalLinesEstimate := 0
if cb.fileLen > 0 {
totalLinesEstimate = int(cb.fileLen/50) + 1 // Rough estimate: 50 bytes per line
}
if startLine < 0 {
startLine = 0
}
if endLine > totalLinesEstimate {
endLine = totalLinesEstimate
}
if startLine >= endLine {
endLine = startLine + 1 // Ensure at least one line is visible
}
// Convert line numbers to byte offsets using the line index if available.
if cb.LineIndex != nil {
if startLine < len(cb.LineIndex.Offsets) {
start = int(cb.LineIndex.Offsets[startLine])
} else {
lastKnownOffset := int64(0)
if len(cb.LineIndex.Offsets) > 0 {
lastKnownOffset = int64(cb.LineIndex.Offsets[len(cb.LineIndex.Offsets)-1])
}
linesBeyondIndex := startLine - (len(cb.LineIndex.Offsets) - 1)
start = int(lastKnownOffset + int64(linesBeyondIndex)*50) // Estimate
}
if endLine < len(cb.LineIndex.Offsets) {
end = int(cb.LineIndex.Offsets[endLine])
} else {
lastKnownOffset := int64(0)
if len(cb.LineIndex.Offsets) > 0 {
lastKnownOffset = int64(cb.LineIndex.Offsets[len(cb.LineIndex.Offsets)-1])
}
linesBeyondIndex := endLine - (len(cb.LineIndex.Offsets) - 1)
end = int(lastKnownOffset + int64(linesBeyondIndex)*50) // Estimate
}
} else {
// Rough byte estimation if no LineIndex
start = startLine * 50 // rough estimate: 50 bytes per line
end = endLine * 50
}
// Clamp to file bounds
if cb.fileLen > 0 {
if start < 0 {
start = 0
}
if end > int(cb.fileLen) {
end = int(cb.fileLen)
}
if end <= start {
end = start + cb.chunkSize // Ensure at least one chunk's worth if range is invalid
}
} else {
start = 0
end = 0 // Empty file
}
return start, end
}
// 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, startLine int) {
if cb.LineIndex == nil || len(cb.LineIndex.Offsets) == 0 {
es, ee := cb.visibleByteRangeEstimate(scrollOffset, viewportHeight, lineHeight)
return es, ee, 0
}
if lineHeight <= 0 {
lineHeight = EffectiveLineHeight()
}
// startLine must use the same floor decomposition as the renderer's
// 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.
//
// 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 {
startLine = 0
}
if startLine >= len(cb.LineIndex.Offsets) {
startLine = len(cb.LineIndex.Offsets) - 1
}
if endLine < 0 {
endLine = 0
}
if endLine >= len(cb.LineIndex.Offsets) {
endLine = len(cb.LineIndex.Offsets) - 1
}
if startLine < len(cb.LineIndex.Offsets)-1 && endLine <= startLine {
endLine = startLine + 1
}
start = int(cb.LineIndex.Offsets[startLine])
if endLine+1 < len(cb.LineIndex.Offsets) {
end = int(cb.LineIndex.Offsets[endLine+1])
} else {
end = int(cb.fileLen)
}
if start < 0 {
start = 0
}
if end > int(cb.fileLen) {
end = int(cb.fileLen)
}
if end <= start {
if start < int(cb.fileLen) {
end = min(start+cb.chunkSize, int(cb.fileLen))
} else {
end = start
}
}
return start, end, startLine
}
// UpdateLineIndexAfterInsert records the insertion of `text` at absolute
// position `pos`, maintaining LineIndex incrementally:
// - old line starts below pos are unchanged;
// - an old line start exactly at pos stays at pos (the byte before it is
// unchanged by the insertion);
// - old line starts above pos shift right by len(text);
// - each '\n' inside `text` creates a new line start immediately after it.
//
// Equivalent to rebuilding the index from the edited content, but in
// O(lines affected) instead of O(file).
func (cb *ChunkedBuffer) UpdateLineIndexAfterInsert(pos int, text string) {
li := cb.LineIndex
if li == nil {
return
}
old := li.Offsets
lower := sort.Search(len(old), func(i int) bool { return int(old[i]) >= pos })
atPos := lower < len(old) && int(old[lower]) == pos
rest := lower
if atPos {
rest++
}
shift := int32(len(text))
newOff := make([]int32, 0, len(old)+strings.Count(text, "\n")+1)
newOff = append(newOff, old[:lower]...)
if atPos {
newOff = append(newOff, int32(pos))
}
for i, b := range text {
if b == '\n' {
newOff = append(newOff, int32(pos+i+1))
}
}
for _, o := range old[rest:] {
newOff = append(newOff, o+shift)
}
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
// [start, end), maintaining LineIndex incrementally:
// - old line starts below start are unchanged;
// - old line starts inside [start, end) are removed;
// - old line starts at or above end shift left by end-start, except the one
// at exactly end, which would land on `start` and is valid only if a line
// starts there in the new content;
// - `start` is (re)inserted as a line start iff start==0 or it was a line
// start in the pre-edit index (equivalently, the byte before it is '\n';
// bytes below start are untouched by the deletion).
func (cb *ChunkedBuffer) UpdateLineIndexAfterDelete(start, end int) {
li := cb.LineIndex
if li == nil {
return
}
old := li.Offsets
shift := int32(end - start)
lower := sort.Search(len(old), func(i int) bool { return int(old[i]) >= start })
atStart := lower < len(old) && int(old[lower]) == start
upper := sort.Search(len(old), func(i int) bool { return int(old[i]) >= end })
newOff := make([]int32, 0, len(old))
newOff = append(newOff, old[:lower]...)
if start == 0 || atStart {
newOff = append(newOff, int32(start))
}
for _, o := range old[upper:] {
no := o - shift
if int(no) == start {
// The old line start at exactly `end` shifted onto `start`. A line
// starts there in the new content iff one already existed there
// (added above); in neither case do we keep this shifted entry.
continue
}
newOff = append(newOff, no)
}
li.Offsets = newOff
li.Size -= int64(end - start)
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.
func max(a, b int) int {
if a > b {
return a
}
return b
}
// min returns the smaller of two ints.
func min(a, b int) int {
if a < b {
return a
}
return b
}