Pad/internal/editor/state.go
Greg Pomerantz f54ca2f81a IME: map commits against the whole buffer; drop the renderer-side model
The renderer kept a mirror of the pushed IME snippet (the 'IME model')
to translate commit positions, but it transiently desynced from the
buffer on fling/tap sequences (observed as a few-byte mapping drift on
both the x86_64 emulator and the ARM phone), corrupting text. The model
string also sat on the main goroutine next to the JNI render path,
where the app observed states that were impossible for Go memory
(string contents changing between reads microseconds apart), pointing
at corruption in the native bridge layer.

Restructure along the lines of the Android InputConnection contract
and Gio's own reference editor (widget/editor.go):

- Commits carry absolute file runes (the pushed snippet's coordinate
  space) straight to the logic goroutine, which maps them to bytes
  against the WHOLE buffer (runeToByteWhole, an 8 KiB-step scan).
  Scrolling moves the window, not the buffer, so the mapping is exact
  mid-fling by construction — no mirror to desync.
- Drift guard in HandleIMECommit: a small commit (range <= 2 runes)
  is always anchored at the caret the IME was last told about; if the
  IME reports it ending elsewhere, its snippet text is stale (a
  dropped restartInput, as Gboard does during flings) and its
  position is in the stale text's coordinates — snap the commit to
  the cursor, the only position it cannot drift from.
- FlushIME simplifies to: push the snippet when the frame's
  (context+window) text differs from the last push (gioui dedupes
  against its own cache), force the selection re-push in the same
  frame. After a commit the frame text equals what the IME already
  holds locally, so the restart is naturally suppressed; a fling
  re-anchors the IME once per text change.
- Remove the renderer model (adoptFrame/ModelTranslate/
  ApplyIMEEdit/ApplyIMEKey/IMECaret), the IME freeze/settle
  machinery (IMEFrozen, markIMEScrollActive, imeSettleChan), and the
  window-relative imeRuneToByte.

Also fixed along the way (both found while chasing the corruption):

- real.ReadFileAt: loop over short reads. A single ReadAt on Android
  FUSE can return a short read, silently truncating a chunk and
  shifting every byte offset after it.
- logic: a late lazy-chunk result no longer clobbers a buffer that
  SetContent has already fully loaded.
- e2e: large-file IME test (1.6 MB file, fling + commit).
- app icon (scripts/make_icon.py + cmd/pad/appicon.png) so gogio
  builds the mipmap/adaptive icon set.

Verified: go vet + staticcheck, go test -race (all packages), and the
emulator scenario loop (open moby excerpt, fling to mid-file, tap,
type 'a', byte-compare the saved file) 75/75 clean.
2026-09-13 11:57:38 -04:00

