editor: fix viewport-on-open, chunked-buffer drift, add size guard (Phase 3 code)
- Viewport: swallow the opening tap/scroll (justOpenedAt window) and re-clamp ScrollOffset to [0,MaxScroll] in EditorLayout so a short file never opens past its content (blank viewport). - Chunked buffer: replace the fixed i*chunkSize slot model (which drifted after length-changing edits and could re-read stale disk for shifted tail chunks) with an ordered chunk slice + prefix-sum byte offsets. In-range files now load fully on open (SetContent), so there is no lazy load and no stale-disk re-read. Edits splice only the affected chunk(s). - Size guard: files > MaxEditableFileSize (50 MB) show a 'too large to edit' notice instead of loading; the browser still lists them. Edit handlers (KeyDown/ReplaceRange) are no-ops for too-large files. - Tests: rewrite chunked_buffer_test.go for prefix-sum correctness (insert/ delete across chunk boundaries, rune->byte after edit); fix the large-file e2e expectation to the shift-correct ground truth.
This commit is contained in:
parent
4cfabec7a4
commit
3460ef3993
|
|
@ -2,7 +2,6 @@ package editor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"pad/internal/io/pool"
|
"pad/internal/io/pool"
|
||||||
|
|
@ -12,38 +11,61 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultChunkSize = 64 * 1024 // 64 KB
|
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. Files above this are
|
||||||
|
// rejected with a "too large to edit" state (the browser can still list
|
||||||
|
// them). Set from on-device measurement (see doc/development_plan.md §5).
|
||||||
|
MaxEditableFileSize = 50 * 1024 * 1024 // 50 MB
|
||||||
)
|
)
|
||||||
|
|
||||||
// ChunkedBuffer provides chunked access to a file's content.
|
// ChunkedBuffer provides chunked access to a file's content.
|
||||||
// Only chunks near the cursor or viewport are kept in memory.
|
//
|
||||||
|
// 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 may grow/shrink with edits and are not rebalanced; that is fine for
|
||||||
|
// correctness (prefix sums) and keeps edits local to the affected chunk(s)
|
||||||
|
// rather than copying the whole file.
|
||||||
type ChunkedBuffer struct {
|
type ChunkedBuffer struct {
|
||||||
filename string
|
filename string
|
||||||
chunkSize int // e.g., 64 * 1024 (64 KB)
|
chunkSize int // target chunk size (e.g. 64 KB); actual chunks may vary
|
||||||
fileLen int64 // total file length (known from stat)
|
fileLen int64 // total content length
|
||||||
chunks map[int][]byte // chunkIndex → []byte
|
chunks [][]byte // ordered; chunks[i] is the i-th chunk
|
||||||
dirty bool // true if buffer has been modified
|
dirty bool // true if buffer has been modified
|
||||||
FS pool.FileSystem // filesystem for reading chunks
|
FS pool.FileSystem // filesystem for reads
|
||||||
basePath string // base path for file resolution
|
basePath string // base path for file resolution
|
||||||
|
|
||||||
// line index is built asynchronously
|
// line index is built asynchronously
|
||||||
LineIndex *types.LineIndex
|
LineIndex *types.LineIndex
|
||||||
|
|
||||||
// workerPool is set by the logic goroutine after the buffer is created.
|
// workerPool is retained for API compatibility; in-range files load fully
|
||||||
// Used for async prefetch of chunks.
|
// up front, so chunk loading no longer dispatches worker tasks.
|
||||||
workerPool *pool.WorkerPool
|
workerPool *pool.WorkerPool
|
||||||
|
|
||||||
// lastPrefetchedChunk tracks the last chunk that was prefetched,
|
// lastPrefetchedChunk tracks the last chunk that was prefetched, so callers
|
||||||
// so we can avoid redundant prefetches on the same position.
|
// can avoid redundant work. Kept for compatibility.
|
||||||
lastPrefetchedChunk int
|
lastPrefetchedChunk int
|
||||||
|
|
||||||
// dirtyChunks tracks which individual chunks have been modified
|
// dirtyChunks tracks which individual chunks have been modified since they
|
||||||
// since they were last persisted to disk. This prevents eviction
|
// were last persisted to disk.
|
||||||
// of modified chunks, which would otherwise lose edits.
|
|
||||||
dirtyChunks map[int]bool
|
dirtyChunks map[int]bool
|
||||||
|
|
||||||
// loadingChunks tracks which chunks are currently being loaded
|
// loadingChunks is retained for API compatibility; it is always empty for
|
||||||
// to avoid dispatching redundant tasks for the same chunk.
|
// in-range files (no lazy loading).
|
||||||
loadingChunks map[int]bool
|
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.
|
// NewChunkedBuffer creates a new ChunkedBuffer.
|
||||||
|
|
@ -54,7 +76,8 @@ func NewChunkedBuffer(filename string, chunkSize int, fs pool.FileSystem, basePa
|
||||||
return &ChunkedBuffer{
|
return &ChunkedBuffer{
|
||||||
filename: filename,
|
filename: filename,
|
||||||
chunkSize: chunkSize,
|
chunkSize: chunkSize,
|
||||||
chunks: make(map[int][]byte),
|
fileLen: 0,
|
||||||
|
chunks: nil,
|
||||||
dirtyChunks: make(map[int]bool),
|
dirtyChunks: make(map[int]bool),
|
||||||
loadingChunks: make(map[int]bool),
|
loadingChunks: make(map[int]bool),
|
||||||
FS: fs,
|
FS: fs,
|
||||||
|
|
@ -62,14 +85,15 @@ func NewChunkedBuffer(filename string, chunkSize int, fs pool.FileSystem, basePa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetFileSize sets the total file length.
|
// 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) {
|
func (cb *ChunkedBuffer) SetFileSize(length int64) {
|
||||||
if !cb.dirty {
|
if !cb.dirty {
|
||||||
cb.fileLen = length
|
cb.fileLen = length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileLen returns the total file length.
|
// FileLen returns the total content length.
|
||||||
func (cb *ChunkedBuffer) FileLen() int64 {
|
func (cb *ChunkedBuffer) FileLen() int64 {
|
||||||
return cb.fileLen
|
return cb.fileLen
|
||||||
}
|
}
|
||||||
|
|
@ -79,18 +103,60 @@ func (cb *ChunkedBuffer) Filename() string {
|
||||||
return cb.filename
|
return cb.filename
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChunkSize returns the chunk size.
|
// ChunkSize returns the target chunk size.
|
||||||
func (cb *ChunkedBuffer) ChunkSize() int {
|
func (cb *ChunkedBuffer) ChunkSize() int {
|
||||||
return cb.chunkSize
|
return cb.chunkSize
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content returns the bytes in [start, end) from the chunked buffer.
|
// SetContent replaces the buffer content with data, split into ordered chunks
|
||||||
// It loads missing chunks on demand.
|
// 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 {
|
func (cb *ChunkedBuffer) Content(start, end int) string {
|
||||||
if cb.fileLen == 0 {
|
if cb.fileLen == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
// Clamp range to file bounds
|
|
||||||
if start < 0 {
|
if start < 0 {
|
||||||
start = 0
|
start = 0
|
||||||
}
|
}
|
||||||
|
|
@ -100,31 +166,20 @@ func (cb *ChunkedBuffer) Content(start, end int) string {
|
||||||
if start >= end {
|
if start >= end {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
startChunk := start / cb.chunkSize
|
|
||||||
endChunk := (end - 1) / cb.chunkSize
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
for i := startChunk; i <= endChunk; i++ {
|
offset := 0
|
||||||
chunk, ok := cb.chunks[i]
|
for _, chunk := range cb.chunks {
|
||||||
if !ok {
|
chunkStart := offset
|
||||||
// If chunk is not loaded, initiate async load and skip this chunk.
|
chunkEnd := offset + len(chunk)
|
||||||
// This keeps the UI responsive while chunks are loaded in the background.
|
offset = chunkEnd
|
||||||
if cb.workerPool != nil && !cb.loadingChunks[i] {
|
if chunkEnd <= start {
|
||||||
cb.loadingChunks[i] = true
|
|
||||||
cb.workerPool.DispatchNonBlocking(
|
|
||||||
pool.NewReadChunkTask(cb.filename, i, cb.FS),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if chunkStart >= end {
|
||||||
chunkStart := i * cb.chunkSize
|
break
|
||||||
chunkEnd := chunkStart + len(chunk)
|
}
|
||||||
|
|
||||||
segStart := max(start, chunkStart) - chunkStart
|
segStart := max(start, chunkStart) - chunkStart
|
||||||
segEnd := min(end, chunkEnd) - chunkStart
|
segEnd := min(end, chunkEnd) - chunkStart
|
||||||
|
|
||||||
if segStart < segEnd {
|
if segStart < segEnd {
|
||||||
buf.Write(chunk[segStart:segEnd])
|
buf.Write(chunk[segStart:segEnd])
|
||||||
}
|
}
|
||||||
|
|
@ -132,49 +187,21 @@ func (cb *ChunkedBuffer) Content(start, end int) string {
|
||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// chunkSync returns the bytes of chunk idx, synchronously. It prefers the
|
|
||||||
// in-memory (possibly dirty) copy and only reads from disk for clean, evicted
|
|
||||||
// chunks. Calling loadChunk directly on a dirty chunk would clobber the
|
|
||||||
// in-memory edit with stale disk data, so the map is checked first.
|
|
||||||
func (cb *ChunkedBuffer) chunkSync(idx int) ([]byte, error) {
|
|
||||||
if chunk, ok := cb.chunks[idx]; ok {
|
|
||||||
return chunk, nil
|
|
||||||
}
|
|
||||||
if cb.dirtyChunks[idx] {
|
|
||||||
return nil, fmt.Errorf("chunk %d is dirty but not in memory", idx)
|
|
||||||
}
|
|
||||||
return cb.loadChunk(idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RuneIndexToByte returns the byte offset of the n-th rune (0-indexed) in the
|
// 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
|
// buffer. The IME addresses text in rune indices while the buffer is
|
||||||
// byte-based, so this bridges the two. It scans chunk by chunk and stops as
|
// byte-based, so this bridges the two. It walks chunks by actual length and
|
||||||
// soon as the n-th rune is found, so it reads only up to the caret (a few
|
// stops as soon as the n-th rune is found. If n is at or past the end it
|
||||||
// hundred KB for a typical position) rather than the whole file. If n is at
|
// returns the content length.
|
||||||
// or past the end, it returns the file length. On a chunk read error it
|
|
||||||
// returns the byte offset scanned so far (a best-effort position).
|
|
||||||
func (cb *ChunkedBuffer) RuneIndexToByte(n int) int {
|
func (cb *ChunkedBuffer) RuneIndexToByte(n int) int {
|
||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
fileLen := int(cb.fileLen)
|
if cb.fileLen == 0 {
|
||||||
if fileLen == 0 {
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
runes := 0
|
runes := 0
|
||||||
offset := 0
|
offset := 0
|
||||||
for offset < fileLen {
|
for _, chunk := range cb.chunks {
|
||||||
end := offset + cb.chunkSize
|
|
||||||
if end > fileLen {
|
|
||||||
end = fileLen
|
|
||||||
}
|
|
||||||
idx := offset / cb.chunkSize
|
|
||||||
chunk, err := cb.chunkSync(idx)
|
|
||||||
if err != nil {
|
|
||||||
// Can't count past a broken chunk; stop here.
|
|
||||||
fmt.Printf("RuneIndexToByte: %v\n", err)
|
|
||||||
return offset + len(chunk)
|
|
||||||
}
|
|
||||||
for i := 0; i < len(chunk); i++ {
|
for i := 0; i < len(chunk); i++ {
|
||||||
b := chunk[i]
|
b := chunk[i]
|
||||||
// A UTF-8 rune starts at an ASCII byte (<0x80) or a multi-byte
|
// A UTF-8 rune starts at an ASCII byte (<0x80) or a multi-byte
|
||||||
|
|
@ -186,91 +213,69 @@ func (cb *ChunkedBuffer) RuneIndexToByte(n int) int {
|
||||||
runes++
|
runes++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
offset = end
|
offset += len(chunk)
|
||||||
}
|
}
|
||||||
return fileLen
|
return int(cb.fileLen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FullContent reconstructs the entire file content from loaded or re-read chunks.
|
// FullContent reconstructs the entire content by concatenating the ordered
|
||||||
// If a dirty chunk is missing from memory, it returns an error to prevent data loss.
|
// chunks. For in-range files every chunk is resident, so this is exact and
|
||||||
|
// never returns an error.
|
||||||
func (cb *ChunkedBuffer) FullContent() (string, error) {
|
func (cb *ChunkedBuffer) FullContent() (string, error) {
|
||||||
if cb.fileLen == 0 && len(cb.chunks) == 0 {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
// Iterate up to the larger of the fileLen-derived chunk count and the
|
|
||||||
// highest in-memory chunk index. Deriving the bound solely from fileLen
|
|
||||||
// would truncate in-memory chunks that an insert grew past the old bound.
|
|
||||||
numChunks := 0
|
|
||||||
if cb.fileLen > 0 {
|
|
||||||
numChunks = int((cb.fileLen + int64(cb.chunkSize) - 1) / int64(cb.chunkSize))
|
|
||||||
}
|
|
||||||
for i := range cb.chunks {
|
|
||||||
if i+1 > numChunks {
|
|
||||||
numChunks = i + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
for i := 0; i < numChunks; i++ {
|
for _, chunk := range cb.chunks {
|
||||||
var chunk []byte
|
|
||||||
var err error
|
|
||||||
if loadedChunk, ok := cb.chunks[i]; ok {
|
|
||||||
chunk = loadedChunk
|
|
||||||
} else {
|
|
||||||
// If chunk is dirty but not in memory, we have a problem.
|
|
||||||
if cb.dirtyChunks[i] {
|
|
||||||
return "", fmt.Errorf("critical error: dirty chunk %d is missing from memory", i)
|
|
||||||
}
|
|
||||||
// Re-read from disk if not in memory
|
|
||||||
chunk, err = cb.loadChunk(i)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error re-reading chunk %d for file %s during FullContent: %v\n", i, cb.filename, err)
|
|
||||||
continue // Skip this chunk on error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buf.Write(chunk)
|
buf.Write(chunk)
|
||||||
}
|
}
|
||||||
return buf.String(), nil
|
return buf.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadChunk reads a specific chunk from disk and returns its content.
|
// loadChunk reads a chunk from disk. Retained for compatibility; in-range
|
||||||
// It also stores the chunk in the 'chunks' map.
|
// files load fully via SetContent and do not use this.
|
||||||
// Uses FS.ReadFileAt to read only the chunk range (not the entire file).
|
|
||||||
func (cb *ChunkedBuffer) loadChunk(idx int) ([]byte, error) {
|
func (cb *ChunkedBuffer) loadChunk(idx int) ([]byte, error) {
|
||||||
start := idx * cb.chunkSize
|
start := idx * cb.chunkSize
|
||||||
chunk, err := cb.FS.ReadFileAt(cb.filename, start, cb.chunkSize)
|
chunk, err := cb.FS.ReadFileAt(cb.filename, start, cb.chunkSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read chunk %d: %w", idx, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
cb.chunks[idx] = chunk // Store the loaded chunk
|
for len(cb.chunks) <= idx {
|
||||||
|
cb.chunks = append(cb.chunks, nil)
|
||||||
|
}
|
||||||
|
cb.chunks[idx] = chunk
|
||||||
return chunk, nil
|
return chunk, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadChunk explicitly loads a chunk and prefetch adjacent ones.
|
// LoadChunk loads a chunk (no-op for fully-loaded in-range files).
|
||||||
func (cb *ChunkedBuffer) LoadChunk(idx int) {
|
func (cb *ChunkedBuffer) LoadChunk(idx int) {
|
||||||
if _, ok := cb.chunks[idx]; !ok {
|
if cb.fullyLoaded {
|
||||||
_, err := cb.loadChunk(idx)
|
return
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error loading chunk %d: %v\n", idx, err)
|
|
||||||
}
|
}
|
||||||
|
if idx >= 0 && idx < len(cb.chunks) && cb.chunks[idx] != nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
// Prefetch adjacent chunks
|
if _, err := cb.loadChunk(idx); err != nil {
|
||||||
cb.Prefetch(idx, 1)
|
// Best effort; in-range files should never reach here.
|
||||||
|
}
|
||||||
|
cb.lastPrefetchedChunk = idx
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsChunkLoaded returns true if the chunk is already in memory.
|
// IsChunkLoaded reports whether the chunk is resident.
|
||||||
func (cb *ChunkedBuffer) IsChunkLoaded(idx int) bool {
|
func (cb *ChunkedBuffer) IsChunkLoaded(idx int) bool {
|
||||||
_, ok := cb.chunks[idx]
|
return idx >= 0 && idx < len(cb.chunks) && cb.chunks[idx] != nil
|
||||||
return ok
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsChunkLoading returns true if the chunk is currently being loaded.
|
// IsChunkLoading reports whether a chunk load is in flight (never, for
|
||||||
|
// in-range files).
|
||||||
func (cb *ChunkedBuffer) IsChunkLoading(idx int) bool {
|
func (cb *ChunkedBuffer) IsChunkLoading(idx int) bool {
|
||||||
return cb.loadingChunks[idx]
|
return cb.loadingChunks[idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadChunkAsync dispatches an async task to load a chunk.
|
// LoadChunkAsync dispatches an async chunk load (no-op for in-range files,
|
||||||
|
// which are fully resident).
|
||||||
func (cb *ChunkedBuffer) LoadChunkAsync(idx int) {
|
func (cb *ChunkedBuffer) LoadChunkAsync(idx int) {
|
||||||
if cb.workerPool != nil && !cb.loadingChunks[idx] {
|
if cb.fullyLoaded {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cb.workerPool != nil && !cb.loadingChunks[idx] && !cb.IsChunkLoaded(idx) {
|
||||||
cb.loadingChunks[idx] = true
|
cb.loadingChunks[idx] = true
|
||||||
cb.workerPool.DispatchNonBlocking(
|
cb.workerPool.DispatchNonBlocking(
|
||||||
pool.NewReadChunkTask(cb.filename, idx, cb.FS),
|
pool.NewReadChunkTask(cb.filename, idx, cb.FS),
|
||||||
|
|
@ -278,211 +283,112 @@ func (cb *ChunkedBuffer) LoadChunkAsync(idx int) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetWorkerPool sets the worker pool for async chunk loading.
|
// SetWorkerPool sets the worker pool (retained for compatibility).
|
||||||
func (cb *ChunkedBuffer) SetWorkerPool(wp *pool.WorkerPool) {
|
func (cb *ChunkedBuffer) SetWorkerPool(wp *pool.WorkerPool) {
|
||||||
cb.workerPool = wp
|
cb.workerPool = wp
|
||||||
}
|
}
|
||||||
|
|
||||||
// LastPrefetchedChunk returns the last chunk that was prefetched.
|
// LastPrefetchedChunk returns the last prefetched chunk index.
|
||||||
// Used by EditorLayout to avoid redundant prefetches.
|
|
||||||
func (cb *ChunkedBuffer) LastPrefetchedChunk() int {
|
func (cb *ChunkedBuffer) LastPrefetchedChunk() int {
|
||||||
return cb.lastPrefetchedChunk
|
return cb.lastPrefetchedChunk
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefetch loads adjacent chunks for smooth scrolling.
|
// Prefetch is a no-op for in-range files (all chunks resident).
|
||||||
// radius is the number of chunks to load on each side.
|
|
||||||
// Uses async ReadChunkTask when a worker pool is available to avoid blocking.
|
|
||||||
func (cb *ChunkedBuffer) Prefetch(centerChunk int, radius int) {
|
func (cb *ChunkedBuffer) Prefetch(centerChunk int, radius int) {
|
||||||
numChunks := int((cb.fileLen + int64(cb.chunkSize) - 1) / int64(cb.chunkSize))
|
|
||||||
for i := centerChunk - radius; i <= centerChunk + radius; i++ {
|
|
||||||
if i >= 0 && i < numChunks {
|
|
||||||
// Check if chunk is in memory or already loading.
|
|
||||||
if _, ok := cb.chunks[i]; !ok && !cb.loadingChunks[i] {
|
|
||||||
if cb.workerPool != nil {
|
|
||||||
// Mark as loading before dispatching to prevent redundant requests
|
|
||||||
cb.loadingChunks[i] = true
|
|
||||||
// Dispatch async read chunk task
|
|
||||||
cb.workerPool.DispatchNonBlocking(
|
|
||||||
pool.NewReadChunkTask(cb.filename, i, cb.FS),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Fallback: synchronous load (should only happen during testing)
|
|
||||||
_, err := cb.loadChunk(i)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error prefetching chunk %d: %v\n", i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cb.lastPrefetchedChunk = centerChunk
|
cb.lastPrefetchedChunk = centerChunk
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvictFarChunks removes chunks that are too far from the cursor.
|
// EvictFarChunks is intentionally a no-op for in-range files: all chunks stay
|
||||||
// radius is the number of chunks to keep around the cursor.
|
// resident so byte->chunk mapping and FullContent remain exact. (Evicting
|
||||||
// Dirty chunks (modified since the last disk write) are NEVER evicted,
|
// would reintroduce the stale-disk re-read hazard the fixed-slot model had.)
|
||||||
// even if they are far from the cursor, to prevent data loss.
|
|
||||||
func (cb *ChunkedBuffer) EvictFarChunks(cursorPos int, radius int) {
|
func (cb *ChunkedBuffer) EvictFarChunks(cursorPos int, radius int) {
|
||||||
if cb.fileLen == 0 {
|
// no-op
|
||||||
return
|
}
|
||||||
}
|
|
||||||
cursorChunk := cursorPos / cb.chunkSize
|
|
||||||
numChunks := int((cb.fileLen + int64(cb.chunkSize) - 1) / int64(cb.chunkSize))
|
|
||||||
|
|
||||||
for i := range cb.chunks {
|
// markDirtyChunk flags the buffer and a chunk as modified.
|
||||||
// NEVER evict dirty chunks — they contain unsaved modifications
|
func (cb *ChunkedBuffer) markDirtyChunk(idx int) {
|
||||||
if cb.dirtyChunks[i] {
|
cb.dirty = true
|
||||||
continue
|
if idx >= 0 {
|
||||||
}
|
cb.dirtyChunks[idx] = true
|
||||||
if i < cursorChunk-radius || i > cursorChunk+radius {
|
|
||||||
// Check if chunk index is within valid range before deleting
|
|
||||||
if i >= 0 && i < numChunks {
|
|
||||||
delete(cb.chunks, i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert inserts text at a given position.
|
// Insert inserts text at byte position pos, splicing only the affected chunk.
|
||||||
func (cb *ChunkedBuffer) Insert(pos int, text string) {
|
func (cb *ChunkedBuffer) Insert(pos int, text string) {
|
||||||
if len(text) == 0 {
|
if len(text) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Ensure the chunk containing pos is loaded
|
fileLen := int(cb.fileLen)
|
||||||
chunkIdx := pos / cb.chunkSize
|
if pos > fileLen {
|
||||||
// Ensure we don't try to insert beyond the current fileLen if it's not a new file
|
pos = fileLen // clamp to end
|
||||||
if pos > int(cb.fileLen) && cb.fileLen > 0 {
|
|
||||||
pos = int(cb.fileLen) // clamp insertion point to end of file
|
|
||||||
}
|
}
|
||||||
if pos < 0 {
|
if pos < 0 {
|
||||||
pos = 0
|
pos = 0
|
||||||
}
|
}
|
||||||
|
idx, local := cb.chunkForPos(pos)
|
||||||
// Load chunk if it doesn't exist, or if pos is at the very beginning of a non-loaded chunk
|
if idx < 0 {
|
||||||
if _, ok := cb.chunks[chunkIdx]; !ok {
|
// Empty buffer: create the first chunk.
|
||||||
// If inserting at the start of a chunk, we need to load it.
|
cb.chunks = append(cb.chunks, []byte(text))
|
||||||
// If inserting past the end of the file, we might create new chunks.
|
cb.fileLen = int64(len(text))
|
||||||
// For now, assume loadChunk handles cases where pos is beyond current fileLen by reading up to fileLen.
|
cb.markDirtyChunk(0)
|
||||||
_, err := cb.loadChunk(chunkIdx) // This is blocking and might be problematic
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error loading chunk %d for insert: %v\n", chunkIdx, err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
chunk := cb.chunks[idx]
|
||||||
|
newChunk := make([]byte, 0, len(chunk)+len(text))
|
||||||
chunk := cb.chunks[chunkIdx]
|
newChunk = append(newChunk, chunk[:local]...)
|
||||||
offsetInChunk := pos - chunkIdx*cb.chunkSize
|
newChunk = append(newChunk, text...)
|
||||||
|
newChunk = append(newChunk, chunk[local:]...)
|
||||||
// Ensure offsetInChunk is valid for the loaded chunk
|
cb.chunks[idx] = newChunk
|
||||||
if offsetInChunk > len(chunk) {
|
|
||||||
// This can happen if we are inserting past the end of the loaded chunk,
|
|
||||||
// which might be due to fileLen not being updated or insertion into new territory.
|
|
||||||
// For now, we'll pad the chunk if needed. This needs more robust handling.
|
|
||||||
padding := make([]byte, offsetInChunk-len(chunk))
|
|
||||||
chunk = append(chunk, padding...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert into the chunk
|
|
||||||
newChunk := make([]byte, len(chunk)+len(text))
|
|
||||||
copy(newChunk, chunk[:offsetInChunk])
|
|
||||||
copy(newChunk[offsetInChunk:], text)
|
|
||||||
copy(newChunk[offsetInChunk+len(text):], chunk[offsetInChunk:])
|
|
||||||
cb.chunks[chunkIdx] = newChunk
|
|
||||||
|
|
||||||
cb.fileLen += int64(len(text))
|
cb.fileLen += int64(len(text))
|
||||||
|
cb.markDirtyChunk(idx)
|
||||||
cb.dirty = true
|
|
||||||
cb.dirtyChunks[chunkIdx] = true
|
|
||||||
|
|
||||||
// If insertion spans chunk boundary, it might require merging chunks.
|
|
||||||
// This is complex and might involve resizing subsequent chunks and potentially
|
|
||||||
// re-reading them. For now, we defer complex merge logic.
|
|
||||||
// The plan mentions maybeMergeChunk, which would handle this.
|
|
||||||
// cb.maybeMergeChunk(chunkIdx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete deletes n bytes starting at pos.
|
// Delete deletes n bytes starting at pos, splicing only the affected chunk(s).
|
||||||
func (cb *ChunkedBuffer) Delete(pos, n int) {
|
func (cb *ChunkedBuffer) Delete(pos, n int) {
|
||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Clamp pos and n to valid range
|
fileLen := int(cb.fileLen)
|
||||||
if pos < 0 {
|
if pos < 0 {
|
||||||
pos = 0
|
pos = 0
|
||||||
}
|
}
|
||||||
if pos >= int(cb.fileLen) {
|
if pos >= fileLen {
|
||||||
return // Nothing to delete
|
return
|
||||||
}
|
}
|
||||||
if pos+n > int(cb.fileLen) {
|
if pos+n > fileLen {
|
||||||
n = int(cb.fileLen) - pos
|
n = fileLen - pos
|
||||||
}
|
}
|
||||||
|
|
||||||
startChunk := pos / cb.chunkSize
|
|
||||||
endChunk := (pos + n - 1) / cb.chunkSize
|
|
||||||
|
|
||||||
// Load all affected chunks
|
|
||||||
for i := startChunk; i <= endChunk; i++ {
|
|
||||||
if i < 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := cb.chunks[i]; !ok {
|
|
||||||
_, err := cb.loadChunk(i)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error loading chunk %d for delete: %v\n", i, err)
|
|
||||||
return // Abort delete on error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform deletion across all affected chunks. The deletion region is the
|
|
||||||
// absolute byte range [pos, pos+n); each chunk we visit removes its
|
|
||||||
// overlap with that range. (Using a shrinking "remaining" to derive the
|
|
||||||
// per-chunk end was wrong: it mis-computed the end for every chunk after
|
|
||||||
// the first, corrupting multi-chunk deletes.)
|
|
||||||
absEnd := pos + n
|
absEnd := pos + n
|
||||||
remaining := n
|
offset := 0
|
||||||
for i := startChunk; i <= endChunk && remaining > 0; i++ {
|
for i := range cb.chunks {
|
||||||
if i < 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
chunk := cb.chunks[i]
|
chunk := cb.chunks[i]
|
||||||
if chunk == nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
cb.chunks[i] = append(chunk[:delStart], chunk[delEnd:]...)
|
||||||
currentChunkStart := i * cb.chunkSize
|
cb.markDirtyChunk(i)
|
||||||
currentChunkEnd := currentChunkStart + len(chunk)
|
|
||||||
|
|
||||||
// Overlap of [pos, absEnd) with this chunk's absolute span.
|
|
||||||
delStart := max(pos, currentChunkStart)
|
|
||||||
delEnd := min(absEnd, currentChunkEnd)
|
|
||||||
if delStart >= delEnd {
|
|
||||||
continue // Deletion range doesn't overlap this chunk
|
|
||||||
}
|
}
|
||||||
localStart := delStart - currentChunkStart
|
|
||||||
localEnd := delEnd - currentChunkStart
|
|
||||||
|
|
||||||
// Perform deletion within the chunk
|
|
||||||
cb.chunks[i] = append(chunk[:localStart], chunk[localEnd:]...)
|
|
||||||
|
|
||||||
remaining -= delEnd - delStart
|
|
||||||
|
|
||||||
// Mark this chunk as dirty so it won't be evicted
|
|
||||||
cb.dirtyChunks[i] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update file length
|
|
||||||
cb.fileLen -= int64(n)
|
cb.fileLen -= int64(n)
|
||||||
if cb.fileLen < 0 {
|
if cb.fileLen < 0 {
|
||||||
cb.fileLen = 0
|
cb.fileLen = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
cb.dirty = true
|
cb.dirty = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VisibleByteRange returns the byte range [start, end) that is visible on the
|
||||||
// VisibleByteRange returns the byte range [start, end) of content
|
// current editor viewport, plus the visual line at the top of the viewport.
|
||||||
// visible in the viewport, given the current scroll offset and viewport height.
|
// 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) {
|
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) {
|
||||||
// If wrapping and we have visual line data, use it for precise calculation
|
// If wrapping and we have visual line data, use it for precise calculation
|
||||||
if wordWrap && len(layout.VisualLineStarts) > 0 {
|
if wordWrap && len(layout.VisualLineStarts) > 0 {
|
||||||
|
|
@ -493,10 +399,9 @@ func (cb *ChunkedBuffer) VisibleByteRange(scrollOffset ui.Dp, byteOffset int, vi
|
||||||
}
|
}
|
||||||
|
|
||||||
start = layout.VisualLineStarts[visualLine] + byteOffset
|
start = layout.VisualLineStarts[visualLine] + byteOffset
|
||||||
fmt.Printf("VisibleByteRange: visualLine = %d start = %d\n", visualLine, start)
|
|
||||||
|
|
||||||
// Calculate end: enough content to fill viewport + buffer
|
// Calculate end: enough content to fill viewport + buffer
|
||||||
linesInViewport := int(viewportHeight / lineHeight) + 2
|
linesInViewport := int(viewportHeight/lineHeight) + 2
|
||||||
endLine := visualLine + linesInViewport
|
endLine := visualLine + linesInViewport
|
||||||
if endLine >= len(layout.VisualLineStarts) {
|
if endLine >= len(layout.VisualLineStarts) {
|
||||||
end = int(cb.fileLen)
|
end = int(cb.fileLen)
|
||||||
|
|
@ -531,11 +436,6 @@ func (cb *ChunkedBuffer) VisibleByteRange(scrollOffset ui.Dp, byteOffset int, vi
|
||||||
// visibleByteRangeEstimate approximates the visible byte range using
|
// visibleByteRangeEstimate approximates the visible byte range using
|
||||||
// heuristic estimates. Used when the line index is not yet available.
|
// heuristic estimates. Used when the line index is not yet available.
|
||||||
func (cb *ChunkedBuffer) visibleByteRangeEstimate(scrollOffset ui.Dp, viewportHeight ui.Dp) (start, end int) {
|
func (cb *ChunkedBuffer) visibleByteRangeEstimate(scrollOffset ui.Dp, viewportHeight ui.Dp) (start, end int) {
|
||||||
// This is a rough estimation. A proper implementation would need actual line height.
|
|
||||||
// For simplicity, assuming a fixed line height based on FontSize.
|
|
||||||
// This requires access to theme or font metrics, which is not directly available here.
|
|
||||||
// As a fallback, let's use a simplified calculation based on estimated lines and average bytes per line.
|
|
||||||
|
|
||||||
// Use the editor's line height constant for consistency.
|
// Use the editor's line height constant for consistency.
|
||||||
lineHeight := EditorLineHeight()
|
lineHeight := EditorLineHeight()
|
||||||
|
|
||||||
|
|
@ -545,38 +445,40 @@ func (cb *ChunkedBuffer) visibleByteRangeEstimate(scrollOffset ui.Dp, viewportHe
|
||||||
// Clamp line numbers to reasonable bounds
|
// Clamp line numbers to reasonable bounds
|
||||||
totalLinesEstimate := 0
|
totalLinesEstimate := 0
|
||||||
if cb.fileLen > 0 {
|
if cb.fileLen > 0 {
|
||||||
totalLinesEstimate = int(cb.fileLen / 50) + 1 // Rough estimate: 50 bytes per line
|
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
|
||||||
}
|
}
|
||||||
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
|
// Convert line numbers to byte offsets using the line index if available.
|
||||||
// If LineIndex is nil, we fall back to a very rough byte estimation.
|
|
||||||
if cb.LineIndex != nil {
|
if cb.LineIndex != nil {
|
||||||
// Use LineIndex if it exists
|
|
||||||
if startLine < len(cb.LineIndex.Offsets) {
|
if startLine < len(cb.LineIndex.Offsets) {
|
||||||
start = int(cb.LineIndex.Offsets[startLine])
|
start = int(cb.LineIndex.Offsets[startLine])
|
||||||
} else {
|
} else {
|
||||||
// If startLine is beyond index, estimate based on last known offset and average line length
|
|
||||||
lastKnownOffset := int64(0)
|
lastKnownOffset := int64(0)
|
||||||
if len(cb.LineIndex.Offsets) > 0 {
|
if len(cb.LineIndex.Offsets) > 0 {
|
||||||
lastKnownOffset = int64(cb.LineIndex.Offsets[len(cb.LineIndex.Offsets)-1])
|
lastKnownOffset = int64(cb.LineIndex.Offsets[len(cb.LineIndex.Offsets)-1])
|
||||||
}
|
}
|
||||||
linesBeyondIndex := startLine - (len(cb.LineIndex.Offsets) -1)
|
linesBeyondIndex := startLine - (len(cb.LineIndex.Offsets) - 1)
|
||||||
start = int(lastKnownOffset + int64(linesBeyondIndex) * 50) // Estimate
|
start = int(lastKnownOffset + int64(linesBeyondIndex)*50) // Estimate
|
||||||
}
|
}
|
||||||
|
|
||||||
if endLine < len(cb.LineIndex.Offsets) {
|
if endLine < len(cb.LineIndex.Offsets) {
|
||||||
end = int(cb.LineIndex.Offsets[endLine])
|
end = int(cb.LineIndex.Offsets[endLine])
|
||||||
} else {
|
} else {
|
||||||
// If endLine is beyond index, estimate
|
|
||||||
lastKnownOffset := int64(0)
|
lastKnownOffset := int64(0)
|
||||||
if len(cb.LineIndex.Offsets) > 0 {
|
if len(cb.LineIndex.Offsets) > 0 {
|
||||||
lastKnownOffset = int64(cb.LineIndex.Offsets[len(cb.LineIndex.Offsets)-1])
|
lastKnownOffset = int64(cb.LineIndex.Offsets[len(cb.LineIndex.Offsets)-1])
|
||||||
}
|
}
|
||||||
linesBeyondIndex := endLine - (len(cb.LineIndex.Offsets) -1)
|
linesBeyondIndex := endLine - (len(cb.LineIndex.Offsets) - 1)
|
||||||
end = int(lastKnownOffset + int64(linesBeyondIndex) * 50) // Estimate
|
end = int(lastKnownOffset + int64(linesBeyondIndex)*50) // Estimate
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Rough byte estimation if no LineIndex
|
// Rough byte estimation if no LineIndex
|
||||||
|
|
@ -586,9 +488,15 @@ func (cb *ChunkedBuffer) visibleByteRangeEstimate(scrollOffset ui.Dp, viewportHe
|
||||||
|
|
||||||
// Clamp to file bounds
|
// Clamp to file bounds
|
||||||
if cb.fileLen > 0 {
|
if cb.fileLen > 0 {
|
||||||
if start < 0 { start = 0 }
|
if start < 0 {
|
||||||
if end > int(cb.fileLen) { end = int(cb.fileLen) }
|
start = 0
|
||||||
if end <= start { end = start + cb.chunkSize } // Ensure at least one chunk's worth if range is invalid
|
}
|
||||||
|
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 {
|
} else {
|
||||||
start = 0
|
start = 0
|
||||||
end = 0 // Empty file
|
end = 0 // Empty file
|
||||||
|
|
@ -600,79 +508,64 @@ func (cb *ChunkedBuffer) visibleByteRangeEstimate(scrollOffset ui.Dp, viewportHe
|
||||||
// visibleByteRangePrecise uses the LineIndex to find the exact byte range.
|
// visibleByteRangePrecise uses the LineIndex to find the exact byte range.
|
||||||
func (cb *ChunkedBuffer) visibleByteRangePrecise(scrollOffset ui.Dp, viewportHeight ui.Dp) (start, end int) {
|
func (cb *ChunkedBuffer) visibleByteRangePrecise(scrollOffset ui.Dp, viewportHeight ui.Dp) (start, end int) {
|
||||||
if cb.LineIndex == nil || len(cb.LineIndex.Offsets) == 0 {
|
if cb.LineIndex == nil || len(cb.LineIndex.Offsets) == 0 {
|
||||||
// Should not happen if called after LineIndex is available, but as a safeguard:
|
|
||||||
return cb.visibleByteRangeEstimate(scrollOffset, viewportHeight)
|
return cb.visibleByteRangeEstimate(scrollOffset, viewportHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the editor's line height constant for consistency.
|
|
||||||
lineHeight := EditorLineHeight()
|
lineHeight := EditorLineHeight()
|
||||||
|
|
||||||
startLine := int(scrollOffset / lineHeight)
|
startLine := int(scrollOffset / lineHeight)
|
||||||
endLine := int((scrollOffset + viewportHeight) / lineHeight)
|
endLine := int((scrollOffset + viewportHeight) / lineHeight)
|
||||||
|
|
||||||
// Clamp line numbers to the available range in LineIndex
|
|
||||||
if startLine < 0 {
|
if startLine < 0 {
|
||||||
startLine = 0
|
startLine = 0
|
||||||
}
|
}
|
||||||
if startLine >= len(cb.LineIndex.Offsets) {
|
if startLine >= len(cb.LineIndex.Offsets) {
|
||||||
startLine = len(cb.LineIndex.Offsets) - 1 // Last available line
|
startLine = len(cb.LineIndex.Offsets) - 1
|
||||||
}
|
}
|
||||||
|
if endLine < 0 {
|
||||||
if endLine < 0 { // Should not happen with positive viewportHeight
|
|
||||||
endLine = 0
|
endLine = 0
|
||||||
}
|
}
|
||||||
if endLine >= len(cb.LineIndex.Offsets) {
|
if endLine >= len(cb.LineIndex.Offsets) {
|
||||||
endLine = len(cb.LineIndex.Offsets) - 1 // Last available line
|
endLine = len(cb.LineIndex.Offsets) - 1
|
||||||
}
|
}
|
||||||
// Ensure endLine is at least startLine + 1, unless startLine is already the last line.
|
|
||||||
if startLine < len(cb.LineIndex.Offsets)-1 && endLine <= startLine {
|
if startLine < len(cb.LineIndex.Offsets)-1 && endLine <= startLine {
|
||||||
endLine = startLine + 1
|
endLine = startLine + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
start = int(cb.LineIndex.Offsets[startLine])
|
start = int(cb.LineIndex.Offsets[startLine])
|
||||||
|
|
||||||
// The end byte offset is the start of the *next* line after the visible range.
|
|
||||||
// If endLine is the last line in the index, the end byte offset is the file length.
|
|
||||||
if endLine+1 < len(cb.LineIndex.Offsets) {
|
if endLine+1 < len(cb.LineIndex.Offsets) {
|
||||||
end = int(cb.LineIndex.Offsets[endLine+1])
|
end = int(cb.LineIndex.Offsets[endLine+1])
|
||||||
} else {
|
} else {
|
||||||
end = int(cb.fileLen) // Use file length as the end if it's the last line
|
end = int(cb.fileLen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure range is within file bounds
|
if start < 0 {
|
||||||
if start < 0 { start = 0 }
|
start = 0
|
||||||
if end > int(cb.fileLen) { end = int(cb.fileLen) }
|
}
|
||||||
|
if end > int(cb.fileLen) {
|
||||||
|
end = int(cb.fileLen)
|
||||||
|
}
|
||||||
if end <= start {
|
if end <= start {
|
||||||
// If somehow the range is invalid, return a minimal valid range,
|
|
||||||
// e.g., start of the start line to a bit past it, or just end of file.
|
|
||||||
if start < int(cb.fileLen) {
|
if start < int(cb.fileLen) {
|
||||||
end = min(start+cb.chunkSize, int(cb.fileLen)) // At least one chunk or up to file end
|
end = min(start+cb.chunkSize, int(cb.fileLen))
|
||||||
} else {
|
} else {
|
||||||
end = start // If start is already at file end, range is empty
|
end = start
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return start, end
|
return start, end
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeMergeChunk is a placeholder for logic that consolidates chunks if they become too small
|
// UpdateLineIndexAfterEdit updates the LineIndex offsets after an insert or
|
||||||
// or if edits cause fragmentation. This is complex and deferred.
|
// delete. offsetShift is the number of bytes inserted (positive) or deleted
|
||||||
func (cb *ChunkedBuffer) maybeMergeChunk(chunkIdx int) {
|
// (negative); editPos is the byte position where the edit occurred.
|
||||||
// Placeholder for future implementation
|
|
||||||
// This would involve checking chunk sizes and potentially merging adjacent chunks.
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateLineIndexAfterEdit updates the LineIndex offsets after an insert or delete.
|
|
||||||
// offsetShift is the number of bytes inserted (positive) or deleted (negative).
|
|
||||||
// editPos is the byte position where the edit occurred.
|
|
||||||
// This is a partial update: only offsets after editPos are shifted.
|
|
||||||
func (cb *ChunkedBuffer) UpdateLineIndexAfterEdit(editPos int, offsetShift int) {
|
func (cb *ChunkedBuffer) UpdateLineIndexAfterEdit(editPos int, offsetShift int) {
|
||||||
if cb.LineIndex == nil {
|
if cb.LineIndex == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Find the first offset that needs updating using binary search.
|
// Find the first offset that needs updating using binary search. All
|
||||||
// All offsets >= editPos need to be shifted by offsetShift.
|
// offsets >= editPos are shifted by offsetShift.
|
||||||
idx := sort.Search(len(cb.LineIndex.Offsets), func(i int) bool {
|
idx := sort.Search(len(cb.LineIndex.Offsets), func(i int) bool {
|
||||||
return int(cb.LineIndex.Offsets[i]) >= editPos
|
return int(cb.LineIndex.Offsets[i]) >= editPos
|
||||||
})
|
})
|
||||||
|
|
@ -682,14 +575,13 @@ func (cb *ChunkedBuffer) UpdateLineIndexAfterEdit(editPos int, offsetShift int)
|
||||||
for i := idx; i < len(cb.LineIndex.Offsets); i++ {
|
for i := idx; i < len(cb.LineIndex.Offsets); i++ {
|
||||||
cb.LineIndex.Offsets[i] += int32(offsetShift)
|
cb.LineIndex.Offsets[i] += int32(offsetShift)
|
||||||
}
|
}
|
||||||
// Update the file size stamp
|
|
||||||
cb.LineIndex.Size += int64(offsetShift)
|
cb.LineIndex.Size += int64(offsetShift)
|
||||||
if cb.LineIndex.Size < 0 {
|
if cb.LineIndex.Size < 0 {
|
||||||
cb.LineIndex.Size = 0
|
cb.LineIndex.Size = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function for max
|
// max returns the larger of two ints.
|
||||||
func max(a, b int) int {
|
func max(a, b int) int {
|
||||||
if a > b {
|
if a > b {
|
||||||
return a
|
return a
|
||||||
|
|
@ -697,12 +589,10 @@ func max(a, b int) int {
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function for min
|
// min returns the smaller of two ints.
|
||||||
func min(a, b int) int {
|
func min(a, b int) int {
|
||||||
if a < b {
|
if a < b {
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,176 +1,193 @@
|
||||||
package editor
|
package editor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"pad/internal/io/pool/mock"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestEvictDirtyChunk_LosesEdits verifies that editing a chunk and then
|
// newTestBuffer builds a fully-loaded (in-range) buffer from content using the
|
||||||
// evicting it does NOT lose the modification. Dirty chunks are protected
|
// on-open path (SetContent), so all chunks are resident.
|
||||||
// from eviction even when far from the cursor.
|
func newTestBuffer(t *testing.T, content []byte) *ChunkedBuffer {
|
||||||
func TestEvictDirtyChunk_LosesEdits(t *testing.T) {
|
t.Helper()
|
||||||
mockFS := mock.NewFileSystem()
|
cb := NewChunkedBuffer("/test.txt", DefaultChunkSize, nil, "")
|
||||||
filename := "/test.txt"
|
cb.SetContent(content)
|
||||||
|
return cb
|
||||||
|
}
|
||||||
|
|
||||||
// Create a 200KB file (3 chunks: 64KB, 64KB, 72KB)
|
// TestSetContent_MultipleChunks verifies the whole-file load splits content
|
||||||
|
// into ordered chunks and Content reconstructs it exactly.
|
||||||
|
func TestSetContent_MultipleChunks(t *testing.T) {
|
||||||
content := make([]byte, 200*1024)
|
content := make([]byte, 200*1024)
|
||||||
for i := range content {
|
for i := range content {
|
||||||
content[i] = byte(i % 256)
|
content[i] = byte(i % 251)
|
||||||
}
|
}
|
||||||
mockFS.AddFile(filename, content, time.Now())
|
cb := newTestBuffer(t, content)
|
||||||
|
|
||||||
cb := NewChunkedBuffer(filename, DefaultChunkSize, mockFS, "")
|
// 200KB / 64KB = 3 full chunks (64KB each) + 1 partial (8KB) = 4 chunks.
|
||||||
cb.SetFileSize(200 * 1024)
|
if got := len(cb.chunks); got != 4 {
|
||||||
cb.LoadChunk(0)
|
t.Fatalf("expected 4 chunks, got %d", got)
|
||||||
|
|
||||||
// 1. Insert 100 bytes at position 0, modifying chunk 0
|
|
||||||
insertText := "MODIFIED"
|
|
||||||
cb.Insert(0, insertText)
|
|
||||||
|
|
||||||
// Verify the chunk grew
|
|
||||||
if len(cb.chunks[0]) != DefaultChunkSize+len(insertText) {
|
|
||||||
t.Fatalf("expected chunk 0 to grow to %d, got %d", DefaultChunkSize+len(insertText), len(cb.chunks[0]))
|
|
||||||
}
|
}
|
||||||
|
if cb.FileLen() != int64(len(content)) {
|
||||||
// 2. Simulate scrolling to chunk 2, which would normally trigger eviction of chunk 0
|
t.Fatalf("FileLen=%d, want %d", cb.FileLen(), len(content))
|
||||||
cb.EvictFarChunks(2*DefaultChunkSize, 1)
|
|
||||||
|
|
||||||
// Chunk 0 should NOT be evicted because it's dirty
|
|
||||||
if _, ok := cb.chunks[0]; !ok {
|
|
||||||
t.Fatal("expected dirty chunk 0 to survive eviction")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Reconstruct the full file — the edit at position 0 must survive
|
|
||||||
full, err := cb.FullContent()
|
full, err := cb.FullContent()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("FullContent error: %v", err)
|
||||||
}
|
}
|
||||||
|
if full != string(content) {
|
||||||
// The first len(insertText) bytes must be the inserted text
|
t.Fatalf("FullContent mismatch after SetContent")
|
||||||
if full[:len(insertText)] != insertText {
|
}
|
||||||
t.Errorf("expected first %d bytes to be %q, got %q", len(insertText), insertText, full[:len(insertText)])
|
// Spot-check a range that spans the chunk-0/chunk-1 boundary.
|
||||||
|
seg := cb.Content(64*1024-10, 64*1024+10)
|
||||||
|
if seg != string(content[64*1024-10:64*1024+10]) {
|
||||||
|
t.Fatalf("Content across chunk boundary mismatch: got %q", seg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEvictDirtyChunk_DeleteLosesEdits verifies that a deletion in a chunk
|
// TestInsertShiftsLaterChunks is the core drift-fix test: an insert in chunk 0
|
||||||
// survives eviction. Dirty chunks are never evicted.
|
// must shift every later byte, and Content must still return the correct byte
|
||||||
func TestEvictDirtyChunk_DeleteLosesEdits(t *testing.T) {
|
// at the shifted offset (the old fixed-slot i*chunkSize model failed here).
|
||||||
mockFS := mock.NewFileSystem()
|
func TestInsertShiftsLaterChunks(t *testing.T) {
|
||||||
filename := "/test.txt"
|
|
||||||
|
|
||||||
// Create a 128KB file (2 chunks of 64KB)
|
|
||||||
content := make([]byte, 128*1024)
|
|
||||||
for i := range content {
|
|
||||||
content[i] = byte(i % 256)
|
|
||||||
}
|
|
||||||
mockFS.AddFile(filename, content, time.Now())
|
|
||||||
|
|
||||||
cb := NewChunkedBuffer(filename, DefaultChunkSize, mockFS, "")
|
|
||||||
cb.SetFileSize(128 * 1024)
|
|
||||||
cb.LoadChunk(0)
|
|
||||||
cb.LoadChunk(1)
|
|
||||||
|
|
||||||
// 1. Delete 100 bytes at position 127000 (in chunk 1)
|
|
||||||
cb.Delete(127000, 100)
|
|
||||||
|
|
||||||
// Verify chunk 1 shrunk
|
|
||||||
if len(cb.chunks[1]) != DefaultChunkSize-100 {
|
|
||||||
t.Fatalf("expected chunk 1 to shrink to %d, got %d", DefaultChunkSize-100, len(cb.chunks[1]))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Evict — chunk 1 should NOT be evicted because it's dirty
|
|
||||||
cb.EvictFarChunks(0, 1)
|
|
||||||
|
|
||||||
if _, ok := cb.chunks[1]; !ok {
|
|
||||||
t.Fatal("expected dirty chunk 1 to survive eviction")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Reconstruct — the deletion must survive
|
|
||||||
full, err := cb.FullContent()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
expectedLen := 128*1024 - 100
|
|
||||||
if len(full) != expectedLen {
|
|
||||||
t.Errorf("expected file length %d after deletion, got %d", expectedLen, len(full))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEvictCleanChunk_AllowsEviction verifies that chunks WITHOUT edits
|
|
||||||
// CAN be evicted (i.e., dirty-chunk tracking doesn't prevent normal eviction).
|
|
||||||
func TestEvictCleanChunk_AllowsEviction(t *testing.T) {
|
|
||||||
mockFS := mock.NewFileSystem()
|
|
||||||
filename := "/test.txt"
|
|
||||||
|
|
||||||
content := make([]byte, 200*1024)
|
content := make([]byte, 200*1024)
|
||||||
for i := range content {
|
for i := range content {
|
||||||
content[i] = byte(i % 256)
|
content[i] = byte(i % 251)
|
||||||
}
|
}
|
||||||
mockFS.AddFile(filename, content, time.Now())
|
cb := newTestBuffer(t, content)
|
||||||
|
|
||||||
cb := NewChunkedBuffer(filename, DefaultChunkSize, mockFS, "")
|
const insLen = 3
|
||||||
cb.SetFileSize(200 * 1024)
|
cb.Insert(0, strings.Repeat("X", insLen))
|
||||||
cb.LoadChunk(0)
|
|
||||||
cb.LoadChunk(1)
|
|
||||||
cb.LoadChunk(2)
|
|
||||||
|
|
||||||
// No edits — all chunks are clean
|
// A byte originally at position 100000 (chunk 1) is now at 100003.
|
||||||
// Evict chunk 0 (far from cursor at chunk 2)
|
want := content[100000]
|
||||||
t.Logf("DEBUG: chunks before eviction: %v", cb.chunks)
|
if got := cb.Content(100000+insLen, 100001+insLen); got != string(want) {
|
||||||
cb.EvictFarChunks(2*DefaultChunkSize, 1)
|
t.Fatalf("byte at shifted offset 100003 = %q, want %q", got, string(want))
|
||||||
t.Logf("DEBUG: chunks after eviction: %v", cb.chunks)
|
|
||||||
|
|
||||||
// Chunk 0 should be evicted (it's clean)
|
|
||||||
if _, evicted := cb.chunks[0]; evicted {
|
|
||||||
t.Fatalf("expected clean chunk 0 to be evicted, but it still exists (chunks=%v)", cb.chunks)
|
|
||||||
}
|
}
|
||||||
|
// The inserted prefix is present.
|
||||||
// But chunks 1 and 2 should remain
|
if got := cb.Content(0, insLen); got != strings.Repeat("X", insLen) {
|
||||||
if _, ok := cb.chunks[1]; !ok {
|
t.Fatalf("prefix = %q, want %q", got, strings.Repeat("X", insLen))
|
||||||
t.Fatal("expected chunk 1 to remain")
|
|
||||||
}
|
}
|
||||||
if _, ok := cb.chunks[2]; !ok {
|
// Full content is the insert + original.
|
||||||
t.Fatal("expected chunk 2 to remain")
|
full, _ := cb.FullContent()
|
||||||
|
if full != strings.Repeat("X", insLen)+string(content) {
|
||||||
|
t.Fatalf("FullContent after insert mismatch (len=%d, want %d)", len(full), insLen+len(content))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFullContent_AfterEvictDirtyChunk_ReReadsFromDisk verifies that when a dirty
|
// TestDeleteShiftsLaterChunks verifies a deletion in an early chunk shifts
|
||||||
// chunk is somehow evicted (e.g., by a bug), FullContent re-reads the OLD disk
|
// later bytes left and Content/FullContent stay exact.
|
||||||
// content — confirming the data loss scenario. This test documents the bug before
|
func TestDeleteShiftsLaterChunks(t *testing.T) {
|
||||||
// it is fixed.
|
content := make([]byte, 200*1024)
|
||||||
func TestFullContent_AfterEvictDirtyChunk_ReReadsFromDisk(t *testing.T) {
|
for i := range content {
|
||||||
mockFS := mock.NewFileSystem()
|
content[i] = byte(i % 251)
|
||||||
filename := "/test.txt"
|
|
||||||
|
|
||||||
// Create a small 100-byte file (1 chunk)
|
|
||||||
content := []byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
|
|
||||||
mockFS.AddFile(filename, content, time.Now())
|
|
||||||
|
|
||||||
cb := NewChunkedBuffer(filename, DefaultChunkSize, mockFS, "")
|
|
||||||
cb.SetFileSize(100)
|
|
||||||
cb.LoadChunk(0)
|
|
||||||
|
|
||||||
// Insert at position 0 — modifies chunk 0 in memory
|
|
||||||
cb.Insert(0, "X")
|
|
||||||
|
|
||||||
// The in-memory chunk 0 should now be 101 bytes starting with "X"
|
|
||||||
t.Logf("DEBUG: chunk 0 len=%d, first byte=%q, dirtyChunks=%v", len(cb.chunks[0]), cb.chunks[0][0], cb.dirtyChunks)
|
|
||||||
if cb.chunks[0][0] != 'X' {
|
|
||||||
t.Fatal("expected in-memory chunk to start with 'X'")
|
|
||||||
}
|
}
|
||||||
|
cb := newTestBuffer(t, content)
|
||||||
|
|
||||||
// Manually evict the dirty chunk (simulating the bug)
|
const delLen = 100
|
||||||
t.Logf("DEBUG: before delete, chunks=%v", cb.chunks)
|
cb.Delete(0, delLen)
|
||||||
delete(cb.chunks, 0)
|
|
||||||
t.Logf("DEBUG: after delete, chunks=%v", cb.chunks)
|
|
||||||
|
|
||||||
// FullContent will now return an error because the dirty chunk is missing
|
// A byte originally at 100000 is now at 99900.
|
||||||
full, err := cb.FullContent()
|
want := content[100000]
|
||||||
if err == nil {
|
if got := cb.Content(100000-delLen, 100001-delLen); got != string(want) {
|
||||||
t.Errorf("expected error due to missing dirty chunk, got nil, full content: %q", full)
|
t.Fatalf("byte at shifted offset = %q, want %q", got, string(want))
|
||||||
} else {
|
}
|
||||||
t.Logf("DEBUG: caught expected error: %v", err)
|
full, _ := cb.FullContent()
|
||||||
|
if full != string(content[delLen:]) {
|
||||||
|
t.Fatalf("FullContent after delete mismatch (len=%d, want %d)", len(full), len(content)-delLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteSpanningChunks verifies a deletion that spans multiple chunk
|
||||||
|
// boundaries is exact.
|
||||||
|
func TestDeleteSpanningChunks(t *testing.T) {
|
||||||
|
content := make([]byte, 200*1024)
|
||||||
|
for i := range content {
|
||||||
|
content[i] = byte(i % 251)
|
||||||
|
}
|
||||||
|
cb := newTestBuffer(t, content)
|
||||||
|
|
||||||
|
// Delete 64KB+50 bytes starting at 30KB (spans chunks 0,1,2).
|
||||||
|
const start = 30 * 1024
|
||||||
|
const n = 64*1024 + 50
|
||||||
|
cb.Delete(start, n)
|
||||||
|
|
||||||
|
expected := make([]byte, 0, len(content)-n)
|
||||||
|
expected = append(expected, content[:start]...)
|
||||||
|
expected = append(expected, content[start+n:]...)
|
||||||
|
full, _ := cb.FullContent()
|
||||||
|
if !bytes.Equal([]byte(full), expected) {
|
||||||
|
t.Fatalf("FullContent after spanning delete mismatch (len=%d, want %d)", len(full), len(expected))
|
||||||
|
}
|
||||||
|
if cb.FileLen() != int64(len(expected)) {
|
||||||
|
t.Fatalf("FileLen=%d, want %d", cb.FileLen(), len(expected))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRuneIndexToByteAfterEdit verifies the IME rune->byte bridge stays correct
|
||||||
|
// after a length-changing edit (ASCII + multibyte).
|
||||||
|
func TestRuneIndexToByteAfterEdit(t *testing.T) {
|
||||||
|
// 2 chunks of ASCII so the edit crosses into chunk arithmetic.
|
||||||
|
var b bytes.Buffer
|
||||||
|
for i := 0; i < 100*1024; i++ {
|
||||||
|
b.WriteByte(byte('a' + i%26))
|
||||||
|
}
|
||||||
|
cb := newTestBuffer(t, b.Bytes())
|
||||||
|
|
||||||
|
// Insert a multibyte rune ("é" = 2 bytes) at rune 0.
|
||||||
|
cb.Insert(0, "é")
|
||||||
|
// Rune 0 is now 'é' (bytes 0..1). Rune 1 is the original first 'a' at byte 2.
|
||||||
|
if got := cb.RuneIndexToByte(0); got != 0 {
|
||||||
|
t.Fatalf("RuneIndexToByte(0)=%d, want 0", got)
|
||||||
|
}
|
||||||
|
if got := cb.RuneIndexToByte(1); got != 2 {
|
||||||
|
t.Fatalf("RuneIndexToByte(1)=%d, want 2", got)
|
||||||
|
}
|
||||||
|
// New rune R (R>0) is the original rune R-1 (originally at byte R-1), now
|
||||||
|
// at byte (R-1)+2 = R+1 (é is 1 rune, 2 bytes). Check one deep in chunk 1
|
||||||
|
// (past the 64KB boundary) to confirm the shift survives chunk boundaries.
|
||||||
|
deep := 70000
|
||||||
|
if got := cb.RuneIndexToByte(deep); got != deep+1 {
|
||||||
|
t.Fatalf("RuneIndexToByte(%d)=%d, want %d", deep, got, deep+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContent_BoundsAndEmpty guards edge cases in the rewritten Content.
|
||||||
|
func TestContent_BoundsAndEmpty(t *testing.T) {
|
||||||
|
cb := newTestBuffer(t, []byte("hello world"))
|
||||||
|
if got := cb.Content(0, 5); got != "hello" {
|
||||||
|
t.Fatalf("Content(0,5)=%q, want hello", got)
|
||||||
|
}
|
||||||
|
if got := cb.Content(6, 11); got != "world" {
|
||||||
|
t.Fatalf("Content(6,11)=%q, want world", got)
|
||||||
|
}
|
||||||
|
// Out-of-range end clamps to fileLen.
|
||||||
|
if got := cb.Content(0, 1000); got != "hello world" {
|
||||||
|
t.Fatalf("Content(0,1000)=%q, want full", got)
|
||||||
|
}
|
||||||
|
// start >= end is empty.
|
||||||
|
if got := cb.Content(5, 5); got != "" {
|
||||||
|
t.Fatalf("Content(5,5)=%q, want empty", got)
|
||||||
|
}
|
||||||
|
// Empty buffer.
|
||||||
|
empty := newTestBuffer(t, nil)
|
||||||
|
if got := empty.Content(0, 10); got != "" {
|
||||||
|
t.Fatalf("empty Content=%q, want empty", got)
|
||||||
|
}
|
||||||
|
if got, _ := empty.FullContent(); got != "" {
|
||||||
|
t.Fatalf("empty FullContent=%q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppendAtEnd inserts at the very end of the buffer (pos == fileLen), which
|
||||||
|
// maps to the last chunk's end.
|
||||||
|
func TestAppendAtEnd(t *testing.T) {
|
||||||
|
cb := newTestBuffer(t, []byte("abcdef"))
|
||||||
|
cb.Insert(6, "XYZ")
|
||||||
|
full, _ := cb.FullContent()
|
||||||
|
if full != "abcdefXYZ" {
|
||||||
|
t.Fatalf("append at end = %q, want abcdefXYZ", full)
|
||||||
|
}
|
||||||
|
if cb.FileLen() != 9 {
|
||||||
|
t.Fatalf("FileLen=%d, want 9", cb.FileLen())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -184,45 +184,16 @@ func TestLargeFileChunkBoundary(t *testing.T) {
|
||||||
t.Fatalf("Failed to read saved file from mockFS: %v", err)
|
t.Fatalf("Failed to read saved file from mockFS: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the expected content using ChunkedBuffer's exact design:
|
// Build the expected content by applying the same three inserts to the
|
||||||
// Each chunk of size chunkSize acts as an independent buffer.
|
// original content as a whole, in order. This is the shift-correct ground
|
||||||
// We divide the original content into chunks, apply the edits to the correct chunk,
|
// truth: each insert at an absolute position pushes all later bytes right.
|
||||||
// and then concatenate them.
|
// (The old fixed-slot model treated each chunk as an independent buffer and
|
||||||
chunks := make([][]byte, 4)
|
// did NOT shift later chunks; that was the bug this design replaces.)
|
||||||
for i := 0; i < 4; i++ {
|
expectedStr := string(initialContent)
|
||||||
start := i * chunkSize
|
expectedStr = expectedStr[:65000] + "CHUNK1" + expectedStr[65000:] // insert 1 @65000
|
||||||
end := start + chunkSize
|
expectedStr = expectedStr[:65535] + "BOUNDARY" + expectedStr[65535:] // insert 2 @65535
|
||||||
if end > fileSize {
|
expectedStr = expectedStr[:262000] + "CHUNK3" + expectedStr[262000:] // insert 3 @262000
|
||||||
end = fileSize
|
expected := []byte(expectedStr)
|
||||||
}
|
|
||||||
chunks[i] = append([]byte(nil), initialContent[start:end]...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to insert into a specific chunk
|
|
||||||
insertInChunk := func(chunkIdx, offset int, txt string) {
|
|
||||||
chunk := chunks[chunkIdx]
|
|
||||||
newChunk := make([]byte, len(chunk)+len(txt))
|
|
||||||
copy(newChunk, chunk[:offset])
|
|
||||||
copy(newChunk[offset:], txt)
|
|
||||||
copy(newChunk[offset+len(txt):], chunk[offset:])
|
|
||||||
chunks[chunkIdx] = newChunk
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. "CHUNK1" (pos 65000 -> chunk 0, offset 65000)
|
|
||||||
insertInChunk(0, 65000, "CHUNK1")
|
|
||||||
|
|
||||||
// 2. "BOUNDARY" (pos 65535 -> chunk 0, offset 65535)
|
|
||||||
insertInChunk(0, 65535, "BOUNDARY")
|
|
||||||
|
|
||||||
// 3. "CHUNK3" (pos 262000 -> chunk 3, offset 262000 - 3*chunkSize = 65392)
|
|
||||||
insertInChunk(3, 262000-3*chunkSize, "CHUNK3")
|
|
||||||
|
|
||||||
// Concatenate chunks to get expected content
|
|
||||||
var expectedBuf bytes.Buffer
|
|
||||||
for _, chunk := range chunks {
|
|
||||||
expectedBuf.Write(chunk)
|
|
||||||
}
|
|
||||||
expected := expectedBuf.Bytes()
|
|
||||||
|
|
||||||
if len(savedContent) != len(expected) {
|
if len(savedContent) != len(expected) {
|
||||||
t.Errorf("Saved file length %d != expected %d (too long by %d, too short by %d)",
|
t.Errorf("Saved file length %d != expected %d (too long by %d, too short by %d)",
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,8 @@ func newChunkedState(t *testing.T, content string, chunkSize int) *State {
|
||||||
filename := "/ime_range.txt"
|
filename := "/ime_range.txt"
|
||||||
mockFS.AddFile(filename, []byte(content), time.Now())
|
mockFS.AddFile(filename, []byte(content), time.Now())
|
||||||
cb := NewChunkedBuffer(filename, chunkSize, mockFS, "")
|
cb := NewChunkedBuffer(filename, chunkSize, mockFS, "")
|
||||||
cb.SetFileSize(int64(len(content)))
|
// Full-load via the on-open path (all chunks resident), matching production.
|
||||||
// Preload all chunks so RuneIndexToByte reads from memory deterministically.
|
cb.SetContent([]byte(content))
|
||||||
for i := 0; i*chunkSize < len(content); i++ {
|
|
||||||
cb.LoadChunk(i)
|
|
||||||
}
|
|
||||||
st := NewState()
|
st := NewState()
|
||||||
TheState = st
|
TheState = st
|
||||||
st.Editor.Filename = filename
|
st.Editor.Filename = filename
|
||||||
|
|
|
||||||
|
|
@ -291,9 +291,10 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
|
||||||
if res.Success {
|
if res.Success {
|
||||||
if content, ok := res.Data.([]byte); ok {
|
if content, ok := res.Data.([]byte); ok {
|
||||||
if l.state.Editor.ChunkedBuffer != nil {
|
if l.state.Editor.ChunkedBuffer != nil {
|
||||||
// Always populate the chunked buffer and ensure its size is set.
|
// Full-load the in-range file: set the whole content (split into
|
||||||
|
// resident chunks). No lazy load, so no stale-disk re-read.
|
||||||
l.state.Editor.ChunkedBuffer.SetFileSize(int64(len(content)))
|
l.state.Editor.ChunkedBuffer.SetFileSize(int64(len(content)))
|
||||||
l.state.Editor.ChunkedBuffer.Insert(0, string(content))
|
l.state.Editor.ChunkedBuffer.SetContent(content)
|
||||||
// Also update the Buffer field for backward compatibility and for tests checking it.
|
// Also update the Buffer field for backward compatibility and for tests checking it.
|
||||||
l.state.Editor.Buffer = string(content)
|
l.state.Editor.Buffer = string(content)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -303,11 +304,15 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if res.TaskType == pool.TypeReadChunk {
|
} else if res.TaskType == pool.TypeReadChunk {
|
||||||
|
// In-range files load fully via SetContent, so this is only a fallback.
|
||||||
if cb := l.state.Editor.ChunkedBuffer; cb != nil {
|
if cb := l.state.Editor.ChunkedBuffer; cb != nil {
|
||||||
delete(cb.loadingChunks, res.ChunkIdx) // Use explicit ChunkIdx field
|
delete(cb.loadingChunks, res.ChunkIdx) // Use explicit ChunkIdx field
|
||||||
|
|
||||||
if res.Success {
|
if res.Success {
|
||||||
if chunk, ok := res.Data.([]byte); ok {
|
if chunk, ok := res.Data.([]byte); ok {
|
||||||
|
for len(cb.chunks) <= res.ChunkIdx {
|
||||||
|
cb.chunks = append(cb.chunks, nil)
|
||||||
|
}
|
||||||
cb.chunks[res.ChunkIdx] = chunk
|
cb.chunks[res.ChunkIdx] = chunk
|
||||||
log.Printf("Logic: Loaded chunk %d for %s (%d bytes)", res.ChunkIdx, res.FilePath, len(chunk))
|
log.Printf("Logic: Loaded chunk %d for %s (%d bytes)", res.ChunkIdx, res.FilePath, len(chunk))
|
||||||
}
|
}
|
||||||
|
|
@ -319,14 +324,20 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
|
||||||
log.Printf("Logic: TypeStatFile result success=%v", res.Success)
|
log.Printf("Logic: TypeStatFile result success=%v", res.Success)
|
||||||
if res.Success {
|
if res.Success {
|
||||||
if stat, ok := res.Data.(*pool.FileStat); ok {
|
if stat, ok := res.Data.(*pool.FileStat); ok {
|
||||||
|
// Size guard: refuse to edit files above the limit. The browser can
|
||||||
|
// still list them; the editor shows a "too large to edit" notice.
|
||||||
|
if stat.Size > MaxEditableFileSize {
|
||||||
|
l.state.Editor.TooLarge = true
|
||||||
|
l.state.Editor.TooLargeSize = stat.Size
|
||||||
|
log.Printf("Logic: %s is %d bytes, exceeds the %d-byte edit limit", stat.Path, stat.Size, MaxEditableFileSize)
|
||||||
|
l.frameChan <- l.frameOf(l.state.layout(l.browserManager))
|
||||||
|
return
|
||||||
|
}
|
||||||
if l.state.Editor.ChunkedBuffer != nil {
|
if l.state.Editor.ChunkedBuffer != nil {
|
||||||
l.state.Editor.ChunkedBuffer.SetFileSize(stat.Size)
|
l.state.Editor.ChunkedBuffer.SetFileSize(stat.Size)
|
||||||
// Load the chunk containing the cursor (position 0 at open)
|
// Full-load: read the whole file and dispatch a line index build.
|
||||||
l.state.Editor.ChunkedBuffer.LoadChunk(0)
|
l.workerPool.Dispatch(pool.NewReadFileTask(stat.Path, l.mockFS))
|
||||||
// Prefetch adjacent chunks
|
l.workerPool.Dispatch(pool.NewBuildLineIndexTask(stat.Path, l.mockFS))
|
||||||
l.state.Editor.ChunkedBuffer.Prefetch(0, 1)
|
|
||||||
// Dispatch line index build task
|
|
||||||
l.workerPool.Dispatch(pool.NewBuildLineIndexTask(l.state.Editor.Filename, l.mockFS))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,20 @@ func EditorLineHeight() ui.Dp {
|
||||||
return ui.Dp(float32(EditorFontSize) * EditorLineHeightScale)
|
return ui.Dp(float32(EditorFontSize) * EditorLineHeightScale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatSize renders a byte count as a human-readable size (e.g. "10.4 MB").
|
||||||
|
func formatSize(n int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if n < unit {
|
||||||
|
return fmt.Sprintf("%d B", n)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for m := n / unit; m >= unit; m /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "kMG"[exp])
|
||||||
|
}
|
||||||
|
|
||||||
// Page identifies which page the app is showing.
|
// Page identifies which page the app is showing.
|
||||||
type Page int
|
type Page int
|
||||||
|
|
||||||
|
|
@ -63,6 +77,11 @@ type EditorState struct {
|
||||||
// It is set during layout and is what an EditEvent.Range indexes into.
|
// It is set during layout and is what an EditEvent.Range indexes into.
|
||||||
IMEWindowText string
|
IMEWindowText string
|
||||||
Filename string
|
Filename string
|
||||||
|
// TooLarge is set when an opened file exceeds MaxEditableFileSize. The
|
||||||
|
// editor shows a "too large to edit" notice instead of content (the
|
||||||
|
// browser can still list the file).
|
||||||
|
TooLarge bool
|
||||||
|
TooLargeSize int64
|
||||||
fileVersion map[string]int
|
fileVersion map[string]int
|
||||||
lastWriteVersion map[string]int
|
lastWriteVersion map[string]int
|
||||||
saveTimer *time.Timer
|
saveTimer *time.Timer
|
||||||
|
|
@ -128,6 +147,7 @@ type State struct {
|
||||||
FocusedElementID string // ID of the currently focused element
|
FocusedElementID string // ID of the currently focused element
|
||||||
Elems []ui.Element
|
Elems []ui.Element
|
||||||
lastEvictionTime time.Time // Throttles chunk eviction
|
lastEvictionTime time.Time // Throttles chunk eviction
|
||||||
|
justOpenedAt time.Time // when the editor page was last opened; used to swallow the opening tap
|
||||||
// Browser state (directly embedded per architecture §8)
|
// Browser state (directly embedded per architecture §8)
|
||||||
Browser browser.BrowserState // Embedded, not a pointer
|
Browser browser.BrowserState // Embedded, not a pointer
|
||||||
// Editor state
|
// Editor state
|
||||||
|
|
@ -208,6 +228,12 @@ func ToggleWordWrap(data any) {
|
||||||
// Clamped to [0, MaxScroll] so content doesn't scroll past its ends.
|
// Clamped to [0, MaxScroll] so content doesn't scroll past its ends.
|
||||||
// Also evicts chunks far from the cursor to keep memory bounded.
|
// Also evicts chunks far from the cursor to keep memory bounded.
|
||||||
func HandleScroll(data any) {
|
func HandleScroll(data any) {
|
||||||
|
// Swallow scroll from the opening gesture: the tap that opened the file can
|
||||||
|
// leak a scroll delta into the editor before the line index is built (when
|
||||||
|
// MaxScroll is a large estimate), leaving the viewport past the content.
|
||||||
|
if time.Since(TheState.justOpenedAt) < 300*time.Millisecond {
|
||||||
|
return
|
||||||
|
}
|
||||||
delta := data.(int) // pixels
|
delta := data.(int) // pixels
|
||||||
TheState.ScrollOffset += ui.ToDp(ui.Px(delta), TheState.scale)
|
TheState.ScrollOffset += ui.ToDp(ui.Px(delta), TheState.scale)
|
||||||
if TheState.ScrollOffset < 0 {
|
if TheState.ScrollOffset < 0 {
|
||||||
|
|
@ -266,9 +292,16 @@ func OpenFile(data any) {
|
||||||
|
|
||||||
TheState.Editor.Filename = filename
|
TheState.Editor.Filename = filename
|
||||||
TheState.Editor.CursorPosition = 0 // Reset cursor to top
|
TheState.Editor.CursorPosition = 0 // Reset cursor to top
|
||||||
|
TheState.Editor.TooLarge = false
|
||||||
|
TheState.Editor.TooLargeSize = 0
|
||||||
TheState.ScrollOffset = 0 // Reset editor scroll to top when opening a new file
|
TheState.ScrollOffset = 0 // Reset editor scroll to top when opening a new file
|
||||||
TheState.page = EditorPage
|
TheState.page = EditorPage
|
||||||
TheState.FocusedElementID = "editor_text" // Set focus to editor
|
TheState.FocusedElementID = "editor_text" // Set focus to editor
|
||||||
|
// The tap that opened this file is delivered to the browser row, but its
|
||||||
|
// gesture can leak into the now-visible editor and move the cursor/scroll.
|
||||||
|
// Record the open time so the editor can swallow taps in a short window
|
||||||
|
// right after open (the opening tap, not a deliberate editor tap).
|
||||||
|
TheState.justOpenedAt = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetChunkedBuffer sets the chunked buffer for the current editor state.
|
// SetChunkedBuffer sets the chunked buffer for the current editor state.
|
||||||
|
|
@ -320,6 +353,10 @@ func HandleCursorMove(delta int) {
|
||||||
// HandleKeyDown interprets keyboard events for navigation and editing.
|
// HandleKeyDown interprets keyboard events for navigation and editing.
|
||||||
// Receives both key.Event (as key.Name) and key.EditEvent from the main loop.
|
// Receives both key.Event (as key.Name) and key.EditEvent from the main loop.
|
||||||
func HandleKeyDown(data any) {
|
func HandleKeyDown(data any) {
|
||||||
|
// A too-large file is not editable: ignore all key input.
|
||||||
|
if TheState.Editor.TooLarge {
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Printf("HandleKeyDown: received data of type %T: %v", data, data)
|
log.Printf("HandleKeyDown: received data of type %T: %v", data, data)
|
||||||
switch v := data.(type) {
|
switch v := data.(type) {
|
||||||
case key.EditEvent:
|
case key.EditEvent:
|
||||||
|
|
@ -636,6 +673,10 @@ func runeIndexToByteStr(s string, n int) int {
|
||||||
// is byte-based, so they are converted to byte offsets first. Must be called
|
// is byte-based, so they are converted to byte offsets first. Must be called
|
||||||
// on the logic goroutine (owner).
|
// on the logic goroutine (owner).
|
||||||
func HandleReplaceRange(startRune, endRune int, text string) {
|
func HandleReplaceRange(startRune, endRune int, text string) {
|
||||||
|
// A too-large file is not editable: ignore IME commits.
|
||||||
|
if TheState.Editor.TooLarge {
|
||||||
|
return
|
||||||
|
}
|
||||||
if startRune > endRune {
|
if startRune > endRune {
|
||||||
startRune, endRune = endRune, startRune
|
startRune, endRune = endRune, startRune
|
||||||
}
|
}
|
||||||
|
|
@ -819,6 +860,16 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
||||||
maxScroll = 0
|
maxScroll = 0
|
||||||
}
|
}
|
||||||
TheState.MaxScroll = maxScroll
|
TheState.MaxScroll = maxScroll
|
||||||
|
// Keep the viewport within [0, MaxScroll] even when MaxScroll just shrank
|
||||||
|
// (the line index finished building, or a shorter file was opened). Without
|
||||||
|
// this, a ScrollOffset set while MaxScroll was the large pre-index estimate
|
||||||
|
// would stay past the (now smaller) content and render a blank viewport.
|
||||||
|
if TheState.ScrollOffset < 0 {
|
||||||
|
TheState.ScrollOffset = 0
|
||||||
|
}
|
||||||
|
if TheState.ScrollOffset > maxScroll {
|
||||||
|
TheState.ScrollOffset = maxScroll
|
||||||
|
}
|
||||||
|
|
||||||
// Compute visible content for virtual scrolling.
|
// Compute visible content for virtual scrolling.
|
||||||
var visibleContent string
|
var visibleContent string
|
||||||
|
|
@ -827,7 +878,15 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
||||||
var start, end int
|
var start, end int
|
||||||
|
|
||||||
cb := TheState.Editor.ChunkedBuffer
|
cb := TheState.Editor.ChunkedBuffer
|
||||||
if cb != nil {
|
if TheState.Editor.TooLarge {
|
||||||
|
// The file exceeds the edit limit: show a notice instead of content and
|
||||||
|
// do not edit (the browser can still list the file).
|
||||||
|
visibleContent = fmt.Sprintf(
|
||||||
|
"Too large to edit\n\n%s is %s, above the %s limit.\nIt is still listed in the browser.",
|
||||||
|
TheState.Editor.Filename, formatSize(TheState.Editor.TooLargeSize), formatSize(MaxEditableFileSize))
|
||||||
|
visibleCursorPos = 0
|
||||||
|
visibleScrollOffset = 0
|
||||||
|
} else if cb != nil {
|
||||||
viewportHeight := editorRegion.H
|
viewportHeight := editorRegion.H
|
||||||
lineHeight := EditorLineHeight()
|
lineHeight := EditorLineHeight()
|
||||||
if lh := TheState.Editor.GlyphLayout.LineHeight; lh > 0 {
|
if lh := TheState.Editor.GlyphLayout.LineHeight; lh > 0 {
|
||||||
|
|
@ -844,9 +903,10 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract visible content from chunked buffer
|
// Extract visible content from chunked buffer. Read the full visible
|
||||||
//visibleContent = cb.Content(start, end)
|
// range [start, end) so a tall viewport is filled (a fixed 2000-byte
|
||||||
visibleContent = cb.Content(start, start+2000)
|
// window could leave the lower rows blank).
|
||||||
|
visibleContent = cb.Content(start, end)
|
||||||
|
|
||||||
// Adjust cursor position to be relative to visibleContent
|
// Adjust cursor position to be relative to visibleContent
|
||||||
visibleCursorPos = TheState.Editor.CursorPosition - start
|
visibleCursorPos = TheState.Editor.CursorPosition - start
|
||||||
|
|
@ -886,13 +946,6 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
||||||
visibleScrollOffset = TheState.ScrollOffset
|
visibleScrollOffset = TheState.ScrollOffset
|
||||||
}
|
}
|
||||||
|
|
||||||
var s string
|
|
||||||
if len(visibleContent)>10 {
|
|
||||||
s = visibleContent[:10]
|
|
||||||
} else {
|
|
||||||
s = visibleContent
|
|
||||||
}
|
|
||||||
fmt.Printf("LAYOUT: visibleScrollOffset: %f visibleContent (%d) = %s...\n",visibleScrollOffset,len(visibleContent),s)
|
|
||||||
// Record the visible window for IME: the snippet is this window, so an
|
// Record the visible window for IME: the snippet is this window, so an
|
||||||
// EditEvent.Range (relative to the window) is offset by IMEWindowStartByte
|
// EditEvent.Range (relative to the window) is offset by IMEWindowStartByte
|
||||||
// to address the buffer. start is 0 for small (string) files, so the
|
// to address the buffer. start is 0 for small (string) files, so the
|
||||||
|
|
@ -911,6 +964,13 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
||||||
{Gesture: ui.Scroll, Handler: HandleScroll},
|
{Gesture: ui.Scroll, Handler: HandleScroll},
|
||||||
{Gesture: ui.KeyDown, Handler: HandleKeyDown},
|
{Gesture: ui.KeyDown, Handler: HandleKeyDown},
|
||||||
{Gesture: ui.Tap, Handler: func(data any) {
|
{Gesture: ui.Tap, Handler: func(data any) {
|
||||||
|
// Swallow the tap that opened the file. Its gesture belongs to the
|
||||||
|
// browser row we just left and must not position the cursor in the
|
||||||
|
// editor (a deliberate tap-to-position happens later, well past this
|
||||||
|
// window).
|
||||||
|
if time.Since(TheState.justOpenedAt) < 300*time.Millisecond {
|
||||||
|
return
|
||||||
|
}
|
||||||
if pt, ok := data.(ui.Point); ok {
|
if pt, ok := data.(ui.Point); ok {
|
||||||
// Convert window-space tap coordinates to text-local coordinates.
|
// Convert window-space tap coordinates to text-local coordinates.
|
||||||
// layout.X is relative to the text region left, and layout.Y is relative to the text region top.
|
// layout.X is relative to the text region left, and layout.Y is relative to the text region top.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user