Pad/internal/editor/state.go
Greg Pomerantz 0f1b6e6290 Hold the restored scroll on its line while wrap counts settle (spec §2.4)
On device (Pixel 9 Pro) the relaunch landed further UP than the saved
position: while the restore scroll is still armed (scale + size + content
can take ~600 ms), the top-of-file window is what gets rendered, and its
shaping feedback — real wrap counts for lines ABOVE the restored line —
lands before or just after the offset is applied. The counts are correct
data, but they change V(line), so the line-derived offset (synthesized
for the all-estimate index) maps to a shallower line and the viewport
drifts up; the save then persists the drifted line and every subsequent
relaunch lands there.

Fix: the restore pins the logical line. Until the restored window's own
shaping arrives (or a 2 s timeout, or the user scrolls / a search jumps),
every accepted wrap correction re-derives the offset as
VisualsBefore(line)*lh + sub under the corrected index. The pin refresh
does not clamp to MaxScroll: the correction just grew the index, so the
pre-layout clamp value is stale and would under-clamp the re-derived
offset (the layout of the emitted frame clamps to the fresh value).

Also: the apply-time offset is mapped through VisualsBefore under the
current index (identical to line*lh while the index is all estimates),
so pre-apply corrections are absorbed instead of gated away.

Verified on device: saved line 420 -> restored 420 (was 333); saved 573
(near EOF, clamp territory) -> restored 573. New e2e regression
TestRestore_LinePinHoldsAcrossLateWrapFeedback replays the late
top-window feedback and fails pre-fix (window drifts 200 -> 66).
2026-08-20 21:03:56 -04:00

2647 lines
88 KiB
Go

