Gboard capitalizes the first character of a fresh input session and
derives its word context from the text it holds locally; it never
queries the app for context while typing. The editor was resetting that
session on essentially every event, so Gboard re-anchored to a fresh
session mid-word and caps went random:
- The pushed snippet WAS the render window. Any viewport change (keyboard
show/hide animation, tap re-centering, scroll) changed the snippet, and
gioui turns every snippet change into imm.restartInput — a full IME
session reset. The snippet is now a hysteresis window around the caret
(32 KB, re-anchor only when the caret is within 4 KB of an edge),
decoupled from the render window: typing, taps within range, keyboard
animation and flings never re-push it.
- gioui's EditEvent callback applies the commit to its own window state
directly, so the op queue lags it by one commit; any frame event in
the gap regressed the state and sent a restartInput with pre-commit
text per keystroke. The drained commit is now applied to the pushed
model immediately (Renderer.ApplyIMECommitToModel), and a short hold
keeps stale pre-commit frames out of FlushIME until the logic's
post-commit frame arrives.
- The layout feedback loop re-emitted on exact float equality of the
derived last-line Y, spinning a re-emit -> shape -> re-emit loop
(float-sum noise) that re-drew the editor and re-pushed IME state;
gate it with a 0.5 dp epsilon.
- The render window bottom mapped through the WrapIndex whose
in-viewport counts land while this very window is being shaped: the
bottom oscillated frame to frame, resizing the window (and the old
snippet) every frame. Use a fixed line span from the stable top
instead (each logical line yields >= 1 visual line, so the viewport is
always covered).
Result: zero snippet re-pushes during typing or flings (one re-anchor at
a far tap/file switch); emulator typing tests show all-lowercase
mid-word commits ('thaaaaaaaae', 'vapoaaaaaaaar') and the stress suite
(7 scenarios) passes clean with contiguous insertions only.
787 lines
26 KiB
Go
787 lines
26 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: the viewport top and bottom
|
|
// are each expressed in VISUAL-line space and mapped to the logical lines
|
|
// that contain them (WrapIndex.LineForVisual). Because each logical line
|
|
// produces at least one visual line, that logical range always covers the
|
|
// viewport; the fetch is viewport-bounded no matter how many wraps lie
|
|
// above it, and any spill past the bottom edge is 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)
|
|
endLine := int(math.Ceil(float64(scrollOffset+viewportHeight) / float64(lineHeight)))
|
|
if w := cb.WrapIndex; w != nil {
|
|
// The window TOP is a visual line (the viewport top): map it to the
|
|
// logical line that contains it.
|
|
//
|
|
// The window BOTTOM must NOT be mapped through the index the same
|
|
// way: the wrap counts for lines INSIDE the viewport land precisely
|
|
// while this window is being shaped (their measured layouts feed the
|
|
// index), so the mapped bottom would oscillate frame to frame as
|
|
// measurements arrive, resizing the window every frame. Each
|
|
// resize changes the pushed IME snippet, and gioui turns a snippet
|
|
// change into a restartInput — a restart on every frame reset the
|
|
// IME's per-word input session, the source of random mid-word
|
|
// capitalization.
|
|
//
|
|
// Instead use a fixed span of logical lines from the stable top:
|
|
// each logical line produces at least one visual line, so the
|
|
// viewport's bottom visual line lies at or above
|
|
// startLine + (bottomVisual - topVisual); the window always covers
|
|
// the viewport, over-fetching only by the wrapped lines inside it
|
|
// (bounded by the viewport height; the renderer clips).
|
|
bottomVisual := endLine
|
|
startLine = w.LineForVisual(int32(v0))
|
|
endLine = startLine + (bottomVisual - int(v0))
|
|
}
|
|
|
|
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
|
|
}
|