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.
1395 lines
43 KiB
Go
1395 lines
43 KiB
Go
package editor
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"sort"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"gioui.org/io/key"
|
|
"pad/internal/browser"
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
// EditorFontSize is the font size used for editor text.
|
|
const EditorFontSize = 14 // unit.Sp
|
|
|
|
// EditorLineHeightScale is the baseline-to-baseline spacing multiplier.
|
|
const EditorLineHeightScale = 1.2
|
|
|
|
// EditorLineHeight returns the fixed line height in Dp for the editor font.
|
|
func EditorLineHeight() ui.Dp {
|
|
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.
|
|
type Page int
|
|
|
|
const (
|
|
BrowserPage Page = iota
|
|
EditorPage
|
|
)
|
|
|
|
// SortMode controls how the browser list is sorted.
|
|
type SortMode int
|
|
|
|
const (
|
|
SortByDateDesc SortMode = iota // default: newest first
|
|
SortByDateAsc
|
|
SortByNameAsc
|
|
SortByNameDesc
|
|
)
|
|
|
|
// EditorState holds all editor-specific state.
|
|
type EditorState struct {
|
|
Buffer string // DEPRECATED: use ChunkedBuffer for large files
|
|
ChunkedBuffer *ChunkedBuffer // NEW: chunked file access for virtual scrolling
|
|
CursorPosition int
|
|
GlyphLayout ui.GlyphLayout
|
|
SelectionStart int
|
|
SelectionEnd int
|
|
// SelectionAnchor is the fixed end of a shift-selection; CursorPosition is
|
|
// the active end. -1 means no shift-selection in progress. The effective
|
|
// selection is [min(anchor,cursor), max(anchor,cursor)]; SelectionStart/
|
|
// SelectionEnd are its cached normalized form (-1/-1 = none).
|
|
SelectionAnchor int
|
|
CursorVisible bool
|
|
// IMEWindowStartByte is the absolute byte offset in the buffer where the
|
|
// visible window (IMEWindowText) begins. For small (string) files it is 0
|
|
// and the window is the whole buffer; for large (chunked) files it is the
|
|
// viewport start. The IME snippet is the window, so an EditEvent.Range is
|
|
// relative to the window and must be offset by this to address the buffer.
|
|
IMEWindowStartByte int
|
|
// IMEWindowText is the visible window text shown to the IME (the snippet).
|
|
// It is set during layout and is what an EditEvent.Range indexes into.
|
|
IMEWindowText string
|
|
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
|
|
lastWriteVersion map[string]int
|
|
saveTimer *time.Timer
|
|
writeFailed map[string]bool // Added: tracks failed writes for UI
|
|
retryAttempts map[string]int // Added: tracks retry attempts
|
|
}
|
|
|
|
// GetBuffer returns the full buffer content, using ChunkedBuffer if available.
|
|
func (e *EditorState) GetBuffer() string {
|
|
if e.ChunkedBuffer != nil {
|
|
fullContent, err := e.ChunkedBuffer.FullContent()
|
|
if err != nil {
|
|
log.Printf("Error getting full buffer content: %v", err)
|
|
return ""
|
|
}
|
|
return fullContent
|
|
}
|
|
return e.Buffer
|
|
}
|
|
|
|
// IsSaving returns true if a save is pending.
|
|
func (e *EditorState) IsSaving() bool {
|
|
return e.saveTimer != nil
|
|
}
|
|
|
|
// IsDirty, WriteFailed, and RetryAttempts are computed:
|
|
|
|
func (e *EditorState) IsDirty() bool {
|
|
bufVer := e.fileVersion[e.Filename]
|
|
writeVer := e.lastWriteVersion[e.Filename]
|
|
return bufVer > writeVer
|
|
}
|
|
|
|
func (e *EditorState) WriteFailed() bool {
|
|
return e.writeFailed[e.Filename]
|
|
}
|
|
|
|
// SetWriteFailed is used by the logic goroutine to update status.
|
|
func (e *EditorState) SetWriteFailed(filename string, failed bool) {
|
|
e.writeFailed[filename] = failed
|
|
if !failed {
|
|
e.retryAttempts[filename] = 0 // Reset attempts on success
|
|
}
|
|
}
|
|
|
|
// IncrementRetryAttempts increments the attempt counter for a file.
|
|
func (e *EditorState) IncrementRetryAttempts(filename string) int {
|
|
e.retryAttempts[filename]++
|
|
return e.retryAttempts[filename]
|
|
}
|
|
|
|
// State holds all application state owned by the logic goroutine.
|
|
type State struct {
|
|
PixelWidth int // raw pixel width from Gio ConfigEvent
|
|
PixelHeight int // raw pixel height from Gio ConfigEvent
|
|
scale float32
|
|
page Page // current page (Browser or Editor)
|
|
WordWrap bool
|
|
ScrollOffset ui.Dp // vertical scroll position in Dp
|
|
ByteOffset int // Byte offset of the first visible line
|
|
LastLineY ui.Dp // last line baseline offset from text origin, from renderer
|
|
MaxScroll ui.Dp // max scroll offset (content height - viewport height)
|
|
// VisibleStart/VisibleEnd are the byte range the last editor layout shaped
|
|
// for the viewport. Set by EditorLayout each frame; read by the profiler
|
|
// probe to confirm the shaped range stays viewport-bounded (not the file).
|
|
VisibleStart int
|
|
VisibleEnd int
|
|
FocusedElementID string // ID of the currently focused element
|
|
Elems []ui.Element
|
|
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 browser.BrowserState // Embedded, not a pointer
|
|
// Editor state
|
|
Editor EditorState // New field
|
|
open func(string)
|
|
}
|
|
|
|
func NewState() *State {
|
|
return &State{
|
|
scale: 1.0,
|
|
page: BrowserPage, // Reverted to BrowserPage
|
|
WordWrap: true, // Enable word wrap by default
|
|
lastEvictionTime: time.Now(),
|
|
Browser: *browser.NewBrowserState(),
|
|
Editor: EditorState{
|
|
CursorPosition: 0,
|
|
SelectionStart: -1,
|
|
SelectionEnd: -1,
|
|
SelectionAnchor: -1,
|
|
fileVersion: make(map[string]int),
|
|
lastWriteVersion: make(map[string]int),
|
|
writeFailed: make(map[string]bool),
|
|
retryAttempts: make(map[string]int),
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *State) SetScale(scale float32) {
|
|
s.scale = scale
|
|
}
|
|
|
|
func (s *State) Scale() float32 {
|
|
return s.scale
|
|
}
|
|
|
|
// layout converts stored pixel dimensions to Dp using the current scale
|
|
// and computes the element tree. Called only when a frame is needed.
|
|
// Search query sync is handled by the logic goroutine via searchQueryChan,
|
|
// not here, to ensure proper channel-based state flow.
|
|
func (s *State) layout(bm *browser.BrowserManager) []ui.Element {
|
|
dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale)
|
|
dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale)
|
|
|
|
// Calculate VisibleCount before laying out the browser page.
|
|
// This ensures the browser shows entries based on the current viewport.
|
|
if s.PixelHeight > 0 && s.scale > 0 {
|
|
listAreaHeight := dpH - ui.Dp(10+24+5+36+5+10)
|
|
rowHeight := ui.Dp(48)
|
|
newVisibleCount := int(listAreaHeight / rowHeight)
|
|
if newVisibleCount > 0 && newVisibleCount != s.Browser.VisibleCount {
|
|
s.Browser.VisibleCount = newVisibleCount
|
|
}
|
|
}
|
|
|
|
switch s.page {
|
|
case BrowserPage:
|
|
tapHandler := func(data any) {
|
|
if idx, ok := data.(int); ok {
|
|
browser.HandleBrowserTap(bm, &s.Browser, idx)
|
|
}
|
|
}
|
|
s.Elems = browser.BrowserLayout(dpW, dpH, &s.Browser, ToggleSortOrder, tapHandler)
|
|
case EditorPage:
|
|
s.Elems = EditorLayout(dpW, dpH, s.WordWrap)
|
|
}
|
|
return s.Elems
|
|
}
|
|
|
|
// ToggleWordWrap toggles the word wrap setting.
|
|
func ToggleWordWrap(data any) {
|
|
TheState.WordWrap = !TheState.WordWrap
|
|
}
|
|
|
|
// HandleScroll updates the editor scroll offset in response to a scroll gesture.
|
|
// The delta is in pixels (from gesture.Scroll.Update). Convert to Dp.
|
|
// Clamped to [0, MaxScroll] so content doesn't scroll past its ends.
|
|
// Also evicts chunks far from the cursor to keep memory bounded.
|
|
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
|
|
TheState.ScrollOffset += ui.ToDp(ui.Px(delta), TheState.scale)
|
|
if TheState.ScrollOffset < 0 {
|
|
TheState.ScrollOffset = 0
|
|
}
|
|
if TheState.ScrollOffset > TheState.MaxScroll {
|
|
TheState.ScrollOffset = TheState.MaxScroll
|
|
}
|
|
// Evict chunks far from the cursor to keep memory bounded.
|
|
// Only evict when the cursor is near the viewport (i.e., scrolled to top).
|
|
// Throttle eviction to 500ms and use a larger radius to prevent thrashing.
|
|
// Temporarily disabled eviction to debug thrashing issues.
|
|
/*
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
if time.Since(TheState.lastEvictionTime) > 500*time.Millisecond {
|
|
cb.EvictFarChunks(TheState.Editor.CursorPosition, 20)
|
|
TheState.lastEvictionTime = time.Now()
|
|
}
|
|
}
|
|
*/
|
|
}
|
|
|
|
// HandleBrowserScroll updates the browser scroll offset in response to a scroll gesture.
|
|
// The delta is in pixels (from gesture.Scroll.Update). Delegates to the browser package.
|
|
func HandleBrowserScroll(data any) {
|
|
delta := data.(int) // pixel delta from gesture.Scroll.Update
|
|
browser.HandlePixelScroll(&TheState.Browser, delta)
|
|
}
|
|
|
|
// GoToBrowser switches the app to the browser page.
|
|
func GoToBrowser(data any) {
|
|
if TheLogic != nil {
|
|
TheLogic.FlushAll()
|
|
}
|
|
TheState.page = BrowserPage
|
|
}
|
|
|
|
// GoToEditor switches the app to the editor page.
|
|
func GoToEditor(data any) {
|
|
TheState.page = EditorPage
|
|
}
|
|
|
|
// OpenFile sets the active filename and switches to the editor page.
|
|
// data is the filename string from the browser list.
|
|
func OpenFile(data any) {
|
|
filename := data.(string)
|
|
// NOTE: the external open hook (TheState.open, e.g. the Android Termux
|
|
// bridge) is intentionally NOT called here. Opening a file is an in-app
|
|
// action; the external bridge was the old tap path and it crashes on
|
|
// Android 7+ (FileUriExposedException from a file:// Intent URI).
|
|
|
|
// Dispatch a request to load the file
|
|
go func() {
|
|
TheLogic.openFileChan <- filename
|
|
}()
|
|
|
|
TheState.Editor.Filename = filename
|
|
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.page = EditorPage
|
|
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.
|
|
func SetChunkedBuffer(cb *ChunkedBuffer) {
|
|
TheState.Editor.ChunkedBuffer = cb
|
|
}
|
|
|
|
// ToggleSortOrder cycles the browser sort mode through four modes.
|
|
func ToggleSortOrder(data any) {
|
|
// Cycle through the 4 sort modes
|
|
TheState.Browser.SortMode = (TheState.Browser.SortMode + 1) % 4
|
|
// Reset scroll on sort change
|
|
TheState.Browser.ScrollOffset = 0
|
|
// Clear cached pages so they reload with the new sort order
|
|
TheState.Browser.Pages = make(map[int]*browser.Page)
|
|
// Reload initial pages with the new sort order
|
|
browser.LoadInitialPages(&TheState.Browser)
|
|
// Recompute search results if there's an active search query
|
|
browser.HandleSortModeChange(&TheState.Browser)
|
|
}
|
|
|
|
// HandleCursorMove updates the cursor position within bounds.
|
|
func HandleCursorMove(delta int) {
|
|
newPos := TheState.Editor.CursorPosition + delta
|
|
if newPos < 0 {
|
|
newPos = 0
|
|
}
|
|
// Use ChunkedBuffer.FileLen() as the authoritative upper bound
|
|
var maxPos int
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
maxPos = int(cb.FileLen())
|
|
}
|
|
if maxPos == 0 {
|
|
// Fallback to Buffer length if ChunkedBuffer not yet initialized
|
|
maxPos = len(TheState.Editor.Buffer)
|
|
}
|
|
if newPos > maxPos {
|
|
newPos = maxPos
|
|
}
|
|
TheState.Editor.CursorPosition = newPos
|
|
}
|
|
|
|
// HandleKeyDown interprets keyboard events for navigation and editing.
|
|
// Receives key.EditEvent (text input) and key presses as ui.KeyEvent (with
|
|
// modifier state) or bare key.Name (legacy/test path, no modifiers).
|
|
func HandleKeyDown(data any) {
|
|
// A too-large file is not editable: ignore all key input.
|
|
if TheState.Editor.TooLarge {
|
|
return
|
|
}
|
|
switch v := data.(type) {
|
|
case key.EditEvent:
|
|
// Text input from IME / keyboard.
|
|
if v.Text == "\b" {
|
|
// Backspace character (legacy / hardware): delete the selection if
|
|
// there is one, else one char before the cursor.
|
|
HandleBackspace()
|
|
} else {
|
|
// IME insert/replace/delete: replace [Range.Start, Range.End) with
|
|
// Text. Range is empty (start==end) for plain inserts; a non-empty
|
|
// range with text is a swipe/autocorrect replacement; a non-empty
|
|
// range with empty text is a range delete. Ignoring Range here is
|
|
// what caused replaced text to be duplicated. A live selection is
|
|
// always replaced (see HandleReplaceRange).
|
|
HandleReplaceRange(v.Range.Start, v.Range.End, v.Text)
|
|
}
|
|
case ui.KeyEvent:
|
|
handleKey(v.Name, v.Shift)
|
|
case key.Name:
|
|
// Bare key name: no modifier state, so no shift-selection here.
|
|
handleKey(v, false)
|
|
}
|
|
}
|
|
|
|
// handleKey dispatches a key press. shift=true turns cursor moves into
|
|
// selection extensions; edit keys act on the selection when one is active.
|
|
func handleKey(name key.Name, shift bool) {
|
|
switch name {
|
|
case key.NameLeftArrow:
|
|
moveCursor(shift, func() { HandleCursorMove(-1) })
|
|
case key.NameRightArrow:
|
|
moveCursor(shift, func() { HandleCursorMove(1) })
|
|
case key.NameUpArrow:
|
|
moveCursor(shift, func() { HandleVerticalCursorMove(true) })
|
|
case key.NameDownArrow:
|
|
moveCursor(shift, func() { HandleVerticalCursorMove(false) })
|
|
case key.NameDeleteBackward:
|
|
HandleBackspace()
|
|
case key.NameDeleteForward:
|
|
HandleDelete()
|
|
case key.NameReturn:
|
|
HandleInsert("\n")
|
|
case key.NameHome:
|
|
moveCursor(shift, HandleHome)
|
|
case key.NameEnd:
|
|
moveCursor(shift, HandleEnd)
|
|
case key.NamePageUp:
|
|
moveCursor(shift, func() { HandlePageUpDown(true) })
|
|
case key.NamePageDown:
|
|
moveCursor(shift, func() { HandlePageUpDown(false) })
|
|
}
|
|
}
|
|
|
|
// glyphBase returns the absolute file byte offset at the start of the current
|
|
// visible window. The GlyphLayout is shaped from the visible window alone, so
|
|
// every one of its ByteOffsets is relative to this base; adding it yields an
|
|
// absolute file offset. For a whole-file window (small files, or scroll 0) the
|
|
// base is 0 and this is a no-op.
|
|
func glyphBase() int {
|
|
return TheState.Editor.IMEWindowStartByte
|
|
}
|
|
|
|
// --- Text selection ---------------------------------------------------------
|
|
//
|
|
// The selection is a byte range in absolute file coordinates derived from
|
|
// SelectionAnchor (fixed end) and CursorPosition (active end). No selection:
|
|
// anchor == -1 and SelectionStart == SelectionEnd == -1. Shift+arrow/home/
|
|
// end/page extend the selection; a plain cursor move or any edit clears it.
|
|
// All of these must run on the logic goroutine (owner).
|
|
|
|
// selActive reports whether there is a non-empty selection.
|
|
func selActive() bool {
|
|
e := &TheState.Editor
|
|
return e.SelectionAnchor >= 0 && e.SelectionStart >= 0 && e.SelectionEnd > e.SelectionStart
|
|
}
|
|
|
|
// ClearSelection drops the selection and the shift-anchor.
|
|
func ClearSelection() {
|
|
e := &TheState.Editor
|
|
e.SelectionAnchor = -1
|
|
e.SelectionStart = -1
|
|
e.SelectionEnd = -1
|
|
}
|
|
|
|
// SetSelection selects the byte range [min(start,end), max(start,end)),
|
|
// anchoring at the lower end and placing the cursor (active end) at the upper
|
|
// end. Clamped to the buffer; an empty/clamped range clears instead.
|
|
// Convenience for tests and future gestures (tap-range, double-tap).
|
|
func SetSelection(start, end int) {
|
|
e := &TheState.Editor
|
|
if start > end {
|
|
start, end = end, start
|
|
}
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
var fileLen int
|
|
if cb := e.ChunkedBuffer; cb != nil {
|
|
fileLen = int(cb.FileLen())
|
|
} else {
|
|
fileLen = len(e.Buffer)
|
|
}
|
|
if end > fileLen {
|
|
end = fileLen
|
|
}
|
|
if end <= start {
|
|
ClearSelection()
|
|
e.CursorPosition = start
|
|
return
|
|
}
|
|
e.SelectionAnchor = start
|
|
e.SelectionStart = start
|
|
e.SelectionEnd = end
|
|
e.CursorPosition = end
|
|
}
|
|
|
|
// updateSelectionFromAnchor recomputes SelectionStart/End from (anchor,
|
|
// cursor). A zero-length range (anchor == cursor) is treated as no selection,
|
|
// but the anchor is kept so the next shift-move extends from the original spot
|
|
// again.
|
|
func updateSelectionFromAnchor() {
|
|
e := &TheState.Editor
|
|
if e.SelectionAnchor < 0 {
|
|
ClearSelection()
|
|
return
|
|
}
|
|
a, c := e.SelectionAnchor, e.CursorPosition
|
|
if a == c {
|
|
e.SelectionStart = -1
|
|
e.SelectionEnd = -1
|
|
return
|
|
}
|
|
if a < c {
|
|
e.SelectionStart, e.SelectionEnd = a, c
|
|
} else {
|
|
e.SelectionStart, e.SelectionEnd = c, a
|
|
}
|
|
}
|
|
|
|
// moveCursor runs op (a cursor-mutating handler). With shift held it keeps
|
|
// the anchor and extends the selection to the new cursor position; without it
|
|
// it clears any selection first. This is the single place where selection
|
|
// bookkeeping meets cursor movement, so every move path (arrows, home/end,
|
|
// page, vertical) gets consistent semantics.
|
|
func moveCursor(shift bool, op func()) {
|
|
if shift {
|
|
if TheState.Editor.SelectionAnchor < 0 {
|
|
TheState.Editor.SelectionAnchor = TheState.Editor.CursorPosition
|
|
}
|
|
} else {
|
|
ClearSelection()
|
|
}
|
|
op()
|
|
if shift {
|
|
updateSelectionFromAnchor()
|
|
}
|
|
}
|
|
|
|
// deleteRange removes the byte range [start, end) from the active buffer and
|
|
// updates the line index. Shared by the selection-aware edit handlers.
|
|
func deleteRange(start, end int) {
|
|
if end <= start {
|
|
return
|
|
}
|
|
if buf := TheState.Editor.ChunkedBuffer; buf != nil {
|
|
buf.Delete(start, end-start)
|
|
buf.UpdateLineIndexAfterDelete(start, end)
|
|
} else {
|
|
str := TheState.Editor.Buffer
|
|
TheState.Editor.Buffer = str[:start] + str[end:]
|
|
}
|
|
}
|
|
|
|
// HandleHome moves the cursor to the start of the current visual line.
|
|
func HandleHome() {
|
|
layout := TheState.Editor.GlyphLayout
|
|
if len(layout.ByteOffsets) == 0 {
|
|
return
|
|
}
|
|
|
|
base := glyphBase()
|
|
pos := TheState.Editor.CursorPosition - base
|
|
if pos < 0 {
|
|
pos = 0
|
|
}
|
|
// Find current glyph index (offsets are window-relative).
|
|
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
|
|
return layout.ByteOffsets[i] >= pos
|
|
})
|
|
if idx == len(layout.ByteOffsets) {
|
|
idx = len(layout.ByteOffsets) - 1
|
|
}
|
|
|
|
currentY := layout.Y[idx]
|
|
// Find first glyph on currentY.
|
|
targetIdx := idx
|
|
for i := idx; i >= 0; i-- {
|
|
if layout.Y[i] == currentY {
|
|
targetIdx = i
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
TheState.Editor.CursorPosition = base + layout.ByteOffsets[targetIdx]
|
|
}
|
|
|
|
// HandleEnd moves the cursor to the end of the current visual line.
|
|
func HandleEnd() {
|
|
layout := TheState.Editor.GlyphLayout
|
|
if len(layout.ByteOffsets) == 0 {
|
|
return
|
|
}
|
|
|
|
base := glyphBase()
|
|
pos := TheState.Editor.CursorPosition - base
|
|
if pos < 0 {
|
|
pos = 0
|
|
}
|
|
// Find current glyph index (offsets are window-relative).
|
|
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
|
|
return layout.ByteOffsets[i] >= pos
|
|
})
|
|
if idx == len(layout.ByteOffsets) {
|
|
idx = len(layout.ByteOffsets) - 1
|
|
}
|
|
|
|
currentY := layout.Y[idx]
|
|
// Find last glyph on currentY.
|
|
targetIdx := idx
|
|
for i := idx; i < len(layout.Y); i++ {
|
|
if layout.Y[i] == currentY {
|
|
targetIdx = i
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Position after the last character of the line.
|
|
// If it's a newline, it's the newline itself.
|
|
// start is window-relative; convert to an absolute file offset.
|
|
start := base + layout.ByteOffsets[targetIdx]
|
|
buf := TheState.Editor.ChunkedBuffer
|
|
var fileContent string
|
|
if buf != nil {
|
|
content, err := buf.FullContent()
|
|
if err != nil {
|
|
log.Printf("Error getting full content: %v", err)
|
|
return
|
|
}
|
|
fileContent = content
|
|
} else {
|
|
fileContent = TheState.Editor.Buffer
|
|
}
|
|
r, size := utf8.DecodeRuneInString(fileContent[start:])
|
|
if r == '\n' {
|
|
TheState.Editor.CursorPosition = start
|
|
} else {
|
|
TheState.Editor.CursorPosition = start + size
|
|
}
|
|
}
|
|
|
|
// HandlePageUpDown scrolls and moves the cursor.
|
|
func HandlePageUpDown(up bool) {
|
|
// For now, simple scrolling. Cursor movement could be added later.
|
|
pageSize := TheState.MaxScroll / 4 // Or some fraction
|
|
if pageSize < EditorLineHeight() {
|
|
pageSize = EditorLineHeight()
|
|
}
|
|
|
|
if up {
|
|
TheState.ScrollOffset -= pageSize
|
|
if TheState.ScrollOffset < 0 {
|
|
TheState.ScrollOffset = 0
|
|
}
|
|
} else {
|
|
TheState.ScrollOffset += pageSize
|
|
if TheState.ScrollOffset > TheState.MaxScroll {
|
|
TheState.ScrollOffset = TheState.MaxScroll
|
|
}
|
|
}
|
|
}
|
|
func HandleVerticalCursorMove(up bool) {
|
|
layout := TheState.Editor.GlyphLayout
|
|
if len(layout.ByteOffsets) == 0 {
|
|
return
|
|
}
|
|
|
|
base := glyphBase()
|
|
pos := TheState.Editor.CursorPosition - base
|
|
if pos < 0 {
|
|
pos = 0
|
|
}
|
|
// Find current glyph index (offsets are window-relative).
|
|
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
|
|
return layout.ByteOffsets[i] >= pos
|
|
})
|
|
// If idx == len, we are at the end. Use the last glyph.
|
|
if idx == len(layout.ByteOffsets) {
|
|
idx = len(layout.ByteOffsets) - 1
|
|
}
|
|
|
|
currentX := layout.X[idx]
|
|
currentY := layout.Y[idx]
|
|
|
|
var targetIdx int = idx
|
|
|
|
if up {
|
|
// Scan backwards to find the previous line's Y.
|
|
prevY := currentY
|
|
for i := idx; i >= 0; i-- {
|
|
if layout.Y[i] < prevY {
|
|
prevY = layout.Y[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if prevY == currentY {
|
|
// Already at the top line?
|
|
return
|
|
}
|
|
|
|
// Now find the closest X in prevY.
|
|
bestDiff := float32(1e9)
|
|
for i := 0; i < len(layout.Y); i++ {
|
|
if layout.Y[i] == prevY {
|
|
diff := float32(layout.X[i] - currentX)
|
|
if diff < 0 {
|
|
diff = -diff
|
|
}
|
|
if diff < bestDiff {
|
|
bestDiff = diff
|
|
targetIdx = i
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Scan forwards to find the next line's Y.
|
|
nextY := currentY
|
|
for i := idx; i < len(layout.Y); i++ {
|
|
if layout.Y[i] > nextY {
|
|
nextY = layout.Y[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if nextY == currentY {
|
|
// Already at the bottom line?
|
|
return
|
|
}
|
|
|
|
// Now find the closest X in nextY.
|
|
bestDiff := float32(1e9)
|
|
for i := 0; i < len(layout.Y); i++ {
|
|
if layout.Y[i] == nextY {
|
|
diff := float32(layout.X[i] - currentX)
|
|
if diff < 0 {
|
|
diff = -diff
|
|
}
|
|
if diff < bestDiff {
|
|
bestDiff = diff
|
|
targetIdx = i
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
TheState.Editor.CursorPosition = base + layout.ByteOffsets[targetIdx]
|
|
}
|
|
|
|
// utf8BackspaceWidth returns the byte width of the UTF-8 rune ending at the
|
|
// end of seg (the rune immediately before the cursor). seg must contain the
|
|
// rune's full bytes (4 or more preceding bytes suffice).
|
|
func utf8BackspaceWidth(seg string) int {
|
|
// The rune ends at the end of seg. Scan back over continuation bytes
|
|
// (0x80-0xBF) until the lead byte; the width is the span covered.
|
|
i := len(seg) - 1
|
|
for i > 0 && seg[i]&0xC0 == 0x80 {
|
|
i--
|
|
}
|
|
return len(seg) - i
|
|
}
|
|
|
|
// utf8AdvanceWidth returns the byte width of the UTF-8 rune starting at the
|
|
// beginning of seg (4 or more following bytes suffice). Returns 0 if seg is
|
|
// empty (cursor at EOF).
|
|
func utf8AdvanceWidth(seg string) int {
|
|
if len(seg) == 0 {
|
|
return 0
|
|
}
|
|
w := 1
|
|
for i := 1; i < len(seg) && seg[i]&0xC0 == 0x80; i++ {
|
|
w++
|
|
}
|
|
return w
|
|
}
|
|
|
|
// HandleDelete removes the character (full UTF-8 rune) after the cursor.
|
|
// With a live selection it deletes the whole selection.
|
|
func HandleDelete() {
|
|
e := &TheState.Editor
|
|
if selActive() {
|
|
deleteRange(e.SelectionStart, e.SelectionEnd)
|
|
e.CursorPosition = e.SelectionStart
|
|
ClearSelection()
|
|
markDirty()
|
|
return
|
|
}
|
|
pos := e.CursorPosition
|
|
buf := e.ChunkedBuffer
|
|
if buf != nil {
|
|
seg := buf.Content(pos, pos+4)
|
|
if w := utf8AdvanceWidth(seg); w > 0 {
|
|
buf.Delete(pos, w)
|
|
buf.UpdateLineIndexAfterDelete(pos, pos+w)
|
|
markDirty()
|
|
}
|
|
return
|
|
}
|
|
// Fallback to string-based editing for small files / no chunked buffer
|
|
str := TheState.Editor.Buffer
|
|
if pos >= len(str) {
|
|
return
|
|
}
|
|
end := len(str)
|
|
if pos+4 < end {
|
|
end = pos + 4
|
|
}
|
|
TheState.Editor.Buffer = str[:pos] + str[pos+utf8AdvanceWidth(str[pos:end]):]
|
|
markDirty()
|
|
}
|
|
|
|
// HandleInsert inserts a string at the current cursor position. With a live
|
|
// selection it replaces the selection instead.
|
|
func HandleInsert(s string) {
|
|
// EditorState is embedded by value in State: take the address, never a
|
|
// copy, or the writes below are lost.
|
|
e := &TheState.Editor
|
|
pos := e.CursorPosition
|
|
if selActive() {
|
|
pos = e.SelectionStart
|
|
deleteRange(e.SelectionStart, e.SelectionEnd)
|
|
ClearSelection()
|
|
}
|
|
buf := e.ChunkedBuffer
|
|
if buf != nil {
|
|
buf.Insert(pos, s)
|
|
buf.UpdateLineIndexAfterInsert(pos, s)
|
|
} else {
|
|
// Fallback to string-based editing for small files / no chunked buffer
|
|
str := e.Buffer
|
|
e.Buffer = str[:pos] + s + str[pos:]
|
|
}
|
|
e.CursorPosition = pos + len(s)
|
|
markDirty()
|
|
}
|
|
|
|
// HandleBackspace removes the character before the cursor. With a live
|
|
// selection it deletes the whole selection instead.
|
|
func HandleBackspace() {
|
|
e := &TheState.Editor
|
|
if selActive() {
|
|
deleteRange(e.SelectionStart, e.SelectionEnd)
|
|
e.CursorPosition = e.SelectionStart
|
|
ClearSelection()
|
|
markDirty()
|
|
return
|
|
}
|
|
pos := e.CursorPosition
|
|
if pos == 0 {
|
|
return
|
|
}
|
|
buf := e.ChunkedBuffer
|
|
if buf != nil {
|
|
segStart := pos - 4
|
|
if segStart < 0 {
|
|
segStart = 0
|
|
}
|
|
w := utf8BackspaceWidth(buf.Content(segStart, pos))
|
|
buf.Delete(pos-w, w)
|
|
buf.UpdateLineIndexAfterDelete(pos-w, pos)
|
|
TheState.Editor.CursorPosition = pos - w
|
|
markDirty()
|
|
return
|
|
}
|
|
// Fallback to string-based editing for small files / no chunked buffer
|
|
str := TheState.Editor.Buffer
|
|
segStart := pos - 4
|
|
if segStart < 0 {
|
|
segStart = 0
|
|
}
|
|
w := utf8BackspaceWidth(str[segStart:pos])
|
|
TheState.Editor.Buffer = str[:pos-w] + str[pos:]
|
|
TheState.Editor.CursorPosition = pos - w
|
|
markDirty()
|
|
}
|
|
|
|
// runeIndexToByteStr returns the byte offset of the n-th rune (0-indexed) in
|
|
// s. A UTF-8 rune starts at an ASCII byte (<0x80) or a multi-byte lead byte
|
|
// (>=0xC0); 0x80-0xBF are continuation bytes. If n is past the end, returns
|
|
// len(s).
|
|
func runeIndexToByteStr(s string, n int) int {
|
|
if n <= 0 {
|
|
return 0
|
|
}
|
|
runes := 0
|
|
for i := 0; i < len(s); i++ {
|
|
b := s[i]
|
|
if b < 0x80 || b >= 0xC0 {
|
|
if runes == n {
|
|
return i
|
|
}
|
|
runes++
|
|
}
|
|
}
|
|
return len(s)
|
|
}
|
|
|
|
// HandleReplaceRange replaces the text in the rune range [startRune, endRune)
|
|
// with text and places the cursor at the end of the inserted text.
|
|
//
|
|
// This implements the IME replacement contract (key.EditEvent.Range): a swipe
|
|
// or autocorrect commit replaces the selected region instead of blindly
|
|
// inserting at the cursor, so the old text is removed (no duplication). It
|
|
// also covers plain inserts (start==end) and range deletes (text == "").
|
|
//
|
|
// startRune/endRune are RUNE indices (the IME's text model), while the buffer
|
|
// is byte-based, so they are converted to byte offsets first. Must be called
|
|
// on the logic goroutine (owner).
|
|
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 {
|
|
startRune, endRune = endRune, startRune
|
|
}
|
|
// The IME snippet is the visible window (IMEWindowText), so startRune/
|
|
// endRune are relative to that window. Resolve them to window-byte
|
|
// offsets, then add the window's absolute start to address the buffer.
|
|
// For small (string) files the window is the whole buffer (start 0), so
|
|
// this reduces to absolute addressing. If IMEWindowText is empty (tests
|
|
// that never run layout), fall back to the whole buffer as the window.
|
|
windowStart := TheState.Editor.IMEWindowStartByte
|
|
windowText := TheState.Editor.IMEWindowText
|
|
if windowText == "" {
|
|
windowStart = 0
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
windowText, _ = cb.FullContent()
|
|
} else {
|
|
windowText = TheState.Editor.Buffer
|
|
}
|
|
}
|
|
absStart := windowStart + runeIndexToByteStr(windowText, startRune)
|
|
absEnd := windowStart + runeIndexToByteStr(windowText, endRune)
|
|
// A live selection is always replaced by the commit: union the IME range
|
|
// with the selection so the outcome is deterministic no matter what the
|
|
// IME reports (some IMEs send the full selection range, others send an
|
|
// empty range at the caret expecting the app to consume its reported
|
|
// selection).
|
|
if selActive() {
|
|
if absStart > TheState.Editor.SelectionStart {
|
|
absStart = TheState.Editor.SelectionStart
|
|
}
|
|
if absEnd < TheState.Editor.SelectionEnd {
|
|
absEnd = TheState.Editor.SelectionEnd
|
|
}
|
|
}
|
|
if imeDebugLog {
|
|
fmt.Printf("IME DEBUG HandleReplaceRange: startRune=%d endRune=%d text=%q windowStart=%d windowLen=%d -> absStart=%d absEnd=%d\n",
|
|
startRune, endRune, text, windowStart, len(windowText), absStart, absEnd)
|
|
}
|
|
|
|
var newCursor int
|
|
if buf := TheState.Editor.ChunkedBuffer; buf != nil {
|
|
if absEnd > absStart {
|
|
buf.Delete(absStart, absEnd-absStart)
|
|
}
|
|
buf.Insert(absStart, text)
|
|
if absEnd > absStart {
|
|
buf.UpdateLineIndexAfterDelete(absStart, absEnd)
|
|
}
|
|
buf.UpdateLineIndexAfterInsert(absStart, text)
|
|
newCursor = absStart + len(text)
|
|
} else {
|
|
s := TheState.Editor.Buffer
|
|
if absEnd > absStart {
|
|
s = s[:absStart] + s[absEnd:]
|
|
}
|
|
TheState.Editor.Buffer = s[:absStart] + text + s[absStart:]
|
|
newCursor = absStart + len(text)
|
|
}
|
|
TheState.Editor.CursorPosition = newCursor
|
|
// A commit consumed any selection it overlapped (see the union above).
|
|
ClearSelection()
|
|
if imeDebugLog {
|
|
dbgBuf := currentEditorText()
|
|
if len(dbgBuf) > 40 {
|
|
dbgBuf = dbgBuf[:40]
|
|
}
|
|
fmt.Printf("IME DEBUG -> newCursor=%d buffer=%q\n", newCursor, dbgBuf)
|
|
}
|
|
markDirty()
|
|
}
|
|
|
|
// imeDebugLog enables verbose per-commit IME logging. Keep false in normal
|
|
// use; enable when debugging IME commit/cursor sync on device.
|
|
const imeDebugLog = false
|
|
|
|
// currentEditorText returns the current editor buffer contents (full for
|
|
// chunked files). Used only for debug logging.
|
|
func currentEditorText() string {
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
s, _ := cb.FullContent()
|
|
return s
|
|
}
|
|
return TheState.Editor.Buffer
|
|
}
|
|
|
|
func markDirty() {
|
|
// TheLogic is nil in pure unit tests (no logic goroutine). Editing the
|
|
// buffer is still valid there; only the autosave side-effect is skipped.
|
|
if TheLogic != nil {
|
|
TheLogic.markDirty()
|
|
}
|
|
}
|
|
|
|
// EditorLayout computes the element tree for the editor page.
|
|
func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
|
margin := ui.Dp(10)
|
|
|
|
// --- Top bar: filename on row 1, icons on row 2 ---
|
|
statusBarRegion := ui.Region{
|
|
X: margin, Y: margin,
|
|
W: screenWidth - margin*2,
|
|
H: ui.Dp(52),
|
|
}
|
|
statusBarW := statusBarRegion.W
|
|
filename := TheState.Editor.Filename
|
|
if filename == "" {
|
|
filename = "untitled.txt"
|
|
}
|
|
statusBar := ui.NewContainer(
|
|
statusBarRegion,
|
|
ui.Color{R: 230, G: 230, B: 230, A: 255},
|
|
[]ui.Element{
|
|
// Row 1: filename
|
|
ui.NewLabel(filename, 14, ui.Region{X: 0, Y: ui.Dp(2), W: statusBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil),
|
|
// Row 2: back, cut, copy, paste icons
|
|
ui.NewIcon("back", ui.Region{X: ui.Dp(0), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0,
|
|
[]ui.Interaction{{Gesture: ui.Tap, Handler: GoToBrowser}}),
|
|
ui.NewIcon("cut", ui.Region{X: ui.Dp(48), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0, nil),
|
|
ui.NewIcon("copy", ui.Region{X: ui.Dp(96), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0, nil),
|
|
ui.NewIcon("paste", ui.Region{X: ui.Dp(144), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0, nil),
|
|
},
|
|
)
|
|
|
|
// --- Bottom bar ---
|
|
bottomBarHeight := ui.BottomBarHeight
|
|
bottomBarY := screenHeight - margin - bottomBarHeight
|
|
bottomBarRegion := ui.Region{
|
|
X: margin, Y: bottomBarY,
|
|
W: screenWidth - margin*2,
|
|
H: bottomBarHeight,
|
|
}
|
|
bottomBarW := bottomBarRegion.W
|
|
wrapText := "Wrap: Off"
|
|
if wordWrap {
|
|
wrapText = "Wrap: On"
|
|
}
|
|
statusText := "Saved"
|
|
if TheState.Editor.IsSaving() {
|
|
statusText = "Saving..."
|
|
} else if TheState.Editor.WriteFailed() {
|
|
statusText = "Error"
|
|
} else if TheState.Editor.IsDirty() {
|
|
statusText = "Modified"
|
|
}
|
|
|
|
// Compute cursor position and file size for the bottom bar.
|
|
cursorPos := TheState.Editor.CursorPosition
|
|
var fileSize int
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
fileSize = int(cb.FileLen())
|
|
} else {
|
|
fileSize = len(TheState.Editor.Buffer)
|
|
}
|
|
cursorPosText := fmt.Sprintf("%d / %d", cursorPos, fileSize)
|
|
|
|
bottomBar := ui.NewContainer(
|
|
bottomBarRegion,
|
|
ui.Color{R: 230, G: 230, B: 230, A: 255},
|
|
[]ui.Element{
|
|
ui.NewLabel(statusText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil),
|
|
ui.NewLabel(cursorPosText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignCenter, "", nil),
|
|
ui.NewLabel(wrapText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignEnd, "wrap", []ui.Interaction{
|
|
{Gesture: ui.Tap, Handler: ToggleWordWrap},
|
|
}),
|
|
},
|
|
)
|
|
|
|
// --- Editor text area ---
|
|
editorY := statusBarRegion.Y + statusBarRegion.H
|
|
editorH := bottomBarRegion.Y - editorY
|
|
editorRegion := ui.Region{
|
|
X: margin, Y: editorY,
|
|
W: screenWidth - margin*2,
|
|
H: editorH,
|
|
}
|
|
// Compute max scroll offset from the last line baseline reported by the renderer.
|
|
// lastLineY is the shaper's Y value for the last line's baseline.
|
|
// Add bottom padding (half line height) so last line isn't flush with the bottom bar.
|
|
var maxScroll ui.Dp
|
|
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
|
if li := cb.LineIndex; li != nil {
|
|
totalLines := li.LineCount()
|
|
maxScroll = ui.Dp(totalLines)*EditorLineHeight() - editorRegion.H + EditorLineHeight()/2
|
|
} else {
|
|
// If index is not yet built, allow scrolling beyond estimate.
|
|
// Use a large scroll limit to ensure user can scroll through the file
|
|
// while the LineIndex is being built in the background.
|
|
maxScroll = ui.Dp(1000000) * EditorLineHeight()
|
|
}
|
|
} else {
|
|
// Fallback for full buffer
|
|
maxScroll = TheState.LastLineY - editorRegion.H + EditorLineHeight()/2
|
|
}
|
|
|
|
if maxScroll < 0 {
|
|
maxScroll = 0
|
|
}
|
|
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.
|
|
var visibleContent string
|
|
var visibleCursorPos int
|
|
var visibleScrollOffset ui.Dp
|
|
var start, end int
|
|
|
|
cb := TheState.Editor.ChunkedBuffer
|
|
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
|
|
lineHeight := EditorLineHeight()
|
|
if lh := TheState.Editor.GlyphLayout.LineHeight; lh > 0 {
|
|
lineHeight = lh
|
|
}
|
|
start, end, _ = cb.VisibleByteRange(TheState.ScrollOffset, TheState.ByteOffset, viewportHeight, lineHeight, TheState.WordWrap, TheState.Editor.GlyphLayout, nil)
|
|
|
|
// Proactively load chunks needed for the current viewport
|
|
startChunk := start / cb.ChunkSize()
|
|
endChunk := (end - 1) / cb.ChunkSize()
|
|
for i := startChunk; i <= endChunk; i++ {
|
|
if !cb.IsChunkLoaded(i) && !cb.IsChunkLoading(i) {
|
|
cb.LoadChunkAsync(i)
|
|
}
|
|
}
|
|
|
|
// Extract visible content from chunked buffer. Read the full visible
|
|
// range [start, end) so a tall viewport is filled (a fixed 2000-byte
|
|
// window could leave the lower rows blank).
|
|
visibleContent = cb.Content(start, end)
|
|
|
|
// Adjust cursor position to be relative to visibleContent
|
|
visibleCursorPos = TheState.Editor.CursorPosition - start
|
|
if visibleCursorPos < 0 {
|
|
visibleCursorPos = 0
|
|
}
|
|
|
|
// Adjust scroll offset to be relative to visibleContent origin
|
|
visibleScrollOffset = ui.Dp(math.Mod(float64(TheState.ScrollOffset), float64(lineHeight)))
|
|
|
|
// Map scroll offset to a chunk index
|
|
// Estimate line-to-byte conversion if LineIndex is missing
|
|
var scrollByteOffset int
|
|
if li := cb.LineIndex; li != nil {
|
|
// Precise
|
|
lineHeight := EditorLineHeight()
|
|
startLine := int(TheState.ScrollOffset / lineHeight)
|
|
if startLine < li.LineCount() {
|
|
scrollByteOffset = li.ByteOffset(startLine)
|
|
} else {
|
|
scrollByteOffset = int(cb.FileLen())
|
|
}
|
|
} else {
|
|
// Estimate
|
|
scrollByteOffset = int(TheState.ScrollOffset/EditorLineHeight()) * 50
|
|
}
|
|
|
|
scrollChunk := scrollByteOffset / cb.ChunkSize()
|
|
// Use a smaller radius to prevent overloading the worker pool.
|
|
// Content() will load chunks immediately needed by the viewport.
|
|
cb.Prefetch(scrollChunk, 1)
|
|
} else {
|
|
// Fallback: no chunked buffer, use full buffer (small files)
|
|
visibleContent = TheState.Editor.Buffer
|
|
visibleCursorPos = TheState.Editor.CursorPosition
|
|
visibleScrollOffset = TheState.ScrollOffset
|
|
}
|
|
|
|
// Record the shaped visible range for the profiler probe (confirms the
|
|
// virtual-scroll window stays viewport-bounded, not the whole file).
|
|
TheState.VisibleStart = start
|
|
TheState.VisibleEnd = end
|
|
|
|
// Record the visible window for IME: the snippet is this window, so an
|
|
// EditEvent.Range (relative to the window) is offset by IMEWindowStartByte
|
|
// to address the buffer. start is 0 for small (string) files, so the
|
|
// window is the whole buffer there.
|
|
TheState.Editor.IMEWindowStartByte = start
|
|
TheState.Editor.IMEWindowText = visibleContent
|
|
// Window-relative selection for the TextField (byte offsets into
|
|
// visibleContent); -1 means nothing visible is selected. The IME push and
|
|
// the in-app highlight consume this; the logic side keeps absolute
|
|
// offsets in EditorState.
|
|
windowSelStart, windowSelEnd := -1, -1
|
|
if ss := TheState.Editor.SelectionStart; ss >= 0 && TheState.Editor.SelectionEnd > ss {
|
|
ws, we := ss-start, TheState.Editor.SelectionEnd-start
|
|
if ws < 0 {
|
|
ws = 0
|
|
}
|
|
if we > len(visibleContent) {
|
|
we = len(visibleContent)
|
|
}
|
|
if ws < we {
|
|
windowSelStart, windowSelEnd = ws, we
|
|
}
|
|
}
|
|
|
|
// Add the TextField back in a way that passes the test.
|
|
editorElem := ui.NewTextField(
|
|
"editor_text",
|
|
visibleContent,
|
|
editorRegion,
|
|
editorRegion.W,
|
|
visibleScrollOffset,
|
|
visibleCursorPos,
|
|
windowSelStart,
|
|
windowSelEnd,
|
|
[]ui.Interaction{
|
|
{Gesture: ui.Scroll, Handler: HandleScroll},
|
|
{Gesture: ui.KeyDown, Handler: HandleKeyDown},
|
|
{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 {
|
|
// Window-space tap -> window-relative text-local coordinates.
|
|
// (See tapLocalY: the GlyphLayout is window-relative, so the Y must
|
|
// be too — never add the full scroll offset here.)
|
|
localX := float64(pt.X - editorRegion.X)
|
|
localY := tapLocalY(pt.Y, editorRegion.Y, TheState.ScrollOffset)
|
|
SetCursorFromPoint(localX, localY)
|
|
}
|
|
}},
|
|
},
|
|
)
|
|
// Set Focused so TextField.Draw() issues key.FocusCmd, which is required
|
|
// for Gio to deliver key events to this element.
|
|
editorElem.Focused = TheState.FocusedElementID == "editor_text"
|
|
|
|
return []ui.Element{statusBar, editorElem, bottomBar}
|
|
}
|
|
|
|
// tapLocalY converts a tap's screen Y (Dp) to a text-local Y in the
|
|
// window-relative coordinate space of the GlyphLayout, where layout.Y==0 is the
|
|
// top of the VISIBLE WINDOW, not the top of the file. It adds only the sub-line
|
|
// remainder of the scroll (ScrollOffset mod lineHeight), never the full scroll:
|
|
// the full scroll would make visualLine a huge content-line number far past the
|
|
// window's line count, clamping the cursor to the bottom line of the viewport on
|
|
// any large file (it only worked by luck on small files whose window spanned the
|
|
// tapped content-line number).
|
|
func tapLocalY(ptY, regionTopY ui.Dp, scrollOffset ui.Dp) float64 {
|
|
return float64(ptY-regionTopY) + math.Mod(float64(scrollOffset), float64(EditorLineHeight()))
|
|
}
|
|
|
|
// SetCursorFromPoint updates the cursor position based on screen coordinates (Dp).
|
|
func SetCursorFromPoint(x, y float64) {
|
|
// A tap is an explicit cursor placement: it always clears any selection.
|
|
ClearSelection()
|
|
layout := TheState.Editor.GlyphLayout
|
|
if len(layout.ByteOffsets) == 0 || len(layout.X) == 0 || len(layout.Advance) == 0 {
|
|
return
|
|
}
|
|
|
|
base := glyphBase()
|
|
lineHeight := float64(EditorLineHeight())
|
|
|
|
// 1. Identify the intended line index based on y
|
|
// layout.Y values are relative to the text region origin.
|
|
// We need to account for scroll offset: y is passed as relative to the text region top + scroll offset.
|
|
// So y is the position in the *content*.
|
|
visualLine := int(y / lineHeight)
|
|
|
|
// Group glyphs by their Y-baseline
|
|
type lineGroup struct {
|
|
y float64
|
|
indices []int
|
|
}
|
|
groups := []lineGroup{}
|
|
|
|
// Find all unique baseline Ys
|
|
// The tap Y is based on line height (top of line).
|
|
// We need to associate Y-baseline with visual line index.
|
|
|
|
// Create map from visual line (0, 1, 2...) to baseline Y.
|
|
// Since line height is fixed:
|
|
// Line 0 baseline is at some Y0.
|
|
// Line 1 baseline is at Y0 + lineHeight.
|
|
|
|
// Let's find Y0 first.
|
|
minY := 1e9
|
|
for _, yVal := range layout.Y {
|
|
if float64(yVal) < minY {
|
|
minY = float64(yVal)
|
|
}
|
|
}
|
|
|
|
// Now group by baseline
|
|
groups = []lineGroup{} // Reset groups
|
|
for i, yVal := range layout.Y {
|
|
yFloat := float64(yVal)
|
|
lineIdx := int((yFloat-minY)/lineHeight + 0.5) // round to nearest line
|
|
if lineIdx < 0 {
|
|
lineIdx = 0
|
|
}
|
|
|
|
// Ensure enough groups
|
|
for len(groups) <= lineIdx {
|
|
groups = append(groups, lineGroup{
|
|
y: minY + float64(len(groups))*lineHeight,
|
|
indices: []int{},
|
|
})
|
|
}
|
|
groups[lineIdx].indices = append(groups[lineIdx].indices, i)
|
|
}
|
|
|
|
// If visualLine is out of bounds, clamp
|
|
if visualLine < 0 {
|
|
visualLine = 0
|
|
}
|
|
if visualLine >= len(groups) || len(groups[visualLine].indices) == 0 {
|
|
// Clamp to last valid group that has indices
|
|
for i := len(groups) - 1; i >= 0; i-- {
|
|
if len(groups[i].indices) > 0 {
|
|
visualLine = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if len(groups[visualLine].indices) == 0 {
|
|
return
|
|
}
|
|
|
|
targetGroup := groups[visualLine]
|
|
|
|
// 3. Identify rightmost extent on this line
|
|
rightmostX := 0.0
|
|
rightmostIdx := -1
|
|
for _, i := range targetGroup.indices {
|
|
xEnd := float64(layout.X[i] + layout.Advance[i])
|
|
if xEnd > rightmostX {
|
|
rightmostX = xEnd
|
|
rightmostIdx = i
|
|
}
|
|
}
|
|
|
|
// 4. Check if tap is to the right of the last character
|
|
if rightmostIdx != -1 && x > rightmostX {
|
|
// Position at the end of the line content, before any trailing newline.
|
|
start := base + layout.ByteOffsets[rightmostIdx]
|
|
buf := TheState.Editor.ChunkedBuffer
|
|
var fileContent string
|
|
if buf != nil {
|
|
content, err := buf.FullContent()
|
|
if err != nil {
|
|
log.Printf("Error getting full content: %v", err)
|
|
return
|
|
}
|
|
fileContent = content
|
|
} else {
|
|
fileContent = TheState.Editor.Buffer
|
|
}
|
|
r, size := utf8.DecodeRuneInString(fileContent[start:])
|
|
if r == '\n' {
|
|
TheState.Editor.CursorPosition = start
|
|
} else {
|
|
TheState.Editor.CursorPosition = start + size
|
|
}
|
|
return
|
|
}
|
|
|
|
// 5. Otherwise, find the closest glyph on this line.
|
|
bestIdx := -1
|
|
minDist := float64(1e9)
|
|
for _, i := range targetGroup.indices {
|
|
if bestIdx == -1 {
|
|
bestIdx = i
|
|
}
|
|
// Calculate distance to the glyph center
|
|
glyphCenterX := float64(layout.X[i] + layout.Advance[i]/2)
|
|
dist := glyphCenterX - x
|
|
if dist < 0 {
|
|
dist = -dist
|
|
}
|
|
if dist < minDist {
|
|
minDist = dist
|
|
bestIdx = i
|
|
}
|
|
}
|
|
if bestIdx != -1 {
|
|
TheState.Editor.CursorPosition = base + layout.ByteOffsets[bestIdx]
|
|
}
|
|
}
|