package editor
import (
"fmt"
"log"
"sort"
"strings"
"time"
"unicode"
"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
// EditSeq counts content edits (incremented by markDirty). The shaped
// glyph layout arriving via layoutChan is only applied to the WrapIndex
// when its EditSeq matches, so a layout shaped before an edit can never
// stamp stale wrap counts onto shifted lines.
EditSeq uint64
// ShowIMESeq pulses whenever the logic layer wants the soft keyboard up
// (file open, tap/double-tap on the editor). The renderer issues
// SoftKeyboardCmd{Show:true} only on a change (see TextField.ShowIMESeq),
// so a user-dismissed keyboard stays down until the next pulse.
ShowIMESeq uint64
// --- Touch selection (v1) ---
// CaretDrag: after a long press on blank space a single draggable caret
// handle is shown (no selection). MenuVisible/MenuRect/MenuItems: the
// floating copy/cut/paste menu (positioned below the line of the selection
// end; items are recomputed when the menu is shown). SelDragging/
// SelDragWhich/SelDragRel: transient state of an in-progress handle/body
// drag (Which: 0 = start handle, 1 = end handle, 2 = body, 3 = caret
// handle). All of it is logic-owned; the renderer only reports finger
// positions and draws the geometry.
CaretDrag bool
MenuVisible bool
MenuRect ui.Region
MenuItems []ui.MenuItem
SelDragging bool
SelDragWhich int
SelDragRel int
// SelDragSwapped: during a start/end-handle drag the finger crossed the
// opposite handle and the selection was flipped (native behaviour): the
// dragged handle now controls the end it crossed. Cleared when the finger
// crosses back or the drag ends.
SelDragSwapped bool
// SelDragPressX/Y is the text-local position of the first event of an
// in-progress handle/caret drag (the grab), and SelDragAnchorX/Y the
// text-local point of the dragged anchor AT THE GRAB (see
// textPointLocal). Each later event moves the anchor by the finger's
// displacement from the grab, so the handle tracks the finger 1:1 (the
// teardrop stays under the finger) and the selection resizes
// continuously — no line snapping, no jump on grab. The reference is
// fixed at the grab, so the anchor's own movement cannot feed back into
// its target: a jittering finger near a line boundary cannot race the
// anchor off the screen.
SelDragPressX float64
SelDragPressY float64
SelDragAnchorX float64
SelDragAnchorY float64
// Find is the in-file search state (find bar); see search.go.
Find FindState
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
fontScale float32 // user font-size setting (PxPerSp/PxPerDp); 0 = unknown -> 1.0
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
// WindowStartLine is the logical line the visible editor window starts
// at, for the last computed layout (-1 when no editor window is shown).
// It is shipped with the shaped glyph layout (Frame) so the layout
// correlation pass applies wrap counts to the lines that layout
// describes, not the current window (a scroll may have moved it).
WindowStartLine 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
// EditorRegion is the editor text region in app-local Dp, recomputed on
// every layout. Input handlers (tap, long press, selection drags) convert
// app-local points to text-local coordinates through it.
EditorRegion ui.Region
// Clipboard channels (touch-selection menu). ClipboardSetChan and
// PasteReqChan are consumed by the main goroutine, which executes the Gio
// clipboard ops and reports the read result back on PasteChan. Buffered so
// a harness without a main loop never blocks the logic goroutine.
clipboardSetChan chan string
pasteReqChan chan struct{}
pasteChan chan string
// 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),
},
clipboardSetChan: make(chan string, 16),
pasteReqChan: make(chan struct{}, 16),
pasteChan: make(chan string, 16),
}
}
func (s *State) SetScale(scale float32) {
s.scale = scale
}
func (s *State) SetFontScale(fs float32) {
s.fontScale = fs
}
// Page returns the current page (BrowserPage or EditorPage).
func (s *State) Page() Page {
return s.page
}
func (s *State) Scale() float32 {
return s.scale
}
// stateFontScale returns the user font-size setting (1.0 when unknown).
func stateFontScale() float32 {
if TheState != nil && TheState.fontScale > 0 {
return TheState.fontScale
}
return 1
}
// EffectiveLineHeight is the editor line height in density-dp WITH the user
// font-size setting applied. The shaper draws baselines at
// Sp(EditorFontSize*LineHeightScale) physical px, which is
// EditorLineHeight()*fontScale density-dp. Every piece of geometry
// bookkeeping (window start, sub-line remainder, tap mapping, scroll
// clamping, cursor vertical move) must use this value rather than the raw
// EditorLineHeight; at a non-default font setting the two differ by the
// font scale, which would misplace taps by up to (fontScale-1) viewportfuls
// of lines and make scroll clamping stop short of (or run past) the file
// ends.
func EffectiveLineHeight() ui.Dp {
return EffectiveLineHeightAt(stateFontScale())
}
// EffectiveLineHeightAt is EffectiveLineHeight for an explicit font scale
// (used where the live State value is not the right source, e.g. tests).
func EffectiveLineHeightAt(fs float32) ui.Dp {
if fs <= 0 {
fs = 1
}
return ui.Dp(float64(EditorLineHeight()) * float64(fs))
}
// 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
if TheLogic != nil {
TheLogic.releaseRestorePin() // the user takes over the viewport
}
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.Editor.findClose()
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
TheState.Editor.ShowIMESeq++ // raise the keyboard for the newly opened file
// 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. It also dismisses
// the selection menu and cancels any in-progress drag bookkeeping: every path
// that clears the selection (tap, plain cursor move, any edit) should take
// the menu and handles down with it.
func ClearSelection() {
e := &TheState.Editor
e.SelectionAnchor = -1
e.SelectionStart = -1
e.SelectionEnd = -1
e.MenuVisible = false
e.MenuItems = nil
e.SelDragging = false
}
// 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:]
}
TheState.Editor.findEdit(start, end, 0)
}
// --- Touch selection (v1) ---------------------------------------------------
//
// Android-style touch interaction, layered over the byte-range selection
// model above. Single tap = caret (existing). Long press = select the word
// under the finger (or a draggable caret handle on blank space). Double tap
// = select the word. Handles: drag start/end to resize, drag the body to move
// the whole selection. Floating menu: Copy / Cut / Paste.
//
// Coordinate flow: the renderer reports app-local window Dp points (the same
// space as a tap). The functions below convert to text-local coordinates via
// EditorRegion + tapLocalY, then to byte offsets via textPosFromLocalPoint.
// All of these run on the logic goroutine (owner).
const (
menuItemW = ui.Dp(56) // width of one selection-menu button
menuH = ui.Dp(52) // selection-menu panel height
// handleDropDp is how far below a visual line's bottom edge the selection
// handle's GRAB BOX extends: 10dp (handle radius, line bottom to handle
// centre) + 24dp (half the 48dp grab box). Keep in sync with the
// renderer's registerDrag geometry (internal/ui/render.go). A menu placed
// below a single-line selection sits this far below the line's bottom
// edge so it does not overlap the handles' grab boxes — the menu is drawn
// topmost (z-order) and would steal the handles' drags.
handleDropDp = 34
)
// isWordRune reports whether a rune is part of a selectable word (letters,
// digits, underscore).
func isWordRune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
}
// windowContent returns a content accessor for the active buffer.
func windowContent() func(a, b int) string {
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
return func(a, b int) string {
return cb.Content(a, b)
}
}
s := TheState.Editor.Buffer
return func(a, b int) string {
if a < 0 {
a = 0
}
if b > len(s) {
b = len(s)
}
if a >= b {
return ""
}
return s[a:b]
}
}
func fileLenBytes() int {
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
return int(cb.FileLen())
}
return len(TheState.Editor.Buffer)
}
// wordRangeAt returns the byte range [start, end) of the word containing the
// rune at (or immediately before) pos. ok=false when neither is a word
// character (e.g. a space or punctuation).
func wordRangeAt(pos int) (start, end int, ok bool) {
content := windowContent()
fileLen := fileLenBytes()
if pos < 0 {
pos = 0
}
if pos > fileLen {
pos = fileLen
}
// 256-byte window around pos; words longer than that are clipped (rare).
a := pos - 256
if a < 0 {
a = 0
}
b := pos + 256
if b > fileLen {
b = fileLen
}
w := content(a, b)
rp := pos - a
// Rune starting at rp, and rune ending at rp (if any).
rAt, szAt := utf8.DecodeRuneInString(w[rp:])
var rBefore rune
var szBefore int
if rp > 0 {
j := rp - 1
for j >= 0 && w[j]&0xC0 == 0x80 {
j--
}
if j >= 0 {
rBefore, szBefore = utf8.DecodeRuneInString(w[j:])
}
}
// expandWord grows [l, r) over consecutive word runes (byte offsets in w).
expandWord := func(l, r int) (int, int) {
for l > 0 {
j := l - 1
for j >= 0 && w[j]&0xC0 == 0x80 {
j--
}
if j < 0 {
break
}
r, _ := utf8.DecodeRuneInString(w[j:])
if !isWordRune(r) {
break
}
l = j
}
for r < len(w) {
rr, sz := utf8.DecodeRuneInString(w[r:])
if sz == 0 || !isWordRune(rr) {
break
}
r += sz
}
return l, r
}
if szAt > 0 && isWordRune(rAt) {
l, r := expandWord(rp, rp+szAt)
return a + l, a + r, true
}
if szBefore > 0 && isWordRune(rBefore) {
l, r := expandWord(rp-szBefore, rp)
return a + l, a + r, true
}
return 0, 0, false
}
// pointInMenu reports whether the app-local Dp point is inside the visible
// selection menu panel.
func pointInMenu(x, y ui.Dp) bool {
r := TheState.Editor.MenuRect
return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H
}
func hideSelectionMenu() {
e := &TheState.Editor
e.MenuVisible = false
e.MenuItems = nil
e.MenuRect = ui.Region{}
}
// positionSelectionMenu places the copy/cut/paste menu relative to the
// selection, mimicking the native Android selection toolbar:
//
// - Preferred: ABOVE the selection's top line. The menu is anchored to the
// STABLE end of the selection — SelectionStart, the end that does not
// move while the user drags the END handle — so the menu does not chase
// the moving handle and does not float inside a tall selection (anchoring
// to the end would drag the menu through the selected text when the end
// handle is dragged down). While the START handle is dragged, the start
// moves and the end is fixed, so it anchors to the end. With no
// selection (caret) it anchors to the caret.
// - Fallback: when there is no room above (the selection starts at the top
// of the window), BELOW the selection. For a single-line selection the
// menu sits below the selection HANDLES (they hang off the line's bottom
// edge): the menu is drawn last (top of the z-order) and would steal the
// handles' drags wherever it overlaps their grab boxes. For a multi-line
// selection it sits 8dp below the selection's bottom edge, like the
// native toolbar.
//
// The menu must not vanish while the selection is still on screen: when both
// ends of the selection are outside the shaped window but some part of the
// selection is still visible, the menu keeps its previous position clamped
// to the window. It reports false (and the caller hides it) only when the
// selection has left the window entirely. Re-anchoring (double-tap) re-shows
// it. It is called from showSelectionMenu and from EditorLayout on every
// frame while the menu is visible, so the menu tracks the selected text when
// the user scrolls: it is anchored to the TEXT (an absolute buffer offset
// mapped through the live screen geometry), not to the screen position where
// it was first shown.
func positionSelectionMenu(e *EditorState) bool {
if e.TooLarge || len(e.GlyphLayout.ByteOffsets) == 0 || len(e.MenuItems) == 0 {
return false
}
anchor, other := e.SelectionStart, e.SelectionEnd
if !selActive() {
anchor, other = e.CursorPosition, e.CursorPosition
} else if e.SelDragging && e.SelDragWhich == 0 {
anchor, other = e.SelectionEnd, e.SelectionStart
}
glyphX, lineTop, ok := bytePosToScreenXY(anchor)
if !ok {
glyphX, lineTop, ok = bytePosToScreenXY(other)
}
if !ok {
// Both ends are outside the shaped window. Hide the menu only when no
// part of the selection is visible; otherwise keep it at its previous
// position, clamped inside the window.
base := glyphBase()
if e.SelectionEnd <= base || e.SelectionStart >= base+len(e.IMEWindowText) {
return false
}
var winW, winH float64
if TheState.scale > 0 {
winW = float64(ui.ToDp(ui.Px(TheState.PixelWidth), TheState.scale))
winH = float64(ui.ToDp(ui.Px(TheState.PixelHeight), TheState.scale))
}
if winW > 0 {
if e.MenuRect.X < 8 {
e.MenuRect.X = 8
}
if float64(e.MenuRect.X+e.MenuRect.W) > winW-8 {
e.MenuRect.X = ui.Dp(winW) - 8 - e.MenuRect.W
}
}
if winH > 0 {
if e.MenuRect.Y < 8 {
e.MenuRect.Y = 8
}
if float64(e.MenuRect.Y+e.MenuRect.H) > winH-8 {
e.MenuRect.Y = ui.Dp(winH) - 8 - e.MenuRect.H
}
}
return true
}
var winW, winH float64
if TheState.scale > 0 {
winW = float64(ui.ToDp(ui.Px(TheState.PixelWidth), TheState.scale))
winH = float64(ui.ToDp(ui.Px(TheState.PixelHeight), TheState.scale))
}
menuW := menuItemW * ui.Dp(len(e.MenuItems))
mx := glyphX - float64(menuW)/2
if mx < 8 {
mx = 8
}
if winW > 0 && mx+float64(menuW) > winW-8 {
mx = winW - float64(menuW) - 8
}
lh := float64(EffectiveLineHeight())
// Multi-line iff the selection spans more than one logical line.
// (Testing whether the opposite end sits on a LOWER visual line does not
// work: a selection ending at a line's trailing newline maps to the NEXT
// line, so single-line selections would read as multi-line.)
multiLine := false
if cb := e.ChunkedBuffer; cb != nil {
if li := cb.LineIndex; li != nil && other > anchor {
multiLine = li.FindLogicalLineForByteOffset(anchor) !=
li.FindLogicalLineForByteOffset(other-1)
}
}
// Selection bottom: the bottom of the visual line containing the opposite
// end (the anchor line itself for a single-line selection).
selBottom := lineTop + lh
if multiLine {
if _, ot, ok2 := bytePosToScreenXY(other); ok2 && ot > lineTop+0.5 {
selBottom = ot + lh
}
}
// Prefer ABOVE the selection's top line (see the doc above).
my := lineTop - float64(menuH) - 8
if my < 8 {
// No room above: place below. A single-line selection needs the menu
// clear of its handles (see the doc above); a multi-line selection
// follows the native toolbar (8dp below the selection's bottom edge).
gap := 8.0
if !multiLine {
gap = float64(handleDropDp) + 8
}
my = selBottom + gap
if winH > 0 && my+float64(menuH) > winH-8 {
my = winH - float64(menuH) - 8 // clamp to window bottom
}
}
if my < 8 {
my = 8
}
e.MenuRect = ui.Region{X: ui.Dp(mx), Y: ui.Dp(my), W: menuW, H: menuH}
return true
}
// showSelectionMenu recomputes the menu items and positions the menu above
// the line containing the selection end (or caret), falling back to below.
// Copy and Cut are offered only while a selection is active; Paste always.
func showSelectionMenu() {
e := &TheState.Editor
if e.TooLarge || len(e.GlyphLayout.ByteOffsets) == 0 {
return
}
var items []ui.MenuItem
add := func(icon, label string) {
items = append(items, ui.MenuItem{
Icon: icon, Label: label,
X: menuItemW * ui.Dp(len(items)), Y: 0, W: menuItemW, H: menuH,
})
}
if selActive() {
add("copy", "Copy")
add("cut", "Cut")
}
add("paste", "Paste")
e.MenuItems = items
if positionSelectionMenu(e) {
e.MenuVisible = true
}
}
// bytePosToScreenXY returns app-local Dp coordinates for absByte: the X of
// the glyph at (or just before) the byte, and the top Y of the visual line
// containing it. ok=false when the layout is empty or the byte is outside
// the visible window. This is the inverse of textPosFromLocalPoint.
func bytePosToScreenXY(absByte int) (glyphX, lineTop float64, ok bool) {
layout := TheState.Editor.GlyphLayout
reg := TheState.EditorRegion
if len(layout.ByteOffsets) == 0 {
return 0, 0, false
}
base := glyphBase()
pos := absByte - base
windowLen := len(TheState.Editor.IMEWindowText)
if pos < 0 || pos > windowLen {
return 0, 0, false
}
// Same pitch the renderer shaped with (font-scale aware), so the visual
// line derived from the shaper's baseline matches the drawn geometry.
lineHeight := float64(EffectiveLineHeight())
if layout.LineHeight > 0 {
lineHeight = float64(layout.LineHeight)
}
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= pos
})
if idx < len(layout.ByteOffsets) && layout.ByteOffsets[idx] > pos {
idx--
}
if idx < 0 {
idx = 0
}
if idx >= len(layout.ByteOffsets) {
// pos is at/past EOF: anchor on the last glyph.
idx = len(layout.ByteOffsets) - 1
}
gx := float64(reg.X) + float64(layout.X[idx])
// layout.Y is the baseline relative to the layout's first line. The
// shaper places baselines at ascent + lineHeight*lineIndex with
// 0 < ascent < lineHeight (see drawWrappedText: ascent = FontSize, lineH
// = FontSize*1.2), so baseline/lineHeight = lineIndex + frac with a frac
// bounded away from both 0 and 1 — TRUNCATION yields the layout-relative
// visual line index, robustly (the frac margin is ~0.2*lineHeight, far
// above float32 noise). Do NOT round to nearest here: with this font's
// frac ≈ 0.83, +0.5 rounding floors to the line BELOW.
visualLine := int(float64(layout.Y[idx]) / lineHeight)
if visualLine < 0 {
visualLine = 0
}
// The window is drawn shifted up by r' (scrollVisualDecompose). For
// chunked files the layout is WINDOW-relative (its first line is the
// window's first line), so visualLine is already relative to the
// viewport top. For small (string) files the layout covers the WHOLE
// buffer and the draw shifts by the full scroll offset: subtract the
// window-start line k so the mapping agrees with the drawn geometry
// (before this, the menu/caret mapping sat k lines off on scrolled
// small files).
k, r := scrollVisualDecompose()
if TheState.Editor.ChunkedBuffer == nil {
// May go negative: the anchor is above the viewport. That is the
// intended off-screen follow-through; callers clamp the resulting
// position (e.g. the menu pins to the window edge via my < 8).
visualLine -= k
}
lt := float64(reg.Y) - r + float64(visualLine)*lineHeight
return gx, lt, true
}
// tapInputGuard swallows editor input for a short window after a file opens:
// the tap that opened the file must not also reposition the caret.
func tapInputGuard() bool {
return time.Since(TheState.justOpenedAt) < 300*time.Millisecond
}
// HandleTapAt places the caret at app-local Dp point (x, y). A tap on the
// visible selection menu is ignored (the menu's own Tap interaction handles
// it); any other tap dismisses the menu and ends a caret drag.
func HandleTapAt(x, y ui.Dp) {
if tapInputGuard() {
return
}
e := &TheState.Editor
if e.MenuVisible && pointInMenu(x, y) {
return
}
e.CaretDrag = false
hideSelectionMenu()
localX := float64(x - TheState.EditorRegion.X)
localY := tapLocalY(y, TheState.EditorRegion.Y)
SetCursorFromPoint(localX, localY)
TheState.Editor.ShowIMESeq++ // tap = intent to type: re-raise a dismissed keyboard
}
// HandleLongPressAt implements Android long-press: on a word it selects the
// word; on blank space it sets the caret there and shows a single draggable
// caret handle. The selection menu is shown either way.
func HandleLongPressAt(x, y ui.Dp) {
if tapInputGuard() {
return
}
e := &TheState.Editor
if e.TooLarge {
return
}
if e.MenuVisible && pointInMenu(x, y) {
return
}
localX := float64(x - TheState.EditorRegion.X)
localY := tapLocalY(y, TheState.EditorRegion.Y)
pos, ok := textPosFromLocalPoint(localX, localY)
if !ok {
return
}
if ws, we, found := wordRangeAt(pos); found {
SetSelection(ws, we)
e.CaretDrag = false
} else {
ClearSelection()
e.CursorPosition = pos
e.CaretDrag = true
}
showSelectionMenu()
}
// HandleDoubleTapAt selects the word under the finger (and shows the menu);
// on blank space it just places the caret.
func HandleDoubleTapAt(x, y ui.Dp) {
if tapInputGuard() {
return
}
e := &TheState.Editor
if e.TooLarge {
return
}
if e.MenuVisible && pointInMenu(x, y) {
return
}
localX := float64(x - TheState.EditorRegion.X)
localY := tapLocalY(y, TheState.EditorRegion.Y)
pos, ok := textPosFromLocalPoint(localX, localY)
if !ok {
return
}
if ws, we, found := wordRangeAt(pos); found {
SetSelection(ws, we)
e.CaretDrag = false
showSelectionMenu()
} else {
SetCursorFromPoint(localX, localY)
}
TheState.Editor.ShowIMESeq++ // tap = intent to type: re-raise a dismissed keyboard
}
// HandleSelDragEvt is the registered SelDrag interaction handler. The
// renderer delivers SelectionDragEvent (finger position) and SelectionDragEnd.
func HandleSelDragEvt(data any) {
switch ev := data.(type) {
case ui.SelectionDragEvent:
selDragMove(ev.Which, ev.X, ev.Y)
case ui.SelectionDragEnd:
TheState.Editor.SelDragging = false
TheState.Editor.SelDragSwapped = false
TheState.Editor.SelDragRel = 0
// The caret handle (long press on blank space) is a transient affordance;
// the selection handles come back from the selection state on the next
// frame, so only the caret mode is dropped here.
TheState.Editor.CaretDrag = false
}
}
// selDragMove applies one finger position of a selection/caret drag.
func selDragMove(which int, x, y ui.Dp) {
e := &TheState.Editor
localX := float64(x - TheState.EditorRegion.X)
localY := tapLocalY(y, TheState.EditorRegion.Y)
pos, ok := textPosFromLocalPoint(localX, localY)
if !e.SelDragging {
e.SelDragging = true
e.SelDragWhich = which
e.SelDragSwapped = false
e.SelDragPressX = localX
e.SelDragPressY = localY
if which == 2 && ok {
// Body drag: the grab fixes the finger's offset from the selection
// start; the selection itself moves on later events.
e.SelDragRel = pos - e.SelectionStart
if e.SelDragRel < 0 {
e.SelDragRel = 0
}
return
}
// Start/end/caret handles: the grab fixes the anchor's text position;
// later events move it by the finger's displacement from the grab.
var anchor int
switch which {
case 0:
anchor = e.SelectionStart
case 1:
anchor = e.SelectionEnd
default: // 3: caret
anchor = e.CursorPosition
}
if ax, ay, ok2 := textPointLocal(anchor); ok2 {
e.SelDragAnchorX = ax
e.SelDragAnchorY = ay
}
return
}
switch e.SelDragWhich {
case 0, 1: // start / end handle
// Move the anchor by the finger's displacement from the grab (1:1
// tracking: the teardrop stays under the finger, the selection
// resizes continuously, lines are crossed only when the finger's
// mapped point crosses them).
pos, ok = handleFollowPos(e, localX, localY)
if !ok {
return // finger outside the laid-out window: keep last position
}
// Crossing the opposite handle flips the selection (native behaviour)
// instead of clamping to zero length and clearing it; while flipped the
// dragged handle controls the end it crossed.
if e.SelDragWhich == 0 {
if !e.SelDragSwapped {
if pos > e.SelectionEnd {
e.SelDragSwapped = true
SetSelection(e.SelectionEnd, pos)
} else if pos < e.SelectionEnd {
SetSelection(pos, e.SelectionEnd)
}
} else {
if pos < e.SelectionStart {
e.SelDragSwapped = false
SetSelection(pos, e.SelectionStart)
} else if pos > e.SelectionStart {
SetSelection(e.SelectionStart, pos)
}
}
} else {
if !e.SelDragSwapped {
if pos < e.SelectionStart {
e.SelDragSwapped = true
SetSelection(pos, e.SelectionStart)
} else if pos > e.SelectionStart {
SetSelection(e.SelectionStart, pos)
}
} else {
if pos > e.SelectionEnd {
e.SelDragSwapped = false
SetSelection(e.SelectionEnd, pos)
} else if pos < e.SelectionEnd {
SetSelection(e.SelectionEnd, pos)
}
}
}
case 2: // body: move the whole selection, preserving length
if !ok {
return
}
selLen := e.SelectionEnd - e.SelectionStart
ns := pos - e.SelDragRel
fl := fileLenBytes()
if ns < 0 {
ns = 0
}
if ns+selLen > fl {
ns = fl - selLen
}
SetSelection(ns, ns+selLen)
case 3: // caret drag handle
if p, ok2 := handleFollowPos(e, localX, localY); ok2 {
e.CursorPosition = p
}
}
}
// handleFollowPos maps the dragged handle's target point — the anchor's grab
// position plus the finger's displacement from the grab — to a byte offset.
func handleFollowPos(e *EditorState, localX, localY float64) (int, bool) {
tx := e.SelDragAnchorX + (localX - e.SelDragPressX)
ty := e.SelDragAnchorY + (localY - e.SelDragPressY)
return textPosFromLocalPoint(tx, ty)
}
// textPointLocal is the inverse of textPosFromLocalPoint: the text-local
// (x, y) point that maps back to byteOff. x is the insertion point's
// x (the glyph's left edge, or the previous glyph's right edge), and y is
// the MIDDLE of the byte's visual line band so that textPosFromLocalPoint's
// int(y/lineHeight) line resolution lands on the byte's own line.
func textPointLocal(byteOff int) (x, y float64, ok bool) {
layout := TheState.Editor.GlyphLayout
if len(layout.ByteOffsets) == 0 || len(layout.Y) == 0 {
return 0, 0, false
}
lh := float64(EffectiveLineHeight())
if lh <= 0 {
return 0, 0, false
}
base := glyphBase()
winByte := byteOff - base
if winByte < 0 {
winByte = 0
}
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= winByte
})
if idx < len(layout.ByteOffsets) && layout.ByteOffsets[idx] == winByte {
x = float64(layout.X[idx])
} else if idx > 0 {
// The byte sits between glyphs (mid-multibyte) or past the last
// glyph (EOF): the previous glyph's right edge.
x = float64(layout.X[idx-1] + layout.Advance[idx-1])
}
line, ok2 := visualLineOfByte(winByte)
if !ok2 {
return 0, 0, false
}
y = (float64(line) + 0.5) * lh
return x, y, true
}
// visualLineOfByte returns the visual line (0-based within the shaped
// window) that holds the insertion point at the given window-relative byte
// offset: the last visual line whose start byte is at or before the offset.
// A line's terminating newline belongs to that line, and an empty line's
// lone insertion point sits on the empty line (the first-glyph-at-or-past
// rule would put both on the next line, which is wrong for empty lines).
func visualLineOfByte(winByte int) (int, bool) {
layout := TheState.Editor.GlyphLayout
if len(layout.ByteOffsets) == 0 || len(layout.Y) == 0 {
return 0, false
}
// The insertion point at winByte is drawn on the last visual line whose
// first byte is at or before it: a line's terminating newline belongs to
// that line, and an empty line's lone insertion point sits on the empty
// line. (Looking at the first glyph at/past the byte instead is wrong
// around empty lines: that glyph is on the NEXT line.) This must agree
// with textPosOnLineAtX's line starts so a dragged handle's grab anchor
// round-trips.
if starts := layout.VisualLineStarts; len(starts) > 0 {
if winByte < 0 {
winByte = 0
}
idx := sort.Search(len(starts), func(i int) bool {
return starts[i] > winByte
})
if idx > 0 {
idx--
}
return idx, true
}
lineHeight := float64(EffectiveLineHeight())
minY := 1e9
for _, yVal := range layout.Y {
if float64(yVal) < minY {
minY = float64(yVal)
}
}
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= winByte
})
if idx == len(layout.ByteOffsets) {
idx = len(layout.ByteOffsets) - 1
}
lineIdx := int((float64(layout.Y[idx])-minY)/lineHeight + 0.5) // round to nearest line
if lineIdx < 0 {
lineIdx = 0
}
return lineIdx, true
}
// HandleMenuTap hit-tests a tap on the menu panel and runs the tapped item.
func HandleMenuTap(x, y ui.Dp) {
e := &TheState.Editor
if !e.MenuVisible || !pointInMenu(x, y) {
return
}
idx := int((x - e.MenuRect.X) / menuItemW)
if idx < 0 || idx >= len(e.MenuItems) {
return
}
switch e.MenuItems[idx].Icon {
case "copy":
handleMenuCopy()
case "cut":
handleMenuCut()
case "paste":
handleMenuPaste()
}
}
// selectedText returns the selected bytes.
func selectedText() (string, bool) {
e := &TheState.Editor
if !selActive() {
return "", false
}
if cb := e.ChunkedBuffer; cb != nil {
return cb.Content(e.SelectionStart, e.SelectionEnd), true
}
return e.Buffer[e.SelectionStart:e.SelectionEnd], true
}
func handleMenuCopy() {
text, ok := selectedText()
if !ok {
return
}
TheState.clipboardSetChan <- text
// The selection (and its highlight) stays; only the menu goes away,
// matching Android behaviour.
hideSelectionMenu()
}
func handleMenuCut() {
e := &TheState.Editor
text, ok := selectedText()
if !ok {
return
}
TheState.clipboardSetChan <- text
deleteRange(e.SelectionStart, e.SelectionEnd)
markDirty()
e.CursorPosition = e.SelectionStart
ClearSelection()
}
func handleMenuPaste() {
if TheState.Editor.TooLarge {
return
}
TheState.pasteReqChan <- struct{}{}
// The read is asynchronous (DataEvent on a later frame), but the menu
// has served its purpose and closes immediately, as on Android.
hideSelectionMenu()
}
// HandlePaste inserts the system clipboard text (replacing a live selection,
// per the selection-aware edit rule). Runs on the logic goroutine.
func HandlePaste(text string) {
if text == "" {
return
}
HandleInsert(text)
}
// 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 < EffectiveLineHeight() {
pageSize = EffectiveLineHeight()
}
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)
e.findEdit(pos, pos+w, 0)
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
}
w := utf8AdvanceWidth(str[pos:end])
TheState.Editor.Buffer = str[:pos] + str[pos+w:]
e.findEdit(pos, pos+w, 0)
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.findEdit(pos, pos, len(s))
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
e.findEdit(pos-w, pos, 0)
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
e.findEdit(pos-w, pos, 0)
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.findEdit(absStart, absEnd, 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() {
// Every content edit funnels through here, so EditSeq is the universal
// "content changed" token (the WrapIndex layout-correlation gate uses it).
TheState.Editor.EditSeq++
// 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.
// FindBarHeight is the find bar's height; buildFindBar and EditorLayout
// (which shrinks the text area to make room) both use it.
const FindBarHeight = ui.Dp(40)
// buildFindBar lays out the in-file search bar: [input][counter][prev][next]
// [clear-X], full screen width, directly below the margin-free top bar (the
// bars merge, so this one does too; inner content keeps the margin). The
// input is a MAIN-owned GioEditor registered as "find_bar" (the browser
// "search_bar" precedent, architecture.md §1); the counter and buttons are
// logic-owned elements whose taps run the handlers in search.go.
func buildFindBar(screenWidth ui.Dp) ui.Element {
margin := ui.Dp(10)
gap := ui.Dp(8)
h := FindBarHeight
topY := ui.Dp(32) // top bar height (see EditorLayout)
// Right-hand button column: prev, next, clear (X).
iconW := ui.IconSize
closeX := screenWidth - margin - iconW
nextX := closeX - gap - iconW
prevX := nextX - gap - iconW
counterW := ui.Dp(96)
inputX := margin
inputW := prevX - gap - counterW - margin
f := TheState.Editor.Find
counter := "0"
switch {
case f.Scanning:
counter = "…"
case f.Query != "" && len(f.Matches) == 0:
counter = "No matches"
case f.Cur >= 0:
counter = fmt.Sprintf("%d / %d", f.Cur+1, len(f.Matches))
}
return ui.NewContainer(
ui.Region{X: 0, Y: topY, W: screenWidth, H: h},
ui.Color{R: 230, G: 230, B: 230, A: 255},
[]ui.Element{
ui.NewGioEditor("find_bar", ui.Region{X: inputX, Y: ui.Dp(6), W: inputW, H: ui.Dp(28)}),
ui.NewLabel(counter, 12, ui.Region{X: inputX + inputW + gap, Y: ui.Dp(8), W: counterW, H: ui.Dp(20)}, ui.AlignEnd, "", nil),
ui.NewIcon("chevron_up", ui.Region{X: prevX, Y: ui.Dp(8), W: iconW, H: iconW}, 0,
[]ui.Interaction{{Gesture: ui.Tap, Handler: FindPrev}}),
ui.NewIcon("chevron_down", ui.Region{X: nextX, Y: ui.Dp(8), W: iconW, H: iconW}, 0,
[]ui.Interaction{{Gesture: ui.Tap, Handler: FindNext}}),
ui.NewIcon("close", ui.Region{X: closeX, Y: ui.Dp(8), W: iconW, H: iconW}, 0,
[]ui.Interaction{{Gesture: ui.Tap, Handler: FindClear}}),
},
)
}
func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
margin := ui.Dp(10)
// --- Top bar: one row, back icon + filename. Cut/copy/paste live in the
// floating selection menu (the native Android pattern); the dead icon row
// is gone, saving 20dp of editor height. The bar spans the full screen
// width with no outer margin (it merges with the system UI); the inner
// content keeps the margin so it aligns with the text area below. ---
statusBarRegion := ui.Region{
X: 0, Y: 0,
W: screenWidth,
H: ui.Dp(32),
}
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{
ui.NewIcon("back", ui.Region{X: margin, Y: ui.Dp(4), W: ui.IconSize, H: ui.IconSize}, 0,
[]ui.Interaction{{Gesture: ui.Tap, Handler: GoToBrowser}}),
ui.NewLabel(filename, 14, ui.Region{X: ui.Dp(32) + margin, Y: ui.Dp(6), W: statusBarW - ui.Dp(32) - margin - ui.IconSize - ui.Dp(8), H: ui.Dp(20)}, ui.AlignStart, "", nil),
ui.NewIcon("search", ui.Region{X: screenWidth - margin - ui.IconSize, Y: ui.Dp(4), W: ui.IconSize, H: ui.IconSize}, 0,
[]ui.Interaction{{Gesture: ui.Tap, Handler: ToggleFind}}),
},
)
// --- Bottom bar: full screen width, flush with the screen bottom (no
// outer margin, same as the top bar). Inner labels keep the margin. ---
bottomBarHeight := ui.BottomBarHeight
bottomBarY := screenHeight - bottomBarHeight
bottomBarRegion := ui.Region{
X: 0, Y: bottomBarY,
W: screenWidth,
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: margin, Y: ui.Dp(2), W: bottomBarW - margin, 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 - margin, H: ui.Dp(20)}, ui.AlignEnd, "wrap", []ui.Interaction{
{Gesture: ui.Tap, Handler: ToggleWordWrap},
}),
},
)
// --- Find bar (in-file search): full width, directly below the top bar,
// while open (see search.go / buildFindBar). ---
var findBar ui.Element
if TheState.Editor.Find.Visible {
findBar = buildFindBar(screenWidth)
}
// --- Editor text area ---
// While the find bar is open it sits in [32, 32+FindBarHeight); shrink the
// text area from the top so the first visible line is not covered.
editorY := statusBarRegion.Y + statusBarRegion.H
if TheState.Editor.Find.Visible {
editorY += FindBarHeight
}
editorH := bottomBarRegion.Y - editorY
editorRegion := ui.Region{
X: margin, Y: editorY,
W: screenWidth - margin*2,
H: editorH,
}
// Stored for the input handlers (tap / long press / drags), which convert
// app-local Dp points to text-local coordinates through it.
TheState.EditorRegion = editorRegion
// WindowStartLine is the logical line the visible window starts at; it is
// shipped with the shaped layout (Frame) so the layout-correlation pass in
// the logic goroutine knows which lines the layout describes. -1 outside
// the editor window (browser page, too-large notice).
TheState.WindowStartLine = -1
// 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 {
// Document height is measured in VISUAL lines: a wrapped logical
// line occupies several of them, so the max scroll must use the
// WrapIndex total, not the logical line count. Before shaping,
// every line estimates to one visual line, so MaxScroll starts at
// the no-wrap value and grows as shaped counts arrive — it only
// ever grows during a warm-up, never jumps under the viewport.
total := int64(li.LineCount())
if w := cb.WrapIndex; w != nil {
total = int64(w.TotalVisuals())
}
lineHeight := EffectiveLineHeight()
if lh := TheState.Editor.GlyphLayout.LineHeight; lh > 0 {
lineHeight = lh
}
maxScroll = ui.Dp(float64(total)*float64(lineHeight)) - editorRegion.H + lineHeight/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) * EffectiveLineHeight()
}
} else {
// Fallback for full buffer
maxScroll = TheState.LastLineY - editorRegion.H + EffectiveLineHeight()/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 := EffectiveLineHeight()
if lh := TheState.Editor.GlyphLayout.LineHeight; lh > 0 {
lineHeight = lh
}
// Assign (not :=) into the outer start/end: they are read below the
// block to derive IMEWindowStartByte and the window-relative
// selection. A `:=` here would shadow them (winLine is new) and the
// outer pair would stay 0 — every scrolled frame would then map the
// selection against byte 0 (the selection "jumps" with the scroll) and
// IME commits would land at the wrong buffer position.
var winLine int
start, end, winLine = cb.VisibleByteRange(TheState.ScrollOffset, TheState.ByteOffset, viewportHeight, lineHeight, TheState.WordWrap, TheState.Editor.GlyphLayout, nil)
// Ship with the frame: the shaped layout's wrap counts belong to THIS
// window's lines (a scroll may move the window before the layout
// arrives, but an edit invalidates it — see EditorState.EditSeq).
TheState.WindowStartLine = winLine
// 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. It may be
// NEGATIVE (the cursor's line scrolled above the window) or exceed
// len(visibleContent) (scrolled below): the renderer treats an
// out-of-window cursor as off-screen and draws no caret, like a native
// editor. Clamping to the window edge here made the caret appear on
// the top/bottom visible line whenever the user scrolled past it.
visibleCursorPos = TheState.Editor.CursorPosition - start
// Adjust scroll offset to be relative to visibleContent origin
// Sub-line shift for the renderer: the SAME visual-line decomposition
// the window start used (VisibleByteRange above), so the drawn
// geometry and the window's content lines agree for every scroll
// offset and wrap state. scrollVisualDecompose re-derives it from the
// same inputs; the two agree by construction (see its doc).
_, subLine := scrollVisualDecompose()
visibleScrollOffset = ui.Dp(subLine)
// 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: same decomposition as the window start above.
prefetchLine := winLine
if prefetchLine < li.LineCount() {
scrollByteOffset = li.ByteOffset(prefetchLine)
} else {
scrollByteOffset = int(cb.FileLen())
}
} else {
// Estimate
prefetchLine, _ := scrollDecompose(TheState.ScrollOffset, EffectiveLineHeight())
scrollByteOffset = prefetchLine * 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
}
}
// Window-relative search matches for the in-text highlight (byte ranges
// into visibleContent). Matches are sorted, so binary-search the first
// that can intersect the window and walk forward. The window is
// [start, start+len(visibleContent)).
var windowMatches [][2]int
windowCur := -1
if f := TheState.Editor.Find; f.Visible && len(f.Matches) > 0 && !TheState.Editor.TooLarge {
winEnd := start + len(visibleContent)
n := len(f.Matches)
i := sort.Search(n, func(i int) bool { return f.Matches[i][0] >= start })
if i > 0 {
i-- // a match starting before the window may extend into it
}
for ; i < n && f.Matches[i][0] < winEnd; i++ {
ws, we := f.Matches[i][0]-start, f.Matches[i][1]-start
if ws < 0 {
ws = 0
}
if we > len(visibleContent) {
we = len(visibleContent)
}
if ws < we {
if i == f.Cur {
windowCur = len(windowMatches)
}
windowMatches = append(windowMatches, [2]int{ws, we})
}
}
}
// Add the TextField back in a way that passes the test.
editorElem := ui.NewTextField(
"editor_text",
visibleContent,
editorRegion,
wordWrap,
editorRegion.W,
visibleScrollOffset,
visibleCursorPos,
windowSelStart,
windowSelEnd,
[]ui.Interaction{
{Gesture: ui.Scroll, Handler: HandleScroll},
{Gesture: ui.KeyDown, Handler: HandleKeyDown},
// The Tap interaction also carries the long-press and double-tap
// events the renderer derives from the same gesture.Click (they share
// the editor's click region). The renderer converts all of them to
// text-local coordinates through EditorRegion + tapLocalY.
{Gesture: ui.Tap, Handler: func(data any) {
// The just-opened guard (swallowing the tap that opened the file)
// lives inside the Handle* functions.
switch pt := data.(type) {
case ui.Point:
HandleTapAt(pt.X, pt.Y)
case ui.DoubleTapPoint:
HandleDoubleTapAt(pt.X, pt.Y)
case ui.LongPressPoint:
HandleLongPressAt(pt.X, pt.Y)
}
}},
{Gesture: ui.SelDrag, Handler: HandleSelDragEvt},
},
)
// While the find bar is open, key focus belongs to the main-owned
// "find_bar" widget.Editor: the editor stops its per-frame IME sync, and
// regaining "editor_text" focus re-issues key.FocusCmd on close (see
// TextField.Draw's focus dedup).
if TheState.Editor.Find.Visible {
TheState.FocusedElementID = "find_bar"
} else if TheState.FocusedElementID == "find_bar" {
TheState.FocusedElementID = "editor_text"
}
// Set Focused so TextField.Draw() issues key.FocusCmd, which is required
// for Gio to deliver key events to this element. ShowIMESeq carries the
// keyboard-raise pulse (see TextField.ShowIMESeq).
editorElem.ShowIMESeq = TheState.Editor.ShowIMESeq
editorElem.Focused = TheState.FocusedElementID == "editor_text"
// Caret handle visibility (long press on blank space).
editorElem.CaretDrag = TheState.Editor.CaretDrag
editorElem.MatchRanges = windowMatches
editorElem.CurrentMatch = windowCur
elems := []ui.Element{statusBar, editorElem, bottomBar}
if findBar != nil {
elems = append(elems, findBar)
}
// The selection menu is added last so it draws on top of the editor.
// Re-anchor it to the selection's live screen position every frame so it
// follows the text while scrolling (it used to stay where it was first
// shown, detaching from the selection).
if TheState.Editor.MenuVisible {
if !positionSelectionMenu(&TheState.Editor) {
hideSelectionMenu()
}
}
if TheState.Editor.MenuVisible {
elems = append(elems, ui.NewMenu("selection_menu", TheState.Editor.MenuRect, TheState.Editor.MenuItems, func(data any) {
if pt, ok := data.(ui.Point); ok {
HandleMenuTap(pt.X, pt.Y)
}
}))
}
return elems
}
// 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).
// scrollDecompose splits a scroll offset s into content line k and sub-line
// remainder r such that k*lh <= s < (k+1)*lh — the floor decomposition in the
// Dp domain, computed in float64.
//
// k and r must be the SINGLE shared source of both the window start line
// (visibleByteRangePrecise/Estimate) and the sub-line draw/tap remainder
// (visibleScrollOffset in layoutFrame, tapLocalY). Computing k as
// int(s/lh) in the Dp float32 domain can round the quotient UP across an
// integer boundary while the (float64) remainder still reflects the line
// below; the two bookkeeping values then disagree by one line in a
// sub-pixel-wide band of scroll offsets, shifting the rendered window — and
// with it every tapped content line — by one. The float64 decomposition with
// the r<0 / r>=lh corrections keeps k and r consistent for every s >= 0.
// scrollVisualDecompose maps the current scroll offset to (windowStartLine,
// drawOffset) in visual-line space — the exact inverse of what the renderer
// does when it draws the window at reg.Y - drawOffset.
//
// v0 is the visual line at the viewport top; k is the logical line that
// contains it (via the WrapIndex); drawOffset = ScrollOffset - V(k)*lh, the
// amount the renderer shifts the window up. With word wrap, drawOffset may
// exceed one lineHeight: the viewport top then sits inside a wrapped line's
// continuation, and the wrapped lines that fall above the viewport are
// clipped away. Without a WrapIndex (index not built yet) or with an
// all-ones index (nothing shaped/wrapped yet), the result is exactly the
// legacy mapping (k = v0, drawOffset = ScrollOffset mod lh).
//
// Must be called on the logic goroutine (reads ScrollOffset, the last
// GlyphLayout and the buffer's WrapIndex; writes nothing).
func scrollVisualDecompose() (k int, r float64) {
lh := EffectiveLineHeight()
if gl := TheState.Editor.GlyphLayout; gl.LineHeight > 0 {
lh = gl.LineHeight
}
v0, r0 := scrollDecompose(TheState.ScrollOffset, lh)
w := (*WrapIndex)(nil)
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
w = cb.WrapIndex
}
if w == nil {
return v0, r0
}
k = w.LineForVisual(int32(v0))
vk := w.VisualsBefore(k)
// r = s - V(k)*lh = (v0 - V(k))*lh + r0: the v0 - V(k) term counts the
// wrapped continuation lines above the window start.
return k, float64(v0-int(vk))*float64(lh) + r0
}
// applyWrapCounts corrects the WrapIndex counts for the lines described by
// a shaped layout. The layout's VisualLineStarts (window-relative byte
// offsets, one per visual line start) are grouped by the logical line whose
// byte range contains each start; a wrapped logical line then carries its
// true visual-line count.
//
// The layout describes the window it was shaped for (fb.WindowStartLine /
// fb.WindowStartByte), which may differ from the CURRENT window (a scroll
// can move the window between shaping and delivery) — that is fine: the
// counts belong to real lines that are still valid as long as no edit has
// shifted them, which the caller checks via EditSeq before calling here.
//
// A logical line whose range contains no visual line start (an empty line,
// or a line the shaper produced no starts for) keeps its current count.
//
// Must be called on the logic goroutine.
func (s *State) applyWrapCounts(fb ui.LayoutFeedback) {
cb := s.Editor.ChunkedBuffer
if cb == nil || cb.WrapIndex == nil || fb.WindowStartLine < 0 {
return
}
// fb.WindowText is the exact text this layout was shaped for (carried in
// the frame). Grouping over the CURRENT window (IMEWindowText) instead
// would attribute counts to the wrong lines whenever a scroll moved the
// window between shaping and delivery.
winText := fb.WindowText
if winText == "" {
return
}
starts := fb.GlyphLayout.VisualLineStarts
if len(starts) == 0 {
return
}
// Walk the window's logical lines (delimited by '\n'); attribute each
// visual line start to the logical line containing it. Both the starts
// and the line ranges are ascending, so a single forward pointer works.
si := 0
lineStart := 0
for li := 0; ; li++ {
idx := strings.IndexByte(winText[lineStart:], '\n')
lineEnd := len(winText)
last := false
if idx >= 0 {
lineEnd = lineStart + idx + 1
} else {
last = true
}
count := 0
for si < len(starts) && int(starts[si]) < lineEnd {
if int(starts[si]) >= lineStart {
count++
}
si++
}
if count > 0 {
cb.WrapIndex.Set(fb.WindowStartLine+li, int32(count))
}
if last {
break
}
lineStart = lineEnd
}
}
func scrollDecompose(s ui.Dp, lh ui.Dp) (k int, r float64) {
sf, lf := float64(s), float64(lh)
if lf <= 0 {
return 0, 0
}
k = int(sf / lf)
r = sf - float64(k)*lf
if r < 0 {
k--
r = sf - float64(k)*lf
}
if r >= lf {
k++
r = sf - float64(k)*lf
}
if k < 0 {
k, r = 0, sf
}
return k, r
}
// tapLocalY returns the text-local Y (Dp, window-relative: 0 = top of the
// visible window, matching GlyphLayout.Y) of an app-local tap at ptY.
//
// The renderer draws the window's top at reg.Y - r' (r' = the sub-line draw
// offset from scrollVisualDecompose), so a point at app-Y ptY sits at
// window-Y (ptY - regionTop) + r'. r' is the same value the renderer was
// given, so the tap maps to the drawn geometry for every scroll offset,
// font setting and wrap state — including the case where the viewport top
// sits inside a wrapped line's continuation (r' > one line).
func tapLocalY(ptY, regionTopY ui.Dp) float64 {
_, r := scrollVisualDecompose()
return float64(ptY-regionTopY) + r
}
// SetCursorFromPoint updates the cursor position based on text-local
// coordinates (Dp, window-relative: x from the text region's left edge, y as
// produced by tapLocalY). A tap is an explicit cursor placement: it always
// clears any selection.
func SetCursorFromPoint(x, y float64) {
ClearSelection()
if pos, ok := textPosFromLocalPoint(x, y); ok {
TheState.Editor.CursorPosition = pos
}
}
// textPosFromLocalPoint maps text-local coordinates (Dp, window-relative) to
// an absolute byte offset in the buffer. ok=false when the layout is empty or
// the point maps to no glyph. Shared by the tap, long-press, double-tap and
// selection-drag handlers.
func textPosFromLocalPoint(x, y float64) (int, bool) {
layout := TheState.Editor.GlyphLayout
if len(layout.ByteOffsets) == 0 || len(layout.X) == 0 || len(layout.Advance) == 0 {
return 0, false
}
lineHeight := float64(EffectiveLineHeight())
// 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)
return textPosOnLineAtX(visualLine, x)
}
// textPosOnLineAtX maps a text-local x (Dp, same convention as
// textPosFromLocalPoint) on the given visual line to an absolute byte
// offset. Out-of-range lines clamp to the nearest non-empty line, exactly
// like the y-based selection in textPosFromLocalPoint. ok=false when the
// layout is empty or the (clamped) line has no glyphs.
func textPosOnLineAtX(visualLine int, x float64) (int, bool) {
layout := TheState.Editor.GlyphLayout
if len(layout.ByteOffsets) == 0 || len(layout.X) == 0 || len(layout.Advance) == 0 {
return 0, false
}
base := glyphBase()
lineHeight := float64(EffectiveLineHeight())
// Group glyphs by their Y-baseline
type lineGroup struct {
y float64
indices []int
}
var groups []lineGroup // assigned in the grouping loop below
// 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 visual line. A glyph's visual line is resolved from the
// recorded line starts (VisualLineStarts), NOT from its Y-baseline: the
// window's first visual line may be an empty line with no recorded
// glyphs, in which case min(layout.Y) is the first NON-empty line's
// baseline and (Y-minY)/lineHeight numbers every line one too high —
// taps and dragged handles then landed one line below the finger
// whenever the viewport top sat on an empty line.
groups = []lineGroup{}
for i, yVal := range layout.Y {
yFloat := float64(yVal)
lineIdx, ok := visualLineOfByte(layout.ByteOffsets[i])
if !ok {
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 {
// A line with no glyphs: an empty line (a lone "\n"), or the trailing
// empty line of a file ending in "\n". Its single insertion point is
// the start of the line, before its terminating newline. Anchor there:
// a handle dragged across an empty line sweeps up to the line instead
// of snapping back to the last text line (or jumping past the empty
// line to the text after it).
if visualLine < len(layout.VisualLineStarts) {
return base + layout.VisualLineStarts[visualLine], true
}
// No line start recorded for this line (finger beyond the content):
// clamp to the last non-empty line.
for i := len(groups) - 1; i >= 0; i-- {
if len(groups[i].indices) > 0 {
visualLine = i
break
}
}
}
if visualLine >= len(groups) || len(groups[visualLine].indices) == 0 {
return 0, false
}
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
}
}
// lineEndByte is the insertion point at the end of the line content,
// before any trailing newline.
lineEndByte := func() (int, bool) {
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 0, false
}
fileContent = content
} else {
fileContent = TheState.Editor.Buffer
}
r, size := utf8.DecodeRuneInString(fileContent[start:])
if r == '\n' {
return start, true
}
return start + size, true
}
// 4. Check if tap is to the right of the last character
if rightmostIdx != -1 && x > rightmostX {
return lineEndByte()
}
// 5. Otherwise, map x to the nearest insertion point on the line: the
// glyph edge closest to x. x exactly on an edge maps to that edge's byte
// (this is where handle anchors sit, so a dragged handle must map back
// to its own byte), and a tie (x exactly midway between two edges) maps
// to the left byte. Tapping the right half of a glyph positions the
// caret after it, as on Android.
idxs := targetGroup.indices
// First glyph whose left edge is right of x.
m := sort.Search(len(idxs), func(k int) bool {
return float64(layout.X[idxs[k]]) > x
})
if m == 0 {
// x at or left of the first glyph's left edge.
return base + layout.ByteOffsets[idxs[0]], true
}
// x sits in the span of glyph idxs[m-1] (or, when m == len(idxs), at or
// past its left edge but within its span, since x <= rightmostX here).
g := idxs[m-1]
left := x - float64(layout.X[g])
right := float64(layout.Advance[g]) - left
if left <= right {
return base + layout.ByteOffsets[g], true
}
if m < len(idxs) {
return base + layout.ByteOffsets[idxs[m]], true
}
// Right half of the last glyph: the insertion point after it.
return lineEndByte()
}