3172 lines
108 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
// App-local font-size scale bounds (pinch zoom), applied on top of the
// system user font scale. The scale itself is a continuous float32 — it is
// never rounded to a whole point value; the bounds only stop the pinch from
// leaving the usable range.
const (
MinAppFontScale = 0.5
MaxAppFontScale = 3.0
)
// 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. HandleReplaceRange uses it to convert IME offsets to
// absolute file offsets.
IMEWindowStartByte int
// IMEWindowText is the visible window text shown to the IME (the snippet).
// It is set during layout.
IMEWindowText string
// IMEContext is up to 10 runes immediately before IMEWindowStartByte. It
// is prepended to the window when the snippet is pushed to the IME, so the
// IME always sees real text preceding the caret — auto-cap must never
// treat the top of the visible window as the start of a sentence.
IMEContext string
// IMEContextStartByte is the absolute byte offset where IMEContext begins
// (IMEWindowStartByte - len(IMEContext)).
IMEContextStartByte int
// IMEOffsetRune is the rune count of file[0:IMEContextStartByte): the
// absolute file rune offset where the pushed snippet starts (its
// Range.Start). A non-zero value tells the IME the window sits
// mid-document, keeping its sentence/word-boundary logic honest. IME edit
// ranges are addressed from this offset (see imeRuneToByte).
IMEOffsetRune int
// imeRuneCache{Byte,Count} memoizes "rune count of [0, Byte)" so
// IMEOffsetRune stays O(delta) while the window scrolls; edits adjust it
// (imeAnchorEdit) and a file switch resets it to (0,0).
imeRuneCacheByte int
imeRuneCacheCount int
// 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
appFontScale float32 // app-local pinch font scale (1.0 = default); 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,
appFontScale: 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 AND the app-local pinch scale applied. The shaper draws
// baselines at Sp(EditorFontSize*appFontScale*LineHeightScale) physical px,
// which is EditorLineHeight()*effectiveFontScale 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(effectiveFontScale())
}
// effectiveFontScale is the TOTAL font scale of the rendered line pitch:
// the system user font scale times the app-local pinch scale. The system
// part is already folded into gtx.Metric on the render side; the logic side
// needs the product for its dp bookkeeping.
func effectiveFontScale() float32 {
fs := stateFontScale()
as := float32(1)
if TheState != nil && TheState.appFontScale > 0 {
as = TheState.appFontScale
}
return fs * as
}
// rescaleScrollAnchored scales the scroll offset by ratio while keeping the
// content point under app-local Y anchorY fixed on screen. The document is
// uniformly scaled by the font change (every line height and the sub-line
// offset scale by the same factor), so the content coordinate under the
// anchor scales by ratio; the new offset re-places that scaled coordinate
// under the same app point. With anchorY at the region top this degenerates
// to the plain top-anchor (new = old * ratio). A zero EditorRegion (not
// laid out yet) likewise degenerates to the top anchor.
func (s *State) rescaleScrollAnchored(ratio float64, anchorY float64) {
regionTop := float64(s.EditorRegion.Y)
dy := anchorY - regionTop
contentY := float64(s.ScrollOffset) + dy
s.ScrollOffset = ui.Dp(contentY*ratio - dy)
}
// glyphAtLocalPoint returns the index of the glyph a window-frame point
// (x, y in Dp; y relative to the window top, the same frame as GlyphLayout.Y)
// sits on: the display line identified from y, and on that line the last
// glyph whose X is at or before x. ok=false when the layout is empty, the
// line has no glyph, or x is in the left margin before the line's first
// glyph. (A point past the line's END still pins that line's last glyph:
// the content there is the line itself.)
func glyphAtLocalPoint(gl ui.GlyphLayout, x, y float64) (int, bool) {
lh := float64(gl.LineHeight)
if lh <= 0 || len(gl.ByteOffsets) == 0 {
return 0, false
}
line := int(y / lh)
if line < 0 {
line = 0
}
// The baseline of display line j sits in (j*lh, (j+1)*lh]; all glyphs on
// a line share one exact shaper value, so a range test finds the line's
// baseline.
base := -1.0
for _, gy := range gl.Y {
if f := float64(gy); f > float64(line)*lh && f <= float64(line+1)*lh {
base = f
break
}
}
if base < 0 {
return 0, false
}
best := -1
for i, gy := range gl.Y {
if float64(gy) != base {
continue
}
if float64(gl.X[i]) <= x+1e-9 {
best = i
}
}
return best, best >= 0
}
// contentPin is the anchor a pinch holds: a CONTENT point, not a layout
// point. Byte/Dy name the glyph (ABSOLUTE buffer byte) under the fingers and
// the point's offset from that glyph's baseline — both invariant under
// rewrap, where a visual line is not (a rewrapped fragment holds different
// text at the same fragment index). Line/Frag/Sub is the (logical line,
// fragment, sub-line) fallback anchor for frames without a shaped glyph
// under the point. EditSeq invalidates the pin on edits.
type contentPin struct {
Byte int
Dy float64
Line int
Frag int
Sub float64
HaveGlyph bool
EditSeq uint64
}
// invalidateShapedLayout drops the last shaped GlyphLayout. Call it whenever
// the SHAPING INPUTS change outside an edit (a font-scale change): the old
// layout's LineHeight/X/Y belong to the old size, and every consumer that
// falls back on it (window-start line, max scroll, tap mapping) would run
// the new scroll offset through the OLD line height for a frame or two —
// enough to put the shaped window ten thousand lines from the viewport.
// The next frame re-shapes and the feedback refills it; until then the
// logic-side geometry uses EffectiveLineHeight(), which tracks the scale.
func (s *State) invalidateShapedLayout() {
s.Editor.GlyphLayout = ui.GlyphLayout{}
}
// captureContentPin identifies the content point at region-relative (x, y)
// (dp from the editor region's left/top): the glyph under the point and the
// point's offset from its baseline, plus the (line, fragment, sub-line)
// fallback anchor. Must be called BEFORE the font change it will anchor.
func (s *State) captureContentPin(x, y float64) contentPin {
pin := contentPin{Byte: -1}
// Window-frame y (the GlyphLayout frame): the point's region-relative y
// plus the sub-line draw offset, the same convention as tapLocalY.
_, r := scrollVisualDecompose()
localY := y + r
gl := s.Editor.GlyphLayout
if i, ok := glyphAtLocalPoint(gl, x, localY); ok {
// ByteOffsets are window-relative; IMEWindowStartByte is the absolute
// byte of the window's first byte (the same value the Frame ships as
// WindowStartByte).
pin.Byte = s.Editor.IMEWindowStartByte + gl.ByteOffsets[i]
pin.Dy = localY - float64(gl.Y[i])
pin.HaveGlyph = true
}
if line, frag, sub, ok := s.captureFontPin(y); ok {
pin.Line, pin.Frag, pin.Sub = line, frag, sub
}
pin.EditSeq = s.Editor.EditSeq
return pin
}
// captureFontPin identifies the fallback layout anchor at region-relative Y
// m (dp from the top of the editor text region): the LOGICAL line under the
// point (through the current WrapIndex), the display line (wrap fragment) of
// that line the point is on, and the sub-line fraction within that fragment.
func (s *State) captureFontPin(m float64) (line, frag int, sub float64, ok bool) {
lh := float64(EffectiveLineHeight())
if lh <= 0 {
return 0, 0, 0, false
}
u := (float64(s.ScrollOffset) + m) / lh // continuous display-line coordinate under the point
k := int(u)
if k < 0 {
k = 0
}
sub = u - float64(k)
if cb := s.Editor.ChunkedBuffer; cb != nil && cb.WrapIndex != nil && k < cb.WrapIndex.Len() {
l := cb.WrapIndex.LineForVisual(int32(k))
base := int(cb.WrapIndex.VisualsBefore(l))
frag = k - base
if frag < 0 {
frag = 0
}
return l, frag, sub, true
}
return k, 0, sub, true // no wrap index: display line == logical line
}
// refineContentPin computes the scroll offset that places the pinned CONTENT
// point — absolute buffer byte byteOff, dy below its baseline — at
// region-relative Y m, given a freshly shaped layout (gl) for the window
// starting at windowStartByte (both from the same LayoutFeedback). The window
// top (layout y=0) sits at the top of the window's FIRST visual line, whose
// content coordinate is VisualsBefore(windowStartLine)*lh — the vk argument
// (NOT floor(shapedScroll/lh), which is a different line whenever the
// viewport top lands mid-way through a wrapped logical line) — so the glyph's
// content coordinate is vk*lh + Y + dy, and setting the offset to that minus
// m puts the point on the center exactly. This is what keeps the CHARACTER
// under the fingers when a rewrap moves it to a different fragment: the byte
// is invariant, its Y is read from the fresh layout. ok=false when the layout
// is unusable or the byte is not in the shaped window (then the caller falls
// back to the (line, frag, sub) anchor).
func refineContentPin(gl ui.GlyphLayout, vk, windowStartByte int, dy, m float64, byteOff int) (ui.Dp, bool) {
lh := gl.LineHeight
if lh <= 0 || len(gl.ByteOffsets) == 0 || byteOff < 0 || vk < 0 {
return 0, false
}
i := sort.Search(len(gl.ByteOffsets), func(i int) bool { return windowStartByte+gl.ByteOffsets[i] >= byteOff })
if i >= len(gl.ByteOffsets) || windowStartByte+gl.ByteOffsets[i] != byteOff {
return 0, false
}
contentY := float64(vk)*float64(lh) + float64(gl.Y[i]) + dy
return ui.Dp(contentY - m), true
}
// applyFontPin sets the scroll offset so the fallback anchor (logical line L,
// its frag-th display line, sub-line fraction sub within it) sits at
// region-relative Y m, under the CURRENT WrapIndex and line height. This is
// the pin's stand-in for frames without a shaped glyph under the point; it is
// re-runnable as wrap-count corrections land (the same mechanism as the
// restore line-pin, aimed at a point mid-viewport). No MaxScroll clamp here:
// the layout pass of the emitted frame clamps to the fresh value (a stale
// MaxScroll would under-clamp).
func (s *State) applyFontPin(line, frag int, sub, m float64) {
lh := float64(EffectiveLineHeight())
if lh <= 0 {
return
}
base := float64(line)
if cb := s.Editor.ChunkedBuffer; cb != nil && cb.WrapIndex != nil && line >= 0 && line < cb.WrapIndex.Len() {
base = float64(cb.WrapIndex.VisualsBefore(line))
// The line's fragment count may have shrunk (pinch out): keep the
// pinned fragment inside the line's new fragment range.
if line+1 < cb.WrapIndex.Len() {
count := int(cb.WrapIndex.VisualsBefore(line+1) - cb.WrapIndex.VisualsBefore(line))
if count > 0 && frag >= count {
frag = count - 1
}
}
}
s.ScrollOffset = ui.Dp((base+float64(frag)+sub)*lh - m)
}
// HandleFontPinch applies one frame's relative two-finger pinch factor to
// the app-local font scale (ui.FontPinchEvent, delivered by the renderer's
// pinch probe). The scale is a continuous float — multiplied by the
// per-frame distance ratio, clamped to [MinAppFontScale, MaxAppFontScale],
// never rounded — so the text size tracks the fingers smoothly. The content
// under the pinch CENTER (event's Center point) stays anchored: the Logic
// pins the logical line + fragment + sub-line under the center and
// re-derives the scroll offset under it as the font — and, a few frames
// later, the rewrap counts — change (see Logic.applyFontPinch).
func HandleFontPinch(data any) {
f, ok := data.(ui.FontPinchEvent)
log.Printf("PINCH logic HandleFontPinch scale=%.4f center=(%.0f,%.0f) ok=%v", f.Scale, f.Center.X, f.Center.Y, ok)
if !ok || f.Scale <= 0 {
return
}
s := TheState
// Region-relative (x, y) of the pinch center. Capture the CONTENT point
// the fingers are on (the glyph under the point + offset from its
// baseline) under the PRE-change layout; the scale change, then the
// re-derivation, keep that same content point under the center.
x := float64(f.Center.X - s.EditorRegion.X)
m := float64(f.Center.Y - s.EditorRegion.Y)
pin := s.captureContentPin(x, m)
old := s.appFontScale
if old <= 0 {
old = 1
}
ns := old * f.Scale
if ns < MinAppFontScale {
ns = MinAppFontScale
}
if ns > MaxAppFontScale {
ns = MaxAppFontScale
}
if ns == s.appFontScale {
return // no change: nothing to re-derive
}
s.appFontScale = ns
// The last shaped layout belongs to the old size (see
// invalidateShapedLayout): without this, the next frame computes its
// window start with the OLD line height and the new (rescaled) offset —
// a window ten thousand lines from the viewport — and the pin chases it.
s.invalidateShapedLayout()
ratio := float64(ns) / float64(old)
if TheLogic != nil {
TheLogic.releaseRestorePin() // the user takes over the viewport
TheLogic.setFontPin(pin, m, ratio) // re-derives the offset under it
} else {
// Test harness without a Logic: the immediate continuous anchor.
s.rescaleScrollAnchored(ratio, float64(s.EditorRegion.Y)+m)
}
}
// SetAppFontScale sets the app-local font scale to an absolute value
// (clamped to [MinAppFontScale, MaxAppFontScale]) with the VIEWPORT TOP as
// the anchor (no fingers are involved). Used by the one-shot debug command.
func SetAppFontScale(v float32) {
s := TheState
old := s.appFontScale
if old <= 0 {
old = 1
}
if v < MinAppFontScale {
v = MinAppFontScale
}
if v > MaxAppFontScale {
v = MaxAppFontScale
}
if v == s.appFontScale {
return // no change
}
s.appFontScale = v
// Same stale-layout hazard as HandleFontPinch: the window start for the
// next frame must be computed with the NEW line height.
s.invalidateShapedLayout()
ratio := float64(s.appFontScale) / float64(old)
s.rescaleScrollAnchored(ratio, float64(s.EditorRegion.Y))
if TheLogic != nil {
TheLogic.releaseRestorePin()
TheLogic.releaseFontPin()
}
}
// 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
TheLogic.releaseFontPin()
}
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()
// Re-read the current directory now (don't wait for the first periodic
// tick) so the list is current the moment we land back: files may have
// been added/removed/renamed, or an inbound sync may have changed them,
// while we were in the editor. The periodic refresh keeps it fresh
// afterwards (see Logic.armBrowserRefresh).
TheLogic.browserManager.Refresh(TheLogic.state.Browser.CurrentPath)
}
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
}
del := TheState.fileContent(start, end)
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:]
}
imeAnchorEdit(start, del, "")
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)
imeAnchorEdit(pos, seg[: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:]
imeAnchorEdit(pos, str[pos: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:]
}
imeAnchorEdit(pos, "", s)
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)
imeAnchorEdit(pos-w, buf.Content(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:]
imeAnchorEdit(pos-w, str[pos-w: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)
}
// countRunes counts the runes in s.
func countRunes(s string) int {
n := 0
for i := 0; i < len(s); {
_, w := utf8.DecodeRuneInString(s[i:])
i += w
n++
}
return n
}
// lastRunesStart returns the byte offset where the last n runes of s begin
// (0 if s has fewer than n runes). It stops at rune boundaries, so the result
// never splits a UTF-8 sequence.
func lastRunesStart(s string, n int) int {
start := len(s)
for i := 0; i < n && start > 0; i++ {
start--
// Walk back over continuation bytes (0x80-0xBF) to the lead byte.
for start > 0 && s[start]&0xC0 == 0x80 {
start--
}
}
return start
}
// fileContent returns file[lo:hi) from the active buffer (chunked or small).
func (s *State) fileContent(lo, hi int) string {
e := &s.Editor
if e.ChunkedBuffer != nil {
return e.ChunkedBuffer.Content(lo, hi)
}
if lo < 0 {
lo = 0
}
if hi > len(e.Buffer) {
hi = len(e.Buffer)
}
if lo > hi {
lo = hi
}
return e.Buffer[lo:hi]
}
// bufferLen is the active buffer length in bytes.
func (s *State) bufferLen() int {
if e := &s.Editor; e.ChunkedBuffer != nil {
return int(e.ChunkedBuffer.FileLen())
}
return len(s.Editor.Buffer)
}
// imeLeadingContext returns up to 10 runes immediately before byte position
// start in the active buffer ("" at file start, or for too-large files). At
// most 64 bytes are scanned (10 runes * 4 bytes + margin).
func imeLeadingContext(start int) string {
if start <= 0 || TheState.Editor.TooLarge {
return ""
}
a := start - 64
if a < 0 {
a = 0
}
span := TheState.fileContent(a, start)
return span[lastRunesStart(span, 10):]
}
// imeRuneOffsetAt returns the rune count of the file prefix [0, bytePos).
// It scans only the delta from the cached anchor (imeRuneCacheByte/Count), so
// repeated calls with a slowly moving byte position (scrolling while typing)
// are O(delta); a large jump (file switch, long scroll) does one full scan
// and then anchors again. Must be called on the logic goroutine (owner).
func (s *State) imeRuneOffsetAt(bytePos int) int {
e := &s.Editor
fileLen := s.bufferLen()
if bytePos > fileLen {
bytePos = fileLen
}
if e.imeRuneCacheByte > fileLen {
e.imeRuneCacheByte, e.imeRuneCacheCount = 0, 0
}
if bytePos == e.imeRuneCacheByte {
return e.imeRuneCacheCount
}
delta := bytePos - e.imeRuneCacheByte
const maxDelta = 256 * 1024
if delta > maxDelta || delta < -maxDelta {
n := countRunes(s.fileContent(0, bytePos))
e.imeRuneCacheByte, e.imeRuneCacheCount = bytePos, n
return n
}
lo, hi := e.imeRuneCacheByte, bytePos
if hi < lo {
lo, hi = hi, lo
}
n := e.imeRuneCacheCount
if delta > 0 {
n += countRunes(s.fileContent(lo, hi))
} else {
n -= countRunes(s.fileContent(lo, hi))
}
e.imeRuneCacheByte, e.imeRuneCacheCount = bytePos, n
return n
}
// imeAnchorEdit adjusts the IME rune-count cache after a buffer mutation at
// absolute byte position editByte that removed delText and inserted insText.
// An edit at or after the cached position does not change the rune count of
// [0, cacheByte), so only edits strictly before it move the anchor.
func imeAnchorEdit(editByte int, delText, insText string) {
e := &TheState.Editor
if editByte < e.imeRuneCacheByte {
e.imeRuneCacheByte += len(insText) - len(delText)
e.imeRuneCacheCount += countRunes(insText) - countRunes(delText)
}
}
// runeToByteWhole maps an absolute file rune offset (the IME's coordinate
// space, in which the pushed snippet starts at IMEOffsetRune) to an absolute
// buffer byte offset by scanning the WHOLE active buffer, in 8 KiB steps.
// Unlike a window-relative mapping it does not depend on the visible window,
// so it stays exact while the window moves (a fling in flight): scrolling
// moves the window, not the buffer. One pass over the file is ~1-2 ms for a
// 1 MB file — negligible next to the disk save a commit triggers.
func runeToByteWhole(runePos int) int {
if runePos <= 0 {
return 0
}
total, off, fileLen := 0, 0, TheState.bufferLen()
const step = 8 * 1024
for off < fileLen {
hi := off + step
if hi > fileLen {
hi = fileLen
}
n := countRunes(TheState.fileContent(off, hi))
if total+n >= runePos {
return off + runeIndexToByteStr(TheState.fileContent(off, hi), runePos-total)
}
total += n
off = hi
}
return fileLen
}
// 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 ABSOLUTE file rune offsets (the IME's coordinate
// space, in which the pushed snippet starts at IMEOffsetRune), while the
// buffer is byte-based, so they are converted to absolute byte offsets first
// (runeToByteWhole, against the whole buffer). Used by the test harness
// (editor.HandleKeyDown with a key.EditEvent); the real app routes commits
// through HandleIMECommit. 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
}
applyIMECommitBytes(runeToByteWhole(startRune), runeToByteWhole(endRune), text)
}
// IMECommit is an IME commit whose range is in ABSOLUTE FILE RUNES — the
// coordinate space of the pushed snippet, whose Range.Start is IMEOffsetRune
// (see editorLayout). The logic maps it to bytes against the whole buffer
// (runeToByteWhole), so it stays exact while the visible window moves
// (a fling in flight): scrolling moves the window, not the buffer.
type IMECommit struct {
StartRune int
EndRune int
Text string
}
// HandleIMECommit applies an IME commit from the real app (cmd/pad/main.go
// routes editor_text key.EditEvents here; the test harness uses
// HandleReplaceRange). In addition to the whole-buffer mapping it applies
// the drift guard: Android's IME contract (InputConnection) says commit
// positions are relative to the text the IME last received; if a
// restartInput is dropped (observed with Gboard during flings), the IME's
// text is stale and its reported position is in the stale text's
// coordinates. A small commit (range ≤ 2 runes) is always anchored at the
// caret the IME was last told about — an insertion is [c,c), a composing
// replacement [c-1,c). If the reported range ends elsewhere, the IME's text
// is stale: snap the commit to the cursor, the only position it cannot
// drift from. Must be called on the logic goroutine (owner).
func HandleIMECommit(data any) {
c, ok := data.(IMECommit)
if !ok {
return
}
// A too-large file is not editable: ignore IME commits.
if TheState.Editor.TooLarge {
return
}
startRune, endRune := c.StartRune, c.EndRune
if startRune > endRune {
startRune, endRune = endRune, startRune
}
if endRune-startRune <= 2 {
caretRune := TheState.imeRuneOffsetAt(TheState.Editor.CursorPosition)
if endRune != caretRune {
log.Printf("IME DRIFT-SNAP range=[%d,%d) caret=%d -> snap to caret", startRune, endRune, caretRune)
startRune, endRune = caretRune, caretRune
}
}
absStart, absEnd := runeToByteWhole(startRune), runeToByteWhole(endRune)
log.Printf("IME COMMIT range=[%d,%d) text=%q -> [%d,%d)", startRune, endRune, c.Text, absStart, absEnd)
applyIMECommitBytes(absStart, absEnd, c.Text)
}
// applyIMECommitBytes replaces [startByte, endByte) with text and places the
// cursor at the end of the inserted text. Both byte addresses are absolute
// file offsets. Shared by HandleReplaceRange (rune-translated) and
// HandleIMECommit (model-translated). Must run on the logic goroutine.
func applyIMECommitBytes(startByte, endByte int, text string) {
if startByte > endByte {
startByte, endByte = endByte, startByte
}
// 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 startByte > TheState.Editor.SelectionStart {
startByte = TheState.Editor.SelectionStart
}
if endByte < TheState.Editor.SelectionEnd {
endByte = TheState.Editor.SelectionEnd
}
}
// Capture the deleted text (before the mutation) for the rune-anchor.
delText := TheState.fileContent(startByte, endByte)
var newCursor int
if buf := TheState.Editor.ChunkedBuffer; buf != nil {
if endByte > startByte {
buf.Delete(startByte, endByte-startByte)
}
buf.Insert(startByte, text)
if endByte > startByte {
buf.UpdateLineIndexAfterDelete(startByte, endByte)
}
buf.UpdateLineIndexAfterInsert(startByte, text)
newCursor = startByte + len(text)
} else {
s := TheState.Editor.Buffer
if endByte > startByte {
s = s[:startByte] + s[endByte:]
}
TheState.Editor.Buffer = s[:startByte] + text + s[startByte:]
newCursor = startByte + len(text)
}
imeAnchorEdit(startByte, delText, text)
TheState.Editor.findEdit(startByte, endByte, len(text))
TheState.Editor.CursorPosition = newCursor
// A commit consumed any selection it overlapped (see the union above).
ClearSelection()
markDirty()
}
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)
// TopBarHeight is the editor top bar's height (buildFindBar hangs off it
// too); TopBarIconSize is that bar's icon size — both ~30% larger than the
// old 32dp/24dp pair so the back/search icons are a comfortable tap
// target. The find bar keeps the smaller ui.IconSize.
const (
TopBarHeight = ui.Dp(42)
TopBarIconSize = ui.Dp(31)
)
// 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 := TopBarHeight // 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: TopBarHeight,
}
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(5), W: TopBarIconSize, H: TopBarIconSize}, 0,
[]ui.Interaction{{Gesture: ui.Tap, Handler: GoToBrowser}}),
ui.NewLabel(filename, 14, ui.Region{X: margin + TopBarIconSize + ui.Dp(8), Y: ui.Dp(11), W: statusBarW - margin - TopBarIconSize - ui.Dp(8) - margin - TopBarIconSize, H: ui.Dp(20)}, ui.AlignStart, "", nil),
ui.NewIcon("search", ui.Region{X: screenWidth - margin - TopBarIconSize, Y: ui.Dp(5), W: TopBarIconSize, H: TopBarIconSize}, 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(5), W: bottomBarW - margin, H: ui.Dp(20)}, ui.AlignStart, "", nil),
ui.NewLabel(cursorPosText, 12, ui.Region{X: 0, Y: ui.Dp(5), W: bottomBarW, H: ui.Dp(20)}, ui.AlignCenter, "", nil),
ui.NewLabel(wrapText, 12, ui.Region{X: 0, Y: ui.Dp(5), 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 pushed to the IME is
// the leading context plus this window, addressed from IMEOffsetRune (the
// absolute file rune offset where the context begins). start is 0 for
// small (string) files, so the window is the whole buffer there and there
// is no leading context. See imeRuneToByte / Renderer.FlushIME.
TheState.Editor.IMEWindowStartByte = start
TheState.Editor.IMEWindowText = visibleContent
TheState.Editor.IMEContext = imeLeadingContext(start)
TheState.Editor.IMEContextStartByte = start - len(TheState.Editor.IMEContext)
TheState.Editor.IMEOffsetRune = TheState.imeRuneOffsetAt(TheState.Editor.IMEContextStartByte)
// 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},
// Two-finger pinch changes the app-local font scale continuously
// (the renderer owns the probe; see ui.Pinch).
{Gesture: ui.Pinch, Handler: HandleFontPinch},
},
)
// Lead the IME snippet with up to 10 runes before the window and address
// it from its true file position (see imeLeadingContext / IMEOffsetRune
// above and Renderer.FlushIME): this is what stops the keyboard from
// auto-capitalizing mid-sentence at the top of the visible window.
editorElem.IMEContext = TheState.Editor.IMEContext
editorElem.IMEOffset = TheState.Editor.IMEOffsetRune
editorElem.WindowStartByte = start
// 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
log.Printf("IME TAP local=(%.0f,%.0f) cursor=%d winStart=%d offsetRune=%d",
x, y, pos, TheState.Editor.IMEWindowStartByte, TheState.Editor.IMEOffsetRune)
}
}
// 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()
}