Selection: shift+arrow extends a selection (absolute byte offsets,
anchor/caret model); insert/backspace/delete replace the selection; the
IME unions its reported range with the active selection; the highlight
is drawn in the TextField and the selection is pushed to the IME.
Android key input (the blocker found during on-device validation):
Gio v0.10 on Android (a) drops modifier state in the JNI bridge and
(b) wraps plain arrow-key presses in input.SystemEvent for focus
navigation, so arrow keys never reached the editor. main.go now
registers explicit named key.Filters for the four arrows (delivers the
press and suppresses the focus jump) and tracks the shift key itself.
Verified on the emulator: plain arrows move the caret, shift+arrow
shows a highlight, typing replaces the selection.
Real-file e2e tests (real on-disk files via the real FileSystem,
multi-chunk 256KB files, chunk-boundary and multi-byte edits) found
and fixed two real bugs:
1. Line index: UpdateLineIndexAfterEdit only shifted offsets; edits
involving newlines left it permanently inconsistent. Replaced with
newline-aware UpdateLineIndexAfterInsert/UpdateLineIndexAfterDelete.
2. Rune granularity: HandleBackspace/HandleDelete deleted one byte,
corrupting multi-byte UTF-8 characters (e.g. a 2-byte char
straddling a chunk boundary). Now rune-granular.
Also: airtight e2e harness load-wait (StatFile/ReadFile/BuildLineIndex
interleaving could satisfy the old condition early).
Full suite green under -race; on-device verified.
673 lines
21 KiB
Go
673 lines
21 KiB
Go
package editor
|
|
|
|
import (
|
|
"bytes"
|
|
"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 splits any chunk that grows past
|
|
// twice the target size (see Insert), so the per-edit copy cost stays bounded
|
|
// by O(chunkSize) even under sustained typing at one spot. 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
|
|
|
|
// 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 result grows past twice the target chunk size (sustained
|
|
// typing at one spot, or a large paste), the chunk is split in half so that
|
|
// chunks stay O(chunkSize) and every future edit remains a bounded
|
|
// O(chunkSize) copy. The split cut is an arbitrary byte offset, 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)
|
|
if idx < 0 {
|
|
// Empty buffer: chunk the new text directly so a large first paste
|
|
// does not create one oversized chunk.
|
|
cb.chunks = nil
|
|
for start := 0; start < len(text); start += cb.chunkSize {
|
|
end := start + cb.chunkSize
|
|
if end > len(text) {
|
|
end = len(text)
|
|
}
|
|
cb.chunks = append(cb.chunks, []byte(text[start:end]))
|
|
}
|
|
cb.fileLen = int64(len(text))
|
|
cb.markDirtyChunk(0)
|
|
return
|
|
}
|
|
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 {
|
|
// Split in half and insert the second half after idx.
|
|
cut := len(newChunk) / 2
|
|
second := make([]byte, len(newChunk)-cut)
|
|
copy(second, newChunk[cut:])
|
|
newChunk = newChunk[:cut]
|
|
cb.chunks = append(cb.chunks, nil)
|
|
copy(cb.chunks[idx+2:], cb.chunks[idx+1:]) // overlap-safe (memmove)
|
|
cb.chunks[idx+1] = second
|
|
}
|
|
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.
|
|
if cb.LineIndex == nil {
|
|
start, end = cb.visibleByteRangeEstimate(scrollOffset, viewportHeight)
|
|
return start, end, 0
|
|
}
|
|
start, end = cb.visibleByteRangePrecise(scrollOffset, viewportHeight)
|
|
return start, end, 0
|
|
}
|
|
|
|
// 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) (start, end int) {
|
|
// Use the editor's line height constant for consistency.
|
|
lineHeight := EditorLineHeight()
|
|
|
|
startLine := int(scrollOffset / lineHeight)
|
|
endLine := int((scrollOffset + viewportHeight) / 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) (start, end int) {
|
|
if cb.LineIndex == nil || len(cb.LineIndex.Offsets) == 0 {
|
|
return cb.visibleByteRangeEstimate(scrollOffset, viewportHeight)
|
|
}
|
|
|
|
lineHeight := EditorLineHeight()
|
|
|
|
startLine := int(scrollOffset / lineHeight)
|
|
endLine := int((scrollOffset + viewportHeight) / 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
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|